@dench.com/cli 3.8.0-staging.389 → 3.8.2-staging.391

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/campaign.ts CHANGED
@@ -31,14 +31,18 @@ import {
31
31
  import {
32
32
  checkCampaignSteps,
33
33
  hasHtmlAndTextBody,
34
+ hasWorkflowShape,
35
+ isBranchStep,
34
36
  planStepEdits,
35
37
  STEP_TYPES,
36
38
  StepEditError,
37
39
  staleHtmlSteps,
38
40
  staleHtmlWarning,
39
41
  stepToInput,
42
+ WORKFLOW_END,
40
43
  } from "./lib/campaign-steps";
41
44
  import {
45
+ branchExitLabel,
42
46
  personAddress,
43
47
  type TimelineResult,
44
48
  timelineSteps,
@@ -109,6 +113,7 @@ export type CampaignRow = {
109
113
  sendSchedules?: SendSchedule[];
110
114
  dailySendCap?: number;
111
115
  stepCount?: number;
116
+ stepModel?: string;
112
117
  totalEnrolled?: number;
113
118
  activeCount?: number;
114
119
  pausedCount?: number;
@@ -147,6 +152,8 @@ type CurrentRun = {
147
152
  type?: string;
148
153
  stepIndex?: number;
149
154
  waitUntil?: number;
155
+ /** A wait for acceptance: when it stops waiting either way. */
156
+ timeoutAt?: number;
150
157
  retryAt?: number;
151
158
  lastError?: string;
152
159
  hold?: LinkedinHold;
@@ -189,6 +196,8 @@ type CampaignAnalytics = {
189
196
  clicked?: number;
190
197
  linkedinSent?: number;
191
198
  replyRate?: number;
199
+ /** Workflow branch steps: how many people took each exit. */
200
+ branchStats?: Array<{ stepId: string; yes: number; no: number }>;
192
201
  truncated?: boolean;
193
202
  };
194
203
 
@@ -211,6 +220,10 @@ type CampaignLive = {
211
220
  personName: string | null;
212
221
  at: number;
213
222
  hold: LinkedinHold | null;
223
+ /** Waiting for them to accept: when the wait ends either way. */
224
+ acceptanceDeadline?: number;
225
+ /** An If / else: what it checks, e.g. "Has an email address". */
226
+ label?: string;
214
227
  } | null;
215
228
  paused: {
216
229
  reason: string;
@@ -617,28 +630,72 @@ export { STEP_TYPES };
617
630
 
618
631
  export type StepsFile = { steps: JsonRecord[]; warnings: string[] };
619
632
 
633
+ export type StepModel = "linear" | "workflow";
634
+
635
+ /**
636
+ * The model to save steps as: the file's `stepModel`, else a workflow when
637
+ * the campaign is one or the steps have keys, paths or logic steps.
638
+ * Undefined leaves a linear campaign linear.
639
+ */
640
+ function stepModelFor(
641
+ steps: readonly unknown[],
642
+ fileModel: unknown,
643
+ campaignModel: StepModel | undefined,
644
+ source: string,
645
+ ): StepModel | undefined {
646
+ if (
647
+ fileModel !== undefined &&
648
+ fileModel !== "linear" &&
649
+ fileModel !== "workflow"
650
+ ) {
651
+ throw new CampaignCliError(
652
+ `${source}: "stepModel" must be "workflow" or "linear".`,
653
+ );
654
+ }
655
+ if (campaignModel === "workflow" && fileModel === "linear") {
656
+ throw new CampaignCliError(
657
+ `This campaign is a workflow, so it can't go back to a plain list of steps. Leave "stepModel" out, or start a new campaign with dench campaign draft and set the steps there (dench campaign duplicate keeps the workflow).`,
658
+ );
659
+ }
660
+ const shaped = steps.findIndex(hasWorkflowShape);
661
+ if (fileModel === "linear" && shaped >= 0) {
662
+ throw new CampaignCliError(
663
+ `Step ${shaped + 1}: condition, wait_for_connection, "key", "next", "yesNext" and "noNext" only work in a workflow. Set "stepModel": "workflow" (or leave it out) to save these steps as one.`,
664
+ );
665
+ }
666
+ if (fileModel === "workflow" || campaignModel === "workflow" || shaped >= 0)
667
+ return "workflow";
668
+ return fileModel === "linear" ? "linear" : undefined;
669
+ }
670
+
620
671
  /**
621
- * Parse a steps file: a JSON array of step objects (or `{ "steps": [...] }`).
672
+ * Parse a steps file: a JSON array of step objects (or `{ "steps": [...] }`,
673
+ * optionally with `"stepModel"`).
622
674
  * Checks the shape the server validates so a typo is caught here with a
623
675
  * step number instead of a Convex validator message.
624
676
  */
625
677
  export function readStepsJson(
626
678
  raw: string,
627
679
  source = "The steps file",
628
- options: { allowSingleBraces?: boolean } = {},
629
- ): StepsFile {
680
+ options: { allowSingleBraces?: boolean; stepModel?: StepModel } = {},
681
+ ): StepsFile & { stepModel?: StepModel } {
630
682
  let parsed: unknown;
631
683
  try {
632
684
  parsed = JSON.parse(raw);
633
685
  } catch {
634
686
  throw new CampaignCliError(`${source} is not valid JSON.`);
635
687
  }
688
+ const wrapped =
689
+ parsed &&
690
+ typeof parsed === "object" &&
691
+ !Array.isArray(parsed) &&
692
+ Array.isArray((parsed as JsonRecord).steps)
693
+ ? (parsed as JsonRecord)
694
+ : null;
636
695
  const steps = Array.isArray(parsed)
637
696
  ? parsed
638
- : parsed &&
639
- typeof parsed === "object" &&
640
- Array.isArray((parsed as JsonRecord).steps)
641
- ? ((parsed as JsonRecord).steps as unknown[])
697
+ : wrapped
698
+ ? (wrapped.steps as unknown[])
642
699
  : null;
643
700
  if (!steps) {
644
701
  throw new CampaignCliError(
@@ -648,22 +705,33 @@ export function readStepsJson(
648
705
  if (steps.length === 0) {
649
706
  throw new CampaignCliError(`${source} has no steps.`);
650
707
  }
708
+ const stepModel = stepModelFor(
709
+ steps,
710
+ wrapped?.stepModel,
711
+ options.stepModel,
712
+ source,
713
+ );
651
714
  const check = checkCampaignSteps(steps, {
652
715
  allowSingleBraces: options.allowSingleBraces,
653
716
  singleBraceOverride: "--allow-single-braces",
717
+ workflow: stepModel === "workflow",
654
718
  });
655
719
  if (check.errors.length > 0) {
656
720
  throw new CampaignCliError(
657
721
  check.errors.map((issue) => issue.message).join("\n"),
658
722
  );
659
723
  }
660
- return { steps: steps as JsonRecord[], warnings: check.warnings };
724
+ return {
725
+ steps: steps as JsonRecord[],
726
+ warnings: check.warnings,
727
+ ...(stepModel ? { stepModel } : {}),
728
+ };
661
729
  }
662
730
 
663
731
  export function parseStepsJson(
664
732
  raw: string,
665
733
  source = "The steps file",
666
- options: { allowSingleBraces?: boolean } = {},
734
+ options: { allowSingleBraces?: boolean; stepModel?: StepModel } = {},
667
735
  ): JsonRecord[] {
668
736
  return readStepsJson(raw, source, options).steps;
669
737
  }
@@ -989,16 +1057,84 @@ const STEP_LABELS: Record<string, string> = {
989
1057
  linkedin_visit: "Visit profile",
990
1058
  linkedin_like_post: "React to a post",
991
1059
  linkedin_inmail: "LinkedIn InMail",
1060
+ condition: "If / else",
1061
+ wait_for_connection: "Wait for them to accept",
992
1062
  };
993
1063
 
994
- function stepLabel(step: { type: string; waitHours?: unknown }): string {
1064
+ type LabeledStep = { type: string; waitHours?: unknown; label?: unknown };
1065
+
1066
+ /** A step's name; If / else steps use the server's label of what they check. */
1067
+ function stepLabel(step: LabeledStep): string {
995
1068
  if (step.type === "wait") {
996
1069
  const hours = typeof step.waitHours === "number" ? step.waitHours : 0;
997
1070
  return `Wait ${fmtHours(hours)}`;
998
1071
  }
1072
+ if (isBranchStep(step.type) && typeof step.label === "string" && step.label)
1073
+ return step.label;
999
1074
  return STEP_LABELS[step.type] ?? step.type;
1000
1075
  }
1001
1076
 
1077
+ type WorkflowStep = LabeledStep & {
1078
+ _id?: unknown;
1079
+ key?: unknown;
1080
+ next?: unknown;
1081
+ yesNext?: unknown;
1082
+ noNext?: unknown;
1083
+ connectionTimeoutHours?: unknown;
1084
+ };
1085
+
1086
+ /**
1087
+ * One line per workflow step, numbered as stored: where each exit leads,
1088
+ * shown only where it is not simply the following step.
1089
+ */
1090
+ export function workflowStepLines(steps: readonly WorkflowStep[]): string[] {
1091
+ const keyIndex = new Map<string, number>();
1092
+ steps.forEach((step, index) => {
1093
+ if (typeof step.key === "string" && !keyIndex.has(step.key))
1094
+ keyIndex.set(step.key, index);
1095
+ });
1096
+ const target = (to: unknown): string => {
1097
+ if (to === undefined || to === WORKFLOW_END) return "End";
1098
+ const index = typeof to === "string" ? keyIndex.get(to) : undefined;
1099
+ return index === undefined
1100
+ ? `"${String(to)}" (not a step)`
1101
+ : `${stepLabel(steps[index])} (step ${index + 1})`;
1102
+ };
1103
+ return steps.map((step, index) => {
1104
+ let name = stepLabel(step);
1105
+ if (
1106
+ step.type === "wait_for_connection" &&
1107
+ typeof step.connectionTimeoutHours === "number"
1108
+ )
1109
+ name += ` (up to ${fmtHours(step.connectionTimeoutHours)})`;
1110
+ if (isBranchStep(step.type))
1111
+ return `${index + 1}. ${name} → ${branchExitLabel(step.type, "yes")}: ${target(step.yesNext)} · ${branchExitLabel(step.type, "no")}: ${target(step.noNext)}`;
1112
+ const next = step.next;
1113
+ const plain =
1114
+ next === undefined ||
1115
+ (next === WORKFLOW_END
1116
+ ? index === steps.length - 1
1117
+ : keyIndex.get(String(next)) === index + 1);
1118
+ return plain
1119
+ ? `${index + 1}. ${name}`
1120
+ : `${index + 1}. ${name} → ${target(next)}`;
1121
+ });
1122
+ }
1123
+
1124
+ function describeSteps(
1125
+ steps: readonly WorkflowStep[],
1126
+ workflow: boolean,
1127
+ ): string[] {
1128
+ if (!workflow)
1129
+ return [
1130
+ `Steps (${steps.length}): ${steps.map((step) => stepLabel(step)).join(" → ")}`,
1131
+ ];
1132
+ return [
1133
+ `Steps (${steps.length}, a workflow with paths):`,
1134
+ ...workflowStepLines(steps).map((line) => ` ${line}`),
1135
+ ];
1136
+ }
1137
+
1002
1138
  const HOLD_TEXT: Record<string, string> = {
1003
1139
  checking: "checking whether they are already connected",
1004
1140
  spacing: "spacing out sends so they look human",
@@ -1046,6 +1182,7 @@ const NEXT_VERBS: Record<string, string> = {
1046
1182
  withdraw: "Withdraw the pending invitation for",
1047
1183
  email: "Send an email to",
1048
1184
  wait: "Wait for",
1185
+ check: "Run an If / else check for",
1049
1186
  };
1050
1187
 
1051
1188
  const TOUCH_LABELS: Record<string, string> = {
@@ -1056,6 +1193,8 @@ const TOUCH_LABELS: Record<string, string> = {
1056
1193
  linkedin_like_post: "post reaction sent",
1057
1194
  linkedin_inmail: "InMail sent",
1058
1195
  wait: "wait finished",
1196
+ condition: "If / else checked",
1197
+ wait_for_connection: "acceptance wait finished",
1059
1198
  };
1060
1199
 
1061
1200
  const RUN_STATUS_TEXT: Record<string, string> = {
@@ -1100,10 +1239,31 @@ function describePerson(
1100
1239
  const hold = describeHold(run?.hold);
1101
1240
  const bits: string[] = [enrollment.status];
1102
1241
  if (run?.status && enrollment.status === "active") {
1103
- const runText = RUN_STATUS_TEXT[run.status] ?? run.status;
1104
- const stepText = run.type ? (STEP_LABELS[run.type] ?? run.type) : "step";
1105
- bits.push(`${runText}: ${stepText.toLowerCase()}`);
1106
- if (run.status === "waiting" && run.waitUntil) {
1242
+ // An If / else or acceptance check reads the profile; it sends nothing.
1243
+ const runText =
1244
+ isBranchStep(run.type) &&
1245
+ (run.status === "queued" || run.status === "running")
1246
+ ? "checking"
1247
+ : (RUN_STATUS_TEXT[run.status] ?? run.status);
1248
+ const stepDoc =
1249
+ typeof run.stepIndex === "number"
1250
+ ? detail.steps.find((step) => step.order === run.stepIndex)
1251
+ : undefined;
1252
+ const stepText = run.type
1253
+ ? isBranchStep(run.type) && stepDoc?.type === run.type
1254
+ ? stepLabel(stepDoc)
1255
+ : (STEP_LABELS[run.type] ?? run.type)
1256
+ : "step";
1257
+ bits.push(
1258
+ `${runText}: ${isBranchStep(run.type) ? stepText : stepText.toLowerCase()}`,
1259
+ );
1260
+ if (
1261
+ run.type === "wait_for_connection" &&
1262
+ run.status === "waiting" &&
1263
+ run.timeoutAt
1264
+ ) {
1265
+ bits.push(`stops waiting ${fmtWhenRelative(run.timeoutAt)}`);
1266
+ } else if (run.status === "waiting" && run.waitUntil) {
1107
1267
  bits.push(`until ${fmtWhenRelative(run.waitUntil)}`);
1108
1268
  } else if (run.status === "retry" && run.retryAt) {
1109
1269
  bits.push(`retry ${fmtRelative(run.retryAt)}`);
@@ -1130,6 +1290,9 @@ function describePerson(
1130
1290
  status: run.status,
1131
1291
  type: run.type,
1132
1292
  waitUntil: run.waitUntil,
1293
+ ...(run.timeoutAt !== undefined
1294
+ ? { timeoutAt: run.timeoutAt }
1295
+ : {}),
1133
1296
  retryAt: run.retryAt,
1134
1297
  lastError: run.lastError,
1135
1298
  }
@@ -1360,11 +1523,12 @@ async function runGet(ctx: CampaignCliContext): Promise<void> {
1360
1523
  const steps = [...detail.steps].sort(
1361
1524
  (a, b) => (a.order ?? 0) - (b.order ?? 0),
1362
1525
  );
1363
- lines.push(
1364
- steps.length === 0
1365
- ? "Steps: none yet — dench campaign steps set <campaign> --file steps.json"
1366
- : `Steps (${steps.length}): ${steps.map((step) => stepLabel(step)).join(" → ")}`,
1367
- );
1526
+ const workflow = sequence.stepModel === "workflow";
1527
+ if (steps.length === 0)
1528
+ lines.push(
1529
+ "Steps: none yet — dench campaign steps set <campaign> --file steps.json",
1530
+ );
1531
+ else lines.push(...describeSteps(steps, workflow));
1368
1532
  if (sequence.lastError) lines.push(`Last error: ${sequence.lastError}`);
1369
1533
 
1370
1534
  lines.push("", "Funnel");
@@ -1396,6 +1560,7 @@ async function runGet(ctx: CampaignCliContext): Promise<void> {
1396
1560
  lines.push(
1397
1561
  ` Done finished ${a.finished ?? 0} · stopped ${a.stopped ?? 0} · failed ${a.failed ?? 0}`,
1398
1562
  );
1563
+ lines.push(...branchStatLines(steps, a.branchStats));
1399
1564
  if (a.truncated) {
1400
1565
  lines.push(
1401
1566
  " (counts are approximate — this campaign has more history than one read can hold)",
@@ -1410,13 +1575,8 @@ async function runGet(ctx: CampaignCliContext): Promise<void> {
1410
1575
  } else if (sequence.status === "active") {
1411
1576
  lines.push(" Now: nothing is being sent right now");
1412
1577
  }
1413
- if (live?.next) {
1414
- const who = live.next.personName ? ` ${live.next.personName}` : "";
1415
- const hold = describeHold(live.next.hold);
1416
- lines.push(
1417
- ` Next: ${NEXT_VERBS[live.next.kind] ?? live.next.kind}${who} at ${fmtWhenRelative(live.next.at)}${hold ? ` — on hold: ${hold}` : ""}`,
1418
- );
1419
- } else if (sequence.status === "active") {
1578
+ if (live?.next) lines.push(` Next: ${describeNext(live.next)}`);
1579
+ else if (sequence.status === "active") {
1420
1580
  lines.push(
1421
1581
  " Next: nothing scheduled — everyone is finished, waiting on a reply, or stopped",
1422
1582
  );
@@ -1476,6 +1636,34 @@ async function runGet(ctx: CampaignCliContext): Promise<void> {
1476
1636
  );
1477
1637
  }
1478
1638
 
1639
+ /** How many people took each exit of the workflow's branch steps. */
1640
+ function branchStatLines(
1641
+ steps: readonly WorkflowStep[],
1642
+ stats: CampaignAnalytics["branchStats"],
1643
+ ): string[] {
1644
+ const lines: string[] = [];
1645
+ for (const stat of stats ?? []) {
1646
+ const index = steps.findIndex((step) => String(step._id) === stat.stepId);
1647
+ if (index < 0) continue;
1648
+ const step = steps[index];
1649
+ lines.push(
1650
+ `${lines.length ? " " : " Paths "} step ${index + 1} ${stepLabel(step)} → ${branchExitLabel(step.type, "yes")} ${stat.yes} · ${branchExitLabel(step.type, "no")} ${stat.no}`,
1651
+ );
1652
+ }
1653
+ return lines;
1654
+ }
1655
+
1656
+ export function describeNext(next: NonNullable<CampaignLive["next"]>): string {
1657
+ const hold = describeHold(next.hold);
1658
+ const held = hold ? ` — on hold: ${hold}` : "";
1659
+ if (typeof next.acceptanceDeadline === "number")
1660
+ return `Wait for ${next.personName ?? "them"} to accept the connection request — next check ${fmtWhenRelative(next.at)}, stops waiting ${fmtWhenRelative(next.acceptanceDeadline)}${held}`;
1661
+ const who = next.personName ? ` ${next.personName}` : "";
1662
+ if (next.kind === "check" && next.label)
1663
+ return `Check "${next.label}" for${who || " the next person"} at ${fmtWhenRelative(next.at)}${held}`;
1664
+ return `${NEXT_VERBS[next.kind] ?? next.kind}${who} at ${fmtWhenRelative(next.at)}${held}`;
1665
+ }
1666
+
1479
1667
  async function runSet(ctx: CampaignCliContext): Promise<void> {
1480
1668
  const name = valueFlag(ctx.args, "--name")?.trim();
1481
1669
  if (name === "") {
@@ -1571,7 +1759,12 @@ async function runSteps(ctx: CampaignCliContext): Promise<void> {
1571
1759
  }
1572
1760
  : stepToInput(step),
1573
1761
  );
1574
- console.log(JSON.stringify(steps, null, 2));
1762
+ // A workflow says so, so the file saves back as one even without paths.
1763
+ const printed =
1764
+ detail.sequence.stepModel === "workflow"
1765
+ ? { stepModel: "workflow", steps }
1766
+ : steps;
1767
+ console.log(JSON.stringify(printed, null, 2));
1575
1768
  return;
1576
1769
  }
1577
1770
  if (!file) {
@@ -1588,7 +1781,10 @@ async function runSteps(ctx: CampaignCliContext): Promise<void> {
1588
1781
  const raw = await readFile(file, "utf8").catch(() => {
1589
1782
  throw new CampaignCliError(`Cannot read ${file}`);
1590
1783
  });
1591
- const { steps, warnings } = readStepsJson(raw, file, { allowSingleBraces });
1784
+ const { steps, warnings, stepModel } = readStepsJson(raw, file, {
1785
+ allowSingleBraces,
1786
+ stepModel: row.stepModel === "workflow" ? "workflow" : undefined,
1787
+ });
1592
1788
  if (steps.some(hasHtmlAndTextBody)) {
1593
1789
  const stored = (await loadDetail(ctx, String(row._id))).steps;
1594
1790
  for (const index of staleHtmlSteps(steps, stored)) {
@@ -1599,13 +1795,36 @@ async function runSteps(ctx: CampaignCliContext): Promise<void> {
1599
1795
  const result = (await callMutation(ctx, `${OUTREACH}:replaceSteps`, {
1600
1796
  sequenceId: row._id,
1601
1797
  steps,
1798
+ ...(stepModel ? { stepModel } : {}),
1602
1799
  })) as { ok: true; stepCount: number };
1603
- out(ctx, { ...result, sequenceId: row._id, steps, warnings }, [
1604
- `Saved ${result.stepCount} steps on "${row.name}": ${steps
1605
- .map((step) => stepLabel(step as { type: string; waitHours?: unknown }))
1606
- .join(" → ")}`,
1607
- ...warnings.map((warning) => `Warning: ${warning}`),
1608
- ]);
1800
+ const workflow = stepModel === "workflow";
1801
+ // A workflow is stored in path order, which can differ from the file's.
1802
+ const saved = workflow
1803
+ ? sortedSteps(await loadDetail(ctx, String(row._id)))
1804
+ : (steps as WorkflowStep[]);
1805
+ out(
1806
+ ctx,
1807
+ {
1808
+ ...result,
1809
+ sequenceId: row._id,
1810
+ ...(stepModel ? { stepModel } : {}),
1811
+ steps,
1812
+ warnings,
1813
+ },
1814
+ [
1815
+ workflow
1816
+ ? `Saved ${result.stepCount} steps on "${row.name}" as a workflow:`
1817
+ : `Saved ${result.stepCount} steps on "${row.name}": ${saved
1818
+ .map((step) => stepLabel(step))
1819
+ .join(" → ")}`,
1820
+ ...(workflow ? workflowStepLines(saved).map((line) => ` ${line}`) : []),
1821
+ ...warnings.map((warning) => `Warning: ${warning}`),
1822
+ ],
1823
+ );
1824
+ }
1825
+
1826
+ function sortedSteps(detail: CampaignDetail): CampaignDetail["steps"] {
1827
+ return [...detail.steps].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
1609
1828
  }
1610
1829
 
1611
1830
  function stepsInFile(parsed: unknown): unknown[] | null {
@@ -1646,6 +1865,7 @@ async function runStepsEdit(
1646
1865
  );
1647
1866
  }
1648
1867
  const detail = await loadDetail(ctx, String(row._id));
1868
+ const workflow = detail.sequence.stepModel === "workflow";
1649
1869
  const status = detail.sequence.status ?? row.status;
1650
1870
  const draft = status === "draft";
1651
1871
  let plan: ReturnType<typeof planStepEdits>;
@@ -1655,6 +1875,7 @@ async function runStepsEdit(
1655
1875
  singleBraceOverride: "--allow-single-braces",
1656
1876
  campaign: String(row._id),
1657
1877
  draft,
1878
+ ...(workflow ? { workflow } : {}),
1658
1879
  });
1659
1880
  } catch (error) {
1660
1881
  if (error instanceof StepEditError)
@@ -1683,6 +1904,9 @@ async function runStepsEdit(
1683
1904
  await callMutation(ctx, `${OUTREACH}:replaceSteps`, {
1684
1905
  sequenceId: row._id,
1685
1906
  steps: plan.steps,
1907
+ ...(workflow || plan.steps.some(hasWorkflowShape)
1908
+ ? { stepModel: "workflow" }
1909
+ : {}),
1686
1910
  });
1687
1911
  } else {
1688
1912
  await callMutation(ctx, `${OUTREACH}:updateSteps`, {
@@ -1704,7 +1928,7 @@ async function runStepsEdit(
1704
1928
  `Updated ${plan.changes.length} of ${plan.steps.length} steps on "${row.name}"${draft ? "" : " for sends from now on; messages already sent are unchanged"}:`,
1705
1929
  ...plan.changes.map(
1706
1930
  (change) =>
1707
- ` Step ${change.step} (${stepLabel(plan.steps[change.step - 1] as { type: string; waitHours?: unknown })}): ${change.fields.join(", ")}`,
1931
+ ` Step ${change.step} (${stepLabel(plan.steps[change.step - 1] as LabeledStep)}): ${change.fields.join(", ")}`,
1708
1932
  ),
1709
1933
  ...warningLines,
1710
1934
  ],
@@ -2035,6 +2259,13 @@ const STEP_BLOCKER_REASONS: Record<string, RegExp> = {
2035
2259
  email_sender_disconnected: /sender must be reconnected/i,
2036
2260
  linkedin_sender_disconnected: /sender must be reconnected/i,
2037
2261
  reply_without_prior_email: /replies to a previous email/i,
2262
+ workflow_path_invalid:
2263
+ /isn't a step in this campaign|goes back to|makes a loop|has nothing on its|can't also have a single next step|only If \/ else and Wait (?:until they|for them to) accept steps can branch|nobody will reach it|share the id|marks where a path stops|only work in a workflow campaign/i,
2264
+ wait_for_connection_without_invite:
2265
+ /without a LinkedIn connection request before it/i,
2266
+ opened_check_without_email: /reaches it without an email/i,
2267
+ open_tracking_unavailable: /opens aren't tracked/i,
2268
+ condition_field_missing: /which your leads don't have/i,
2038
2269
  };
2039
2270
 
2040
2271
  function blockerReasons(
@@ -2042,7 +2273,11 @@ function blockerReasons(
2042
2273
  reasons: readonly string[],
2043
2274
  ): string[] {
2044
2275
  if (typeof group.stepIndex !== "number") return [];
2045
- const step = new RegExp(`\\bstep ${group.stepIndex + 1}\\b`, "i");
2276
+ // "step 3", or one of a list: "Steps 1 and 3 share the id …".
2277
+ const step = new RegExp(
2278
+ `\\bsteps? (?:\\d+(?:, | and ))*${group.stepIndex + 1}\\b`,
2279
+ "i",
2280
+ );
2046
2281
  const forStep = reasons.filter((reason) => step.test(reason));
2047
2282
  const own = STEP_BLOCKER_REASONS[group.code];
2048
2283
  if (own) return forStep.filter((reason) => own.test(reason));
@@ -2060,6 +2295,14 @@ const STEPS_FIX = (id: string) => [
2060
2295
  ];
2061
2296
 
2062
2297
  const BLOCKER_FIXES: Record<string, (id: string) => string[]> = {
2298
+ workflow_path_invalid: STEPS_FIX,
2299
+ wait_for_connection_without_invite: STEPS_FIX,
2300
+ opened_check_without_email: STEPS_FIX,
2301
+ open_tracking_unavailable: STEPS_FIX,
2302
+ condition_field_missing: (id) => [
2303
+ "dench crm fields list lead",
2304
+ ...STEPS_FIX(id),
2305
+ ],
2063
2306
  no_steps: STEPS_FIX,
2064
2307
  too_many_steps: STEPS_FIX,
2065
2308
  step_invalid: STEPS_FIX,
@@ -2522,18 +2765,24 @@ async function runRemove(ctx: CampaignCliContext): Promise<void> {
2522
2765
  const PERSON_SET_USAGE =
2523
2766
  'dench campaign person set <campaign> --person <enrollmentId|name> [--email ada@new.com] [--linkedin-url https://linkedin.com/in/ada] [--field "Job Title=CTO" ...]';
2524
2767
 
2768
+ /** A field reference may be pasted directly from either merge-token syntax. */
2769
+ function unwrappedFieldReference(reference: string): string {
2770
+ const value = reference.trim();
2771
+ if (
2772
+ (value.startsWith("{{") && value.endsWith("}}")) ||
2773
+ (value.startsWith("[[") && value.endsWith("]]"))
2774
+ ) {
2775
+ return value.slice(2, -2).trim();
2776
+ }
2777
+ return value;
2778
+ }
2779
+
2525
2780
  export function parseFieldFlags(
2526
2781
  values: readonly string[],
2527
2782
  ): Array<{ key: string; value: string }> {
2528
2783
  return values.map((raw) => {
2529
2784
  const at = raw.indexOf("=");
2530
- const key =
2531
- at === -1
2532
- ? ""
2533
- : raw
2534
- .slice(0, at)
2535
- .trim()
2536
- .replace(/^\{\{\s*|\s*\}\}$/g, "");
2785
+ const key = at === -1 ? "" : unwrappedFieldReference(raw.slice(0, at));
2537
2786
  const value = at === -1 ? "" : raw.slice(at + 1).trim();
2538
2787
  if (!key || !value) {
2539
2788
  throw new CampaignCliError(
@@ -2676,8 +2925,12 @@ async function runPersonShow(ctx: CampaignCliContext): Promise<void> {
2676
2925
  const doc = stepDocs.get(step.stepId);
2677
2926
  const label = stepLabel(doc ?? { type: step.type });
2678
2927
  const when = step.at === null ? "" : ` · ${fmtRelative(step.at)}`;
2928
+ const until =
2929
+ step.acceptanceDeadline !== undefined
2930
+ ? ` · stops waiting ${fmtWhenRelative(step.acceptanceDeadline)}`
2931
+ : "";
2679
2932
  lines.push(
2680
- ` Step ${step.step} · ${label} · ${[step.outcome, ...step.detail].join(" · ")}${when}`,
2933
+ ` Step ${step.step} · ${label} · ${[step.outcome, ...step.detail].join(" · ")}${when}${until}`,
2681
2934
  );
2682
2935
  if (step.subject) lines.push(` Subject: ${step.subject}`);
2683
2936
  if (step.text) {
@@ -2828,7 +3081,7 @@ function resolveLineKey(
2828
3081
  ): string {
2829
3082
  const variables = template?.variables ?? [];
2830
3083
  if (!variables.length) throw noLines(row);
2831
- const wanted = normalizedLineKey(reference.replace(/^\{\{\s*|\s*\}\}$/g, ""));
3084
+ const wanted = normalizedLineKey(unwrappedFieldReference(reference));
2832
3085
  const match = variables.find(
2833
3086
  (variable) =>
2834
3087
  normalizedLineKey(variable.key) === wanted ||
@@ -3322,7 +3575,7 @@ async function runPreview(ctx: CampaignCliContext): Promise<void> {
3322
3575
  );
3323
3576
  const sendable = steps
3324
3577
  .map((step, index) => ({ step, number: index + 1 }))
3325
- .filter(({ step }) => step.type !== "wait");
3578
+ .filter(({ step }) => step.type !== "wait" && !isBranchStep(step.type));
3326
3579
  if (!sendable.length) {
3327
3580
  throw new CampaignCliError(
3328
3581
  `"${row.name}" has no email or LinkedIn steps to preview yet. Add them: dench campaign steps set ${row._id} --file steps.json`,
@@ -3338,8 +3591,9 @@ async function runPreview(ctx: CampaignCliContext): Promise<void> {
3338
3591
  }
3339
3592
  chosen = sendable.filter((entry) => entry.number === number);
3340
3593
  if (!chosen.length) {
3594
+ const kind = steps[number - 1]?.type;
3341
3595
  throw new CampaignCliError(
3342
- `Step ${number} is a wait, so it sends nothing. Steps with a message: ${sendable.map((entry) => entry.number).join(", ")}.`,
3596
+ `Step ${number} is ${kind === "condition" ? "an If / else" : kind === "wait_for_connection" ? "a Wait for them to accept" : "a wait"}, so it sends nothing. Steps with a message: ${sendable.map((entry) => entry.number).join(", ")}.`,
3343
3597
  );
3344
3598
  }
3345
3599
  }
@@ -3364,8 +3618,13 @@ async function runPreview(ctx: CampaignCliContext): Promise<void> {
3364
3618
  })) as MessagePreview,
3365
3619
  })),
3366
3620
  );
3621
+ const firstPerson = personRef
3622
+ ? ""
3623
+ : " (the first person; pick someone with --person)";
3367
3624
  const lines = [
3368
- `What ${name} gets from "${row.name}"${personRef ? "" : " (the first person; pick someone with --person)"}:`,
3625
+ detail.sequence.stepModel === "workflow"
3626
+ ? `The messages on every path of "${row.name}", for ${name}${firstPerson}. They get only the ones on the path they take:`
3627
+ : `What ${name} gets from "${row.name}"${firstPerson}:`,
3369
3628
  ];
3370
3629
  for (const { step, number, preview } of rendered)
3371
3630
  lines.push("", ...previewLines(number, step, preview));
@@ -3959,7 +4218,7 @@ export function campaignHelp(subcommand?: string): void {
3959
4218
  );
3960
4219
  }
3961
4220
 
3962
- function campaignHelpText(): string {
4221
+ export function campaignHelpText(): string {
3963
4222
  return `Usage: dench campaign <subcommand> [--json]
3964
4223
 
3965
4224
  Outreach campaigns send email and LinkedIn steps to a list of people on one
@@ -4002,12 +4261,16 @@ Create and configure:
4002
4261
  dench campaign duplicate <campaign> [--name "Founder outreach 2"] # new draft: same steps, schedule, senders and lines, no people
4003
4262
  Over ${MAX_CAMPAIGN_PEOPLE} people: duplicate, then add the next page of the search (--page-token) to the copy.
4004
4263
  dench campaign steps get <campaign> [--with-ids] # prints steps as JSON you can edit; --with-ids adds stepId and updatedAt
4264
+ A workflow prints {"stepModel":"workflow","steps":[...]} with each step's key and paths; steps set takes it back as is.
4005
4265
  dench campaign steps set <campaign> --file steps.json [--allow-single-braces] # replaces every step; drafts only
4006
4266
  dench campaign steps edit <campaign> --file steps.json [--allow-single-braces] # changes text and waits in place, also after launch
4007
4267
  Keeps the number, order and type of every step. Each step in the file is its full new content: leaving out a
4008
- subject, body, note, onlyIf or emailReplyToPrevious clears it (senders and LinkedIn wait settings are kept).
4009
- Steps match by order, or by stepId from steps get --with-ids to edit only some; a step saved since you read it
4010
- is refused, not overwritten. Messages already sent, or already being prepared, keep their text.
4268
+ subject, body, note, onlyIf or emailReplyToPrevious clears it (senders, LinkedIn wait settings, workflow
4269
+ paths and If / else settings are kept). Steps match by order (a workflow's by key, in any order), or by
4270
+ stepId from steps get --with-ids to edit only some; a step saved since you read it is refused, not
4271
+ overwritten. Messages already sent, or already being prepared, keep their text. After launch a workflow's
4272
+ paths (key, next, yesNext, noNext) and what an If / else checks (check, conditionField, conditionOperator)
4273
+ stay fixed; conditionValue can change.
4011
4274
 
4012
4275
  Fill it:
4013
4276
  dench campaign draft [--name "New campaign"] [--request-id <8-64 char stable id>] [--timezone <tz>] \\
@@ -4110,11 +4373,29 @@ Send window spec (--window, repeatable, all in --timezone):
4110
4373
  of that day in the campaign's timezone; add a time and offset to be exact.
4111
4374
 
4112
4375
  steps.json: a JSON array of steps, in order. Types: email, wait, linkedin_visit,
4113
- linkedin_connection, linkedin_message, linkedin_like_post, linkedin_inmail.
4376
+ linkedin_connection, linkedin_message, linkedin_like_post, linkedin_inmail,
4377
+ condition (If / else) and wait_for_connection (Wait for them to accept).
4114
4378
  See references/CAMPAIGNS.md for every field.
4379
+ Workflows (branching paths): give steps a "key" and say where each goes. A
4380
+ condition or wait_for_connection step needs "yesNext" and "noNext" (Yes / No, or
4381
+ Accepted / Not accepted); any other step may have "next" (default: the step after
4382
+ it). Each points at another step's "key", or "end" to stop there. condition needs
4383
+ "check": linkedin_connected, has_email, opened_last_email or field_value (with
4384
+ "conditionField", "conditionOperator" is|is_not|contains|is_empty|is_not_empty and,
4385
+ except for the empty checks, "conditionValue"). wait_for_connection waits
4386
+ connectionTimeoutHours = 24-2160 (1-90 days, default 720) and needs a
4387
+ linkedin_connection before it on every path. Steps with keys, paths or these
4388
+ types save the campaign as a workflow ("stepModel": "workflow"); a workflow
4389
+ stays one, and in a workflow a LinkedIn message does not wait for the accept on
4390
+ its own. steps set checks keys and paths before saving; the rest (every step
4391
+ reachable, paths only moving forward) is checked when it saves.
4115
4392
  Post reactions: likeReaction = like|celebrate|support|love|insightful|funny
4116
- (default like); likeMaxPostAgeDays = 1-365 (default 30); likeSkipNextWait = true
4117
- by default, skipping consecutive following waits when the reaction is skipped.
4393
+ (default like); likeMaxPostAgeDays = 1-365 (default 30); likeCount = 1-3 recent
4394
+ posts (default 1). likeSkipPreviousWait = true checks for eligible posts before
4395
+ the immediately preceding wait starts; no eligible posts skips that wait and
4396
+ the like while keeping following waits (omitted defaults false).
4397
+ likeSkipNextWait = true by default, skipping consecutive following waits when
4398
+ the reaction itself is skipped. Set false to keep those waits.
4118
4399
  InMail: linkedinMessageBody required (1-1900 chars), inmailSubject optional
4119
4400
  (up to 200 chars). Connected people skip InMail automatically; do not set onlyIf.
4120
4401
  Connection requests: withdrawAfterHours = 168-2160 (7-90 days); omitted or null