fullstackgtm 0.49.0 → 0.50.1
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 +77 -0
- package/DATA-FLOWS.md +1 -0
- package/README.md +48 -0
- package/dist/acquireCheckpoint.d.ts +33 -0
- package/dist/acquireCheckpoint.js +149 -0
- package/dist/acquireLinkedIn.d.ts +2 -0
- package/dist/acquireLinkedIn.js +1 -1
- package/dist/cli/audit.js +6 -1
- package/dist/cli/auth.js +25 -0
- package/dist/cli/backfill.js +4 -0
- package/dist/cli/call.js +28 -6
- package/dist/cli/draft.js +11 -1
- package/dist/cli/enrich.js +286 -81
- package/dist/cli/fix.js +6 -3
- package/dist/cli/help.js +30 -26
- package/dist/cli/icp.js +15 -1
- package/dist/cli/market.js +14 -1
- package/dist/cli/planOutput.d.ts +5 -0
- package/dist/cli/planOutput.js +27 -0
- package/dist/cli/plans.js +269 -24
- package/dist/cli/schedule.js +22 -11
- package/dist/cli/shared.d.ts +3 -1
- package/dist/cli/shared.js +2 -2
- package/dist/cli/signals.js +22 -3
- package/dist/cli/tam.js +10 -1
- package/dist/cli/ui.d.ts +10 -5
- package/dist/cli/ui.js +28 -9
- package/dist/connectors/hubspot.d.ts +4 -0
- package/dist/connectors/hubspot.js +36 -18
- package/dist/connectors/linkedin.d.ts +2 -0
- package/dist/connectors/linkedin.js +5 -0
- package/dist/connectors/prospectSources.d.ts +23 -0
- package/dist/connectors/prospectSources.js +21 -3
- package/dist/enrich.d.ts +44 -1
- package/dist/hostedAcquireCheckpoint.d.ts +43 -0
- package/dist/hostedAcquireCheckpoint.js +129 -0
- package/dist/hostedPatchPlan.d.ts +87 -0
- package/dist/hostedPatchPlan.js +275 -0
- package/dist/icp.js +13 -2
- package/dist/index.d.ts +4 -1
- package/dist/index.js +3 -0
- package/dist/planStore.d.ts +14 -0
- package/dist/planStore.js +73 -0
- package/dist/progress.d.ts +2 -2
- package/dist/progress.js +2 -2
- package/docs/api.md +24 -0
- package/docs/architecture.md +9 -0
- package/package.json +1 -1
- package/skills/fullstackgtm/SKILL.md +1 -1
- package/src/acquireCheckpoint.ts +186 -0
- package/src/acquireLinkedIn.ts +3 -1
- package/src/cli/audit.ts +5 -1
- package/src/cli/auth.ts +24 -0
- package/src/cli/backfill.ts +3 -0
- package/src/cli/call.ts +25 -6
- package/src/cli/draft.ts +9 -1
- package/src/cli/enrich.ts +325 -80
- package/src/cli/fix.ts +5 -3
- package/src/cli/help.ts +31 -26
- package/src/cli/icp.ts +13 -1
- package/src/cli/market.ts +12 -1
- package/src/cli/planOutput.ts +30 -0
- package/src/cli/plans.ts +259 -25
- package/src/cli/schedule.ts +21 -15
- package/src/cli/shared.ts +2 -1
- package/src/cli/signals.ts +22 -3
- package/src/cli/tam.ts +8 -1
- package/src/cli/ui.ts +31 -13
- package/src/connectors/hubspot.ts +41 -17
- package/src/connectors/linkedin.ts +6 -0
- package/src/connectors/prospectSources.ts +46 -4
- package/src/enrich.ts +47 -1
- package/src/hostedAcquireCheckpoint.ts +156 -0
- package/src/hostedPatchPlan.ts +291 -0
- package/src/icp.ts +14 -2
- package/src/index.ts +20 -0
- package/src/planStore.ts +87 -0
- package/src/progress.ts +2 -2
package/src/cli/schedule.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { computeMissedFirings, createFileScheduleRunStore, createFileScheduleSto
|
|
|
9
9
|
import { runCli } from "../cli.ts";
|
|
10
10
|
import { isOptionValue, numericOption, option } from "./shared.ts";
|
|
11
11
|
import { unknownSubcommandError } from "./suggest.ts";
|
|
12
|
+
import { colorEnabled, paint, table } from "./ui.ts";
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
/**
|
|
@@ -120,12 +121,19 @@ trigger: manual. status shows next firing and surfaces missed firings
|
|
|
120
121
|
console.log('No schedules. Add one with `fullstackgtm schedule add "<command>" --cron "<expr>"`.');
|
|
121
122
|
return;
|
|
122
123
|
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
)
|
|
127
|
-
|
|
128
|
-
|
|
124
|
+
const p = paint(colorEnabled(process.stdout));
|
|
125
|
+
const verbose = rest.includes("--verbose");
|
|
126
|
+
const rows = [
|
|
127
|
+
["STATE", "SCHEDULE", "NEXT", ...(verbose ? ["CRON", "COMMAND"] : [])],
|
|
128
|
+
...withNext.map((entry) => [
|
|
129
|
+
entry.enabled ? "on" : "off",
|
|
130
|
+
`${entry.label} · ${entry.id}`,
|
|
131
|
+
entry.nextFiring ?? "—",
|
|
132
|
+
...(verbose ? [entry.cron, entry.argv.join(" ")] : []),
|
|
133
|
+
]),
|
|
134
|
+
];
|
|
135
|
+
console.log(table(rows, [p.dim, null, null]).join("\n"));
|
|
136
|
+
console.log("\nDeclarative only · run `fullstackgtm schedule install` after changes.");
|
|
129
137
|
return;
|
|
130
138
|
}
|
|
131
139
|
|
|
@@ -272,21 +280,19 @@ trigger: manual. status shows next firing and surfaces missed firings
|
|
|
272
280
|
const last = entry.lastRun as { firedAt: string; trigger: string; exitCode: number; noopReason?: string; artifacts: { planIds: string[]; runLabels: string[] } } | null;
|
|
273
281
|
const streak = entry.streak as { outcome: string; length: number } | null;
|
|
274
282
|
const missed = entry.missedFirings as string[];
|
|
275
|
-
|
|
276
|
-
|
|
283
|
+
const verbose = rest.includes("--verbose");
|
|
284
|
+
const outcome = !last ? "never run" : last.exitCode === 0 ? (last.noopReason ? `no-op · ${last.noopReason}` : "healthy") : `failed · exit ${last.exitCode}`;
|
|
285
|
+
console.log(`${entry.enabled ? "●" : "○"} ${entry.label} · ${outcome}`);
|
|
286
|
+
console.log(` ${entry.id} · next ${entry.nextFiring ?? "— (disabled)"}`);
|
|
287
|
+
if (verbose) console.log(` ${(entry.argv as string[]).join(" ")} · cron ${entry.cron}`);
|
|
277
288
|
if (last) {
|
|
278
289
|
const artifacts = [
|
|
279
290
|
...last.artifacts.planIds.map((planId) => `plan ${planId}`),
|
|
280
291
|
...last.artifacts.runLabels.map((runLabel) => `run ${runLabel}`),
|
|
281
292
|
];
|
|
282
|
-
console.log(
|
|
283
|
-
` last: ${last.firedAt} (${last.trigger}) exit ${last.exitCode}` +
|
|
284
|
-
(last.noopReason ? ` — no-op: ${last.noopReason}` : "") +
|
|
285
|
-
(artifacts.length ? ` — ${artifacts.join(", ")}` : ""),
|
|
286
|
-
);
|
|
287
|
-
console.log(` streak: ${streak!.length} ${streak!.outcome}(s)`);
|
|
293
|
+
console.log(` last ${last.firedAt} · ${last.trigger} · ${streak!.length} ${streak!.outcome}${streak!.length === 1 ? "" : "s"}${artifacts.length ? ` · ${artifacts.join(", ")}` : ""}`);
|
|
288
294
|
} else {
|
|
289
|
-
console.log(" last
|
|
295
|
+
console.log(" last never fired");
|
|
290
296
|
}
|
|
291
297
|
if (missed.length > 0) {
|
|
292
298
|
console.log(
|
package/src/cli/shared.ts
CHANGED
|
@@ -164,6 +164,7 @@ export async function connectorFor(
|
|
|
164
164
|
export async function readSnapshot(
|
|
165
165
|
args: string[],
|
|
166
166
|
progress?: ProgressEmitter,
|
|
167
|
+
options: { persistProgress?: boolean } = {},
|
|
167
168
|
): Promise<CanonicalGtmSnapshot> {
|
|
168
169
|
const provider = option(args, "--provider");
|
|
169
170
|
if (provider) {
|
|
@@ -189,7 +190,7 @@ export async function readSnapshot(
|
|
|
189
190
|
try {
|
|
190
191
|
return await connector.fetchSnapshot();
|
|
191
192
|
} finally {
|
|
192
|
-
renderer.done();
|
|
193
|
+
renderer.done({ persist: options.persistProgress });
|
|
193
194
|
}
|
|
194
195
|
}
|
|
195
196
|
if (args.includes("--demo")) {
|
package/src/cli/signals.ts
CHANGED
|
@@ -27,6 +27,21 @@ function resolveSignalsConfig(args: string[]): SignalsConfig {
|
|
|
27
27
|
/** A watchlist entry: an account domain plus optional per-source board tokens. */
|
|
28
28
|
type WatchlistAccount = { domain: string; boards?: Partial<Record<AtsBoardSource, string>> };
|
|
29
29
|
|
|
30
|
+
function concise(value: string, max = 72): string {
|
|
31
|
+
const oneLine = value.replace(/\s+/g, " ").trim();
|
|
32
|
+
return oneLine.length <= max ? oneLine : `${oneLine.slice(0, max - 1)}…`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function renderSignals(signals: Signal[]): string {
|
|
36
|
+
if (signals.length === 0) return "No signals matched.";
|
|
37
|
+
const lines = [`Fresh signals (${signals.length})`, ""];
|
|
38
|
+
for (const signal of signals) {
|
|
39
|
+
lines.push(`${signal.weight.toFixed(2).padStart(5)} ${signal.accountDomain} · ${signal.bucket}`);
|
|
40
|
+
lines.push(` ${concise(signal.trigger)}`);
|
|
41
|
+
}
|
|
42
|
+
return lines.join("\n");
|
|
43
|
+
}
|
|
44
|
+
|
|
30
45
|
/**
|
|
31
46
|
* Resolve the watchlist of accounts to scan. Sources, in precedence order:
|
|
32
47
|
* - a --watchlist file (JSON array of {domain, boards?} or bare domain strings),
|
|
@@ -228,7 +243,7 @@ from the credential ladder, never argv; --connector-opt carries non-secret knobs
|
|
|
228
243
|
const ranked = [...fresh].sort((a, b) => b.weight - a.weight || a.accountDomain.localeCompare(b.accountDomain));
|
|
229
244
|
|
|
230
245
|
// Ranked fresh signals to stdout; guidance to stderr.
|
|
231
|
-
console.log(JSON.stringify(ranked, null, 2));
|
|
246
|
+
console.log(rest.includes("--json") || rest.includes("--verbose") ? JSON.stringify(ranked, null, 2) : renderSignals(ranked));
|
|
232
247
|
console.error(
|
|
233
248
|
`Fetched ${fetched} candidate signal(s); ${fresh.length} fresh, ${deduped.length} deduped ` +
|
|
234
249
|
`(window ${config.dedupWindowDays}d). NO plan emitted — signals are read-only re: CRM.`,
|
|
@@ -275,7 +290,7 @@ from the credential ladder, never argv; --connector-opt carries non-secret knobs
|
|
|
275
290
|
return true;
|
|
276
291
|
});
|
|
277
292
|
const ranked = filtered.sort((a, b) => b.weight - a.weight || a.accountDomain.localeCompare(b.accountDomain));
|
|
278
|
-
console.log(JSON.stringify(ranked, null, 2));
|
|
293
|
+
console.log(rest.includes("--json") || rest.includes("--verbose") ? JSON.stringify(ranked, null, 2) : renderSignals(ranked));
|
|
279
294
|
console.error(`${ranked.length} signal(s)${unjudgedOnly ? " (unjudged)" : ""}.`);
|
|
280
295
|
return;
|
|
281
296
|
}
|
|
@@ -305,7 +320,11 @@ from the credential ladder, never argv; --connector-opt carries non-secret knobs
|
|
|
305
320
|
const outcomes = await store.listOutcomes();
|
|
306
321
|
const signalsById = new Map((await store.allSignals()).map((s) => [s.id, s]));
|
|
307
322
|
const weights = computeWeights(config, outcomes, signalsById);
|
|
308
|
-
|
|
323
|
+
if (rest.includes("--json") || rest.includes("--verbose")) {
|
|
324
|
+
console.log(JSON.stringify(weights, null, 2));
|
|
325
|
+
} else {
|
|
326
|
+
console.log(["Learned signal weights", "", ...SIGNAL_BUCKETS.map((bucket) => `${bucket.padEnd(10)} ${weights[bucket].toFixed(4)}`)].join("\n"));
|
|
327
|
+
}
|
|
309
328
|
if (rest.includes("--explain")) {
|
|
310
329
|
// Per-bucket config default vs learned + booked/total over credited signals.
|
|
311
330
|
const booked = new Map<SignalBucket, number>();
|
package/src/cli/tam.ts
CHANGED
|
@@ -275,8 +275,15 @@ RevOps universe, with real names. --source explorium is a firmographic count onl
|
|
|
275
275
|
if (out) {
|
|
276
276
|
writeFileSync(resolve(process.cwd(), out), md);
|
|
277
277
|
console.log(`Wrote ${out}.`);
|
|
278
|
-
} else {
|
|
278
|
+
} else if (rest.includes("--verbose")) {
|
|
279
279
|
console.log(md);
|
|
280
|
+
} else if (timeline.length > 0) {
|
|
281
|
+
console.log(coverageToText(model, timeline.at(-1)!, eta));
|
|
282
|
+
console.log("\nUse --verbose for assumptions, cross-checks, and the full coverage history.");
|
|
283
|
+
} else {
|
|
284
|
+
console.log(`TAM "${model.name}" · ${model.universe.accounts.toLocaleString()} accounts · ${model.universe.contacts.toLocaleString()} buyers · $${Math.round(model.tamUsd).toLocaleString()}`);
|
|
285
|
+
console.log(`ICP ${model.icpName} · ${model.acv.basis}-basis ACV $${Math.round(model.acv.valueUsd).toLocaleString()} (${model.acv.source})`);
|
|
286
|
+
console.log("No coverage readings yet. Run `fullstackgtm tam status --save` to establish one; use --verbose for the full assumptions report.");
|
|
280
287
|
}
|
|
281
288
|
return;
|
|
282
289
|
}
|
package/src/cli/ui.ts
CHANGED
|
@@ -286,8 +286,8 @@ export function startElapsedStatus(
|
|
|
286
286
|
|
|
287
287
|
export type Checklist = {
|
|
288
288
|
update(id: string, state: "pending" | "running" | "ok" | "warn" | "fail", note?: string): void;
|
|
289
|
-
/** Stop animating
|
|
290
|
-
done(): void;
|
|
289
|
+
/** Stop animating; optionally leave the final board in the terminal history. */
|
|
290
|
+
done(options?: { persist?: boolean }): void;
|
|
291
291
|
readonly active: boolean;
|
|
292
292
|
};
|
|
293
293
|
|
|
@@ -296,7 +296,8 @@ const NOOP_CHECKLIST: Checklist = { update() {}, done() {}, active: false };
|
|
|
296
296
|
/**
|
|
297
297
|
* A multi-line live board — one line per item, each flipping ○ → ⠹ → ✓ as work
|
|
298
298
|
* progresses (the audit rule registry renders through this). Repaints with
|
|
299
|
-
* cursor-up; erases
|
|
299
|
+
* cursor-up; done() normally erases it, while done({ persist: true }) leaves a
|
|
300
|
+
* final static history for multi-phase human workflows.
|
|
300
301
|
*/
|
|
301
302
|
export function createChecklist(
|
|
302
303
|
items: Array<{ id: string; label: string }>,
|
|
@@ -323,8 +324,12 @@ export function createChecklist(
|
|
|
323
324
|
const label = entry.state === "pending" ? p.dim(item.label.padEnd(labelWidth)) : item.label.padEnd(labelWidth);
|
|
324
325
|
return ` ${glyph} ${label}${note}`;
|
|
325
326
|
});
|
|
326
|
-
|
|
327
|
-
|
|
327
|
+
// Keep the cursor on the board's last row while it is live. A trailing
|
|
328
|
+
// newline on every repaint can scroll the old top row into permanent
|
|
329
|
+
// history when the board sits at the bottom of the terminal, producing
|
|
330
|
+
// apparent duplicate/errored frames in terminal captures.
|
|
331
|
+
const up = painted > 1 ? `\u001b[${painted - 1}A` : "";
|
|
332
|
+
stream.write(`${up}${lines.map((line) => `\u001b[2K${line}`).join("\n")}`);
|
|
328
333
|
painted = lines.length;
|
|
329
334
|
};
|
|
330
335
|
|
|
@@ -343,16 +348,28 @@ export function createChecklist(
|
|
|
343
348
|
const entry = states.get(id);
|
|
344
349
|
if (!entry) return;
|
|
345
350
|
entry.state = state;
|
|
346
|
-
entry.note = note;
|
|
351
|
+
if (note !== undefined) entry.note = note;
|
|
347
352
|
start();
|
|
348
353
|
render();
|
|
349
354
|
},
|
|
350
|
-
done() {
|
|
355
|
+
done(options = {}) {
|
|
351
356
|
if (timer) clearInterval(timer);
|
|
352
357
|
timer = null;
|
|
358
|
+
if (options.persist) {
|
|
359
|
+
// The caller's final state update already painted the completed board.
|
|
360
|
+
// Advance exactly once so subsequent output begins below it.
|
|
361
|
+
if (painted > 0) stream.write("\n");
|
|
362
|
+
painted = 0;
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
353
365
|
if (painted > 0) {
|
|
354
|
-
// Erase
|
|
355
|
-
|
|
366
|
+
// Erase in place without linefeeds, which could themselves scroll.
|
|
367
|
+
const up = painted > 1 ? `\u001b[${painted - 1}A` : "";
|
|
368
|
+
const clear = Array.from({ length: painted }, (_, index) =>
|
|
369
|
+
`\u001b[2K${index < painted - 1 ? "\u001b[1B" : ""}`,
|
|
370
|
+
).join("");
|
|
371
|
+
const restore = painted > 1 ? `\u001b[${painted - 1}A` : "";
|
|
372
|
+
stream.write(`${up}${clear}${restore}`);
|
|
356
373
|
painted = 0;
|
|
357
374
|
}
|
|
358
375
|
},
|
|
@@ -366,8 +383,8 @@ export type ProgressRenderer = {
|
|
|
366
383
|
* listener via `composeListeners` when the run should also heartbeat).
|
|
367
384
|
*/
|
|
368
385
|
listener: ProgressListener;
|
|
369
|
-
/** Stop animating
|
|
370
|
-
done(): void;
|
|
386
|
+
/** Stop animating; optionally leave the completed board in terminal history. */
|
|
387
|
+
done(options?: { persist?: boolean }): void;
|
|
371
388
|
readonly active: boolean;
|
|
372
389
|
};
|
|
373
390
|
|
|
@@ -423,8 +440,9 @@ export function createProgressRenderer(
|
|
|
423
440
|
// larger flow) are ignored by createChecklist's unknown-id no-op.
|
|
424
441
|
if (current) board.update(current, "running", noteFor(event, snapshot));
|
|
425
442
|
},
|
|
426
|
-
done() {
|
|
427
|
-
board.
|
|
443
|
+
done(options) {
|
|
444
|
+
if (current) board.update(current, "ok");
|
|
445
|
+
board.done(options);
|
|
428
446
|
},
|
|
429
447
|
active: true,
|
|
430
448
|
};
|
|
@@ -42,6 +42,10 @@ export type HubspotConnectorOptions = {
|
|
|
42
42
|
* alongside the legacy `onProgress` callback; both are presentation-only.
|
|
43
43
|
*/
|
|
44
44
|
progress?: ProgressEmitter;
|
|
45
|
+
/** Maximum retries for HubSpot 429/5xx responses (default 5). */
|
|
46
|
+
maxRetries?: number;
|
|
47
|
+
/** Injectable delay for deterministic retry tests. */
|
|
48
|
+
sleep?: (milliseconds: number) => Promise<void>;
|
|
45
49
|
};
|
|
46
50
|
|
|
47
51
|
const OBJECT_PATHS: Partial<Record<GtmObjectType, string>> = {
|
|
@@ -76,6 +80,8 @@ const PULL_STAGE_BY_TYPE: Record<SnapshotProgress["objectType"], (typeof SNAPSHO
|
|
|
76
80
|
export function createHubspotConnector(options: HubspotConnectorOptions): HubspotWritableConnector {
|
|
77
81
|
const baseUrl = (options.apiBaseUrl ?? DEFAULT_API_BASE_URL).replace(/\/$/, "");
|
|
78
82
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
83
|
+
const maxRetries = options.maxRetries ?? 5;
|
|
84
|
+
const sleep = options.sleep ?? ((milliseconds: number) => new Promise<void>((resolve) => setTimeout(resolve, milliseconds)));
|
|
79
85
|
const mappings = options.fieldMappings;
|
|
80
86
|
// create:<Name> dedup within one connector lifetime (one apply run): the
|
|
81
87
|
// search API is eventually consistent, so a just-created company is
|
|
@@ -113,27 +119,45 @@ export function createHubspotConnector(options: HubspotConnectorOptions): Hubspo
|
|
|
113
119
|
};
|
|
114
120
|
|
|
115
121
|
async function request(path: string, init: RequestInit = {}): Promise<any> {
|
|
116
|
-
|
|
117
|
-
let
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
122
|
+
let response: Response | undefined;
|
|
123
|
+
for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
|
|
124
|
+
const token = await options.getAccessToken();
|
|
125
|
+
try {
|
|
126
|
+
response = await fetchImpl(`${baseUrl}${path}`, {
|
|
127
|
+
...init,
|
|
128
|
+
headers: {
|
|
129
|
+
Authorization: `Bearer ${token}`,
|
|
130
|
+
"Content-Type": "application/json",
|
|
131
|
+
...(init.headers ?? {}),
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
} catch (error) {
|
|
135
|
+
const cause = error instanceof Error && error.cause instanceof Error ? `: ${error.cause.message}` : "";
|
|
136
|
+
throw new Error(`Cannot reach HubSpot at ${baseUrl}${cause}. Check network access.`);
|
|
137
|
+
}
|
|
138
|
+
const retryable = response.status === 429 || response.status >= 500;
|
|
139
|
+
if (!retryable || attempt === maxRetries) break;
|
|
140
|
+
await response.text().catch(() => undefined);
|
|
141
|
+
const retryAfter = response.headers.get("Retry-After");
|
|
142
|
+
const seconds = retryAfter === null ? NaN : Number(retryAfter);
|
|
143
|
+
const dateDelay = retryAfter && !Number.isFinite(seconds) ? Date.parse(retryAfter) - Date.now() : NaN;
|
|
144
|
+
const delayMs = Math.min(
|
|
145
|
+
30_000,
|
|
146
|
+
Math.max(0, Number.isFinite(seconds) ? seconds * 1_000 : Number.isFinite(dateDelay) ? dateDelay : 1_000 * 2 ** attempt),
|
|
147
|
+
);
|
|
148
|
+
const reason = response.status === 429 ? "rate limited" : `temporarily unavailable (${response.status})`;
|
|
149
|
+
options.progress?.note(`HubSpot ${reason}; retrying in ${Math.ceil(delayMs / 1_000)}s (${attempt + 1}/${maxRetries})`);
|
|
150
|
+
await sleep(delayMs);
|
|
130
151
|
}
|
|
131
|
-
if (!response.ok) {
|
|
152
|
+
if (!response || !response.ok) {
|
|
132
153
|
// Status line only — HubSpot 4xx bodies echo submitted property values
|
|
133
154
|
// (contact emails, company domains) and the request payload, and these
|
|
134
155
|
// errors are persisted into scheduled-run records. Never interpolate it.
|
|
135
|
-
await response
|
|
136
|
-
|
|
156
|
+
await response?.text().catch(() => undefined);
|
|
157
|
+
if (response?.status === 429) {
|
|
158
|
+
throw new Error(`HubSpot rate limit (429) persisted after ${maxRetries} retries. Wait for the portal limit to reset, then retry; no CRM writes were made.`);
|
|
159
|
+
}
|
|
160
|
+
throw new Error(`HubSpot API error ${response?.status ?? "unknown"}. Check the token scopes and request.`);
|
|
137
161
|
}
|
|
138
162
|
// DELETE and some association writes return 204 with an empty body.
|
|
139
163
|
const text = await response.text();
|
|
@@ -41,6 +41,8 @@ export type FetchProspectsOptions = {
|
|
|
41
41
|
sourceId?: string;
|
|
42
42
|
/** Hard cap on prospects returned; also bounds pagination. */
|
|
43
43
|
max?: number;
|
|
44
|
+
/** Opaque continuation cursor. HeyReach uses the decimal list offset. */
|
|
45
|
+
cursor?: string;
|
|
44
46
|
};
|
|
45
47
|
|
|
46
48
|
export type ConnectionStatus = { ok: boolean; detail: string };
|
|
@@ -152,6 +154,10 @@ export function createHeyReachProvider(options: HeyReachProviderOptions): Linked
|
|
|
152
154
|
const max = opts.max ?? Number.POSITIVE_INFINITY;
|
|
153
155
|
const out: LinkedInProspect[] = [];
|
|
154
156
|
let offset = 0;
|
|
157
|
+
if (opts.cursor !== undefined) {
|
|
158
|
+
if (!/^\d+$/.test(opts.cursor)) throw new Error(`Invalid HeyReach cursor: ${opts.cursor}`);
|
|
159
|
+
offset = Number(opts.cursor);
|
|
160
|
+
}
|
|
155
161
|
while (out.length < max) {
|
|
156
162
|
const data = await call("/list/GetLeadsFromList", "POST", { listId, offset, limit: pageSize });
|
|
157
163
|
const items = toArray(data);
|
|
@@ -68,6 +68,8 @@ export async function fetchExploriumProspects(opts: {
|
|
|
68
68
|
apiKey: string;
|
|
69
69
|
filters: ExploriumFilters;
|
|
70
70
|
size?: number;
|
|
71
|
+
/** One-based result page. */
|
|
72
|
+
page?: number;
|
|
71
73
|
apiBaseUrl?: string;
|
|
72
74
|
fetchImpl?: FetchImpl;
|
|
73
75
|
}): Promise<Prospect[]> {
|
|
@@ -77,7 +79,7 @@ export async function fetchExploriumProspects(opts: {
|
|
|
77
79
|
const response = await fetchImpl(`${base}/v1/prospects`, {
|
|
78
80
|
method: "POST",
|
|
79
81
|
headers: { "api_key": opts.apiKey, "Content-Type": "application/json" },
|
|
80
|
-
body: JSON.stringify({ mode: "full", size, page_size: size, page: 1, filters: opts.filters }),
|
|
82
|
+
body: JSON.stringify({ mode: "full", size, page_size: size, page: opts.page ?? 1, filters: opts.filters }),
|
|
81
83
|
});
|
|
82
84
|
if (!response.ok) {
|
|
83
85
|
throw new ProviderHttpError("Explorium", "prospect search", response.status);
|
|
@@ -119,17 +121,44 @@ export async function fetchPipe0CrustdataProspects(opts: {
|
|
|
119
121
|
apiKey: string;
|
|
120
122
|
filters: Record<string, unknown>;
|
|
121
123
|
limit?: number;
|
|
124
|
+
/** Opaque cursor returned by a previous page. */
|
|
125
|
+
cursor?: string;
|
|
122
126
|
apiBaseUrl?: string;
|
|
123
127
|
fetchImpl?: FetchImpl;
|
|
124
128
|
}): Promise<Prospect[]> {
|
|
129
|
+
return (await fetchPipe0CrustdataProspectPage(opts)).prospects;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** A page from pipe0/Crustdata's cursor-based people search. */
|
|
133
|
+
export type Pipe0CrustdataProspectPage = {
|
|
134
|
+
prospects: Prospect[];
|
|
135
|
+
/** Opaque cursor for the next page, or null when the search is exhausted. */
|
|
136
|
+
nextCursor: string | null;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Fetch one page and expose its continuation cursor. The array-returning
|
|
141
|
+
* `fetchPipe0CrustdataProspects` remains available for existing consumers.
|
|
142
|
+
*/
|
|
143
|
+
export async function fetchPipe0CrustdataProspectPage(opts: {
|
|
144
|
+
apiKey: string;
|
|
145
|
+
filters: Record<string, unknown>;
|
|
146
|
+
limit?: number;
|
|
147
|
+
/** Opaque cursor returned by a previous page. */
|
|
148
|
+
cursor?: string;
|
|
149
|
+
apiBaseUrl?: string;
|
|
150
|
+
fetchImpl?: FetchImpl;
|
|
151
|
+
}): Promise<Pipe0CrustdataProspectPage> {
|
|
125
152
|
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
126
153
|
const base = (opts.apiBaseUrl ?? "https://api.pipe0.com").replace(/\/$/, "");
|
|
127
154
|
const limit = Math.min(opts.limit ?? 25, 100);
|
|
155
|
+
const config: Record<string, unknown> = { limit, filters: opts.filters };
|
|
156
|
+
if (opts.cursor !== undefined) config.cursor = opts.cursor;
|
|
128
157
|
const response = await fetchImpl(`${base}/v1/searches/run/sync`, {
|
|
129
158
|
method: "POST",
|
|
130
159
|
headers: { Authorization: `Bearer ${opts.apiKey}`, "Content-Type": "application/json" },
|
|
131
160
|
body: JSON.stringify({
|
|
132
|
-
searches: [{ search_id: "people:profiles:crustdata@1", config
|
|
161
|
+
searches: [{ search_id: "people:profiles:crustdata@1", config }],
|
|
133
162
|
}),
|
|
134
163
|
});
|
|
135
164
|
if (!response.ok) {
|
|
@@ -137,7 +166,12 @@ export async function fetchPipe0CrustdataProspects(opts: {
|
|
|
137
166
|
}
|
|
138
167
|
const body = (await response.json()) as {
|
|
139
168
|
results?: Array<Record<string, { value?: unknown }>>;
|
|
140
|
-
|
|
169
|
+
next_cursor?: unknown;
|
|
170
|
+
search_statuses?: Array<{
|
|
171
|
+
errors?: Array<{ code?: string; message?: string }>;
|
|
172
|
+
next_cursor?: unknown;
|
|
173
|
+
cursor?: unknown;
|
|
174
|
+
}>;
|
|
141
175
|
};
|
|
142
176
|
// Surface upstream provider errors (e.g. CreditBalanceInsufficient) instead of
|
|
143
177
|
// silently returning [] — a throttle/credit failure must not look like "no ICP matches".
|
|
@@ -147,7 +181,7 @@ export async function fetchPipe0CrustdataProspects(opts: {
|
|
|
147
181
|
`pipe0/Crustdata search error: ${upstreamErrors.map((e) => `${e.code ?? "error"}: ${e.message ?? ""}`).join("; ")}`,
|
|
148
182
|
);
|
|
149
183
|
}
|
|
150
|
-
|
|
184
|
+
const prospects = (body.results ?? []).map((row) => {
|
|
151
185
|
const fullName = strField(row.name);
|
|
152
186
|
const { firstName, lastName } = splitName(fullName);
|
|
153
187
|
return {
|
|
@@ -160,6 +194,14 @@ export async function fetchPipe0CrustdataProspects(opts: {
|
|
|
160
194
|
sourceId: strField(row.profile_url) ?? fullName,
|
|
161
195
|
};
|
|
162
196
|
});
|
|
197
|
+
// Current pipe0 responses expose `next_cursor` at the top level. Accept the
|
|
198
|
+
// status-envelope variants too so older deployments remain usable.
|
|
199
|
+
const status = body.search_statuses?.[0];
|
|
200
|
+
const rawCursor = body.next_cursor ?? status?.next_cursor ?? status?.cursor;
|
|
201
|
+
return {
|
|
202
|
+
prospects,
|
|
203
|
+
nextCursor: typeof rawCursor === "string" && rawCursor.length > 0 ? rawCursor : null,
|
|
204
|
+
};
|
|
163
205
|
}
|
|
164
206
|
|
|
165
207
|
function strField(field: { value?: unknown } | undefined): string | undefined {
|
package/src/enrich.ts
CHANGED
|
@@ -111,6 +111,8 @@ export type AcquireDiscoveryConfig = {
|
|
|
111
111
|
provider: "explorium" | "pipe0" | "linkedin" | "heyreach";
|
|
112
112
|
filters?: Record<string, unknown>;
|
|
113
113
|
size?: number;
|
|
114
|
+
/** Maximum raw provider candidates scanned per run while seeking fresh leads. */
|
|
115
|
+
scanLimit?: number;
|
|
114
116
|
resolveEmailsWith?: "pipe0";
|
|
115
117
|
/** LinkedIn sources only: the provider-native list id to read (HeyReach lead-list id). */
|
|
116
118
|
listId?: string;
|
|
@@ -1326,7 +1328,49 @@ export function selectStaleWork(
|
|
|
1326
1328
|
// Checkpoint (cursor), staleness ledger (stamps), and observability surface
|
|
1327
1329
|
// (enrich status) in one structure — mirrors the market observations store.
|
|
1328
1330
|
|
|
1329
|
-
export type EnrichRunMode = EnrichMode | "ingest";
|
|
1331
|
+
export type EnrichRunMode = EnrichMode | "ingest" | "acquire";
|
|
1332
|
+
|
|
1333
|
+
/**
|
|
1334
|
+
* Provider continuation state for an acquisition query. The fingerprint binds
|
|
1335
|
+
* a cursor/offset to the ICP + provider filters that produced it, preventing a
|
|
1336
|
+
* changed query from accidentally resuming in the middle of the old audience.
|
|
1337
|
+
*/
|
|
1338
|
+
export type AcquireDiscoveryCheckpoint = {
|
|
1339
|
+
queryFingerprint: string;
|
|
1340
|
+
/** Opaque provider cursor (Pipe0 and other cursor-based sources). */
|
|
1341
|
+
cursor?: string | null;
|
|
1342
|
+
/** Zero-based provider offset (LinkedIn/HeyReach and other offset sources). */
|
|
1343
|
+
offset?: number | null;
|
|
1344
|
+
/** True only when the provider has positively reported no next page. */
|
|
1345
|
+
exhausted: boolean;
|
|
1346
|
+
};
|
|
1347
|
+
|
|
1348
|
+
/**
|
|
1349
|
+
* Truthful acquisition funnel for one run. Each field counts candidates, not
|
|
1350
|
+
* provider requests, making duplicate saturation and resolution loss visible
|
|
1351
|
+
* instead of reporting every run as zero.
|
|
1352
|
+
*/
|
|
1353
|
+
export type AcquireRunFunnel = {
|
|
1354
|
+
/** Raw candidates returned across all discovery pages scanned this run. */
|
|
1355
|
+
discovered: number;
|
|
1356
|
+
/** Candidates that met the configured ICP threshold. */
|
|
1357
|
+
qualified: number;
|
|
1358
|
+
/** Qualified candidates removed by the CRM pre-email dedupe. */
|
|
1359
|
+
skippedCrm: number;
|
|
1360
|
+
/** Qualified candidates removed by the cross-run seen ledger. */
|
|
1361
|
+
skippedSeen: number;
|
|
1362
|
+
/** Fresh candidates carrying the configured resolve-first key. */
|
|
1363
|
+
resolved: number;
|
|
1364
|
+
/** Governed create operations emitted into the saved/dry-run plan. */
|
|
1365
|
+
proposed: number;
|
|
1366
|
+
/** Otherwise-proposable candidates held back by the acquire meter. */
|
|
1367
|
+
withheldByMeter: number;
|
|
1368
|
+
};
|
|
1369
|
+
|
|
1370
|
+
export type AcquireRunTelemetry = {
|
|
1371
|
+
funnel: AcquireRunFunnel;
|
|
1372
|
+
discovery: AcquireDiscoveryCheckpoint;
|
|
1373
|
+
};
|
|
1330
1374
|
|
|
1331
1375
|
export type EnrichRun = {
|
|
1332
1376
|
id: string;
|
|
@@ -1339,6 +1383,8 @@ export type EnrichRun = {
|
|
|
1339
1383
|
/** Resume point for an interrupted pull (last processed pull key). */
|
|
1340
1384
|
cursor: string | null;
|
|
1341
1385
|
counts: EnrichCounts;
|
|
1386
|
+
/** Acquisition-only funnel + provider continuation state (absent on legacy runs). */
|
|
1387
|
+
acquireTelemetry?: AcquireRunTelemetry;
|
|
1342
1388
|
planIds: string[];
|
|
1343
1389
|
stamps: EnrichStamp[];
|
|
1344
1390
|
/** Staged source rows (ingest mode only), consumed by append/refresh. */
|