@indigoai-us/hq-cli 5.345.190 → 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.
Files changed (122) hide show
  1. package/CHANGELOG.md +420 -0
  2. package/assets/bot-workers/setup/worker.yaml +91 -34
  3. package/dist/command-catalog.generated.d.ts +96 -19
  4. package/dist/command-catalog.generated.js +124 -21
  5. package/dist/commands/bot-jobs.d.ts +21 -18
  6. package/dist/commands/bot-jobs.js +127 -39
  7. package/dist/commands/bot.d.ts +7 -4
  8. package/dist/commands/bot.js +289 -140
  9. package/dist/commands/core-jobs-schedule.js +10 -0
  10. package/dist/commands/daemon.js +37 -0
  11. package/dist/commands/files.d.ts +41 -6
  12. package/dist/commands/files.js +108 -27
  13. package/dist/commands/pack-start-bot.d.ts +4 -6
  14. package/dist/commands/pack-start-bot.js +4 -8
  15. package/dist/commands/signals-run.d.ts +45 -0
  16. package/dist/commands/signals-run.js +175 -0
  17. package/dist/commands/signals.d.ts +1 -0
  18. package/dist/commands/signals.js +3 -0
  19. package/dist/commands/sources.d.ts +11 -0
  20. package/dist/commands/sources.js +137 -25
  21. package/dist/commands/staff/reset-test-account.d.ts +10 -0
  22. package/dist/commands/staff/reset-test-account.js +18 -2
  23. package/dist/hq-cloud-sync-runner/bin/sync-runner.js +31171 -52512
  24. package/dist/hq-cloud-sync-runner/package.json +1 -1
  25. package/dist/lib/bot/api.d.ts +3 -2
  26. package/dist/lib/bot/config.d.ts +2 -0
  27. package/dist/lib/bot/fleet-cron.d.ts +121 -0
  28. package/dist/lib/bot/fleet-cron.js +319 -0
  29. package/dist/lib/bot/fleet-local.d.ts +113 -2
  30. package/dist/lib/bot/fleet-local.js +597 -35
  31. package/dist/lib/bot/fleet-owner-companies.d.ts +12 -0
  32. package/dist/lib/bot/fleet-owner-companies.js +30 -0
  33. package/dist/lib/bot/fleet-watch.d.ts +25 -0
  34. package/dist/lib/bot/fleet-watch.js +51 -0
  35. package/dist/lib/bot/index.d.ts +1 -1
  36. package/dist/lib/bot/index.js +1 -1
  37. package/dist/lib/bot/job-run.d.ts +5 -4
  38. package/dist/lib/bot/job-run.js +5 -4
  39. package/dist/lib/bot/jobs/hot-signal-sync.d.ts +57 -3
  40. package/dist/lib/bot/jobs/hot-signal-sync.js +163 -4
  41. package/dist/lib/bot/local-jobs.d.ts +13 -2
  42. package/dist/lib/bot/local-jobs.js +17 -4
  43. package/dist/lib/bot/progress.d.ts +14 -0
  44. package/dist/lib/bot/progress.js +47 -1
  45. package/dist/lib/bot/run.d.ts +55 -2
  46. package/dist/lib/bot/run.js +192 -10
  47. package/dist/lib/bot/setup-tool-offer.d.ts +1 -0
  48. package/dist/lib/bot/setup-tool-offer.js +16 -0
  49. package/dist/lib/bot/signals-engine-flag.d.ts +18 -0
  50. package/dist/lib/bot/signals-engine-flag.js +30 -0
  51. package/dist/lib/daemon/catalog.d.ts +4 -0
  52. package/dist/lib/daemon/catalog.js +29 -9
  53. package/dist/lib/daemon/legacy.d.ts +6 -0
  54. package/dist/lib/daemon/legacy.js +16 -10
  55. package/dist/lib/daemon/run.d.ts +1 -0
  56. package/dist/lib/daemon/run.js +10 -0
  57. package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
  58. package/dist/lib/flag-registry-worker-source.generated.js +1 -1
  59. package/dist/lib/flag-registry.d.ts +13 -1
  60. package/dist/lib/flag-registry.js +46 -0
  61. package/dist/lib/hot-channels/adapters/attio.d.ts +75 -2
  62. package/dist/lib/hot-channels/adapters/attio.js +367 -29
  63. package/dist/lib/hot-channels/adapters/gmail.d.ts +14 -0
  64. package/dist/lib/hot-channels/adapters/gmail.js +59 -2
  65. package/dist/lib/hot-channels/adapters/linear.d.ts +24 -0
  66. package/dist/lib/hot-channels/adapters/linear.js +71 -1
  67. package/dist/lib/hot-channels/adapters/meetings.js +4 -1
  68. package/dist/lib/hot-channels/adapters/slack.js +7 -3
  69. package/dist/lib/hot-channels/alert.js +1 -1
  70. package/dist/lib/hot-channels/contract.d.ts +11 -0
  71. package/dist/lib/hot-channels/contract.js +1 -0
  72. package/dist/lib/hot-channels/cursors.js +1 -1
  73. package/dist/lib/hot-channels/landing.d.ts +6 -2
  74. package/dist/lib/hot-channels/landing.js +13 -6
  75. package/dist/lib/hot-channels/ledger.js +2 -0
  76. package/dist/lib/hot-channels/loop.js +18 -4
  77. package/dist/lib/hot-channels/registry.js +9 -1
  78. package/dist/lib/hot-channels/run.d.ts +18 -0
  79. package/dist/lib/hot-channels/run.js +15 -4
  80. package/dist/lib/hot-channels/signals-pass.d.ts +1 -0
  81. package/dist/lib/hot-channels/signals-pass.js +7 -5
  82. package/dist/lib/hot-channels/sync-lock.js +22 -6
  83. package/dist/lib/mesh/live/daemon/install.d.ts +22 -0
  84. package/dist/lib/mesh/live/daemon/install.js +117 -30
  85. package/dist/lib/signals/adapters-catalog.d.ts +77 -0
  86. package/dist/lib/signals/adapters-catalog.js +194 -0
  87. package/dist/lib/signals/engine.d.ts +196 -0
  88. package/dist/lib/signals/engine.js +495 -0
  89. package/dist/lib/signals/failure-alert.d.ts +89 -0
  90. package/dist/lib/signals/failure-alert.js +175 -0
  91. package/dist/lib/signals/local-date.d.ts +3 -0
  92. package/dist/lib/signals/local-date.js +6 -0
  93. package/dist/lib/signals/projection.d.ts +42 -0
  94. package/dist/lib/signals/projection.js +67 -0
  95. package/dist/lib/signals/reply.d.ts +112 -0
  96. package/dist/lib/signals/reply.js +368 -0
  97. package/dist/lib/signals/run-lock.d.ts +36 -0
  98. package/dist/lib/signals/run-lock.js +148 -0
  99. package/dist/lib/signals/run-record.d.ts +24 -0
  100. package/dist/lib/signals/run-record.js +82 -0
  101. package/dist/lib/signals/sanitize.d.ts +105 -0
  102. package/dist/lib/signals/sanitize.js +439 -0
  103. package/dist/lib/signals/settings.d.ts +10 -0
  104. package/dist/lib/signals/settings.js +48 -0
  105. package/dist/lib/signals/steps/adapters.d.ts +14 -0
  106. package/dist/lib/signals/steps/adapters.js +156 -0
  107. package/dist/lib/signals/steps/entity-similarity.d.ts +10 -0
  108. package/dist/lib/signals/steps/entity-similarity.js +47 -0
  109. package/dist/lib/signals/steps/extract.d.ts +113 -0
  110. package/dist/lib/signals/steps/extract.js +765 -0
  111. package/dist/lib/signals/steps/garden.d.ts +24 -0
  112. package/dist/lib/signals/steps/garden.js +250 -0
  113. package/dist/lib/signals/steps/types.d.ts +112 -0
  114. package/dist/lib/signals/steps/types.js +4 -0
  115. package/dist/lib/signals/steps/upload.d.ts +43 -0
  116. package/dist/lib/signals/steps/upload.js +324 -0
  117. package/dist/lib/signals/write-guard.d.ts +63 -0
  118. package/dist/lib/signals/write-guard.js +287 -0
  119. package/dist/utils/resident-package-preload.js +16 -0
  120. package/package.json +9 -9
  121. package/dist/lib/bot/fleet-default-flag.d.ts +0 -31
  122. 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:{Idempotency-Key}` in the frontmatter and the webhook
8
- * payload in the `.raw.json` sidecar. This adapter is the drain half, a poll
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 idempotencyKey = eventKey.slice("attio:".length);
91
- if (idempotencyKey === "")
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
- idempotencyKey,
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
- async function fetch(cursor, ctx) {
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 (err) {
228
- if (err.code === "ENOENT")
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
- : ctx.gateway
237
- ? await lookupMemberIds(ctx.gateway, emails)
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
- events.push(toEvent(d, [...reasons], actor, ref, ctx.personUid, options.identities));
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
- function toEvent(d, reasons, actor, ref, personUid, identities) {
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
- providerId: d.idempotencyKey,
358
- // The receiver dedupes by Idempotency-Key, so one delivery has one version.
359
- version: d.eventType,
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 gateway = ctx.gateway;
328
- if (!gateway)
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 {