@vellumai/assistant 0.11.9-dev.202609082015.1ff08e1 → 0.11.9-dev.202609082126.a4f0fef

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/openapi.yaml CHANGED
@@ -13753,6 +13753,13 @@ paths:
13753
13753
  anyOf:
13754
13754
  - type: string
13755
13755
  - type: "null"
13756
+ effectiveTimezone:
13757
+ anyOf:
13758
+ - type: string
13759
+ - type: "null"
13760
+ description:
13761
+ Timezone used for cron and active-hours evaluation after resolving heartbeat.timezone, then the user's
13762
+ configured or detected zone
13756
13763
  nextRunAt:
13757
13764
  anyOf:
13758
13765
  - type: number
@@ -13770,6 +13777,7 @@ paths:
13770
13777
  - activeHoursEnd
13771
13778
  - cronExpression
13772
13779
  - timezone
13780
+ - effectiveTimezone
13773
13781
  - nextRunAt
13774
13782
  - lastRunAt
13775
13783
  - success
@@ -13809,7 +13817,7 @@ paths:
13809
13817
  - type: string
13810
13818
  - type: "null"
13811
13819
  timezone:
13812
- description: Timezone for cron evaluation
13820
+ description: Timezone for cron and active-hours evaluation
13813
13821
  anyOf:
13814
13822
  - type: string
13815
13823
  - type: "null"
@@ -13841,6 +13849,13 @@ paths:
13841
13849
  anyOf:
13842
13850
  - type: string
13843
13851
  - type: "null"
13852
+ effectiveTimezone:
13853
+ anyOf:
13854
+ - type: string
13855
+ - type: "null"
13856
+ description:
13857
+ Timezone used for cron and active-hours evaluation after resolving heartbeat.timezone, then the user's
13858
+ configured or detected zone
13844
13859
  nextRunAt:
13845
13860
  anyOf:
13846
13861
  - type: number
@@ -13858,6 +13873,7 @@ paths:
13858
13873
  - activeHoursEnd
13859
13874
  - cronExpression
13860
13875
  - timezone
13876
+ - effectiveTimezone
13861
13877
  - nextRunAt
13862
13878
  - lastRunAt
13863
13879
  - success
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.11.9-dev.202609082015.1ff08e1",
3
+ "version": "0.11.9-dev.202609082126.a4f0fef",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -304,9 +304,11 @@ describe("formatModeSelectionFailure", () => {
304
304
  expect(formatted).toContain(
305
305
  "host browser proxy exists but is not connected",
306
306
  );
307
- expect(formatted).toContain("Remediation:");
308
- expect(formatted).toContain("extension is installed and enabled");
307
+ expect(formatted).toContain("Setup details:");
308
+ expect(formatted).toContain("Chrome extension");
309
309
  expect(formatted).toContain(CHROME_WEB_STORE_INSTALL_URL);
310
+ expect(formatted).toContain("www.vellum.ai/download");
311
+ expect(formatted).toContain("Offer those first");
310
312
  });
311
313
 
