@zoowork-ai/sdk 0.5.0 → 0.5.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/CHANGELOG.md CHANGED
@@ -3,6 +3,38 @@
3
3
  All notable changes to `@zoowork-ai/sdk` (formerly `@zooclaw-agents/sdk`). Dates are the
4
4
  day the behaviour was verified, not the day it was written.
5
5
 
6
+ ## 0.5.1 — 2026-08-31
7
+
8
+ ### Added
9
+
10
+ - **`ZooworkError` keeps the evidence: `contentType`, `bodySnippet`, `cfRay`, `requestId`,
11
+ `retryable`.** A production `postEvents` failure surfaced as `HTTP 502, type: undefined`
12
+ and cost the reporter a day of black-box contrast experiments (2026-08-30,
13
+ `notes/probes/system-message-cold-session-probe.mts`): the edge replaces an origin 502/504
14
+ body wholesale with a branded `text/html` page, so no JSON envelope ever reaches the SDK —
15
+ and 0.5.0 then dropped the only three facts that survived. Now every transport error keeps
16
+ the response `Content-Type`, the first 600 characters of the raw body, and the `cf-ray`
17
+ header (present on JSON errors too, verified 2026-08-31) — the id to quote when reporting
18
+ a gateway failure. `requestId` reads `request_id` from either error envelope once the
19
+ server starts sending one; today it is usually absent. `retryable` is a transport-class
20
+ hint (`408/429/502/503/504`): it says the failure class tends to pass, not that a replay
21
+ is safe — pair it with `idempotency_key` before looping on it.
22
+ SDK-synthesized wait timeouts keep `retryable: false`: they report that the caller's own
23
+ polling budget expired, not that an HTTP 408 came back from the service.
24
+
25
+ ### Changed
26
+
27
+ - **The fallback error message names what actually came back.** A non-JSON error body used
28
+ to read a bare `HTTP 502`; it now reads
29
+ `HTTP 502 (text/html; charset=UTF-8) [cf-ray a338c539…]`. Messages parsed from a server
30
+ envelope are unchanged — keep matching on `type`/`status`, never on message text.
31
+ - **`streamEvents` raises the same enriched envelope** on a non-ok response instead of the
32
+ bare `events stream HTTP <status>` string, so SSE failures are diagnosable the same way.
33
+ - **A structured `detail` object no longer stringifies into the message.** The agents-family
34
+ envelope may carry `detail` as an object; it previously became the literal message
35
+ `[object Object]`, now it falls through to the status line and stays readable in
36
+ `bodySnippet`.
37
+
6
38
  ## 0.5.0 — 2026-08-28
7
39
 
8
40
  ### Added
package/dist/client.d.ts CHANGED
@@ -55,7 +55,39 @@ export declare class ZooworkError extends Error {
55
55
  * you only need the class of failure.
56
56
  */
57
57
  type?: string;
58
- constructor(status: number, message: string, type?: string);
58
+ /**
59
+ * `Content-Type` of the error response. The field that tells an edge error page apart from an
60
+ * API answer: both envelopes above are `application/json`, while a gateway 502/504 arrives as
61
+ * `text/html` — the edge replaces the origin's body wholesale, so no JSON survives to parse
62
+ * (verified 2026-08-30 against both deployments;
63
+ * `notes/probes/system-message-cold-session-probe.mts`).
64
+ */
65
+ contentType?: string;
66
+ /** First {@link BODY_SNIPPET_LIMIT} characters of the raw error body, whatever it was.
67
+ * Without it a non-JSON failure cannot be reconstructed from `status` alone. */
68
+ bodySnippet?: string;
69
+ /**
70
+ * Cloudflare ray id (`cf-ray` response header), when the response crossed Cloudflare. It
71
+ * survives even the replaced-body case above — on an HTML 502 it is the only correlation id
72
+ * left, and the value to quote when reporting a gateway failure.
73
+ */
74
+ cfRay?: string;
75
+ /** Correlation id from the error envelope (`request_id` on either vocabulary), when the
76
+ * server includes one. Usually absent today. */
77
+ requestId?: string;
78
+ /**
79
+ * Transport-class transient hint: `true` for 408, 429, 502, 503 and 504. It says the failure
80
+ * CLASS tends to pass, not that a replay is safe — retrying a `postEvents` without an
81
+ * `idempotency_key` can still deliver twice. Pair it with idempotency keys before looping.
82
+ */
83
+ retryable: boolean;
84
+ constructor(status: number, message: string, type?: string, extra?: {
85
+ contentType?: string;
86
+ bodySnippet?: string;
87
+ cfRay?: string;
88
+ requestId?: string;
89
+ retryable?: boolean;
90
+ });
59
91
  }
