@herdctl/core 0.0.1 → 0.0.2

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 (275) hide show
  1. package/dist/config/__tests__/agent.test.js +31 -13
  2. package/dist/config/__tests__/agent.test.js.map +1 -1
  3. package/dist/config/__tests__/merge.test.js +9 -2
  4. package/dist/config/__tests__/merge.test.js.map +1 -1
  5. package/dist/config/__tests__/schema.test.js +350 -1
  6. package/dist/config/__tests__/schema.test.js.map +1 -1
  7. package/dist/config/index.d.ts +1 -1
  8. package/dist/config/index.d.ts.map +1 -1
  9. package/dist/config/index.js +3 -1
  10. package/dist/config/index.js.map +1 -1
  11. package/dist/config/schema.d.ts +828 -24
  12. package/dist/config/schema.d.ts.map +1 -1
  13. package/dist/config/schema.js +118 -6
  14. package/dist/config/schema.js.map +1 -1
  15. package/dist/fleet-manager/__tests__/coverage.test.js +11 -332
  16. package/dist/fleet-manager/__tests__/coverage.test.js.map +1 -1
  17. package/dist/fleet-manager/__tests__/errors.test.js +1 -49
  18. package/dist/fleet-manager/__tests__/errors.test.js.map +1 -1
  19. package/dist/fleet-manager/__tests__/integration.test.js +109 -0
  20. package/dist/fleet-manager/__tests__/integration.test.js.map +1 -1
  21. package/dist/fleet-manager/__tests__/reload.test.js +1 -1
  22. package/dist/fleet-manager/__tests__/reload.test.js.map +1 -1
  23. package/dist/fleet-manager/config-reload.d.ts +164 -0
  24. package/dist/fleet-manager/config-reload.d.ts.map +1 -0
  25. package/dist/fleet-manager/config-reload.js +445 -0
  26. package/dist/fleet-manager/config-reload.js.map +1 -0
  27. package/dist/fleet-manager/context.d.ts +76 -0
  28. package/dist/fleet-manager/context.d.ts.map +1 -0
  29. package/dist/fleet-manager/context.js +11 -0
  30. package/dist/fleet-manager/context.js.map +1 -0
  31. package/dist/fleet-manager/errors.d.ts +0 -25
  32. package/dist/fleet-manager/errors.d.ts.map +1 -1
  33. package/dist/fleet-manager/errors.js +0 -38
  34. package/dist/fleet-manager/errors.js.map +1 -1
  35. package/dist/fleet-manager/event-emitters.d.ts +123 -0
  36. package/dist/fleet-manager/event-emitters.d.ts.map +1 -0
  37. package/dist/fleet-manager/event-emitters.js +136 -0
  38. package/dist/fleet-manager/event-emitters.js.map +1 -0
  39. package/dist/fleet-manager/event-types.d.ts +0 -15
  40. package/dist/fleet-manager/event-types.d.ts.map +1 -1
  41. package/dist/fleet-manager/fleet-manager.d.ts +40 -653
  42. package/dist/fleet-manager/fleet-manager.d.ts.map +1 -1
  43. package/dist/fleet-manager/fleet-manager.js +95 -1720
  44. package/dist/fleet-manager/fleet-manager.js.map +1 -1
  45. package/dist/fleet-manager/index.d.ts +13 -2
  46. package/dist/fleet-manager/index.d.ts.map +1 -1
  47. package/dist/fleet-manager/index.js +19 -6
  48. package/dist/fleet-manager/index.js.map +1 -1
  49. package/dist/fleet-manager/job-control.d.ts +64 -0
  50. package/dist/fleet-manager/job-control.d.ts.map +1 -0
  51. package/dist/fleet-manager/job-control.js +296 -0
  52. package/dist/fleet-manager/job-control.js.map +1 -0
  53. package/dist/fleet-manager/log-streaming.d.ts +171 -0
  54. package/dist/fleet-manager/log-streaming.d.ts.map +1 -0
  55. package/dist/fleet-manager/log-streaming.js +503 -0
  56. package/dist/fleet-manager/log-streaming.js.map +1 -0
  57. package/dist/fleet-manager/schedule-executor.d.ts +63 -0
  58. package/dist/fleet-manager/schedule-executor.d.ts.map +1 -0
  59. package/dist/fleet-manager/schedule-executor.js +209 -0
  60. package/dist/fleet-manager/schedule-executor.js.map +1 -0
  61. package/dist/fleet-manager/schedule-management.d.ts +71 -0
  62. package/dist/fleet-manager/schedule-management.d.ts.map +1 -0
  63. package/dist/fleet-manager/schedule-management.js +171 -0
  64. package/dist/fleet-manager/schedule-management.js.map +1 -0
  65. package/dist/fleet-manager/status-queries.d.ts +105 -0
  66. package/dist/fleet-manager/status-queries.d.ts.map +1 -0
  67. package/dist/fleet-manager/status-queries.js +247 -0
  68. package/dist/fleet-manager/status-queries.js.map +1 -0
  69. package/dist/fleet-manager/types.d.ts +0 -39
  70. package/dist/fleet-manager/types.d.ts.map +1 -1
  71. package/dist/runner/__tests__/job-executor.test.js +206 -1
  72. package/dist/runner/__tests__/job-executor.test.js.map +1 -1
  73. package/dist/runner/job-executor.d.ts +9 -0
  74. package/dist/runner/job-executor.d.ts.map +1 -1
  75. package/dist/runner/job-executor.js +78 -4
  76. package/dist/runner/job-executor.js.map +1 -1
  77. package/dist/runner/types.d.ts +2 -0
  78. package/dist/runner/types.d.ts.map +1 -1
  79. package/dist/scheduler/__tests__/cron.test.d.ts +2 -0
  80. package/dist/scheduler/__tests__/cron.test.d.ts.map +1 -0
  81. package/dist/scheduler/__tests__/cron.test.js +867 -0
  82. package/dist/scheduler/__tests__/cron.test.js.map +1 -0
  83. package/dist/scheduler/__tests__/scheduler.test.js +164 -5
  84. package/dist/scheduler/__tests__/scheduler.test.js.map +1 -1
  85. package/dist/scheduler/cron.d.ts +126 -0
  86. package/dist/scheduler/cron.d.ts.map +1 -0
  87. package/dist/scheduler/cron.js +390 -0
  88. package/dist/scheduler/cron.js.map +1 -0
  89. package/dist/scheduler/errors.d.ts +81 -1
  90. package/dist/scheduler/errors.d.ts.map +1 -1
  91. package/dist/scheduler/errors.js +81 -6
  92. package/dist/scheduler/errors.js.map +1 -1
  93. package/dist/scheduler/index.d.ts +1 -0
  94. package/dist/scheduler/index.d.ts.map +1 -1
  95. package/dist/scheduler/index.js +2 -0
  96. package/dist/scheduler/index.js.map +1 -1
  97. package/dist/scheduler/schedule-runner.d.ts +2 -2
  98. package/dist/scheduler/schedule-runner.d.ts.map +1 -1
  99. package/dist/scheduler/schedule-runner.js +20 -8
  100. package/dist/scheduler/schedule-runner.js.map +1 -1
  101. package/dist/scheduler/scheduler.d.ts +4 -4
  102. package/dist/scheduler/scheduler.d.ts.map +1 -1
  103. package/dist/scheduler/scheduler.js +86 -20
  104. package/dist/scheduler/scheduler.js.map +1 -1
  105. package/dist/scheduler/types.d.ts +1 -1
  106. package/dist/scheduler/types.d.ts.map +1 -1
  107. package/dist/state/schemas/job-metadata.d.ts +2 -2
  108. package/package.json +33 -8
  109. package/.turbo/turbo-build.log +0 -4
  110. package/.turbo/turbo-test.log +0 -219
  111. package/.turbo/turbo-typecheck.log +0 -4
  112. package/coverage/base.css +0 -224
  113. package/coverage/block-navigation.js +0 -87
  114. package/coverage/coverage-final.json +0 -51
  115. package/coverage/favicon.png +0 -0
  116. package/coverage/index.html +0 -251
  117. package/coverage/prettify.css +0 -1
  118. package/coverage/prettify.js +0 -2
  119. package/coverage/sort-arrow-sprite.png +0 -0
  120. package/coverage/sorter.js +0 -210
  121. package/coverage/src/config/index.html +0 -191
  122. package/coverage/src/config/index.ts.html +0 -442
  123. package/coverage/src/config/interpolate.ts.html +0 -652
  124. package/coverage/src/config/loader.ts.html +0 -1501
  125. package/coverage/src/config/merge.ts.html +0 -823
  126. package/coverage/src/config/parser.ts.html +0 -1213
  127. package/coverage/src/config/schema.ts.html +0 -1123
  128. package/coverage/src/fleet-manager/errors.ts.html +0 -2326
  129. package/coverage/src/fleet-manager/event-types.ts.html +0 -1219
  130. package/coverage/src/fleet-manager/fleet-manager.ts.html +0 -7030
  131. package/coverage/src/fleet-manager/index.html +0 -206
  132. package/coverage/src/fleet-manager/index.ts.html +0 -469
  133. package/coverage/src/fleet-manager/job-manager.ts.html +0 -2074
  134. package/coverage/src/fleet-manager/job-queue.ts.html +0 -2479
  135. package/coverage/src/fleet-manager/types.ts.html +0 -2602
  136. package/coverage/src/index.html +0 -116
  137. package/coverage/src/index.ts.html +0 -181
  138. package/coverage/src/runner/errors.ts.html +0 -1006
  139. package/coverage/src/runner/index.html +0 -191
  140. package/coverage/src/runner/index.ts.html +0 -256
  141. package/coverage/src/runner/job-executor.ts.html +0 -1429
  142. package/coverage/src/runner/message-processor.ts.html +0 -1150
  143. package/coverage/src/runner/sdk-adapter.ts.html +0 -658
  144. package/coverage/src/runner/types.ts.html +0 -559
  145. package/coverage/src/scheduler/errors.ts.html +0 -388
  146. package/coverage/src/scheduler/index.html +0 -206
  147. package/coverage/src/scheduler/index.ts.html +0 -244
  148. package/coverage/src/scheduler/interval.ts.html +0 -652
  149. package/coverage/src/scheduler/schedule-runner.ts.html +0 -1411
  150. package/coverage/src/scheduler/schedule-state.ts.html +0 -718
  151. package/coverage/src/scheduler/scheduler.ts.html +0 -1795
  152. package/coverage/src/scheduler/types.ts.html +0 -733
  153. package/coverage/src/state/directory.ts.html +0 -736
  154. package/coverage/src/state/errors.ts.html +0 -376
  155. package/coverage/src/state/fleet-state.ts.html +0 -937
  156. package/coverage/src/state/index.html +0 -221
  157. package/coverage/src/state/index.ts.html +0 -322
  158. package/coverage/src/state/job-metadata.ts.html +0 -1420
  159. package/coverage/src/state/job-output.ts.html +0 -1033
  160. package/coverage/src/state/schemas/fleet-state.ts.html +0 -445
  161. package/coverage/src/state/schemas/index.html +0 -176
  162. package/coverage/src/state/schemas/index.ts.html +0 -286
  163. package/coverage/src/state/schemas/job-metadata.ts.html +0 -628
  164. package/coverage/src/state/schemas/job-output.ts.html +0 -616
  165. package/coverage/src/state/schemas/session-info.ts.html +0 -361
  166. package/coverage/src/state/session.ts.html +0 -844
  167. package/coverage/src/state/types.ts.html +0 -262
  168. package/coverage/src/state/utils/atomic.ts.html +0 -748
  169. package/coverage/src/state/utils/index.html +0 -146
  170. package/coverage/src/state/utils/index.ts.html +0 -103
  171. package/coverage/src/state/utils/reads.ts.html +0 -1621
  172. package/coverage/src/work-sources/adapters/github.ts.html +0 -3583
  173. package/coverage/src/work-sources/adapters/index.html +0 -131
  174. package/coverage/src/work-sources/adapters/index.ts.html +0 -277
  175. package/coverage/src/work-sources/errors.ts.html +0 -298
  176. package/coverage/src/work-sources/index.html +0 -176
  177. package/coverage/src/work-sources/index.ts.html +0 -529
  178. package/coverage/src/work-sources/manager.ts.html +0 -1324
  179. package/coverage/src/work-sources/registry.ts.html +0 -619
  180. package/coverage/src/work-sources/types.ts.html +0 -568
  181. package/dist/fleet-manager/__tests__/event-helpers.test.d.ts +0 -7
  182. package/dist/fleet-manager/__tests__/event-helpers.test.d.ts.map +0 -1
  183. package/dist/fleet-manager/__tests__/event-helpers.test.js +0 -368
  184. package/dist/fleet-manager/__tests__/event-helpers.test.js.map +0 -1
  185. package/src/config/__tests__/agent.test.ts +0 -864
  186. package/src/config/__tests__/interpolate.test.ts +0 -644
  187. package/src/config/__tests__/loader.test.ts +0 -784
  188. package/src/config/__tests__/merge.test.ts +0 -751
  189. package/src/config/__tests__/parser.test.ts +0 -533
  190. package/src/config/__tests__/schema.test.ts +0 -873
  191. package/src/config/index.ts +0 -119
  192. package/src/config/interpolate.ts +0 -189
  193. package/src/config/loader.ts +0 -472
  194. package/src/config/merge.ts +0 -246
  195. package/src/config/parser.ts +0 -376
  196. package/src/config/schema.ts +0 -346
  197. package/src/fleet-manager/__tests__/coverage.test.ts +0 -2869
  198. package/src/fleet-manager/__tests__/errors.test.ts +0 -660
  199. package/src/fleet-manager/__tests__/event-helpers.test.ts +0 -448
  200. package/src/fleet-manager/__tests__/integration.test.ts +0 -1209
  201. package/src/fleet-manager/__tests__/job-control.test.ts +0 -283
  202. package/src/fleet-manager/__tests__/job-manager.test.ts +0 -869
  203. package/src/fleet-manager/__tests__/job-queue.test.ts +0 -401
  204. package/src/fleet-manager/__tests__/reload.test.ts +0 -751
  205. package/src/fleet-manager/__tests__/status-queries.test.ts +0 -595
  206. package/src/fleet-manager/__tests__/trigger.test.ts +0 -601
  207. package/src/fleet-manager/errors.ts +0 -747
  208. package/src/fleet-manager/event-types.ts +0 -378
  209. package/src/fleet-manager/fleet-manager.ts +0 -2315
  210. package/src/fleet-manager/index.ts +0 -128
  211. package/src/fleet-manager/job-manager.ts +0 -663
  212. package/src/fleet-manager/job-queue.ts +0 -798
  213. package/src/fleet-manager/types.ts +0 -839
  214. package/src/index.ts +0 -32
  215. package/src/runner/__tests__/errors.test.ts +0 -382
  216. package/src/runner/__tests__/job-executor.test.ts +0 -1708
  217. package/src/runner/__tests__/message-processor.test.ts +0 -960
  218. package/src/runner/__tests__/sdk-adapter.test.ts +0 -626
  219. package/src/runner/errors.ts +0 -307
  220. package/src/runner/index.ts +0 -57
  221. package/src/runner/job-executor.ts +0 -448
  222. package/src/runner/message-processor.ts +0 -355
  223. package/src/runner/sdk-adapter.ts +0 -191
  224. package/src/runner/types.ts +0 -158
  225. package/src/scheduler/__tests__/errors.test.ts +0 -159
  226. package/src/scheduler/__tests__/interval.test.ts +0 -515
  227. package/src/scheduler/__tests__/schedule-runner.test.ts +0 -798
  228. package/src/scheduler/__tests__/schedule-state.test.ts +0 -671
  229. package/src/scheduler/__tests__/scheduler.test.ts +0 -1280
  230. package/src/scheduler/errors.ts +0 -101
  231. package/src/scheduler/index.ts +0 -53
  232. package/src/scheduler/interval.ts +0 -189
  233. package/src/scheduler/schedule-runner.ts +0 -442
  234. package/src/scheduler/schedule-state.ts +0 -211
  235. package/src/scheduler/scheduler.ts +0 -570
  236. package/src/scheduler/types.ts +0 -216
  237. package/src/state/__tests__/directory.test.ts +0 -595
  238. package/src/state/__tests__/fleet-state.test.ts +0 -868
  239. package/src/state/__tests__/job-metadata-schema.test.ts +0 -414
  240. package/src/state/__tests__/job-metadata.test.ts +0 -831
  241. package/src/state/__tests__/job-output.test.ts +0 -856
  242. package/src/state/__tests__/session-schema.test.ts +0 -378
  243. package/src/state/__tests__/session.test.ts +0 -604
  244. package/src/state/directory.ts +0 -217
  245. package/src/state/errors.ts +0 -97
  246. package/src/state/fleet-state.ts +0 -284
  247. package/src/state/index.ts +0 -79
  248. package/src/state/job-metadata.ts +0 -445
  249. package/src/state/job-output.ts +0 -316
  250. package/src/state/schemas/__tests__/job-output.test.ts +0 -338
  251. package/src/state/schemas/fleet-state.ts +0 -120
  252. package/src/state/schemas/index.ts +0 -67
  253. package/src/state/schemas/job-metadata.ts +0 -181
  254. package/src/state/schemas/job-output.ts +0 -177
  255. package/src/state/schemas/session-info.ts +0 -92
  256. package/src/state/session.ts +0 -253
  257. package/src/state/types.ts +0 -59
  258. package/src/state/utils/__tests__/atomic.test.ts +0 -723
  259. package/src/state/utils/__tests__/reads.test.ts +0 -1071
  260. package/src/state/utils/atomic.ts +0 -221
  261. package/src/state/utils/index.ts +0 -6
  262. package/src/state/utils/reads.ts +0 -512
  263. package/src/work-sources/__tests__/github.test.ts +0 -1800
  264. package/src/work-sources/__tests__/manager.test.ts +0 -529
  265. package/src/work-sources/__tests__/registry.test.ts +0 -477
  266. package/src/work-sources/__tests__/types.test.ts +0 -479
  267. package/src/work-sources/adapters/github.ts +0 -1166
  268. package/src/work-sources/adapters/index.ts +0 -64
  269. package/src/work-sources/errors.ts +0 -71
  270. package/src/work-sources/index.ts +0 -148
  271. package/src/work-sources/manager.ts +0 -413
  272. package/src/work-sources/registry.ts +0 -178
  273. package/src/work-sources/types.ts +0 -161
  274. package/tsconfig.json +0 -9
  275. package/vitest.config.ts +0 -19
