@indigoai-us/hq-cli 5.341.5 → 5.342.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 CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.342.0] — 2026-10-04
6
+
7
+ - Keep `hq integrations call` alive through slow provider calls with bounded async-job polling, safe idempotent retry guidance on timeout, and validation of terminal responses.
8
+
9
+ - Let `hq sync now` skip unchanged server-owned cloud placeholders while keeping their warnings visible, so the sync can complete.
10
+
5
11
  ## [5.341.5] — 2026-10-04
6
12
  - Reconcile can close an eligible done-story worker with a legacy missing `interrupted_at` when a matching watcher classification proves the interruption age.
7
13
  - `hq doctor --verbose` now shows passing checks, including hqd runtime health details.
@@ -1751,6 +1751,7 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict, mode
1751
1751
  hqRoot,
1752
1752
  progressRecorder: trackingProgressRecorder(planLimitTracker, pushProgressCompany),
1753
1753
  skipUnchanged: true,
1754
+ serverOwnedPathPolicy: "skip",
1754
1755
  propagateDeletes: true,
1755
1756
  propagateDeletePolicy: resolveDeletePolicy(),
1756
1757
  ...(onConflict ? { onConflict } : {}),
@@ -248,7 +248,7 @@ export declare function resolveConnection(token: string, companyUid: string, app
248
248
  }, recoveryOpts?: {
249
249
  allowSingleRevoked?: boolean;
250
250
  }): Promise<AdminConnection>;
