Skip to content
Draft
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
3 changes: 2 additions & 1 deletion apps/server/src/provider/Layers/OpenCodeAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
import { type OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts";
import {
buildOpenCodePermissionRules,
isOpenCodeRuntimeError,
OpenCodeRuntime,
OpenCodeRuntimeError,
openCodeQuestionId,
Expand Down Expand Up @@ -130,7 +131,7 @@ const toProcessError = (threadId: ThreadId, cause: unknown): ProviderAdapterProc
new ProviderAdapterProcessError({
provider: PROVIDER,
threadId,
detail: OpenCodeRuntimeError.is(cause) ? cause.detail : openCodeRuntimeErrorDetail(cause),
detail: isOpenCodeRuntimeError(cause) ? cause.detail : openCodeRuntimeErrorDetail(cause),
cause,
});

Expand Down
41 changes: 22 additions & 19 deletions apps/server/src/provider/opencodeRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,13 @@ import {
} from "@opencode-ai/sdk/v2";
import * as Cause from "effect/Cause";
import * as Context from "effect/Context";
import * as Data from "effect/Data";
import * as Deferred from "effect/Deferred";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as P from "effect/Predicate";
import * as Ref from "effect/Ref";
import * as Result from "effect/Result";
import * as Scope from "effect/Scope";
Expand All @@ -35,7 +34,7 @@ const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.UnknownFromJ
const OPENCODE_EMPTY_CONFIG_CONTENT = "{}";

const OPENCODE_SERVER_READY_PREFIX = "opencode server listening";
const DEFAULT_OPENCODE_SERVER_TIMEOUT_MS = 5_000;
const DEFAULT_OPENCODE_SERVER_TIMEOUT = Duration.seconds(5);
const DEFAULT_HOSTNAME = "127.0.0.1";
export interface OpenCodeServerProcess {
readonly url: string;
Expand All @@ -48,23 +47,27 @@ export interface OpenCodeServerConnection {
readonly external: boolean;
}

const OPENCODE_RUNTIME_ERROR_TAG = "OpenCodeRuntimeError";
export class OpenCodeRuntimeError extends Data.TaggedError(OPENCODE_RUNTIME_ERROR_TAG)<{
readonly operation: string;
readonly cause?: unknown;
readonly detail: string;
}> {
static readonly is = (u: unknown): u is OpenCodeRuntimeError =>
P.isTagged(u, OPENCODE_RUNTIME_ERROR_TAG);
export class OpenCodeRuntimeError extends Schema.TaggedErrorClass<OpenCodeRuntimeError>()(
"OpenCodeRuntimeError",
{
operation: Schema.String,
detail: Schema.String,
cause: Schema.optional(Schema.Defect),
},
) {
override get message() {
return this.detail;
}
}
export const isOpenCodeRuntimeError = Schema.is(OpenCodeRuntimeError);

function encodeJsonStringForDiagnostics(input: unknown): string | undefined {
const result = encodeUnknownJsonStringExit(input);
return Exit.isSuccess(result) ? result.value : undefined;
}

export function openCodeRuntimeErrorDetail(cause: unknown): string {
if (OpenCodeRuntimeError.is(cause)) return cause.detail;
if (isOpenCodeRuntimeError(cause)) return cause.detail;
if (cause instanceof Error && cause.message.trim().length > 0) return cause.message.trim();
if (cause && typeof cause === "object") {
// SDK v2 throws { response, request, error? } shapes — extract what's useful
Expand Down Expand Up @@ -117,7 +120,7 @@ export interface OpenCodeRuntimeShape {
readonly environment?: NodeJS.ProcessEnv;
readonly port?: number;
readonly hostname?: string;
readonly timeoutMs?: number;
readonly timeout?: Duration.Input;
}) => Effect.Effect<OpenCodeServerProcess, OpenCodeRuntimeError, Scope.Scope>;
/**
* Returns a handle to either an externally-managed OpenCode server (when
Expand All @@ -130,7 +133,7 @@ export interface OpenCodeRuntimeShape {
readonly environment?: NodeJS.ProcessEnv;
readonly port?: number;
readonly hostname?: string;
readonly timeoutMs?: number;
readonly timeout?: Duration.Input;
}) => Effect.Effect<OpenCodeServerConnection, OpenCodeRuntimeError, Scope.Scope>;
readonly runOpenCodeCommand: (input: {
readonly binaryPath: string;
Expand Down Expand Up @@ -268,7 +271,7 @@ function ensureRuntimeError(
detail: string,
cause: unknown,
): OpenCodeRuntimeError {
return OpenCodeRuntimeError.is(cause)
return isOpenCodeRuntimeError(cause)
? cause
: new OpenCodeRuntimeError({ operation, detail, cause });
}
Expand Down Expand Up @@ -332,7 +335,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () {
}),
),
));
const timeoutMs = input.timeoutMs ?? DEFAULT_OPENCODE_SERVER_TIMEOUT_MS;
const timeout = Duration.fromInputUnsafe(input.timeout ?? DEFAULT_OPENCODE_SERVER_TIMEOUT);
const args = ["serve", `--hostname=${hostname}`, `--port=${port}`];

const child = yield* spawner
Expand Down Expand Up @@ -431,7 +434,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () {
);

const readyExit = yield* Effect.exit(
Deferred.await(readyDeferred).pipe(Effect.timeoutOption(timeoutMs)),
Deferred.await(readyDeferred).pipe(Effect.timeoutOption(timeout)),
);

// Startup-time fibers are no longer needed once ready has resolved (either
Expand All @@ -455,7 +458,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () {
yield* Fiber.interrupt(exitFiber).pipe(Effect.ignore);
return yield* new OpenCodeRuntimeError({
operation: "startOpenCodeServerProcess",
detail: `Timed out waiting for OpenCode server start after ${timeoutMs}ms.`,
detail: `Timed out waiting for OpenCode server start after ${Duration.format(timeout)}.`,
});
}

Expand Down Expand Up @@ -484,7 +487,7 @@ const makeOpenCodeRuntime = Effect.gen(function* () {
...(input.environment !== undefined ? { environment: input.environment } : {}),
...(input.port !== undefined ? { port: input.port } : {}),
...(input.hostname !== undefined ? { hostname: input.hostname } : {}),
...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}),
...(input.timeout !== undefined ? { timeout: input.timeout } : {}),
}).pipe(
Effect.map((server) => ({
url: server.url,
Expand Down
13 changes: 12 additions & 1 deletion apps/server/src/provider/providerStatusCache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => {
const claudeProvider = makeProvider(CLAUDE_AGENT_DRIVER, {
status: "warning",
auth: { status: "unknown" },
updateState: {
status: "queued",
startedAt: null,
finishedAt: null,
message: null,
output: null,
},
});
const openCodeProvider = makeProvider(OPENCODE_DRIVER, {
status: "warning",
Expand Down Expand Up @@ -81,9 +88,13 @@ it.layer(NodeServices.layer)("providerStatusCache", (it) => {
provider: openCodeProvider,
});

const { updateState: _updateState, ...cacheableClaudeProvider } = claudeProvider;
assert.deepStrictEqual(yield* readProviderStatusCache(codexPath), codexProvider);
assert.deepStrictEqual(yield* readProviderStatusCache(claudePath), claudeProvider);
assert.deepStrictEqual(yield* readProviderStatusCache(claudePath), cacheableClaudeProvider);
assert.deepStrictEqual(yield* readProviderStatusCache(openCodePath), openCodeProvider);

const claudeCacheContents = yield* fs.readFileString(claudePath);
assert.strictEqual(claudeCacheContents.includes("updateState"), false);
}),
);

Expand Down
12 changes: 9 additions & 3 deletions apps/server/src/provider/providerStatusCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,15 @@ import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
import * as Schema from "effect/Schema";
import { fromJsonStringPretty } from "@t3tools/shared/schemaJson";

import { writeFileStringAtomically } from "../atomicWrite.ts";

const decodeProviderStatusCache = Schema.decodeUnknownEffect(
Schema.fromJsonString(ServerProviderSchema),
);
const ProviderStatusCacheJson = fromJsonStringPretty(ServerProviderSchema);
const encodeProviderStatusCache = Schema.encodeEffect(ProviderStatusCacheJson);

const mergeProviderModels = (
fallbackModels: ReadonlyArray<ServerProvider["models"][number]>,
Expand Down Expand Up @@ -146,8 +149,11 @@ export const writeProviderStatusCache = (input: {
readonly provider: ServerProvider;
}) => {
const { updateState: _updateState, ...cacheableProvider } = input.provider;
return writeFileStringAtomically({
filePath: input.filePath,
contents: `${JSON.stringify(cacheableProvider, null, 2)}\n`,
return Effect.gen(function* () {
const contents = yield* encodeProviderStatusCache(cacheableProvider);
yield* writeFileStringAtomically({
filePath: input.filePath,
contents: `${contents}\n`,
});
});
};
Loading