60
92
  export interface Ownership {
61
93
  owner_uid: string;
package/dist/client.js CHANGED
@@ -39,6 +39,11 @@ function stripTrailingSlashes(url) {
39
39
  end--;
40
40
  return end === url.length ? url : url.slice(0, end);
41
41
  }
42
+ /** Statuses whose failure class tends to pass on its own: timeout, throttle, gateway. */
43
+ const RETRYABLE_STATUSES = new Set([408, 429, 502, 503, 504]);
44
+ /** Bound on `ZooworkError.bodySnippet` — enough to keep a whole error envelope or the
45
+ * opening of an HTML error page, small enough to log unconditionally. */
46
+ const BODY_SNIPPET_LIMIT = 600;
42
47
  export class ZooworkError extends Error {
43
48
  status;
44
49
  /**
@@ -52,14 +57,88 @@ export class ZooworkError extends Error {
52
57
  * you only need the class of failure.
53
58
  */
54
59
  type;
55
- constructor(status, message, type) {
60
+ /**
61
+ * `Content-Type` of the error response. The field that tells an edge error page apart from an
62
+ * API answer: both envelopes above are `application/json`, while a gateway 502/504 arrives as
63
+ * `text/html` — the edge replaces the origin's body wholesale, so no JSON survives to parse
64
+ * (verified 2026-08-30 against both deployments;
65
+ * `notes/probes/system-message-cold-session-probe.mts`).
66
+ */
67
+ contentType;
68
+ /** First {@link BODY_SNIPPET_LIMIT} characters of the raw error body, whatever it was.
69
+ * Without it a non-JSON failure cannot be reconstructed from `status` alone. */
70
+ bodySnippet;
71
+ /**
72
+ * Cloudflare ray id (`cf-ray` response header), when the response crossed Cloudflare. It
73
+ * survives even the replaced-body case above — on an HTML 502 it is the only correlation id
74
+ * left, and the value to quote when reporting a gateway failure.
75
+ */
76
+ cfRay;
77
+ /** Correlation id from the error envelope (`request_id` on either vocabulary), when the
78
+ * server includes one. Usually absent today. */
79
+ requestId;
80
+ /**
81
+ * Transport-class transient hint: `true` for 408, 429, 502, 503 and 504. It says the failure
82
+ * CLASS tends to pass, not that a replay is safe — retrying a `postEvents` without an
83
+ * `idempotency_key` can still deliver twice. Pair it with idempotency keys before looping.
84
+ */
85
+ retryable;
86
+ constructor(status, message, type, extra) {
56
87
  super(message);
57
88
  this.name = 'ZooworkError';
58
89
  this.status = status;
59
90
  if (type)
60
91
  this.type = type;
92
+ if (extra?.contentType)
93
+ this.contentType = extra.contentType;
94
+ if (extra?.bodySnippet)
95
+ this.bodySnippet = extra.bodySnippet;
96
+ if (extra?.cfRay)
97
+ this.cfRay = extra.cfRay;
98
+ if (extra?.requestId)
99
+ this.requestId = extra.requestId;
100
+ this.retryable = extra?.retryable ?? RETRYABLE_STATUSES.has(status);
61
101
  }
62
102
  }
103
+ /** The response facts worth keeping on every transport-level ZooworkError, whatever the body. */
104
+ function responseForensics(res, text) {
105
+ return {
106
+ contentType: res.headers.get('content-type') ?? undefined,
107
+ cfRay: res.headers.get('cf-ray') ?? undefined,
108
+ bodySnippet: text ? text.slice(0, BODY_SNIPPET_LIMIT) : undefined,
109
+ };
110
+ }
111
+ /**
112
+ * Build the ZooworkError for a non-2xx response. Unpacks BOTH envelope vocabularies (see
113
+ * {@link ZooworkError.type}); when neither matches — typically an edge error page whose body
114
+ * replaced the origin's JSON — the message names status, content-type and ray id instead of a
115
+ * bare `HTTP 502`. Those three are what turn an "HTTP 502, type: undefined" report into an
116
+ * answerable one (2026-08-30). A structured `detail` object is deliberately NOT stringified
117
+ * into the message (`[object Object]`); it stays readable in `bodySnippet`.
118
+ */
119
+ function httpError(res, text) {
120
+ const forensics = responseForensics(res, text);
121
+ let msg;
122
+ let type;
123
+ let requestId;
124
+ try {
125
+ const j = JSON.parse(text);
126
+ const detail = typeof j?.detail === 'string' ? j.detail : undefined;
127
+ msg = j?.error?.message || j?.message || detail || undefined;
128
+ type = j?.error?.type ?? j?.code;
129
+ requestId = j?.error?.request_id ?? j?.request_id;
130
+ }
131
+ catch {
132
+ /* non-JSON error body — usually the edge speaking, not the API */
133
+ }
134
+ if (!msg) {
135
+ msg =
136
+ `HTTP ${res.status}` +
137
+ (forensics.contentType ? ` (${forensics.contentType})` : '') +
138
+ (forensics.cfRay ? ` [cf-ray ${forensics.cfRay}]` : '');
139
+ }
140
+ return new ZooworkError(res.status, msg, type, { ...forensics, requestId });
141
+ }
63
142
  /**
64
143
  * Create a client.
65
144
  *
@@ -90,36 +169,22 @@ export function createZooworkClient(cfg = {}) {
90
169
  }
91
170
  const bearer = 'serviceToken' in auth ? auth.serviceToken : auth.apiKey;
92
171
  /**
93
- * TWO error envelopes, one ZooworkError shape, for every helper below.
94
- *
95
- * The API does not answer failures the same way everywhere — staging-verified 2026-08-07. Most
96
- * families send `{ error: { type, message } }`; the agents family sends `{ code, detail }`.
97
- * Reading only the first left every agent 404 with `type: undefined` and the message `HTTP 404`,
98
- * so both are unpacked here. The codes stay verbatim (`not_found` vs `service_api.not_found`) —
99
- * inventing a shared vocabulary would be this SDK guessing, which is what it exists not to do.
172
+ * TWO error envelopes, one ZooworkError shape, for every helper below — the unpacking (and
173
+ * why both vocabularies exist) lives in module-level {@link httpError}. The codes stay
174
+ * verbatim (`not_found` vs `service_api.not_found`) inventing a shared vocabulary would be
175
+ * this SDK guessing, which is what it exists not to do.
100
176
  */
101
177
  const readResponse = async (res, path) => {
102
178
  const text = await res.text();
103
- if (!res.ok) {
104
- let msg = `HTTP ${res.status}`;
105
- let type;
106
- try {
107
- const j = JSON.parse(text);
108
- msg = j?.error?.message || j?.message || j?.detail || msg;
109
- type = j?.error?.type ?? j?.code;
110
- }
111
- catch {
112
- /* non-JSON error body → keep clean status */
113
- }
114
- throw new ZooworkError(res.status, msg, type);
115
- }
179
+ if (!res.ok)
180
+ throw httpError(res, text);
116
181
  if (!text)
117
182
  return {};
118
183
  try {
119
184
  return JSON.parse(text);
120
185
  }
121
186
  catch {
122
- throw new ZooworkError(res.status, `non-JSON response: ${path}`);
187
+ throw new ZooworkError(res.status, `non-JSON response: ${path}`, undefined, responseForensics(res, text));
123
188
  }
124
189
  };
125
190
  /**
@@ -270,7 +335,7 @@ export function createZooworkClient(cfg = {}) {
270
335
  let lastSeen = 'unknown';
271
336
  const abortedError = () => new ZooworkError(0, `waitUntilRunning(${agentId}) aborted`, 'aborted');
272
337
  const timeoutError = () => new ZooworkError(408, `agent ${agentId} did not reach status.desired_state=running within ${timeoutMs}ms ` +
273
- `(last seen: ${lastSeen})`, 'timeout');
338
+ `(last seen: ${lastSeen})`, 'timeout', { retryable: false });
274
339
  for (;;) {
275
340
  if (opts.signal?.aborted)
276
341
  throw abortedError();
@@ -350,7 +415,7 @@ export function createZooworkClient(cfg = {}) {
350
415
  let lastStatus = 'unknown';
351
416
  const abortedError = () => new ZooworkError(0, `waitForChannelSetup(${agentId}, ${platform}, ${sessionId}) aborted`, 'aborted');
352
417
  const timeoutError = () => new ZooworkError(408, `${platform} setup session ${sessionId} still '${lastStatus}' after ${timeoutMs}ms — ` +
353
- 'the QR may simply not have been scanned yet; the session itself expires server-side', 'timeout');
418
+ 'the QR may simply not have been scanned yet; the session itself expires server-side', 'timeout', { retryable: false });
354
419
  for (;;) {
355
420
  if (opts.signal?.aborted)
356
421
  throw abortedError();
@@ -517,7 +582,7 @@ export function createZooworkClient(cfg = {}) {
517
582
  ...(opts.signal ? { signal: opts.signal } : {}),
518
583
  });
519
584
  if (!res.ok)
520
- throw new ZooworkError(res.status, `events stream HTTP ${res.status}`);
585
+ throw httpError(res, await res.text().catch(() => ''));
521
586
  if (!res.body)
522
587
  return;
523
588
  for await (const msg of parseSSE(res.body)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zoowork-ai/sdk",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "TypeScript SDK for the ZooWork Managed Agents API (Developer Preview)",
5
5
  "keywords": [
6
6
  "zoowork",