diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index fbf6d80c560..d84be80fcbe 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -33,8 +33,16 @@ import { import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts"; import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts"; +import { + ProjectionProjectRepository, + NoOpProjectionProjectRepository, +} from "./persistence/Services/ProjectionProjects.ts"; -const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); +const CliRuntimeLayer = Layer.mergeAll( + NodeServices.layer, + NetService.layer, + Layer.succeed(ProjectionProjectRepository, NoOpProjectionProjectRepository), +); const runCli = (args: ReadonlyArray) => Command.runWith(cli, { version: "0.0.0" })(args); const runCliWithRuntime = (args: ReadonlyArray) => diff --git a/apps/server/src/persistence/Services/ProjectionProjects.ts b/apps/server/src/persistence/Services/ProjectionProjects.ts index 5632205a269..904ee837069 100644 --- a/apps/server/src/persistence/Services/ProjectionProjects.ts +++ b/apps/server/src/persistence/Services/ProjectionProjects.ts @@ -10,7 +10,7 @@ import { IsoDateTime, ModelSelection, ProjectId, ProjectScript } from "@t3tools/ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Context from "effect/Context"; -import type * as Effect from "effect/Effect"; +import * as Effect from "effect/Effect"; import type { ProjectionRepositoryError } from "../Errors.ts"; @@ -79,3 +79,13 @@ export class ProjectionProjectRepository extends Context.Service< ProjectionProjectRepository, ProjectionProjectRepositoryShape >()("t3/persistence/Services/ProjectionProjects/ProjectionProjectRepository") {} + +/** + * No-op implementation for use in tests that don't exercise project persistence. + */ +export const NoOpProjectionProjectRepository: ProjectionProjectRepositoryShape = { + upsert: () => Effect.void, + getById: () => Effect.succeed(Option.none()), + listAll: () => Effect.succeed([]), + deleteById: () => Effect.void, +}; diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index e7216f83366..db80c6f3aab 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -25,6 +25,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { makeOpenCodeTextGeneration } from "../../textGeneration/OpenCodeTextGeneration.ts"; import { ServerConfig } from "../../config.ts"; +import { ProjectionProjectRepository } from "../../persistence/Services/ProjectionProjects.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeOpenCodeAdapter } from "../Layers/OpenCodeAdapter.ts"; import { @@ -79,6 +80,7 @@ export type OpenCodeDriverEnv = | HttpClient.HttpClient | OpenCodeRuntime | Path.Path + | ProjectionProjectRepository | ProviderEventLoggers | ServerConfig; @@ -110,6 +112,7 @@ export const OpenCodeDriver: ProviderDriver Effect.gen(function* () { const openCodeRuntime = yield* OpenCodeRuntime; const serverConfig = yield* ServerConfig; + const projectRepository = yield* ProjectionProjectRepository; const httpClient = yield* HttpClient.HttpClient; const eventLoggers = yield* ProviderEventLoggers; const processEnv = mergeProviderInstanceEnvironment(environment); @@ -136,11 +139,29 @@ export const OpenCodeDriver: ProviderDriver }); const textGeneration = yield* makeOpenCodeTextGeneration(effectiveConfig, processEnv); - const checkProvider = checkOpenCodeProviderStatus( - effectiveConfig, - serverConfig.cwd, - processEnv, - ).pipe(Effect.map(stampIdentity), Effect.provideService(OpenCodeRuntime, openCodeRuntime)); + const buildProjectCwds = projectRepository.listAll().pipe( + Effect.map((projects) => { + const cwds = new Set([ + serverConfig.cwd, + ...projects.filter((p) => p.deletedAt === null).map((p) => p.workspaceRoot), + ]); + return Array.from(cwds); + }), + Effect.tapError((e) => + Effect.logWarning( + "Failed to list projects for OpenCode skills discovery; falling back to server cwd only", + e, + ), + ), + Effect.orElseSucceed(() => [serverConfig.cwd]), + ); + + const checkProvider = Effect.flatMap(buildProjectCwds, (cwds) => + checkOpenCodeProviderStatus(effectiveConfig, cwds, processEnv).pipe( + Effect.map(stampIdentity), + Effect.provideService(OpenCodeRuntime, openCodeRuntime), + ), + ); const snapshot = yield* makeManagedServerProvider({ maintenanceCapabilities, diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index e56806e26b5..2790583c7af 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -120,7 +120,7 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { it.effect("shows a codex-style missing binary message", () => Effect.gen(function* () { runtimeMock.state.runVersionError = new Error("spawn opencode ENOENT"); - const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); + const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), [process.cwd()]); assert.equal(snapshot.status, "error"); assert.equal(snapshot.installed, false); @@ -131,7 +131,7 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { it.effect("hides generic Effect.tryPromise text for local CLI probe failures", () => Effect.gen(function* () { runtimeMock.state.runVersionError = new Error("An error occurred in Effect.tryPromise"); - const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); + const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), [process.cwd()]); assert.equal(snapshot.status, "error"); assert.equal(snapshot.installed, true); @@ -171,7 +171,7 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { ], }; - const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); + const snapshot = yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), [process.cwd()]); const model = snapshot.models.find((entry) => entry.slug === "openai/gpt-5.4"); assert.ok(model); @@ -196,7 +196,7 @@ it.layer(testLayer)("checkOpenCodeProviderStatus", (it) => { it.effect("closes the local OpenCode server scope after provider refresh", () => Effect.gen(function* () { - yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), process.cwd()); + yield* checkOpenCodeProviderStatus(makeOpenCodeSettings(), [process.cwd()]); assert.equal(runtimeMock.state.closeCalls, 1); }), @@ -212,7 +212,7 @@ it.layer(testLayer)("checkOpenCodeProviderStatus with configured server URL", (i serverUrl: "http://127.0.0.1:9999", serverPassword: "secret-password", }), - process.cwd(), + [process.cwd()], ); assert.equal(snapshot.status, "error"); @@ -234,9 +234,8 @@ it.layer(testLayer)("checkOpenCodeProviderStatus with configured server URL", (i serverUrl: "http://127.0.0.1:9999", serverPassword: "secret-password", }), - process.cwd(), + [process.cwd()], ); - assert.equal(snapshot.status, "error"); assert.equal(snapshot.installed, true); assert.equal( diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.ts b/apps/server/src/provider/Layers/OpenCodeProvider.ts index 8842b1da5ce..48117e651a5 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.ts @@ -3,6 +3,7 @@ import { type ModelCapabilities, type OpenCodeSettings, type ServerProviderModel, + type ServerProviderSkill, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Data from "effect/Data"; @@ -22,6 +23,7 @@ import { OpenCodeRuntime, openCodeRuntimeErrorDetail, type OpenCodeInventory, + type OpenCodeSkill, } from "../opencodeRuntime.ts"; import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2"; @@ -298,9 +300,20 @@ export const makePendingOpenCodeProvider = ( }); }); +function mapOpenCodeSkills( + skills: ReadonlyArray, +): ReadonlyArray { + return skills.map((skill) => ({ + name: skill.name, + description: skill.description || undefined, + path: skill.location, + enabled: true, + })); +} + export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatus")(function* ( openCodeSettings: OpenCodeSettings, - cwd: string, + cwds: ReadonlyArray, environment: NodeJS.ProcessEnv = process.env, ): Effect.fn.Return { const openCodeRuntime = yield* OpenCodeRuntime; @@ -418,11 +431,11 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu return yield* openCodeRuntime.loadOpenCodeInventory( openCodeRuntime.createOpenCodeSdkClient({ baseUrl: server.url, - directory: cwd, ...(isExternalServer && openCodeSettings.serverPassword ? { serverPassword: openCodeSettings.serverPassword } : {}), }), + cwds, ); }).pipe( Effect.mapError( @@ -441,12 +454,14 @@ export const checkOpenCodeProviderStatus = Effect.fn("checkOpenCodeProviderStatu customModels, DEFAULT_OPENCODE_MODEL_CAPABILITIES, ); + const skills = mapOpenCodeSkills(inventoryExit.value.skills); const connectedCount = inventoryExit.value.providerList.connected.length; return buildServerProvider({ presentation: OPENCODE_PRESENTATION, enabled: true, checkedAt, models, + skills, probe: { installed: true, version, diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index 86f99c97326..1f757156783 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -38,6 +38,10 @@ import * as Layer from "effect/Layer"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { ServerConfig } from "../../config.ts"; +import { + ProjectionProjectRepository, + NoOpProjectionProjectRepository, +} from "../../persistence/Services/ProjectionProjects.ts"; import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts"; import { CodexDriver } from "../Drivers/CodexDriver.ts"; import { CursorDriver } from "../Drivers/CursorDriver.ts"; @@ -237,6 +241,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { Layer.provideMerge(infraLayer), Layer.provideMerge(TestHttpClientLive), Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provideMerge(Layer.succeed(ProjectionProjectRepository, NoOpProjectionProjectRepository)), ); it.live("boots one instance of every shipped driver from a single config map", () => diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index fb6eb3b443d..ec18aec806a 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -33,6 +33,10 @@ import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; import { checkCodexProviderStatus, type CodexAppServerProviderSnapshot } from "./CodexProvider.ts"; import { checkClaudeProviderStatus } from "./ClaudeProvider.ts"; import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts"; +import { + ProjectionProjectRepository, + NoOpProjectionProjectRepository, +} from "../../persistence/Services/ProjectionProjects.ts"; import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts"; import { ProviderInstanceRegistryHydrationLive } from "./ProviderInstanceRegistryHydration.ts"; import { @@ -1029,6 +1033,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T Layer.provideMerge(TestHttpClientLive), Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), Layer.provideMerge(OpenCodeRuntimeLive), + Layer.provideMerge( + Layer.succeed(ProjectionProjectRepository, NoOpProjectionProjectRepository), + ), // NO spawner mock — `ChildProcessSpawner` is supplied by the // outer `NodeServices.layer` on `it.layer(...)` and will // genuinely spawn a subprocess. The missing-binary ENOENT is @@ -1104,6 +1111,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T Layer.provideMerge(TestHttpClientLive), Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), Layer.provideMerge(OpenCodeRuntimeLive), + Layer.provideMerge( + Layer.succeed(ProjectionProjectRepository, NoOpProjectionProjectRepository), + ), // `it.live` does not inherit layers from the outer `it.layer` // wrapper, so provide `NodeServices.layer` inline. This is the // same real `ChildProcessSpawner` + `FileSystem` + `Path` @@ -1207,6 +1217,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T Layer.provideMerge(TestHttpClientLive), Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), Layer.provideMerge(OpenCodeRuntimeLive), + Layer.provideMerge( + Layer.succeed(ProjectionProjectRepository, NoOpProjectionProjectRepository), + ), Layer.provideMerge(NodeServices.layer), ); const runtimeServices = yield* Layer.build(providerRegistryLayer).pipe( @@ -1258,6 +1271,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T Layer.provideMerge(TestHttpClientLive), Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), Layer.provideMerge(OpenCodeRuntimeLive), + Layer.provideMerge( + Layer.succeed(ProjectionProjectRepository, NoOpProjectionProjectRepository), + ), Layer.provideMerge( mockCommandSpawnerLayer((command, args) => { if (command === "agent") { diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index 9c48e441032..8b65265c058 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -95,9 +95,16 @@ export interface OpenCodeCommandResult { readonly code: number; } +export interface OpenCodeSkill { + readonly name: string; + readonly description: string; + readonly location: string; +} + export interface OpenCodeInventory { readonly providerList: ProviderListResponse; readonly agents: ReadonlyArray; + readonly skills: ReadonlyArray; } export interface ParsedOpenCodeModelSlug { @@ -139,11 +146,12 @@ export interface OpenCodeRuntimeShape { }) => Effect.Effect; readonly createOpenCodeSdkClient: (input: { readonly baseUrl: string; - readonly directory: string; + readonly directory?: string; readonly serverPassword?: string; }) => OpencodeClient; readonly loadOpenCodeInventory: ( client: OpencodeClient, + cwds: ReadonlyArray, ) => Effect.Effect; } @@ -497,7 +505,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () { const createOpenCodeSdkClient: OpenCodeRuntimeShape["createOpenCodeSdkClient"] = (input) => createOpencodeClient({ baseUrl: input.baseUrl, - directory: input.directory, + ...(input.directory !== undefined ? { directory: input.directory } : {}), ...(input.serverPassword ? { headers: { @@ -529,9 +537,42 @@ const makeOpenCodeRuntime = Effect.gen(function* () { Effect.map((result) => result.data ?? []), ); - const loadOpenCodeInventory: OpenCodeRuntimeShape["loadOpenCodeInventory"] = (client) => + const loadSkills = (client: OpencodeClient, directory: string) => + runOpenCodeSdk("app.skills", () => client.app.skills({ directory })).pipe( + Effect.map((result) => + (result.data ?? []).map(({ name, description, location }) => ({ + name, + description, + location, + })), + ), + ); + + const loadOpenCodeInventory: OpenCodeRuntimeShape["loadOpenCodeInventory"] = (client, cwds) => Effect.all([loadProviders(client), loadAgents(client)], { concurrency: "unbounded" }).pipe( - Effect.map(([providerList, agents]) => ({ providerList, agents })), + Effect.flatMap(([providerList, agents]) => + Effect.all( + cwds.map((cwd) => + loadSkills(client, cwd).pipe(Effect.orElseSucceed(() => [] as OpenCodeSkill[])), + ), + { concurrency: "unbounded" }, + ).pipe( + Effect.map((skillArrays) => { + // Deduplication is first-occurrence wins. `cwds` is ordered with + // `serverConfig.cwd` first, followed by project workspace roots in + // DB insertion order (createdAt ASC). A skill defined in the + // server's startup directory therefore takes precedence over a + // same-named skill in any open project. + const seen = new Set(); + const skills = skillArrays.flat().filter((s) => { + if (seen.has(s.name)) return false; + seen.add(s.name); + return true; + }); + return { providerList, agents, skills }; + }), + ), + ), ); return { diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 13516c77259..72e0806937e 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -19,6 +19,7 @@ import { ServerLifecycleEventsLive } from "./serverLifecycleEvents.ts"; import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService.ts"; import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory.ts"; import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime.ts"; +import { ProjectionProjectRepositoryLive } from "./persistence/Layers/ProjectionProjects.ts"; import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry.ts"; import { ProviderEventLoggersLive } from "./provider/Layers/ProviderEventLoggers.ts"; import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts"; @@ -256,7 +257,6 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( Layer.provideMerge(VcsLayerLive), Layer.provideMerge(ProviderRuntimeLayerLive), Layer.provideMerge(TerminalLayerLive), - Layer.provideMerge(PersistenceLayerLive), Layer.provideMerge(KeybindingsLive), Layer.provideMerge(ProviderRegistryLive), // The instance registry is the new routing keystone — text generation, @@ -265,6 +265,12 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( // `providerInstances` hydration merges `settings.providers.` // with explicit `providerInstances` entries on boot. Layer.provideMerge(ProviderInstanceRegistryHydrationLive), + // ProjectionProjectRepositoryLive must follow ProviderInstanceRegistryHydrationLive + // so the Hydration layer's ProjectionProjectRepository requirement (via + // BuiltInDriversEnv/OpenCodeDriverEnv) is satisfied by this later step. + // PersistenceLayerLive (SqlClient) must follow it in turn. + Layer.provideMerge(ProjectionProjectRepositoryLive), + Layer.provideMerge(PersistenceLayerLive), // Shared native/canonical NDJSON writers used by both the per-instance // drivers (native stream, written from inside each `Adapter`) and // `ProviderService` (canonical stream, written after event normalization).