@elitedcs/ghl-mcp 3.46.0 → 3.48.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 +67 -0
- package/dist/index.js +449 -61
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,72 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 3.48.0 — Blueprint: complete workflow-building (opportunities, branching, appointment timers, opt-in publish)
|
|
4
|
+
|
|
5
|
+
Completes the `apply_build_plan` executor so a Blueprint build produces workflows that
|
|
6
|
+
create and move real opportunities, branch on whether a contact has one, fire on
|
|
7
|
+
appointment-relative timers, and (opt-in) go live instead of staging as drafts. Every
|
|
8
|
+
item below was proven live on a throwaway account and read back before shipping.
|
|
9
|
+
|
|
10
|
+
- **Fixed: `create_opportunity` now actually creates an opportunity.** The executor was
|
|
11
|
+
emitting GHL's *update* node (`internal_update_opportunity`), which only updates an
|
|
12
|
+
existing opp and silently no-ops when none exists. It now emits the real
|
|
13
|
+
`internal_create_opportunity` node (pipeline hoisted to the attribute level, stage + name
|
|
14
|
+
in the custom input fields), so a new-lead workflow lands a deal in the pipeline as
|
|
15
|
+
intended.
|
|
16
|
+
- **New: `wait_appointment` action** — appointment-relative reminder timers (e.g. "24 hours
|
|
17
|
+
before the appointment"), emitting GHL's appointment-relative wait node. The workflow
|
|
18
|
+
builder's action-chain validation accepts the appointment-relative `appointmentStartAfter`
|
|
19
|
+
shape alongside the standard time-based `startAfter`.
|
|
20
|
+
- **New: optional `monetaryValue`** on `create_opportunity` / `update_opportunity` so a deal
|
|
21
|
+
carries its value.
|
|
22
|
+
- **New: `find_opportunity` multi-path branching.** The first branching logical action: it
|
|
23
|
+
loads a contact's latest opportunity in a pipeline, then routes to a `found` / `not-found`
|
|
24
|
+
branch (each with its own actions, including waits and sends). This is what makes a
|
|
25
|
+
move-the-opp step inside a branch work — the find loads the opp the move then acts on.
|
|
26
|
+
- **Fixed: backward opportunity moves (win-back / reactivation) silently no-op'd.** GHL's
|
|
27
|
+
"Move opportunity" defaults `allowBackward:false`, which lets a forward stage move through
|
|
28
|
+
but silently refuses a move to an earlier stage — breaking every reactivation build (whose
|
|
29
|
+
whole point is a backward move). The executor now emits `allowBackward:true` so the target
|
|
30
|
+
stage is honored in either direction; forward moves are unaffected.
|
|
31
|
+
- **New: opt-in `publishWorkflows`.** Workflows still build as DRAFT by default (the safe
|
|
32
|
+
default). Passing `publishWorkflows:true` publishes ungated, newly-built workflows live
|
|
33
|
+
(re-syncing their triggers); a workflow gated DRAFT by an unmet handoff is never
|
|
34
|
+
auto-published, and a publish failure never halts or rolls back the build (it surfaces a
|
|
35
|
+
"publish it yourself" note and the build still succeeds).
|
|
36
|
+
- **New: funnel page content template** (`templates/funnel-page-content-template.md`) —
|
|
37
|
+
brand-themeable CSS plus always-on A2P/10DLC opt-in, SEO, and AI-search (GEO) structure
|
|
38
|
+
for the manual funnel-page-content step.
|
|
39
|
+
|
|
40
|
+
## 3.47.0 — Blueprint Cap-0: cross-workflow exit chaining + native trigger builders
|
|
41
|
+
|
|
42
|
+
Two foundational executor fixes so `apply_build_plan` builds workflows that actually
|
|
43
|
+
chain together AND actually fire — the prerequisites for a non-hollow account build.
|
|
44
|
+
|
|
45
|
+
- **Fixed: a cross-workflow `remove_from_workflow` / `add_to_workflow` halted the build.**
|
|
46
|
+
When one workflow's exit action targeted another workflow in the SAME plan, execute
|
|
47
|
+
halted, reporting the (already-built) target as unresolved — so the canonical "pull the
|
|
48
|
+
lead out of nurture when they book / reply" pattern (workflows that enrol each other)
|
|
49
|
+
could not fully execute. The executor now resolves EVERY workflow's identity in a first
|
|
50
|
+
pass (never-clobber bind existing, else create an empty DRAFT) before expanding any
|
|
51
|
+
actions, so forward, backward, AND mutual cross-workflow references all resolve. Safety
|
|
52
|
+
rails intact and hardened: never-clobber (existing workflows are never modified),
|
|
53
|
+
empty-orphan detection (now halts rather than blind-binds when an existing workflow's
|
|
54
|
+
action count is unreadable), verify-after, and rollback of every unsaved shell on a halt
|
|
55
|
+
— with any orphan id that could not be deleted SURFACED in the halt message (never
|
|
56
|
+
swallowed) and dropped from the returned id map.
|
|
57
|
+
- **New: native trigger builders for the trigger types real accounts actually use.**
|
|
58
|
+
Previously only `contact_tag` triggers were auto-built; every other workflow shipped
|
|
59
|
+
trigger-less (inactive) — the #1 reason builds were hollow. Blueprint now builds
|
|
60
|
+
`form_submission`, `appointment` (status-conditioned, e.g. confirmed / no-show),
|
|
61
|
+
`customer_reply`, `pipeline_stage_updated`, `inbound_webhook`, and `payment_received`
|
|
62
|
+
triggers natively. Each saved shape was CAPTURED from a real, live, UI-built trigger in a
|
|
63
|
+
reference account (not guessed) so the synthesized trigger reads back identical and fires.
|
|
64
|
+
Refs in trigger conditions (form / pipeline / stage) resolve to real GHL ids at build
|
|
65
|
+
time. A trigger type Blueprint doesn't yet build natively — or one missing the field it
|
|
66
|
+
needs (e.g. an `appointment` trigger with no status) — is still surfaced as a manual step.
|
|
67
|
+
- Plan validation now rejects two workflows that share a name (the executor binds workflows
|
|
68
|
+
by name, so duplicates would collapse onto one shell).
|
|
69
|
+
|
|
3
70
|
## 3.46.0 — `verify_funnel`: prove a funnel actually captures leads (+ external-funnel wiring bundle)
|
|
4
71
|
|
|
5
72
|
The live-runtime companion to `audit_workflows`. `audit_workflows` finds dead workflow
|
package/dist/index.js
CHANGED
|
@@ -31,7 +31,7 @@ var require_package = __commonJS({
|
|
|
31
31
|
"package.json"(exports2, module2) {
|
|
32
32
|
module2.exports = {
|
|
33
33
|
name: "@elitedcs/ghl-mcp",
|
|
34
|
-
version: "3.
|
|
34
|
+
version: "3.48.0",
|
|
35
35
|
mcpName: "io.github.drjerryrelth/ghl-command",
|
|
36
36
|
description: "GoHighLevel MCP Server for Claude. 220 tools \u2014 full CRM, automation, marketing control, account-wide workflow audit, live funnel-capture verification, and the only programmatic GHL workflow builder, now multi-tenant across client accounts.",
|
|
37
37
|
main: "dist/index.js",
|
|
@@ -1237,7 +1237,9 @@ function validateActionChain(actions, existingIds) {
|
|
|
1237
1237
|
if (!Array.isArray(attr.tags) || !attr.tags.length) throw new Error(`Tag action "${action.name}" missing 'tags' array.`);
|
|
1238
1238
|
break;
|
|
1239
1239
|
case "wait":
|
|
1240
|
-
if (!attr.startAfter
|
|
1240
|
+
if (!attr.startAfter && !attr.appointmentStartAfter) {
|
|
1241
|
+
throw new Error(`Wait action "${action.name}" missing required 'startAfter' (or 'appointmentStartAfter' for an appointment-relative wait) in attributes.`);
|
|
1242
|
+
}
|
|
1241
1243
|
break;
|
|
1242
1244
|
case "internal_update_opportunity": {
|
|
1243
1245
|
const isRoundTripped = hasId(action) && (existingIds ? existingIds.has(action.id) : true);
|
|
@@ -1288,6 +1290,31 @@ function validateActionChain(actions, existingIds) {
|
|
|
1288
1290
|
if (!sibling || sibling.length !== 1) throw new Error(`If/else branch "${action.name}" must have exactly one sibling id.`);
|
|
1289
1291
|
}
|
|
1290
1292
|
}
|
|
1293
|
+
for (const action of actions) {
|
|
1294
|
+
if (action.type !== "find_opportunity") continue;
|
|
1295
|
+
if (existingIds && hasId(action) && existingIds.has(action.id)) continue;
|
|
1296
|
+
if (!action.id) throw new Error(`find_opportunity "${action.name}" missing id.`);
|
|
1297
|
+
const next = getStringArray(action.next);
|
|
1298
|
+
if (!next || next.length !== 2) {
|
|
1299
|
+
throw new Error(`find_opportunity "${action.name}" must have next as exactly [foundTransitionId, notFoundTransitionId].`);
|
|
1300
|
+
}
|
|
1301
|
+
for (const tid of next) {
|
|
1302
|
+
const t = byId.get(tid);
|
|
1303
|
+
if (!t || t.type !== "transition") {
|
|
1304
|
+
throw new Error(`find_opportunity "${action.name}" next must reference its two transition nodes.`);
|
|
1305
|
+
}
|
|
1306
|
+
if (t.parent !== action.id || t.parentKey !== action.id) {
|
|
1307
|
+
throw new Error(`Transition "${t.name}" of find_opportunity "${action.name}" must have parent and parentKey set to the find node id.`);
|
|
1308
|
+
}
|
|
1309
|
+
const firstChildId = typeof t.next === "string" ? t.next : void 0;
|
|
1310
|
+
if (firstChildId) {
|
|
1311
|
+
const c = byId.get(firstChildId);
|
|
1312
|
+
if (!c || c.parent !== tid || c.parentKey !== tid) {
|
|
1313
|
+
throw new Error(`First child of transition "${t.name}" must have parent and parentKey set to the transition id.`);
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1291
1318
|
}
|
|
1292
1319
|
var WorkflowBuilderClient = class _WorkflowBuilderClient {
|
|
1293
1320
|
// Active Firebase auth — swapped when operating in another company's GHL.
|
|
@@ -1891,7 +1918,12 @@ ${errorBody}`
|
|
|
1891
1918
|
});
|
|
1892
1919
|
for (let i = 0; i < linked.length; i++) {
|
|
1893
1920
|
const action = linked[i];
|
|
1894
|
-
if (action.
|
|
1921
|
+
if (action.parent || action.nodeType) continue;
|
|
1922
|
+
if (action.cat === "multi-path") {
|
|
1923
|
+
if (i > 0 && !action.parentKey) action.parentKey = linked[i - 1].id;
|
|
1924
|
+
continue;
|
|
1925
|
+
}
|
|
1926
|
+
if (action.cat) continue;
|
|
1895
1927
|
if (i > 0 && !action.parentKey) {
|
|
1896
1928
|
action.parentKey = linked[i - 1].id;
|
|
1897
1929
|
}
|
|
@@ -11116,12 +11148,25 @@ var smsAssetSchema = import_zod53.z.object({
|
|
|
11116
11148
|
mergeTags: import_zod53.z.array(import_zod53.z.string()).optional()
|
|
11117
11149
|
});
|
|
11118
11150
|
var waitUnit = import_zod53.z.enum(["minutes", "hours", "days"]);
|
|
11119
|
-
var
|
|
11151
|
+
var branchActionOptions = [
|
|
11120
11152
|
import_zod53.z.object({ type: import_zod53.z.literal("add_contact_tag"), tagRef: nsRef("tag") }),
|
|
11121
11153
|
import_zod53.z.object({ type: import_zod53.z.literal("remove_contact_tag"), tagRef: nsRef("tag") }),
|
|
11122
11154
|
import_zod53.z.object({ type: import_zod53.z.literal("send_email"), emailRef: nsRef("email") }),
|
|
11123
11155
|
import_zod53.z.object({ type: import_zod53.z.literal("send_sms"), smsRef: nsRef("sms") }),
|
|
11124
11156
|
import_zod53.z.object({ type: import_zod53.z.literal("wait"), value: import_zod53.z.number().positive(), unit: waitUnit }),
|
|
11157
|
+
// Appointment-relative wait ("wait until N BEFORE the appointment").
|
|
11158
|
+
// Only works when the workflow has an appointment in context (i.e. an
|
|
11159
|
+
// `appointment` trigger) — enforced by validateBuildPlan. Expands to GHL's
|
|
11160
|
+
// appointment-wait node shape (attributes.type "appointment" +
|
|
11161
|
+
// appointmentStartAfter, captured live from Lux Bio). Integer units only
|
|
11162
|
+
// (GHL stores whole minutes). Only "before" is emitted today — that's the
|
|
11163
|
+
// shape we captured + proved; "after" (post-appointment follow-up) is
|
|
11164
|
+
// deferred until its shape is captured from a real workflow.
|
|
11165
|
+
import_zod53.z.object({
|
|
11166
|
+
type: import_zod53.z.literal("wait_appointment"),
|
|
11167
|
+
value: import_zod53.z.number().int().positive(),
|
|
11168
|
+
unit: waitUnit
|
|
11169
|
+
}),
|
|
11125
11170
|
import_zod53.z.object({
|
|
11126
11171
|
type: import_zod53.z.literal("internal_notification"),
|
|
11127
11172
|
to: import_zod53.z.string(),
|
|
@@ -11147,12 +11192,22 @@ var actionSchema = import_zod53.z.discriminatedUnion("type", [
|
|
|
11147
11192
|
type: import_zod53.z.literal("create_opportunity"),
|
|
11148
11193
|
pipelineRef: nsRef("pipeline"),
|
|
11149
11194
|
stageRef: nsRef("stage"),
|
|
11150
|
-
|
|
11195
|
+
// Opportunity name (merge fields allowed). Defaults to the contact's name.
|
|
11196
|
+
// Required by GHL's create node; without it the create silently no-ops.
|
|
11197
|
+
name: import_zod53.z.string().optional(),
|
|
11198
|
+
// Opportunity monetary value (the deal/sale dollar amount). A string so it
|
|
11199
|
+
// can be a literal ("2500") OR a merge field ("{{contact.package_value}}").
|
|
11200
|
+
// Optional — omitted → GHL leaves the value unset. Shape captured from Lux
|
|
11201
|
+
// Bio "14. Package Sale". Lux models the lifecycle by pipeline STAGE, not GHL
|
|
11202
|
+
// won/lost status, so a "won" opp = move to the closing stage WITH this value.
|
|
11203
|
+
value: import_zod53.z.string().optional()
|
|
11151
11204
|
}),
|
|
11152
11205
|
import_zod53.z.object({
|
|
11153
11206
|
type: import_zod53.z.literal("update_opportunity"),
|
|
11154
11207
|
pipelineRef: nsRef("pipeline"),
|
|
11155
|
-
stageRef: nsRef("stage")
|
|
11208
|
+
stageRef: nsRef("stage"),
|
|
11209
|
+
// Opportunity monetary value (see create_opportunity.value). Optional.
|
|
11210
|
+
value: import_zod53.z.string().optional()
|
|
11156
11211
|
}),
|
|
11157
11212
|
import_zod53.z.object({
|
|
11158
11213
|
type: import_zod53.z.literal("goal_event"),
|
|
@@ -11160,14 +11215,25 @@ var actionSchema = import_zod53.z.discriminatedUnion("type", [
|
|
|
11160
11215
|
// GHL's GoalAction enum (extracted 2026-05-18): continue | wait | exit.
|
|
11161
11216
|
action: import_zod53.z.enum(["exit", "continue", "wait"]).optional()
|
|
11162
11217
|
})
|
|
11163
|
-
]
|
|
11218
|
+
];
|
|
11219
|
+
var branchActionSchema = import_zod53.z.discriminatedUnion("type", branchActionOptions);
|
|
11220
|
+
var findOpportunitySchema = import_zod53.z.object({
|
|
11221
|
+
type: import_zod53.z.literal("find_opportunity"),
|
|
11222
|
+
pipelineRef: nsRef("pipeline"),
|
|
11223
|
+
found: import_zod53.z.array(branchActionSchema).default([]),
|
|
11224
|
+
notFound: import_zod53.z.array(branchActionSchema).default([])
|
|
11225
|
+
});
|
|
11226
|
+
var actionSchema = import_zod53.z.discriminatedUnion("type", [...branchActionOptions, findOpportunitySchema]);
|
|
11227
|
+
var APPOINTMENT_STATUSES = ["new", "confirmed", "showed", "noshow", "cancelled", "invalid"];
|
|
11164
11228
|
var triggerSchema = import_zod53.z.object({
|
|
11165
11229
|
type: import_zod53.z.string(),
|
|
11166
11230
|
formRef: nsRef("form").optional(),
|
|
11167
11231
|
tagRef: nsRef("tag").optional(),
|
|
11168
11232
|
calendarRef: nsRef("calendar").optional(),
|
|
11169
11233
|
pipelineRef: nsRef("pipeline").optional(),
|
|
11170
|
-
stageRef: nsRef("stage").optional()
|
|
11234
|
+
stageRef: nsRef("stage").optional(),
|
|
11235
|
+
// Required for a native `appointment` trigger (the status it fires on).
|
|
11236
|
+
appointmentStatus: import_zod53.z.enum(APPOINTMENT_STATUSES).optional()
|
|
11171
11237
|
});
|
|
11172
11238
|
var workflowSchema = import_zod53.z.object({
|
|
11173
11239
|
ref: nsRef("workflow"),
|
|
@@ -11290,8 +11356,7 @@ function checkRefIntegrity(plan, defined) {
|
|
|
11290
11356
|
check(t.pipelineRef, "pipeline", `workflows[${w.ref}].trigger.pipelineRef`);
|
|
11291
11357
|
check(t.stageRef, "stage", `workflows[${w.ref}].trigger.stageRef`);
|
|
11292
11358
|
}
|
|
11293
|
-
|
|
11294
|
-
const where = `workflows[${w.ref}].actions[${i}](${a.type})`;
|
|
11359
|
+
const checkActionRefs = (a, where) => {
|
|
11295
11360
|
if ("tagRef" in a) check(a.tagRef, "tag", where);
|
|
11296
11361
|
if ("emailRef" in a) check(a.emailRef, "email", where);
|
|
11297
11362
|
if ("smsRef" in a) check(a.smsRef, "sms", where);
|
|
@@ -11299,6 +11364,17 @@ function checkRefIntegrity(plan, defined) {
|
|
|
11299
11364
|
if ("pipelineRef" in a) check(a.pipelineRef, "pipeline", where);
|
|
11300
11365
|
if ("stageRef" in a) check(a.stageRef, "stage", where);
|
|
11301
11366
|
if ("workflowRef" in a) check(a.workflowRef, "workflow", where);
|
|
11367
|
+
};
|
|
11368
|
+
w.actions.forEach((a, i) => {
|
|
11369
|
+
const where = `workflows[${w.ref}].actions[${i}](${a.type})`;
|
|
11370
|
+
checkActionRefs(a, where);
|
|
11371
|
+
if (a.type === "find_opportunity") {
|
|
11372
|
+
if (i !== w.actions.length - 1) {
|
|
11373
|
+
errors.push(`${where}: find_opportunity must be the last action in the workflow (its Found/Not-Found branches do not rejoin a linear tail).`);
|
|
11374
|
+
}
|
|
11375
|
+
a.found.forEach((c, ci) => checkActionRefs(c, `${where}.found[${ci}](${c.type})`));
|
|
11376
|
+
a.notFound.forEach((c, ci) => checkActionRefs(c, `${where}.notFound[${ci}](${c.type})`));
|
|
11377
|
+
}
|
|
11302
11378
|
});
|
|
11303
11379
|
}
|
|
11304
11380
|
for (const h of plan.handoffs ?? []) {
|
|
@@ -11376,7 +11452,12 @@ function validateBuildPlan(input) {
|
|
|
11376
11452
|
["customValues", (plan.customValues ?? []).map((x) => ({ ref: x.ref, name: x.name }))],
|
|
11377
11453
|
["calendars", (plan.calendars ?? []).map((x) => ({ ref: x.ref, name: x.name }))],
|
|
11378
11454
|
["forms", (plan.forms ?? []).map((x) => ({ ref: x.ref, name: x.name }))],
|
|
11379
|
-
["funnels", (plan.funnels ?? []).map((x) => ({ ref: x.ref, name: x.name }))]
|
|
11455
|
+
["funnels", (plan.funnels ?? []).map((x) => ({ ref: x.ref, name: x.name }))],
|
|
11456
|
+
// Workflows bind by name too: the executor's two-pass build resolves each
|
|
11457
|
+
// workflow's identity by name (bind same-named existing, else create a shell).
|
|
11458
|
+
// Two plan workflows sharing a name would make the second bind to the FIRST's
|
|
11459
|
+
// freshly-created shell — only one would ever be saved. Reject before any write.
|
|
11460
|
+
["workflows", (plan.workflows ?? []).map((x) => ({ ref: x.ref, name: x.name }))]
|
|
11380
11461
|
];
|
|
11381
11462
|
for (const [type, objs] of nameGroups) {
|
|
11382
11463
|
const seen = /* @__PURE__ */ new Map();
|
|
@@ -11425,6 +11506,32 @@ function validateBuildPlan(input) {
|
|
|
11425
11506
|
);
|
|
11426
11507
|
}
|
|
11427
11508
|
}
|
|
11509
|
+
for (const wf of plan.workflows ?? []) {
|
|
11510
|
+
const hasApptWait = wf.actions.some((a) => a.type === "wait_appointment");
|
|
11511
|
+
if (hasApptWait && wf.trigger?.type !== "appointment") {
|
|
11512
|
+
warnings.push(
|
|
11513
|
+
`workflow "${wf.ref}" uses a wait_appointment ("before appointment" timer) but its trigger is ${wf.trigger?.type ? `"${wf.trigger.type}"` : "not set to an appointment trigger"} \u2014 that timer needs an appointment in context (an "appointment" trigger), or GHL silently mis-times it`
|
|
11514
|
+
);
|
|
11515
|
+
}
|
|
11516
|
+
}
|
|
11517
|
+
const MAX_NODES = 40;
|
|
11518
|
+
for (const wf of plan.workflows ?? []) {
|
|
11519
|
+
let nodeCount = 0;
|
|
11520
|
+
let hasBranch = false;
|
|
11521
|
+
for (const a of wf.actions) {
|
|
11522
|
+
if (a.type === "find_opportunity") {
|
|
11523
|
+
hasBranch = true;
|
|
11524
|
+
nodeCount += 3 + a.found.length + a.notFound.length;
|
|
11525
|
+
} else {
|
|
11526
|
+
nodeCount += 1;
|
|
11527
|
+
}
|
|
11528
|
+
}
|
|
11529
|
+
if (hasBranch && nodeCount > MAX_NODES) {
|
|
11530
|
+
allErrors.push(
|
|
11531
|
+
`workflow "${wf.ref}" expands to ${nodeCount} nodes (> ${MAX_NODES}); a workflow with a find_opportunity branch cannot be auto-split \u2014 move actions into separate workflows.`
|
|
11532
|
+
);
|
|
11533
|
+
}
|
|
11534
|
+
}
|
|
11428
11535
|
return {
|
|
11429
11536
|
valid: allErrors.length === 0,
|
|
11430
11537
|
errors: allErrors,
|
|
@@ -11518,6 +11625,15 @@ function htmlWrap(text) {
|
|
|
11518
11625
|
if (/^\s*<[a-z]/i.test(t)) return t;
|
|
11519
11626
|
return `<p style="margin:0px;">${t}</p>`;
|
|
11520
11627
|
}
|
|
11628
|
+
function monetaryValueField(value) {
|
|
11629
|
+
return {
|
|
11630
|
+
__customInputs__: { value: "numerical" },
|
|
11631
|
+
filterField: "monetaryValue",
|
|
11632
|
+
valueFieldType: "custom-input",
|
|
11633
|
+
value,
|
|
11634
|
+
dataType: "NUMERICAL"
|
|
11635
|
+
};
|
|
11636
|
+
}
|
|
11521
11637
|
function resolveId(ref, idMap) {
|
|
11522
11638
|
const real = idMap.get(ref);
|
|
11523
11639
|
return real ?? PENDING(ref);
|
|
@@ -11600,6 +11716,41 @@ function expandAction(action, idx, idMap) {
|
|
|
11600
11716
|
}
|
|
11601
11717
|
};
|
|
11602
11718
|
}
|
|
11719
|
+
case "wait_appointment": {
|
|
11720
|
+
const totalMinutes = action.value * (action.unit === "days" ? 1440 : action.unit === "hours" ? 60 : 1);
|
|
11721
|
+
const distributed = {
|
|
11722
|
+
months: 0,
|
|
11723
|
+
days: Math.floor(totalMinutes / 1440),
|
|
11724
|
+
hours: Math.floor(totalMinutes % 1440 / 60),
|
|
11725
|
+
minutes: totalMinutes % 60
|
|
11726
|
+
};
|
|
11727
|
+
const label = `Wait until ${action.value} ${action.unit} before appointment`;
|
|
11728
|
+
return {
|
|
11729
|
+
kind: "expanded",
|
|
11730
|
+
pendingRefs: [],
|
|
11731
|
+
native: {
|
|
11732
|
+
type: "wait",
|
|
11733
|
+
name: label,
|
|
11734
|
+
attributes: {
|
|
11735
|
+
type: "appointment",
|
|
11736
|
+
name: label,
|
|
11737
|
+
cat: "",
|
|
11738
|
+
isHybridAction: true,
|
|
11739
|
+
hybridActionType: "wait",
|
|
11740
|
+
convertToMultipath: false,
|
|
11741
|
+
transitions: [],
|
|
11742
|
+
appointmentStartAfter: {
|
|
11743
|
+
// Only "before" is emitted — the captured/proven shape. See plan.ts.
|
|
11744
|
+
when: "before",
|
|
11745
|
+
type: "minutes",
|
|
11746
|
+
value: totalMinutes,
|
|
11747
|
+
distributed
|
|
11748
|
+
},
|
|
11749
|
+
appointmentCondition: "skip"
|
|
11750
|
+
}
|
|
11751
|
+
}
|
|
11752
|
+
};
|
|
11753
|
+
}
|
|
11603
11754
|
case "internal_notification": {
|
|
11604
11755
|
const looksLikeUserId = /^[A-Za-z0-9]{17,}$/.test(action.to);
|
|
11605
11756
|
return {
|
|
@@ -11690,7 +11841,40 @@ function expandAction(action, idx, idMap) {
|
|
|
11690
11841
|
}
|
|
11691
11842
|
};
|
|
11692
11843
|
}
|
|
11693
|
-
case "create_opportunity":
|
|
11844
|
+
case "create_opportunity": {
|
|
11845
|
+
const pipelineId = resolveId(action.pipelineRef, idMap);
|
|
11846
|
+
const stageId = resolveId(action.stageRef, idMap);
|
|
11847
|
+
const pendingRefs = [];
|
|
11848
|
+
if (isPending(pipelineId)) pendingRefs.push(action.pipelineRef);
|
|
11849
|
+
if (isPending(stageId)) pendingRefs.push(action.stageRef);
|
|
11850
|
+
const pName = idx.pipelineName.get(action.pipelineRef) ?? action.pipelineRef;
|
|
11851
|
+
const sName = idx.stageName.get(action.stageRef) ?? action.stageRef;
|
|
11852
|
+
const oppName = action.name ?? "{{contact.first_name}} {{contact.last_name}}";
|
|
11853
|
+
return {
|
|
11854
|
+
kind: "expanded",
|
|
11855
|
+
pendingRefs,
|
|
11856
|
+
native: {
|
|
11857
|
+
type: "internal_create_opportunity",
|
|
11858
|
+
name: `Create opportunity: ${pName} / ${sName}`,
|
|
11859
|
+
workflowsActionType: "INTERNAL",
|
|
11860
|
+
attributes: {
|
|
11861
|
+
type: "internal_create_opportunity",
|
|
11862
|
+
pipelineId,
|
|
11863
|
+
// The save-time normalizer only scaffolds internal_update_opportunity, so this
|
|
11864
|
+
// create node carries GHL's full shape itself (attribute-level + per-field
|
|
11865
|
+
// __customInputs__), matching the captured live node exactly.
|
|
11866
|
+
__customInputs__: {},
|
|
11867
|
+
__customInputFields__: [
|
|
11868
|
+
{ __customInputs__: {}, filterField: "pipelineStageId", value: stageId, valueFieldType: "select", dataType: "SINGLE_OPTIONS" },
|
|
11869
|
+
{ __customInputs__: {}, filterField: "name", value: oppName, valueFieldType: "string", dataType: "TEXT" },
|
|
11870
|
+
// Optional opportunity dollar value (Lux "14. Package Sale" shape). Only
|
|
11871
|
+
// emitted when the plan supplies a value; omitted → GHL leaves it unset.
|
|
11872
|
+
...action.value ? [monetaryValueField(action.value)] : []
|
|
11873
|
+
]
|
|
11874
|
+
}
|
|
11875
|
+
}
|
|
11876
|
+
};
|
|
11877
|
+
}
|
|
11694
11878
|
case "update_opportunity": {
|
|
11695
11879
|
const pipelineId = resolveId(action.pipelineRef, idMap);
|
|
11696
11880
|
const stageId = resolveId(action.stageRef, idMap);
|
|
@@ -11699,26 +11883,119 @@ function expandAction(action, idx, idMap) {
|
|
|
11699
11883
|
if (isPending(stageId)) pendingRefs.push(action.stageRef);
|
|
11700
11884
|
const pName = idx.pipelineName.get(action.pipelineRef) ?? action.pipelineRef;
|
|
11701
11885
|
const sName = idx.stageName.get(action.stageRef) ?? action.stageRef;
|
|
11702
|
-
const verb = action.type === "create_opportunity" ? "Create opportunity" : "Move opportunity";
|
|
11703
11886
|
return {
|
|
11704
11887
|
kind: "expanded",
|
|
11705
11888
|
pendingRefs,
|
|
11706
11889
|
native: {
|
|
11707
11890
|
type: "internal_update_opportunity",
|
|
11708
|
-
name:
|
|
11709
|
-
// NODE-level discriminator (the normalizer also forces this; included so
|
|
11710
|
-
// the expanded action is correct even outside the save path).
|
|
11891
|
+
name: `Move opportunity: ${pName} / ${sName}`,
|
|
11711
11892
|
workflowsActionType: "INTERNAL",
|
|
11712
11893
|
attributes: {
|
|
11713
11894
|
type: "internal_update_opportunity",
|
|
11895
|
+
// Honor the operator's explicit target stage in EITHER direction. GHL's
|
|
11896
|
+
// "Move opportunity" action defaults allowBackward:false, which SILENTLY
|
|
11897
|
+
// refuses to move an opp to an EARLIER pipeline stage (lower position) —
|
|
11898
|
+
// breaking win-back / reactivation moves whose whole point is to move a
|
|
11899
|
+
// deal BACKWARD (e.g. Cancelled → Active). The Blueprint intent of an
|
|
11900
|
+
// update_opportunity action is "put this opp in this stage" regardless of
|
|
11901
|
+
// direction, so allow backward moves. (Live-caught on Cap-3 wf7 2026-06-24:
|
|
11902
|
+
// the identical node moved a deal FORWARD fine in wf6 but silently no-op'd
|
|
11903
|
+
// moving it BACKWARD here.) The save-time normalizer preserves an explicit
|
|
11904
|
+
// boolean, so this is honored end-to-end.
|
|
11905
|
+
allowBackward: true,
|
|
11714
11906
|
__customInputFields__: [
|
|
11715
11907
|
{ filterField: "pipelineId", value: pipelineId, valueFieldType: "select", dataType: "SINGLE_OPTIONS" },
|
|
11716
|
-
{ filterField: "pipelineStageId", value: stageId, valueFieldType: "select", dataType: "SINGLE_OPTIONS" }
|
|
11908
|
+
{ filterField: "pipelineStageId", value: stageId, valueFieldType: "select", dataType: "SINGLE_OPTIONS" },
|
|
11909
|
+
// Optional opportunity dollar value (Lux "14. Package Sale" shape). Only
|
|
11910
|
+
// emitted when the plan supplies a value; omitted → GHL leaves it unchanged.
|
|
11911
|
+
...action.value ? [monetaryValueField(action.value)] : []
|
|
11717
11912
|
]
|
|
11718
11913
|
}
|
|
11719
11914
|
}
|
|
11720
11915
|
};
|
|
11721
11916
|
}
|
|
11917
|
+
case "find_opportunity": {
|
|
11918
|
+
const pipelineId = resolveId(action.pipelineRef, idMap);
|
|
11919
|
+
const pendingRefs = [];
|
|
11920
|
+
if (isPending(pipelineId)) pendingRefs.push(action.pipelineRef);
|
|
11921
|
+
for (const child of [...action.found, ...action.notFound]) {
|
|
11922
|
+
const probe = expandAction(child, idx, idMap);
|
|
11923
|
+
if (probe.kind === "needs_content")
|
|
11924
|
+
return { kind: "needs_content", logicalType: `find_opportunity \u2192 ${child.type}`, reason: probe.reason };
|
|
11925
|
+
if (probe.kind === "manual")
|
|
11926
|
+
return { kind: "manual", logicalType: `find_opportunity \u2192 ${child.type}`, reason: probe.reason };
|
|
11927
|
+
}
|
|
11928
|
+
const findId = crypto.randomUUID();
|
|
11929
|
+
const foundTId = crypto.randomUUID();
|
|
11930
|
+
const notFoundTId = crypto.randomUUID();
|
|
11931
|
+
const wireBranch = (children, transitionId) => {
|
|
11932
|
+
const out = [];
|
|
11933
|
+
children.forEach((child, ci) => {
|
|
11934
|
+
const exp = expandAction(child, idx, idMap);
|
|
11935
|
+
if (exp.kind !== "expanded") return;
|
|
11936
|
+
exp.pendingRefs.forEach((r) => pendingRefs.push(r));
|
|
11937
|
+
const node = exp.native;
|
|
11938
|
+
node.id = crypto.randomUUID();
|
|
11939
|
+
node.parent = transitionId;
|
|
11940
|
+
node.parentKey = ci === 0 ? transitionId : out[ci - 1].id;
|
|
11941
|
+
node.order = ci;
|
|
11942
|
+
delete node.next;
|
|
11943
|
+
out.push(node);
|
|
11944
|
+
});
|
|
11945
|
+
for (let k = 0; k < out.length - 1; k++) out[k].next = out[k + 1].id;
|
|
11946
|
+
return out;
|
|
11947
|
+
};
|
|
11948
|
+
const foundNodes = wireBranch(action.found, foundTId);
|
|
11949
|
+
const notFoundNodes = wireBranch(action.notFound, notFoundTId);
|
|
11950
|
+
const makeTransition = (id, name, firstChildId) => ({
|
|
11951
|
+
id,
|
|
11952
|
+
type: "transition",
|
|
11953
|
+
name,
|
|
11954
|
+
cat: "transition",
|
|
11955
|
+
parent: findId,
|
|
11956
|
+
parentKey: findId,
|
|
11957
|
+
attributes: {},
|
|
11958
|
+
...firstChildId ? { next: firstChildId } : {}
|
|
11959
|
+
});
|
|
11960
|
+
const pName = idx.pipelineName.get(action.pipelineRef) ?? action.pipelineRef;
|
|
11961
|
+
const findNode = {
|
|
11962
|
+
id: findId,
|
|
11963
|
+
type: "find_opportunity",
|
|
11964
|
+
name: `Find opportunity: ${pName}`,
|
|
11965
|
+
cat: "multi-path",
|
|
11966
|
+
workflowsActionType: "INTERNAL",
|
|
11967
|
+
next: [foundTId, notFoundTId],
|
|
11968
|
+
attributes: {
|
|
11969
|
+
sorting: "latest",
|
|
11970
|
+
type: "find_opportunity",
|
|
11971
|
+
// Filter shape is DISTINCT from the update/create node: the field name is
|
|
11972
|
+
// `pipeline_id` (underscore), the operator lives in `value`, the id in
|
|
11973
|
+
// `secondValue` (captured from Lux "01"/"02").
|
|
11974
|
+
__customInputFields__: [
|
|
11975
|
+
{ __customInputs__: {}, filterField: "pipeline_id", value: "eq", secondValue: pipelineId }
|
|
11976
|
+
],
|
|
11977
|
+
__customInputs__: {},
|
|
11978
|
+
cat: "multi-path",
|
|
11979
|
+
convertToMultipath: true,
|
|
11980
|
+
transitions: [
|
|
11981
|
+
{ id: foundTId, name: "Opportunity Found", fields: [], meta: { __branchKey__: "predefined_Opportunity Found" }, conditionType: "pre-defined" },
|
|
11982
|
+
{ id: notFoundTId, name: "Opportunity Not Found", fields: [], meta: { __branchKey__: "predefined_Opportunity Not Found" }, conditionType: "pre-defined" }
|
|
11983
|
+
],
|
|
11984
|
+
__name__: "Find Opportunity"
|
|
11985
|
+
}
|
|
11986
|
+
};
|
|
11987
|
+
return {
|
|
11988
|
+
kind: "expanded",
|
|
11989
|
+
pendingRefs,
|
|
11990
|
+
native: findNode,
|
|
11991
|
+
extraNodes: [
|
|
11992
|
+
makeTransition(foundTId, "Opportunity Found", foundNodes[0]?.id),
|
|
11993
|
+
...foundNodes,
|
|
11994
|
+
makeTransition(notFoundTId, "Opportunity Not Found", notFoundNodes[0]?.id),
|
|
11995
|
+
...notFoundNodes
|
|
11996
|
+
]
|
|
11997
|
+
};
|
|
11998
|
+
}
|
|
11722
11999
|
case "goal_event": {
|
|
11723
12000
|
return {
|
|
11724
12001
|
kind: "expanded",
|
|
@@ -11760,47 +12037,112 @@ function triggerPlainEnglish(trigger, idx) {
|
|
|
11760
12037
|
return `trigger type "${trigger.type}"`;
|
|
11761
12038
|
}
|
|
11762
12039
|
}
|
|
11763
|
-
function expandTrigger(trigger, idx) {
|
|
11764
|
-
|
|
11765
|
-
|
|
11766
|
-
|
|
11767
|
-
|
|
11768
|
-
|
|
11769
|
-
trigger
|
|
12040
|
+
function expandTrigger(trigger, idx, idMap) {
|
|
12041
|
+
const manual = (reason) => ({ kind: "manual", reason });
|
|
12042
|
+
const native = (t, pendingRefs = []) => ({ kind: "native", trigger: t, pendingRefs });
|
|
12043
|
+
switch (trigger.type) {
|
|
12044
|
+
case "contact_tag": {
|
|
12045
|
+
if (!trigger.tagRef) return manual("Set this workflow's contact-tag trigger in the GHL UI (the plan trigger has no tag).");
|
|
12046
|
+
const tagName = idx.tagName.get(trigger.tagRef) ?? trigger.tagRef;
|
|
12047
|
+
return native({
|
|
11770
12048
|
name: "Contact Tag",
|
|
11771
12049
|
type: "contact_tag",
|
|
11772
12050
|
conditions: [{ operator: "index-of-true", field: "tagsAdded", value: tagName, title: "Tag Added", type: "select", id: "tag-added" }]
|
|
11773
|
-
}
|
|
11774
|
-
}
|
|
12051
|
+
});
|
|
12052
|
+
}
|
|
12053
|
+
case "form_submission":
|
|
12054
|
+
case "form_submitted": {
|
|
12055
|
+
if (!trigger.formRef) return manual("Set this workflow's form-submission trigger in the GHL UI (the plan trigger has no form).");
|
|
12056
|
+
const formId = resolveId(trigger.formRef, idMap);
|
|
12057
|
+
return native({
|
|
12058
|
+
name: "Form Submitted",
|
|
12059
|
+
type: "form_submission",
|
|
12060
|
+
conditions: [{ operator: "is-any-of", field: "form.id", value: [formId], title: "Form is", type: "string" }]
|
|
12061
|
+
}, isPending(formId) ? [trigger.formRef] : []);
|
|
12062
|
+
}
|
|
12063
|
+
case "appointment": {
|
|
12064
|
+
const status = trigger.appointmentStatus;
|
|
12065
|
+
if (!status) return manual(`Set this workflow's appointment trigger in the GHL UI: ${triggerPlainEnglish(trigger, idx)} (the plan trigger has no appointmentStatus to fire on, e.g. confirmed / noshow).`);
|
|
12066
|
+
return native({
|
|
12067
|
+
name: `Appointment Status \u2014 ${status}`,
|
|
12068
|
+
type: "appointment",
|
|
12069
|
+
conditions: [
|
|
12070
|
+
{ operator: "==", field: "appointment.eventType", value: "normal", title: "Event Type", type: "select" },
|
|
12071
|
+
{ operator: "==", field: "appointment.status", value: status, title: "Appointment status is", type: "select" }
|
|
12072
|
+
]
|
|
12073
|
+
});
|
|
12074
|
+
}
|
|
12075
|
+
case "customer_reply":
|
|
12076
|
+
case "contact_replied": {
|
|
12077
|
+
return native({ name: "Customer Replied", type: "customer_reply", conditions: [] });
|
|
12078
|
+
}
|
|
12079
|
+
case "pipeline_stage_updated": {
|
|
12080
|
+
if (!trigger.pipelineRef || !trigger.stageRef) return manual("Set this workflow's pipeline-stage trigger in the GHL UI (the plan trigger needs both a pipeline and a stage).");
|
|
12081
|
+
const pipelineId = resolveId(trigger.pipelineRef, idMap);
|
|
12082
|
+
const stageId = resolveId(trigger.stageRef, idMap);
|
|
12083
|
+
const pending = [];
|
|
12084
|
+
if (isPending(pipelineId)) pending.push(trigger.pipelineRef);
|
|
12085
|
+
if (isPending(stageId)) pending.push(trigger.stageRef);
|
|
12086
|
+
return native({
|
|
12087
|
+
name: "Pipeline Stage Changed",
|
|
12088
|
+
type: "pipeline_stage_updated",
|
|
12089
|
+
conditions: [
|
|
12090
|
+
{ operator: "==", field: "opportunity.pipelineId", value: pipelineId, title: "In pipeline", type: "select" },
|
|
12091
|
+
{ operator: "==", field: "opportunity.pipelineStageId", value: stageId, title: "Pipeline stage", type: "select" }
|
|
12092
|
+
]
|
|
12093
|
+
}, pending);
|
|
12094
|
+
}
|
|
12095
|
+
case "inbound_webhook": {
|
|
12096
|
+
return native({ name: "Inbound Webhook", type: "inbound_webhook", conditions: [] });
|
|
12097
|
+
}
|
|
12098
|
+
case "payment_received": {
|
|
12099
|
+
return native({ name: "Payment Received", type: "payment_received", conditions: [] });
|
|
12100
|
+
}
|
|
12101
|
+
default:
|
|
12102
|
+
return manual(`Set this workflow's trigger in the GHL UI: ${triggerPlainEnglish(trigger, idx)}, then publish. (Blueprint builds the workflow + all its steps as DRAFT; this trigger type isn't auto-built yet.)`);
|
|
11775
12103
|
}
|
|
11776
|
-
return {
|
|
11777
|
-
kind: "manual",
|
|
11778
|
-
reason: `Set this workflow's trigger in the GHL UI: ${triggerPlainEnglish(trigger, idx)}, then publish. (Blueprint builds the workflow + all its steps as DRAFT; only this trigger type isn't auto-built yet.)`
|
|
11779
|
-
};
|
|
11780
12104
|
}
|
|
11781
12105
|
function expandWorkflow(workflow, idx, idMap, gatedBy) {
|
|
11782
12106
|
const nativeActions = [];
|
|
11783
12107
|
const manual = [];
|
|
11784
12108
|
const needsContent = [];
|
|
11785
12109
|
const pendingRefs = /* @__PURE__ */ new Set();
|
|
12110
|
+
let branchEmitted = false;
|
|
11786
12111
|
workflow.actions.forEach((a, i) => {
|
|
12112
|
+
if (branchEmitted) {
|
|
12113
|
+
manual.push({ index: i, logicalType: a.type, reason: "action follows a find_opportunity branch (which must be the last action) \u2014 not emitted to avoid an orphaned node" });
|
|
12114
|
+
return;
|
|
12115
|
+
}
|
|
11787
12116
|
const exp = expandAction(a, idx, idMap);
|
|
11788
12117
|
if (exp.kind === "expanded") {
|
|
11789
12118
|
nativeActions.push(exp.native);
|
|
11790
12119
|
exp.pendingRefs.forEach((r) => pendingRefs.add(r));
|
|
12120
|
+
if (exp.extraNodes && exp.extraNodes.length) {
|
|
12121
|
+
const baseOrder = nativeActions.length - 1;
|
|
12122
|
+
exp.native.order = baseOrder;
|
|
12123
|
+
for (const n of exp.extraNodes) {
|
|
12124
|
+
if (n.cat === "transition") n.order = baseOrder + 1;
|
|
12125
|
+
nativeActions.push(n);
|
|
12126
|
+
}
|
|
12127
|
+
}
|
|
11791
12128
|
} else if (exp.kind === "manual") {
|
|
11792
12129
|
manual.push({ index: i, logicalType: exp.logicalType, reason: exp.reason });
|
|
11793
12130
|
} else {
|
|
11794
12131
|
needsContent.push({ index: i, logicalType: exp.logicalType, reason: exp.reason });
|
|
11795
12132
|
}
|
|
12133
|
+
if (a.type === "find_opportunity") branchEmitted = true;
|
|
11796
12134
|
});
|
|
11797
12135
|
const splitInto = Math.max(1, Math.ceil(nativeActions.length / MAX_ACTIONS_PER_WORKFLOW));
|
|
11798
12136
|
let nativeTrigger;
|
|
11799
12137
|
let triggerManual;
|
|
11800
12138
|
if (workflow.trigger) {
|
|
11801
|
-
const t = expandTrigger(workflow.trigger, idx);
|
|
11802
|
-
if (t.kind === "native")
|
|
11803
|
-
|
|
12139
|
+
const t = expandTrigger(workflow.trigger, idx, idMap);
|
|
12140
|
+
if (t.kind === "native") {
|
|
12141
|
+
nativeTrigger = t.trigger;
|
|
12142
|
+
t.pendingRefs.forEach((r) => pendingRefs.add(r));
|
|
12143
|
+
} else {
|
|
12144
|
+
triggerManual = t.reason;
|
|
12145
|
+
}
|
|
11804
12146
|
}
|
|
11805
12147
|
return {
|
|
11806
12148
|
ref: workflow.ref,
|
|
@@ -12078,11 +12420,13 @@ async function executeBackbone(plan, deps, opts = {}) {
|
|
|
12078
12420
|
const idMap = {};
|
|
12079
12421
|
const built = [];
|
|
12080
12422
|
const manual = [];
|
|
12423
|
+
const published = [];
|
|
12081
12424
|
const halt = (atRef, type, reason) => ({
|
|
12082
12425
|
ok: false,
|
|
12083
12426
|
idMap,
|
|
12084
12427
|
built,
|
|
12085
12428
|
manual,
|
|
12429
|
+
published,
|
|
12086
12430
|
halted: { atRef, type, reason },
|
|
12087
12431
|
deferred: deferredSections(plan)
|
|
12088
12432
|
});
|
|
@@ -12375,35 +12719,56 @@ async function executeBackbone(plan, deps, opts = {}) {
|
|
|
12375
12719
|
}
|
|
12376
12720
|
const wfIndex = buildRefIndex(plan);
|
|
12377
12721
|
const wfIdMap = new Map(Object.entries(idMap));
|
|
12378
|
-
|
|
12722
|
+
const planWorkflows = plan.workflows ?? [];
|
|
12723
|
+
const createdSlots = [];
|
|
12724
|
+
const savedWfIds = /* @__PURE__ */ new Set();
|
|
12725
|
+
const rollbackUnsavedShells = async () => {
|
|
12726
|
+
const failedDeletes = [];
|
|
12727
|
+
for (const slot of createdSlots) {
|
|
12728
|
+
if (savedWfIds.has(slot.id)) continue;
|
|
12729
|
+
try {
|
|
12730
|
+
await deps.deleteWorkflow(slot.id);
|
|
12731
|
+
} catch {
|
|
12732
|
+
failedDeletes.push(slot.id);
|
|
12733
|
+
}
|
|
12734
|
+
delete idMap[slot.ref];
|
|
12735
|
+
wfIdMap.delete(slot.ref);
|
|
12736
|
+
}
|
|
12737
|
+
return failedDeletes;
|
|
12738
|
+
};
|
|
12739
|
+
const orphanSuffix = (failed) => failed.length ? ` WARNING: could not delete orphan shell workflow(s) ${failed.join(", ")} \u2014 delete them in GHL before re-running.` : "";
|
|
12740
|
+
const toSave = [];
|
|
12741
|
+
for (const wf of planWorkflows) {
|
|
12379
12742
|
let workflows;
|
|
12380
12743
|
try {
|
|
12381
12744
|
workflows = await deps.listWorkflows();
|
|
12382
12745
|
} catch (e) {
|
|
12383
|
-
|
|
12746
|
+
const f = await rollbackUnsavedShells();
|
|
12747
|
+
return halt(wf.ref, "workflow", `could not read existing workflows: ${msg(e)}.${orphanSuffix(f)}`);
|
|
12384
12748
|
}
|
|
12385
12749
|
const matches = workflows.filter((w) => norm2(w.name) === norm2(wf.name));
|
|
12386
12750
|
if (matches.length > 1) {
|
|
12387
|
-
|
|
12388
|
-
|
|
12389
|
-
const expansion = expandWorkflow(wf, wfIndex, wfIdMap, []);
|
|
12390
|
-
if (expansion.pendingRefs.length) {
|
|
12391
|
-
return halt(wf.ref, "workflow", `workflow "${wf.name}" references objects that weren't built/resolved: ${expansion.pendingRefs.join(", ")}. Cannot build it.`);
|
|
12751
|
+
const f = await rollbackUnsavedShells();
|
|
12752
|
+
return halt(wf.ref, "workflow", `${matches.length} existing workflows are named "${wf.name}" \u2014 ambiguous, cannot safely bind ${wf.ref}. Resolve the duplicate in GHL, then re-run.${orphanSuffix(f)}`);
|
|
12392
12753
|
}
|
|
12393
12754
|
if (matches.length === 1) {
|
|
12394
12755
|
const exId = matches[0].id;
|
|
12395
|
-
|
|
12396
|
-
|
|
12756
|
+
const planHasActions = expandWorkflow(wf, wfIndex, wfIdMap, []).nativeActions.length > 0;
|
|
12757
|
+
if (planHasActions) {
|
|
12758
|
+
let existingCount;
|
|
12397
12759
|
try {
|
|
12398
12760
|
existingCount = await deps.getWorkflowActionCount(exId);
|
|
12399
|
-
} catch {
|
|
12400
|
-
|
|
12761
|
+
} catch (e) {
|
|
12762
|
+
const f = await rollbackUnsavedShells();
|
|
12763
|
+
return halt(wf.ref, "workflow", `could not read the existing workflow named "${wf.name}" to tell whether it's an empty orphan (${msg(e)}) \u2014 refusing to bind it blindly (Blueprint never modifies an existing workflow, and binding an orphan would ship a stepless workflow). Re-run.${orphanSuffix(f)}`);
|
|
12401
12764
|
}
|
|
12402
12765
|
if (existingCount === 0) {
|
|
12403
|
-
|
|
12766
|
+
const f = await rollbackUnsavedShells();
|
|
12767
|
+
return halt(wf.ref, "workflow", `a workflow named "${wf.name}" already exists but has NO actions \u2014 likely an empty orphan from a failed prior run (or a name collision). Delete it in GHL, then re-run. (Blueprint never modifies an existing workflow.)${orphanSuffix(f)}`);
|
|
12404
12768
|
}
|
|
12405
12769
|
}
|
|
12406
12770
|
idMap[wf.ref] = exId;
|
|
12771
|
+
wfIdMap.set(wf.ref, exId);
|
|
12407
12772
|
built.push({ ref: wf.ref, type: "workflow", name: wf.name, status: "existing", realId: exId });
|
|
12408
12773
|
continue;
|
|
12409
12774
|
}
|
|
@@ -12411,9 +12776,24 @@ async function executeBackbone(plan, deps, opts = {}) {
|
|
|
12411
12776
|
try {
|
|
12412
12777
|
wfId = await deps.createWorkflow(wf.name);
|
|
12413
12778
|
} catch (e) {
|
|
12414
|
-
|
|
12779
|
+
const f = await rollbackUnsavedShells();
|
|
12780
|
+
return halt(wf.ref, "workflow", `create failed: ${msg(e)}.${orphanSuffix(f)}`);
|
|
12781
|
+
}
|
|
12782
|
+
if (!wfId) {
|
|
12783
|
+
const f = await rollbackUnsavedShells();
|
|
12784
|
+
return halt(wf.ref, "workflow", `workflow create returned no id.${orphanSuffix(f)}`);
|
|
12785
|
+
}
|
|
12786
|
+
createdSlots.push({ ref: wf.ref, id: wfId });
|
|
12787
|
+
idMap[wf.ref] = wfId;
|
|
12788
|
+
wfIdMap.set(wf.ref, wfId);
|
|
12789
|
+
toSave.push({ wf, id: wfId });
|
|
12790
|
+
}
|
|
12791
|
+
for (const { wf, id: wfId } of toSave) {
|
|
12792
|
+
const expansion = expandWorkflow(wf, wfIndex, wfIdMap, []);
|
|
12793
|
+
if (expansion.pendingRefs.length) {
|
|
12794
|
+
const f = await rollbackUnsavedShells();
|
|
12795
|
+
return halt(wf.ref, "workflow", `workflow "${wf.name}" references objects that weren't built/resolved: ${expansion.pendingRefs.join(", ")}. Cannot build it.${orphanSuffix(f)}`);
|
|
12415
12796
|
}
|
|
12416
|
-
if (!wfId) return halt(wf.ref, "workflow", "workflow create returned no id");
|
|
12417
12797
|
const triggers = expansion.nativeTrigger ? [expansion.nativeTrigger] : [];
|
|
12418
12798
|
try {
|
|
12419
12799
|
await deps.saveWorkflow(wfId, { actions: expansion.nativeActions, triggers, stopOnResponse: wf.stopOnResponse });
|
|
@@ -12422,25 +12802,24 @@ async function executeBackbone(plan, deps, opts = {}) {
|
|
|
12422
12802
|
if (count === 0) throw new Error("saved but no actions persisted (read-after-write)");
|
|
12423
12803
|
}
|
|
12424
12804
|
} catch (e) {
|
|
12425
|
-
|
|
12805
|
+
const f = await rollbackUnsavedShells();
|
|
12806
|
+
return halt(wf.ref, "workflow", `actions/trigger save failed; the partial workflow${createdSlots.length > 1 ? " (and any other unsaved shells)" : ""} was rolled back: ${msg(e)}.${orphanSuffix(f)}`);
|
|
12807
|
+
}
|
|
12808
|
+
savedWfIds.add(wfId);
|
|
12809
|
+
built.push({ ref: wf.ref, type: "workflow", name: wf.name, status: "created", realId: wfId });
|
|
12810
|
+
if (opts.publishWorkflows && !opts.gatedWorkflowRefs?.has(wf.ref)) {
|
|
12426
12811
|
try {
|
|
12427
|
-
await deps.
|
|
12428
|
-
|
|
12429
|
-
} catch {
|
|
12812
|
+
await deps.publishWorkflow(wfId);
|
|
12813
|
+
published.push(wf.ref);
|
|
12814
|
+
} catch (e) {
|
|
12815
|
+
manual.push({ ref: wf.ref, type: "workflow-publish", name: wf.name, reason: `[${wf.name}] built successfully but auto-publish failed (${msg(e)}) \u2014 publish it manually in GHL.` });
|
|
12430
12816
|
}
|
|
12431
|
-
return halt(
|
|
12432
|
-
wf.ref,
|
|
12433
|
-
"workflow",
|
|
12434
|
-
rolledBack ? `actions/trigger save failed; the partial workflow was rolled back (deleted): ${msg(e)}` : `actions/trigger save failed AND the rollback delete also failed \u2014 manually delete the orphan workflow (id ${wfId}) in GHL before re-running: ${msg(e)}`
|
|
12435
|
-
);
|
|
12436
12817
|
}
|
|
12437
|
-
idMap[wf.ref] = wfId;
|
|
12438
|
-
built.push({ ref: wf.ref, type: "workflow", name: wf.name, status: "created", realId: wfId });
|
|
12439
12818
|
if (expansion.triggerManual) manual.push({ ref: wf.ref, type: "workflow-trigger", name: wf.name, reason: `[${wf.name}] ${expansion.triggerManual}` });
|
|
12440
12819
|
for (const nc of expansion.needsContent) manual.push({ ref: wf.ref, type: "workflow-action", name: wf.name, reason: `[${wf.name}] ${nc.reason}` });
|
|
12441
12820
|
for (const m of expansion.manual) manual.push({ ref: wf.ref, type: "workflow-action", name: wf.name, reason: `[${wf.name}] ${m.reason}` });
|
|
12442
12821
|
}
|
|
12443
|
-
return { ok: true, idMap, built, manual, deferred: deferredSections(plan) };
|
|
12822
|
+
return { ok: true, idMap, built, manual, published, deferred: deferredSections(plan) };
|
|
12444
12823
|
}
|
|
12445
12824
|
async function buildSimple(objects, type, list, create, nameOf, pollForNew, idMap, built) {
|
|
12446
12825
|
if (objects.length === 0) return null;
|
|
@@ -12854,6 +13233,9 @@ ${text2.slice(0, 300)}`);
|
|
|
12854
13233
|
},
|
|
12855
13234
|
deleteWorkflow: async (workflowId) => {
|
|
12856
13235
|
await builderClient.deleteWorkflow(workflowId);
|
|
13236
|
+
},
|
|
13237
|
+
publishWorkflow: async (workflowId) => {
|
|
13238
|
+
await builderClient.publishWorkflow(workflowId);
|
|
12857
13239
|
}
|
|
12858
13240
|
};
|
|
12859
13241
|
}
|
|
@@ -12986,7 +13368,11 @@ function registerIntakeToBuildTools(server2, client, builderClient) {
|
|
|
12986
13368
|
}
|
|
12987
13369
|
}
|
|
12988
13370
|
const deps = makeExecuteDeps(client, builderClient, activeLocation);
|
|
12989
|
-
const
|
|
13371
|
+
const gatedWorkflowRefs = new Set(result.workflows.filter((w) => w.gatedBy.length > 0).map((w) => w.ref));
|
|
13372
|
+
const exec = await executeBackbone(typedPlan, deps, {
|
|
13373
|
+
publishWorkflows: publishWorkflows ?? false,
|
|
13374
|
+
gatedWorkflowRefs
|
|
13375
|
+
});
|
|
12990
13376
|
const externalWiring = buildExternalWiring(typedPlan, exec.idMap, activeLocation);
|
|
12991
13377
|
const execManualLines = exec.manual.map((m) => `[${m.type}] ${m.reason}`);
|
|
12992
13378
|
const manualLines = result.workflows.flatMap((w) => [
|
|
@@ -13005,12 +13391,14 @@ function registerIntakeToBuildTools(server2, client, builderClient) {
|
|
|
13005
13391
|
built: exec.built,
|
|
13006
13392
|
manual: exec.manual,
|
|
13007
13393
|
idMap: exec.idMap,
|
|
13394
|
+
published: exec.published,
|
|
13395
|
+
publishNote: exec.published.length ? `Auto-published ${exec.published.length} workflow(s) live (you opted in). Any gated workflow stays DRAFT until its handoff is met.` : exec.built.some((b) => b.type === "workflow" && b.status === "created") ? "Workflows were built as DRAFT (the safe default). Review them in GHL and publish, or re-run with publishWorkflows:true to auto-publish ungated ones." : "No workflows were built in this run (none in the plan, or all already existed and were left untouched).",
|
|
13008
13396
|
externalWiring,
|
|
13009
13397
|
externalWiringNote: externalWiring ? "For each target:\"external\" funnel: plug these into your self-hosted site form + lead bridge (templates/external-funnel/). custom formFields carry the VERIFIED GHL field id your form's `custom` object must send (never name-guess keys); add a triggerTag to start the speed-to-lead workflow; bookingUrl is the GHL calendar widget for the CTA. Anything in `unresolved` wasn't built yet \u2014 re-run after it is." : void 0,
|
|
13010
13398
|
deferred: exec.deferred,
|
|
13011
13399
|
deferredNote: 'execute builds the WHOLE plan live: CRM backbone (pipelines, custom fields, tags, custom values), calendars, forms, funnels (GHL-built = funnel + named steps), and workflows (DRAFT, with all steps incl. opportunity create/move). Remaining manual steps are surfaced per item: staff-requiring calendars in multi-user accounts, GHL funnel page content/design, EXTERNAL funnels (target:"external" \u2014 the user builds + hosts the site themselves and wires it back; not built here), workflow triggers other than contact_tag, and publishing the DRAFT workflows.',
|
|
13012
13400
|
nextManualSteps: [...execManualLines, ...manualLines, ...handoffLines],
|
|
13013
|
-
summary: exec.ok ? `Built ${exec.built.filter((b) => b.status === "created").length} new object(s), bound ${exec.built.filter((b) => b.status === "existing").length} existing.${exec.manual.length ? ` ${exec.manual.length} item(s) need a manual step (see nextManualSteps).` : ""}${exec.deferred.length ? " Deferred: " + exec.deferred.map((d) => `${d.count} ${d.section}`).join(", ") + " (manual)." : ""}` : `HALTED at ${exec.halted?.atRef} (${exec.halted?.reason}). ${exec.built.length} object(s) were created before the halt \u2014 see idMap to resume or clean up. NEVER-CLOBBER means a re-run will bind those, not duplicate them.`
|
|
13401
|
+
summary: exec.ok ? `Built ${exec.built.filter((b) => b.status === "created").length} new object(s), bound ${exec.built.filter((b) => b.status === "existing").length} existing.${exec.published.length ? ` Published ${exec.published.length} workflow(s) live.` : exec.built.some((b) => b.type === "workflow" && b.status === "created") ? " Workflows are DRAFT (opt in with publishWorkflows to auto-publish)." : ""}${exec.manual.length ? ` ${exec.manual.length} item(s) need a manual step (see nextManualSteps).` : ""}${exec.deferred.length ? " Deferred: " + exec.deferred.map((d) => `${d.count} ${d.section}`).join(", ") + " (manual)." : ""}` : `HALTED at ${exec.halted?.atRef} (${exec.halted?.reason}). ${exec.built.length} object(s) were created before the halt \u2014 see idMap to resume or clean up. NEVER-CLOBBER means a re-run will bind those, not duplicate them.`
|
|
13014
13402
|
});
|
|
13015
13403
|
}
|
|
13016
13404
|
const collisions = result.items.filter((i) => i.status === "existing");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elitedcs/ghl-mcp",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.48.0",
|
|
4
4
|
"mcpName": "io.github.drjerryrelth/ghl-command",
|
|
5
5
|
"description": "GoHighLevel MCP Server for Claude. 220 tools — full CRM, automation, marketing control, account-wide workflow audit, live funnel-capture verification, and the only programmatic GHL workflow builder, now multi-tenant across client accounts.",
|
|
6
6
|
"main": "dist/index.js",
|