251
- export declare function callGateway(token: string, params: Record<string, unknown>): Promise<GatewayMessage>;
251
+ export declare function callGateway(token: string, params: Record<string, unknown>, idempotencyKey?: string): Promise<GatewayMessage>;
252
252
  /**
253
253
  * Gateway results arrive MCP-style: `{ content: [{ type: "text", text }] }`
254
254
  * where `text` is the provider's JSON. Unwrap to the inner payload; fall back
@@ -18,6 +18,8 @@ import { redactSensitiveUrls, redactUrlsInText } from "../utils/redact-url.js";
18
18
  import { connectionHealthNotice } from "../lib/integrations/health.js";
19
19
  import { bareProvider } from "../lib/integrations/provider-slug.js";
20
20
  export { bareProvider } from "../lib/integrations/provider-slug.js";
21
+ const ASYNC_GATEWAY_POLL_INTERVAL_MS = 1_000;
22
+ const ASYNC_GATEWAY_DEADLINE_MS = 4 * 60_000 + 30_000;
21
23
  export class IntegrationsCliError extends Error {
22
24
  /**
23
25
  * True when the error is the caller's request/state/permission (a client 4xx
@@ -397,7 +399,8 @@ export async function resolveConnection(token, companyUid, app, opts, recoveryOp
397
399
  }
398
400
  return selectConnection(connections, opts);
399
401
  }
400
- export async function callGateway(token, params) {
402
+ export async function callGateway(token, params, idempotencyKey) {
403
+ const deadline = Date.now() + ASYNC_GATEWAY_DEADLINE_MS;
401
404
  const res = await vaultApiFetch({
402
405
  token,
403
406
  path: "/v1/integrations/mcp",
@@ -410,7 +413,23 @@ export async function callGateway(token, params) {
410
413
  },
411
414
  });
412
415
  const message = (await res.json().catch(() => null));
413
- if (!res.ok || !message) {
416
+ let terminalMessage = message;
417
+ if (res.status === 202) {
418
+ const accepted = message;
419
+ const jobId = accepted && typeof accepted.jobId === "string" ? accepted.jobId : "";
420
+ if (accepted?.status !== "pending" || !/^imj_[A-Za-z0-9_-]+$/.test(jobId)) {
421
+ throw new IntegrationsCliError(`Integration gateway returned an invalid async job response (job id: ${jobId || "missing"}).`, { expected: false, status: 202 });
422
+ }
423
+ const companyUid = typeof params.companyUid === "string" ? params.companyUid : "";
424
+ if (!companyUid) {
425
+ throw new IntegrationsCliError(`Cannot poll integration job ${jobId}: the company id is missing.`, { expected: true });
426
+ }
427
+ if (!idempotencyKey) {
428
+ throw new IntegrationsCliError(`Integration gateway accepted async job ${jobId} without an idempotency key.`, { expected: false, status: 202 });
429
+ }
430
+ terminalMessage = await pollAsyncGatewayJob(token, companyUid, jobId, idempotencyKey, deadline);
431
+ }
432
+ if (res.status !== 202 && (!res.ok || !terminalMessage)) {
414
433
  raiseIfUnauthorized(res);
415
434
  raiseIfUpstreamUnavailable(res);
416
435
  // Carry the HTTP status so a reportable transport failure (e.g. a 501 that
@@ -420,7 +439,7 @@ export async function callGateway(token, params) {
420
439
  // `status`. This does not change the `expected` decision above.
421
440
  throw new IntegrationsCliError(`Integration gateway request failed (HTTP ${res.status}).`, { expected: isClientError(res.status), status: res.status });
422
441
  }
423
- if (message.error) {
442
+ if (terminalMessage?.error) {
424
443
  // The gateway's error text is minted UPSTREAM (hq-pro, the integration
425
444
  // factory, and beyond it the third-party provider), so it is untrusted:
426
445
  // scrub credentials and bound the length here, at the throw site, because
@@ -429,16 +448,87 @@ export async function callGateway(token, params) {
429
448
  // The chain is idempotent, so the unexpected path scrubbing again is a
430
449
  // no-op. This preserves PR #298's user-visible diagnostic; it only makes
431
450
  // it safe on the newly-expected path.
432
- throw new IntegrationsCliError(redactErrorText(message.error.message ?? "") ||
451
+ throw new IntegrationsCliError(redactErrorText(terminalMessage.error.message ?? "") ||
433
452
  "Integration gateway returned an error.", {
434
- expected: isExpectedGatewayError(message.error.code),
453
+ expected: isExpectedGatewayError(terminalMessage.error.code),
435
454
  // Carry the protocol code to the capture site as a bounded grouping
436
455
  // discriminator (HQ-CLI collision, Sentry 7642756130). This does not
437
456
  // alter the `expected` decision above; it only labels the error.
438
- ...(message.error.code !== undefined ? { rpcCode: message.error.code } : {}),
457
+ ...(terminalMessage.error.code !== undefined ? { rpcCode: terminalMessage.error.code } : {}),
439
458
  });
440
459
  }
441
- return message;
460
+ return terminalMessage;
461
+ }
462
+ async function pollAsyncGatewayJob(token, companyUid, jobId, idempotencyKey, deadline) {
463
+ const jobPath = `/v1/integrations/mcp/jobs/${encodeURIComponent(jobId)}`;
464
+ while (Date.now() < deadline) {
465
+ const requestRemainingMs = deadline - Date.now();
466
+ if (requestRemainingMs <= 0)
467
+ break;
468
+ const controller = new AbortController();
469
+ const requestTimeout = setTimeout(() => controller.abort(), requestRemainingMs);
470
+ let res;
471
+ let poll;
472
+ try {
473
+ res = await vaultApiFetch({
474
+ token,
475
+ path: jobPath,
476
+ method: "GET",
477
+ query: { companyUid },
478
+ signal: controller.signal,
479
+ });
480
+ poll = (await res.json().catch(() => null));
481
+ }
482
+ catch (error) {
483
+ if (!controller.signal.aborted)
484
+ throw error;
485
+ break;
486
+ }
487
+ finally {
488
+ clearTimeout(requestTimeout);
489
+ }
490
+ if (!res.ok) {
491
+ raiseIfUnauthorized(res);
492
+ raiseIfUpstreamUnavailable(res);
493
+ if (res.status === 404) {
494
+ throw new IntegrationsCliError(`Integration job ${jobId} was not found or is no longer available.`, { expected: true, status: 404 });
495
+ }
496
+ throw new IntegrationsCliError(`Integration job ${jobId} polling failed (HTTP ${res.status}).`, { expected: isClientError(res.status), status: res.status });
497
+ }
498
+ if (!poll || typeof poll !== "object") {
499
+ throw new IntegrationsCliError(`Integration job ${jobId} returned an invalid poll response.`, { expected: true });
500
+ }
501
+ if (poll.status === "result" || poll.status === "error") {
502
+ const response = poll.response;
503
+ const isPlainObject = response !== null &&
504
+ typeof response === "object" &&
505
+ !Array.isArray(response) &&
506
+ (Object.getPrototypeOf(response) === Object.prototype || Object.getPrototypeOf(response) === null);
507
+ const responseRecord = isPlainObject ? response : undefined;
508
+ const hasResult = responseRecord ? Object.hasOwn(responseRecord, "result") : false;
509
+ const hasError = responseRecord ? Object.hasOwn(responseRecord, "error") : false;
510
+ const error = responseRecord?.error;
511
+ const validError = error !== null &&
512
+ typeof error === "object" &&
513
+ !Array.isArray(error) &&
514
+ (Object.getPrototypeOf(error) === Object.prototype || Object.getPrototypeOf(error) === null);
515
+ const hasJsonRpcOutcome = responseRecord?.jsonrpc === "2.0" &&
516
+ hasResult !== hasError &&
517
+ (!hasError || validError);
518
+ if (!hasJsonRpcOutcome) {
519
+ throw new IntegrationsCliError(`Integration job ${jobId} returned an invalid JSON-RPC response.`, { expected: false });
520
+ }
521
+ return response;
522
+ }
523
+ if (poll.status !== "pending") {
524
+ throw new IntegrationsCliError(`Integration job ${jobId} returned unknown status '${String(poll.status)}'.`, { expected: true });
525
+ }
526
+ const remainingMs = deadline - Date.now();
527
+ if (remainingMs <= 0)
528
+ break;
529
+ await new Promise((resolve) => setTimeout(resolve, Math.min(ASYNC_GATEWAY_POLL_INTERVAL_MS, remainingMs)));
530
+ }
531
+ throw new IntegrationsCliError(`Timed out waiting for integration job ${jobId}; the server job may still finish. To safely retry, rerun with --idempotency-key ${idempotencyKey}.`, { expected: true });
442
532
  }
443
533
  /**
444
534
  * Gateway results arrive MCP-style: `{ content: [{ type: "text", text }] }`
@@ -338,6 +338,7 @@ export function registerIntegrationsCommand(program) {
338
338
  const idempotencyKey = opts.idempotencyKey ?? `hq-cli-${randomUUID()}`;
339
339
  const message = await callGateway(token, {
340
340
  companyUid,
341
+ acceptAsync: true,
341
342
  name: `${prefix}.mcp.tools.call`,
342
343
  arguments: {
343
344
  companyUid,
@@ -346,7 +347,7 @@ export function registerIntegrationsCommand(program) {
346
347
  arguments: parsedArgs,
347
348
  idempotencyKey,
348
349
  },
349
- });
350
+ }, idempotencyKey);
350
351
  const payload = unwrapGatewayResult(message.result);
351
352
  const queued = queuedOutcome(payload);
352
353
  if (queued) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.341.5",
3
+ "version": "5.342.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {