crewly 1.20.133 → 1.20.134
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/dist/backend/backend/src/constants.d.ts +82 -0
- package/dist/backend/backend/src/constants.d.ts.map +1 -1
- package/dist/backend/backend/src/constants.js +87 -0
- package/dist/backend/backend/src/constants.js.map +1 -1
- package/dist/backend/backend/src/controllers/bundle/bundle.controller.d.ts +44 -0
- package/dist/backend/backend/src/controllers/bundle/bundle.controller.d.ts.map +1 -0
- package/dist/backend/backend/src/controllers/bundle/bundle.controller.js +122 -0
- package/dist/backend/backend/src/controllers/bundle/bundle.controller.js.map +1 -0
- package/dist/backend/backend/src/controllers/bundle/bundle.routes.d.ts +25 -0
- package/dist/backend/backend/src/controllers/bundle/bundle.routes.d.ts.map +1 -0
- package/dist/backend/backend/src/controllers/bundle/bundle.routes.js +33 -0
- package/dist/backend/backend/src/controllers/bundle/bundle.routes.js.map +1 -0
- package/dist/backend/backend/src/index.d.ts.map +1 -1
- package/dist/backend/backend/src/index.js +14 -0
- package/dist/backend/backend/src/index.js.map +1 -1
- package/dist/backend/backend/src/routes/api.routes.d.ts.map +1 -1
- package/dist/backend/backend/src/routes/api.routes.js +4 -0
- package/dist/backend/backend/src/routes/api.routes.js.map +1 -1
- package/dist/backend/backend/src/services/bundle/bundle-apply.factory.d.ts +62 -0
- package/dist/backend/backend/src/services/bundle/bundle-apply.factory.d.ts.map +1 -0
- package/dist/backend/backend/src/services/bundle/bundle-apply.factory.js +173 -0
- package/dist/backend/backend/src/services/bundle/bundle-apply.factory.js.map +1 -0
- package/dist/backend/backend/src/services/bundle/bundle-apply.service.d.ts +359 -0
- package/dist/backend/backend/src/services/bundle/bundle-apply.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/bundle/bundle-apply.service.js +888 -0
- package/dist/backend/backend/src/services/bundle/bundle-apply.service.js.map +1 -0
- package/dist/backend/backend/src/services/bundle/bundle-catalog.d.ts +167 -0
- package/dist/backend/backend/src/services/bundle/bundle-catalog.d.ts.map +1 -0
- package/dist/backend/backend/src/services/bundle/bundle-catalog.js +248 -0
- package/dist/backend/backend/src/services/bundle/bundle-catalog.js.map +1 -0
- package/dist/backend/backend/src/services/bundle/bundle-connectors.d.ts +43 -0
- package/dist/backend/backend/src/services/bundle/bundle-connectors.d.ts.map +1 -0
- package/dist/backend/backend/src/services/bundle/bundle-connectors.js +57 -0
- package/dist/backend/backend/src/services/bundle/bundle-connectors.js.map +1 -0
- package/dist/backend/backend/src/services/bundle/bundle-deps.d.ts +37 -0
- package/dist/backend/backend/src/services/bundle/bundle-deps.d.ts.map +1 -0
- package/dist/backend/backend/src/services/bundle/bundle-deps.js +60 -0
- package/dist/backend/backend/src/services/bundle/bundle-deps.js.map +1 -0
- package/dist/backend/backend/src/services/bundle/bundle-manifest.d.ts +93 -0
- package/dist/backend/backend/src/services/bundle/bundle-manifest.d.ts.map +1 -0
- package/dist/backend/backend/src/services/bundle/bundle-manifest.js +532 -0
- package/dist/backend/backend/src/services/bundle/bundle-manifest.js.map +1 -0
- package/dist/backend/backend/src/services/bundle/bundle-placeholders.d.ts +100 -0
- package/dist/backend/backend/src/services/bundle/bundle-placeholders.d.ts.map +1 -0
- package/dist/backend/backend/src/services/bundle/bundle-placeholders.js +183 -0
- package/dist/backend/backend/src/services/bundle/bundle-placeholders.js.map +1 -0
- package/dist/backend/backend/src/services/bundle/bundle-runtime.d.ts +36 -0
- package/dist/backend/backend/src/services/bundle/bundle-runtime.d.ts.map +1 -0
- package/dist/backend/backend/src/services/bundle/bundle-runtime.js +38 -0
- package/dist/backend/backend/src/services/bundle/bundle-runtime.js.map +1 -0
- package/dist/backend/backend/src/services/bundle/bundle-skill-installer.d.ts +105 -0
- package/dist/backend/backend/src/services/bundle/bundle-skill-installer.d.ts.map +1 -0
- package/dist/backend/backend/src/services/bundle/bundle-skill-installer.js +144 -0
- package/dist/backend/backend/src/services/bundle/bundle-skill-installer.js.map +1 -0
- package/dist/backend/backend/src/services/bundle/bundle-state.store.d.ts +48 -0
- package/dist/backend/backend/src/services/bundle/bundle-state.store.d.ts.map +1 -0
- package/dist/backend/backend/src/services/bundle/bundle-state.store.js +90 -0
- package/dist/backend/backend/src/services/bundle/bundle-state.store.js.map +1 -0
- package/dist/backend/backend/src/services/bundle/bundle-team.builder.d.ts +81 -0
- package/dist/backend/backend/src/services/bundle/bundle-team.builder.d.ts.map +1 -0
- package/dist/backend/backend/src/services/bundle/bundle-team.builder.js +172 -0
- package/dist/backend/backend/src/services/bundle/bundle-team.builder.js.map +1 -0
- package/dist/backend/backend/src/services/bundle/bundle-time.d.ts +59 -0
- package/dist/backend/backend/src/services/bundle/bundle-time.d.ts.map +1 -0
- package/dist/backend/backend/src/services/bundle/bundle-time.js +98 -0
- package/dist/backend/backend/src/services/bundle/bundle-time.js.map +1 -0
- package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.js +5 -0
- package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.js.map +1 -1
- package/dist/backend/backend/src/services/core/storage.service.js +1 -1
- package/dist/backend/backend/src/services/core/storage.service.js.map +1 -1
- package/dist/backend/backend/src/services/core/teams-backup.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/core/teams-backup.service.js +4 -2
- package/dist/backend/backend/src/services/core/teams-backup.service.js.map +1 -1
- package/dist/backend/backend/src/services/onboarding/onboarding-checklist.factory.d.ts.map +1 -1
- package/dist/backend/backend/src/services/onboarding/onboarding-checklist.factory.js +2 -0
- package/dist/backend/backend/src/services/onboarding/onboarding-checklist.factory.js.map +1 -1
- package/dist/backend/backend/src/services/onboarding/onboarding-checklist.service.d.ts +14 -2
- package/dist/backend/backend/src/services/onboarding/onboarding-checklist.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/onboarding/onboarding-checklist.service.js +37 -1
- package/dist/backend/backend/src/services/onboarding/onboarding-checklist.service.js.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-team-channel.service.d.ts +21 -0
- package/dist/backend/backend/src/services/slack/slack-team-channel.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/slack/slack-team-channel.service.js +85 -0
- package/dist/backend/backend/src/services/slack/slack-team-channel.service.js.map +1 -1
- package/dist/backend/backend/src/services/template/template.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/template/template.service.js +5 -2
- package/dist/backend/backend/src/services/template/template.service.js.map +1 -1
- package/dist/backend/backend/src/services/workflow/cron-task.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/workflow/cron-task.service.js +4 -2
- package/dist/backend/backend/src/services/workflow/cron-task.service.js.map +1 -1
- package/dist/backend/backend/src/types/solution-bundle.types.d.ts +367 -0
- package/dist/backend/backend/src/types/solution-bundle.types.d.ts.map +1 -0
- package/dist/backend/backend/src/types/solution-bundle.types.js +17 -0
- package/dist/backend/backend/src/types/solution-bundle.types.js.map +1 -0
- package/dist/backend/backend/src/types/team-template.types.d.ts +8 -0
- package/dist/backend/backend/src/types/team-template.types.d.ts.map +1 -1
- package/dist/backend/backend/src/types/team-template.types.js.map +1 -1
- package/dist/backend/build-info.json +2 -2
- package/dist/cli/backend/src/constants.d.ts +82 -0
- package/dist/cli/backend/src/constants.d.ts.map +1 -1
- package/dist/cli/backend/src/constants.js +87 -0
- package/dist/cli/backend/src/constants.js.map +1 -1
- package/dist/cli/backend/src/services/bundle/bundle-apply.service.d.ts +359 -0
- package/dist/cli/backend/src/services/bundle/bundle-apply.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/bundle/bundle-apply.service.js +888 -0
- package/dist/cli/backend/src/services/bundle/bundle-apply.service.js.map +1 -0
- package/dist/cli/backend/src/services/bundle/bundle-catalog.d.ts +167 -0
- package/dist/cli/backend/src/services/bundle/bundle-catalog.d.ts.map +1 -0
- package/dist/cli/backend/src/services/bundle/bundle-catalog.js +248 -0
- package/dist/cli/backend/src/services/bundle/bundle-catalog.js.map +1 -0
- package/dist/cli/backend/src/services/bundle/bundle-deps.d.ts +37 -0
- package/dist/cli/backend/src/services/bundle/bundle-deps.d.ts.map +1 -0
- package/dist/cli/backend/src/services/bundle/bundle-deps.js +60 -0
- package/dist/cli/backend/src/services/bundle/bundle-deps.js.map +1 -0
- package/dist/cli/backend/src/services/bundle/bundle-manifest.d.ts +93 -0
- package/dist/cli/backend/src/services/bundle/bundle-manifest.d.ts.map +1 -0
- package/dist/cli/backend/src/services/bundle/bundle-manifest.js +532 -0
- package/dist/cli/backend/src/services/bundle/bundle-manifest.js.map +1 -0
- package/dist/cli/backend/src/services/bundle/bundle-placeholders.d.ts +100 -0
- package/dist/cli/backend/src/services/bundle/bundle-placeholders.d.ts.map +1 -0
- package/dist/cli/backend/src/services/bundle/bundle-placeholders.js +183 -0
- package/dist/cli/backend/src/services/bundle/bundle-placeholders.js.map +1 -0
- package/dist/cli/backend/src/services/bundle/bundle-runtime.d.ts +36 -0
- package/dist/cli/backend/src/services/bundle/bundle-runtime.d.ts.map +1 -0
- package/dist/cli/backend/src/services/bundle/bundle-runtime.js +38 -0
- package/dist/cli/backend/src/services/bundle/bundle-runtime.js.map +1 -0
- package/dist/cli/backend/src/services/bundle/bundle-skill-installer.d.ts +105 -0
- package/dist/cli/backend/src/services/bundle/bundle-skill-installer.d.ts.map +1 -0
- package/dist/cli/backend/src/services/bundle/bundle-skill-installer.js +144 -0
- package/dist/cli/backend/src/services/bundle/bundle-skill-installer.js.map +1 -0
- package/dist/cli/backend/src/services/bundle/bundle-state.store.d.ts +48 -0
- package/dist/cli/backend/src/services/bundle/bundle-state.store.d.ts.map +1 -0
- package/dist/cli/backend/src/services/bundle/bundle-state.store.js +90 -0
- package/dist/cli/backend/src/services/bundle/bundle-state.store.js.map +1 -0
- package/dist/cli/backend/src/services/bundle/bundle-team.builder.d.ts +81 -0
- package/dist/cli/backend/src/services/bundle/bundle-team.builder.d.ts.map +1 -0
- package/dist/cli/backend/src/services/bundle/bundle-team.builder.js +172 -0
- package/dist/cli/backend/src/services/bundle/bundle-team.builder.js.map +1 -0
- package/dist/cli/backend/src/services/bundle/bundle-time.d.ts +59 -0
- package/dist/cli/backend/src/services/bundle/bundle-time.d.ts.map +1 -0
- package/dist/cli/backend/src/services/bundle/bundle-time.js +98 -0
- package/dist/cli/backend/src/services/bundle/bundle-time.js.map +1 -0
- package/dist/cli/backend/src/services/core/storage.service.js +1 -1
- package/dist/cli/backend/src/services/core/storage.service.js.map +1 -1
- package/dist/cli/backend/src/services/core/teams-backup.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/core/teams-backup.service.js +4 -2
- package/dist/cli/backend/src/services/core/teams-backup.service.js.map +1 -1
- package/dist/cli/backend/src/services/marketplace/marketplace-installer.service.d.ts +72 -0
- package/dist/cli/backend/src/services/marketplace/marketplace-installer.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/marketplace/marketplace-installer.service.js +389 -0
- package/dist/cli/backend/src/services/marketplace/marketplace-installer.service.js.map +1 -0
- package/dist/cli/backend/src/services/messaging/index.d.ts +15 -0
- package/dist/cli/backend/src/services/messaging/index.d.ts.map +1 -0
- package/dist/cli/backend/src/services/messaging/index.js +20 -0
- package/dist/cli/backend/src/services/messaging/index.js.map +1 -0
- package/dist/cli/backend/src/services/messaging/queue-processor.service.d.ts +224 -0
- package/dist/cli/backend/src/services/messaging/queue-processor.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/messaging/queue-processor.service.js +969 -0
- package/dist/cli/backend/src/services/messaging/queue-processor.service.js.map +1 -0
- package/dist/cli/backend/src/services/messaging/response-router.service.d.ts +110 -0
- package/dist/cli/backend/src/services/messaging/response-router.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/messaging/response-router.service.js +268 -0
- package/dist/cli/backend/src/services/messaging/response-router.service.js.map +1 -0
- package/dist/cli/backend/src/services/onboarding/onboarding-checklist.service.d.ts +14 -2
- package/dist/cli/backend/src/services/onboarding/onboarding-checklist.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/onboarding/onboarding-checklist.service.js +37 -1
- package/dist/cli/backend/src/services/onboarding/onboarding-checklist.service.js.map +1 -1
- package/dist/cli/backend/src/services/orchestrator/commitment-approval-guard.d.ts +133 -0
- package/dist/cli/backend/src/services/orchestrator/commitment-approval-guard.d.ts.map +1 -0
- package/dist/cli/backend/src/services/orchestrator/commitment-approval-guard.js +232 -0
- package/dist/cli/backend/src/services/orchestrator/commitment-approval-guard.js.map +1 -0
- package/dist/cli/backend/src/services/skill/skill-catalog.service.d.ts +277 -0
- package/dist/cli/backend/src/services/skill/skill-catalog.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/skill/skill-catalog.service.js +708 -0
- package/dist/cli/backend/src/services/skill/skill-catalog.service.js.map +1 -0
- package/dist/cli/backend/src/services/skill-setup/skill-install-job.service.d.ts +243 -0
- package/dist/cli/backend/src/services/skill-setup/skill-install-job.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/skill-setup/skill-install-job.service.js +439 -0
- package/dist/cli/backend/src/services/skill-setup/skill-install-job.service.js.map +1 -0
- package/dist/cli/backend/src/services/slack/slack-team-channel.service.d.ts +21 -0
- package/dist/cli/backend/src/services/slack/slack-team-channel.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/slack/slack-team-channel.service.js +85 -0
- package/dist/cli/backend/src/services/slack/slack-team-channel.service.js.map +1 -1
- package/dist/cli/backend/src/services/template/template.service.d.ts.map +1 -1
- package/dist/cli/backend/src/services/template/template.service.js +5 -2
- package/dist/cli/backend/src/services/template/template.service.js.map +1 -1
- package/dist/cli/backend/src/services/workflow/cron-task.service.d.ts +316 -0
- package/dist/cli/backend/src/services/workflow/cron-task.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/workflow/cron-task.service.js +1043 -0
- package/dist/cli/backend/src/services/workflow/cron-task.service.js.map +1 -0
- package/dist/cli/backend/src/types/cron-task.types.d.ts +95 -0
- package/dist/cli/backend/src/types/cron-task.types.d.ts.map +1 -0
- package/dist/cli/backend/src/types/cron-task.types.js +10 -0
- package/dist/cli/backend/src/types/cron-task.types.js.map +1 -0
- package/dist/cli/backend/src/types/solution-bundle.types.d.ts +367 -0
- package/dist/cli/backend/src/types/solution-bundle.types.d.ts.map +1 -0
- package/dist/cli/backend/src/types/solution-bundle.types.js +17 -0
- package/dist/cli/backend/src/types/solution-bundle.types.js.map +1 -0
- package/dist/cli/backend/src/types/team-template.types.d.ts +8 -0
- package/dist/cli/backend/src/types/team-template.types.d.ts.map +1 -1
- package/dist/cli/backend/src/types/team-template.types.js.map +1 -1
- package/dist/cli/cli/src/commands/deploy-bundle.d.ts +160 -0
- package/dist/cli/cli/src/commands/deploy-bundle.d.ts.map +1 -0
- package/dist/cli/cli/src/commands/deploy-bundle.js +386 -0
- package/dist/cli/cli/src/commands/deploy-bundle.js.map +1 -0
- package/dist/cli/cli/src/commands/onboard.d.ts +32 -1
- package/dist/cli/cli/src/commands/onboard.d.ts.map +1 -1
- package/dist/cli/cli/src/commands/onboard.js +80 -3
- package/dist/cli/cli/src/commands/onboard.js.map +1 -1
- package/dist/cli/cli/src/constants.d.ts +5 -0
- package/dist/cli/cli/src/constants.d.ts.map +1 -1
- package/dist/cli/cli/src/constants.js +5 -0
- package/dist/cli/cli/src/constants.js.map +1 -1
- package/dist/cli/cli/src/index.js +15 -0
- package/dist/cli/cli/src/index.js.map +1 -1
- package/frontend/dist/assets/{index-e6c403b6.js → index-97bb17d4.js} +274 -274
- package/frontend/dist/index.html +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,1043 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cron Task Service
|
|
3
|
+
*
|
|
4
|
+
* Manages user-defined recurring tasks that run on cron schedules.
|
|
5
|
+
* Unlike scheduled messages (agent-created/cancellable), cron tasks
|
|
6
|
+
* can only be cancelled by user or orchestrator.
|
|
7
|
+
*
|
|
8
|
+
* Storage: Per-team at ~/.crewly/teams/{teamId}/cron-tasks.json
|
|
9
|
+
* Migration: Global ~/.crewly/cron-tasks.json is auto-migrated on first startup.
|
|
10
|
+
*
|
|
11
|
+
* @module services/workflow/cron-task.service
|
|
12
|
+
*/
|
|
13
|
+
import * as path from 'path';
|
|
14
|
+
import { existsSync } from 'fs';
|
|
15
|
+
import { readFile, writeFile, mkdir, readdir } from 'fs/promises';
|
|
16
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
17
|
+
import { LoggerService } from '../core/logger.service.js';
|
|
18
|
+
import { CRON_SCHEDULE_CONSTANTS } from '../../constants.js';
|
|
19
|
+
import { getCrewlyHomePath } from '../core/crewly-home.utils.js';
|
|
20
|
+
/**
|
|
21
|
+
* Check interval for cron task evaluation (60 seconds).
|
|
22
|
+
*/
|
|
23
|
+
const CRON_CHECK_INTERVAL_MS = 60_000;
|
|
24
|
+
/**
|
|
25
|
+
* Issue #305: when a cron task fires against an offline agent, we
|
|
26
|
+
* kick off auto-start, then poll the agent's online status until it
|
|
27
|
+
* reports ready or this deadline fires.
|
|
28
|
+
*
|
|
29
|
+
* 15s covers a cold tmux + Claude-Code init (~5-10s typically) with
|
|
30
|
+
* some headroom. Tasks that miss this window are skipped with
|
|
31
|
+
* `lastSkipReason: 'agent_offline_not_ready'`.
|
|
32
|
+
*/
|
|
33
|
+
const CRON_AUTO_START_READY_TIMEOUT_MS = 15_000;
|
|
34
|
+
/** How often to re-check the agent's status during the readiness wait. */
|
|
35
|
+
const CRON_AUTO_START_READY_POLL_MS = 500;
|
|
36
|
+
/**
|
|
37
|
+
* #611 transient-skip retry budget. When an auto-start fails because the
|
|
38
|
+
* PTY spawn momentarily can't acquire a slot (`posix_spawnp failed.` —
|
|
39
|
+
* observed 2026-05-28 when the user proc count hit 353 against a
|
|
40
|
+
* `maxProcPerUid` of 1333 → kernel briefly rejected new spawns), the
|
|
41
|
+
* cron-eval path used to permanently skip the slot. The error itself is
|
|
42
|
+
* transient ("Wait ~30 s and the next attempt should succeed") so we
|
|
43
|
+
* instead push `nextRunAt` forward by a short backoff and let the cron
|
|
44
|
+
* loop retry the same slot up to {@link CRON_TRANSIENT_SKIP_MAX_ATTEMPTS}
|
|
45
|
+
* times before permanently skipping.
|
|
46
|
+
*/
|
|
47
|
+
const CRON_TRANSIENT_SKIP_MAX_ATTEMPTS = 3;
|
|
48
|
+
/**
|
|
49
|
+
* Base backoff for transient skip retries (#611). Total time waited
|
|
50
|
+
* across all attempts ≈ BACKOFF × (1 + 2 + 3) = 60 s with the default
|
|
51
|
+
* 10s base — enough for the PTY spawn limit to clear without unduly
|
|
52
|
+
* delaying tasks. Multiplied by `transientSkipAttempts` for a linear
|
|
53
|
+
* backoff (a multi-process spike clears in a small handful of seconds,
|
|
54
|
+
* exponential would over-delay).
|
|
55
|
+
*/
|
|
56
|
+
const CRON_TRANSIENT_SKIP_BACKOFF_MS = 10_000;
|
|
57
|
+
/** Name of the per-team cron task file */
|
|
58
|
+
const CRON_TASKS_FILENAME = 'cron-tasks.json';
|
|
59
|
+
/** Suffix appended to the global file after successful migration */
|
|
60
|
+
const MIGRATED_SUFFIX = '.migrated';
|
|
61
|
+
/** Team ID value used for orchestrator-level cron tasks (not team-scoped) */
|
|
62
|
+
const ORCHESTRATOR_TEAM_ID = 'orchestrator';
|
|
63
|
+
/**
|
|
64
|
+
* Extract date/time parts from a Date in a specific IANA timezone.
|
|
65
|
+
*
|
|
66
|
+
* Uses Intl.DateTimeFormat to convert the UTC instant into the target
|
|
67
|
+
* timezone, then extracts numeric minute, hour, dayOfWeek, dayOfMonth,
|
|
68
|
+
* and month fields.
|
|
69
|
+
*
|
|
70
|
+
* @param date - The Date object (represents a UTC instant)
|
|
71
|
+
* @param tz - IANA timezone string (e.g. "America/New_York", "Asia/Shanghai")
|
|
72
|
+
* @returns Object with minute, hour, dayOfWeek (0=Sun), dayOfMonth, month (1-12)
|
|
73
|
+
*/
|
|
74
|
+
export function getDatePartsInTimezone(date, tz) {
|
|
75
|
+
const formatter = new Intl.DateTimeFormat('en-US', {
|
|
76
|
+
timeZone: tz,
|
|
77
|
+
hour: 'numeric',
|
|
78
|
+
minute: 'numeric',
|
|
79
|
+
weekday: 'short',
|
|
80
|
+
day: 'numeric',
|
|
81
|
+
month: 'numeric',
|
|
82
|
+
hour12: false,
|
|
83
|
+
});
|
|
84
|
+
const partsMap = new Map();
|
|
85
|
+
for (const p of formatter.formatToParts(date)) {
|
|
86
|
+
partsMap.set(p.type, p.value);
|
|
87
|
+
}
|
|
88
|
+
const weekdayStr = partsMap.get('weekday') || '';
|
|
89
|
+
const weekdayMap = {
|
|
90
|
+
Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6,
|
|
91
|
+
};
|
|
92
|
+
return {
|
|
93
|
+
minute: parseInt(partsMap.get('minute') || '0', 10),
|
|
94
|
+
// Some ICU/Node versions format midnight as "24" under hour12:false;
|
|
95
|
+
// normalize 24→0 so a `0 0 * * *` (midnight) cron actually matches
|
|
96
|
+
// instead of never matching → falling through to the drift fallback.
|
|
97
|
+
hour: parseInt(partsMap.get('hour') || '0', 10) % 24,
|
|
98
|
+
dayOfWeek: weekdayMap[weekdayStr] ?? 0,
|
|
99
|
+
dayOfMonth: parseInt(partsMap.get('day') || '1', 10),
|
|
100
|
+
month: parseInt(partsMap.get('month') || '1', 10),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Validate an IANA timezone, falling back to UTC for an invalid/empty one.
|
|
105
|
+
*
|
|
106
|
+
* `new Intl.DateTimeFormat(..., { timeZone })` THROWS `RangeError` on a bad
|
|
107
|
+
* tz. That throw was previously uncaught inside `getNextRunTime`'s minute scan,
|
|
108
|
+
* so a single cron task with a malformed/empty timezone would crash the whole
|
|
109
|
+
* `evaluateTasks` tick and stall EVERY cron. Defaulting to UTC (with a one-time
|
|
110
|
+
* warn) keeps the scheduler resilient to one bad task.
|
|
111
|
+
*
|
|
112
|
+
* @param tz - Candidate IANA timezone
|
|
113
|
+
* @returns The tz if valid, otherwise 'UTC'
|
|
114
|
+
*/
|
|
115
|
+
export function safeTimezone(tz) {
|
|
116
|
+
const candidate = (tz ?? '').trim();
|
|
117
|
+
if (!candidate)
|
|
118
|
+
return 'UTC';
|
|
119
|
+
try {
|
|
120
|
+
new Intl.DateTimeFormat('en-US', { timeZone: candidate });
|
|
121
|
+
return candidate;
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
console.warn(`[cron] Invalid timezone "${candidate}" — falling back to UTC`);
|
|
125
|
+
return 'UTC';
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Parse a single cron field spec into a set of allowed values.
|
|
130
|
+
* Supports: `*`, single number, ranges (`1-5`), lists (`1,3,5`),
|
|
131
|
+
* and step values (`*\/5`, `10-30/5`).
|
|
132
|
+
*
|
|
133
|
+
* @param spec - The cron field string (e.g. "*", "5", "1-5", "1,3,5", "*\/10")
|
|
134
|
+
* @param min - Minimum allowed value for this field (inclusive)
|
|
135
|
+
* @param max - Maximum allowed value for this field (inclusive)
|
|
136
|
+
* @returns Set of matching integer values, or null for wildcard (match any)
|
|
137
|
+
*/
|
|
138
|
+
export function parseCronField(spec, min, max) {
|
|
139
|
+
if (spec === '*')
|
|
140
|
+
return null; // wildcard
|
|
141
|
+
const result = new Set();
|
|
142
|
+
for (const part of spec.split(',')) {
|
|
143
|
+
const stepMatch = part.match(/^(.+)\/(\d+)$/);
|
|
144
|
+
if (stepMatch) {
|
|
145
|
+
const [, rangePart, stepStr] = stepMatch;
|
|
146
|
+
const step = parseInt(stepStr, 10);
|
|
147
|
+
let start = min;
|
|
148
|
+
let end = max;
|
|
149
|
+
if (rangePart !== '*') {
|
|
150
|
+
const dashIdx = rangePart.indexOf('-');
|
|
151
|
+
if (dashIdx !== -1) {
|
|
152
|
+
start = parseInt(rangePart.slice(0, dashIdx), 10);
|
|
153
|
+
end = parseInt(rangePart.slice(dashIdx + 1), 10);
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
start = parseInt(rangePart, 10);
|
|
157
|
+
end = max;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
for (let v = start; v <= end; v += step) {
|
|
161
|
+
result.add(v);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
else if (part.includes('-')) {
|
|
165
|
+
const [startStr, endStr] = part.split('-');
|
|
166
|
+
const start = parseInt(startStr, 10);
|
|
167
|
+
const end = parseInt(endStr, 10);
|
|
168
|
+
for (let v = start; v <= end; v++) {
|
|
169
|
+
result.add(v);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
result.add(parseInt(part, 10));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return result;
|
|
177
|
+
}
|
|
178
|
+
/** Minutes in a calendar day, used for in-day skips. */
|
|
179
|
+
const MINUTES_PER_DAY = 24 * 60;
|
|
180
|
+
let cronHelperLogger;
|
|
181
|
+
/**
|
|
182
|
+
* Logger for the module-level cron helpers, created on first use.
|
|
183
|
+
*
|
|
184
|
+
* `getNextRunTime` is a free function with no service instance, so it cannot
|
|
185
|
+
* use `CronTaskService`'s logger. Lazy so importing this module never touches
|
|
186
|
+
* the logger singleton.
|
|
187
|
+
*
|
|
188
|
+
* @returns The shared component logger for cron helpers
|
|
189
|
+
*/
|
|
190
|
+
function helperLogger() {
|
|
191
|
+
if (!cronHelperLogger) {
|
|
192
|
+
cronHelperLogger = LoggerService.getInstance().createComponentLogger('CronSchedule');
|
|
193
|
+
}
|
|
194
|
+
return cronHelperLogger;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Parse a cron expression and calculate the next run time.
|
|
198
|
+
* Supports standard 5-field cron: minute hour day-of-month month day-of-week.
|
|
199
|
+
* Each field supports: `*`, numbers, ranges (`1-5`), lists (`1,3,5`),
|
|
200
|
+
* and step values (`*\/5`).
|
|
201
|
+
*
|
|
202
|
+
* All field matching is done in the specified timezone so that a cron like
|
|
203
|
+
* "0 9 * * *" in Asia/Shanghai fires at 09:00 Shanghai time regardless of
|
|
204
|
+
* the server's local timezone.
|
|
205
|
+
*
|
|
206
|
+
* The search covers `CRON_SCHEDULE_CONSTANTS.NEXT_RUN_HORIZON_DAYS` (a full
|
|
207
|
+
* year) without stepping every minute: a day whose date fields do not match
|
|
208
|
+
* is skipped in one hop (to just before its end, then hour-by-hour to the
|
|
209
|
+
* boundary), and an hour that does not match is skipped to the next hour
|
|
210
|
+
* boundary. Every hop stays inside the unit it is skipping, so no matching
|
|
211
|
+
* minute can be jumped over — including across DST transitions.
|
|
212
|
+
*
|
|
213
|
+
* @param cronExpression - Standard 5-field cron expression
|
|
214
|
+
* @param timezone - IANA timezone (e.g. "UTC", "America/New_York")
|
|
215
|
+
* @param after - Calculate next run after this date (defaults to now)
|
|
216
|
+
* @returns ISO string of next run time (always UTC). For an expression with
|
|
217
|
+
* no match inside the horizon (e.g. "0 0 31 2 *") returns now+24h,
|
|
218
|
+
* which is NOT schedule-aligned, and logs a WARN naming the expression
|
|
219
|
+
* @throws Error if the expression does not have exactly 5 fields
|
|
220
|
+
*/
|
|
221
|
+
export function getNextRunTime(cronExpression, timezone, after) {
|
|
222
|
+
const now = after || new Date();
|
|
223
|
+
const parts = cronExpression.trim().split(/\s+/);
|
|
224
|
+
if (parts.length !== 5) {
|
|
225
|
+
throw new Error(`Invalid cron expression: expected 5 fields, got ${parts.length}`);
|
|
226
|
+
}
|
|
227
|
+
// A bad/empty tz must not crash the whole evaluate tick (it would throw
|
|
228
|
+
// inside the scan below); fall back to UTC.
|
|
229
|
+
const tz = safeTimezone(timezone);
|
|
230
|
+
const minuteSet = parseCronField(parts[0], 0, 59);
|
|
231
|
+
const hourSet = parseCronField(parts[1], 0, 23);
|
|
232
|
+
const domSet = parseCronField(parts[2], 1, 31);
|
|
233
|
+
const monthSet = parseCronField(parts[3], 1, 12);
|
|
234
|
+
const dowSet = parseCronField(parts[4], 0, 6);
|
|
235
|
+
const candidate = new Date(now.getTime());
|
|
236
|
+
// Advance by 1 minute minimum to avoid re-triggering
|
|
237
|
+
candidate.setSeconds(0, 0);
|
|
238
|
+
candidate.setMinutes(candidate.getMinutes() + 1);
|
|
239
|
+
const horizonMs = CRON_SCHEDULE_CONSTANTS.NEXT_RUN_HORIZON_DAYS * MINUTES_PER_DAY * 60_000;
|
|
240
|
+
const limit = now.getTime() + horizonMs;
|
|
241
|
+
const buffer = CRON_SCHEDULE_CONSTANTS.DAY_END_SKIP_BUFFER_MINUTES;
|
|
242
|
+
let candidatesExamined = 0;
|
|
243
|
+
while (candidate.getTime() <= limit) {
|
|
244
|
+
candidatesExamined += 1;
|
|
245
|
+
// Extract fields in the TARGET timezone, not server-local
|
|
246
|
+
const tp = getDatePartsInTimezone(candidate, tz);
|
|
247
|
+
const domMatch = !domSet || domSet.has(tp.dayOfMonth);
|
|
248
|
+
const monthMatch = !monthSet || monthSet.has(tp.month);
|
|
249
|
+
const dowMatch = !dowSet || dowSet.has(tp.dayOfWeek);
|
|
250
|
+
if (!(domMatch && monthMatch && dowMatch)) {
|
|
251
|
+
// Wrong day: skip to ~buffer minutes before its end, then hour by
|
|
252
|
+
// hour to the day boundary. Both hops stay inside this day.
|
|
253
|
+
const minutesLeftInDay = MINUTES_PER_DAY - (tp.hour * 60 + tp.minute);
|
|
254
|
+
const hop = minutesLeftInDay > buffer ? minutesLeftInDay - buffer : Math.max(1, 60 - tp.minute);
|
|
255
|
+
candidate.setMinutes(candidate.getMinutes() + hop);
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
const hourMatch = !hourSet || hourSet.has(tp.hour);
|
|
259
|
+
if (!hourMatch) {
|
|
260
|
+
// Wrong hour: skip to the next hour boundary (in tz).
|
|
261
|
+
candidate.setMinutes(candidate.getMinutes() + Math.max(1, 60 - tp.minute));
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
const minuteMatch = !minuteSet || minuteSet.has(tp.minute);
|
|
265
|
+
if (minuteMatch) {
|
|
266
|
+
return candidate.toISOString();
|
|
267
|
+
}
|
|
268
|
+
candidate.setMinutes(candidate.getMinutes() + 1);
|
|
269
|
+
}
|
|
270
|
+
// No match inside a full year. With tz validated (UTC fallback) this only
|
|
271
|
+
// happens for a genuinely impossible expression (e.g. "0 0 31 2 *" — Feb
|
|
272
|
+
// 31) or a DOM/DOW contradiction. Warn loudly instead of silently
|
|
273
|
+
// returning a non-cron-aligned now+24h that would drift the task forever
|
|
274
|
+
// (the #678 fire-time-drift symptom). We still return a value so the eval
|
|
275
|
+
// loop never throws; the WARN + the resulting daily WorkItem make the
|
|
276
|
+
// broken expression visible so it can be fixed/disabled.
|
|
277
|
+
helperLogger().warn('getNextRunTime: no run time within the horizon — expression is impossible (e.g. an invalid ' +
|
|
278
|
+
'day/month combo). Returning now+24h, which will NOT align to the schedule. Fix or disable this cron.', {
|
|
279
|
+
cronExpression,
|
|
280
|
+
timezone: tz,
|
|
281
|
+
horizonDays: CRON_SCHEDULE_CONSTANTS.NEXT_RUN_HORIZON_DAYS,
|
|
282
|
+
candidatesExamined,
|
|
283
|
+
});
|
|
284
|
+
return new Date(now.getTime() + CRON_SCHEDULE_CONSTANTS.IMPOSSIBLE_EXPRESSION_FALLBACK_MS).toISOString();
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Service for managing cron tasks with per-team scoped storage.
|
|
288
|
+
*
|
|
289
|
+
* Storage layout:
|
|
290
|
+
* ~/.crewly/teams/{teamId}/cron-tasks.json
|
|
291
|
+
*
|
|
292
|
+
* On first startup, migrates the legacy global file
|
|
293
|
+
* (~/.crewly/cron-tasks.json) by grouping tasks by targetTeamId
|
|
294
|
+
* and writing per-team files. The global file is renamed to
|
|
295
|
+
* cron-tasks.json.migrated to prevent re-migration.
|
|
296
|
+
*
|
|
297
|
+
* Lifecycle:
|
|
298
|
+
* 1. Get singleton via `getInstance()`
|
|
299
|
+
* 2. Wire callbacks: `setExecutionCallback()`, `setAgentStatusCallback()`, `setAgentStartCallback()`
|
|
300
|
+
* 3. Call `migrateGlobalToTeamScoped()` to auto-migrate legacy storage
|
|
301
|
+
* 4. Call `start()` to begin the evaluation loop
|
|
302
|
+
* 5. On startup, call `recalculateAllNextRunTimes()` to self-heal stale values
|
|
303
|
+
*/
|
|
304
|
+
export class CronTaskService {
|
|
305
|
+
static instance = null;
|
|
306
|
+
logger;
|
|
307
|
+
/** Root crewly home directory (e.g. ~/.crewly) */
|
|
308
|
+
crewlyHome;
|
|
309
|
+
timer = null;
|
|
310
|
+
executionCallback = null;
|
|
311
|
+
agentStatusCallback = null;
|
|
312
|
+
agentStartCallback = null;
|
|
313
|
+
constructor(crewlyHome) {
|
|
314
|
+
this.logger = LoggerService.getInstance().createComponentLogger('CronTaskService');
|
|
315
|
+
// CREWLY_HOME-aware: a test profile or trial must not write into the
|
|
316
|
+
// developer's real ~/.crewly (crewly-home.utils).
|
|
317
|
+
this.crewlyHome = crewlyHome || getCrewlyHomePath();
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Get singleton instance.
|
|
321
|
+
*/
|
|
322
|
+
static getInstance() {
|
|
323
|
+
if (!CronTaskService.instance) {
|
|
324
|
+
CronTaskService.instance = new CronTaskService();
|
|
325
|
+
}
|
|
326
|
+
return CronTaskService.instance;
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Reset singleton (for testing).
|
|
330
|
+
*/
|
|
331
|
+
static resetInstance() {
|
|
332
|
+
if (CronTaskService.instance) {
|
|
333
|
+
CronTaskService.instance.stop();
|
|
334
|
+
}
|
|
335
|
+
CronTaskService.instance = null;
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Get the store file path for a specific team.
|
|
339
|
+
*
|
|
340
|
+
* @param teamId - Team ID
|
|
341
|
+
* @returns Absolute path to the team's cron-tasks.json
|
|
342
|
+
*/
|
|
343
|
+
getStoreFile(teamId) {
|
|
344
|
+
return path.join(this.crewlyHome, 'teams', teamId, CRON_TASKS_FILENAME);
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Get the legacy global store file path.
|
|
348
|
+
*
|
|
349
|
+
* @returns Absolute path to the global cron-tasks.json
|
|
350
|
+
*/
|
|
351
|
+
getGlobalStoreFile() {
|
|
352
|
+
return path.join(this.crewlyHome, CRON_TASKS_FILENAME);
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Migrate legacy global cron-tasks.json to per-team files.
|
|
356
|
+
*
|
|
357
|
+
* Orchestrator-targeted tasks (targetTeamId === 'orchestrator') are kept
|
|
358
|
+
* in the global file since they don't belong to any specific team.
|
|
359
|
+
* All other tasks are grouped by targetTeamId and written to per-team files.
|
|
360
|
+
*
|
|
361
|
+
* After migration, the global file is rewritten to contain only orchestrator
|
|
362
|
+
* tasks, and a .migrated marker is created to prevent re-running.
|
|
363
|
+
*
|
|
364
|
+
* Idempotent: skips if the .migrated marker already exists or no global file exists.
|
|
365
|
+
*
|
|
366
|
+
* @returns Number of tasks migrated to per-team files (excludes orchestrator tasks kept in global)
|
|
367
|
+
*/
|
|
368
|
+
async migrateGlobalToTeamScoped() {
|
|
369
|
+
const globalFile = this.getGlobalStoreFile();
|
|
370
|
+
const migratedFile = globalFile + MIGRATED_SUFFIX;
|
|
371
|
+
// Skip if already migrated or no global file exists
|
|
372
|
+
if (existsSync(migratedFile) || !existsSync(globalFile)) {
|
|
373
|
+
return 0;
|
|
374
|
+
}
|
|
375
|
+
let globalStore;
|
|
376
|
+
try {
|
|
377
|
+
const data = await readFile(globalFile, 'utf-8');
|
|
378
|
+
globalStore = JSON.parse(data);
|
|
379
|
+
}
|
|
380
|
+
catch {
|
|
381
|
+
// Cannot read/parse global file — nothing to migrate
|
|
382
|
+
return 0;
|
|
383
|
+
}
|
|
384
|
+
if (globalStore.tasks.length === 0) {
|
|
385
|
+
// Empty store — write marker and skip
|
|
386
|
+
await writeFile(migratedFile, '', 'utf-8');
|
|
387
|
+
return 0;
|
|
388
|
+
}
|
|
389
|
+
// Partition: orchestrator tasks stay global, everything else goes per-team
|
|
390
|
+
const orchestratorTasks = [];
|
|
391
|
+
const teamGroups = new Map();
|
|
392
|
+
for (const task of globalStore.tasks) {
|
|
393
|
+
if (task.targetTeamId === ORCHESTRATOR_TEAM_ID) {
|
|
394
|
+
orchestratorTasks.push(task);
|
|
395
|
+
}
|
|
396
|
+
else {
|
|
397
|
+
const teamId = task.targetTeamId;
|
|
398
|
+
if (!teamGroups.has(teamId)) {
|
|
399
|
+
teamGroups.set(teamId, []);
|
|
400
|
+
}
|
|
401
|
+
teamGroups.get(teamId).push(task);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
// Write team-scoped tasks to per-team files
|
|
405
|
+
let migratedCount = 0;
|
|
406
|
+
for (const [teamId, tasks] of teamGroups) {
|
|
407
|
+
const teamStore = await this.loadTeamStore(teamId);
|
|
408
|
+
// Merge: add migrated tasks that don't already exist
|
|
409
|
+
const existingIds = new Set(teamStore.tasks.map(t => t.id));
|
|
410
|
+
for (const task of tasks) {
|
|
411
|
+
if (!existingIds.has(task.id)) {
|
|
412
|
+
teamStore.tasks.push(task);
|
|
413
|
+
migratedCount++;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
await this.saveTeamStore(teamId, teamStore);
|
|
417
|
+
}
|
|
418
|
+
// Rewrite global file with only orchestrator tasks
|
|
419
|
+
await this.saveGlobalStore({ tasks: orchestratorTasks });
|
|
420
|
+
// Write migration marker to prevent re-running
|
|
421
|
+
await writeFile(migratedFile, new Date().toISOString(), 'utf-8');
|
|
422
|
+
this.logger.info('Migrated global cron-tasks.json to per-team storage', {
|
|
423
|
+
migratedCount,
|
|
424
|
+
orchestratorKept: orchestratorTasks.length,
|
|
425
|
+
teamCount: teamGroups.size,
|
|
426
|
+
});
|
|
427
|
+
return migratedCount;
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Set the callback invoked when a cron task is due for execution.
|
|
431
|
+
* The callback receives the task and should delegate it to the target agent.
|
|
432
|
+
*
|
|
433
|
+
* @param callback - Async function that executes a cron task
|
|
434
|
+
*/
|
|
435
|
+
setExecutionCallback(callback) {
|
|
436
|
+
this.executionCallback = callback;
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Set the callback for checking agent online status.
|
|
440
|
+
* Called before executing a task to verify the target agent is reachable.
|
|
441
|
+
* Should treat both 'active' and 'started' states as online.
|
|
442
|
+
*
|
|
443
|
+
* @param callback - Async function returning true if agent is online
|
|
444
|
+
*/
|
|
445
|
+
setAgentStatusCallback(callback) {
|
|
446
|
+
this.agentStatusCallback = callback;
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Set the callback for auto-starting an offline agent.
|
|
450
|
+
* Called when a cron task fires but the target agent is offline.
|
|
451
|
+
*
|
|
452
|
+
* @param callback - Async function that starts an agent, returns true on success
|
|
453
|
+
*/
|
|
454
|
+
setAgentStartCallback(callback) {
|
|
455
|
+
this.agentStartCallback = callback;
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Start the cron task evaluation loop.
|
|
459
|
+
* Checks every minute for tasks whose nextRunAt has passed.
|
|
460
|
+
*/
|
|
461
|
+
start() {
|
|
462
|
+
if (this.timer)
|
|
463
|
+
return;
|
|
464
|
+
this.logger.info('Starting cron task service', { intervalMs: CRON_CHECK_INTERVAL_MS });
|
|
465
|
+
this.timer = setInterval(async () => {
|
|
466
|
+
try {
|
|
467
|
+
await this.evaluateTasks();
|
|
468
|
+
}
|
|
469
|
+
catch (error) {
|
|
470
|
+
this.logger.error('Cron task evaluation error', {
|
|
471
|
+
error: error instanceof Error ? error.message : String(error),
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
}, CRON_CHECK_INTERVAL_MS);
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* Stop the cron task evaluation loop.
|
|
478
|
+
*/
|
|
479
|
+
stop() {
|
|
480
|
+
if (this.timer) {
|
|
481
|
+
clearInterval(this.timer);
|
|
482
|
+
this.timer = null;
|
|
483
|
+
this.logger.info('Stopped cron task service');
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* Whether the service is running.
|
|
488
|
+
*/
|
|
489
|
+
isRunning() {
|
|
490
|
+
return this.timer !== null;
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Recalculate nextRunAt for all enabled tasks using the stored timezone.
|
|
494
|
+
* This self-heals stale values from older versions that computed nextRunAt
|
|
495
|
+
* in the server's local timezone instead of the task's specified timezone.
|
|
496
|
+
*
|
|
497
|
+
* Should be called once on backend startup, before start().
|
|
498
|
+
*
|
|
499
|
+
* @returns Number of tasks whose nextRunAt was updated
|
|
500
|
+
*/
|
|
501
|
+
async recalculateAllNextRunTimes() {
|
|
502
|
+
const allTasks = await this.loadAllTasks();
|
|
503
|
+
const now = new Date();
|
|
504
|
+
let updated = 0;
|
|
505
|
+
// Group tasks back by team for saving
|
|
506
|
+
const dirtyTeams = new Set();
|
|
507
|
+
for (const task of allTasks) {
|
|
508
|
+
if (!task.enabled)
|
|
509
|
+
continue;
|
|
510
|
+
// If task has never run AND its nextRunAt is in the past, this is a missed first run.
|
|
511
|
+
if (!task.lastRunAt && task.nextRunAt && new Date(task.nextRunAt) <= now) {
|
|
512
|
+
this.logger.info('Missed first run detected — keeping stale nextRunAt for immediate execution', {
|
|
513
|
+
id: task.id,
|
|
514
|
+
nextRunAt: task.nextRunAt,
|
|
515
|
+
timezone: task.timezone,
|
|
516
|
+
});
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
const after = task.lastRunAt ? new Date(task.lastRunAt) : undefined;
|
|
520
|
+
const recalculated = getNextRunTime(task.cronExpression, task.timezone, after);
|
|
521
|
+
if (recalculated !== task.nextRunAt) {
|
|
522
|
+
this.logger.info('Self-healed stale nextRunAt', {
|
|
523
|
+
id: task.id,
|
|
524
|
+
old: task.nextRunAt,
|
|
525
|
+
new: recalculated,
|
|
526
|
+
timezone: task.timezone,
|
|
527
|
+
});
|
|
528
|
+
task.nextRunAt = recalculated;
|
|
529
|
+
updated++;
|
|
530
|
+
dirtyTeams.add(task.targetTeamId);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
// Save only modified team stores
|
|
534
|
+
if (dirtyTeams.size > 0) {
|
|
535
|
+
for (const teamId of dirtyTeams) {
|
|
536
|
+
const teamTasks = allTasks.filter(t => t.targetTeamId === teamId);
|
|
537
|
+
await this.saveTeamStore(teamId, { tasks: teamTasks });
|
|
538
|
+
}
|
|
539
|
+
this.logger.info('Recalculated nextRunAt for stale tasks', { count: updated });
|
|
540
|
+
}
|
|
541
|
+
return updated;
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* Create a new cron task.
|
|
545
|
+
*
|
|
546
|
+
* @param request - Cron task creation parameters
|
|
547
|
+
* @returns The created cron task
|
|
548
|
+
*/
|
|
549
|
+
async create(request) {
|
|
550
|
+
const timezone = request.timezone || 'UTC';
|
|
551
|
+
const isOrchestratorTask = request.targetTeamId === ORCHESTRATOR_TEAM_ID;
|
|
552
|
+
// Load the destination store up front so we can both dedup against it
|
|
553
|
+
// and append to it (orchestrator tasks live in the global store,
|
|
554
|
+
// team tasks in their per-team store).
|
|
555
|
+
const store = isOrchestratorTask
|
|
556
|
+
? await this.loadGlobalStore()
|
|
557
|
+
: await this.loadTeamStore(request.targetTeamId);
|
|
558
|
+
// Idempotency guard (#613, #621). A cron is logically identified by its
|
|
559
|
+
// target agent + team + schedule + timezone + description. If an
|
|
560
|
+
// identical task already exists, return it instead of pushing a
|
|
561
|
+
// duplicate. Without this, repeated create-cron calls — an agent
|
|
562
|
+
// self-scheduling on every restart (#621), or the orchestrator
|
|
563
|
+
// re-running a startup routine — accumulate duplicate entries in the
|
|
564
|
+
// store file, and `evaluateTasks` fires EVERY copy, delivering N
|
|
565
|
+
// identical reports per trigger (#613). create() generates a fresh
|
|
566
|
+
// random id per call, so id-based dedup cannot catch this; we match on
|
|
567
|
+
// the logical key. Genuinely distinct schedules (different time,
|
|
568
|
+
// timezone, or description) are never collapsed.
|
|
569
|
+
const existing = store.tasks.find(t => t.targetAgent === request.targetAgent &&
|
|
570
|
+
t.targetTeamId === request.targetTeamId &&
|
|
571
|
+
t.cronExpression === request.cronExpression &&
|
|
572
|
+
(t.timezone || 'UTC') === timezone &&
|
|
573
|
+
t.taskDescription === request.taskDescription);
|
|
574
|
+
if (existing) {
|
|
575
|
+
this.logger.info('Cron task create deduplicated — identical task already exists', {
|
|
576
|
+
id: existing.id,
|
|
577
|
+
cron: existing.cronExpression,
|
|
578
|
+
target: existing.targetAgent,
|
|
579
|
+
teamId: existing.targetTeamId,
|
|
580
|
+
});
|
|
581
|
+
return existing;
|
|
582
|
+
}
|
|
583
|
+
const nextRunAt = getNextRunTime(request.cronExpression, timezone);
|
|
584
|
+
const task = {
|
|
585
|
+
id: `cron-${uuidv4().slice(0, 8)}`,
|
|
586
|
+
cronExpression: request.cronExpression,
|
|
587
|
+
timezone,
|
|
588
|
+
targetAgent: request.targetAgent,
|
|
589
|
+
targetTeamId: request.targetTeamId,
|
|
590
|
+
taskDescription: request.taskDescription,
|
|
591
|
+
createdBy: request.createdBy || 'user',
|
|
592
|
+
createdAt: new Date().toISOString(),
|
|
593
|
+
enabled: true,
|
|
594
|
+
lastRunAt: null,
|
|
595
|
+
nextRunAt,
|
|
596
|
+
};
|
|
597
|
+
// Orchestrator tasks go to global store; team tasks go to per-team store
|
|
598
|
+
store.tasks.push(task);
|
|
599
|
+
if (isOrchestratorTask) {
|
|
600
|
+
await this.saveGlobalStore(store);
|
|
601
|
+
}
|
|
602
|
+
else {
|
|
603
|
+
await this.saveTeamStore(task.targetTeamId, store);
|
|
604
|
+
}
|
|
605
|
+
this.logger.info('Cron task created', { id: task.id, cron: task.cronExpression, target: task.targetAgent, teamId: task.targetTeamId });
|
|
606
|
+
return task;
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* List all cron tasks across all teams, optionally filtered.
|
|
610
|
+
*
|
|
611
|
+
* @param filter - Optional filter criteria
|
|
612
|
+
* @returns Array of matching cron tasks
|
|
613
|
+
*/
|
|
614
|
+
async list(filter) {
|
|
615
|
+
let tasks = await this.loadAllTasks();
|
|
616
|
+
if (filter?.targetAgent) {
|
|
617
|
+
tasks = tasks.filter(t => t.targetAgent === filter.targetAgent);
|
|
618
|
+
}
|
|
619
|
+
if (filter?.enabled !== undefined) {
|
|
620
|
+
tasks = tasks.filter(t => t.enabled === filter.enabled);
|
|
621
|
+
}
|
|
622
|
+
return tasks;
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* List cron tasks for a specific team.
|
|
626
|
+
*
|
|
627
|
+
* @param teamId - Team ID to filter by
|
|
628
|
+
* @param filter - Optional additional filter criteria
|
|
629
|
+
* @returns Array of matching cron tasks for the team
|
|
630
|
+
*/
|
|
631
|
+
async getByTeam(teamId, filter) {
|
|
632
|
+
const store = await this.loadTeamStore(teamId);
|
|
633
|
+
let tasks = store.tasks;
|
|
634
|
+
if (filter?.targetAgent) {
|
|
635
|
+
tasks = tasks.filter(t => t.targetAgent === filter.targetAgent);
|
|
636
|
+
}
|
|
637
|
+
if (filter?.enabled !== undefined) {
|
|
638
|
+
tasks = tasks.filter(t => t.enabled === filter.enabled);
|
|
639
|
+
}
|
|
640
|
+
return tasks;
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* Get a single cron task by ID. Searches across all teams.
|
|
644
|
+
*
|
|
645
|
+
* @param id - Cron task ID
|
|
646
|
+
* @returns The cron task or null
|
|
647
|
+
*/
|
|
648
|
+
async get(id) {
|
|
649
|
+
const allTasks = await this.loadAllTasks();
|
|
650
|
+
return allTasks.find(t => t.id === id) || null;
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* Update a cron task. Searches across all teams to find the task.
|
|
654
|
+
*
|
|
655
|
+
* @param id - Cron task ID
|
|
656
|
+
* @param updates - Fields to update
|
|
657
|
+
* @returns Updated task or null if not found
|
|
658
|
+
*/
|
|
659
|
+
async update(id, updates) {
|
|
660
|
+
// Helper to apply updates to a task
|
|
661
|
+
const applyUpdates = (task) => {
|
|
662
|
+
if (updates.cronExpression !== undefined) {
|
|
663
|
+
task.cronExpression = updates.cronExpression;
|
|
664
|
+
task.nextRunAt = getNextRunTime(updates.cronExpression, task.timezone);
|
|
665
|
+
}
|
|
666
|
+
if (updates.timezone !== undefined) {
|
|
667
|
+
task.timezone = updates.timezone;
|
|
668
|
+
task.nextRunAt = getNextRunTime(task.cronExpression, task.timezone);
|
|
669
|
+
}
|
|
670
|
+
if (updates.taskDescription !== undefined) {
|
|
671
|
+
task.taskDescription = updates.taskDescription;
|
|
672
|
+
}
|
|
673
|
+
if (updates.enabled !== undefined) {
|
|
674
|
+
task.enabled = updates.enabled;
|
|
675
|
+
}
|
|
676
|
+
};
|
|
677
|
+
// Search global store first (orchestrator tasks)
|
|
678
|
+
const globalStore = await this.loadGlobalStore();
|
|
679
|
+
const globalTask = globalStore.tasks.find(t => t.id === id);
|
|
680
|
+
if (globalTask) {
|
|
681
|
+
applyUpdates(globalTask);
|
|
682
|
+
await this.saveGlobalStore(globalStore);
|
|
683
|
+
this.logger.info('Cron task updated', { id, location: 'global', updates: Object.keys(updates) });
|
|
684
|
+
return globalTask;
|
|
685
|
+
}
|
|
686
|
+
// Search per-team stores
|
|
687
|
+
const teamIds = await this.getTeamIds();
|
|
688
|
+
for (const teamId of teamIds) {
|
|
689
|
+
const store = await this.loadTeamStore(teamId);
|
|
690
|
+
const task = store.tasks.find(t => t.id === id);
|
|
691
|
+
if (!task)
|
|
692
|
+
continue;
|
|
693
|
+
applyUpdates(task);
|
|
694
|
+
await this.saveTeamStore(teamId, store);
|
|
695
|
+
this.logger.info('Cron task updated', { id, teamId, updates: Object.keys(updates) });
|
|
696
|
+
return task;
|
|
697
|
+
}
|
|
698
|
+
return null;
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* Delete a cron task. Searches across all teams to find and remove it.
|
|
702
|
+
*
|
|
703
|
+
* @param id - Cron task ID
|
|
704
|
+
* @returns true if deleted, false if not found
|
|
705
|
+
*/
|
|
706
|
+
async delete(id) {
|
|
707
|
+
// Search global store first (orchestrator tasks)
|
|
708
|
+
const globalStore = await this.loadGlobalStore();
|
|
709
|
+
const globalIndex = globalStore.tasks.findIndex(t => t.id === id);
|
|
710
|
+
if (globalIndex !== -1) {
|
|
711
|
+
globalStore.tasks.splice(globalIndex, 1);
|
|
712
|
+
await this.saveGlobalStore(globalStore);
|
|
713
|
+
this.logger.info('Cron task deleted', { id, location: 'global' });
|
|
714
|
+
return true;
|
|
715
|
+
}
|
|
716
|
+
// Search per-team stores
|
|
717
|
+
const teamIds = await this.getTeamIds();
|
|
718
|
+
for (const teamId of teamIds) {
|
|
719
|
+
const store = await this.loadTeamStore(teamId);
|
|
720
|
+
const index = store.tasks.findIndex(t => t.id === id);
|
|
721
|
+
if (index === -1)
|
|
722
|
+
continue;
|
|
723
|
+
store.tasks.splice(index, 1);
|
|
724
|
+
await this.saveTeamStore(teamId, store);
|
|
725
|
+
this.logger.info('Cron task deleted', { id, teamId });
|
|
726
|
+
return true;
|
|
727
|
+
}
|
|
728
|
+
return false;
|
|
729
|
+
}
|
|
730
|
+
/**
|
|
731
|
+
* Evaluate all enabled tasks and execute those whose nextRunAt has passed.
|
|
732
|
+
* If an agentStatusCallback is set, checks whether the target agent is online
|
|
733
|
+
* before execution. If the agent is offline and an agentStartCallback is set,
|
|
734
|
+
* attempts to auto-start the agent before executing.
|
|
735
|
+
*/
|
|
736
|
+
async evaluateTasks() {
|
|
737
|
+
const now = new Date();
|
|
738
|
+
// Evaluate global orchestrator tasks
|
|
739
|
+
const globalStore = await this.loadGlobalStore();
|
|
740
|
+
let globalUpdated = false;
|
|
741
|
+
for (const task of globalStore.tasks) {
|
|
742
|
+
const result = await this.evaluateSingleTask(task, now);
|
|
743
|
+
if (result)
|
|
744
|
+
globalUpdated = true;
|
|
745
|
+
}
|
|
746
|
+
if (globalUpdated) {
|
|
747
|
+
await this.saveGlobalStore(globalStore);
|
|
748
|
+
}
|
|
749
|
+
// Evaluate per-team tasks
|
|
750
|
+
const teamIds = await this.getTeamIds();
|
|
751
|
+
for (const teamId of teamIds) {
|
|
752
|
+
const store = await this.loadTeamStore(teamId);
|
|
753
|
+
let updated = false;
|
|
754
|
+
for (const task of store.tasks) {
|
|
755
|
+
const result = await this.evaluateSingleTask(task, now);
|
|
756
|
+
if (result)
|
|
757
|
+
updated = true;
|
|
758
|
+
}
|
|
759
|
+
if (updated) {
|
|
760
|
+
await this.saveTeamStore(teamId, store);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
/**
|
|
765
|
+
* Evaluate a single cron task: check if due, verify agent status, execute.
|
|
766
|
+
* Mutates the task in-place (lastRunAt, nextRunAt).
|
|
767
|
+
*
|
|
768
|
+
* @param task - The cron task to evaluate
|
|
769
|
+
* @param now - Current time for comparison
|
|
770
|
+
* @returns true if the task was modified (needs saving)
|
|
771
|
+
*/
|
|
772
|
+
async evaluateSingleTask(task, now) {
|
|
773
|
+
if (!task.enabled || !task.nextRunAt)
|
|
774
|
+
return false;
|
|
775
|
+
const nextRun = new Date(task.nextRunAt);
|
|
776
|
+
if (nextRun > now)
|
|
777
|
+
return false;
|
|
778
|
+
// Task is due — check agent status before executing
|
|
779
|
+
let agentOnline = true;
|
|
780
|
+
if (this.agentStatusCallback) {
|
|
781
|
+
try {
|
|
782
|
+
agentOnline = await this.agentStatusCallback(task.targetAgent, task.targetTeamId);
|
|
783
|
+
}
|
|
784
|
+
catch (error) {
|
|
785
|
+
this.logger.warn('Agent status check failed, assuming offline', {
|
|
786
|
+
id: task.id,
|
|
787
|
+
target: task.targetAgent,
|
|
788
|
+
error: error instanceof Error ? error.message : String(error),
|
|
789
|
+
});
|
|
790
|
+
agentOnline = false;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
// Auto-start offline agent if callback is available. Issue #305:
|
|
794
|
+
// the callback returns immediately when it kicks off the registration
|
|
795
|
+
// flow, but the agent takes a few seconds to actually report
|
|
796
|
+
// `active` / `started`. Without a readiness wait, the execution
|
|
797
|
+
// callback fires while the agent is still booting and the message
|
|
798
|
+
// is lost. We now poll `agentStatusCallback` up to the configured
|
|
799
|
+
// wait window before proceeding.
|
|
800
|
+
let skipReason = null;
|
|
801
|
+
if (!agentOnline && this.agentStartCallback) {
|
|
802
|
+
this.logger.info('Agent offline for cron task, attempting auto-start', {
|
|
803
|
+
id: task.id,
|
|
804
|
+
target: task.targetAgent,
|
|
805
|
+
});
|
|
806
|
+
let kicked = false;
|
|
807
|
+
try {
|
|
808
|
+
kicked = await this.agentStartCallback(task.targetAgent, task.targetTeamId);
|
|
809
|
+
if (kicked) {
|
|
810
|
+
this.logger.info('Agent auto-start kicked off, waiting for ready', {
|
|
811
|
+
id: task.id,
|
|
812
|
+
target: task.targetAgent,
|
|
813
|
+
maxWaitMs: CRON_AUTO_START_READY_TIMEOUT_MS,
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
catch (error) {
|
|
818
|
+
this.logger.error('Agent auto-start failed', {
|
|
819
|
+
id: task.id,
|
|
820
|
+
target: task.targetAgent,
|
|
821
|
+
error: error instanceof Error ? error.message : String(error),
|
|
822
|
+
});
|
|
823
|
+
skipReason = 'agent_offline_start_failed';
|
|
824
|
+
}
|
|
825
|
+
if (kicked && this.agentStatusCallback) {
|
|
826
|
+
// Poll until ready or timeout. The status callback already
|
|
827
|
+
// treats both `active` and `started` as online (#286).
|
|
828
|
+
const deadline = Date.now() + CRON_AUTO_START_READY_TIMEOUT_MS;
|
|
829
|
+
while (Date.now() < deadline) {
|
|
830
|
+
try {
|
|
831
|
+
if (await this.agentStatusCallback(task.targetAgent, task.targetTeamId)) {
|
|
832
|
+
agentOnline = true;
|
|
833
|
+
break;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
catch {
|
|
837
|
+
// Transient — keep retrying until deadline.
|
|
838
|
+
}
|
|
839
|
+
await new Promise((res) => setTimeout(res, CRON_AUTO_START_READY_POLL_MS));
|
|
840
|
+
}
|
|
841
|
+
if (!agentOnline) {
|
|
842
|
+
skipReason = 'agent_offline_not_ready';
|
|
843
|
+
this.logger.warn('Agent did not become ready before cron auto-start deadline', {
|
|
844
|
+
id: task.id,
|
|
845
|
+
target: task.targetAgent,
|
|
846
|
+
maxWaitMs: CRON_AUTO_START_READY_TIMEOUT_MS,
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
else {
|
|
850
|
+
this.logger.info('Agent auto-started and ready', {
|
|
851
|
+
id: task.id,
|
|
852
|
+
target: task.targetAgent,
|
|
853
|
+
});
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
else if (!kicked && skipReason === null) {
|
|
857
|
+
skipReason = 'agent_offline_start_failed';
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
else if (!agentOnline) {
|
|
861
|
+
skipReason = 'agent_offline_no_callback';
|
|
862
|
+
}
|
|
863
|
+
if (!agentOnline) {
|
|
864
|
+
// #611: transient-skip retry budget. The reasons that fire here
|
|
865
|
+
// (`agent_offline_not_ready` / `agent_offline_start_failed`) can
|
|
866
|
+
// be caused by short-lived PTY spawn failures that clear in
|
|
867
|
+
// seconds (observed 2026-05-28: kernel briefly rejected
|
|
868
|
+
// posix_spawnp at user-proc-count 353/1333). Push `nextRunAt`
|
|
869
|
+
// forward by a small linear backoff and retry the SAME slot up
|
|
870
|
+
// to CRON_TRANSIENT_SKIP_MAX_ATTEMPTS before surrendering to the
|
|
871
|
+
// old "advance + mark skipped" path. Surplus skip reasons
|
|
872
|
+
// (`agent_offline_no_callback`) are NOT transient — they go
|
|
873
|
+
// straight to permanent skip.
|
|
874
|
+
const isTransient = skipReason === 'agent_offline_not_ready' ||
|
|
875
|
+
skipReason === 'agent_offline_start_failed';
|
|
876
|
+
if (isTransient) {
|
|
877
|
+
const attempts = (task.transientSkipAttempts ?? 0) + 1;
|
|
878
|
+
if (attempts < CRON_TRANSIENT_SKIP_MAX_ATTEMPTS) {
|
|
879
|
+
const backoffMs = CRON_TRANSIENT_SKIP_BACKOFF_MS * attempts;
|
|
880
|
+
task.transientSkipAttempts = attempts;
|
|
881
|
+
task.nextRunAt = new Date(Date.now() + backoffMs).toISOString();
|
|
882
|
+
this.logger.warn('Cron transient-skip — retrying slot after backoff', {
|
|
883
|
+
id: task.id,
|
|
884
|
+
target: task.targetAgent,
|
|
885
|
+
skipReason,
|
|
886
|
+
attempt: attempts,
|
|
887
|
+
maxAttempts: CRON_TRANSIENT_SKIP_MAX_ATTEMPTS,
|
|
888
|
+
backoffMs,
|
|
889
|
+
nextRunAt: task.nextRunAt,
|
|
890
|
+
});
|
|
891
|
+
return true;
|
|
892
|
+
}
|
|
893
|
+
// Budget exhausted — fall through to permanent skip with a
|
|
894
|
+
// distinct reason so dashboards can tell "we tried" vs "gave
|
|
895
|
+
// up first round". Reset the counter so the next regular
|
|
896
|
+
// firing starts fresh.
|
|
897
|
+
skipReason = 'agent_offline_retries_exhausted';
|
|
898
|
+
task.transientSkipAttempts = 0;
|
|
899
|
+
}
|
|
900
|
+
this.logger.warn('Skipping cron task — agent offline and auto-start unavailable', {
|
|
901
|
+
id: task.id,
|
|
902
|
+
target: task.targetAgent,
|
|
903
|
+
skipReason,
|
|
904
|
+
});
|
|
905
|
+
// Mark the skip distinctly from "never ran" so the user can tell
|
|
906
|
+
// from API surfaces why `lastRunAt` is still null. Issue #305.
|
|
907
|
+
task.lastSkippedAt = now.toISOString();
|
|
908
|
+
task.lastSkipReason = skipReason ?? 'agent_offline_no_callback';
|
|
909
|
+
// Still advance nextRunAt to prevent re-evaluating the same missed slot
|
|
910
|
+
task.nextRunAt = getNextRunTime(task.cronExpression, task.timezone, now);
|
|
911
|
+
return true;
|
|
912
|
+
}
|
|
913
|
+
this.logger.info('Cron task due, executing', {
|
|
914
|
+
id: task.id,
|
|
915
|
+
target: task.targetAgent,
|
|
916
|
+
nextRunAt: task.nextRunAt,
|
|
917
|
+
});
|
|
918
|
+
try {
|
|
919
|
+
if (this.executionCallback) {
|
|
920
|
+
await this.executionCallback(task);
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
catch (error) {
|
|
924
|
+
this.logger.error('Cron task execution failed', {
|
|
925
|
+
id: task.id,
|
|
926
|
+
error: error instanceof Error ? error.message : String(error),
|
|
927
|
+
});
|
|
928
|
+
}
|
|
929
|
+
// Update run times regardless of execution success
|
|
930
|
+
task.lastRunAt = now.toISOString();
|
|
931
|
+
task.nextRunAt = getNextRunTime(task.cronExpression, task.timezone, now);
|
|
932
|
+
// #611: a successful (or attempted) execution clears the
|
|
933
|
+
// transient-skip retry counter so the NEXT slot starts with a
|
|
934
|
+
// fresh budget. Without this reset, a string of bad-luck runs
|
|
935
|
+
// would carry attempt-counts across firings and exhaust the
|
|
936
|
+
// budget prematurely.
|
|
937
|
+
if ((task.transientSkipAttempts ?? 0) > 0) {
|
|
938
|
+
task.transientSkipAttempts = 0;
|
|
939
|
+
}
|
|
940
|
+
return true;
|
|
941
|
+
}
|
|
942
|
+
// ============ Storage Methods ============
|
|
943
|
+
/**
|
|
944
|
+
* Load cron task store for a specific team.
|
|
945
|
+
*
|
|
946
|
+
* @param teamId - Team ID
|
|
947
|
+
* @returns Team's cron task store (empty if file doesn't exist)
|
|
948
|
+
*/
|
|
949
|
+
async loadTeamStore(teamId) {
|
|
950
|
+
try {
|
|
951
|
+
const filePath = this.getStoreFile(teamId);
|
|
952
|
+
const data = await readFile(filePath, 'utf-8');
|
|
953
|
+
return JSON.parse(data);
|
|
954
|
+
}
|
|
955
|
+
catch {
|
|
956
|
+
return { tasks: [] };
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
/**
|
|
960
|
+
* Save cron task store for a specific team.
|
|
961
|
+
*
|
|
962
|
+
* @param teamId - Team ID
|
|
963
|
+
* @param store - Store data to persist
|
|
964
|
+
*/
|
|
965
|
+
async saveTeamStore(teamId, store) {
|
|
966
|
+
const filePath = this.getStoreFile(teamId);
|
|
967
|
+
const dir = path.dirname(filePath);
|
|
968
|
+
await mkdir(dir, { recursive: true });
|
|
969
|
+
await writeFile(filePath, JSON.stringify(store, null, 2), 'utf-8');
|
|
970
|
+
}
|
|
971
|
+
/**
|
|
972
|
+
* Load the global cron task store (orchestrator tasks only).
|
|
973
|
+
*
|
|
974
|
+
* @returns Global store (empty if file doesn't exist)
|
|
975
|
+
*/
|
|
976
|
+
async loadGlobalStore() {
|
|
977
|
+
try {
|
|
978
|
+
const data = await readFile(this.getGlobalStoreFile(), 'utf-8');
|
|
979
|
+
return JSON.parse(data);
|
|
980
|
+
}
|
|
981
|
+
catch {
|
|
982
|
+
return { tasks: [] };
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
/**
|
|
986
|
+
* Save the global cron task store (orchestrator tasks only).
|
|
987
|
+
*
|
|
988
|
+
* @param store - Store data to persist
|
|
989
|
+
*/
|
|
990
|
+
async saveGlobalStore(store) {
|
|
991
|
+
const filePath = this.getGlobalStoreFile();
|
|
992
|
+
const dir = path.dirname(filePath);
|
|
993
|
+
await mkdir(dir, { recursive: true });
|
|
994
|
+
await writeFile(filePath, JSON.stringify(store, null, 2), 'utf-8');
|
|
995
|
+
}
|
|
996
|
+
/**
|
|
997
|
+
* Load all tasks from all team stores plus the global store,
|
|
998
|
+
* aggregated into a single array.
|
|
999
|
+
*
|
|
1000
|
+
* @returns All cron tasks across all teams and the global orchestrator store
|
|
1001
|
+
*/
|
|
1002
|
+
async loadAllTasks() {
|
|
1003
|
+
const teamIds = await this.getTeamIds();
|
|
1004
|
+
const allTasks = [];
|
|
1005
|
+
// Load orchestrator tasks from global store
|
|
1006
|
+
const globalStore = await this.loadGlobalStore();
|
|
1007
|
+
allTasks.push(...globalStore.tasks);
|
|
1008
|
+
// Load per-team tasks
|
|
1009
|
+
for (const teamId of teamIds) {
|
|
1010
|
+
const store = await this.loadTeamStore(teamId);
|
|
1011
|
+
allTasks.push(...store.tasks);
|
|
1012
|
+
}
|
|
1013
|
+
return allTasks;
|
|
1014
|
+
}
|
|
1015
|
+
/**
|
|
1016
|
+
* Discover all team IDs that have cron task files.
|
|
1017
|
+
*
|
|
1018
|
+
* Scans ~/.crewly/teams/ for directories containing cron-tasks.json.
|
|
1019
|
+
*
|
|
1020
|
+
* @returns Array of team IDs
|
|
1021
|
+
*/
|
|
1022
|
+
async getTeamIds() {
|
|
1023
|
+
const teamsDir = path.join(this.crewlyHome, 'teams');
|
|
1024
|
+
try {
|
|
1025
|
+
const entries = await readdir(teamsDir, { withFileTypes: true });
|
|
1026
|
+
const teamIds = [];
|
|
1027
|
+
for (const entry of entries) {
|
|
1028
|
+
if (!entry.isDirectory())
|
|
1029
|
+
continue;
|
|
1030
|
+
const cronFile = path.join(teamsDir, entry.name, CRON_TASKS_FILENAME);
|
|
1031
|
+
if (existsSync(cronFile)) {
|
|
1032
|
+
teamIds.push(entry.name);
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
return teamIds;
|
|
1036
|
+
}
|
|
1037
|
+
catch {
|
|
1038
|
+
// teams directory doesn't exist yet
|
|
1039
|
+
return [];
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
//# sourceMappingURL=cron-task.service.js.map
|