@zeyos/client 0.2.0 → 0.3.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.
@@ -253,6 +253,26 @@ const page2 = await client.api.listTickets({
253
253
  Use `count: true` to get the total number of matching records without fetching the full dataset. This is useful for building pagination controls.
254
254
  :::
255
255
 
256
+ ### Auto-pagination
257
+
258
+ To iterate an entire result set without managing `offset` yourself, use `client.paginate(operationId, input, opts)` — an async iterator that pages until a short/empty page (or `opts.max`) is reached. It removes the off-by-one and "I only got the first 1000 / 50 rows" mistakes the list caps otherwise invite.
259
+
260
+ ```js
261
+ // Stream every matching ticket, one page at a time
262
+ for await (const ticket of client.paginate('listTickets', { filters: { visibility: 0 } }, { pageSize: 1000 })) {
263
+ process(ticket);
264
+ }
265
+
266
+ // Eagerly collect up to a cap
267
+ const recent = await client.collect(
268
+ 'listTickets',
269
+ { filters: { visibility: 0 }, sort: ['-lastmodified'] },
270
+ { pageSize: 500, max: 2000 }
271
+ );
272
+ ```
273
+
274
+ `opts`: `pageSize` (default 1000, clamped to the server max of 10000), `max` (stop after N records), and `requestOptions` (forwarded to each underlying call, e.g. `{ signal, timeoutMs }`). `collect` is the eager array form of `paginate`.
275
+
256
276
  ## Normalising List Responses
257
277
 
258
278
  List endpoints are not uniform across the full surface area. Depending on the endpoint and response mode, you may see:
@@ -432,9 +452,32 @@ const noRetry = createZeyosClient({ platform: 'live', instance: 'demo', retry: f
432
452
  Only `429`/`503` are retried by default -- statuses that clearly mean "try again later". `5xx` codes such as `500`/`502` are **not** retried automatically, to avoid re-applying a non-idempotent write that may have partially succeeded. Add them to `retryOn` only for read-heavy workloads.
433
453
  :::
434
454
 
455
+ ### Request timeout
456
+
457
+ A request with no built-in deadline can hang indefinitely if the connection stalls (e.g. an instance restarting). Set `timeoutMs` to bound each attempt; it composes with any `AbortSignal` you pass. The timeout applies **per attempt**, so a retry gets a fresh deadline.
458
+
459
+ ```js
460
+ // Per request
461
+ await client.api.listTickets({ filters: { visibility: 0 } }, { timeoutMs: 8000 });
462
+
463
+ // Or a client-wide default
464
+ const client = createZeyosClient({ platform: 'live', instance: 'demo', timeoutMs: 8000 });
465
+ ```
466
+
467
+ A timeout rejects with an `Error` whose `isTimeout === true` (and `code === 'ETIMEDOUT'`). A timeout is distinct from a caller abort: aborting your own `AbortSignal` always propagates immediately and is never retried.
468
+
469
+ ### Network-error retries
470
+
471
+ Network-level failures (a dropped connection, DNS blip, or a timeout) are retried for **read** operations only — `GET`/`HEAD` plus side-effect-free `list`/`count`/`search` queries — using the same retry budget and backoff. Writes (`create`/`update`/`delete`) are **never** auto-retried on a network error, so a dropped connection can't cause a duplicate mutation. Override per request or per client with `retryOnNetworkError: true | false`.
472
+
473
+ ```js
474
+ await client.api.listTickets({ filters: {} }, { retryOnNetworkError: true }); // force (already on for reads)
475
+ await client.api.createTicket({ name: 'x' }, { retryOnNetworkError: true }); // opt a write in, at your own risk
476
+ ```
477
+
435
478
  ## Error Handling
436
479
 
437
- All API errors are thrown as `ZeyosApiError` instances. This class extends `Error` and includes structured information about the failed request.
480
+ All API errors are thrown as `ZeyosApiError` instances. This class extends `Error` and includes structured information about the failed request. When the server returns an error body with a message, a short snippet of it is folded into `err.message` (e.g. `api.listTickets failed with HTTP 400: unknown filter field: bogus`), so the thrown error says *why*, not just the status code. The full body is always on `err.body`.
438
481
 
439
482
  ```js
440
483
  import { ZeyosApiError } from '@zeyos/client';
@@ -535,6 +578,8 @@ All generated methods and `client.request()` accept an optional second argument
535
578
  | Option | Type | Description |
536
579
  |--------|------|-------------|
537
580
  | `signal` | `AbortSignal` | An `AbortController` signal to cancel the request |
581
+ | `timeoutMs` | `number` | Abort this attempt after N ms (composes with `signal`); also settable client-wide as `timeoutMs` |
582
+ | `retryOnNetworkError` | `boolean` | Force/disable retrying network errors & timeouts for this call (default: on for reads, off for writes) |
538
583
  | `raw` | `boolean` | Return the full response envelope instead of just the data |
539
584
  | `auth` | `string \| { mode?: string, accessToken?: string, access_token?: string, refreshToken?: string, refresh_token?: string, clientId?: string, client_id?: string, clientSecret?: string, client_secret?: string }` | Override the authentication mode or credentials for this request |
540
585
  | `baseUrl` | `string` | Override the base URL for this request |
@@ -14,6 +14,7 @@ These options work with any command:
14
14
  |--------|-------------|
15
15
  | `--json` | Output as formatted JSON |
16
16
  | `--yaml` | Output as YAML |
17
+ | `--profile <name>` | Use a named credential profile for this command |
17
18
  | `--no-color` | Disable ANSI color output |
18
19
  | `-h`, `--help` | Show help for a command |
19
20
 
@@ -101,6 +102,36 @@ zeyos whoami --show-token --json # explicitly include the current access token
101
102
 
102
103
  ---
103
104
 
105
+ ## profile
106
+
107
+ Manage named credential profiles and switch between ZeyOS instances. See [Configuration → Profiles](./03-configuration.md#profiles) for the full model.
108
+
109
+ ```
110
+ zeyos profile <list|current|use|add|remove> [options]
111
+ ```
112
+
113
+ | Command | Description |
114
+ |---------|-------------|
115
+ | `profile list` | List all profiles; the active one is marked `*`, with token status |
116
+ | `profile current` | Show which profile resolves right now, and why (flag/env/pin/active) |
117
+ | `profile use <name>` | Make `<name>` the active profile (global) |
118
+ | `profile use <name> --local` | Pin `<name>` to the current project (`.zeyos/profile`) |
119
+ | `profile add <name> [opts]` | Create/update a profile (`--base-url`, `--client-id`, `--secret`, or `--from-current`) |
120
+ | `profile remove <name>` | Delete a profile |
121
+
122
+ **Examples:**
123
+
124
+ ```bash
125
+ zeyos profile add dev --base-url https://zeyos.cms-it.de/dev
126
+ zeyos profile add prod --from-current # snapshot current credentials
127
+ zeyos login --profile prod # authenticate into & activate a profile
128
+ zeyos profile use dev # switch active profile
129
+ zeyos profile use prod --local # pin to this project
130
+ zeyos list tickets --profile dev # one-off override on any command
131
+ ```
132
+
133
+ ---
134
+
104
135
  ## list
105
136
 
106
137
  Query and list records for a resource with filtering, sorting, and pagination.
@@ -363,7 +394,10 @@ List all curated CLI resources and their operations. This is the authoritative b
363
394
  zeyos resources
364
395
  ```
365
396
 
366
- Shows a table of all CLI-supported resource types and available operations.
397
+ Shows a table of all CLI-supported resource types and available operations. Operational
398
+ workflows can use `actionstep` / `actionsteps` / `time-entries` for follow-ups and
399
+ effort records. Read-only platform schema definitions are available as `customfield` /
400
+ `customfields` with `list`, `get`, and therefore `count` support.
367
401
 
368
402
  ---
369
403
 
@@ -12,15 +12,47 @@ These settings apply to the CLI's curated resource registry. If you need a resou
12
12
 
13
13
  ## Credential Cascade
14
14
 
15
- Credentials are resolved from three sources in priority order:
15
+ The CLI first decides **which credential set** is the base, then lets environment credential variables field-override on top. The base is chosen by the first match in this order:
16
16
 
17
17
  | Priority | Source | Location |
18
18
  |----------|--------|----------|
19
- | 1 (highest) | Environment variables | `ZEYOS_BASE_URL`, `ZEYOS_TOKEN`, etc. |
20
- | 2 | Local config file | `.zeyos/auth.json` (walks up from CWD) |
21
- | 3 (lowest) | Global config file | `~/.config/zeyos/credentials.json` |
19
+ | 1 (highest) | `--profile <name>` flag | named profile (per command) |
20
+ | 2 | `ZEYOS_PROFILE` env var | named profile |
21
+ | 3 | Project pin | `.zeyos/profile` (walks up from CWD) |
22
+ | 4 | Local config file | `.zeyos/auth.json` (walks up from CWD) |
23
+ | 5 | Global active profile | `~/.config/zeyos/profiles.json` (`active`) |
24
+ | 6 (lowest) | Global config file | `~/.config/zeyos/credentials.json` |
22
25
 
23
- Configuration is merged from global, then local, then environment variables. Higher-priority sources override individual fields from lower-priority sources. For example, setting `ZEYOS_TOKEN` as an environment variable overrides the stored token while still allowing `baseUrl` and client credentials to come from a config file.
26
+ On top of the chosen base, the credential **environment variables** (`ZEYOS_BASE_URL`, `ZEYOS_TOKEN`, …) always override individual fields so you can keep `baseUrl` and client credentials in a profile while overriding just the token via `ZEYOS_TOKEN` in CI.
27
+
28
+ If you have never created a profile, nothing changes: the CLI falls through to the legacy local `.zeyos/auth.json` and global `credentials.json` exactly as before.
29
+
30
+ ## Profiles
31
+
32
+ Profiles let you store several ZeyOS instances (e.g. `dev`, `prod`, `client-x`) and switch between them without re-running `login`. Each profile is a full credential set (URL, OAuth app, tokens) kept in `~/.config/zeyos/profiles.json`, with one marked **active**.
33
+
34
+ ```bash
35
+ # Create profiles (connection params now; tokens via login)
36
+ zeyos profile add dev --base-url https://zeyos.cms-it.de/dev
37
+ zeyos profile add prod --base-url https://cloud.zeyos.com/acme --client-id app --secret "$SECRET"
38
+
39
+ # Authenticate into a profile (also makes it active)
40
+ zeyos login --profile prod
41
+
42
+ # See and switch profiles
43
+ zeyos profile list # active marked with *, shows token status
44
+ zeyos profile use dev # switch the global active profile
45
+ zeyos profile current # what resolves right now, and why
46
+
47
+ # Per-project: pin a profile so cd-ing into a repo selects its instance
48
+ cd ~/work/acme && zeyos profile use prod --local # writes ./.zeyos/profile
49
+
50
+ # One-off override on any command (beats env, pin, and active)
51
+ zeyos whoami --profile dev
52
+ zeyos list tickets --profile prod
53
+ ```
54
+
55
+ `zeyos profile add <name> --from-current` snapshots whatever credentials are in effect (including tokens) into a new profile — handy for adopting an existing `.zeyos/auth.json` setup. Add `.zeyos/profile` to `.gitignore` alongside `.zeyos/auth.json`.
24
56
 
25
57
  ## Environment Variables
26
58
 
@@ -107,6 +107,28 @@ Count matching records:
107
107
 
108
108
  ```bash
109
109
  zeyos count tickets --filter '{"visibility":0,"status":4}' --json
110
+ zeyos count customfields --json
111
+ zeyos count actionsteps --filter '{"status":0}' --json
112
+ ```
113
+
114
+ Summarize actionstep effort/time-entry evidence:
115
+
116
+ ```bash
117
+ zeyos list actionsteps \
118
+ --fields ID,name,status,date,duedate,effort,ticket,task,account \
119
+ --filter '{"status":3}' \
120
+ --limit 100 \
121
+ --json
122
+ ```
123
+
124
+ Inspect ticket-linked mail without sending anything:
125
+
126
+ ```bash
127
+ zeyos list messages \
128
+ --fields ID,date,mailbox,subject,sender_email,to_email,ticket,reference,messageid \
129
+ --filter '{"ticket":42}' \
130
+ --sort +date \
131
+ --json
110
132
  ```
111
133
 
112
134
  ## Write Data
@@ -145,4 +167,6 @@ zeyos delete ticket 42 --force
145
167
  - Include `visibility: 0` in filters for normal business views.
146
168
  - Prefer `--data '<json>'` over many separate flags in automation.
147
169
  - Run `zeyos resources` before assuming a resource is CLI-supported.
170
+ - Use `actionsteps.effort` for time-entry totals; do not infer booked time from task assignment.
171
+ - Draft e-mail text in the response unless the user explicitly asks for a real ZeyOS draft record; never send mail from an agent workflow.
148
172
  - Escalate to [`@zeyos/client`](./03-cli-coverage-and-escalation.md) when the CLI resource registry is not enough.
@@ -13,10 +13,11 @@ The command `zeyos resources` is the source of truth for CLI-supported resource
13
13
 
14
14
  | Resource | Operations |
15
15
  |----------|------------|
16
- | `account`, `appointment`, `campaign`, `contact`, `document`, `event`, `file`, `invitation`, `item`, `message`, `note`, `opportunity`, `payment`, `project`, `storage`, `task`, `ticket`, `transaction` | `list`, `get`, `create`, `update`, `delete` |
16
+ | `account`, `actionstep`, `appointment`, `campaign`, `contact`, `document`, `event`, `file`, `invitation`, `item`, `message`, `note`, `opportunity`, `payment`, `project`, `storage`, `task`, `ticket`, `transaction` | `list`, `get`, `create`, `update`, `delete` |
17
+ | `customfield` / `customfields` | `list`, `get` |
17
18
  | `group`, `user` | `list`, `get` |
18
19
 
19
- Plural names and common aliases such as `tickets`, `docs`, `invoice`, and `crm` are resolved by the CLI, but the underlying coverage boundary is still the registry above.
20
+ Plural names and common aliases such as `tickets`, `actionsteps`, `time-entries`, `docs`, `invoice`, and `crm` are resolved by the CLI, but the underlying coverage boundary is still the registry above.
20
21
 
21
22
  ## What the CLI Does Not Try to Cover
22
23
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeyos/client",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Dependency-light JavaScript client for ZeyOS OpenAPI services",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -34,9 +34,6 @@
34
34
  "samples/crm",
35
35
  "samples/dashboard",
36
36
  "samples/kanban",
37
- "samples/missioncontrol/README.md",
38
- "samples/missioncontrol/fetch-data.mjs",
39
- "samples/missioncontrol/index.html",
40
37
  "agents",
41
38
  "README.md",
42
39
  "CHANGELOG.md",
@@ -56,8 +56,14 @@ function abortableDelay(ms, signal) {
56
56
  });
57
57
  }
58
58
 
59
- // Honor a Retry-After header (seconds or HTTP-date), else exponential backoff
60
- // with jitter, capped at maxDelayMs.
59
+ // Exponential backoff with jitter, capped at maxDelayMs.
60
+ function backoffDelay(attempt, retryConfig) {
61
+ const exp = retryConfig.baseDelayMs * Math.pow(2, attempt);
62
+ const jitter = Math.random() * retryConfig.baseDelayMs;
63
+ return Math.min(retryConfig.maxDelayMs, exp + jitter);
64
+ }
65
+
66
+ // Honor a Retry-After header (seconds or HTTP-date), else exponential backoff.
61
67
  function computeRetryDelay(response, attempt, retryConfig) {
62
68
  const header = response.headers?.['retry-after'];
63
69
  // An empty or whitespace-only header carries no delay directive — `Number('')`
@@ -74,9 +80,88 @@ function computeRetryDelay(response, attempt, retryConfig) {
74
80
  return Math.min(retryConfig.maxDelayMs, Math.max(0, dateMs - Date.now()));
75
81
  }
76
82
  }
77
- const exp = retryConfig.baseDelayMs * Math.pow(2, attempt);
78
- const jitter = Math.random() * retryConfig.baseDelayMs;
79
- return Math.min(retryConfig.maxDelayMs, exp + jitter);
83
+ return backoffDelay(attempt, retryConfig);
84
+ }
85
+
86
+ // Operations that are safe to transparently retry on a network error / timeout:
87
+ // HTTP GET/HEAD, plus ZeyOS read queries (list/count/search) which are POST but
88
+ // side-effect-free. Writes (create/update/delete) are never auto-retried, so a
89
+ // dropped connection can't cause a duplicate mutation.
90
+ function isReadOperation(operation) {
91
+ const method = operation?.method;
92
+ if (method === 'GET' || method === 'HEAD') {
93
+ return true;
94
+ }
95
+ return /^(list|count|search|exists|get)/i.test(operation?.operationId || '');
96
+ }
97
+
98
+ // Build a one-line, human-readable summary from a server error body so the thrown
99
+ // error message says *why* (e.g. an unknown-filter 400), not just the status code.
100
+ // The full body remains available on error.body.
101
+ function summarizeErrorBody(body, maxLength = 200) {
102
+ let text = '';
103
+ if (typeof body === 'string') {
104
+ text = body.trim();
105
+ } else if (body && typeof body === 'object') {
106
+ const candidate =
107
+ body.message ?? body.error_description ?? body.error ?? body.detail ?? body.title;
108
+ if (typeof candidate === 'string' && candidate.trim()) {
109
+ text = candidate.trim();
110
+ } else {
111
+ try {
112
+ text = JSON.stringify(body);
113
+ } catch {
114
+ text = '';
115
+ }
116
+ }
117
+ }
118
+ if (!text) {
119
+ return '';
120
+ }
121
+ return text.length > maxLength ? `${text.slice(0, maxLength)}…` : text;
122
+ }
123
+
124
+ // Run fetch with an optional per-attempt timeout, composed with the caller's
125
+ // AbortSignal (Node 18 compatible — no AbortSignal.any). A timeout aborts the
126
+ // internal controller only, so `externalSignal.aborted` stays false and the
127
+ // caller can distinguish a timeout (retryable for reads) from a user abort.
128
+ async function fetchWithTimeout(httpRequestImpl, requestArgs, externalSignal, timeoutMs) {
129
+ if (!(timeoutMs > 0)) {
130
+ return httpRequestImpl({ ...requestArgs, signal: externalSignal });
131
+ }
132
+
133
+ const controller = new AbortController();
134
+ let timedOut = false;
135
+ const onExternalAbort = () => controller.abort(externalSignal.reason);
136
+
137
+ if (externalSignal) {
138
+ if (externalSignal.aborted) {
139
+ controller.abort(externalSignal.reason);
140
+ } else {
141
+ externalSignal.addEventListener('abort', onExternalAbort, { once: true });
142
+ }
143
+ }
144
+
145
+ const timer = setTimeout(() => {
146
+ timedOut = true;
147
+ controller.abort();
148
+ }, timeoutMs);
149
+
150
+ try {
151
+ return await httpRequestImpl({ ...requestArgs, signal: controller.signal });
152
+ } catch (error) {
153
+ if (timedOut) {
154
+ const timeoutError = new Error(`Request timed out after ${timeoutMs}ms`);
155
+ timeoutError.name = 'TimeoutError';
156
+ timeoutError.code = 'ETIMEDOUT';
157
+ timeoutError.isTimeout = true;
158
+ throw timeoutError;
159
+ }
160
+ throw error;
161
+ } finally {
162
+ clearTimeout(timer);
163
+ externalSignal?.removeEventListener?.('abort', onExternalAbort);
164
+ }
80
165
  }
81
166
 
82
167
  const AUTH_SCHEME_MAP = Object.freeze({
@@ -390,7 +475,8 @@ function chooseBodyType(serviceKey, operation, prepared, fallbackBodyType) {
390
475
 
391
476
  function createApiError(response, { serviceKey, operation, method, url }) {
392
477
  const operationDescription = operation.operationId ? `${serviceKey}.${operation.operationId}` : `${serviceKey} request`;
393
- const message = `${operationDescription} failed with HTTP ${response.status}`;
478
+ const detail = summarizeErrorBody(response.data);
479
+ const message = `${operationDescription} failed with HTTP ${response.status}${detail ? `: ${detail}` : ''}`;
394
480
 
395
481
  return new ZeyosApiError(message, {
396
482
  status: response.status,
@@ -606,6 +692,10 @@ export function createZeyosClient(rawConfig = {}) {
606
692
 
607
693
  const defaultHeaders = isObject(config.headers) ? config.headers : {};
608
694
  const retryConfig = normalizeRetry(config.retry);
695
+ const defaultTimeoutMs = Number(config.timeoutMs) > 0 ? Number(config.timeoutMs) : 0;
696
+ // Whether to transparently retry network errors / timeouts. `undefined` (default)
697
+ // means "auto": retry only read operations. `true`/`false` force the behavior.
698
+ const defaultRetryOnNetworkError = typeof config.retryOnNetworkError === 'boolean' ? config.retryOnNetworkError : undefined;
609
699
  const schemaApi = createSchema({ services: SERVICES, schema: SCHEMA });
610
700
  const validateByDefault = config.validate === true;
611
701
  const operationLookup = new Map();
@@ -616,6 +706,10 @@ export function createZeyosClient(rawConfig = {}) {
616
706
  }
617
707
  }
618
708
 
709
+ // Single-flight token refresh: shared across all concurrent operations so an
710
+ // expired token triggers exactly one getToken call (see refreshAccessTokenOnce).
711
+ let refreshInFlight = null;
712
+
619
713
  async function getTokenSet() {
620
714
  return normalizeTokenSet(await tokenStore.get());
621
715
  }
@@ -644,6 +738,15 @@ export function createZeyosClient(rawConfig = {}) {
644
738
  return `ZEYOSID=${cookieValue}`;
645
739
  }
646
740
 
741
+ // Resolve whether a network error / timeout may be retried for this call.
742
+ // Explicit per-request / client config wins; otherwise auto = read ops only.
743
+ function resolveNetworkRetry(operation, requestOptions) {
744
+ const explicit = requestOptions?.retryOnNetworkError ?? defaultRetryOnNetworkError;
745
+ if (explicit === true) return true;
746
+ if (explicit === false) return false;
747
+ return isReadOperation(operation);
748
+ }
749
+
647
750
  async function sendRequestOnce({ serviceKey, operation, prepared, requestAuth, tokenSet, candidate, requestOptions }) {
648
751
  const body = cloneValue(prepared.body);
649
752
  const authHeaders = {};
@@ -698,19 +801,27 @@ export function createZeyosClient(rawConfig = {}) {
698
801
  );
699
802
 
700
803
  const signal = prepared.signal ?? requestOptions?.signal;
804
+ const timeoutMs = Number(requestOptions?.timeoutMs ?? defaultTimeoutMs) || 0;
805
+ const networkRetryAllowed = resolveNetworkRetry(operation, requestOptions);
806
+ const requestArgs = { fetchImpl, url, method: operation.method, headers, body, bodyType, credentials };
701
807
 
702
808
  let response;
703
809
  for (let attempt = 0; ; attempt++) {
704
- response = await httpRequest({
705
- fetchImpl,
706
- url,
707
- method: operation.method,
708
- headers,
709
- body,
710
- bodyType,
711
- signal,
712
- credentials
713
- });
810
+ try {
811
+ response = await fetchWithTimeout(httpRequest, requestArgs, signal, timeoutMs);
812
+ } catch (error) {
813
+ // A caller-initiated abort must never be retried — propagate immediately.
814
+ // (A timeout aborts only the internal controller, so signal.aborted is false.)
815
+ if (signal?.aborted) {
816
+ throw error;
817
+ }
818
+ // Network error or timeout: retry only safe (read) operations, within budget.
819
+ if (!networkRetryAllowed || attempt >= retryConfig.maxRetries) {
820
+ throw error;
821
+ }
822
+ await abortableDelay(backoffDelay(attempt, retryConfig), signal);
823
+ continue;
824
+ }
714
825
 
715
826
  if (attempt >= retryConfig.maxRetries || !retryConfig.retryOn.has(response.status)) {
716
827
  break;
@@ -787,6 +898,22 @@ export function createZeyosClient(rawConfig = {}) {
787
898
  return nextTokenSet;
788
899
  }
789
900
 
901
+ // Single-flight wrapper: when several operations notice an expired token at the
902
+ // same time (e.g. `Promise.all([...])`), they must share ONE refresh. Without
903
+ // this each fires its own getToken — redundant load, and a hard failure when the
904
+ // server rotates refresh tokens (all but the first present a stale refresh token).
905
+ function refreshAccessTokenOnce(currentTokenSet, requestAuth, requestOptions) {
906
+ if (refreshInFlight) {
907
+ return refreshInFlight;
908
+ }
909
+ refreshInFlight = Promise.resolve()
910
+ .then(() => refreshAccessToken(currentTokenSet, requestAuth, requestOptions))
911
+ .finally(() => {
912
+ refreshInFlight = null;
913
+ });
914
+ return refreshInFlight;
915
+ }
916
+
790
917
  async function executeOperation({ serviceKey, operation, prepared, requestOptions = {} }) {
791
918
  // Dry run: resolve the route + payload exactly as they would be sent, but
792
919
  // return that descriptor instead of performing any network request or token
@@ -826,7 +953,7 @@ export function createZeyosClient(rawConfig = {}) {
826
953
  canRefreshAccessToken({ mode, operation, tokenSet, oauthConfig })
827
954
  ) {
828
955
  try {
829
- const refreshed = await refreshAccessToken(tokenSet, requestAuth, requestOptions);
956
+ const refreshed = await refreshAccessTokenOnce(tokenSet, requestAuth, requestOptions);
830
957
  if (refreshed?.accessToken) {
831
958
  tokenSet = refreshed;
832
959
  }
@@ -865,7 +992,7 @@ export function createZeyosClient(rawConfig = {}) {
865
992
 
866
993
  if (candidate.type === 'bearer' && canRefreshAccessToken({ mode, operation, tokenSet, oauthConfig })) {
867
994
  try {
868
- const refreshed = await refreshAccessToken(tokenSet, requestAuth, requestOptions);
995
+ const refreshed = await refreshAccessTokenOnce(tokenSet, requestAuth, requestOptions);
869
996
  if (refreshed?.accessToken) {
870
997
  tokenSet = refreshed;
871
998
  const retryResponse = await sendRequestOnce({
@@ -1017,6 +1144,58 @@ export function createZeyosClient(rawConfig = {}) {
1017
1144
  const oauth2Operations = bindService('oauth2');
1018
1145
  const legacyAuth = bindService('legacyAuth');
1019
1146
 
1147
+ // Async-iterate every record from a list operation, paging by `offset` until a
1148
+ // short/empty page (or `opts.max`) is reached — removing the manual offset
1149
+ // bookkeeping the 1000/10000 list caps otherwise force on callers.
1150
+ //
1151
+ // for await (const ticket of client.paginate('listTickets', { filters: { visibility: 0 } })) { … }
1152
+ //
1153
+ async function* paginate(operationId, input = {}, opts = {}) {
1154
+ const op = operationLookup.get(`api.${operationId}`);
1155
+ if (!op) {
1156
+ const candidates = (SERVICES.api?.operations ?? []).map((entry) => entry.operationId);
1157
+ const suggestion = suggestClosest(operationId, candidates);
1158
+ throw new ZeyosApiError(
1159
+ `Unknown list operation: api.${operationId}.` + (suggestion ? ` Did you mean '${suggestion}'?` : ''),
1160
+ { operationId, service: 'api' }
1161
+ );
1162
+ }
1163
+
1164
+ const requested = Number(opts.pageSize) > 0 ? Number(opts.pageSize)
1165
+ : Number(input.limit) > 0 ? Number(input.limit) : 1000;
1166
+ // Clamp to the server's max page size: a larger request would be silently
1167
+ // capped server-side, making the short-page terminator stop early and drop rows.
1168
+ const pageSize = Math.min(requested, 10000);
1169
+ const max = Number(opts.max) > 0 ? Number(opts.max) : Infinity;
1170
+ let offset = Number(input.offset) > 0 ? Number(input.offset) : 0;
1171
+ let yielded = 0;
1172
+
1173
+ for (;;) {
1174
+ const page = await api[operationId]({ ...input, limit: pageSize, offset }, opts.requestOptions);
1175
+ const rows = Array.isArray(page) ? page : Array.isArray(page?.data) ? page.data : [];
1176
+ for (const row of rows) {
1177
+ yield row;
1178
+ yielded += 1;
1179
+ if (yielded >= max) {
1180
+ return;
1181
+ }
1182
+ }
1183
+ if (rows.length < pageSize) {
1184
+ return; // last (short or empty) page
1185
+ }
1186
+ offset += pageSize;
1187
+ }
1188
+ }
1189
+
1190
+ // Eager convenience: collect up to `opts.max` records into an array.
1191
+ async function collect(operationId, input = {}, opts = {}) {
1192
+ const out = [];
1193
+ for await (const row of paginate(operationId, input, opts)) {
1194
+ out.push(row);
1195
+ }
1196
+ return out;
1197
+ }
1198
+
1020
1199
  function buildAuthorizationUrl(options = {}) {
1021
1200
  const clientId = options.clientId ?? options.client_id ?? oauthConfig.clientId;
1022
1201
  const redirectUri = options.redirectUri ?? options.redirect_uri;
@@ -1219,6 +1398,8 @@ export function createZeyosClient(rawConfig = {}) {
1219
1398
  oauth2,
1220
1399
  legacyAuth,
1221
1400
  request,
1401
+ paginate,
1402
+ collect,
1222
1403
  schema: schemaApi,
1223
1404
  auth: {
1224
1405
  getTokenSet,
@@ -1,106 +0,0 @@
1
- # Mission Control
2
-
3
- A team-performance dashboard for an IT consultancy running on ZeyOS — built on
4
- the [`@zeyos/client`](../../README.md) library. It answers the managing
5
- director's question: **who is actually working, and where is there untapped
6
- capacity?**
7
-
8
- A dark "mission control" view: a velocity KPI row, a 13-week throughput trend,
9
- a team-capacity strip, and a searchable/sortable/filterable grid of per-employee
10
- activity cards (click one for a per-type digest + contribution graph).
11
-
12
- ## What it shows
13
-
14
- 1. **Velocity** — tickets opened vs closed in the window, net flow, average /
15
- median / p90 **cycle time** (open → close), open backlog (with overdue), and
16
- total **hours booked**. A 13-week **throughput trend** of opened vs closed.
17
- 2. **Activity card per employee** — open tickets, open tasks, tickets closed,
18
- hours booked, a weekly-hours sparkline, team/location tags, a **capacity
19
- badge** (Overloaded / Balanced / Available / Idle / Former), and **last
20
- activity** (the most recent time entry) — flagged red when it's **older than
21
- 7 days**. Hover "last activity" to see the engineer's **last 10 time entries**
22
- (date · type · customer · ticket · hours).
23
- 3. **Per-employee digest** (click a card) — **booked hours stacked by
24
- `extdata.type`** (Weekly / Monthly), and a **GitHub-style contribution graph**
25
- of their time-entry activity over the last 53 weeks.
26
-
27
- Filters: search by name, **filter by team / department / location** (group
28
- membership), capacity chips (Engaged / Spare capacity / Inactive >7d /
29
- Overloaded / All), and sort by activity, hours, throughput, workload, cycle,
30
- overdue, or least-recent.
31
-
32
- The **Team capacity** strip and the *Spare capacity* filter surface the
33
- "untapped resources": active engineers running light or with no load at all,
34
- contrasted with the overloaded ones — the clearest place to rebalance work.
35
-
36
- ## Run it
37
-
38
- ```bash
39
- # 1. Authenticate once (if you haven't already)
40
- zeyos login --base-url https://zeyos.cms-it.de/dev
41
-
42
- # 2. Pull live data into data.js (read-only; reuses your CLI credentials)
43
- node samples/missioncontrol/fetch-data.mjs # 90-day window
44
- node samples/missioncontrol/fetch-data.mjs --days 180 # custom window
45
-
46
- # 3. Open the dashboard
47
- # Either open index.html directly, or serve the folder:
48
- python3 -m http.server 8765 --directory samples/missioncontrol
49
- # → http://localhost:8765
50
- ```
51
-
52
- `fetch-data.mjs` writes `data.js` (a `window.MISSION_DATA = …` assignment) so
53
- `index.html` works straight from disk — no server, no CORS, no token pasting.
54
- Re-run the fetcher to refresh; the page is otherwise a static, dependency-free
55
- single file (hand-rolled CSS + inline-SVG charts).
56
-
57
- ## How it works
58
-
59
- `fetch-data.mjs` reads the credentials `zeyos login` stored
60
- (`.zeyos/auth.json` or `~/.config/zeyos/credentials.json`), builds an
61
- auto-refreshing client with `createZeyosClient`, and issues a handful of
62
- **read-only** `list` queries (tickets, `actionsteps`, tasks, groups), then
63
- aggregates them client-side (ZeyOS has no server-side group-by). It never writes
64
- to ZeyOS.
65
-
66
- ### Metric definitions (so the numbers are reproducible)
67
-
68
- | Metric | Definition |
69
- |--------|------------|
70
- | **Time entry** | an `actionsteps` row with `status IN [1, 3]` (COMPLETED + BOOKED), attributed to an engineer via **`assigneduser`** (`owneruser` is unused here). `effort` is in **minutes**. |
71
- | **Last activity** | the engineer's most recent time-entry `date`; **stale** (red ⚠) when it's >7 days before the "as of" date. |
72
- | **Type** | `extdata.type` of each time entry (Intern / Auftrag / Consulting / Wartung / …), selected on `list` via the `extdata.type` field. Top 6 are charted; the rest roll into *Other*. |
73
- | **Closed / done** | tickets with `status IN [9, 11]` — COMPLETED + BOOKED (BOOKED, completed & billed, is the dominant terminal state). |
74
- | **Open backlog** | `status IN [0, 1, 2, 4, 6, 7]` — started/accepted/active but not done. |
75
- | **Opened in window** | tickets whose indexed `date` falls in the window. |
76
- | **Cycle time** | `lastmodified − creationdate` for tickets closed in the window (a proxy for the close timestamp, which ZeyOS does not store separately). |
77
- | **Capacity** | relative to the engaged-and-active cohort: *overloaded* (top-quintile workload or ≥3 overdue), *available* (low workload **and** below-median throughput), *idle* (active user, zero load), *balanced* (else), *former* (deactivated user with leftover assignments). |
78
- | **Team / location** | the engineer's **group memberships** (see below). |
79
-
80
- ### Notes & limitations
81
-
82
- - **Department/location = groups.** ZeyOS *defines* custom fields
83
- `users.department`/`users.location`/`users.team`, but the API returns
84
- *"Extension data not available for users"* — they can't be read. The org
85
- structure instead lives in **group membership** (e.g. `Developers`,
86
- `Services`, `Technik`, `Berlin`, `Bayern`, `Nordrhein-Westfalen`), which is
87
- what the team/location filter uses.
88
- - **`extdata` only lists via dot-fields.** Custom fields aren't returned by a
89
- plain `list` (or `extdata=1`); they must be selected explicitly, e.g.
90
- `fields: ['…','extdata.type']` (returned as `extdata_type`). On single records
91
- `getTicket(…, { query:{ extdata:1 } })` returns them under an `extdata` object.
92
- - **Corrupt time-entry dates.** Many `actionsteps` have far-future `date` values
93
- (year 2099+); the fetcher bounds queries to `date ≤ now` to exclude them.
94
- - **"As of" anchoring.** Windows are measured back from the latest activity in
95
- the data, not wall-clock today — so a frozen/dev snapshot still produces
96
- meaningful windows (and the 7-day staleness check is relative to it). The
97
- anchor date is shown in the header.
98
- - **`date`, not `creationdate`, for windows.** `creationdate`/`lastmodified` are
99
- unindexed on `tickets`; range-scanning them can time out (HTTP 503). The
100
- indexed `date` column is used for opened-in-window queries.
101
- - **Roles aren't distinguished.** Every active user is included; a salesperson
102
- with no tickets shows as *Idle*. Use the team filter / search to focus on a
103
- delivery team.
104
-
105
- > `data.js` / `data.json` are generated and contain real names from your
106
- > instance — they are git-ignored. Commit only the source.