@@ -1,49 +1,29 @@
1
1
  /**
2
- * FleetManager class for library consumers
2
+ * FleetManager - High-level orchestration layer for autonomous agents
3
3
  *
4
- * Provides a simple, high-level API to initialize and run a fleet of agents
5
- * with minimal configuration. Handles config loading, state directory setup,
6
- * and scheduler orchestration internally.
7
- *
8
- * @example
9
- * ```typescript
10
- * import { FleetManager } from '@herdctl/core';
11
- *
12
- * const manager = new FleetManager({
13
- * configPath: './herdctl.yaml',
14
- * stateDir: './.herdctl',
15
- * });
16
- *
17
- * await manager.initialize();
18
- * await manager.start();
4
+ * The FleetManager class provides a simple interface for library consumers
5
+ * to initialize and run agent fleets. It coordinates between:
6
+ * - Configuration loading and validation
7
+ * - State directory management
8
+ * - Scheduler setup and lifecycle
9
+ * - Event emission for monitoring
19
10
  *
20
- * // Later...
21
- * await manager.stop();
22
- * ```
11
+ * @module fleet-manager
23
12
  */
24
13
  import { EventEmitter } from "node:events";
25
14
  import { resolve } from "node:path";
26
15
  import { loadConfig, ConfigNotFoundError, ConfigError, } from "../config/index.js";
27
- import { initStateDirectory, createJob, getJob, updateJob, listJobs } from "../state/index.js";
16
+ import { initStateDirectory } from "../state/index.js";
28
17
  import { Scheduler } from "../scheduler/index.js";
29
- import { join } from "node:path";
30
- import { FleetManagerStateError, FleetManagerConfigError, FleetManagerStateDirError, FleetManagerShutdownError, AgentNotFoundError, ScheduleNotFoundError, ConcurrencyLimitError, InvalidStateError,
31
- // Job control errors (US-6)
32
- JobCancelError, JobForkError, JobNotFoundError, } from "./errors.js";
33
- import { readFleetState } from "../state/fleet-state.js";
34
- // =============================================================================
35
- // Constants
36
- // =============================================================================
37
- /**
38
- * Default check interval in milliseconds (1 second)
39
- */
18
+ import { InvalidStateError, ConfigurationError, FleetManagerStateDirError, FleetManagerShutdownError, } from "./errors.js";
19
+ // Module classes
20
+ import { StatusQueries } from "./status-queries.js";
21
+ import { ScheduleManagement } from "./schedule-management.js";
22
+ import { ConfigReload, computeConfigChanges } from "./config-reload.js";
23
+ import { JobControl } from "./job-control.js";
24
+ import { LogStreaming } from "./log-streaming.js";
25
+ import { ScheduleExecutor } from "./schedule-executor.js";
40
26
  const DEFAULT_CHECK_INTERVAL = 1000;
