@flink-app/flink 1.0.0 → 2.0.0-alpha.100

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 (277) hide show
  1. package/CHANGELOG.md +991 -0
  2. package/SCHEMA_EXTRACTION_ANALYSIS.md +494 -0
  3. package/SIMPLE_AST_FEASIBILITY.md +570 -0
  4. package/bin/flink.ts +13 -2
  5. package/cli/build.ts +24 -44
  6. package/cli/clean.ts +13 -25
  7. package/cli/cli-utils.ts +190 -17
  8. package/cli/dev.ts +252 -0
  9. package/cli/loadEnvFiles.ts +116 -0
  10. package/cli/run.ts +45 -62
  11. package/dist/bin/flink.js +61 -2
  12. package/dist/cli/build.js +20 -25
  13. package/dist/cli/clean.js +12 -10
  14. package/dist/cli/cli-utils.d.ts +34 -3
  15. package/dist/cli/cli-utils.js +193 -12
  16. package/dist/cli/dev.d.ts +2 -0
  17. package/dist/cli/dev.js +279 -0
  18. package/dist/cli/loadEnvFiles.d.ts +30 -0
  19. package/dist/cli/loadEnvFiles.js +113 -0
  20. package/dist/cli/run.js +47 -46
  21. package/dist/src/DependencyTracker.d.ts +44 -0
  22. package/dist/src/DependencyTracker.js +239 -0
  23. package/dist/src/FlinkApp.d.ts +163 -10
  24. package/dist/src/FlinkApp.js +823 -184
  25. package/dist/src/FlinkContext.d.ts +41 -0
  26. package/dist/src/FlinkErrors.d.ts +19 -6
  27. package/dist/src/FlinkErrors.js +36 -42
  28. package/dist/src/FlinkHttpHandler.d.ts +157 -18
  29. package/dist/src/FlinkJob.d.ts +10 -0
  30. package/dist/src/FlinkLog.d.ts +82 -18
  31. package/dist/src/FlinkLog.js +165 -13
  32. package/dist/src/FlinkLogFactory.d.ts +288 -0
  33. package/dist/src/FlinkLogFactory.js +619 -0
  34. package/dist/src/FlinkRepo.d.ts +10 -2
  35. package/dist/src/FlinkRepo.js +11 -1
  36. package/dist/src/FlinkRequestContext.d.ts +63 -0
  37. package/dist/src/FlinkRequestContext.js +74 -0
  38. package/dist/src/FlinkResponse.d.ts +6 -0
  39. package/dist/src/FlinkService.d.ts +38 -0
  40. package/dist/src/FlinkService.js +46 -0
  41. package/dist/src/LeaderElection.d.ts +45 -0
  42. package/dist/src/LeaderElection.js +269 -0
  43. package/dist/src/SchemaCache.d.ts +84 -0
  44. package/dist/src/SchemaCache.js +289 -0
  45. package/dist/src/TypeScriptCompiler.d.ts +161 -51
  46. package/dist/src/TypeScriptCompiler.js +1253 -617
  47. package/dist/src/TypeScriptUtils.js +4 -0
  48. package/dist/src/ai/AgentRunner.d.ts +39 -0
  49. package/dist/src/ai/AgentRunner.js +760 -0
  50. package/dist/src/ai/ConversationAgent.d.ts +279 -0
  51. package/dist/src/ai/ConversationAgent.js +404 -0
  52. package/dist/src/ai/ConversationFlinkAgent.d.ts +278 -0
  53. package/dist/src/ai/ConversationFlinkAgent.js +404 -0
  54. package/dist/src/ai/FlinkAgent.d.ts +690 -0
  55. package/dist/src/ai/FlinkAgent.js +729 -0
  56. package/dist/src/ai/FlinkTool.d.ts +135 -0
  57. package/dist/src/ai/FlinkTool.js +2 -0
  58. package/dist/src/ai/InMemoryConversationAgent.d.ts +121 -0
  59. package/dist/src/ai/InMemoryConversationAgent.js +209 -0
  60. package/dist/src/ai/LLMAdapter.d.ts +148 -0
  61. package/dist/src/ai/LLMAdapter.js +2 -0
  62. package/dist/src/ai/PersistentFlinkAgent.d.ts +278 -0
  63. package/dist/src/ai/PersistentFlinkAgent.js +403 -0
  64. package/dist/src/ai/SubAgentExecutor.d.ts +38 -0
  65. package/dist/src/ai/SubAgentExecutor.js +223 -0
  66. package/dist/src/ai/ToolExecutor.d.ts +64 -0
  67. package/dist/src/ai/ToolExecutor.js +497 -0
  68. package/dist/src/ai/agentInstructions.d.ts +68 -0
  69. package/dist/src/ai/agentInstructions.js +286 -0
  70. package/dist/src/ai/index.d.ts +8 -0
  71. package/dist/src/ai/index.js +26 -0
  72. package/dist/src/ai/instructionFileLoader.d.ts +44 -0
  73. package/dist/src/ai/instructionFileLoader.js +179 -0
  74. package/dist/src/auth/FlinkAuthPlugin.d.ts +1 -1
  75. package/dist/src/handlers/StreamWriterFactory.d.ts +20 -0
  76. package/dist/src/handlers/StreamWriterFactory.js +83 -0
  77. package/dist/src/index.d.ts +14 -0
  78. package/dist/src/index.js +17 -0
  79. package/dist/src/loadPluginSchemas.d.ts +45 -0
  80. package/dist/src/loadPluginSchemas.js +143 -0
  81. package/dist/src/schema-extraction/ComplexTypeDetection.d.ts +40 -0
  82. package/dist/src/schema-extraction/ComplexTypeDetection.js +75 -0
  83. package/dist/src/schema-extraction/TypeScriptSourceParser.d.ts +321 -0
  84. package/dist/src/schema-extraction/TypeScriptSourceParser.js +925 -0
  85. package/dist/src/schema-extraction/TypeScriptSourceParser.spec.d.ts +1 -0
  86. package/dist/src/schema-extraction/TypeScriptSourceParser.spec.js +233 -0
  87. package/dist/src/schema-extraction/TypeScriptTokenizer.d.ts +57 -0
  88. package/dist/src/schema-extraction/TypeScriptTokenizer.js +177 -0
  89. package/dist/src/schema-extraction/index.d.ts +2 -0
  90. package/dist/src/schema-extraction/index.js +20 -0
  91. package/dist/src/schema-extraction/types.d.ts +31 -0
  92. package/dist/src/schema-extraction/types.js +2 -0
  93. package/dist/src/utils/loadFlinkConfig.d.ts +53 -0
  94. package/dist/src/utils/loadFlinkConfig.js +77 -0
  95. package/dist/src/utils.d.ts +30 -0
  96. package/dist/src/utils.js +52 -0
  97. package/dist/src/workers/SchemaGeneratorWorker.d.ts +1 -0
  98. package/dist/src/workers/SchemaGeneratorWorker.js +49 -0
  99. package/dist/src/workers/WorkerPool.d.ts +60 -0
  100. package/dist/src/workers/WorkerPool.js +306 -0
  101. package/examples/logging-hierarchical-example.ts +125 -0
  102. package/package.json +27 -4
  103. package/readme.md +499 -0
  104. package/spec/AgentDescendantDetection.spec.ts +335 -0
  105. package/spec/AgentDuplicateDetection.spec.ts +112 -0
  106. package/spec/AgentObserver.spec.ts +266 -0
  107. package/spec/AgentRunner.spec.ts +1062 -0
  108. package/spec/AsyncLocalStorageContext.spec.ts +223 -0
  109. package/spec/ConversationHooks.spec.ts +257 -0
  110. package/spec/FlinkAgent.spec.ts +681 -0
  111. package/spec/FlinkApp.htmlResponse.spec.ts +260 -0
  112. package/spec/FlinkApp.onError.invocation.spec.ts +151 -0
  113. package/spec/FlinkApp.onError.spec.ts +1 -2
  114. package/spec/FlinkApp.routeOrdering.spec.ts +61 -0
  115. package/spec/FlinkApp.undefinedResponse.spec.ts +123 -0
  116. package/spec/FlinkJob.spec.ts +171 -0
  117. package/spec/FlinkLogFactory.spec.ts +337 -0
  118. package/spec/FlinkRepo.spec.ts +1 -1
  119. package/spec/LeaderElection.spec.ts +174 -0
  120. package/spec/StreamingIntegration.spec.ts +139 -0
  121. package/spec/ToolExecutor.spec.ts +465 -0
  122. package/spec/TypeScriptCompiler.spec.ts +1 -1
  123. package/spec/TypeScriptSourceParser.spec.ts +1215 -0
  124. package/spec/TypeScriptTokenizer.spec.ts +366 -0
  125. package/spec/ai/ContextCompaction.spec.ts +405 -0
  126. package/spec/ai/ConversationAgent.spec.ts +520 -0
  127. package/spec/ai/InMemoryConversationAgent.spec.ts +144 -0
  128. package/spec/ai/agentInstructions.spec.ts +358 -0
  129. package/spec/fixtures/agent-instructions/TestAgent.ts +24 -0
  130. package/spec/fixtures/agent-instructions/simple.md +3 -0
  131. package/spec/fixtures/agent-instructions/template.md +18 -0
  132. package/spec/fixtures/agent-instructions/yaml-format.yaml +9 -0
  133. package/spec/mock-project/dist/.tsbuildinfo +1 -0
  134. package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCar.js +56 -0
  135. package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCar2.js +58 -0
  136. package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCarWithArraySchema.js +52 -0
  137. package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCarWithArraySchema2.js +52 -0
  138. package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCarWithArraySchema3.js +52 -0
  139. package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCarWithLiteralSchema.js +54 -0
  140. package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCarWithLiteralSchema2.js +54 -0
  141. package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCarWithSchemaInFile.js +57 -0
  142. package/spec/mock-project/dist/spec/mock-project/src/handlers/GetCarWithSchemaInFile2.js +57 -0
  143. package/spec/mock-project/dist/spec/mock-project/src/handlers/ManuallyAddedHandler.js +53 -0
  144. package/spec/mock-project/dist/spec/mock-project/src/handlers/ManuallyAddedHandler2.js +55 -0
  145. package/spec/mock-project/dist/spec/mock-project/src/handlers/PatchCar.js +57 -0
  146. package/spec/mock-project/dist/spec/mock-project/src/handlers/PatchOnboardingSession.js +75 -0
  147. package/spec/mock-project/dist/spec/mock-project/src/handlers/PatchOrderWithComplexTypes.js +57 -0
  148. package/spec/mock-project/dist/spec/mock-project/src/handlers/PatchProductWithIntersection.js +58 -0
  149. package/spec/mock-project/dist/spec/mock-project/src/handlers/PatchUserWithUnion.js +58 -0
  150. package/spec/mock-project/dist/spec/mock-project/src/handlers/PostCar.js +54 -0
  151. package/spec/mock-project/dist/spec/mock-project/src/handlers/PostLogin.js +55 -0
  152. package/spec/mock-project/dist/spec/mock-project/src/handlers/PostLogout.js +54 -0
  153. package/spec/mock-project/dist/spec/mock-project/src/handlers/PutCar.js +54 -0
  154. package/spec/mock-project/dist/spec/mock-project/src/index.js +83 -0
  155. package/spec/mock-project/dist/spec/mock-project/src/repos/CarRepo.js +26 -0
  156. package/spec/mock-project/dist/spec/mock-project/src/schemas/Car.js +2 -0
  157. package/spec/mock-project/dist/spec/mock-project/src/schemas/DefaultExportSchema.js +2 -0
  158. package/spec/mock-project/dist/spec/mock-project/src/schemas/FileWithTwoSchemas.js +2 -0
  159. package/spec/mock-project/dist/src/FlinkApp.js +1000 -0
  160. package/spec/mock-project/dist/src/FlinkContext.js +2 -0
  161. package/spec/mock-project/dist/src/FlinkErrors.js +143 -0
  162. package/spec/mock-project/dist/src/FlinkHttpHandler.js +47 -0
  163. package/spec/mock-project/dist/src/FlinkJob.js +2 -0
  164. package/spec/mock-project/dist/src/FlinkLog.js +119 -0
  165. package/spec/mock-project/dist/src/FlinkLogFactory.js +617 -0
  166. package/spec/mock-project/dist/src/FlinkPlugin.js +2 -0
  167. package/spec/mock-project/dist/src/FlinkRepo.js +224 -0
  168. package/spec/mock-project/dist/src/FlinkRequestContext.js +74 -0
  169. package/spec/mock-project/dist/src/FlinkResponse.js +2 -0
  170. package/spec/mock-project/dist/src/ai/AgentExecutor.js +279 -0
  171. package/spec/mock-project/dist/src/ai/AgentRunner.js +632 -0
  172. package/spec/mock-project/dist/src/ai/ConversationAgent.js +402 -0
  173. package/spec/mock-project/dist/src/ai/ConversationFlinkAgent.js +422 -0
  174. package/spec/mock-project/dist/src/ai/FlinkAgent.js +699 -0
  175. package/spec/mock-project/dist/src/ai/FlinkTool.js +2 -0
  176. package/spec/mock-project/dist/src/ai/InMemoryConversationAgent.js +209 -0
  177. package/spec/mock-project/dist/src/ai/LLMAdapter.js +2 -0
  178. package/spec/mock-project/dist/src/ai/SubAgentExecutor.js +223 -0
  179. package/spec/mock-project/dist/src/ai/ToolExecutor.js +412 -0
  180. package/spec/mock-project/dist/src/ai/agentInstructions.js +246 -0
  181. package/spec/mock-project/dist/src/auth/FlinkAuthPlugin.js +2 -0
  182. package/spec/mock-project/dist/src/auth/FlinkAuthUser.js +2 -0
  183. package/spec/mock-project/dist/src/handlers/GetCar.js +26 -52
  184. package/spec/mock-project/dist/src/handlers/GetCar.js.map +1 -0
  185. package/spec/mock-project/dist/src/handlers/GetCar2.js +32 -54
  186. package/spec/mock-project/dist/src/handlers/GetCar2.js.map +1 -0
  187. package/spec/mock-project/dist/src/handlers/GetCarWithArraySchema.js +26 -48
  188. package/spec/mock-project/dist/src/handlers/GetCarWithArraySchema.js.map +1 -0
  189. package/spec/mock-project/dist/src/handlers/GetCarWithArraySchema2.js +28 -48
  190. package/spec/mock-project/dist/src/handlers/GetCarWithArraySchema2.js.map +1 -0
  191. package/spec/mock-project/dist/src/handlers/GetCarWithArraySchema3.js +29 -48
  192. package/spec/mock-project/dist/src/handlers/GetCarWithArraySchema3.js.map +1 -0
  193. package/spec/mock-project/dist/src/handlers/GetCarWithLiteralSchema.js +26 -50
  194. package/spec/mock-project/dist/src/handlers/GetCarWithLiteralSchema.js.map +1 -0
  195. package/spec/mock-project/dist/src/handlers/GetCarWithLiteralSchema2.js +28 -50
  196. package/spec/mock-project/dist/src/handlers/GetCarWithLiteralSchema2.js.map +1 -0
  197. package/spec/mock-project/dist/src/handlers/GetCarWithSchemaInFile.js +27 -53
  198. package/spec/mock-project/dist/src/handlers/GetCarWithSchemaInFile.js.map +1 -0
  199. package/spec/mock-project/dist/src/handlers/GetCarWithSchemaInFile2.js +29 -53
  200. package/spec/mock-project/dist/src/handlers/GetCarWithSchemaInFile2.js.map +1 -0
  201. package/spec/mock-project/dist/src/handlers/ManuallyAddedHandler.js +16 -49
  202. package/spec/mock-project/dist/src/handlers/ManuallyAddedHandler.js.map +1 -0
  203. package/spec/mock-project/dist/src/handlers/ManuallyAddedHandler2.js +25 -50
  204. package/spec/mock-project/dist/src/handlers/ManuallyAddedHandler2.js.map +1 -0
  205. package/spec/mock-project/dist/src/handlers/PatchCar.js +27 -53
  206. package/spec/mock-project/dist/src/handlers/PatchCar.js.map +1 -0
  207. package/spec/mock-project/dist/src/handlers/PatchOnboardingSession.js +44 -70
  208. package/spec/mock-project/dist/src/handlers/PatchOnboardingSession.js.map +1 -0
  209. package/spec/mock-project/dist/src/handlers/PatchOrderWithComplexTypes.js +27 -53
  210. package/spec/mock-project/dist/src/handlers/PatchOrderWithComplexTypes.js.map +1 -0
  211. package/spec/mock-project/dist/src/handlers/PatchProductWithIntersection.js +28 -54
  212. package/spec/mock-project/dist/src/handlers/PatchProductWithIntersection.js.map +1 -0
  213. package/spec/mock-project/dist/src/handlers/PatchUserWithUnion.js +28 -54
  214. package/spec/mock-project/dist/src/handlers/PatchUserWithUnion.js.map +1 -0
  215. package/spec/mock-project/dist/src/handlers/PostCar.js +24 -50
  216. package/spec/mock-project/dist/src/handlers/PostCar.js.map +1 -0
  217. package/spec/mock-project/dist/src/handlers/PostLogin.js +25 -51
  218. package/spec/mock-project/dist/src/handlers/PostLogin.js.map +1 -0
  219. package/spec/mock-project/dist/src/handlers/PostLogout.js +24 -50
  220. package/spec/mock-project/dist/src/handlers/PostLogout.js.map +1 -0
  221. package/spec/mock-project/dist/src/handlers/PutCar.js +24 -50
  222. package/spec/mock-project/dist/src/handlers/PutCar.js.map +1 -0
  223. package/spec/mock-project/dist/src/handlers/StreamWriterFactory.js +83 -0
  224. package/spec/mock-project/dist/src/index.js +52 -76
  225. package/spec/mock-project/dist/src/index.js.map +1 -0
  226. package/spec/mock-project/dist/src/mock-data-generator.js +9 -0
  227. package/spec/mock-project/dist/src/repos/CarRepo.js +12 -24
  228. package/spec/mock-project/dist/src/repos/CarRepo.js.map +1 -0
  229. package/spec/mock-project/dist/src/schemas/Car.js +3 -1
  230. package/spec/mock-project/dist/src/schemas/Car.js.map +1 -0
  231. package/spec/mock-project/dist/src/schemas/DefaultExportSchema.js +3 -1
  232. package/spec/mock-project/dist/src/schemas/DefaultExportSchema.js.map +1 -0
  233. package/spec/mock-project/dist/src/schemas/FileWithTwoSchemas.js +3 -1
  234. package/spec/mock-project/dist/src/schemas/FileWithTwoSchemas.js.map +1 -0
  235. package/spec/mock-project/dist/src/utils.js +290 -0
  236. package/spec/mock-project/tsconfig.json +6 -1
  237. package/spec/schema-generation-nested-objects.spec.ts +97 -0
  238. package/spec/testHelpers.ts +49 -0
  239. package/spec/utils.caseConversion.spec.ts +78 -0
  240. package/spec/utils.spec.ts +13 -13
  241. package/src/DependencyTracker.ts +166 -0
  242. package/src/FlinkApp.ts +895 -154
  243. package/src/FlinkContext.ts +43 -0
  244. package/src/FlinkErrors.ts +32 -12
  245. package/src/FlinkHttpHandler.ts +182 -20
  246. package/src/FlinkJob.ts +11 -0
  247. package/src/FlinkLog.ts +119 -12
  248. package/src/FlinkLogFactory.ts +699 -0
  249. package/src/FlinkRepo.ts +10 -3
  250. package/src/FlinkRequestContext.ts +95 -0
  251. package/src/FlinkResponse.ts +6 -0
  252. package/src/FlinkService.ts +49 -0
  253. package/src/LeaderElection.ts +203 -0
  254. package/src/SchemaCache.ts +232 -0
  255. package/src/TypeScriptCompiler.ts +1347 -610
  256. package/src/TypeScriptUtils.ts +5 -0
  257. package/src/ai/AgentRunner.ts +646 -0
  258. package/src/ai/ConversationAgent.ts +413 -0
  259. package/src/ai/FlinkAgent.ts +1069 -0
  260. package/src/ai/FlinkTool.ts +165 -0
  261. package/src/ai/InMemoryConversationAgent.ts +149 -0
  262. package/src/ai/LLMAdapter.ts +126 -0
  263. package/src/ai/ToolExecutor.ts +485 -0
  264. package/src/ai/agentInstructions.ts +245 -0
  265. package/src/ai/index.ts +8 -0
  266. package/src/ai/instructionFileLoader.ts +156 -0
  267. package/src/auth/FlinkAuthPlugin.ts +2 -1
  268. package/src/handlers/StreamWriterFactory.ts +84 -0
  269. package/src/index.ts +14 -0
  270. package/src/loadPluginSchemas.ts +141 -0
  271. package/src/schema-extraction/TypeScriptSourceParser.ts +1058 -0
  272. package/src/schema-extraction/TypeScriptTokenizer.ts +205 -0
  273. package/src/schema-extraction/index.ts +2 -0
  274. package/src/schema-extraction/types.ts +34 -0
  275. package/src/utils/loadFlinkConfig.ts +89 -0
  276. package/src/utils.ts +52 -0
  277. package/tsconfig.json +6 -1
