@indigoai-us/hq-cli 5.60.0 → 5.61.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.
@@ -39,6 +39,100 @@ describe("SandboxRunnerClient", () => {
39
39
  });
40
40
  });
41
41
 
42
+ it("retries startJob after fetch rejects twice then succeeds", async () => {
43
+ const fetchImpl = vi
44
+ .fn<typeof fetch>()
45
+ .mockRejectedValueOnce(new TypeError("fetch failed"))
46
+ .mockRejectedValueOnce(new Error("ECONNRESET"))
47
+ .mockResolvedValueOnce(jsonRes({ jobId: "job_1", status: "queued" }));
48
+ const sleep = vi.fn(async () => {});
49
+ const client = new SandboxRunnerClient({
50
+ baseUrl: "https://runner.example",
51
+ fetchImpl,
52
+ sleep,
53
+ });
54
+
55
+ await expect(
56
+ client.startJob("jwt-token", {
57
+ companyUid: "cmp_123",
58
+ secretNames: ["API_KEY"],
59
+ command: "node script.js",
60
+ }),
61
+ ).resolves.toEqual({ jobId: "job_1", status: "queued" });
62
+ expect(fetchImpl).toHaveBeenCalledTimes(3);
63
+ expect(sleep).toHaveBeenCalledTimes(2);
64
+ });
65
+
66
+ it("retries startJob after a retryable 503 response", async () => {
67
+ const fetchImpl = vi
68
+ .fn<typeof fetch>()
69
+ .mockResolvedValueOnce(jsonRes({ message: "warming up" }, 503))
70
+ .mockResolvedValueOnce(jsonRes({ jobId: "job_1", status: "queued" }));
71
+ const sleep = vi.fn(async () => {});
72
+ const client = new SandboxRunnerClient({
73
+ baseUrl: "https://runner.example",
74
+ fetchImpl,
75
+ sleep,
76
+ });
77
+
78
+ await expect(
79
+ client.startJob("jwt-token", {
80
+ companyUid: "cmp_123",
81
+ secretNames: ["API_KEY"],
82
+ command: "node script.js",
83
+ }),
84
+ ).resolves.toEqual({ jobId: "job_1", status: "queued" });
85
+ expect(fetchImpl).toHaveBeenCalledTimes(2);
86
+ expect(sleep).toHaveBeenCalledTimes(1);
87
+ });
88
+
89
+ it.each([400, 401])("does not retry startJob on %i responses", async (status) => {
90
+ const fetchImpl = vi.fn<typeof fetch>(async () =>
91
+ jsonRes({ message: "bad request" }, status),
92
+ );
93
+ const sleep = vi.fn(async () => {});
94
+ const client = new SandboxRunnerClient({
95
+ baseUrl: "https://runner.example",
96
+ fetchImpl,
97
+ sleep,
98
+ });
99
+
100
+ await expect(
101
+ client.startJob("jwt-token", {
102
+ companyUid: "cmp_123",
103
+ secretNames: ["API_KEY"],
104
+ command: "node script.js",
105
+ }),
106
+ ).rejects.toThrow("Sandbox Runner rejected job: bad request");
107
+ expect(fetchImpl).toHaveBeenCalledTimes(1);
108
+ expect(sleep).not.toHaveBeenCalled();
109
+ });
110
+
111
+ it("throws a clear exhaustion error after maxAttempts transient failures", async () => {
112
+ const fetchImpl = vi
113
+ .fn<typeof fetch>()
114
+ .mockRejectedValue(new TypeError("fetch failed"));
115
+ const sleep = vi.fn(async () => {});
116
+ const client = new SandboxRunnerClient({
117
+ baseUrl: "https://runner.example",
118
+ fetchImpl,
119
+ retry: { maxAttempts: 3 },
120
+ sleep,
121
+ });
122
+
123
+ await expect(
124
+ client.startJob("jwt-token", {
125
+ companyUid: "cmp_123",
126
+ secretNames: ["API_KEY"],
127
+ command: "node script.js",
128
+ }),
129
+ ).rejects.toThrow(
130
+ "Sandbox Runner did not respond after 3 attempts (cold start or transient network); last error: fetch failed",
131
+ );
132
+ expect(fetchImpl).toHaveBeenCalledTimes(3);
133
+ expect(sleep).toHaveBeenCalledTimes(2);
134
+ });
135
+
42
136
  it("polls queued and running states until succeeded", async () => {
43
137
  const fetchImpl = vi
44
138
  .fn<typeof fetch>()
@@ -77,6 +171,40 @@ describe("SandboxRunnerClient", () => {
77
171
  );
78
172
  });
79
173
 
174
+ it("tolerates one transient fetch rejection while polling", async () => {
175
+ const fetchImpl = vi
176
+ .fn<typeof fetch>()
177
+ .mockResolvedValueOnce(jsonRes({ jobId: "job_1", status: "queued" }))
178
+ .mockRejectedValueOnce(new TypeError("fetch failed"))
179
+ .mockResolvedValueOnce(jsonRes({ jobId: "job_1", status: "running" }))
180
+ .mockResolvedValueOnce(
181
+ jsonRes({
182
+ jobId: "job_1",
183
+ status: "succeeded",
184
+ output: "ok\n",
185
+ exitCode: 0,
186
+ success: true,
187
+ }),
188
+ );
189
+ const sleep = vi.fn(async () => {});
190
+ const client = new SandboxRunnerClient({
191
+ baseUrl: "https://runner.example",
192
+ fetchImpl,
193
+ sleep,
194
+ });
195
+
196
+ await expect(
197
+ client.pollJob("jwt-token", "job_1", { intervalMs: 0 }),
198
+ ).resolves.toMatchObject({
199
+ jobId: "job_1",
200
+ status: "succeeded",
201
+ output: "ok\n",
202
+ exitCode: 0,
203
+ success: true,
204
+ });
205
+ expect(fetchImpl).toHaveBeenCalledTimes(4);
206
+ });
207
+
80
208
  it("returns failed terminal jobs with error details", async () => {
81
209
  const fetchImpl = vi.fn<typeof fetch>(async () =>
82
210
  jsonRes({
@@ -22,6 +22,8 @@ export interface SandboxRunnerJob {
22
22
  export interface SandboxRunnerClientOptions {
23
23
  baseUrl?: string;
24
24
  fetchImpl?: typeof fetch;
25
+ retry?: SandboxRunnerRetryOptions;
26
+ sleep?: (ms: number) => Promise<void>;
25
27
  }
26
28
 
27
29
  export interface SandboxRunnerPollOptions {
@@ -29,7 +31,21 @@ export interface SandboxRunnerPollOptions {
29
31
  maxPolls?: number;
30
32
  }
31
33
 
34
+ export interface SandboxRunnerRetryOptions {
35
+ maxAttempts?: number;
36
+ maxElapsedMs?: number;
37
+ baseDelayMs?: number;
38
+ maxDelayMs?: number;
39
+ }
40
+
32
41
  const DEFAULT_SANDBOX_RUNNER_URL = "https://hqapi.getindigo.ai/sandbox";
42
+ const RETRYABLE_STATUS_CODES = new Set([408, 425, 429, 500, 502, 503, 504]);
43
+ const DEFAULT_RETRY_OPTIONS: Required<SandboxRunnerRetryOptions> = {
44
+ maxAttempts: 8,
45
+ maxElapsedMs: 90_000,
46
+ baseDelayMs: 500,
47
+ maxDelayMs: 5_000,
48
+ };
33
49
 
34
50
  function normalizeBaseUrl(baseUrl: string): string {
35
51
  return baseUrl.replace(/\/+$/, "");
@@ -82,20 +98,100 @@ function delay(ms: number): Promise<void> {
82
98
  return new Promise((resolve) => setTimeout(resolve, ms));
83
99
  }
84
100
 
101
+ function getErrorMessage(error: unknown): string {
102
+ if (error instanceof Error) {
103
+ return error.message;
104
+ }
105
+ if (typeof error === "string") {
106
+ return error;
107
+ }
108
+ return String(error);
109
+ }
110
+
111
+ function parseRetryAfterMs(value: string | null): number | undefined {
112
+ if (!value) return undefined;
113
+ const seconds = Number(value);
114
+ if (Number.isFinite(seconds) && seconds >= 0) {
115
+ return seconds * 1000;
116
+ }
117
+ const dateMs = Date.parse(value);
118
+ if (!Number.isNaN(dateMs)) {
119
+ return Math.max(0, dateMs - Date.now());
120
+ }
121
+ return undefined;
122
+ }
123
+
85
124
  export class SandboxRunnerClient {
86
125
  private readonly baseUrl: string;
87
126
  private readonly fetchImpl: typeof fetch;
127
+ private readonly retry: Required<SandboxRunnerRetryOptions>;
128
+ private readonly sleep: (ms: number) => Promise<void>;
88
129
 
89
130
  constructor(options: SandboxRunnerClientOptions = {}) {
90
131
  this.baseUrl = normalizeBaseUrl(options.baseUrl ?? getSandboxRunnerBaseUrl());
91
132
  this.fetchImpl = options.fetchImpl ?? fetch;
133
+ this.retry = { ...DEFAULT_RETRY_OPTIONS, ...options.retry };
134
+ this.sleep = options.sleep ?? delay;
135
+ }
136
+
137
+ private async fetchWithRetry(
138
+ url: string,
139
+ init: RequestInit,
140
+ ): Promise<Response> {
141
+ const startedAt = Date.now();
142
+ let lastError = "unknown error";
143
+ let attempts = 0;
144
+
145
+ for (let attempt = 1; attempt <= this.retry.maxAttempts; attempt += 1) {
146
+ attempts = attempt;
147
+ try {
148
+ const res = await this.fetchImpl(url, init);
149
+ if (!RETRYABLE_STATUS_CODES.has(res.status)) {
150
+ return res;
151
+ }
152
+ lastError = `HTTP ${res.status} ${res.statusText}`.trim();
153
+ if (!this.shouldRetry(attempt, startedAt)) {
154
+ break;
155
+ }
156
+ await this.sleep(this.nextDelayMs(attempt, res));
157
+ } catch (error) {
158
+ lastError = getErrorMessage(error);
159
+ if (!this.shouldRetry(attempt, startedAt)) {
160
+ break;
161
+ }
162
+ await this.sleep(this.nextDelayMs(attempt));
163
+ }
164
+ }
165
+
166
+ throw new Error(
167
+ `Sandbox Runner did not respond after ${attempts} attempts ` +
168
+ `(cold start or transient network); last error: ${lastError}`,
169
+ );
170
+ }
171
+
172
+ private shouldRetry(attempt: number, startedAt: number): boolean {
173
+ if (attempt >= this.retry.maxAttempts) {
174
+ return false;
175
+ }
176
+ return Date.now() - startedAt < this.retry.maxElapsedMs;
177
+ }
178
+
179
+ private nextDelayMs(attempt: number, res?: Response): number {
180
+ const retryAfterMs = parseRetryAfterMs(res?.headers.get("Retry-After") ?? null);
181
+ if (retryAfterMs !== undefined) {
182
+ return Math.min(retryAfterMs, this.retry.maxDelayMs);
183
+ }
184
+ const exponential = this.retry.baseDelayMs * 2 ** (attempt - 1);
185
+ const capped = Math.min(exponential, this.retry.maxDelayMs);
186
+ const jitter = Math.floor(Math.random() * Math.max(1, capped * 0.25));
187
+ return Math.min(capped + jitter, this.retry.maxDelayMs);
92
188
  }
93
189
 
94
190
  async startJob(
95
191
  token: string,
96
192
  request: SandboxRunnerStartRequest,
97
193
  ): Promise<SandboxRunnerStartResponse> {
98
- const res = await this.fetchImpl(`${this.baseUrl}/jobs`, {
194
+ const res = await this.fetchWithRetry(`${this.baseUrl}/jobs`, {
99
195
  method: "POST",
100
196
  headers: {
101
197
  Authorization: `Bearer ${token}`,
@@ -124,7 +220,7 @@ export class SandboxRunnerClient {
124
220
  }
125
221
 
126
222
  async getJob(token: string, jobId: string): Promise<SandboxRunnerJob> {
127
- const res = await this.fetchImpl(
223
+ const res = await this.fetchWithRetry(
128
224
  `${this.baseUrl}/jobs/${encodeURIComponent(jobId)}`,
129
225
  {
130
226
  headers: { Authorization: `Bearer ${token}` },
@@ -155,7 +251,7 @@ export class SandboxRunnerClient {
155
251
  if (job.status === "succeeded" || job.status === "failed") {
156
252
  return job;
157
253
  }
158
- await delay(intervalMs);
254
+ await this.sleep(intervalMs);
159
255
  }
160
256
  throw new Error(`Sandbox Runner job '${jobId}' did not finish before the poll limit.`);
161
257
  }
@@ -120,6 +120,36 @@ describe("refreshVersionCache", () => {
120
120
  expect(typeof written.fetchedAt).toBe("number");
121
121
  });
122
122
 
123
+ it("returns without fetching when the cached version is still within the TTL", async () => {
124
+ writeCache("5.99.0", 60 * 1000);
125
+ const fetchMock = vi.fn();
126
+ vi.stubGlobal("fetch", fetchMock);
127
+
128
+ const mod = await loadModule();
129
+ await mod.refreshVersionCache();
130
+
131
+ expect(fetchMock).not.toHaveBeenCalled();
132
+ const cachePath = path.join(tmpHome, ".hq", "version-check.json");
133
+ const written = JSON.parse(fs.readFileSync(cachePath, "utf-8"));
134
+ expect(written.latest).toBe("5.99.0");
135
+ });
136
+
137
+ it("skips passive refresh for noninteractive status probes", async () => {
138
+ const originalArgv = process.argv;
139
+ process.argv = ["node", "hq", "mcp", "status", "--json"];
140
+ const fetchMock = vi.fn();
141
+ vi.stubGlobal("fetch", fetchMock);
142
+
143
+ try {
144
+ const mod = await loadModule();
145
+ await mod.refreshVersionCache();
146
+ } finally {
147
+ process.argv = originalArgv;
148
+ }
149
+
150
+ expect(fetchMock).not.toHaveBeenCalled();
151
+ });
152
+
123
153
  it("does not throw or write a cache when fetch fails", async () => {
124
154
  const fetchMock = vi.fn().mockRejectedValue(new Error("network down"));
125
155
  vi.stubGlobal("fetch", fetchMock);
@@ -8,7 +8,9 @@ import { CLI_VERSION } from "../cli-version.js";
8
8
  const PACKAGE_NAME = "@indigoai-us/hq-cli";
9
9
  const REGISTRY_URL = `https://registry.npmjs.org/${encodeURIComponent(PACKAGE_NAME)}/latest`;
10
10
  const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
11
+ const CACHE_TTL_JITTER_MS = 60 * 60 * 1000;
11
12
  const FETCH_TIMEOUT_MS = 3_000;
13
+ const REFRESH_LOCK_STALE_MS = 10 * 60 * 1000;
12
14
 
13
15
  interface CacheEntry {
14
16
  latest: string;
@@ -19,6 +21,10 @@ function cachePath(): string {
19
21
  return path.join(os.homedir(), ".hq", "version-check.json");
20
22
  }
21
23
 
24
+ function lockPath(): string {
25
+ return path.join(os.homedir(), ".hq", "version-check.lock");
26
+ }
27
+
22
28
  function isOptedOut(): boolean {
23
29
  return process.env.HQ_NO_UPDATE_CHECK === "1";
24
30
  }
@@ -49,6 +55,58 @@ function writeCache(entry: CacheEntry): void {
49
55
  }
50
56
  }
51
57
 
58
+ function freshEnough(entry: CacheEntry, now = Date.now()): boolean {
59
+ const jitter = Math.floor(Math.random() * CACHE_TTL_JITTER_MS);
60
+ return now - entry.fetchedAt <= CACHE_TTL_MS - jitter;
61
+ }
62
+
63
+ function isKnownNoninteractiveStatusProbe(argv: readonly string[] = process.argv): boolean {
64
+ const args = argv.slice(2);
65
+ const positional = args.filter((arg) => !arg.startsWith("-"));
66
+ const json = args.includes("--json") || !process.stdout.isTTY;
67
+ if (!json) return false;
68
+ if (positional[0] === "mcp" && positional[1] === "status") return true;
69
+ if (positional[0] === "packs" && (positional[1] === "list" || positional[1] === "ls")) return true;
70
+ if (
71
+ positional[0] === "packages" &&
72
+ positional[1] === "packs" &&
73
+ (positional[2] === "list" || positional[2] === "ls")
74
+ ) {
75
+ return true;
76
+ }
77
+ if ((positional[0] === "sources" || positional[0] === "signals") && positional[1] === "list") {
78
+ return true;
79
+ }
80
+ return false;
81
+ }
82
+
83
+ function acquireRefreshLock(now = Date.now()): (() => void) | null {
84
+ const dir = lockPath();
85
+ try {
86
+ fs.mkdirSync(path.dirname(dir), { recursive: true });
87
+ fs.mkdirSync(dir);
88
+ fs.writeFileSync(path.join(dir, "owner"), `${process.pid}\n${now}\n`);
89
+ return () => {
90
+ try {
91
+ fs.rmSync(dir, { recursive: true, force: true });
92
+ } catch {
93
+ // best-effort lock cleanup
94
+ }
95
+ };
96
+ } catch {
97
+ try {
98
+ const stat = fs.statSync(dir);
99
+ if (now - stat.mtimeMs > REFRESH_LOCK_STALE_MS) {
100
+ fs.rmSync(dir, { recursive: true, force: true });
101
+ return acquireRefreshLock(now);
102
+ }
103
+ } catch {
104
+ // ignore lock inspection failures
105
+ }
106
+ return null;
107
+ }
108
+ }
109
+
52
110
  export function maybeWarnNewVersion(): void {
53
111
  if (isOptedOut()) return;
54
112
  const entry = readCache();
@@ -68,7 +126,14 @@ export function maybeWarnNewVersion(): void {
68
126
 
69
127
  export async function refreshVersionCache(): Promise<void> {
70
128
  if (isOptedOut()) return;
129
+ if (isKnownNoninteractiveStatusProbe()) return;
130
+ const existing = readCache();
131
+ if (existing && freshEnough(existing)) return;
132
+ const releaseLock = acquireRefreshLock();
133
+ if (!releaseLock) return;
71
134
  try {
135
+ const lockedExisting = readCache();
136
+ if (lockedExisting && freshEnough(lockedExisting)) return;
72
137
  const res = await fetch(REGISTRY_URL, {
73
138
  headers: { Accept: "application/json" },
74
139
  signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
@@ -79,5 +144,12 @@ export async function refreshVersionCache(): Promise<void> {
79
144
  writeCache({ latest: body.version, fetchedAt: Date.now() });
80
145
  } catch {
81
146
  // best-effort; offline / registry down / timeout — silent
147
+ } finally {
148
+ releaseLock();
82
149
  }
83
150
  }
151
+
152
+ export const __test__ = {
153
+ CACHE_TTL_MS,
154
+ isKnownNoninteractiveStatusProbe,
155
+ };