fullstackgtm 0.45.0 → 0.47.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +115 -1
- package/INSTALL_FOR_AGENTS.md +13 -11
- package/README.md +27 -18
- package/dist/backfill.d.ts +86 -0
- package/dist/backfill.js +251 -0
- package/dist/bin.js +4 -1
- package/dist/cli/auth.d.ts +2 -0
- package/dist/cli/auth.js +119 -12
- package/dist/cli/backfill.d.ts +1 -0
- package/dist/cli/backfill.js +125 -0
- package/dist/cli/backfillRuns.d.ts +1 -0
- package/dist/cli/backfillRuns.js +187 -0
- package/dist/cli/call.js +23 -9
- package/dist/cli/enrich.js +28 -10
- package/dist/cli/fix.js +9 -7
- package/dist/cli/help.js +60 -23
- package/dist/cli/plans.js +13 -11
- package/dist/cli/shared.d.ts +4 -3
- package/dist/cli/shared.js +31 -20
- package/dist/cli/ui.d.ts +21 -0
- package/dist/cli/ui.js +53 -1
- package/dist/cli.js +37 -41
- package/dist/connector.d.ts +8 -0
- package/dist/connector.js +104 -21
- package/dist/connectors/hubspot.d.ts +8 -1
- package/dist/connectors/hubspot.js +406 -13
- package/dist/connectors/hubspotAuth.js +5 -1
- package/dist/connectors/linkedin.js +14 -14
- package/dist/connectors/salesforce.d.ts +8 -1
- package/dist/connectors/salesforce.js +163 -2
- package/dist/connectors/stripe.d.ts +37 -0
- package/dist/connectors/stripe.js +103 -31
- package/dist/enrich.d.ts +7 -0
- package/dist/enrich.js +7 -0
- package/dist/health.d.ts +11 -69
- package/dist/health.js +4 -134
- package/dist/healthScore.d.ts +71 -0
- package/dist/healthScore.js +143 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/llm.d.ts +29 -0
- package/dist/llm.js +206 -0
- package/dist/market.d.ts +39 -1
- package/dist/market.js +116 -44
- package/dist/marketClassify.d.ts +9 -1
- package/dist/marketClassify.js +10 -1
- package/dist/marketTaxonomy.d.ts +6 -1
- package/dist/marketTaxonomy.js +4 -2
- package/dist/mcp-bin.js +29 -0
- package/dist/mcp.js +117 -4
- package/dist/progress.d.ts +96 -0
- package/dist/progress.js +142 -0
- package/dist/runReport.d.ts +24 -0
- package/dist/runReport.js +139 -4
- package/dist/types.d.ts +33 -1
- package/docs/api.md +4 -2
- package/docs/architecture.md +2 -0
- package/docs/linkedin-connector-spec.md +1 -1
- package/llms.txt +3 -3
- package/package.json +10 -3
- package/skills/fullstackgtm/SKILL.md +1 -0
- package/src/backfill.ts +340 -0
- package/src/bin.ts +5 -1
- package/src/cli/auth.ts +135 -15
- package/src/cli/backfill.ts +156 -0
- package/src/cli/backfillRuns.ts +198 -0
- package/src/cli/call.ts +26 -10
- package/src/cli/enrich.ts +33 -10
- package/src/cli/fix.ts +11 -10
- package/src/cli/help.ts +61 -23
- package/src/cli/plans.ts +15 -14
- package/src/cli/shared.ts +44 -27
- package/src/cli/ui.ts +72 -1
- package/src/cli.ts +38 -41
- package/src/connector.ts +110 -16
- package/src/connectors/hubspot.ts +423 -14
- package/src/connectors/hubspotAuth.ts +5 -1
- package/src/connectors/linkedin.ts +14 -14
- package/src/connectors/salesforce.ts +168 -3
- package/src/connectors/stripe.ts +154 -34
- package/src/enrich.ts +13 -0
- package/src/health.ts +14 -213
- package/src/healthScore.ts +223 -0
- package/src/index.ts +28 -1
- package/src/llm.ts +239 -0
- package/src/market.ts +157 -44
- package/src/marketClassify.ts +18 -1
- package/src/marketTaxonomy.ts +10 -2
- package/src/mcp-bin.ts +32 -0
- package/src/mcp.ts +140 -6
- package/src/progress.ts +197 -0
- package/src/runReport.ts +159 -4
- package/src/types.ts +35 -2
- package/docs/dx-punch-list.md +0 -87
- package/docs/roadmap-to-1.0.md +0 -211
package/src/mcp.ts
CHANGED
|
@@ -53,6 +53,8 @@ import {
|
|
|
53
53
|
import { generateDemoSnapshot } from "./demo.ts";
|
|
54
54
|
import type { FieldMappings } from "./mappings.ts";
|
|
55
55
|
import { formatPatchPlanRun, patchPlanToMarkdown } from "./format.ts";
|
|
56
|
+
import { verifyApprovalDigests } from "./integrity.ts";
|
|
57
|
+
import { createFilePlanStore, type StoredPlan } from "./planStore.ts";
|
|
56
58
|
import { builtinAuditRules } from "./rules.ts";
|
|
57
59
|
import { sampleSnapshot } from "./sampleData.ts";
|
|
58
60
|
import { normalizeTranscript, parseCall } from "./calls.ts";
|
|
@@ -322,7 +324,11 @@ const toolDefinitions: ToolDefinition[] = [
|
|
|
322
324
|
description:
|
|
323
325
|
"Apply explicitly approved operations from a patch plan through a provider " +
|
|
324
326
|
"connector. Operations not listed in approvedOperationIds are never written, " +
|
|
325
|
-
"and requires_human_* placeholders need a value override."
|
|
327
|
+
"and requires_human_* placeholders need a value override. When the plan is in " +
|
|
328
|
+
"the local plan store (saved via `audit --save`), approvals are verified against " +
|
|
329
|
+
"the store's HMAC approval digests — ids not approved with `plans approve` are " +
|
|
330
|
+
"refused — and the run is recorded onto the stored plan so `plans show` and " +
|
|
331
|
+
"`audit-log export` include it.",
|
|
326
332
|
inputSchema: {
|
|
327
333
|
provider: z.enum(["hubspot", "salesforce"]),
|
|
328
334
|
planPath: z.string(),
|
|
@@ -332,14 +338,128 @@ const toolDefinitions: ToolDefinition[] = [
|
|
|
332
338
|
},
|
|
333
339
|
},
|
|
334
340
|
handler: async ({ provider, planPath, approvedOperationIds, valueOverrides, output }) => {
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
) as
|
|
341
|
+
// The file may be a raw PatchPlan (audit --out) or a StoredPlan envelope
|
|
342
|
+
// (a file straight out of the plan-store directory).
|
|
343
|
+
const parsed = JSON.parse(readFileSync(resolve(process.cwd(), planPath), "utf8")) as unknown;
|
|
344
|
+
const filePlan = isStoredPlanFile(parsed) ? parsed.plan : (parsed as PatchPlan);
|
|
345
|
+
if (!filePlan || !Array.isArray(filePlan.operations)) {
|
|
346
|
+
throw new Error(`${planPath} is not a patch plan (expected { id, operations: [...] }).`);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Governance parity with CLI apply: when this plan id exists in the local
|
|
350
|
+
// plan store, the STORE is the source of truth — the approved-id set must
|
|
351
|
+
// come from `plans approve` (which HMAC-signed each approved op), the
|
|
352
|
+
// signatures are re-verified here, and the run is recorded back onto the
|
|
353
|
+
// plan so audit-log export sees MCP applies exactly like CLI applies.
|
|
354
|
+
const store = createFilePlanStore();
|
|
355
|
+
const stored =
|
|
356
|
+
typeof filePlan.id === "string" && /^[\w.-]+$/.test(filePlan.id)
|
|
357
|
+
? await store.get(filePlan.id)
|
|
358
|
+
: null;
|
|
359
|
+
|
|
360
|
+
let plan: PatchPlan;
|
|
361
|
+
let effectiveOverrides: Record<string, unknown>;
|
|
362
|
+
if (stored) {
|
|
363
|
+
if (stored.status !== "approved") {
|
|
364
|
+
throw new Error(
|
|
365
|
+
`Plan ${filePlan.id} is ${stored.status}; approve operations first with ` +
|
|
366
|
+
`\`fullstackgtm plans approve ${filePlan.id} --operations <ids|all>\`.`,
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
// Downgrade guard (same as CLI apply): an approved plan with no
|
|
370
|
+
// signatures either predates 0.26 or had approvalDigests stripped to
|
|
371
|
+
// skip the integrity check. Refuse rather than trust the file.
|
|
372
|
+
if (stored.approvedOperationIds.length > 0 && !stored.approvalDigests) {
|
|
373
|
+
throw new Error(
|
|
374
|
+
`Refusing to apply plan ${filePlan.id}: it was approved without integrity signatures ` +
|
|
375
|
+
"(approved before 0.26.0, or its signatures were removed). Re-approve it with " +
|
|
376
|
+
`\`fullstackgtm plans approve ${filePlan.id} --operations <ids|all>\`.`,
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
// Never widen: every id the tool wants applied must already be
|
|
380
|
+
// store-approved. (A subset is fine — narrowing never writes more.)
|
|
381
|
+
const storeApproved = new Set(stored.approvedOperationIds);
|
|
382
|
+
const unapproved = approvedOperationIds.filter((id: string) => !storeApproved.has(id));
|
|
383
|
+
if (unapproved.length > 0) {
|
|
384
|
+
throw new Error(
|
|
385
|
+
`Refusing to apply plan ${filePlan.id}: operation(s) ${unapproved.join(", ")} were never ` +
|
|
386
|
+
"approved in the plan store. Approve them first with " +
|
|
387
|
+
`\`fullstackgtm plans approve ${filePlan.id} --operations <ids>\`.`,
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
// Integrity gate: verify against the EFFECTIVE overrides (stored ∪ tool
|
|
391
|
+
// args), so a tool-supplied value that changes what the human approved
|
|
392
|
+
// is treated as tamper, not a live override — what gets written must
|
|
393
|
+
// equal what was signed.
|
|
394
|
+
effectiveOverrides = { ...stored.valueOverrides, ...(valueOverrides ?? {}) };
|
|
395
|
+
const verification = verifyApprovalDigests(
|
|
396
|
+
stored.plan.operations,
|
|
397
|
+
stored.approvedOperationIds,
|
|
398
|
+
effectiveOverrides,
|
|
399
|
+
stored.approvalDigests,
|
|
400
|
+
);
|
|
401
|
+
if (!verification.ok) {
|
|
402
|
+
const detail =
|
|
403
|
+
verification.reason === "no_key"
|
|
404
|
+
? "the plan-signing key is missing (was this plan approved on another machine?). Re-approve it here with `fullstackgtm plans approve`."
|
|
405
|
+
: `these operations differ from what was approved: ${verification.tampered.join(", ")}. ` +
|
|
406
|
+
"If you want a different value, set it at approval (`plans approve --value <op>=<v>`) and re-approve; " +
|
|
407
|
+
"otherwise the plan was edited after approval — review and re-approve.";
|
|
408
|
+
throw new Error(`Refusing to apply plan ${filePlan.id}: ${detail}`);
|
|
409
|
+
}
|
|
410
|
+
plan = stored.plan; // apply what was signed, not what the file says now
|
|
411
|
+
} else {
|
|
412
|
+
// External plan file, not in the store: no digests exist to verify, but
|
|
413
|
+
// approvals must still reference real operations in the plan content.
|
|
414
|
+
plan = filePlan;
|
|
415
|
+
const known = new Set(plan.operations.map((operation) => operation.id));
|
|
416
|
+
const unknown = approvedOperationIds.filter((id: string) => !known.has(id));
|
|
417
|
+
if (unknown.length > 0) {
|
|
418
|
+
throw new Error(
|
|
419
|
+
`Plan ${planPath} has no operation(s) ${unknown.join(", ")} — approvedOperationIds ` +
|
|
420
|
+
"must reference operations in the plan.",
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
effectiveOverrides = valueOverrides ?? {};
|
|
424
|
+
}
|
|
425
|
+
|
|
338
426
|
const run = await applyPatchPlan(await connectorFor(provider), plan, {
|
|
339
427
|
approvedOperationIds,
|
|
340
|
-
valueOverrides,
|
|
428
|
+
valueOverrides: effectiveOverrides,
|
|
341
429
|
});
|
|
342
|
-
|
|
430
|
+
|
|
431
|
+
// Persist the run (same data the CLI records) so runs[] and plan status
|
|
432
|
+
// update and audit-log export includes MCP applies. External plan files
|
|
433
|
+
// have no store entry to update — say so instead of silently dropping it.
|
|
434
|
+
let runRecorded = false;
|
|
435
|
+
if (stored) {
|
|
436
|
+
await store.recordRun(plan.id, run);
|
|
437
|
+
runRecorded = true;
|
|
438
|
+
}
|
|
439
|
+
const governance = {
|
|
440
|
+
planSource: stored ? ("store" as const) : ("external" as const),
|
|
441
|
+
approvalDigestsVerified: stored !== null,
|
|
442
|
+
runRecorded,
|
|
443
|
+
...(stored
|
|
444
|
+
? {}
|
|
445
|
+
: {
|
|
446
|
+
note:
|
|
447
|
+
"Plan file is not in the local plan store: approvals came from tool arguments " +
|
|
448
|
+
"(verified against the plan content only — no approval-digest check) and the run " +
|
|
449
|
+
"was not recorded. Use `fullstackgtm audit --save` + `plans approve` for " +
|
|
450
|
+
"store-backed governance.",
|
|
451
|
+
}),
|
|
452
|
+
};
|
|
453
|
+
if (output === "markdown") {
|
|
454
|
+
return content(
|
|
455
|
+
`${formatPatchPlanRun(run)}\n\nGovernance: plan source ${governance.planSource}; ` +
|
|
456
|
+
(runRecorded
|
|
457
|
+
? "approval digests verified; run recorded on the stored plan (audit-log export includes it)."
|
|
458
|
+
: governance.note!),
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
// Backward compatible: the run's own fields stay top-level; governance is additive.
|
|
462
|
+
return content({ ...run, governance });
|
|
343
463
|
},
|
|
344
464
|
},
|
|
345
465
|
{
|
|
@@ -456,6 +576,20 @@ export async function startMcpServer() {
|
|
|
456
576
|
await server.connect(transport);
|
|
457
577
|
}
|
|
458
578
|
|
|
579
|
+
/**
|
|
580
|
+
* A plan-store file is a StoredPlan envelope ({ plan, status, runs, ... });
|
|
581
|
+
* `audit --out` writes a bare PatchPlan. fullstackgtm_apply accepts either.
|
|
582
|
+
*/
|
|
583
|
+
function isStoredPlanFile(value: unknown): value is StoredPlan {
|
|
584
|
+
if (typeof value !== "object" || value === null || !("plan" in value)) return false;
|
|
585
|
+
const plan = (value as { plan?: unknown }).plan;
|
|
586
|
+
return (
|
|
587
|
+
typeof plan === "object" &&
|
|
588
|
+
plan !== null &&
|
|
589
|
+
Array.isArray((plan as { operations?: unknown }).operations)
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
|
|
459
593
|
function loadMarketConfigOrHint(path: string): ReturnType<typeof loadMarketConfig> {
|
|
460
594
|
try {
|
|
461
595
|
return loadMarketConfig(path);
|
package/src/progress.ts
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared progress-event vocabulary — ONE set of semantics for every
|
|
3
|
+
* long-running process, rendered natively by each surface:
|
|
4
|
+
*
|
|
5
|
+
* CLI → ui.ts (spinner / checklist / apply-ticker; TTY-only styling)
|
|
6
|
+
* app → Convex heartbeat patches → reactive ProgressChip / StageTimeline
|
|
7
|
+
* both → optional broker streaming (a paired CLI POSTs heartbeats to the
|
|
8
|
+
* hosted app under its clientRunId, so long local runs tick live in
|
|
9
|
+
* the org's activity feed)
|
|
10
|
+
*
|
|
11
|
+
* Renderer-agnostic by contract: no event assumes persistence (the CLI drops
|
|
12
|
+
* them after painting) or ANSI (the app can't use it). Listeners are
|
|
13
|
+
* best-effort — a throwing renderer must never fail the work — and the
|
|
14
|
+
* throttle policy lives HERE so both surfaces feel identical.
|
|
15
|
+
*
|
|
16
|
+
* Standing convention: if a verb loops, it emits. New long-running verbs wire
|
|
17
|
+
* an emitter at the package layer; surfaces get feedback for free.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export type ProgressEvent =
|
|
21
|
+
| { kind: "stage"; stage: string; stageIndex: number; stageCount: number }
|
|
22
|
+
| { kind: "items"; done: number; total?: number }
|
|
23
|
+
| { kind: "note"; note: string }
|
|
24
|
+
| {
|
|
25
|
+
kind: "opResult";
|
|
26
|
+
opId: string;
|
|
27
|
+
status: "applied" | "skipped" | "failed" | "conflict";
|
|
28
|
+
detail?: string;
|
|
29
|
+
}
|
|
30
|
+
| { kind: "meter"; spent: number; budget: number; unit: string };
|
|
31
|
+
|
|
32
|
+
/** Accumulated state — what a heartbeat persists / a chip renders. */
|
|
33
|
+
export type ProgressSnapshot = {
|
|
34
|
+
stage?: string;
|
|
35
|
+
stageIndex?: number;
|
|
36
|
+
stageCount?: number;
|
|
37
|
+
itemsDone?: number;
|
|
38
|
+
itemsTotal?: number;
|
|
39
|
+
note?: string;
|
|
40
|
+
opsApplied: number;
|
|
41
|
+
opsSkipped: number;
|
|
42
|
+
opsFailed: number;
|
|
43
|
+
updatedAt: number;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type ProgressListener = (event: ProgressEvent, snapshot: ProgressSnapshot) => void;
|
|
47
|
+
|
|
48
|
+
export type ProgressEmitter = {
|
|
49
|
+
stage(stage: string, stageIndex: number, stageCount: number): void;
|
|
50
|
+
items(done: number, total?: number): void;
|
|
51
|
+
note(note: string): void;
|
|
52
|
+
opResult(opId: string, status: "applied" | "skipped" | "failed" | "conflict", detail?: string): void;
|
|
53
|
+
meter(spent: number, budget: number, unit: string): void;
|
|
54
|
+
/** Current accumulated state (always fresh, ignoring the throttle). */
|
|
55
|
+
snapshot(): ProgressSnapshot;
|
|
56
|
+
/** Force-deliver the latest state (call at stage ends / completion). */
|
|
57
|
+
flush(): void;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const DEFAULT_THROTTLE_MS = 2000;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Throttled emitter: `items` heartbeats coalesce to at most one delivery per
|
|
64
|
+
* throttle window (terminal `done === total` always delivers); `stage`,
|
|
65
|
+
* `opResult`, `note`, and `meter` are structural and always deliver.
|
|
66
|
+
*/
|
|
67
|
+
export function createProgressEmitter(
|
|
68
|
+
listener: ProgressListener,
|
|
69
|
+
options: { throttleMs?: number; now?: () => number } = {},
|
|
70
|
+
): ProgressEmitter {
|
|
71
|
+
const throttleMs = options.throttleMs ?? DEFAULT_THROTTLE_MS;
|
|
72
|
+
const now = options.now ?? Date.now;
|
|
73
|
+
const state: ProgressSnapshot = {
|
|
74
|
+
opsApplied: 0,
|
|
75
|
+
opsSkipped: 0,
|
|
76
|
+
opsFailed: 0,
|
|
77
|
+
updatedAt: now(),
|
|
78
|
+
};
|
|
79
|
+
let lastItemsDelivery = 0;
|
|
80
|
+
let pendingItems = false;
|
|
81
|
+
|
|
82
|
+
function deliver(event: ProgressEvent) {
|
|
83
|
+
state.updatedAt = now();
|
|
84
|
+
try {
|
|
85
|
+
listener(event, { ...state });
|
|
86
|
+
} catch {
|
|
87
|
+
// Renderers are presentation-only; never fail the work.
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
stage(stage, stageIndex, stageCount) {
|
|
93
|
+
state.stage = stage;
|
|
94
|
+
state.stageIndex = stageIndex;
|
|
95
|
+
state.stageCount = stageCount;
|
|
96
|
+
state.itemsDone = undefined;
|
|
97
|
+
state.itemsTotal = undefined;
|
|
98
|
+
state.note = undefined;
|
|
99
|
+
pendingItems = false;
|
|
100
|
+
deliver({ kind: "stage", stage, stageIndex, stageCount });
|
|
101
|
+
},
|
|
102
|
+
items(done, total) {
|
|
103
|
+
state.itemsDone = done;
|
|
104
|
+
state.itemsTotal = total;
|
|
105
|
+
const terminal = total !== undefined && done >= total;
|
|
106
|
+
const due = now() - lastItemsDelivery >= throttleMs;
|
|
107
|
+
if (terminal || due) {
|
|
108
|
+
lastItemsDelivery = now();
|
|
109
|
+
pendingItems = false;
|
|
110
|
+
deliver({ kind: "items", done, total });
|
|
111
|
+
} else {
|
|
112
|
+
pendingItems = true;
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
note(note) {
|
|
116
|
+
state.note = note;
|
|
117
|
+
deliver({ kind: "note", note });
|
|
118
|
+
},
|
|
119
|
+
opResult(opId, status, detail) {
|
|
120
|
+
if (status === "applied") state.opsApplied += 1;
|
|
121
|
+
else if (status === "skipped") state.opsSkipped += 1;
|
|
122
|
+
else state.opsFailed += 1;
|
|
123
|
+
deliver({ kind: "opResult", opId, status, detail });
|
|
124
|
+
},
|
|
125
|
+
meter(spent, budget, unit) {
|
|
126
|
+
deliver({ kind: "meter", spent, budget, unit });
|
|
127
|
+
},
|
|
128
|
+
snapshot() {
|
|
129
|
+
return { ...state, updatedAt: now() };
|
|
130
|
+
},
|
|
131
|
+
flush() {
|
|
132
|
+
if (pendingItems && state.itemsDone !== undefined) {
|
|
133
|
+
lastItemsDelivery = now();
|
|
134
|
+
pendingItems = false;
|
|
135
|
+
deliver({ kind: "items", done: state.itemsDone, total: state.itemsTotal });
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Fan one event stream out to several listeners (e.g. the CLI's local renderer
|
|
143
|
+
* plus the broker heartbeat streamer). Each listener is isolated: one throwing
|
|
144
|
+
* never starves the others, matching the emitter's own best-effort contract.
|
|
145
|
+
*/
|
|
146
|
+
export function composeListeners(
|
|
147
|
+
...listeners: Array<ProgressListener | undefined | null>
|
|
148
|
+
): ProgressListener {
|
|
149
|
+
const active = listeners.filter((listener): listener is ProgressListener => Boolean(listener));
|
|
150
|
+
return (event, snapshot) => {
|
|
151
|
+
for (const listener of active) {
|
|
152
|
+
try {
|
|
153
|
+
listener(event, snapshot);
|
|
154
|
+
} catch {
|
|
155
|
+
// Listeners are presentation/observability only; never fail the work.
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── Shared stage registries ─────────────────────────────────────────────────
|
|
162
|
+
// Package constants so the CLI checklist and the app StageTimeline render
|
|
163
|
+
// LITERALLY the same stages in the same order — one mental model everywhere.
|
|
164
|
+
|
|
165
|
+
export const CRM_SYNC_STAGES = [
|
|
166
|
+
"owners",
|
|
167
|
+
"accounts",
|
|
168
|
+
"contacts",
|
|
169
|
+
"deals",
|
|
170
|
+
"counters",
|
|
171
|
+
"health",
|
|
172
|
+
] as const;
|
|
173
|
+
|
|
174
|
+
/** A connector snapshot pull — the CRM-sync stages that happen CLI-side. */
|
|
175
|
+
export const SNAPSHOT_PULL_STAGES = ["owners", "accounts", "contacts", "deals"] as const;
|
|
176
|
+
|
|
177
|
+
/** The Stripe connector's snapshot pull (billing systems have no owners). */
|
|
178
|
+
export const STRIPE_SNAPSHOT_STAGES = ["customers", "subscriptions"] as const;
|
|
179
|
+
|
|
180
|
+
export const CALL_SYNC_STAGES = ["notes", "transcripts", "insights"] as const;
|
|
181
|
+
|
|
182
|
+
export const BACKFILL_STRIPE_STAGES = ["invoices", "snapshot", "matching", "plan"] as const;
|
|
183
|
+
|
|
184
|
+
/** `backfill runs` — replaying local plan runs + health history to the broker. */
|
|
185
|
+
export const RUNS_REPLAY_STAGES = ["runs", "health"] as const;
|
|
186
|
+
|
|
187
|
+
export const APPLY_STAGES = ["preflight", "operations", "results"] as const;
|
|
188
|
+
|
|
189
|
+
/** `enrich acquire` — routing sourced candidate rows into a create plan. */
|
|
190
|
+
export const ACQUIRE_STAGES = ["candidates"] as const;
|
|
191
|
+
|
|
192
|
+
export const MARKET_CAPTURE_STAGES = ["sources", "capture", "classify", "persist"] as const;
|
|
193
|
+
|
|
194
|
+
/** No-op emitter for callers that don't care (keeps signatures simple). */
|
|
195
|
+
export function nullProgressEmitter(): ProgressEmitter {
|
|
196
|
+
return createProgressEmitter(() => {});
|
|
197
|
+
}
|
package/src/runReport.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* pure added value for users who granted it.
|
|
9
9
|
*/
|
|
10
10
|
import { getCredential } from "./credentials.ts";
|
|
11
|
+
import type { ProgressListener, ProgressSnapshot } from "./progress.ts";
|
|
11
12
|
|
|
12
13
|
type RunEvent = { ts: number; type: string; detail?: string };
|
|
13
14
|
|
|
@@ -61,6 +62,16 @@ export function reportEvent(type: string, detail?: string): void {
|
|
|
61
62
|
events.push({ ts: Date.now(), type, detail });
|
|
62
63
|
}
|
|
63
64
|
|
|
65
|
+
// FNV-1a — the stable-id hash used across the package (see backfill.ts).
|
|
66
|
+
function fnv1a(value: string): string {
|
|
67
|
+
let hash = 0x811c9dc5;
|
|
68
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
69
|
+
hash ^= value.charCodeAt(i);
|
|
70
|
+
hash = Math.imul(hash, 0x01000193);
|
|
71
|
+
}
|
|
72
|
+
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
73
|
+
}
|
|
74
|
+
|
|
64
75
|
// Setup/inspection verbs aren't interesting as "runs" — skip the noise.
|
|
65
76
|
const SKIP_COMMANDS = new Set([
|
|
66
77
|
"login",
|
|
@@ -74,6 +85,144 @@ const SKIP_COMMANDS = new Set([
|
|
|
74
85
|
"-v",
|
|
75
86
|
]);
|
|
76
87
|
|
|
88
|
+
/** The stable per-invocation identity flushRunReport and heartbeats share. */
|
|
89
|
+
function runIdentity(
|
|
90
|
+
args: string[],
|
|
91
|
+
startedAt: number,
|
|
92
|
+
): { command: string; clientRunId: string } | null {
|
|
93
|
+
const command = args[0];
|
|
94
|
+
if (!command || SKIP_COMMANDS.has(command)) return null;
|
|
95
|
+
const sub = args[1] && !args[1].startsWith("-") ? ` ${args[1]}` : "";
|
|
96
|
+
const full = `${command}${sub}`;
|
|
97
|
+
return { command: full, clientRunId: `run_${fnv1a(`${full}:${startedAt}:${process.pid}`)}` };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── Live progress streaming ─────────────────────────────────────────────────
|
|
101
|
+
// While a long command runs, a paired CLI streams heartbeats to the hosted
|
|
102
|
+
// app: POST /api/cli/run with the SAME clientRunId the final flushRunReport
|
|
103
|
+
// will use, status "in_progress", and the progress emitter's snapshot. The
|
|
104
|
+
// server upserts by clientRunId, so the terminal flush simply wins. Same
|
|
105
|
+
// invariants as flushRunReport: opt-in (paired only), fire-and-forget,
|
|
106
|
+
// time-capped, never affects the command's outcome or output.
|
|
107
|
+
|
|
108
|
+
const HEARTBEAT_INTERVAL_MS = 5000;
|
|
109
|
+
/** No heartbeat before the run is ~this old — fast commands never stream. */
|
|
110
|
+
const HEARTBEAT_MIN_RUN_MS = 5000;
|
|
111
|
+
const HEARTBEAT_TIMEOUT_MS = 2000;
|
|
112
|
+
|
|
113
|
+
type RunContext = { command: string; clientRunId: string; startedAt: number };
|
|
114
|
+
|
|
115
|
+
let runContext: RunContext | null = null;
|
|
116
|
+
let lastHeartbeatAt = 0;
|
|
117
|
+
// Broker credential resolved once per run (undefined = not looked up yet;
|
|
118
|
+
// null = looked up, not paired).
|
|
119
|
+
let heartbeatBroker: { baseUrl: string; accessToken: string } | null | undefined;
|
|
120
|
+
let inflightHeartbeat: AbortController | null = null;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Arm heartbeat streaming for this invocation. Call once from the entry point
|
|
124
|
+
* (alongside capturing `startedAt`); without it, progress listeners from
|
|
125
|
+
* `progressReporter()` are inert. Safe to call for any command — skip-listed
|
|
126
|
+
* commands simply never stream.
|
|
127
|
+
*/
|
|
128
|
+
export function beginRunReport(args: string[], startedAt: number): void {
|
|
129
|
+
lastHeartbeatAt = 0;
|
|
130
|
+
heartbeatBroker = undefined;
|
|
131
|
+
inflightHeartbeat = null;
|
|
132
|
+
const identity = runIdentity(args, startedAt);
|
|
133
|
+
runContext = identity ? { ...identity, startedAt } : null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* A ProgressListener that streams the run's progress snapshot to the paired
|
|
138
|
+
* hosted app. Verbs compose it with their local renderer:
|
|
139
|
+
*
|
|
140
|
+
* createProgressEmitter(composeListeners(renderer.listener, progressReporter()))
|
|
141
|
+
*
|
|
142
|
+
* Throttled to one POST per ~5s, first no earlier than ~5s into the run,
|
|
143
|
+
* 2s-capped and fire-and-forget. Privacy: stage names and counts only — notes
|
|
144
|
+
* must stay generic ("batch 4/12"); CRM field values never leave the machine.
|
|
145
|
+
*/
|
|
146
|
+
export function progressReporter(
|
|
147
|
+
overrides: {
|
|
148
|
+
now?: () => number;
|
|
149
|
+
intervalMs?: number;
|
|
150
|
+
minRunMs?: number;
|
|
151
|
+
fetchImpl?: typeof fetch;
|
|
152
|
+
} = {},
|
|
153
|
+
): ProgressListener {
|
|
154
|
+
const now = overrides.now ?? Date.now;
|
|
155
|
+
const intervalMs = overrides.intervalMs ?? HEARTBEAT_INTERVAL_MS;
|
|
156
|
+
const minRunMs = overrides.minRunMs ?? HEARTBEAT_MIN_RUN_MS;
|
|
157
|
+
const fetchImpl = overrides.fetchImpl ?? fetch;
|
|
158
|
+
return (_event, snapshot) => {
|
|
159
|
+
try {
|
|
160
|
+
if (!runContext) return;
|
|
161
|
+
const at = now();
|
|
162
|
+
if (at - runContext.startedAt < minRunMs) return;
|
|
163
|
+
if (at - lastHeartbeatAt < intervalMs) return;
|
|
164
|
+
if (heartbeatBroker === undefined) {
|
|
165
|
+
const broker = getCredential("broker");
|
|
166
|
+
heartbeatBroker =
|
|
167
|
+
broker?.baseUrl && broker.accessToken
|
|
168
|
+
? { baseUrl: broker.baseUrl.replace(/\/+$/, ""), accessToken: broker.accessToken }
|
|
169
|
+
: null; // opt-in: only when paired
|
|
170
|
+
}
|
|
171
|
+
if (!heartbeatBroker) return;
|
|
172
|
+
lastHeartbeatAt = at;
|
|
173
|
+
sendHeartbeat(fetchImpl, heartbeatBroker, runContext, snapshot, at);
|
|
174
|
+
} catch {
|
|
175
|
+
// Observability is best-effort; never affect the command.
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function sendHeartbeat(
|
|
181
|
+
fetchImpl: typeof fetch,
|
|
182
|
+
broker: { baseUrl: string; accessToken: string },
|
|
183
|
+
context: RunContext,
|
|
184
|
+
snapshot: ProgressSnapshot,
|
|
185
|
+
at: number,
|
|
186
|
+
): void {
|
|
187
|
+
// One heartbeat in flight at a time: a stalled POST is superseded, and the
|
|
188
|
+
// terminal flush aborts any straggler so it can never delay process exit.
|
|
189
|
+
inflightHeartbeat?.abort();
|
|
190
|
+
const controller = new AbortController();
|
|
191
|
+
inflightHeartbeat = controller;
|
|
192
|
+
const timer = setTimeout(() => controller.abort(), HEARTBEAT_TIMEOUT_MS);
|
|
193
|
+
(timer as { unref?: () => void }).unref?.();
|
|
194
|
+
void fetchImpl(`${broker.baseUrl}/api/cli/run`, {
|
|
195
|
+
method: "POST",
|
|
196
|
+
headers: { Authorization: `Bearer ${broker.accessToken}`, "Content-Type": "application/json" },
|
|
197
|
+
body: JSON.stringify({
|
|
198
|
+
command: context.command,
|
|
199
|
+
clientRunId: context.clientRunId,
|
|
200
|
+
status: "in_progress",
|
|
201
|
+
startedAt: context.startedAt,
|
|
202
|
+
finishedAt: at,
|
|
203
|
+
durationMs: at - context.startedAt,
|
|
204
|
+
progress: {
|
|
205
|
+
// Stage names + counts only — never CRM field values.
|
|
206
|
+
stage: snapshot.stage,
|
|
207
|
+
stageIndex: snapshot.stageIndex,
|
|
208
|
+
stageCount: snapshot.stageCount,
|
|
209
|
+
itemsDone: snapshot.itemsDone,
|
|
210
|
+
itemsTotal: snapshot.itemsTotal,
|
|
211
|
+
note: snapshot.note,
|
|
212
|
+
heartbeatAt: at,
|
|
213
|
+
},
|
|
214
|
+
}),
|
|
215
|
+
signal: controller.signal,
|
|
216
|
+
})
|
|
217
|
+
.catch(() => {
|
|
218
|
+
// fire-and-forget
|
|
219
|
+
})
|
|
220
|
+
.finally(() => {
|
|
221
|
+
clearTimeout(timer);
|
|
222
|
+
if (inflightHeartbeat === controller) inflightHeartbeat = null;
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
77
226
|
/**
|
|
78
227
|
* Send the run record if the CLI is paired. Best-effort: a 4s-capped POST that
|
|
79
228
|
* swallows every error. Call once per process from the entry point.
|
|
@@ -84,19 +233,25 @@ export async function flushRunReport(
|
|
|
84
233
|
startedAt: number,
|
|
85
234
|
error?: string,
|
|
86
235
|
): Promise<void> {
|
|
87
|
-
|
|
88
|
-
|
|
236
|
+
// The terminal upsert supersedes any streaming heartbeat still in flight.
|
|
237
|
+
inflightHeartbeat?.abort();
|
|
238
|
+
inflightHeartbeat = null;
|
|
239
|
+
// Stable per-invocation identity: the server dedupes on clientRunId, so a
|
|
240
|
+
// retried report, an "in_progress" heartbeat, or a later `backfill runs`
|
|
241
|
+
// replay of local history never duplicates.
|
|
242
|
+
const identity = runIdentity(args, startedAt);
|
|
243
|
+
if (!identity) return;
|
|
89
244
|
const broker = getCredential("broker");
|
|
90
245
|
if (!broker?.baseUrl || !broker.accessToken) return; // opt-in: only when paired
|
|
91
246
|
|
|
92
|
-
const sub = args[1] && !args[1].startsWith("-") ? ` ${args[1]}` : "";
|
|
93
247
|
const finishedAt = Date.now();
|
|
94
248
|
try {
|
|
95
249
|
await fetch(`${broker.baseUrl.replace(/\/+$/, "")}/api/cli/run`, {
|
|
96
250
|
method: "POST",
|
|
97
251
|
headers: { Authorization: `Bearer ${broker.accessToken}`, "Content-Type": "application/json" },
|
|
98
252
|
body: JSON.stringify({
|
|
99
|
-
command:
|
|
253
|
+
command: identity.command,
|
|
254
|
+
clientRunId: identity.clientRunId,
|
|
100
255
|
status,
|
|
101
256
|
startedAt,
|
|
102
257
|
finishedAt,
|
package/src/types.ts
CHANGED
|
@@ -48,7 +48,8 @@ export type PatchOperationType =
|
|
|
48
48
|
// on matchKey at apply time (search is the source of truth; the plan-time
|
|
49
49
|
// snapshot can be stale) and creates only on a confirmed miss, so apply is
|
|
50
50
|
// resolve-first and never double-creates a record a concurrent writer added.
|
|
51
|
-
// Emitted
|
|
51
|
+
// Emitted by `enrich acquire` (metered against the acquire budget) and by
|
|
52
|
+
// `backfill stripe` (closed-won deals from paid invoices, unmetered).
|
|
52
53
|
| "create_record"
|
|
53
54
|
// Merge a duplicate group into a survivor. beforeValue is the group's
|
|
54
55
|
// record ids; afterValue is the survivor id (requires_human_survivor_selection
|
|
@@ -59,7 +60,8 @@ export type PatchOperationType =
|
|
|
59
60
|
* The afterValue of a `create_record` operation. The connector re-resolves on
|
|
60
61
|
* `matchKey`/`matchValue` at apply time and creates only on a confirmed miss.
|
|
61
62
|
* `estCostUsd` is the acquire meter's per-record charge, recorded against the
|
|
62
|
-
* budget on a successful create.
|
|
63
|
+
* budget on a successful create. Emitted by `enrich acquire` (metered) and
|
|
64
|
+
* `backfill stripe` (closed-won deals from paid invoices, unmetered).
|
|
63
65
|
*/
|
|
64
66
|
export type CreateRecordPayload = {
|
|
65
67
|
properties: Record<string, string>;
|
|
@@ -83,6 +85,22 @@ export type CreateRecordPayload = {
|
|
|
83
85
|
ownerId?: string;
|
|
84
86
|
/** Audit label for how the owner was chosen (e.g. "fixed", "territory:0"). */
|
|
85
87
|
assignedBy?: string;
|
|
88
|
+
/**
|
|
89
|
+
* Deal creates only: provider-neutral stage sentinel. "closed_won" tells the
|
|
90
|
+
* connector to resolve the target pipeline's REAL closed-won stage id from
|
|
91
|
+
* the provider's pipeline metadata at apply time (HubSpot: stage with
|
|
92
|
+
* `metadata.isClosed` true and probability 1) — never by substring-guessing
|
|
93
|
+
* a stage name. If no such stage is resolvable the operation is skipped.
|
|
94
|
+
* Plain deal properties (amount, closedate, dealname, …) travel in
|
|
95
|
+
* `properties` as provider property names, like every other create.
|
|
96
|
+
*/
|
|
97
|
+
dealStage?: "closed_won";
|
|
98
|
+
/**
|
|
99
|
+
* Deal creates only: which pipeline to create the deal in — a pipeline id
|
|
100
|
+
* or a case-insensitive pipeline label. Absent = the portal's default
|
|
101
|
+
* pipeline (lowest displayOrder).
|
|
102
|
+
*/
|
|
103
|
+
dealPipeline?: string;
|
|
86
104
|
};
|
|
87
105
|
|
|
88
106
|
export type AuditFindingSeverity = "info" | "warning" | "critical";
|
|
@@ -505,6 +523,21 @@ export type GtmConnector = {
|
|
|
505
523
|
* result per input op. Optional: connectors without it use `applyOperation`.
|
|
506
524
|
*/
|
|
507
525
|
applyCreateContactsBatch?: (operations: PatchOperation[]) => Promise<PatchOperationResult[]>;
|
|
526
|
+
/**
|
|
527
|
+
* Optional bulk apply path for homogeneous operation runs selected by
|
|
528
|
+
* `applyPatchPlan` (currently consecutive `set_field` ops on one object type
|
|
529
|
+
* and consecutive `archive_record` ops on one object type). Implementations
|
|
530
|
+
* MUST return exactly one result per input operation, preserve per-operation
|
|
531
|
+
* CAS/conflict semantics for field writes, and must not write operations that
|
|
532
|
+
* would have conflicted under the serial `readField` + `applyOperation` path.
|
|
533
|
+
* Connectors without it use `applyOperation` per operation.
|
|
534
|
+
*/
|
|
535
|
+
applyBatch?: (operations: PatchOperation[]) => Promise<PatchOperationResult[]>;
|
|
536
|
+
/**
|
|
537
|
+
* Maximum number of operations `applyPatchPlan` should pass to one
|
|
538
|
+
* `applyBatch` call. Defaults to 200 when unspecified.
|
|
539
|
+
*/
|
|
540
|
+
applyBatchLimit?: number;
|
|
508
541
|
/**
|
|
509
542
|
* Read the live value of one canonical field, used for compare-and-set:
|
|
510
543
|
* apply orchestration refuses to write over values that drifted since the
|