@codemation/host 0.5.1 → 0.7.0

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 (226) hide show
  1. package/CHANGELOG.md +465 -0
  2. package/LICENSE +1 -37
  3. package/dist/{ApiPaths-CLTHphYZ.js → ApiPaths-Dv1dcHu_.js} +4 -4
  4. package/dist/ApiPaths-Dv1dcHu_.js.map +1 -0
  5. package/dist/{AppConfigFactory-CvpFScwB.js → AppConfigFactory-Cx4qQvRk.js} +114 -53
  6. package/dist/AppConfigFactory-Cx4qQvRk.js.map +1 -0
  7. package/dist/{AppConfigFactory-LK76niPc.d.ts → AppConfigFactory-DnLoQ9Li.d.ts} +8527 -5549
  8. package/dist/{AppContainerFactory-BlLrm6_h.js → AppContainerFactory-DqKYCRNP.js} +7656 -2090
  9. package/dist/AppContainerFactory-DqKYCRNP.js.map +1 -0
  10. package/dist/{CodemationAppContext-CvWi5gey.d.ts → CodemationAppContext-CKVv9W9q.d.ts} +8 -4
  11. package/dist/{CodemationAuthoring.types-BuKNTDC1.d.ts → CodemationAuthoring.types-DA3G3s6d.d.ts} +25 -5
  12. package/dist/{CodemationAuthoring.types-DZl-sJaM.js → CodemationAuthoring.types-NGkBcmmT.js} +18 -6
  13. package/dist/CodemationAuthoring.types-NGkBcmmT.js.map +1 -0
  14. package/dist/{CodemationConfigNormalizer-CYdR0PR5.d.ts → CodemationConfigNormalizer-BAKjetJ6.d.ts} +3 -3
  15. package/dist/{CodemationConsumerConfigLoader-BeAUS144.js → CodemationConsumerConfigLoader-GYpBBvqE.js} +79 -10
  16. package/dist/CodemationConsumerConfigLoader-GYpBBvqE.js.map +1 -0
  17. package/dist/{CodemationConsumerConfigLoader-C3nAj9Bj.d.ts → CodemationConsumerConfigLoader-nxOqvv46.d.ts} +17 -2
  18. package/dist/{CodemationPluginListMerger-B-W5Fa_X.js → CodemationPluginListMerger-D1B1IEbt.js} +1 -1
  19. package/dist/{CodemationPluginListMerger-B-W5Fa_X.js.map → CodemationPluginListMerger-D1B1IEbt.js.map} +1 -1
  20. package/dist/{CodemationPluginListMerger-D-gwVwtw.d.ts → CodemationPluginListMerger-DKLAHT2b.d.ts} +123 -16
  21. package/dist/CodemationTsyringeTypeInfoRegistrar-Bj6FJYFz.js +97 -0
  22. package/dist/CodemationTsyringeTypeInfoRegistrar-Bj6FJYFz.js.map +1 -0
  23. package/dist/{CodemationWhitelabelConfig-CWbcyQqn.d.ts → CodemationWhitelabelConfig-Ca2mCUeC.d.ts} +2 -2
  24. package/dist/{CollectionContracts.types-DdpHft0i.d.ts → CollectionContracts.types-DDyFYT_D.d.ts} +1 -1
  25. package/dist/{CredentialContractsRegistry-D7mcPed2.d.ts → CredentialContractsRegistry-Bq2bq28t.d.ts} +2 -2
  26. package/dist/{CredentialServices-DdCEP2xt.d.ts → CredentialServices-Be2I60Th.d.ts} +65 -20
  27. package/dist/{CredentialServices-CgxwguAv.js → CredentialServices-Dk8yypeL.js} +310 -51
  28. package/dist/CredentialServices-Dk8yypeL.js.map +1 -0
  29. package/dist/InternalHonoApiRouteRegistrar-Ce1yxpnO.d.ts +17 -0
  30. package/dist/InternalPingRegistrar-DY3kSfxP.js +221 -0
  31. package/dist/InternalPingRegistrar-DY3kSfxP.js.map +1 -0
  32. package/dist/{ItemsInputNormalizer-D1WppVMU.d.ts → ItemsInputNormalizer-_RwIfRIQ.d.ts} +108 -25
  33. package/dist/{LogLevelPolicyFactory-CampWO0l.d.ts → LogLevelPolicyFactory-ewCHLDLn.d.ts} +2 -2
  34. package/dist/{PublicFrontendBootstrap-DzBgwOnG.d.ts → PublicFrontendBootstrap-Cev3qK46.d.ts} +9 -2
  35. package/dist/PublicFrontendBootstrapFactory-CY2FS-5g.d.ts +82 -0
  36. package/dist/{PublicFrontendBootstrapJsonCodec-Cl_DLRh0.d.ts → PublicFrontendBootstrapJsonCodec-CXG9Dxft.d.ts} +3 -3
  37. package/dist/{PublicFrontendBootstrapJsonCodec-DzqvA0uo.js → PublicFrontendBootstrapJsonCodec-CegIF_ne.js} +7 -2
  38. package/dist/PublicFrontendBootstrapJsonCodec-CegIF_ne.js.map +1 -0
  39. package/dist/ServerLoggerFactory-Ckk52S3w.js +223 -0
  40. package/dist/ServerLoggerFactory-Ckk52S3w.js.map +1 -0
  41. package/dist/{TelemetryContracts-BsOD_Y17.d.ts → TelemetryContracts-BtDx84Cp.d.ts} +13 -4
  42. package/dist/{WorkflowPolicyUiPresentationFactory-DNE5oAI6.d.ts → WorkflowPolicyUiPresentationFactory-6MyjCvBO.d.ts} +2 -2
  43. package/dist/{WorkflowPolicyUiPresentationFactory-DhPqQ9aB.js → WorkflowPolicyUiPresentationFactory-Bb-ae_Zh.js} +1 -1
  44. package/dist/{WorkflowPolicyUiPresentationFactory-DhPqQ9aB.js.map → WorkflowPolicyUiPresentationFactory-Bb-ae_Zh.js.map} +1 -1
  45. package/dist/{WorkflowViewContracts-0ZgsHQdp.d.ts → WorkflowViewContracts-B7aFQcIw.d.ts} +15 -1
  46. package/dist/authoring.d.ts +5 -5
  47. package/dist/authoring.js +1 -1
  48. package/dist/client.d.ts +4 -4
  49. package/dist/client.js +2 -2
  50. package/dist/consumer.d.ts +6 -6
  51. package/dist/consumer.js +2 -2
  52. package/dist/credentials.d.ts +6 -6
  53. package/dist/credentials.js +1 -1
  54. package/dist/devServerSidecar.d.ts +2 -2
  55. package/dist/devServerSidecar.js +1 -94
  56. package/dist/devServerSidecar.js.map +1 -1
  57. package/dist/dto.d.ts +6 -6
  58. package/dist/{index-BlGs9e9Q.d.ts → index-DilAYwnH.d.ts} +49 -3
  59. package/dist/index.d.ts +110 -21
  60. package/dist/index.js +15 -13
  61. package/dist/mapping.d.ts +2 -2
  62. package/dist/mapping.js +1 -1
  63. package/dist/nextServer.d.ts +43 -88
  64. package/dist/nextServer.js +9 -7
  65. package/dist/pairing.d.ts +93 -0
  66. package/dist/pairing.js +5 -0
  67. package/dist/pairing.types-snfZ_OzB.d.ts +19 -0
  68. package/dist/{persistenceServer-CpNFYa_q.js → persistenceServer-C-hH4z6l.js} +2 -2
  69. package/dist/{persistenceServer-CpNFYa_q.js.map → persistenceServer-C-hH4z6l.js.map} +1 -1
  70. package/dist/persistenceServer-CeTHtC6E.d.ts +30 -0
  71. package/dist/persistenceServer.d.ts +8 -8
  72. package/dist/persistenceServer.js +3 -3
  73. package/dist/{server-CQWdkT7t.d.ts → server-C4bS62rg.d.ts} +21 -6
  74. package/dist/{server-BK43OKxW.js → server-Y7kxwtCK.js} +7 -6
  75. package/dist/{server-BK43OKxW.js.map → server-Y7kxwtCK.js.map} +1 -1
  76. package/dist/server.d.ts +14 -14
  77. package/dist/server.js +13 -11
  78. package/package.json +29 -42
  79. package/prisma/migrations/20260507120000_execution_instance_child_run_id/migration.sql +5 -0
  80. package/prisma/migrations/20260519000000_workflow_audit_log/migration.sql +23 -0
  81. package/prisma/migrations/20260519100000_storage_growth_fixes/migration.sql +61 -0
  82. package/prisma/migrations.sqlite/20260507120000_execution_instance_child_run_id/migration.sql +5 -0
  83. package/prisma/migrations.sqlite/20260519000000_workflow_audit_log/migration.sql +21 -0
  84. package/prisma/migrations.sqlite/20260519100000_storage_growth_fixes/migration.sql +29 -0
  85. package/prisma/schema.postgresql.prisma +56 -17
  86. package/prisma/schema.sqlite.prisma +56 -17
  87. package/prisma-generated/prisma-postgresql-client/edge.js +35 -6
  88. package/prisma-generated/prisma-postgresql-client/index-browser.js +31 -2
  89. package/prisma-generated/prisma-postgresql-client/index.d.ts +8971 -5718
  90. package/prisma-generated/prisma-postgresql-client/index.js +35 -6
  91. package/prisma-generated/prisma-postgresql-client/package.json +1 -1
  92. package/prisma-generated/prisma-postgresql-client/schema.prisma +39 -0
  93. package/prisma-generated/prisma-sqlite-client/edge.js +35 -6
  94. package/prisma-generated/prisma-sqlite-client/index-browser.js +31 -2
  95. package/prisma-generated/prisma-sqlite-client/index.d.ts +8963 -5715
  96. package/prisma-generated/prisma-sqlite-client/index.js +35 -6
  97. package/prisma-generated/prisma-sqlite-client/package.json +1 -1
  98. package/prisma-generated/prisma-sqlite-client/schema.prisma +39 -0
  99. package/scripts/check-collections.mjs +18 -0
  100. package/scripts/generate-prisma-clients.mjs +20 -11
  101. package/src/application/WorkflowAuditLogPruneScheduler.ts +96 -0
  102. package/src/application/auth/AuthenticatedPrincipal.ts +4 -0
  103. package/src/application/commands/StartWorkflowRunCommandHandler.ts +4 -0
  104. package/src/application/contracts/WorkflowViewContracts.ts +11 -0
  105. package/src/application/contracts/WorkflowWebsocketMessage.ts +3 -1
  106. package/src/application/mapping/WorkflowDefinitionMapper.ts +44 -1
  107. package/src/application/runs/WorkflowRunRetentionPruneScheduler.ts +7 -1
  108. package/src/application/telemetry/OtelExecutionTelemetry.types.ts +5 -0
  109. package/src/application/telemetry/OtelExecutionTelemetryFactory.ts +4 -0
  110. package/src/application/telemetry/StoredTelemetrySpanScope.ts +6 -2
  111. package/src/application/telemetry/TelemetryRetentionTimestampFactory.ts +27 -17
  112. package/src/application/telemetry/TelemetrySpanPublisher.ts +11 -0
  113. package/src/application/websocket/TelemetrySpanWebsocketRelay.ts +31 -0
  114. package/src/applicationTokens.ts +20 -1
  115. package/src/audit/IAuditEmitter.ts +32 -0
  116. package/src/audit/PrismaWorkflowAuditLogRepository.ts +34 -0
  117. package/src/audit/WorkflowAuditLogWriter.ts +125 -0
  118. package/src/auth/managed/ManagedAuthConfig.ts +29 -0
  119. package/src/auth/managed/ManagedAuthMiddleware.ts +52 -0
  120. package/src/auth/managed/ManagedCorsMiddleware.ts +43 -0
  121. package/src/auth/managed/ManagedModeBootGuard.ts +27 -0
  122. package/src/auth/managed/index.ts +5 -0
  123. package/src/bootstrap/AppContainerFactory.ts +277 -29
  124. package/src/bootstrap/AppContainerLifecycle.ts +31 -0
  125. package/src/bootstrap/perf/BootTimer.ts +168 -0
  126. package/src/bootstrap/runtime/AppConfigFactory.ts +21 -65
  127. package/src/bootstrap/runtime/FrontendRuntime.ts +4 -1
  128. package/src/bootstrap/runtime/WorkerRuntime.ts +2 -1
  129. package/src/credentials/BrokerClient.ts +49 -0
  130. package/src/credentials/BrokerRefreshError.ts +12 -0
  131. package/src/credentials/BrokerRefreshInvalidGrantError.ts +13 -0
  132. package/src/credentials/ControlPlaneCatalogFetcher.ts +261 -0
  133. package/src/credentials/CredentialOAuth2MaterialReader.ts +136 -0
  134. package/src/credentials/InternalCredentialsListRegistrar.ts +48 -0
  135. package/src/credentials/InternalCredentialsPushRegistrar.ts +125 -0
  136. package/src/credentials/LocalOAuthFlowExecutor.ts +316 -0
  137. package/src/credentials/ManagedOAuthFlowExecutor.ts +94 -0
  138. package/src/credentials/ManagedOAuthRefreshInvalidGrantError.ts +13 -0
  139. package/src/credentials/catalogTypes.ts +4 -0
  140. package/src/credentials/refresh/CredentialDisconnectedError.ts +11 -0
  141. package/src/domain/credentials/CredentialBindingService.ts +54 -2
  142. package/src/domain/credentials/CredentialKeyRotatedError.ts +22 -0
  143. package/src/domain/credentials/CredentialSecretCipher.ts +68 -6
  144. package/src/domain/credentials/CredentialTypeRegistryImpl.ts +117 -10
  145. package/src/domain/credentials/OAuth2RedirectUriResolver.ts +79 -0
  146. package/src/domain/credentials/WorkflowCredentialNodeResolver.ts +14 -5
  147. package/src/domain/telemetry/TelemetryContracts.ts +7 -1
  148. package/src/domain/workflows/WorkflowActivationPreflight.ts +24 -1
  149. package/src/domain/workflows/WorkflowActivationPreflightRules.ts +40 -1
  150. package/src/index.ts +6 -0
  151. package/src/infrastructure/binary/LocalFilesystemBinaryStorageRegistry.ts +29 -1
  152. package/src/infrastructure/binary/S3BinaryStorage.ts +169 -0
  153. package/src/infrastructure/binary/S3BinaryStorageConfig.ts +17 -0
  154. package/src/infrastructure/config/CodemationPluginRegistrar.ts +3 -1
  155. package/src/infrastructure/persistence/CodemationDatabaseUrlParser.ts +41 -0
  156. package/src/infrastructure/persistence/InMemoryTelemetryArtifactStore.ts +8 -3
  157. package/src/infrastructure/persistence/InMemoryWorkflowRunRepository.ts +1 -0
  158. package/src/infrastructure/persistence/PrismaMigrationDeployer.ts +21 -13
  159. package/src/infrastructure/persistence/PrismaTelemetryArtifactStore.ts +43 -8
  160. package/src/infrastructure/persistence/PrismaWorkflowRunRepository.ts +33 -3
  161. package/src/infrastructure/persistence/PrismaWorkflowSnapshotRepository.ts +48 -0
  162. package/src/mcp/AgentMcpIntegrationImpl.ts +344 -0
  163. package/src/mcp/McpClientFactory.ts +29 -0
  164. package/src/mcp/McpConnectionPool.ts +184 -0
  165. package/src/mcp/McpConnectionPool.types.ts +12 -0
  166. package/src/mcp/McpServerCatalog.ts +104 -0
  167. package/src/mcp/index.ts +5 -0
  168. package/src/pairing/HmacRequestSigner.ts +32 -0
  169. package/src/pairing/IncomingHmacVerifier.ts +82 -0
  170. package/src/pairing/InternalHmacAuthMiddleware.ts +33 -0
  171. package/src/pairing/InternalPingRegistrar.ts +25 -0
  172. package/src/pairing/PairedFetch.ts +33 -0
  173. package/src/pairing/PairingConfigFactory.ts +35 -0
  174. package/src/pairing/PairingConfigToken.ts +6 -0
  175. package/src/pairing/index.ts +14 -0
  176. package/src/pairing/pairing.types.ts +18 -0
  177. package/src/pairing.ts +17 -0
  178. package/src/persistenceServer.ts +1 -0
  179. package/src/presentation/config/AppConfig.ts +7 -1
  180. package/src/presentation/config/CodemationAuthConfig.ts +1 -1
  181. package/src/presentation/config/CodemationAuthoring.types.ts +54 -5
  182. package/src/presentation/config/CodemationConfig.ts +3 -0
  183. package/src/presentation/config/CodemationConfigNormalizer.ts +39 -1
  184. package/src/presentation/config/CodemationPlugin.ts +2 -1
  185. package/src/presentation/frontend/CodemationFrontendAuthSnapshot.ts +5 -0
  186. package/src/presentation/frontend/CodemationFrontendAuthSnapshotFactory.ts +7 -1
  187. package/src/presentation/frontend/PublicFrontendBootstrap.ts +2 -0
  188. package/src/presentation/frontend/PublicFrontendBootstrapFactory.ts +5 -1
  189. package/src/presentation/frontend/PublicFrontendBootstrapJsonCodec.ts +4 -1
  190. package/src/presentation/http/ApiPaths.ts +4 -4
  191. package/src/presentation/http/ServerHttpErrorResponseFactory.ts +39 -2
  192. package/src/presentation/http/hono/CodemationHonoApiAppFactory.ts +33 -8
  193. package/src/presentation/http/hono/InternalHonoApiRouteRegistrar.ts +12 -0
  194. package/src/presentation/http/hono/registrars/ManagedMeHonoApiRouteRegistrar.ts +35 -0
  195. package/src/presentation/http/hono/registrars/OAuth2HonoApiRouteRegistrar.ts +2 -2
  196. package/src/presentation/http/routeHandlers/CredentialHttpRouteHandler.ts +28 -0
  197. package/src/presentation/http/routeHandlers/OAuth2HttpRouteHandlerFactory.ts +98 -41
  198. package/src/presentation/server/CodemationConsumerConfigLoader.ts +54 -7
  199. package/src/presentation/server/CodemationPluginDiscovery.ts +5 -0
  200. package/src/presentation/server/WorkflowDefinitionExportsResolver.ts +18 -0
  201. package/src/presentation/server/WorkflowModulePathFinder.ts +12 -1
  202. package/src/presentation/websocket/ManagedWebsocketAuthenticator.ts +50 -0
  203. package/src/presentation/websocket/WebsocketAuthenticator.types.ts +12 -0
  204. package/src/presentation/websocket/WorkflowWebsocketServer.ts +24 -3
  205. package/src/process/ExecaProcessRunner.ts +41 -0
  206. package/src/process/ProcessRunner.types.ts +39 -0
  207. package/src/server.ts +2 -0
  208. package/src/workflows/InternalWorkflowActivationRegistrar.ts +42 -0
  209. package/src/workflows/InternalWorkflowDetailRegistrar.ts +33 -0
  210. package/src/workflows/InternalWorkflowTestRunRegistrar.ts +91 -0
  211. package/src/workflows/InternalWorkflowsListRegistrar.ts +28 -0
  212. package/src/workflows/discovery/WorkflowDirectoryDiscoverer.ts +79 -0
  213. package/tsconfig.json +2 -0
  214. package/vitest.shared.ts +5 -0
  215. package/dist/ApiPaths-CLTHphYZ.js.map +0 -1
  216. package/dist/AppConfigFactory-CvpFScwB.js.map +0 -1
  217. package/dist/AppContainerFactory-BlLrm6_h.js.map +0 -1
  218. package/dist/CodemationAuthoring.types-DZl-sJaM.js.map +0 -1
  219. package/dist/CodemationConsumerConfigLoader-BeAUS144.js.map +0 -1
  220. package/dist/CredentialServices-CgxwguAv.js.map +0 -1
  221. package/dist/PublicFrontendBootstrapFactory-BMWqNM9a.d.ts +0 -45
  222. package/dist/PublicFrontendBootstrapJsonCodec-DzqvA0uo.js.map +0 -1
  223. package/dist/ServerLoggerFactory-BKSIh9Xv.js +0 -98
  224. package/dist/ServerLoggerFactory-BKSIh9Xv.js.map +0 -1
  225. package/dist/persistenceServer-CIVt3UOX.d.ts +0 -9
  226. package/src/domain/credentials/OAuth2ConnectServiceFactory.ts +0 -411
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Lightweight boot-phase timer. Designed for diagnosing slow `pnpm dev` cold starts.
3
+ *
4
+ * Usage: every meaningful boot phase wraps its async work in
5
+ * `await BootTimer.measureAsync("phase.name", async () => { ... })`
6
+ * (or the sync `measure` for non-async work). When `--trace-boot` is set on the CLI
7
+ * the timer records each phase in a tree, prints a pretty tree to stderr at the end
8
+ * of boot, and writes the same tree to `tmp/boot-trace.json` for diffing later.
9
+ *
10
+ * When disabled (default), every method is a near-zero-cost passthrough — no allocation,
11
+ * no Date.now() calls, no tree construction. Safe to leave instrumentation in production
12
+ * code paths.
13
+ */
14
+
15
+ import { promises as fs } from "node:fs";
16
+ import path from "node:path";
17
+
18
+ type PhaseNode = {
19
+ name: string;
20
+ startNs: bigint;
21
+ endNs: bigint | null;
22
+ children: PhaseNode[];
23
+ parent: PhaseNode | null;
24
+ };
25
+
26
+ export type BootTracePhase = Readonly<{
27
+ name: string;
28
+ ms: number;
29
+ pct: number;
30
+ children: ReadonlyArray<BootTracePhase>;
31
+ }>;
32
+
33
+ export class BootTimer {
34
+ private static enabled = false;
35
+ private static root: PhaseNode | null = null;
36
+ private static current: PhaseNode | null = null;
37
+
38
+ /** Enable boot tracing for the lifetime of the current process. Idempotent. */
39
+ static enable(): void {
40
+ if (this.enabled) return;
41
+ this.enabled = true;
42
+ this.root = { name: "boot", startNs: process.hrtime.bigint(), endNs: null, children: [], parent: null };
43
+ this.current = this.root;
44
+ }
45
+
46
+ static isEnabled(): boolean {
47
+ return this.enabled;
48
+ }
49
+
50
+ /** Wrap an async phase. Pass-through when disabled. */
51
+ static async measureAsync<T>(name: string, fn: () => Promise<T>): Promise<T> {
52
+ if (!this.enabled || !this.current) {
53
+ return await fn();
54
+ }
55
+ const node = this.pushPhase(name);
56
+ try {
57
+ return await fn();
58
+ } finally {
59
+ this.popPhase(node);
60
+ }
61
+ }
62
+
63
+ /** Wrap a sync phase. Pass-through when disabled. */
64
+ static measure<T>(name: string, fn: () => T): T {
65
+ if (!this.enabled || !this.current) {
66
+ return fn();
67
+ }
68
+ const node = this.pushPhase(name);
69
+ try {
70
+ return fn();
71
+ } finally {
72
+ this.popPhase(node);
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Manual start/stop for fire-and-forget phases (e.g. a spawned child process you only
78
+ * stop once a readiness probe succeeds). Returns the stop function.
79
+ */
80
+ static start(name: string): () => void {
81
+ if (!this.enabled || !this.current) {
82
+ return () => {};
83
+ }
84
+ const node = this.pushPhase(name);
85
+ return () => this.popPhase(node);
86
+ }
87
+
88
+ /** Print the tree to stderr and write JSON to the given path. Finalizes the root span. */
89
+ static async finish(outputJsonPath?: string): Promise<void> {
90
+ if (!this.enabled || !this.root) return;
91
+ if (this.root.endNs === null) {
92
+ this.root.endNs = process.hrtime.bigint();
93
+ }
94
+ const totalMs = nodeMs(this.root);
95
+ process.stderr.write(`\n=== Boot trace (total ${totalMs.toFixed(0)}ms) ===\n`);
96
+ writeTree(this.root, 0, totalMs);
97
+ process.stderr.write("=========================================\n\n");
98
+ if (outputJsonPath) {
99
+ try {
100
+ await fs.mkdir(path.dirname(outputJsonPath), { recursive: true });
101
+ await fs.writeFile(outputJsonPath, JSON.stringify(this.snapshot(), null, 2), "utf8");
102
+ } catch (error) {
103
+ process.stderr.write(`[boot-timer] failed to write JSON trace: ${String(error)}\n`);
104
+ }
105
+ }
106
+ }
107
+
108
+ /** Return a plain-object snapshot of the current tree (for tests / diffing). */
109
+ static snapshot(): BootTracePhase {
110
+ if (!this.root) {
111
+ return { name: "boot", ms: 0, pct: 100, children: [] };
112
+ }
113
+ const totalMs = nodeMs(this.root);
114
+ return toReport(this.root, totalMs);
115
+ }
116
+
117
+ /** Test helper: reset all state. Do not call in production code. */
118
+ static reset(): void {
119
+ this.enabled = false;
120
+ this.root = null;
121
+ this.current = null;
122
+ }
123
+
124
+ private static pushPhase(name: string): PhaseNode {
125
+ const node: PhaseNode = {
126
+ name,
127
+ startNs: process.hrtime.bigint(),
128
+ endNs: null,
129
+ children: [],
130
+ parent: this.current,
131
+ };
132
+ this.current!.children.push(node);
133
+ this.current = node;
134
+ return node;
135
+ }
136
+
137
+ private static popPhase(node: PhaseNode): void {
138
+ node.endNs = process.hrtime.bigint();
139
+ this.current = node.parent ?? this.root;
140
+ }
141
+ }
142
+
143
+ function nodeMs(node: PhaseNode): number {
144
+ const end = node.endNs ?? node.startNs;
145
+ return Number(end - node.startNs) / 1e6;
146
+ }
147
+
148
+ function toReport(node: PhaseNode, totalMs: number): BootTracePhase {
149
+ const ms = nodeMs(node);
150
+ return {
151
+ name: node.name,
152
+ ms,
153
+ pct: totalMs > 0 ? (ms / totalMs) * 100 : 0,
154
+ children: node.children.map((child) => toReport(child, totalMs)),
155
+ };
156
+ }
157
+
158
+ function writeTree(node: PhaseNode, depth: number, totalMs: number): void {
159
+ const ms = nodeMs(node);
160
+ const pct = totalMs > 0 ? ((ms / totalMs) * 100).toFixed(1) : "—";
161
+ const indent = " ".repeat(depth);
162
+ const msStr = ms.toFixed(0).padStart(6);
163
+ const pctStr = pct.padStart(5);
164
+ process.stderr.write(`${indent}${msStr}ms ${pctStr}% ${node.name}\n`);
165
+ for (const child of node.children) {
166
+ writeTree(child, depth + 1, totalMs);
167
+ }
168
+ }
@@ -1,17 +1,18 @@
1
1
  import { CoreTokens } from "@codemation/core";
2
- import type { AppConfig, AppPluginLoadSummary } from "../../presentation/config/AppConfig";
2
+ import type { AppConfig, AppPersistenceConfig, AppPluginLoadSummary } from "../../presentation/config/AppConfig";
3
3
  import { CodemationPluginPackageMetadata } from "../../presentation/config/CodemationPlugin";
4
4
  import type { NormalizedCodemationConfig } from "../../presentation/config/CodemationConfigNormalizer";
5
5
  import type {
6
6
  CodemationApplicationRuntimeConfig,
7
- CodemationDatabaseKind,
8
7
  CodemationEventBusKind,
9
8
  CodemationSchedulerKind,
10
9
  } from "../../presentation/config/CodemationConfig";
10
+ import { CodemationDatabaseUrlParser } from "../../infrastructure/persistence/CodemationDatabaseUrlParser";
11
11
  import path from "node:path";
12
12
 
13
13
  export class AppConfigFactory {
14
14
  private readonly pluginPackageMetadata = new CodemationPluginPackageMetadata();
15
+ private readonly databaseUrlParser = new CodemationDatabaseUrlParser();
15
16
 
16
17
  create(
17
18
  args: Readonly<{
@@ -23,7 +24,7 @@ export class AppConfigFactory {
23
24
  }>,
24
25
  ): AppConfig {
25
26
  const runtimeConfig = args.config.runtime ?? {};
26
- const persistence = this.resolvePersistence(runtimeConfig, args.env, args.consumerRoot);
27
+ const persistence = this.resolvePersistence(args.env, args.consumerRoot);
27
28
  const redisUrl = runtimeConfig.eventBus?.redisUrl ?? args.env.REDIS_URL;
28
29
  const schedulerKind = this.resolveSchedulerKind(runtimeConfig, args.env, redisUrl);
29
30
  const eventBusKind = this.resolveEventBusKind(runtimeConfig, args.env, schedulerKind, redisUrl);
@@ -46,12 +47,11 @@ export class AppConfigFactory {
46
47
  collections: [...(args.config.collections ?? [])],
47
48
  plugins,
48
49
  pluginLoadSummary: this.createConfiguredPluginLoadSummary(plugins),
50
+ mcpServers: [...(args.config.mcpServers ?? [])],
49
51
  hasConfiguredCredentialSessionServiceRegistration,
50
52
  log: args.config.log,
51
53
  engineExecutionLimits: runtimeConfig.engineExecutionLimits,
52
- databaseUrl:
53
- persistence.kind === "postgresql" ? persistence.databaseUrl : runtimeConfig.database?.url?.trim() || undefined,
54
- database: runtimeConfig.database,
54
+ databaseUrl: persistence.kind === "postgresql" ? persistence.databaseUrl : undefined,
55
55
  persistence,
56
56
  scheduler: {
57
57
  kind: schedulerKind,
@@ -86,71 +86,27 @@ export class AppConfigFactory {
86
86
  return summaries;
87
87
  }
88
88
 
89
- private resolvePersistence(
90
- runtimeConfig: CodemationApplicationRuntimeConfig,
91
- env: NodeJS.ProcessEnv,
92
- consumerRoot: string,
93
- ): AppConfig["persistence"] {
94
- const database = runtimeConfig.database;
95
- if (!database) {
96
- return { kind: "none" };
97
- }
98
- const kind = this.resolveDatabaseKind(database.kind, database.url, env);
99
- if (kind === "postgresql") {
100
- const databaseUrl = database.url?.trim() ?? "";
101
- if (!databaseUrl) {
102
- throw new Error('runtime.database.kind is "postgresql" but no database URL was set (runtime.database.url).');
103
- }
104
- if (!databaseUrl.startsWith("postgresql://") && !databaseUrl.startsWith("postgres://")) {
105
- throw new Error(
106
- `runtime.database.url must be a postgresql:// or postgres:// URL when kind is postgresql. Received: ${databaseUrl}`,
107
- );
108
- }
109
- return { kind: "postgresql", databaseUrl };
89
+ /**
90
+ * Database persistence is resolved exclusively from `CODEMATION_DATABASE_URL` (DSN format).
91
+ * Supported schemes: `sqlite://`, `pgsql://`, `postgresql://`, `postgres://`. When the env
92
+ * var is absent we default to a project-local SQLite file at
93
+ * `<consumerRoot>/.codemation/codemation.sqlite` — convenient for dev, explicit for prod.
94
+ *
95
+ * Config-based DB settings (`runtime.database` in codemation.config.ts) are intentionally
96
+ * not supported: keeping the resolver env-only lets the CLI skip the entire ~9s consumer
97
+ * config load on the migrations path and lets ops swap databases without touching code.
98
+ */
99
+ private resolvePersistence(env: NodeJS.ProcessEnv, consumerRoot: string): AppPersistenceConfig {
100
+ const url = env.CODEMATION_DATABASE_URL?.trim();
101
+ if (url) {
102
+ return this.databaseUrlParser.parse(url, consumerRoot);
110
103
  }
111
104
  return {
112
105
  kind: "sqlite",
113
- databaseFilePath: this.resolveSqliteFilePath(database.sqliteFilePath, env, consumerRoot),
106
+ databaseFilePath: path.resolve(consumerRoot, ".codemation", "codemation.sqlite"),
114
107
  };
115
108
  }
116
109
 
117
- private resolveDatabaseKind(
118
- configuredKind: CodemationDatabaseKind | undefined,
119
- databaseUrl: string | undefined,
120
- env: NodeJS.ProcessEnv,
121
- ): CodemationDatabaseKind {
122
- const kindFromEnv = env.CODEMATION_DATABASE_KIND?.trim();
123
- if (kindFromEnv === "postgresql" || kindFromEnv === "sqlite") {
124
- return kindFromEnv;
125
- }
126
- if (configuredKind) {
127
- return configuredKind;
128
- }
129
- const trimmedUrl = databaseUrl?.trim();
130
- if (trimmedUrl && (trimmedUrl.startsWith("postgresql://") || trimmedUrl.startsWith("postgres://"))) {
131
- return "postgresql";
132
- }
133
- return "sqlite";
134
- }
135
-
136
- private resolveSqliteFilePath(
137
- configuredPath: string | undefined,
138
- env: NodeJS.ProcessEnv,
139
- consumerRoot: string,
140
- ): string {
141
- const envPath = env.CODEMATION_SQLITE_FILE_PATH?.trim();
142
- if (envPath && envPath.length > 0) {
143
- return path.isAbsolute(envPath) ? envPath : path.resolve(consumerRoot, envPath);
144
- }
145
- const trimmedConfiguredPath = configuredPath?.trim();
146
- if (trimmedConfiguredPath && trimmedConfiguredPath.length > 0) {
147
- return path.isAbsolute(trimmedConfiguredPath)
148
- ? trimmedConfiguredPath
149
- : path.resolve(consumerRoot, trimmedConfiguredPath);
150
- }
151
- return path.resolve(consumerRoot, ".codemation", "codemation.sqlite");
152
- }
153
-
154
110
  private resolveSchedulerKind(
155
111
  runtimeConfig: CodemationApplicationRuntimeConfig,
156
112
  env: NodeJS.ProcessEnv,
@@ -41,8 +41,11 @@ export class FrontendRuntime {
41
41
  async start(args?: Readonly<{ skipPresentationServers?: boolean }>): Promise<void> {
42
42
  if (this.appConfig.env.CODEMATION_SKIP_STARTUP_MIGRATIONS !== "true") {
43
43
  await this.databaseMigrations.migrate();
44
- await this.collectionSchemaSyncerHolder.syncIfAvailable();
45
44
  }
45
+ // Collection schema sync is gated separately: the CLI runs Prisma migrations ahead of dev startup
46
+ // and sets CODEMATION_SKIP_STARTUP_MIGRATIONS=true, but it cannot run consumer-defined collection
47
+ // sync because consumer collections are only known to the runtime via codemation.config.ts.
48
+ await this.collectionSchemaSyncerHolder.syncIfAvailable();
46
49
  await this.runtimeWorkflowActivationPolicy.hydrateFromRepository(this.workflowActivationRepository);
47
50
  if (args?.skipPresentationServers === true) {
48
51
  return;
@@ -45,12 +45,13 @@ export class WorkerRuntime {
45
45
  async start(queues: ReadonlyArray<string>): Promise<WorkerRuntimeHandle> {
46
46
  if (this.appConfig.env.CODEMATION_SKIP_STARTUP_MIGRATIONS !== "true") {
47
47
  await this.databaseMigrations.migrate();
48
- await this.collectionSchemaSyncerHolder.syncIfAvailable();
49
48
  }
49
+ await this.collectionSchemaSyncerHolder.syncIfAvailable();
50
50
  await this.runtimeWorkflowActivationPolicy.hydrateFromRepository(this.workflowActivationRepository);
51
51
  const workflows = [...this.workflowRepository.list()];
52
52
  await this.engine.start(workflows);
53
53
  await this.runEventBusTelemetryReporter.start();
54
+ await this.lifecycle.startWorkerSubscribers();
54
55
  this.workflowRunRetentionPruneScheduler.start();
55
56
  const worker = this.scheduler.createWorker({
56
57
  queues,
@@ -0,0 +1,49 @@
1
+ import { inject, injectable } from "@codemation/core";
2
+ import { PairedFetch } from "../pairing/PairedFetch";
3
+ import type { PairingConfig } from "../pairing/pairing.types";
4
+ import { PairingConfigToken } from "../pairing/PairingConfigToken";
5
+ import { BrokerRefreshInvalidGrantError } from "./BrokerRefreshInvalidGrantError";
6
+ import { BrokerRefreshError } from "./BrokerRefreshError";
7
+
8
+ export type BrokerRefreshResult = Readonly<{
9
+ accessToken: string;
10
+ expiresAt: number;
11
+ scopesGranted: ReadonlyArray<string>;
12
+ refreshToken?: string;
13
+ }>;
14
+
15
+ export { BrokerRefreshInvalidGrantError, BrokerRefreshError };
16
+
17
+ /**
18
+ * Calls the control-plane broker's credential refresh endpoint via a HMAC-signed
19
+ * PairedFetch request. The broker performs the actual provider token exchange,
20
+ * keeping client_secret out of the installation.
21
+ *
22
+ * Endpoint: POST {controlPlaneUrl}/internal/credentials/refresh
23
+ */
24
+ @injectable()
25
+ export class BrokerClient {
26
+ constructor(
27
+ @inject(PairedFetch) private readonly pairedFetch: PairedFetch,
28
+ @inject(PairingConfigToken) private readonly pairingConfig: PairingConfig,
29
+ ) {}
30
+
31
+ async refreshCredential(
32
+ args: Readonly<{ credentialInstanceId: string; refreshToken: string }>,
33
+ ): Promise<BrokerRefreshResult> {
34
+ const url = `${this.pairingConfig.controlPlaneUrl}/internal/credentials/refresh`;
35
+ const response = await this.pairedFetch.post(url, {
36
+ credentialInstanceId: args.credentialInstanceId,
37
+ refreshToken: args.refreshToken,
38
+ });
39
+
40
+ if (response.status === 410) {
41
+ throw new BrokerRefreshInvalidGrantError(args.credentialInstanceId);
42
+ }
43
+ if (!response.ok) {
44
+ throw new BrokerRefreshError(args.credentialInstanceId, response.status);
45
+ }
46
+
47
+ return (await response.json()) as BrokerRefreshResult;
48
+ }
49
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Thrown when the broker returns an unexpected non-success HTTP status during refresh.
3
+ */
4
+ export class BrokerRefreshError extends Error {
5
+ constructor(
6
+ readonly credentialInstanceId: string,
7
+ readonly status: number,
8
+ ) {
9
+ super(`Credential ${credentialInstanceId}: broker refresh failed with status ${status}.`);
10
+ this.name = "BrokerRefreshError";
11
+ }
12
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Thrown when the control-plane broker returns HTTP 410 (invalid_grant).
3
+ * The refresh token is dead — user revoked, token rotated away, etc.
4
+ * The credential cannot be auto-recovered; the user must reconnect via the Connect flow.
5
+ */
6
+ export class BrokerRefreshInvalidGrantError extends Error {
7
+ constructor(readonly credentialInstanceId: string) {
8
+ super(
9
+ `Credential ${credentialInstanceId}: refresh token is invalid or revoked (invalid_grant). Reconnect required.`,
10
+ );
11
+ this.name = "BrokerRefreshInvalidGrantError";
12
+ }
13
+ }
@@ -0,0 +1,261 @@
1
+ import { inject, injectable } from "@codemation/core";
2
+ import type { CredentialTypeDefinition, McpServerDeclaration } from "@codemation/core";
3
+ import { ApplicationTokens } from "../applicationTokens";
4
+ import type { LoggerFactory } from "../application/logging/Logger";
5
+ import type { AppConfig } from "../presentation/config/AppConfig";
6
+ import { PairedFetch } from "../pairing/PairedFetch";
7
+ import { PairingConfigToken } from "../pairing/PairingConfigToken";
8
+ import type { PairingConfig } from "../pairing/pairing.types";
9
+ import type { OAuthAppCatalogEntry } from "./catalogTypes";
10
+
11
+ /**
12
+ * Configuration read from env at construction time.
13
+ *
14
+ * - `CODEMATION_CATALOG_POLL_INTERVAL_SECONDS`: seconds between polls (default 300; 0 = startup-only).
15
+ * - `CODEMATION_CATALOG_STALE_FAILURES`: consecutive failures before warn → error escalation (default 5).
16
+ * - `CODEMATION_CATALOG_STALE_HOURS`: hours stale before escalating to error level (default 24).
17
+ */
18
+ interface CatalogFetcherConfig {
19
+ readonly pollIntervalMs: number;
20
+ readonly staleFailuresThreshold: number;
21
+ readonly staleHoursThreshold: number;
22
+ }
23
+
24
+ type EndpointState = {
25
+ consecutiveFailures: number;
26
+ lastSuccessAt: Date | null;
27
+ };
28
+
29
+ /**
30
+ * Polls the three control-plane catalog endpoints on a configurable interval,
31
+ * caches the last-known-good responses, and exposes the fetched data for
32
+ * credential-type overrides, MCP server registrations, and OAuth app availability.
33
+ *
34
+ * Endpoints (HMAC-gated via PairedFetch):
35
+ * GET /api/catalog/oauth-apps
36
+ * GET /api/catalog/mcp-servers
37
+ * GET /api/catalog/credential-types
38
+ *
39
+ * Failure semantics: a failure on one endpoint does NOT prevent updating the
40
+ * others. Each endpoint's consecutive-failure counter and staleness escalation
41
+ * are tracked independently.
42
+ *
43
+ * When not paired with a control plane (PairingConfigToken is null),
44
+ * start() returns immediately and all three getters remain null.
45
+ */
46
+ @injectable()
47
+ export class ControlPlaneCatalogFetcher {
48
+ private readonly config: CatalogFetcherConfig;
49
+ private timerHandle: ReturnType<typeof setTimeout> | null = null;
50
+ private stopped = false;
51
+ /** Tracks in-flight refresh so stop() can safely await it. */
52
+ private inFlight: Promise<void> | null = null;
53
+
54
+ private _oauthApps: readonly OAuthAppCatalogEntry[] | null = null;
55
+ private _mcpServers: readonly McpServerDeclaration[] | null = null;
56
+ private _credentialTypeOverrides: readonly CredentialTypeDefinition[] | null = null;
57
+
58
+ private readonly oauthAppsState: EndpointState = { consecutiveFailures: 0, lastSuccessAt: null };
59
+ private readonly mcpServersState: EndpointState = { consecutiveFailures: 0, lastSuccessAt: null };
60
+ private readonly credentialTypesState: EndpointState = { consecutiveFailures: 0, lastSuccessAt: null };
61
+
62
+ /**
63
+ * Called after each successful full fetch, before the next poll is scheduled.
64
+ * Set by AppContainerFactory to re-apply overrides on each refresh cycle.
65
+ */
66
+ onRefresh: (() => void) | null = null;
67
+
68
+ constructor(
69
+ @inject(PairedFetch) private readonly pairedFetch: PairedFetch,
70
+ @inject(PairingConfigToken, { isOptional: true }) private readonly pairingConfig: PairingConfig | null,
71
+ @inject(ApplicationTokens.LoggerFactory) private readonly loggers: LoggerFactory,
72
+ @inject(ApplicationTokens.AppConfig) appConfig: AppConfig,
73
+ ) {
74
+ const env = appConfig.env;
75
+ const pollSec = Number(env["CODEMATION_CATALOG_POLL_INTERVAL_SECONDS"] ?? 300);
76
+ const staleFailures = Number(env["CODEMATION_CATALOG_STALE_FAILURES"] ?? 5);
77
+ const staleHours = Number(env["CODEMATION_CATALOG_STALE_HOURS"] ?? 24);
78
+ this.config = {
79
+ pollIntervalMs: Number.isFinite(pollSec) ? pollSec * 1_000 : 300_000,
80
+ staleFailuresThreshold: Number.isFinite(staleFailures) ? staleFailures : 5,
81
+ staleHoursThreshold: Number.isFinite(staleHours) ? staleHours : 24,
82
+ };
83
+ }
84
+
85
+ /** Latest fetched OAuth app catalog; null until first successful fetch. */
86
+ get oauthApps(): readonly OAuthAppCatalogEntry[] | null {
87
+ return this._oauthApps;
88
+ }
89
+
90
+ /** Latest fetched MCP server declarations; null until first successful fetch. */
91
+ get mcpServers(): readonly McpServerDeclaration[] | null {
92
+ return this._mcpServers;
93
+ }
94
+
95
+ /** Latest fetched credential type overrides; null until first successful fetch. */
96
+ get credentialTypeOverrides(): readonly CredentialTypeDefinition[] | null {
97
+ return this._credentialTypeOverrides;
98
+ }
99
+
100
+ /**
101
+ * Fires the first fetch (non-blocking — failure is logged, not thrown) and
102
+ * schedules the periodic poll if `pollIntervalMs > 0`.
103
+ * No-ops immediately when pairing config is absent.
104
+ */
105
+ async start(): Promise<void> {
106
+ if (!this.pairingConfig) {
107
+ return;
108
+ }
109
+ try {
110
+ await this.refresh();
111
+ } catch {
112
+ // refresh() handles its own errors; this catch is belt-and-suspenders.
113
+ }
114
+ this.scheduleNext();
115
+ }
116
+
117
+ /**
118
+ * Cancels the poll timer. Awaits any in-flight fetch before resolving.
119
+ */
120
+ async stop(): Promise<void> {
121
+ this.stopped = true;
122
+ if (this.timerHandle !== null) {
123
+ clearTimeout(this.timerHandle);
124
+ this.timerHandle = null;
125
+ }
126
+ if (this.inFlight) {
127
+ await this.inFlight;
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Manual one-shot fetch. Same code path as the periodic tick.
133
+ * Errors are caught and logged internally; this method never rejects.
134
+ */
135
+ async refresh(): Promise<void> {
136
+ const run = this.fetchAll();
137
+ this.inFlight = run;
138
+ try {
139
+ await run;
140
+ } finally {
141
+ if (this.inFlight === run) {
142
+ this.inFlight = null;
143
+ }
144
+ }
145
+ }
146
+
147
+ private scheduleNext(): void {
148
+ if (this.stopped || this.config.pollIntervalMs <= 0) {
149
+ return;
150
+ }
151
+ this.timerHandle = setTimeout(() => {
152
+ if (this.stopped) {
153
+ return;
154
+ }
155
+ void this.refresh().finally(() => this.scheduleNext());
156
+ }, this.config.pollIntervalMs);
157
+ }
158
+
159
+ private async fetchAll(): Promise<void> {
160
+ if (!this.pairingConfig) {
161
+ return;
162
+ }
163
+ const logger = this.loggers.create("ControlPlaneCatalogFetcher");
164
+ const base = this.pairingConfig.controlPlaneUrl;
165
+
166
+ const [oauthResult, mcpResult, credTypesResult] = await Promise.allSettled([
167
+ this.pairedFetch.get(`${base}/api/catalog/oauth-apps`),
168
+ this.pairedFetch.get(`${base}/api/catalog/mcp-servers`),
169
+ this.pairedFetch.get(`${base}/api/catalog/credential-types`),
170
+ ]);
171
+
172
+ await this.handleEndpointResult(
173
+ oauthResult,
174
+ this.oauthAppsState,
175
+ "oauth-apps",
176
+ (data) => {
177
+ this._oauthApps = data as OAuthAppCatalogEntry[];
178
+ },
179
+ logger,
180
+ );
181
+
182
+ await this.handleEndpointResult(
183
+ mcpResult,
184
+ this.mcpServersState,
185
+ "mcp-servers",
186
+ (data) => {
187
+ this._mcpServers = data as McpServerDeclaration[];
188
+ },
189
+ logger,
190
+ );
191
+
192
+ await this.handleEndpointResult(
193
+ credTypesResult,
194
+ this.credentialTypesState,
195
+ "credential-types",
196
+ (data) => {
197
+ this._credentialTypeOverrides = data as CredentialTypeDefinition[];
198
+ },
199
+ logger,
200
+ );
201
+
202
+ this.onRefresh?.();
203
+ }
204
+
205
+ private async handleEndpointResult(
206
+ result: PromiseSettledResult<Response>,
207
+ state: EndpointState,
208
+ endpointName: string,
209
+ onSuccess: (data: unknown[]) => void,
210
+ logger: ReturnType<LoggerFactory["create"]>,
211
+ ): Promise<void> {
212
+ if (result.status === "fulfilled") {
213
+ const res = result.value;
214
+ if (res.ok) {
215
+ try {
216
+ const data = (await res.json()) as unknown[];
217
+ onSuccess(data);
218
+ state.consecutiveFailures = 0;
219
+ state.lastSuccessAt = new Date();
220
+ logger.info(`ControlPlaneCatalogFetcher: fetched ${endpointName} (count=${data.length})`);
221
+ return;
222
+ } catch (err) {
223
+ this.logEndpointFailure(state, endpointName, err, logger);
224
+ return;
225
+ }
226
+ }
227
+ this.logEndpointFailure(state, endpointName, new Error(`HTTP ${res.status} ${res.statusText}`), logger);
228
+ } else {
229
+ this.logEndpointFailure(state, endpointName, result.reason, logger);
230
+ }
231
+ }
232
+
233
+ private logEndpointFailure(
234
+ state: EndpointState,
235
+ endpointName: string,
236
+ err: unknown,
237
+ logger: ReturnType<LoggerFactory["create"]>,
238
+ ): void {
239
+ state.consecutiveFailures++;
240
+ const staleHours = state.lastSuccessAt ? (Date.now() - state.lastSuccessAt.getTime()) / 3_600_000 : null;
241
+ const errMsg = err instanceof Error ? err.message : String(err);
242
+
243
+ const isStale =
244
+ state.consecutiveFailures >= this.config.staleFailuresThreshold ||
245
+ (staleHours !== null && staleHours >= this.config.staleHoursThreshold);
246
+
247
+ if (isStale) {
248
+ const staleLabel = staleHours !== null ? `${staleHours.toFixed(1)}h` : "never-succeeded";
249
+ logger.error(
250
+ `ControlPlaneCatalogFetcher: ${endpointName} is stale — control plane unreachable (failures=${state.consecutiveFailures}, stale=${staleLabel}): ${errMsg}`,
251
+ err instanceof Error ? err : undefined,
252
+ );
253
+ } else {
254
+ logger.warn(
255
+ `ControlPlaneCatalogFetcher: ${endpointName} fetch failed, retaining prior cached value (failures=${state.consecutiveFailures}): ${errMsg}`,
256
+ err instanceof Error ? err : undefined,
257
+ );
258
+ }
259
+ // NOTE: cached value is intentionally preserved (last-known-good).
260
+ }
261
+ }