package/src/FlinkApp.ts CHANGED
@@ -9,18 +9,32 @@ import morgan from "morgan";
9
9
  import ms from "ms";
10
10
  import { AsyncTask, CronJob, SimpleIntervalJob, ToadScheduler } from "toad-scheduler";
11
11
  import { v4 } from "uuid";
12
+ import { FlinkAgentFile } from "./ai/FlinkAgent";
13
+ import { FlinkToolFile } from "./ai/FlinkTool";
14
+ import { AgentObserver } from "./ai/FlinkAgent";
15
+ import { LLMAdapter } from "./ai/LLMAdapter";
16
+ import { ToolExecutor } from "./ai/ToolExecutor";
12
17
  import { FlinkAuthPlugin } from "./auth/FlinkAuthPlugin";
13
18
  import { FlinkContext } from "./FlinkContext";
14
19
  import { FlinkError, internalServerError, notFound, unauthorized } from "./FlinkErrors";
15
20
  import { FlinkRequest, Handler, HandlerFile, HttpMethod, QueryParamMetadata, RouteProps, ValidationMode } from "./FlinkHttpHandler";
16
- import { FlinkJobFile } from "./FlinkJob";
21
+ import { FlinkJobFile, FlinkJobProps } from "./FlinkJob";
22
+ import { LeaderElection, LeaderElectionOptions } from "./LeaderElection";
17
23
  import { log } from "./FlinkLog";