41
- // =============================================================================
42
- // Default Logger
43
- // =============================================================================
44
- /**
45
- * Create a default console-based logger
46
- */
47
27
  function createDefaultLogger() {
48
28
  return {
49
29
  debug: (message) => console.debug(`[fleet-manager] ${message}`),
@@ -52,66 +32,11 @@ function createDefaultLogger() {
52
32
  error: (message) => console.error(`[fleet-manager] ${message}`),
53
33
  };
54
34
  }
55
- // =============================================================================
56
- // FleetManager Class
57
- // =============================================================================
58
35
  /**
59
- * FleetManager provides a simple API to manage a fleet of agents
60
- *
61
- * This class is the primary entry point for library consumers who want to
62
- * run herdctl programmatically. It handles:
63
- *
64
- * - Configuration loading and validation
65
- * - State directory initialization
66
- * - Scheduler lifecycle management
67
- * - Event emission for monitoring
68
- *
69
- * ## Lifecycle
70
- *
71
- * 1. **Construction**: Create with options (configPath, stateDir)
72
- * 2. **Initialize**: Call `initialize()` to load config and prepare state
73
- * 3. **Start**: Call `start()` to begin scheduler and process schedules
74
- * 4. **Stop**: Call `stop()` to gracefully shut down
75
- *
76
- * ## Events
77
- *
78
- * The FleetManager emits events for monitoring:
79
- * - `initialized` - After successful initialization
80
- * - `started` - When the scheduler starts running
81
- * - `stopped` - When the scheduler stops
82
- * - `error` - When an error occurs
83
- * - `schedule:trigger` - When a schedule triggers an agent
84
- * - `schedule:complete` - When an agent run completes
85
- * - `schedule:error` - When an agent run fails
36
+ * FleetManager provides high-level orchestration for autonomous agents
86
37
  *
87
- * ## Typed Events (US-2)
88
- *
89
- * The FleetManager also supports strongly-typed events via TypeScript:
90
- * - `config:reloaded` - When configuration is hot-reloaded
91
- * - `agent:started` - When an agent is started
92
- * - `agent:stopped` - When an agent is stopped
93
- * - `schedule:triggered` - When a schedule triggers (with payload)
94
- * - `schedule:skipped` - When a schedule is skipped
95
- * - `job:created` - When a job is created
96
- * - `job:output` - When a job produces output
97
- * - `job:completed` - When a job completes successfully
98
- * - `job:failed` - When a job fails
99
- *
100
- * @example
101
- * ```typescript
102
- * // Subscribe to typed events
103
- * manager.on('job:created', (payload) => {
104
- * console.log(`Job ${payload.job.id} created for ${payload.agentName}`);
105
- * });
106
- *
107
- * manager.on('job:output', (payload) => {
108
- * process.stdout.write(payload.output);
109
- * });
110
- *
111
- * manager.on('job:completed', (payload) => {
112
- * console.log(`Job completed in ${payload.durationSeconds}s`);
113
- * });
114
- * ```
38
+ * Implements FleetManagerContext to provide clean access to internal state
39
+ * for composed module classes.
115
40
  */
116
41
  export class FleetManager extends EventEmitter {
117
42
  // Configuration
@@ -129,44 +54,40 @@ export class FleetManager extends EventEmitter {
129
54
  startedAt = null;
130
55
  stoppedAt = null;
131
56
  lastError = null;
132
- /**
133
- * Create a new FleetManager instance
134
- *
135
- * @param options - Configuration options
136
- *
137
- * @example
138
- * ```typescript
139
- * // Minimal configuration
140
- * const manager = new FleetManager({
141
- * configPath: './herdctl.yaml',
142
- * stateDir: './.herdctl',
143
- * });
144
- *
145
- * // With custom logger
146
- * const manager = new FleetManager({
147
- * configPath: './herdctl.yaml',
148
- * stateDir: './.herdctl',
149
- * logger: myLogger,
150
- * checkInterval: 5000, // 5 seconds
151
- * });
152
- * ```
153
- */
57
+ // Module class instances
58
+ statusQueries;
59
+ scheduleManagement;
60
+ configReloadModule;
61
+ jobControl;
62
+ logStreaming;
63
+ scheduleExecutor;
154
64
  constructor(options) {
155
65
  super();
156
66
  this.configPath = options.configPath;
157
67
  this.stateDir = resolve(options.stateDir);
158
68
  this.logger = options.logger ?? createDefaultLogger();
159
69
  this.checkInterval = options.checkInterval ?? DEFAULT_CHECK_INTERVAL;
70
+ // Initialize modules in constructor so they work before initialize() is called
71
+ this.initializeModules();
160
72
  }
161
73
  // ===========================================================================
74
+ // FleetManagerContext Implementation
75
+ // ===========================================================================
76
+ getConfig() { return this.config; }
77
+ getStateDir() { return this.stateDir; }
78
+ getStateDirInfo() { return this.stateDirInfo; }
79
+ getLogger() { return this.logger; }
80
+ getScheduler() { return this.scheduler; }
81
+ getStatus() { return this.status; }
82
+ getInitializedAt() { return this.initializedAt; }
83
+ getStartedAt() { return this.startedAt; }
84
+ getStoppedAt() { return this.stoppedAt; }
85
+ getLastError() { return this.lastError; }
86
+ getCheckInterval() { return this.checkInterval; }
87
+ getEmitter() { return this; }
88
+ // ===========================================================================
162
89
  // Public State Accessors
163
90
  // ===========================================================================
164
- /**
165
- * Get the current fleet manager state
166
- *
167
- * This provides a snapshot of the fleet manager's current status and
168
- * configuration for monitoring purposes.
169
- */
170
91
  get state() {
171
92
  return {
172
93
  status: this.status,
@@ -177,473 +98,26 @@ export class FleetManager extends EventEmitter {
177
98
  lastError: this.lastError,
178
99
  };
179
100
  }
180
- /**
181
- * Get the loaded configuration
182
- *
183
- * @returns The resolved configuration, or null if not initialized
184
- */
185
- getConfig() {
186
- return this.config;
187
- }
188
- /**
189
- * Get the loaded agents
190
- *
191
- * @returns Array of resolved agents, or empty array if not initialized
192
- */
193
- getAgents() {
194
- return this.config?.agents ?? [];
195
- }
196
- // ===========================================================================
197
- // Fleet Status Query Methods (US-3)
198
- // ===========================================================================
199
- /**
200
- * Get overall fleet status
201
- *
202
- * Returns a comprehensive snapshot of the fleet state including:
203
- * - Current state and uptime
204
- * - Agent counts (total, idle, running, error)
205
- * - Job counts
206
- * - Scheduler information
207
- *
208
- * This method works whether the fleet is running or stopped.
209
- *
210
- * @returns A consistent FleetStatus snapshot
211
- *
212
- * @example
213
- * ```typescript
214
- * const status = await manager.getFleetStatus();
215
- * console.log(`Fleet: ${status.state}`);
216
- * console.log(`Uptime: ${status.uptimeSeconds}s`);
217
- * console.log(`Running jobs: ${status.counts.runningJobs}`);
218
- * ```
219
- */
220
- async getFleetStatus() {
221
- // Get agent info to compute counts
222
- const agentInfoList = await this.getAgentInfo();
223
- // Compute counts from agent info
224
- const counts = this.computeFleetCounts(agentInfoList);
225
- // Compute uptime
226
- let uptimeSeconds = null;
227
- if (this.startedAt) {
228
- const startTime = new Date(this.startedAt).getTime();
229
- const endTime = this.stoppedAt
230
- ? new Date(this.stoppedAt).getTime()
231
- : Date.now();
232
- uptimeSeconds = Math.floor((endTime - startTime) / 1000);
233
- }
234
- // Get scheduler state
235
- const schedulerState = this.scheduler?.getState();
236
- return {
237
- state: this.status,
238
- uptimeSeconds,
239
- initializedAt: this.initializedAt,
240
- startedAt: this.startedAt,
241
- stoppedAt: this.stoppedAt,
242
- counts,
243
- scheduler: {
244
- status: schedulerState?.status ?? "stopped",
245
- checkCount: schedulerState?.checkCount ?? 0,
246
- triggerCount: schedulerState?.triggerCount ?? 0,
247
- lastCheckAt: schedulerState?.lastCheckAt ?? null,
248
- checkIntervalMs: this.checkInterval,
249
- },
250
- lastError: this.lastError,
251
- };
252
- }
253
- /**
254
- * Get information about all configured agents
255
- *
256
- * Returns detailed information for each agent including:
257
- * - Current status and job information
258
- * - Schedule details with runtime state
259
- * - Configuration details
260
- *
261
- * This method works whether the fleet is running or stopped.
262
- *
263
- * @returns Array of AgentInfo objects with current state
264
- *
265
- * @example
266
- * ```typescript
267
- * const agents = await manager.getAgentInfo();
268
- * for (const agent of agents) {
269
- * console.log(`${agent.name}: ${agent.status}`);
270
- * console.log(` Schedules: ${agent.scheduleCount}`);
271
- * }
272
- * ```
273
- */
274
- async getAgentInfo() {
275
- const agents = this.config?.agents ?? [];
276
- // Read fleet state for runtime information
277
- const fleetState = await this.readFleetStateSnapshot();
278
- return agents.map((agent) => {
279
- const agentState = fleetState.agents[agent.name];
280
- return this.buildAgentInfo(agent, agentState);
281
- });
282
- }
283
- /**
284
- * Get information about a specific agent by name
285
- *
286
- * Returns detailed information for the specified agent including:
287
- * - Current status and job information
288
- * - Schedule details with runtime state
289
- * - Configuration details
290
- *
291
- * This method works whether the fleet is running or stopped.
292
- *
293
- * @param name - The agent name to look up
294
- * @returns AgentInfo for the specified agent
295
- * @throws {AgentNotFoundError} If no agent with that name exists
296
- *
297
- * @example
298
- * ```typescript
299
- * const agent = await manager.getAgentInfoByName('my-agent');
300
- * console.log(`Agent: ${agent.name}`);
301
- * console.log(`Status: ${agent.status}`);
302
- * console.log(`Running: ${agent.runningCount}/${agent.maxConcurrent}`);
303
- * ```
304
- */
305
- async getAgentInfoByName(name) {
306
- const agents = this.config?.agents ?? [];
307
- const agent = agents.find((a) => a.name === name);
308
- if (!agent) {
309
- throw new AgentNotFoundError(name);
310
- }
311
- // Read fleet state for runtime information
312
- const fleetState = await this.readFleetStateSnapshot();
313
- const agentState = fleetState.agents[name];
314
- return this.buildAgentInfo(agent, agentState);
315
- }
316
- // ===========================================================================
317
- // Private Status Query Helpers
318
- // ===========================================================================
319
- /**
320
- * Read fleet state from disk for status queries
321
- *
322
- * This provides a consistent snapshot of the fleet state.
323
- */
324
- async readFleetStateSnapshot() {
325
- if (!this.stateDirInfo) {
326
- // Not initialized yet, return empty state
327
- return { fleet: {}, agents: {} };
328
- }
329
- return await readFleetState(this.stateDirInfo.stateFile, {
330
- logger: { warn: this.logger.warn },
331
- });
332
- }
333
- /**
334
- * Build AgentInfo from configuration and state
335
- */
336
- buildAgentInfo(agent, agentState) {
337
- // Build schedule info
338
- const schedules = this.buildScheduleInfoList(agent, agentState);
339
- // Get running count from scheduler or state
340
- const runningCount = this.scheduler?.getRunningJobCount(agent.name) ?? 0;
341
- // Determine workspace path
342
- let workspace;
343
- if (typeof agent.workspace === "string") {
344
- workspace = agent.workspace;
345
- }
346
- else if (agent.workspace?.root) {
347
- workspace = agent.workspace.root;
348
- }
349
- return {
350
- name: agent.name,
351
- description: agent.description,
352
- status: agentState?.status ?? "idle",
353
- currentJobId: agentState?.current_job ?? null,
354
- lastJobId: agentState?.last_job ?? null,
355
- maxConcurrent: agent.instances?.max_concurrent ?? 1,
356
- runningCount,
357
- errorMessage: agentState?.error_message ?? null,
358
- scheduleCount: schedules.length,
359
- schedules,
360
- model: agent.model,
361
- workspace,
362
- };
363
- }
364
- /**
365
- * Build schedule info list from agent configuration and state
366
- */
367
- buildScheduleInfoList(agent, agentState) {
368
- if (!agent.schedules) {
369
- return [];
370
- }
371
- return Object.entries(agent.schedules).map(([name, schedule]) => {
372
- const scheduleState = agentState?.schedules?.[name];
373
- return {
374
- name,
375
- agentName: agent.name,
376
- type: schedule.type,
377
- interval: schedule.interval,
378
- expression: schedule.expression,
379
- status: scheduleState?.status ?? "idle",
380
- lastRunAt: scheduleState?.last_run_at ?? null,
381
- nextRunAt: scheduleState?.next_run_at ?? null,
382
- lastError: scheduleState?.last_error ?? null,
383
- };
384
- });
385
- }
386
- /**
387
- * Compute fleet counts from agent info list
388
- */
389
- computeFleetCounts(agentInfoList) {
390
- let idleAgents = 0;
391
- let runningAgents = 0;
392
- let errorAgents = 0;
393
- let totalSchedules = 0;
394
- let runningSchedules = 0;
395
- let runningJobs = 0;
396
- for (const agent of agentInfoList) {
397
- switch (agent.status) {
398
- case "idle":
399
- idleAgents++;
400
- break;
401
- case "running":
402
- runningAgents++;
403
- break;
404
- case "error":
405
- errorAgents++;
406
- break;
407
- }
408
- totalSchedules += agent.scheduleCount;
409
- runningJobs += agent.runningCount;
410
- for (const schedule of agent.schedules) {
411
- if (schedule.status === "running") {
412
- runningSchedules++;
413
- }
414
- }
415
- }
416
- return {
417
- totalAgents: agentInfoList.length,
418
- idleAgents,
419
- runningAgents,
420
- errorAgents,
421
- totalSchedules,
422
- runningSchedules,
423
- runningJobs,
424
- };
425
- }
426
- // ===========================================================================
427
- // Schedule Management Methods (US-7)
428
- // ===========================================================================
429
- /**
430
- * Get all schedules across all agents
431
- *
432
- * Returns a list of all configured schedules with their current state,
433
- * including next trigger times.
434
- *
435
- * @returns Array of ScheduleInfo objects with current state
436
- *
437
- * @example
438
- * ```typescript
439
- * const schedules = await manager.getSchedules();
440
- * for (const schedule of schedules) {
441
- * console.log(`${schedule.agentName}/${schedule.name}: ${schedule.status}`);
442
- * console.log(` Next run: ${schedule.nextRunAt}`);
443
- * }
444
- * ```
445
- */
446
- async getSchedules() {
447
- const agents = this.config?.agents ?? [];
448
- const fleetState = await this.readFleetStateSnapshot();
449
- const allSchedules = [];
450
- for (const agent of agents) {
451
- const agentState = fleetState.agents[agent.name];
452
- const schedules = this.buildScheduleInfoList(agent, agentState);
453
- allSchedules.push(...schedules);
454
- }
455
- return allSchedules;
456
- }
457
- /**
458
- * Get a specific schedule by agent name and schedule name
459
- *
460
- * @param agentName - The name of the agent
461
- * @param scheduleName - The name of the schedule
462
- * @returns The schedule info with current state
463
- * @throws {AgentNotFoundError} If the agent doesn't exist
464
- * @throws {ScheduleNotFoundError} If the schedule doesn't exist
465
- *
466
- * @example
467
- * ```typescript
468
- * const schedule = await manager.getSchedule('my-agent', 'hourly');
469
- * console.log(`Status: ${schedule.status}`);
470
- * console.log(`Last run: ${schedule.lastRunAt}`);
471
- * console.log(`Next run: ${schedule.nextRunAt}`);
472
- * ```
473
- */
474
- async getSchedule(agentName, scheduleName) {
475
- const agents = this.config?.agents ?? [];
476
- const agent = agents.find((a) => a.name === agentName);
477
- if (!agent) {
478
- throw new AgentNotFoundError(agentName, {
479
- availableAgents: agents.map((a) => a.name),
480
- });
481
- }
482
- if (!agent.schedules || !(scheduleName in agent.schedules)) {
483
- const availableSchedules = agent.schedules
484
- ? Object.keys(agent.schedules)
485
- : [];
486
- throw new ScheduleNotFoundError(agentName, scheduleName, {
487
- availableSchedules,
488
- });
489
- }
490
- const fleetState = await this.readFleetStateSnapshot();
491
- const agentState = fleetState.agents[agentName];
492
- const schedule = agent.schedules[scheduleName];
493
- const scheduleState = agentState?.schedules?.[scheduleName];
494
- return {
495
- name: scheduleName,
496
- agentName,
497
- type: schedule.type,
498
- interval: schedule.interval,
499
- expression: schedule.expression,
500
- status: scheduleState?.status ?? "idle",
501
- lastRunAt: scheduleState?.last_run_at ?? null,
502
- nextRunAt: scheduleState?.next_run_at ?? null,
503
- lastError: scheduleState?.last_error ?? null,
504
- };
505
- }
506
- /**
507
- * Enable a disabled schedule
508
- *
509
- * Enables a schedule that was previously disabled, allowing it to trigger
510
- * again on its configured interval. The enabled state is persisted to the
511
- * state directory and survives restarts.
512
- *
513
- * @param agentName - The name of the agent
514
- * @param scheduleName - The name of the schedule
515
- * @returns The updated schedule info
516
- * @throws {AgentNotFoundError} If the agent doesn't exist
517
- * @throws {ScheduleNotFoundError} If the schedule doesn't exist
518
- *
519
- * @example
520
- * ```typescript
521
- * // Enable a previously disabled schedule
522
- * const schedule = await manager.enableSchedule('my-agent', 'hourly');
523
- * console.log(`Schedule status: ${schedule.status}`); // 'idle'
524
- * ```
525
- */
526
- async enableSchedule(agentName, scheduleName) {
527
- // Validate the agent and schedule exist
528
- const agents = this.config?.agents ?? [];
529
- const agent = agents.find((a) => a.name === agentName);
530
- if (!agent) {
531
- throw new AgentNotFoundError(agentName, {
532
- availableAgents: agents.map((a) => a.name),
533
- });
534
- }
535
- if (!agent.schedules || !(scheduleName in agent.schedules)) {
536
- const availableSchedules = agent.schedules
537
- ? Object.keys(agent.schedules)
538
- : [];
539
- throw new ScheduleNotFoundError(agentName, scheduleName, {
540
- availableSchedules,
541
- });
542
- }
543
- // Update schedule state to enabled (idle)
544
- const { updateScheduleState } = await import("../scheduler/schedule-state.js");
545
- await updateScheduleState(this.stateDir, agentName, scheduleName, { status: "idle" }, { logger: { warn: this.logger.warn } });
546
- this.logger.info(`Enabled schedule ${agentName}/${scheduleName}`);
547
- // Return the updated schedule info
548
- return this.getSchedule(agentName, scheduleName);
549
- }
550
- /**
551
- * Disable a schedule
552
- *
553
- * Disables a schedule, preventing it from triggering on its configured
554
- * interval. The schedule remains in the configuration but won't run until
555
- * re-enabled. The disabled state is persisted to the state directory and
556
- * survives restarts.
557
- *
558
- * @param agentName - The name of the agent
559
- * @param scheduleName - The name of the schedule
560
- * @returns The updated schedule info
561
- * @throws {AgentNotFoundError} If the agent doesn't exist
562
- * @throws {ScheduleNotFoundError} If the schedule doesn't exist
563
- *
564
- * @example
565
- * ```typescript
566
- * // Disable a schedule temporarily
567
- * const schedule = await manager.disableSchedule('my-agent', 'hourly');
568
- * console.log(`Schedule status: ${schedule.status}`); // 'disabled'
569
- *
570
- * // Later, re-enable it
571
- * await manager.enableSchedule('my-agent', 'hourly');
572
- * ```
573
- */
574
- async disableSchedule(agentName, scheduleName) {
575
- // Validate the agent and schedule exist
576
- const agents = this.config?.agents ?? [];
577
- const agent = agents.find((a) => a.name === agentName);
578
- if (!agent) {
579
- throw new AgentNotFoundError(agentName, {
580
- availableAgents: agents.map((a) => a.name),
581
- });
582
- }
583
- if (!agent.schedules || !(scheduleName in agent.schedules)) {
584
- const availableSchedules = agent.schedules
585
- ? Object.keys(agent.schedules)
586
- : [];
587
- throw new ScheduleNotFoundError(agentName, scheduleName, {
588
- availableSchedules,
589
- });
590
- }
591
- // Update schedule state to disabled
592
- const { updateScheduleState } = await import("../scheduler/schedule-state.js");
593
- await updateScheduleState(this.stateDir, agentName, scheduleName, { status: "disabled" }, { logger: { warn: this.logger.warn } });
594
- this.logger.info(`Disabled schedule ${agentName}/${scheduleName}`);
595
- // Return the updated schedule info
596
- return this.getSchedule(agentName, scheduleName);
597
- }
101
+ getAgents() { return this.config?.agents ?? []; }
598
102
  // ===========================================================================
599
103
  // Lifecycle Methods
600
104
  // ===========================================================================
601
- /**
602
- * Initialize the fleet manager
603
- *
604
- * This method:
605
- * 1. Loads and validates the configuration file
606
- * 2. Initializes the state directory structure
607
- * 3. Prepares the scheduler (but does not start it)
608
- *
609
- * After initialization, the fleet manager is ready to start.
610
- *
611
- * @throws {FleetManagerStateError} If already initialized or running
612
- * @throws {FleetManagerConfigError} If configuration is invalid or not found
613
- * @throws {FleetManagerStateDirError} If state directory cannot be created
614
- *
615
- * @example
616
- * ```typescript
617
- * const manager = new FleetManager({ ... });
618
- * await manager.initialize();
619
- * console.log(`Loaded ${manager.state.agentCount} agents`);
620
- * ```
621
- */
622
105
  async initialize() {
623
- // Validate current state
624
106
  if (this.status !== "uninitialized" && this.status !== "stopped") {
625
- throw new FleetManagerStateError("initialize", this.status, ["uninitialized", "stopped"]);
107
+ throw new InvalidStateError("initialize", this.status, ["uninitialized", "stopped"]);
626
108
  }
627
109
  this.logger.info("Initializing fleet manager...");
628
110
  try {
629
- // Load configuration
630
- this.logger.debug(this.configPath
631
- ? `Loading config from: ${this.configPath}`
632
- : "Auto-discovering config...");
633
111
  this.config = await this.loadConfiguration();
634
112
  this.logger.info(`Loaded ${this.config.agents.length} agent(s) from config`);
635
- // Initialize state directory
636
- this.logger.debug(`Initializing state directory: ${this.stateDir}`);
637
113
  this.stateDirInfo = await this.initializeStateDir();
638
114
  this.logger.debug("State directory initialized");
639
- // Create scheduler (but don't start it)
640
115
  this.scheduler = new Scheduler({
641
116
  stateDir: this.stateDir,
642
117
  checkInterval: this.checkInterval,
643
118
  logger: this.logger,
644
119
  onTrigger: (info) => this.handleScheduleTrigger(info),
645
120
  });
646
- // Update state
647
121
  this.status = "initialized";
648
122
  this.initializedAt = new Date().toISOString();
649
123
  this.lastError = null;
@@ -657,40 +131,14 @@ export class FleetManager extends EventEmitter {
657
131
  throw error;
658
132
  }
659
133
  }
660
- /**
661
- * Start the fleet manager
662
- *
663
- * This begins the scheduler, which will:
664
- * 1. Check agent schedules at the configured interval
665
- * 2. Trigger agents when their schedules are due
666
- * 3. Track schedule state in the state directory
667
- *
668
- * @throws {FleetManagerStateError} If not initialized
669
- *
670
- * @example
671
- * ```typescript
672
- * await manager.initialize();
673
- * await manager.start();
674
- *
675
- * // The manager is now running and processing schedules
676
- * manager.on('schedule:trigger', (agent, schedule) => {
677
- * console.log(`Triggered ${agent}/${schedule}`);
678
- * });
679
- * ```
680
- */
681
134
  async start() {
682
- // Validate current state
683
135
  if (this.status !== "initialized") {
684
- throw new FleetManagerStateError("start", this.status, "initialized");
136
+ throw new InvalidStateError("start", this.status, "initialized");
685
137
  }
686
138
  this.logger.info("Starting fleet manager...");
687
139
  this.status = "starting";
688
140
  try {
689
- // Start the scheduler with loaded agents
690
- const agents = this.config.agents;
691
- // Start scheduler in background (don't await the loop)
692
- this.startSchedulerAsync(agents);
693
- // Update state
141
+ this.startSchedulerAsync(this.config.agents);
694
142
  this.status = "running";
695
143
  this.startedAt = new Date().toISOString();
696
144
  this.stoppedAt = null;
@@ -704,75 +152,29 @@ export class FleetManager extends EventEmitter {
704
152
  throw error;
705
153
  }
706
154
  }
707
- /**
708
- * Stop the fleet manager gracefully
709
- *
710
- * This will:
711
- * 1. Signal the scheduler to stop accepting new triggers
712
- * 2. Wait for running jobs to complete (with timeout)
713
- * 3. If timeout is reached and cancelOnTimeout is true, cancel remaining jobs
714
- * 4. Persist all state before shutdown completes
715
- * 5. Emit 'stopped' event when complete
716
- *
717
- * @param options - Stop options for controlling shutdown behavior
718
- * @throws {FleetManagerShutdownError} If shutdown times out and cancelOnTimeout is false
719
- *
720
- * @example
721
- * ```typescript
722
- * // Normal shutdown - wait for jobs with default 30s timeout
723
- * await manager.stop();
724
- *
725
- * // Shutdown with custom timeout
726
- * await manager.stop({ timeout: 60000 });
727
- *
728
- * // Shutdown without waiting for jobs (not recommended)
729
- * await manager.stop({ waitForJobs: false });
730
- *
731
- * // Cancel jobs if they don't complete in time
732
- * await manager.stop({
733
- * timeout: 30000,
734
- * cancelOnTimeout: true,
735
- * cancelTimeout: 10000,
736
- * });
737
- * ```
738
- */
739
155
  async stop(options) {
740
156
  if (this.status !== "running" && this.status !== "starting") {
741
157
  this.logger.debug(`Stop called but status is '${this.status}', ignoring`);
742
158
  return;
743
159
  }
744
- const waitForJobs = options?.waitForJobs ?? true;
745
- const timeout = options?.timeout ?? 30000;
746
- const cancelOnTimeout = options?.cancelOnTimeout ?? false;
747
- const cancelTimeout = options?.cancelTimeout ?? 10000;
160
+ const { waitForJobs = true, timeout = 30000, cancelOnTimeout = false, cancelTimeout = 10000 } = options ?? {};
748
161
  this.logger.info("Stopping fleet manager...");
749
162
  this.status = "stopping";
750
163
  try {
751
- // Stop the scheduler - don't wait for jobs here, we'll handle it ourselves
752
- // This stops new triggers from being accepted
753
164
  if (this.scheduler) {
754
165
  try {
755
- await this.scheduler.stop({
756
- waitForJobs,
757
- timeout,
758
- });
166
+ await this.scheduler.stop({ waitForJobs, timeout });
759
167
  }
760
168
  catch (error) {
761
- // Check if it's a scheduler shutdown timeout
762
169
  if (error instanceof Error && error.name === "SchedulerShutdownError") {
763
170
  if (cancelOnTimeout) {
764
- // Cancel all running jobs
765
171
  this.logger.info("Timeout reached, cancelling running jobs...");
766
- await this.cancelRunningJobs(cancelTimeout);
172
+ await this.jobControl.cancelRunningJobs(cancelTimeout);
767
173
  }
768
174
  else {
769
- // Re-throw the error
770
175
  this.status = "error";
771
176
  this.lastError = error.message;
772
- throw new FleetManagerShutdownError(error.message, {
773
- timedOut: true,
774
- cause: error,
775
- });
177
+ throw new FleetManagerShutdownError(error.message, { timedOut: true, cause: error });
776
178
  }
777
179
  }
778
180
  else {
@@ -780,9 +182,7 @@ export class FleetManager extends EventEmitter {
780
182
  }
781
183
  }
782
184
  }
783
- // Persist fleet state before completing shutdown
784
185
  await this.persistShutdownState();
785
- // Update state
786
186
  this.status = "stopped";
787
187
  this.stoppedAt = new Date().toISOString();
788
188
  this.logger.info("Fleet manager stopped");
@@ -794,974 +194,54 @@ export class FleetManager extends EventEmitter {
794
194
  throw error;
795
195
  }
796
196
  }
797
- /**
798
- * Reload configuration without restarting the fleet
799
- *
800
- * This method provides hot configuration reload capability:
801
- * 1. Loads and validates the new configuration
802
- * 2. If validation fails, keeps the old configuration (fails gracefully)
803
- * 3. Running jobs continue with their original configuration
804
- * 4. New jobs will use the new configuration
805
- * 5. Updates the scheduler with new agent definitions and schedules
806
- * 6. Emits a 'config:reloaded' event with a list of changes
807
- *
808
- * @returns The reload result with change details
809
- * @throws {InvalidStateError} If the fleet manager is not initialized
810
- * @throws {FleetManagerConfigError} If the new configuration is invalid (re-thrown after logging)
811
- *
812
- * @example
813
- * ```typescript
814
- * // Reload configuration
815
- * const result = await manager.reload();
816
- * console.log(`Reloaded with ${result.changes.length} changes`);
817
- *
818
- * // Subscribe to reload events
819
- * manager.on('config:reloaded', (payload) => {
820
- * console.log(`Config reloaded: ${payload.changes.length} changes`);
821
- * for (const change of payload.changes) {
822
- * console.log(` ${change.type} ${change.category}: ${change.name}`);
823
- * }
824
- * });
825
- * ```
826
- */
827
- async reload() {
828
- // Validate state - must be at least initialized
829
- if (this.status === "uninitialized") {
830
- throw new InvalidStateError("reload", this.status, ["initialized", "starting", "running", "stopping", "stopped"]);
831
- }
832
- this.logger.info("Reloading configuration...");
833
- // Store old config for comparison
834
- const oldConfig = this.config;
835
- // Try to load new configuration
836
- let newConfig;
837
- try {
838
- newConfig = await this.loadConfiguration();
839
- }
840
- catch (error) {
841
- // Log the error but don't update config - fail gracefully
842
- this.logger.error(`Failed to reload configuration: ${error instanceof Error ? error.message : String(error)}`);
843
- this.logger.info("Keeping existing configuration");
844
- // Re-throw so caller knows reload failed
845
- throw error;
846
- }
847
- // Compute changes between old and new config
848
- const changes = this.computeConfigChanges(oldConfig, newConfig);
849
- // Update the stored configuration
850
- this.config = newConfig;
851
- // Update the scheduler with new agents (if scheduler exists and is running)
852
- if (this.scheduler) {
853
- this.scheduler.setAgents(newConfig.agents);
854
- this.logger.debug(`Updated scheduler with ${newConfig.agents.length} agents`);
855
- }
856
- const timestamp = new Date().toISOString();
857
- // Build the reload payload
858
- const payload = {
859
- agentCount: newConfig.agents.length,
860
- agentNames: newConfig.agents.map((a) => a.name),
861
- configPath: newConfig.configPath,
862
- changes,
863
- timestamp,
864
- };
865
- // Emit the config:reloaded event
866
- this.emit("config:reloaded", payload);
867
- this.logger.info(`Configuration reloaded: ${newConfig.agents.length} agents, ${changes.length} changes`);
868
- return payload;
869
- }
870
- /**
871
- * Compute the list of changes between old and new configuration
872
- */
873
- computeConfigChanges(oldConfig, newConfig) {
874
- const changes = [];
875
- const oldAgents = oldConfig?.agents ?? [];
876
- const newAgents = newConfig.agents;
877
- const oldAgentNames = new Set(oldAgents.map((a) => a.name));
878
- const newAgentNames = new Set(newAgents.map((a) => a.name));
879
- // Find added agents
880
- for (const agent of newAgents) {
881
- if (!oldAgentNames.has(agent.name)) {
882
- changes.push({
883
- type: "added",
884
- category: "agent",
885
- name: agent.name,
886
- details: agent.description,
887
- });
888
- // Also add all schedules for new agents
889
- if (agent.schedules) {
890
- for (const scheduleName of Object.keys(agent.schedules)) {
891
- changes.push({
892
- type: "added",
893
- category: "schedule",
894
- name: `${agent.name}/${scheduleName}`,
895
- });
896
- }
897
- }
898
- }
899
- }
900
- // Find removed agents
901
- for (const agent of oldAgents) {
902
- if (!newAgentNames.has(agent.name)) {
903
- changes.push({
904
- type: "removed",
905
- category: "agent",
906
- name: agent.name,
907
- });
908
- // Also mark all schedules as removed
909
- if (agent.schedules) {
910
- for (const scheduleName of Object.keys(agent.schedules)) {
911
- changes.push({
912
- type: "removed",
913
- category: "schedule",
914
- name: `${agent.name}/${scheduleName}`,
915
- });
916
- }
917
- }
918
- }
919
- }
920
- // Find modified agents and schedules
921
- for (const newAgent of newAgents) {
922
- const oldAgent = oldAgents.find((a) => a.name === newAgent.name);
923
- if (!oldAgent) {
924
- continue; // Already handled as "added"
925
- }
926
- // Check for agent-level modifications
927
- const agentModified = this.isAgentModified(oldAgent, newAgent);
928
- if (agentModified) {
929
- changes.push({
930
- type: "modified",
931
- category: "agent",
932
- name: newAgent.name,
933
- details: agentModified,
934
- });
935
- }
936
- // Check for schedule changes
937
- const oldScheduleNames = new Set(oldAgent.schedules ? Object.keys(oldAgent.schedules) : []);
938
- const newScheduleNames = new Set(newAgent.schedules ? Object.keys(newAgent.schedules) : []);
939
- // Added schedules
940
- for (const scheduleName of newScheduleNames) {
941
- if (!oldScheduleNames.has(scheduleName)) {
942
- changes.push({
943
- type: "added",
944
- category: "schedule",
945
- name: `${newAgent.name}/${scheduleName}`,
946
- });
947
- }
948
- }
949
- // Removed schedules
950
- for (const scheduleName of oldScheduleNames) {
951
- if (!newScheduleNames.has(scheduleName)) {
952
- changes.push({
953
- type: "removed",
954
- category: "schedule",
955
- name: `${newAgent.name}/${scheduleName}`,
956
- });
957
- }
958
- }
959
- // Modified schedules
960
- for (const scheduleName of newScheduleNames) {
961
- if (oldScheduleNames.has(scheduleName)) {
962
- const oldSchedule = oldAgent.schedules[scheduleName];
963
- const newSchedule = newAgent.schedules[scheduleName];
964
- if (this.isScheduleModified(oldSchedule, newSchedule)) {
965
- changes.push({
966
- type: "modified",
967
- category: "schedule",
968
- name: `${newAgent.name}/${scheduleName}`,
969
- details: this.getScheduleModificationDetails(oldSchedule, newSchedule),
970
- });
971
- }
972
- }
973
- }
974
- }
975
- return changes;
976
- }
977
- /**
978
- * Check if an agent configuration has been modified
979
- * Returns a description of what changed, or null if not modified
980
- */
981
- isAgentModified(oldAgent, newAgent) {
982
- const modifications = [];
983
- // Check key properties
984
- if (oldAgent.description !== newAgent.description) {
985
- modifications.push("description");
986
- }
987
- if (oldAgent.model !== newAgent.model) {
988
- modifications.push("model");
989
- }
990
- if (oldAgent.max_turns !== newAgent.max_turns) {
991
- modifications.push("max_turns");
992
- }
993
- if (oldAgent.system_prompt !== newAgent.system_prompt) {
994
- modifications.push("system_prompt");
995
- }
996
- // Check workspace
997
- const oldWorkspace = typeof oldAgent.workspace === "string"
998
- ? oldAgent.workspace
999
- : oldAgent.workspace?.root;
1000
- const newWorkspace = typeof newAgent.workspace === "string"
1001
- ? newAgent.workspace
1002
- : newAgent.workspace?.root;
1003
- if (oldWorkspace !== newWorkspace) {
1004
- modifications.push("workspace");
1005
- }
1006
- // Check instances
1007
- const oldMaxConcurrent = oldAgent.instances?.max_concurrent ?? 1;
1008
- const newMaxConcurrent = newAgent.instances?.max_concurrent ?? 1;
1009
- if (oldMaxConcurrent !== newMaxConcurrent) {
1010
- modifications.push("max_concurrent");
1011
- }
1012
- return modifications.length > 0 ? modifications.join(", ") : null;
1013
- }
1014
- /**
1015
- * Check if a schedule configuration has been modified
1016
- */
1017
- isScheduleModified(oldSchedule, newSchedule) {
1018
- return (oldSchedule.type !== newSchedule.type ||
1019
- oldSchedule.interval !== newSchedule.interval ||
1020
- oldSchedule.expression !== newSchedule.expression ||
1021
- oldSchedule.prompt !== newSchedule.prompt);
1022
- }
1023
- /**
1024
- * Get a description of what changed in a schedule
1025
- */
1026
- getScheduleModificationDetails(oldSchedule, newSchedule) {
1027
- const details = [];
1028
- if (oldSchedule.type !== newSchedule.type) {
1029
- details.push(`type: ${oldSchedule.type} → ${newSchedule.type}`);
1030
- }
1031
- if (oldSchedule.interval !== newSchedule.interval) {
1032
- details.push(`interval: ${oldSchedule.interval ?? "none"} → ${newSchedule.interval ?? "none"}`);
1033
- }
1034
- if (oldSchedule.expression !== newSchedule.expression) {
1035
- details.push(`expression: ${oldSchedule.expression ?? "none"} → ${newSchedule.expression ?? "none"}`);
1036
- }
1037
- if (oldSchedule.prompt !== newSchedule.prompt) {
1038
- details.push("prompt changed");
1039
- }
1040
- return details.join("; ");
1041
- }
1042
- /**
1043
- * Cancel all running jobs during shutdown
1044
- *
1045
- * @param cancelTimeout - Timeout for each job cancellation
1046
- */
1047
- async cancelRunningJobs(cancelTimeout) {
1048
- const jobsDir = join(this.stateDir, "jobs");
1049
- // Get all running jobs from the fleet status
1050
- const agentInfoList = await this.getAgentInfo();
1051
- const runningJobIds = [];
1052
- for (const agent of agentInfoList) {
1053
- if (agent.currentJobId) {
1054
- runningJobIds.push(agent.currentJobId);
1055
- }
1056
- }
1057
- if (runningJobIds.length === 0) {
1058
- this.logger.debug("No running jobs to cancel");
1059
- return;
1060
- }
1061
- this.logger.info(`Cancelling ${runningJobIds.length} running job(s)...`);
1062
- // Cancel all jobs in parallel
1063
- const cancelPromises = runningJobIds.map(async (jobId) => {
1064
- try {
1065
- const result = await this.cancelJob(jobId, { timeout: cancelTimeout });
1066
- this.logger.debug(`Cancelled job ${jobId}: ${result.terminationType}`);
1067
- }
1068
- catch (error) {
1069
- this.logger.warn(`Failed to cancel job ${jobId}: ${error.message}`);
1070
- }
1071
- });
1072
- await Promise.all(cancelPromises);
1073
- this.logger.info("All jobs cancelled");
1074
- }
1075
- /**
1076
- * Persist shutdown state to ensure all state is saved before completing
1077
- */
1078
- async persistShutdownState() {
1079
- if (!this.stateDirInfo) {
1080
- return;
1081
- }
1082
- // Persist fleet state
1083
- const { writeFleetState } = await import("../state/fleet-state.js");
1084
- // Read current state and update with stopped status
1085
- const currentState = await this.readFleetStateSnapshot();
1086
- // Update fleet-level state
1087
- const updatedState = {
1088
- ...currentState,
1089
- fleet: {
1090
- ...currentState.fleet,
1091
- stoppedAt: new Date().toISOString(),
1092
- },
1093
- };
1094
- try {
1095
- await writeFleetState(this.stateDirInfo.stateFile, updatedState);
1096
- this.logger.debug("Fleet state persisted");
1097
- }
1098
- catch (error) {
1099
- this.logger.warn(`Failed to persist fleet state: ${error.message}`);
1100
- }
1101
- }
1102
197
  // ===========================================================================
1103
- // Manual Triggering (US-5)
198
+ // Public API - One-liner delegations to module classes
1104
199
  // ===========================================================================
1105
- /**
1106
- * Manually trigger an agent outside its normal schedule
1107
- *
1108
- * This method allows you to trigger an agent on-demand for testing or
1109
- * handling urgent situations. You can optionally specify a schedule to use
1110
- * for configuration (prompt, work source, etc.) or pass runtime options
1111
- * to override defaults.
1112
- *
1113
- * @param agentName - Name of the agent to trigger
1114
- * @param scheduleName - Optional schedule name to use for configuration
1115
- * @param options - Optional runtime options to override defaults
1116
- * @returns The created job information
1117
- * @throws {InvalidStateError} If the fleet manager is not initialized
1118
- * @throws {AgentNotFoundError} If the agent doesn't exist
1119
- * @throws {ScheduleNotFoundError} If the specified schedule doesn't exist
1120
- * @throws {ConcurrencyLimitError} If the agent is at capacity and bypassConcurrencyLimit is false
1121
- *
1122
- * @example
1123
- * ```typescript
1124
- * // Trigger with agent defaults
1125
- * const job = await manager.trigger('my-agent');
1126
- *
1127
- * // Trigger a specific schedule
1128
- * const job = await manager.trigger('my-agent', 'hourly');
1129
- *
1130
- * // Trigger with custom prompt
1131
- * const job = await manager.trigger('my-agent', undefined, {
1132
- * prompt: 'Review the latest security updates',
1133
- * });
1134
- *
1135
- * // Force trigger even at capacity
1136
- * const job = await manager.trigger('my-agent', undefined, {
1137
- * bypassConcurrencyLimit: true,
1138
- * });
1139
- * ```
1140
- */
1141
- async trigger(agentName, scheduleName, options) {
1142
- // Validate state - must be at least initialized
1143
- if (this.status === "uninitialized") {
1144
- throw new InvalidStateError("trigger", this.status, ["initialized", "running", "stopped"]);
1145
- }
1146
- // Find the agent
1147
- const agents = this.config?.agents ?? [];
1148
- const agent = agents.find((a) => a.name === agentName);
1149
- if (!agent) {
1150
- throw new AgentNotFoundError(agentName, {
1151
- availableAgents: agents.map((a) => a.name),
1152
- });
1153
- }
1154
- // If a schedule name is provided, validate it exists
1155
- let schedule;
1156
- if (scheduleName) {
1157
- if (!agent.schedules || !(scheduleName in agent.schedules)) {
1158
- const availableSchedules = agent.schedules
1159
- ? Object.keys(agent.schedules)
1160
- : [];
1161
- throw new ScheduleNotFoundError(agentName, scheduleName, {
1162
- availableSchedules,
1163
- });
1164
- }
1165
- schedule = agent.schedules[scheduleName];
1166
- }
1167
- // Check concurrency limits unless bypassed
1168
- if (!options?.bypassConcurrencyLimit) {
1169
- const maxConcurrent = agent.instances?.max_concurrent ?? 1;
1170
- const runningCount = this.scheduler?.getRunningJobCount(agentName) ?? 0;
1171
- if (runningCount >= maxConcurrent) {
1172
- throw new ConcurrencyLimitError(agentName, runningCount, maxConcurrent);
1173
- }
1174
- }
1175
- // Determine the prompt to use (priority: options > schedule > agent default)
1176
- const prompt = options?.prompt ?? schedule?.prompt ?? undefined;
1177
- // Create the job
1178
- const jobsDir = join(this.stateDir, "jobs");
1179
- const job = await createJob(jobsDir, {
1180
- agent: agentName,
1181
- trigger_type: "manual",
1182
- schedule: scheduleName ?? null,
1183
- prompt: prompt ?? null,
1184
- });
1185
- const timestamp = new Date().toISOString();
1186
- this.logger.info(`Manually triggered ${agentName}${scheduleName ? `/${scheduleName}` : ""} - job ${job.id}`);
1187
- // Emit job:created event
1188
- this.emit("job:created", {
1189
- job,
1190
- agentName,
1191
- scheduleName: scheduleName ?? null,
1192
- timestamp,
1193
- });
1194
- // Build and return the result
1195
- const result = {
1196
- jobId: job.id,
1197
- agentName,
1198
- scheduleName: scheduleName ?? null,
1199
- startedAt: job.started_at,
1200
- prompt,
1201
- };
1202
- return result;
1203
- }
200
+ // Status Queries
201
+ async getFleetStatus() { return this.statusQueries.getFleetStatus(); }
202
+ async getAgentInfo() { return this.statusQueries.getAgentInfo(); }
203
+ async getAgentInfoByName(name) { return this.statusQueries.getAgentInfoByName(name); }
204
+ // Schedule Management
205
+ async getSchedules() { return this.scheduleManagement.getSchedules(); }
206
+ async getSchedule(agentName, scheduleName) { return this.scheduleManagement.getSchedule(agentName, scheduleName); }
207
+ async enableSchedule(agentName, scheduleName) { return this.scheduleManagement.enableSchedule(agentName, scheduleName); }
208
+ async disableSchedule(agentName, scheduleName) { return this.scheduleManagement.disableSchedule(agentName, scheduleName); }
209
+ // Config Reload
210
+ async reload() { return this.configReloadModule.reload(); }
211
+ computeConfigChanges(oldConfig, newConfig) { return computeConfigChanges(oldConfig, newConfig); }
212
+ // Job Control
213
+ async trigger(agentName, scheduleName, options) { return this.jobControl.trigger(agentName, scheduleName, options); }
214
+ async cancelJob(jobId, options) { return this.jobControl.cancelJob(jobId, options); }
215
+ async forkJob(jobId, modifications) { return this.jobControl.forkJob(jobId, modifications); }
216
+ // Log Streaming
217
+ async *streamLogs(options) { yield* this.logStreaming.streamLogs(options); }
218
+ async *streamJobOutput(jobId) { yield* this.logStreaming.streamJobOutput(jobId); }
219
+ async *streamAgentLogs(agentName) { yield* this.logStreaming.streamAgentLogs(agentName); }
1204
220
  // ===========================================================================
1205
- // Job Control (US-6)
221
+ // Private Methods
1206
222
  // ===========================================================================
1207
- /**
1208
- * Cancel a running job gracefully
1209
- *
1210
- * This method cancels a running job by first sending SIGTERM to allow
1211
- * graceful shutdown. If the job doesn't terminate within the timeout,
1212
- * it will be forcefully killed with SIGKILL.
1213
- *
1214
- * @param jobId - ID of the job to cancel
1215
- * @param options - Optional cancellation options
1216
- * @param options.timeout - Time in ms to wait for graceful shutdown (default: 10000)
1217
- * @returns Result of the cancellation operation
1218
- * @throws {InvalidStateError} If the fleet manager is not initialized
1219
- * @throws {JobNotFoundError} If the job doesn't exist
1220
- *
1221
- * @example
1222
- * ```typescript
1223
- * // Cancel with default timeout
1224
- * const result = await manager.cancelJob('job-2024-01-15-abc123');
1225
- * console.log(`Job cancelled: ${result.terminationType}`);
1226
- *
1227
- * // Cancel with custom timeout
1228
- * const result = await manager.cancelJob('job-2024-01-15-abc123', {
1229
- * timeout: 30000, // 30 seconds
1230
- * });
1231
- * ```
1232
- */
1233
- async cancelJob(jobId, options) {
1234
- // Validate state - must be at least initialized
1235
- if (this.status === "uninitialized") {
1236
- throw new InvalidStateError("cancelJob", this.status, ["initialized", "running", "stopped"]);
1237
- }
1238
- const jobsDir = join(this.stateDir, "jobs");
1239
- const timeout = options?.timeout ?? 10000; // Default 10 seconds
1240
- // Get the job to verify it exists and check its status
1241
- const job = await getJob(jobsDir, jobId, { logger: this.logger });
1242
- if (!job) {
1243
- throw new JobNotFoundError(jobId);
1244
- }
1245
- const timestamp = new Date().toISOString();
1246
- let terminationType;
1247
- let durationSeconds;
1248
- // If job is already not running, return early
1249
- if (job.status !== "running" && job.status !== "pending") {
1250
- this.logger.info(`Job ${jobId} is already ${job.status}, no cancellation needed`);
1251
- terminationType = 'already_stopped';
1252
- // Calculate duration if we have finished_at
1253
- if (job.finished_at) {
1254
- const startTime = new Date(job.started_at).getTime();
1255
- const endTime = new Date(job.finished_at).getTime();
1256
- durationSeconds = Math.round((endTime - startTime) / 1000);
1257
- }
1258
- return {
1259
- jobId,
1260
- success: true,
1261
- terminationType,
1262
- canceledAt: timestamp,
1263
- };
1264
- }
1265
- // Calculate duration
1266
- const startTime = new Date(job.started_at).getTime();
1267
- const endTime = new Date(timestamp).getTime();
1268
- durationSeconds = Math.round((endTime - startTime) / 1000);
1269
- // Try to cancel via the scheduler if it has process tracking
1270
- // For now, we'll update the job status directly since we don't have
1271
- // direct process control yet. In a full implementation, this would
1272
- // send SIGTERM to the process, wait, then SIGKILL if needed.
1273
- // Note: The scheduler/executor would need to track job processes
1274
- // and provide an API to cancel them. For this implementation,
1275
- // we'll update the job state and emit events, assuming the executor
1276
- // monitors the job status and handles cancellation.
1277
- this.logger.info(`Cancelling job ${jobId} for agent ${job.agent}`);
1278
- // Update job status to cancelled
1279
- try {
1280
- await updateJob(jobsDir, jobId, {
1281
- status: "cancelled",
1282
- exit_reason: "cancelled",
1283
- finished_at: timestamp,
1284
- });
1285
- // Assume graceful termination for now
1286
- // In a full implementation, this would be determined by whether
1287
- // the process responded to SIGTERM or required SIGKILL
1288
- terminationType = 'graceful';
1289
- }
1290
- catch (error) {
1291
- this.logger.error(`Failed to update job status: ${error.message}`);
1292
- throw new JobCancelError(jobId, 'process_error', {
1293
- cause: error,
1294
- });
1295
- }
1296
- // Emit job:cancelled event
1297
- const updatedJob = await getJob(jobsDir, jobId, { logger: this.logger });
1298
- if (updatedJob) {
1299
- this.emit("job:cancelled", {
1300
- job: updatedJob,
1301
- agentName: job.agent,
1302
- terminationType,
1303
- durationSeconds,
1304
- timestamp,
1305
- });
1306
- }
1307
- this.logger.info(`Job ${jobId} cancelled (${terminationType}) after ${durationSeconds}s`);
1308
- return {
1309
- jobId,
1310
- success: true,
1311
- terminationType,
1312
- canceledAt: timestamp,
1313
- };
1314
- }
1315
- /**
1316
- * Fork a job to create a new job based on an existing one
1317
- *
1318
- * This method creates a new job that is based on an existing job's
1319
- * configuration. The new job will have the same agent and can optionally
1320
- * have modifications applied (different prompt, schedule, etc.).
1321
- *
1322
- * If the original job has a session ID, the new job will fork from that
1323
- * session, preserving conversation context.
1324
- *
1325
- * @param jobId - ID of the job to fork
1326
- * @param modifications - Optional modifications to apply to the forked job
1327
- * @returns Result of the fork operation including the new job ID
1328
- * @throws {InvalidStateError} If the fleet manager is not initialized
1329
- * @throws {JobNotFoundError} If the original job doesn't exist
1330
- * @throws {JobForkError} If the job cannot be forked (e.g., no session)
1331
- *
1332
- * @example
1333
- * ```typescript
1334
- * // Fork with same configuration
1335
- * const result = await manager.forkJob('job-2024-01-15-abc123');
1336
- * console.log(`Forked to: ${result.jobId}`);
1337
- *
1338
- * // Fork with modified prompt
1339
- * const result = await manager.forkJob('job-2024-01-15-abc123', {
1340
- * prompt: 'Continue the previous task but focus on testing',
1341
- * });
1342
- *
1343
- * // Fork with different schedule
1344
- * const result = await manager.forkJob('job-2024-01-15-abc123', {
1345
- * schedule: 'nightly',
1346
- * });
1347
- * ```
1348
- */
1349
- async forkJob(jobId, modifications) {
1350
- // Validate state - must be at least initialized
1351
- if (this.status === "uninitialized") {
1352
- throw new InvalidStateError("forkJob", this.status, ["initialized", "running", "stopped"]);
1353
- }
1354
- const jobsDir = join(this.stateDir, "jobs");
1355
- // Get the original job
1356
- const originalJob = await getJob(jobsDir, jobId, { logger: this.logger });
1357
- if (!originalJob) {
1358
- throw new JobForkError(jobId, 'job_not_found');
1359
- }
1360
- // Verify the agent exists in config
1361
- const agents = this.config?.agents ?? [];
1362
- const agent = agents.find((a) => a.name === originalJob.agent);
1363
- if (!agent) {
1364
- throw new JobForkError(jobId, 'agent_not_found', {
1365
- message: `Agent "${originalJob.agent}" for job "${jobId}" not found in current configuration`,
1366
- });
1367
- }
1368
- // Determine the prompt to use (priority: modifications > original job)
1369
- const prompt = modifications?.prompt ?? originalJob.prompt ?? undefined;
1370
- // Determine the schedule to use
1371
- const scheduleName = modifications?.schedule ?? originalJob.schedule ?? undefined;
1372
- // Create the new job
1373
- const timestamp = new Date().toISOString();
1374
- const newJob = await createJob(jobsDir, {
1375
- agent: originalJob.agent,
1376
- trigger_type: "fork",
1377
- schedule: scheduleName ?? null,
1378
- prompt: prompt ?? null,
1379
- forked_from: jobId,
1380
- });
1381
- this.logger.info(`Forked job ${jobId} to new job ${newJob.id} for agent ${originalJob.agent}`);
1382
- // Emit job:created event
1383
- this.emit("job:created", {
1384
- job: newJob,
1385
- agentName: originalJob.agent,
1386
- scheduleName: scheduleName ?? undefined,
1387
- timestamp,
1388
- });
1389
- // Emit job:forked event
1390
- this.emit("job:forked", {
1391
- job: newJob,
1392
- originalJob,
1393
- agentName: originalJob.agent,
1394
- timestamp,
1395
- });
1396
- return {
1397
- jobId: newJob.id,
1398
- forkedFromJobId: jobId,
1399
- agentName: originalJob.agent,
1400
- startedAt: newJob.started_at,
1401
- prompt,
1402
- };
1403
- }
1404
- // ===========================================================================
1405
- // Log Streaming (US-11)
1406
- // ===========================================================================
1407
- /**
1408
- * Stream all fleet logs as an async iterable
1409
- *
1410
- * Provides a unified stream of logs from all sources in the fleet including
1411
- * agents, jobs, and the scheduler. Logs can be filtered by level and optionally
1412
- * by agent or job.
1413
- *
1414
- * For completed jobs, this will replay their history (if includeHistory is true)
1415
- * before streaming new logs from running jobs.
1416
- *
1417
- * @param options - Options for filtering and configuring the stream
1418
- * @returns An async iterable of LogEntry objects
1419
- *
1420
- * @example
1421
- * ```typescript
1422
- * // Stream all info+ logs
1423
- * for await (const log of manager.streamLogs()) {
1424
- * console.log(`[${log.level}] ${log.message}`);
1425
- * }
1426
- *
1427
- * // Stream only errors for a specific agent
1428
- * for await (const log of manager.streamLogs({
1429
- * level: 'error',
1430
- * agentName: 'my-agent',
1431
- * })) {
1432
- * console.error(log.message);
1433
- * }
1434
- * ```
1435
- */
1436
- async *streamLogs(options) {
1437
- const level = options?.level ?? "info";
1438
- const includeHistory = options?.includeHistory ?? true;
1439
- const historyLimit = options?.historyLimit ?? 1000;
1440
- const agentFilter = options?.agentName;
1441
- const jobFilter = options?.jobId;
1442
- const jobsDir = join(this.stateDir, "jobs");
1443
- const { readJobOutputAll } = await import("../state/job-output.js");
1444
- // Replay historical logs if requested
1445
- if (includeHistory) {
1446
- // Get jobs to replay history from
1447
- const jobsResult = await listJobs(jobsDir, agentFilter ? { agent: agentFilter } : {}, { logger: this.logger });
1448
- // Filter by job ID if specified
1449
- let jobs = jobsResult.jobs;
1450
- if (jobFilter) {
1451
- jobs = jobs.filter((j) => j.id === jobFilter);
1452
- }
1453
- // Sort by started_at ascending to replay in chronological order
1454
- jobs.sort((a, b) => new Date(a.started_at).getTime() - new Date(b.started_at).getTime());
1455
- let yielded = 0;
1456
- for (const job of jobs) {
1457
- if (yielded >= historyLimit)
1458
- break;
1459
- // Read job output and convert to log entries
1460
- const output = await readJobOutputAll(jobsDir, job.id, {
1461
- skipInvalidLines: true,
1462
- logger: this.logger,
1463
- });
1464
- for (const msg of output) {
1465
- if (yielded >= historyLimit)
1466
- break;
1467
- const logEntry = this.jobOutputToLogEntry(job, msg);
1468
- if (this.shouldYieldLog(logEntry, level, agentFilter, jobFilter)) {
1469
- yield logEntry;
1470
- yielded++;
1471
- }
1472
- }
1473
- }
1474
- }
1475
- // For running jobs, subscribe to job:output events
1476
- const outputQueue = [];
1477
- let resolveWait = null;
1478
- let stopped = false;
1479
- const outputHandler = (payload) => {
1480
- if (stopped)
1481
- return;
1482
- const logEntry = {
1483
- timestamp: payload.timestamp,
1484
- level: "info",
1485
- source: "job",
1486
- agentName: payload.agentName,
1487
- jobId: payload.jobId,
1488
- message: payload.output,
1489
- };
1490
- if (this.shouldYieldLog(logEntry, level, agentFilter, jobFilter)) {
1491
- outputQueue.push(logEntry);
1492
- if (resolveWait) {
1493
- resolveWait();
1494
- resolveWait = null;
1495
- }
1496
- }
1497
- };
1498
- this.on("job:output", outputHandler);
1499
- try {
1500
- // Yield queued entries as they arrive
1501
- while (!stopped) {
1502
- while (outputQueue.length > 0) {
1503
- const entry = outputQueue.shift();
1504
- yield entry;
1505
- }
1506
- // Wait for more entries
1507
- await new Promise((resolve) => {
1508
- resolveWait = resolve;
1509
- // Add timeout to prevent hanging forever
1510
- setTimeout(resolve, 1000);
1511
- });
1512
- }
1513
- }
1514
- finally {
1515
- stopped = true;
1516
- this.off("job:output", outputHandler);
1517
- }
223
+ initializeModules() {
224
+ this.statusQueries = new StatusQueries(this);
225
+ this.scheduleManagement = new ScheduleManagement(this, () => this.statusQueries.readFleetStateSnapshot());
226
+ this.configReloadModule = new ConfigReload(this, () => this.loadConfiguration(), (config) => { this.config = config; });
227
+ this.jobControl = new JobControl(this, () => this.statusQueries.getAgentInfo());
228
+ this.logStreaming = new LogStreaming(this);
229
+ this.scheduleExecutor = new ScheduleExecutor(this);
1518
230
  }
1519
- /**
1520
- * Stream output from a specific job as an async iterable
1521
- *
1522
- * Provides a stream of log entries for a specific job. For completed jobs,
1523
- * this will replay the job's history and then complete. For running jobs,
1524
- * it will continue streaming until the job completes.
1525
- *
1526
- * @param jobId - The ID of the job to stream output from
1527
- * @returns An async iterable of LogEntry objects
1528
- * @throws {JobNotFoundError} If the job doesn't exist
1529
- *
1530
- * @example
1531
- * ```typescript
1532
- * // Stream job output
1533
- * for await (const log of manager.streamJobOutput('job-2024-01-15-abc123')) {
1534
- * console.log(`[${log.level}] ${log.message}`);
1535
- * }
1536
- * ```
1537
- */
1538
- async *streamJobOutput(jobId) {
1539
- const jobsDir = join(this.stateDir, "jobs");
1540
- // Verify job exists
1541
- const job = await getJob(jobsDir, jobId, { logger: this.logger });
1542
- if (!job) {
1543
- throw new JobNotFoundError(jobId);
1544
- }
1545
- const { readJobOutputAll, getJobOutputPath } = await import("../state/job-output.js");
1546
- const { watch } = await import("node:fs");
1547
- const { stat } = await import("node:fs/promises");
1548
- const { createReadStream } = await import("node:fs");
1549
- const { createInterface } = await import("node:readline");
1550
- const outputPath = getJobOutputPath(jobsDir, jobId);
1551
- // First, replay all existing output
1552
- const existingOutput = await readJobOutputAll(jobsDir, jobId, {
1553
- skipInvalidLines: true,
1554
- logger: this.logger,
1555
- });
1556
- for (const msg of existingOutput) {
1557
- yield this.jobOutputToLogEntry(job, msg);
1558
- }
1559
- // If job is already completed, we're done
1560
- if (job.status !== "running" && job.status !== "pending") {
1561
- return;
1562
- }
1563
- // For running jobs, watch for new output
1564
- const outputQueue = [];
1565
- let resolveWait = null;
1566
- let stopped = false;
1567
- let lastReadPosition = 0;
1568
- // Get current file position
1569
- try {
1570
- const stats = await stat(outputPath);
1571
- lastReadPosition = stats.size;
1572
- }
1573
- catch {
1574
- // File doesn't exist yet
1575
- }
1576
- // Watch for file changes
1577
- let watcher = null;
1578
- try {
1579
- watcher = watch(outputPath, async (eventType) => {
1580
- if (stopped || eventType !== "change")
1581
- return;
1582
- try {
1583
- const currentStats = await stat(outputPath);
1584
- if (currentStats.size > lastReadPosition) {
1585
- // Read new content
1586
- const fileStream = createReadStream(outputPath, {
1587
- encoding: "utf-8",
1588
- start: lastReadPosition,
1589
- });
1590
- const rl = createInterface({
1591
- input: fileStream,
1592
- crlfDelay: Infinity,
1593
- });
1594
- for await (const line of rl) {
1595
- if (stopped)
1596
- break;
1597
- const trimmedLine = line.trim();
1598
- if (trimmedLine === "")
1599
- continue;
1600
- try {
1601
- const parsed = JSON.parse(trimmedLine);
1602
- const logEntry = this.jobOutputToLogEntry(job, parsed);
1603
- outputQueue.push(logEntry);
1604
- if (resolveWait) {
1605
- resolveWait();
1606
- resolveWait = null;
1607
- }
1608
- }
1609
- catch {
1610
- // Skip malformed lines
1611
- }
1612
- }
1613
- rl.close();
1614
- fileStream.destroy();
1615
- lastReadPosition = currentStats.size;
1616
- }
1617
- }
1618
- catch (err) {
1619
- this.logger.warn(`Error reading output file: ${err.message}`);
1620
- }
1621
- });
1622
- }
1623
- catch {
1624
- // Can't watch file - might not exist yet
1625
- }
1626
- // Poll for job completion
1627
- const checkJobComplete = async () => {
1628
- const currentJob = await getJob(jobsDir, jobId, { logger: this.logger });
1629
- return (!currentJob ||
1630
- (currentJob.status !== "running" && currentJob.status !== "pending"));
1631
- };
1632
- try {
1633
- // Yield queued entries as they arrive
1634
- while (!stopped) {
1635
- while (outputQueue.length > 0) {
1636
- const entry = outputQueue.shift();
1637
- yield entry;
1638
- }
1639
- // Check if job is complete
1640
- if (await checkJobComplete()) {
1641
- stopped = true;
1642
- break;
1643
- }
1644
- // Wait for more entries
1645
- await new Promise((resolve) => {
1646
- resolveWait = resolve;
1647
- setTimeout(resolve, 1000);
1648
- });
1649
- }
1650
- }
1651
- finally {
1652
- stopped = true;
1653
- if (watcher) {
1654
- watcher.close();
1655
- }
1656
- }
1657
- }
1658
- /**
1659
- * Stream logs for a specific agent as an async iterable
1660
- *
1661
- * Provides a stream of log entries for all jobs belonging to a specific agent.
1662
- * For completed jobs, this will replay their history. For running jobs, it
1663
- * will continue streaming until the iterator is stopped.
1664
- *
1665
- * @param agentName - The name of the agent to stream logs for
1666
- * @returns An async iterable of LogEntry objects
1667
- * @throws {AgentNotFoundError} If the agent doesn't exist in the configuration
1668
- *
1669
- * @example
1670
- * ```typescript
1671
- * // Stream all logs for an agent
1672
- * for await (const log of manager.streamAgentLogs('my-agent')) {
1673
- * console.log(`[${log.jobId}] ${log.message}`);
1674
- * }
1675
- * ```
1676
- */
1677
- async *streamAgentLogs(agentName) {
1678
- // Verify agent exists
1679
- const agents = this.config?.agents ?? [];
1680
- const agent = agents.find((a) => a.name === agentName);
1681
- if (!agent) {
1682
- throw new AgentNotFoundError(agentName, {
1683
- availableAgents: agents.map((a) => a.name),
1684
- });
1685
- }
1686
- // Delegate to streamLogs with agent filter
1687
- yield* this.streamLogs({
1688
- agentName,
1689
- includeHistory: true,
1690
- });
1691
- }
1692
- // ===========================================================================
1693
- // Log Streaming Helpers (US-11)
1694
- // ===========================================================================
1695
- /**
1696
- * Convert a job output message to a LogEntry
1697
- */
1698
- jobOutputToLogEntry(job, msg) {
1699
- // Determine log level based on message type
1700
- let level = "info";
1701
- if (msg.type === "error") {
1702
- level = "error";
1703
- }
1704
- else if (msg.type === "system") {
1705
- level = "debug";
1706
- }
1707
- return {
1708
- timestamp: msg.timestamp ?? new Date().toISOString(),
1709
- level,
1710
- source: "job",
1711
- agentName: job.agent,
1712
- jobId: job.id,
1713
- scheduleName: job.schedule ?? undefined,
1714
- message: msg.content ?? "",
1715
- data: { type: msg.type },
1716
- };
1717
- }
1718
- /**
1719
- * Determine if a log entry should be yielded based on filters
1720
- */
1721
- shouldYieldLog(entry, minLevel, agentFilter, jobFilter) {
1722
- // Check log level
1723
- const levelOrder = {
1724
- debug: 0,
1725
- info: 1,
1726
- warn: 2,
1727
- error: 3,
1728
- };
1729
- if (levelOrder[entry.level] < levelOrder[minLevel]) {
1730
- return false;
1731
- }
1732
- // Check agent filter
1733
- if (agentFilter && entry.agentName !== agentFilter) {
1734
- return false;
1735
- }
1736
- // Check job filter
1737
- if (jobFilter && entry.jobId !== jobFilter) {
1738
- return false;
1739
- }
1740
- return true;
1741
- }
1742
- // ===========================================================================
1743
- // Private Helper Methods
1744
- // ===========================================================================
1745
- /**
1746
- * Load configuration with proper error handling
1747
- */
1748
231
  async loadConfiguration() {
1749
232
  try {
1750
233
  return await loadConfig(this.configPath);
1751
234
  }
1752
235
  catch (error) {
1753
236
  if (error instanceof ConfigNotFoundError) {
1754
- throw new FleetManagerConfigError(`Configuration file not found. ${error.message}`, this.configPath, { cause: error });
237
+ throw new ConfigurationError(`Configuration file not found. ${error.message}`, { configPath: this.configPath, cause: error });
1755
238
  }
1756
239
  if (error instanceof ConfigError) {
1757
- throw new FleetManagerConfigError(`Invalid configuration: ${error.message}`, this.configPath, { cause: error });
240
+ throw new ConfigurationError(`Invalid configuration: ${error.message}`, { configPath: this.configPath, cause: error });
1758
241
  }
1759
- throw new FleetManagerConfigError(`Failed to load configuration: ${error instanceof Error ? error.message : String(error)}`, this.configPath, { cause: error instanceof Error ? error : undefined });
242
+ throw new ConfigurationError(`Failed to load configuration: ${error instanceof Error ? error.message : String(error)}`, { configPath: this.configPath, cause: error instanceof Error ? error : undefined });
1760
243
  }
1761
244
  }
1762
- /**
1763
- * Initialize state directory with proper error handling
1764
- */
1765
245
  async initializeStateDir() {
1766
246
  try {
1767
247
  return await initStateDirectory({ path: this.stateDir });
@@ -1770,14 +250,8 @@ export class FleetManager extends EventEmitter {
1770
250
  throw new FleetManagerStateDirError(`Failed to initialize state directory: ${error instanceof Error ? error.message : String(error)}`, this.stateDir, { cause: error instanceof Error ? error : undefined });
1771
251
  }
1772
252
  }
1773
- /**
1774
- * Start the scheduler asynchronously (don't block on the loop)
1775
- */
1776
253
  startSchedulerAsync(agents) {
1777
- // Start the scheduler loop in the background
1778
- // The scheduler.start() method runs the loop and returns when stopped
1779
254
  this.scheduler.start(agents).catch((error) => {
1780
- // Only handle errors if we're still supposed to be running
1781
255
  if (this.status === "running" || this.status === "starting") {
1782
256
  this.logger.error(`Scheduler error: ${error instanceof Error ? error.message : String(error)}`);
1783
257
  this.status = "error";
@@ -1786,121 +260,22 @@ export class FleetManager extends EventEmitter {
1786
260
  }
1787
261
  });
1788
262
  }
1789
- /**
1790
- * Handle schedule trigger callback from scheduler
1791
- */
1792
263
  async handleScheduleTrigger(info) {
1793
- const { agent, scheduleName, schedule } = info;
1794
- const timestamp = new Date().toISOString();
1795
- this.logger.info(`Triggering ${agent.name}/${scheduleName}`);
1796
- // Emit legacy event for backwards compatibility
1797
- this.emit("schedule:trigger", agent.name, scheduleName);
1798
- // Emit new typed event with full payload
1799
- this.emit("schedule:triggered", {
1800
- agentName: agent.name,
1801
- scheduleName,
1802
- schedule,
1803
- timestamp,
1804
- });
264
+ await this.scheduleExecutor.executeSchedule(info);
265
+ }
266
+ async persistShutdownState() {
267
+ if (!this.stateDirInfo)
268
+ return;
269
+ const { writeFleetState } = await import("../state/fleet-state.js");
270
+ const currentState = await this.statusQueries.readFleetStateSnapshot();
271
+ const updatedState = { ...currentState, fleet: { ...currentState.fleet, stoppedAt: new Date().toISOString() } };
1805
272
  try {
1806
- // For now, just log the trigger
1807
- // In future PRDs, this will actually run the agent via the runner
1808
- this.logger.debug(`Schedule ${scheduleName} triggered for agent ${agent.name} ` +
1809
- `(type: ${schedule.type}, prompt: ${schedule.prompt ?? "default"})`);
1810
- // Emit legacy completion event for backwards compatibility
1811
- this.emit("schedule:complete", agent.name, scheduleName);
273
+ await writeFleetState(this.stateDirInfo.stateFile, updatedState);
274
+ this.logger.debug("Fleet state persisted");
1812
275
  }
1813
276
  catch (error) {
1814
- const err = error instanceof Error ? error : new Error(String(error));
1815
- this.logger.error(`Error in ${agent.name}/${scheduleName}: ${err.message}`);
1816
- // Emit legacy error event for backwards compatibility
1817
- this.emit("schedule:error", agent.name, scheduleName, err);
1818
- throw error;
277
+ this.logger.warn(`Failed to persist fleet state: ${error.message}`);
1819
278
  }
1820
279
  }
1821
- // ===========================================================================
1822
- // Event Emission Helpers (US-2)
1823
- // ===========================================================================
1824
- /**
1825
- * Emit a config:reloaded event
1826
- *
1827
- * Called when configuration is hot-reloaded.
1828
- */
1829
- emitConfigReloaded(payload) {
1830
- this.emit("config:reloaded", payload);
1831
- }
1832
- /**
1833
- * Emit an agent:started event
1834
- *
1835
- * Called when an agent is started/registered with the fleet.
1836
- */
1837
- emitAgentStarted(payload) {
1838
- this.emit("agent:started", payload);
1839
- }
1840
- /**
1841
- * Emit an agent:stopped event
1842
- *
1843
- * Called when an agent is stopped/unregistered from the fleet.
1844
- */
1845
- emitAgentStopped(payload) {
1846
- this.emit("agent:stopped", payload);
1847
- }
1848
- /**
1849
- * Emit a schedule:skipped event
1850
- *
1851
- * Called when a schedule check is skipped (already running, disabled, etc.).
1852
- */
1853
- emitScheduleSkipped(payload) {
1854
- this.emit("schedule:skipped", payload);
1855
- }
1856
- /**
1857
- * Emit a job:created event
1858
- *
1859
- * Called when a new job is created.
1860
- */
1861
- emitJobCreated(payload) {
1862
- this.emit("job:created", payload);
1863
- }
1864
- /**
1865
- * Emit a job:output event
1866
- *
1867
- * Called when a job produces output during execution.
1868
- * This enables real-time streaming of output to UIs.
1869
- */
1870
- emitJobOutput(payload) {
1871
- this.emit("job:output", payload);
1872
- }
1873
- /**
1874
- * Emit a job:completed event
1875
- *
1876
- * Called when a job completes successfully.
1877
- */
1878
- emitJobCompleted(payload) {
1879
- this.emit("job:completed", payload);
1880
- }
1881
- /**
1882
- * Emit a job:failed event
1883
- *
1884
- * Called when a job fails.
1885
- */
1886
- emitJobFailed(payload) {
1887
- this.emit("job:failed", payload);
1888
- }
1889
- /**
1890
- * Emit a job:cancelled event (US-6)
1891
- *
1892
- * Called when a job is cancelled.
1893
- */
1894
- emitJobCancelled(payload) {
1895
- this.emit("job:cancelled", payload);
1896
- }
1897
- /**
1898
- * Emit a job:forked event (US-6)
1899
- *
1900
- * Called when a job is forked to create a new job.
1901
- */
1902
- emitJobForked(payload) {
1903
- this.emit("job:forked", payload);
1904
- }
1905
280
  }
1906
281
  //# sourceMappingURL=fleet-manager.js.map