@indigoai-us/hq-cli 5.345.144 → 5.345.145
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 +19 -0
- package/dist/command-catalog.generated.d.ts +8 -0
- package/dist/command-catalog.generated.js +10 -0
- package/dist/commands/core-checkpoint-native.d.ts +30 -0
- package/dist/commands/core-checkpoint-native.js +167 -10
- package/dist/commands/core-job-notify-native.js +53 -3
- package/dist/commands/core-jobs-native.d.ts +8 -0
- package/dist/commands/core-jobs-native.js +34 -2
- package/dist/commands/members.d.ts +20 -0
- package/dist/commands/members.js +51 -2
- package/dist/commands/whoami.js +9 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.345.145] — 2026-10-17
|
|
6
|
+
- `hq core checkpoint`: the background checkpoint helper now fills in automatic
|
|
7
|
+
saves that were queued before it started. It writes its own report first,
|
|
8
|
+
then works through at most 5 queued saves per run; any extra queued saves go
|
|
9
|
+
back on the queue for the next run instead of being dropped. If a run is
|
|
10
|
+
stopped partway through its queued saves, the next run puts the ones it did
|
|
11
|
+
not finish back on the queue.
|
|
12
|
+
- Outpost scheduled-job alerts now reach owners recorded by their Cognito user
|
|
13
|
+
id. `hq core job notify` used to pass the id to `hq dm`, which read it as a
|
|
14
|
+
channel name and failed with "No channel named …". It now sends to the email
|
|
15
|
+
`hq whoami --json` reports for that id (the signed-in person, or the person
|
|
16
|
+
an Outpost acts for), and logs a clear reason when it cannot match one.
|
|
17
|
+
- `hq whoami --json` adds `sub`, `delegatedEmail` and `delegatedSub`.
|
|
18
|
+
- `hq core jobs reconcile` matches company jobs owned by the Outpost owner's
|
|
19
|
+
email or id, and reaches the user systemd manager when it runs from a system
|
|
20
|
+
service with no login session (it used to fail with "Failed to connect to
|
|
21
|
+
bus: No medium found").
|
|
22
|
+
- `hq members list --json` prints each member's person id (`person_id`, the `prs_` id that file and secret grants use), email, name, slug, role and membership key. `--pending --json` does the same for pending invites. `hq members list --ids` adds a PERSON_ID column to the table. The default table is unchanged.
|
|
23
|
+
|
|
5
24
|
## [5.345.144] — 2026-10-16
|
|
6
25
|
- New `hq library add <file|url>...` uploads files or http(s) URLs straight to
|
|
7
26
|
the company vault and writes only a `.hqcloud` placeholder into
|
|
@@ -3586,6 +3586,14 @@ export declare const COMMAND_CATALOG: readonly [{
|
|
|
3586
3586
|
readonly flags: "--pending";
|
|
3587
3587
|
readonly description: "List pending invites instead of active members";
|
|
3588
3588
|
readonly hidden: false;
|
|
3589
|
+
}, {
|
|
3590
|
+
readonly flags: "--ids";
|
|
3591
|
+
readonly description: "Add a PERSON_ID column (the prs_ id used in grants and membership keys)";
|
|
3592
|
+
readonly hidden: false;
|
|
3593
|
+
}, {
|
|
3594
|
+
readonly flags: "--json";
|
|
3595
|
+
readonly description: "Print machine-readable JSON, including each person id";
|
|
3596
|
+
readonly hidden: false;
|
|
3589
3597
|
}];
|
|
3590
3598
|
readonly subcommands: readonly [];
|
|
3591
3599
|
}, {
|
|
@@ -4333,6 +4333,16 @@ export const COMMAND_CATALOG = [
|
|
|
4333
4333
|
"flags": "--pending",
|
|
4334
4334
|
"description": "List pending invites instead of active members",
|
|
4335
4335
|
"hidden": false
|
|
4336
|
+
},
|
|
4337
|
+
{
|
|
4338
|
+
"flags": "--ids",
|
|
4339
|
+
"description": "Add a PERSON_ID column (the prs_ id used in grants and membership keys)",
|
|
4340
|
+
"hidden": false
|
|
4341
|
+
},
|
|
4342
|
+
{
|
|
4343
|
+
"flags": "--json",
|
|
4344
|
+
"description": "Print machine-readable JSON, including each person id",
|
|
4345
|
+
"hidden": false
|
|
4336
4346
|
}
|
|
4337
4347
|
],
|
|
4338
4348
|
"subcommands": []
|
|
@@ -27,6 +27,14 @@ export declare const NO_BACKEND_RETRY_MS: number;
|
|
|
27
27
|
* always releases the queue before the lock would be presumed abandoned.
|
|
28
28
|
*/
|
|
29
29
|
export declare const SIBLING_MAX_RUNTIME_MS: number;
|
|
30
|
+
/**
|
|
31
|
+
* Queued payloads one sibling run may process, across its launch claim and its
|
|
32
|
+
* own queue drain. Each one runs the full upgrade flow, so a 50-deep queue in
|
|
33
|
+
* one run outlasts SIBLING_MAX_RUNTIME_MS and the grok turn cap; the run is
|
|
34
|
+
* then killed with work half done. The launcher re-queues the overflow for the
|
|
35
|
+
* next sibling instead.
|
|
36
|
+
*/
|
|
37
|
+
export declare const MAX_QUEUED_PAYLOADS_PER_RUN = 5;
|
|
30
38
|
type Backend = "claude" | "codex" | "grok" | "none";
|
|
31
39
|
type SpawnableBackend = Exclude<Backend, "none">;
|
|
32
40
|
/**
|
|
@@ -121,6 +129,28 @@ export declare function siblingLaunch(backend: SpawnableBackend, args: string[],
|
|
|
121
129
|
command: string;
|
|
122
130
|
args: string[];
|
|
123
131
|
};
|
|
132
|
+
/**
|
|
133
|
+
* Keep the oldest {@link MAX_QUEUED_PAYLOADS_PER_RUN} claimed payloads for this
|
|
134
|
+
* run and append the rest back to the queue, so a deep queue is worked off
|
|
135
|
+
* across several runs instead of being lost when one run hits its cap. The
|
|
136
|
+
* claim file is rewritten to hold only what this run owns.
|
|
137
|
+
*/
|
|
138
|
+
export declare function limitClaimedPayloads(entries: unknown[], claimPath: string, pendingPath: string, limit?: number): unknown[];
|
|
139
|
+
/** Times one queued record may be recovered from a finished run before it is dropped. */
|
|
140
|
+
export declare const MAX_CLAIM_RECOVERIES = 3;
|
|
141
|
+
/**
|
|
142
|
+
* Put back the queued records a finished sibling claimed but never processed.
|
|
143
|
+
*
|
|
144
|
+
* A sibling stopped at its wall-clock or turn cap leaves its claim files
|
|
145
|
+
* (pending-claimed*.jsonl) in its run directory. A record whose thread file
|
|
146
|
+
* still exists at its thread_path was not upgraded (the sibling renames the
|
|
147
|
+
* -auto- file when it is done), so it is appended back to pending.jsonl for
|
|
148
|
+
* this launch to claim. Only runs whose process is gone are read, records
|
|
149
|
+
* already queued are not duplicated, and each record is recovered at most
|
|
150
|
+
* {@link MAX_CLAIM_RECOVERIES} times so one the sibling keeps skipping cannot
|
|
151
|
+
* cycle forever. Each claim file read is renamed to *.recovered.
|
|
152
|
+
*/
|
|
153
|
+
export declare function recoverOrphanedClaims(siblingRoot: string, pendingPath: string, liveRoot: string): number;
|
|
124
154
|
/** Attach the native checkpoint command to the hidden `hq core` group. */
|
|
125
155
|
export declare function registerCoreCheckpointCommand(core: Command, preResolvedHqRoot?: string): void;
|
|
126
156
|
export {};
|
|
@@ -92,6 +92,14 @@ const SIBLING_LOCK_TTL_MS = 60 * 60 * 1000;
|
|
|
92
92
|
export const SIBLING_MAX_RUNTIME_MS = 30 * 60 * 1000;
|
|
93
93
|
/** Newest-wins cap so a wedged sibling cannot grow an unbounded queue. */
|
|
94
94
|
const MAX_PENDING_PAYLOADS = 50;
|
|
95
|
+
/**
|
|
96
|
+
* Queued payloads one sibling run may process, across its launch claim and its
|
|
97
|
+
* own queue drain. Each one runs the full upgrade flow, so a 50-deep queue in
|
|
98
|
+
* one run outlasts SIBLING_MAX_RUNTIME_MS and the grok turn cap; the run is
|
|
99
|
+
* then killed with work half done. The launcher re-queues the overflow for the
|
|
100
|
+
* next sibling instead.
|
|
101
|
+
*/
|
|
102
|
+
export const MAX_QUEUED_PAYLOADS_PER_RUN = 5;
|
|
95
103
|
class CheckpointUsageError extends Error {
|
|
96
104
|
}
|
|
97
105
|
/**
|
|
@@ -202,16 +210,37 @@ report instead of guessing.
|
|
|
202
210
|
skip silently on any failure. Hook or automation improvements go ONLY under
|
|
203
211
|
personal/hooks/ as proposals.
|
|
204
212
|
7. WRITE BOUNDS: you may write only under personal/, workspace/, and companies/<company>/ as constrained above. You must NEVER write into .claude/, core/, .agents/, .codex/, repos/, or anywhere outside the HQ root.
|
|
205
|
-
8. Write <runDir>/report.md
|
|
206
|
-
(paths), and what you skipped and
|
|
207
|
-
|
|
213
|
+
8. Write <runDir>/report.md NOW, before you touch any queued payload — full
|
|
214
|
+
prose: what you read, what you changed (paths), and what you skipped and
|
|
215
|
+
why. List every policy or knowledge file you
|
|
216
|
+
amended, superseded, merged or deleted with the reason, so a human can
|
|
208
217
|
audit and reverse it; a deletion you do not name in the report is a defect.
|
|
209
|
-
|
|
210
|
-
|
|
218
|
+
The run can be stopped at its time or turn cap at any point after this, so
|
|
219
|
+
this run's own report must already exist.
|
|
220
|
+
9. QUEUED PAYLOADS CLAIMED AT LAUNCH: the payload's pending_payloads array
|
|
221
|
+
holds checkpoints that were queued while an earlier sibling was busy; the
|
|
222
|
+
same records are in <runDir>/pending-claimed.jsonl. The launcher hands one
|
|
223
|
+
run at most <maxQueued> of them and has already put any others back in
|
|
224
|
+
workspace/checkpoints/sibling/pending.jsonl. For EACH record in
|
|
225
|
+
pending_payloads, in order, run steps 1-5 with that record as the payload:
|
|
226
|
+
upgrade the thread file named in its thread_path IN PLACE and drop -auto-
|
|
227
|
+
from its filename. If that -auto- file no longer exists, the record was
|
|
228
|
+
already processed; note it and move on. For a queued record, skip the
|
|
229
|
+
journal close in step 4 (it belongs to the parent session) and skip step 5b
|
|
230
|
+
(ontology); in steps 3 and 7, <company> means that record's own company, not
|
|
231
|
+
this run's. After EACH record, append one line to <runDir>/report.md naming
|
|
232
|
+
its thread and what you did, so a run stopped mid-queue shows how far it got.
|
|
233
|
+
If the array is empty, skip this step.
|
|
234
|
+
10. Drain the queue, still within the same limit of <maxQueued> queued records
|
|
235
|
+
per run counted across step 9 and this step: while
|
|
236
|
+
workspace/checkpoints/sibling/pending.jsonl exists and is non-empty and you
|
|
237
|
+
have processed fewer than <maxQueued> queued records, claim
|
|
211
238
|
it atomically by renaming it aside —
|
|
212
239
|
mv workspace/checkpoints/sibling/pending.jsonl <runDir>/pending-claimed-N.jsonl
|
|
213
|
-
(N counting up from 2) — and process
|
|
214
|
-
|
|
240
|
+
(N counting up from 2) — and process its records as in step 9. When you
|
|
241
|
+
reach the limit, append every record of that claimed file you did not
|
|
242
|
+
process back to workspace/checkpoints/sibling/pending.jsonl (append with >>,
|
|
243
|
+
never overwrite) so the next sibling takes it, and record that in the report.
|
|
215
244
|
NEVER read the queue and truncate it in place: a payload appended between
|
|
216
245
|
your read and the truncate is lost, and its checkpoint is never enriched.
|
|
217
246
|
`;
|
|
@@ -241,7 +270,8 @@ export function renderSiblingPrompt(runDir, payloadPath, options = {}) {
|
|
|
241
270
|
.replaceAll("<ontologyStep>", () => ontologyStep)
|
|
242
271
|
.replaceAll("<runDir>", () => runDir)
|
|
243
272
|
.replaceAll("<payloadPath>", () => payloadPath)
|
|
244
|
-
.replaceAll("<hqRoot>", () => options.hqRoot ?? "")
|
|
273
|
+
.replaceAll("<hqRoot>", () => options.hqRoot ?? "")
|
|
274
|
+
.replaceAll("<maxQueued>", () => String(MAX_QUEUED_PAYLOADS_PER_RUN));
|
|
245
275
|
}
|
|
246
276
|
function collect(value, previous) {
|
|
247
277
|
return [...previous, value];
|
|
@@ -1137,7 +1167,8 @@ function appendPending(pendingPath, payload) {
|
|
|
1137
1167
|
* describes is never enriched. Renaming is atomic, so this run receives exactly
|
|
1138
1168
|
* the file that existed at the moment of the claim while concurrent callers go
|
|
1139
1169
|
* on appending to a fresh queue. The claimed file is left in the run directory
|
|
1140
|
-
* as an audit trail and as a recovery point if this sibling dies
|
|
1170
|
+
* as an audit trail and as a recovery point if this sibling dies: the next
|
|
1171
|
+
* launch re-queues its unprocessed records through recoverOrphanedClaims.
|
|
1141
1172
|
*/
|
|
1142
1173
|
function claimPending(pendingPath, claimPath) {
|
|
1143
1174
|
try {
|
|
@@ -1162,6 +1193,124 @@ function claimPending(pendingPath, claimPath) {
|
|
|
1162
1193
|
}
|
|
1163
1194
|
return entries;
|
|
1164
1195
|
}
|
|
1196
|
+
/**
|
|
1197
|
+
* Keep the oldest {@link MAX_QUEUED_PAYLOADS_PER_RUN} claimed payloads for this
|
|
1198
|
+
* run and append the rest back to the queue, so a deep queue is worked off
|
|
1199
|
+
* across several runs instead of being lost when one run hits its cap. The
|
|
1200
|
+
* claim file is rewritten to hold only what this run owns.
|
|
1201
|
+
*/
|
|
1202
|
+
export function limitClaimedPayloads(entries, claimPath, pendingPath, limit = MAX_QUEUED_PAYLOADS_PER_RUN) {
|
|
1203
|
+
if (entries.length <= limit)
|
|
1204
|
+
return entries;
|
|
1205
|
+
const kept = entries.slice(0, limit);
|
|
1206
|
+
const overflow = entries.slice(limit);
|
|
1207
|
+
// Re-queue first: if the rewrite below fails, every record is still on disk.
|
|
1208
|
+
fs.appendFileSync(pendingPath, overflow.map((entry) => `${JSON.stringify(entry)}\n`).join(""));
|
|
1209
|
+
fs.writeFileSync(claimPath, kept.map((entry) => `${JSON.stringify(entry)}\n`).join(""));
|
|
1210
|
+
return kept;
|
|
1211
|
+
}
|
|
1212
|
+
/** Times one queued record may be recovered from a finished run before it is dropped. */
|
|
1213
|
+
export const MAX_CLAIM_RECOVERIES = 3;
|
|
1214
|
+
/**
|
|
1215
|
+
* Put back the queued records a finished sibling claimed but never processed.
|
|
1216
|
+
*
|
|
1217
|
+
* A sibling stopped at its wall-clock or turn cap leaves its claim files
|
|
1218
|
+
* (pending-claimed*.jsonl) in its run directory. A record whose thread file
|
|
1219
|
+
* still exists at its thread_path was not upgraded (the sibling renames the
|
|
1220
|
+
* -auto- file when it is done), so it is appended back to pending.jsonl for
|
|
1221
|
+
* this launch to claim. Only runs whose process is gone are read, records
|
|
1222
|
+
* already queued are not duplicated, and each record is recovered at most
|
|
1223
|
+
* {@link MAX_CLAIM_RECOVERIES} times so one the sibling keeps skipping cannot
|
|
1224
|
+
* cycle forever. Each claim file read is renamed to *.recovered.
|
|
1225
|
+
*/
|
|
1226
|
+
export function recoverOrphanedClaims(siblingRoot, pendingPath, liveRoot) {
|
|
1227
|
+
let runDirs;
|
|
1228
|
+
try {
|
|
1229
|
+
runDirs = fs.readdirSync(siblingRoot).filter((entry) => entry !== "pending.jsonl");
|
|
1230
|
+
}
|
|
1231
|
+
catch {
|
|
1232
|
+
return 0;
|
|
1233
|
+
}
|
|
1234
|
+
const queuedThreads = new Set();
|
|
1235
|
+
try {
|
|
1236
|
+
for (const line of fs.readFileSync(pendingPath, "utf8").split("\n")) {
|
|
1237
|
+
if (!line.trim())
|
|
1238
|
+
continue;
|
|
1239
|
+
try {
|
|
1240
|
+
const threadPath = JSON.parse(line).thread_path;
|
|
1241
|
+
if (typeof threadPath === "string")
|
|
1242
|
+
queuedThreads.add(threadPath);
|
|
1243
|
+
}
|
|
1244
|
+
catch {
|
|
1245
|
+
// A torn line names no thread.
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
catch {
|
|
1250
|
+
// No queue yet.
|
|
1251
|
+
}
|
|
1252
|
+
const recovered = [];
|
|
1253
|
+
let count = 0;
|
|
1254
|
+
for (const runDir of runDirs.sort()) {
|
|
1255
|
+
const absolute = path.join(siblingRoot, runDir);
|
|
1256
|
+
let claimFiles;
|
|
1257
|
+
try {
|
|
1258
|
+
claimFiles = fs.readdirSync(absolute).filter((entry) => /^pending-claimed(?:-\d+)?\.jsonl$/u.test(entry));
|
|
1259
|
+
}
|
|
1260
|
+
catch {
|
|
1261
|
+
continue;
|
|
1262
|
+
}
|
|
1263
|
+
if (claimFiles.length === 0)
|
|
1264
|
+
continue;
|
|
1265
|
+
let pid = Number.NaN;
|
|
1266
|
+
try {
|
|
1267
|
+
pid = Number.parseInt(fs.readFileSync(path.join(absolute, "sibling.pid"), "utf8").trim(), 10);
|
|
1268
|
+
}
|
|
1269
|
+
catch {
|
|
1270
|
+
// A run that never recorded a PID cannot still be running.
|
|
1271
|
+
}
|
|
1272
|
+
if (Number.isSafeInteger(pid) && pid > 0 && processAlive(pid))
|
|
1273
|
+
continue;
|
|
1274
|
+
for (const claimFile of claimFiles.sort()) {
|
|
1275
|
+
const claimPath = path.join(absolute, claimFile);
|
|
1276
|
+
let content;
|
|
1277
|
+
try {
|
|
1278
|
+
content = fs.readFileSync(claimPath, "utf8");
|
|
1279
|
+
}
|
|
1280
|
+
catch {
|
|
1281
|
+
continue;
|
|
1282
|
+
}
|
|
1283
|
+
for (const line of content.split("\n")) {
|
|
1284
|
+
if (!line.trim())
|
|
1285
|
+
continue;
|
|
1286
|
+
let entry;
|
|
1287
|
+
try {
|
|
1288
|
+
entry = JSON.parse(line);
|
|
1289
|
+
}
|
|
1290
|
+
catch {
|
|
1291
|
+
continue;
|
|
1292
|
+
}
|
|
1293
|
+
const threadPath = entry.thread_path;
|
|
1294
|
+
if (typeof threadPath !== "string" || !threadPath || queuedThreads.has(threadPath))
|
|
1295
|
+
continue;
|
|
1296
|
+
if (!fs.existsSync(path.resolve(liveRoot, threadPath)))
|
|
1297
|
+
continue;
|
|
1298
|
+
const attempts = typeof entry.claim_recoveries === "number" ? entry.claim_recoveries : 0;
|
|
1299
|
+
if (attempts >= MAX_CLAIM_RECOVERIES)
|
|
1300
|
+
continue;
|
|
1301
|
+
queuedThreads.add(threadPath);
|
|
1302
|
+
recovered.push(`${JSON.stringify({ ...entry, claim_recoveries: attempts + 1 })}\n`);
|
|
1303
|
+
count += 1;
|
|
1304
|
+
}
|
|
1305
|
+
// Re-queue before retiring the claim file, so a failure leaves the
|
|
1306
|
+
// records on disk; a repeat read is deduplicated by thread_path above.
|
|
1307
|
+
if (recovered.length > 0)
|
|
1308
|
+
fs.appendFileSync(pendingPath, recovered.splice(0).join(""));
|
|
1309
|
+
fs.renameSync(claimPath, `${claimPath}.recovered`);
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
return count;
|
|
1313
|
+
}
|
|
1165
1314
|
async function startSibling(liveRoot, input, threadPath, backend) {
|
|
1166
1315
|
const stateDir = path.join(liveRoot, "workspace", "orchestrator", "hook-state");
|
|
1167
1316
|
const siblingRoot = path.join(liveRoot, "workspace", "checkpoints", "sibling");
|
|
@@ -1183,9 +1332,17 @@ async function startSibling(liveRoot, input, threadPath, backend) {
|
|
|
1183
1332
|
printResult(`checkpoint: sibling busy — payload queued (${depth} pending)`);
|
|
1184
1333
|
return;
|
|
1185
1334
|
}
|
|
1335
|
+
// Before this run's directory exists, so only earlier runs are read.
|
|
1336
|
+
try {
|
|
1337
|
+
recoverOrphanedClaims(siblingRoot, pendingPath, liveRoot);
|
|
1338
|
+
}
|
|
1339
|
+
catch {
|
|
1340
|
+
// Recovery is best effort; the claim files stay on disk for the next launch.
|
|
1341
|
+
}
|
|
1186
1342
|
const runDir = path.join(siblingRoot, `${formatTimestamp(new Date())}-${summarySlug(input.summary ?? "checkpoint")}`);
|
|
1187
1343
|
fs.mkdirSync(runDir, { recursive: true });
|
|
1188
|
-
|
|
1344
|
+
const claimPath = path.join(runDir, "pending-claimed.jsonl");
|
|
1345
|
+
payload.pending_payloads = limitClaimedPayloads(claimPending(pendingPath, claimPath), claimPath, pendingPath);
|
|
1189
1346
|
if (!payload.company) {
|
|
1190
1347
|
let queued;
|
|
1191
1348
|
try {
|
|
@@ -195,6 +195,40 @@ function emailFromWhoami(output) {
|
|
|
195
195
|
}
|
|
196
196
|
return "";
|
|
197
197
|
}
|
|
198
|
+
const EMAIL_RECIPIENT = /^[^\s@]+@[^\s@]+$/;
|
|
199
|
+
const COGNITO_SUB = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
200
|
+
/**
|
|
201
|
+
* Outpost jobs may record `owner` as the owner's Cognito user id (the box's
|
|
202
|
+
* OUTPOST_USER_ID; the reconciler matches company jobs on it). `hq dm` cannot
|
|
203
|
+
* address a Cognito id: any token that is not an email or prs_/agt_ uid is
|
|
204
|
+
* treated as a channel name and bounces with "No channel named …". Map the id
|
|
205
|
+
* to the signed-in identity's email when `hq whoami --json` reports the same
|
|
206
|
+
* subject, directly or as the human an Outpost session acts for.
|
|
207
|
+
*/
|
|
208
|
+
function resolveCognitoOwner(owner, deps) {
|
|
209
|
+
if (!deps.commandExists("hq"))
|
|
210
|
+
return "";
|
|
211
|
+
let parsed;
|
|
212
|
+
try {
|
|
213
|
+
const raw = deps.run("hq", ["whoami", "--json"], { cwd: deps.cwd, env: deps.env, captureStdout: true }).stdout;
|
|
214
|
+
const value = JSON.parse(raw);
|
|
215
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
216
|
+
return "";
|
|
217
|
+
parsed = value;
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
return "";
|
|
221
|
+
}
|
|
222
|
+
const target = owner.toLowerCase();
|
|
223
|
+
for (const [subKey, emailKey] of [["sub", "email"], ["delegatedSub", "delegatedEmail"]]) {
|
|
224
|
+
const sub = parsed[subKey];
|
|
225
|
+
const email = parsed[emailKey];
|
|
226
|
+
if (typeof sub === "string" && sub.toLowerCase() === target
|
|
227
|
+
&& typeof email === "string" && EMAIL_RECIPIENT.test(email))
|
|
228
|
+
return email;
|
|
229
|
+
}
|
|
230
|
+
return "";
|
|
231
|
+
}
|
|
198
232
|
function normalizedEpoch(value) {
|
|
199
233
|
if (!/^-?\d+$/.test(value))
|
|
200
234
|
throw new Error(`invalid HQ_JOB_NOTIFY_NOW_EPOCH: ${value}`);
|
|
@@ -260,6 +294,17 @@ export function runJobNotify(argv, options = {}) {
|
|
|
260
294
|
log("no owner recipient — cannot dm (logged only); never fails the job");
|
|
261
295
|
deps.appendFile(deliveryLog, '{"delivered":false,"reason":"missing_owner"}\n');
|
|
262
296
|
}
|
|
297
|
+
let recipient = owner;
|
|
298
|
+
const cognitoOwner = Boolean(owner) && COGNITO_SUB.test(owner);
|
|
299
|
+
if (cognitoOwner) {
|
|
300
|
+
recipient = resolveCognitoOwner(owner, deps);
|
|
301
|
+
if (recipient)
|
|
302
|
+
log(`owner ${owner} is a Cognito user id — delivering to ${recipient}`);
|
|
303
|
+
else {
|
|
304
|
+
log(`owner ${owner} is a Cognito user id that hq dm cannot address and hq whoami does not report it — set the job owner to an email or prs_ uid (logged only); never fails the job`);
|
|
305
|
+
deps.appendFile(deliveryLog, '{"delivered":false,"reason":"unresolved_owner_id"}\n');
|
|
306
|
+
}
|
|
307
|
+
}
|
|
263
308
|
let previous = {};
|
|
264
309
|
if (deps.exists(stateFile))
|
|
265
310
|
previous = JSON.parse(deps.readFile(stateFile));
|
|
@@ -306,18 +351,22 @@ export function runJobNotify(argv, options = {}) {
|
|
|
306
351
|
log("skip dm send — missing owner");
|
|
307
352
|
deliverRc = 1;
|
|
308
353
|
}
|
|
354
|
+
else if (!recipient) {
|
|
355
|
+
log("skip dm send — owner id has no dm recipient");
|
|
356
|
+
deliverRc = 1;
|
|
357
|
+
}
|
|
309
358
|
else if (!dmBin) {
|
|
310
359
|
log("skip dm send — hq CLI not found");
|
|
311
360
|
deliverRc = 1;
|
|
312
361
|
}
|
|
313
362
|
else {
|
|
314
363
|
const args = dmBin === "hq"
|
|
315
|
-
? ["dm",
|
|
316
|
-
: [
|
|
364
|
+
? ["dm", recipient, headline, "--details", details]
|
|
365
|
+
: [recipient, headline, details];
|
|
317
366
|
deliverRc = deps.run(dmBin, args, { cwd: deps.cwd, env: deps.env, captureStdout: false, logPath: deliveryLog }).code;
|
|
318
367
|
if (deliverRc === 0) {
|
|
319
368
|
delivered = 1;
|
|
320
|
-
log(`dm delivered to ${
|
|
369
|
+
log(`dm delivered to ${recipient} kind=${kind} job=${parsed.jobId}`);
|
|
321
370
|
}
|
|
322
371
|
else
|
|
323
372
|
log(`dm delivery failed rc=${deliverRc} job=${parsed.jobId} (logged; not failing run)`);
|
|
@@ -344,6 +393,7 @@ export function runJobNotify(argv, options = {}) {
|
|
|
344
393
|
delivered: delivered === 1,
|
|
345
394
|
kind,
|
|
346
395
|
owner,
|
|
396
|
+
...(cognitoOwner ? { recipient: recipient || null } : {}),
|
|
347
397
|
channel_unimplemented: channelUnimplemented === 1,
|
|
348
398
|
deliver_rc: deliverRc,
|
|
349
399
|
}, "compact"));
|
|
@@ -15,6 +15,14 @@ export type ReconcileOptions = {
|
|
|
15
15
|
fetcher?: typeof fetch;
|
|
16
16
|
now?: () => Date;
|
|
17
17
|
};
|
|
18
|
+
/**
|
|
19
|
+
* A system unit with User=ec2-user (outpost-jobs-reconcile.service) or a cron
|
|
20
|
+
* job has no login session, so XDG_RUNTIME_DIR / DBUS_SESSION_BUS_ADDRESS are
|
|
21
|
+
* unset and `systemctl --user` fails with "Failed to connect to bus: No medium
|
|
22
|
+
* found" (feedback_6ce027d7). When the lingering user manager's runtime dir
|
|
23
|
+
* exists, point systemctl at it. Caller-provided values are kept.
|
|
24
|
+
*/
|
|
25
|
+
export declare function withUserBusEnvironment(env: NodeJS.ProcessEnv, uid: number | undefined, runtimeBase?: string, exists?: (file: string) => boolean): NodeJS.ProcessEnv;
|
|
18
26
|
export declare function runReconcile(argv: string[], rootArg?: string, options?: ReconcileOptions): Promise<number>;
|
|
19
27
|
export declare function registerCoreJobsNativeCommand(core: Command): void;
|
|
20
28
|
export declare function rawArgumentsForCommand(command: Command, fallback: string[], raw?: string[]): string[];
|
|
@@ -379,9 +379,36 @@ Env / overrides (tests):
|
|
|
379
379
|
|
|
380
380
|
Exit: 0 success; 1 partial failures; 2 usage/config.
|
|
381
381
|
`;
|
|
382
|
-
|
|
382
|
+
/**
|
|
383
|
+
* A system unit with User=ec2-user (outpost-jobs-reconcile.service) or a cron
|
|
384
|
+
* job has no login session, so XDG_RUNTIME_DIR / DBUS_SESSION_BUS_ADDRESS are
|
|
385
|
+
* unset and `systemctl --user` fails with "Failed to connect to bus: No medium
|
|
386
|
+
* found" (feedback_6ce027d7). When the lingering user manager's runtime dir
|
|
387
|
+
* exists, point systemctl at it. Caller-provided values are kept.
|
|
388
|
+
*/
|
|
389
|
+
export function withUserBusEnvironment(env, uid, runtimeBase = "/run/user", exists = fs.existsSync) {
|
|
390
|
+
if (uid === undefined)
|
|
391
|
+
return env;
|
|
392
|
+
const next = { ...env };
|
|
393
|
+
if (!next.XDG_RUNTIME_DIR) {
|
|
394
|
+
const directory = path.posix.join(runtimeBase, String(uid));
|
|
395
|
+
if (!exists(directory))
|
|
396
|
+
return next;
|
|
397
|
+
next.XDG_RUNTIME_DIR = directory;
|
|
398
|
+
}
|
|
399
|
+
if (!next.DBUS_SESSION_BUS_ADDRESS && exists(path.posix.join(next.XDG_RUNTIME_DIR, "bus"))) {
|
|
400
|
+
next.DBUS_SESSION_BUS_ADDRESS = `unix:path=${next.XDG_RUNTIME_DIR}/bus`;
|
|
401
|
+
}
|
|
402
|
+
return next;
|
|
403
|
+
}
|
|
404
|
+
function schedulerCommandResult(command, args) {
|
|
405
|
+
const env = withUserBusEnvironment(process.env, typeof process.getuid === "function" ? process.getuid() : undefined, process.env.HQ_JOB_USER_RUNTIME_BASE || "/run/user");
|
|
406
|
+
return commandResult(command, args, env);
|
|
407
|
+
}
|
|
408
|
+
function commandResult(command, args, env = process.env) {
|
|
383
409
|
const result = spawnSync(command, args, {
|
|
384
410
|
encoding: "utf8",
|
|
411
|
+
env,
|
|
385
412
|
stdio: ["ignore", "pipe", "pipe"],
|
|
386
413
|
});
|
|
387
414
|
return {
|
|
@@ -486,6 +513,11 @@ function resolveBoxOwners(options) {
|
|
|
486
513
|
email = shellText(record.email ?? user?.email);
|
|
487
514
|
add(email);
|
|
488
515
|
add(shellText(record.personUid ?? record.person_uid ?? record.uid));
|
|
516
|
+
// The person (or the human an Outpost session acts for) by Cognito
|
|
517
|
+
// id and email, so a job owner written either way matches the box.
|
|
518
|
+
add(shellText(record.sub));
|
|
519
|
+
add(shellText(record.delegatedEmail));
|
|
520
|
+
add(shellText(record.delegatedSub));
|
|
489
521
|
}
|
|
490
522
|
if (!email) {
|
|
491
523
|
const humanEmail = /.*Email:\s*(.*)/i.exec(who)?.[1]
|
|
@@ -757,7 +789,7 @@ function systemctlResult(args, dryRun, command, options) {
|
|
|
757
789
|
return { status: 127, stdout: "", stderr: "scheduler stub was requested but no injected runner was supplied" };
|
|
758
790
|
}
|
|
759
791
|
try {
|
|
760
|
-
const runner = options.schedulerRunner ??
|
|
792
|
+
const runner = options.schedulerRunner ?? schedulerCommandResult;
|
|
761
793
|
return runner(command, ["--user", ...args]);
|
|
762
794
|
}
|
|
763
795
|
catch (error) {
|
|
@@ -145,6 +145,26 @@ export declare class InviteHttpError extends Error {
|
|
|
145
145
|
}
|
|
146
146
|
export declare function formatInviteHttpError(status: number, fallback: string, code?: string): string;
|
|
147
147
|
export declare function listPendingInvites(token: string, companyUid: string): Promise<PendingInvite[]>;
|
|
148
|
+
/** JSON row for `hq members list --json`. `person_id` is the prs_/agt_ id grants use. */
|
|
149
|
+
export declare function activeMemberJson(m: ActiveMember): {
|
|
150
|
+
person_id: string;
|
|
151
|
+
email: string | null;
|
|
152
|
+
name: string | null;
|
|
153
|
+
slug: string | null;
|
|
154
|
+
role: string;
|
|
155
|
+
status: string;
|
|
156
|
+
membership_key: string;
|
|
157
|
+
};
|
|
158
|
+
/** JSON row for `hq members list --pending --json`. */
|
|
159
|
+
export declare function pendingInviteJson(inv: PendingInvite): {
|
|
160
|
+
person_id: string | null;
|
|
161
|
+
email: string | null;
|
|
162
|
+
role: string;
|
|
163
|
+
status: string;
|
|
164
|
+
invited_by: string;
|
|
165
|
+
invited_at: string;
|
|
166
|
+
membership_key: string;
|
|
167
|
+
};
|
|
148
168
|
export declare function listActiveMembers(token: string, companyUid: string): Promise<ActiveMember[]>;
|
|
149
169
|
/**
|
|
150
170
|
* Resolve a role-change target to an active membership key.
|
package/dist/commands/members.js
CHANGED
|
@@ -237,6 +237,30 @@ export async function listPendingInvites(token, companyUid) {
|
|
|
237
237
|
const data = (await res.json());
|
|
238
238
|
return data?.pending ?? data?.invites ?? [];
|
|
239
239
|
}
|
|
240
|
+
/** JSON row for `hq members list --json`. `person_id` is the prs_/agt_ id grants use. */
|
|
241
|
+
export function activeMemberJson(m) {
|
|
242
|
+
return {
|
|
243
|
+
person_id: m.personUid,
|
|
244
|
+
email: m.personEmail ?? null,
|
|
245
|
+
name: m.personName ?? null,
|
|
246
|
+
slug: m.personSlug ?? null,
|
|
247
|
+
role: m.role,
|
|
248
|
+
status: m.status,
|
|
249
|
+
membership_key: m.membershipKey,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
/** JSON row for `hq members list --pending --json`. */
|
|
253
|
+
export function pendingInviteJson(inv) {
|
|
254
|
+
return {
|
|
255
|
+
person_id: inv.personUid ?? null,
|
|
256
|
+
email: inv.inviteeEmail ?? null,
|
|
257
|
+
role: inv.role,
|
|
258
|
+
status: inv.status,
|
|
259
|
+
invited_by: inv.invitedBy,
|
|
260
|
+
invited_at: inv.invitedAt,
|
|
261
|
+
membership_key: inv.membershipKey,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
240
264
|
export async function listActiveMembers(token, companyUid) {
|
|
241
265
|
const res = await vaultApiFetch({
|
|
242
266
|
token,
|
|
@@ -750,6 +774,8 @@ export function registerMembersCommand(program) {
|
|
|
750
774
|
.command("list")
|
|
751
775
|
.description("List the company's active members (use --pending for pending invites)")
|
|
752
776
|
.option("--pending", "List pending invites instead of active members")
|
|
777
|
+
.option("--ids", "Add a PERSON_ID column (the prs_ id used in grants and membership keys)")
|
|
778
|
+
.option("--json", "Print machine-readable JSON, including each person id")
|
|
753
779
|
.action(async (opts) => {
|
|
754
780
|
try {
|
|
755
781
|
const token = (await resolveVaultCredential()).token;
|
|
@@ -758,6 +784,13 @@ export function registerMembersCommand(program) {
|
|
|
758
784
|
if (opts.pending) {
|
|
759
785
|
// --pending: preserve the original pending-invites view verbatim.
|
|
760
786
|
const invites = await listPendingInvites(token, companyUid);
|
|
787
|
+
if (opts.json) {
|
|
788
|
+
console.log(JSON.stringify({
|
|
789
|
+
company_uid: companyUid,
|
|
790
|
+
pending: invites.map(pendingInviteJson),
|
|
791
|
+
}, null, 2));
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
761
794
|
if (invites.length === 0) {
|
|
762
795
|
console.log(chalk.gray("No pending invites for this company."));
|
|
763
796
|
return;
|
|
@@ -788,17 +821,33 @@ export function registerMembersCommand(program) {
|
|
|
788
821
|
// Default: list ACTIVE members so their emails drop straight into
|
|
789
822
|
// `hq secrets share <path> --with <email>`.
|
|
790
823
|
const activeMembers = await listActiveMembers(token, companyUid);
|
|
824
|
+
if (opts.json) {
|
|
825
|
+
console.log(JSON.stringify({
|
|
826
|
+
company_uid: companyUid,
|
|
827
|
+
members: activeMembers.map(activeMemberJson),
|
|
828
|
+
}, null, 2));
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
791
831
|
if (activeMembers.length === 0) {
|
|
792
832
|
console.log(chalk.gray("No active members found for this company."));
|
|
793
833
|
return;
|
|
794
834
|
}
|
|
795
835
|
const emailW = Math.max(5, ...activeMembers.map((m) => (m.personEmail ?? m.personUid).length));
|
|
796
836
|
const roleW = Math.max(4, ...activeMembers.map((m) => m.role.length));
|
|
797
|
-
|
|
837
|
+
const idW = Math.max(9, ...activeMembers.map((m) => m.personUid.length));
|
|
838
|
+
const header = ["EMAIL".padEnd(emailW), "ROLE".padEnd(roleW)];
|
|
839
|
+
if (opts.ids)
|
|
840
|
+
header.push("PERSON_ID".padEnd(idW));
|
|
841
|
+
header.push("NAME");
|
|
842
|
+
console.log(chalk.bold(header.join(" ")));
|
|
798
843
|
for (const m of activeMembers) {
|
|
799
844
|
const email = m.personEmail ?? m.personUid;
|
|
800
845
|
const name = m.personName ?? m.personSlug ?? "";
|
|
801
|
-
|
|
846
|
+
const row = [email.padEnd(emailW), m.role.padEnd(roleW)];
|
|
847
|
+
if (opts.ids)
|
|
848
|
+
row.push(m.personUid.padEnd(idW));
|
|
849
|
+
row.push(name);
|
|
850
|
+
console.log(row.join(" "));
|
|
802
851
|
}
|
|
803
852
|
}
|
|
804
853
|
catch (err) {
|
package/dist/commands/whoami.js
CHANGED
|
@@ -153,6 +153,15 @@ export function registerWhoamiCommand(program) {
|
|
|
153
153
|
authenticated: machine ? Boolean(machineCreds) : Boolean(cached && !isExpiring(cached, 0)),
|
|
154
154
|
tokenSource: machine ? 'machine' : 'person',
|
|
155
155
|
email: machine ? null : displayedIdentity.email ?? null,
|
|
156
|
+
// Cognito subject of the person shown in `email` (null for machine
|
|
157
|
+
// identities). Outpost jobs record owners by this id.
|
|
158
|
+
sub: machine ? null : displayedIdentity.sub ?? null,
|
|
159
|
+
// The human an Outpost session acts for, from the token's
|
|
160
|
+
// delegated claims, for machine and person sources alike.
|
|
161
|
+
delegatedEmail: identity.delegatedEmail !== undefined && identity.delegatedSub !== undefined
|
|
162
|
+
? identity.delegatedEmail : null,
|
|
163
|
+
delegatedSub: identity.delegatedEmail !== undefined && identity.delegatedSub !== undefined
|
|
164
|
+
? identity.delegatedSub : null,
|
|
156
165
|
personUid: !machine && identity.entityType === 'person' ? identity.entityUid ?? null : null,
|
|
157
166
|
agentUid,
|
|
158
167
|
username: machineCreds?.username ?? null,
|