@indigoai-us/hq-cli 5.77.11 → 5.77.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/dist/commands/api-keys.js +53 -10
  3. package/dist/commands/outposts-heartbeat.d.ts +96 -0
  4. package/dist/commands/outposts-heartbeat.js +188 -0
  5. package/dist/commands/outposts.js +3 -0
  6. package/dist/commands/secrets.js +127 -21
  7. package/dist/outpost/session-heartbeat-publisher.d.ts +76 -0
  8. package/dist/outpost/session-heartbeat-publisher.js +117 -0
  9. package/dist/outpost/session-heartbeat.d.ts +210 -0
  10. package/dist/outpost/session-heartbeat.js +657 -0
  11. package/dist/utils/resolve-vault-credential.d.ts +30 -0
  12. package/dist/utils/resolve-vault-credential.js +48 -0
  13. package/dist/utils/vault-api.d.ts +8 -1
  14. package/dist/utils/vault-api.js +3 -2
  15. package/package.json +3 -1
  16. package/src/commands/api-keys.test.ts +75 -1
  17. package/src/commands/api-keys.ts +86 -10
  18. package/src/commands/outposts-heartbeat.test.ts +299 -0
  19. package/src/commands/outposts-heartbeat.ts +310 -0
  20. package/src/commands/outposts.ts +4 -0
  21. package/src/commands/secrets.test.ts +133 -0
  22. package/src/commands/secrets.ts +172 -29
  23. package/src/outpost/session-heartbeat-bounds.test.ts +195 -0
  24. package/src/outpost/session-heartbeat-guard.test.ts +105 -0
  25. package/src/outpost/session-heartbeat-publisher.test.ts +178 -0
  26. package/src/outpost/session-heartbeat-publisher.ts +186 -0
  27. package/src/outpost/session-heartbeat-retain-guard.test.ts +126 -0
  28. package/src/outpost/session-heartbeat.test.ts +459 -0
  29. package/src/outpost/session-heartbeat.ts +877 -0
  30. package/src/packaging.test.ts +45 -0
  31. package/src/utils/resolve-vault-credential.test.ts +69 -0
  32. package/src/utils/resolve-vault-credential.ts +60 -0
  33. package/src/utils/vault-api.ts +13 -2
