@cat-factory/node-server 0.107.10 → 0.107.12

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 (33) hide show
  1. package/dist/container-account-deps.d.ts +40 -0
  2. package/dist/container-account-deps.d.ts.map +1 -0
  3. package/dist/container-account-deps.js +142 -0
  4. package/dist/container-account-deps.js.map +1 -0
  5. package/dist/container-executor-deps.d.ts +26 -4
  6. package/dist/container-executor-deps.d.ts.map +1 -1
  7. package/dist/container-executor-deps.js +2 -13
  8. package/dist/container-executor-deps.js.map +1 -1
  9. package/dist/container-github-deps.d.ts +56 -0
  10. package/dist/container-github-deps.d.ts.map +1 -0
  11. package/dist/container-github-deps.js +275 -0
  12. package/dist/container-github-deps.js.map +1 -0
  13. package/dist/container-model-deps.d.ts +50 -0
  14. package/dist/container-model-deps.d.ts.map +1 -0
  15. package/dist/container-model-deps.js +127 -0
  16. package/dist/container-model-deps.js.map +1 -0
  17. package/dist/container-realtime-deps.d.ts +34 -0
  18. package/dist/container-realtime-deps.d.ts.map +1 -0
  19. package/dist/container-realtime-deps.js +112 -0
  20. package/dist/container-realtime-deps.js.map +1 -0
  21. package/dist/container-run-services-deps.d.ts +44 -0
  22. package/dist/container-run-services-deps.d.ts.map +1 -0
  23. package/dist/container-run-services-deps.js +148 -0
  24. package/dist/container-run-services-deps.js.map +1 -0
  25. package/dist/container-transport-deps.d.ts +72 -0
  26. package/dist/container-transport-deps.d.ts.map +1 -0
  27. package/dist/container-transport-deps.js +88 -0
  28. package/dist/container-transport-deps.js.map +1 -0
  29. package/dist/container.d.ts +2 -2
  30. package/dist/container.d.ts.map +1 -1
  31. package/dist/container.js +144 -742
  32. package/dist/container.js.map +1 -1
  33. package/package.json +12 -12
package/dist/container.js CHANGED
@@ -1,39 +1,40 @@
1
- import { AiAgentExecutor, defaultAgentKindRegistry, defaultInitiativePresetRegistry, inlineWebSearchOptionsFromEnv, vendorConcurrencyLimiterFromEnv, } from '@cat-factory/agents';
1
+ import { defaultAgentKindRegistry, defaultInitiativePresetRegistry, } from '@cat-factory/agents';
2
2
  // Opt-in AWS EKS backends (runner + environment), registered by reference below (the Worker
3
3
  // facade registers the same pair, keeping the runtimes symmetric with the native `kubernetes`
4
4
  // backend these extend). They are pass-throughs until a workspace actually connects an `eks`
5
5
  // backend, and carry NO runtime AWS SDK dependency (the token is minted with WebCrypto), so this
6
6
  // adds no cost to a deployment that never uses EKS.
7
7
  import { eksEnvironmentBackend, eksRunnerBackend } from '@cat-factory/eks';
8
- import { ConfluenceProvider, FigmaProvider, ZeplinProvider, GitHubDocsProvider, GitHubIssuesProvider, JiraProvider, LinearDocumentProvider, LinearTaskProvider, createBackendRegistries, HttpRunnerPoolProvider, NotionProvider, ApiKeyService, LocalModelEndpointService, ProvisioningLogRecorder, EMAIL_CIPHER_INFO, createEmailSender, SLACK_CIPHER_INFO, SlackNotificationChannel, TicketTrackerService, IssueWritebackService, githubIssuesLogic, OBSERVABILITY_CIPHER_INFO, RegistryReleaseHealthProvider, defaultObservabilityRegistry, RegistrySubscriptionQuotaProvider, defaultSubscriptionQuotaRegistry, WorkspaceIncidentEnrichmentProvider, INCIDENT_ENRICHMENT_CIPHER_INFO, AccountSettingsService, ACCOUNT_SETTINGS_CIPHER_INFO, TestSecretsService, TEST_SECRETS_CIPHER_INFO, } from '@cat-factory/integrations';
9
- import { CompositeNotificationChannel, DEFAULT_MODEL_PRESET_ID, defaultProviderRegistry, defaultVcsRegistry, } from '@cat-factory/kernel';
10
- import { AgentContextObservabilityService, SearchQueryObservabilityService, createCore, defaultStepResolverRegistry, PACKAGE_REGISTRY_CIPHER_INFO, resolvePackageRegistriesForDispatch, LlmObservabilityService, makeHarnessCallRecorder, resolvePresetModelForKind, } from '@cat-factory/orchestration';
11
- import { CompositeAgentExecutor, ContainerSessionService, FanOutEventPublisher, FetchGitHubClient, FetchGitHubProvisioningClient, GitHubAppAuth, GitHubAppRegistry, GitHubIdentityResolver, GitHubCiStatusProvider, GitHubDocQualityProvider, GitHubMergeabilityProvider, GitHubPullRequestReviewProvider, GitHubBranchUpdater, GitHubPullRequestMerger, InAppNotificationChannel, PatPreferringAppRegistry, runWithInitiator, WebCryptoPasswordHasher, WebCryptoSecretCipher, WebCryptoWebhookVerifier, buildInfrastructureCapabilities, testEnvHasZeroConfigDefault, buildResolveRepoTarget, buildResolveRepoTargets, createDefaultWebSearchUpstream, createWebSearchUpstream, makePreviewJobBuilder, makeResolveDeployCloneTarget, makeResolveRunRepoContext, makeResolveRepoFilesForCoords, makeResolveBinaryArtifactStore, RunnerJobClient, logger, resolveUrlSafetyPolicy, resolveWorkspaceCapabilities, wrapResolverWithLimiter, } from '@cat-factory/server';
8
+ import { ConfluenceProvider, FigmaProvider, ZeplinProvider, GitHubDocsProvider, LinearDocumentProvider, createBackendRegistries, HttpRunnerPoolProvider, NotionProvider, EMAIL_CIPHER_INFO, createEmailSender, TicketTrackerService, } from '@cat-factory/integrations';
9
+ import { DEFAULT_MODEL_PRESET_ID, defaultProviderRegistry, defaultVcsRegistry, } from '@cat-factory/kernel';
10
+ import { createCore, defaultStepResolverRegistry, resolvePresetModelForKind, } from '@cat-factory/orchestration';
11
+ import { CompositeAgentExecutor, ContainerSessionService, GitHubAppAuth, GitHubAppRegistry, GitHubIdentityResolver, runWithInitiator, WebCryptoPasswordHasher, WebCryptoSecretCipher, buildInfrastructureCapabilities, testEnvHasZeroConfigDefault, buildResolveRepoTarget, buildResolveRepoTargets, makePreviewJobBuilder, logger, resolveUrlSafetyPolicy, resolveWorkspaceCapabilities, } from '@cat-factory/server';
12
12
  // The built-in polling-gate suite (ci / conflicts / post-release-health + on-call). The facade
13
13
  // builds an app-owned `GateRegistry` pre-loaded with the suite via `gateRegistryWithBuiltins()`
14
14
  // below, then wires each gate's provider.
15
- import { applyGateProviders, gateRegistryWithBuiltins, wireCiStatusProvider, wireMergeabilityProvider, wireReleaseHealthProvider, wireIncidentEnrichment, wirePullRequestReviewProvider, wireDocQualityProvider, warnUnwiredGates, } from '@cat-factory/gates';
15
+ import { applyGateProviders, gateRegistryWithBuiltins, warnUnwiredGates, } from '@cat-factory/gates';
16
16
  import { buildGitLabEngineClient, GitLabIdentityResolver, registerGitLab, StaticGitLabTokenSource, } from '@cat-factory/gitlab';
17
17
  import { loadNodeConfig } from './config.js';
18
+ import { selectNodeGitHubDeps } from './container-github-deps.js';
19
+ import { buildNodeModelDeps } from './container-model-deps.js';
20
+ import { buildNodeRunServices } from './container-run-services-deps.js';
21
+ import { buildNodeBootstrapper, buildNodeTransportDeploy } from './container-transport-deps.js';
22
+ import { buildNodeAccountDeps } from './container-account-deps.js';
23
+ import { buildNodeRealtimeDeps } from './container-realtime-deps.js';
18
24
  import { executionRuntime } from './execution/config.js';
19
25
  import { PgBossBootstrapRunner } from './execution/bootstrapRunner.js';
20
26
  import { PgBossEnvConfigRepairRunner } from './execution/envConfigRepairRunner.js';
21
27
  import { PgBossEnvironmentTestRunner } from './execution/envTestRunner.js';
22
28
  import { PgBossWorkRunner } from './execution/pgBossRunner.js';
23
29
  import { createNodeGateways } from './gateways.js';
