Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion apps/server/src/bin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>) => Command.runWith(cli, { version: "0.0.0" })(args);
const runCliWithRuntime = (args: ReadonlyArray<string>) =>
Expand Down
12 changes: 11 additions & 1 deletion apps/server/src/persistence/Services/ProjectionProjects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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,
};
31 changes: 26 additions & 5 deletions apps/server/src/provider/Drivers/OpenCodeDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -79,6 +80,7 @@ export type OpenCodeDriverEnv =
| HttpClient.HttpClient
| OpenCodeRuntime
| Path.Path
| ProjectionProjectRepository
| ProviderEventLoggers
| ServerConfig;

Expand Down Expand Up @@ -110,6 +112,7 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv>
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);
Expand All @@ -136,11 +139,29 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv>
});
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<OpenCodeSettings>({
maintenanceCapabilities,
Expand Down
13 changes: 6 additions & 7 deletions apps/server/src/provider/Layers/OpenCodeProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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);
}),
Expand All @@ -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");
Expand All @@ -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(
Expand Down
19 changes: 17 additions & 2 deletions apps/server/src/provider/Layers/OpenCodeProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -22,6 +23,7 @@ import {
OpenCodeRuntime,
openCodeRuntimeErrorDetail,
type OpenCodeInventory,
type OpenCodeSkill,
} from "../opencodeRuntime.ts";
import type { Agent, ProviderListResponse } from "@opencode-ai/sdk/v2";

Expand Down Expand Up @@ -298,9 +300,20 @@ export const makePendingOpenCodeProvider = (
});
});

function mapOpenCodeSkills(
skills: ReadonlyArray<OpenCodeSkill>,
): ReadonlyArray<ServerProviderSkill> {
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<string>,
environment: NodeJS.ProcessEnv = process.env,
): Effect.fn.Return<ServerProviderDraft, never, OpenCodeRuntime> {
const openCodeRuntime = yield* OpenCodeRuntime;
Expand Down Expand Up @@ -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(
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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", () =>
Expand Down
16 changes: 16 additions & 0 deletions apps/server/src/provider/Layers/ProviderRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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") {
Expand Down
49 changes: 45 additions & 4 deletions apps/server/src/provider/opencodeRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Agent>;
readonly skills: ReadonlyArray<OpenCodeSkill>;
}

export interface ParsedOpenCodeModelSlug {
Expand Down Expand Up @@ -139,11 +146,12 @@ export interface OpenCodeRuntimeShape {
}) => Effect.Effect<OpenCodeCommandResult, OpenCodeRuntimeError>;
readonly createOpenCodeSdkClient: (input: {
readonly baseUrl: string;
readonly directory: string;
readonly directory?: string;
readonly serverPassword?: string;
}) => OpencodeClient;
readonly loadOpenCodeInventory: (
client: OpencodeClient,
cwds: ReadonlyArray<string>,
) => Effect.Effect<OpenCodeInventory, OpenCodeRuntimeError>;
}

Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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<string>();
const skills = skillArrays.flat().filter((s) => {
if (seen.has(s.name)) return false;
seen.add(s.name);
return true;
});
return { providerList, agents, skills };
}),
),
),
);

return {
Expand Down
Loading
Loading