312
314
  test("renders cdp-inspect transport_error with remediation", () => {
@@ -330,8 +332,9 @@ describe("formatModeSelectionFailure", () => {
330
332
  expect(formatted).toContain('Browser mode "cdp-inspect" failed');
331
333
  expect(formatted).toContain("cdp-inspect: FAILED at send");
332
334
  expect(formatted).toContain("Discovery code: unreachable");
333
- expect(formatted).toContain("Remediation:");
335
+ expect(formatted).toContain("Setup details:");
334
336
  expect(formatted).toContain("--remote-debugging-port");
337
+ expect(formatted).toContain(CHROME_WEB_STORE_INSTALL_URL);
335
338
  });
336
339
 
337
340
  test("includes the Chrome Web Store install URL for host-bridge failures", () => {
@@ -353,8 +356,32 @@ describe("formatModeSelectionFailure", () => {
353
356
 
354
357
  const formatted = formatModeSelectionFailure("auto", error);
355
358
 
356
- expect(formatted).toContain("Remediation:");
359
+ expect(formatted).toContain("Setup details:");
360
+ expect(formatted).toContain(CHROME_WEB_STORE_INSTALL_URL);
361
+ expect(formatted).toContain("www.vellum.ai/download");
362
+ });
363
+
364
+ test("phone surfaces do not invent an in-app browser panel", () => {
365
+ const diagnostics: AttemptDiagnostic[] = [
366
+ {
367
+ candidateKind: "extension",
368
+ inclusionReason: "pinned mode: extension",
369
+ stage: "candidate_selection",
370
+ errorCode: "transport_error",
371
+ errorMessage: "not connected",
372
+ },
373
+ ];
374
+ const formatted = formatModeSelectionFailure(
375
+ "extension",
376
+ new CdpError("transport_error", "not connected", {
377
+ attemptDiagnostics: diagnostics,
378
+ }),
379
+ { transportInterface: "ios", clientOs: "ios" },
380
+ );
381
+ expect(formatted).toContain("no in-app browser");
382
+ expect(formatted).toContain("Do not describe a browser panel");
357
383
  expect(formatted).toContain(CHROME_WEB_STORE_INSTALL_URL);
384
+ expect(formatted).toContain("www.vellum.ai/download");
358
385
  });
359
386
  });
360
387
 
@@ -372,6 +399,7 @@ describe("browser_mode wiring through tool execution", () => {
372
399
  );
373
400
  expect(result.isError).toBe(true);
374
401
  expect(result.content).toContain('Invalid browser_mode "bogus"');
402
+ expect(result.content).not.toContain(CHROME_WEB_STORE_INSTALL_URL);
375
403
  expect(cdpSendCalls).toEqual([]);
376
404
  });
377
405
 
@@ -394,6 +422,28 @@ describe("browser_mode wiring through tool execution", () => {
394
422
 
395
423
  // ── Pinned extension with no proxy returns remediation error ───
396
424
 
425
+ test("navigation timeout offers the desktop app and Chrome extension", async () => {
426
+ cdpSendHandler = (method, params) => {
427
+ if (method === "Page.navigate") {
428
+ throw new Error(
429
+ "Navigation to https://intranet.example.com timed out after 15000ms",
430
+ );
431
+ }
432
+ return defaultCdpHandler(method, params);
433
+ };
434
+
435
+ const result = await executeBrowserNavigate(
436
+ { url: "https://intranet.example.com" },
437
+ ctx,
438
+ );
439
+ expect(result.isError).toBe(true);
440
+ expect(result.content).toContain("Navigation failed");
441
+ expect(result.content).toContain("timed out");
442
+ expect(result.content).toContain(CHROME_WEB_STORE_INSTALL_URL);
443
+ expect(result.content).toContain("www.vellum.ai/download");
444
+ expect(result.content).toContain("Offer those first");
445
+ });
446
+
397
447
  test("pinned extension with no proxy returns remediation-rich error in navigate", async () => {
398
448
  factoryThrowError = new CdpError(
399
449
  "transport_error",
@@ -421,8 +471,8 @@ describe("browser_mode wiring through tool execution", () => {
421
471
  expect(result.content).toContain(
422
472
  "extension: FAILED at candidate_selection",
423
473
  );
424
- expect(result.content).toContain("Remediation:");
425
- expect(result.content).toContain("extension is installed and enabled");
474
+ expect(result.content).toContain("Setup details:");
475
+ expect(result.content).toContain("Chrome extension");
426
476
  expect(result.content).toContain(CHROME_WEB_STORE_INSTALL_URL);
427
477
  // Factory should not have been called for CDP
428
478
  expect(cdpSendCalls).toEqual([]);
@@ -451,7 +501,7 @@ describe("browser_mode wiring through tool execution", () => {
451
501
  );
452
502
  expect(result.isError).toBe(true);
453
503
  expect(result.content).toContain('Browser mode "extension" failed');
454
- expect(result.content).toContain("Remediation:");
504
+ expect(result.content).toContain("Setup details:");
455
505
  });
456
506
 
457
507
  // ── cdp-debugger alias normalization ──────────────────────────
@@ -482,7 +532,7 @@ describe("browser_mode wiring through tool execution", () => {
482
532
  // The error should reference the canonical cdp-inspect name
483
533
  expect(result.content).toContain('Browser mode "cdp-inspect" failed');
484
534
  expect(result.content).toContain("Discovery code: unreachable");
485
- expect(result.content).toContain("Remediation:");
535
+ expect(result.content).toContain("Setup details:");
486
536
  expect(result.content).toContain("--remote-debugging-port");
487
537
  });
488
538
 
@@ -544,7 +594,7 @@ describe("browser_mode wiring through tool execution", () => {
544
594
  "Reason: HTTP discovery failed (unreachable)",
545
595
  );
546
596
  expect(result.content).toContain("Discovery code: unreachable");
547
- expect(result.content).toContain("Remediation:");
597
+ expect(result.content).toContain("Setup details:");
548
598
  });
549
599
 
550
600
  test("click tool returns remediation error on pinned mode failure", async () => {
@@ -576,7 +626,7 @@ describe("browser_mode wiring through tool execution", () => {
576
626
  expect(result.content).toContain(
577
627
  "extension: FAILED at candidate_selection",
578
628
  );
579
- expect(result.content).toContain("Remediation:");
629
+ expect(result.content).toContain("Setup details:");
580
630
  });
581
631
 
582
632
  // ── Transport-classified host-browser errors produce failover diagnostics ──
@@ -609,8 +659,8 @@ describe("browser_mode wiring through tool execution", () => {
609
659
  expect(result.content).toContain('Browser mode "extension" failed');
610
660
  expect(result.content).toContain("extension: FAILED at send");
611
661
  expect(result.content).toContain("Reason: Host browser not reachable");
612
- expect(result.content).toContain("Remediation:");
613
- expect(result.content).toContain("extension is installed and enabled");
662
+ expect(result.content).toContain("Setup details:");
663
+ expect(result.content).toContain("Chrome extension");
614
664
  expect(result.content).toContain(CHROME_WEB_STORE_INSTALL_URL);
615
665
  });
616
666
 
@@ -634,7 +684,7 @@ describe("browser_mode wiring through tool execution", () => {
634
684
  expect(result.isError).toBe(true);
635
685
  expect(result.content).toContain('Browser mode "extension" failed');
636
686
  expect(result.content).toContain("extension: FAILED at send");
637
- expect(result.content).toContain("Remediation:");
687
+ expect(result.content).toContain("Setup details:");
638
688
  });
639
689
 
640
690
  // ── Per-conversation sticky backend kind ─────────────────────────
@@ -1,5 +1,6 @@
1
1
  import { beforeEach, describe, expect, mock, test } from "bun:test";
2
2
 
3
+ import { hasSetConstructs } from "../schedule/recurrence-engine.js";
3
4
  import { setConfig } from "./helpers/set-config.js";
4
5
 
5
6
  // Default the warm-pool gate to OPEN — these tests probe disk-pressure
@@ -30,6 +31,7 @@ mock.module("../heartbeat/heartbeat-run-store.js", () => ({
30
31
  }));
31
32
 
32
33
  mock.module("../schedule/recurrence-engine.js", () => ({
34
+ hasSetConstructs,
33
35
  computeNextRunAt: () => Date.now() + 60_000,
34
36
  }));
35
37
 
@@ -2,6 +2,8 @@ import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
4
4
 
5
+ import { hasSetConstructs } from "../schedule/recurrence-engine.js";
6
+
5
7
  const testWorkspaceDir = process.env.VELLUM_WORKSPACE_DIR!;
6
8
 
7
9
  // Default the warm-pool gate to OPEN for existing tests — they predate
@@ -80,11 +82,14 @@ function setHeartbeatConfig(overrides: Partial<HeartbeatSeed> = {}): void {
80
82
  //
81
83
  // HeartbeatService imports computeNextRunAt for cron scheduling.
82
84
  // Tests mutate `mockComputeNextRunAt` to control the next cron occurrence.
85
+ // `hasSetConstructs` is re-exported because `schedule-timezone` imports it
86
+ // from the same module and must keep working under this mock.
83
87
  let mockComputeNextRunAtResult: number | null = null;
84
88
  let mockComputeNextRunAtError: Error | null = null;
85
89
  let computeNextRunAtCallCount = 0;
86
90
 
87
91
  mock.module("../schedule/recurrence-engine.js", () => ({
92
+ hasSetConstructs,
88
93
  computeNextRunAt: (_spec: {
89
94
  syntax: string;
90
95
  expression: string;
@@ -303,8 +308,11 @@ export function setTestProcessMessage(
303
308
  }
304
309
 
305
310
  // Import after mocks are set up
306
- const { HeartbeatService, isShallowProfile } =
307
- await import("../heartbeat/heartbeat-service.js");
311
+ const {
312
+ HeartbeatService,
313
+ isShallowProfile,
314
+ shouldRescheduleHeartbeatForTimezoneChange,
315
+ } = await import("../heartbeat/heartbeat-service.js");
308
316
 
309
317
  // Read the bundled template files so we can write them into the test workspace
310
318
  const templatesDir = join(import.meta.dirname!, "..", "prompts", "templates");
@@ -410,17 +418,20 @@ describe("HeartbeatService", () => {
410
418
 
411
419
  heartbeatConfig = defaultHeartbeatSeed();
412
420
  setHeartbeatConfig();
421
+ setConfig("ui", {});
413
422
  });
414
423
 
415
424
  function createService(overrides?: {
416
425
  processMessage?: (...args: unknown[]) => Promise<{ messageId: string }>;
417
426
  getCurrentHour?: () => number;
427
+ now?: () => Date;
418
428
  }) {
419
429
  if (overrides?.processMessage) {
420
430
  setTestProcessMessage(overrides.processMessage);
421
431
  }
422
432
  return new HeartbeatService({
423
433
  getCurrentHour: overrides?.getCurrentHour,
434
+ now: overrides?.now,
424
435
  });
425
436
  }
426
437
 
@@ -546,21 +557,52 @@ describe("HeartbeatService", () => {
546
557
  expect(processMessageCalls).toHaveLength(1);
547
558
  });
548
559
 
549
- test("active hours handles overnight window", async () => {
550
- setHeartbeatConfig({ activeHoursStart: 22, activeHoursEnd: 6 });
560
+ test("active hours handles overnight window", async () => {
561
+ setHeartbeatConfig({ activeHoursStart: 22, activeHoursEnd: 6 });
551
562
 
552
- // 23:00 should be within the window
553
- const service = createService({ getCurrentHour: () => 23 });
554
- await service.runOnce();
555
- expect(processMessageCalls).toHaveLength(1);
563
+ // 23:00 should be within the window
564
+ const service = createService({ getCurrentHour: () => 23 });
565
+ await service.runOnce();
566
+ expect(processMessageCalls).toHaveLength(1);
556
567
 
557
- // 10:00 should be outside the window
558
- processMessageCalls.length = 0;
559
- createdConversations.length = 0;
560
- const service2 = createService({ getCurrentHour: () => 10 });
561
- await service2.runOnce();
562
- expect(processMessageCalls).toHaveLength(0);
563
- });
568
+ // 10:00 should be outside the window
569
+ processMessageCalls.length = 0;
570
+ createdConversations.length = 0;
571
+ const service2 = createService({ getCurrentHour: () => 10 });
572
+ await service2.runOnce();
573
+ expect(processMessageCalls).toHaveLength(0);
574
+ });
575
+
576
+ test("active hours guard uses user timezone in interval mode", async () => {
577
+ setConfig("ui", { detectedTimezone: "America/Los_Angeles" });
578
+ setHeartbeatConfig({
579
+ cronExpression: null,
580
+ timezone: null,
581
+ activeHoursStart: 8,
582
+ activeHoursEnd: 22,
583
+ });
584
+
585
+ // 23:00 UTC is 16:00 Pacific (inside 8-22) and 23:00 host-local
586
+ // (outside 8-22). If the guard still used the host clock this would skip.
587
+ const inside = createService({
588
+ now: () => new Date("2026-09-08T23:00:00Z"),
589
+ getCurrentHour: () => 23,
590
+ });
591
+ expect(await inside.runOnce()).toBe(true);
592
+ expect(processMessageCalls).toHaveLength(1);
593
+
594
+ processMessageCalls.length = 0;
595
+ createdConversations.length = 0;
596
+
597
+ // 06:00 UTC is 23:00 Pacific, outside 8-22, even though 6 is inside
598
+ // a naive UTC 8-22 window.
599
+ const outside = createService({
600
+ now: () => new Date("2026-09-09T06:00:00Z"),
601
+ getCurrentHour: () => 6,
602
+ });
603
+ expect(await outside.runOnce()).toBe(false);
604
+ expect(processMessageCalls).toHaveLength(0);
605
+ });
564
606
 
565
607
  test("overlap prevention works", async () => {
566
608
  let resolveFirst: () => void;
@@ -1307,26 +1349,25 @@ describe("HeartbeatService", () => {
1307
1349
  test("active hours guard uses cron timezone when configured", async () => {
1308
1350
  setHeartbeatConfig({
1309
1351
  cronExpression: "0 9,12,15,18 * * *",
1310
- timezone: "UTC",
1311
- activeHoursStart: 9,
1312
- activeHoursEnd: 17,
1352
+ timezone: "America/Los_Angeles",
1353
+ activeHoursStart: 8,
1354
+ activeHoursEnd: 22,
1313
1355
  });
1314
1356
  mockComputeNextRunAtResult = Date.now() + 3_600_000;
1315
1357
 
1316
- const service = createService();
1358
+ // 23:00 UTC = 16:00 PDT, inside 8-22 Pacific.
1359
+ const service = createService({
1360
+ now: () => new Date("2026-09-08T23:00:00Z"),
1361
+ getCurrentHour: () => 23,
1362
+ });
1317
1363
  service.start();
1318
-
1319
- // In cron mode with timezone, the hour is computed via Intl.DateTimeFormat
1320
- // rather than getCurrentHour(). The test verifies the code path runs without
1321
- // error — the actual hour depends on the system clock and UTC conversion.
1322
- // We just verify it doesn't throw and returns a boolean result.
1323
1364
  const result = await service.runOnce();
1324
- // Result depends on current UTC hour vs active window — either outcome is valid
1325
- expect(typeof result).toBe("boolean");
1365
+ expect(result).toBe(true);
1366
+ expect(processMessageCalls).toHaveLength(1);
1326
1367
  service.stop();
1327
1368
  });
1328
1369
 
1329
- test("active hours guard falls back to getCurrentHour when cron mode has no timezone", async () => {
1370
+ test("active hours guard falls back to getCurrentHour when no timezone is known", async () => {
1330
1371
  setHeartbeatConfig({
1331
1372
  cronExpression: "0 9,12,15,18 * * *",
1332
1373
  timezone: null,
@@ -1705,3 +1746,53 @@ describe("HeartbeatService", () => {
1705
1746
  });
1706
1747
  });
1707
1748
  });
1749
+
1750
+ describe("shouldRescheduleHeartbeatForTimezoneChange", () => {
1751
+ test("reschedules cron heartbeats when the fallback user timezone changes", () => {
1752
+ expect(
1753
+ shouldRescheduleHeartbeatForTimezoneChange(
1754
+ {
1755
+ ui: { userTimezone: "America/New_York" },
1756
+ heartbeat: { cronExpression: "0 9 * * *" },
1757
+ },
1758
+ {
1759
+ ui: { userTimezone: "America/Los_Angeles" },
1760
+ heartbeat: { cronExpression: "0 9 * * *" },
1761
+ },
1762
+ ),
1763
+ ).toBe(true);
1764
+ });
1765
+
1766
+ test("ignores ui timezone changes in interval mode", () => {
1767
+ expect(
1768
+ shouldRescheduleHeartbeatForTimezoneChange(
1769
+ { ui: { userTimezone: "America/New_York" }, heartbeat: {} },
1770
+ {
1771
+ ui: { userTimezone: "America/Los_Angeles" },
1772
+ heartbeat: {},
1773
+ },
1774
+ ),
1775
+ ).toBe(false);
1776
+ });
1777
+
1778
+ test("ignores ui timezone changes when heartbeat.timezone is set", () => {
1779
+ expect(
1780
+ shouldRescheduleHeartbeatForTimezoneChange(
1781
+ {
1782
+ ui: { userTimezone: "America/New_York" },
1783
+ heartbeat: {
1784
+ cronExpression: "0 9 * * *",
1785
+ timezone: "UTC",
1786
+ },
1787
+ },
1788
+ {
1789
+ ui: { userTimezone: "America/Los_Angeles" },
1790
+ heartbeat: {
1791
+ cronExpression: "0 9 * * *",
1792
+ timezone: "UTC",
1793
+ },
1794
+ },
1795
+ ),
1796
+ ).toBe(false);
1797
+ });
1798
+ });
@@ -1,6 +1,7 @@
1
1
  import { beforeEach, describe, expect, mock, test } from "bun:test";
2
2
 
3
3
  import { setConfig } from "../__tests__/helpers/set-config.js";
4
+ import { hasSetConstructs } from "../schedule/recurrence-engine.js";
4
5
 
5
6
  type MockHeartbeatConfig = {
6
7
  enabled: boolean;
@@ -44,6 +45,7 @@ mock.module("../heartbeat/heartbeat-service.js", () => ({
44
45
  }));
45
46
 
46
47
  mock.module("../schedule/recurrence-engine.js", () => ({
48
+ hasSetConstructs,
47
49
  computeNextRunAt: () => computedCronNextRunAt,
48
50
  }));
49
51
 
@@ -80,6 +82,7 @@ describe("computeNextBackgroundWakeIntent", () => {
80
82
  heartbeatConsecutiveRunCapReached = false;
81
83
  schedules = [];
82
84
  computedCronNextRunAt = NOW + 3_600_000;
85
+ setConfig("ui", {});
83
86
  });
84
87
 
85
88
  test("returns heartbeat-only interval wake intent", () => {
@@ -109,6 +112,16 @@ describe("computeNextBackgroundWakeIntent", () => {
109
112
  expect(intent!.reason).toBe("heartbeat");
110
113
  });
111
114
 
115
+ test("resolves heartbeat timezone from the user zone when unset", () => {
116
+ setConfig("ui", { detectedTimezone: "America/Los_Angeles" });
117
+ const intent = computeWakeIntent(NOW);
118
+ expect(intent).not.toBeNull();
119
+ expect(intent!.sourcePayload.heartbeat!.timezone).toBe(
120
+ "America/Los_Angeles",
121
+ );
122
+ setConfig("ui", {});
123
+ });
124
+
112
125
  test("returns heartbeat-only cron wake intent", () => {
113
126
  heartbeatConfig.cronExpression = "0 9 * * *";
114
127
  heartbeatConfig.timezone = "America/New_York";
@@ -4,6 +4,7 @@ import { getConfig } from "../config/loader.js";
4
4
  import { HeartbeatService } from "../heartbeat/heartbeat-service.js";
5
5
  import { computeNextRunAt } from "../schedule/recurrence-engine.js";
6
6
  import { listSchedules, type ScheduleJob } from "../schedule/schedule-store.js";
7
+ import { resolveScheduleTimezone } from "../schedule/schedule-timezone.js";
7
8
 
8
9
  export type BackgroundWakeIntentReason = "heartbeat" | "schedule" | "mixed";
9
10
 
@@ -110,6 +111,7 @@ function getHeartbeatWakeSource(now: number): HeartbeatWakeSource | null {
110
111
  const serviceNextRunAt = service?.nextRunAt ?? null;
111
112
  let nextRunAt = serviceNextRunAt;
112
113
  const mode = config.cronExpression != null ? "cron" : "interval";
114
+ const timezone = resolveScheduleTimezone(config.timezone);
113
115
 
114
116
  if (nextRunAt == null) {
115
117
  if (config.cronExpression != null) {
@@ -118,7 +120,7 @@ function getHeartbeatWakeSource(now: number): HeartbeatWakeSource | null {
118
120
  {
119
121
  syntax: "cron",
120
122
  expression: config.cronExpression,
121
- timezone: config.timezone,
123
+ timezone,
122
124
  },
123
125
  now,
124
126
  );
@@ -139,7 +141,7 @@ function getHeartbeatWakeSource(now: number): HeartbeatWakeSource | null {
139
141
  mode,
140
142
  intervalMs: config.intervalMs,
141
143
  cronExpression: config.cronExpression,
142
- timezone: config.timezone,
144
+ timezone,
143
145
  activeHoursStart: config.activeHoursStart,
144
146
  activeHoursEnd: config.activeHoursEnd,
145
147
  maxConsecutiveRuns: config.maxConsecutiveRuns,
@@ -25,7 +25,7 @@ export const HeartbeatConfigSchema = z
25
25
  .nullable()
26
26
  .default(null)
27
27
  .describe(
28
- "Timezone for cron expression evaluation, e.g. 'America/New_York'. Ignored when cronExpression is null.",
28
+ "Timezone for cron expression and active-hours evaluation, e.g. 'America/New_York'. When null, uses the user's configured or detected timezone, then the host clock.",
29
29
  ),
30
30
  activeHoursStart: z
31
31
  .number({ error: "heartbeat.activeHoursStart must be a number" })
@@ -35,7 +35,7 @@ export const HeartbeatConfigSchema = z
35
35
  .nullable()
36
36
  .default(8)
37
37
  .describe(
38
- "Hour of the day (0-23) when heartbeat checks begin, or null to disable active hours restriction",
38
+ "Hour of the day (0-23) in the heartbeat timezone when heartbeat checks begin, or null to disable active hours restriction",
39
39
  ),
40
40
  activeHoursEnd: z
41
41
  .number({ error: "heartbeat.activeHoursEnd must be a number" })
@@ -45,7 +45,7 @@ export const HeartbeatConfigSchema = z
45
45
  .nullable()
46
46
  .default(22)
47
47
  .describe(
48
- "Hour of the day (0-23) when heartbeat checks stop, or null to disable active hours restriction",
48
+ "Hour of the day (0-23) in the heartbeat timezone when heartbeat checks stop, or null to disable active hours restriction",
49
49
  ),
50
50
  maxConsecutiveRuns: z
51
51
  .number({ error: "heartbeat.maxConsecutiveRuns must be a number" })