@flowapt/flowiq-cli 0.12.0 → 0.12.1

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/README.md CHANGED
@@ -2303,6 +2303,20 @@ flowiq woo get <org> products --all --fields id,name,permalink,status # past t
2303
2303
  - Not a CLI fix: #186 (Shopify store analytics need the `read_reports` scope
2304
2304
  on the FlowIQ app).
2305
2305
 
2306
+ ### 0.12.1: dropped connections and `insights status <org>`
2307
+
2308
+ - **A read survives a dropped connection.** A bare `Request failed: fetch failed`
2309
+ (the connection dropped, often when several commands run at once) is retried
2310
+ twice with a short backoff on every GET. A write (POST / PUT / DELETE) is never
2311
+ repeated blind: it may have reached the server, so the error says to check the
2312
+ result before retrying. Measured on 2 Oct 2026: 30 of 57 month-by-month
2313
+ `shopify get --all` pulls failed this way five at a time on 0.11.0; twelve run
2314
+ six at a time on 0.12.1 all came back (6,538 orders, `flowiq gaps` #198).
2315
+ - **`flowiq insights status <org>` no longer times out.** The server used to
2316
+ count 30 days of messages for every org and keep one; it now counts only the
2317
+ org asked for (0.2 s), and the all-orgs listing reads each org on its own index
2318
+ (about 1 s for 60 orgs). Server-side, so it works on any CLI version (#130).
2319
+
2306
2320
  ### Guide — `flowiq guide`
2307
2321
 
2308
2322
  Read the bundled docs in the terminal — no digging through node_modules.
package/TEAM-GUIDE.md CHANGED
@@ -459,6 +459,8 @@ Three things worth knowing:
459
459
  | "Organization has no active_whatsapp_agent" | Target the agent directly: `--agent <id>` (ids from `flowiq agent list <org_id>`) |
460
460
  | Login browser page says the code expired | Codes live 10 minutes — just re-run `flowiq auth login` |
461
461
  | Pushed the wrong thing | Everything is pull→push, so re-pull an older copy if you have one, or check with Matt — server logs record every push with who/what/when |
462
+ | `could not reach the API after 3 tries` | The connection dropped (0.12.1 already retried the read twice). Run it again, and run fewer commands at once |
463
+ | `lost its connection … may still have finished the write` | A write is never retried for you: check the result (`list` / `status` / `pull`, or `flowiq audit`) before running it again |
462
464
 
463
465
  ChatCart rollout: Pick n Pay uses retailer `pnp` and the private connection link.
464
466
  Enable its gateway before adding it to the agent tools. Cart retries use
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowapt/flowiq-cli",
3
- "version": "0.12.0",
3
+ "version": "0.12.1",
4
4
  "description": "Command-line tool for FlowIQ staff: round-trip agent prompts, questionnaires, fine-tuning, pin-board tasks, webhooks, templates, agent-updates, chat exports, and live agent testing without ever touching service-role credentials.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,48 @@
1
+ // node --test src/http-retry.test.mjs
2
+ import test from "node:test";
3
+ import assert from "node:assert/strict";
4
+ import { fetchWithRetry, isNetworkError } from "./http.js";
5
+
6
+ const dropped = () => Object.assign(new TypeError("fetch failed"), { cause: { code: "ECONNRESET" } });
7
+ const noSleep = async () => {};
8
+
9
+ test("isNetworkError: a dropped connection yes, a programming error no", () => {
10
+ assert.equal(isNetworkError(dropped()), true);
11
+ assert.equal(isNetworkError(new TypeError("Invalid URL")), false);
12
+ assert.equal(isNetworkError(new Error("boom")), false);
13
+ });
14
+
15
+ test("fetchWithRetry: a GET is retried after a dropped connection", async () => {
16
+ let calls = 0;
17
+ const fetchImpl = async () => { calls++; if (calls < 3) throw dropped(); return { ok: true }; };
18
+ const resp = await fetchWithRetry("u", {}, { method: "GET", endpoint: "store-api", fetchImpl, sleep: noSleep });
19
+ assert.deepEqual(resp, { ok: true });
20
+ assert.equal(calls, 3);
21
+ });
22
+
23
+ test("fetchWithRetry: a GET gives up after 3 tries and says it is the network", async () => {
24
+ let calls = 0;
25
+ const fetchImpl = async () => { calls++; throw dropped(); };
26
+ await assert.rejects(
27
+ fetchWithRetry("u", {}, { method: "GET", endpoint: "store-api", fetchImpl, sleep: noSleep }),
28
+ (e) => e.code === "ENETWORK" && /after 3 tries/.test(e.message) && /ECONNRESET/.test(e.message)
29
+ );
30
+ assert.equal(calls, 3);
31
+ });
32
+
33
+ test("fetchWithRetry: a write is never repeated and says check before retrying", async () => {
34
+ let calls = 0;
35
+ const fetchImpl = async () => { calls++; throw dropped(); };
36
+ await assert.rejects(
37
+ fetchWithRetry("u", {}, { method: "POST", endpoint: "prompts", fetchImpl, sleep: noSleep }),
38
+ (e) => e.code === "ENETWORK" && /may still have finished the write/.test(e.message)
39
+ );
40
+ assert.equal(calls, 1);
41
+ });
42
+
43
+ test("fetchWithRetry: a non-network error is not retried", async () => {
44
+ let calls = 0;
45
+ const fetchImpl = async () => { calls++; throw new TypeError("Invalid URL"); };
46
+ await assert.rejects(fetchWithRetry("u", {}, { method: "GET", fetchImpl, sleep: noSleep }), /Invalid URL/);
47
+ assert.equal(calls, 1);
48
+ });
package/src/http.js CHANGED
@@ -118,6 +118,45 @@ export function isVercelChallenge(status, headers, text) {
118
118
  return (status === 403 || status === 429) && /Vercel Security Checkpoint|vercel-challenge|_vercel\/challenge/i.test(text || "");
119
119
  }
120
120
 
121
+ // A dropped connection surfaces from undici as a bare "TypeError: fetch failed"
122
+ // (the real reason sits in err.cause). Several CLI calls in parallel hit it
123
+ // (2 Oct 2026: 30 of 57 month-by-month `shopify get --all` pulls, `flowiq gaps`
124
+ // #198), and every one succeeded when simply re-run. A read is safe to repeat,
125
+ // so GETs retry twice with a short backoff; a write is never repeated blind,
126
+ // because it may have reached the server before the connection dropped.
127
+ const NETWORK_ERROR = /fetch failed|ECONNRESET|ECONNREFUSED|ETIMEDOUT|EAI_AGAIN|ENOTFOUND|EPIPE|UND_ERR_|socket hang up|other side closed/i;
128
+
129
+ export function isNetworkError(e) {
130
+ const text = [e?.message, e?.code, e?.cause?.code, e?.cause?.message].filter(Boolean).join(" ");
131
+ return NETWORK_ERROR.test(text);
132
+ }
133
+
134
+ export async function fetchWithRetry(url, init, { method = "GET", endpoint = "", fetchImpl = fetch, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), attempts } = {}) {
135
+ const max = attempts ?? (method === "GET" ? 3 : 1);
136
+ for (let i = 1; ; i++) {
137
+ try {
138
+ return await fetchImpl(url, init);
139
+ } catch (e) {
140
+ if (!isNetworkError(e)) throw e;
141
+ const reason = e?.cause?.code || e?.cause?.message || e?.message || "network error";
142
+ if (i < max) {
143
+ await sleep(400 * 2 ** (i - 1) + Math.floor(Math.random() * 250));
144
+ continue;
145
+ }
146
+ const err = new Error(
147
+ method === "GET"
148
+ ? `${method} ${endpoint} could not reach the API after ${max} tries (${reason}). ` +
149
+ `This is the network, not the request: run it again, and run fewer at once.`
150
+ : `${method} ${endpoint} lost its connection (${reason}). The server may still have finished the write: ` +
151
+ `check the result (the command's list / status / pull verb, or flowiq audit) before retrying.`
152
+ );
153
+ err.code = "ENETWORK";
154
+ err.cause = e;
155
+ throw err;
156
+ }
157
+ }
158
+ }
159
+
121
160
  async function call(method, endpoint, { query, body } = {}) {
122
161
  const cfg = await loadConfig();
123
162
  if (!cfg.token) {
@@ -148,7 +187,7 @@ async function call(method, endpoint, { query, body } = {}) {
148
187
  init.body = JSON.stringify(body);
149
188
  }
150
189
 
151
- const resp = await fetch(url, init);
190
+ const resp = await fetchWithRetry(url, init, { method, endpoint });
152
191
  noteServerContract(resp);
153
192
  const text = await resp.text();
154
193
  let parsed, isJson = true;