24
- import { baseUrlForNode, createNodeModelProviderResolver, } from './modelProvider.js';
25
- import { ConsensusAgentExecutor, registerConsensusTraits } from '@cat-factory/consensus';
30
+ import { baseUrlForNode } from './modelProvider.js';
26
31
  import { LocalMachineEventRelay } from './machineEventRelay.js';
27
- import { NodeEventPublisher } from './realtime.js';
28
32
  import { DrizzleGitHubInstallationRepository, DrizzleRunnerPoolConnectionRepository, } from './repositories/containerExecution.js';
29
- import { DrizzleBranchProjectionRepository, DrizzleCheckRunProjectionRepository, DrizzleCommitProjectionRepository, DrizzleIssueProjectionRepository, DrizzlePullRequestProjectionRepository, DrizzleRepoProjectionRepository, } from './repositories/github.js';
33
+ import { DrizzleRepoProjectionRepository } from './repositories/github.js';
30
34
  import { DrizzleSubscriptionActivationRepository } from './repositories/personalSubscription.js';
31
35
  import { DrizzleUserRepoAccessRepository } from './repositories/userRepoAccess.js';
32
36
  import { createDrizzleRepositories, createDrizzleSandboxDeps } from './repositories/drizzle.js';
33
- import { PostgresBinaryBlobBackend } from './storage/PostgresBinaryBlobBackend.js';
34
- import { FilesystemBinaryBlobBackend } from './storage/FilesystemBinaryBlobBackend.js';
35
- import { S3BinaryBlobBackend } from '@cat-factory/provider-s3';
36
- import { DrizzleBootstrapJobRepository, DrizzleReferenceArchitectureRepository, } from './repositories/bootstrap.js';
37
+ import { DrizzleReferenceArchitectureRepository } from './repositories/bootstrap.js';
37
38
  import { DrizzleEnvConfigRepairJobRepository } from './repositories/envConfigRepair.js';
38
39
  import { DrizzleEnvironmentTestRunRepository } from './repositories/environmentTest.js';
39
40
  import { DrizzleDocumentConnectionRepository, DrizzleDocumentRepository, } from './repositories/documents.js';
