@cat-factory/node-server 0.6.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 (156) hide show
  1. package/LICENSE +21 -0
  2. package/dist/config.d.ts +3 -0
  3. package/dist/config.d.ts.map +1 -0
  4. package/dist/config.js +297 -0
  5. package/dist/config.js.map +1 -0
  6. package/dist/container.d.ts +88 -0
  7. package/dist/container.d.ts.map +1 -0
  8. package/dist/container.js +937 -0
  9. package/dist/container.js.map +1 -0
  10. package/dist/db/client.d.ts +13 -0
  11. package/dist/db/client.d.ts.map +1 -0
  12. package/dist/db/client.js +21 -0
  13. package/dist/db/client.js.map +1 -0
  14. package/dist/db/migrate.d.ts +12 -0
  15. package/dist/db/migrate.d.ts.map +1 -0
  16. package/dist/db/migrate.js +40 -0
  17. package/dist/db/migrate.js.map +1 -0
  18. package/dist/db/schema.d.ts +7858 -0
  19. package/dist/db/schema.d.ts.map +1 -0
  20. package/dist/db/schema.js +928 -0
  21. package/dist/db/schema.js.map +1 -0
  22. package/dist/environments.d.ts +11 -0
  23. package/dist/environments.d.ts.map +1 -0
  24. package/dist/environments.js +31 -0
  25. package/dist/environments.js.map +1 -0
  26. package/dist/execution/bootstrapRunner.d.ts +27 -0
  27. package/dist/execution/bootstrapRunner.d.ts.map +1 -0
  28. package/dist/execution/bootstrapRunner.js +79 -0
  29. package/dist/execution/bootstrapRunner.js.map +1 -0
  30. package/dist/execution/config.d.ts +37 -0
  31. package/dist/execution/config.d.ts.map +1 -0
  32. package/dist/execution/config.js +86 -0
  33. package/dist/execution/config.js.map +1 -0
  34. package/dist/execution/drive.d.ts +6 -0
  35. package/dist/execution/drive.d.ts.map +1 -0
  36. package/dist/execution/drive.js +13 -0
  37. package/dist/execution/drive.js.map +1 -0
  38. package/dist/execution/pgBossRunner.d.ts +82 -0
  39. package/dist/execution/pgBossRunner.d.ts.map +1 -0
  40. package/dist/execution/pgBossRunner.js +163 -0
  41. package/dist/execution/pgBossRunner.js.map +1 -0
  42. package/dist/gateways.d.ts +4 -0
  43. package/dist/gateways.d.ts.map +1 -0
  44. package/dist/gateways.js +91 -0
  45. package/dist/gateways.js.map +1 -0
  46. package/dist/index.d.ts +13 -0
  47. package/dist/index.d.ts.map +1 -0
  48. package/dist/index.js +22 -0
  49. package/dist/index.js.map +1 -0
  50. package/dist/main.d.ts +2 -0
  51. package/dist/main.d.ts.map +1 -0
  52. package/dist/main.js +9 -0
  53. package/dist/main.js.map +1 -0
  54. package/dist/modelProvider.d.ts +6 -0
  55. package/dist/modelProvider.d.ts.map +1 -0
  56. package/dist/modelProvider.js +72 -0
  57. package/dist/modelProvider.js.map +1 -0
  58. package/dist/realtime.d.ts +62 -0
  59. package/dist/realtime.d.ts.map +1 -0
  60. package/dist/realtime.js +171 -0
  61. package/dist/realtime.js.map +1 -0
  62. package/dist/recurring.d.ts +11 -0
  63. package/dist/recurring.d.ts.map +1 -0
  64. package/dist/recurring.js +33 -0
  65. package/dist/recurring.js.map +1 -0
  66. package/dist/repositories/bootstrap.d.ts +25 -0
  67. package/dist/repositories/bootstrap.d.ts.map +1 -0
  68. package/dist/repositories/bootstrap.js +280 -0
  69. package/dist/repositories/bootstrap.js.map +1 -0
  70. package/dist/repositories/containerExecution.d.ts +33 -0
  71. package/dist/repositories/containerExecution.d.ts.map +1 -0
  72. package/dist/repositories/containerExecution.js +199 -0
  73. package/dist/repositories/containerExecution.js.map +1 -0
  74. package/dist/repositories/documents.d.ts +31 -0
  75. package/dist/repositories/documents.d.ts.map +1 -0
  76. package/dist/repositories/documents.js +176 -0
  77. package/dist/repositories/documents.js.map +1 -0
  78. package/dist/repositories/drizzle.d.ts +105 -0
  79. package/dist/repositories/drizzle.d.ts.map +1 -0
  80. package/dist/repositories/drizzle.js +1872 -0
  81. package/dist/repositories/drizzle.js.map +1 -0
  82. package/dist/repositories/environments.d.ts +23 -0
  83. package/dist/repositories/environments.d.ts.map +1 -0
  84. package/dist/repositories/environments.js +162 -0
  85. package/dist/repositories/environments.js.map +1 -0
  86. package/dist/repositories/fragments.d.ts +23 -0
  87. package/dist/repositories/fragments.d.ts.map +1 -0
  88. package/dist/repositories/fragments.js +190 -0
  89. package/dist/repositories/fragments.js.map +1 -0
  90. package/dist/repositories/github.d.ts +53 -0
  91. package/dist/repositories/github.d.ts.map +1 -0
  92. package/dist/repositories/github.js +441 -0
  93. package/dist/repositories/github.js.map +1 -0
  94. package/dist/repositories/localModelEndpoint.d.ts +12 -0
  95. package/dist/repositories/localModelEndpoint.d.ts.map +1 -0
  96. package/dist/repositories/localModelEndpoint.js +75 -0
  97. package/dist/repositories/localModelEndpoint.js.map +1 -0
  98. package/dist/repositories/notifications.d.ts +11 -0
  99. package/dist/repositories/notifications.d.ts.map +1 -0
  100. package/dist/repositories/notifications.js +88 -0
  101. package/dist/repositories/notifications.js.map +1 -0
  102. package/dist/repositories/personalSubscription.d.ts +22 -0
  103. package/dist/repositories/personalSubscription.d.ts.map +1 -0
  104. package/dist/repositories/personalSubscription.js +159 -0
  105. package/dist/repositories/personalSubscription.js.map +1 -0
  106. package/dist/repositories/providerApiKey.d.ts +18 -0
  107. package/dist/repositories/providerApiKey.d.ts.map +1 -0
  108. package/dist/repositories/providerApiKey.js +111 -0
  109. package/dist/repositories/providerApiKey.js.map +1 -0
  110. package/dist/repositories/providerSubscription.d.ts +16 -0
  111. package/dist/repositories/providerSubscription.d.ts.map +1 -0
  112. package/dist/repositories/providerSubscription.js +88 -0
  113. package/dist/repositories/providerSubscription.js.map +1 -0
  114. package/dist/repositories/slack.d.ts +23 -0
  115. package/dist/repositories/slack.d.ts.map +1 -0
  116. package/dist/repositories/slack.js +150 -0
  117. package/dist/repositories/slack.js.map +1 -0
  118. package/dist/repositories/tasks.d.ts +24 -0
  119. package/dist/repositories/tasks.d.ts.map +1 -0
  120. package/dist/repositories/tasks.js +194 -0
  121. package/dist/repositories/tasks.js.map +1 -0
  122. package/dist/retention.d.ts +38 -0
  123. package/dist/retention.d.ts.map +1 -0
  124. package/dist/retention.js +53 -0
  125. package/dist/retention.js.map +1 -0
  126. package/dist/runtime.d.ts +10 -0
  127. package/dist/runtime.d.ts.map +1 -0
  128. package/dist/runtime.js +13 -0
  129. package/dist/runtime.js.map +1 -0
  130. package/dist/server.d.ts +41 -0
  131. package/dist/server.d.ts.map +1 -0
  132. package/dist/server.js +138 -0
  133. package/dist/server.js.map +1 -0
  134. package/dist/tasks/JiraProvider.d.ts +27 -0
  135. package/dist/tasks/JiraProvider.d.ts.map +1 -0
  136. package/dist/tasks/JiraProvider.js +79 -0
  137. package/dist/tasks/JiraProvider.js.map +1 -0
  138. package/drizzle/20260622175812_flashy_maginty/migration.sql +689 -0
  139. package/drizzle/20260622175812_flashy_maginty/snapshot.json +8318 -0
  140. package/drizzle/20260623172634_loud_wallop/migration.sql +11 -0
  141. package/drizzle/20260623172634_loud_wallop/snapshot.json +8439 -0
  142. package/drizzle/20260623174706_acoustic_zemo/migration.sql +16 -0
  143. package/drizzle/20260623174706_acoustic_zemo/snapshot.json +8506 -0
  144. package/drizzle/20260623184400_silent_cardiac/migration.sql +24 -0
  145. package/drizzle/20260623184400_silent_cardiac/snapshot.json +8639 -0
  146. package/drizzle/20260623205323_quick_arclight/migration.sql +1 -0
  147. package/drizzle/20260623205323_quick_arclight/snapshot.json +8963 -0
  148. package/drizzle/20260623221910_black_zombie/migration.sql +22 -0
  149. package/drizzle/20260623221910_black_zombie/snapshot.json +9189 -0
  150. package/drizzle/20260624131343_far_lily_hollister/migration.sql +3 -0
  151. package/drizzle/20260624131343_far_lily_hollister/snapshot.json +9228 -0
  152. package/drizzle/20260624135452_tiny_norman_osborn/migration.sql +11 -0
  153. package/drizzle/20260624135452_tiny_norman_osborn/snapshot.json +9126 -0
  154. package/drizzle/20260624140138_wandering_avengers/migration.sql +1 -0
  155. package/drizzle/20260624140138_wandering_avengers/snapshot.json +9045 -0
  156. package/package.json +62 -0
