iit-mcp-autotask 0.35.1 → 0.36.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/README.md +8 -8
- package/capabilities.json +2 -2
- package/dist/tools/catalog-data.d.ts +5 -5
- package/dist/tools/catalog-data.js +8 -8
- package/dist/tools/dispatch.d.ts +7 -0
- package/dist/tools/dispatch.js +63 -1
- package/dist/tools/meta.js +38 -24
- package/dist/tools/provenance-scrub.d.ts +20 -0
- package/dist/tools/provenance-scrub.js +209 -0
- package/dist/tools/reasons.d.ts +2 -1
- package/dist/tools/reasons.js +10 -0
- package/dist/tools/results/errors.js +2 -0
- package/dist/tools/results/untrusted.d.ts +10 -5
- package/dist/tools/results/untrusted.js +7 -3
- package/dist/tools/schemas.d.ts +1 -0
- package/dist/tools/server-version.d.ts +1 -1
- package/dist/tools/server-version.js +1 -1
- package/dist/tools/server.d.ts +1 -1
- package/package.json +2 -2
- package/server.json +2 -2
- package/skills/filter-semantics.md +1 -1
package/README.md
CHANGED
|
@@ -8,10 +8,10 @@ context into one call, a generic read layer for the long tail, one result envelo
|
|
|
8
8
|
packaged playbooks, strong security boundaries, and predictable per-tenant in-process state (pooled
|
|
9
9
|
client + SDK-owned TTL caches).
|
|
10
10
|
|
|
11
|
-
> **Package status (2026-10-
|
|
12
|
-
> at `0.
|
|
11
|
+
> **Package status (2026-10-29):** the runtime dependency `node-autotask` is published to public npm
|
|
12
|
+
> at `0.37.0`; the committed `pnpm-lock.yaml` pins that
|
|
13
13
|
> release with its integrity hash, so installs resolve deterministically. The dependency is always npm
|
|
14
|
-
> (node-autotask pinned exactly at `0.
|
|
14
|
+
> (node-autotask pinned exactly at `0.37.0`) — never a `file:`, `git:`, `link:` or workspace reference
|
|
15
15
|
> to a local checkout.
|
|
16
16
|
|
|
17
17
|
> **0.6.0 is a breaking release** (unreleased): tool names, result shapes and configuration all
|
|
@@ -21,7 +21,7 @@ client + SDK-owned TTL caches).
|
|
|
21
21
|
|
|
22
22
|
The **core** toolset (12 tools) is always served; `AUTOTASK_TOOLSETS` adds `service`, `records` and
|
|
23
23
|
`finance` (or `all`, 19 tools); the operator profile adds two. The whole tool surface (21 tools) is
|
|
24
|
-
listed below. Every other operation in the SDK's 1,
|
|
24
|
+
listed below. Every other operation in the SDK's 1,281-operation registry stays
|
|
25
25
|
reachable through the generic layer: `autotask_catalog` finds the key, `autotask_describe` serves
|
|
26
26
|
its contract, `autotask_read` runs it.
|
|
27
27
|
|
|
@@ -763,7 +763,7 @@ independent review with its resolutions, [`docs/reviews/0.6.0.md`](docs/reviews/
|
|
|
763
763
|
|
|
764
764
|
## SDK dependency
|
|
765
765
|
|
|
766
|
-
`iit-mcp-autotask` depends on **`node-autotask` from npm at exactly `0.
|
|
766
|
+
`iit-mcp-autotask` depends on **`node-autotask` from npm at exactly `0.37.0`** (an exact pin —
|
|
767
767
|
no range operator; subpaths `.` / `/capabilities` / `/operations` / `/resources` / `/types` /
|
|
768
768
|
`/pool`) — the only **vendor** dependency; the other two runtime dependencies are
|
|
769
769
|
`@modelcontextprotocol/server` (the MCP framework) and `zod` (schema validation). It never
|
|
@@ -773,7 +773,7 @@ user — the published tarball ships no lockfile) must resolve to the release th
|
|
|
773
773
|
reviewed against, or the plan-hash boot check refuses to start.
|
|
774
774
|
|
|
775
775
|
**Publishing `node-autotask` is the maintainer's action, done manually.** It is published on the
|
|
776
|
-
public registry at `0.
|
|
776
|
+
public registry at `0.37.0` (published 2026-10-28 UTC); the
|
|
777
777
|
exact pin and the committed `pnpm-lock.yaml` (with `pnpm-workspace.yaml`) pin the real published
|
|
778
778
|
tarball with its integrity hash, so installs resolve to exactly that release — the lockfile
|
|
779
779
|
freezes the pnpm/git/Docker paths, the pin freezes the npm/npx paths. The docs are written against this
|
|
@@ -867,9 +867,9 @@ physical assets (Autotask models them as Configuration Items — covered).
|
|
|
867
867
|
|
|
868
868
|
| # | Item | Status |
|
|
869
869
|
|---|------|--------|
|
|
870
|
-
| B1 | **Project phase & task lists** (W7 sub-capability): no `project_tasks`/`project_phases` operations in the SDK's 1,
|
|
870
|
+
| B1 | **Project phase & task lists** (W7 sub-capability): no `project_tasks`/`project_phases` operations in the SDK's 1,281-op registry. W7 ships with project status + context; phase/task lists excluded and documented. Classification (SDK gap vs vendor API-version limit) is decided by recon's spec-side check (A2). | blocked — documented |
|
|
871
871
|
| B2 | **Time-entry writes on ww29/australia-nz for API-only users**: vendor 500 (live-verified). Time-entry creation works where the API user is a real resource, through the operator profile (`log-time-entry`); on ww29 the write is classified `failed` (a 500 with a vendor `errors[]` body), never `unknown`. | zone limitation |
|
|
872
|
-
| B3 | **`time_entries.getPosted`/`getUnposted`: the zone's TimeEntry entity has no `postedDate`**, so SDK 0.31 and earlier 500ed (and `postedWithin`/`dateWorkedWindow` silently counted every entry unposted). SDK 0.32.0 derives posted state from a `billing_items.timeEntryID` join and returns `{ items, hasMore, window, approvedCount, unapprovedCount }` (optional date-only `dateFrom`/`dateTo`; no dates reads only the first 500 entries). Needs BillingItems read permission (one extra request per call); `approvedCount`/`unapprovedCount` are not posted state. Reachable via the generic layer. | resolved in SDK 0.32.0 (not re-verified live) |
|
|
872
|
+
| B3 | **`time_entries.getPosted`/`getUnposted`: the zone's TimeEntry entity has no `postedDate`**, so SDK 0.31 and earlier 500ed (and `postedWithin`/`dateWorkedWindow` silently counted every entry unposted). SDK 0.32.0 derives posted state from a `billing_items.timeEntryID` join and returns `{ items, hasMore, window, approvedCount, unapprovedCount }` (optional date-only `dateFrom`/`dateTo`; no dates reads only the first 500 entries). Needs BillingItems read permission (one extra request per call); `approvedCount`/`unapprovedCount` are not posted state. SDK 0.37.0 adds `cursor`/`nextCursor` (page past 500 entries), `unconfirmed` (entries whose posted state is unknown, kept out of both counts) and `countOnly: true` on the four posted-state ops. Reachable via the generic layer. | resolved in SDK 0.32.0 (not re-verified live) |
|
|
873
873
|
| B4 | **CI create on tenants without products** (ww29 product wall: `products.count = 0`): `configuration_items.create` requires an existing `productID`. No dedicated CI-create in v1; the wall is documented on the generic side. | zone limitation |
|
|
874
874
|
| B5 | **Blind count/query surfaces on ww29** (companies not owned by the caller; tickets): count/query return 0/empty even when data exists. Not fixable client-side; the surface carries the typed blind-surface sentinel — `get(id)`/full-profile are the authorities. | zone limitation |
|
|
875
875
|
| B6 | **`node-autotask` npm publish**: the Phase-C install gate re-checks the registry; if still 404 the build pauses and escalates (SCOPING-resolved: the user publishes — no git/file dependency fallback). | open — user action |
|
package/capabilities.json
CHANGED
|
@@ -69,7 +69,7 @@
|
|
|
69
69
|
},
|
|
70
70
|
{
|
|
71
71
|
"id": "autotask.platform.catalog",
|
|
72
|
-
"description": "Find the exact operation key for anything without a dedicated tool: ranked search (q) over the 1,
|
|
72
|
+
"description": "Find the exact operation key for anything without a dedicated tool: ranked search (q) over the 1,281 registry operations, with effect and whether this deployment serves it. No vendor call.",
|
|
73
73
|
"mcpPrimitive": "tool",
|
|
74
74
|
"toolName": "autotask_catalog",
|
|
75
75
|
"type": "read",
|
|
@@ -395,7 +395,7 @@
|
|
|
395
395
|
"autotask_describe",
|
|
396
396
|
"autotask_read"
|
|
397
397
|
],
|
|
398
|
-
"backingRegistryOperations":
|
|
398
|
+
"backingRegistryOperations": 1281,
|
|
399
399
|
"note": "autotask_catalog finds the exact operation key (ranked search), autotask_describe serves its contract, autotask_read runs any effect=read key. Writes go through the operator profile (autotask_write then autotask_apply); authenticate.* is never served."
|
|
400
400
|
},
|
|
401
401
|
"schemas": {}
|
|
@@ -65,10 +65,10 @@ export declare const UNTRUSTED_NOTE = "Externally-sourced free text in the resul
|
|
|
65
65
|
/** The record's plan-derived untrusted field list (the registry stamp). */
|
|
66
66
|
export declare function untrustedFieldsOf(rec: CapabilityRecord): string[];
|
|
67
67
|
/**
|
|
68
|
-
* The resources that carry user-defined fields (#183 S8),
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
68
|
+
* The resources that carry user-defined fields (#183 S8), read from the registry with zero wire:
|
|
69
|
+
* each record's published `udfCapable` flag (node-autotask 0.37.0 #254; the plan's entities whose SDK
|
|
70
|
+
* resource class normalises `userDefinedFields`). Not the `getUdfInfo` set (every vendor entity has
|
|
71
|
+
* that metadata read, UDFs or not). A test pins the exact set.
|
|
72
72
|
*/
|
|
73
73
|
export declare const UDF_CAPABLE: ReadonlySet<string>;
|
|
74
74
|
/** The per-tool description budget (A0 slimming: every served description <= 250 chars). */
|
|
@@ -109,7 +109,7 @@ export declare function cacheOptionsForPreset(preset: 'on' | 'off', fieldCacheTt
|
|
|
109
109
|
* fails the boot (or the CI test) instead of quietly shipping a tool surface that no longer
|
|
110
110
|
* describes the SDK behind it.
|
|
111
111
|
*/
|
|
112
|
-
export declare const BUILT_AGAINST_PLAN_HASH = "
|
|
112
|
+
export declare const BUILT_AGAINST_PLAN_HASH = "5cd6c6679384651e7f67a0135915bcc031ef542640d9ebb6992b729ca42efe37";
|
|
113
113
|
/**
|
|
114
114
|
* Fail fast when the installed SDK's plan has moved out from under this surface.
|
|
115
115
|
* Called at boot (main.ts) and safe to call repeatedly (pure comparison).
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Two sources, both shipped inside the `node-autotask` package:
|
|
5
5
|
* - `MCP_TOOL_CATALOG.json` — the machine catalog: one row per registry operation (projected
|
|
6
6
|
* tool, effect, dry-run/confirm, required args, tier, reach) and the plan hash;
|
|
7
|
-
* - the compiled capability registry (`node-autotask/capabilities`) — the 1,
|
|
7
|
+
* - the compiled capability registry (`node-autotask/capabilities`) — the 1,281-operation
|
|
8
8
|
* records the SDK's own invoke governor resolves against.
|
|
9
9
|
*
|
|
10
10
|
* The JSON file is NOT behind the package's `exports` map, so it is read from the installed
|
|
@@ -102,12 +102,12 @@ export function untrustedFieldsOf(rec) {
|
|
|
102
102
|
return Array.isArray(list) ? list : [];
|
|
103
103
|
}
|
|
104
104
|
/**
|
|
105
|
-
* The resources that carry user-defined fields (#183 S8),
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
105
|
+
* The resources that carry user-defined fields (#183 S8), read from the registry with zero wire:
|
|
106
|
+
* each record's published `udfCapable` flag (node-autotask 0.37.0 #254; the plan's entities whose SDK
|
|
107
|
+
* resource class normalises `userDefinedFields`). Not the `getUdfInfo` set (every vendor entity has
|
|
108
|
+
* that metadata read, UDFs or not). A test pins the exact set.
|
|
109
109
|
*/
|
|
110
|
-
export const UDF_CAPABLE = new Set(RECORDS.filter((r) =>
|
|
110
|
+
export const UDF_CAPABLE = new Set(RECORDS.filter((r) => r.udfCapable).map((r) => r.resource));
|
|
111
111
|
/** The per-tool description budget (A0 slimming: every served description <= 250 chars). */
|
|
112
112
|
export const MAX_DESCRIPTION_CHARS = 250;
|
|
113
113
|
/**
|
|
@@ -117,7 +117,7 @@ export const MAX_DESCRIPTION_CHARS = 250;
|
|
|
117
117
|
*/
|
|
118
118
|
export const TOOL_DESCRIPTIONS = {
|
|
119
119
|
autotask_guide: 'Step-by-step playbooks for multi-step Autotask tasks. No args: the index; {skill} returns one playbook, {skill, section} one section. Static text, no vendor call. Use it first when a task needs several tools.',
|
|
120
|
-
autotask_catalog: 'Find the exact operation key for anything without a dedicated tool: ranked search (q) over the 1,
|
|
120
|
+
autotask_catalog: 'Find the exact operation key for anything without a dedicated tool: ranked search (q) over the 1,281 registry operations, with effect and whether this deployment serves it. No vendor call.',
|
|
121
121
|
autotask_describe: 'One operation\'s (registry key or tool name) exact input/output contract, examples, errors, policy verdict and playbooks (operation), or one resource\'s relationship map: parent, children, lookup keys (resource). No vendor call.',
|
|
122
122
|
autotask_read: 'Run reads by exact registry key, or up to 10 in one call: unbilled work, profitability and margin, SLA health. *.query: 50 rows; scope by company/contract ids (unscoped = tenant walk); roll-ups: autotask_aggregate; slim: includeFields, noteChars.',
|
|
123
123
|
autotask_resolve: 'Find the record behind an identifier: id or name, ticket number, CI serial, contact email (by), staff by email, user name or name (resource resources, include_api_users); one resource or a sweep. Exact by default; contains: companies, tickets, sweep.',
|
|
@@ -173,7 +173,7 @@ export function cacheOptionsForPreset(preset, fieldCacheTtlMs) {
|
|
|
173
173
|
* fails the boot (or the CI test) instead of quietly shipping a tool surface that no longer
|
|
174
174
|
* describes the SDK behind it.
|
|
175
175
|
*/
|
|
176
|
-
export const BUILT_AGAINST_PLAN_HASH = '
|
|
176
|
+
export const BUILT_AGAINST_PLAN_HASH = '5cd6c6679384651e7f67a0135915bcc031ef542640d9ebb6992b729ca42efe37';
|
|
177
177
|
/**
|
|
178
178
|
* Fail fast when the installed SDK's plan has moved out from under this surface.
|
|
179
179
|
* Called at boot (main.ts) and safe to call repeatedly (pure comparison).
|
package/dist/tools/dispatch.d.ts
CHANGED
|
@@ -56,6 +56,13 @@ export declare function abortedAnswer(corr: string): ToolReturn;
|
|
|
56
56
|
* in a 4xx body; the error wrap value-scrubs them before the envelope crosses.
|
|
57
57
|
*/
|
|
58
58
|
export declare function requestSecrets(ctx: ToolContext): string[];
|
|
59
|
+
/**
|
|
60
|
+
* A vendor TimeEntries continuation URL (#224; mirrors the SDK's cursorToPath + entryCursorPath, tighter):
|
|
61
|
+
* printable ASCII with no whitespace, absolute and already in canonical form, on this request's own Autotask origin, no userinfo, no
|
|
62
|
+
* fragment, exactly the continuation path, and only `paging` / `search` query parameters with encoded
|
|
63
|
+
* values. Anything else is not granted the cursor exemption (it is served as wrapped, capped vendor text).
|
|
64
|
+
*/
|
|
65
|
+
export declare function isEntryCursor(value: string, origin: string | undefined): boolean;
|
|
59
66
|
/**
|
|
60
67
|
* Per-operation post-processing for results read through `autotask_read`: the helper operations
|
|
61
68
|
* that used to have their own tools carry vendor text beyond the registry stamp, and this is where
|
package/dist/tools/dispatch.js
CHANGED
|
@@ -15,8 +15,9 @@ import { AutotaskConfigError } from 'node-autotask';
|
|
|
15
15
|
import { stripUntrustedDeep } from 'node-autotask/untrusted';
|
|
16
16
|
import { assertFilterShape, QUERY_LIMIT, QUERY_RESOURCES } from './schemas.js';
|
|
17
17
|
import { recordById, untrustedFieldsOf } from './catalog-data.js';
|
|
18
|
+
import { AUTOTASK_BASE_PATH } from '../base-hosts.js';
|
|
18
19
|
import { releaseClientOnError } from './context.js';
|
|
19
|
-
import { abortedResult, closedArgsError, crossResourceUntrusted, errorContent, isFileOperation, markFilePayload, markResult, outcome, refusalContent } from './results.js';
|
|
20
|
+
import { abortedResult, closedArgsError, crossResourceUntrusted, errorContent, isFileOperation, markFilePayload, markResult, markServerField, outcome, refusalContent } from './results.js';
|
|
20
21
|
import { lastFullCalendarMonth, lastFullCalendarMonthLabel } from './profitability-window.js';
|
|
21
22
|
import { assertOperationPermitted } from '../policy.js';
|
|
22
23
|
import { ACK_KEY, guardUnscopedWalk, ScopeRequiredError } from './scope-guard.js';
|
|
@@ -143,6 +144,35 @@ export function requestSecrets(ctx) {
|
|
|
143
144
|
}
|
|
144
145
|
return out;
|
|
145
146
|
}
|
|
147
|
+
/** The time_entries posted-state reads whose `nextCursor` is a pass-back pagination cursor (SDK 0.37.0 #242). */
|
|
148
|
+
const CURSOR_OPS = new Set(['time_entries.getPosted', 'time_entries.getUnposted', 'time_entries.postedWithin', 'time_entries.dateWorkedWindow']);
|
|
149
|
+
/** The continuation path a TimeEntries cursor names, under the fixed base path (compared case-insensitively). */
|
|
150
|
+
const ENTRY_CURSOR_PATH = `${AUTOTASK_BASE_PATH}/V1.0/TimeEntries/query/next`.toLowerCase();
|
|
151
|
+
/** The query a vendor continuation URL carries: `paging` and `search`, URL-encoded values only. */
|
|
152
|
+
const ENTRY_CURSOR_PARAM = /^(?:paging|search)=[A-Za-z0-9%._~+-]+$/;
|
|
153
|
+
/**
|
|
154
|
+
* A vendor TimeEntries continuation URL (#224; mirrors the SDK's cursorToPath + entryCursorPath, tighter):
|
|
155
|
+
* printable ASCII with no whitespace, absolute and already in canonical form, on this request's own Autotask origin, no userinfo, no
|
|
156
|
+
* fragment, exactly the continuation path, and only `paging` / `search` query parameters with encoded
|
|
157
|
+
* values. Anything else is not granted the cursor exemption (it is served as wrapped, capped vendor text).
|
|
158
|
+
*/
|
|
159
|
+
export function isEntryCursor(value, origin) {
|
|
160
|
+
if (origin === undefined || !/^[\x21-\x7e]+$/.test(value) || !value.startsWith(`${origin}/`))
|
|
161
|
+
return false;
|
|
162
|
+
let u;
|
|
163
|
+
try {
|
|
164
|
+
u = new URL(value);
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
if (u.href !== value || u.origin !== origin || u.username !== '' || u.password !== '' || u.hash !== '' || value.includes('#'))
|
|
170
|
+
return false;
|
|
171
|
+
if (u.pathname.toLowerCase() !== ENTRY_CURSOR_PATH)
|
|
172
|
+
return false;
|
|
173
|
+
const query = u.search.replace(/^\?/, '');
|
|
174
|
+
return query === '' || query.split('&').every((p) => ENTRY_CURSOR_PARAM.test(p));
|
|
175
|
+
}
|
|
146
176
|
/** The reads behind the profitability result's customer and part containers (their untrusted stamps). */
|
|
147
177
|
const PROFITABILITY_PART_OPS = [
|
|
148
178
|
'contracts.profitability',
|
|
@@ -179,7 +209,30 @@ const PROFITABILITY_UNTRUSTED = [
|
|
|
179
209
|
*/
|
|
180
210
|
export const READ_POSTPROCESS = {
|
|
181
211
|
'contracts.profitability': (result) => markResult(result, PROFITABILITY_UNTRUSTED),
|
|
212
|
+
'operations.revenueRollup': markRevenueRollup,
|
|
182
213
|
};
|
|
214
|
+
/**
|
|
215
|
+
* `operations.revenueRollup` (SDK 0.37.0): the helper rows carry no `metadata.untrustedFields`. Vendor text
|
|
216
|
+
* sits in `rows[].keys.<dimension>` (`name`, `division`, `label`) and in the values of
|
|
217
|
+
* `coverage.contractTypeMap` / `contractCategoryMap` (id -> picklist label). The cross-resource list
|
|
218
|
+
* covers `name` and `label` at every depth; `division` (the LOB level-1 name) is added. ponytail: the
|
|
219
|
+
* `coverage.excluded.otherContract*` maps are keyed by label, and a key cannot be wrapped (it is still
|
|
220
|
+
* cleaned and capped by the output sanitiser).
|
|
221
|
+
*/
|
|
222
|
+
const ROLLUP_UNTRUSTED = [...crossResourceUntrusted(), 'division'];
|
|
223
|
+
function markRevenueRollup(result) {
|
|
224
|
+
const out = markResult(result, ROLLUP_UNTRUSTED);
|
|
225
|
+
const cov = out?.coverage;
|
|
226
|
+
for (const key of ['contractTypeMap', 'contractCategoryMap']) {
|
|
227
|
+
const map = cov?.[key];
|
|
228
|
+
if (map === null || typeof map !== 'object' || Array.isArray(map))
|
|
229
|
+
continue;
|
|
230
|
+
for (const [id, label] of Object.entries(map))
|
|
231
|
+
if (typeof label === 'string')
|
|
232
|
+
markServerField(map, id, 'wrap');
|
|
233
|
+
}
|
|
234
|
+
return out;
|
|
235
|
+
}
|
|
183
236
|
/**
|
|
184
237
|
* Server-built annotations added to a served result AFTER the untrusted marking (issue #102). Each
|
|
185
238
|
* `operations.fieldCatalog` resource row says which `autotask_query` operation serves it (or null:
|
|
@@ -295,6 +348,10 @@ export async function readOneItem(ctx, operation, args, signal, opts) {
|
|
|
295
348
|
}
|
|
296
349
|
const nameChecked = operation.endsWith('.query') || operation.endsWith('.count');
|
|
297
350
|
const result = await ctx.ops.invokeRead(ctx.client, operation, cleanArgs, opts, signal).catch((err) => {
|
|
351
|
+
// #224: a refused cursor the caller passed is the caller's input; a refused vendor continuation is not
|
|
352
|
+
if (CURSOR_OPS.has(operation) && err?.code === 'RESPONSE_REJECTED' && cleanArgs.some((a) => a !== null && typeof a === 'object' && typeof a.cursor === 'string')) {
|
|
353
|
+
Object.assign(err, { callerInput: true });
|
|
354
|
+
}
|
|
298
355
|
throw nameChecked ? rewriteFieldNameRefusal(err, operation, operation.slice(0, operation.lastIndexOf('.')), 'read') : err;
|
|
299
356
|
});
|
|
300
357
|
if (result === undefined)
|
|
@@ -312,6 +369,11 @@ export async function readOneItem(ctx, operation, args, signal, opts) {
|
|
|
312
369
|
const projected = result !== null && typeof result === 'object' ? markResult(result, crossResourceUntrusted()) : result;
|
|
313
370
|
if (isFileOperation(operation))
|
|
314
371
|
markFilePayload(projected);
|
|
372
|
+
// #224: the posted-state reads' nextCursor goes back verbatim as opts.cursor; the 500 cap would cut it
|
|
373
|
+
const cursor = CURSOR_OPS.has(operation) && projected !== null && typeof projected === 'object' ? projected.nextCursor : undefined;
|
|
374
|
+
// only a TimeEntries continuation URL is a cursor; anything else is vendor text (500 cap, wrapped)
|
|
375
|
+
if (typeof cursor === 'string')
|
|
376
|
+
markServerField(projected, 'nextCursor', isEntryCursor(cursor, ctx.origin) ? 'cursor' : 'wrap');
|
|
315
377
|
if (operation === 'operations.fieldCatalog')
|
|
316
378
|
annotateCatalogRows(projected);
|
|
317
379
|
return { result: projected, ...(note !== undefined ? { note } : {}), ...(scopeNote !== undefined ? { scopeNote } : {}) };
|
package/dist/tools/meta.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
import { AutotaskConfigError } from 'node-autotask';
|
|
19
19
|
import { effectiveRequiredFields } from 'node-autotask/operations';
|
|
20
20
|
import { UDF_MERGE_NOTE, UDF_NONE, UDF_ROWS_NOTE, associationFields, associationRules, caveatNotes, constraintNotes, coupledNotes, dateConventionNotes, statusScopeNote, udfDefinitionRows, udfMergeNote, udfMoreNote, udfUnavailable } from './write-guidance.js';
|
|
21
|
+
import { scrubDeep, scrubProvenance } from './provenance-scrub.js';
|
|
21
22
|
import { CATALOG, RECORDS, SDK_VERSION, TOOL_NAME_BY_OP, UDF_CAPABLE, opsForTool, recordById, servedToolName, untrustedFieldsOf } from './catalog-data.js';
|
|
22
23
|
import { releaseClientOnError } from './context.js';
|
|
23
24
|
import { abortedAnswer, InputInvalidError, readOneItem, requestSecrets } from './dispatch.js';
|
|
@@ -259,7 +260,7 @@ const NO_CREATE_NOTE = 'do not look for a create operation: this server has none
|
|
|
259
260
|
/**
|
|
260
261
|
* S8 (#183): the UDF block for `describe {resource}`: the mechanism facts (SDK row shape) + the definitions
|
|
261
262
|
* pointer. Zero wire: the capability fact is the registry itself (UDF_CAPABLE: the resources whose records
|
|
262
|
-
*
|
|
263
|
+
* carry the published udfCapable flag, SDK 0.37.0 #254; SDK 0.34.0 #219 N7), and the definitions are the live tenant
|
|
263
264
|
* read the pointer names (the scoped fieldCatalog read with the `udf` include).
|
|
264
265
|
*/
|
|
265
266
|
function udfBlock(resource) {
|
|
@@ -316,7 +317,7 @@ async function guidanceFor(ctx, rec, reachable) {
|
|
|
316
317
|
// #173: the positional [from, to, opts] mixes types; the same window as one uniform-string filter
|
|
317
318
|
return {
|
|
318
319
|
exampleArgs: ['2026-10-20', '2026-10-22', { resourceID: 123 }],
|
|
319
|
-
sameWindowAsQuery: 'autotask_query {resource:"time_entries", filter:[{field:"resourceID",op:"eq",value:"123"},{field:"dateWorked",op:"gte",value:"2026-10-20"},{field:"dateWorked",op:"lte",value:"2026-10-22"}], limit:500}: the same rows up to limit (
|
|
320
|
+
sameWindowAsQuery: 'autotask_query {resource:"time_entries", filter:[{field:"resourceID",op:"eq",value:"123"},{field:"dateWorked",op:"gte",value:"2026-10-20"},{field:"dateWorked",op:"lte",value:"2026-10-22"}], limit:500}: the same rows up to limit (one window call returns at most 500 and a nextCursor to pass as cursor for the rest; dateWorked is a date, both days inclusive), without the posted counts, for a client that cannot send a mixed-type array.',
|
|
320
321
|
};
|
|
321
322
|
}
|
|
322
323
|
if (op === 'moveToTicket') {
|
|
@@ -352,8 +353,9 @@ async function guidanceFor(ctx, rec, reachable) {
|
|
|
352
353
|
// The SDK's one precedence (live-proven > zone > spec) is a create fact; an update keeps the vendor's own set.
|
|
353
354
|
const zone = op === 'create' ? await zoneFactsOf(ctx, rec.resource) : undefined;
|
|
354
355
|
facts = zone?.createGuidance ?? undefined;
|
|
355
|
-
//
|
|
356
|
-
|
|
356
|
+
// quirk statements are SDK free text: none is served on a create, only a pointer, and only when a quirk
|
|
357
|
+
// names the create op (SDK 0.37.0 #253 structured quirks; #224)
|
|
358
|
+
hasQuirks = (zone?.quirks ?? []).some((q) => Array.isArray(q?.ops) && q.ops.includes('create'));
|
|
357
359
|
const eff = op === 'create' ? effectiveRequiredFields({ requiredFields: spec }, zone) : { fields: spec, from: 'spec', unproven: true };
|
|
358
360
|
// eff.fields is already the unconditional set (value-gated fields are in eff.conditional, SDK 0.34.0)
|
|
359
361
|
required = eff.fields;
|
|
@@ -416,21 +418,32 @@ async function zoneFactsOf(ctx, resource) {
|
|
|
416
418
|
}
|
|
417
419
|
/** The one read envelope of autotask_read and autotask_fetch_many (#97), served on the on-demand surface. */
|
|
418
420
|
const READ_ENVELOPE = 'data = { results: Slot[], ok: <count>, failed: <count>, budget?, budgetWarning? }, always an array in input order, one slot for a single read. Slot = { ok: true, operation, result, windowNote?, scopeNote? } or { ok: false, operation, error }; autotask_fetch_many slots also echo resource and id (operation is "<resource>.get"; a miss is { ok: false, error: { code: "NOT_FOUND" } }). A single read that fails still answers the whole-call error envelope.';
|
|
419
|
-
/** Served relationship map
|
|
421
|
+
/** Served relationship map without the SDK's free text: createGuidance loses `vendorEvidence` and
|
|
422
|
+
* `blocker.message`/`remedy`; each structured `zoneFacts.quirks` entry loses `evidence` (SDK 0.37.0 #253: raw
|
|
423
|
+
* campaign strings) in {@link scrubDeep}, which drops every string `evidence` key and scrubs the rest (#224). */
|
|
420
424
|
function projectMap(map) {
|
|
421
425
|
const m = map;
|
|
422
|
-
const
|
|
423
|
-
if (
|
|
424
|
-
return map;
|
|
425
|
-
const
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
426
|
+
const z = m?.zoneFacts;
|
|
427
|
+
if (z === undefined || z === null || typeof z !== 'object')
|
|
428
|
+
return scrubDeep(map);
|
|
429
|
+
const zone = { ...z };
|
|
430
|
+
// #224: a live write keeps its op and outcome; its date and note are maintainer history
|
|
431
|
+
const lw = z.liveWrites;
|
|
432
|
+
if (Array.isArray(lw))
|
|
433
|
+
zone.liveWrites = lw.map((w) => (w !== null && typeof w === 'object' ? { op: w.op, outcome: w.outcome } : w));
|
|
434
|
+
const g = z.createGuidance;
|
|
435
|
+
if (g !== undefined && g !== null && typeof g === 'object') {
|
|
436
|
+
const slim = { ...g };
|
|
437
|
+
delete slim.vendorEvidence;
|
|
438
|
+
if (slim.blocker !== null && typeof slim.blocker === 'object') {
|
|
439
|
+
const b = { ...slim.blocker };
|
|
440
|
+
delete b.message;
|
|
441
|
+
delete b.remedy;
|
|
442
|
+
slim.blocker = b;
|
|
443
|
+
}
|
|
444
|
+
zone.createGuidance = slim;
|
|
432
445
|
}
|
|
433
|
-
return { ...m, zoneFacts:
|
|
446
|
+
return scrubDeep({ ...m, zoneFacts: zone });
|
|
434
447
|
}
|
|
435
448
|
/** Served record: `metadata.caveats` rows keep kind/fields/remedyOp, not the SDK's free-text `note`. */
|
|
436
449
|
function projectCaveats(rec) {
|
|
@@ -460,14 +473,15 @@ function servedUsage(rec) {
|
|
|
460
473
|
// the read-back is one `<resource>.get`: a create with no get in the registry answers `data.record: null`
|
|
461
474
|
return u.includes(CREATE_USAGE_FALSE) && recordById(`${rec.resource}.get`) !== undefined ? u.replace(CREATE_USAGE_FALSE, CREATE_USAGE_TRUE) : u;
|
|
462
475
|
}
|
|
463
|
-
/** Served record: caveats projected
|
|
476
|
+
/** Served record: caveats projected, the create usage corrected (AC24), the SDK's own test list dropped and
|
|
477
|
+
* its maintainer provenance scrubbed (#224). */
|
|
464
478
|
function servedRecord(rec) {
|
|
465
|
-
const projected = projectCaveats(rec);
|
|
479
|
+
const projected = { ...projectCaveats(rec) };
|
|
480
|
+
delete projected.tests;
|
|
466
481
|
const md = projected.metadata;
|
|
467
|
-
const usage = servedUsage(
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
return { ...projected, metadata: { ...md, usage } };
|
|
482
|
+
const usage = servedUsage(rec);
|
|
483
|
+
const out = typeof md?.usage !== 'string' || usage === undefined || md.usage === usage ? projected : { ...projected, metadata: { ...md, usage } };
|
|
484
|
+
return scrubDeep(out);
|
|
471
485
|
}
|
|
472
486
|
/** The per-field caps of a describe summary (characters): purpose, args and usage only, about 2 KB in all (#197). */
|
|
473
487
|
const SUMMARY_CAPS = { purpose: 500, args: 600, usage: 400 };
|
|
@@ -486,13 +500,13 @@ function summariseRecord(ctx, operation, viaTool) {
|
|
|
486
500
|
const reason = refusalReason(operation, rec, ctx.config, ctx.writable);
|
|
487
501
|
const md = (rec.metadata ?? {});
|
|
488
502
|
const row = CATALOG.catalog.find((r) => r.operation === operation);
|
|
489
|
-
const text = (v) => (typeof v === 'string' ? v : '');
|
|
503
|
+
const text = (v) => (typeof v === 'string' ? scrubProvenance(v) : '');
|
|
490
504
|
const cut = [];
|
|
491
505
|
const tool = viaTool ?? TOOL_NAME_BY_OP[operation];
|
|
492
506
|
return ok(`Summary of "${rec.id}" (purpose, args, usage).`, {
|
|
493
507
|
operation: rec.id,
|
|
494
508
|
...(tool !== undefined ? { tool, ...toolServed(ctx, tool) } : {}),
|
|
495
|
-
purpose: capText(text(md.purpose) || (row?.purpose
|
|
509
|
+
purpose: capText(text(md.purpose) || text(row?.purpose), SUMMARY_CAPS.purpose, 'purpose', cut),
|
|
496
510
|
requiredArgs: row?.requiredArgs ?? [],
|
|
497
511
|
args: capText(text(md.args), SUMMARY_CAPS.args, 'args', cut),
|
|
498
512
|
usage: capText(text(servedUsage(rec)), SUMMARY_CAPS.usage, 'usage', cut),
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tools/provenance-scrub.ts — maintainer provenance out of the SDK text `autotask_describe` serves (#224).
|
|
3
|
+
*
|
|
4
|
+
* The SDK's registry records and relationship maps carry its own build history inside otherwise useful
|
|
5
|
+
* guidance: the test zone (`ww29`), verification dates, issue numbers, campaign stages and markers, wall
|
|
6
|
+
* ids. None of it helps a model call the API, and some of it is tenant-identifying. Only the provenance
|
|
7
|
+
* tokens are removed: a parenthetical is split into clauses (`;`, ` — `, `, ` at its own depth) and only a
|
|
8
|
+
* clause that itself carries a marker is edited; a bare date survives unless its clause carries a marker
|
|
9
|
+
* (a format example such as `e.g. 2026-01-01` stays); a parenthetical left empty is dropped.
|
|
10
|
+
* Deterministic text surgery, not a guarantee: a marker the patterns below do not name passes through.
|
|
11
|
+
* ponytail: pattern list, extend it when an SDK bump adds a new marker shape.
|
|
12
|
+
*/
|
|
13
|
+
/** Object nesting bound for {@link scrubDeep}; deeper subtrees are dropped, never served unscrubbed. */
|
|
14
|
+
export declare const MAX_DEEP = 32;
|
|
15
|
+
export declare const MAX_PAREN_DEPTH = 16;
|
|
16
|
+
/** `text` with the provenance tokens removed (a text with no marker is returned as it is). */
|
|
17
|
+
export declare function scrubProvenance(text: string): string;
|
|
18
|
+
export declare function scrubExample(text: string): string;
|
|
19
|
+
/** A deep copy with every string scrubbed (`examples` by {@link scrubExample}) and the provenance-only string keys dropped. */
|
|
20
|
+
export declare function scrubDeep(value: unknown, depth?: number): unknown;
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tools/provenance-scrub.ts — maintainer provenance out of the SDK text `autotask_describe` serves (#224).
|
|
3
|
+
*
|
|
4
|
+
* The SDK's registry records and relationship maps carry its own build history inside otherwise useful
|
|
5
|
+
* guidance: the test zone (`ww29`), verification dates, issue numbers, campaign stages and markers, wall
|
|
6
|
+
* ids. None of it helps a model call the API, and some of it is tenant-identifying. Only the provenance
|
|
7
|
+
* tokens are removed: a parenthetical is split into clauses (`;`, ` — `, `, ` at its own depth) and only a
|
|
8
|
+
* clause that itself carries a marker is edited; a bare date survives unless its clause carries a marker
|
|
9
|
+
* (a format example such as `e.g. 2026-01-01` stays); a parenthetical left empty is dropped.
|
|
10
|
+
* Deterministic text surgery, not a guarantee: a marker the patterns below do not name passes through.
|
|
11
|
+
* ponytail: pattern list, extend it when an SDK bump adds a new marker shape.
|
|
12
|
+
*/
|
|
13
|
+
const DATE = String.raw `20\d\d-\d\d-\d\d`;
|
|
14
|
+
/** A provenance marker (a clause carrying one is edited; the others are left as they are). */
|
|
15
|
+
const MARKER = new RegExp(String.raw `ww29|\bAU03\b|australia-nz|live-verified|\bissues? #\d+|\bPR #\d+|^#\d+(?:,\s*#\d+)*$|\bstage[- ]\d+\b|FULL100W-\d+|\bconfirm-\d+\b|\bre-(?:walled|stamped|verified|probed)\b|\bW-[A-Z]\d+\b|\b(?:measured|live|verified|re-verified|fetched|on)\s+${DATE}`, 'i');
|
|
16
|
+
/** Token removals, in order; `inner` ones apply only inside a marker clause of a parenthetical. */
|
|
17
|
+
const TOKENS = [
|
|
18
|
+
[/^\s*issues? #\d+(?:,\s*#\d+)*\s*:\s*/i, '', 'any'],
|
|
19
|
+
[/\bissues? #\d+(?:,\s*#\d+)*/gi, '', 'any'],
|
|
20
|
+
[/\bPR #\d+/g, '', 'any'],
|
|
21
|
+
[/^\s*#\d+(?:,\s*#\d+)*\s*$/, '', 'inner'],
|
|
22
|
+
[/\b(?:the )?W-[A-Z]\d+ (?:wall|pointer note)\b|\bwall W-[A-Z]\d+\b|\bW-[A-Z]\d+:?/g, '', 'any'],
|
|
23
|
+
[/\b(?:the )?stage[- ]\d+(?: apply[- ]matrix)?\b/gi, '', 'any'],
|
|
24
|
+
[/\b(?:marker )?FULL100W-\d+|\bconfirm-\d+\b/g, '', 'any'],
|
|
25
|
+
[/\blive-verified\b/gi, 'verified', 'any'],
|
|
26
|
+
[new RegExp(String.raw `\bmeasured\s+${DATE}(?::[^;,)]*)?|\b(?:live|verified|re-verified|fetched|on)\s+${DATE}:?`, 'gi'), '', 'any'],
|
|
27
|
+
[/\bww29[- ](?:proven|zone-labelled)\b/g, 'zone-proven', 'any'],
|
|
28
|
+
[/\bin ww29\b/g, 'in this zone', 'outer'],
|
|
29
|
+
[/\bww29\b/g, 'this zone', 'outer'],
|
|
30
|
+
[/\bww29\b/g, 'this zone', 'inner'],
|
|
31
|
+
[/\baustralia-nz\b|\bAU03\b/g, '', 'any'],
|
|
32
|
+
[new RegExp(String.raw `\b${DATE}\b`, 'g'), '', 'inner'],
|
|
33
|
+
];
|
|
34
|
+
function tokens(text, where) {
|
|
35
|
+
let out = text;
|
|
36
|
+
for (const [rx, to, scope] of TOKENS)
|
|
37
|
+
if (scope === 'any' || scope === where)
|
|
38
|
+
out = out.replace(rx, to);
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
/** Collapse the gaps a removal leaves: doubled spaces, a space before punctuation, doubled separators. */
|
|
42
|
+
function tidy(text) {
|
|
43
|
+
return text
|
|
44
|
+
.replace(/(?<=\S)[ \t]{2,}/g, ' ')
|
|
45
|
+
.replace(/ +([,;:)]|\.(?!\.))/g, '$1')
|
|
46
|
+
.replace(/\( +/g, '(')
|
|
47
|
+
.replace(/([,;:])(?:\s*[,;:])+/g, '$1')
|
|
48
|
+
.replace(/[,;:]\)/g, ')')
|
|
49
|
+
.replace(/\s*[—–]\s*\)/g, ')');
|
|
50
|
+
}
|
|
51
|
+
/** What a marker clause can reduce to that carries no fact of its own. */
|
|
52
|
+
const EMPTY_CLAUSE = /^(?:(?:live|verified|re-verified|re-walled|re-stamped|re-probed|scope|on|in|this|zone|the)\s*)+$/i;
|
|
53
|
+
const DATE_ONLY = new RegExp(String.raw `^\s*${DATE}\s*$`);
|
|
54
|
+
/** Separators and spaces left at either end of an edited clause. */
|
|
55
|
+
const LEFTOVER = /^[\s,;:—–-]+|[\s,;:—–-]+$/g;
|
|
56
|
+
/** Split at `;`, ` — ` and `, ` at depth 0 (kept as separators), edit marker clauses only, drop emptied ones. */
|
|
57
|
+
function clauses(inner) {
|
|
58
|
+
const parts = [];
|
|
59
|
+
let depth = 0;
|
|
60
|
+
let start = 0;
|
|
61
|
+
for (let i = 0; i < inner.length; i++) {
|
|
62
|
+
const c = inner[i];
|
|
63
|
+
if (c === '(')
|
|
64
|
+
depth++;
|
|
65
|
+
else if (c === ')')
|
|
66
|
+
depth--;
|
|
67
|
+
else if (depth === 0) {
|
|
68
|
+
const sep = c === ';' ? 1 : inner.startsWith(' — ', i) ? 3 : inner.startsWith(', ', i) ? 2 : 0;
|
|
69
|
+
if (sep > 0) {
|
|
70
|
+
parts.push(inner.slice(start, i), inner.slice(i, i + sep));
|
|
71
|
+
start = i + sep;
|
|
72
|
+
i += sep - 1;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
parts.push(inner.slice(start));
|
|
77
|
+
// even indexes are clauses, odd ones the separator before the next clause
|
|
78
|
+
const kept = [];
|
|
79
|
+
// a clause that is only a date is provenance when a sibling clause carries a marker ("wall W-A7, stage 11, <date>")
|
|
80
|
+
const sibling = parts.some((p, k) => k % 2 === 0 && MARKER.test(p.trim()));
|
|
81
|
+
for (let k = 0; k < parts.length; k += 2) {
|
|
82
|
+
const clause = parts[k];
|
|
83
|
+
const marked = MARKER.test(clause.trim()) || (sibling && DATE_ONLY.test(clause));
|
|
84
|
+
const edited = marked ? tidy(tokens(clause, 'inner')).replace(LEFTOVER, '').replace(EMPTY_CLAUSE, '') : clause;
|
|
85
|
+
if (sibling && clause.trim() === '')
|
|
86
|
+
continue;
|
|
87
|
+
if (!marked || edited.trim() !== '')
|
|
88
|
+
kept.push({ text: marked && k > 0 && /^\s/.test(clause) ? ` ${edited}` : edited, sep: k > 0 ? parts[k - 1] : '' });
|
|
89
|
+
}
|
|
90
|
+
return kept.map((p, n) => (n === 0 ? p.text.replace(/^\s+/, '') : p.sep + p.text)).join('');
|
|
91
|
+
}
|
|
92
|
+
const MAX_DEPTH = 16;
|
|
93
|
+
/** Object nesting bound for {@link scrubDeep}; deeper subtrees are dropped, never served unscrubbed. */
|
|
94
|
+
export const MAX_DEEP = 32;
|
|
95
|
+
export const MAX_PAREN_DEPTH = MAX_DEPTH;
|
|
96
|
+
/** Scrub a segment: parentheticals recursively (innermost first), then this level's text. */
|
|
97
|
+
function segment(text, inParen, depth = 0) {
|
|
98
|
+
// ponytail: nesting past this depth is left as it is (no fact is dropped; a deep marker may pass)
|
|
99
|
+
if (depth > MAX_DEPTH)
|
|
100
|
+
return text;
|
|
101
|
+
let out = '';
|
|
102
|
+
let i = 0;
|
|
103
|
+
while (i < text.length) {
|
|
104
|
+
const open = text.indexOf('(', i);
|
|
105
|
+
if (open === -1) {
|
|
106
|
+
out += text.slice(i);
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
let level = 0;
|
|
110
|
+
let close = -1;
|
|
111
|
+
for (let j = open; j < text.length; j++) {
|
|
112
|
+
if (text[j] === '(')
|
|
113
|
+
level++;
|
|
114
|
+
else if (text[j] === ')' && --level === 0) {
|
|
115
|
+
close = j;
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (close === -1) {
|
|
120
|
+
out += text.slice(i);
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
out += text.slice(i, open);
|
|
124
|
+
const raw = text.slice(open + 1, close);
|
|
125
|
+
const inner = clauses(segment(raw, true, depth + 1));
|
|
126
|
+
if (inner.trim() === '' && raw.trim() !== '')
|
|
127
|
+
out = out.replace(/[ \t]+$/, '');
|
|
128
|
+
else
|
|
129
|
+
out += `(${inner})`;
|
|
130
|
+
i = close + 1;
|
|
131
|
+
}
|
|
132
|
+
return inParen ? out : tidy(tokens(out, 'outer')).replace(/^[\s,;:—–-]+/, '');
|
|
133
|
+
}
|
|
134
|
+
const ANY_MARKER = new RegExp(`${MARKER.source}|\\(#\\d+`, 'i');
|
|
135
|
+
/**
|
|
136
|
+
* Rewrites applied to the whole text before the clause pass: campaign labels and issue tags that sit in
|
|
137
|
+
* running prose, and the zone name where dropping it reads better than "this zone".
|
|
138
|
+
*/
|
|
139
|
+
const PRE = [
|
|
140
|
+
[/^\s*W-?[A-Z]?\d+(?=\s*[(:])\s*:?\s*/, ''],
|
|
141
|
+
[/\bww29\/australia-nz:?\s*/g, ''],
|
|
142
|
+
[/\bww29[- ]zone-labelled\b|\bzone-labelled ww29\b/g, 'zone-labelled'],
|
|
143
|
+
[/\b(the|a) ww29 /g, '$1 '],
|
|
144
|
+
[/\bpre- or post-#\d+/g, 'before or after that change'],
|
|
145
|
+
[/\bpre-#\d+/g, 'earlier'],
|
|
146
|
+
[/\bpost-#\d+/g, 'later'],
|
|
147
|
+
[/\bdiscrepancy #\d+/g, 'a documented discrepancy'],
|
|
148
|
+
[/\bthe issue-#\d+ /g, 'the '],
|
|
149
|
+
[/(?<![Tt]icket |[Nn]o\.? |[Nn]umber |[Ii]ssues? )#\d{2,4} (?=[a-z_])/g, ''],
|
|
150
|
+
[/\s*[—-]\s*residue records under ticket \d+(?:, name=marker)?/g, ''],
|
|
151
|
+
[/\b(?:the )?S\d+[a-z]?(?:-[A-Z])? live(?: wire)? oracle:?\s*/g, ''],
|
|
152
|
+
[/\bS\d+-[A-Z] live run:\s*/g, ''],
|
|
153
|
+
[/\bS\d+-[A-Z] [A-Z] probes\b/g, 'probes'],
|
|
154
|
+
[/\bthe S\d+[a-z]?(?:-[A-Z])? scratch (ticket|record)\b/g, 'a sample $1'],
|
|
155
|
+
[/\bthe S\d+-[A-Z] (typed )?sentinel\b/g, 'the $1sentinel'],
|
|
156
|
+
[/,?\s*\bS\d+-[A-Z] [A-Z]\d+(?:\/[A-Z]\d+)*(?=[)\s,;])/g, ''],
|
|
157
|
+
[/\b\d\d-\d\d text\b/g, 'vendor text'],
|
|
158
|
+
[/\b(\w+'s) \d\d-\d\d wall\b/g, '$1 wall'],
|
|
159
|
+
];
|
|
160
|
+
/** `text` with the provenance tokens removed (a text with no marker is returned as it is). */
|
|
161
|
+
export function scrubProvenance(text) {
|
|
162
|
+
let out = text;
|
|
163
|
+
for (const [rx, to] of PRE)
|
|
164
|
+
out = out.replace(rx, to);
|
|
165
|
+
return ANY_MARKER.test(out) ? segment(out, false) : out === text ? text : tidy(out);
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* The SDK's `examples` are code samples and prose notes. Their argument values are kept (sample ids and
|
|
169
|
+
* dates are what a call looks like), but the campaign tokens are not: a record name such as `WW29-S11A-C2`
|
|
170
|
+
* reads `Example`, a run label (`S11-A C2`, `the S12-B run`) and a bare `#N` issue tag are removed, a
|
|
171
|
+
* campaign email and first name read `jane.doe@example.com` / `Jane`, then the text gets the same scrub as any other served string.
|
|
172
|
+
*/
|
|
173
|
+
const EXAMPLE_TOKENS = [
|
|
174
|
+
[/\bWW29-S\d+[A-Z]?-[A-Z]\d+\b/g, 'Example'],
|
|
175
|
+
[/\b(?:the )?S\d+[a-z]?(?:-[A-Z])? run\b/g, 'a run'],
|
|
176
|
+
[/[\w.+-]+@ww29[\w.-]*/g, 'jane.doe@example.com'],
|
|
177
|
+
[/\bfirstName: 'Max'/g, "firstName: 'Jane'"],
|
|
178
|
+
[/\bS\d+-[A-Z] [A-Z]\d+ /g, ''],
|
|
179
|
+
[/\blive-validated\b/g, 'validated'],
|
|
180
|
+
[/(?<=\s)#\d{2,4} (?=[a-z_])/g, ''],
|
|
181
|
+
];
|
|
182
|
+
export function scrubExample(text) {
|
|
183
|
+
let out = text;
|
|
184
|
+
for (const [rx, to] of EXAMPLE_TOKENS)
|
|
185
|
+
out = out.replace(rx, to);
|
|
186
|
+
return scrubProvenance(out);
|
|
187
|
+
}
|
|
188
|
+
/** String-valued keys that are provenance only, dropped at any depth: the zone label and the raw evidence strings. */
|
|
189
|
+
const DROP_STRING_KEYS = new Set(['zone', 'evidence', 'vendorEvidence']);
|
|
190
|
+
/** A deep copy with every string scrubbed (`examples` by {@link scrubExample}) and the provenance-only string keys dropped. */
|
|
191
|
+
export function scrubDeep(value, depth = 0) {
|
|
192
|
+
if (typeof value === 'string')
|
|
193
|
+
return scrubProvenance(value);
|
|
194
|
+
if (value === null || typeof value !== 'object')
|
|
195
|
+
return value;
|
|
196
|
+
// ponytail: fail closed past the depth bound (a subtree this deep would pass unscrubbed); the registry sweep test pins that none is
|
|
197
|
+
if (depth > MAX_DEEP)
|
|
198
|
+
return undefined;
|
|
199
|
+
if (Array.isArray(value))
|
|
200
|
+
return value.map((v) => scrubDeep(v, depth + 1));
|
|
201
|
+
const out = {};
|
|
202
|
+
for (const [k, v] of Object.entries(value)) {
|
|
203
|
+
if (DROP_STRING_KEYS.has(k) && (typeof v === 'string' || Array.isArray(v)))
|
|
204
|
+
continue;
|
|
205
|
+
out[k] = k === 'examples' && Array.isArray(v) ? v.map((e) => (typeof e === 'string' ? scrubExample(e) : scrubDeep(e, depth + 1))) : scrubDeep(v, depth + 1);
|
|
206
|
+
}
|
|
207
|
+
return out;
|
|
208
|
+
}
|
|
209
|
+
//# sourceMappingURL=provenance-scrub.js.map
|
package/dist/tools/reasons.d.ts
CHANGED
|
@@ -11,7 +11,7 @@ export declare const STATUSES: readonly ["ok", "needs_input", "ambiguous", "deni
|
|
|
11
11
|
export type Status = (typeof STATUSES)[number];
|
|
12
12
|
export declare const NEXT_ACTIONS: readonly ["proceed", "supply_input", "choose_candidate", "repreview", "manual_action", "poll_task", "verify_then_repreview", "retry", "stop"];
|
|
13
13
|
export type NextAction = (typeof NEXT_ACTIONS)[number];
|
|
14
|
-
export declare const REASON_CODES: readonly ["INPUT_INVALID", "TARGET_NOT_FOUND", "AMBIGUOUS_MATCH", "RESULT_TRUNCATED", "POLICY_DENIED", "HOST_POLICY_DENIED", "CREDENTIAL_MISSING", "CREDENTIAL_MALFORMED", "UNAUTHORIZED", "RATE_LIMITED", "ABORTED", "UPSTREAM_ERROR", "INTERNAL_ERROR", "INPUT_MISSING", "SCOPE_REQUIRED", "OP_NOT_ALLOWED", "TARGET_NOT_IN_SCOPE", "MOVE_UNRESOLVED", "MOVE_REFUSED", "LIMIT_RATE", "DUPLICATE_EXISTS", "COOLDOWN_ACTIVE", "KILL_SWITCH", "STATE_UNAVAILABLE", "GATE_UNSUPPORTED", "GATE_DECLINED", "PLAN_NOT_FOUND", "PLAN_EXPIRED", "PRINCIPAL_MISMATCH", "STATE_DRIFT", "IDEMPOTENCY_CONFLICT", "WRITE_FAILED", "FIELD_NOT_PERSISTED", "WRITE_OUTCOME_UNKNOWN"];
|
|
14
|
+
export declare const REASON_CODES: readonly ["INPUT_INVALID", "TARGET_NOT_FOUND", "AMBIGUOUS_MATCH", "RESULT_TRUNCATED", "POLICY_DENIED", "HOST_POLICY_DENIED", "CREDENTIAL_MISSING", "CREDENTIAL_MALFORMED", "UNAUTHORIZED", "RATE_LIMITED", "ABORTED", "UPSTREAM_ERROR", "INTERNAL_ERROR", "BUDGET_EXCEEDED", "INPUT_MISSING", "SCOPE_REQUIRED", "OP_NOT_ALLOWED", "TARGET_NOT_IN_SCOPE", "MOVE_UNRESOLVED", "MOVE_REFUSED", "LIMIT_RATE", "DUPLICATE_EXISTS", "COOLDOWN_ACTIVE", "KILL_SWITCH", "STATE_UNAVAILABLE", "GATE_UNSUPPORTED", "GATE_DECLINED", "PLAN_NOT_FOUND", "PLAN_EXPIRED", "PRINCIPAL_MISMATCH", "STATE_DRIFT", "IDEMPOTENCY_CONFLICT", "WRITE_FAILED", "FIELD_NOT_PERSISTED", "WRITE_OUTCOME_UNKNOWN"];
|
|
15
15
|
export type ReasonCode = (typeof REASON_CODES)[number];
|
|
16
16
|
export interface Reason {
|
|
17
17
|
code: ReasonCode;
|
|
@@ -27,6 +27,7 @@ export declare function classifyError(payload: {
|
|
|
27
27
|
category?: unknown;
|
|
28
28
|
retryable?: unknown;
|
|
29
29
|
httpStatus?: unknown;
|
|
30
|
+
callerInput?: unknown;
|
|
30
31
|
}): {
|
|
31
32
|
status: Status;
|
|
32
33
|
next: NextAction;
|
package/dist/tools/reasons.js
CHANGED
|
@@ -33,6 +33,7 @@ export const REASON_CODES = [
|
|
|
33
33
|
'ABORTED',
|
|
34
34
|
'UPSTREAM_ERROR',
|
|
35
35
|
'INTERNAL_ERROR',
|
|
36
|
+
'BUDGET_EXCEEDED',
|
|
36
37
|
// the operator write path (autotask_write / autotask_apply)
|
|
37
38
|
'INPUT_MISSING',
|
|
38
39
|
'SCOPE_REQUIRED',
|
|
@@ -85,6 +86,15 @@ export function classifyError(payload) {
|
|
|
85
86
|
if (INPUT_CODES.has(code) || payload.category === 'validation') {
|
|
86
87
|
return { status: 'error', next: 'supply_input', code: 'INPUT_INVALID', retryable: false };
|
|
87
88
|
}
|
|
89
|
+
// SDK 0.37.0: a budgeted helper (operations.revenueRollup, the time_entries countOnly walk) refused before or
|
|
90
|
+
// during the walk; the same input fails again, a narrower window or scope, or a raised maxVendorCalls, may not
|
|
91
|
+
if (code === 'BUDGET_EXCEEDED')
|
|
92
|
+
return { status: 'error', next: 'supply_input', code: 'BUDGET_EXCEEDED', retryable: false };
|
|
93
|
+
// the SDK refused a pagination cursor the CALLER passed (a time_entries posted-state cursor that was edited,
|
|
94
|
+
// cut, or carried to another window; dispatch marks callerInput): pass nextCursor back unchanged, or omit it.
|
|
95
|
+
// A refused vendor continuation (generic query paging) is not the caller's input and falls through below.
|
|
96
|
+
if (code === 'RESPONSE_REJECTED' && payload.callerInput === true)
|
|
97
|
+
return { status: 'error', next: 'supply_input', code: 'INPUT_INVALID', retryable: false };
|
|
88
98
|
if (RATE_CODES.has(code))
|
|
89
99
|
return { status: 'error', next: 'retry', code: 'RATE_LIMITED', retryable: true };
|
|
90
100
|
if (code === 'ABORTED')
|
|
@@ -304,6 +304,8 @@ export function typedErrorPayload(err, correlationId, secrets = []) {
|
|
|
304
304
|
payload.retryAfter = retryAfter;
|
|
305
305
|
if (e.notSent === true)
|
|
306
306
|
payload.notSent = true;
|
|
307
|
+
if (e.callerInput === true)
|
|
308
|
+
payload.callerInput = true;
|
|
307
309
|
// V23/FT-5: the vendor body crosses bounded + scrubbed, never raw (a string body serves its
|
|
308
310
|
// shape; an object body keeps structure with scrubbed/capped strings, dropped to a shape
|
|
309
311
|
// descriptor past the total cap).
|
|
@@ -28,7 +28,7 @@ export declare function stripUnsafeChars(text: string): string;
|
|
|
28
28
|
* Clean one untrusted value: strip the unsafe characters, then (when `cap`) cut at
|
|
29
29
|
* {@link UNTRUSTED_TEXT_CAP}; a cut value carries {@link TRUNCATION_MARKER}.
|
|
30
30
|
*/
|
|
31
|
-
export declare function cleanUntrustedText(text: string, cap?: boolean): string;
|
|
31
|
+
export declare function cleanUntrustedText(text: string, cap?: boolean, max?: number): string;
|
|
32
32
|
/** One character outside the base64 alphabet (a linear test: no backtracking, any length). */
|
|
33
33
|
export declare const NOT_BASE64: RegExp;
|
|
34
34
|
/** Nesting past this depth is dropped rather than walked (final review R7). */
|
|
@@ -42,16 +42,21 @@ export declare const EVERY_ITEM = "*";
|
|
|
42
42
|
* - `server`: a value the server built from values that already passed the choke point (a TSV
|
|
43
43
|
* table): kept as it is;
|
|
44
44
|
* - `binary`: the file payload of a download (sanitiser review S1/S2): kept uncapped only while it
|
|
45
|
-
* is pure base64, otherwise it is vendor text like any other
|
|
46
|
-
*
|
|
45
|
+
* is pure base64, otherwise it is vendor text like any other;
|
|
46
|
+
* - `cursor`: a pagination cursor the caller passes back verbatim (the `nextCursor` of the four
|
|
47
|
+
* time_entries posted-state reads, #224): cleaned and unwrapped, capped at {@link CURSOR_CAP} (the
|
|
48
|
+
* SDK refuses a longer one) instead of 500, a cut one carrying the truncation marker.
|
|
49
|
+
* No other field is ever exempt from the cap, whatever its content looks like.
|
|
47
50
|
* Keyed by the exact object the server built (a WeakMap: vendor data can never be in it), so a
|
|
48
51
|
* vendor key that merely shares a name (`tsv`, `result`) at any depth is plain vendor text.
|
|
49
52
|
*/
|
|
50
|
-
export type Provenance = 'wrap' | 'document' | 'server' | 'binary';
|
|
53
|
+
export type Provenance = 'wrap' | 'document' | 'server' | 'binary' | 'cursor';
|
|
54
|
+
/** The cap on a `cursor` value: the SDK's own cursor cap (a longer cursor is refused before any request). */
|
|
55
|
+
export declare const CURSOR_CAP = 2048;
|
|
51
56
|
export declare const PROVENANCE: WeakMap<object, Map<string, Provenance>>;
|
|
52
57
|
export declare function note(target: object, key: string, kind: Provenance): void;
|
|
53
58
|
/** Record that `target[key]` is a server-built document or table (see {@link Provenance}). */
|
|
54
|
-
export declare function markServerField(target: object, key: string, kind:
|
|
59
|
+
export declare function markServerField(target: object, key: string, kind: Provenance): void;
|
|
55
60
|
export interface SanitiseOptions {
|
|
56
61
|
/** false: no cap anywhere (the output_path file form). */
|
|
57
62
|
cap?: boolean;
|
|
@@ -33,11 +33,11 @@ export function stripUnsafeChars(text) {
|
|
|
33
33
|
* Clean one untrusted value: strip the unsafe characters, then (when `cap`) cut at
|
|
34
34
|
* {@link UNTRUSTED_TEXT_CAP}; a cut value carries {@link TRUNCATION_MARKER}.
|
|
35
35
|
*/
|
|
36
|
-
export function cleanUntrustedText(text, cap = true) {
|
|
36
|
+
export function cleanUntrustedText(text, cap = true, max = UNTRUSTED_TEXT_CAP) {
|
|
37
37
|
const clean = stripUnsafeChars(text);
|
|
38
|
-
if (!cap || clean.length <=
|
|
38
|
+
if (!cap || clean.length <= max)
|
|
39
39
|
return clean;
|
|
40
|
-
let cut = clean.slice(0,
|
|
40
|
+
let cut = clean.slice(0, max);
|
|
41
41
|
// never end on half a surrogate pair (final review R8)
|
|
42
42
|
if (/[\uD800-\uDBFF]$/.test(cut))
|
|
43
43
|
cut = cut.slice(0, -1);
|
|
@@ -49,6 +49,8 @@ export const NOT_BASE64 = /[^A-Za-z0-9+/=\r\n]/;
|
|
|
49
49
|
export const MAX_OUTPUT_DEPTH = 64;
|
|
50
50
|
/** The per-array provenance key: applies to every string item of an array. */
|
|
51
51
|
export const EVERY_ITEM = '*';
|
|
52
|
+
/** The cap on a `cursor` value: the SDK's own cursor cap (a longer cursor is refused before any request). */
|
|
53
|
+
export const CURSOR_CAP = 2048;
|
|
52
54
|
export const PROVENANCE = new WeakMap();
|
|
53
55
|
export function note(target, key, kind) {
|
|
54
56
|
let fields = PROVENANCE.get(target);
|
|
@@ -119,6 +121,8 @@ export function sanitiseVendorOutput(value, opts = {}, kind, depth = 0) {
|
|
|
119
121
|
const text = inner ?? value;
|
|
120
122
|
if (kind === 'document')
|
|
121
123
|
return wrapOrSelf(cleanUntrustedText(text, false));
|
|
124
|
+
if (kind === 'cursor' && inner === undefined)
|
|
125
|
+
return cleanUntrustedText(text, cap, CURSOR_CAP);
|
|
122
126
|
const clean = cleanUntrustedText(text, cap);
|
|
123
127
|
return kind === 'wrap' || inner !== undefined ? wrapOrSelf(clean) : clean;
|
|
124
128
|
}
|
package/dist/tools/schemas.d.ts
CHANGED
|
@@ -144,6 +144,7 @@ export declare const ENVELOPE: z.ZodObject<{
|
|
|
144
144
|
ABORTED: "ABORTED";
|
|
145
145
|
UPSTREAM_ERROR: "UPSTREAM_ERROR";
|
|
146
146
|
INTERNAL_ERROR: "INTERNAL_ERROR";
|
|
147
|
+
BUDGET_EXCEEDED: "BUDGET_EXCEEDED";
|
|
147
148
|
INPUT_MISSING: "INPUT_MISSING";
|
|
148
149
|
SCOPE_REQUIRED: "SCOPE_REQUIRED";
|
|
149
150
|
OP_NOT_ALLOWED: "OP_NOT_ALLOWED";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
/** The wire identity of this server: a leaf module (no imports) so any tool can read it without an import cycle. */
|
|
2
2
|
export const SERVER_NAME = 'iit-mcp-autotask';
|
|
3
|
-
export const SERVER_VERSION = '0.
|
|
3
|
+
export const SERVER_VERSION = '0.36.0';
|
|
4
4
|
//# sourceMappingURL=server-version.js.map
|
package/dist/tools/server.d.ts
CHANGED
|
@@ -60,7 +60,7 @@ export declare function serverInstructions(config: Pick<Config, 'AUTOTASK_PROFIL
|
|
|
60
60
|
/** The full identity stamp (the `/metadata` build block — the era pair, FR-2). */
|
|
61
61
|
export declare const SERVER_IDENTITY: {
|
|
62
62
|
readonly name: "iit-mcp-autotask";
|
|
63
|
-
readonly version: "0.
|
|
63
|
+
readonly version: "0.36.0";
|
|
64
64
|
readonly mcpProtocolBaseline: "2026-07-28";
|
|
65
65
|
readonly legacyProtocolFallback: "2025-11-25";
|
|
66
66
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "iit-mcp-autotask",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.36.0",
|
|
4
4
|
"description": "MCP server for the Autotask (Datto) PSA domain, built on the node-autotask SDK",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Max Soukhomlinov",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
55
55
|
"@modelcontextprotocol/server": "^2.2.0",
|
|
56
|
-
"node-autotask": "0.
|
|
56
|
+
"node-autotask": "0.37.0",
|
|
57
57
|
"zod": "^4.6.7"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
package/server.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "iit-mcp-autotask",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.36.0",
|
|
4
4
|
"description": "MCP server for the Autotask (Datto) PSA API, powered by the node-autotask SDK",
|
|
5
5
|
"mcpProtocolBaseline": "2026-07-28",
|
|
6
6
|
"legacyProtocolFallback": "2025-11-25",
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
"capabilitiesManifest": "capabilities.json",
|
|
62
62
|
"sdk": {
|
|
63
63
|
"package": "node-autotask",
|
|
64
|
-
"versionRange": "0.
|
|
64
|
+
"versionRange": "0.37.0"
|
|
65
65
|
},
|
|
66
66
|
"vendorExtensions": false,
|
|
67
67
|
"credentialModelNote": "per-request; server-held under stdio or, on HTTP, only behind AUTOTASK_HTTP_AUTH=tokens; operator plans and the ledger are keyed by principal and tenant id, never by credential, and an apply uses the credential on the apply request"
|
|
@@ -28,7 +28,7 @@ No inputs: this is a reference page about the filter behaviour of autotask_query
|
|
|
28
28
|
3. tickets.search is a vendor contains on the TITLE only: no free-text search, no matched-field reporting, and vendor contains is looser than a literal substring (case/token matching can return MORE hits than a local title-substring check). The per-field contains of tickets.query is the precise tool.
|
|
29
29
|
4. Unassigned FKs are not uniform. tickets store 0; time_entries store NULL. Unallocated time needs notExist (ticketID eq 0 finds nothing); assignedResourceID eq 0 and notExist differ.
|
|
30
30
|
5. Field names are vendor casing, and an unknown field name now fails pre-dispatch as a typed CONFIG_ERROR with the field-catalog pointer, with zero query wire (0.20.0 pre-validation), on all five autotask_query operations (tickets.query, time_entries.query, service_calls.query, contracts.query, time_off_requests.query). One udf: true condition per query is exempt from the name check. List names for the queried resource with the scoped call `autotask_read {operation:"operations.fieldCatalog", args:[{resources:["<resource>"]}]}` (add `fields: [names]` to project, `labels: true`; never `<resource>.getFieldInfo`). Never the bare `operations.fieldCatalog`: it serves the whole five-resource catalog (~85 KB) and has no row for time_entries, service_calls, contracts or time_off_requests. Do not guess names. The answer is `data.results[0].result`.
|
|
31
|
-
6. Some clients (Copilot Studio) fail locally, before any request, when one array mixes value types (a number beside a string in one filter, or dateWorkedWindow args: strings plus an object). The autotask_query filter declares value as a string, so write every value as a string ("<resource id>", "2026-10-22", "true"); numbers still work for clients that send them. For a time entry window over a resource or ticket, query time entries instead of dateWorkedWindow: `autotask_query {resource:"time_entries", filter:[{field:"resourceID",op:"eq",value:"<resource id>"},{field:"dateWorked",op:"gte",value:"2026-10-20"},{field:"dateWorked",op:"lte",value:"2026-10-22"}], limit:500}`: the window's rows up to limit (dateWorkedWindow
|
|
31
|
+
6. Some clients (Copilot Studio) fail locally, before any request, when one array mixes value types (a number beside a string in one filter, or dateWorkedWindow args: strings plus an object). The autotask_query filter declares value as a string, so write every value as a string ("<resource id>", "2026-10-22", "true"); numbers still work for clients that send them. For a time entry window over a resource or ticket, query time entries instead of dateWorkedWindow: `autotask_query {resource:"time_entries", filter:[{field:"resourceID",op:"eq",value:"<resource id>"},{field:"dateWorked",op:"gte",value:"2026-10-20"},{field:"dateWorked",op:"lte",value:"2026-10-22"}], limit:500}`: the window's rows up to limit (dateWorkedWindow returns 500 per call plus a nextCursor to pass back as cursor; the query defaults to 50), both days inclusive because dateWorked is a date, without the posted counts. A bare window with no narrowing is `autotask_read {operation:"time_entries.dateWorkedWindow", args:["2026-10-20","2026-10-22"]}` (all strings). The server and SDK pass filter values through unchanged; string dates, ids and booleans are confirmed on resources and time_entries, other fields are not verified.
|
|
32
32
|
7. tickets.assignedResourceID is the PRIMARY owner only. Secondary assignments live in the separate ticket_secondary_resources table, which carries no status, so "how many open tickets does <resource> have" cannot be answered from tickets.query alone or from that table alone. Use `autotask_resource_workload {id}`: its open_tickets part is the de-duplicated union of primary and secondary open tickets with counts {primary, secondary, both, open} for the returned rows, plus exact totals {primary, secondary, both, open, capped} from a separate count read. For "how many" quote totals, not counts. totals.capped true means the SDK's link-read cap was hit (the totals are a lower bound); it is not the row value.capped, which only says the returned rows were cut at ticket_limit. If totalsAbsent is set the count read failed (it is the error code): the rows are still valid, and counts describe only them. When value.capped is true the rows are partial: call again with a larger ticket_limit (up to 500) before listing them, and say so if it is still capped. If the part is absent with CLOSED_STATUS_UNRESOLVED the tenant's closed statuses could not be read: say the count is unavailable rather than guessing.
|
|
33
33
|
8. Scope every `*.query` by ids (`eq`/`in` on `*ID`): unscoped is a tenant-wide walk, refused above 500 rows unless `acknowledge_unscoped` (query) or `acknowledgeUnscoped` (read). Totals/sums: `autotask_aggregate`.
|
|
34
34
|
|