24
+ import { FlinkLogFactory } from "./FlinkLogFactory";
18
25
  import { FlinkPlugin } from "./FlinkPlugin";
19
26
  import { FlinkRepo } from "./FlinkRepo";
27
+ import { FlinkService } from "./FlinkService";
20
28
  import { FlinkResponse } from "./FlinkResponse";
29
+ import { requestContext } from "./FlinkRequestContext";
30
+ import { StreamWriterFactory } from "./handlers/StreamWriterFactory";
21
31
  import generateMockData from "./mock-data-generator";
22
32
  import { formatValidationErrors, getPathParams, isError } from "./utils";
23
33
 
34
+ const initLog = FlinkLogFactory.createLogger("flink.init");
35
+ const perfLog = FlinkLogFactory.createLogger("flink.perf");
36
+ const schedulerLog = FlinkLogFactory.createLogger("flink.scheduler");
37
+
24
38
  const ajv = new Ajv();
25
39
  addFormats(ajv);
26
40
 
@@ -45,6 +59,7 @@ export const expressFn = express;
45
59
  export const autoRegisteredHandlers: {
46
60
  handler: HandlerFile;
47
61
  assumedHttpMethod: HttpMethod;
62
+ __file?: string;
48
63
  }[] = [];
49
64
 
50
65
  /**
@@ -63,7 +78,28 @@ export const autoRegisteredRepos: {
63
78
  */
64
79
  export const autoRegisteredJobs: FlinkJobFile[] = [];
65
80
 
