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/dist/cli/ui.js
CHANGED
|
@@ -241,7 +241,8 @@ const NOOP_CHECKLIST = { update() { }, done() { }, active: false };
|
|
|
241
241
|
/**
|
|
242
242
|
* A multi-line live board — one line per item, each flipping ○ → ⠹ → ✓ as work
|
|
243
243
|
* progresses (the audit rule registry renders through this). Repaints with
|
|
244
|
-
* cursor-up; erases
|
|
244
|
+
* cursor-up; done() normally erases it, while done({ persist: true }) leaves a
|
|
245
|
+
* final static history for multi-phase human workflows.
|
|
245
246
|
*/
|
|
246
247
|
export function createChecklist(items, stream = process.stderr, env = process.env) {
|
|
247
248
|
if (!animationEnabled(stream, env) || items.length === 0)
|
|
@@ -261,8 +262,12 @@ export function createChecklist(items, stream = process.stderr, env = process.en
|
|
|
261
262
|
const label = entry.state === "pending" ? p.dim(item.label.padEnd(labelWidth)) : item.label.padEnd(labelWidth);
|
|
262
263
|
return ` ${glyph} ${label}${note}`;
|
|
263
264
|
});
|
|
264
|
-
|
|
265
|
-
|
|
265
|
+
// Keep the cursor on the board's last row while it is live. A trailing
|
|
266
|
+
// newline on every repaint can scroll the old top row into permanent
|
|
267
|
+
// history when the board sits at the bottom of the terminal, producing
|
|
268
|
+
// apparent duplicate/errored frames in terminal captures.
|
|
269
|
+
const up = painted > 1 ? `\u001b[${painted - 1}A` : "";
|
|
270
|
+
stream.write(`${up}${lines.map((line) => `\u001b[2K${line}`).join("\n")}`);
|
|
266
271
|
painted = lines.length;
|
|
267
272
|
};
|
|
268
273
|
const start = () => {
|
|
@@ -280,17 +285,29 @@ export function createChecklist(items, stream = process.stderr, env = process.en
|
|
|
280
285
|
if (!entry)
|
|
281
286
|
return;
|
|
282
287
|
entry.state = state;
|
|
283
|
-
|
|
288
|
+
if (note !== undefined)
|
|
289
|
+
entry.note = note;
|
|
284
290
|
start();
|
|
285
291
|
render();
|
|
286
292
|
},
|
|
287
|
-
done() {
|
|
293
|
+
done(options = {}) {
|
|
288
294
|
if (timer)
|
|
289
295
|
clearInterval(timer);
|
|
290
296
|
timer = null;
|
|
297
|
+
if (options.persist) {
|
|
298
|
+
// The caller's final state update already painted the completed board.
|
|
299
|
+
// Advance exactly once so subsequent output begins below it.
|
|
300
|
+
if (painted > 0)
|
|
301
|
+
stream.write("\n");
|
|
302
|
+
painted = 0;
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
291
305
|
if (painted > 0) {
|
|
292
|
-
// Erase
|
|
293
|
-
|
|
306
|
+
// Erase in place without linefeeds, which could themselves scroll.
|
|
307
|
+
const up = painted > 1 ? `\u001b[${painted - 1}A` : "";
|
|
308
|
+
const clear = Array.from({ length: painted }, (_, index) => `\u001b[2K${index < painted - 1 ? "\u001b[1B" : ""}`).join("");
|
|
309
|
+
const restore = painted > 1 ? `\u001b[${painted - 1}A` : "";
|
|
310
|
+
stream.write(`${up}${clear}${restore}`);
|
|
294
311
|
painted = 0;
|
|
295
312
|
}
|
|
296
313
|
},
|
|
@@ -344,8 +361,10 @@ export function createProgressRenderer(stages, stream = process.stderr, env = pr
|
|
|
344
361
|
if (current)
|
|
345
362
|
board.update(current, "running", noteFor(event, snapshot));
|
|
346
363
|
},
|
|
347
|
-
done() {
|
|
348
|
-
|
|
364
|
+
done(options) {
|
|
365
|
+
if (current)
|
|
366
|
+
board.update(current, "ok");
|
|
367
|
+
board.done(options);
|
|
349
368
|
},
|
|
350
369
|
active: true,
|
|
351
370
|
};
|
|
@@ -18,6 +18,10 @@ export type HubspotConnectorOptions = {
|
|
|
18
18
|
* alongside the legacy `onProgress` callback; both are presentation-only.
|
|
19
19
|
*/
|
|
20
20
|
progress?: ProgressEmitter;
|
|
21
|
+
/** Maximum retries for HubSpot 429/5xx responses (default 5). */
|
|
22
|
+
maxRetries?: number;
|
|
23
|
+
/** Injectable delay for deterministic retry tests. */
|
|
24
|
+
sleep?: (milliseconds: number) => Promise<void>;
|
|
21
25
|
};
|
|
22
26
|
/**
|
|
23
27
|
* Reference connector for HubSpot.
|
|
@@ -30,6 +30,8 @@ const PULL_STAGE_BY_TYPE = {
|
|
|
30
30
|
export function createHubspotConnector(options) {
|
|
31
31
|
const baseUrl = (options.apiBaseUrl ?? DEFAULT_API_BASE_URL).replace(/\/$/, "");
|
|
32
32
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
33
|
+
const maxRetries = options.maxRetries ?? 5;
|
|
34
|
+
const sleep = options.sleep ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
|
|
33
35
|
const mappings = options.fieldMappings;
|
|
34
36
|
// create:<Name> dedup within one connector lifetime (one apply run): the
|
|
35
37
|
// search API is eventually consistent, so a just-created company is
|
|
@@ -66,28 +68,44 @@ export function createHubspotConnector(options) {
|
|
|
66
68
|
emitter.items(fetched);
|
|
67
69
|
};
|
|
68
70
|
async function request(path, init = {}) {
|
|
69
|
-
const token = await options.getAccessToken();
|
|
70
71
|
let response;
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
72
|
+
for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
|
|
73
|
+
const token = await options.getAccessToken();
|
|
74
|
+
try {
|
|
75
|
+
response = await fetchImpl(`${baseUrl}${path}`, {
|
|
76
|
+
...init,
|
|
77
|
+
headers: {
|
|
78
|
+
Authorization: `Bearer ${token}`,
|
|
79
|
+
"Content-Type": "application/json",
|
|
80
|
+
...(init.headers ?? {}),
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
const cause = error instanceof Error && error.cause instanceof Error ? `: ${error.cause.message}` : "";
|
|
86
|
+
throw new Error(`Cannot reach HubSpot at ${baseUrl}${cause}. Check network access.`);
|
|
87
|
+
}
|
|
88
|
+
const retryable = response.status === 429 || response.status >= 500;
|
|
89
|
+
if (!retryable || attempt === maxRetries)
|
|
90
|
+
break;
|
|
91
|
+
await response.text().catch(() => undefined);
|
|
92
|
+
const retryAfter = response.headers.get("Retry-After");
|
|
93
|
+
const seconds = retryAfter === null ? NaN : Number(retryAfter);
|
|
94
|
+
const dateDelay = retryAfter && !Number.isFinite(seconds) ? Date.parse(retryAfter) - Date.now() : NaN;
|
|
95
|
+
const delayMs = Math.min(30_000, Math.max(0, Number.isFinite(seconds) ? seconds * 1_000 : Number.isFinite(dateDelay) ? dateDelay : 1_000 * 2 ** attempt));
|
|
96
|
+
const reason = response.status === 429 ? "rate limited" : `temporarily unavailable (${response.status})`;
|
|
97
|
+
options.progress?.note(`HubSpot ${reason}; retrying in ${Math.ceil(delayMs / 1_000)}s (${attempt + 1}/${maxRetries})`);
|
|
98
|
+
await sleep(delayMs);
|
|
99
|
+
}
|
|
100
|
+
if (!response || !response.ok) {
|
|
86
101
|
// Status line only — HubSpot 4xx bodies echo submitted property values
|
|
87
102
|
// (contact emails, company domains) and the request payload, and these
|
|
88
103
|
// errors are persisted into scheduled-run records. Never interpolate it.
|
|
89
|
-
await response
|
|
90
|
-
|
|
104
|
+
await response?.text().catch(() => undefined);
|
|
105
|
+
if (response?.status === 429) {
|
|
106
|
+
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.`);
|
|
107
|
+
}
|
|
108
|
+
throw new Error(`HubSpot API error ${response?.status ?? "unknown"}. Check the token scopes and request.`);
|
|
91
109
|
}
|
|
92
110
|
// DELETE and some association writes return 204 with an empty body.
|
|
93
111
|
const text = await response.text();
|
|
@@ -38,6 +38,8 @@ export type FetchProspectsOptions = {
|
|
|
38
38
|
sourceId?: string;
|
|
39
39
|
/** Hard cap on prospects returned; also bounds pagination. */
|
|
40
40
|
max?: number;
|
|
41
|
+
/** Opaque continuation cursor. HeyReach uses the decimal list offset. */
|
|
42
|
+
cursor?: string;
|
|
41
43
|
};
|
|
42
44
|
export type ConnectionStatus = {
|
|
43
45
|
ok: boolean;
|
|
@@ -85,6 +85,11 @@ export function createHeyReachProvider(options) {
|
|
|
85
85
|
const max = opts.max ?? Number.POSITIVE_INFINITY;
|
|
86
86
|
const out = [];
|
|
87
87
|
let offset = 0;
|
|
88
|
+
if (opts.cursor !== undefined) {
|
|
89
|
+
if (!/^\d+$/.test(opts.cursor))
|
|
90
|
+
throw new Error(`Invalid HeyReach cursor: ${opts.cursor}`);
|
|
91
|
+
offset = Number(opts.cursor);
|
|
92
|
+
}
|
|
88
93
|
while (out.length < max) {
|
|
89
94
|
const data = await call("/list/GetLeadsFromList", "POST", { listId, offset, limit: pageSize });
|
|
90
95
|
const items = toArray(data);
|
|
@@ -46,6 +46,8 @@ export declare function fetchExploriumProspects(opts: {
|
|
|
46
46
|
apiKey: string;
|
|
47
47
|
filters: ExploriumFilters;
|
|
48
48
|
size?: number;
|
|
49
|
+
/** One-based result page. */
|
|
50
|
+
page?: number;
|
|
49
51
|
apiBaseUrl?: string;
|
|
50
52
|
fetchImpl?: FetchImpl;
|
|
51
53
|
}): Promise<Prospect[]>;
|
|
@@ -53,9 +55,30 @@ export declare function fetchPipe0CrustdataProspects(opts: {
|
|
|
53
55
|
apiKey: string;
|
|
54
56
|
filters: Record<string, unknown>;
|
|
55
57
|
limit?: number;
|
|
58
|
+
/** Opaque cursor returned by a previous page. */
|
|
59
|
+
cursor?: string;
|
|
56
60
|
apiBaseUrl?: string;
|
|
57
61
|
fetchImpl?: FetchImpl;
|
|
58
62
|
}): Promise<Prospect[]>;
|
|
63
|
+
/** A page from pipe0/Crustdata's cursor-based people search. */
|
|
64
|
+
export type Pipe0CrustdataProspectPage = {
|
|
65
|
+
prospects: Prospect[];
|
|
66
|
+
/** Opaque cursor for the next page, or null when the search is exhausted. */
|
|
67
|
+
nextCursor: string | null;
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* Fetch one page and expose its continuation cursor. The array-returning
|
|
71
|
+
* `fetchPipe0CrustdataProspects` remains available for existing consumers.
|
|
72
|
+
*/
|
|
73
|
+
export declare function fetchPipe0CrustdataProspectPage(opts: {
|
|
74
|
+
apiKey: string;
|
|
75
|
+
filters: Record<string, unknown>;
|
|
76
|
+
limit?: number;
|
|
77
|
+
/** Opaque cursor returned by a previous page. */
|
|
78
|
+
cursor?: string;
|
|
79
|
+
apiBaseUrl?: string;
|
|
80
|
+
fetchImpl?: FetchImpl;
|
|
81
|
+
}): Promise<Pipe0CrustdataProspectPage>;
|
|
59
82
|
export declare const EXPLORIUM_BUSINESS_COUNT_CAP = 60000;
|
|
60
83
|
export type BusinessCountProbe = {
|
|
61
84
|
/** matching companies (the account universe); == cap when saturated. */
|
|
@@ -24,7 +24,7 @@ export async function fetchExploriumProspects(opts) {
|
|
|
24
24
|
const response = await fetchImpl(`${base}/v1/prospects`, {
|
|
25
25
|
method: "POST",
|
|
26
26
|
headers: { "api_key": opts.apiKey, "Content-Type": "application/json" },
|
|
27
|
-
body: JSON.stringify({ mode: "full", size, page_size: size, page: 1, filters: opts.filters }),
|
|
27
|
+
body: JSON.stringify({ mode: "full", size, page_size: size, page: opts.page ?? 1, filters: opts.filters }),
|
|
28
28
|
});
|
|
29
29
|
if (!response.ok) {
|
|
30
30
|
throw new ProviderHttpError("Explorium", "prospect search", response.status);
|
|
@@ -48,14 +48,24 @@ export async function fetchExploriumProspects(opts) {
|
|
|
48
48
|
// (no separate connection needed). Returns profiles; pair with
|
|
49
49
|
// pipe0ResolveWorkEmails to get real emails.
|
|
50
50
|
export async function fetchPipe0CrustdataProspects(opts) {
|
|
51
|
+
return (await fetchPipe0CrustdataProspectPage(opts)).prospects;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Fetch one page and expose its continuation cursor. The array-returning
|
|
55
|
+
* `fetchPipe0CrustdataProspects` remains available for existing consumers.
|
|
56
|
+
*/
|
|
57
|
+
export async function fetchPipe0CrustdataProspectPage(opts) {
|
|
51
58
|
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
52
59
|
const base = (opts.apiBaseUrl ?? "https://api.pipe0.com").replace(/\/$/, "");
|
|
53
60
|
const limit = Math.min(opts.limit ?? 25, 100);
|
|
61
|
+
const config = { limit, filters: opts.filters };
|
|
62
|
+
if (opts.cursor !== undefined)
|
|
63
|
+
config.cursor = opts.cursor;
|
|
54
64
|
const response = await fetchImpl(`${base}/v1/searches/run/sync`, {
|
|
55
65
|
method: "POST",
|
|
56
66
|
headers: { Authorization: `Bearer ${opts.apiKey}`, "Content-Type": "application/json" },
|
|
57
67
|
body: JSON.stringify({
|
|
58
|
-
searches: [{ search_id: "people:profiles:crustdata@1", config
|
|
68
|
+
searches: [{ search_id: "people:profiles:crustdata@1", config }],
|
|
59
69
|
}),
|
|
60
70
|
});
|
|
61
71
|
if (!response.ok) {
|
|
@@ -68,7 +78,7 @@ export async function fetchPipe0CrustdataProspects(opts) {
|
|
|
68
78
|
if (upstreamErrors.length > 0 && !(body.results ?? []).length) {
|
|
69
79
|
throw new Error(`pipe0/Crustdata search error: ${upstreamErrors.map((e) => `${e.code ?? "error"}: ${e.message ?? ""}`).join("; ")}`);
|
|
70
80
|
}
|
|
71
|
-
|
|
81
|
+
const prospects = (body.results ?? []).map((row) => {
|
|
72
82
|
const fullName = strField(row.name);
|
|
73
83
|
const { firstName, lastName } = splitName(fullName);
|
|
74
84
|
return {
|
|
@@ -81,6 +91,14 @@ export async function fetchPipe0CrustdataProspects(opts) {
|
|
|
81
91
|
sourceId: strField(row.profile_url) ?? fullName,
|
|
82
92
|
};
|
|
83
93
|
});
|
|
94
|
+
// Current pipe0 responses expose `next_cursor` at the top level. Accept the
|
|
95
|
+
// status-envelope variants too so older deployments remain usable.
|
|
96
|
+
const status = body.search_statuses?.[0];
|
|
97
|
+
const rawCursor = body.next_cursor ?? status?.next_cursor ?? status?.cursor;
|
|
98
|
+
return {
|
|
99
|
+
prospects,
|
|
100
|
+
nextCursor: typeof rawCursor === "string" && rawCursor.length > 0 ? rawCursor : null,
|
|
101
|
+
};
|
|
84
102
|
}
|
|
85
103
|
function strField(field) {
|
|
86
104
|
const v = field?.value;
|
package/dist/enrich.d.ts
CHANGED
|
@@ -86,6 +86,8 @@ export type AcquireDiscoveryConfig = {
|
|
|
86
86
|
provider: "explorium" | "pipe0" | "linkedin" | "heyreach";
|
|
87
87
|
filters?: Record<string, unknown>;
|
|
88
88
|
size?: number;
|
|
89
|
+
/** Maximum raw provider candidates scanned per run while seeking fresh leads. */
|
|
90
|
+
scanLimit?: number;
|
|
89
91
|
resolveEmailsWith?: "pipe0";
|
|
90
92
|
/** LinkedIn sources only: the provider-native list id to read (HeyReach lead-list id). */
|
|
91
93
|
listId?: string;
|
|
@@ -286,7 +288,46 @@ export declare function selectStaleWork(config: EnrichConfig, runs: EnrichRun[],
|
|
|
286
288
|
now?: () => Date;
|
|
287
289
|
staleDaysOverride?: number;
|
|
288
290
|
}): EnrichWorkItem[];
|
|
289
|
-
export type EnrichRunMode = EnrichMode | "ingest";
|
|
291
|
+
export type EnrichRunMode = EnrichMode | "ingest" | "acquire";
|
|
292
|
+
/**
|
|
293
|
+
* Provider continuation state for an acquisition query. The fingerprint binds
|
|
294
|
+
* a cursor/offset to the ICP + provider filters that produced it, preventing a
|
|
295
|
+
* changed query from accidentally resuming in the middle of the old audience.
|
|
296
|
+
*/
|
|
297
|
+
export type AcquireDiscoveryCheckpoint = {
|
|
298
|
+
queryFingerprint: string;
|
|
299
|
+
/** Opaque provider cursor (Pipe0 and other cursor-based sources). */
|
|
300
|
+
cursor?: string | null;
|
|
301
|
+
/** Zero-based provider offset (LinkedIn/HeyReach and other offset sources). */
|
|
302
|
+
offset?: number | null;
|
|
303
|
+
/** True only when the provider has positively reported no next page. */
|
|
304
|
+
exhausted: boolean;
|
|
305
|
+
};
|
|
306
|
+
/**
|
|
307
|
+
* Truthful acquisition funnel for one run. Each field counts candidates, not
|
|
308
|
+
* provider requests, making duplicate saturation and resolution loss visible
|
|
309
|
+
* instead of reporting every run as zero.
|
|
310
|
+
*/
|
|
311
|
+
export type AcquireRunFunnel = {
|
|
312
|
+
/** Raw candidates returned across all discovery pages scanned this run. */
|
|
313
|
+
discovered: number;
|
|
314
|
+
/** Candidates that met the configured ICP threshold. */
|
|
315
|
+
qualified: number;
|
|
316
|
+
/** Qualified candidates removed by the CRM pre-email dedupe. */
|
|
317
|
+
skippedCrm: number;
|
|
318
|
+
/** Qualified candidates removed by the cross-run seen ledger. */
|
|
319
|
+
skippedSeen: number;
|
|
320
|
+
/** Fresh candidates carrying the configured resolve-first key. */
|
|
321
|
+
resolved: number;
|
|
322
|
+
/** Governed create operations emitted into the saved/dry-run plan. */
|
|
323
|
+
proposed: number;
|
|
324
|
+
/** Otherwise-proposable candidates held back by the acquire meter. */
|
|
325
|
+
withheldByMeter: number;
|
|
326
|
+
};
|
|
327
|
+
export type AcquireRunTelemetry = {
|
|
328
|
+
funnel: AcquireRunFunnel;
|
|
329
|
+
discovery: AcquireDiscoveryCheckpoint;
|
|
330
|
+
};
|
|
290
331
|
export type EnrichRun = {
|
|
291
332
|
id: string;
|
|
292
333
|
runLabel: string;
|
|
@@ -298,6 +339,8 @@ export type EnrichRun = {
|
|
|
298
339
|
/** Resume point for an interrupted pull (last processed pull key). */
|
|
299
340
|
cursor: string | null;
|
|
300
341
|
counts: EnrichCounts;
|
|
342
|
+
/** Acquisition-only funnel + provider continuation state (absent on legacy runs). */
|
|
343
|
+
acquireTelemetry?: AcquireRunTelemetry;
|
|
301
344
|
planIds: string[];
|
|
302
345
|
stamps: EnrichStamp[];
|
|
303
346
|
/** Staged source rows (ingest mode only), consumed by append/refresh. */
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { AcquireDiscoveryCheckpoint } from "./enrich.ts";
|
|
2
|
+
export type HostedAcquireCheckpoint = {
|
|
3
|
+
checkpoint: AcquireDiscoveryCheckpoint;
|
|
4
|
+
/** Opaque server revision used only for compare-and-swap. */
|
|
5
|
+
version: number;
|
|
6
|
+
updatedAt: number;
|
|
7
|
+
};
|
|
8
|
+
export type HostedCheckpointReadResult = {
|
|
9
|
+
status: "unpaired";
|
|
10
|
+
} | {
|
|
11
|
+
status: "missing";
|
|
12
|
+
} | {
|
|
13
|
+
status: "found";
|
|
14
|
+
record: HostedAcquireCheckpoint;
|
|
15
|
+
} | {
|
|
16
|
+
status: "unavailable";
|
|
17
|
+
reason: string;
|
|
18
|
+
};
|
|
19
|
+
export type HostedCheckpointWriteResult = {
|
|
20
|
+
status: "unpaired";
|
|
21
|
+
} | {
|
|
22
|
+
status: "saved";
|
|
23
|
+
record: HostedAcquireCheckpoint;
|
|
24
|
+
} | {
|
|
25
|
+
status: "conflict";
|
|
26
|
+
current: HostedAcquireCheckpoint | null;
|
|
27
|
+
} | {
|
|
28
|
+
status: "unavailable";
|
|
29
|
+
reason: string;
|
|
30
|
+
};
|
|
31
|
+
type Options = {
|
|
32
|
+
fetchImpl?: typeof fetch;
|
|
33
|
+
timeoutMs?: number;
|
|
34
|
+
};
|
|
35
|
+
/** Read a query/list checkpoint. Inert unless this profile is paired. */
|
|
36
|
+
export declare function readHostedAcquireCheckpoint(key: string, options?: Options): Promise<HostedCheckpointReadResult>;
|
|
37
|
+
/**
|
|
38
|
+
* Mirror a local checkpoint using compare-and-swap. Pass null only when the
|
|
39
|
+
* caller observed no hosted record (sent as version zero); pass the exact
|
|
40
|
+
* positive version returned by read for an update.
|
|
41
|
+
*/
|
|
42
|
+
export declare function writeHostedAcquireCheckpoint(key: string, checkpoint: AcquireDiscoveryCheckpoint, expectedVersion: number | null, options?: Options): Promise<HostedCheckpointWriteResult>;
|
|
43
|
+
export {};
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authenticated acquisition-checkpoint transport for paired CLI installs.
|
|
3
|
+
*
|
|
4
|
+
* The local checkpoint remains authoritative for running a job. This module
|
|
5
|
+
* only mirrors/query-scoped continuation state to the paired hosted app so a
|
|
6
|
+
* different authenticated worker can resume it. Writes use compare-and-swap:
|
|
7
|
+
* a stale client receives a conflict and must explicitly reconcile instead of
|
|
8
|
+
* silently moving a provider cursor backwards or overwriting another worker.
|
|
9
|
+
*/
|
|
10
|
+
import { getCredential } from "./credentials.js";
|
|
11
|
+
const ENDPOINT = "/api/cli/acquisition-checkpoint";
|
|
12
|
+
const DEFAULT_TIMEOUT_MS = 4000;
|
|
13
|
+
const CHECKPOINT_KEY = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/;
|
|
14
|
+
function validKey(key) {
|
|
15
|
+
if (!CHECKPOINT_KEY.test(key)) {
|
|
16
|
+
throw new Error("Acquisition checkpoint key must be 1-256 URL-safe characters.");
|
|
17
|
+
}
|
|
18
|
+
return key;
|
|
19
|
+
}
|
|
20
|
+
function broker() {
|
|
21
|
+
const credential = getCredential("broker");
|
|
22
|
+
if (!credential?.baseUrl || !credential.accessToken)
|
|
23
|
+
return null;
|
|
24
|
+
let url;
|
|
25
|
+
try {
|
|
26
|
+
url = new URL(credential.baseUrl);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
const local = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
|
|
32
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && local))
|
|
33
|
+
return null;
|
|
34
|
+
return { baseUrl: url.href.replace(/\/+$/, ""), accessToken: credential.accessToken };
|
|
35
|
+
}
|
|
36
|
+
function isCheckpoint(value) {
|
|
37
|
+
if (!value || typeof value !== "object")
|
|
38
|
+
return false;
|
|
39
|
+
const item = value;
|
|
40
|
+
return (typeof item.queryFingerprint === "string" &&
|
|
41
|
+
item.queryFingerprint.length > 0 &&
|
|
42
|
+
typeof item.exhausted === "boolean" &&
|
|
43
|
+
(item.cursor === undefined || item.cursor === null || typeof item.cursor === "string") &&
|
|
44
|
+
(item.offset === undefined || item.offset === null ||
|
|
45
|
+
(typeof item.offset === "number" && Number.isSafeInteger(item.offset) && item.offset >= 0)));
|
|
46
|
+
}
|
|
47
|
+
function parseRecord(value) {
|
|
48
|
+
if (!value || typeof value !== "object")
|
|
49
|
+
return null;
|
|
50
|
+
const body = value;
|
|
51
|
+
if (!isCheckpoint(body.checkpoint) || !Number.isSafeInteger(body.version) || body.version < 1)
|
|
52
|
+
return null;
|
|
53
|
+
if (!Number.isSafeInteger(body.updatedAt) || body.updatedAt < 0)
|
|
54
|
+
return null;
|
|
55
|
+
return {
|
|
56
|
+
checkpoint: body.checkpoint,
|
|
57
|
+
version: body.version,
|
|
58
|
+
updatedAt: body.updatedAt,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function reasonFor(status) {
|
|
62
|
+
if (status === 401 || status === 403)
|
|
63
|
+
return "hosted authentication was rejected; pair the CLI again";
|
|
64
|
+
return `hosted checkpoint request failed (HTTP ${status})`;
|
|
65
|
+
}
|
|
66
|
+
/** Read a query/list checkpoint. Inert unless this profile is paired. */
|
|
67
|
+
export async function readHostedAcquireCheckpoint(key, options = {}) {
|
|
68
|
+
validKey(key);
|
|
69
|
+
const paired = broker();
|
|
70
|
+
if (!paired)
|
|
71
|
+
return { status: "unpaired" };
|
|
72
|
+
try {
|
|
73
|
+
const response = await (options.fetchImpl ?? fetch)(`${paired.baseUrl}${ENDPOINT}?key=${encodeURIComponent(key)}`, {
|
|
74
|
+
headers: { Authorization: `Bearer ${paired.accessToken}`, Accept: "application/json" },
|
|
75
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS),
|
|
76
|
+
});
|
|
77
|
+
if (response.status === 404)
|
|
78
|
+
return { status: "missing" };
|
|
79
|
+
if (!response.ok)
|
|
80
|
+
return { status: "unavailable", reason: reasonFor(response.status) };
|
|
81
|
+
const record = parseRecord(await response.json());
|
|
82
|
+
return record
|
|
83
|
+
? { status: "found", record }
|
|
84
|
+
: { status: "unavailable", reason: "hosted checkpoint response was invalid" };
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return { status: "unavailable", reason: "hosted checkpoint request was unavailable" };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Mirror a local checkpoint using compare-and-swap. Pass null only when the
|
|
92
|
+
* caller observed no hosted record (sent as version zero); pass the exact
|
|
93
|
+
* positive version returned by read for an update.
|
|
94
|
+
*/
|
|
95
|
+
export async function writeHostedAcquireCheckpoint(key, checkpoint, expectedVersion, options = {}) {
|
|
96
|
+
validKey(key);
|
|
97
|
+
if (!isCheckpoint(checkpoint))
|
|
98
|
+
throw new Error("Refusing to sync an invalid acquisition checkpoint.");
|
|
99
|
+
if (expectedVersion !== null && (!Number.isSafeInteger(expectedVersion) || expectedVersion < 1)) {
|
|
100
|
+
throw new Error("expectedVersion must be null or a positive integer.");
|
|
101
|
+
}
|
|
102
|
+
const paired = broker();
|
|
103
|
+
if (!paired)
|
|
104
|
+
return { status: "unpaired" };
|
|
105
|
+
try {
|
|
106
|
+
const response = await (options.fetchImpl ?? fetch)(`${paired.baseUrl}${ENDPOINT}`, {
|
|
107
|
+
method: "POST",
|
|
108
|
+
headers: { Authorization: `Bearer ${paired.accessToken}`, "Content-Type": "application/json" },
|
|
109
|
+
body: JSON.stringify({ key, checkpoint, expectedVersion: expectedVersion ?? 0 }),
|
|
110
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS),
|
|
111
|
+
});
|
|
112
|
+
if (response.status === 409) {
|
|
113
|
+
const body = await response.json().catch(() => null);
|
|
114
|
+
const current = body && typeof body === "object"
|
|
115
|
+
? parseRecord(body.current)
|
|
116
|
+
: null;
|
|
117
|
+
return { status: "conflict", current };
|
|
118
|
+
}
|
|
119
|
+
if (!response.ok)
|
|
120
|
+
return { status: "unavailable", reason: reasonFor(response.status) };
|
|
121
|
+
const record = parseRecord(await response.json());
|
|
122
|
+
return record
|
|
123
|
+
? { status: "saved", record }
|
|
124
|
+
: { status: "unavailable", reason: "hosted checkpoint response was invalid" };
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return { status: "unavailable", reason: "hosted checkpoint request was unavailable" };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { PlanStore, StoredPlan } from "./planStore.ts";
|
|
2
|
+
import type { PatchOperation, PatchPlan, PatchPlanRun } from "./types.ts";
|
|
3
|
+
type Options = {
|
|
4
|
+
fetchImpl?: typeof fetch;
|
|
5
|
+
timeoutMs?: number;
|
|
6
|
+
};
|
|
7
|
+
export type HostedPlanState = {
|
|
8
|
+
patchPlanId: string;
|
|
9
|
+
externalPlanId: string;
|
|
10
|
+
documentSha256: string;
|
|
11
|
+
status: string;
|
|
12
|
+
approvedOperationIds: string[];
|
|
13
|
+
valueOverrides: Record<string, unknown>;
|
|
14
|
+
updatedAt: number;
|
|
15
|
+
runRecord?: PatchPlanRun;
|
|
16
|
+
url: string;
|
|
17
|
+
};
|
|
18
|
+
export type HostedPlanResult = {
|
|
19
|
+
status: "unpaired";
|
|
20
|
+
} | {
|
|
21
|
+
status: "missing";
|
|
22
|
+
} | {
|
|
23
|
+
status: "found" | "saved";
|
|
24
|
+
state: HostedPlanState;
|
|
25
|
+
} | {
|
|
26
|
+
status: "conflict";
|
|
27
|
+
reason: string;
|
|
28
|
+
} | {
|
|
29
|
+
status: "unavailable";
|
|
30
|
+
reason: string;
|
|
31
|
+
};
|
|
32
|
+
/** Review document intentionally omits evidence/findings and all mutable store security state. */
|
|
33
|
+
export declare function hostedReviewDocument(plan: PatchPlan): {
|
|
34
|
+
guards?: import("./types.ts").PlanGuard[] | undefined;
|
|
35
|
+
filter?: {
|
|
36
|
+
objectType: "account" | "contact" | "deal";
|
|
37
|
+
where: string[];
|
|
38
|
+
today?: string;
|
|
39
|
+
} | undefined;
|
|
40
|
+
id: string;
|
|
41
|
+
title: string;
|
|
42
|
+
createdAt: string;
|
|
43
|
+
status: string;
|
|
44
|
+
dryRun: boolean;
|
|
45
|
+
summary: string;
|
|
46
|
+
operations: PatchOperation[];
|
|
47
|
+
};
|
|
48
|
+
export declare function hostedPlanDigest(plan: PatchPlan): string;
|
|
49
|
+
export declare function uploadHostedPatchPlan(stored: StoredPlan, options?: Options): Promise<HostedPlanResult>;
|
|
50
|
+
export declare function readHostedPatchPlan(planId: string, options?: Options): Promise<HostedPlanResult>;
|
|
51
|
+
export type HostedReconcileResult = {
|
|
52
|
+
status: "unchanged" | "unpaired" | "missing";
|
|
53
|
+
} | {
|
|
54
|
+
status: "updated";
|
|
55
|
+
stored: StoredPlan;
|
|
56
|
+
remoteStatus: string;
|
|
57
|
+
} | {
|
|
58
|
+
status: "conflict" | "unavailable";
|
|
59
|
+
reason: string;
|
|
60
|
+
};
|
|
61
|
+
/** Pull a peer's newer lifecycle into the local replica without weakening local integrity gates. */
|
|
62
|
+
export declare function reconcileHostedPatchPlan(store: PlanStore, stored: StoredPlan, options?: Options): Promise<HostedReconcileResult>;
|
|
63
|
+
export declare function reportHostedPlanLifecycle(stored: StoredPlan, options?: Options & {
|
|
64
|
+
claimId?: string;
|
|
65
|
+
}): Promise<HostedPlanResult>;
|
|
66
|
+
export type HostedApplyClaimResult = {
|
|
67
|
+
status: "unpaired";
|
|
68
|
+
} | {
|
|
69
|
+
status: "claimed";
|
|
70
|
+
claimId: string;
|
|
71
|
+
expiresAt?: number;
|
|
72
|
+
} | {
|
|
73
|
+
status: "applied";
|
|
74
|
+
} | {
|
|
75
|
+
status: "conflict" | "unavailable";
|
|
76
|
+
reason: string;
|
|
77
|
+
};
|
|
78
|
+
/** Coordinate online replicas before provider I/O; provider idempotency remains the offline fallback. */
|
|
79
|
+
export declare function claimHostedPlanApply(stored: StoredPlan, options?: Options): Promise<HostedApplyClaimResult>;
|
|
80
|
+
/** Release a shared CLI claim only when preflight aborts before provider I/O. */
|
|
81
|
+
export declare function releaseHostedPlanApply(stored: StoredPlan, claimId: string, reason: string, options?: Options): Promise<{
|
|
82
|
+
status: "released" | "unpaired";
|
|
83
|
+
} | {
|
|
84
|
+
status: "conflict" | "unavailable";
|
|
85
|
+
reason: string;
|
|
86
|
+
}>;
|
|
87
|
+
export {};
|