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.
Files changed (219) hide show
  1. package/dist/backend/backend/src/constants.d.ts +82 -0
  2. package/dist/backend/backend/src/constants.d.ts.map +1 -1
  3. package/dist/backend/backend/src/constants.js +87 -0
  4. package/dist/backend/backend/src/constants.js.map +1 -1
  5. package/dist/backend/backend/src/controllers/bundle/bundle.controller.d.ts +44 -0
  6. package/dist/backend/backend/src/controllers/bundle/bundle.controller.d.ts.map +1 -0
  7. package/dist/backend/backend/src/controllers/bundle/bundle.controller.js +122 -0
  8. package/dist/backend/backend/src/controllers/bundle/bundle.controller.js.map +1 -0
  9. package/dist/backend/backend/src/controllers/bundle/bundle.routes.d.ts +25 -0
  10. package/dist/backend/backend/src/controllers/bundle/bundle.routes.d.ts.map +1 -0
  11. package/dist/backend/backend/src/controllers/bundle/bundle.routes.js +33 -0
  12. package/dist/backend/backend/src/controllers/bundle/bundle.routes.js.map +1 -0
  13. package/dist/backend/backend/src/index.d.ts.map +1 -1
  14. package/dist/backend/backend/src/index.js +14 -0
  15. package/dist/backend/backend/src/index.js.map +1 -1
  16. package/dist/backend/backend/src/routes/api.routes.d.ts.map +1 -1
  17. package/dist/backend/backend/src/routes/api.routes.js +4 -0
  18. package/dist/backend/backend/src/routes/api.routes.js.map +1 -1
  19. package/dist/backend/backend/src/services/bundle/bundle-apply.factory.d.ts +62 -0
  20. package/dist/backend/backend/src/services/bundle/bundle-apply.factory.d.ts.map +1 -0
  21. package/dist/backend/backend/src/services/bundle/bundle-apply.factory.js +173 -0
  22. package/dist/backend/backend/src/services/bundle/bundle-apply.factory.js.map +1 -0
  23. package/dist/backend/backend/src/services/bundle/bundle-apply.service.d.ts +359 -0
  24. package/dist/backend/backend/src/services/bundle/bundle-apply.service.d.ts.map +1 -0
  25. package/dist/backend/backend/src/services/bundle/bundle-apply.service.js +888 -0
  26. package/dist/backend/backend/src/services/bundle/bundle-apply.service.js.map +1 -0
  27. package/dist/backend/backend/src/services/bundle/bundle-catalog.d.ts +167 -0
  28. package/dist/backend/backend/src/services/bundle/bundle-catalog.d.ts.map +1 -0
  29. package/dist/backend/backend/src/services/bundle/bundle-catalog.js +248 -0
  30. package/dist/backend/backend/src/services/bundle/bundle-catalog.js.map +1 -0
  31. package/dist/backend/backend/src/services/bundle/bundle-connectors.d.ts +43 -0
  32. package/dist/backend/backend/src/services/bundle/bundle-connectors.d.ts.map +1 -0
  33. package/dist/backend/backend/src/services/bundle/bundle-connectors.js +57 -0
  34. package/dist/backend/backend/src/services/bundle/bundle-connectors.js.map +1 -0
  35. package/dist/backend/backend/src/services/bundle/bundle-deps.d.ts +37 -0
  36. package/dist/backend/backend/src/services/bundle/bundle-deps.d.ts.map +1 -0
  37. package/dist/backend/backend/src/services/bundle/bundle-deps.js +60 -0
  38. package/dist/backend/backend/src/services/bundle/bundle-deps.js.map +1 -0
  39. package/dist/backend/backend/src/services/bundle/bundle-manifest.d.ts +93 -0
  40. package/dist/backend/backend/src/services/bundle/bundle-manifest.d.ts.map +1 -0
  41. package/dist/backend/backend/src/services/bundle/bundle-manifest.js +532 -0
  42. package/dist/backend/backend/src/services/bundle/bundle-manifest.js.map +1 -0
  43. package/dist/backend/backend/src/services/bundle/bundle-placeholders.d.ts +100 -0
  44. package/dist/backend/backend/src/services/bundle/bundle-placeholders.d.ts.map +1 -0
  45. package/dist/backend/backend/src/services/bundle/bundle-placeholders.js +183 -0
  46. package/dist/backend/backend/src/services/bundle/bundle-placeholders.js.map +1 -0
  47. package/dist/backend/backend/src/services/bundle/bundle-runtime.d.ts +36 -0
  48. package/dist/backend/backend/src/services/bundle/bundle-runtime.d.ts.map +1 -0
  49. package/dist/backend/backend/src/services/bundle/bundle-runtime.js +38 -0
  50. package/dist/backend/backend/src/services/bundle/bundle-runtime.js.map +1 -0
  51. package/dist/backend/backend/src/services/bundle/bundle-skill-installer.d.ts +105 -0
  52. package/dist/backend/backend/src/services/bundle/bundle-skill-installer.d.ts.map +1 -0
  53. package/dist/backend/backend/src/services/bundle/bundle-skill-installer.js +144 -0
  54. package/dist/backend/backend/src/services/bundle/bundle-skill-installer.js.map +1 -0
  55. package/dist/backend/backend/src/services/bundle/bundle-state.store.d.ts +48 -0
  56. package/dist/backend/backend/src/services/bundle/bundle-state.store.d.ts.map +1 -0
  57. package/dist/backend/backend/src/services/bundle/bundle-state.store.js +90 -0
  58. package/dist/backend/backend/src/services/bundle/bundle-state.store.js.map +1 -0
  59. package/dist/backend/backend/src/services/bundle/bundle-team.builder.d.ts +81 -0
  60. package/dist/backend/backend/src/services/bundle/bundle-team.builder.d.ts.map +1 -0
  61. package/dist/backend/backend/src/services/bundle/bundle-team.builder.js +172 -0
  62. package/dist/backend/backend/src/services/bundle/bundle-team.builder.js.map +1 -0
  63. package/dist/backend/backend/src/services/bundle/bundle-time.d.ts +59 -0
  64. package/dist/backend/backend/src/services/bundle/bundle-time.d.ts.map +1 -0
  65. package/dist/backend/backend/src/services/bundle/bundle-time.js +98 -0
  66. package/dist/backend/backend/src/services/bundle/bundle-time.js.map +1 -0
  67. package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.d.ts.map +1 -1
  68. package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.js +5 -0
  69. package/dist/backend/backend/src/services/cloud/mobile-api-relay.service.js.map +1 -1
  70. package/dist/backend/backend/src/services/core/storage.service.js +1 -1
  71. package/dist/backend/backend/src/services/core/storage.service.js.map +1 -1
  72. package/dist/backend/backend/src/services/core/teams-backup.service.d.ts.map +1 -1
  73. package/dist/backend/backend/src/services/core/teams-backup.service.js +4 -2
  74. package/dist/backend/backend/src/services/core/teams-backup.service.js.map +1 -1
  75. package/dist/backend/backend/src/services/onboarding/onboarding-checklist.factory.d.ts.map +1 -1
  76. package/dist/backend/backend/src/services/onboarding/onboarding-checklist.factory.js +2 -0
  77. package/dist/backend/backend/src/services/onboarding/onboarding-checklist.factory.js.map +1 -1
  78. package/dist/backend/backend/src/services/onboarding/onboarding-checklist.service.d.ts +14 -2
  79. package/dist/backend/backend/src/services/onboarding/onboarding-checklist.service.d.ts.map +1 -1
  80. package/dist/backend/backend/src/services/onboarding/onboarding-checklist.service.js +37 -1
  81. package/dist/backend/backend/src/services/onboarding/onboarding-checklist.service.js.map +1 -1
  82. package/dist/backend/backend/src/services/slack/slack-team-channel.service.d.ts +21 -0
  83. package/dist/backend/backend/src/services/slack/slack-team-channel.service.d.ts.map +1 -1
  84. package/dist/backend/backend/src/services/slack/slack-team-channel.service.js +85 -0
  85. package/dist/backend/backend/src/services/slack/slack-team-channel.service.js.map +1 -1
  86. package/dist/backend/backend/src/services/template/template.service.d.ts.map +1 -1
  87. package/dist/backend/backend/src/services/template/template.service.js +5 -2
  88. package/dist/backend/backend/src/services/template/template.service.js.map +1 -1
  89. package/dist/backend/backend/src/services/workflow/cron-task.service.d.ts.map +1 -1
  90. package/dist/backend/backend/src/services/workflow/cron-task.service.js +4 -2
  91. package/dist/backend/backend/src/services/workflow/cron-task.service.js.map +1 -1
  92. package/dist/backend/backend/src/types/solution-bundle.types.d.ts +367 -0
  93. package/dist/backend/backend/src/types/solution-bundle.types.d.ts.map +1 -0
  94. package/dist/backend/backend/src/types/solution-bundle.types.js +17 -0
  95. package/dist/backend/backend/src/types/solution-bundle.types.js.map +1 -0
  96. package/dist/backend/backend/src/types/team-template.types.d.ts +8 -0
  97. package/dist/backend/backend/src/types/team-template.types.d.ts.map +1 -1
  98. package/dist/backend/backend/src/types/team-template.types.js.map +1 -1
  99. package/dist/backend/build-info.json +2 -2
  100. package/dist/cli/backend/src/constants.d.ts +82 -0
  101. package/dist/cli/backend/src/constants.d.ts.map +1 -1
  102. package/dist/cli/backend/src/constants.js +87 -0
  103. package/dist/cli/backend/src/constants.js.map +1 -1
  104. package/dist/cli/backend/src/services/bundle/bundle-apply.service.d.ts +359 -0
  105. package/dist/cli/backend/src/services/bundle/bundle-apply.service.d.ts.map +1 -0
  106. package/dist/cli/backend/src/services/bundle/bundle-apply.service.js +888 -0
  107. package/dist/cli/backend/src/services/bundle/bundle-apply.service.js.map +1 -0
  108. package/dist/cli/backend/src/services/bundle/bundle-catalog.d.ts +167 -0
  109. package/dist/cli/backend/src/services/bundle/bundle-catalog.d.ts.map +1 -0
  110. package/dist/cli/backend/src/services/bundle/bundle-catalog.js +248 -0
  111. package/dist/cli/backend/src/services/bundle/bundle-catalog.js.map +1 -0
  112. package/dist/cli/backend/src/services/bundle/bundle-deps.d.ts +37 -0
  113. package/dist/cli/backend/src/services/bundle/bundle-deps.d.ts.map +1 -0
  114. package/dist/cli/backend/src/services/bundle/bundle-deps.js +60 -0
  115. package/dist/cli/backend/src/services/bundle/bundle-deps.js.map +1 -0
  116. package/dist/cli/backend/src/services/bundle/bundle-manifest.d.ts +93 -0
  117. package/dist/cli/backend/src/services/bundle/bundle-manifest.d.ts.map +1 -0
  118. package/dist/cli/backend/src/services/bundle/bundle-manifest.js +532 -0
  119. package/dist/cli/backend/src/services/bundle/bundle-manifest.js.map +1 -0
  120. package/dist/cli/backend/src/services/bundle/bundle-placeholders.d.ts +100 -0
  121. package/dist/cli/backend/src/services/bundle/bundle-placeholders.d.ts.map +1 -0
  122. package/dist/cli/backend/src/services/bundle/bundle-placeholders.js +183 -0
  123. package/dist/cli/backend/src/services/bundle/bundle-placeholders.js.map +1 -0
  124. package/dist/cli/backend/src/services/bundle/bundle-runtime.d.ts +36 -0
  125. package/dist/cli/backend/src/services/bundle/bundle-runtime.d.ts.map +1 -0
  126. package/dist/cli/backend/src/services/bundle/bundle-runtime.js +38 -0
  127. package/dist/cli/backend/src/services/bundle/bundle-runtime.js.map +1 -0
  128. package/dist/cli/backend/src/services/bundle/bundle-skill-installer.d.ts +105 -0
  129. package/dist/cli/backend/src/services/bundle/bundle-skill-installer.d.ts.map +1 -0
  130. package/dist/cli/backend/src/services/bundle/bundle-skill-installer.js +144 -0
  131. package/dist/cli/backend/src/services/bundle/bundle-skill-installer.js.map +1 -0
  132. package/dist/cli/backend/src/services/bundle/bundle-state.store.d.ts +48 -0
  133. package/dist/cli/backend/src/services/bundle/bundle-state.store.d.ts.map +1 -0
  134. package/dist/cli/backend/src/services/bundle/bundle-state.store.js +90 -0
  135. package/dist/cli/backend/src/services/bundle/bundle-state.store.js.map +1 -0
  136. package/dist/cli/backend/src/services/bundle/bundle-team.builder.d.ts +81 -0
  137. package/dist/cli/backend/src/services/bundle/bundle-team.builder.d.ts.map +1 -0
  138. package/dist/cli/backend/src/services/bundle/bundle-team.builder.js +172 -0
  139. package/dist/cli/backend/src/services/bundle/bundle-team.builder.js.map +1 -0
  140. package/dist/cli/backend/src/services/bundle/bundle-time.d.ts +59 -0
  141. package/dist/cli/backend/src/services/bundle/bundle-time.d.ts.map +1 -0
  142. package/dist/cli/backend/src/services/bundle/bundle-time.js +98 -0
  143. package/dist/cli/backend/src/services/bundle/bundle-time.js.map +1 -0
  144. package/dist/cli/backend/src/services/core/storage.service.js +1 -1
  145. package/dist/cli/backend/src/services/core/storage.service.js.map +1 -1
  146. package/dist/cli/backend/src/services/core/teams-backup.service.d.ts.map +1 -1
  147. package/dist/cli/backend/src/services/core/teams-backup.service.js +4 -2
  148. package/dist/cli/backend/src/services/core/teams-backup.service.js.map +1 -1
  149. package/dist/cli/backend/src/services/marketplace/marketplace-installer.service.d.ts +72 -0
  150. package/dist/cli/backend/src/services/marketplace/marketplace-installer.service.d.ts.map +1 -0
  151. package/dist/cli/backend/src/services/marketplace/marketplace-installer.service.js +389 -0
  152. package/dist/cli/backend/src/services/marketplace/marketplace-installer.service.js.map +1 -0
  153. package/dist/cli/backend/src/services/messaging/index.d.ts +15 -0
  154. package/dist/cli/backend/src/services/messaging/index.d.ts.map +1 -0
  155. package/dist/cli/backend/src/services/messaging/index.js +20 -0
  156. package/dist/cli/backend/src/services/messaging/index.js.map +1 -0
  157. package/dist/cli/backend/src/services/messaging/queue-processor.service.d.ts +224 -0
  158. package/dist/cli/backend/src/services/messaging/queue-processor.service.d.ts.map +1 -0
  159. package/dist/cli/backend/src/services/messaging/queue-processor.service.js +969 -0
  160. package/dist/cli/backend/src/services/messaging/queue-processor.service.js.map +1 -0
  161. package/dist/cli/backend/src/services/messaging/response-router.service.d.ts +110 -0
  162. package/dist/cli/backend/src/services/messaging/response-router.service.d.ts.map +1 -0
  163. package/dist/cli/backend/src/services/messaging/response-router.service.js +268 -0
  164. package/dist/cli/backend/src/services/messaging/response-router.service.js.map +1 -0
  165. package/dist/cli/backend/src/services/onboarding/onboarding-checklist.service.d.ts +14 -2
  166. package/dist/cli/backend/src/services/onboarding/onboarding-checklist.service.d.ts.map +1 -1
  167. package/dist/cli/backend/src/services/onboarding/onboarding-checklist.service.js +37 -1
  168. package/dist/cli/backend/src/services/onboarding/onboarding-checklist.service.js.map +1 -1
  169. package/dist/cli/backend/src/services/orchestrator/commitment-approval-guard.d.ts +133 -0
  170. package/dist/cli/backend/src/services/orchestrator/commitment-approval-guard.d.ts.map +1 -0
  171. package/dist/cli/backend/src/services/orchestrator/commitment-approval-guard.js +232 -0
  172. package/dist/cli/backend/src/services/orchestrator/commitment-approval-guard.js.map +1 -0
  173. package/dist/cli/backend/src/services/skill/skill-catalog.service.d.ts +277 -0
  174. package/dist/cli/backend/src/services/skill/skill-catalog.service.d.ts.map +1 -0
  175. package/dist/cli/backend/src/services/skill/skill-catalog.service.js +708 -0
  176. package/dist/cli/backend/src/services/skill/skill-catalog.service.js.map +1 -0
  177. package/dist/cli/backend/src/services/skill-setup/skill-install-job.service.d.ts +243 -0
  178. package/dist/cli/backend/src/services/skill-setup/skill-install-job.service.d.ts.map +1 -0
  179. package/dist/cli/backend/src/services/skill-setup/skill-install-job.service.js +439 -0
  180. package/dist/cli/backend/src/services/skill-setup/skill-install-job.service.js.map +1 -0
  181. package/dist/cli/backend/src/services/slack/slack-team-channel.service.d.ts +21 -0
  182. package/dist/cli/backend/src/services/slack/slack-team-channel.service.d.ts.map +1 -1
  183. package/dist/cli/backend/src/services/slack/slack-team-channel.service.js +85 -0
  184. package/dist/cli/backend/src/services/slack/slack-team-channel.service.js.map +1 -1
  185. package/dist/cli/backend/src/services/template/template.service.d.ts.map +1 -1
  186. package/dist/cli/backend/src/services/template/template.service.js +5 -2
  187. package/dist/cli/backend/src/services/template/template.service.js.map +1 -1
  188. package/dist/cli/backend/src/services/workflow/cron-task.service.d.ts +316 -0
  189. package/dist/cli/backend/src/services/workflow/cron-task.service.d.ts.map +1 -0
  190. package/dist/cli/backend/src/services/workflow/cron-task.service.js +1043 -0
  191. package/dist/cli/backend/src/services/workflow/cron-task.service.js.map +1 -0
  192. package/dist/cli/backend/src/types/cron-task.types.d.ts +95 -0
  193. package/dist/cli/backend/src/types/cron-task.types.d.ts.map +1 -0
  194. package/dist/cli/backend/src/types/cron-task.types.js +10 -0
  195. package/dist/cli/backend/src/types/cron-task.types.js.map +1 -0
  196. package/dist/cli/backend/src/types/solution-bundle.types.d.ts +367 -0
  197. package/dist/cli/backend/src/types/solution-bundle.types.d.ts.map +1 -0
  198. package/dist/cli/backend/src/types/solution-bundle.types.js +17 -0
  199. package/dist/cli/backend/src/types/solution-bundle.types.js.map +1 -0
  200. package/dist/cli/backend/src/types/team-template.types.d.ts +8 -0
  201. package/dist/cli/backend/src/types/team-template.types.d.ts.map +1 -1
  202. package/dist/cli/backend/src/types/team-template.types.js.map +1 -1
  203. package/dist/cli/cli/src/commands/deploy-bundle.d.ts +160 -0
  204. package/dist/cli/cli/src/commands/deploy-bundle.d.ts.map +1 -0
  205. package/dist/cli/cli/src/commands/deploy-bundle.js +386 -0
  206. package/dist/cli/cli/src/commands/deploy-bundle.js.map +1 -0
  207. package/dist/cli/cli/src/commands/onboard.d.ts +32 -1
  208. package/dist/cli/cli/src/commands/onboard.d.ts.map +1 -1
  209. package/dist/cli/cli/src/commands/onboard.js +80 -3
  210. package/dist/cli/cli/src/commands/onboard.js.map +1 -1
  211. package/dist/cli/cli/src/constants.d.ts +5 -0
  212. package/dist/cli/cli/src/constants.d.ts.map +1 -1
  213. package/dist/cli/cli/src/constants.js +5 -0
  214. package/dist/cli/cli/src/constants.js.map +1 -1
  215. package/dist/cli/cli/src/index.js +15 -0
  216. package/dist/cli/cli/src/index.js.map +1 -1
  217. package/frontend/dist/assets/{index-e6c403b6.js → index-97bb17d4.js} +274 -274
  218. package/frontend/dist/index.html +1 -1
  219. 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