66
- export interface FlinkOptions {
81
+ /**
82
+ * This will be populated at compile time when the apps tools
83
+ * are picked up by TypeScript compiler
84
+ */
85
+ export const autoRegisteredTools: FlinkToolFile[] = [];
86
+
87
+ /**
88
+ * This will be populated at compile time when the apps agents
89
+ * are picked up by TypeScript compiler
90
+ */
91
+ export const autoRegisteredAgents: FlinkAgentFile<any, any>[] = [];
92
+
93
+ /**
94
+ * This will be populated at compile time when the apps services
95
+ * are picked up by TypeScript compiler
96
+ */
97
+ export const autoRegisteredServices: {
98
+ serviceInstanceName: string;
99
+ Service: any;
100
+ }[] = [];
101
+
102
+ export interface FlinkOptions<C extends FlinkContext = FlinkContext> {
67
103
  /**
68
104
  * Name of application, will only show in logs and in HTTP header.
69
105
  */
@@ -156,28 +192,62 @@ export interface FlinkOptions {
156
192
  */
157
193
  enabled?: boolean;
158
194
 
159
- // TODO: Implement master auto assignment
160
- // /**
161
- // * If true, the master (the instance if flink app that will run jobs) will be
162
- // * automatically assigned to the first node that starts.
163
- // *
164
- // * Is persisted in database.
165
- // *
166
- // * Will throw and exception if true but no database is configured.
167
- // */
168
- // autoAssignMaster?: boolean;
169
-
170
- // /**
171
- // * Name of collection to be used for storing master assignment.
172
- // *
173
- // * Defaults to `flink-scheduling`
174
- // */
175
- // autoAssignCollection?: string;
195
+ /**
196
+ * Enable leader election for horizontally scaled deployments.
197
+ *
198
+ * When enabled, only one instance (the leader) will run scheduled jobs.
199
+ * If the leader goes down, another instance automatically takes over.
200
+ *
201
+ * Requires a database connection (`db` option) since leader election
202
+ * state is persisted in MongoDB. If no database is configured, a warning
203
+ * will be logged and jobs will run on all instances (no leader election).
204
+ *
205
+ * Set to `true` for default settings, or pass an options object to customize.
206
+ *
207
+ * @example
208
+ * ```ts
209
+ * // Use defaults (15s lease, 5s heartbeat)
210
+ * scheduling: { leaderElection: true }
211
+ *
212
+ * // Custom settings
213
+ * scheduling: {
214
+ * leaderElection: {
215
+ * leaseDurationMs: 30000,
216
+ * heartbeatIntervalMs: 10000,
217
+ * }
218
+ * }
219
+ * ```
220
+ */
221
+ leaderElection?: boolean | LeaderElectionOptions;
222
+ };
223
+
224
+ /**
225
+ * AI configuration for agents and tools
226
+ * Register LLM adapters with custom IDs (e.g., "anthropic", "openai", "anthropic-eu", etc.)
227
+ * This allows multiple adapters of the same type with different configurations
228
+ */
229
+ ai?: {
230
+ llms?: { [id: string]: LLMAdapter };
231
+
232
+ /**
233
+ * Global agent observer for app-level tracing, APM, cost accounting, dev tools, etc.
234
+ *
235
+ * Fires for every agent execution in the app. Observer callbacks are invoked
236
+ * fire-and-forget — they may return a Promise but the framework does not await
237
+ * them, and any thrown/rejected errors are caught and logged without affecting
238
+ * agent execution.
239
+ *
240
+ * Events: `onRun` (pre-loop), `onLlmCall` (per step, pre-adapter call),
241
+ * `onStep` (per step end), `onFinish` (post-loop, including error path).
242
+ *
243
+ * For agent-local business logic (conversation persistence, guardrails) use the
244
+ * per-agent `beforeRun` / `onStep` / `afterRun` hooks on `FlinkAgent` instead.
245
+ */
246
+ observer?: AgentObserver;
176
247
  };
177
248
 
178
249
  /**
179
250
  * If true, the HTTP server will be disabled.
180
- * Only useful when starting a Flink app for testing purposes.
181
251
  */
182
252
  disableHttpServer?: boolean;
183
253
 
@@ -199,17 +269,26 @@ export interface FlinkOptions {
199
269
  };
200
270
 
201
271
  /**
202
- * Optional callback invoked when an error occurs in a handler.
272
+ * Optional callback invoked when an error occurs while serving a request.
203
273
  * The error response and request context are passed for custom
204
274
  * error logging or monitoring. This is a side-effect only and
205
275
  * will not modify the response flow.
206
276
  *
277
+ * Invoked for:
278
+ * - Handler-thrown errors (FlinkErrors and unhandled exceptions)
279
+ * - Request validation failures (400 Bad request)
280
+ * - Response validation failures (500 Bad response)
281
+ *
282
+ * Not invoked for errors in streaming (SSE/NDJSON) handlers, which are
283
+ * delivered to the client via `stream.error()` instead.
284
+ *
207
285
  * Supports both synchronous and asynchronous callbacks. Any errors
208
286
  * thrown or rejected by the callback will be caught and logged
209
- * without affecting the error response to the client.
287
+ * without affecting the error response to the client. Async callbacks
288
+ * are fire-and-forget — they are not awaited before the response is sent.
210
289
  *
211
290
  * @param error - The error response with status and error details
212
- * @param context - Request context including method, path, and request ID
291
+ * @param context - Request context including method, path, request ID and the app context
213
292
  *
214
293
  * @example
215
294
  * ```ts
@@ -225,12 +304,14 @@ export interface FlinkOptions {
225
304
  * }
226
305
  * }
227
306
  *
228
- * // Asynchronous callback
307
+ * // Asynchronous callback using the app context
229
308
  * onError: async (error, context) => {
230
309
  * if (error.status >= 500) {
231
- * await monitoringService.reportError({
232
- * error,
233
- * context
310
+ * await context.ctx.repos.errorLogRepo.create({
311
+ * status: error.status,
312
+ * detail: error.error?.detail,
313
+ * route: `${context.method} ${context.path}`,
314
+ * reqId: context.reqId,
234
315
  * });
235
316
  * }
236
317
  * }
@@ -243,6 +324,7 @@ export interface FlinkOptions {
243
324
  method: HttpMethod;
244
325
  path: string;
245
326
  reqId: string;
327
+ ctx: C;
246
328
  }
247
329
  ) => void | Promise<void>;
248
330
  }
@@ -278,6 +360,8 @@ export class FlinkApp<C extends FlinkContext> {
278
360
 
279
361
  private _ctx?: C;
280
362
  private dbOpts?: FlinkOptions["db"];
363
+ private schemaManifest?: any;
364
+ private schemaAjv?: Ajv;
281
365
  private debug = false;
282
366
  private onDbConnection?: FlinkOptions["onDbConnection"];
283
367
 
@@ -290,9 +374,15 @@ export class FlinkApp<C extends FlinkContext> {
290
374
  private schedulingOptions?: FlinkOptions["scheduling"];
291
375
  private disableHttpServer = false;
292
376
  private expressServer: any; // for simplicity, we don't want to import types from express/node here
293
- private onError?: FlinkOptions["onError"];
377
+ private onError?: FlinkOptions<C>["onError"];
294
378
 
295
379
  private repos: { [x: string]: FlinkRepo<C, any> } = {};
380
+ private services: { [x: string]: FlinkService<C> } = {};
381
+
382
+ private llmAdapters: Map<string, LLMAdapter> = new Map();
383
+ private agentObserver?: AgentObserver;
384
+ private tools: { [x: string]: ToolExecutor<C> } = {};
385
+ private agents: { [x: string]: any } = {}; // FlinkAgent<C> instances
296
386
 
297
387
  /**
298
388
  * Internal cache used to track registered handlers and potentially any overlapping routes
@@ -300,10 +390,17 @@ export class FlinkApp<C extends FlinkContext> {
300
390
  private handlerRouteCache = new Map<string, string>();
301
391
 
302
392
  public scheduler?: ToadScheduler;
393
+ private allInstanceScheduler?: ToadScheduler;
394
+ private leaderElection?: LeaderElection;
303
395
 
304
396
  private accessLog: { enabled: boolean; format: string };
305
397
 
306
- constructor(opts: FlinkOptions) {
398
+ constructor(opts: FlinkOptions<C>) {
399
+ // Load config file and initialize logging
400
+ const { loadFlinkConfig } = require("./utils/loadFlinkConfig");
401
+ const flinkConfig = loadFlinkConfig();
402
+ FlinkLogFactory.configure(flinkConfig?.logging);
403
+
307
404
  this.name = opts.name;
308
405
  this.port = opts.port || 3333;
309
406
  this.dbOpts = opts.db;
@@ -314,14 +411,23 @@ export class FlinkApp<C extends FlinkContext> {
314
411
  this.rawContentTypes = Array.isArray(opts.rawContentTypes)
315
412
  ? opts.rawContentTypes
316
413
  : typeof opts.rawContentTypes === "string"
317
- ? [opts.rawContentTypes]
318
- : undefined;
414
+ ? [opts.rawContentTypes]
415
+ : undefined;
319
416
  this.auth = opts.auth;
320
417
  this.jsonOptions = opts.jsonOptions || { limit: "1mb" };
321
418
  this.schedulingOptions = opts.scheduling;
322
419
  this.disableHttpServer = !!opts.disableHttpServer;
323
420
  this.accessLog = { enabled: true, format: "dev", ...opts.accessLog };
324
421
  this.onError = opts.onError;
422
+
423
+ // Register LLM adapters if configured
424
+ if (opts.ai?.llms) {
425
+ // Convert plain object to Map for internal use
426
+ this.llmAdapters = new Map(Object.entries(opts.ai.llms));
427
+ }
428
+
429
+ // Register global agent observer if configured
430
+ this.agentObserver = opts.ai?.observer;
325
431
  }
326
432
 
327
433
  get ctx() {
@@ -333,26 +439,35 @@ export class FlinkApp<C extends FlinkContext> {
333
439
 
334
440
  async start() {
335
441
  const startTime = Date.now();
336
- let offsetTime = 0;
337
442
 
443
+ const dbStartTime = Date.now();
338
444
  await this.initDb();
445
+ perfLog.debug(`Init db took ${Date.now() - dbStartTime}ms`);
339
446
 
340
- if (this.debug) {
341
- offsetTime = Date.now();
342
- log.bgColorLog("cyan", `Init db took ${offsetTime - startTime} ms`);
343
- }
344
-
447
+ // Build initial context (without agents - they'll be added later)
448
+ const contextStartTime = Date.now();
345
449
  await this.buildContext();
450
+ perfLog.debug(`Build context took ${Date.now() - contextStartTime}ms`);
346
451
 
347
- if (this.debug) {
348
- log.bgColorLog("cyan", `Build context took ${Date.now() - offsetTime} ms`);
349
- offsetTime = Date.now();
350
- }
452
+ // Register tools (needs context for ToolExecutor)
453
+ const toolsStartTime = Date.now();
454
+ await this.registerAutoRegisterableTools();
455
+ perfLog.debug(`Register tools took ${Date.now() - toolsStartTime}ms`);
351
456
 
352
- if (this.isSchedulingEnabled) {
457
+ // Register agents (creates agent instances)
458
+ const agentsStartTime = Date.now();
459
+ await this.registerAutoRegisterableAgents();
460
+ perfLog.debug(`Register agents took ${Date.now() - agentsStartTime}ms`);
461
+
462
+ // Initialize agents now that context and tools are ready
463
+ const agentInitStartTime = Date.now();
464
+ await this.initializeAgents();
465
+ perfLog.debug(`Initialize agents took ${Date.now() - agentInitStartTime}ms`);
466
+
467
+ if (this.isSchedulingEnabled && !this.leaderElectionConfig) {
353
468
  this.scheduler = new ToadScheduler();
354
- } else {
355
- log.info("🚫 Scheduling is disabled");
469
+ } else if (!this.isSchedulingEnabled) {
470
+ schedulerLog.info("Scheduling is disabled");
356
471
  }
357
472
 
358
473
  if (!this.disableHttpServer) {
@@ -379,6 +494,7 @@ export class FlinkApp<C extends FlinkContext> {
379
494
 
380
495
  // TODO: Add better more fine grained control when plugins are initialized, i.e. in what order
381
496
 
497
+ const pluginsStartTime = Date.now();
382
498
  for (const plugin of this.plugins) {
383
499
  let db;
384
500
 
@@ -390,22 +506,23 @@ export class FlinkApp<C extends FlinkContext> {
390
506
  await plugin.init(this, db);
391
507
  }
392
508
 
393
- log.info(`Initialized plugin '${plugin.id}'`);
509
+ initLog.info(`Initialized plugin '${plugin.id}'`);
510
+ }
511
+ if (this.plugins.length > 0) {
512
+ perfLog.debug(`Initialize plugins took ${Date.now() - pluginsStartTime}ms (${this.plugins.length} plugins)`);
394
513
  }
395
514
 
515
+ const handlersStartTime = Date.now();
396
516
  await this.registerAutoRegisterableHandlers();
397
-
398
- if (this.debug) {
399
- log.bgColorLog("cyan", `Register handlers took ${Date.now() - offsetTime} ms`);
400
- offsetTime = Date.now();
401
- }
517
+ perfLog.debug(`Register handlers took ${Date.now() - handlersStartTime}ms`);
402
518
 
403
519
  if (this.isSchedulingEnabled) {
404
- await this.registerAutoRegisterableJobs();
405
-
406
- if (this.debug) {
407
- log.bgColorLog("cyan", `Register jobs took ${Date.now() - offsetTime} ms`);
408
- offsetTime = Date.now();
520
+ if (this.leaderElectionConfig) {
521
+ await this.startLeaderElection();
522
+ } else {
523
+ const jobsStartTime = Date.now();
524
+ await this.registerAutoRegisterableJobs();
525
+ perfLog.debug(`Register jobs took ${Date.now() - jobsStartTime}ms`);
409
526
  }
410
527
  }
411
528
 
@@ -422,7 +539,7 @@ export class FlinkApp<C extends FlinkContext> {
422
539
  });
423
540
 
424
541
  if (this.disableHttpServer) {
425
- log.info("🚧 HTTP server is disabled, but flink app is running");
542
+ initLog.info("🚧 HTTP server is disabled, but flink app is running");
426
543
  this.started = true;
427
544
  } else {
428
545
  this.expressServer = this.expressApp?.listen(this.port, () => {
@@ -431,16 +548,27 @@ export class FlinkApp<C extends FlinkContext> {
431
548
  });
432
549
  }
433
550
 
551
+ const totalStartTime = Date.now() - startTime;
552
+ perfLog.info(`✓ FlinkApp started in ${totalStartTime}ms`);
553
+
434
554
  return this;
435
555
  }
436
556
 
437
557
  async stop() {
438
558
  log.info("🛑 Stopping Flink app...");
439
559
 
560
+ if (this.leaderElection) {
561
+ await this.leaderElection.stop();
562
+ }
563
+
440
564
  if (this.scheduler) {
441
565
  await this.scheduler.stop();
442
566
  }
443
567
 
568
+ if (this.allInstanceScheduler) {
569
+ await this.allInstanceScheduler.stop();
570
+ }
571
+
444
572
  if (this.expressServer) {
445
573
  return new Promise<void>((resolve, reject) => {
446
574
  const int = setTimeout(() => {
@@ -490,6 +618,21 @@ export class FlinkApp<C extends FlinkContext> {
490
618
  log.warn(`${methodAndPath} overlaps existing route`);
491
619
  }
492
620
 
621
+ // Use direct schemas from routeProps if provided, otherwise fall back to manifest lookup
622
+ let reqSchema = routeProps.reqSchema;
623
+ let resSchema = routeProps.resSchema;
624
+ let queryMetadata: any[] = [];
625
+ let paramsMetadata: any[] = [];
626
+
627
+ if (!reqSchema || !resSchema) {
628
+ const schemaManifest = this.loadSchemaManifest();
629
+ const metadata = handler.__file ? schemaManifest.handlers[handler.__file] : undefined;
630
+ if (!reqSchema) reqSchema = this.resolveSchema(metadata?.reqSchemaName);
631
+ if (!resSchema) resSchema = this.resolveSchema(metadata?.resSchemaName);
632
+ queryMetadata = metadata?.queryMetadata || [];
633
+ paramsMetadata = metadata?.paramsMetadata || [];
634
+ }
635
+
493
636
  const handlerConfig: HandlerConfigWithMethod = {
494
637
  routeProps: {
495
638
  ...routeProps,
@@ -497,21 +640,13 @@ export class FlinkApp<C extends FlinkContext> {
497
640
  path: routeProps.path!,
498
641
  },
499
642
  schema: {
500
- reqSchema: handler.__schemas?.reqSchema,
501
- resSchema: handler.__schemas?.resSchema,
643
+ reqSchema,
644
+ resSchema,
502
645
  },
503
- queryMetadata: handler.__query || [],
504
- paramsMetadata: handler.__params || [],
646
+ queryMetadata,
647
+ paramsMetadata,
505
648
  };
506
649
 
507
- if (handler.__schemas?.reqSchema && !handlerConfig.schema?.reqSchema) {
508
- log.warn(`Expected request schema ${handler.__schemas.reqSchema} for handler ${methodAndPath} but no such schema was found`);
509
- }
510
-
511
- if (handler.__schemas?.resSchema && !handlerConfig.schema?.resSchema) {
512
- log.warn(`Expected response schema ${handler.__schemas.resSchema} for handler ${methodAndPath} but no such schema was found`);
513
- }
514
-
515
650
  this.registerHandler(handlerConfig, handler.default);
516
651
  }
517
652
 
@@ -519,7 +654,7 @@ export class FlinkApp<C extends FlinkContext> {
519
654
  this.handlers.push(handlerConfig);
520
655
 
521
656
  const { routeProps, schema = {} } = handlerConfig;
522
- const { method } = routeProps;
657
+ const { method, streamFormat } = routeProps;
523
658
 
524
659
  if (!method) {
525
660
  log.error(`Route ${routeProps.path} is missing http method`);
@@ -535,17 +670,45 @@ export class FlinkApp<C extends FlinkContext> {
535
670
  let validateReq: ValidateFunction<any> | undefined;
536
671
  let validateRes: ValidateFunction<any> | undefined;
537
672
 
673
+ // Select AJV instance (use schemaAjv for v2.0 manifests, fallback to global ajv)
674
+ const ajvInstance = this.schemaAjv || ajv;
675
+
538
676
  // Determine validation mode (default to Validate if not specified)
539
677
  const validationMode = routeProps.validation || ValidationMode.Validate;
540
678
 
541
679
  // Compile request schema if validation mode requires it
542
680
  if (schema.reqSchema && validationMode !== ValidationMode.SkipValidation && validationMode !== ValidationMode.ValidateResponse) {
543
- validateReq = ajv.compile(schema.reqSchema);
681
+ // For v2.0 manifests with $id, use getSchema() if available
682
+ if (schema.reqSchema.$id && this.schemaAjv) {
683
+ validateReq = this.schemaAjv.getSchema(schema.reqSchema.$id);
684
+ if (!validateReq) {
685
+ log.warn(`Schema ${schema.reqSchema.$id} not found in AJV registry, compiling inline`);
686
+ validateReq = ajvInstance.compile(schema.reqSchema);
687
+ }
688
+ } else {
689
+ validateReq = ajvInstance.compile(schema.reqSchema);
690
+ }
544
691
  }
545
692
 
546
- // Compile response schema if validation mode requires it
547
- if (schema.resSchema && validationMode !== ValidationMode.SkipValidation && validationMode !== ValidationMode.ValidateRequest) {
548
- validateRes = ajv.compile(schema.resSchema);
693
+ // Skip response validation for streaming handlers (responses are stream chunks, not final JSON)
694
+ // Skip response validation for non-JSON response types (html, csv, etc.)
695
+ if (
696
+ !streamFormat &&
697
+ !routeProps.responseType &&
698
+ schema.resSchema &&
699
+ validationMode !== ValidationMode.SkipValidation &&
700
+ validationMode !== ValidationMode.ValidateRequest
701
+ ) {
702
+ // For v2.0 manifests with $id, use getSchema() if available
703
+ if (schema.resSchema.$id && this.schemaAjv) {
704
+ validateRes = this.schemaAjv.getSchema(schema.resSchema.$id);
705
+ if (!validateRes) {
706
+ log.warn(`Schema ${schema.resSchema.$id} not found in AJV registry, compiling inline`);
707
+ validateRes = ajvInstance.compile(schema.resSchema);
708
+ }
709
+ } else {
710
+ validateRes = ajvInstance.compile(schema.resSchema);
711
+ }
549
712
  }
550
713
 
551
714
  this.expressApp![method](routeProps.path, async (req, res) => {
@@ -562,18 +725,23 @@ export class FlinkApp<C extends FlinkContext> {
562
725
  const formattedErrors = formatValidationErrors(validateReq.errors, req.body);
563
726
  log.warn(`[${req.reqId}] ${methodAndRoute}: Bad request\n${formattedErrors}`);
564
727
 
565
- return res.status(400).json({
728
+ const errorResponse: FlinkResponse<FlinkError> = {
566
729
  status: 400,
567
730
  error: {
568
731
  id: v4(),
569
732
  title: "Bad request",
570
733
  detail: formattedErrors,
571
734
  },
572
- });
735
+ };
736
+
737
+ this.invokeOnError(errorResponse, req as FlinkRequest, method!, routeProps.path);
738
+
739
+ return res.status(400).json(errorResponse);
573
740
  }
574
741
  }
575
742
 
576
- if (routeProps.mockApi && schema.resSchema) {
743
+ // Skip mock API for streaming handlers
744
+ if (routeProps.mockApi && schema.resSchema && !streamFormat) {
577
745
  log.warn(`Mock response for ${req.method.toUpperCase()} ${req.path}`);
578
746
 
579
747
  const data = generateMockData(schema.resSchema);
@@ -603,16 +771,45 @@ export class FlinkApp<C extends FlinkContext> {
603
771
  req.query = normalizedQuery;
604
772
  }
605
773
 
774
+ // Create stream writer if streaming handler
775
+ const stream = streamFormat ? StreamWriterFactory.create(res, streamFormat) : undefined;
776
+
606
777
  let handlerRes: FlinkResponse<any>;
607
778
 
608
779
  try {
609
- // 👇 This is where the actual handler gets invoked
610
- handlerRes = await handler({
611
- req: req as FlinkRequest,
612
- ctx: this.ctx,
613
- origin: routeProps.origin,
614
- });
780
+ // Wrap handler execution in AsyncLocalStorage context
781
+ // Context persists through entire async chain: handler → agent → tools
782
+ const flinkReq = req as FlinkRequest;
783
+ handlerRes = await requestContext.run(
784
+ {
785
+ reqId: flinkReq.reqId,
786
+ user: flinkReq.user,
787
+ userPermissions: flinkReq.userPermissions,
788
+ method: method,
789
+ path: routeProps.path,
790
+ timestamp: Date.now(),
791
+ isStreaming: !!streamFormat,
792
+ },
793
+ async () => {
794
+ return await handler({
795
+ req: flinkReq,
796
+ ctx: this.ctx,
797
+ origin: routeProps.origin,
798
+ stream,
799
+ });
800
+ }
801
+ );
615
802
  } catch (err: any) {
803
+ // Handle errors for streaming handlers
804
+ if (streamFormat && stream) {
805
+ log.error(`Streaming handler error on ${req.method.toUpperCase()} ${req.path}: ${err.message}`, {
806
+ error: err,
807
+ path: req.path,
808
+ method: req.method,
809
+ });
810
+ stream.error(err);
811
+ return;
812
+ }
616
813
  let errorResponse: FlinkResponse<FlinkError>;
617
814
 
618
815
  // duck typing to check if it is a FlinkError
@@ -632,50 +829,78 @@ export class FlinkApp<C extends FlinkContext> {
632
829
  errorResponse = internalServerError(err as any);
633
830
  }
634
831
 
635
- // Invoke onError callback if provided
636
- if (this.onError) {
637
- try {
638
- const result = this.onError(errorResponse, {
639
- req: req as FlinkRequest,
640
- method: method!,
641
- path: routeProps.path,
642
- reqId: req.reqId,
643
- });
644
-
645
- // Handle async callbacks - don't wait for them
646
- if (result instanceof Promise) {
647
- result.catch((callbackErr) => {
648
- log.error(`onError callback rejected with: ${callbackErr}`);
649
- });
650
- }
651
- } catch (callbackErr) {
652
- log.error(`onError callback threw an exception: ${callbackErr}`);
653
- }
654
- }
832
+ this.invokeOnError(errorResponse, req as FlinkRequest, method!, routeProps.path);
655
833
 
656
834
  return res.status(errorResponse.status || 500).json(errorResponse);
657
835
  }
658
836
 
837
+ // Skip response handling for streaming handlers (stream controls response lifecycle)
838
+ if (streamFormat) {
839
+ return;
840
+ }
841
+
842
+ // Ensure handlerRes is defined for non-streaming handlers
843
+ if (!handlerRes) {
844
+ return res.status(204).send();
845
+ }
846
+
659
847
  if (validateRes && !isError(handlerRes)) {
660
- const valid = validateRes(JSON.parse(JSON.stringify(handlerRes.data)));
848
+ if (handlerRes.data === undefined) {
849
+ if (handlerRes.status !== 204) {
850
+ const detail =
851
+ "Response schema is defined but handler returned no data";
852
+ log.warn(`[${req.reqId}] ${methodAndRoute}: Bad response - ${detail}`);
661
853
 
662
- if (!valid) {
663
- const formattedErrors = formatValidationErrors(validateRes.errors, handlerRes.data);
664
- log.warn(`[${req.reqId}] ${methodAndRoute}: Bad response\n${formattedErrors}`);
854
+ const errorResponse: FlinkResponse<FlinkError> = {
855
+ status: 500,
856
+ error: { id: v4(), title: "Bad response", detail },
857
+ };
665
858
 
666
- return res.status(500).json({
667
- status: 500,
668
- error: {
669
- id: v4(),
670
- title: "Bad response",
671
- detail: formattedErrors,
672
- },
673
- });
859
+ this.invokeOnError(errorResponse, req as FlinkRequest, method!, routeProps.path);
860
+
861
+ return res.status(500).json(errorResponse);
862
+ }
863
+ } else {
864
+ const valid = validateRes(JSON.parse(JSON.stringify(handlerRes.data)));
865
+
866
+ if (!valid) {
867
+ const formattedErrors = formatValidationErrors(validateRes.errors, handlerRes.data);
868
+ log.warn(`[${req.reqId}] ${methodAndRoute}: Bad response\n${formattedErrors}`);
869
+
870
+ const errorResponse: FlinkResponse<FlinkError> = {
871
+ status: 500,
872
+ error: {
873
+ id: v4(),
874
+ title: "Bad response",
875
+ detail: formattedErrors,
876
+ },
877
+ };
878
+
879
+ this.invokeOnError(errorResponse, req as FlinkRequest, method!, routeProps.path);
880
+
881
+ return res.status(500).json(errorResponse);
882
+ }
674
883
  }
675
884
  }
676
885
 
677
886
  res.set(handlerRes.headers);
678
887
 
888
+ if (routeProps.responseType) {
889
+ return res
890
+ .status(handlerRes.status || 200)
891
+ .type(routeProps.responseType)
892
+ .send(handlerRes.data);
893
+ }
894
+
895
+ if (handlerRes.error?.meta !== undefined) {
896
+ try {
897
+ JSON.stringify(handlerRes.error.meta);
898
+ } catch (e) {
899
+ log.warn(`[${handlerRes.reqId}] error.meta stripped from error ${handlerRes.error.id}: not JSON-serializable (${(e as Error).message})`);
900
+ delete handlerRes.error.meta;
901
+ }
902
+ }
903
+
679
904
  res.status(handlerRes.status || 200).json(handlerRes);
680
905
  });
681
906
 
@@ -684,11 +909,224 @@ export class FlinkApp<C extends FlinkContext> {
684
909
  return process.exit(1); // TODO: Do we need to exit?
685
910
  } else {
686
911
  this.handlerRouteCache.set(methodAndRoute, JSON.stringify(routeProps));
687
- log.info(`Registered route ${methodAndRoute}`);
912
+ initLog.info(`Registered ${streamFormat ? "streaming " : ""}route ${methodAndRoute}${streamFormat ? ` (${streamFormat})` : ""}`);
913
+ }
914
+ }
915
+ }
916
+
917
+ /**
918
+ * Load schema manifest from .flink directory.
919
+ * Returns empty structure if manifest doesn't exist (dev mode without build).
920
+ *
921
+ * The manifest contains:
922
+ * - definitions: ALL JSON Schema type definitions (supports $ref resolution)
923
+ * - handlers: Handler metadata with schema names (references to definitions)
924
+ * - tools: Tool metadata with schema names (references to definitions)
925
+ */
926
+ private loadSchemaManifest(): {
927
+ version?: string;
928
+ definitions?: Record<string, any>;
929
+ schemas?: Record<string, any>;
930
+ handlers: Record<
931
+ string,
932
+ {
933
+ reqSchemaName?: string;
934
+ resSchemaName?: string;
935
+ queryMetadata?: any[];
936
+ paramsMetadata?: any[];
937
+ assumedMethod?: string;
938
+ }
939
+ >;
940
+ tools: Record<
941
+ string,
942
+ {
943
+ inputSchemaName?: string;
944
+ outputSchemaName?: string;
945
+ inputTypeHint?: "void" | "any" | "named";
946
+ outputTypeHint?: "void" | "any" | "named";
947
+ }
948
+ >;
949
+ } {
950
+ // Return cached manifest if already loaded
951
+ if (this.schemaManifest) {
952
+ return this.schemaManifest;
953
+ }
954
+
955
+ const fs = require("fs");
956
+ const path = require("path");
957
+ const manifestPath = path.join(process.cwd(), "dist/.flink/schema-manifest.json");
958
+
959
+ if (!fs.existsSync(manifestPath)) {
960
+ log.warn("Schema manifest not found at dist/.flink/schema-manifest.json - handlers/tools may not have validation schemas");
961
+ const emptyManifest = { definitions: {}, handlers: {}, tools: {} };
962
+ this.schemaManifest = emptyManifest;
963
+ return emptyManifest;
964
+ }
965
+
966
+ try {
967
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
968
+
969
+ // Check version for backward compatibility
970
+ if (manifest.version === "2.0") {
971
+ // New format with schema universe and AJV global registry
972
+ this.schemaManifest = manifest;
973
+
974
+ // Initialize AJV and register all schemas
975
+ this.schemaAjv = new Ajv({
976
+ strict: false, // Allow additional properties by default
977
+ allErrors: true, // Return all validation errors, not just first
978
+ });
979
+ addFormats(this.schemaAjv);
980
+
981
+ // Register all schemas in the universe
982
+ const schemas = manifest.schemas || {};
983
+ for (const schema of Object.values(schemas)) {
984
+ if (schema && typeof schema === "object" && (schema as any).$id) {
985
+ // Skip schemas with unresolved generic type parameters (T, U, V, etc.)
986
+ if (this.hasUnresolvedTypeParams(schema, Object.keys(schemas))) {
987
+ log.debug(`Skipping registration of generic schema: ${(schema as any).$id}`);
988
+ continue;
989
+ }
990
+ this.schemaAjv.addSchema(schema);
991
+ }
992
+ }
993
+
994
+ log.debug(
995
+ `Loaded schema manifest v2.0: ${Object.keys(schemas).length} schemas, ${Object.keys(manifest.handlers).length} handlers, ${
996
+ Object.keys(manifest.tools).length
997
+ } tools`
998
+ );
999
+
1000
+ return manifest;
1001
+ } else {
1002
+ // Old format (v1.0) - still supported for migration
1003
+ log.debug(
1004
+ `Loaded schema manifest v1.0: ${Object.keys(manifest.definitions || {}).length} definitions, ${Object.keys(manifest.handlers).length} handlers, ${
1005
+ Object.keys(manifest.tools).length
1006
+ } tools`
1007
+ );
1008
+ this.schemaManifest = manifest;
1009
+ return manifest;
688
1010
  }
1011
+ } catch (error) {
1012
+ log.error("Failed to parse schema manifest:", error);
1013
+ const errorManifest = { definitions: {}, handlers: {}, tools: {} };
1014
+ this.schemaManifest = errorManifest;
1015
+ return errorManifest;
689
1016
  }
690
1017
  }
691
1018
 
1019
+ /**
1020
+ * Get the AJV instance for validation (v2.0 manifests).
1021
+ * Returns undefined for v1.0 manifests.
1022
+ */
1023
+ public getSchemaAjv(): Ajv | undefined {
1024
+ return this.schemaAjv;
1025
+ }
1026
+
1027
+ /**
1028
+ * Register plugin schemas into the app's AJV instance.
1029
+ * Schema $id values are prefixed with `pluginId::` to avoid collisions
1030
+ * with the app's own schemas or other plugins.
1031
+ *
1032
+ * @param pluginId Unique identifier for the plugin (used as namespace prefix)
1033
+ * @param schemas Record of schema name to JSON Schema object (already prefixed)
1034
+ */
1035
+ public registerSchemas(pluginId: string, schemas: Record<string, any>): void {
1036
+ // Ensure schema manifest and AJV are initialized
1037
+ this.loadSchemaManifest();
1038
+
1039
+ if (!this.schemaAjv) {
1040
+ // If no v2.0 manifest exists, create a fresh AJV instance
1041
+ this.schemaAjv = new Ajv({
1042
+ strict: false,
1043
+ allErrors: true,
1044
+ });
1045
+ addFormats(this.schemaAjv);
1046
+ }
1047
+
1048
+ for (const schema of Object.values(schemas)) {
1049
+ if (schema && typeof schema === "object" && schema.$id) {
1050
+ // Skip if already registered
1051
+ if (this.schemaAjv.getSchema(schema.$id)) {
1052
+ continue;
1053
+ }
1054
+ try {
1055
+ this.schemaAjv.addSchema(schema);
1056
+ } catch (err) {
1057
+ log.warn(`Failed to register plugin schema ${schema.$id}: ${err}`);
1058
+ }
1059
+ }
1060
+ }
1061
+
1062
+ log.debug(`Registered ${Object.keys(schemas).length} schemas from plugin '${pluginId}'`);
1063
+ }
1064
+
1065
+ /**
1066
+ * Check if a schema has unresolved generic type parameter references.
1067
+ * Generic type parameters like T, U, V are single uppercase letters.
1068
+ *
1069
+ * @param schema JSON schema object
1070
+ * @param registeredSchemaIds List of schema IDs in the manifest
1071
+ * @returns true if schema references generic type params that don't exist
1072
+ */
1073
+ private hasUnresolvedTypeParams(schema: any, registeredSchemaIds: string[]): boolean {
1074
+ const schemaIdSet = new Set(registeredSchemaIds);
1075
+
1076
+ const checkRefs = (obj: any): boolean => {
1077
+ if (!obj || typeof obj !== "object") {
1078
+ return false;
1079
+ }
1080
+
1081
+ if (Array.isArray(obj)) {
1082
+ return obj.some((item) => checkRefs(item));
1083
+ }
1084
+
1085
+ for (const [key, value] of Object.entries(obj)) {
1086
+ if (key === "$ref" && typeof value === "string") {
1087
+ // Check if ref is a single uppercase letter (generic type param)
1088
+ // and not in the registered schema IDs
1089
+ if (/^[A-Z]$/.test(value) && !schemaIdSet.has(value)) {
1090
+ return true;
1091
+ }
1092
+ } else if (typeof value === "object") {
1093
+ if (checkRefs(value)) {
1094
+ return true;
1095
+ }
1096
+ }
1097
+ }
1098
+
1099
+ return false;
1100
+ };
1101
+
1102
+ return checkRefs(schema);
1103
+ }
1104
+
1105
+ /**
1106
+ * Resolve schema by name from manifest.
1107
+ * Works with both v1.0 (definitions) and v2.0 (schema universe) formats.
1108
+ *
1109
+ * @param schemaName Schema name or $id
1110
+ * @returns JSON schema object or undefined
1111
+ */
1112
+ private resolveSchema(schemaName: string | undefined): any {
1113
+ if (!schemaName) return undefined;
1114
+
1115
+ const manifest = this.loadSchemaManifest();
1116
+
1117
+ // v2.0 manifest - return schema from universe
1118
+ if (manifest.version === "2.0" && manifest.schemas) {
1119
+ return manifest.schemas[schemaName];
1120
+ }
1121
+
1122
+ // v1.0 manifest - return from definitions
1123
+ if (manifest.definitions) {
1124
+ return manifest.definitions[schemaName];
1125
+ }
1126
+
1127
+ return undefined;
1128
+ }
1129
+
692
1130
  /**
693
1131
  * Register handlers found within the `/src/handlers`
694
1132
  * directory in Flink App.
@@ -696,30 +1134,70 @@ export class FlinkApp<C extends FlinkContext> {
696
1134
  * Will not register any handlers added programmatically.
697
1135
  */
698
1136
  private async registerAutoRegisterableHandlers() {
699
- for (const { handler, assumedHttpMethod } of autoRegisteredHandlers.sort((a, b) => (a.handler.Route?.order || 0) - (b.handler.Route?.order || 0))) {
1137
+ // Load schema manifest once
1138
+ const schemaManifest = this.loadSchemaManifest();
1139
+ const schemaCount =
1140
+ schemaManifest.version === "2.0" ? Object.keys(schemaManifest.schemas || {}).length : Object.keys(schemaManifest.definitions || {}).length;
1141
+
1142
+ log.debug(`Registering ${schemaCount} schemas with AJV (manifest version: ${schemaManifest.version || "1.0"})`);
1143
+
1144
+ for (const { handler, assumedHttpMethod, __file } of autoRegisteredHandlers.sort((a, b) => {
1145
+ const orderDiff = (a.handler.Route?.order || 0) - (b.handler.Route?.order || 0);
1146
+ if (orderDiff !== 0) return orderDiff;
1147
+ // Static segments must be registered before parameterized ones to avoid
1148
+ // Express matching e.g. GET /jobs/by-tags with the /jobs/:id route.
1149
+ const aHasParam = a.handler.Route?.path?.includes("/:") ? 1 : 0;
1150
+ const bHasParam = b.handler.Route?.path?.includes("/:") ? 1 : 0;
1151
+ return aHasParam - bHasParam;
1152
+ })) {
700
1153
  if (!handler.Route) {
701
- log.error(`Missing Props in handler ${handler.__file}`);
1154
+ log.error(`Missing Props in handler ${__file}`);
702
1155
  continue;
703
1156
  }
704
1157
 
705
1158
  if (!handler.default) {
706
- log.error(`Missing exported handler function in handler ${handler.__file}`);
1159
+ log.error(`Missing exported handler function in handler ${__file}`);
707
1160
  continue;
708
1161
  }
709
1162
 
710
- if (!!handler.__params?.length) {
1163
+ // Look up ALL metadata by file path from the schema manifest
1164
+ const metadata = schemaManifest.handlers[__file || ""];
1165
+
1166
+ // Use direct schemas from Route if provided, otherwise resolve from manifest
1167
+ const reqSchema = handler.Route.reqSchema || this.resolveSchema(metadata?.reqSchemaName);
1168
+ const resSchema = handler.Route.resSchema || this.resolveSchema(metadata?.resSchemaName);
1169
+
1170
+ // Validation warnings
1171
+ if (
1172
+ !metadata &&
1173
+ (handler.Route.validation === ValidationMode.Validate ||
1174
+ handler.Route.validation === ValidationMode.ValidateRequest ||
1175
+ handler.Route.validation === ValidationMode.ValidateResponse)
1176
+ ) {
1177
+ log.warn(`Handler ${__file} expects validation but no metadata found in manifest`);
1178
+ }
1179
+
1180
+ // Warn if schema name doesn't resolve
1181
+ if (metadata?.reqSchemaName && !reqSchema) {
1182
+ log.warn(`Handler ${__file} references reqSchema "${metadata.reqSchemaName}" but not found in schema universe`);
1183
+ }
1184
+ if (metadata?.resSchemaName && !resSchema) {
1185
+ log.warn(`Handler ${__file} references resSchema "${metadata.resSchemaName}" but not found in schema universe`);
1186
+ }
1187
+
1188
+ if (!!metadata?.paramsMetadata?.length) {
711
1189
  const pathParams = getPathParams(handler.Route.path);
712
1190
 
713
- for (const param of handler.__params) {
1191
+ for (const param of metadata.paramsMetadata) {
714
1192
  if (!pathParams.includes(param.name)) {
715
- log.error(`Handler ${handler.__file} has param ${param.name} but it is not present in the path '${handler.Route.path}'`);
1193
+ log.error(`Handler ${__file} has param ${param.name} but it is not present in the path '${handler.Route.path}'`);
716
1194
  throw new Error("Invalid/missing handler path param");
717
1195
  }
718
1196
  }
719
1197
 
720
- if (pathParams.length !== handler.__params.length) {
1198
+ if (pathParams.length !== metadata.paramsMetadata.length) {
721
1199
  log.warn(
722
- `Handler ${handler.__file} has ${handler.__params.length} typed params but the path '${handler.Route.path}' has ${pathParams.length} params`
1200
+ `Handler ${__file} has ${metadata.paramsMetadata.length} typed params but the path '${handler.Route.path}' has ${pathParams.length} params`
723
1201
  );
724
1202
  }
725
1203
  }
@@ -728,55 +1206,58 @@ export class FlinkApp<C extends FlinkContext> {
728
1206
  {
729
1207
  routeProps: {
730
1208
  ...handler.Route,
731
- method: handler.Route.method || assumedHttpMethod,
1209
+ method: handler.Route.method || assumedHttpMethod || metadata?.assumedMethod,
732
1210
  origin: this.name,
733
1211
  },
734
1212
  schema: {
735
- reqSchema: handler.__schemas?.reqSchema,
736
- resSchema: handler.__schemas?.resSchema,
1213
+ reqSchema,
1214
+ resSchema,
737
1215
  },
738
- queryMetadata: handler.__query || [],
739
- paramsMetadata: handler.__params || [],
1216
+ queryMetadata: metadata?.queryMetadata || [],
1217
+ paramsMetadata: metadata?.paramsMetadata || [],
740
1218
  },
741
1219
  handler.default
742
1220
  );
743
1221
  }
744
1222
  }
745
1223
 
746
- private async registerAutoRegisterableJobs() {
1224
+ private async registerAutoRegisterableJobs(filter?: (jobProps: FlinkJobProps) => boolean) {
747
1225
  if (!this.scheduler) {
748
1226
  throw new Error("Scheduler not initialized"); // should never happen
749
1227
  }
750
1228
 
751
1229
  for (const { Job: jobProps, default: jobFn, __file } of autoRegisteredJobs) {
1230
+ if (filter && !filter(jobProps)) {
1231
+ continue;
1232
+ }
752
1233
  if (jobProps.cron && jobProps.interval) {
753
- log.error(`Cannot register job ${jobProps.id} - both cron and interval are set in ${__file}`);
1234
+ schedulerLog.error(`Cannot register job ${jobProps.id} - both cron and interval are set in ${__file}`);
754
1235
  continue;
755
1236
  }
756
1237
 
757
1238
  if (jobProps.cron && jobProps.afterDelay) {
758
- log.error(`Cannot register job ${jobProps.id} - both cron and afterDelay are set in ${__file}`);
1239
+ schedulerLog.error(`Cannot register job ${jobProps.id} - both cron and afterDelay are set in ${__file}`);
759
1240
  continue;
760
1241
  }
761
1242
 
762
1243
  if (jobProps.interval && jobProps.afterDelay) {
763
- log.error(`Cannot register job ${jobProps.id} - both interval and afterDelay are set in ${__file}`);
1244
+ schedulerLog.error(`Cannot register job ${jobProps.id} - both interval and afterDelay are set in ${__file}`);
764
1245
  continue;
765
1246
  }
766
1247
 
767
1248
  if (this.scheduler.existsById(jobProps.id)) {
768
- log.error(`Job with id ${jobProps.id} is already registered, found duplicate in ${__file}`);
1249
+ schedulerLog.error(`Job with id ${jobProps.id} is already registered, found duplicate in ${__file}`);
769
1250
  continue;
770
1251
  }
771
1252
 
772
- log.debug(`Registering job ${jobProps.id}: ${JSON.stringify(jobProps)} from ${__file}`);
1253
+ schedulerLog.debug(`Registering job ${jobProps.id}: ${JSON.stringify(jobProps)} from ${__file}`);
773
1254
 
774
1255
  const task = new AsyncTask(
775
1256
  jobProps.id,
776
1257
  async () => {
777
1258
  await jobFn({ ctx: this.ctx });
778
1259
 
779
- log.debug(`Job ${jobProps.id} completed`);
1260
+ schedulerLog.debug(`Job ${jobProps.id} completed`);
780
1261
 
781
1262
  if (jobProps.afterDelay) {
782
1263
  // afterDelay runs only once, so we remove the job
@@ -784,7 +1265,7 @@ export class FlinkApp<C extends FlinkContext> {
784
1265
  }
785
1266
  },
786
1267
  (err) => {
787
- log.error(`Job ${jobProps.id} threw unhandled exception ${err}`);
1268
+ schedulerLog.error(`Job ${jobProps.id} threw unhandled exception ${err}`);
788
1269
  console.error(err);
789
1270
  }
790
1271
  );
@@ -811,20 +1292,32 @@ export class FlinkApp<C extends FlinkContext> {
811
1292
 
812
1293
  this.scheduler.addSimpleIntervalJob(job);
813
1294
  } else if (jobProps.afterDelay !== undefined) {
814
- const job = new SimpleIntervalJob(
815
- {
816
- milliseconds: ms(jobProps.afterDelay),
817
- runImmediately: false,
818
- },
819
- task,
820
- {
821
- id: jobProps.id,
822
- preventOverrun: jobProps.singleton,
823
- }
824
- );
825
- this.scheduler.addSimpleIntervalJob(job);
1295
+ const delayMs = ms(jobProps.afterDelay);
1296
+ if (delayMs === 0) {
1297
+ setImmediate(async () => {
1298
+ try {
1299
+ await jobFn({ ctx: this.ctx });
1300
+ } catch (err) {
1301
+ schedulerLog.error(`Job ${jobProps.id} threw unhandled exception ${err}`);
1302
+ console.error(err);
1303
+ }
1304
+ });
1305
+ } else {
1306
+ const job = new SimpleIntervalJob(
1307
+ {
1308
+ milliseconds: delayMs,
1309
+ runImmediately: false,
1310
+ },
1311
+ task,
1312
+ {
1313
+ id: jobProps.id,
1314
+ preventOverrun: jobProps.singleton,
1315
+ }
1316
+ );
1317
+ this.scheduler.addSimpleIntervalJob(job);
1318
+ }
826
1319
  } else {
827
- log.error(`Cannot register job ${jobProps.id} - no cron, interval or once set in ${__file}`);
1320
+ schedulerLog.error(`Cannot register job ${jobProps.id} - no cron, interval or once set in ${__file}`);
828
1321
  continue;
829
1322
  }
830
1323
  }
@@ -836,6 +1329,142 @@ export class FlinkApp<C extends FlinkContext> {
836
1329
  // repoInstance.ctx = this.ctx;
837
1330
  }
838
1331
 
1332
+ private async registerAutoRegisterableTools() {
1333
+ const { ToolExecutor } = require("./ai/ToolExecutor");
1334
+ const { getRepoInstanceName } = require("./utils");
1335
+
1336
+ // Load schema manifest once
1337
+ const schemaManifest = this.loadSchemaManifest();
1338
+
1339
+ for (const toolFile of autoRegisteredTools) {
1340
+ if (!toolFile.Tool) {
1341
+ log.error(`Missing FlinkToolProps export in tool ${toolFile.__file}`);
1342
+ continue;
1343
+ }
1344
+
1345
+ if (!toolFile.default) {
1346
+ log.error(`Missing exported tool function in tool ${toolFile.__file}`);
1347
+ continue;
1348
+ }
1349
+
1350
+ const toolId = toolFile.Tool.id;
1351
+ if (!toolId) {
1352
+ log.error(`Tool ${toolFile.__file} missing 'id' property`);
1353
+ continue;
1354
+ }
1355
+
1356
+ const toolInstanceName = getRepoInstanceName(toolId);
1357
+
1358
+ // Look up schema names from manifest
1359
+ const metadata = schemaManifest.tools[toolFile.__file || ""];
1360
+
1361
+ // Resolve schemas using helper (works with both v1.0 and v2.0)
1362
+ const schemas = metadata
1363
+ ? {
1364
+ inputSchema: this.resolveSchema(metadata.inputSchemaName),
1365
+ outputSchema: this.resolveSchema(metadata.outputSchemaName),
1366
+ inputTypeHint: metadata.inputTypeHint,
1367
+ outputTypeHint: metadata.outputTypeHint,
1368
+ }
1369
+ : undefined;
1370
+
1371
+ // Warn if schema name doesn't resolve
1372
+ if (metadata?.inputSchemaName && !schemas?.inputSchema) {
1373
+ log.warn(`Tool ${toolFile.__file} references inputSchema "${metadata.inputSchemaName}" but not found in schema universe`);
1374
+ }
1375
+ if (metadata?.outputSchemaName && !schemas?.outputSchema) {
1376
+ log.warn(`Tool ${toolFile.__file} references outputSchema "${metadata.outputSchemaName}" but not found in schema universe`);
1377
+ }
1378
+
1379
+ // Pass full schema universe so AJV can resolve $ref across schemas
1380
+ const allSchemas = schemaManifest.version === "2.0" ? schemaManifest.schemas : schemaManifest.definitions;
1381
+
1382
+ const toolExecutor = new ToolExecutor(
1383
+ toolFile.Tool,
1384
+ toolFile.default,
1385
+ this.ctx,
1386
+ schemas, // Auto-generated schemas from manifest (resolved from definitions)
1387
+ allSchemas
1388
+ );
1389
+ this.tools[toolInstanceName] = toolExecutor;
1390
+
1391
+ initLog.info(`Registered tool ${toolInstanceName} (${toolId})`);
1392
+ }
1393
+ }
1394
+
1395
+ private async registerAutoRegisterableAgents() {
1396
+ const { getRepoInstanceName, toKebabCase } = require("./utils");
1397
+
1398
+ for (const agentFile of autoRegisteredAgents) {
1399
+ // agentFile now exports a class, not a config object
1400
+ const AgentClass = agentFile.default;
1401
+
1402
+ if (!AgentClass) {
1403
+ log.error(`Missing default export in agent ${agentFile.__file}`);
1404
+ continue;
1405
+ }
1406
+
1407
+ // Instantiate agent (similar to repo instantiation)
1408
+ const agentInstance = new AgentClass();
1409
+
1410
+ // Derive instance name from class name (camelCase)
1411
+ const agentInstanceName = getRepoInstanceName(AgentClass.name);
1412
+
1413
+ // Get agent ID (kebab-case) - either explicit or derived
1414
+ const agentId = agentInstance.id;
1415
+
1416
+ // Check for duplicate instance name
1417
+ if (this.agents[agentInstanceName]) {
1418
+ const existingAgent = this.agents[agentInstanceName];
1419
+ throw new Error(
1420
+ `Duplicate agent instance name: "${agentInstanceName}". ` +
1421
+ `Agent class "${AgentClass.name}" conflicts with existing agent "${existingAgent.constructor.name}". ` +
1422
+ `Instance names are derived by lowercasing the first letter of the class name. ` +
1423
+ `Rename one of the classes or use a unique explicit 'id' property.`
1424
+ );
1425
+ }
1426
+
1427
+ // Check for duplicate agent ID
1428
+ const existingAgentWithSameId = Object.values(this.agents).find((agent: any) => agent.id === agentId);
1429
+
1430
+ if (existingAgentWithSameId) {
1431
+ throw new Error(
1432
+ `Duplicate agent ID: "${agentId}". ` +
1433
+ `Agent class "${AgentClass.name}" conflicts with existing agent "${existingAgentWithSameId.constructor.name}". ` +
1434
+ `Change the 'id' property on one of them to resolve the conflict.`
1435
+ );
1436
+ }
1437
+
1438
+ // Validate tools exist
1439
+ if (agentInstance.tools) {
1440
+ for (const toolRef of agentInstance.tools) {
1441
+ // Handle string IDs, tool file references, and tool props
1442
+ let toolId: string;
1443
+ if (typeof toolRef === "string") {
1444
+ toolId = toolRef;
1445
+ } else if ("Tool" in toolRef) {
1446
+ // FlinkToolFile - extract ID from Tool property
1447
+ toolId = toolRef.Tool.id;
1448
+ } else {
1449
+ // FlinkToolProps - extract ID directly
1450
+ toolId = toolRef.id;
1451
+ }
1452
+
1453
+ const tool = this.tools[toolId];
1454
+
1455
+ if (!tool) {
1456
+ log.error(`Agent ${AgentClass.name} references tool ${toolId} which is not registered`);
1457
+ throw new Error(`Invalid tool reference in agent ${AgentClass.name}`);
1458
+ }
1459
+ }
1460
+ }
1461
+
1462
+ // Register agent (duplicate checks already performed above)
1463
+ this.agents[agentInstanceName] = agentInstance;
1464
+ initLog.info(`Registered agent ${agentInstanceName} (${AgentClass.name}) with ID: ${agentId}`);
1465
+ }
1466
+ }
1467
+
839
1468
  /**
840
1469
  * Constructs the app context. Will inject context in all components
841
1470
  * except for handlers which are handled in later stage.
@@ -847,7 +1476,7 @@ export class FlinkApp<C extends FlinkContext> {
847
1476
 
848
1477
  this.repos[repoInstanceName] = repoInstance;
849
1478
 
850
- log.info(`Registered repo ${repoInstanceName}`);
1479
+ initLog.info(`Registered repo ${repoInstanceName}`);
851
1480
  }
852
1481
  } else if (autoRegisteredRepos.length > 0) {
853
1482
  log.warn(`No db configured but found repo(s)`);
@@ -861,15 +1490,47 @@ export class FlinkApp<C extends FlinkContext> {
861
1490
  return out;
862
1491
  }, {});
863
1492
 
1493
+ // Instantiate services (ctx not yet available - constructors must not access it)
1494
+ for (const { serviceInstanceName, Service } of autoRegisteredServices) {
1495
+ const serviceInstance: FlinkService<C> = new Service();
1496
+ this.services[serviceInstanceName] = serviceInstance;
1497
+ initLog.info(`Registered service ${serviceInstanceName}`);
1498
+ }
1499
+
864
1500
  this._ctx = {
865
1501
  repos: this.repos,
866
1502
  plugins: pluginCtx,
867
1503
  auth: this.auth,
1504
+ agents: this.agents,
1505
+ services: this.services,
868
1506
  } as C;
869
1507
 
1508
+ // Inject context into repos
870
1509
  for (const repo of Object.values(this.repos)) {
871
1510
  repo.ctx = this.ctx;
872
1511
  }
1512
+
1513
+ // Inject context into services, then call onInit() in parallel
1514
+ for (const service of Object.values(this.services)) {
1515
+ service.ctx = this.ctx;
1516
+ }
1517
+
1518
+ const servicesWithInit = Object.values(this.services).filter((s) => typeof s.onInit === "function");
1519
+ if (servicesWithInit.length > 0) {
1520
+ await Promise.all(servicesWithInit.map((s) => s.onInit!()));
1521
+ }
1522
+ }
1523
+
1524
+ /**
1525
+ * Initialize agents after they've been registered and context is ready
1526
+ * Must be called after registerAutoRegisterableAgents()
1527
+ */
1528
+ private async initializeAgents() {
1529
+ // Inject context and initialize agents
1530
+ for (const agent of Object.values(this.agents)) {
1531
+ agent.ctx = this.ctx;
1532
+ agent.__init(this.llmAdapters, this.tools, this.agentObserver);
1533
+ }
873
1534
  }
874
1535
 
875
1536
  /**
@@ -911,7 +1572,7 @@ export class FlinkApp<C extends FlinkContext> {
911
1572
  }
912
1573
  } else if (plugin.db.uri) {
913
1574
  try {
914
- log.debug(`Connecting to '${plugin.id}' db`);
1575
+ initLog.debug(`Connecting to '${plugin.id}' db`);
915
1576
  const client = await MongoClient.connect(plugin.db.uri, this.getMongoConnectionOptions());
916
1577
  return client.db();
917
1578
  } catch (err) {
@@ -925,7 +1586,37 @@ export class FlinkApp<C extends FlinkContext> {
925
1586
  if (!this.auth) {
926
1587
  throw new Error(`Attempting to authenticate request (${req.method} ${req.path}) but no authPlugin is set`);
927
1588
  }
928
- return await this.auth.authenticateRequest(req as FlinkRequest, permissions);
1589
+ return await this.auth.authenticateRequest(req as FlinkRequest, permissions, this._ctx);
1590
+ }
1591
+
1592
+ /**
1593
+ * Invokes the optional onError callback in a fire-and-forget manner.
1594
+ * Any error thrown or rejected by the callback is caught and logged so
1595
+ * it never affects the error response sent to the client.
1596
+ */
1597
+ private invokeOnError(errorResponse: FlinkResponse<FlinkError>, req: FlinkRequest, method: HttpMethod, path: string) {
1598
+ if (!this.onError) {
1599
+ return;
1600
+ }
1601
+
1602
+ try {
1603
+ const result = this.onError(errorResponse, {
1604
+ req,
1605
+ method,
1606
+ path,
1607
+ reqId: req.reqId,
1608
+ ctx: this.ctx,
1609
+ });
1610
+
1611
+ // Handle async callbacks - don't wait for them
1612
+ if (result instanceof Promise) {
1613
+ result.catch((callbackErr) => {
1614
+ log.error(`onError callback rejected with: ${callbackErr}`);
1615
+ });
1616
+ }
1617
+ } catch (callbackErr) {
1618
+ log.error(`onError callback threw an exception: ${callbackErr}`);
1619
+ }
929
1620
  }
930
1621
 
931
1622
  public getRegisteredRoutes() {
@@ -936,6 +1627,56 @@ export class FlinkApp<C extends FlinkContext> {
936
1627
  return this.schedulingOptions?.enabled !== false;
937
1628
  }
938
1629
 
1630
+ private get leaderElectionConfig(): LeaderElectionOptions | undefined {
1631
+ const opt = this.schedulingOptions?.leaderElection;
1632
+ if (!opt) return undefined;
1633
+ return opt === true ? {} : opt;
1634
+ }
1635
+
1636
+ private async startLeaderElection() {
1637
+ if (!this.db) {
1638
+ schedulerLog.warn(
1639
+ "Leader election is enabled but no database is configured. " +
1640
+ "Leader election requires a MongoDB connection to coordinate between instances. " +
1641
+ "Either add a database connection via the `db` option, or remove `scheduling.leaderElection` from your config. " +
1642
+ "Jobs will run on ALL instances without leader election."
1643
+ );
1644
+ // Fall back to running jobs on all instances
1645
+ this.scheduler = new ToadScheduler();
1646
+ await this.registerAutoRegisterableJobs();
1647
+ return;
1648
+ }
1649
+
1650
+ // Register runOnAllInstances jobs immediately on a separate scheduler
1651
+ const hasAllInstanceJobs = autoRegisteredJobs.some((j) => j.Job.runOnAllInstances);
1652
+ if (hasAllInstanceJobs) {
1653
+ this.allInstanceScheduler = new ToadScheduler();
1654
+ this.scheduler = this.allInstanceScheduler;
1655
+ await this.registerAutoRegisterableJobs((job) => !!job.runOnAllInstances);
1656
+ this.scheduler = undefined;
1657
+ }
1658
+
1659
+ const opts = this.leaderElectionConfig;
1660
+ this.leaderElection = new LeaderElection(this.db, opts);
1661
+
1662
+ await this.leaderElection.start(
1663
+ // onBecameLeader
1664
+ async () => {
1665
+ schedulerLog.info("This instance is now the leader - starting scheduled jobs");
1666
+ this.scheduler = new ToadScheduler();
1667
+ await this.registerAutoRegisterableJobs((job) => !job.runOnAllInstances);
1668
+ },
1669
+ // onLostLeadership
1670
+ () => {
1671
+ schedulerLog.info("This instance lost leadership - stopping scheduled jobs");
1672
+ if (this.scheduler) {
1673
+ this.scheduler.stop();
1674
+ this.scheduler = undefined;
1675
+ }
1676
+ }
1677
+ );
1678
+ }
1679
+
939
1680
  private getMongoConnectionOptions() {
940
1681
  if (!this.dbOpts) {
941
1682
  throw new Error("No db configured");
@@ -944,14 +1685,14 @@ export class FlinkApp<C extends FlinkContext> {
944
1685
  const { version: driverVersion } = require("mongodb/package.json");
945
1686
 
946
1687
  if (driverVersion.startsWith("3")) {
947
- log.debug(`Using legacy mongodb connection options as mongo client is version ${driverVersion}`);
1688
+ initLog.debug(`Using legacy mongodb connection options as mongo client is version ${driverVersion}`);
948
1689
  return {
949
1690
  useNewUrlParser: true,
950
1691
  useUnifiedTopology: true,
951
1692
  };
952
1693
  }
953
1694
 
954
- log.debug(`Using modern MongoDB client options (driver version ${driverVersion})`);
1695
+ initLog.debug(`Using modern MongoDB client options (driver version ${driverVersion})`);
955
1696
 
956
1697
  return {
957
1698
  serverApi: {