@indigoai-us/hq-cli 5.295.0 → 5.296.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 +8 -0
- package/dist/command-catalog.generated.d.ts +58 -0
- package/dist/command-catalog.generated.js +73 -0
- package/dist/commands/core-job-native.d.ts +7 -0
- package/dist/commands/core-job-native.js +20 -0
- package/dist/commands/core-job-probe-native.d.ts +45 -0
- package/dist/commands/core-job-probe-native.js +564 -0
- package/dist/commands/core-job-remote-launcher.d.ts +7 -0
- package/dist/commands/core-job-remote-launcher.js +51 -0
- package/dist/commands/core-job-run-native.d.ts +47 -0
- package/dist/commands/core-job-run-native.js +1215 -0
- package/dist/commands/core.js +1 -1
- package/dist/commands/lanes-message.js +1 -0
- package/dist/commands/lanes-resume.js +1 -0
- package/dist/commands/lanes-stories.d.ts +1 -0
- package/dist/commands/lanes-stories.js +40 -1
- package/dist/commands/lanes.js +20 -2
- package/dist/commands/run.js +5 -0
- package/dist/lib/lanes/mesh-events.js +3 -0
- package/dist/lib/lanes/reconcile.js +23 -19
- package/dist/lib/lanes/spawn.js +39 -0
- package/dist/lib/lanes/store.js +32 -0
- package/dist/lib/lanes/stories.d.ts +7 -1
- package/dist/lib/lanes/stories.js +16 -0
- package/dist/lib/lanes/types.d.ts +12 -0
- package/dist/lib/lanes/types.js +1 -1
- package/dist/lib/lanes/watcher.js +20 -22
- package/dist/run/env-graph-guard.d.ts +2 -2
- package/dist/run/env-graph-guard.js +5 -2
- package/dist/run/hq-plugin.js +6 -5
- package/package.json +2 -2
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { load, JSON_SCHEMA } from "js-yaml";
|
|
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
|
+
export const JOB_PROBE_ASSET = "core/scripts/hq-job-probe.sh";
|
|
12
|
+
export const JOB_PROBE_USAGE = [
|
|
13
|
+
"hq-job-probe.sh — Outpost requirements probe → hq-pro jobs status (US-008).", "",
|
|
14
|
+
"On the Outpost, deterministically checks a job's requirements checklist and",
|
|
15
|
+
"POSTs readiness to POST /outpost/internal/jobs-status. Never rewrites job YAML.", "",
|
|
16
|
+
"Usage:", " core/scripts/hq-job-probe.sh [options] <job.yaml|job-id>",
|
|
17
|
+
" core/scripts/hq-job-probe.sh [options] --all", "", "Options:",
|
|
18
|
+
" --hq-root DIR HQ tree root (default: HQ_ROOT or repo root above core/)",
|
|
19
|
+
" --dry-run Run checks + print JSON; skip ingest", " --no-ingest Alias of --dry-run",
|
|
20
|
+
" --json Force JSON on stdout (default)", " -h, --help Show this help", "",
|
|
21
|
+
"Checks (per requirements / job fields):",
|
|
22
|
+
" - runtime CLI auth: claude → ~/.claude/.credentials.json; codex → ~/.codex/auth.json",
|
|
23
|
+
" - requirements.secrets[] visible via `hq secrets list` (names only; never values)",
|
|
24
|
+
" - optional cwd exists (HQ-root-relative or absolute)",
|
|
25
|
+
" - company path present when requirements.company / company scope set", "",
|
|
26
|
+
"Ingest auth (box identity — same trust model as disk-status / relay-status):",
|
|
27
|
+
" Header: x-outpost-instance-token",
|
|
28
|
+
" Body: userId + jobId + kind=probe + readiness + probed_at + next_actions + checks",
|
|
29
|
+
" Identity from (first match):",
|
|
30
|
+
" 1. OUTPOST_USER_ID + OUTPOST_INSTANCE_TOKEN (+ optional OUTPOST_ID)",
|
|
31
|
+
" 2. /etc/outpost/codex-identity.env (OUTPOST_USER_ID / OUTPOST_INSTANCE_TOKEN)",
|
|
32
|
+
" 3. USER_ID= / INSTANCE_TOKEN= / OUTPOST_ID= lines in /usr/local/bin/outpost-runner.sh",
|
|
33
|
+
" API base: HQ_PRO_API_URL → HQ_API_URL → HQ_VAULT_API_URL → https://hqapi.hq.computer", "",
|
|
34
|
+
"StatusIngestError (unreachable / 5xx / retryable body):",
|
|
35
|
+
" Bounded backoff retries; writes ~/.hq/jobs/probes/{id}/last-attempt.json;",
|
|
36
|
+
" never reports readiness=ready solely because ingest failed; surfaces",
|
|
37
|
+
" next_action \"status ingest failed — retry probe\".", "", "Env (tests / overrides):",
|
|
38
|
+
" HQ_ROOT, HOME, HQ_PRO_API_URL, OUTPOST_USER_ID, OUTPOST_INSTANCE_TOKEN, OUTPOST_ID",
|
|
39
|
+
" HQ_JOB_PROBE_CLAUDE_CRED, HQ_JOB_PROBE_CODEX_AUTH",
|
|
40
|
+
" HQ_JOB_PROBE_STATE_DIR, HQ_JOB_PROBE_MAX_ATTEMPTS, HQ_JOB_PROBE_BACKOFF_BASE_SEC",
|
|
41
|
+
" HQ_JOB_PROBE_SLEEP (set to \":\" to no-op sleep), HQ_JOB_PROBE_NOW (ISO timestamp)",
|
|
42
|
+
" HQ_JOB_PROBE_CURL (curl binary override), HQ_JOB_PROBE_RUNNER (outpost-runner path)", "",
|
|
43
|
+
"Exit: 0 ready+ingested (or dry-run ready); 1 blocked; 2 usage/config; 3 StatusIngestError", "",
|
|
44
|
+
"set -euo pipefail", "",
|
|
45
|
+
].join("\n");
|
|
46
|
+
class Fatal extends Error {
|
|
47
|
+
code;
|
|
48
|
+
prefix;
|
|
49
|
+
rawStderr;
|
|
50
|
+
constructor(message, code = 2, prefix = "hq-job-probe", rawStderr = "") {
|
|
51
|
+
super(message);
|
|
52
|
+
this.code = code;
|
|
53
|
+
this.prefix = prefix;
|
|
54
|
+
this.rawStderr = rawStderr;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const compact = (x) => serializeJqJson(x, "compact").trimEnd();
|
|
58
|
+
const statSize = (f) => { try {
|
|
59
|
+
return fs.statSync(f).size;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return undefined;
|
|
63
|
+
} };
|
|
64
|
+
const isFile = (f) => { try {
|
|
65
|
+
return fs.statSync(f).isFile();
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return false;
|
|
69
|
+
} };
|
|
70
|
+
const isDirectory = (f) => { try {
|
|
71
|
+
return fs.statSync(f).isDirectory();
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return false;
|
|
75
|
+
} };
|
|
76
|
+
const realpath = (f) => { try {
|
|
77
|
+
return fs.realpathSync(f);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return undefined;
|
|
81
|
+
} };
|
|
82
|
+
function writeState(file, body) { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, `${body}\n`, { mode: 0o600 }); try {
|
|
83
|
+
fs.chmodSync(file, 0o600);
|
|
84
|
+
}
|
|
85
|
+
catch { /* legacy chmod is best-effort */ } }
|
|
86
|
+
function hasCommand(name, env) {
|
|
87
|
+
for (const dir of (env.PATH ?? "").split(path.delimiter)) {
|
|
88
|
+
if (!dir)
|
|
89
|
+
continue;
|
|
90
|
+
try {
|
|
91
|
+
const f = path.join(dir, name);
|
|
92
|
+
fs.accessSync(f, fs.constants.X_OK);
|
|
93
|
+
if (fs.statSync(f).isFile())
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
catch { /* search remaining PATH */ }
|
|
97
|
+
}
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
function listSecrets(company) {
|
|
101
|
+
// Keep the `hq secrets list` subprocess: no in-process API produces the same names-only table.
|
|
102
|
+
const args = company ? ["secrets", "list", "--company", company] : ["secrets", "list", "--personal"];
|
|
103
|
+
const r = spawnSync("hq", args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
104
|
+
return Promise.resolve({ code: r.status ?? (r.error ? 127 : 1), stdout: r.stdout ?? "" });
|
|
105
|
+
}
|
|
106
|
+
export function defaultProbeDeps(root, env = process.env) {
|
|
107
|
+
const stubs = env.HQ_JOB_TEST_STUB === "1";
|
|
108
|
+
return { root, home: env.HOME || os.homedir(), env, now: () => new Date(),
|
|
109
|
+
sleep: async (s) => { if (env.HQ_JOB_PROBE_SLEEP === ":" || env.HQ_JOB_PROBE_SLEEP === "true")
|
|
110
|
+
return; await new Promise((resolve) => setTimeout(resolve, Math.max(0, s * 1000))); },
|
|
111
|
+
statSize, isFile, isDirectory, realpath, readText: (f) => fs.readFileSync(f, "utf8"), writeState,
|
|
112
|
+
writeStderr: (line) => process.stderr.write(line),
|
|
113
|
+
listSecrets: stubs ? async () => { throw new Error("HQ_JOB_TEST_STUB blocked real hq secrets list"); } : listSecrets,
|
|
114
|
+
commandExists: (name) => hasCommand(name, env),
|
|
115
|
+
fetcher: stubs ? async () => { throw new Error("HQ_JOB_TEST_STUB blocked real network fetch"); } : (url, init) => fetch(url, init) };
|
|
116
|
+
}
|
|
117
|
+
const isoNow = (d) => d.env.HQ_JOB_PROBE_NOW || d.now().toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
118
|
+
const obj = (x) => x && typeof x === "object" && !Array.isArray(x) ? x : {};
|
|
119
|
+
function yaml(file, d) { return obj(load(d.readText(file), { schema: JSON_SCHEMA })); }
|
|
120
|
+
function nested(x, ...keys) { let v = x; for (const k of keys)
|
|
121
|
+
v = obj(v)[k]; return v; }
|
|
122
|
+
function scalar(x) { return x == null ? "" : typeof x === "string" ? x : typeof x === "object" ? JSON.stringify(x) : String(x); }
|
|
123
|
+
function allFiles(root) {
|
|
124
|
+
const files = [];
|
|
125
|
+
const walk = (dir, jobsOnly, companiesRoot) => {
|
|
126
|
+
let entries;
|
|
127
|
+
try {
|
|
128
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
for (const e of entries) {
|
|
134
|
+
const f = path.join(dir, e.name);
|
|
135
|
+
if (e.isDirectory())
|
|
136
|
+
walk(f, jobsOnly, companiesRoot);
|
|
137
|
+
else if (e.isFile() && /\.ya?ml$/.test(e.name)
|
|
138
|
+
&& (!jobsOnly || (companiesRoot ? path.relative(companiesRoot, f).split(path.sep).includes("jobs") : true)))
|
|
139
|
+
files.push(f);
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
walk(path.join(root, "personal", "jobs"), false);
|
|
143
|
+
const companiesRoot = path.join(root, "companies");
|
|
144
|
+
walk(companiesRoot, true, companiesRoot);
|
|
145
|
+
return files.sort((a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b)));
|
|
146
|
+
}
|
|
147
|
+
function legacyYamlParseDiagnostics(file, d) {
|
|
148
|
+
// The shell evaluates each field with yq before checking the now-empty id;
|
|
149
|
+
// preserve the six parser diagnostics it emits on malformed input.
|
|
150
|
+
const expressions = [".id // \"\"", ".name // \"\"", ".runtime // \"\"", ".requirements.runtime // \"\"", ".requirements.company // \"\"", ".requirements.cwd // .cwd // \"\""];
|
|
151
|
+
return expressions.map((expression) => spawnSync("yq", ["-r", expression, file], {
|
|
152
|
+
cwd: d.root, env: d.env, encoding: "utf8",
|
|
153
|
+
}).stderr ?? "").join("");
|
|
154
|
+
}
|
|
155
|
+
function findById(id, d) {
|
|
156
|
+
let found;
|
|
157
|
+
for (const file of allFiles(d.root)) {
|
|
158
|
+
let job;
|
|
159
|
+
try {
|
|
160
|
+
job = yaml(file, d);
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (scalar(job.id) !== id)
|
|
166
|
+
continue;
|
|
167
|
+
if (found)
|
|
168
|
+
throw new Fatal(`duplicate job id '${id}' in registry: ${found} and ${file}`);
|
|
169
|
+
found = file;
|
|
170
|
+
}
|
|
171
|
+
return found;
|
|
172
|
+
}
|
|
173
|
+
function secretNames(output) {
|
|
174
|
+
let foundHeader = false;
|
|
175
|
+
const names = [];
|
|
176
|
+
for (const line of output.split(/\r?\n/)) {
|
|
177
|
+
if (/^NAME(?:\s|$)/.test(line)) {
|
|
178
|
+
foundHeader = true;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (!foundHeader || /^\s*$/.test(line) || /^Secrets /.test(line))
|
|
182
|
+
continue;
|
|
183
|
+
const name = line.trim().split(/\s+/)[0] ?? "";
|
|
184
|
+
if (/^[A-Za-z][A-Za-z0-9._:/-]{0,127}$/.test(name))
|
|
185
|
+
names.push(name);
|
|
186
|
+
}
|
|
187
|
+
return names;
|
|
188
|
+
}
|
|
189
|
+
function parseAssignments(body, allowed) {
|
|
190
|
+
const values = {};
|
|
191
|
+
for (const line of body.split(/\r?\n/)) {
|
|
192
|
+
const m = /^\s*(?:export\s+)?([A-Z_]+)=(?:"([^"]*)"|'([^']*)'|([^#\s]*))\s*$/.exec(line);
|
|
193
|
+
if (m && allowed.includes(m[1]))
|
|
194
|
+
values[m[1]] = m[2] ?? m[3] ?? m[4] ?? "";
|
|
195
|
+
}
|
|
196
|
+
return values;
|
|
197
|
+
}
|
|
198
|
+
function resolveIdentity(d) {
|
|
199
|
+
let user = d.env.OUTPOST_USER_ID || "", token = d.env.OUTPOST_INSTANCE_TOKEN || "", outpost = d.env.OUTPOST_ID || "";
|
|
200
|
+
if (user && token)
|
|
201
|
+
return { user, token, outpost };
|
|
202
|
+
const identityFile = "/etc/outpost/codex-identity.env";
|
|
203
|
+
if (d.isFile(identityFile)) {
|
|
204
|
+
try {
|
|
205
|
+
const assignments = parseAssignments(d.readText(identityFile), ["OUTPOST_USER_ID", "OUTPOST_INSTANCE_TOKEN", "OUTPOST_ID"]);
|
|
206
|
+
// A sourced assignment replaces only its own field; an explicit empty value still clears it.
|
|
207
|
+
if (Object.hasOwn(assignments, "OUTPOST_USER_ID"))
|
|
208
|
+
user = assignments.OUTPOST_USER_ID;
|
|
209
|
+
if (Object.hasOwn(assignments, "OUTPOST_INSTANCE_TOKEN"))
|
|
210
|
+
token = assignments.OUTPOST_INSTANCE_TOKEN;
|
|
211
|
+
if (Object.hasOwn(assignments, "OUTPOST_ID"))
|
|
212
|
+
outpost = assignments.OUTPOST_ID;
|
|
213
|
+
if (user && token)
|
|
214
|
+
return { user, token, outpost };
|
|
215
|
+
}
|
|
216
|
+
catch { /* continue to runner with the environment values */ }
|
|
217
|
+
}
|
|
218
|
+
const runner = d.env.HQ_JOB_PROBE_RUNNER || "/usr/local/bin/outpost-runner.sh";
|
|
219
|
+
if (d.isFile(runner)) {
|
|
220
|
+
try {
|
|
221
|
+
const body = d.readText(runner), get = (k) => new RegExp(`^${k}="(.*)"$`, "m").exec(body)?.[1] ?? "";
|
|
222
|
+
if (!user)
|
|
223
|
+
user = get("USER_ID");
|
|
224
|
+
if (!token)
|
|
225
|
+
token = get("INSTANCE_TOKEN");
|
|
226
|
+
if (!outpost)
|
|
227
|
+
outpost = get("OUTPOST_ID");
|
|
228
|
+
}
|
|
229
|
+
catch { /* legacy sed is best-effort */ }
|
|
230
|
+
}
|
|
231
|
+
if (user && token)
|
|
232
|
+
return { user, token, outpost };
|
|
233
|
+
return undefined;
|
|
234
|
+
}
|
|
235
|
+
function auth(runtime, d) {
|
|
236
|
+
if (runtime !== "claude" && runtime !== "codex")
|
|
237
|
+
return "unknown_runtime";
|
|
238
|
+
if (!d.commandExists(runtime))
|
|
239
|
+
return "no_cli";
|
|
240
|
+
const file = runtime === "claude" ? d.env.HQ_JOB_PROBE_CLAUDE_CRED || path.join(d.home, ".claude/.credentials.json") : d.env.HQ_JOB_PROBE_CODEX_AUTH || path.join(d.home, ".codex/auth.json");
|
|
241
|
+
return (d.statSize(file) ?? 0) > 0 ? "ok" : "missing";
|
|
242
|
+
}
|
|
243
|
+
function writeAttempt(d, id, data) { d.writeState(path.join(d.env.HQ_JOB_PROBE_STATE_DIR || path.join(d.home, ".hq/jobs/probes"), id, "last-attempt.json"), compact(data)); }
|
|
244
|
+
function writeCache(d, id, readiness, source) { d.writeState(path.join(d.env.HQ_JOB_STATUS_CACHE_DIR || path.join(d.home, ".hq/jobs/status-cache"), `${id}.json`), compact({ job_id: id, readiness, updated_at: isoNow(d), source })); }
|
|
245
|
+
function resolveCwd(root, cwd, d) {
|
|
246
|
+
const canon = d.realpath(root) ?? path.resolve(root), candidate = path.resolve(root, cwd), file = d.realpath(candidate) ?? candidate;
|
|
247
|
+
return { file, allowed: file === canon || file.startsWith(`${canon}${path.sep}`) };
|
|
248
|
+
}
|
|
249
|
+
async function post(payload, token, d) {
|
|
250
|
+
const env = d.env, max = Number.parseInt(env.HQ_JOB_PROBE_MAX_ATTEMPTS || "3", 10), backoff = Number.parseInt(env.HQ_JOB_PROBE_BACKOFF_BASE_SEC || "1", 10);
|
|
251
|
+
const base = (env.HQ_PRO_API_URL || env.HQ_API_URL || env.HQ_VAULT_API_URL || "https://hqapi.hq.computer").replace(/\/$/, ""), bytes = compact(payload);
|
|
252
|
+
let http = 0, body = "", retryable = false;
|
|
253
|
+
for (let attempt = 1; attempt <= max; attempt += 1) {
|
|
254
|
+
try {
|
|
255
|
+
const abort = new AbortController(), timer = setTimeout(() => abort.abort(), 20_000);
|
|
256
|
+
let response;
|
|
257
|
+
try {
|
|
258
|
+
response = await d.fetcher(`${base}/outpost/internal/jobs-status`, { method: "POST", redirect: "manual", headers: { "content-type": "application/json", "x-outpost-instance-token": token }, body: bytes, signal: abort.signal });
|
|
259
|
+
}
|
|
260
|
+
finally {
|
|
261
|
+
clearTimeout(timer);
|
|
262
|
+
}
|
|
263
|
+
http = response.status;
|
|
264
|
+
body = await response.text();
|
|
265
|
+
}
|
|
266
|
+
catch (e) {
|
|
267
|
+
const timedOut = e instanceof Error && e.name === "AbortError";
|
|
268
|
+
http = 0;
|
|
269
|
+
body = compact({ error: true, code: "StatusIngestError", step: "lookup", message: `curl failed rc=${timedOut ? 28 : 7} (API unreachable)`, retryable: true });
|
|
270
|
+
}
|
|
271
|
+
if (http >= 200 && http < 300)
|
|
272
|
+
return { http, body, retryable: false };
|
|
273
|
+
let res = {};
|
|
274
|
+
try {
|
|
275
|
+
res = obj(JSON.parse(body));
|
|
276
|
+
}
|
|
277
|
+
catch { /* invalid body is not retryable unless http says so */ }
|
|
278
|
+
retryable = http === 0 || http >= 500 || res.retryable === true || (res.code === "StatusIngestError" && ["persist", "lookup", "config"].includes(String(res.step)));
|
|
279
|
+
if (!retryable || attempt >= max)
|
|
280
|
+
break;
|
|
281
|
+
d.writeStderr(`hq-job-probe: ingest attempt ${attempt}/${max} failed (http=${http}); backoff ${backoff * (2 ** (attempt - 1))}s\n`);
|
|
282
|
+
await d.sleep(backoff * (2 ** (attempt - 1)));
|
|
283
|
+
}
|
|
284
|
+
return { http, body, retryable };
|
|
285
|
+
}
|
|
286
|
+
async function probeFile(file, dry, d) {
|
|
287
|
+
if (!d.isFile(file))
|
|
288
|
+
throw new Fatal(`job file not found: ${file}`);
|
|
289
|
+
let job;
|
|
290
|
+
try {
|
|
291
|
+
job = yaml(file, d);
|
|
292
|
+
}
|
|
293
|
+
catch (e) {
|
|
294
|
+
const rawStderr = legacyYamlParseDiagnostics(file, d);
|
|
295
|
+
if (rawStderr)
|
|
296
|
+
throw new Fatal(`job missing id: ${file}`, 2, "hq-job-probe", rawStderr);
|
|
297
|
+
throw new Fatal(`invalid job YAML: ${file}${e instanceof Error ? `: ${e.message}` : ""}`);
|
|
298
|
+
}
|
|
299
|
+
const id = scalar(job.id), name = scalar(job.name), runtime = scalar(nested(job, "requirements", "runtime")) || scalar(job.runtime);
|
|
300
|
+
const company = scalar(nested(job, "requirements", "company")), cwd = scalar(nested(job, "requirements", "cwd") ?? job.cwd);
|
|
301
|
+
if (!id)
|
|
302
|
+
throw new Fatal(`job missing id: ${file}`);
|
|
303
|
+
if (!runtime)
|
|
304
|
+
throw new Fatal(`job missing runtime: ${file}`);
|
|
305
|
+
const at = isoNow(d), actions = [], authState = auth(runtime, d);
|
|
306
|
+
let blocked = false;
|
|
307
|
+
if (authState === "no_cli") {
|
|
308
|
+
blocked = true;
|
|
309
|
+
actions.push(`install ${runtime} CLI on Outpost and re-login`);
|
|
310
|
+
}
|
|
311
|
+
else if (authState === "missing") {
|
|
312
|
+
blocked = true;
|
|
313
|
+
actions.push(`device re-login for ${runtime} on Outpost (cached credentials missing)`);
|
|
314
|
+
}
|
|
315
|
+
else if (authState !== "ok") {
|
|
316
|
+
blocked = true;
|
|
317
|
+
actions.push(`unsupported runtime '${runtime}' — fix job requirements.runtime`);
|
|
318
|
+
}
|
|
319
|
+
const listed = await d.listSecrets(company || undefined), missing = [];
|
|
320
|
+
let secretState = "ok";
|
|
321
|
+
if (listed.code !== 0) {
|
|
322
|
+
blocked = true;
|
|
323
|
+
secretState = "list_failed";
|
|
324
|
+
actions.push(company ? `hq secrets list --company ${company} failed on Outpost — check hq login / vault access` : "hq secrets list --personal failed on Outpost — check hq login / vault access");
|
|
325
|
+
}
|
|
326
|
+
else {
|
|
327
|
+
const names = new Set(secretNames(listed.stdout)), required = nested(job, "requirements", "secrets");
|
|
328
|
+
for (const v of Array.isArray(required) ? required : []) {
|
|
329
|
+
const n = scalar(v);
|
|
330
|
+
if (n && n !== "null" && !names.has(n))
|
|
331
|
+
missing.push(n);
|
|
332
|
+
}
|
|
333
|
+
if (missing.length) {
|
|
334
|
+
blocked = true;
|
|
335
|
+
secretState = "missing";
|
|
336
|
+
for (const n of missing)
|
|
337
|
+
actions.push(`hq secrets share ${n} (grant Outpost owner visibility; names only — never paste values)`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
let cwdState = "ok";
|
|
341
|
+
if (!cwd || cwd === "null")
|
|
342
|
+
cwdState = "skipped";
|
|
343
|
+
else if (cwd.startsWith("/")) {
|
|
344
|
+
blocked = true;
|
|
345
|
+
cwdState = "invalid";
|
|
346
|
+
actions.push("fix cwd to HQ-root-relative path (absolute not allowed)");
|
|
347
|
+
}
|
|
348
|
+
else if (cwd.split("/").includes("..")) {
|
|
349
|
+
blocked = true;
|
|
350
|
+
cwdState = "invalid";
|
|
351
|
+
actions.push("fix cwd — remove '..' path components");
|
|
352
|
+
}
|
|
353
|
+
else {
|
|
354
|
+
const target = resolveCwd(d.root, cwd, d);
|
|
355
|
+
if (!target.allowed) {
|
|
356
|
+
blocked = true;
|
|
357
|
+
cwdState = "invalid";
|
|
358
|
+
actions.push("fix cwd — must resolve under HQ root");
|
|
359
|
+
}
|
|
360
|
+
else if (!d.isDirectory(target.file)) {
|
|
361
|
+
blocked = true;
|
|
362
|
+
cwdState = "missing";
|
|
363
|
+
actions.push(`create cwd on Outpost HQ tree: ${cwd}`);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
let companyState = "ok";
|
|
367
|
+
if (company && company !== "null" && !d.isDirectory(path.join(d.root, "companies", company))) {
|
|
368
|
+
companyState = "missing";
|
|
369
|
+
blocked = true;
|
|
370
|
+
}
|
|
371
|
+
else if (!company || company === "null")
|
|
372
|
+
companyState = "skipped";
|
|
373
|
+
const readiness = blocked ? "blocked" : "ready", actionsUnique = [...new Set(actions)];
|
|
374
|
+
const checks = { runtime, auth: authState, secrets: { status: secretState, missing }, cwd: cwdState, company: companyState === "skipped" ? { status: companyState } : { status: companyState, slug: company } };
|
|
375
|
+
const payload = { userId: d.env.OUTPOST_USER_ID || "", jobId: id, kind: "probe", readiness, probed_at: at, event_id: `probe-${at}-${id}`, next_actions: actionsUnique, checks };
|
|
376
|
+
let ingest, http = 0, finalReadiness = readiness, finalActions = actionsUnique, ingestFailed = false;
|
|
377
|
+
if (dry) {
|
|
378
|
+
ingest = "dry_run";
|
|
379
|
+
writeCache(d, id, readiness, "probe-dry-run");
|
|
380
|
+
}
|
|
381
|
+
else {
|
|
382
|
+
const authIdentity = resolveIdentity(d);
|
|
383
|
+
if (!authIdentity) {
|
|
384
|
+
ingestFailed = true;
|
|
385
|
+
ingest = "StatusIngestError";
|
|
386
|
+
finalReadiness = "unknown";
|
|
387
|
+
finalActions = [...actionsUnique, "status ingest failed — retry probe"];
|
|
388
|
+
writeAttempt(d, id, { job_id: id, probed_at: at, local_readiness: readiness, ingest: { ok: false, code: "StatusIngestError", step: "config", message: "missing Outpost identity (OUTPOST_USER_ID / OUTPOST_INSTANCE_TOKEN)", retryable: true }, reported_readiness: null, next_actions: finalActions, checks });
|
|
389
|
+
}
|
|
390
|
+
else {
|
|
391
|
+
const body = { ...payload, userId: authIdentity.user, ...(authIdentity.outpost ? { outpostId: authIdentity.outpost } : {}) }, res = await post(body, authIdentity.token, d);
|
|
392
|
+
http = res.http;
|
|
393
|
+
if (http >= 200 && http < 300) {
|
|
394
|
+
ingest = "ok";
|
|
395
|
+
writeAttempt(d, id, { job_id: id, probed_at: at, local_readiness: readiness, ingest: { ok: true, http }, reported_readiness: readiness, next_actions: actionsUnique, checks });
|
|
396
|
+
writeCache(d, id, readiness, "probe-ingest");
|
|
397
|
+
}
|
|
398
|
+
else {
|
|
399
|
+
ingestFailed = true;
|
|
400
|
+
ingest = "StatusIngestError";
|
|
401
|
+
finalReadiness = "unknown";
|
|
402
|
+
finalActions = [...actionsUnique, "status ingest failed — retry probe"];
|
|
403
|
+
let response;
|
|
404
|
+
try {
|
|
405
|
+
response = JSON.parse(res.body);
|
|
406
|
+
}
|
|
407
|
+
catch {
|
|
408
|
+
response = { raw: res.body };
|
|
409
|
+
}
|
|
410
|
+
writeAttempt(d, id, { job_id: id, probed_at: at, local_readiness: readiness, ingest: { ok: false, code: "StatusIngestError", http, retryable: res.retryable, body: response }, reported_readiness: null, next_actions: finalActions, checks });
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
return { code: ingestFailed ? 3 : blocked ? 1 : 0, value: { job_id: id, name, job_file: file, readiness: finalReadiness, probed_at: at, ingest, http, dry_run: dry, checks, next_actions: finalActions } };
|
|
415
|
+
}
|
|
416
|
+
function parseArgs(argv) {
|
|
417
|
+
const a = [...argv];
|
|
418
|
+
let root, dry = false, all = false, target = "";
|
|
419
|
+
while (a.length) {
|
|
420
|
+
const x = a[0];
|
|
421
|
+
if (x === "--hq-root") {
|
|
422
|
+
root = a[1] ?? "";
|
|
423
|
+
if (!root)
|
|
424
|
+
throw new Fatal("--hq-root requires a directory");
|
|
425
|
+
a.splice(0, 2);
|
|
426
|
+
}
|
|
427
|
+
else if (x === "--dry-run" || x === "--no-ingest") {
|
|
428
|
+
dry = true;
|
|
429
|
+
a.shift();
|
|
430
|
+
}
|
|
431
|
+
else if (x === "--all") {
|
|
432
|
+
all = true;
|
|
433
|
+
a.shift();
|
|
434
|
+
}
|
|
435
|
+
else if (x === "--json")
|
|
436
|
+
a.shift();
|
|
437
|
+
else if (x === "-h" || x === "--help")
|
|
438
|
+
return { help: true, dry, all, target };
|
|
439
|
+
else if (x === "--") {
|
|
440
|
+
a.shift();
|
|
441
|
+
break;
|
|
442
|
+
}
|
|
443
|
+
else if (x.startsWith("-"))
|
|
444
|
+
throw new Fatal(`unknown option: ${x}`);
|
|
445
|
+
else {
|
|
446
|
+
if (target)
|
|
447
|
+
throw new Fatal(`unexpected argument: ${x}`);
|
|
448
|
+
target = x;
|
|
449
|
+
a.shift();
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
return { root, dry, all, target };
|
|
453
|
+
}
|
|
454
|
+
export async function runJobProbe(argv, injected) {
|
|
455
|
+
let opts;
|
|
456
|
+
try {
|
|
457
|
+
opts = parseArgs(argv);
|
|
458
|
+
}
|
|
459
|
+
catch (x) {
|
|
460
|
+
const e = x instanceof Fatal ? x : new Fatal(String(x));
|
|
461
|
+
return { code: e.code, stdout: "", stderr: `${e.prefix}: ${e.message}\n` };
|
|
462
|
+
}
|
|
463
|
+
if (opts.help)
|
|
464
|
+
return { code: 0, stdout: JOB_PROBE_USAGE, stderr: "" };
|
|
465
|
+
const root = opts.root || injected?.root || process.env.HQ_ROOT || process.cwd();
|
|
466
|
+
const d = injected ? { ...injected, root } : defaultProbeDeps(root), output = [];
|
|
467
|
+
const failure = (x) => { const e = x instanceof Fatal ? x : new Fatal(String(x)); return { code: e.code, stdout: output.map((s) => `${s}\n`).join(""), stderr: `${e.rawStderr}${e.prefix}: ${e.message}\n` }; };
|
|
468
|
+
if (opts.all) {
|
|
469
|
+
if (opts.target)
|
|
470
|
+
return { code: 2, stdout: "", stderr: "hq-job-probe: --all does not take a job argument\n" };
|
|
471
|
+
const files = allFiles(root);
|
|
472
|
+
if (!files.length)
|
|
473
|
+
return { code: 2, stdout: "", stderr: `hq-job-probe: no job YAML found under personal/jobs or companies/*/jobs in ${root}\n` };
|
|
474
|
+
let code = 0;
|
|
475
|
+
try {
|
|
476
|
+
for (const f of files) {
|
|
477
|
+
const r = await probeFile(f, opts.dry, d);
|
|
478
|
+
output.push(compact(r.value));
|
|
479
|
+
code = Math.max(code, r.code);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
catch (x) {
|
|
483
|
+
return failure(x);
|
|
484
|
+
}
|
|
485
|
+
return { code, stdout: output.map((s) => `${s}\n`).join(""), stderr: "" };
|
|
486
|
+
}
|
|
487
|
+
if (!opts.target)
|
|
488
|
+
return { code: 2, stdout: "", stderr: "hq-job-probe: usage: hq-job-probe.sh [--dry-run] <job.yaml|job-id> | --all\n" };
|
|
489
|
+
let file;
|
|
490
|
+
if (d.isFile(opts.target))
|
|
491
|
+
file = opts.target;
|
|
492
|
+
else if (d.isFile(path.join(root, opts.target)))
|
|
493
|
+
file = path.join(root, opts.target);
|
|
494
|
+
else {
|
|
495
|
+
try {
|
|
496
|
+
file = findById(opts.target, d);
|
|
497
|
+
}
|
|
498
|
+
catch (x) {
|
|
499
|
+
// The shell's duplicate-ID die runs inside process substitution; its caller
|
|
500
|
+
// then emits the ordinary not-found error after the process substitution
|
|
501
|
+
// closes. Preserve both stderr lines for byte parity.
|
|
502
|
+
if (x instanceof Fatal && x.message.startsWith("duplicate job id '")) {
|
|
503
|
+
return { code: x.code, stdout: "", stderr: `${x.prefix}: ${x.message}\n${x.prefix}: job id not found in registry: ${opts.target}\n` };
|
|
504
|
+
}
|
|
505
|
+
return failure(x);
|
|
506
|
+
}
|
|
507
|
+
if (!file)
|
|
508
|
+
return { code: 2, stdout: "", stderr: `hq-job-probe: job id not found in registry: ${opts.target}\n` };
|
|
509
|
+
}
|
|
510
|
+
try {
|
|
511
|
+
const r = await probeFile(file, opts.dry, d);
|
|
512
|
+
return { code: r.code, stdout: `${compact(r.value)}\n`, stderr: "" };
|
|
513
|
+
}
|
|
514
|
+
catch (x) {
|
|
515
|
+
return failure(x);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
function rawArgs(command, fallback) { let cursor = 0, start = -1; for (const n of ["core", "job", "probe"]) {
|
|
519
|
+
const i = process.argv.indexOf(n, cursor);
|
|
520
|
+
if (i < 0)
|
|
521
|
+
return command.args.length ? command.args : fallback;
|
|
522
|
+
cursor = i + 1;
|
|
523
|
+
start = cursor;
|
|
524
|
+
} return start < 0 ? fallback : process.argv.slice(start); }
|
|
525
|
+
function registrationRootOptions(argv) {
|
|
526
|
+
let help = false, hasRoot = false, root;
|
|
527
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
528
|
+
const arg = argv[index];
|
|
529
|
+
if (arg === "--")
|
|
530
|
+
break;
|
|
531
|
+
if (arg === "-h" || arg === "--help")
|
|
532
|
+
help = true;
|
|
533
|
+
if (arg === "--hq-root") {
|
|
534
|
+
hasRoot = true;
|
|
535
|
+
root = argv[index + 1];
|
|
536
|
+
index += 1;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
return { help, hasRoot, root };
|
|
540
|
+
}
|
|
541
|
+
export function registerCoreJobProbeCommand(job) {
|
|
542
|
+
const command = job.command("probe").description("Check scheduled-job requirements and report readiness").helpOption(false).allowUnknownOption().allowExcessArguments(true).argument("[args...]")
|
|
543
|
+
.action(async (args = [], _options, current) => {
|
|
544
|
+
const argv = rawArgs(current, args), core = job.parent, forwarded = registrationRootOptions(argv);
|
|
545
|
+
const root = !forwarded.help && (!forwarded.hasRoot || Boolean(forwarded.root))
|
|
546
|
+
? resolveLiveRoot({ hqRoot: forwarded.hasRoot ? forwarded.root : core.opts().hqRoot })
|
|
547
|
+
: undefined;
|
|
548
|
+
if (!await coreNativeJobsEnabled()) {
|
|
549
|
+
const r = runBundledScript({ asset: JOB_PROBE_ASSET, args: argv, cwd: process.cwd(), ...(root ? { hqRoot: root } : {}) });
|
|
550
|
+
if (r.code)
|
|
551
|
+
process.exit(r.code);
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
const r = await runJobProbe(argv, defaultProbeDeps(root ?? (process.env.HQ_ROOT || process.cwd())));
|
|
555
|
+
if (r.stdout)
|
|
556
|
+
process.stdout.write(r.stdout.endsWith("\n") ? r.stdout : `${r.stdout}\n`);
|
|
557
|
+
if (r.stderr)
|
|
558
|
+
process.stderr.write(r.stderr);
|
|
559
|
+
if (r.code)
|
|
560
|
+
process.exit(r.code);
|
|
561
|
+
});
|
|
562
|
+
describeCoreCommand(command, { execution: "native", root: "live" });
|
|
563
|
+
}
|
|
564
|
+
//# sourceMappingURL=core-job-probe-native.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export type RemoteLauncherDependencies = {
|
|
2
|
+
runAgent(binary: string, args: string[], cwd: string): Promise<number>;
|
|
3
|
+
writeExitFile(file: string, exitCode: number): void;
|
|
4
|
+
sleep(milliseconds: number): Promise<void>;
|
|
5
|
+
};
|
|
6
|
+
export declare function runRemoteLauncher(argv: string[], injected?: Partial<RemoteLauncherDependencies>): Promise<number>;
|
|
7
|
+
//# sourceMappingURL=core-job-remote-launcher.d.ts.map
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
function signalNumber(signal) {
|
|
6
|
+
if (!signal)
|
|
7
|
+
return 0;
|
|
8
|
+
return { SIGHUP: 1, SIGINT: 2, SIGQUIT: 3, SIGKILL: 9, SIGTERM: 15 }[signal] ?? 0;
|
|
9
|
+
}
|
|
10
|
+
const defaultDependencies = {
|
|
11
|
+
runAgent: (binary, args, cwd) => new Promise((resolve) => {
|
|
12
|
+
let settled = false;
|
|
13
|
+
const child = spawn(binary, args, { cwd, stdio: "inherit" });
|
|
14
|
+
child.once("error", (error) => {
|
|
15
|
+
if (settled)
|
|
16
|
+
return;
|
|
17
|
+
settled = true;
|
|
18
|
+
process.stderr.write(`${binary}: ${error.message}\n`);
|
|
19
|
+
resolve(127);
|
|
20
|
+
});
|
|
21
|
+
child.once("close", (code, signal) => {
|
|
22
|
+
if (settled)
|
|
23
|
+
return;
|
|
24
|
+
settled = true;
|
|
25
|
+
resolve(code ?? 128 + signalNumber(signal));
|
|
26
|
+
});
|
|
27
|
+
}),
|
|
28
|
+
writeExitFile: (file, exitCode) => fs.writeFileSync(file, `EXIT=${exitCode}\n`),
|
|
29
|
+
sleep: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)),
|
|
30
|
+
};
|
|
31
|
+
export async function runRemoteLauncher(argv, injected = {}) {
|
|
32
|
+
const [exitFile, ...agentArgs] = argv;
|
|
33
|
+
if (!exitFile || agentArgs.length === 0) {
|
|
34
|
+
process.stderr.write("core-job-remote-launcher: expected exit file and Claude arguments\n");
|
|
35
|
+
return 2;
|
|
36
|
+
}
|
|
37
|
+
const deps = { ...defaultDependencies, ...injected };
|
|
38
|
+
const exitCode = await deps.runAgent("claude", agentArgs, process.cwd());
|
|
39
|
+
deps.writeExitFile(exitFile, exitCode);
|
|
40
|
+
await deps.sleep(15_000);
|
|
41
|
+
return exitCode;
|
|
42
|
+
}
|
|
43
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
44
|
+
void runRemoteLauncher(process.argv.slice(2))
|
|
45
|
+
.then((exitCode) => { process.exitCode = exitCode; })
|
|
46
|
+
.catch((error) => {
|
|
47
|
+
process.stderr.write(`core-job-remote-launcher: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
48
|
+
process.exitCode = 1;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
//# sourceMappingURL=core-job-remote-launcher.js.map
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** Native TypeScript port of the serialized scheduled-job runner. */
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
export declare const OUTPOST_IDENTITY_FILE = "/etc/outpost/codex-identity.env";
|
|
5
|
+
export declare const OUTPOST_RUNNER_FILE = "/usr/local/bin/outpost-runner.sh";
|
|
6
|
+
type ProcessResult = {
|
|
7
|
+
code: number;
|
|
8
|
+
stdout: Buffer;
|
|
9
|
+
stderr: Buffer;
|
|
10
|
+
};
|
|
11
|
+
export type ProcessRequest = {
|
|
12
|
+
file: string;
|
|
13
|
+
args: string[];
|
|
14
|
+
cwd: string;
|
|
15
|
+
env: NodeJS.ProcessEnv;
|
|
16
|
+
logPath?: string;
|
|
17
|
+
timeoutMs?: number;
|
|
18
|
+
};
|
|
19
|
+
type LockLease = {
|
|
20
|
+
release(): Promise<void>;
|
|
21
|
+
};
|
|
22
|
+
export type JobRunDependencies = {
|
|
23
|
+
env: NodeJS.ProcessEnv;
|
|
24
|
+
cwd: string;
|
|
25
|
+
home: string;
|
|
26
|
+
now(): Date;
|
|
27
|
+
epochNow(): number;
|
|
28
|
+
readFile(file: string): Buffer;
|
|
29
|
+
stat(file: string): fs.Stats;
|
|
30
|
+
mkdir(directory: string): void;
|
|
31
|
+
writeFile(file: string, content: string | Buffer): void;
|
|
32
|
+
appendFile(file: string, content: string | Buffer): void;
|
|
33
|
+
chmod(file: string, mode: number): void;
|
|
34
|
+
remove(file: string): void;
|
|
35
|
+
fetch(input: string, init: RequestInit): Promise<Response>;
|
|
36
|
+
runProcess(request: ProcessRequest): Promise<ProcessResult>;
|
|
37
|
+
acquireLock(file: string): Promise<LockLease>;
|
|
38
|
+
sleep(milliseconds: number): Promise<void>;
|
|
39
|
+
scriptPath: string;
|
|
40
|
+
};
|
|
41
|
+
export declare function spawnProcess(request: ProcessRequest): Promise<ProcessResult>;
|
|
42
|
+
/** Holds the same kernel flock(2) lock used by the shell companion. */
|
|
43
|
+
export declare function acquireFlock(file: string, env?: NodeJS.ProcessEnv): Promise<LockLease>;
|
|
44
|
+
export declare function runJob(argv: string[], hqRootOverride?: string, injected?: Partial<JobRunDependencies>): Promise<number>;
|
|
45
|
+
export declare function registerCoreJobRunCommand(job: Command): void;
|
|
46
|
+
export {};
|
|
47
|
+
//# sourceMappingURL=core-job-run-native.d.ts.map
|