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
21 changes: 14 additions & 7 deletions apps/server/src/provider/Drivers/CodexDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ import type { ServerProviderDraft } from "../providerSnapshot.ts";
import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";
import {
enrichProviderSnapshotWithVersionAdvisory,
makePackageManagedProviderMaintenanceResolver,
makeProviderMaintenanceCapabilities,
type ProviderMaintenanceCapabilitiesResolver,
resolveProviderMaintenanceCapabilitiesEffect,
} from "../providerMaintenance.ts";
import {
Expand All @@ -60,12 +61,18 @@ import {
const decodeCodexSettings = Schema.decodeSync(CodexSettings);

const DRIVER_KIND = ProviderDriverKind.make("codex");
const UPDATE = makePackageManagedProviderMaintenanceResolver({
provider: DRIVER_KIND,
npmPackageName: "@openai/codex",
homebrewFormula: "codex",
nativeUpdate: null,
});
const UPDATE: ProviderMaintenanceCapabilitiesResolver = {
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
resolve: (options) =>
makeProviderMaintenanceCapabilities({
provider: DRIVER_KIND,
packageName: "@openai/codex",
updateExecutable: options?.binaryPath?.trim() || "codex",
updateArgs: ["update"],
...(options?.env ? { updateEnv: options.env } : {}),
updateLockKey: "codex-self-update",
updateMinimumVersion: "0.126.0",
}),
Comment thread
cursor[bot] marked this conversation as resolved.
};

/**
* Services the driver needs to materialize an instance. Surfaced as the
Expand Down
55 changes: 55 additions & 0 deletions apps/server/src/provider/providerMaintenance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,16 @@ const staticToolUpdate = makeStaticProviderMaintenanceResolver(
updateLockKey: "static-tool",
}),
);
const selfUpdatingTool = makeStaticProviderMaintenanceResolver(
makeProviderMaintenanceCapabilities({
provider: driver("selfUpdatingTool"),
packageName: "@example/self-updating-tool",
updateExecutable: "self-updating-tool",
updateArgs: ["update"],
updateLockKey: "self-updating-tool",
updateMinimumVersion: "2.0.0",
}),
);
const installedPackageToolProvider: ServerProvider = {
instanceId: ProviderInstanceId.make("packageTool"),
driver: driver("packageTool"),
Expand Down Expand Up @@ -198,6 +208,51 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => {
});
});

it("keeps self-update guidance manual below the supported provider version", () => {
expect(
createProviderVersionAdvisory({
driver: driver("selfUpdatingTool"),
currentVersion: "1.9.9",
latestVersion: "2.1.0",
maintenanceCapabilities: selfUpdatingTool.resolve(),
}),
).toMatchObject({
status: "behind_latest",
updateCommand: null,
canUpdate: false,
message: "Update this provider manually, then refresh provider status.",
});
});

it("enables self-updates at the minimum supported provider version", () => {
expect(
createProviderVersionAdvisory({
driver: driver("selfUpdatingTool"),
currentVersion: "2.0.0",
latestVersion: "2.1.0",
maintenanceCapabilities: selfUpdatingTool.resolve(),
}),
).toMatchObject({
status: "behind_latest",
updateCommand: "self-updating-tool update",
canUpdate: true,
});
});

it("keeps self-update guidance manual when the provider version is not semver", () => {
expect(
createProviderVersionAdvisory({
driver: driver("selfUpdatingTool"),
currentVersion: "unknown",
latestVersion: "2.1.0",
maintenanceCapabilities: selfUpdatingTool.resolve(),
}),
).toMatchObject({
updateCommand: null,
canUpdate: false,
});
});

it.effect(
"switches package-managed providers to vite-plus updates when the resolved binary lives in vite-plus global bin",
() =>
Expand Down
35 changes: 31 additions & 4 deletions apps/server/src/provider/providerMaintenance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
type ServerProvider,
type ServerProviderVersionAdvisory,
} from "@t3tools/contracts";
import { compareSemverVersions } from "@t3tools/shared/semver";
import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver";
import { resolveCommandPath } from "@t3tools/shared/shell";
import * as Config from "effect/Config";
import * as Context from "effect/Context";
Expand Down Expand Up @@ -47,7 +47,9 @@ export interface ProviderMaintenanceCommandAction {
readonly command: string;
readonly executable: string;
readonly args: ReadonlyArray<string>;
readonly env?: NodeJS.ProcessEnv;
readonly lockKey: string;
readonly minimumVersion?: string;
}

export interface ProviderMaintenanceCapabilityResolutionOptions {
Expand Down Expand Up @@ -99,7 +101,9 @@ export function makeProviderMaintenanceCapabilities(input: {
readonly packageName: string | null;
readonly updateExecutable: string | null;
readonly updateArgs: ReadonlyArray<string>;
readonly updateEnv?: NodeJS.ProcessEnv;
readonly updateLockKey: string | null;
readonly updateMinimumVersion?: string;
}): ProviderMaintenanceCapabilities {
const update =
input.updateExecutable === null || input.updateLockKey === null
Expand All @@ -108,7 +112,9 @@ export function makeProviderMaintenanceCapabilities(input: {
command: [input.updateExecutable, ...input.updateArgs].join(" "),
executable: input.updateExecutable,
args: input.updateArgs,
...(input.updateEnv ? { env: input.updateEnv } : {}),
lockKey: input.updateLockKey,
...(input.updateMinimumVersion ? { minimumVersion: input.updateMinimumVersion } : {}),
};
return {
provider: input.provider,
Expand Down Expand Up @@ -408,18 +414,39 @@ export function createProviderVersionAdvisory(input: {
currentVersion: input.currentVersion,
latestVersion,
});
const canUpdate = canRunProviderMaintenanceUpdate(capabilities, input.currentVersion);

return {
status: advisory.status,
currentVersion: input.currentVersion,
latestVersion,
updateCommand: capabilities.update?.command ?? null,
canUpdate: capabilities.update !== null,
updateCommand: canUpdate ? (capabilities.update?.command ?? null) : null,
canUpdate,
checkedAt: input.checkedAt ?? null,
message: advisory.message,
message:
advisory.status === "behind_latest" && !canUpdate
? "Update this provider manually, then refresh provider status."
: advisory.message,
};
}

export function canRunProviderMaintenanceUpdate(
maintenanceCapabilities: ProviderMaintenanceCapabilities,
currentVersion: string | null | undefined,
): boolean {
const update = maintenanceCapabilities.update;
if (!update) {
return false;
}
if (!update.minimumVersion) {
return true;
}
if (!currentVersion || !parseSemver(currentVersion) || !parseSemver(update.minimumVersion)) {
return false;
}
return compareSemverVersions(currentVersion, update.minimumVersion) >= 0;
}

const fetchNpmLatestVersion = Effect.fn("fetchNpmLatestVersion")(function* (packageName: string) {
const client = yield* HttpClient.HttpClient;
const request = HttpClientRequest.get(
Expand Down
114 changes: 113 additions & 1 deletion apps/server/src/provider/providerMaintenanceRunner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,10 @@ function mockSpawnerLayer(
handler: (
command: string,
args: ReadonlyArray<string>,
options: {
readonly env?: Record<string, string | undefined>;
readonly extendEnv?: boolean;
},
) => {
readonly stdout?: string;
readonly stderr?: string;
Expand All @@ -142,8 +146,14 @@ function mockSpawnerLayer(
const childProcess = command as unknown as {
readonly command: string;
readonly args: ReadonlyArray<string>;
readonly options: {
readonly env?: Record<string, string | undefined>;
readonly extendEnv?: boolean;
};
};
return Effect.succeed(mockHandle(handler(childProcess.command, childProcess.args)));
return Effect.succeed(
mockHandle(handler(childProcess.command, childProcess.args, childProcess.options)),
);
}),
);
}
Expand Down Expand Up @@ -299,6 +309,108 @@ describe("providerMaintenanceRunner", () => {
);
});

it.effect("requires manual updates below a provider self-update minimum version", () => {
const calls: Array<{ command: string; args: ReadonlyArray<string> }> = [];
return Effect.gen(function* () {
const { registry } = yield* makeRegistry({
...baseProvider,
version: "0.125.0",
});
const updater = yield* makeTestRunner({
...registry,
getProviderMaintenanceCapabilitiesForInstance: () =>
Effect.succeed(
makeProviderMaintenanceCapabilities({
provider: CODEX_DRIVER,
packageName: "@openai/codex",
updateExecutable: "codex",
updateArgs: ["update"],
updateLockKey: "codex-self-update",
updateMinimumVersion: "0.126.0",
}),
),
});

const exit = yield* updater.updateProvider(CODEX_DRIVER).pipe(Effect.exit);
assert(Exit.isFailure(exit));
const error = Cause.squash(exit.cause);
assert(isServerProviderUpdateError(error));
assert.strictEqual(error.reason, "This installed provider version must be updated manually.");
assert.deepStrictEqual(calls, []);
}).pipe(
Effect.provide(
Layer.mergeAll(
NonWindowsPlatform,
latestVersionHttpClient("0.126.0"),
mockSpawnerLayer((command, args) => {
calls.push({ command, args });
return { stdout: "updated" };
}),
),
),
);
});

it.effect("preserves the provider environment when running a self-update", () => {
const calls: Array<{
command: string;
args: ReadonlyArray<string>;
env: Record<string, string | undefined> | undefined;
extendEnv: boolean | undefined;
}> = [];
const updateEnv = {
PATH: "/custom/codex/bin",
CODEX_HOME: "/custom/codex/home",
};
return Effect.gen(function* () {
const { registry } = yield* makeRegistry({
...baseProvider,
version: "0.126.0",
});
const updater = yield* makeTestRunner({
...registry,
getProviderMaintenanceCapabilitiesForInstance: () =>
Effect.succeed(
makeProviderMaintenanceCapabilities({
provider: CODEX_DRIVER,
packageName: "@openai/codex",
updateExecutable: "codex",
updateArgs: ["update"],
updateEnv,
updateLockKey: "codex-self-update",
updateMinimumVersion: "0.126.0",
}),
),
});

yield* updater.updateProvider(CODEX_DRIVER);
assert.deepStrictEqual(calls, [
{
command: "codex",
args: ["update"],
env: updateEnv,
extendEnv: true,
},
]);
}).pipe(
Effect.provide(
Layer.mergeAll(
NonWindowsPlatform,
latestVersionHttpClient("0.126.0"),
mockSpawnerLayer((command, args, options) => {
calls.push({
command,
args,
env: options.env,
extendEnv: options.extendEnv,
});
return { stdout: "updated" };
}),
),
),
);
});

it.effect(
"runs update commands through Effect ChildProcess when no test runner is injected",
() => {
Expand Down
Loading
Loading