@indigoai-us/hq-cli 5.345.191 → 5.345.193
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 +398 -0
- package/assets/bot-workers/setup/worker.yaml +91 -34
- package/dist/command-catalog.generated.d.ts +96 -19
- package/dist/command-catalog.generated.js +124 -21
- package/dist/commands/bot-jobs.d.ts +21 -18
- package/dist/commands/bot-jobs.js +127 -39
- package/dist/commands/bot.d.ts +4 -3
- package/dist/commands/bot.js +265 -140
- package/dist/commands/core-jobs-schedule.js +10 -0
- package/dist/commands/daemon.js +37 -0
- package/dist/commands/files.d.ts +41 -6
- package/dist/commands/files.js +108 -27
- package/dist/commands/pack-start-bot.d.ts +4 -6
- package/dist/commands/pack-start-bot.js +4 -8
- package/dist/commands/signals-run.d.ts +45 -0
- package/dist/commands/signals-run.js +175 -0
- package/dist/commands/signals.d.ts +1 -0
- package/dist/commands/signals.js +3 -0
- package/dist/commands/sources.d.ts +11 -0
- package/dist/commands/sources.js +137 -25
- package/dist/commands/staff/reset-test-account.d.ts +10 -0
- package/dist/commands/staff/reset-test-account.js +18 -2
- package/dist/hq-cloud-sync-runner/bin/sync-runner.js +31171 -52512
- package/dist/hq-cloud-sync-runner/package.json +1 -1
- package/dist/lib/bot/api.d.ts +3 -2
- package/dist/lib/bot/config.d.ts +2 -0
- package/dist/lib/bot/fleet-cron.d.ts +121 -0
- package/dist/lib/bot/fleet-cron.js +319 -0
- package/dist/lib/bot/fleet-local.d.ts +93 -1
- package/dist/lib/bot/fleet-local.js +546 -34
- package/dist/lib/bot/fleet-owner-companies.d.ts +12 -0
- package/dist/lib/bot/fleet-owner-companies.js +30 -0
- package/dist/lib/bot/fleet-watch.d.ts +25 -0
- package/dist/lib/bot/fleet-watch.js +51 -0
- package/dist/lib/bot/index.d.ts +1 -1
- package/dist/lib/bot/index.js +1 -1
- package/dist/lib/bot/job-run.d.ts +5 -4
- package/dist/lib/bot/job-run.js +5 -4
- package/dist/lib/bot/jobs/hot-signal-sync.d.ts +57 -3
- package/dist/lib/bot/jobs/hot-signal-sync.js +163 -4
- package/dist/lib/bot/local-jobs.d.ts +13 -2
- package/dist/lib/bot/local-jobs.js +17 -4
- package/dist/lib/bot/progress.d.ts +14 -0
- package/dist/lib/bot/progress.js +47 -1
- package/dist/lib/bot/run.d.ts +55 -2
- package/dist/lib/bot/run.js +192 -10
- package/dist/lib/bot/setup-tool-offer.d.ts +1 -0
- package/dist/lib/bot/setup-tool-offer.js +16 -0
- package/dist/lib/bot/signals-engine-flag.d.ts +18 -0
- package/dist/lib/bot/signals-engine-flag.js +30 -0
- package/dist/lib/daemon/catalog.d.ts +4 -0
- package/dist/lib/daemon/catalog.js +29 -9
- package/dist/lib/daemon/legacy.d.ts +6 -0
- package/dist/lib/daemon/legacy.js +16 -10
- package/dist/lib/daemon/run.d.ts +1 -0
- package/dist/lib/daemon/run.js +10 -0
- package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
- package/dist/lib/flag-registry-worker-source.generated.js +1 -1
- package/dist/lib/flag-registry.d.ts +13 -1
- package/dist/lib/flag-registry.js +46 -0
- package/dist/lib/hot-channels/adapters/attio.d.ts +75 -2
- package/dist/lib/hot-channels/adapters/attio.js +367 -29
- package/dist/lib/hot-channels/adapters/gmail.d.ts +14 -0
- package/dist/lib/hot-channels/adapters/gmail.js +59 -2
- package/dist/lib/hot-channels/adapters/linear.d.ts +24 -0
- package/dist/lib/hot-channels/adapters/linear.js +71 -1
- package/dist/lib/hot-channels/adapters/meetings.js +4 -1
- package/dist/lib/hot-channels/adapters/slack.js +7 -3
- package/dist/lib/hot-channels/alert.js +1 -1
- package/dist/lib/hot-channels/contract.d.ts +11 -0
- package/dist/lib/hot-channels/contract.js +1 -0
- package/dist/lib/hot-channels/cursors.js +1 -1
- package/dist/lib/hot-channels/landing.d.ts +6 -2
- package/dist/lib/hot-channels/landing.js +13 -6
- package/dist/lib/hot-channels/ledger.js +2 -0
- package/dist/lib/hot-channels/loop.js +18 -4
- package/dist/lib/hot-channels/registry.js +9 -1
- package/dist/lib/hot-channels/run.d.ts +18 -0
- package/dist/lib/hot-channels/run.js +15 -4
- package/dist/lib/hot-channels/signals-pass.d.ts +1 -0
- package/dist/lib/hot-channels/signals-pass.js +7 -5
- package/dist/lib/hot-channels/sync-lock.js +22 -6
- package/dist/lib/mesh/live/daemon/install.d.ts +22 -0
- package/dist/lib/mesh/live/daemon/install.js +117 -30
- package/dist/lib/signals/adapters-catalog.d.ts +77 -0
- package/dist/lib/signals/adapters-catalog.js +194 -0
- package/dist/lib/signals/engine.d.ts +196 -0
- package/dist/lib/signals/engine.js +495 -0
- package/dist/lib/signals/failure-alert.d.ts +89 -0
- package/dist/lib/signals/failure-alert.js +175 -0
- package/dist/lib/signals/local-date.d.ts +3 -0
- package/dist/lib/signals/local-date.js +6 -0
- package/dist/lib/signals/projection.d.ts +42 -0
- package/dist/lib/signals/projection.js +67 -0
- package/dist/lib/signals/reply.d.ts +112 -0
- package/dist/lib/signals/reply.js +368 -0
- package/dist/lib/signals/run-lock.d.ts +36 -0
- package/dist/lib/signals/run-lock.js +148 -0
- package/dist/lib/signals/run-record.d.ts +24 -0
- package/dist/lib/signals/run-record.js +82 -0
- package/dist/lib/signals/sanitize.d.ts +105 -0
- package/dist/lib/signals/sanitize.js +439 -0
- package/dist/lib/signals/settings.d.ts +10 -0
- package/dist/lib/signals/settings.js +48 -0
- package/dist/lib/signals/steps/adapters.d.ts +14 -0
- package/dist/lib/signals/steps/adapters.js +156 -0
- package/dist/lib/signals/steps/entity-similarity.d.ts +10 -0
- package/dist/lib/signals/steps/entity-similarity.js +47 -0
- package/dist/lib/signals/steps/extract.d.ts +113 -0
- package/dist/lib/signals/steps/extract.js +765 -0
- package/dist/lib/signals/steps/garden.d.ts +24 -0
- package/dist/lib/signals/steps/garden.js +250 -0
- package/dist/lib/signals/steps/types.d.ts +112 -0
- package/dist/lib/signals/steps/types.js +4 -0
- package/dist/lib/signals/steps/upload.d.ts +43 -0
- package/dist/lib/signals/steps/upload.js +324 -0
- package/dist/lib/signals/write-guard.d.ts +63 -0
- package/dist/lib/signals/write-guard.js +287 -0
- package/dist/utils/resident-package-preload.js +16 -0
- package/package.json +9 -9
- package/dist/lib/bot/fleet-default-flag.d.ts +0 -31
- package/dist/lib/bot/fleet-default-flag.js +0 -127
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
* The Attio webhook receiver in hq-pro-agents
|
|
5
5
|
* (`src/sources/inbound/attio-events.ts`) is the push half: it lands one
|
|
6
6
|
* document per delivery in the company vault under `sources/attio/`, with
|
|
7
|
-
* `event_key: attio:{
|
|
8
|
-
*
|
|
7
|
+
* `event_key: attio:{deliveryHash}` in the frontmatter, where deliveryHash is
|
|
8
|
+
* `sha256hex(Idempotency-Key + "\n" + rawBody)` (the raw key is kept in a
|
|
9
|
+
* separate `idempotency_key` field), and the webhook payload in the
|
|
10
|
+
* `.raw.json` sidecar. This adapter is the drain half, a poll
|
|
9
11
|
* adapter over that folder, the same shape as Slack (US-006). The cursor is
|
|
10
12
|
* the newest `ingested_at` it has read.
|
|
11
13
|
*
|
|
@@ -31,12 +33,56 @@
|
|
|
31
33
|
* `payload_ref` names the landed source document. No payload is copied: the
|
|
32
34
|
* `.raw.json` sidecar holds the source frontmatter and why the delivery
|
|
33
35
|
* matched.
|
|
36
|
+
*
|
|
37
|
+
* US-010 adds a gateway pull as the catch-up path alongside the drain, for
|
|
38
|
+
* changes whose webhook never landed. Once the drain has no more pages, each
|
|
39
|
+
* fetch asks the gateway for records (`list-records`, one call per object in
|
|
40
|
+
* `pullObjects`, filtered `created_at > watermark`, sorted ascending, limit
|
|
41
|
+
* 50) and notes (`search-notes-by-metadata` with `created_at_gt`) newer than
|
|
42
|
+
* that source's watermark. A pulled item is kept on the same rule as a
|
|
43
|
+
* delivery: the person created it or owns its record. The Attio gateway has
|
|
44
|
+
* no updated-at filter on records, so record updates still come from the
|
|
45
|
+
* webhook drain; the pull catches records and notes that were created.
|
|
46
|
+
*
|
|
47
|
+
* Event keys follow the US-008 shape `attio:{recordId}:{eventId}`, built from
|
|
48
|
+
* Attio ids both paths have, so a create the webhook delivered and the pull
|
|
49
|
+
* found lands once:
|
|
50
|
+
* - a create (a delivery holding one `record.created` or `note.created`
|
|
51
|
+
* event, or a pulled record or note) is `attio:{record_id}:record.created`
|
|
52
|
+
* or `attio:{note_id}:note.created`;
|
|
53
|
+
* - every other delivery is `attio:{subjectId}:{deliveryHash}`, where the
|
|
54
|
+
* subject is the note, comment, record or entry the first event names, so
|
|
55
|
+
* two updates to one record stay two items.
|
|
56
|
+
*
|
|
57
|
+
* The cursor is JSON `{ drain, pull }`: `drain` is the newest `ingested_at`
|
|
58
|
+
* read, and `pull` maps `records:{object}` and `notes` to the newest
|
|
59
|
+
* `created_at` read. A plain ISO string from before US-010 reads as `drain`.
|
|
60
|
+
*
|
|
61
|
+
* Upgrading from the drain before US-010 (re-review C): that drain keyed every
|
|
62
|
+
* delivery `attio:{deliveryHash}:{event_type}`, and a plain ISO cursor gives
|
|
63
|
+
* the pull no watermark, so the first pull reads the whole catch-up window.
|
|
64
|
+
* Landing dedupes on the event key alone (the landed document's path), so
|
|
65
|
+
* nothing the old drain landed may come back under a new key:
|
|
66
|
+
* - the drain keeps the old key for a delivery whose document under the old
|
|
67
|
+
* key is already landed (the deliveries at the old cursor are re-read);
|
|
68
|
+
* - the pull gives a create the old key of the landed delivery that carried
|
|
69
|
+
* it, so landing reports a duplicate.
|
|
70
|
+
* A create the old drain never landed (its webhook was lost, or it is newer
|
|
71
|
+
* than the old cursor) has no document under an old key and lands under the
|
|
72
|
+
* new one. The pull watermark is not seeded from the old cursor, so those
|
|
73
|
+
* lost creates are not skipped.
|
|
74
|
+
*
|
|
75
|
+
* Rate limits: every gateway call retries a 429 with backoff (Retry-After
|
|
76
|
+
* when Attio sends it), at most ATTIO_MAX_RETRIES times, and one fetch makes
|
|
77
|
+
* at most ATTIO_MAX_REQUESTS_PER_FETCH calls. A persistent 429 or the cap
|
|
78
|
+
* throws AttioRateLimitedError before a cursor is returned, so the watermark
|
|
79
|
+
* stays where it was.
|
|
34
80
|
*/
|
|
35
81
|
import * as fs from "node:fs/promises";
|
|
36
82
|
import * as path from "node:path";
|
|
37
83
|
import * as yaml from "js-yaml";
|
|
38
84
|
import { MAX_CATCH_UP_WINDOW_MS } from "../contract.js";
|
|
39
|
-
import { HOT_SIGNAL_ORIGIN } from "../landing.js";
|
|
85
|
+
import { eventKey, HOT_SIGNAL_ORIGIN, hotSourceId } from "../landing.js";
|
|
40
86
|
export const ATTIO_ADAPTER_ID = "attio";
|
|
41
87
|
export const ATTIO_CHANNEL = "attio-events";
|
|
42
88
|
export const ATTIO_PROVIDER = "attio";
|
|
@@ -46,6 +92,17 @@ export const ATTIO_COMMENT_EVENT = "attio.comment";
|
|
|
46
92
|
export const ATTIO_NOTE_EVENT = "attio.note";
|
|
47
93
|
export const ATTIO_LIST_MEMBERS_TOOL = "list-workspace-members";
|
|
48
94
|
export const ATTIO_GET_RECORDS_TOOL = "get-records-by-ids";
|
|
95
|
+
export const ATTIO_LIST_RECORDS_TOOL = "list-records";
|
|
96
|
+
export const ATTIO_SEARCH_NOTES_TOOL = "search-notes-by-metadata";
|
|
97
|
+
/** Records per `list-records` call; the Attio policy caps it at 50. */
|
|
98
|
+
export const ATTIO_PULL_LIMIT = 50;
|
|
99
|
+
/** Objects the catch-up pull lists by default. */
|
|
100
|
+
export const ATTIO_DEFAULT_PULL_OBJECTS = ["companies", "people", "deals"];
|
|
101
|
+
export const ATTIO_MAX_RETRIES = 3;
|
|
102
|
+
const ATTIO_BASE_BACKOFF_MS = 1000;
|
|
103
|
+
const ATTIO_MAX_BACKOFF_MS = 30_000;
|
|
104
|
+
/** Gateway calls one fetch may make, retries included. */
|
|
105
|
+
export const ATTIO_MAX_REQUESTS_PER_FETCH = 200;
|
|
49
106
|
/**
|
|
50
107
|
* Suggestion rule data: Attio is suggested when the person owns a record with
|
|
51
108
|
* activity in the last 14 days. The contract's `owned-by-me` rule has no
|
|
@@ -53,6 +110,85 @@ export const ATTIO_GET_RECORDS_TOOL = "get-records-by-ids";
|
|
|
53
110
|
*/
|
|
54
111
|
export const ATTIO_SUGGESTION_ACTIVITY_WINDOW_MS = 14 * 24 * 60 * 60 * 1000;
|
|
55
112
|
const DEFAULT_PAGE_SIZE = 100;
|
|
113
|
+
/** Attio kept answering 429 after every retry, or the run hit its request cap; the cursor stays put. */
|
|
114
|
+
export class AttioRateLimitedError extends Error {
|
|
115
|
+
retryAfterMs;
|
|
116
|
+
code = "rate_limited";
|
|
117
|
+
status = 429;
|
|
118
|
+
constructor(retryAfterMs) {
|
|
119
|
+
super("Attio rate limit (429): the run stopped and will resume from the same cursor next time");
|
|
120
|
+
this.retryAfterMs = retryAfterMs;
|
|
121
|
+
this.name = "AttioRateLimitedError";
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/** Parse a stored cursor. A plain ISO string (before US-010) is the drain cursor. */
|
|
125
|
+
export function parseAttioCursor(raw) {
|
|
126
|
+
if (raw === null || raw === "")
|
|
127
|
+
return { drain: null, pull: {} };
|
|
128
|
+
if (raw.trimStart().startsWith("{")) {
|
|
129
|
+
try {
|
|
130
|
+
const parsed = JSON.parse(raw);
|
|
131
|
+
const pull = {};
|
|
132
|
+
if (isRecord(parsed.pull)) {
|
|
133
|
+
for (const [k, v] of Object.entries(parsed.pull))
|
|
134
|
+
if (typeof v === "string" && !Number.isNaN(Date.parse(v)))
|
|
135
|
+
pull[k] = v;
|
|
136
|
+
}
|
|
137
|
+
return { drain: typeof parsed.drain === "string" ? parsed.drain : null, pull };
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return { drain: null, pull: {} };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return { drain: raw, pull: {} };
|
|
144
|
+
}
|
|
145
|
+
const RATE_LIMIT_PATTERN = /\b429\b|rate[ _-]?limit|too many requests/i;
|
|
146
|
+
function isRateLimited(err) {
|
|
147
|
+
if (!err || typeof err !== "object")
|
|
148
|
+
return false;
|
|
149
|
+
const e = err;
|
|
150
|
+
if (e.status === 429 || e.statusCode === 429)
|
|
151
|
+
return true;
|
|
152
|
+
if (typeof e.code === "string" && RATE_LIMIT_PATTERN.test(e.code))
|
|
153
|
+
return true;
|
|
154
|
+
return typeof e.message === "string" && RATE_LIMIT_PATTERN.test(e.message);
|
|
155
|
+
}
|
|
156
|
+
function retryAfterMs(err) {
|
|
157
|
+
const e = err;
|
|
158
|
+
let raw = e?.retryAfter;
|
|
159
|
+
if (raw === undefined && e?.headers && typeof e.headers === "object") {
|
|
160
|
+
const h = e.headers;
|
|
161
|
+
raw = typeof h.get === "function" ? h.get("retry-after") : (h["retry-after"] ?? h["Retry-After"]);
|
|
162
|
+
}
|
|
163
|
+
const secs = typeof raw === "number" ? raw : typeof raw === "string" ? Number(raw) : NaN;
|
|
164
|
+
return Number.isFinite(secs) && secs >= 0 ? secs * 1000 : null;
|
|
165
|
+
}
|
|
166
|
+
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
167
|
+
/** A gateway whose calls retry 429s with backoff and stop at the per-fetch request cap. */
|
|
168
|
+
function retryingGateway(gateway, sleep) {
|
|
169
|
+
let requests = 0;
|
|
170
|
+
return {
|
|
171
|
+
...gateway,
|
|
172
|
+
async call(tool, args) {
|
|
173
|
+
for (let attempt = 0;; attempt++) {
|
|
174
|
+
if (requests >= ATTIO_MAX_REQUESTS_PER_FETCH)
|
|
175
|
+
throw new AttioRateLimitedError(null);
|
|
176
|
+
requests++;
|
|
177
|
+
try {
|
|
178
|
+
return await gateway.call(tool, args);
|
|
179
|
+
}
|
|
180
|
+
catch (err) {
|
|
181
|
+
if (!isRateLimited(err))
|
|
182
|
+
throw err;
|
|
183
|
+
const wait = retryAfterMs(err);
|
|
184
|
+
if (attempt >= ATTIO_MAX_RETRIES)
|
|
185
|
+
throw new AttioRateLimitedError(wait);
|
|
186
|
+
await sleep(Math.min(ATTIO_MAX_BACKOFF_MS, wait ?? ATTIO_BASE_BACKOFF_MS * 2 ** attempt));
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
}
|
|
56
192
|
/** Source frontmatter copied to the raw sidecar. */
|
|
57
193
|
const RAW_FIELDS = ["source_id", "event_key", "event_type", "event_count", "ingested_at", "company_id"];
|
|
58
194
|
function isRecord(value) {
|
|
@@ -87,14 +223,14 @@ function toDelivery(file, fm) {
|
|
|
87
223
|
const tsMs = parseTime(fm.ingested_at);
|
|
88
224
|
if (!eventKey?.startsWith("attio:") || !eventType || Number.isNaN(tsMs))
|
|
89
225
|
return null;
|
|
90
|
-
const
|
|
91
|
-
if (
|
|
226
|
+
const deliveryHash = eventKey.slice("attio:".length);
|
|
227
|
+
if (deliveryHash === "")
|
|
92
228
|
return null;
|
|
93
229
|
return {
|
|
94
230
|
file,
|
|
95
231
|
sourceId: nonEmptyString(fm.source_id) ?? file.replace(/\.md$/, ""),
|
|
96
232
|
tsMs,
|
|
97
|
-
|
|
233
|
+
deliveryHash,
|
|
98
234
|
eventType,
|
|
99
235
|
frontmatter: fm,
|
|
100
236
|
};
|
|
@@ -147,6 +283,31 @@ function recordRef(evt) {
|
|
|
147
283
|
const recordId = nonEmptyString(id.record_id) ?? nonEmptyString(evt.record_id) ?? nonEmptyString(evt.parent_record_id);
|
|
148
284
|
return objectId && recordId ? { objectId, recordId } : null;
|
|
149
285
|
}
|
|
286
|
+
/** Create events the gateway pull can also find; keyed by Attio id and event type on both paths. */
|
|
287
|
+
const ATTIO_CREATE_EVENTS = new Set(["record.created", "note.created"]);
|
|
288
|
+
/** The Attio id an event is about: its note, comment, record or list entry. */
|
|
289
|
+
function subjectId(evt) {
|
|
290
|
+
const id = isRecord(evt.id) ? evt.id : {};
|
|
291
|
+
return (nonEmptyString(id.note_id) ??
|
|
292
|
+
nonEmptyString(id.comment_id) ??
|
|
293
|
+
nonEmptyString(id.record_id) ??
|
|
294
|
+
nonEmptyString(id.entry_id));
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* `providerId` and `version` for one delivery (event_key
|
|
298
|
+
* `attio:{providerId}:{version}`). A delivery holding exactly one create keys
|
|
299
|
+
* on the created record or note and the event type, the same key the pull
|
|
300
|
+
* gives that create. Anything else keys on its subject and the delivery hash.
|
|
301
|
+
*/
|
|
302
|
+
function deliveryKey(d, evts, ref) {
|
|
303
|
+
const only = evts.length === 1 ? evts[0] : undefined;
|
|
304
|
+
const type = only ? (nonEmptyString(only.event_type) ?? d.eventType) : d.eventType;
|
|
305
|
+
const subject = only ? subjectId(only) : undefined;
|
|
306
|
+
if (only && subject && ATTIO_CREATE_EVENTS.has(type))
|
|
307
|
+
return { providerId: subject, version: type };
|
|
308
|
+
const first = evts.map(subjectId).find((id) => id !== undefined);
|
|
309
|
+
return { providerId: first ?? ref?.recordId ?? d.deliveryHash, version: d.deliveryHash };
|
|
310
|
+
}
|
|
150
311
|
/** Unwrap a gateway result: a JSON string, an MCP `content[].text` block, or an object. */
|
|
151
312
|
function unwrapResult(result) {
|
|
152
313
|
if (typeof result === "string") {
|
|
@@ -219,28 +380,181 @@ export function createAttioAdapter(options) {
|
|
|
219
380
|
return null;
|
|
220
381
|
}
|
|
221
382
|
}
|
|
222
|
-
|
|
383
|
+
const pullObjects = options.pullObjects ?? ATTIO_DEFAULT_PULL_OBJECTS;
|
|
384
|
+
const sleep = options.sleep ?? defaultSleep;
|
|
385
|
+
/** The key the drain before US-010 gave a delivery: `attio:{deliveryHash}:{event_type}`. */
|
|
386
|
+
const legacyKey = (d) => ({
|
|
387
|
+
providerId: d.deliveryHash,
|
|
388
|
+
version: d.eventType,
|
|
389
|
+
});
|
|
390
|
+
/** True when the drain before US-010 landed this delivery: its document under the old key exists. */
|
|
391
|
+
async function landedUnderLegacyKey(d) {
|
|
392
|
+
const key = legacyKey(d);
|
|
393
|
+
const sourceId = hotSourceId(ATTIO_ADAPTER_ID, eventKey(ATTIO_ADAPTER_ID, key.providerId, key.version));
|
|
394
|
+
try {
|
|
395
|
+
await fs.stat(path.join(options.vaultRoot, "sources", ATTIO_CHANNEL, `${sourceId}.md`));
|
|
396
|
+
return true;
|
|
397
|
+
}
|
|
398
|
+
catch {
|
|
399
|
+
return false;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Creates the drain before US-010 already landed, by `{event type}\n{id}`,
|
|
404
|
+
* mapped to the old key of the landed delivery that carried them. Only
|
|
405
|
+
* deliveries inside the catch-up window can match a pulled create.
|
|
406
|
+
*/
|
|
407
|
+
async function legacyLandedCreates(sinceMs) {
|
|
408
|
+
const out = new Map();
|
|
223
409
|
let names;
|
|
224
410
|
try {
|
|
225
411
|
names = await fs.readdir(dir);
|
|
226
412
|
}
|
|
227
|
-
catch
|
|
228
|
-
|
|
229
|
-
return { events: [], cursor, hasMore: false };
|
|
230
|
-
throw err;
|
|
413
|
+
catch {
|
|
414
|
+
return out;
|
|
231
415
|
}
|
|
416
|
+
for (const file of names.filter((n) => n.endsWith(".md")).sort()) {
|
|
417
|
+
let text;
|
|
418
|
+
try {
|
|
419
|
+
text = await fs.readFile(path.join(dir, file), "utf8");
|
|
420
|
+
}
|
|
421
|
+
catch {
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
const fm = readFrontmatter(text);
|
|
425
|
+
const d = fm ? toDelivery(file, fm) : null;
|
|
426
|
+
if (!d || (!Number.isNaN(sinceMs) && d.tsMs < sinceMs))
|
|
427
|
+
continue;
|
|
428
|
+
if (!(await landedUnderLegacyKey(d)))
|
|
429
|
+
continue;
|
|
430
|
+
for (const evt of payloadEvents(await readPayload(d))) {
|
|
431
|
+
const type = nonEmptyString(evt.event_type) ?? d.eventType;
|
|
432
|
+
const subject = subjectId(evt);
|
|
433
|
+
if (subject && ATTIO_CREATE_EVENTS.has(type))
|
|
434
|
+
out.set(`${type}\n${subject}`, legacyKey(d));
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return out;
|
|
438
|
+
}
|
|
439
|
+
async function fetch(rawCursor, ctx) {
|
|
440
|
+
const gateway = ctx.gateway ? retryingGateway(ctx.gateway, sleep) : undefined;
|
|
441
|
+
const state = parseAttioCursor(rawCursor);
|
|
232
442
|
const identities = [ctx.personUid, ...(options.identities ?? [])].filter((id) => id !== "");
|
|
233
443
|
const emails = new Set(identities.filter((id) => id.includes("@")).map((id) => id.toLowerCase()));
|
|
234
444
|
const memberIds = new Set((options.memberIds && options.memberIds.length > 0
|
|
235
445
|
? [...options.memberIds]
|
|
236
|
-
:
|
|
237
|
-
? await lookupMemberIds(
|
|
446
|
+
: gateway
|
|
447
|
+
? await lookupMemberIds(gateway, emails)
|
|
238
448
|
: []).map((id) => id.toLowerCase()));
|
|
239
449
|
// Without the person's member id nothing can be scoped to them: emit
|
|
240
450
|
// nothing and keep the cursor, so the deliveries are read once it resolves.
|
|
241
451
|
if (memberIds.size === 0)
|
|
242
|
-
return { events: [], cursor, hasMore: false };
|
|
452
|
+
return { events: [], cursor: rawCursor, hasMore: false };
|
|
243
453
|
const isMe = (id) => memberIds.has(id.toLowerCase());
|
|
454
|
+
const ownerCache = new Map();
|
|
455
|
+
const ownersOf = (ref) => {
|
|
456
|
+
const key = `${ref.objectId}/${ref.recordId}`;
|
|
457
|
+
let hit = ownerCache.get(key);
|
|
458
|
+
if (!hit) {
|
|
459
|
+
hit = gateway ? lookupOwnerIds(gateway, ref) : Promise.resolve([]);
|
|
460
|
+
ownerCache.set(key, hit);
|
|
461
|
+
}
|
|
462
|
+
return hit;
|
|
463
|
+
};
|
|
464
|
+
const drained = await drain(state.drain, ctx, isMe, ownersOf);
|
|
465
|
+
const next = { drain: drained.cursor, pull: { ...state.pull } };
|
|
466
|
+
const events = [...drained.events];
|
|
467
|
+
let hasMore = drained.hasMore;
|
|
468
|
+
// The catch-up pull runs once the drain is caught up.
|
|
469
|
+
if (gateway && !hasMore) {
|
|
470
|
+
const pulled = await pull(gateway, state.pull, ctx, isMe, ownersOf);
|
|
471
|
+
events.push(...pulled.events);
|
|
472
|
+
next.pull = pulled.watermarks;
|
|
473
|
+
hasMore = pulled.hasMore;
|
|
474
|
+
}
|
|
475
|
+
return { events, cursor: JSON.stringify(next), hasMore };
|
|
476
|
+
}
|
|
477
|
+
async function pull(gateway, watermarks, ctx, isMe, ownersOf) {
|
|
478
|
+
const out = { ...watermarks };
|
|
479
|
+
const events = [];
|
|
480
|
+
let hasMore = false;
|
|
481
|
+
const sinceMs = Date.parse(ctx.since);
|
|
482
|
+
// Read once per fetch, and only when the pull has a create to check.
|
|
483
|
+
let legacy;
|
|
484
|
+
const keyed = async (event) => {
|
|
485
|
+
legacy ??= legacyLandedCreates(sinceMs);
|
|
486
|
+
const old = (await legacy).get(`${event.version}\n${event.providerId}`);
|
|
487
|
+
return old ? { ...event, ...old } : event;
|
|
488
|
+
};
|
|
489
|
+
const floor = (key) => {
|
|
490
|
+
const mark = out[key];
|
|
491
|
+
if (mark && (Number.isNaN(sinceMs) || Date.parse(mark) >= sinceMs))
|
|
492
|
+
return mark;
|
|
493
|
+
return new Date(Number.isNaN(sinceMs) ? 0 : sinceMs).toISOString();
|
|
494
|
+
};
|
|
495
|
+
for (const object of pullObjects) {
|
|
496
|
+
const key = `records:${object}`;
|
|
497
|
+
const after = floor(key);
|
|
498
|
+
const rows = listOf(unwrapResult(await gateway.call(ATTIO_LIST_RECORDS_TOOL, {
|
|
499
|
+
object,
|
|
500
|
+
filter: { attribute: "created_at", op: "gt", value: after },
|
|
501
|
+
sorts: [{ attribute: "created_at", direction: "asc" }],
|
|
502
|
+
limit: ATTIO_PULL_LIMIT,
|
|
503
|
+
}))).filter(isRecord);
|
|
504
|
+
if (rows.length >= ATTIO_PULL_LIMIT)
|
|
505
|
+
hasMore = true;
|
|
506
|
+
let newest = after;
|
|
507
|
+
for (const row of rows) {
|
|
508
|
+
const created = nonEmptyString(row.created_at);
|
|
509
|
+
const id = isRecord(row.id) ? nonEmptyString(row.id.record_id) : nonEmptyString(row.id);
|
|
510
|
+
if (!created || Number.isNaN(Date.parse(created)) || !id)
|
|
511
|
+
continue;
|
|
512
|
+
if (Date.parse(created) > Date.parse(newest))
|
|
513
|
+
newest = new Date(Date.parse(created)).toISOString();
|
|
514
|
+
if (!recordOwnerIds(row).some(isMe))
|
|
515
|
+
continue;
|
|
516
|
+
if (ctx.selection && !ctx.selection.has(id))
|
|
517
|
+
continue;
|
|
518
|
+
events.push(await keyed(pulledEvent("record", object, id, created, ctx.personUid, options.identities)));
|
|
519
|
+
}
|
|
520
|
+
out[key] = newest;
|
|
521
|
+
}
|
|
522
|
+
const after = floor("notes");
|
|
523
|
+
const notes = listOf(unwrapResult(await gateway.call(ATTIO_SEARCH_NOTES_TOOL, { created_at_gt: after, limit: ATTIO_PULL_LIMIT }))).filter(isRecord);
|
|
524
|
+
if (notes.length >= ATTIO_PULL_LIMIT)
|
|
525
|
+
hasMore = true;
|
|
526
|
+
let newest = after;
|
|
527
|
+
for (const note of notes) {
|
|
528
|
+
const created = nonEmptyString(note.created_at);
|
|
529
|
+
const id = isRecord(note.id) ? nonEmptyString(note.id.note_id) : nonEmptyString(note.id);
|
|
530
|
+
if (!created || Number.isNaN(Date.parse(created)) || !id)
|
|
531
|
+
continue;
|
|
532
|
+
if (Date.parse(created) > Date.parse(newest))
|
|
533
|
+
newest = new Date(Date.parse(created)).toISOString();
|
|
534
|
+
const parentObject = nonEmptyString(note.parent_object);
|
|
535
|
+
const parentId = nonEmptyString(note.parent_record_id);
|
|
536
|
+
const ref = parentObject && parentId ? { objectId: parentObject, recordId: parentId } : null;
|
|
537
|
+
const author = actorRefId(note.created_by_actor);
|
|
538
|
+
const mine = (author !== undefined && isMe(author)) || (ref !== null && (await ownersOf(ref)).some(isMe));
|
|
539
|
+
if (!mine)
|
|
540
|
+
continue;
|
|
541
|
+
if (ctx.selection && !(ref && ctx.selection.has(ref.recordId)))
|
|
542
|
+
continue;
|
|
543
|
+
events.push(await keyed(pulledEvent("note", parentObject ?? "note", id, created, ctx.personUid, options.identities, ref, author)));
|
|
544
|
+
}
|
|
545
|
+
out.notes = newest;
|
|
546
|
+
return { events, watermarks: out, hasMore };
|
|
547
|
+
}
|
|
548
|
+
async function drain(cursor, ctx, isMe, ownersOf) {
|
|
549
|
+
let names;
|
|
550
|
+
try {
|
|
551
|
+
names = await fs.readdir(dir);
|
|
552
|
+
}
|
|
553
|
+
catch (err) {
|
|
554
|
+
if (err.code === "ENOENT")
|
|
555
|
+
return { events: [], cursor, hasMore: false };
|
|
556
|
+
throw err;
|
|
557
|
+
}
|
|
244
558
|
const sinceMs = Date.parse(ctx.since);
|
|
245
559
|
const cursorMs = cursor === null ? NaN : Date.parse(cursor);
|
|
246
560
|
const candidates = [];
|
|
@@ -271,16 +585,6 @@ export function createAttioAdapter(options) {
|
|
|
271
585
|
while (end > 0 && end < newer.length && newer[end].tsMs === newer[end - 1].tsMs)
|
|
272
586
|
end += 1;
|
|
273
587
|
const page = newer.slice(0, end);
|
|
274
|
-
const ownerCache = new Map();
|
|
275
|
-
const ownersOf = (ref) => {
|
|
276
|
-
const key = `${ref.objectId}/${ref.recordId}`;
|
|
277
|
-
let hit = ownerCache.get(key);
|
|
278
|
-
if (!hit) {
|
|
279
|
-
hit = ctx.gateway ? lookupOwnerIds(ctx.gateway, ref) : Promise.resolve([]);
|
|
280
|
-
ownerCache.set(key, hit);
|
|
281
|
-
}
|
|
282
|
-
return hit;
|
|
283
|
-
};
|
|
284
588
|
const events = [];
|
|
285
589
|
for (const d of [...atCursor, ...page]) {
|
|
286
590
|
const evts = payloadEvents(await readPayload(d));
|
|
@@ -309,7 +613,9 @@ export function createAttioAdapter(options) {
|
|
|
309
613
|
// A saved record pick: only deliveries about a chosen record land.
|
|
310
614
|
if (ctx.selection && !(ref && ctx.selection.has(ref.recordId)))
|
|
311
615
|
continue;
|
|
312
|
-
|
|
616
|
+
// A delivery the drain before US-010 landed keeps its old key (see the file comment).
|
|
617
|
+
const key = (await landedUnderLegacyKey(d)) ? legacyKey(d) : deliveryKey(d, evts, ref);
|
|
618
|
+
events.push(toEvent(d, key, [...reasons], actor, ref, ctx.personUid, options.identities));
|
|
313
619
|
}
|
|
314
620
|
const last = page[page.length - 1];
|
|
315
621
|
return {
|
|
@@ -320,6 +626,9 @@ export function createAttioAdapter(options) {
|
|
|
320
626
|
}
|
|
321
627
|
return {
|
|
322
628
|
id: ATTIO_ADAPTER_ID,
|
|
629
|
+
displayName: "Attio",
|
|
630
|
+
provider: ATTIO_PROVIDER,
|
|
631
|
+
watermarkKind: "timestamp",
|
|
323
632
|
channel: ATTIO_CHANNEL,
|
|
324
633
|
transport: "poll",
|
|
325
634
|
hotEventTypes: [ATTIO_RECORD_EVENT, ATTIO_COMMENT_EVENT, ATTIO_NOTE_EVENT],
|
|
@@ -331,7 +640,35 @@ export function createAttioAdapter(options) {
|
|
|
331
640
|
fetch,
|
|
332
641
|
};
|
|
333
642
|
}
|
|
334
|
-
|
|
643
|
+
/** A pointer event for a record or note the catch-up pull found. Nothing from the record is copied. */
|
|
644
|
+
function pulledEvent(kind, object, id, createdAt, personUid, identities, parent = null, author) {
|
|
645
|
+
const when = new Date(Date.parse(createdAt)).toISOString();
|
|
646
|
+
const person = identities?.find((i) => i.startsWith("prs_")) ?? personUid;
|
|
647
|
+
const ref = kind === "record" ? { objectId: object, recordId: id } : parent;
|
|
648
|
+
const where = ref ? `attio:${ref.objectId}/${ref.recordId}` : "attio";
|
|
649
|
+
const title = kind === "record" ? `Attio ${object} record created` : "Attio note created";
|
|
650
|
+
const body = [`# ${title}`, "", `- When: ${when}`, `- Who: ${author ?? "unknown"}`, `- Where: ${where}`, "- Source: gateway pull", ""].join("\n");
|
|
651
|
+
return {
|
|
652
|
+
// Same key as the webhook delivery of this create: attio:{id}:{event type}.
|
|
653
|
+
providerId: id,
|
|
654
|
+
version: kind === "record" ? "record.created" : "note.created",
|
|
655
|
+
who: author ?? "unknown",
|
|
656
|
+
what: kind === "record" ? ATTIO_RECORD_EVENT : ATTIO_NOTE_EVENT,
|
|
657
|
+
where,
|
|
658
|
+
when,
|
|
659
|
+
audience: [person],
|
|
660
|
+
title,
|
|
661
|
+
body,
|
|
662
|
+
frontmatter: {
|
|
663
|
+
audience_source: "person_uid",
|
|
664
|
+
attio_origin: "gateway-pull",
|
|
665
|
+
...(ref ? { attio_object_id: ref.objectId, attio_record_id: ref.recordId } : {}),
|
|
666
|
+
...(kind === "note" ? { attio_note_id: id } : {}),
|
|
667
|
+
},
|
|
668
|
+
raw: { source: "gateway-pull", kind, object, id, created_at: when, record: ref },
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
function toEvent(d, key, reasons, actor, ref, personUid, identities) {
|
|
335
672
|
const fm = d.frontmatter;
|
|
336
673
|
const when = new Date(d.tsMs).toISOString();
|
|
337
674
|
const what = eventWhat(d.eventType);
|
|
@@ -354,9 +691,10 @@ function toEvent(d, reasons, actor, ref, personUid, identities) {
|
|
|
354
691
|
if (fm[field] !== undefined)
|
|
355
692
|
raw[field] = fm[field];
|
|
356
693
|
return {
|
|
357
|
-
|
|
358
|
-
//
|
|
359
|
-
|
|
694
|
+
// See deliveryKey: a create keys on the Attio id and event type, anything
|
|
695
|
+
// else on its subject and the receiver's delivery hash.
|
|
696
|
+
providerId: key.providerId,
|
|
697
|
+
version: key.version,
|
|
360
698
|
who: actor ?? "unknown",
|
|
361
699
|
what,
|
|
362
700
|
where,
|
|
@@ -60,7 +60,21 @@ export interface GmailAdapterOptions {
|
|
|
60
60
|
pageSize?: number;
|
|
61
61
|
/** Clock for watch renewal and event times without a Date header. */
|
|
62
62
|
now?: () => Date;
|
|
63
|
+
/** Waits between rate-limit retries. Tests pass a fake. */
|
|
64
|
+
sleep?: (ms: number) => Promise<void>;
|
|
63
65
|
}
|
|
66
|
+
/** Gmail kept answering 429 after every retry; the cursor stays put. */
|
|
67
|
+
export declare class GmailRateLimitedError extends Error {
|
|
68
|
+
readonly retryAfterMs: number | null;
|
|
69
|
+
readonly code = "rate_limited";
|
|
70
|
+
readonly status = 429;
|
|
71
|
+
constructor(retryAfterMs: number | null);
|
|
72
|
+
}
|
|
73
|
+
/** Every call reads only the connected person's own mailbox. */
|
|
74
|
+
export declare const GMAIL_USER_ID = "me";
|
|
75
|
+
export declare const GMAIL_MAX_RETRIES = 3;
|
|
76
|
+
/** Gateway calls one fetch may make, retries included, before it stops with the cursor unmoved. */
|
|
77
|
+
export declare const GMAIL_MAX_REQUESTS_PER_FETCH = 1000;
|
|
64
78
|
/** The adapter's cursor: see the file comment. */
|
|
65
79
|
export interface GmailCursor {
|
|
66
80
|
historyId: string;
|
|
@@ -57,6 +57,24 @@ export const GMAIL_WATCH_RENEW_MS = 24 * 60 * 60 * 1000;
|
|
|
57
57
|
const SUGGESTION_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
|
|
58
58
|
const DEFAULT_PAGE_SIZE = 100;
|
|
59
59
|
const MAX_PAGE_SIZE = 500;
|
|
60
|
+
/** Gmail kept answering 429 after every retry; the cursor stays put. */
|
|
61
|
+
export class GmailRateLimitedError extends Error {
|
|
62
|
+
retryAfterMs;
|
|
63
|
+
code = "rate_limited";
|
|
64
|
+
status = 429;
|
|
65
|
+
constructor(retryAfterMs) {
|
|
66
|
+
super("Gmail rate limit (429): the run stopped and will resume from the same cursor next time");
|
|
67
|
+
this.retryAfterMs = retryAfterMs;
|
|
68
|
+
this.name = "GmailRateLimitedError";
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** Every call reads only the connected person's own mailbox. */
|
|
72
|
+
export const GMAIL_USER_ID = "me";
|
|
73
|
+
export const GMAIL_MAX_RETRIES = 3;
|
|
74
|
+
const GMAIL_BASE_BACKOFF_MS = 1000;
|
|
75
|
+
const GMAIL_MAX_BACKOFF_MS = 30_000;
|
|
76
|
+
/** Gateway calls one fetch may make, retries included, before it stops with the cursor unmoved. */
|
|
77
|
+
export const GMAIL_MAX_REQUESTS_PER_FETCH = 1000;
|
|
60
78
|
function nonEmptyString(value) {
|
|
61
79
|
return typeof value === "string" && value.trim() !== "" ? value : undefined;
|
|
62
80
|
}
|
|
@@ -168,6 +186,17 @@ function isRateLimited(err) {
|
|
|
168
186
|
return true;
|
|
169
187
|
return err instanceof Error && RATE_LIMIT_PATTERN.test(err.message);
|
|
170
188
|
}
|
|
189
|
+
function retryAfterMs(err) {
|
|
190
|
+
const e = err;
|
|
191
|
+
let raw = e?.retryAfter;
|
|
192
|
+
if (raw === undefined && e?.headers && typeof e.headers === "object") {
|
|
193
|
+
const h = e.headers;
|
|
194
|
+
raw = typeof h.get === "function" ? h.get("retry-after") : (h["retry-after"] ?? h["Retry-After"]);
|
|
195
|
+
}
|
|
196
|
+
const secs = typeof raw === "number" ? raw : typeof raw === "string" ? Number(raw) : NaN;
|
|
197
|
+
return Number.isFinite(secs) && secs >= 0 ? secs * 1000 : null;
|
|
198
|
+
}
|
|
199
|
+
const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
171
200
|
function parseWatch(result, now) {
|
|
172
201
|
const body = unwrapResult(result);
|
|
173
202
|
if (!isRecord(body))
|
|
@@ -323,10 +352,35 @@ function toEvent(change, headers, personUid, now) {
|
|
|
323
352
|
export function createGmailAdapter(options = {}) {
|
|
324
353
|
const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, Math.floor(options.pageSize ?? DEFAULT_PAGE_SIZE)));
|
|
325
354
|
const clock = options.now ?? (() => new Date());
|
|
355
|
+
const sleep = options.sleep ?? defaultSleep;
|
|
326
356
|
async function fetch(cursor, ctx) {
|
|
327
|
-
const
|
|
328
|
-
if (!
|
|
357
|
+
const gw = ctx.gateway;
|
|
358
|
+
if (!gw)
|
|
329
359
|
throw new Error("Gmail hot channel needs a gateway connection");
|
|
360
|
+
let requests = 0;
|
|
361
|
+
// Every call is scoped to userId "me" and retries 429s with backoff. A
|
|
362
|
+
// persistent 429 or the per-fetch request cap throws before any cursor
|
|
363
|
+
// is returned, so the watermark does not move.
|
|
364
|
+
const gateway = {
|
|
365
|
+
async call(tool, args) {
|
|
366
|
+
for (let attempt = 0;; attempt++) {
|
|
367
|
+
if (requests >= GMAIL_MAX_REQUESTS_PER_FETCH)
|
|
368
|
+
throw new GmailRateLimitedError(null);
|
|
369
|
+
requests++;
|
|
370
|
+
try {
|
|
371
|
+
return await gw.call(tool, { ...args, userId: GMAIL_USER_ID });
|
|
372
|
+
}
|
|
373
|
+
catch (err) {
|
|
374
|
+
if (isAuthFailure(err) || !isRateLimited(err))
|
|
375
|
+
throw err;
|
|
376
|
+
const wait = retryAfterMs(err);
|
|
377
|
+
if (attempt >= GMAIL_MAX_RETRIES)
|
|
378
|
+
throw new GmailRateLimitedError(wait);
|
|
379
|
+
await sleep(Math.min(GMAIL_MAX_BACKOFF_MS, wait ?? GMAIL_BASE_BACKOFF_MS * 2 ** attempt));
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
},
|
|
383
|
+
};
|
|
330
384
|
const now = clock();
|
|
331
385
|
const state = parseGmailCursor(cursor);
|
|
332
386
|
let watch = null;
|
|
@@ -399,6 +453,9 @@ export function createGmailAdapter(options = {}) {
|
|
|
399
453
|
}
|
|
400
454
|
return {
|
|
401
455
|
id: GMAIL_ADAPTER_ID,
|
|
456
|
+
displayName: "Gmail",
|
|
457
|
+
provider: GMAIL_PROVIDER,
|
|
458
|
+
watermarkKind: "cursor",
|
|
402
459
|
channel: GMAIL_CHANNEL,
|
|
403
460
|
transport: "poll",
|
|
404
461
|
hotEventTypes: [GMAIL_MESSAGE_ADDED_EVENT, GMAIL_LABEL_ADDED_EVENT],
|
|
@@ -34,6 +34,17 @@
|
|
|
34
34
|
* A revoked or expired token (`invalid_grant`) surfaces from the gateway call;
|
|
35
35
|
* run.ts turns it into a reconnect prompt in the run ledger and the cursor
|
|
36
36
|
* does not move.
|
|
37
|
+
*
|
|
38
|
+
* A rate-limited call (HTTP 429, or a rate-limit error from the gateway) is
|
|
39
|
+
* retried up to `LINEAR_MAX_RETRIES` times, waiting for `Retry-After` when the
|
|
40
|
+
* error carries one (capped) or an exponential backoff otherwise. When the
|
|
41
|
+
* retries run out the fetch throws `LinearRateLimitedError`, so the run fails,
|
|
42
|
+
* nothing lands for that page and the cursor does not move.
|
|
43
|
+
*
|
|
44
|
+
* Request budget: a run makes at most `DEFAULT_MAX_PAGES_PER_ADAPTER` (20)
|
|
45
|
+
* pages, each at most `1 + LINEAR_MAX_RETRIES` requests, so
|
|
46
|
+
* `LINEAR_MAX_REQUESTS_PER_RUN` is 60. 41 worst-case runs in one hour still
|
|
47
|
+
* stay under `LINEAR_REQUESTS_PER_HOUR` (2,500).
|
|
37
48
|
*/
|
|
38
49
|
import { type PollHotChannelAdapter } from "../contract.js";
|
|
39
50
|
export declare const LINEAR_ADAPTER_ID = "linear";
|
|
@@ -44,10 +55,23 @@ export declare const LINEAR_LIST_ISSUES_TOOL = "list_issues";
|
|
|
44
55
|
/** Linear's request budget for one personal token. */
|
|
45
56
|
export declare const LINEAR_REQUESTS_PER_HOUR = 2500;
|
|
46
57
|
export declare const LINEAR_CATCH_UP_WINDOW_MS: number;
|
|
58
|
+
/** Retries for one rate-limited `list_issues` call before the run gives up. */
|
|
59
|
+
export declare const LINEAR_MAX_RETRIES = 2;
|
|
60
|
+
/** Most requests one run can make: every page, every retry. */
|
|
61
|
+
export declare const LINEAR_MAX_REQUESTS_PER_RUN: number;
|
|
47
62
|
export declare const LINEAR_ISSUE_FIELDS: readonly ["id", "uuid", "title", "priority", "url", "createdAt", "updatedAt", "completedAt", "dueDate", "status", "statusType", "labels", "assignee", "project", "team"];
|
|
48
63
|
export interface LinearAdapterOptions {
|
|
49
64
|
/** Issues per `list_issues` call. Clamped to 1..250. Default 100. */
|
|
50
65
|
pageSize?: number;
|
|
66
|
+
/** Waits between rate-limit retries. Tests pass a fake. */
|
|
67
|
+
sleep?: (ms: number) => Promise<void>;
|
|
68
|
+
}
|
|
69
|
+
/** Linear kept answering 429 after every retry; the cursor stays put. */
|
|
70
|
+
export declare class LinearRateLimitedError extends Error {
|
|
71
|
+
readonly retryAfterMs: number | null;
|
|
72
|
+
readonly code = "rate_limited";
|
|
73
|
+
readonly status = 429;
|
|
74
|
+
constructor(retryAfterMs: number | null);
|
|
51
75
|
}
|
|
52
76
|
/** The adapter's cursor: see the file comment. */
|
|
53
77
|
export interface LinearCursor {
|