@zeyos/client 0.2.0 → 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 +33 -4
- package/README.md +31 -1
- package/agents/README.md +4 -0
- package/agents/shared/zeyos-entity-map.md +5 -1
- package/agents/shared/zeyos-entity-reference.md +89 -33
- package/agents/zeyos-mail-operations/SKILL.md +7 -1
- package/agents/zeyos-mail-operations/references/workflows.md +19 -2
- package/agents/zeyos-platform-and-schema/SKILL.md +1 -1
- package/agents/zeyos-platform-and-schema/references/workflows.md +21 -5
- package/agents/zeyos-time-tracking/SKILL.md +48 -0
- package/agents/zeyos-time-tracking/references/workflows.md +230 -0
- package/agents/zeyos-work-management/SKILL.md +5 -2
- package/agents/zeyos-work-management/references/workflows.md +54 -4
- package/docs/02-javascript-client/03-making-requests.md +46 -1
- package/docs/03-cli/02-commands.md +63 -1
- package/docs/03-cli/03-configuration.md +37 -5
- package/docs/04-agent-workflows/01-agent-quickstart.md +24 -0
- package/docs/04-agent-workflows/03-cli-coverage-and-escalation.md +3 -2
- package/docs/06-okf/01-overview.md +70 -0
- package/docs/06-okf/02-producing-and-consuming.md +46 -0
- package/docs/06-okf/03-keeping-fresh.md +53 -0
- package/docs/06-okf/04-loops.md +58 -0
- package/docs/06-okf/_category_.json +9 -0
- package/okf/concepts/counting-and-sums.md +10 -0
- package/okf/concepts/dates-unix-seconds.md +12 -0
- package/okf/concepts/enums.md +14 -0
- package/okf/concepts/filters-vs-filter.md +14 -0
- package/okf/concepts/index.md +8 -0
- package/okf/concepts/operationid-vocabulary.md +17 -0
- package/okf/concepts/visibility-column.md +13 -0
- package/okf/entities/accounts.md +82 -0
- package/okf/entities/actionsteps.md +84 -0
- package/okf/entities/addresses.md +50 -0
- package/okf/entities/applicationassets.md +43 -0
- package/okf/entities/applications.md +62 -0
- package/okf/entities/appointments.md +79 -0
- package/okf/entities/associations.md +41 -0
- package/okf/entities/binfiles.md +32 -0
- package/okf/entities/campaigns.md +66 -0
- package/okf/entities/categories.md +55 -0
- package/okf/entities/channels.md +54 -0
- package/okf/entities/comments.md +44 -0
- package/okf/entities/components.md +46 -0
- package/okf/entities/contacts.md +96 -0
- package/okf/entities/contacts2contacts.md +42 -0
- package/okf/entities/contracts.md +83 -0
- package/okf/entities/couponcodes.md +58 -0
- package/okf/entities/coupons.md +69 -0
- package/okf/entities/customfields.md +59 -0
- package/okf/entities/davservers.md +74 -0
- package/okf/entities/devices.md +65 -0
- package/okf/entities/documents.md +76 -0
- package/okf/entities/dunning.md +82 -0
- package/okf/entities/dunning2transactions.md +46 -0
- package/okf/entities/entities2channels.md +42 -0
- package/okf/entities/events.md +57 -0
- package/okf/entities/feedservers.md +67 -0
- package/okf/entities/files.md +50 -0
- package/okf/entities/follows.md +40 -0
- package/okf/entities/forks.md +54 -0
- package/okf/entities/groups.md +48 -0
- package/okf/entities/groups2users.md +44 -0
- package/okf/entities/index.md +93 -0
- package/okf/entities/invitations.md +53 -0
- package/okf/entities/items.md +95 -0
- package/okf/entities/ledgers.md +56 -0
- package/okf/entities/likes.md +40 -0
- package/okf/entities/links.md +70 -0
- package/okf/entities/mailinglists.md +67 -0
- package/okf/entities/mailingrecipients.md +45 -0
- package/okf/entities/mailservers.md +77 -0
- package/okf/entities/messagereads.md +40 -0
- package/okf/entities/messages.md +104 -0
- package/okf/entities/notes.md +73 -0
- package/okf/entities/objects.md +70 -0
- package/okf/entities/opportunities.md +87 -0
- package/okf/entities/participants.md +52 -0
- package/okf/entities/payments.md +76 -0
- package/okf/entities/permissions.md +46 -0
- package/okf/entities/pricelists.md +70 -0
- package/okf/entities/pricelists2accounts.md +46 -0
- package/okf/entities/prices.md +49 -0
- package/okf/entities/projects.md +72 -0
- package/okf/entities/records.md +75 -0
- package/okf/entities/relateditems.md +43 -0
- package/okf/entities/resources.md +55 -0
- package/okf/entities/services.md +64 -0
- package/okf/entities/stocktransactions.md +72 -0
- package/okf/entities/storages.md +56 -0
- package/okf/entities/suppliers.md +51 -0
- package/okf/entities/tasks.md +86 -0
- package/okf/entities/tickets.md +86 -0
- package/okf/entities/transactions.md +118 -0
- package/okf/entities/users.md +66 -0
- package/okf/entities/weblets.md +66 -0
- package/okf/index.md +11 -0
- package/okf/log.md +4 -0
- package/okf/metrics/cash-received.md +10 -0
- package/okf/metrics/index.md +6 -0
- package/okf/metrics/invoiced-net-revenue.md +16 -0
- package/okf/metrics/open-customers.md +14 -0
- package/okf/metrics/overdue-receivables.md +12 -0
- package/okf/playbooks/customer-360.md +12 -0
- package/okf/playbooks/index.md +5 -0
- package/okf/playbooks/revenue-this-year.md +19 -0
- package/okf/playbooks/ticket-work-packet.md +11 -0
- package/package.json +9 -5
- package/scripts/data/okf-curation.mjs +258 -0
- package/scripts/generate-client.mjs +4 -275
- package/scripts/generate-okf.mjs +241 -0
- package/scripts/lib/okf.mjs +272 -0
- package/scripts/lib/spec-model.mjs +325 -0
- package/src/index.js +4 -0
- package/src/runtime/client.js +199 -18
- package/src/runtime/okf.js +237 -0
- package/samples/missioncontrol/README.md +0 -106
- package/samples/missioncontrol/fetch-data.mjs +0 -341
- package/samples/missioncontrol/index.html +0 -419
package/src/runtime/client.js
CHANGED
|
@@ -56,8 +56,14 @@ function abortableDelay(ms, signal) {
|
|
|
56
56
|
});
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
//
|
|
60
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
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
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
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
|
|
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
|
|
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,
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// Open Knowledge Format (OKF v0.1) support for @zeyos/client.
|
|
2
|
+
//
|
|
3
|
+
// Two halves:
|
|
4
|
+
// • Pure (browser + Node): the OKF conformance constants, a tolerant concept
|
|
5
|
+
// parser, a bundle validator, and buildOkf() — which synthesizes a conformant
|
|
6
|
+
// OKF bundle from the client's own introspection surface (the generated
|
|
7
|
+
// SCHEMA + SERVICES). No filesystem access.
|
|
8
|
+
// • Node-only: loadOkfBundle() reads the shipped okf/ bundle (or any directory)
|
|
9
|
+
// from disk via a lazy import, so this module stays bundler/browser-safe.
|
|
10
|
+
//
|
|
11
|
+
// The richer build-time producer (scripts/generate-okf.mjs) emits the curated,
|
|
12
|
+
// managed-block bundle under okf/. buildOkf() here is the lightweight runtime
|
|
13
|
+
// projection of what the shipped client knows — handy for emitting OKF in
|
|
14
|
+
// environments without the bundled files, or to diff against a live instance.
|
|
15
|
+
|
|
16
|
+
import { SCHEMA } from '../generated/schema.js';
|
|
17
|
+
import { SERVICES } from '../generated/operations.js';
|
|
18
|
+
|
|
19
|
+
export const OKF_VERSION = '0.1';
|
|
20
|
+
|
|
21
|
+
// Markers fencing producer-generated content inside a concept's body. Exported so
|
|
22
|
+
// the build-time renderer (scripts/lib/okf.mjs) shares one definition.
|
|
23
|
+
export const GENERATED_START = '<!-- okf:generated:start — rewritten by scripts/generate-okf.mjs; do not edit by hand -->';
|
|
24
|
+
export const GENERATED_END = '<!-- okf:generated:end -->';
|
|
25
|
+
export const GENERATED_FRONTMATTER_KEYS = Object.freeze([
|
|
26
|
+
'type', 'title', 'description', 'resource', 'tags', 'timestamp',
|
|
27
|
+
'api_backed', 'list_operation', 'visibility_column'
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
// Files that are not concept documents (spec §5/§6).
|
|
31
|
+
const RESERVED_BASENAMES = new Set(['index.md', 'log.md']);
|
|
32
|
+
|
|
33
|
+
const VERB_RE = /^(list|get|create|update|delete|exists)/;
|
|
34
|
+
|
|
35
|
+
// ── Parsing / validation (pure) ────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
/** Tolerant frontmatter + body split. Returns `{ frontmatter, body }` where
|
|
38
|
+
* frontmatter is a flat string→string map (lists kept as their raw text). */
|
|
39
|
+
export function parseConcept(content) {
|
|
40
|
+
const text = String(content || '');
|
|
41
|
+
const match = /^---\n([\s\S]*?)\n---\n?/.exec(text);
|
|
42
|
+
if (!match) return { frontmatter: {}, body: text };
|
|
43
|
+
const frontmatter = {};
|
|
44
|
+
for (const line of match[1].split('\n')) {
|
|
45
|
+
const kv = /^([A-Za-z0-9_]+):\s*(.*)$/.exec(line);
|
|
46
|
+
if (kv) frontmatter[kv[1]] = kv[2].trim();
|
|
47
|
+
}
|
|
48
|
+
return { frontmatter, body: text.slice(match[0].length) };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isReserved(relPath) {
|
|
52
|
+
const base = relPath.split('/').pop();
|
|
53
|
+
return RESERVED_BASENAMES.has(base);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Validate an OKF bundle for v0.1 conformance (spec §9): every non-reserved `.md`
|
|
58
|
+
* file must have parseable frontmatter with a non-empty `type`. Tolerant by
|
|
59
|
+
* design — unknown types/keys and broken links are NOT errors.
|
|
60
|
+
*
|
|
61
|
+
* @param {Record<string,string>} files - relativePath → file content.
|
|
62
|
+
* @returns {{ valid: boolean, errors: { path: string, message: string }[], conceptCount: number }}
|
|
63
|
+
*/
|
|
64
|
+
export function validateOkfFiles(files) {
|
|
65
|
+
const errors = [];
|
|
66
|
+
let conceptCount = 0;
|
|
67
|
+
for (const [relPath, content] of Object.entries(files || {})) {
|
|
68
|
+
if (!relPath.endsWith('.md') || isReserved(relPath)) continue;
|
|
69
|
+
conceptCount += 1;
|
|
70
|
+
const { frontmatter } = parseConcept(content);
|
|
71
|
+
if (!Object.keys(frontmatter).length) {
|
|
72
|
+
errors.push({ path: relPath, message: 'Missing YAML frontmatter.' });
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (!frontmatter.type) {
|
|
76
|
+
errors.push({ path: relPath, message: 'Frontmatter is missing the required `type` field.' });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return { valid: errors.length === 0, errors, conceptCount };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The OKF concept ID for a ZeyOS resource, e.g. `tickets` → `entities/tickets`. */
|
|
83
|
+
export function conceptIdForResource(resource) {
|
|
84
|
+
return `entities/${resource}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── buildOkf: synthesize a bundle from the client's introspection surface ───────
|
|
88
|
+
|
|
89
|
+
function groupOperations(services) {
|
|
90
|
+
const byResource = new Map();
|
|
91
|
+
for (const service of Object.values(services || {})) {
|
|
92
|
+
for (const op of service.operations || []) {
|
|
93
|
+
const resource = resourceFromPath(op.path);
|
|
94
|
+
if (!resource) continue;
|
|
95
|
+
if (!byResource.has(resource)) byResource.set(resource, {});
|
|
96
|
+
const bucket = byResource.get(resource);
|
|
97
|
+
const m = VERB_RE.exec(op.operationId);
|
|
98
|
+
const key = m ? m[1] : op.operationId;
|
|
99
|
+
if (!bucket[key]) bucket[key] = op.operationId;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return byResource;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function resourceFromPath(p) {
|
|
106
|
+
if (typeof p !== 'string') return null;
|
|
107
|
+
for (const segment of p.split('/')) {
|
|
108
|
+
if (segment && !segment.startsWith('{')) return segment;
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function titleFromOps(ops, fallback) {
|
|
114
|
+
const op = ops.list || ops.get;
|
|
115
|
+
if (!op) return fallback.charAt(0).toUpperCase() + fallback.slice(1);
|
|
116
|
+
return op.replace(VERB_RE, '').replace(/([a-z0-9])([A-Z])/g, '$1 $2').trim() || fallback;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Deterministic, dependency-free 32-bit hash for the bundle source snapshot.
|
|
120
|
+
function fnv1a(str) {
|
|
121
|
+
let h = 0x811c9dc5;
|
|
122
|
+
for (let i = 0; i < str.length; i += 1) {
|
|
123
|
+
h ^= str.charCodeAt(i);
|
|
124
|
+
h = Math.imul(h, 0x01000193);
|
|
125
|
+
}
|
|
126
|
+
return (h >>> 0).toString(16).padStart(8, '0');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function renderEntityDoc(name, entry, ops, hasDoc) {
|
|
130
|
+
const link = (target) => (hasDoc.has(target) ? `/entities/${target}.md` : null);
|
|
131
|
+
const fields = Object.entries(entry.fields || {});
|
|
132
|
+
|
|
133
|
+
const schemaRows = fields.map(([fname, def]) => {
|
|
134
|
+
const fkCell = def.fk ? (link(def.fk) ? `[${def.fk}](${link(def.fk)})` : def.fk) : '—';
|
|
135
|
+
const enumCell = def.enum ? Object.entries(def.enum).map(([k, v]) => `${k}=${v}`).join(', ') : '—';
|
|
136
|
+
return `| \`${fname}\` | ${def.type || 'unknown'} | ${def.indexed ? 'yes' : '—'} | ${fkCell} | ${enumCell} |`;
|
|
137
|
+
});
|
|
138
|
+
const schema = `# Schema\n\n| Column | Type | Indexed | FK | Enum |\n|---|---|---|---|---|\n${schemaRows.join('\n')}`;
|
|
139
|
+
|
|
140
|
+
const fks = fields.filter(([, d]) => d.fk);
|
|
141
|
+
const fkSection = fks.length
|
|
142
|
+
? `\n\n# Foreign Keys\n\n${fks.map(([f, d]) => `- \`${f}\` → ${link(d.fk) ? `[${d.fk}](${link(d.fk)})` : d.fk}`).join('\n')}`
|
|
143
|
+
: '';
|
|
144
|
+
|
|
145
|
+
const order = ['list', 'get', 'create', 'update', 'delete', 'exists'];
|
|
146
|
+
const opLines = order.filter((k) => ops[k]).map((k) => `- ${k}: \`${ops[k]}\``);
|
|
147
|
+
const opSection = opLines.length ? `\n\n# Operations\n\n${opLines.join('\n')}` : '';
|
|
148
|
+
|
|
149
|
+
const title = titleFromOps(ops, name);
|
|
150
|
+
const fm = [
|
|
151
|
+
'type: ZeyOS Entity',
|
|
152
|
+
`title: ${title}`,
|
|
153
|
+
`resource: zeyos://api/${name}`,
|
|
154
|
+
'tags: [generated]',
|
|
155
|
+
'api_backed: true'
|
|
156
|
+
];
|
|
157
|
+
if (ops.list) fm.push(`list_operation: ${ops.list}`);
|
|
158
|
+
fm.push(`visibility_column: ${Object.prototype.hasOwnProperty.call(entry.fields || {}, 'visibility')}`);
|
|
159
|
+
|
|
160
|
+
return `---\n${fm.join('\n')}\n---\n\n${schema}${fkSection}${opSection}\n`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Synthesize a conformant OKF v0.1 bundle from a client schema + services. Pure:
|
|
165
|
+
* returns a `{ relativePath: content }` map; the caller decides whether to write
|
|
166
|
+
* it to disk. Defaults to the generated SCHEMA/SERVICES baked into the client.
|
|
167
|
+
*
|
|
168
|
+
* @param {{ schema?: object, services?: object }} [input]
|
|
169
|
+
* @returns {Record<string,string>}
|
|
170
|
+
*/
|
|
171
|
+
export function buildOkf({ schema = SCHEMA, services = SERVICES } = {}) {
|
|
172
|
+
const ops = groupOperations(services);
|
|
173
|
+
const resources = Object.keys(schema).filter((r) => ops.has(r)).sort();
|
|
174
|
+
const hasDoc = new Set(resources);
|
|
175
|
+
const files = {};
|
|
176
|
+
|
|
177
|
+
for (const name of resources) {
|
|
178
|
+
files[`entities/${name}.md`] = renderEntityDoc(name, schema[name], ops.get(name) || {}, hasDoc);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const indexItems = resources
|
|
182
|
+
.map((name) => `* [${titleFromOps(ops.get(name) || {}, name)}](${name}.md)`)
|
|
183
|
+
.join('\n');
|
|
184
|
+
files['entities/index.md'] = `# Entities\n\n${indexItems}\n`;
|
|
185
|
+
|
|
186
|
+
const signature = resources.map((r) => `${r}:${Object.keys(schema[r].fields || {}).join(',')}`).join('|');
|
|
187
|
+
files['index.md'] = `---\nokf_version: ${OKF_VERSION}\nsource_snapshot: ${fnv1a(signature)}\n---\n\n# ZeyOS Knowledge Bundle\n\n* [Entities](entities/) - ${resources.length} API-backed entity concepts.\n`;
|
|
188
|
+
|
|
189
|
+
return files;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ── Node-only loaders (lazy fs so the module stays browser-safe) ────────────────
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Read an OKF bundle directory from disk. Node only.
|
|
196
|
+
* @param {string} dir - Path to a bundle root (e.g. the shipped okf/).
|
|
197
|
+
* @returns {Promise<{ version: string|null, files: Record<string,string>, concepts: Record<string,{frontmatter:object,body:string}> }>}
|
|
198
|
+
*/
|
|
199
|
+
export async function loadOkfBundle(dir) {
|
|
200
|
+
const { readFile, readdir } = await import('node:fs/promises');
|
|
201
|
+
const path = await import('node:path');
|
|
202
|
+
|
|
203
|
+
const files = {};
|
|
204
|
+
async function walk(current, prefix) {
|
|
205
|
+
const entries = await readdir(current, { withFileTypes: true });
|
|
206
|
+
for (const entry of entries) {
|
|
207
|
+
const abs = path.join(current, entry.name);
|
|
208
|
+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
209
|
+
if (entry.isDirectory()) await walk(abs, rel);
|
|
210
|
+
else if (entry.name.endsWith('.md')) files[rel] = await readFile(abs, 'utf8');
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
await walk(dir, '');
|
|
214
|
+
|
|
215
|
+
const concepts = {};
|
|
216
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
217
|
+
if (isReserved(rel)) continue;
|
|
218
|
+
concepts[rel.replace(/\.md$/, '')] = parseConcept(content);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
let version = null;
|
|
222
|
+
if (files['index.md']) version = parseConcept(files['index.md']).frontmatter.okf_version || null;
|
|
223
|
+
|
|
224
|
+
return { version, files, concepts };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Validate an OKF bundle. Accepts a directory path (Node) or an in-memory
|
|
229
|
+
* `{ path: content }` map (universal). Returns the validateOkfFiles() result.
|
|
230
|
+
*/
|
|
231
|
+
export async function validateOkfBundle(dirOrFiles) {
|
|
232
|
+
if (typeof dirOrFiles === 'string') {
|
|
233
|
+
const { files } = await loadOkfBundle(dirOrFiles);
|
|
234
|
+
return validateOkfFiles(files);
|
|
235
|
+
}
|
|
236
|
+
return validateOkfFiles(dirOrFiles);
|
|
237
|
+
}
|
|
@@ -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.
|