package/CHANGELOG.md CHANGED
@@ -2,6 +2,52 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.77.13]
6
+
7
+ ### Added
8
+
9
+ - `hq outposts heartbeat` — the Outpost session-heartbeat runner. The unit on
10
+ every box invoked `outpost-session-heartbeat-runner`, a command that was
11
+ never written in any package, so each tick logged `command not found` behind
12
+ a `|| echo … continuing` while systemd and the box audit both reported
13
+ healthy. The enumeration and publisher modules existed in hq-pro (a server
14
+ repo) with no entrypoint; they move here and the cadence runs in-process —
15
+ no bash loop, no per-tick `npx …@latest`, no per-tick `hq auth refresh`.
16
+ That removes ~35k process launches per box per day. (#274)
17
+
18
+ ### Fixed
19
+
20
+ - Heartbeat payloads no longer exceed the AWS IoT 128 KiB limit. A box with
21
+ 9,340 transcripts produced ~1.9 MB and was rejected outright; `ended`
22
+ sessions are no longer published and the remainder is bounded by serialized
23
+ bytes, newest first, with `totalSessions`/`truncated` on the envelope so a
24
+ filtered list cannot read as a complete one. (#274)
25
+ - Transcript bodies are read only for sessions that will be published — the
26
+ enumerator previously did a 16 KiB bounded read on every file before
27
+ filtering (~150 MB of disk per tick on a large box). (#274)
28
+ - The no-secrets guard no longer matches ordinary paths. `sk-` and `asia` were
29
+ bare substrings, so a directory named `task-runner` or `flask-app` made the
30
+ guard throw and silently stopped the box reporting; markers are now
31
+ credential-shaped. (#274)
32
+ - `nodeFileSystem.readDir` propagates operational errors instead of treating
33
+ every failure as an empty directory — an EACCES previously published an
34
+ empty session list and refreshed the liveness marker as if collection had
35
+ succeeded. (#274)
36
+ - The realtime-credentials request is bounded, and the cadence sleep races the
37
+ abort signal so SIGTERM stops the loop cooperatively. (#274)
38
+ - `vaultApiFetch` accepts an optional `baseUrl` so identity resolves against
39
+ the same control plane as the credentials fetch on non-default
40
+ deployments. (#274)
41
+
42
+ ## [5.77.12]
43
+
44
+ ### Added
45
+
46
+ - `HQ_API_KEY` fail-closed consume path: `hqk_…` keys route `hq secrets get`
47
+ / `exec` / `env` through vault key fetch; Cognito-only commands hard-error.
48
+ - `hq api-keys create --deploy-app` (repeatable) and deploy-app column in list
49
+ output for identity-bound deploy keys. (#270)
50
+
5
51
  ## [5.77.11]
6
52
 
7
53
  ### Fixed
@@ -1,6 +1,11 @@
1
1
  import chalk from "chalk";
2
2
  import { ensureCognitoToken } from "../utils/cognito-session.js";
3
+ import { assertCognitoOnlyCommand } from "../utils/resolve-vault-credential.js";
3
4
  import { getCompanyUid, vaultApiFetch } from "./secrets.js";
5
+ async function requireCognitoForApiKeys(label) {
6
+ assertCognitoOnlyCommand(label);
7
+ return ensureCognitoToken();
8
+ }
4
9
  function collectRepeatedOption(value, previous) {
5
10
  return [...previous, value];
6
11
  }
@@ -10,6 +15,11 @@ function formatMaybe(value) {
10
15
  function formatPrefixes(prefixes) {
11
16
  return prefixes.length > 0 ? prefixes.join(", ") : "-";
12
17
  }
18
+ function formatDeployApps(deploy) {
19
+ if (!deploy?.apps?.length)
20
+ return "-";
21
+ return deploy.apps.join(", ");
22
+ }
13
23
  function parsePermission(value) {
14
24
  if (value === "read" || value === "write" || value === "admin") {
15
25
  return value;
@@ -51,6 +61,7 @@ function renderApiKeysTable(apiKeys) {
51
61
  name: apiKey.name,
52
62
  permission: apiKey.scope.permission,
53
63
  prefixes: formatPrefixes(apiKey.scope.allowedPrefixes),
64
+ deployApps: formatDeployApps(apiKey.scope.deploy),
54
65
  status: apiKey.status,
55
66
  lastUsedAt: formatMaybe(apiKey.lastUsedAt),
56
67
  expiresAt: formatMaybe(apiKey.expiresAt),
@@ -59,6 +70,7 @@ function renderApiKeysTable(apiKeys) {
59
70
  const nameWidth = Math.max(4, ...rows.map((row) => row.name.length));
60
71
  const permissionWidth = Math.max(10, ...rows.map((row) => row.permission.length));
61
72
  const prefixesWidth = Math.max(8, ...rows.map((row) => row.prefixes.length));
73
+ const deployWidth = Math.max(6, ...rows.map((row) => row.deployApps.length));
62
74
  const statusWidth = Math.max(6, ...rows.map((row) => row.status.length));
63
75
  const lastUsedWidth = Math.max(11, ...rows.map((row) => row.lastUsedAt.length));
64
76
  const expiresWidth = Math.max(10, ...rows.map((row) => row.expiresAt.length));
@@ -67,6 +79,7 @@ function renderApiKeysTable(apiKeys) {
67
79
  "NAME".padEnd(nameWidth),
68
80
  "PERMISSION".padEnd(permissionWidth),
69
81
  "PREFIXES".padEnd(prefixesWidth),
82
+ "DEPLOY".padEnd(deployWidth),
70
83
  "STATUS".padEnd(statusWidth),
71
84
  "LAST USED".padEnd(lastUsedWidth),
72
85
  "EXPIRES".padEnd(expiresWidth),
@@ -78,6 +91,7 @@ function renderApiKeysTable(apiKeys) {
78
91
  row.name.padEnd(nameWidth),
79
92
  row.permission.padEnd(permissionWidth),
80
93
  row.prefixes.padEnd(prefixesWidth),
94
+ row.deployApps.padEnd(deployWidth),
81
95
  row.status.padEnd(statusWidth),
82
96
  row.lastUsedAt.padEnd(lastUsedWidth),
83
97
  row.expiresAt.padEnd(expiresWidth),
@@ -91,20 +105,21 @@ export function registerApiKeysCommand(program) {
91
105
  .option("--company <slug>", "Company slug (resolves to companyUid)");
92
106
  apiKeys
93
107
  .command("create")
94
- .description("Create a new API key")
108
+ .description("Create a new API key (vault secrets and/or scoped deploy via --deploy-app)")
95
109
  .requiredOption("--name <label>", "Human-readable label for the API key")
96
- .option("--scope <prefix>", "Allowed prefix (repeatable)", collectRepeatedOption, [])
97
- .option("--permission <level>", "Permission level: read | write | admin", "read")
110
+ .option("--scope <prefix>", "Allowed secret prefix (repeatable)", collectRepeatedOption, [])
111
+ .option("--deploy-app <id>", "Deploy app id/slug allowed for publish (repeatable)", collectRepeatedOption, [])
112
+ .option("--permission <level>", "Secret permission level: read | write | admin (required when --scope is set)", "read")
98
113
  .option("--expires <ISO8601>", "Optional ISO-8601 expiry timestamp")
99
114
  .action(async (opts) => {
100
115
  try {
101
- if (opts.scope.length === 0) {
102
- console.error(chalk.red("Error: at least one --scope <prefix> is required."));
116
+ if (opts.scope.length === 0 && opts.deployApp.length === 0) {
117
+ console.error(chalk.red("Error: provide at least one --scope <prefix> and/or --deploy-app <id>."));
103
118
  process.exit(1);
104
119
  }
105
120
  const permission = parsePermission(opts.permission);
106
121
  const expiresAt = parseExpires(opts.expires);
107
- const token = await ensureCognitoToken();
122
+ const token = await requireCognitoForApiKeys("api-keys create");
108
123
  const companyUid = await getCompanyUid(token, apiKeys.opts().company);
109
124
  const res = await vaultApiFetch({
110
125
  token,
@@ -113,8 +128,20 @@ export function registerApiKeysCommand(program) {
113
128
  body: {
114
129
  companyUid,
115
130
  name: opts.name,
116
- allowedPrefixes: opts.scope,
117
- permission,
131
+ ...(opts.scope.length > 0
132
+ ? { allowedPrefixes: opts.scope, permission }
133
+ : {}),
134
+ ...(opts.deployApp.length > 0
135
+ ? {
136
+ deploy: {
137
+ apps: opts.deployApp,
138
+ capabilities: ["deploy:write"],
139
+ },
140
+ }
141
+ : {}),
142
+ ...(opts.scope.length === 0 && opts.deployApp.length > 0
143
+ ? { permission }
144
+ : {}),
118
145
  ...(expiresAt ? { expiresAt } : {}),
119
146
  },
120
147
  });
@@ -131,10 +158,26 @@ export function registerApiKeysCommand(program) {
131
158
  console.log(` Company: ${data.apiKey.companyUid}`);
132
159
  console.log(` Permission: ${data.apiKey.scope.permission}`);
133
160
  console.log(` Prefixes: ${formatPrefixes(data.apiKey.scope.allowedPrefixes)}`);
161
+ console.log(` Deploy apps: ${formatDeployApps(data.apiKey.scope.deploy)}`);
134
162
  console.log(` Status: ${data.apiKey.status}`);
135
163
  console.log(` Created: ${data.apiKey.createdAt}`);
136
164
  console.log(` Last used: ${formatMaybe(data.apiKey.lastUsedAt)}`);
137
165
  console.log(` Expires: ${formatMaybe(data.apiKey.expiresAt)}`);
166
+ console.log("");
167
+ console.log(chalk.bold("Usage"));
168
+ console.log(" This key acts as you (Cognito identity), limited to the scopes above.");
169
+ console.log(" Export it for automation (never falls back to a session):");
170
+ console.log(`\n export HQ_API_KEY='${data.key.value}'\n`);
171
+ console.log(" Vault secrets:");
172
+ console.log(" hq secrets get <NAME> --reveal");
173
+ console.log(" hq secrets exec --only <NAME> -- <command>");
174
+ console.log(" Or HTTP: POST /v1/keys/secrets/fetch with Authorization: Bearer <key>");
175
+ if (data.apiKey.scope.deploy?.apps?.length) {
176
+ console.log(" Deploy (scoped apps only):");
177
+ console.log(" Authorization: Bearer <key> against the hq-deploy API");
178
+ console.log(chalk.dim(" Cannot change access-mode, password, or mint hqd_ keys."));
179
+ }
180
+ console.log(chalk.dim(" Unsupported under HQ_API_KEY: secrets list/set/share/acl (use a Cognito session)."));
138
181
  }
139
182
  catch (err) {
140
183
  console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
@@ -146,7 +189,7 @@ export function registerApiKeysCommand(program) {
146
189
  .description("List API keys for a company")
147
190
  .action(async () => {
148
191
  try {
149
- const token = await ensureCognitoToken();
192
+ const token = await requireCognitoForApiKeys("api-keys list");
150
193
  const companyUid = await getCompanyUid(token, apiKeys.opts().company);
151
194
  const res = await vaultApiFetch({
152
195
  token,
@@ -173,7 +216,7 @@ export function registerApiKeysCommand(program) {
173
216
  .description("Revoke an API key")
174
217
  .action(async (keyId) => {
175
218
  try {
176
- const token = await ensureCognitoToken();
219
+ const token = await requireCognitoForApiKeys("api-keys revoke");
177
220
  const res = await vaultApiFetch({
178
221
  token,
179
222
  path: `/v1/api-keys/${encodeURIComponent(keyId)}/revoke`,
@@ -0,0 +1,96 @@
1
+ /**
2
+ * `hq outposts heartbeat` — publish this box's agent-session summary to the
3
+ * realtime fabric.
4
+ *
5
+ * Runs ON an Outpost, under systemd. It enumerates the box's local Claude Code
6
+ * and Codex sessions with cheap scandir + stat + bounded reads, projects them
7
+ * down to the compact secret-free `AgentSession[]` payload, and publishes to
8
+ * `hq/{personUid}/sessions` so Mission Control can see what the box is doing.
9
+ *
10
+ * ## Why the cadence lives in here
11
+ *
12
+ * The original box-side design was a bash `while` loop that, every 5 seconds,
13
+ * ran `hq auth refresh` and then `npx -y --package=@indigoai-us/hq-cloud@latest
14
+ * outpost-session-heartbeat-runner`. That is ~35k process launches a day, and
15
+ * every `npx` re-resolves the package and re-reads it off disk — on a t3 box
16
+ * whose EBS budget is the scarce resource, that is a meaningful, permanent tax
17
+ * for a job that should be nearly free.
18
+ *
19
+ * So the loop lives in-process instead:
20
+ * - identity is resolved ONCE per process, not once per tick;
21
+ * - the Cognito session refreshes itself on demand (`ensureCognitoToken`
22
+ * returns the cached token until it is close to expiry), so there is no
23
+ * separate per-tick `hq auth refresh`;
24
+ * - the IoT client is cached across ticks by the publisher and only rebuilt
25
+ * when its credentials near expiry.
26
+ *
27
+ * The loop is deliberately unkillable-by-transients: a failed publish, a failed
28
+ * enumeration, or a not-yet-warm session are all logged and retried on the next
29
+ * tick. Nothing here should ever take the service down, because a service that
30
+ * exits looks identical to a service that is quietly failing.
31
+ */
32
+ import type { Command } from "commander";
33
+ import { type FileSystemPort, type PublishPort, type SessionsHeartbeatPayload } from "../outpost/session-heartbeat.js";
34
+ /** Everything the loop touches, injected so it is testable without a box. */
35
+ export interface HeartbeatLoopDeps {
36
+ fs: FileSystemPort;
37
+ publish: PublishPort;
38
+ /** Resolve the caller's canonical `prs_*` id. Called once per process. */
39
+ getPersonUid: () => Promise<string>;
40
+ /**
41
+ * Wait between ticks. MUST return early when `signal` aborts — otherwise a
42
+ * SIGTERM arriving mid-sleep is not noticed until the full interval elapses,
43
+ * and systemd force-kills the process instead of it stopping cooperatively.
44
+ */
45
+ sleep: (ms: number, signal?: AbortSignal) => Promise<void>;
46
+ now: () => Date;
47
+ log: (line: string) => void;
48
+ /**
49
+ * Record that a heartbeat actually LANDED. Called only after a successful
50
+ * publish — never on failure, or the freshness signal it feeds would always
51
+ * read fresh and be worth nothing.
52
+ */
53
+ onPublished?: (payload: SessionsHeartbeatPayload) => Promise<void>;
54
+ }
55
+ export interface HeartbeatLoopOptions {
56
+ /** Emit a single heartbeat and return. */
57
+ once?: boolean;
58
+ /** Cadence between ticks. Defaults to {@link DEFAULT_HEARTBEAT_INTERVAL_SECONDS}. */
59
+ intervalSeconds?: number;
60
+ /** Home directory to scan (defaults to the process HOME). */
61
+ home?: string;
62
+ /** Print each published payload as JSON. */
63
+ json?: boolean;
64
+ /** Stop the loop cooperatively. */
65
+ signal?: AbortSignal;
66
+ /** Bound the loop — used by tests; production runs until aborted. */
67
+ maxTicks?: number;
68
+ }
69
+ /**
70
+ * Resolve the cadence from the flag, then the environment, then the default.
71
+ * Floored at 1s: a sub-second cadence hammers the fabric for no benefit, and a
72
+ * zero/negative value would spin the loop hot.
73
+ */
74
+ export declare function resolveIntervalSeconds(flag: string | undefined, env?: NodeJS.ProcessEnv): number;
75
+ /**
76
+ * Where the box records its last LANDED heartbeat. The post-provision audit
77
+ * reads this file's freshness, because "systemd says the unit is active" is not
78
+ * evidence that anything was published — that gap is what let a heartbeat fail
79
+ * every five seconds for nine days while every dashboard stayed green.
80
+ *
81
+ * Lives under the service user's home so the unit (User=ec2-user) can write it
82
+ * without extra tmpfiles.d wiring; the SSM collector reads it as root.
83
+ */
84
+ export declare const DEFAULT_HEARTBEAT_STATE_FILE: string;
85
+ /** Persist the liveness marker the box audit reads. */
86
+ export declare function writeHeartbeatState(path: string, payload: SessionsHeartbeatPayload): Promise<void>;
87
+ /**
88
+ * Run the heartbeat loop. Returns the number of ticks performed.
89
+ *
90
+ * A "tick" is one attempt, whether or not it published — the count is the
91
+ * loop's liveness, not its success rate.
92
+ */
93
+ export declare function runHeartbeatLoop(options: HeartbeatLoopOptions, deps: HeartbeatLoopDeps): Promise<number>;
94
+ /** Wire the production dependencies and register the subcommand. */
95
+ export declare function registerHeartbeatCommand(outposts: Command): void;
96
+ //# sourceMappingURL=outposts-heartbeat.d.ts.map
@@ -0,0 +1,188 @@
1
+ /**
2
+ * `hq outposts heartbeat` — publish this box's agent-session summary to the
3
+ * realtime fabric.
4
+ *
5
+ * Runs ON an Outpost, under systemd. It enumerates the box's local Claude Code
6
+ * and Codex sessions with cheap scandir + stat + bounded reads, projects them
7
+ * down to the compact secret-free `AgentSession[]` payload, and publishes to
8
+ * `hq/{personUid}/sessions` so Mission Control can see what the box is doing.
9
+ *
10
+ * ## Why the cadence lives in here
11
+ *
12
+ * The original box-side design was a bash `while` loop that, every 5 seconds,
13
+ * ran `hq auth refresh` and then `npx -y --package=@indigoai-us/hq-cloud@latest
14
+ * outpost-session-heartbeat-runner`. That is ~35k process launches a day, and
15
+ * every `npx` re-resolves the package and re-reads it off disk — on a t3 box
16
+ * whose EBS budget is the scarce resource, that is a meaningful, permanent tax
17
+ * for a job that should be nearly free.
18
+ *
19
+ * So the loop lives in-process instead:
20
+ * - identity is resolved ONCE per process, not once per tick;
21
+ * - the Cognito session refreshes itself on demand (`ensureCognitoToken`
22
+ * returns the cached token until it is close to expiry), so there is no
23
+ * separate per-tick `hq auth refresh`;
24
+ * - the IoT client is cached across ticks by the publisher and only rebuilt
25
+ * when its credentials near expiry.
26
+ *
27
+ * The loop is deliberately unkillable-by-transients: a failed publish, a failed
28
+ * enumeration, or a not-yet-warm session are all logged and retried on the next
29
+ * tick. Nothing here should ever take the service down, because a service that
30
+ * exits looks identical to a service that is quietly failing.
31
+ */
32
+ import { mkdir, writeFile } from "node:fs/promises";
33
+ import { homedir } from "node:os";
34
+ import { dirname, join } from "node:path";
35
+ import { ensureCognitoToken } from "../utils/cognito-session.js";
36
+ import { DEFAULT_VAULT_API_URL } from "../utils/cognito-session.js";
37
+ import { resolveCallerPersonUid } from "../utils/vault-api.js";
38
+ import { DEFAULT_HEARTBEAT_INTERVAL_SECONDS, collectSessions, nodeFileSystem, sessionsTopicForPerson, } from "../outpost/session-heartbeat.js";
39
+ import { createIotPublishPort, defaultRealtimeCredentialsFetcher, } from "../outpost/session-heartbeat-publisher.js";
40
+ /**
41
+ * Resolve the cadence from the flag, then the environment, then the default.
42
+ * Floored at 1s: a sub-second cadence hammers the fabric for no benefit, and a
43
+ * zero/negative value would spin the loop hot.
44
+ */
45
+ export function resolveIntervalSeconds(flag, env = process.env) {
46
+ for (const raw of [flag, env.OUTPOST_SESSIONS_HEARTBEAT_INTERVAL_SECONDS]) {
47
+ if (raw === undefined || raw === null || raw === "")
48
+ continue;
49
+ const parsed = Number.parseInt(raw, 10);
50
+ if (!Number.isFinite(parsed))
51
+ return DEFAULT_HEARTBEAT_INTERVAL_SECONDS;
52
+ return Math.max(1, parsed);
53
+ }
54
+ return DEFAULT_HEARTBEAT_INTERVAL_SECONDS;
55
+ }
56
+ /**
57
+ * Where the box records its last LANDED heartbeat. The post-provision audit
58
+ * reads this file's freshness, because "systemd says the unit is active" is not
59
+ * evidence that anything was published — that gap is what let a heartbeat fail
60
+ * every five seconds for nine days while every dashboard stayed green.
61
+ *
62
+ * Lives under the service user's home so the unit (User=ec2-user) can write it
63
+ * without extra tmpfiles.d wiring; the SSM collector reads it as root.
64
+ */
65
+ export const DEFAULT_HEARTBEAT_STATE_FILE = join(homedir(), ".hq", "outpost-session-heartbeat.json");
66
+ /** Persist the liveness marker the box audit reads. */
67
+ export async function writeHeartbeatState(path, payload) {
68
+ await mkdir(dirname(path), { recursive: true });
69
+ await writeFile(path, `${JSON.stringify({
70
+ lastPublishAt: payload.emittedAt,
71
+ sessions: payload.sessions.length,
72
+ })}\n`, "utf8");
73
+ }
74
+ /** Structured one-line log — journald gets JSON, not prose. */
75
+ function logLine(step, outcome, extra, now) {
76
+ return JSON.stringify({
77
+ service: "outpost-session-heartbeat",
78
+ step,
79
+ outcome,
80
+ ...extra,
81
+ timestamp: now().toISOString(),
82
+ });
83
+ }
84
+ /**
85
+ * Run the heartbeat loop. Returns the number of ticks performed.
86
+ *
87
+ * A "tick" is one attempt, whether or not it published — the count is the
88
+ * loop's liveness, not its success rate.
89
+ */
90
+ export async function runHeartbeatLoop(options, deps) {
91
+ const intervalMs = (options.intervalSeconds ?? DEFAULT_HEARTBEAT_INTERVAL_SECONDS) * 1000;
92
+ const maxTicks = options.once ? 1 : options.maxTicks;
93
+ // Resolved once and reused. Re-resolving per tick was the original sin.
94
+ let personUid = null;
95
+ let ticks = 0;
96
+ for (;;) {
97
+ ticks += 1;
98
+ try {
99
+ if (!personUid)
100
+ personUid = await deps.getPersonUid();
101
+ const payload = await collectSessions({ personUid, home: options.home, now: deps.now }, { fs: deps.fs });
102
+ const topic = sessionsTopicForPerson(personUid);
103
+ await deps.publish(topic, payload);
104
+ // Ordering matters: the marker is written only once the publish has
105
+ // resolved, so a stale marker means "nothing landed", not "nothing ran".
106
+ await deps.onPublished?.(payload);
107
+ if (options.json)
108
+ deps.log(JSON.stringify(payload));
109
+ deps.log(logLine("publish", "ok", { topic, sessions: payload.sessions.length }, deps.now));
110
+ }
111
+ catch (err) {
112
+ // Every failure mode lands here on purpose: a transient must never take
113
+ // the service down, because systemd restarting a hot-failing loop looks
114
+ // exactly like a healthy one.
115
+ deps.log(logLine("tick", "error", { message: err instanceof Error ? err.message : String(err) }, deps.now));
116
+ }
117
+ if (options.once)
118
+ break;
119
+ if (maxTicks !== undefined && ticks >= maxTicks)
120
+ break;
121
+ if (options.signal?.aborted)
122
+ break;
123
+ await deps.sleep(intervalMs, options.signal);
124
+ if (options.signal?.aborted)
125
+ break;
126
+ }
127
+ return ticks;
128
+ }
129
+ /** Wire the production dependencies and register the subcommand. */
130
+ export function registerHeartbeatCommand(outposts) {
131
+ outposts
132
+ .command("heartbeat", { hidden: true })
133
+ .description("Publish this box's agent-session summary to the realtime fabric (runs on the Outpost, under systemd)")
134
+ .option("--once", "Emit a single heartbeat and exit")
135
+ .option("--interval <seconds>", "Cadence in seconds (default 5; env OUTPOST_SESSIONS_HEARTBEAT_INTERVAL_SECONDS)")
136
+ .option("--home <path>", "Home directory to scan for sessions")
137
+ .option("--api-base-url <url>", "HQ API base URL")
138
+ .option("--state-file <path>", `Liveness marker read by the box audit (default ${DEFAULT_HEARTBEAT_STATE_FILE})`)
139
+ .option("--json", "Print each published payload as JSON")
140
+ .action(async (opts) => {
141
+ const apiBaseUrl = opts.apiBaseUrl ??
142
+ process.env.HQAPI_BASE_URL ??
143
+ DEFAULT_VAULT_API_URL;
144
+ const getJwt = () => ensureCognitoToken({ interactive: false });
145
+ const deps = {
146
+ fs: nodeFileSystem,
147
+ publish: createIotPublishPort({
148
+ fetchCredentials: defaultRealtimeCredentialsFetcher({
149
+ apiBaseUrl,
150
+ getJwt,
151
+ }),
152
+ }),
153
+ // Same base as the credentials fetch. Resolving identity against the
154
+ // default plane while vending credentials from another sends a
155
+ // deployment-specific token to the wrong control plane, which is
156
+ // rejected — and the loop then retries forever without publishing.
157
+ getPersonUid: async () => resolveCallerPersonUid(await getJwt(), apiBaseUrl),
158
+ sleep: (ms, signal) => new Promise((resolve) => {
159
+ if (signal?.aborted)
160
+ return resolve();
161
+ const t = setTimeout(done, ms);
162
+ function done() {
163
+ clearTimeout(t);
164
+ signal?.removeEventListener("abort", done);
165
+ resolve();
166
+ }
167
+ signal?.addEventListener("abort", done, { once: true });
168
+ }),
169
+ now: () => new Date(),
170
+ log: (line) => console.error(line),
171
+ onPublished: (payload) => writeHeartbeatState(opts.stateFile ?? DEFAULT_HEARTBEAT_STATE_FILE, payload),
172
+ };
173
+ // systemd sends SIGTERM on stop/restart — finish the in-flight tick,
174
+ // then exit cleanly rather than being killed mid-publish.
175
+ const controller = new AbortController();
176
+ const stop = () => controller.abort();
177
+ process.once("SIGTERM", stop);
178
+ process.once("SIGINT", stop);
179
+ await runHeartbeatLoop({
180
+ once: opts.once,
181
+ intervalSeconds: resolveIntervalSeconds(opts.interval),
182
+ home: opts.home,
183
+ json: opts.json,
184
+ signal: controller.signal,
185
+ }, deps);
186
+ });
187
+ }
188
+ //# sourceMappingURL=outposts-heartbeat.js.map
@@ -31,6 +31,7 @@ import * as yaml from "js-yaml";
31
31
  import { loadCachedTokens } from "@indigoai-us/hq-cloud";
32
32
  import { ensureCognitoToken } from "../utils/cognito-session.js";
33
33
  import { vaultApiFetch } from "../utils/vault-api.js";
34
+ import { registerHeartbeatCommand } from "./outposts-heartbeat.js";
34
35
  import { OUTPOST_PRICE_CENTS, confirmChargeOrExit, parseBillingPayload, surfaceBillingBlocked, } from "../utils/billing-gate.js";
35
36
  /** Decode hq-pro's `{ capped, limit, outposts }` cap envelope, if present. */
36
37
  export function parseCappedPayload(body) {
@@ -738,6 +739,8 @@ export function registerOutpostsCommand(program, selfDeployOverrides = {}) {
738
739
  const outposts = program
739
740
  .command("outposts")
740
741
  .description("Manage your personal HQ Outposts (EC2 boxes)");
742
+ // On-box session heartbeat (systemd). Hidden — operators never run it.
743
+ registerHeartbeatCommand(outposts);
741
744
  outposts
742
745
  .command("self-deploy", { hidden: true })
743
746
  .description("Configure this EC2 host as a locally self-hosted HQ outpost")