diff --git a/src/node/services/coreServices.ts b/src/node/services/coreServices.ts index 73f251c1390..848a0818a0b 100644 --- a/src/node/services/coreServices.ts +++ b/src/node/services/coreServices.ts @@ -2,37 +2,39 @@ * Core service graph shared by `xum run`/`xum workflow` (CLI) and * `ServiceContainer` (desktop). * - * `buildCoreGraph` is the imperative construction body. Both roots reach it - * through the Effect Layer graph — `CoreProjectionLive` in `di/layers/core.ts` - * wraps it as a single coarse layer (Effect migration Phase 11) — so the roots - * are `createCoreServices` (`./coreServicesRoot.ts`, CLI) and `AppLive` - * (`di/layers/app.ts`, desktop). Construction order and wiring here are the - * behavioral contract the per-service layers of the next phase must replay. + * The graph is built by the Effect Layer graph in `di/layers/core.ts` + * (`CoreLive`, Effect migration Phase 11): the head — every service up to and + * including `AIService` — as staged per-service layers, and the remainder + * through `buildCoreTail` below, today's imperative construction of the + * remaining services plus all setter/listener wiring, unchanged in order. The + * roots are `createCoreServices` (`./coreServicesRoot.ts`, CLI) and `AppLive` + * (`di/layers/app.ts`, desktop). The tail's construction order and wiring are + * the behavioral contract the next PR's stages and wiring layer must replay. */ -import * as os from "os"; import * as path from "path"; -import type { Config } from "@/node/config"; -import { +import type { + Config, + ConfigStores, FileLeaseManager, ProvidersConfigStore, SecretsStore, WorkspaceSessionLocator, } from "@/node/config"; -import { HistoryService } from "@/node/services/historyService"; -import { IdleDispatcher } from "@/node/services/idleDispatcher"; -import { InitStateManager } from "@/node/services/initStateManager"; -import { ProviderService } from "@/node/services/providerService"; -import { AIService } from "@/node/services/aiService"; +import type { HistoryService } from "@/node/services/historyService"; +import type { IdleDispatcher } from "@/node/services/idleDispatcher"; +import type { InitStateManager } from "@/node/services/initStateManager"; +import type { ProviderService } from "@/node/services/providerService"; +import type { AIService } from "@/node/services/aiService"; import type { TurnRequestBuilderBindings } from "@/node/services/turnRequestBuilder"; -import { StreamManager } from "@/node/services/streamManager"; -import { BackgroundProcessManager } from "@/node/services/backgroundProcessManager"; -import { SessionUsageService } from "@/node/services/sessionUsageService"; +import type { StreamManager } from "@/node/services/streamManager"; +import type { BackgroundProcessManager } from "@/node/services/backgroundProcessManager"; +import type { SessionUsageService } from "@/node/services/sessionUsageService"; import { log } from "@/node/services/log"; -import { +import type { WorkspaceGoalService, - type GoalLifecycleAnalyticsSink, - type WorkspaceGoalServiceOptions, + GoalLifecycleAnalyticsSink, + WorkspaceGoalServiceOptions, } from "@/node/services/workspaceGoalService"; import { EXPERIMENT_IDS } from "@/common/constants/experiments"; import { STAGING_DIR_NAME, readMutationEpochToken } from "@/node/services/agentPlugins/journals"; @@ -45,18 +47,18 @@ import { MCPServerManager, type MCPServerManagerOptions } from "@/node/services/ import { mergeMultiProjectSecrets } from "@/node/services/utils/multiProjectSecrets"; import { isMultiProject } from "@/common/utils/multiProject"; import { secretsToRecord } from "@/common/types/secrets"; -import { ExtensionMetadataService } from "@/node/services/ExtensionMetadataService"; +import type { ExtensionMetadataService } from "@/node/services/ExtensionMetadataService"; import { WorkspaceService } from "@/node/services/workspaceService"; import { TaskService } from "@/node/services/taskService"; import { WorkspaceTurnManager } from "@/node/services/workspaceTurnManager"; -import { TerminalAttentionStore } from "@/node/services/terminalAttentionStore"; -import { WorkspaceMcpOverridesService } from "@/node/services/workspaceMcpOverridesService"; +import type { TerminalAttentionStore } from "@/node/services/terminalAttentionStore"; +import type { WorkspaceMcpOverridesService } from "@/node/services/workspaceMcpOverridesService"; import type { PolicyService } from "@/node/services/policyService"; import type { TelemetryService } from "@/node/services/telemetryService"; import type { ExperimentsService } from "@/node/services/experimentsService"; -import { MemoryService } from "@/node/services/memoryService"; +import type { MemoryService } from "@/node/services/memoryService"; import { MemoryConsolidationService } from "@/node/services/memoryConsolidationService"; -import { MemoryMetaService } from "@/node/services/memoryMeta"; +import type { MemoryMetaService } from "@/node/services/memoryMeta"; import type { SessionTimingService } from "@/node/services/sessionTimingService"; import type { DevToolsService } from "@/node/services/devToolsService"; @@ -70,12 +72,6 @@ export interface CoreServicesOptions { /** Overrides config for MCPConfigService; CLI passes its persistent realConfig. */ mcpConfig?: Config; mcpServerManagerOptions?: MCPServerManagerOptions; - workspaceMcpOverridesService?: WorkspaceMcpOverridesService; - /** - * Layer-provided instance (desktop `ServiceContainer` builds it from its - * Effect graph, see `di/layers/core.ts`); default-constructed when absent. - */ - memoryMetaService?: MemoryMetaService; /** Optional cross-cutting services (desktop creates before core services). */ policyService?: PolicyService; telemetryService?: TelemetryService; @@ -86,6 +82,14 @@ export interface CoreServicesOptions { devToolsService?: DevToolsService; } +/** + * The graph's inputs other than the stores (`CoreOptionsTag` in + * `di/layers/core.ts`). The optional cross-cutting services stay optional here + * (present in the desktop graph, absent in CLI roots), so core constructors + * see exactly the arguments they saw before. + */ +export type CoreOptions = Omit; + export interface CoreServices { historyService: HistoryService; initStateManager: InitStateManager; @@ -113,31 +117,71 @@ export interface CoreServices { turnRequestBuilderBindings: TurnRequestBuilderBindings; } -export function buildCoreGraph(opts: CoreServicesOptions): CoreServices { - const { config, extensionMetadataPath } = opts; +/** The layer-built head of the graph (stages S1–S3 in `di/layers/core.ts`) plus what the tail reads. */ +export interface CoreGraphHead extends Pick< + CoreServices, + | "historyService" + | "initStateManager" + | "backgroundProcessManager" + | "sessionUsageService" + | "extensionMetadata" + | "workspaceGoalService" + | "idleDispatcher" + | "streamManager" + | "aiService" + | "memoryService" + | "memoryMetaService" + | "turnRequestBuilderBindings" +> { + config: Config; + secretsStore: SecretsStore; + providersConfigStore: ProvidersConfigStore; + options: CoreOptions; + workspaceMcpOverridesService: WorkspaceMcpOverridesService; + terminalAttentionStore: TerminalAttentionStore; +} + +/** The services the tail constructs (the rest of `CoreServices` comes from the head). */ +export type CoreGraphTail = Pick< + CoreServices, + | "memoryConsolidationService" + | "mcpConfigService" + | "mcpServerManager" + | "workspaceService" + | "taskService" + | "workspaceTurnManager" +>; - const sessionLocator = opts.sessionLocator ?? new WorkspaceSessionLocator(config.rootDir); - const historyService = new HistoryService(sessionLocator); - const initStateManager = new InitStateManager(config); - const providersConfigStore = - opts.providersConfigStore ?? new ProvidersConfigStore(config.rootDir); - const secretsStore = opts.secretsStore ?? new SecretsStore(config.rootDir); - const fileLeaseManager = opts.fileLeaseManager ?? new FileLeaseManager(config.rootDir); - const providerService = new ProviderService( +/** + * Today's imperative construction of the services after `AIService`, and all + * of the graph's setter/listener wiring, in the original order. Transitional: + * the next PR replays this as staged layers + a wiring layer; until then the + * wiring lines that only need head services (the registration probe, the + * memory binding) simply run first here — nothing constructed in between + * observes them (verified per constructor, see the PR's I6 audit). + */ +export function buildCoreTail(head: CoreGraphHead): CoreGraphTail { + const { config, - opts.policyService, + secretsStore, providersConfigStore, - fileLeaseManager - ); - const backgroundProcessManager = new BackgroundProcessManager( - path.join(os.tmpdir(), "mux-bashes") - ); - // Providers config accessor enables mappedToModel alias resolution for - // headless usage pricing (status generation and memory sweeps). - const sessionUsageService = new SessionUsageService(config, historyService, () => - providerService.getConfig() - ); - const extensionMetadata = new ExtensionMetadataService(extensionMetadataPath); + options: opts, + historyService, + initStateManager, + backgroundProcessManager, + sessionUsageService, + extensionMetadata, + workspaceGoalService, + idleDispatcher, + streamManager, + aiService, + memoryService, + memoryMetaService, + turnRequestBuilderBindings, + workspaceMcpOverridesService, + terminalAttentionStore, + } = head; + // Write tombstones are process-local removal knowledge; the shared config // is the authority (with XUM_ALLOW_MULTIPLE_INSTANCES a downgraded backend // can legitimately re-register a deterministic legacy id this process @@ -177,49 +221,6 @@ export function buildCoreGraph(opts: CoreServicesOptions): CoreServices { ).some((metadata) => metadata.id === workspaceId); return registered || legacyAliasIds.has(workspaceId); }); - const workspaceGoalService = new WorkspaceGoalService( - config, - historyService, - extensionMetadata, - opts.analyticsService, - opts.goalServiceOptions, - providersConfigStore - ); - - // Default-construct when the caller (CLI) does not pass one: workspace MCP - // override reads AND registration-time plugin-override sanitization must - // work in every process that can register workspaces, not just desktop. - const workspaceMcpOverridesService = - opts.workspaceMcpOverridesService ?? new WorkspaceMcpOverridesService(config); - - const turnRequestBuilderBindings: TurnRequestBuilderBindings = {}; - const streamManager = new StreamManager(historyService, sessionUsageService, () => - providerService.getConfig() - ); - - const aiService = new AIService( - config, - historyService, - initStateManager, - providerService, - backgroundProcessManager, - sessionUsageService, - workspaceMcpOverridesService, - opts.policyService, - opts.telemetryService, - opts.devToolsService, - opts.experimentsService, - streamManager, - turnRequestBuilderBindings, - providersConfigStore, - secretsStore - ); - - // Agent memory (memory experiment): scope roots derive from Config (xum home - // + session dirs); experiment gating happens per stream in AIService. - // Host-local sidecar for user-owned memory metadata (pins + usage stats). - const memoryMetaService = opts.memoryMetaService ?? new MemoryMetaService(config.rootDir); - const memoryService = new MemoryService(config, memoryMetaService); turnRequestBuilderBindings.memoryService = memoryService; // Background dream consolidation (memory-consolidation experiment). Without @@ -337,7 +338,6 @@ export function buildCoreGraph(opts: CoreServicesOptions): CoreServices { } }); - const terminalAttentionStore = new TerminalAttentionStore(config); const taskService = new TaskService( config, historyService, @@ -367,9 +367,7 @@ export function buildCoreGraph(opts: CoreServicesOptions): CoreServices { // Goal continuation bridge lives at the core scope so every codepath that // uses the core graph (xum run, xum server via ServiceContainer, tests) // gets a working dispatcher. Without this, requestContinuationAfterStreamEnd - // is a no-op and the auto-continuation loop never fires. The dispatcher is - // also exposed so ServiceContainer can share it with HeartbeatService. - const idleDispatcher = new IdleDispatcher(); + // is a no-op and the auto-continuation loop never fires. workspaceGoalService.registerGoalContinuationConsumer(idleDispatcher, { hasActiveDescendantTasks: (workspaceId) => taskService.hasActiveDescendantAgentTasksForWorkspace(workspaceId), @@ -380,24 +378,11 @@ export function buildCoreGraph(opts: CoreServicesOptions): CoreServices { }); return { - historyService, - initStateManager, - providerService, - backgroundProcessManager, - sessionUsageService, - workspaceGoalService, - idleDispatcher, - aiService, - streamManager, + memoryConsolidationService, mcpConfigService, mcpServerManager, - extensionMetadata, workspaceService, taskService, workspaceTurnManager, - memoryService, - memoryMetaService, - memoryConsolidationService, - turnRequestBuilderBindings, }; } diff --git a/src/node/services/coreServicesRoot.test.ts b/src/node/services/coreServicesRoot.test.ts index c469209e166..364f53c78b3 100644 --- a/src/node/services/coreServicesRoot.test.ts +++ b/src/node/services/coreServicesRoot.test.ts @@ -7,9 +7,13 @@ import { createConfigStores, type ConfigStores } from "@/node/config"; import * as coreServices from "@/node/services/coreServices"; import type { CoreServices } from "@/node/services/coreServices"; import { AppFiberScopeTag } from "@/node/services/di/appFiberScope"; -import { closeScopeBounded, disposeAppRuntime } from "@/node/services/di/appRuntime"; +import { + closeScopeBounded, + disposeAppRuntime, + makeAppRuntime, +} from "@/node/services/di/appRuntime"; import { EffectRunnerTag } from "@/node/services/di/effectRunner"; -import { CoreOptionsTag } from "@/node/services/di/layers/core"; +import { CoreLive, CoreOptionsTag } from "@/node/services/di/layers/core"; import { AI, BackgroundProcessManagerTag, @@ -31,13 +35,16 @@ import { SessionUsage, StreamManagerTag, Task, + TerminalAttentionStoreTag, TurnRequestBuilderBindingsTag, Workspace, WorkspaceGoal, + WorkspaceMcpOverrides, WorkspaceTurnManagerTag, type CoreRootTags, type CoreTags, } from "@/node/services/di/tags"; +import type { TurnRequestBuilderBindings } from "@/node/services/turnRequestBuilder"; import { createCoreServices, type CoreServicesRoot } from "./coreServicesRoot"; /** @@ -151,7 +158,7 @@ describe("createCoreServices", () => { }); it("surfaces a throwing graph body as a synchronous throw", () => { - const buildSpy = spyOn(coreServices, "buildCoreGraph").mockImplementation(() => { + const buildSpy = spyOn(coreServices, "buildCoreTail").mockImplementation(() => { throw new Error("core boom"); }); try { @@ -167,4 +174,89 @@ describe("createCoreServices", () => { buildSpy.mockRestore(); } }); + + it("provides the CLI defaults for the desktop-built inputs and the graph-internal store", () => { + root = createCoreServices({ + ...stores, + extensionMetadataPath: path.join(tempDir, "extensionMetadata.json"), + }); + + expect(root.runtime.get(WorkspaceMcpOverrides)).toBeDefined(); + expect(root.runtime.get(TerminalAttentionStoreTag)).toBeDefined(); + expect(root.memoryMetaService).toBe(root.runtime.get(MemoryMeta)); + }); + + it("wires the graph like the construction body did (each line has an observable effect)", async () => { + root = createCoreServices({ + ...stores, + extensionMetadataPath: path.join(tempDir, "extensionMetadata.json"), + }); + + // turnRequestBuilderBindings: every collaborator the core wiring binds + // (the desktop container adds analyticsService and the OAuth services). + const expectedBindings: Pick< + Required, + | "memoryService" + | "mcpServerManager" + | "workspaceHeartbeatService" + | "workflowResultContinuationSender" + | "taskService" + | "workspaceTurnManager" + > = { + memoryService: root.memoryService, + mcpServerManager: root.mcpServerManager, + workspaceHeartbeatService: root.workspaceService, + workflowResultContinuationSender: root.workspaceService, + taskService: root.taskService, + workspaceTurnManager: root.workspaceTurnManager, + }; + for (const [key, expected] of Object.entries(expectedBindings)) { + expect(root.turnRequestBuilderBindings[key as keyof typeof expectedBindings]).toBe(expected); + } + const emitSpy = spyOn(root.workspaceService, "emitWorkflowRunActivity").mockResolvedValue( + undefined + ); + const event = { workspaceId: "ws-1", runId: "run-1", status: "completed" as const }; + await root.turnRequestBuilderBindings.onWorkflowRunStatusChanged?.(event); + expect(emitSpy).toHaveBeenCalledWith(event); + + // Goal continuation consumer registered on the shared idle dispatcher: the + // goal service refuses a second registration. + const { workspaceGoalService, idleDispatcher } = root; + expect(() => + workspaceGoalService.registerGoalContinuationConsumer(idleDispatcher, { + hasActiveDescendantTasks: () => false, + getRuntimeState: () => { + throw new Error("unused"); + }, + executeGoalContinuation: () => Promise.resolve(false), + getKickoffSendOptions: () => { + throw new Error("unused"); + }, + }) + ).toThrow("already registered"); + + // streamManager knows the MCP manager (lease acquire/release per stream). + const streamManagerInternals = root.streamManager as unknown as { + mcpServerManager?: unknown; + }; + expect(streamManagerInternals.mcpServerManager).toBe(root.mcpServerManager); + + // Registration probe installed on the extension metadata store and bound to + // this config: an unknown id is reported as not registered. + const extensionMetadataInternals = root.extensionMetadata as unknown as { + registrationProbe: ((workspaceId: string) => Promise) | null; + }; + expect(extensionMetadataInternals.registrationProbe).not.toBeNull(); + const probe = extensionMetadataInternals.registrationProbe!; + expect(await probe("no-such-workspace")).toBe(false); + }); + + it("rejects a core graph whose inputs are missing at compile time", () => { + // `makeAppRuntime` accepts only fully provided graphs (R = never); `CoreLive` + // alone still requires its inputs (stores, options, MemoryMeta, overrides). + // @ts-expect-error CoreLive requires CoreInputTags + const build = () => makeAppRuntime(CoreLive); + expect(typeof build).toBe("function"); + }); }); diff --git a/src/node/services/di/layers/app.ts b/src/node/services/di/layers/app.ts index a7a9916d4d6..2ef424fc95c 100644 --- a/src/node/services/di/layers/app.ts +++ b/src/node/services/di/layers/app.ts @@ -3,7 +3,7 @@ import type { ConfigStores } from "@/node/config"; import { AppFiberScopeLive } from "@/node/services/di/appFiberScope"; import { EffectRunnerLive } from "@/node/services/di/effectRunner"; import type { AppTags } from "@/node/services/di/tags"; -import { CoreProjectionLive, MemoryMetaLive } from "./core"; +import { CoreLive, MemoryMetaLive } from "./core"; import { CoreOptionsFromDesktopLive, CrossCuttingLive } from "./desktop"; import { StoresLive } from "./stores"; @@ -20,13 +20,14 @@ import { StoresLive } from "./stores"; * captures its building context, so placing it there keeps that context to the * stores plus references (`Clock`, …). Above them the graph replays the * constructor's former order: memory metadata, the cross-cutting services, the - * core options derived from them, then the core graph. + * core options derived from them, then the staged core graph (which reads + * `MemoryMeta` and `WorkspaceMcpOverrides` from those layers directly). */ export function AppLive(stores: ConfigStores): Layer.Layer { const runtimeSeams = AppFiberScopeLive.pipe( Layer.provideMerge(EffectRunnerLive.pipe(Layer.provideMerge(StoresLive(stores)))) ); - return CoreProjectionLive.pipe( + return CoreLive.pipe( Layer.provideMerge(CoreOptionsFromDesktopLive), Layer.provideMerge(CrossCuttingLive), Layer.provideMerge(MemoryMetaLive), diff --git a/src/node/services/di/layers/core.ts b/src/node/services/di/layers/core.ts index 288dbdaa4ef..4a12f7c065f 100644 --- a/src/node/services/di/layers/core.ts +++ b/src/node/services/di/layers/core.ts @@ -1,7 +1,11 @@ +import * as os from "os"; +import * as path from "path"; import { Context, Effect, Layer } from "effect"; -import type { ConfigStores } from "@/node/config"; +import { AIService } from "@/node/services/aiService"; +import { BackgroundProcessManager } from "@/node/services/backgroundProcessManager"; import { - buildCoreGraph, + buildCoreTail, + type CoreOptions, type CoreServices, type CoreServicesOptions, } from "@/node/services/coreServices"; @@ -28,23 +32,60 @@ import { SessionUsage, StreamManagerTag, Task, + TerminalAttentionStoreTag, TurnRequestBuilderBindingsTag, Workspace, WorkspaceGoal, + WorkspaceMcpOverrides, WorkspaceTurnManagerTag, type CoreRootTags, type CoreTags, type StoreTags, } from "@/node/services/di/tags"; +import { ExtensionMetadataService } from "@/node/services/ExtensionMetadataService"; +import { HistoryService } from "@/node/services/historyService"; +import { IdleDispatcher } from "@/node/services/idleDispatcher"; +import { InitStateManager } from "@/node/services/initStateManager"; import { MemoryMetaService } from "@/node/services/memoryMeta"; +import { MemoryService } from "@/node/services/memoryService"; +import { ProviderService } from "@/node/services/providerService"; +import { SessionUsageService } from "@/node/services/sessionUsageService"; +import { StreamManager } from "@/node/services/streamManager"; +import { TerminalAttentionStore } from "@/node/services/terminalAttentionStore"; +import type { TurnRequestBuilderBindings } from "@/node/services/turnRequestBuilder"; +import { WorkspaceGoalService } from "@/node/services/workspaceGoalService"; +import { WorkspaceMcpOverridesService } from "@/node/services/workspaceMcpOverridesService"; import { StoresFromCoreOptionsLive } from "./stores"; /** * Layers for the core service graph shared by the desktop/server app and the - * headless CLI roots. Bodies are thin adapters around the existing constructors - * and must stay synchronous (see the DI contract in `../appRuntime.ts`). + * headless CLI roots (Effect migration Phase 11). + * + * Every `*Live` below is a thin adapter around an existing constructor with + * its existing argument list; the bodies stay synchronous (DI contract in + * `../appRuntime.ts`) and register no finalizers. Dependencies are expressed + * only through what a body yields, and build order only through the explicit + * stages at the bottom of this file (`Layer.provideMerge` between stages; + * `Layer.mergeAll` for true siblings within a stage — siblings may build in + * any order, so nothing may rely on sibling order). The head of the graph + * (S1–S3, through `AIService`) is staged here; the remainder and the + * setter/listener wiring still run imperatively (`buildCoreTail`) behind a + * projection layer until the next PR peels them too. */ +/** The core graph's inputs other than the stores (`CoreOptions` in coreServices.ts). */ +export class CoreOptionsTag extends Context.Service()( + "xum/CoreOptions" +) {} + +/** + * What the roots must provide beneath `CoreLive`: the stores, the options, + * and the two always-present collaborators the desktop builds elsewhere + * (`MemoryMetaLive`; `WorkspaceMcpOverrides` from `CrossCuttingLive`). CLI + * roots supply the defaults (`MemoryMetaLive`, `WorkspaceMcpOverridesDefaultLive`). + */ +export type CoreInputTags = StoreTags | CoreOptionsTag | MemoryMeta | WorkspaceMcpOverrides; + /** Memory metadata sidecar; scope root derives from the xum home (`config.rootDir`). */ export const MemoryMetaLive: Layer.Layer = Layer.effect( MemoryMeta, @@ -52,64 +93,242 @@ export const MemoryMetaLive: Layer.Layer = Layer.e ); /** - * The core graph's inputs other than the stores: today's `CoreServicesOptions` - * minus `ConfigStores`. The optional cross-cutting services stay optional here - * (present in the desktop graph, absent in CLI roots), so core constructors - * see exactly the arguments they saw before. + * Default for roots without a desktop `CrossCuttingLive`: workspace MCP + * override reads AND registration-time plugin-override sanitization must work + * in every process that can register workspaces, not just desktop. */ -export type CoreOptions = Omit; +export const WorkspaceMcpOverridesDefaultLive: Layer.Layer< + WorkspaceMcpOverrides, + never, + ConfigTag +> = Layer.effect( + WorkspaceMcpOverrides, + Effect.map(ConfigTag, (config) => new WorkspaceMcpOverridesService(config)) +); -export class CoreOptionsTag extends Context.Service()( - "xum/CoreOptions" -) {} +// --------------------------------------------------------------------------- +// S1 — leaves: depend only on the graph inputs. +// --------------------------------------------------------------------------- + +export const HistoryLive = Layer.effect( + History, + Effect.map(SessionLocatorTag, (sessionLocator) => new HistoryService(sessionLocator)) +); + +export const InitStateManagerLive = Layer.effect( + InitStateManagerTag, + Effect.map(ConfigTag, (config) => new InitStateManager(config)) +); + +export const ProviderLive = Layer.effect( + Provider, + Effect.gen(function* () { + const opts = yield* CoreOptionsTag; + return new ProviderService( + yield* ConfigTag, + opts.policyService, + yield* ProvidersConfigStoreTag, + yield* FileLeaseManagerTag + ); + }) +); + +export const BackgroundProcessManagerLive = Layer.sync( + BackgroundProcessManagerTag, + () => new BackgroundProcessManager(path.join(os.tmpdir(), "mux-bashes")) +); + +export const ExtensionMetadataLive = Layer.effect( + ExtensionMetadata, + Effect.map(CoreOptionsTag, (opts) => new ExtensionMetadataService(opts.extensionMetadataPath)) +); + +// Agent memory (memory experiment): scope roots derive from Config (xum home +// + session dirs); experiment gating happens per stream in AIService. +export const MemoryLive = Layer.effect( + Memory, + Effect.gen(function* () { + return new MemoryService(yield* ConfigTag, yield* MemoryMeta); + }) +); + +export const TerminalAttentionStoreLive = Layer.effect( + TerminalAttentionStoreTag, + Effect.map(ConfigTag, (config) => new TerminalAttentionStore(config)) +); + +// Goal continuation bridge lives at the core scope so every codepath that +// uses the core graph (xum run, xum server via ServiceContainer, tests) +// gets a working dispatcher. Without this, requestContinuationAfterStreamEnd +// is a no-op and the auto-continuation loop never fires. The dispatcher is +// also exposed so ServiceContainer can share it with HeartbeatService. Its +// consumer is registered by the graph's wiring once Task/Workspace exist. +export const IdleDispatcherLive = Layer.sync(IdleDispatcherTag, () => new IdleDispatcher()); + +/** One mutable record per graph build (`sync`, not `succeed`); filled by the graph's wiring. */ +export const TurnRequestBuilderBindingsLive = Layer.sync( + TurnRequestBuilderBindingsTag, + (): TurnRequestBuilderBindings => ({}) +); + +// --------------------------------------------------------------------------- +// S2a — need S1 leaves. +// --------------------------------------------------------------------------- + +// Providers config accessor enables mappedToModel alias resolution for +// headless usage pricing (status generation and memory sweeps). +export const SessionUsageLive = Layer.effect( + SessionUsage, + Effect.gen(function* () { + const providerService = yield* Provider; + return new SessionUsageService(yield* ConfigTag, yield* History, () => + providerService.getConfig() + ); + }) +); + +export const WorkspaceGoalLive = Layer.effect( + WorkspaceGoal, + Effect.gen(function* () { + const opts = yield* CoreOptionsTag; + return new WorkspaceGoalService( + yield* ConfigTag, + yield* History, + yield* ExtensionMetadata, + opts.analyticsService, + opts.goalServiceOptions, + yield* ProvidersConfigStoreTag + ); + }) +); + +// --------------------------------------------------------------------------- +// S2b — StreamManager needs SessionUsage (S2a). +// --------------------------------------------------------------------------- + +export const StreamManagerLive = Layer.effect( + StreamManagerTag, + Effect.gen(function* () { + const providerService = yield* Provider; + return new StreamManager(yield* History, yield* SessionUsage, () => + providerService.getConfig() + ); + }) +); + +// --------------------------------------------------------------------------- +// S3 — AIService needs StreamManager (S2b). Its constructor installs itself as +// the stream manager's event sink and subscribes to provider config changes — +// both on declared constructor dependencies (I6). +// --------------------------------------------------------------------------- + +export const AILive = Layer.effect( + AI, + Effect.gen(function* () { + const opts = yield* CoreOptionsTag; + return new AIService( + yield* ConfigTag, + yield* History, + yield* InitStateManagerTag, + yield* Provider, + yield* BackgroundProcessManagerTag, + yield* SessionUsage, + yield* WorkspaceMcpOverrides, + opts.policyService, + opts.telemetryService, + opts.devToolsService, + opts.experimentsService, + yield* StreamManagerTag, + yield* TurnRequestBuilderBindingsTag, + yield* ProvidersConfigStoreTag, + yield* SecretsStoreTag + ); + }) +); + +// --------------------------------------------------------------------------- +// Remainder projection (transitional): today's imperative construction of the +// services after AIService plus all of the graph's wiring (`buildCoreTail`, +// order unchanged) over the layer-built head, exposed under their tags. The +// next PR peels it into stages S4–S8 and a `CoreWiringLive`. +// --------------------------------------------------------------------------- + +/** What `buildCoreTail` produces. */ +export type CoreTailTags = + | MemoryConsolidation + | MCPConfig + | MCPServerManagerTag + | Workspace + | Task + | WorkspaceTurnManagerTag; + +const CoreTailProjectionLive: Layer.Layer< + CoreTailTags, + never, + Exclude | CoreInputTags +> = Layer.effectContext( + Effect.gen(function* () { + const tail = buildCoreTail({ + config: yield* ConfigTag, + secretsStore: yield* SecretsStoreTag, + providersConfigStore: yield* ProvidersConfigStoreTag, + options: yield* CoreOptionsTag, + historyService: yield* History, + initStateManager: yield* InitStateManagerTag, + backgroundProcessManager: yield* BackgroundProcessManagerTag, + sessionUsageService: yield* SessionUsage, + extensionMetadata: yield* ExtensionMetadata, + workspaceGoalService: yield* WorkspaceGoal, + idleDispatcher: yield* IdleDispatcherTag, + streamManager: yield* StreamManagerTag, + aiService: yield* AI, + memoryService: yield* Memory, + memoryMetaService: yield* MemoryMeta, + turnRequestBuilderBindings: yield* TurnRequestBuilderBindingsTag, + workspaceMcpOverridesService: yield* WorkspaceMcpOverrides, + terminalAttentionStore: yield* TerminalAttentionStoreTag, + }); + return Context.empty().pipe( + Context.add(MemoryConsolidation, tail.memoryConsolidationService), + Context.add(MCPConfig, tail.mcpConfigService), + Context.add(MCPServerManagerTag, tail.mcpServerManager), + Context.add(Workspace, tail.workspaceService), + Context.add(Task, tail.taskService), + Context.add(WorkspaceTurnManagerTag, tail.workspaceTurnManager) + ); + }) +); + +// --------------------------------------------------------------------------- +// Staged composition. Each stage depends only on stages above it; `provideMerge` +// keeps both sides exposed, so the final context carries every core tag. +// --------------------------------------------------------------------------- + +const S1 = Layer.mergeAll( + HistoryLive, + InitStateManagerLive, + ProviderLive, + BackgroundProcessManagerLive, + ExtensionMetadataLive, + MemoryLive, + TerminalAttentionStoreLive, + IdleDispatcherLive, + TurnRequestBuilderBindingsLive +); +const S2a = Layer.mergeAll(SessionUsageLive, WorkspaceGoalLive).pipe(Layer.provideMerge(S1)); +const S2b = StreamManagerLive.pipe(Layer.provideMerge(S2a)); +const S3 = AILive.pipe(Layer.provideMerge(S2b)); /** - * Coarse projection of the whole core graph: runs today's imperative - * construction body (`buildCoreGraph`, unchanged) once and exposes every - * `CoreServices` field under its tag. Zero behavior change by construction — - * the peel into staged per-service layers is the next phase's work, gated on - * this layer's typecheck/startup budgets. + * The whole core graph, wired. The roots provide `CoreInputTags` beneath it + * (`MemoryMeta` is one of them, hence excluded from the outputs here; the + * root's merged context still carries every `CoreTags` entry). */ -export const CoreProjectionLive: Layer.Layer = - Layer.effectContext( - Effect.gen(function* () { - const opts = yield* CoreOptionsTag; - const core = buildCoreGraph({ - ...opts, - config: yield* ConfigTag, - sessionLocator: yield* SessionLocatorTag, - providersConfigStore: yield* ProvidersConfigStoreTag, - secretsStore: yield* SecretsStoreTag, - fileLeaseManager: yield* FileLeaseManagerTag, - }); - return coreContextFromServices(core); - }) - ); - -/** `CoreServices` → tagged context; inverse of `coreServicesFromContext`. */ -export function coreContextFromServices(core: CoreServices): Context.Context { - return Context.empty().pipe( - Context.add(History, core.historyService), - Context.add(InitStateManagerTag, core.initStateManager), - Context.add(Provider, core.providerService), - Context.add(BackgroundProcessManagerTag, core.backgroundProcessManager), - Context.add(SessionUsage, core.sessionUsageService), - Context.add(WorkspaceGoal, core.workspaceGoalService), - Context.add(IdleDispatcherTag, core.idleDispatcher), - Context.add(AI, core.aiService), - Context.add(StreamManagerTag, core.streamManager), - Context.add(MCPConfig, core.mcpConfigService), - Context.add(MCPServerManagerTag, core.mcpServerManager), - Context.add(ExtensionMetadata, core.extensionMetadata), - Context.add(Workspace, core.workspaceService), - Context.add(Task, core.taskService), - Context.add(WorkspaceTurnManagerTag, core.workspaceTurnManager), - Context.add(Memory, core.memoryService), - Context.add(MemoryMeta, core.memoryMetaService), - Context.add(MemoryConsolidation, core.memoryConsolidationService), - Context.add(TurnRequestBuilderBindingsTag, core.turnRequestBuilderBindings) - ); -} +export const CoreLive: Layer.Layer< + Exclude, + never, + CoreInputTags +> = CoreTailProjectionLive.pipe(Layer.provideMerge(S3)); /** Tagged context → the plain `CoreServices` object the roots hand out. */ export function coreServicesFromContext(context: Context.Context): CoreServices { @@ -138,8 +357,9 @@ export function coreServicesFromContext(context: Context.Context): Cor /** * Full Layer graph for a headless CLI root (`xum run`, `xum workflow`): the - * core projection over the caller's options, with the runtime seams at the - * base exactly as in `AppLive` (`./app.ts`). Composition direction is + * core graph over the caller's options with the CLI defaults for the two + * desktop-built inputs, and the runtime seams at the base exactly as in + * `AppLive` (`./app.ts`). Composition direction is * `consumer.pipe(Layer.provideMerge(provider))`; every tag stays exposed. */ export function CoreRootLive(opts: CoreServicesOptions): Layer.Layer { @@ -155,7 +375,9 @@ export function CoreRootLive(opts: CoreServicesOptions): Layer.Layer = }) ); -/** The desktop's core graph options: every optional cross-cutting service present. */ +/** + * The desktop's core graph options: every optional cross-cutting service + * present. (`MemoryMeta` and `WorkspaceMcpOverrides` are core graph inputs in + * their own right, read from their tags by the core layers.) + */ export const CoreOptionsFromDesktopLive: Layer.Layer< CoreOptionsTag, never, - ConfigTag | MemoryMeta | CrossCuttingTags + ConfigTag | CrossCuttingTags > = Layer.effect( CoreOptionsTag, Effect.gen(function* () { const config = yield* ConfigTag; return { extensionMetadataPath: path.join(config.rootDir, "extensionMetadata.json"), - workspaceMcpOverridesService: yield* WorkspaceMcpOverrides, - memoryMetaService: yield* MemoryMeta, policyService: yield* Policy, telemetryService: yield* Telemetry, analyticsService: yield* Analytics, diff --git a/src/node/services/di/tags.ts b/src/node/services/di/tags.ts index 35423398d34..dc3eac3d378 100644 --- a/src/node/services/di/tags.ts +++ b/src/node/services/di/tags.ts @@ -40,6 +40,7 @@ import type { SessionUsageService } from "@/node/services/sessionUsageService"; import type { StreamManager } from "@/node/services/streamManager"; import type { TaskService } from "@/node/services/taskService"; import type { TelemetryService } from "@/node/services/telemetryService"; +import type { TerminalAttentionStore } from "@/node/services/terminalAttentionStore"; import type { TurnRequestBuilderBindings } from "@/node/services/turnRequestBuilder"; import type { WorkspaceGoalService } from "@/node/services/workspaceGoalService"; import type { WorkspaceMcpOverridesService } from "@/node/services/workspaceMcpOverridesService"; @@ -113,6 +114,11 @@ export class MemoryConsolidation extends Context.Service< MemoryConsolidation, MemoryConsolidationService >()("xum/MemoryConsolidation") {} +/** Terminal attention records; built by the core graph for Task/TurnManager (not a `CoreServices` field). */ +export class TerminalAttentionStoreTag extends Context.Service< + TerminalAttentionStoreTag, + TerminalAttentionStore +>()("xum/TerminalAttentionStore") {} /** Late-bound collaborators of the turn request builder (a mutable record, filled by wiring). */ export class TurnRequestBuilderBindingsTag extends Context.Service< TurnRequestBuilderBindingsTag, @@ -150,7 +156,10 @@ export type StoreTags = */ export type RuntimeSeamTags = EffectRunnerTag | AppFiberScopeTag; -/** Every `CoreServices` field, as provided by `CoreProjectionLive` (./layers/core.ts). */ +/** + * Everything `CoreLive` (./layers/core.ts) provides: every `CoreServices` + * field plus the graph-internal `TerminalAttentionStore`. + */ export type CoreTags = | History | InitStateManagerTag @@ -170,10 +179,19 @@ export type CoreTags = | Memory | MemoryMeta | MemoryConsolidation + | TerminalAttentionStoreTag | TurnRequestBuilderBindingsTag; -/** Everything a headless CLI root (`createCoreServices`) provides. */ -export type CoreRootTags = StoreTags | RuntimeSeamTags | CoreOptionsTag | CoreTags; +/** + * Everything a headless CLI root (`createCoreServices`) provides; the CLI + * default `WorkspaceMcpOverrides` stands in for the desktop's cross-cutting one. + */ +export type CoreRootTags = + | StoreTags + | RuntimeSeamTags + | CoreOptionsTag + | WorkspaceMcpOverrides + | CoreTags; /** The desktop cross-cutting services provided by `CrossCuttingLive`. */ export type CrossCuttingTags = diff --git a/src/node/services/serviceContainer.test.ts b/src/node/services/serviceContainer.test.ts index 76c65340b51..35dacf73b29 100644 --- a/src/node/services/serviceContainer.test.ts +++ b/src/node/services/serviceContainer.test.ts @@ -319,8 +319,6 @@ describe("ServiceContainer", () => { const coreOptions = services.runtime.get(CoreOptionsTag); expect(coreOptions.policyService).toBe(services.policyService); expect(coreOptions.experimentsService).toBe(services.experimentsService); - expect(coreOptions.workspaceMcpOverridesService).toBe(services.workspaceMcpOverridesService); - expect(coreOptions.memoryMetaService).toBe(services.memoryMetaService); }); it("surfaces a throwing layer as a synchronous constructor throw", () => { diff --git a/src/node/services/serviceContainer.ts b/src/node/services/serviceContainer.ts index b2f512ab897..154aa52b09d 100644 --- a/src/node/services/serviceContainer.ts +++ b/src/node/services/serviceContainer.ts @@ -234,7 +234,7 @@ export class ServiceContainer { this.workspaceMcpOverridesService = this.runtime.get(WorkspaceMcpOverrides); // The core graph (shared with the `xum run`/`xum workflow` roots) is built by - // `CoreProjectionLive`; read it back as the plain object the wiring below uses. + // `CoreLive`; read it back as the plain object the wiring below uses. const core = coreServicesFromContext(this.runtime.context); // Spread core services into class fields