@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 +17 -1
- package/package.json +1 -1
- package/src/__tests__/headless-browser-mode.test.ts +63 -13
- package/src/__tests__/heartbeat-disk-pressure.test.ts +2 -0
- package/src/__tests__/heartbeat-service.test.ts +118 -27
- package/src/background-wake/next-wake.test.ts +13 -0
- package/src/background-wake/next-wake.ts +4 -2
- package/src/config/schemas/heartbeat.ts +3 -3
- package/src/heartbeat/heartbeat-service.ts +72 -13
- package/src/runtime/routes/__tests__/heartbeat-routes.test.ts +24 -0
- package/src/runtime/routes/conversation-query-routes.ts +2 -0
- package/src/runtime/routes/heartbeat-routes.ts +41 -53
- package/src/schedule/__tests__/schedule-timezone.test.ts +21 -3
- package/src/schedule/schedule-timezone.ts +25 -0
- package/src/tools/browser/__tests__/browser-status.test.ts +3 -0
- package/src/tools/browser/browser-execution.ts +117 -53
- package/src/tools/browser/browser-status-constants.ts +6 -6
- package/src/tools/capability-offer.test.ts +87 -0
- package/src/tools/capability-offer.ts +93 -0
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
|
@@ -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("
|
|
308
|
-
expect(formatted).toContain("extension
|
|
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("
|
|
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("
|
|
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("
|
|
425
|
-
expect(result.content).toContain("extension
|
|
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("
|
|
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("
|
|
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("
|
|
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("
|
|
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("
|
|
613
|
-
expect(result.content).toContain("extension
|
|
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("
|
|
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 {
|
|
307
|
-
|
|
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
|
-
|
|
550
|
-
|
|
560
|
+
test("active hours handles overnight window", async () => {
|
|
561
|
+
setHeartbeatConfig({ activeHoursStart: 22, activeHoursEnd: 6 });
|
|
551
562
|
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
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
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
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: "
|
|
1311
|
-
activeHoursStart:
|
|
1312
|
-
activeHoursEnd:
|
|
1352
|
+
timezone: "America/Los_Angeles",
|
|
1353
|
+
activeHoursStart: 8,
|
|
1354
|
+
activeHoursEnd: 22,
|
|
1313
1355
|
});
|
|
1314
1356
|
mockComputeNextRunAtResult = Date.now() + 3_600_000;
|
|
1315
1357
|
|
|
1316
|
-
|
|
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
|
-
|
|
1325
|
-
expect(
|
|
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
|
|
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
|
|
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
|
|
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'.
|
|
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" })
|