@zoowork-ai/sdk 0.4.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 +366 -0
- package/LICENSE +21 -0
- package/README.md +198 -0
- package/dist/client.d.ts +1424 -0
- package/dist/client.js +622 -0
- package/dist/events.d.ts +88 -0
- package/dist/events.js +151 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/sse.d.ts +17 -0
- package/dist/sse.js +69 -0
- package/package.json +54 -0
package/dist/client.js
ADDED
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ZooWork Managed Agents SDK — core client. Developer Preview.
|
|
3
|
+
*
|
|
4
|
+
* Authenticate with your organization API key (`zct_…`). It carries full tenant
|
|
5
|
+
* authority, so it is SERVER-SIDE ONLY: never ship it in a browser or mobile bundle.
|
|
6
|
+
*
|
|
7
|
+
* IDs are opaque strings and unknown response fields must be ignored — both are
|
|
8
|
+
* forward-compatibility rules, not suggestions. Errors surface an `error.type`; match on
|
|
9
|
+
* that, never on the message text.
|
|
10
|
+
*/
|
|
11
|
+
import { parseSSE, isObj } from './sse.js';
|
|
12
|
+
import { normalizeEvent } from './events.js';
|
|
13
|
+
/**
|
|
14
|
+
* The production API base URL — the default. You should not need to set this:
|
|
15
|
+
* `ZOOWORK_BASE_URL` overrides it, and so does the `baseUrl` option, only when you
|
|
16
|
+
* need to point at a different deployment.
|
|
17
|
+
*/
|
|
18
|
+
export const DEFAULT_BASE_URL = 'https://clawapi.ecap.gsmo.ai/service/v1';
|
|
19
|
+
/**
|
|
20
|
+
* Read an environment variable without assuming a Node runtime.
|
|
21
|
+
*
|
|
22
|
+
* The SDK runs in Workers, Deno and browsers too, where `process` does not exist —
|
|
23
|
+
* touching it unguarded is a ReferenceError, not `undefined`.
|
|
24
|
+
*/
|
|
25
|
+
function readEnv(name) {
|
|
26
|
+
const proc = globalThis.process;
|
|
27
|
+
const value = proc?.env?.[name];
|
|
28
|
+
return value === undefined || value === '' ? undefined : value;
|
|
29
|
+
}
|
|
30
|
+
export class ZooworkError extends Error {
|
|
31
|
+
status;
|
|
32
|
+
/**
|
|
33
|
+
* The machine-readable error code. Match on this, never on the message.
|
|
34
|
+
*
|
|
35
|
+
* TWO VOCABULARIES, because there are two error envelopes — staging-verified 2026-08-07. The
|
|
36
|
+
* sessions/schedules/environments family answers `{ error: { type, message } }` with a bare code
|
|
37
|
+
* (`agent_not_running`, `session_archived`, `environment_not_ready`); the agents family answers
|
|
38
|
+
* `{ code, detail }` with a DOTTED one (`service_api.not_found`). Both land here, so a caller
|
|
39
|
+
* always gets something — but do not assume one spelling covers both, and prefer `status` when
|
|
40
|
+
* you only need the class of failure.
|
|
41
|
+
*/
|
|
42
|
+
type;
|
|
43
|
+
constructor(status, message, type) {
|
|
44
|
+
super(message);
|
|
45
|
+
this.name = 'ZooworkError';
|
|
46
|
+
this.status = status;
|
|
47
|
+
if (type)
|
|
48
|
+
this.type = type;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Create a client.
|
|
53
|
+
*
|
|
54
|
+
* ```ts
|
|
55
|
+
* const zc = createZooworkClient({ apiKey: 'zct_...' })
|
|
56
|
+
* const zc = createZooworkClient() // reads ZOOWORK_API_KEY
|
|
57
|
+
* ```
|
|
58
|
+
*
|
|
59
|
+
* Resolution order for both settings is the same: explicit argument, then environment
|
|
60
|
+
* variable, then (for `baseUrl` only) the built-in default.
|
|
61
|
+
*
|
|
62
|
+
* @throws if no API key can be resolved — a missing key is a setup mistake worth failing
|
|
63
|
+
* loudly at construction rather than as a 401 on the first call.
|
|
64
|
+
*/
|
|
65
|
+
export function createZooworkClient(cfg = {}) {
|
|
66
|
+
const doFetch = cfg.fetch ?? ((input, init) => fetch(input, init));
|
|
67
|
+
const base = (cfg.baseUrl ?? readEnv('ZOOWORK_BASE_URL') ?? DEFAULT_BASE_URL).replace(/\/+$/, '');
|
|
68
|
+
const auth = cfg.auth ??
|
|
69
|
+
(cfg.apiKey !== undefined
|
|
70
|
+
? { apiKey: cfg.apiKey }
|
|
71
|
+
: (() => {
|
|
72
|
+
const fromEnv = readEnv('ZOOWORK_API_KEY');
|
|
73
|
+
return fromEnv ? { apiKey: fromEnv } : undefined;
|
|
74
|
+
})());
|
|
75
|
+
if (!auth) {
|
|
76
|
+
throw new Error('No ZooWork API key. Pass createZooworkClient({ apiKey }) or set ZOOWORK_API_KEY. ' +
|
|
77
|
+
'Keys look like zct_… and are issued by an organization administrator.');
|
|
78
|
+
}
|
|
79
|
+
const bearer = 'serviceToken' in auth ? auth.serviceToken : auth.apiKey;
|
|
80
|
+
/**
|
|
81
|
+
* TWO error envelopes, one ZooworkError shape, for every helper below.
|
|
82
|
+
*
|
|
83
|
+
* The API does not answer failures the same way everywhere — staging-verified 2026-08-07. Most
|
|
84
|
+
* families send `{ error: { type, message } }`; the agents family sends `{ code, detail }`.
|
|
85
|
+
* Reading only the first left every agent 404 with `type: undefined` and the message `HTTP 404`,
|
|
86
|
+
* so both are unpacked here. The codes stay verbatim (`not_found` vs `service_api.not_found`) —
|
|
87
|
+
* inventing a shared vocabulary would be this SDK guessing, which is what it exists not to do.
|
|
88
|
+
*/
|
|
89
|
+
const readResponse = async (res, path) => {
|
|
90
|
+
const text = await res.text();
|
|
91
|
+
if (!res.ok) {
|
|
92
|
+
let msg = `HTTP ${res.status}`;
|
|
93
|
+
let type;
|
|
94
|
+
try {
|
|
95
|
+
const j = JSON.parse(text);
|
|
96
|
+
msg = j?.error?.message || j?.message || j?.detail || msg;
|
|
97
|
+
type = j?.error?.type ?? j?.code;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
/* non-JSON error body → keep clean status */
|
|
101
|
+
}
|
|
102
|
+
throw new ZooworkError(res.status, msg, type);
|
|
103
|
+
}
|
|
104
|
+
if (!text)
|
|
105
|
+
return {};
|
|
106
|
+
try {
|
|
107
|
+
return JSON.parse(text);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
throw new ZooworkError(res.status, `non-JSON response: ${path}`);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
/**
|
|
114
|
+
* `signal` is forwarded to `fetch`, so a request can be cancelled WHILE IT IS IN FLIGHT.
|
|
115
|
+
* That matters because neither Node's `fetch` nor the Workers one has a default timeout:
|
|
116
|
+
* a gateway that accepts the connection and then stalls hangs the promise forever unless
|
|
117
|
+
* somebody hands it a signal. See `waitUntilRunning`, which bounds every poll with one.
|
|
118
|
+
*/
|
|
119
|
+
const json = async (path, init = {}) => {
|
|
120
|
+
const headers = { ...init.headers, Authorization: `Bearer ${bearer}` };
|
|
121
|
+
if (init.body && !('Content-Type' in headers))
|
|
122
|
+
headers['Content-Type'] = 'application/json';
|
|
123
|
+
const res = await doFetch(`${base}${path}`, {
|
|
124
|
+
method: init.method,
|
|
125
|
+
body: init.body,
|
|
126
|
+
headers,
|
|
127
|
+
...(init.signal ? { signal: init.signal } : {}),
|
|
128
|
+
});
|
|
129
|
+
return readResponse(res, path);
|
|
130
|
+
};
|
|
131
|
+
/**
|
|
132
|
+
* Multipart sibling of `json()`: same auth, same error envelope, but the body is a `FormData`
|
|
133
|
+
* and the SDK deliberately does NOT set `Content-Type`. The runtime has to set it, because
|
|
134
|
+
* only the runtime knows the boundary it generated — hand-writing
|
|
135
|
+
* `multipart/form-data` yourself produces a body the server cannot parse.
|
|
136
|
+
*/
|
|
137
|
+
const multipart = async (path, form, init = {}) => {
|
|
138
|
+
const headers = { ...init.headers, Authorization: `Bearer ${bearer}` };
|
|
139
|
+
const res = await doFetch(`${base}${path}`, { method: init.method ?? 'POST', body: form, headers });
|
|
140
|
+
return readResponse(res, path);
|
|
141
|
+
};
|
|
142
|
+
const agents = (id) => `/agents/${encodeURIComponent(id)}`;
|
|
143
|
+
const sessions = (id) => `${agents(id)}/sessions`;
|
|
144
|
+
const schedules = (id) => `${agents(id)}/schedules`;
|
|
145
|
+
const environments = (id) => `/environments/${encodeURIComponent(id)}`;
|
|
146
|
+
const query = (params) => {
|
|
147
|
+
const q = new URLSearchParams();
|
|
148
|
+
for (const [k, v] of Object.entries(params))
|
|
149
|
+
if (v !== undefined)
|
|
150
|
+
q.set(k, String(v));
|
|
151
|
+
const qs = q.toString();
|
|
152
|
+
return qs ? `?${qs}` : '';
|
|
153
|
+
};
|
|
154
|
+
/**
|
|
155
|
+
* A zip from any of the three shapes callers actually hold. `Blob` exists in every runtime we
|
|
156
|
+
* target, so it is the one currency `FormData` always accepts.
|
|
157
|
+
*
|
|
158
|
+
* The cast is a typings artefact, not a runtime risk: lib.dom narrows `BlobPart`'s view branch
|
|
159
|
+
* to `ArrayBufferView<ArrayBuffer>`, which excludes a plain `Uint8Array` (whose buffer is
|
|
160
|
+
* `ArrayBufferLike`) — i.e. exactly what `fs.readFile` hands you.
|
|
161
|
+
*/
|
|
162
|
+
const zipBlob = (zip) => zip instanceof Blob ? zip : new Blob([zip], { type: 'application/zip' });
|
|
163
|
+
const skillForm = (zip, opts) => {
|
|
164
|
+
const form = new FormData();
|
|
165
|
+
// `files[]` is the field name the Skills API expects — it is isomorphic to the Claude
|
|
166
|
+
// Skills API, and exactly one zip goes in it.
|
|
167
|
+
form.append('files[]', zipBlob(zip), opts.fileName ?? 'skill.zip');
|
|
168
|
+
if (opts.description !== undefined)
|
|
169
|
+
form.append('description', opts.description);
|
|
170
|
+
return form;
|
|
171
|
+
};
|
|
172
|
+
/** Interruptible sleep, so an aborted wait does not linger for the rest of its interval. */
|
|
173
|
+
const sleep = (ms, signal) => new Promise((resolve) => {
|
|
174
|
+
// Check first: `addEventListener('abort')` never fires on an ALREADY-aborted signal, so
|
|
175
|
+
// without this an abort that landed during the preceding request would sleep the whole
|
|
176
|
+
// interval before anyone noticed it.
|
|
177
|
+
if (signal?.aborted) {
|
|
178
|
+
resolve();
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
let timer;
|
|
182
|
+
const done = () => {
|
|
183
|
+
if (timer !== undefined)
|
|
184
|
+
clearTimeout(timer);
|
|
185
|
+
signal?.removeEventListener('abort', done);
|
|
186
|
+
resolve();
|
|
187
|
+
};
|
|
188
|
+
timer = setTimeout(done, ms);
|
|
189
|
+
signal?.addEventListener('abort', done, { once: true });
|
|
190
|
+
});
|
|
191
|
+
/**
|
|
192
|
+
* `owner_uid` + `org_id` for the artifacts family, derived once per agent from its own
|
|
193
|
+
* projection. The engine requires both selectors on every artifact route and re-checks them
|
|
194
|
+
* against the agent's ownership; the gateway forwards the caller's query verbatim there
|
|
195
|
+
* (it injects selectors only on `GET /agents`), so the SDK has to supply them — and the
|
|
196
|
+
* projection's `ownership` is the one source a key-holder always has. Ownership is
|
|
197
|
+
* immutable for the life of an agent, so the cache never needs invalidating.
|
|
198
|
+
*/
|
|
199
|
+
const ownershipByAgent = new Map();
|
|
200
|
+
const artifactSelectors = async (agentId) => {
|
|
201
|
+
const hit = ownershipByAgent.get(agentId);
|
|
202
|
+
if (hit)
|
|
203
|
+
return hit;
|
|
204
|
+
const projection = await json(agents(agentId));
|
|
205
|
+
const own = projection.ownership;
|
|
206
|
+
if (!own?.owner_uid || !own.org_id) {
|
|
207
|
+
throw new ZooworkError(500, `agent ${agentId} projection carries no ownership — cannot derive the owner_uid/org_id ` +
|
|
208
|
+
'selectors the artifact routes require', 'ownership_unavailable');
|
|
209
|
+
}
|
|
210
|
+
const sel = { owner_uid: own.owner_uid, org_id: own.org_id };
|
|
211
|
+
ownershipByAgent.set(agentId, sel);
|
|
212
|
+
return sel;
|
|
213
|
+
};
|
|
214
|
+
const client = {
|
|
215
|
+
listModels: async () => {
|
|
216
|
+
const data = await json('/models');
|
|
217
|
+
return Array.isArray(data) ? data : (data.models ?? []);
|
|
218
|
+
},
|
|
219
|
+
createAgent: (input, idempotencyKey) => {
|
|
220
|
+
// Runtime strip, not just type-level: `warm` races the platform credential seeding
|
|
221
|
+
// (sandbox born without creds, never heals — zooclaw-engine#791), and the BOOTSTRAP
|
|
222
|
+
// interview is never what an API caller wants. JS callers bypass the types, so both
|
|
223
|
+
// are enforced here.
|
|
224
|
+
const { warm: _warm, onboarding: _onboarding, ...resource } = input.resource;
|
|
225
|
+
return json('/agents', {
|
|
226
|
+
method: 'POST',
|
|
227
|
+
body: JSON.stringify({
|
|
228
|
+
resource: { ...resource, onboarding: false },
|
|
229
|
+
...(input.ownership ? { ownership: input.ownership } : {}),
|
|
230
|
+
}),
|
|
231
|
+
...(idempotencyKey ? { headers: { 'Idempotency-Key': idempotencyKey } } : {}),
|
|
232
|
+
});
|
|
233
|
+
},
|
|
234
|
+
listAgents: async (opts = {}) => {
|
|
235
|
+
const params = { page: opts.page };
|
|
236
|
+
for (const [k, v] of Object.entries(opts.labels ?? {}))
|
|
237
|
+
params[`label.${k}`] = v;
|
|
238
|
+
const data = await json(`/agents${query(params)}`);
|
|
239
|
+
return data.agents ?? [];
|
|
240
|
+
},
|
|
241
|
+
getAgent: (agentId) => json(agents(agentId)),
|
|
242
|
+
updateAgent: (agentId, sections) => json(agents(agentId), { method: 'PUT', body: JSON.stringify(sections) }),
|
|
243
|
+
deleteAgent: async (agentId) => {
|
|
244
|
+
await json(agents(agentId), { method: 'DELETE' });
|
|
245
|
+
},
|
|
246
|
+
startAgent: async (agentId) => {
|
|
247
|
+
const data = await json(`${agents(agentId)}/start`, { method: 'POST' });
|
|
248
|
+
return { warnings: data.warnings ?? [] };
|
|
249
|
+
},
|
|
250
|
+
stopAgent: async (agentId) => {
|
|
251
|
+
const data = await json(`${agents(agentId)}/stop`, { method: 'POST' });
|
|
252
|
+
return { warnings: data.warnings ?? [] };
|
|
253
|
+
},
|
|
254
|
+
waitUntilRunning: async (agentId, opts = {}) => {
|
|
255
|
+
const timeoutMs = opts.timeoutMs ?? 30_000;
|
|
256
|
+
const intervalMs = opts.intervalMs ?? 500;
|
|
257
|
+
const deadline = Date.now() + timeoutMs;
|
|
258
|
+
let lastSeen = 'unknown';
|
|
259
|
+
const abortedError = () => new ZooworkError(0, `waitUntilRunning(${agentId}) aborted`, 'aborted');
|
|
260
|
+
const timeoutError = () => new ZooworkError(408, `agent ${agentId} did not reach status.desired_state=running within ${timeoutMs}ms ` +
|
|
261
|
+
`(last seen: ${lastSeen})`, 'timeout');
|
|
262
|
+
for (;;) {
|
|
263
|
+
if (opts.signal?.aborted)
|
|
264
|
+
throw abortedError();
|
|
265
|
+
const remaining = deadline - Date.now();
|
|
266
|
+
if (remaining <= 0)
|
|
267
|
+
throw timeoutError();
|
|
268
|
+
// THE POLL ITSELF IS BOUNDED, not just the gap between polls. `fetch` has no default
|
|
269
|
+
// timeout anywhere we run, so a gateway that accepts the connection and then stalls
|
|
270
|
+
// would otherwise park this promise forever — outliving both `timeoutMs` and the
|
|
271
|
+
// caller's `signal`, which is the exact never-returning readiness loop this helper
|
|
272
|
+
// exists to prevent. The per-request signal fires on whichever comes first.
|
|
273
|
+
const poll = new AbortController();
|
|
274
|
+
const cancelPoll = () => poll.abort();
|
|
275
|
+
opts.signal?.addEventListener('abort', cancelPoll, { once: true });
|
|
276
|
+
const budget = setTimeout(cancelPoll, remaining);
|
|
277
|
+
let agent;
|
|
278
|
+
try {
|
|
279
|
+
agent = await json(agents(agentId), { signal: poll.signal });
|
|
280
|
+
}
|
|
281
|
+
catch (e) {
|
|
282
|
+
// Our own cancellation surfaces as a fetch AbortError; translate it into the two
|
|
283
|
+
// outcomes this method documents instead of leaking a DOMException.
|
|
284
|
+
if (poll.signal.aborted)
|
|
285
|
+
throw opts.signal?.aborted ? abortedError() : timeoutError();
|
|
286
|
+
throw e;
|
|
287
|
+
}
|
|
288
|
+
finally {
|
|
289
|
+
clearTimeout(budget);
|
|
290
|
+
opts.signal?.removeEventListener('abort', cancelPoll);
|
|
291
|
+
}
|
|
292
|
+
// desired_state, never actual_state — see the doc comment on this method.
|
|
293
|
+
lastSeen = agent.status?.desired_state ?? 'unknown';
|
|
294
|
+
if (lastSeen === 'running')
|
|
295
|
+
return agent;
|
|
296
|
+
if (Date.now() + intervalMs > deadline)
|
|
297
|
+
throw timeoutError();
|
|
298
|
+
await sleep(intervalMs, opts.signal);
|
|
299
|
+
}
|
|
300
|
+
},
|
|
301
|
+
listAgentSkills: async (agentId, opts = {}) => {
|
|
302
|
+
const data = await json(`${agents(agentId)}/skills${opts.verbose ? '?verbose=true' : ''}`);
|
|
303
|
+
return data.skills ?? [];
|
|
304
|
+
},
|
|
305
|
+
putAgentSkill: (agentId, skillId, opts = {}) => json(`${agents(agentId)}/skills/${encodeURIComponent(skillId)}`, {
|
|
306
|
+
method: 'PUT',
|
|
307
|
+
body: JSON.stringify({ enabled: opts.enabled ?? true, version_pin: opts.versionPin ?? null }),
|
|
308
|
+
}),
|
|
309
|
+
deleteAgentSkill: async (agentId, skillId) => {
|
|
310
|
+
await json(`${agents(agentId)}/skills/${encodeURIComponent(skillId)}`, { method: 'DELETE' });
|
|
311
|
+
},
|
|
312
|
+
listChannels: async (agentId) => {
|
|
313
|
+
const data = await json(`${agents(agentId)}/channels`);
|
|
314
|
+
return data.channels ?? [];
|
|
315
|
+
},
|
|
316
|
+
addChannel: (agentId, input) => json(`${agents(agentId)}/channels`, { method: 'POST', body: JSON.stringify(input) }),
|
|
317
|
+
updateChannel: (agentId, platform, input = {}) => json(`${agents(agentId)}/channels/${encodeURIComponent(platform)}/update`, {
|
|
318
|
+
method: 'POST',
|
|
319
|
+
body: JSON.stringify(input),
|
|
320
|
+
}),
|
|
321
|
+
removeChannel: async (agentId, platform, opts = {}) => {
|
|
322
|
+
await json(`${agents(agentId)}/channels/${encodeURIComponent(platform)}/remove`, {
|
|
323
|
+
method: 'POST',
|
|
324
|
+
body: JSON.stringify({ account: opts.account ?? 'default' }),
|
|
325
|
+
});
|
|
326
|
+
},
|
|
327
|
+
startFeishuSetup: (agentId, input = {}) => json(`${agents(agentId)}/channels/feishu/setup`, { method: 'POST', body: JSON.stringify(input) }),
|
|
328
|
+
pollFeishuSetup: (agentId, sessionId) => json(`${agents(agentId)}/channels/feishu/poll${query({ session_id: sessionId })}`),
|
|
329
|
+
cancelFeishuSetup: async (agentId, sessionId) => {
|
|
330
|
+
await json(`${agents(agentId)}/channels/feishu/setup/cancel${query({ session_id: sessionId })}`, {
|
|
331
|
+
method: 'POST',
|
|
332
|
+
});
|
|
333
|
+
},
|
|
334
|
+
waitForFeishuSetup: async (agentId, sessionId, opts = {}) => {
|
|
335
|
+
const timeoutMs = opts.timeoutMs ?? 600_000;
|
|
336
|
+
const deadline = Date.now() + timeoutMs;
|
|
337
|
+
let lastStatus = 'unknown';
|
|
338
|
+
const abortedError = () => new ZooworkError(0, `waitForFeishuSetup(${agentId}, ${sessionId}) aborted`, 'aborted');
|
|
339
|
+
const timeoutError = () => new ZooworkError(408, `Feishu setup session ${sessionId} still '${lastStatus}' after ${timeoutMs}ms — ` +
|
|
340
|
+
'the QR may simply not have been scanned yet; the session itself expires server-side', 'timeout');
|
|
341
|
+
for (;;) {
|
|
342
|
+
if (opts.signal?.aborted)
|
|
343
|
+
throw abortedError();
|
|
344
|
+
const remaining = deadline - Date.now();
|
|
345
|
+
if (remaining <= 0)
|
|
346
|
+
throw timeoutError();
|
|
347
|
+
// Same in-flight bound as waitUntilRunning: without it, a stalled gateway would park
|
|
348
|
+
// this promise past both the budget and the caller's signal.
|
|
349
|
+
const poll = new AbortController();
|
|
350
|
+
const cancelPoll = () => poll.abort();
|
|
351
|
+
opts.signal?.addEventListener('abort', cancelPoll, { once: true });
|
|
352
|
+
const budget = setTimeout(cancelPoll, remaining);
|
|
353
|
+
let result;
|
|
354
|
+
try {
|
|
355
|
+
result = await json(`${agents(agentId)}/channels/feishu/poll${query({ session_id: sessionId })}`, { signal: poll.signal });
|
|
356
|
+
}
|
|
357
|
+
catch (e) {
|
|
358
|
+
if (poll.signal.aborted)
|
|
359
|
+
throw opts.signal?.aborted ? abortedError() : timeoutError();
|
|
360
|
+
throw e;
|
|
361
|
+
}
|
|
362
|
+
finally {
|
|
363
|
+
clearTimeout(budget);
|
|
364
|
+
opts.signal?.removeEventListener('abort', cancelPoll);
|
|
365
|
+
}
|
|
366
|
+
opts.onPoll?.(result);
|
|
367
|
+
lastStatus = result.status ?? 'unknown';
|
|
368
|
+
// Only a literal 'pending' keeps the loop alive… except that an UNKNOWN status is
|
|
369
|
+
// treated as still-in-flight too (see FeishuPollResult): a new intermediate state on
|
|
370
|
+
// the server should stretch the wait, not end it with a fake terminal result.
|
|
371
|
+
const terminal = ['success', 'expired', 'denied', 'error'].includes(lastStatus);
|
|
372
|
+
if (terminal)
|
|
373
|
+
return result;
|
|
374
|
+
// Server semantics: poll_interval is in SECONDS. The floor only guards a 0/negative
|
|
375
|
+
// value from ever busy-looping the gateway.
|
|
376
|
+
const intervalMs = Math.max(250, (result.poll_interval ?? 5) * 1000);
|
|
377
|
+
if (Date.now() + intervalMs > deadline)
|
|
378
|
+
throw timeoutError();
|
|
379
|
+
await sleep(intervalMs, opts.signal);
|
|
380
|
+
}
|
|
381
|
+
},
|
|
382
|
+
uploadSkill: (zip, opts) => {
|
|
383
|
+
const form = skillForm(zip, opts);
|
|
384
|
+
form.append('scope', opts.scope);
|
|
385
|
+
return multipart('/skills', form, {
|
|
386
|
+
...(opts.idempotencyKey ? { headers: { 'Idempotency-Key': opts.idempotencyKey } } : {}),
|
|
387
|
+
});
|
|
388
|
+
},
|
|
389
|
+
uploadSkillVersion: (skillId, zip, opts = {}) => multipart(`/skills/${encodeURIComponent(skillId)}/versions`, skillForm(zip, opts), {
|
|
390
|
+
...(opts.idempotencyKey ? { headers: { 'Idempotency-Key': opts.idempotencyKey } } : {}),
|
|
391
|
+
}),
|
|
392
|
+
listSkills: async (opts = {}) => {
|
|
393
|
+
const data = await json(`/skills${query({ scope: opts.scope, q: opts.q, page: opts.page })}`);
|
|
394
|
+
return data.skills ?? [];
|
|
395
|
+
},
|
|
396
|
+
deleteSkill: async (skillId) => {
|
|
397
|
+
await json(`/skills/${encodeURIComponent(skillId)}`, { method: 'DELETE' });
|
|
398
|
+
},
|
|
399
|
+
createSession: (agentId, input, idempotencyKey) => json(sessions(agentId), {
|
|
400
|
+
method: 'POST',
|
|
401
|
+
body: JSON.stringify(input),
|
|
402
|
+
...(idempotencyKey ? { headers: { 'Idempotency-Key': idempotencyKey } } : {}),
|
|
403
|
+
}),
|
|
404
|
+
getSession: (agentId, sessionId, opts = {}) => {
|
|
405
|
+
const q = new URLSearchParams();
|
|
406
|
+
if (opts.history)
|
|
407
|
+
q.set('history', 'true');
|
|
408
|
+
if (opts.limit !== undefined)
|
|
409
|
+
q.set('limit', String(opts.limit));
|
|
410
|
+
const qs = q.toString();
|
|
411
|
+
return json(`${sessions(agentId)}/${encodeURIComponent(sessionId)}${qs ? `?${qs}` : ''}`);
|
|
412
|
+
},
|
|
413
|
+
listSessions: async (agentId, opts = {}) => {
|
|
414
|
+
const data = await json(`${sessions(agentId)}${query({ page: opts.page })}`);
|
|
415
|
+
return data.sessions ?? [];
|
|
416
|
+
},
|
|
417
|
+
archiveSession: async (agentId, sessionId) => {
|
|
418
|
+
const data = await json(`${sessions(agentId)}/${encodeURIComponent(sessionId)}/archive`, { method: 'POST' });
|
|
419
|
+
return { ...data, archived: data.archived ?? false };
|
|
420
|
+
},
|
|
421
|
+
deleteSession: async (agentId, sessionId) => {
|
|
422
|
+
await json(`${sessions(agentId)}/${encodeURIComponent(sessionId)}`, { method: 'DELETE' });
|
|
423
|
+
},
|
|
424
|
+
postEvents: async (agentId, sessionId, events) => {
|
|
425
|
+
const data = await json(`${sessions(agentId)}/${encodeURIComponent(sessionId)}/events`, { method: 'POST', body: JSON.stringify({ events }) });
|
|
426
|
+
return { events: data.events ?? [] };
|
|
427
|
+
},
|
|
428
|
+
listEvents: async (agentId, sessionId, opts = {}) => (await client.listEventsPage(agentId, sessionId, opts)).events,
|
|
429
|
+
listEventsPage: async (agentId, sessionId, opts = {}) => {
|
|
430
|
+
const data = await json(`${sessions(agentId)}/${encodeURIComponent(sessionId)}/events${query({
|
|
431
|
+
after: opts.after,
|
|
432
|
+
cursor: opts.cursor,
|
|
433
|
+
types: opts.types?.join(','),
|
|
434
|
+
limit: opts.limit,
|
|
435
|
+
})}`);
|
|
436
|
+
return {
|
|
437
|
+
events: (data.events ?? []).map((e) => normalizeEvent(e)),
|
|
438
|
+
...(data.has_more !== undefined ? { hasMore: data.has_more } : {}),
|
|
439
|
+
...(data.next_cursor !== undefined ? { nextCursor: data.next_cursor } : {}),
|
|
440
|
+
};
|
|
441
|
+
},
|
|
442
|
+
listAllEvents: async (agentId, sessionId, opts = {}) => {
|
|
443
|
+
const pageSize = Math.min(Math.max(opts.pageSize ?? 500, 1), 500);
|
|
444
|
+
const out = [];
|
|
445
|
+
// Unified lane (default): follow the server's cursor until has_more is false.
|
|
446
|
+
if (opts.after === undefined) {
|
|
447
|
+
let pageCursor;
|
|
448
|
+
for (;;) {
|
|
449
|
+
const page = await client.listEventsPage(agentId, sessionId, {
|
|
450
|
+
...(pageCursor !== undefined ? { cursor: pageCursor } : {}),
|
|
451
|
+
...(opts.types ? { types: opts.types } : {}),
|
|
452
|
+
limit: pageSize,
|
|
453
|
+
});
|
|
454
|
+
// A cursor that fails to advance means the same page again — stop before
|
|
455
|
+
// re-appending it rather than refetching it forever.
|
|
456
|
+
if (page.hasMore !== undefined && page.nextCursor === pageCursor)
|
|
457
|
+
return out;
|
|
458
|
+
out.push(...page.events);
|
|
459
|
+
if (page.hasMore === undefined) {
|
|
460
|
+
// Before the first cursor this is a server without cursor pagination: a short
|
|
461
|
+
// page already ends the history, a full one continues on the `after` walk below.
|
|
462
|
+
// Mid-walk it is a protocol violation — return what we have rather than silently
|
|
463
|
+
// switching to the engine-only lane and dropping input events.
|
|
464
|
+
if (pageCursor !== undefined || page.events.length < pageSize)
|
|
465
|
+
return out;
|
|
466
|
+
break;
|
|
467
|
+
}
|
|
468
|
+
if (!page.hasMore || !page.nextCursor || page.events.length === 0)
|
|
469
|
+
return out;
|
|
470
|
+
pageCursor = page.nextCursor;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
let cursor = opts.after ?? out.reduce((max, e) => (e.seq > max ? e.seq : max), 0);
|
|
474
|
+
for (;;) {
|
|
475
|
+
const page = await client.listEvents(agentId, sessionId, {
|
|
476
|
+
after: cursor,
|
|
477
|
+
...(opts.types ? { types: opts.types } : {}),
|
|
478
|
+
limit: pageSize,
|
|
479
|
+
});
|
|
480
|
+
// Anything at or below the cursor is a boundary replay — or a server that ignored
|
|
481
|
+
// `after`. Dropping it keeps the result deduplicated AND the walk finite.
|
|
482
|
+
const fresh = cursor > 0 ? page.filter((e) => e.seq > cursor) : page;
|
|
483
|
+
out.push(...fresh);
|
|
484
|
+
const highest = fresh.reduce((max, e) => (e.seq > max ? e.seq : max), cursor);
|
|
485
|
+
if (page.length < pageSize || highest <= cursor)
|
|
486
|
+
return out;
|
|
487
|
+
cursor = highest;
|
|
488
|
+
}
|
|
489
|
+
},
|
|
490
|
+
async *streamEvents(agentId, sessionId, opts = {}) {
|
|
491
|
+
const after = opts.after ?? 0;
|
|
492
|
+
let lastSeq = after;
|
|
493
|
+
const qs = query(opts.cursor !== undefined ? { cursor: opts.cursor } : { after: after > 0 ? after : undefined });
|
|
494
|
+
const path = `${sessions(agentId)}/${encodeURIComponent(sessionId)}/events/stream${qs}`;
|
|
495
|
+
try {
|
|
496
|
+
const res = await doFetch(`${base}${path}`, {
|
|
497
|
+
headers: { Authorization: `Bearer ${bearer}`, Accept: 'text/event-stream' },
|
|
498
|
+
...(opts.signal ? { signal: opts.signal } : {}),
|
|
499
|
+
});
|
|
500
|
+
if (!res.ok)
|
|
501
|
+
throw new ZooworkError(res.status, `events stream HTTP ${res.status}`);
|
|
502
|
+
if (!res.body)
|
|
503
|
+
return;
|
|
504
|
+
for await (const msg of parseSSE(res.body)) {
|
|
505
|
+
if (msg.event === 'event_delta')
|
|
506
|
+
continue;
|
|
507
|
+
if (!isObj(msg.data))
|
|
508
|
+
continue;
|
|
509
|
+
const ev = normalizeEvent(msg.data, msg.id);
|
|
510
|
+
// The server already resumes from `after`; this guards the boundary event being
|
|
511
|
+
// replayed when a dropped connection is re-established.
|
|
512
|
+
if (ev.seq >= 0 && ev.seq <= lastSeq)
|
|
513
|
+
continue;
|
|
514
|
+
if (ev.seq > lastSeq)
|
|
515
|
+
lastSeq = ev.seq;
|
|
516
|
+
yield ev;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
catch (e) {
|
|
520
|
+
if (opts.signal?.aborted)
|
|
521
|
+
return;
|
|
522
|
+
throw e;
|
|
523
|
+
}
|
|
524
|
+
},
|
|
525
|
+
listApprovals: async (agentId, opts = {}) => {
|
|
526
|
+
const data = await json(`${agents(agentId)}/approvals${query({ status: opts.status })}`);
|
|
527
|
+
return data.approvals ?? [];
|
|
528
|
+
},
|
|
529
|
+
resolveApproval: (agentId, approvalId, input) => json(`${agents(agentId)}/approvals/${encodeURIComponent(approvalId)}/resolve`, {
|
|
530
|
+
method: 'POST',
|
|
531
|
+
body: JSON.stringify(input),
|
|
532
|
+
}),
|
|
533
|
+
getSystemPrompt: (agentId) => json(`${agents(agentId)}/system-prompt`),
|
|
534
|
+
// The colon goes RAW here: this family matches the literal `system-prompt:preview` segment,
|
|
535
|
+
// and both spellings pass the gateway (verified 2026-08-14) — so raw also serves the
|
|
536
|
+
// deployment-internal direct mode, whose HTTP server never percent-decodes the path.
|
|
537
|
+
previewSystemPrompt: (agentId, input) => json(`${agents(agentId)}/system-prompt:preview`, { method: 'POST', body: JSON.stringify(input) }),
|
|
538
|
+
upgradeSystemPrompt: (agentId, input) => json(`${agents(agentId)}:upgrade-system-prompt`, { method: 'POST', body: JSON.stringify(input) }),
|
|
539
|
+
listArtifacts: async (agentId, opts = {}) => {
|
|
540
|
+
const sel = await artifactSelectors(agentId);
|
|
541
|
+
const data = await json(`${agents(agentId)}/artifacts${query({
|
|
542
|
+
...sel,
|
|
543
|
+
page: opts.page,
|
|
544
|
+
limit: opts.limit,
|
|
545
|
+
session_id: opts.sessionId,
|
|
546
|
+
source_path: opts.sourcePath,
|
|
547
|
+
created_before: opts.createdBefore,
|
|
548
|
+
})}`);
|
|
549
|
+
return { ...data, artifacts: data.artifacts ?? [] };
|
|
550
|
+
},
|
|
551
|
+
getArtifact: async (agentId, artifactId) => {
|
|
552
|
+
const sel = await artifactSelectors(agentId);
|
|
553
|
+
return json(`${agents(agentId)}/artifacts/${encodeURIComponent(artifactId)}${query({ ...sel })}`);
|
|
554
|
+
},
|
|
555
|
+
downloadArtifact: async (agentId, artifactId) => {
|
|
556
|
+
const sel = await artifactSelectors(agentId);
|
|
557
|
+
return json(`${agents(agentId)}/artifacts/${encodeURIComponent(artifactId)}:download${query({ ...sel })}`, {
|
|
558
|
+
method: 'POST',
|
|
559
|
+
});
|
|
560
|
+
},
|
|
561
|
+
deleteArtifact: async (agentId, artifactId) => {
|
|
562
|
+
const sel = await artifactSelectors(agentId);
|
|
563
|
+
return json(`${agents(agentId)}/artifacts/${encodeURIComponent(artifactId)}${query({ ...sel })}`, {
|
|
564
|
+
method: 'DELETE',
|
|
565
|
+
});
|
|
566
|
+
},
|
|
567
|
+
listSchedules: async (agentId) => {
|
|
568
|
+
const data = await json(schedules(agentId));
|
|
569
|
+
return data.schedules ?? [];
|
|
570
|
+
},
|
|
571
|
+
createSchedule: (agentId, input, idempotencyKey) => json(schedules(agentId), {
|
|
572
|
+
method: 'POST',
|
|
573
|
+
body: JSON.stringify(input),
|
|
574
|
+
...(idempotencyKey ? { headers: { 'Idempotency-Key': idempotencyKey } } : {}),
|
|
575
|
+
}),
|
|
576
|
+
getSchedule: (agentId, scheduleId) => json(`${schedules(agentId)}/${encodeURIComponent(scheduleId)}`),
|
|
577
|
+
updateSchedule: (agentId, scheduleId, update) => {
|
|
578
|
+
// Stripped rather than forwarded. Every one of these is a field `getSchedule()` hands you
|
|
579
|
+
// and the PUT refuses: four are `server-derived` 400s, `sessionTarget` is an `immutable`
|
|
580
|
+
// 400, and `scheduleSpec` is worse than a 400 — it is accepted and ignored, so echoing a
|
|
581
|
+
// read back would answer 200 while quietly dropping the cadence change. The types already
|
|
582
|
+
// refuse all six; this is what makes the same round trip work from JavaScript.
|
|
583
|
+
const { sessionTarget: _immutable, scheduleSpec: _readShape, execution: _derived1, originMetadata: _derived2, contextSnapshot: _derived3, creatorPrincipalRef: _derived4, ...body } = update;
|
|
584
|
+
return json(`${schedules(agentId)}/${encodeURIComponent(scheduleId)}`, {
|
|
585
|
+
method: 'PUT',
|
|
586
|
+
body: JSON.stringify(body),
|
|
587
|
+
});
|
|
588
|
+
},
|
|
589
|
+
deleteSchedule: async (agentId, scheduleId) => {
|
|
590
|
+
await json(`${schedules(agentId)}/${encodeURIComponent(scheduleId)}`, { method: 'DELETE' });
|
|
591
|
+
},
|
|
592
|
+
triggerSchedule: async (agentId, scheduleId) => {
|
|
593
|
+
const data = await json(`${schedules(agentId)}/${encodeURIComponent(scheduleId)}/trigger`, { method: 'POST' });
|
|
594
|
+
return { ...data, triggered: data.triggered ?? false };
|
|
595
|
+
},
|
|
596
|
+
listScheduleRuns: async (agentId, scheduleId, opts = {}) => {
|
|
597
|
+
const data = await json(`${schedules(agentId)}/${encodeURIComponent(scheduleId)}/runs${query({ limit: opts.limit })}`);
|
|
598
|
+
return data.runs ?? [];
|
|
599
|
+
},
|
|
600
|
+
wake: (agentId, input) => json(`${agents(agentId)}/wake`, { method: 'POST', body: JSON.stringify(input) }),
|
|
601
|
+
exec: (agentId, args) => json(`${agents(agentId)}/exec`, { method: 'POST', body: JSON.stringify({ args }) }),
|
|
602
|
+
listEnvironments: async (opts = {}) => {
|
|
603
|
+
const data = await json(`/environments${query({ page: opts.page })}`);
|
|
604
|
+
return data.environments ?? [];
|
|
605
|
+
},
|
|
606
|
+
getEnvironment: (environmentId) => json(environments(environmentId)),
|
|
607
|
+
createEnvironment: (input, idempotencyKey) => json('/environments', {
|
|
608
|
+
method: 'POST',
|
|
609
|
+
body: JSON.stringify(input),
|
|
610
|
+
...(idempotencyKey ? { headers: { 'Idempotency-Key': idempotencyKey } } : {}),
|
|
611
|
+
}),
|
|
612
|
+
// `%3A`, never a literal ':' — the engine misses the route on a raw colon and answers 404.
|
|
613
|
+
archiveEnvironment: (environmentId) => json(`/environments/${encodeURIComponent(environmentId)}%3Aarchive`, { method: 'POST' }),
|
|
614
|
+
createEnvironmentVersion: (environmentId, config, idempotencyKey) => json(`${environments(environmentId)}/versions`, {
|
|
615
|
+
method: 'POST',
|
|
616
|
+
body: JSON.stringify({ resource: { config } }),
|
|
617
|
+
...(idempotencyKey ? { headers: { 'Idempotency-Key': idempotencyKey } } : {}),
|
|
618
|
+
}),
|
|
619
|
+
getEnvironmentVersion: (environmentId, version) => json(`${environments(environmentId)}/versions/${encodeURIComponent(String(version))}`),
|
|
620
|
+
};
|
|
621
|
+
return client;
|
|
622
|
+
}
|