@@ -0,0 +1,937 @@
1
+ import { AiAgentExecutor, LlmFragmentSelector, inlineWebSearchOptionsFromEnv, resolveAgentConfig, } from '@cat-factory/agents';
2
+ import { ConfluenceProvider, GitHubDocsProvider, GitHubIssuesProvider, JiraProvider, HttpEnvironmentProvider, HttpRunnerPoolProvider, NotionProvider, ApiKeyService, LocalModelEndpointService, PersonalSubscriptionService, ProviderSubscriptionService, RunnerPoolConnectionService, RunnerPoolTransport, EMAIL_CIPHER_INFO, SLACK_CIPHER_INFO, SlackNotificationChannel, TicketTrackerService, createGitHubIssueViaToken, } from '@cat-factory/integrations';
3
+ import { CompositeNotificationChannel, } from '@cat-factory/kernel';
4
+ import { createCore } from '@cat-factory/orchestration';
5
+ import { createLangfuseSink } from '@cat-factory/observability-langfuse';
6
+ import { CompositeAgentExecutor, ContainerAgentExecutor, ContainerRepoBootstrapper, ContainerSessionService, FanOutEventPublisher, FetchGitHubClient, GitHubAppAuth, GitHubAppRegistry, GitHubCiStatusProvider, GitHubMergeabilityProvider, GitHubPullRequestMerger, InAppNotificationChannel, WebCryptoPasswordHasher, WebCryptoPersonalSecretCipher, WebCryptoSecretCipher, WebCryptoWebhookVerifier, buildResolveRepoTarget, createWebSearchUpstreamFromEnv, ensureWorkBranchViaRest, logger, resolveWorkspaceCapabilities, } from '@cat-factory/server';
7
+ import { loadNodeConfig } from './config.js';
8
+ import { executionRuntime } from './execution/config.js';
9
+ import { PgBossBootstrapRunner } from './execution/bootstrapRunner.js';
10
+ import { PgBossWorkRunner } from './execution/pgBossRunner.js';
11
+ import { createNodeGateways } from './gateways.js';
12
+ import { baseUrlForNode, createNodeModelProviderResolver } from './modelProvider.js';
13
+ import { ConsensusAgentExecutor, registerConsensusTraits } from '@cat-factory/consensus';
14
+ import { NodeEventPublisher } from './realtime.js';
15
+ import { DrizzleGitHubInstallationRepository, DrizzleRunnerPoolConnectionRepository, DrizzleServiceFrameRepository, } from './repositories/containerExecution.js';
16
+ import { DrizzleBranchProjectionRepository, DrizzleCheckRunProjectionRepository, DrizzleCommitProjectionRepository, DrizzleIssueProjectionRepository, DrizzlePullRequestProjectionRepository, DrizzleRepoProjectionRepository, } from './repositories/github.js';
17
+ import { DrizzleProviderSubscriptionTokenRepository } from './repositories/providerSubscription.js';
18
+ import { DrizzleProviderApiKeyRepository } from './repositories/providerApiKey.js';
19
+ import { DrizzlePersonalSubscriptionRepository, DrizzleSubscriptionActivationRepository, } from './repositories/personalSubscription.js';
20
+ import { DrizzleLocalModelEndpointRepository } from './repositories/localModelEndpoint.js';
21
+ import { createDrizzleRepositories } from './repositories/drizzle.js';
22
+ import { DrizzleBootstrapJobRepository, DrizzleReferenceArchitectureRepository, } from './repositories/bootstrap.js';
23
+ import { DrizzleDocumentConnectionRepository, DrizzleDocumentRepository, } from './repositories/documents.js';
24
+ import { DrizzleEnvironmentConnectionRepository, DrizzleEnvironmentRegistryRepository, } from './repositories/environments.js';
25
+ import { DrizzleFragmentSourceRepository, DrizzlePromptFragmentRepository, } from './repositories/fragments.js';
26
+ import { DrizzleNotificationRepository } from './repositories/notifications.js';
27
+ import { DrizzleSlackConnectionRepository, DrizzleSlackMemberMappingRepository, DrizzleSlackSettingsRepository, } from './repositories/slack.js';
28
+ import { DrizzleTaskConnectionRepository, DrizzleTaskRepository } from './repositories/tasks.js';
29
+ import { CryptoIdGenerator, SystemClock } from './runtime.js';
30
+ // HKDF domain tag separating runner-pool scheduler secrets from any other use of
31
+ // the same master key (mirrors the Worker's `cat-factory:runners`).
32
+ const RUNNERS_CIPHER_INFO = 'cat-factory:runners';
33
+ // Memoised per object so a container build shares ONE model provider (hence one inline
34
+ // Langfuse sink) across the agent executor, requirements reviewer, doc planner and
35
+ // fragment selector, and ONE core trace sink — instead of each call constructing its
36
+ // own. Mirrors the Worker's `buildModelProvider` memoisation.
37
+ const langfuseSinkCache = new WeakMap();
38
+ /** Truthy env flag (`true`/`1`/`yes`). */
39
+ function isTruthy(value) {
40
+ return value === 'true' || value === '1' || value === 'yes';
41
+ }
42
+ /**
43
+ * The Node model-provider RESOLVER (instrumented when Langfuse is on), shared per
44
+ * `(env, db)`. Builds a per-scope provider from the DB-backed API-key pool plus opt-in
45
+ * Cloudflare-REST / Bedrock registries. Mirrors the Worker's buildModelProviderResolver.
46
+ */
47
+ const modelResolverCache = new WeakMap();
48
+ function buildModelProviderResolver(env, db, apiKeys, localModelEndpoints) {
49
+ const cached = modelResolverCache.get(db);
50
+ if (cached)
51
+ return cached;
52
+ const resolver = createNodeModelProviderResolver(env, apiKeys, localModelEndpoints);
53
+ modelResolverCache.set(db, resolver);
54
+ return resolver;
55
+ }
56
+ /**
57
+ * Build the opt-in Langfuse trace sink (fetch-based, so identical to the Worker's
58
+ * `selectLangfuseSink`). Returns undefined unless `LANGFUSE_ENABLED=true` and both keys
59
+ * are set; the observability service then fans every recorded LLM call out to it.
60
+ * Memoised per config so both wiring sites share one sink instance.
61
+ */
62
+ function buildLangfuseSink(config) {
63
+ if (langfuseSinkCache.has(config))
64
+ return langfuseSinkCache.get(config);
65
+ const sink = !config.langfuse.enabled || !config.langfuse.publicKey || !config.langfuse.secretKey
66
+ ? undefined
67
+ : createLangfuseSink({
68
+ publicKey: config.langfuse.publicKey,
69
+ secretKey: config.langfuse.secretKey,
70
+ baseUrl: config.langfuse.baseUrl,
71
+ logger,
72
+ });
73
+ langfuseSinkCache.set(config, sink);
74
+ return sink;
75
+ }
76
+ /**
77
+ * Wire the Slack integration when enabled: the notification *channel* (an extra
78
+ * delivery transport composed onto the notification mechanism — Node has no in-app
79
+ * channel, so this is its only one) plus the management repositories (per-account
80
+ * connect + per-workspace routing + member map) and the bot-token cipher. The
81
+ * per-account bot token is sealed with the shared ENCRYPTION_KEY under a
82
+ * slack-scoped HKDF info, mirroring the Worker. OAuth credentials are optional.
83
+ */
84
+ function selectNodeSlackDeps(config, db, repos) {
85
+ if (!config.slack.enabled || !config.slack.encryptionKey)
86
+ return {};
87
+ const secretCipher = new WebCryptoSecretCipher({
88
+ masterKeyBase64: config.slack.encryptionKey,
89
+ info: SLACK_CIPHER_INFO,
90
+ });
91
+ const slackConnectionRepository = new DrizzleSlackConnectionRepository(db);
92
+ const slackSettingsRepository = new DrizzleSlackSettingsRepository(db);
93
+ const slackMemberMappingRepository = new DrizzleSlackMemberMappingRepository(db);
94
+ return {
95
+ notificationChannel: new SlackNotificationChannel({
96
+ workspaceRepository: repos.workspaceRepository,
97
+ slackConnectionRepository,
98
+ slackSettingsRepository,
99
+ slackMemberMappingRepository,
100
+ blockRepository: repos.blockRepository,
101
+ secretCipher,
102
+ // Best-effort delivery still surfaces failures (revoked token, missing channel
103
+ // invite) through the structured logger so a broken route is diagnosable.
104
+ onError: (error, ctx) => logger.warn({ err: error instanceof Error ? error.message : String(error), ...ctx }, 'slack notification delivery failed'),
105
+ }),
106
+ slackConnectionRepository,
107
+ slackSettingsRepository,
108
+ slackMemberMappingRepository,
109
+ slackSecretCipher: secretCipher,
110
+ ...(config.slack.oauth ? { slackOAuth: config.slack.oauth } : {}),
111
+ };
112
+ }
113
+ /**
114
+ * Wire account invitations + per-account email senders for the Node facade (parity
115
+ * with the Worker's `selectEmailInvitationDeps`). Invitations are always available (an
116
+ * invite link works without email); the email-connection store + cipher are wired only
117
+ * when EMAIL is enabled, so an account can onboard a SendGrid/Resend key in the UI and
118
+ * have invites emailed. The provider key is sealed with the shared ENCRYPTION_KEY.
119
+ */
120
+ function selectNodeEmailInvitationDeps(config, repos) {
121
+ const deps = {
122
+ invitationRepository: repos.invitationRepository,
123
+ appBaseUrl: config.email.appBaseUrl || undefined,
124
+ };
125
+ if (config.email.enabled && config.email.encryptionKey) {
126
+ deps.emailConnectionRepository = repos.emailConnectionRepository;
127
+ deps.emailSecretCipher = new WebCryptoSecretCipher({
128
+ masterKeyBase64: config.email.encryptionKey,
129
+ info: EMAIL_CIPHER_INFO,
130
+ });
131
+ }
132
+ return deps;
133
+ }
134
+ /**
135
+ * Rate-limit accounting is best-effort telemetry the Worker persists to D1; the Node
136
+ * facade has no such table, so it drops the snapshots (exactly like the local facade).
137
+ */
138
+ class NoopRateLimitRepository {
139
+ record(_snapshot) {
140
+ return Promise.resolve();
141
+ }
142
+ deleteOlderThan(_epochMs) {
143
+ return Promise.resolve(0);
144
+ }
145
+ }
146
+ /**
147
+ * The workspace-spanning GitHub App registry, built once and shared by everything that
148
+ * needs an App credential: the container executor's push-token mint, the tech-debt
149
+ * issue filer, and the CI / merge / mergeability gate client. Returns undefined when
150
+ * the App isn't configured (`github.enabled` + `GITHUB_APP_PRIVATE_KEY`), so each
151
+ * caller degrades the way it always has.
152
+ */
153
+ function buildNodeAppRegistry(env, config, clock, installationRepository) {
154
+ const privateKeyPem = env.GITHUB_APP_PRIVATE_KEY?.trim();
155
+ if (!config.github.enabled || !privateKeyPem)
156
+ return undefined;
157
+ return new GitHubAppRegistry({
158
+ default: {
159
+ appId: config.github.appId,
160
+ auth: new GitHubAppAuth({
161
+ appId: config.github.appId,
162
+ privateKeyPem,
163
+ installationRepository,
164
+ clock,
165
+ apiBase: config.github.apiBase,
166
+ }),
167
+ },
168
+ installationRepository,
169
+ });
170
+ }
171
+ /**
172
+ * Resolve which runner backend a workspace's container jobs dispatch to. The Node
173
+ * facade has no built-in per-run container runtime (unlike the Worker's Cloudflare
174
+ * Containers), so it serves a workspace's self-hosted runner pool when one is
175
+ * registered and throws a clear error otherwise. Returns null (no transport at all)
176
+ * when runner pools are not enabled. Mirrors the Worker's `buildResolveTransport`,
177
+ * minus the Cloudflare-container path.
178
+ */
179
+ function buildNodeResolveTransport(config, runnerPoolConnectionRepository, workspaceRepository, clock) {
180
+ if (!config.runners.enabled || !config.runners.encryptionKey)
181
+ return null;
182
+ const runnerService = new RunnerPoolConnectionService({
183
+ runnerPoolConnectionRepository,
184
+ workspaceRepository,
185
+ secretCipher: new WebCryptoSecretCipher({
186
+ masterKeyBase64: config.runners.encryptionKey,
187
+ info: RUNNERS_CIPHER_INFO,
188
+ }),
189
+ clock,
190
+ });
191
+ const poolProvider = new HttpRunnerPoolProvider();
192
+ return async (workspaceId) => {
193
+ if (workspaceId) {
194
+ const resolved = await runnerService.resolve(workspaceId);
195
+ if (resolved) {
196
+ return new RunnerPoolTransport(poolProvider, resolved.manifest, resolved.resolveSecret);
197
+ }
198
+ }
199
+ throw new Error(`No runner backend available for workspace '${workspaceId ?? '(unknown)'}': the Node ` +
200
+ `service runs repo-operating agents on a self-hosted runner pool — register one for ` +
201
+ `this workspace (POST /workspaces/:id/runner-pools).`);
202
+ };
203
+ }
204
+ /**
205
+ * Build the container agent executor (repo-operating steps: coder, mocker,
206
+ * playwright, blueprints, ci-fixer, conflict-resolver, merger) when its
207
+ * prerequisites are configured: a token source for the push/clone token, the public
208
+ * URL backing the LLM proxy, the session secret to sign proxy tokens, and a runner
209
+ * backend. Returns null when any is missing, so the composite fails those kinds
210
+ * loudly rather than running them as useless one-shot LLM calls.
211
+ *
212
+ * The token source is pluggable: a sibling facade may pass `mintInstallationToken`
213
+ * (e.g. a static PAT for local mode), otherwise it is minted via the GitHub App
214
+ * registry (which additionally requires the App private key + `github.enabled`).
215
+ */
216
+ function buildNodeContainerExecutor(env, config, appRegistry, resolveRepoTarget, resolveTransport, resolveWorkspaceModelDefault, mintInstallationTokenOverride, subscriptions, personalSubscriptions, resolveAccountId) {
217
+ // The harness reaches models only through this service's LLM proxy; `PUBLIC_URL`
218
+ // is this service's externally reachable base (the runner pool / local container
219
+ // must be able to reach it). Pi posts to `${PUBLIC_URL}/v1/chat/completions`.
220
+ const publicUrl = env.PUBLIC_URL?.trim();
221
+ const sessionSecret = config.auth.sessionSecret;
222
+ if (!publicUrl || !sessionSecret || !resolveTransport)
223
+ return null;
224
+ // Token source: an explicit override (e.g. a static PAT in local mode) wins; else
225
+ // the GitHub App registry mints a per-installation token (when the App is configured).
226
+ const mintInstallationToken = mintInstallationTokenOverride ??
227
+ (appRegistry ? (id) => appRegistry.installationToken(id) : undefined);
228
+ if (!mintInstallationToken)
229
+ return null;
230
+ return new ContainerAgentExecutor({
231
+ resolveTransport,
232
+ agentRouting: config.agents.routing,
233
+ resolveBlockModel: config.agents.resolveBlockModel,
234
+ resolveWorkspaceModelDefault,
235
+ resolveRepoTarget,
236
+ ...(resolveAccountId ? { resolveAccountId } : {}),
237
+ mintInstallationToken,
238
+ // Ensure the shared per-task work branch up front so every agent (including the
239
+ // read-only architect) operates on the same branch — idempotent, best-effort. Writers
240
+ // create it from base; read-only agents only probe (`options.create`).
241
+ ensureWorkBranch: async (repo, branch, options) => ensureWorkBranchViaRest({
242
+ ...(config.github.apiBase ? { apiBase: config.github.apiBase } : {}),
243
+ token: await mintInstallationToken(repo.installationId),
244
+ owner: repo.owner,
245
+ name: repo.name,
246
+ baseBranch: repo.baseBranch,
247
+ branch,
248
+ create: options.create,
249
+ }),
250
+ sessionService: new ContainerSessionService({ secret: sessionSecret }),
251
+ // The subscription harnesses (Claude Code / Codex) lease a pooled token and
252
+ // attribute usage back for usage-aware rotation; absent ⇒ those harnesses are
253
+ // unavailable and a subscription-only model fails loudly at dispatch.
254
+ ...(subscriptions
255
+ ? {
256
+ leaseSubscriptionToken: (workspaceId, vendor) => subscriptions.leaseToken(workspaceId, vendor),
257
+ recordSubscriptionUsage: (workspaceId, tokenId, usage) => subscriptions.recordTokenUsage(workspaceId, tokenId, usage),
258
+ hasSubscriptionToken: (workspaceId, vendor) => subscriptions.hasToken(workspaceId, vendor),
259
+ }
260
+ : {}),
261
+ // Individual-usage harnesses (Claude) lease the run-initiator's OWN activated
262
+ // personal credential; absent ⇒ such models fail loudly at dispatch.
263
+ ...(personalSubscriptions
264
+ ? {
265
+ leasePersonalSubscriptionToken: (executionId, userId, vendor) => personalSubscriptions.leaseForRun(executionId, userId, vendor),
266
+ // Route a dual-mode individual model (GLM) to the initiator's own subscription
267
+ // when they have one; otherwise dispatch keeps it on the Cloudflare base.
268
+ hasPersonalSubscription: (userId, vendor) => personalSubscriptions.has(userId, vendor),
269
+ }
270
+ : {}),
271
+ proxyBaseUrl: `${publicUrl.replace(/\/+$/, '')}/v1`,
272
+ // Point container agents' web search at the backend search proxy (no provider key
273
+ // in the sandbox) whenever an upstream is configured for this deployment.
274
+ webSearchProxyEnabled: Boolean(createWebSearchUpstreamFromEnv(env)),
275
+ githubApiBase: config.github.apiBase,
276
+ // Forward container tool spans to Langfuse (when configured) as child spans under
277
+ // the run trace — the same sink the LLM proxy fans generations out to.
278
+ llmTraceSink: buildLangfuseSink(config),
279
+ });
280
+ }
281
+ /**
282
+ * Build the repo bootstrapper (the "bootstrap repo" container dispatch) when its
283
+ * prerequisites are configured — mirroring the Worker's `selectRepoBootstrapper` and
284
+ * the container-executor prerequisites: a resolvable runner transport, the public URL
285
+ * + session secret backing the LLM proxy, a token source, and a GitHub client.
286
+ * Returns undefined otherwise (the bootstrap module then has no runner and the service
287
+ * reports a clean dispatch failure). Bootstrap is an `architect`-kind run, so it
288
+ * follows that kind's routing. The promoted `ContainerRepoBootstrapper` dispatches
289
+ * through the same shared runner seam the container executor uses, so on Node it runs
290
+ * against the self-hosted pool and on local against the per-job Docker container.
291
+ */
292
+ function selectNodeRepoBootstrapper(deps) {
293
+ const publicUrl = deps.env.PUBLIC_URL?.trim();
294
+ const sessionSecret = deps.config.auth.sessionSecret;
295
+ if (!deps.resolveTransport ||
296
+ !publicUrl ||
297
+ !sessionSecret ||
298
+ !deps.githubClient ||
299
+ !deps.mintInstallationToken) {
300
+ return undefined;
301
+ }
302
+ return new ContainerRepoBootstrapper({
303
+ resolveTransport: deps.resolveTransport,
304
+ installationRepository: deps.installationRepository,
305
+ bootstrapJobRepository: deps.bootstrapJobRepository,
306
+ repoRepository: deps.repoRepository,
307
+ githubClient: deps.githubClient,
308
+ mintInstallationToken: deps.mintInstallationToken,
309
+ sessionService: new ContainerSessionService({ secret: sessionSecret }),
310
+ model: resolveAgentConfig(deps.config.agents.routing, 'architect').ref,
311
+ proxyBaseUrl: `${publicUrl.replace(/\/+$/, '')}/v1`,
312
+ githubApiBase: deps.config.github.apiBase,
313
+ });
314
+ }
315
+ /**
316
+ * Build the workspace subscription-token pool service for the Node/local facade
317
+ * (Postgres-backed), or undefined when the shared ENCRYPTION_KEY is absent. Tokens
318
+ * are sealed under a subscriptions-scoped HKDF info of the shared master key.
319
+ */
320
+ function buildNodeSubscriptionService(env, db, workspaceRepository, idGenerator, clock) {
321
+ const masterKeyBase64 = env.ENCRYPTION_KEY?.trim();
322
+ if (!masterKeyBase64)
323
+ return undefined;
324
+ return new ProviderSubscriptionService({
325
+ providerSubscriptionTokenRepository: new DrizzleProviderSubscriptionTokenRepository(db),
326
+ workspaceRepository,
327
+ secretCipher: new WebCryptoSecretCipher({
328
+ masterKeyBase64,
329
+ info: 'cat-factory:provider-subscriptions',
330
+ }),
331
+ idGenerator,
332
+ clock,
333
+ });
334
+ }
335
+ /**
336
+ * Build the direct-provider API-key pool (account/workspace/user) for the Node/local
337
+ * facade (Postgres-backed), or undefined when the shared ENCRYPTION_KEY is absent.
338
+ * Keys are sealed under an api-keys-scoped HKDF info of the shared master key. Mirrors
339
+ * the Worker's buildApiKeyService.
340
+ */
341
+ function buildNodeApiKeyService(env, db, workspaceRepository, idGenerator, clock) {
342
+ const masterKeyBase64 = env.ENCRYPTION_KEY?.trim();
343
+ if (!masterKeyBase64)
344
+ return undefined;
345
+ return new ApiKeyService({
346
+ providerApiKeyRepository: new DrizzleProviderApiKeyRepository(db),
347
+ workspaceRepository,
348
+ secretCipher: new WebCryptoSecretCipher({
349
+ masterKeyBase64,
350
+ info: 'cat-factory:provider-api-keys',
351
+ }),
352
+ idGenerator,
353
+ clock,
354
+ });
355
+ }
356
+ /**
357
+ * Build the per-USER individual-usage subscription service (Claude) for the Node/local
358
+ * facade (Postgres-backed), or undefined when the shared ENCRYPTION_KEY is absent.
359
+ * Double-encrypts the credential (password layer inside the system layer). Mirrors the
360
+ * Worker's buildPersonalSubscriptionService.
361
+ */
362
+ function buildNodeLocalModelEndpointService(env, db, clock) {
363
+ const masterKeyBase64 = env.ENCRYPTION_KEY?.trim();
364
+ if (!masterKeyBase64)
365
+ return undefined;
366
+ return new LocalModelEndpointService({
367
+ localModelEndpointRepository: new DrizzleLocalModelEndpointRepository(db),
368
+ secretCipher: new WebCryptoSecretCipher({
369
+ masterKeyBase64,
370
+ info: 'cat-factory:local-model-endpoints',
371
+ }),
372
+ clock,
373
+ });
374
+ }
375
+ function buildNodePersonalSubscriptionService(env, db, idGenerator, clock) {
376
+ const masterKeyBase64 = env.ENCRYPTION_KEY?.trim();
377
+ if (!masterKeyBase64)
378
+ return undefined;
379
+ return new PersonalSubscriptionService({
380
+ personalSubscriptionRepository: new DrizzlePersonalSubscriptionRepository(db),
381
+ subscriptionActivationRepository: new DrizzleSubscriptionActivationRepository(db),
382
+ secretCipher: new WebCryptoSecretCipher({
383
+ masterKeyBase64,
384
+ info: 'cat-factory:personal-subscriptions',
385
+ }),
386
+ personalCipher: new WebCryptoPersonalSecretCipher(),
387
+ idGenerator,
388
+ clock,
389
+ });
390
+ }
391
+ /**
392
+ * Build the GitHub-issue tracker filer for the tech-debt pipeline when the GitHub
393
+ * App is configured. It resolves the service's repo from the workspace's
394
+ * `github_repos` projection and mints a short-lived token from that workspace's OWN
395
+ * App installation (per-tenant) — the same infra the container executor uses — then
396
+ * files the issue via the token. Returns undefined when the App isn't configured (the
397
+ * GitHub tracker then passes through). A run whose service isn't linked to a repo
398
+ * resolves to null (a clean pass-through, not a run failure).
399
+ */
400
+ function buildNodeGitHubIssueFiler(config, registry, resolveRepoTarget) {
401
+ if (!registry)
402
+ return undefined;
403
+ return async (request) => {
404
+ let repo;
405
+ try {
406
+ repo = await resolveRepoTarget(request.workspaceId, request.frameId);
407
+ }
408
+ catch {
409
+ // The service isn't linked to a repo — nothing to file against; pass through.
410
+ return null;
411
+ }
412
+ if (!repo)
413
+ return null;
414
+ const token = await registry.installationToken(repo.installationId);
415
+ const issue = await createGitHubIssueViaToken({
416
+ fetchImpl: fetch,
417
+ token,
418
+ owner: repo.owner,
419
+ repo: repo.name,
420
+ title: request.title,
421
+ body: request.body,
422
+ apiBase: config.github.apiBase,
423
+ });
424
+ return { externalId: `${repo.owner}/${repo.name}#${issue.number}`, url: issue.url };
425
+ };
426
+ }
427
+ /**
428
+ * The Node composition root: assemble the framework-agnostic domain `Core` with
429
+ * Drizzle/Postgres repositories + Node implementations of the runtime ports, then
430
+ * attach the shared-controller extras (`config`, the kind-spanning agent-run repo,
431
+ * the runtime gateways). The same persistence is used in dev, test and prod — tests
432
+ * run against a real Postgres, exactly as the Worker runs against a real D1.
433
+ *
434
+ * Repo-operating agent steps (coder, blueprints, merger, …) run in a container
435
+ * dispatched to a workspace's self-hosted runner pool — the shared
436
+ * `ContainerAgentExecutor`, exactly as on the Worker. When the prerequisites (GitHub
437
+ * App, `PUBLIC_URL`, `AUTH_SESSION_SECRET`, `ENCRYPTION_KEY`) are absent the
438
+ * composite still serves inline kinds but fails container kinds loudly.
439
+ */
440
+ export function buildNodeContainer(options) {
441
+ const env = options.env ?? process.env;
442
+ const config = options.config ?? loadNodeConfig(env);
443
+ const clock = new SystemClock();
444
+ const idGenerator = new CryptoIdGenerator();
445
+ const repos = options.repos ?? createDrizzleRepositories(options.db, clock);
446
+ // Honour the workspace's per-agent-kind defaults at run time (block-pinned >
447
+ // workspace per-kind default > env routing), uniformly for inline and container kinds.
448
+ const resolveWorkspaceModelDefault = (workspaceId, agentKind) => repos.modelDefaultsRepository.getForKind(workspaceId, agentKind).then((v) => v ?? undefined);
449
+ // The direct-provider API-key pool + the per-scope model-provider resolver, shared by
450
+ // the inline executor, the inline modules (planner/reviewer/fragment selector), the
451
+ // API-key controller, and the LLM proxy key lease.
452
+ const apiKeys = buildNodeApiKeyService(env, options.db, repos.workspaceRepository, idGenerator, clock);
453
+ // The per-user locally-run model endpoints store (Ollama / LM Studio / …), shared by
454
+ // the local-runner controller, the per-user model catalog, the inline model provider,
455
+ // and the LLM proxy.
456
+ const localModelEndpoints = buildNodeLocalModelEndpointService(env, options.db, clock);
457
+ const modelProviderResolver = buildModelProviderResolver(env, options.db, apiKeys, localModelEndpoints);
458
+ // Cloudflare Workers AI is opt-in on Node: enabled when the REST creds are present.
459
+ const cloudflareModelsEnabled = options.cloudflareModelsEnabled ?? !!(env.CLOUDFLARE_ACCOUNT_ID && env.CLOUDFLARE_API_TOKEN);
460
+ const inline = new AiAgentExecutor({
461
+ modelProviderResolver,
462
+ agentRouting: config.agents.routing,
463
+ resolveBlockModel: config.agents.resolveBlockModel,
464
+ resolveWorkspaceModelDefault,
465
+ // Opt-in provider web search for the inline design/research kinds (no-op unless
466
+ // INLINE_WEB_SEARCH_ENABLED and an Anthropic/OpenAI model).
467
+ webSearch: inlineWebSearchOptionsFromEnv(env),
468
+ });
469
+ // Persistence the container-execution path needs (built from the same db). The
470
+ // runner-pool repo also backs the `runners` Core module so a pool is registrable
471
+ // via the API; the installation repo backs both token minting and repo resolution.
472
+ const runnerPoolConnectionRepository = new DrizzleRunnerPoolConnectionRepository(options.db);
473
+ const githubInstallationRepository = options.githubInstallationRepository ?? new DrizzleGitHubInstallationRepository(options.db);
474
+ // The repositories projection (+ sync cursors), shared by `buildResolveRepoTarget`
475
+ // (block→repo resolution) and the GitHub sync/webhook module below.
476
+ const repoProjectionRepository = new DrizzleRepoProjectionRepository(options.db);
477
+ // The GitHub App registry, built once when the App is configured and shared by the
478
+ // container executor's push-token mint, the tech-debt issue filer, and the CI / merge
479
+ // gate client below. Undefined when the App isn't configured.
480
+ const appRegistry = buildNodeAppRegistry(env, config, clock, githubInstallationRepository);
481
+ // The repo a running block targets (installation + owner/name), resolved from the
482
+ // github_repos projection. Built once and shared by the container executor, the
483
+ // GitHub-issue tracker filer, and the CI / merge providers.
484
+ const resolveRepoTarget = buildResolveRepoTarget({
485
+ installationRepository: githubInstallationRepository,
486
+ repoProjectionRepository,
487
+ blockRepository: repos.blockRepository,
488
+ serviceRepository: new DrizzleServiceFrameRepository(options.db),
489
+ });
490
+ // A sibling facade (local mode) may inject its own transport — even `null` — which
491
+ // replaces the default self-hosted-pool resolution; undefined keeps Node's default.
492
+ const resolveTransport = options.resolveTransport !== undefined
493
+ ? options.resolveTransport
494
+ : buildNodeResolveTransport(config, runnerPoolConnectionRepository, repos.workspaceRepository, clock);
495
+ // The subscription-token pool (Claude Code / Codex credentials), shared by the
496
+ // container executor (lease + usage feedback) and the vendor-credential controller.
497
+ const subscriptions = buildNodeSubscriptionService(env, options.db, repos.workspaceRepository, idGenerator, clock);
498
+ // The per-user individual-usage subscription store (Claude), shared by the
499
+ // container executor's personal lease and the personal-subscription controller.
500
+ const personalSubscriptions = buildNodePersonalSubscriptionService(env, options.db, idGenerator, clock);
501
+ const container = buildNodeContainerExecutor(env, config, appRegistry, resolveRepoTarget, resolveTransport, resolveWorkspaceModelDefault, options.mintInstallationToken, subscriptions, personalSubscriptions, (workspaceId) => repos.workspaceRepository.accountOf(workspaceId));
502
+ // Always a composite: inline kinds run as one-shot LLM calls; repo-operating kinds
503
+ // route to the container (and fail loudly when its prerequisites are unconfigured).
504
+ // Optionally wrapped with the consensus mechanism below (after the event publisher
505
+ // is built, so live consensus pushes ride the same hub).
506
+ const standardAgentExecutor = new CompositeAgentExecutor(inline, container);
507
+ // GitHub-issue tracker: file the tech-debt pipeline's issue through the workspace's
508
+ // own GitHub App installation (per-tenant), resolving the service's repo from the
509
+ // github_repos projection — the same per-tenant infra the container executor uses.
510
+ const fileGitHubIssue = buildNodeGitHubIssueFiler(config, appRegistry, resolveRepoTarget);
511
+ // The GitHub client backing the CI gate + merge / mergeability providers: an injected
512
+ // one wins (the local facade supplies a PAT-backed client), else — when the GitHub App
513
+ // is configured — one minted from the shared App registry, so a stock Node deployment
514
+ // with an App ALSO gates on real GitHub Actions CI and merges the PR for real (parity
515
+ // with the Worker). Undefined → these stay unwired and the gates pass through.
516
+ const githubClient = options.githubClient ??
517
+ (appRegistry
518
+ ? new FetchGitHubClient({
519
+ registry: appRegistry,
520
+ rateLimitRepository: new NoopRateLimitRepository(),
521
+ idGenerator,
522
+ clock,
523
+ apiBase: config.github.apiBase,
524
+ })
525
+ : undefined);
526
+ // Task-source integration (Jira + GitHub issues). Tenants connect their own Jira
527
+ // site through the UI (credentials stored per-workspace, encrypted at rest); the
528
+ // tracker resolves each workspace's own credentials from this same store. GitHub
529
+ // issues reuse the workspace's installed App, so they wire only when `githubClient`
530
+ // is available — kept here, after the client is built, for parity with the Worker.
531
+ const tasks = selectNodeTasksDeps(config, options.db, githubClient, githubInstallationRepository);
532
+ const githubGateDeps = githubClient
533
+ ? {
534
+ ciStatusProvider: new GitHubCiStatusProvider({
535
+ githubClient,
536
+ resolveRepoTarget,
537
+ blockRepository: repos.blockRepository,
538
+ }),
539
+ mergeabilityProvider: new GitHubMergeabilityProvider({
540
+ githubClient,
541
+ resolveRepoTarget,
542
+ blockRepository: repos.blockRepository,
543
+ }),
544
+ pullRequestMerger: new GitHubPullRequestMerger({
545
+ githubClient,
546
+ resolveRepoTarget,
547
+ blockRepository: repos.blockRepository,
548
+ }),
549
+ }
550
+ : {};
551
+ // GitHub installation + projections + sync/webhook module: wired when the App is
552
+ // configured (a real githubClient), mirroring the Worker's selectGitHubDeps. This
553
+ // turns the GitHub read endpoints + the inline webhook/backfill sync on for Node —
554
+ // the sync engine (GitHubSyncService) is runtime-neutral, so populating the
555
+ // projection repos here makes the inline ingest actually persist (parity with the
556
+ // Worker, which fans the same sync through a queue/Workflow). `canCreateRepos` /
557
+ // `workflowsGranted` come from the App registry when present (advisory).
558
+ const githubModuleDeps = config.github.enabled && githubClient
559
+ ? {
560
+ githubClient,
561
+ githubInstallationRepository,
562
+ repoProjectionRepository,
563
+ branchProjectionRepository: new DrizzleBranchProjectionRepository(options.db),
564
+ pullRequestProjectionRepository: new DrizzlePullRequestProjectionRepository(options.db),
565
+ issueProjectionRepository: new DrizzleIssueProjectionRepository(options.db),
566
+ commitProjectionRepository: new DrizzleCommitProjectionRepository(options.db),
567
+ checkRunProjectionRepository: new DrizzleCheckRunProjectionRepository(options.db),
568
+ webhookVerifier: new WebCryptoWebhookVerifier(config.github.webhookSecret),
569
+ // Bound the initial backfill to the commit retention horizon (0 = full).
570
+ commitBackfillHorizonMs: config.retention.commitMs || undefined,
571
+ ...(appRegistry
572
+ ? {
573
+ canCreateRepos: (installation) => appRegistry.canCreateRepos(installation),
574
+ workflowsGranted: async (installation) => {
575
+ const perms = await appRegistry.installationPermissions(installation.installationId);
576
+ return perms.workflows === 'write';
577
+ },
578
+ }
579
+ : {}),
580
+ }
581
+ : {};
582
+ // Repo-bootstrap: the reference-architecture library + the bootstrap runs (stored as
583
+ // kind='bootstrap' rows of agent_runs). The repos are wired unconditionally (the
584
+ // module + ref-arch CRUD then work like the Worker); the container-dispatching
585
+ // `repoBootstrapper` wires only when its prerequisites are met (transport + proxy +
586
+ // token + GitHub client) — the same token source the container executor uses.
587
+ const bootstrapJobRepository = new DrizzleBootstrapJobRepository(options.db);
588
+ const bootstrapMintInstallationToken = options.mintInstallationToken ??
589
+ (appRegistry ? (id) => appRegistry.installationToken(id) : undefined);
590
+ const repoBootstrapper = selectNodeRepoBootstrapper({
591
+ env,
592
+ config,
593
+ resolveTransport,
594
+ installationRepository: githubInstallationRepository,
595
+ bootstrapJobRepository,
596
+ repoRepository: repoProjectionRepository,
597
+ githubClient,
598
+ mintInstallationToken: bootstrapMintInstallationToken,
599
+ });
600
+ // Real-time push + notification delivery. When a realtime hub is wired (start()), the
601
+ // engine pushes execution/board/notification events to subscribed browsers via the
602
+ // NodeEventPublisher, decorated with FanOutEventPublisher so a shared service's live
603
+ // events reach EVERY board that mounts it (parity with the Worker's selectEventPublisher).
604
+ // The in-app push is also a notification channel, composed alongside Slack (when
605
+ // enabled) so a raised notification both lands in the inbox live AND fans to Slack.
606
+ const slackDeps = selectNodeSlackDeps(config, options.db, repos);
607
+ const executionEventPublisher = options.realtimeHub
608
+ ? new FanOutEventPublisher(new NodeEventPublisher(options.realtimeHub), {
609
+ workspaceMountRepository: repos.workspaceMountRepository,
610
+ })
611
+ : undefined;
612
+ // Optionally wrap the executor with the consensus mechanism (CONSENSUS_ENABLED). Off ⇒
613
+ // the standard composite, unchanged. Registers the capability traits + routes
614
+ // consensus-enabled steps through a multi-model process, persisting + pushing the
615
+ // transcript (same hub as run/board events).
616
+ const agentExecutor = isTruthy(env.CONSENSUS_ENABLED)
617
+ ? (registerConsensusTraits(),
618
+ new ConsensusAgentExecutor({
619
+ standard: standardAgentExecutor,
620
+ modelProviderResolver,
621
+ agentRouting: config.agents.routing,
622
+ resolveBlockModel: config.agents.resolveBlockModel,
623
+ resolveWorkspaceModelDefault,
624
+ sessionRepository: repos.consensusSessionRepository,
625
+ ...(executionEventPublisher ? { eventPublisher: executionEventPublisher } : {}),
626
+ }))
627
+ : standardAgentExecutor;
628
+ const notificationChannels = [];
629
+ if (executionEventPublisher)
630
+ notificationChannels.push(new InAppNotificationChannel(executionEventPublisher));
631
+ if (slackDeps.notificationChannel)
632
+ notificationChannels.push(slackDeps.notificationChannel);
633
+ const notificationChannel = notificationChannels.length === 0
634
+ ? undefined
635
+ : notificationChannels.length === 1
636
+ ? notificationChannels[0]
637
+ : new CompositeNotificationChannel(notificationChannels);
638
+ const dependencies = {
639
+ workspaceRepository: repos.workspaceRepository,
640
+ accountRepository: repos.accountRepository,
641
+ membershipRepository: repos.membershipRepository,
642
+ userRepository: repos.userRepository,
643
+ passwordHasher: new WebCryptoPasswordHasher(),
644
+ blockRepository: repos.blockRepository,
645
+ pipelineRepository: repos.pipelineRepository,
646
+ executionRepository: repos.executionRepository,
647
+ // Clear a finished run's personal-credential activation promptly (TTL sweep is the backstop).
648
+ subscriptionActivationRepository: new DrizzleSubscriptionActivationRepository(options.db),
649
+ // In-org shared services. When a realtime hub is wired (start()), the engine's
650
+ // event publisher (composed above) is a `FanOutEventPublisher` over these two repos,
651
+ // so a shared service's live events reach every board that mounts it — parity with
652
+ // the Cloudflare facade. Without a hub (createServer/tests) the engine uses its
653
+ // NoopEventPublisher and nothing is pushed.
654
+ serviceRepository: repos.serviceRepository,
655
+ workspaceMountRepository: repos.workspaceMountRepository,
656
+ tokenUsageRepository: repos.tokenUsageRepository,
657
+ llmCallMetricRepository: repos.llmCallMetricRepository,
658
+ recordLlmPrompts: config.observability.recordPrompts,
659
+ // Opt-in Langfuse trace sink (fans every recorded LLM call out as a generation).
660
+ // Built only when configured; otherwise undefined and there is no external emission.
661
+ llmTraceSink: buildLangfuseSink(config),
662
+ modelDefaultsRepository: repos.modelDefaultsRepository,
663
+ serviceFragmentDefaultsRepository: repos.serviceFragmentDefaultsRepository,
664
+ // Requirements-review feature (stateless reviewer + the requirements-rework
665
+ // step). Wired identically to the Cloudflare facade's `selectRequirementsDeps`
666
+ // so both runtimes serve the review/rework API AND substitute a block's reworked
667
+ // requirements into the agent context (the cross-runtime conformance suite asserts
668
+ // the substitution against both stores). The reviewer's model resolves exactly
669
+ // like a pipeline step: block-pin > workspace per-kind default > routing default
670
+ // (which falls back to Cloudflare Workers AI unless a direct key is set).
671
+ requirementReviewRepository: repos.requirementReviewRepository,
672
+ clarityReviewRepository: repos.clarityReviewRepository,
673
+ // Merge threshold presets: the per-workspace auto-merge ceiling library a task's
674
+ // merge gate resolves (block-pinned preset > workspace default). Wired
675
+ // unconditionally, exactly like the Worker's `selectMergeLifecycleDeps`, so the
676
+ // preset CRUD API + the merger step's threshold resolution work identically.
677
+ mergePresetRepository: repos.mergePresetRepository,
678
+ // Board-scan: the persisted repository blueprints (the service → modules map the
679
+ // blueprint pipeline step reconciles, and the manual scan command writes). Wiring
680
+ // the repo makes the board-scan module + blueprint read endpoints available, like
681
+ // the Worker (which wires it unconditionally). Actually *running* a scan also needs
682
+ // a `repoScanner` — a per-run container that clones + decomposes the repo. The Node
683
+ // facade has no such synchronous scanner, so `service.canScan` stays false and the
684
+ // scan endpoint returns its graceful 503 (the blueprint decomposition itself runs as
685
+ // a normal `blueprints` pipeline step through the runner transport, like the Worker).
686
+ repoBlueprintRepository: repos.repoBlueprintRepository,
687
+ modelProviderResolver,
688
+ requirementReviewModel: config.agents.routing.default.ref,
689
+ requirementReviewResolveModel: config.agents.resolveBlockModel,
690
+ // Notifications subsystem (parity with the Worker, which wires it unconditionally):
691
+ // the inbox + the human-action surfaces. Node has no real-time push, so the rows
692
+ // persist (inbox + snapshot) and any channel composed below — e.g. Slack — delivers.
693
+ notificationRepository: new DrizzleNotificationRepository(options.db),
694
+ ...tasks.deps,
695
+ // Recurring pipelines + the workspace tracker selection. The tracker provider
696
+ // files the tech-debt pipeline's issue by resolving the *workspace's* connected
697
+ // integration: GitHub issues through the workspace's GitHub App installation,
698
+ // Jira tickets from the per-workspace encrypted connection store — both per-tenant.
699
+ pipelineScheduleRepository: repos.pipelineScheduleRepository,
700
+ trackerSettingsRepository: repos.trackerSettingsRepository,
701
+ ticketTrackerProvider: new TicketTrackerService({
702
+ trackerSettingsRepository: repos.trackerSettingsRepository,
703
+ fetchImpl: fetch,
704
+ ...(fileGitHubIssue ? { fileGitHubIssue } : {}),
705
+ ...(tasks.taskConnectionRepository
706
+ ? {
707
+ resolveJiraConnection: async (workspaceId) => {
708
+ const connection = await tasks.taskConnectionRepository.getByWorkspace(workspaceId, 'jira');
709
+ const { baseUrl, accountEmail, apiToken } = connection?.credentials ?? {};
710
+ if (!baseUrl || !accountEmail || !apiToken)
711
+ return null;
712
+ return { baseUrl, accountEmail, apiToken };
713
+ },
714
+ }
715
+ : {}),
716
+ }),
717
+ idGenerator,
718
+ clock,
719
+ agentExecutor,
720
+ spendPricing: config.spend,
721
+ // The runner-pool integration assembles when enabled, so a workspace can
722
+ // register the self-hosted pool its container agents dispatch to.
723
+ ...(config.runners.enabled && config.runners.encryptionKey
724
+ ? {
725
+ runnerPoolConnectionRepository,
726
+ runnerSecretCipher: new WebCryptoSecretCipher({
727
+ masterKeyBase64: config.runners.encryptionKey,
728
+ info: RUNNERS_CIPHER_INFO,
729
+ }),
730
+ }
731
+ : {}),
732
+ ...(options.boss
733
+ ? {
734
+ workRunner: new PgBossWorkRunner(options.boss, executionRuntime(config, env).queue),
735
+ // The durable bootstrap driver (analogue of the Worker's BootstrapWorkflow):
736
+ // BootstrapService.startRun enqueues a drive job that polls the run to terminal.
737
+ bootstrapRunner: new PgBossBootstrapRunner(options.boss, executionRuntime(config, env).queue),
738
+ }
739
+ : {}),
740
+ ...githubGateDeps,
741
+ // GitHub installation + repo/branch/PR/issue/commit/check-run projections + the
742
+ // sync/webhook module (inline ingest persists to these repos on Node).
743
+ ...githubModuleDeps,
744
+ // Repo-bootstrap: the reference-architecture library + bootstrap-run store make the
745
+ // module + API available; `repoBootstrapper` (when wired) dispatches the bootstrap
746
+ // container through the shared runner seam, and `bootstrapRunner` (pg-boss, below)
747
+ // durably drives its poll loop — parity with the Worker's BootstrapWorkflow.
748
+ referenceArchitectureRepository: new DrizzleReferenceArchitectureRepository(options.db),
749
+ bootstrapJobRepository,
750
+ ...(repoBootstrapper ? { repoBootstrapper } : {}),
751
+ // Document sources (Confluence / Notion / GitHub docs): wired from the shared
752
+ // integration providers exactly like the Worker, so a workspace can connect a
753
+ // source and import requirement/PRD/RFC pages as agent context.
754
+ ...selectNodeDocumentsDeps(config, options.db, githubClient, githubInstallationRepository),
755
+ // Ephemeral environments (opt-in): a workspace registers its own environment
756
+ // management API; the tester provisions/destroys per-run environments from it.
757
+ ...selectNodeEnvironmentsDeps(config, options.db),
758
+ // Prompt-fragment library (ADR 0006; opt-in): the managed tenant-scoped catalog
759
+ // of best-practice fragments feeding every agent run, wired exactly like the
760
+ // Worker's selectFragmentLibraryDeps (repos + installation resolver + selector).
761
+ ...selectNodeFragmentLibraryDeps(config, env, options.db, githubClient, githubInstallationRepository, modelProviderResolver),
762
+ // Slack: an extra notification transport (the channel) + its management module.
763
+ // Default-off; when enabled its channel is composed into `notificationChannel` below
764
+ // alongside the in-app push, identically to the Worker.
765
+ ...slackDeps,
766
+ // Account invitations + per-account email senders (UI-onboarded, DB-stored).
767
+ ...selectNodeEmailInvitationDeps(config, repos),
768
+ // The pipeline-start guard resolves what's configured for a workspace + initiator.
769
+ resolveProviderCapabilities: (workspaceId, initiatedBy) => resolveWorkspaceCapabilities({
770
+ apiKeys,
771
+ subscriptions,
772
+ personalSubscriptions,
773
+ cloudflareModelsEnabled,
774
+ baseUrlFor: (provider) => baseUrlForNode(provider, env),
775
+ localModelEndpoints,
776
+ }, workspaceId, initiatedBy),
777
+ // Real-time push (when a hub is wired) + the composed notification channel (in-app
778
+ // push + Slack). These come AFTER the spreads so the composite replaces the bare
779
+ // Slack channel `slackDeps` set; both are absent (no override) when nothing is wired.
780
+ ...(executionEventPublisher ? { executionEventPublisher } : {}),
781
+ ...(notificationChannel ? { notificationChannel } : {}),
782
+ ...options.overrides,
783
+ };
784
+ return {
785
+ ...createCore(dependencies),
786
+ config,
787
+ agentRunRepository: repos.agentRunRepository,
788
+ // The consensus transcript store, for the read endpoint (window load / reload).
789
+ consensusSessionRepository: repos.consensusSessionRepository,
790
+ gateways: createNodeGateways(env),
791
+ // The vendor-credential (subscription token pool) service the shared controller
792
+ // reads; present when the shared ENCRYPTION_KEY is configured.
793
+ subscriptions,
794
+ // The per-user individual-usage subscription store (Claude); present when the
795
+ // shared ENCRYPTION_KEY is configured.
796
+ personalSubscriptions,
797
+ // The direct-provider API-key pool (account/workspace/user); present when the
798
+ // shared ENCRYPTION_KEY is configured.
799
+ apiKeys,
800
+ // Whether the opt-in Cloudflare Workers AI lib is enabled (REST creds present).
801
+ cloudflareModelsEnabled,
802
+ // The direct-provider base-URL resolver the catalog uses to gate selectability on a
803
+ // resolvable endpoint (e.g. LiteLLM stays unselectable until LITELLM_BASE_URL is set).
804
+ baseUrlFor: (provider) => baseUrlForNode(provider, env),
805
+ // The per-user locally-run model endpoints store; present when ENCRYPTION_KEY is set.
806
+ localModelEndpoints,
807
+ };
808
+ }
809
+ /**
810
+ * Wire the task-source integration for the Node facade when it is enabled (the
811
+ * `tasks` module then assembles so tenants can connect Jira through the existing
812
+ * UI). Returns the `CoreDependencies` fragment plus the connection repository so the
813
+ * tracker can resolve each workspace's Jira credentials from the same store.
814
+ * No registered providers → `{ deps: {} }` and both the tasks module and the Jira
815
+ * tracker stay off (the encryption key is guaranteed present by `loadTasksConfig`).
816
+ */
817
+ function selectNodeTasksDeps(config, db, githubClient, installations) {
818
+ if (!config.tasks.enabled || !config.tasks.encryptionKey)
819
+ return { deps: {} };
820
+ const providers = [];
821
+ if (config.tasks.sources.includes('jira'))
822
+ providers.push(new JiraProvider());
823
+ // GitHub issues reuse the workspace's installed GitHub App, so this provider is
824
+ // wired only when a GitHub client is available (the App is configured) — it has
825
+ // no credentials of its own and resolves the installation per issue. Mirrors the
826
+ // Cloudflare facade's `config.github.enabled` gate (see CLAUDE.md parity rule).
827
+ if (config.tasks.sources.includes('github') && githubClient) {
828
+ providers.push(new GitHubIssuesProvider({ githubClient, installations }));
829
+ }
830
+ if (providers.length === 0)
831
+ return { deps: {} };
832
+ const taskConnectionRepository = new DrizzleTaskConnectionRepository(db,
833
+ // Source credentials are encrypted at rest under a tasks-scoped HKDF info (the
834
+ // same domain the Cloudflare facade uses), keyed by the shared ENCRYPTION_KEY.
835
+ new WebCryptoSecretCipher({
836
+ masterKeyBase64: config.tasks.encryptionKey,
837
+ info: 'cat-factory:tasks',
838
+ }));
839
+ return {
840
+ deps: {
841
+ taskSourceProviders: providers,
842
+ taskConnectionRepository,
843
+ taskRepository: new DrizzleTaskRepository(db),
844
+ },
845
+ taskConnectionRepository,
846
+ };
847
+ }
848
+ /**
849
+ * Wire the document-source integration for the Node facade, mirroring the Worker's
850
+ * `selectDocumentsDeps`: the shared `@cat-factory/integrations` provider shells
851
+ * (Confluence/Notion always; GitHub-docs only when a GitHub client is available, since
852
+ * it reuses the workspace's App installation), the Drizzle connection/document repos,
853
+ * and — in `llm` planner mode — the default model ref the doc→board planner runs with
854
+ * (the container's `modelProvider` is shared). Source credentials are encrypted at rest
855
+ * under a documents-scoped HKDF info, keyed by the shared ENCRYPTION_KEY.
856
+ */
857
+ function selectNodeDocumentsDeps(config, db, githubClient, installations) {
858
+ if (!config.documents.enabled || !config.documents.encryptionKey)
859
+ return {};
860
+ const providers = [];
861
+ if (config.documents.sources.includes('confluence'))
862
+ providers.push(new ConfluenceProvider());
863
+ if (config.documents.sources.includes('notion'))
864
+ providers.push(new NotionProvider());
865
+ if (config.documents.sources.includes('github') && githubClient) {
866
+ providers.push(new GitHubDocsProvider({ githubClient, installations }));
867
+ }
868
+ if (providers.length === 0)
869
+ return {};
870
+ return {
871
+ documentSourceProviders: providers,
872
+ documentConnectionRepository: new DrizzleDocumentConnectionRepository(db, new WebCryptoSecretCipher({
873
+ masterKeyBase64: config.documents.encryptionKey,
874
+ info: 'cat-factory:documents',
875
+ })),
876
+ documentRepository: new DrizzleDocumentRepository(db),
877
+ ...(config.documents.planner === 'llm'
878
+ ? { documentPlannerModel: config.agents.routing.default.ref }
879
+ : {}),
880
+ };
881
+ }
882
+ /**
883
+ * Wire the ephemeral-environment integration for the Node facade when enabled,
884
+ * mirroring the Worker's `selectEnvironmentsDeps`: the shared `HttpEnvironmentProvider`
885
+ * (a manifest-driven `fetch` shell), the Drizzle connection + registry repos, and the
886
+ * environment-scoped `SecretCipher`. Per-tenant management-API secrets are encrypted at
887
+ * rest with the shared ENCRYPTION_KEY. Disabled → `{}` and the module stays off.
888
+ */
889
+ function selectNodeEnvironmentsDeps(config, db) {
890
+ if (!config.environments.enabled || !config.environments.encryptionKey)
891
+ return {};
892
+ return {
893
+ environmentProvider: new HttpEnvironmentProvider(),
894
+ environmentConnectionRepository: new DrizzleEnvironmentConnectionRepository(db),
895
+ environmentRegistryRepository: new DrizzleEnvironmentRegistryRepository(db),
896
+ secretCipher: new WebCryptoSecretCipher({
897
+ masterKeyBase64: config.environments.encryptionKey,
898
+ }),
899
+ };
900
+ }
901
+ /**
902
+ * Wire the prompt-fragment library (ADR 0006) for the Node facade when opted in,
903
+ * mirroring the Worker's `selectFragmentLibraryDeps`: the two Drizzle repositories,
904
+ * the installation resolver repo-source sync uses to read guideline repos through the
905
+ * tier's GitHub installation, and — in `llm` selector mode — the shared
906
+ * `LlmFragmentSelector` over the Node model provider (else the core deterministic
907
+ * matcher, via `fragmentSelector: undefined`). Disabled → `{}` and the module stays
908
+ * unassembled (the engine falls back to the static built-in catalog).
909
+ */
910
+ function selectNodeFragmentLibraryDeps(config, env, db, githubClient, installations, modelProviderResolver) {
911
+ if (!config.fragmentLibrary.enabled)
912
+ return {};
913
+ const resolveFragmentInstallationId = async (ownerKind, ownerId) => {
914
+ if (ownerKind === 'workspace') {
915
+ return (await installations.getByWorkspace(ownerId))?.installationId ?? null;
916
+ }
917
+ const active = await installations.listActive();
918
+ return active.find((i) => i.accountId === ownerId)?.installationId ?? null;
919
+ };
920
+ return {
921
+ promptFragmentRepository: new DrizzlePromptFragmentRepository(db),
922
+ fragmentSourceRepository: new DrizzleFragmentSourceRepository(db),
923
+ // Repo-sourced fragments read guideline files through the workspace's App
924
+ // installation; only wired when a real GitHub client is available (parity with
925
+ // the Worker — hand-authored fragments work without it).
926
+ ...(githubClient ? { githubClient, resolveFragmentInstallationId } : {}),
927
+ ...(config.fragmentLibrary.selector === 'llm'
928
+ ? {
929
+ fragmentSelector: new LlmFragmentSelector({
930
+ modelProviderResolver,
931
+ modelRef: config.agents.routing.default.ref,
932
+ }),
933
+ }
934
+ : {}),
935
+ };
936
+ }
937
+ //# sourceMappingURL=container.js.map