@@ -41,14 +42,14 @@ import { DrizzleEnvironmentConnectionRepository, DrizzleEnvironmentRegistryRepos
41
42
  import { DrizzleCustomManifestTypeRepository } from './repositories/customManifestType.js';
42
43
  import { DrizzleNotificationRepository } from './repositories/notifications.js';
43
44
  import { selectNodeFragmentLibraryDeps, selectNodeSkillLibraryDeps, } from './container-content-library-deps.js';
44
- import { DrizzleSlackConnectionRepository, DrizzleSlackMemberMappingRepository, DrizzleSlackSettingsRepository, } from './repositories/slack.js';
45
- import { DrizzleTaskConnectionRepository, DrizzleTaskRepository, DrizzleTaskSourceSettingsRepository, } from './repositories/tasks.js';
45
+ import {} from './repositories/slack.js';
46
+ import {} from './repositories/tasks.js';
46
47
  import { CryptoIdGenerator, SystemClock } from './runtime.js';
47
- import { buildNodeApiKeyService, buildNodeLocalModelEndpointService, buildNodeOpenRouterCatalogService, buildNodePersonalSubscriptionService, buildNodePublicApiKeyService, buildNodeSubscriptionService, buildNodeUserSecretService, } from './wireCredentialServices.js';
48
+ import {} from './wireCredentialServices.js';
48
49
  // The container-agent-executor wiring (transport resolver, provisioning-log wrapper, container
49
50
  // executor + bootstrapper + env-config repairer, GitHub-issue filer, trace-sink builder), lifted
50
51
  // into a sibling module so this composition root stays within the file-size budget.
51
- import { RUNNERS_CIPHER_INFO, buildNodeContainerExecutor, buildNodeGitHubIssueFiler, buildNodeResolveTransport, buildTraceSink, selectNodeEnvConfigRepairer, selectNodeRepoBootstrapper, withProvisioningLog, } from './container-executor-deps.js';
52
+ import { RUNNERS_CIPHER_INFO, buildNodeContainerExecutor, buildTraceSink, selectNodeEnvConfigRepairer, } from './container-executor-deps.js';
52
53
  // Re-export the public seams the local facade + tests still import from `./container.js`.
53
54
  export { buildNodeResolveTransport, missingContainerExecutorPrereqs, withProvisioningLog, } from './container-executor-deps.js';
54
55
  /**
@@ -72,79 +73,6 @@ export function pickRepoSource(remote, name, build) {
72
73
  // own. Mirrors the Worker's `buildModelProvider` memoisation. Memoisation matters more for
73
74
  // OTel than Langfuse: the SDK sink owns batch processors/exporters, so it must be built
74
75
  // once per config, not per wiring site.
75
- /** Truthy env flag (`true`/`1`/`yes`). */
76
- function isTruthy(value) {
77
- return value === 'true' || value === '1' || value === 'yes';
78
- }
79
- /**
80
- * The Node model-provider RESOLVER (instrumented when Langfuse is on), shared per
81
- * `(env, db)`. Builds a per-scope provider from the DB-backed API-key pool plus opt-in
82
- * Cloudflare-REST / Bedrock registries. Mirrors the Worker's buildModelProviderResolver.
83
- */
84
- const modelResolverCache = new WeakMap();
85
- function buildModelProviderResolver(env, db, apiKeys, localModelEndpoints,
86
- // The shared inline instrument (one trace sink for the proxied path, the core AND the
87
- // inline calls) so the OTel SDK exporter isn't rebuilt per wiring site.
88
- instrument) {
89
- // The cache keys on the db handle (one resolver per Drizzle client). Mothership mode has no
90
- // db, so skip the cache entirely (WeakMap keys must be objects) and build a fresh resolver —
91
- // a mothership node builds one container, so there is nothing to share it with anyway.
92
- if (!db)
93
- return createNodeModelProviderResolver(env, apiKeys, localModelEndpoints, instrument);
94
- const cached = modelResolverCache.get(db);
95
- if (cached)
96
- return cached;
97
- const resolver = createNodeModelProviderResolver(env, apiKeys, localModelEndpoints, instrument);
98
- modelResolverCache.set(db, resolver);
99
- return resolver;
100
- }
101
- /**
102
- * Wire the Slack integration when enabled: the notification *channel* (an extra
103
- * delivery transport composed onto the notification mechanism — Node has no in-app
104
- * channel, so this is its only one) plus the management repositories (per-account
105
- * connect + per-workspace routing + member map) and the bot-token cipher. The
106
- * per-account bot token is sealed with the shared ENCRYPTION_KEY under a
107
- * slack-scoped HKDF info, mirroring the Worker. OAuth credentials are optional.
108
- */
109
- function selectNodeSlackDeps(config, repos,
110
- // The remote-source seam (mothership mode): `sourced('name', build)` returns the remote registry
111
- // entry when there's no `db`, else builds the Drizzle repo. Routing the three Slack repos through
112
- // it makes the connect / route / member-map management surface functional in mothership mode —
113
- // AND keeps the `SlackNotificationChannel` (which captures these repos directly) reading the SAME
114
- // remote-backed repos, so it can't drift to the broken db-less Drizzle instances. The bot token
115
- // rides a SEALED `tokenCipher` (sealed/decrypted under the LOCAL key), so the sealed blob — never
116
- // plaintext — crosses the machine API; the settings + member-mapping rows carry no secrets. The
117
- // RPC allow-list gates each method by its account/workspace scope. (Mothership-SIDE delivery for a
118
- // hosted teammate's notification — the mothership decrypting a laptop-sealed token — is the later
119
- // secrets-delegation slice; local delivery, where the run's own node holds the key, works.)
120
- sourced) {
121
- if (!config.slack.enabled || !config.slack.encryptionKey)
122
- return {};
123
- const secretCipher = new WebCryptoSecretCipher({
124
- masterKeyBase64: config.slack.encryptionKey,
125
- info: SLACK_CIPHER_INFO,
126
- });
127
- const slackConnectionRepository = sourced('slackConnectionRepository', (d) => new DrizzleSlackConnectionRepository(d));
128
- const slackSettingsRepository = sourced('slackSettingsRepository', (d) => new DrizzleSlackSettingsRepository(d));
129
- const slackMemberMappingRepository = sourced('slackMemberMappingRepository', (d) => new DrizzleSlackMemberMappingRepository(d));
130
- return {
131
- notificationChannel: new SlackNotificationChannel({
132
- workspaceRepository: repos.workspaceRepository,
133
- slackConnectionRepository,
134
- slackSettingsRepository,
135
- slackMemberMappingRepository,
136
- blockRepository: repos.blockRepository,
137
- secretCipher,
138
- // Best-effort delivery still surfaces failures (revoked token, missing channel
139
- // invite) through the structured logger so a broken route is diagnosable.
140
- onError: (error, ctx) => logger.warn({ err: error instanceof Error ? error.message : String(error), ...ctx }, 'slack notification delivery failed'),
141
- }),
142
- slackConnectionRepository,
143
- slackSettingsRepository,
144
- slackMemberMappingRepository,
145
- slackSecretCipher: secretCipher,
146
- };
147
- }
148
76
  /**
149
77
  * Wire account invitations + per-account email senders for the Node facade (parity
150
78
  * with the Worker's `selectEmailInvitationDeps`). Invitations are always available (an
@@ -193,14 +121,6 @@ function buildSystemEmailSender(config) {
193
121
  * Rate-limit accounting is best-effort telemetry the Worker persists to D1; the Node
194
122
  * facade has no such table, so it drops the snapshots (exactly like the local facade).
195
123
  */
196
- class NoopRateLimitRepository {
197
- record(_snapshot) {
198
- return Promise.resolve();
199
- }
200
- deleteOlderThan(_epochMs) {
201
- return Promise.resolve(0);
202
- }
203
- }
204
124
  /**
205
125
  * The workspace-spanning GitHub App registry, built once and shared by everything that
206
126
  * needs an App credential: the container executor's push-token mint, the tech-debt
@@ -360,42 +280,6 @@ export function buildNodeContainer(options) {
360
280
  // private-CA `kubernetes` connection already carries.
361
281
  runnerBackendRegistry.register(eksRunnerBackend);
362
282
  environmentBackendRegistry.register(eksEnvironmentBackend);
363
- // Binary-artifact storage (UI screenshots + reference design images) for the
364
- // visual-confirmation gate. The backend is configured PER ACCOUNT in the UI (no env vars):
365
- // the metadata always lives in Postgres; the bytes go to the account's chosen blob backend
366
- // (`fs` → the local filesystem; `db` → a Postgres `bytea` table; `s3` → an S3 bucket). The
367
- // composed store is resolved per request/run from the account settings (see
368
- // `resolveBinaryArtifactStore`, built below once `accountSettings` exists).
369
- const contentStorageCapability = {
370
- supportedBackends: ['off', 'fs', 's3', 'db'],
371
- defaultBackend: options.contentStorageDefaultBackend ?? 'off',
372
- };
373
- const buildNodeBlobBackend = (kind, opts) => {
374
- switch (kind) {
375
- case 'fs':
376
- // NOTE: the filesystem backend is local-disk only. It is correct for the local facade
377
- // and a single-instance Node deployment with a persistent volume, but NOT for a scaled
378
- // (multi-replica) or ephemeral-disk deployment — bytes written on one replica are
379
- // invisible to the others and lost on redeploy. Scaled deployments should pick `s3`.
380
- return new FilesystemBinaryBlobBackend({ basePath: opts.fs?.basePath });
381
- case 'db':
382
- return new PostgresBinaryBlobBackend(db);
383
- case 's3':
384
- if (!opts.s3)
385
- return null;
386
- // Omitting credentials is intentional: the S3 client then falls back to the ambient AWS
387
- // credential chain (instance role / `AWS_*` env), which is the right behaviour for a
388
- // deployment running on AWS with an attached role. The UI requires explicit keys, so this
389
- // path is only reached by a config written through another channel.
390
- return new S3BinaryBlobBackend({
391
- ...opts.s3,
392
- ...(opts.s3Credentials ? { credentials: opts.s3Credentials } : {}),
393
- });
394
- default:
395
- // `r2`/`memory` are not served on Node/local — null ⇒ storage unavailable.
396
- return null;
397
- }
398
- };
399
283
  // The built-in gates' providers are wired onto the app-owned `providerRegistry` (news'd below,
400
284
  // fresh unless injected via `options`). The GitHub + release-health wiring runs only inside its
401
285
  // `enabled`/`githubClient` branches; a fresh registry starts empty, so an unconfigured gate just
@@ -436,79 +320,28 @@ export function buildNodeContainer(options) {
436
320
  // selected/default model preset > env routing), uniformly for inline and container
437
321
  // kinds. The built-in default preset points every agent kind at Kimi K2.7.
438
322
  const resolveWorkspaceModelDefault = (workspaceId, agentKind, modelPresetId) => resolvePresetModelForKind(repos.modelPresetRepository, workspaceId, agentKind, modelPresetId);
439
- // The direct-provider API-key pool + the per-scope model-provider resolver, shared by
440
- // the inline executor, the inline modules (planner/reviewer/fragment selector), the
441
- // API-key controller, and the LLM proxy key lease.
442
- const apiKeys = buildNodeApiKeyService(env, db, repos.workspaceRepository, idGenerator, clock, options.providerApiKeyRepository);
443
- // The inbound public-API key store drives the public `/api/v1` surface's authentication.
444
- const publicApiKeys = buildNodePublicApiKeyService(env, db, idGenerator, clock);
445
- // The per-user locally-run model endpoints store (Ollama / LM Studio / …), shared by
446
- // the local-runner controller, the per-user model catalog, the inline model provider,
447
- // and the LLM proxy.
448
- const localModelEndpoints = buildNodeLocalModelEndpointService(env, db, clock, options.localModelEndpointRepository);
449
- // The per-user generic secret store (a GitHub PAT today), shared by the user-secret
450
- // controller and the run-initiator PAT resolver below.
451
- const userSecrets = buildNodeUserSecretService(env, db, clock, userSecretKindRegistry, options.caches?.viewerRepos);
452
- // Resolve the run initiator's stored GitHub PAT (when set) — preferred over the
453
- // App/env token by the container push-token mint + the engine GitHub client.
454
- const resolveUserGitHubToken = userSecrets
455
- ? (userId) => userSecrets.resolve(userId, 'github_pat')
456
- : undefined;
457
- // The per-workspace OpenRouter dynamic-catalog store — shared by the catalog controller,
458
- // the per-workspace model catalog's dynamic OpenRouter entries, and the spend overlay.
459
- const openRouterCatalog = buildNodeOpenRouterCatalogService(env, db, clock, apiKeys, config.spend.currency);
460
- // The subscription-token pool (Claude Code / Codex credentials), shared by the
461
- // container executor (lease + usage feedback) and the vendor-credential controller.
462
- // Built HERE (before the model-provider wrap below) so its lease closures can be handed
463
- // to `wrapModelProviderResolver` — the local facade's inline-harness wrap serves an
464
- // inline subscription ref through a warm container on a LEASED credential, so it needs the
465
- // same lease seams the container executor uses (built once, shared by both).
466
- const subscriptions = buildNodeSubscriptionService(env, db, repos.workspaceRepository, idGenerator, clock, options.providerSubscriptionTokenRepository);
467
- // The per-user individual-usage subscription store (Claude), shared by the
468
- // container executor's personal lease, the personal-subscription controller, and the
469
- // inline-harness wrap's per-run personal lease.
470
- const personalSubscriptions = buildNodePersonalSubscriptionService(env, db, idGenerator, clock, options.personalSubscriptionRepository, options.subscriptionActivationRepository);
471
- // The ONE external trace sink for this container (memoised per config): the core, the
472
- // container executor AND the inline model-provider instrumentation all share this single
473
- // instance, so the OTel SDK exporter's batch processors/timers exist exactly once (and its
474
- // shutdown is wired below). Its `recordPrompts` matches the proxied path's gating.
475
- const traceSink = buildTraceSink(config);
476
- const baseModelProviderResolver = buildModelProviderResolver(env, db, apiKeys, localModelEndpoints, traceSink ? { traceSink, recordPrompts: config.observability.recordPrompts } : undefined);
477
- const wrappedModelProviderResolver = options.wrapModelProviderResolver
478
- ? options.wrapModelProviderResolver(baseModelProviderResolver, {
479
- ...(personalSubscriptions
480
- ? {
481
- leasePersonalSubscriptionToken: (executionId, userId, vendor) => personalSubscriptions.leaseForRun(executionId, userId, vendor),
482
- }
483
- : {}),
484
- ...(subscriptions
485
- ? {
486
- leaseSubscriptionToken: (workspaceId, vendor) => subscriptions.leaseToken(workspaceId, vendor),
487
- }
488
- : {}),
489
- })
490
- : baseModelProviderResolver;
491
- // Cap concurrent inline calls to a subscription vendor, OUTERMOST so it sits outside the
492
- // local facade's subscription-inline harness wrap above (and therefore sees the un-degraded
493
- // subscription ref). One limiter per container = per process for a stock node, per tenant in
494
- // mothership mode; a pass-through when nothing is capped. Symmetric with the Worker's wrap in
495
- // `buildModelProviderResolver` (see "Keep the runtimes symmetric").
496
- const modelProviderResolver = wrapResolverWithLimiter(wrappedModelProviderResolver, vendorConcurrencyLimiterFromEnv((key) => env[key]));
497
- // Cloudflare Workers AI is opt-in on Node: enabled when the REST creds are present.
498
- const cloudflareModelsEnabled = options.cloudflareModelsEnabled ?? !!(env.CLOUDFLARE_ACCOUNT_ID && env.CLOUDFLARE_API_TOKEN);
499
- const inline = new AiAgentExecutor({
500
- modelProviderResolver,
501
- agentRouting: config.agents.routing,
502
- resolveBlockModel: config.agents.resolveBlockModel,
503
- resolveWorkspaceModelDefault,
504
- // In local mode this keeps an ambient-eligible subscription harness ref so the inline
505
- // design/research kinds run on the developer's Claude Code / Codex CLI; undefined on
506
- // stock Node (no inline harness), where such a ref degrades to the routing default.
507
- ...(config.agents.inlineHarnessRef ? { runsInline: config.agents.inlineHarnessRef } : {}),
508
- // Opt-in provider web search for the inline design/research kinds (no-op unless
509
- // INLINE_WEB_SEARCH_ENABLED and an Anthropic/OpenAI model).
510
- webSearch: inlineWebSearchOptionsFromEnv(env),
323
+ // The credential/token stores + the model-provisioning stack (API-key pool, public-API +
324
+ // local-model-endpoint + user-secret + OpenRouter + subscription + personal-subscription
325
+ // stores, the trace sink, the model-provider resolver, and the inline executor), lifted into
326
+ // `container-model-deps.ts` so this composition root stays within the file-size budget.
327
+ const { apiKeys, publicApiKeys, localModelEndpoints, userSecrets, resolveUserGitHubToken, openRouterCatalog, subscriptions, personalSubscriptions, traceSink, modelProviderResolver, cloudflareModelsEnabled, inline, } = buildNodeModelDeps({
328
+ env,
329
+ config,
330
+ db,
331
+ workspaceRepository: repos.workspaceRepository,
332
+ idGenerator,
333
+ clock,
511
334
  agentKindRegistry,
335
+ userSecretKindRegistry,
336
+ resolveWorkspaceModelDefault,
337
+ providerApiKeyRepository: options.providerApiKeyRepository,
338
+ localModelEndpointRepository: options.localModelEndpointRepository,
339
+ providerSubscriptionTokenRepository: options.providerSubscriptionTokenRepository,
340
+ personalSubscriptionRepository: options.personalSubscriptionRepository,
341
+ subscriptionActivationRepository: options.subscriptionActivationRepository,
342
+ wrapModelProviderResolver: options.wrapModelProviderResolver,
343
+ cloudflareModelsEnabled: options.cloudflareModelsEnabled,
344
+ caches: options.caches,
512
345
  });
513
346
  // Persistence the container-execution path needs (built from the same db). The
514
347
  // runner-pool repo also backs the `runners` Core module so a pool is registrable
@@ -548,528 +381,128 @@ export function buildNodeContainer(options) {
548
381
  blockRepository: repos.blockRepository,
549
382
  serviceRepository: repos.serviceRepository,
550
383
  });
551
- // Best-effort recorder for the provisioning event log (its own Postgres schema).
552
- // Shared by the env services (via createCore) and the runner/container transport
553
- // decorator below, so every spin-up/down attempt is logged.
554
- const provisioningLogRecorder = new ProvisioningLogRecorder({
555
- repository: repos.provisioningLogRepository,
556
- idGenerator,
557
- clock,
558
- });
559
- // A sibling facade (local mode) may inject its own transport — even `null` — which
560
- // replaces the default self-hosted-pool resolution; undefined keeps Node's default
561
- // (a self-hosted pool, optionally driven by an injected native `runnerPoolProvider`).
562
- // The injected transport is a per-run container (local mode), the default is a
563
- // self-hosted pool — tag each accordingly so the logs drawer can filter by subsystem.
564
- // A facade that pre-wraps its branches with their own subsystem tags (local mode) sets
565
- // `skipProvisioningLogWrap` so we don't double-wrap.
566
- const baseResolveTransport = options.resolveTransport !== undefined
567
- ? options.resolveTransport
568
- : buildNodeResolveTransport(config, runnerPoolConnectionRepository, repos.workspaceRepository, clock, runnerBackendRegistry, options.runnerPoolProvider);
569
- const resolveTransport = options.skipProvisioningLogWrap
570
- ? baseResolveTransport
571
- : withProvisioningLog(baseResolveTransport, provisioningLogRecorder, options.resolveTransport !== undefined ? 'container' : 'runner-pool');
572
- // The async, container-backed Kubernetes deploy lifecycle (slice 9's `deployJobClient` +
573
- // `resolveDeployCloneTarget` seams). Node deploys on the workspace's self-hosted runner pool
574
- // (which pulls the `imageDeploy` variant), so the default deploy client wraps the SAME
575
- // `resolveTransport` the agent executor uses — the pool is Node's analogue of the Worker's
576
- // DeployContainer. The clone-target resolver mints a short-lived install token + a github.com
577
- // origin from the App registry. The local facade injects BOTH (a deploy-dedicated native/
578
- // container transport + a PAT/GitLab clone target) via `options`, which win here. Absent any
579
- // backend ⇒ unwired, so a render-needing config fails loudly (the raw REST path is unaffected).
580
- const baseDeployMint = options.mintInstallationToken ??
581
- (appRegistry ? (id) => appRegistry.installationToken(id) : undefined);
582
- const deployJobClient = options.deployJobClient ??
583
- (options.disableDefaultDeployJobClient || !resolveTransport
584
- ? undefined
585
- : new RunnerJobClient(resolveTransport));
586
- const resolveDeployCloneTarget = options.resolveDeployCloneTarget ??
587
- (baseDeployMint
588
- ? makeResolveDeployCloneTarget(resolveRepoTarget, (id) => baseDeployMint(id), options.resolveRepoOrigin
589
- ? { resolveCloneUrl: (t) => options.resolveRepoOrigin(t).cloneUrl }
590
- : {})
591
- : undefined);
592
- const deployDeps = config.environments.encryptionKey
593
- ? {
594
- ...(deployJobClient ? { deployJobClient } : {}),
595
- ...(resolveDeployCloneTarget ? { resolveDeployCloneTarget } : {}),
596
- }
597
- : {};
598
- // Agent-context observability sink: records the complete, redacted context provided
599
- // to each container agent (composed prompts + folded-in fragments + injected files).
600
- // Gated by the deployment prompt-recording switch + the workspace storeAgentContext
601
- // setting. Wired into the executor (write) AND createCore (read). The telemetry rows
602
- // live in the `telemetry` Postgres schema (see schema.ts).
603
- const agentContextObservability = new AgentContextObservabilityService({
604
- agentContextSnapshotRepository: repos.agentContextSnapshotRepository,
605
- workspaceSettingsRepository: repos.workspaceSettingsRepository,
606
- idGenerator,
607
- clock,
608
- recordPrompts: config.observability.recordPrompts,
609
- });
610
- // Agent-search-query observability sink: records each web search a container agent
611
- // performed through the search proxy. Same double gate + retention window as the
612
- // agent-context sink. Wired into the search proxy (write, via the container) AND
613
- // createCore (read). Telemetry rows live in the `telemetry` Postgres schema.
614
- const searchQueryObservability = new SearchQueryObservabilityService({
615
- agentSearchQueryRepository: repos.agentSearchQueryRepository,
616
- workspaceSettingsRepository: repos.workspaceSettingsRepository,
617
- idGenerator,
618
- clock,
619
- recordPrompts: config.observability.recordPrompts,
620
- });
621
- // Record a subscription harness's (Claude Code / Codex) per-call telemetry into the
622
- // SAME `llm_call_metrics` store the LLM proxy writes for Pi — those harnesses bypass
623
- // the proxy, so the executor lifts the metrics off the CLI stream and feeds them here.
624
- const recordHarnessCalls = makeHarnessCallRecorder(new LlmObservabilityService({
625
- llmCallMetricRepository: repos.llmCallMetricRepository,
384
+ // The runner-transport resolver + the container-backed deploy lifecycle seams (resolve the
385
+ // workspace's transport, wrap it with the provisioning-log decorator, build the deploy job
386
+ // client + clone-target resolver), lifted into `container-transport-deps.ts` to keep this
387
+ // root within budget.
388
+ const { resolveTransport, baseDeployMint, deployDeps } = buildNodeTransportDeploy({
389
+ config,
390
+ repos,
626
391
  idGenerator,
627
392
  clock,
628
- recordPrompts: config.observability.recordPrompts,
629
- }));
630
- // A deployment-wide trusted web-search upstream, built from this facade's own `WEB_SEARCH_*`
631
- // env, used by the search proxy as a fallback when a run's account has no web-search config
632
- // (local mode defaults `WEB_SEARCH_SEARXNG_URL` to its self-hosted SearXNG). Distinct from the
633
- // harness's own `SEARXNG_URL`/`BRAVE_SEARCH_API_KEY` runner-pool autodetect — those are for
634
- // self-hosted pool containers; these keys stay on the backend. Surfaced on the ServerContainer
635
- // below and read by `WebSearchProxyController`.
636
- const defaultWebSearchUpstream = createDefaultWebSearchUpstream({
637
- braveApiKey: env.WEB_SEARCH_BRAVE_API_KEY,
638
- searxngUrl: env.WEB_SEARCH_SEARXNG_URL,
639
- searxngApiKey: env.WEB_SEARCH_SEARXNG_API_KEY,
393
+ runnerPoolConnectionRepository,
394
+ runnerBackendRegistry,
395
+ appRegistry,
396
+ resolveRepoTarget,
397
+ workspaceRepository: repos.workspaceRepository,
398
+ resolveTransportOverride: options.resolveTransport,
399
+ runnerPoolProvider: options.runnerPoolProvider,
400
+ skipProvisioningLogWrap: options.skipProvisioningLogWrap,
401
+ mintInstallationToken: options.mintInstallationToken,
402
+ deployJobClientOverride: options.deployJobClient,
403
+ disableDefaultDeployJobClient: options.disableDefaultDeployJobClient,
404
+ resolveDeployCloneTargetOverride: options.resolveDeployCloneTarget,
405
+ resolveRepoOrigin: options.resolveRepoOrigin,
640
406
  });
641
- // Web-search keys live per-account; advertise Pi's `web_search` tool to a run only when a
642
- // usable upstream exists either the deployment default above (⇒ always on) or the run's
643
- // account has its own keys (else the tool would just fail/return nothing). The per-account
644
- // check runs off a dedicated account-settings instance (short-TTL cache).
645
- const webSearchAccountKey = env.ENCRYPTION_KEY?.trim();
646
- const webSearchAccountSettings = webSearchAccountKey
647
- ? new AccountSettingsService({
648
- accountSettingsRepository: repos.accountSettingsRepository,
649
- secretCipher: new WebCryptoSecretCipher({
650
- masterKeyBase64: webSearchAccountKey,
651
- info: ACCOUNT_SETTINGS_CIPHER_INFO,
652
- }),
653
- clock,
654
- ...(options.caches ? { settingsCache: options.caches.accountSettings } : {}),
655
- })
656
- : undefined;
657
- const resolveWebSearchAvailability = defaultWebSearchUpstream || webSearchAccountSettings
658
- ? async (workspaceId) => {
659
- // Mirror the proxy's own resolution (`accountUpstream ?? defaultWebSearchUpstream`):
660
- // the run's account keys WIN and the deployment default is only the fallback, so the
661
- // surfaced provider matches the one that will actually serve the run's searches. Build
662
- // the account upstream the SAME way the proxy does before falling back to the default.
663
- if (webSearchAccountSettings) {
664
- const accountId = await repos.workspaceRepository.accountOf(workspaceId);
665
- if (accountId) {
666
- const accountUpstream = createWebSearchUpstream((await webSearchAccountSettings.resolve(accountId)).webSearch ?? {});
667
- if (accountUpstream)
668
- return { available: true, provider: accountUpstream.provider };
669
- }
670
- }
671
- if (defaultWebSearchUpstream)
672
- return { available: true, provider: defaultWebSearchUpstream.provider };
673
- return { available: false, provider: null };
674
- }
675
- : undefined;
676
- // Private package registries (npm private orgs, GitHub Packages): sealed per-workspace
677
- // entries decrypted only at container dispatch, rendered by the harness into ~/.npmrc.
678
- // The cipher is shared by the dispatch resolver here and the management service below.
679
- const packageRegistryEncryptionKey = env.ENCRYPTION_KEY?.trim();
680
- const packageRegistrySecretCipher = packageRegistryEncryptionKey
681
- ? new WebCryptoSecretCipher({
682
- masterKeyBase64: packageRegistryEncryptionKey,
683
- info: PACKAGE_REGISTRY_CIPHER_INFO,
684
- })
685
- : undefined;
686
- const resolvePackageRegistries = packageRegistrySecretCipher
687
- ? (workspaceId) => resolvePackageRegistriesForDispatch(repos.packageRegistryConnectionRepository, packageRegistrySecretCipher, workspaceId)
688
- : undefined;
689
- // Sensitive per-service test credentials (sealed): the service backs the CRUD controller, the
690
- // engine's prompt refs (via `resolveTestSecretRefs`) and the executor's out-of-band value
691
- // injection (via `resolveTestSecrets`). Guarded by ENCRYPTION_KEY like the other sealed stores.
692
- const testSecretsEncryptionKey = env.ENCRYPTION_KEY?.trim();
693
- const testSecretsService = testSecretsEncryptionKey
694
- ? new TestSecretsService({
695
- testSecretsRepository: repos.testSecretsRepository,
696
- secretCipher: new WebCryptoSecretCipher({
697
- masterKeyBase64: testSecretsEncryptionKey,
698
- info: TEST_SECRETS_CIPHER_INFO,
699
- }),
700
- blockRepository: repos.blockRepository,
701
- clock,
702
- })
703
- : undefined;
704
- const resolveTestSecrets = testSecretsService
705
- ? (workspaceId, blockId) => testSecretsService.resolveValuesForBlock(workspaceId, blockId)
706
- : undefined;
707
- const resolveTestSecretRefs = testSecretsService
708
- ? (workspaceId, blockId) => testSecretsService.resolveRefsForBlock(workspaceId, blockId)
709
- : undefined;
710
- // Modeled subscription quota-cycle provider (usage-and-quota-tracking, Part B): folds a
711
- // finished subscription run's tokens into rolling windows (real reads land in B2). The
712
- // registry of REAL vendor adapters is empty today, so every vendor reports modeled.
713
- const subscriptionQuotaProvider = new RegistrySubscriptionQuotaProvider({
714
- subscriptionQuotaCycleRepository: repos.subscriptionQuotaCycleRepository,
715
- idGenerator,
716
- clock,
717
- registry: defaultSubscriptionQuotaRegistry,
407
+ // The per-run agent-observability + web-search + sealed-secret services (agent-context /
408
+ // search-query / harness-call telemetry sinks, the web-search upstream + availability
409
+ // resolver, the package-registry + test-secret dispatch resolvers, the subscription-quota
410
+ // provider), lifted into `container-run-services-deps.ts` to keep this root within budget.
411
+ const { agentContextObservability, searchQueryObservability, recordHarnessCalls, defaultWebSearchUpstream, resolveWebSearchAvailability, packageRegistrySecretCipher, resolvePackageRegistries, testSecretsService, resolveTestSecrets, resolveTestSecretRefs, subscriptionQuotaProvider, } = buildNodeRunServices({ env, config, repos, idGenerator, clock, caches: options.caches });
412
+ const container = buildNodeContainerExecutor({
413
+ env,
414
+ config,
415
+ appRegistry,
416
+ resolveRepoTarget,
417
+ resolveRepoTargets,
418
+ resolveTransport,
419
+ resolveWorkspaceModelDefault,
420
+ agentKindRegistry,
421
+ mintInstallationTokenOverride: options.mintInstallationToken,
422
+ subscriptions,
423
+ personalSubscriptions,
424
+ resolveAccountId: (workspaceId) => repos.workspaceRepository.accountOf(workspaceId),
425
+ resolveUserGitHubToken,
426
+ agentContextObservability,
427
+ resolveWebSearchAvailability,
428
+ resolveRepoOrigin: options.resolveRepoOrigin,
429
+ resolvePackageRegistries,
430
+ resolveTestSecrets,
431
+ recordHarnessCalls,
432
+ recordSubscriptionQuotaUsage: (target, usage) => subscriptionQuotaProvider.recordUsage(target, usage),
718
433
  });
719
- const container = buildNodeContainerExecutor(env, config, appRegistry, resolveRepoTarget, resolveRepoTargets, resolveTransport, resolveWorkspaceModelDefault, agentKindRegistry, options.mintInstallationToken, subscriptions, personalSubscriptions, (workspaceId) => repos.workspaceRepository.accountOf(workspaceId), resolveUserGitHubToken, agentContextObservability, resolveWebSearchAvailability, options.resolveRepoOrigin, resolvePackageRegistries, resolveTestSecrets, recordHarnessCalls, (target, usage) => subscriptionQuotaProvider.recordUsage(target, usage));
720
434
  // Always a composite: inline kinds run as one-shot LLM calls; repo-operating kinds
721
435
  // route to the container (and fail loudly when its prerequisites are unconfigured).
722
436
  // Optionally wrapped with the consensus mechanism below (after the event publisher
723
437
  // is built, so live consensus pushes ride the same hub).
724
438
  const standardAgentExecutor = new CompositeAgentExecutor(inline, container, agentKindRegistry);
725
- // GitHub-issue tracker: file the tech-debt pipeline's issue through the workspace's
726
- // own GitHub App installation (per-tenant), resolving the service's repo from the
727
- // github_repos projectionthe same per-tenant infra the container executor uses.
728
- const fileGitHubIssue = buildNodeGitHubIssueFiler(config, appRegistry, resolveRepoTarget);
729
- // The GitHub client backing the CI gate + merge / mergeability providers: an injected
730
- // one wins (the local facade supplies a PAT-backed client), else when the GitHub App
731
- // is configured one minted from the shared App registry, so a stock Node deployment
732
- // with an App ALSO gates on real GitHub Actions CI and merges the PR for real (parity
733
- // with the Worker). Undefined → these stay unwired and the gates pass through.
734
- // Prefer the run initiator's per-user PAT (when stored) over the App token for the
735
- // engine's CI gate + merge reads, so those are attributed to them too. The engine
736
- // sets the initiator in ambient context around the gate-probe / merge boundaries.
737
- const engineRegistry = appRegistry && resolveUserGitHubToken
738
- ? new PatPreferringAppRegistry(appRegistry, resolveUserGitHubToken)
739
- : appRegistry;
740
- const githubClient = options.githubClient ??
741
- (engineRegistry
742
- ? new FetchGitHubClient({
743
- registry: engineRegistry,
744
- rateLimitRepository: new NoopRateLimitRepository(),
745
- idGenerator,
746
- clock,
747
- apiBase: config.github.apiBase,
748
- })
749
- : undefined);
750
- // The client the engine's gate / merge / RepoFiles seams read through: the real GitHub client
751
- // when present, else the GitLab-backed fallback so a GitLab-only deployment still gates on real
752
- // CI and merges for real (the GitHub App wins when both are configured). Kept SEPARATE from
753
- // `githubClient` on purpose — the GitHub-issue-specific consumers below (the GitHub Issues task
754
- // source, issue writeback, the App projection module) must NOT be fed the GitLab client, or a
755
- // GitLab-only deployment would offer a non-functional "GitHub Issues" source (it resolves the
756
- // empty github_installations projection). Parity with the Worker, which keeps the App client
757
- // distinct from its GitLab engine fallback.
758
- const engineVcsClient = githubClient ?? gitlabEngineClient;
759
- // Task-source integration (Jira + GitHub issues). Tenants connect their own Jira
760
- // site through the UI (credentials stored per-workspace, encrypted at rest); the
761
- // tracker resolves each workspace's own credentials from this same store. GitHub
762
- // issues reuse the workspace's installed App, so they wire only when `githubClient`
763
- // is available — kept here, after the client is built, for parity with the Worker.
764
- const tasks = selectNodeTasksDeps(config, db, githubClient, githubInstallationRepository);
765
- // Issue-tracker writeback (comment-on-PR-open + close-on-merge of a task's linked
766
- // issue), gated per workspace + per task inside the provider. GitHub uses the same
767
- // per-tenant client + installation lookup as the tracker/CI/merge providers; Jira
768
- // reuses the workspace's encrypted connection. Wired whenever the tracker-settings
769
- // repo exists (always on Node) so the engine can write back when a tracker is set.
770
- const resolveWritebackIssue = githubClient
771
- ? async (workspaceId, externalId) => {
772
- const parsed = githubIssuesLogic.parseGitHubIssueExternalId(externalId);
773
- if (!parsed)
774
- return null;
775
- const installation = await githubInstallationRepository.getByWorkspace(workspaceId);
776
- if (!installation)
777
- return null;
778
- return { installationId: installation.installationId, parsed };
779
- }
780
- : undefined;
781
- const issueWritebackProvider = new IssueWritebackService({
439
+ // The GitHub-client-dependent slice of the composition root: the engine's GitHub client, the
440
+ // CI / mergeability / review / doc-quality gate-provider wiring (registered onto
441
+ // `providerRegistry` as a side effect kept BEFORE `applyGateProviders` below), the task-source
442
+ // deps, issue writeback, and the GitHub gate + projection/sync module deps. Lifted into
443
+ // `container-github-deps.ts` (mirroring the Worker's `selectGitHubDeps`) so this composition root
444
+ // stays within the file-size budgetsame reason `container-executor-deps.ts` exists.
445
+ const { githubClient, tasks, fileGitHubIssue, issueWritebackProvider, githubGateDeps, githubModuleDeps, } = selectNodeGitHubDeps({
446
+ config,
447
+ db,
448
+ remoteRepos,
449
+ sourced,
450
+ idGenerator,
451
+ clock,
452
+ appRegistry,
453
+ githubClientOverride: options.githubClient,
454
+ resolveUserGitHubToken,
455
+ gitlabEngineClient,
456
+ providerRegistry,
457
+ resolveRepoTarget,
458
+ githubInstallationRepository,
459
+ repoProjectionRepository,
460
+ blockRepository: repos.blockRepository,
782
461
  trackerSettingsRepository: repos.trackerSettingsRepository,
783
- taskRepository: sourced('taskRepository', (d) => new DrizzleTaskRepository(d)),
784
- fetchImpl: fetch,
785
- ...(githubClient && resolveWritebackIssue
786
- ? {
787
- commentOnGitHubIssue: async (workspaceId, externalId, body) => {
788
- const target = await resolveWritebackIssue(workspaceId, externalId);
789
- if (!target)
790
- return;
791
- await githubClient.comment(target.installationId, { owner: target.parsed.owner, repo: target.parsed.repo }, target.parsed.number, body);
792
- },
793
- closeGitHubIssue: async (workspaceId, externalId) => {
794
- const target = await resolveWritebackIssue(workspaceId, externalId);
795
- if (!target)
796
- return;
797
- await githubClient.closeIssue(target.installationId, { owner: target.parsed.owner, repo: target.parsed.repo }, target.parsed.number);
798
- },
799
- labelGitHubIssue: async (workspaceId, externalId, label) => {
800
- const target = await resolveWritebackIssue(workspaceId, externalId);
801
- if (!target)
802
- return;
803
- await githubClient.applyIssueLabel?.(target.installationId, { owner: target.parsed.owner, repo: target.parsed.repo }, target.parsed.number, label);
804
- },
805
- }
806
- : {}),
807
- ...(tasks.taskConnectionRepository
808
- ? {
809
- resolveJiraConnection: async (workspaceId) => {
810
- const connection = await tasks.taskConnectionRepository.getByWorkspace(workspaceId, 'jira');
811
- const { baseUrl, accountEmail, apiToken } = connection?.credentials ?? {};
812
- if (!baseUrl || !accountEmail || !apiToken)
813
- return null;
814
- return { baseUrl, accountEmail, apiToken };
815
- },
816
- resolveLinearConnection: async (workspaceId) => {
817
- const connection = await tasks.taskConnectionRepository.getByWorkspace(workspaceId, 'linear');
818
- const { apiKey, token } = connection?.credentials ?? {};
819
- return apiKey || token ? { apiKey, token } : null;
820
- },
821
- }
822
- : {}),
462
+ caches: options.caches,
823
463
  });
824
- let githubGateDeps = {};
825
- if (engineVcsClient) {
826
- // The `ci` / `conflicts` gates now live in `@cat-factory/gates`; wire their providers into
827
- // the gate suite instead of onto the engine's CoreDependencies (single-process startup, so
828
- // the deployment-global handles are set once here). Parity with the Worker's selectGitHubDeps.
829
- // These read through `engineVcsClient` (GitHub App or the GitLab fallback), so a GitLab-only
830
- // deployment gates + merges for real too.
831
- wireCiStatusProvider(providerRegistry, new GitHubCiStatusProvider({
832
- githubClient: engineVcsClient,
833
- resolveRepoTarget,
834
- blockRepository: repos.blockRepository,
835
- }));
836
- wireMergeabilityProvider(providerRegistry, new GitHubMergeabilityProvider({
837
- githubClient: engineVcsClient,
838
- resolveRepoTarget,
839
- blockRepository: repos.blockRepository,
840
- }));
841
- wirePullRequestReviewProvider(providerRegistry, new GitHubPullRequestReviewProvider({
842
- githubClient: engineVcsClient,
843
- resolveRepoTarget,
844
- blockRepository: repos.blockRepository,
845
- }));
846
- wireDocQualityProvider(providerRegistry, new GitHubDocQualityProvider({
847
- githubClient: engineVcsClient,
848
- resolveRepoTarget,
849
- blockRepository: repos.blockRepository,
850
- // The gate resolves a workspace-linked template (WS1) for the block's kind, so it checks
851
- // against the SAME sections the doc-writer followed. In db-less mothership mode the writer
852
- // resolves the template through the RPC-proxied documents repo (getRoleLink is run-path
853
- // allow-listed), so the gate MUST use that same repo — not `undefined` — or a doc written
854
- // to the workspace template would be graded against the built-in skeleton (writer/gate drift).
855
- documentRepository: db
856
- ? new DrizzleDocumentRepository(db)
857
- : remoteRepos?.documentRepository,
858
- }));
859
- githubGateDeps = {
860
- // The engine binds a registered custom kind's pre/post-op hooks to a run's repo
861
- // via this checkout-free RepoFiles resolver, composed from the same client +
862
- // repo-target walk the gates/merger use — parity with the Worker. The `repoFiles`
863
- // cache (slice 4) makes the post-op idempotency re-reads a read-through hit.
864
- resolveRunRepoContext: makeResolveRunRepoContext(engineVcsClient, resolveRepoTarget, options.caches?.repoFiles),
865
- // Block-less repo resolver for the environments module's on-demand repo
866
- // validation / config bootstrap (operator names owner+repo).
867
- resolveRepoFilesForCoords: makeResolveRepoFilesForCoords(engineVcsClient, githubInstallationRepository, repoProjectionRepository),
868
- branchUpdater: new GitHubBranchUpdater({
869
- githubClient: engineVcsClient,
870
- resolveRepoTarget,
871
- blockRepository: repos.blockRepository,
872
- }),
873
- pullRequestMerger: new GitHubPullRequestMerger({
874
- githubClient: engineVcsClient,
875
- resolveRepoTarget,
876
- blockRepository: repos.blockRepository,
877
- }),
878
- };
879
- }
880
- // GitHub installation + projections + sync/webhook module: wired when the App is
881
- // configured (a real githubClient), mirroring the Worker's selectGitHubDeps. This
882
- // turns the GitHub read endpoints + the inline webhook/backfill sync on for Node —
883
- // the sync engine (GitHubSyncService) is runtime-neutral, so populating the
884
- // projection repos here makes the inline ingest actually persist (parity with the
885
- // Worker, which fans the same sync through a queue/Workflow). `canCreateRepos` /
886
- // `workflowsGranted` come from the App registry when present (advisory).
887
- const githubModuleDeps = config.github.enabled && githubClient
888
- ? {
889
- githubClient,
890
- githubInstallationRepository,
891
- repoProjectionRepository,
892
- // The five GitHub projection repos share one shape (remote in mothership mode, else
893
- // Drizzle over `db`), routed through the shared `sourced` helper.
894
- branchProjectionRepository: sourced('branchProjectionRepository', (d) => new DrizzleBranchProjectionRepository(d)),
895
- pullRequestProjectionRepository: sourced('pullRequestProjectionRepository', (d) => new DrizzlePullRequestProjectionRepository(d)),
896
- issueProjectionRepository: sourced('issueProjectionRepository', (d) => new DrizzleIssueProjectionRepository(d)),
897
- commitProjectionRepository: sourced('commitProjectionRepository', (d) => new DrizzleCommitProjectionRepository(d)),
898
- checkRunProjectionRepository: sourced('checkRunProjectionRepository', (d) => new DrizzleCheckRunProjectionRepository(d)),
899
- // Per-user PAT-reachable repo projection (picker expansion + redaction); Postgres-only,
900
- // so absent in a no-DB mothership node (the picker keeps its App-only behaviour there).
901
- userRepoAccessRepository: db ? new DrizzleUserRepoAccessRepository(db) : undefined,
902
- webhookVerifier: new WebCryptoWebhookVerifier(config.github.webhookSecret),
903
- // Bound the initial backfill to the commit retention horizon (0 = full).
904
- commitBackfillHorizonMs: config.retention.commitMs || undefined,
905
- ...(appRegistry
906
- ? {
907
- // Privileged App tier (ADR 0005): when configured, its client backs the
908
- // create-repo endpoint; `canCreateRepos` flags a connection whose
909
- // installation is owned by the privileged App. Absent → repo creation
910
- // stays the manual flow (parity with the Worker's selectGitHubDeps).
911
- repoProvisioningClient: config.github.privilegedApp
912
- ? new FetchGitHubProvisioningClient({
913
- registry: appRegistry,
914
- apiBase: config.github.apiBase,
915
- })
916
- : undefined,
917
- canCreateRepos: (installation) => appRegistry.canCreateRepos(installation),
918
- workflowsGranted: async (installation) => {
919
- const perms = await appRegistry.installationPermissions(installation.installationId);
920
- return perms.workflows === 'write';
921
- },
922
- }
923
- : {}),
924
- }
925
- : {};
926
- // Repo-bootstrap: the reference-architecture library + the bootstrap runs (stored as
927
- // kind='bootstrap' rows of agent_runs). The repos are wired unconditionally (the
928
- // module + ref-arch CRUD then work like the Worker); the container-dispatching
929
- // `repoBootstrapper` wires only when its prerequisites are met (transport + proxy +
930
- // token + GitHub client) — the same token source the container executor uses.
931
- const bootstrapJobRepository = sourced('bootstrapJobRepository', (d) => new DrizzleBootstrapJobRepository(d));
932
- const bootstrapMintInstallationToken = options.mintInstallationToken ??
933
- (appRegistry ? (id) => appRegistry.installationToken(id) : undefined);
934
- const repoBootstrapper = selectNodeRepoBootstrapper({
464
+ // Repo-bootstrap: the reference-architecture library + the container-dispatching
465
+ // `repoBootstrapper`, lifted into `container-transport-deps.ts` to keep this root within budget.
466
+ const { bootstrapJobRepository, bootstrapMintInstallationToken, repoBootstrapper } = buildNodeBootstrapper({
935
467
  env,
936
468
  config,
469
+ sourced,
937
470
  resolveTransport,
938
- installationRepository: githubInstallationRepository,
939
- bootstrapJobRepository,
940
- repoRepository: repoProjectionRepository,
941
- ...(options.caches?.repoProjection
942
- ? { repoProjectionCache: options.caches.repoProjection }
943
- : {}),
471
+ githubInstallationRepository,
472
+ repoProjectionRepository,
473
+ appRegistry,
944
474
  githubClient,
945
- mintInstallationToken: bootstrapMintInstallationToken,
946
- ...(resolvePackageRegistries ? { resolvePackageRegistries } : {}),
475
+ mintInstallationToken: options.mintInstallationToken,
476
+ resolvePackageRegistries,
477
+ caches: options.caches,
947
478
  });
948
- // Real-time push + notification delivery. When a realtime hub is wired (start()), the
949
- // engine pushes execution/board/notification events to subscribed browsers via the
950
- // NodeEventPublisher, decorated with FanOutEventPublisher so a shared service's live
951
- // events reach EVERY board that mounts it (parity with the Worker's selectEventPublisher).
952
- // The in-app push is also a notification channel, composed alongside Slack (when
953
- // enabled) so a raised notification both lands in the inbox live AND fans to Slack.
954
- const slackDeps = selectNodeSlackDeps(config, repos, sourced);
955
- const executionEventPublisher = options.realtimeSink
956
- ? new FanOutEventPublisher(new NodeEventPublisher(options.realtimeSink), {
957
- workspaceMountRepository: repos.workspaceMountRepository,
958
- })
959
- : undefined;
960
- // Optionally wrap the executor with the consensus mechanism (CONSENSUS_ENABLED). Off ⇒
961
- // the standard composite, unchanged. Registers the capability traits + routes
962
- // consensus-enabled steps through a multi-model process, persisting + pushing the
963
- // transcript (same hub as run/board events).
964
- const agentExecutor = isTruthy(env.CONSENSUS_ENABLED)
965
- ? (registerConsensusTraits(agentKindRegistry),
966
- new ConsensusAgentExecutor({
967
- standard: standardAgentExecutor,
968
- modelProviderResolver,
969
- agentRouting: config.agents.routing,
970
- resolveBlockModel: config.agents.resolveBlockModel,
971
- resolveWorkspaceModelDefault,
972
- // Consensus runs its participants INLINE, so in local mode keep an ambient-eligible
973
- // subscription harness ref (served via the CLI) instead of degrading it; undefined on
974
- // stock Node/Worker, where such a ref degrades to the routing default as before.
975
- ...(config.agents.inlineHarnessRef ? { runsInline: config.agents.inlineHarnessRef } : {}),
976
- sessionRepository: repos.consensusSessionRepository,
977
- ...(executionEventPublisher ? { eventPublisher: executionEventPublisher } : {}),
978
- agentKindRegistry,
979
- }))
980
- : standardAgentExecutor;
981
- const notificationChannels = [];
982
- if (executionEventPublisher)
983
- notificationChannels.push(new InAppNotificationChannel(executionEventPublisher));
984
- if (slackDeps.notificationChannel)
985
- notificationChannels.push(slackDeps.notificationChannel);
986
- const notificationChannel = notificationChannels.length === 0
987
- ? undefined
988
- : notificationChannels.length === 1
989
- ? notificationChannels[0]
990
- : new CompositeNotificationChannel(notificationChannels);
991
- // Observability post-release-health: wire the gate + the release-health settings module
992
- // when enabled (+ ENCRYPTION_KEY), mirroring the Worker's `selectReleaseHealthDeps`. Off →
993
- // the `post-release-health` gate is a pass-through and the module isn't assembled.
994
- const releaseHealthDeps = {};
995
- if (config.releaseHealth.enabled && config.releaseHealth.encryptionKey) {
996
- const observabilitySecretCipher = new WebCryptoSecretCipher({
997
- masterKeyBase64: config.releaseHealth.encryptionKey,
998
- info: OBSERVABILITY_CIPHER_INFO,
999
- });
1000
- releaseHealthDeps.observabilityConnectionRepository = repos.observabilityConnectionRepository;
1001
- releaseHealthDeps.releaseHealthConfigRepository = repos.releaseHealthConfigRepository;
1002
- releaseHealthDeps.observabilitySecretCipher = observabilitySecretCipher;
1003
- // The post-release-health gate + on-call escalation now live in `@cat-factory/gates`; wire
1004
- // their providers into the gate suite. The observability repos/cipher above stay on
1005
- // CoreDependencies — they power the management API (ReleaseHealthService), not the gate.
1006
- wireReleaseHealthProvider(providerRegistry, new RegistryReleaseHealthProvider({
1007
- observabilityConnectionRepository: repos.observabilityConnectionRepository,
1008
- releaseHealthConfigRepository: repos.releaseHealthConfigRepository,
1009
- blockRepository: repos.blockRepository,
1010
- secretCipher: observabilitySecretCipher,
1011
- registry: defaultObservabilityRegistry,
1012
- }));
1013
- }
1014
- // Per-workspace incident-enrichment (PagerDuty + incident.io): credentials moved out of
1015
- // env into a sealed per-workspace row, resolved + decrypted at enrichment time. Wired
1016
- // whenever the shared ENCRYPTION_KEY is present (independent of the release-health gate).
1017
- const encryptionKey = env.ENCRYPTION_KEY?.trim();
1018
- // Per-workspace private package registries (npm private orgs, GitHub Packages): the
1019
- // management API over the same repo + cipher the dispatch resolver above rides.
1020
- const packageRegistryDeps = packageRegistrySecretCipher
1021
- ? {
1022
- packageRegistryConnectionRepository: repos.packageRegistryConnectionRepository,
1023
- packageRegistrySecretCipher,
1024
- }
1025
- : {};
1026
- const incidentEnrichmentDeps = {};
1027
- if (encryptionKey) {
1028
- const incidentEnrichmentSecretCipher = new WebCryptoSecretCipher({
1029
- masterKeyBase64: encryptionKey,
1030
- info: INCIDENT_ENRICHMENT_CIPHER_INFO,
1031
- });
1032
- incidentEnrichmentDeps.incidentEnrichmentConnectionRepository =
1033
- repos.incidentEnrichmentConnectionRepository;
1034
- incidentEnrichmentDeps.incidentEnrichmentSecretCipher = incidentEnrichmentSecretCipher;
1035
- // The on-call enrichment provider now lives in `@cat-factory/gates`; wire the
1036
- // workspace-backed provider into the gate suite. The connection repo + cipher above
1037
- // stay on CoreDependencies to power the management API.
1038
- wireIncidentEnrichment(providerRegistry, new WorkspaceIncidentEnrichmentProvider({
1039
- incidentEnrichmentConnectionRepository: repos.incidentEnrichmentConnectionRepository,
1040
- secretCipher: incidentEnrichmentSecretCipher,
1041
- }));
1042
- }
1043
- // Per-account deployment settings (Slack OAuth + web-search keys + content-storage), built
1044
- // once so the service's short-TTL cache spans requests; the Slack OAuth + content-storage
1045
- // resolvers derive from it.
1046
- const accountSettings = encryptionKey
1047
- ? new AccountSettingsService({
1048
- accountSettingsRepository: repos.accountSettingsRepository,
1049
- secretCipher: new WebCryptoSecretCipher({
1050
- masterKeyBase64: encryptionKey,
1051
- info: ACCOUNT_SETTINGS_CIPHER_INFO,
1052
- }),
1053
- clock,
1054
- contentStorageCapability,
1055
- ...(options.caches ? { settingsCache: options.caches.accountSettings } : {}),
1056
- })
1057
- : undefined;
1058
- // Resolve the binary-artifact store for a workspace's account from its content-storage
1059
- // settings (the blob backend is per-account; the metadata is the shared Postgres store).
1060
- // Without `accountSettings` (no encryption key) there is no per-account override, so every
1061
- // workspace falls back to the runtime default — which on Node is `off`, so the resolver then
1062
- // returns null and the controllers 503 / the gate passes through. Caches per account, so a
1063
- // backend switch rebuilds and the many workspaces under one account share a store.
1064
- const resolveBinaryArtifactStore = makeResolveBinaryArtifactStore({
1065
- accountSettings,
1066
- accountOf: (workspaceId) => repos.workspaceRepository.accountOf(workspaceId),
1067
- metadata: repos.binaryArtifactMetadataStore,
479
+ // Real-time event publisher + notification channel + optional consensus wrap, lifted into
480
+ // `container-realtime-deps.ts` to keep this root within the file-size budget.
481
+ const { slackDeps, executionEventPublisher, agentExecutor, notificationChannel } = buildNodeRealtimeDeps({
482
+ env,
483
+ config,
484
+ repos,
485
+ sourced,
486
+ realtimeSink: options.realtimeSink,
487
+ standardAgentExecutor,
488
+ modelProviderResolver,
489
+ resolveWorkspaceModelDefault,
490
+ agentKindRegistry,
491
+ });
492
+ // Per-account settings + binary-artifact storage + the observability/incident gate-provider
493
+ // wiring (onto `providerRegistry`, before `applyGateProviders` below), plus the package-registry
494
+ // management deps, lifted into `container-account-deps.ts` to keep this root within budget.
495
+ const { releaseHealthDeps, packageRegistryDeps, incidentEnrichmentDeps, accountSettings, resolveBinaryArtifactStore, } = buildNodeAccountDeps({
496
+ env,
497
+ config,
498
+ db,
499
+ repos,
1068
500
  idGenerator,
1069
501
  clock,
1070
- buildBlobBackend: buildNodeBlobBackend,
1071
- defaultBackend: contentStorageCapability.defaultBackend,
1072
- logger,
502
+ providerRegistry,
503
+ packageRegistrySecretCipher,
504
+ contentStorageDefaultBackend: options.contentStorageDefaultBackend,
505
+ caches: options.caches,
1073
506
  });
1074
507
  // Runner-pool URL/host guard, scoped to its own config (independent of the environment
1075
508
  // allow-list); absent => strict public-https.
@@ -1636,37 +1069,6 @@ export function buildNodeContainer(options) {
1636
1069
  * No registered providers → `{ deps: {} }` and both the tasks module and the Jira
1637
1070
  * tracker stay off (the encryption key is guaranteed present by `loadTasksConfig`).
1638
1071
  */
1639
- function selectNodeTasksDeps(config, db, githubClient, installations) {
1640
- if (!config.tasks.enabled || !config.tasks.encryptionKey)
1641
- return { deps: {} };
1642
- // Jira and Linear are always registered (their credentials are per-workspace, entered in the UI).
1643
- const providers = [new JiraProvider(), new LinearTaskProvider()];
1644
- // GitHub Issues reuse the workspace's installed GitHub App, so this provider is
1645
- // wired whenever a GitHub client is available (the App is configured) — it has no
1646
- // credentials of its own and resolves the installation per issue. Mirrors the
1647
- // Cloudflare facade's `config.github.enabled` gate (see CLAUDE.md parity rule).
1648
- // Whether a workspace OFFERS a source is the per-workspace toggle
1649
- // (task_source_settings), not a deployment env gate.
1650
- if (githubClient) {
1651
- providers.push(new GitHubIssuesProvider({ githubClient, installations }));
1652
- }
1653
- const taskConnectionRepository = new DrizzleTaskConnectionRepository(db,
1654
- // Source credentials are encrypted at rest under a tasks-scoped HKDF info (the
1655
- // same domain the Cloudflare facade uses), keyed by the shared ENCRYPTION_KEY.
1656
- new WebCryptoSecretCipher({
1657
- masterKeyBase64: config.tasks.encryptionKey,
1658
- info: 'cat-factory:tasks',
1659
- }));
1660
- return {
1661
- deps: {
1662
- taskSourceProviders: providers,
1663
- taskConnectionRepository,
1664
- taskSourceSettingsRepository: new DrizzleTaskSourceSettingsRepository(db),
1665
- taskRepository: new DrizzleTaskRepository(db),
1666
- },
1667
- taskConnectionRepository,
1668
- };
1669
- }
1670
1072
  /**
1671
1073
  * Wire the document-source integration for the Node facade, mirroring the Worker's
1672
1074
  * `selectDocumentsDeps`: the shared `@cat-factory/integrations` provider shells