fullstackgtm 0.44.0 → 0.46.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 +293 -1
- package/INSTALL_FOR_AGENTS.md +13 -11
- package/README.md +45 -24
- package/dist/audit.d.ts +3 -1
- package/dist/audit.js +29 -2
- package/dist/backfill.d.ts +86 -0
- package/dist/backfill.js +251 -0
- package/dist/bin.js +4 -1
- package/dist/cli/audit.d.ts +15 -0
- package/dist/cli/audit.js +392 -0
- package/dist/cli/auth.d.ts +82 -0
- package/dist/cli/auth.js +607 -0
- 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.d.ts +1 -0
- package/dist/cli/call.js +387 -0
- package/dist/cli/capabilities.d.ts +30 -0
- package/dist/cli/capabilities.js +197 -0
- package/dist/cli/draft.d.ts +7 -0
- package/dist/cli/draft.js +87 -0
- package/dist/cli/enrich.d.ts +8 -0
- package/dist/cli/enrich.js +806 -0
- package/dist/cli/fix.d.ts +33 -0
- package/dist/cli/fix.js +346 -0
- package/dist/cli/help.d.ts +25 -0
- package/dist/cli/help.js +646 -0
- package/dist/cli/icp.d.ts +7 -0
- package/dist/cli/icp.js +229 -0
- package/dist/cli/init.d.ts +7 -0
- package/dist/cli/init.js +58 -0
- package/dist/cli/market.d.ts +1 -0
- package/dist/cli/market.js +391 -0
- package/dist/cli/plans.d.ts +5 -0
- package/dist/cli/plans.js +456 -0
- package/dist/cli/schedule.d.ts +8 -0
- package/dist/cli/schedule.js +479 -0
- package/dist/cli/shared.d.ts +71 -0
- package/dist/cli/shared.js +342 -0
- package/dist/cli/signals.d.ts +7 -0
- package/dist/cli/signals.js +412 -0
- package/dist/cli/suggest.d.ts +49 -0
- package/dist/cli/suggest.js +135 -0
- package/dist/cli/tam.d.ts +9 -0
- package/dist/cli/tam.js +387 -0
- package/dist/cli/ui.d.ts +142 -0
- package/dist/cli/ui.js +427 -0
- package/dist/cli.d.ts +1 -57
- package/dist/cli.js +123 -5157
- package/dist/connector.d.ts +23 -0
- package/dist/connector.js +47 -0
- package/dist/connectors/hubspot.d.ts +10 -1
- package/dist/connectors/hubspot.js +244 -10
- package/dist/connectors/hubspotAuth.js +5 -1
- package/dist/connectors/linkedin.js +14 -14
- package/dist/connectors/salesforce.d.ts +10 -1
- package/dist/connectors/salesforce.js +39 -6
- package/dist/connectors/signalSources.js +7 -59
- 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/icp.d.ts +15 -11
- package/dist/icp.js +19 -36
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/judge.d.ts +2 -0
- package/dist/judge.js +6 -0
- 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 +11 -1
- package/dist/marketClassify.js +17 -2
- package/dist/marketTaxonomy.d.ts +6 -1
- package/dist/marketTaxonomy.js +4 -2
- package/dist/mcp-bin.js +31 -2
- package/dist/mcp.js +372 -166
- 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/schedule.d.ts +80 -2
- package/dist/schedule.js +254 -1
- package/dist/spoolFiles.d.ts +44 -0
- package/dist/spoolFiles.js +114 -0
- package/dist/types.d.ts +29 -1
- package/docs/api.md +75 -8
- package/docs/architecture.md +2 -0
- package/docs/linkedin-connector-spec.md +1 -1
- package/docs/signal-spool-format.md +13 -0
- package/llms.txt +18 -9
- package/package.json +10 -3
- package/skills/fullstackgtm/SKILL.md +2 -1
- package/src/audit.ts +27 -1
- package/src/backfill.ts +340 -0
- package/src/bin.ts +5 -1
- package/src/cli/audit.ts +447 -0
- package/src/cli/auth.ts +669 -0
- package/src/cli/backfill.ts +156 -0
- package/src/cli/backfillRuns.ts +198 -0
- package/src/cli/call.ts +414 -0
- package/src/cli/capabilities.ts +215 -0
- package/src/cli/draft.ts +101 -0
- package/src/cli/enrich.ts +908 -0
- package/src/cli/fix.ts +373 -0
- package/src/cli/help.ts +702 -0
- package/src/cli/icp.ts +265 -0
- package/src/cli/init.ts +65 -0
- package/src/cli/market.ts +423 -0
- package/src/cli/plans.ts +524 -0
- package/src/cli/schedule.ts +526 -0
- package/src/cli/shared.ts +392 -0
- package/src/cli/signals.ts +434 -0
- package/src/cli/suggest.ts +151 -0
- package/src/cli/tam.ts +435 -0
- package/src/cli/ui.ts +497 -0
- package/src/cli.ts +122 -5855
- package/src/connector.ts +66 -0
- package/src/connectors/hubspot.ts +273 -9
- package/src/connectors/hubspotAuth.ts +5 -1
- package/src/connectors/linkedin.ts +14 -14
- package/src/connectors/salesforce.ts +51 -3
- package/src/connectors/signalSources.ts +7 -56
- 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/icp.ts +19 -35
- package/src/index.ts +28 -1
- package/src/judge.ts +7 -0
- package/src/llm.ts +239 -0
- package/src/market.ts +157 -44
- package/src/marketClassify.ts +26 -2
- package/src/marketTaxonomy.ts +10 -2
- package/src/mcp-bin.ts +34 -2
- package/src/mcp.ts +270 -63
- package/src/progress.ts +197 -0
- package/src/runReport.ts +159 -4
- package/src/schedule.ts +310 -3
- package/src/spoolFiles.ts +116 -0
- package/src/types.ts +32 -2
- package/docs/dx-punch-list.md +0 -87
- package/docs/roadmap-to-1.0.md +0 -211
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// `backfill stripe` — paid Stripe invoices → closed-won deal proposals through
|
|
2
|
+
// the governed plan/apply flow. This command NEVER writes to a CRM: it reads
|
|
3
|
+
// paid invoices (Stripe, read-only), reads a CRM snapshot, and builds a
|
|
4
|
+
// dry-run create_record plan. --save persists the plan for the normal
|
|
5
|
+
// plans approve → apply spine, where the HubSpot connector re-resolves each
|
|
6
|
+
// invoice id (resolve-first) and creates only on a confirmed miss.
|
|
7
|
+
|
|
8
|
+
import { buildStripeBackfillPlan, DEFAULT_BACKFILL_MATCH_PROPERTY } from "../backfill.ts";
|
|
9
|
+
import { fetchStripePaidInvoices } from "../connectors/stripe.ts";
|
|
10
|
+
import { getCredential } from "../credentials.ts";
|
|
11
|
+
import { patchPlanToMarkdown } from "../format.ts";
|
|
12
|
+
import { createFilePlanStore } from "../planStore.ts";
|
|
13
|
+
import { BACKFILL_STRIPE_STAGES, composeListeners, createProgressEmitter } from "../progress.ts";
|
|
14
|
+
import { progressReporter } from "../runReport.ts";
|
|
15
|
+
import { unknownSubcommandError } from "./suggest.ts";
|
|
16
|
+
import { option, readSnapshot, saveRequested } from "./shared.ts";
|
|
17
|
+
import { colorEnabled, createProgressRenderer, paint, stylizePlanMarkdown, table } from "./ui.ts";
|
|
18
|
+
|
|
19
|
+
/** STRIPE_SECRET_KEY env ∨ stored `login stripe` credential (same ladder as `--provider stripe`). */
|
|
20
|
+
function resolveStripeKey(): string {
|
|
21
|
+
const key = process.env.STRIPE_SECRET_KEY ?? getCredential("stripe")?.accessToken;
|
|
22
|
+
if (!key) {
|
|
23
|
+
throw new Error(
|
|
24
|
+
"No Stripe credentials. Run `echo \"$STRIPE_KEY\" | fullstackgtm login stripe` or set STRIPE_SECRET_KEY. " +
|
|
25
|
+
"A restricted key with read access to Invoices and Customers is enough. (`fullstackgtm doctor` shows credential status.)",
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
return key;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function backfillCommand(args: string[]) {
|
|
32
|
+
const [subcommand, ...rest] = args;
|
|
33
|
+
if (subcommand === "runs") {
|
|
34
|
+
const { backfillRunsCommand } = await import("./backfillRuns.ts");
|
|
35
|
+
await backfillRunsCommand(rest);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (subcommand !== "stripe") {
|
|
39
|
+
throw unknownSubcommandError("backfill", subcommand ?? "", ["stripe", "runs"]);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const since = option(rest, "--since");
|
|
43
|
+
const pipeline = option(rest, "--pipeline") ?? undefined;
|
|
44
|
+
const matchProperty = option(rest, "--match-property") ?? undefined;
|
|
45
|
+
const skipUnmatched = rest.includes("--skip-unmatched");
|
|
46
|
+
const save = saveRequested(rest);
|
|
47
|
+
|
|
48
|
+
// Shared progress: the BACKFILL_STRIPE_STAGES tick on an interactive stderr
|
|
49
|
+
// board (inert when piped) and heartbeat to a paired hosted app on long runs.
|
|
50
|
+
const renderer = createProgressRenderer(BACKFILL_STRIPE_STAGES);
|
|
51
|
+
const progress = createProgressEmitter(
|
|
52
|
+
composeListeners(renderer.listener, progressReporter()),
|
|
53
|
+
);
|
|
54
|
+
const stage = (name: (typeof BACKFILL_STRIPE_STAGES)[number]) =>
|
|
55
|
+
progress.stage(name, BACKFILL_STRIPE_STAGES.indexOf(name), BACKFILL_STRIPE_STAGES.length);
|
|
56
|
+
let result: ReturnType<typeof buildStripeBackfillPlan>;
|
|
57
|
+
try {
|
|
58
|
+
// 1. Revenue truth: paid invoices from Stripe (read-only).
|
|
59
|
+
stage("invoices");
|
|
60
|
+
const stripeKey = resolveStripeKey();
|
|
61
|
+
const invoices = await fetchStripePaidInvoices(
|
|
62
|
+
{ getApiKey: () => stripeKey },
|
|
63
|
+
{ ...(since ? { sinceIso: since } : {}), onPage: (fetched) => progress.items(fetched) },
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
// 2. The CRM as it stands (same source options as audit: --provider <crm>,
|
|
67
|
+
// --input <snapshot.json>, --demo, --sample). The pull's own stage/items
|
|
68
|
+
// events ride the same emitter (the board ignores the nested stages).
|
|
69
|
+
stage("snapshot");
|
|
70
|
+
const snapshot = await readSnapshot(rest, progress);
|
|
71
|
+
|
|
72
|
+
// 3. Dry-run plan: one closed-won create_record deal per invoice with no
|
|
73
|
+
// deal yet; unmatched customers also get a proposed account (unless
|
|
74
|
+
// --skip-unmatched restores report-only behavior).
|
|
75
|
+
stage("matching");
|
|
76
|
+
result = buildStripeBackfillPlan(invoices, snapshot, {
|
|
77
|
+
pipeline,
|
|
78
|
+
matchProperty,
|
|
79
|
+
source: "stripe:invoices",
|
|
80
|
+
createMissingAccounts: !skipUnmatched,
|
|
81
|
+
});
|
|
82
|
+
stage("plan");
|
|
83
|
+
progress.flush();
|
|
84
|
+
} finally {
|
|
85
|
+
renderer.done();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (rest.includes("--json")) {
|
|
89
|
+
console.log(
|
|
90
|
+
JSON.stringify(
|
|
91
|
+
{ plan: result.plan, counts: result.counts, unmatched: result.unmatched, proposedAccounts: result.proposedAccounts },
|
|
92
|
+
null,
|
|
93
|
+
2,
|
|
94
|
+
),
|
|
95
|
+
);
|
|
96
|
+
} else {
|
|
97
|
+
// TTY-only styling; piped output stays byte-identical plain text.
|
|
98
|
+
console.log(
|
|
99
|
+
stylizePlanMarkdown(patchPlanToMarkdown(result.plan), paint(colorEnabled(process.stdout))),
|
|
100
|
+
);
|
|
101
|
+
if (result.proposedAccounts.length > 0) {
|
|
102
|
+
console.log(
|
|
103
|
+
`\nProposed new accounts (${result.proposedAccounts.length}) — Stripe customers the CRM doesn't know; ` +
|
|
104
|
+
"created only if this plan is approved and applied:",
|
|
105
|
+
);
|
|
106
|
+
const accountRows = [
|
|
107
|
+
["account", "domain", "invoices"],
|
|
108
|
+
...result.proposedAccounts.map((entry) => [
|
|
109
|
+
entry.name,
|
|
110
|
+
entry.domain ?? "—",
|
|
111
|
+
String(entry.invoiceCount),
|
|
112
|
+
]),
|
|
113
|
+
];
|
|
114
|
+
for (const line of table(accountRows)) console.log(` ${line}`);
|
|
115
|
+
}
|
|
116
|
+
if (result.unmatched.length > 0) {
|
|
117
|
+
console.log(
|
|
118
|
+
`\nUnmatched invoices (${result.unmatched.length}) — ${
|
|
119
|
+
skipUnmatched
|
|
120
|
+
? "customer matched no CRM account by domain or name; reported only (--skip-unmatched)"
|
|
121
|
+
: "customer has no usable name or company domain; reported only"
|
|
122
|
+
}:`,
|
|
123
|
+
);
|
|
124
|
+
const rows = [
|
|
125
|
+
["invoice", "customer", "domain", "amount", "paid"],
|
|
126
|
+
...result.unmatched.map((entry) => [
|
|
127
|
+
entry.invoiceNumber ?? entry.invoiceId,
|
|
128
|
+
entry.customerName ?? "—",
|
|
129
|
+
entry.customerDomain ?? "—",
|
|
130
|
+
`${entry.amountPaid}${entry.currency ? ` ${entry.currency}` : ""}`,
|
|
131
|
+
entry.paidAt ?? "—",
|
|
132
|
+
]),
|
|
133
|
+
];
|
|
134
|
+
for (const line of table(rows)) console.log(` ${line}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (!save) {
|
|
139
|
+
console.error(
|
|
140
|
+
"\nDry run — nothing written. Re-run with --save to persist the plan, then approve + apply.",
|
|
141
|
+
);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (result.plan.operations.length === 0) {
|
|
145
|
+
console.error("No deals to backfill (every paid invoice already matched a deal or its customer was unmatched); nothing saved.");
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
await createFilePlanStore().save(result.plan);
|
|
149
|
+
console.error(
|
|
150
|
+
`Saved plan ${result.plan.id} — ${result.counts.planned} closed-won deal(s)` +
|
|
151
|
+
`${result.counts.accountsProposed > 0 ? ` + ${result.counts.accountsProposed} new account(s)` : ""} from ${result.counts.invoices} paid invoice(s). ` +
|
|
152
|
+
`Review \`fullstackgtm plans show ${result.plan.id}\`, approve \`fullstackgtm plans approve ${result.plan.id} --operations all\`, ` +
|
|
153
|
+
`then \`fullstackgtm apply --plan-id ${result.plan.id} --provider hubspot\`. ` +
|
|
154
|
+
`Apply re-checks ${result.plan.operations.length ? `the ${matchProperty ?? DEFAULT_BACKFILL_MATCH_PROPERTY} dedupe key` : "resolve-first"} before creating.`,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// `backfill runs` — replay the LOCAL execution history to the paired hosted
|
|
2
|
+
// app, so an engagement that started CLI-only gets its full trail the moment
|
|
3
|
+
// it pairs (instead of a feed that begins mid-engagement):
|
|
4
|
+
// - every stored plan's runs[] (apply attempts, per-op result counts)
|
|
5
|
+
// → POST /api/cli/run, one record per run
|
|
6
|
+
// - the health.jsonl timeline (per-audit hygiene scores)
|
|
7
|
+
// → POST /api/cli/health, chunked
|
|
8
|
+
// Idempotent by construction: each run carries a DETERMINISTIC clientRunId
|
|
9
|
+
// (fnv1a of planId + startedAt + index) and health entries dedupe server-side
|
|
10
|
+
// on their timestamp — re-running backfill never duplicates anything.
|
|
11
|
+
// Privacy: replays only what live reporting would have sent — statuses,
|
|
12
|
+
// counts, timestamps, scores. No CRM field values leave the machine.
|
|
13
|
+
|
|
14
|
+
import { getCredential } from "../credentials.ts";
|
|
15
|
+
import { readHealthTimeline } from "../health.ts";
|
|
16
|
+
import { createFilePlanStore } from "../planStore.ts";
|
|
17
|
+
import { activeProfile } from "../credentials.ts";
|
|
18
|
+
import { composeListeners, createProgressEmitter, RUNS_REPLAY_STAGES } from "../progress.ts";
|
|
19
|
+
import { progressReporter } from "../runReport.ts";
|
|
20
|
+
import { createProgressRenderer } from "./ui.ts";
|
|
21
|
+
import type { PatchPlanRun } from "../types.ts";
|
|
22
|
+
|
|
23
|
+
function fnv1a(value: string): string {
|
|
24
|
+
let hash = 0x811c9dc5;
|
|
25
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
26
|
+
hash ^= value.charCodeAt(i);
|
|
27
|
+
hash = Math.imul(hash, 0x01000193);
|
|
28
|
+
}
|
|
29
|
+
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const RUN_STATUS: Record<PatchPlanRun["status"], "success" | "partial" | "error"> = {
|
|
33
|
+
applied: "success",
|
|
34
|
+
partial: "partial",
|
|
35
|
+
failed: "error",
|
|
36
|
+
rejected: "error",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export async function backfillRunsCommand(args: string[]) {
|
|
40
|
+
const dryRun = args.includes("--dry-run");
|
|
41
|
+
const json = args.includes("--json");
|
|
42
|
+
|
|
43
|
+
const broker = getCredential("broker");
|
|
44
|
+
if (!broker?.baseUrl || !broker.accessToken) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
"Not paired with a hosted app. Run `fullstackgtm login --via <url>` first — backfill replays local history to the paired backend.",
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
const baseUrl = broker.baseUrl.replace(/\/+$/, "");
|
|
50
|
+
const headers = {
|
|
51
|
+
Authorization: `Bearer ${broker.accessToken}`,
|
|
52
|
+
"Content-Type": "application/json",
|
|
53
|
+
};
|
|
54
|
+
const profile = activeProfile();
|
|
55
|
+
|
|
56
|
+
// 1. Gather: every stored plan's runs[] + the health timeline.
|
|
57
|
+
const plans = await createFilePlanStore().list();
|
|
58
|
+
const runPayloads = plans.flatMap((stored) =>
|
|
59
|
+
stored.runs.map((run, index) => {
|
|
60
|
+
const startedAt = Date.parse(run.startedAt);
|
|
61
|
+
const finishedAt = Date.parse(run.finishedAt);
|
|
62
|
+
const tally = { applied: 0, skipped: 0, conflicts: 0, failed: 0 };
|
|
63
|
+
for (const result of run.results) {
|
|
64
|
+
if (result.status === "applied") tally.applied += 1;
|
|
65
|
+
else if (result.status === "skipped") tally.skipped += 1;
|
|
66
|
+
else if (result.status === "conflict") tally.conflicts += 1;
|
|
67
|
+
else tally.failed += 1;
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
clientRunId: `bkf_${fnv1a(`${stored.plan.id}:${run.startedAt}:${index}`)}`,
|
|
71
|
+
command: `apply --plan-id ${stored.plan.id}`,
|
|
72
|
+
status: RUN_STATUS[run.status] ?? "error",
|
|
73
|
+
profile,
|
|
74
|
+
startedAt: Number.isFinite(startedAt) ? startedAt : Date.now(),
|
|
75
|
+
finishedAt: Number.isFinite(finishedAt) ? finishedAt : startedAt,
|
|
76
|
+
durationMs:
|
|
77
|
+
Number.isFinite(startedAt) && Number.isFinite(finishedAt) && finishedAt >= startedAt
|
|
78
|
+
? finishedAt - startedAt
|
|
79
|
+
: 0,
|
|
80
|
+
counts: { ...tally, provider: run.provider, backfilled: true },
|
|
81
|
+
};
|
|
82
|
+
}),
|
|
83
|
+
);
|
|
84
|
+
const healthEntries = readHealthTimeline().map((entry) => ({
|
|
85
|
+
at: Date.parse(entry.at),
|
|
86
|
+
score: entry.score,
|
|
87
|
+
findings: entry.findings,
|
|
88
|
+
weightedFindings: entry.weightedFindings,
|
|
89
|
+
records: entry.records,
|
|
90
|
+
severityCounts: entry.severityCounts,
|
|
91
|
+
byObjectType: entry.byObjectType,
|
|
92
|
+
byRule: entry.byRule,
|
|
93
|
+
})).filter((entry) => Number.isFinite(entry.at));
|
|
94
|
+
|
|
95
|
+
if (dryRun) {
|
|
96
|
+
const summary = {
|
|
97
|
+
ok: true,
|
|
98
|
+
dryRun: true,
|
|
99
|
+
plans: plans.length,
|
|
100
|
+
runs: runPayloads.length,
|
|
101
|
+
healthEntries: healthEntries.length,
|
|
102
|
+
target: baseUrl,
|
|
103
|
+
};
|
|
104
|
+
if (json) console.log(JSON.stringify(summary, null, 2));
|
|
105
|
+
else {
|
|
106
|
+
console.log(
|
|
107
|
+
`Dry run — would replay ${runPayloads.length} plan run(s) (from ${plans.length} stored plan(s)) ` +
|
|
108
|
+
`and ${healthEntries.length} health reading(s) to ${baseUrl}. Nothing sent.`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Shared progress: replay ticks on an interactive stderr board (inert when
|
|
115
|
+
// piped) and heartbeats to the paired app — this command is paired by
|
|
116
|
+
// definition, so a big replay shows live on the dashboard as it lands.
|
|
117
|
+
const renderer = createProgressRenderer(RUNS_REPLAY_STAGES);
|
|
118
|
+
const progress = createProgressEmitter(
|
|
119
|
+
composeListeners(renderer.listener, progressReporter()),
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
// 2. Replay runs (sequential; server dedupes on clientRunId).
|
|
123
|
+
let reported = 0;
|
|
124
|
+
let failed = 0;
|
|
125
|
+
const errors: string[] = [];
|
|
126
|
+
let healthInserted = 0;
|
|
127
|
+
let healthDeduped = 0;
|
|
128
|
+
try {
|
|
129
|
+
progress.stage(RUNS_REPLAY_STAGES[0], 0, RUNS_REPLAY_STAGES.length);
|
|
130
|
+
for (const payload of runPayloads) {
|
|
131
|
+
try {
|
|
132
|
+
const response = await fetch(`${baseUrl}/api/cli/run`, {
|
|
133
|
+
method: "POST",
|
|
134
|
+
headers,
|
|
135
|
+
body: JSON.stringify(payload),
|
|
136
|
+
signal: AbortSignal.timeout(10_000),
|
|
137
|
+
});
|
|
138
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
139
|
+
reported += 1;
|
|
140
|
+
} catch (error) {
|
|
141
|
+
failed += 1;
|
|
142
|
+
if (errors.length < 5) {
|
|
143
|
+
errors.push(`${payload.command}: ${error instanceof Error ? error.message : String(error)}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
progress.items(reported + failed, runPayloads.length);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// 3. Replay health history in chunks (server dedupes on timestamp).
|
|
150
|
+
progress.stage(RUNS_REPLAY_STAGES[1], 1, RUNS_REPLAY_STAGES.length);
|
|
151
|
+
for (let i = 0; i < healthEntries.length; i += 200) {
|
|
152
|
+
const chunk = healthEntries.slice(i, i + 200);
|
|
153
|
+
try {
|
|
154
|
+
const response = await fetch(`${baseUrl}/api/cli/health`, {
|
|
155
|
+
method: "POST",
|
|
156
|
+
headers,
|
|
157
|
+
body: JSON.stringify({ entries: chunk }),
|
|
158
|
+
signal: AbortSignal.timeout(15_000),
|
|
159
|
+
});
|
|
160
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
161
|
+
const result = (await response.json()) as { inserted?: number; deduped?: number };
|
|
162
|
+
healthInserted += result.inserted ?? 0;
|
|
163
|
+
healthDeduped += result.deduped ?? 0;
|
|
164
|
+
} catch (error) {
|
|
165
|
+
failed += 1;
|
|
166
|
+
if (errors.length < 5) {
|
|
167
|
+
errors.push(`health chunk: ${error instanceof Error ? error.message : String(error)}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
progress.items(Math.min(i + 200, healthEntries.length), healthEntries.length);
|
|
171
|
+
}
|
|
172
|
+
progress.flush();
|
|
173
|
+
} finally {
|
|
174
|
+
renderer.done();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const summary = {
|
|
178
|
+
ok: failed === 0,
|
|
179
|
+
runsReported: reported,
|
|
180
|
+
runsFailed: failed,
|
|
181
|
+
healthInserted,
|
|
182
|
+
healthDeduped,
|
|
183
|
+
target: baseUrl,
|
|
184
|
+
...(errors.length > 0 ? { errors } : {}),
|
|
185
|
+
};
|
|
186
|
+
if (json) {
|
|
187
|
+
console.log(JSON.stringify(summary, null, 2));
|
|
188
|
+
} else {
|
|
189
|
+
console.log(
|
|
190
|
+
`Replayed ${reported}/${runPayloads.length} plan run(s) and ${healthInserted} health reading(s) ` +
|
|
191
|
+
`to ${baseUrl} (${healthDeduped} readings already there — dedupe is server-side, re-running is safe).`,
|
|
192
|
+
);
|
|
193
|
+
for (const line of errors) console.error(` error: ${line}`);
|
|
194
|
+
}
|
|
195
|
+
if (failed > 0) {
|
|
196
|
+
process.exitCode = 1;
|
|
197
|
+
}
|
|
198
|
+
}
|