Skip to content
Merged
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
34 changes: 22 additions & 12 deletions apps/server/src/provider/Layers/ProviderRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,11 @@ import * as Semaphore from "effect/Semaphore";

import { ServerConfig } from "../../config.ts";
import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts";
import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry.ts";
import {
ProviderRegistry,
type ProviderMaintenanceActionKind,
type ProviderRegistryShape,
} from "../Services/ProviderRegistry.ts";
import {
hydrateCachedProvider,
isCachedProviderCorrelated,
Expand Down Expand Up @@ -424,7 +428,13 @@ export const ProviderRegistryLive = Layer.effect(
);
const providersRef = yield* Ref.make<ReadonlyArray<ServerProvider>>(cachedProviders);
const maintenanceActionStatesRef = yield* Ref.make<
ReadonlyMap<ProviderInstanceId, { readonly update?: ServerProviderUpdateState | undefined }>
ReadonlyMap<
ProviderInstanceId,
{
readonly action: ProviderMaintenanceActionKind;
readonly state: ServerProviderUpdateState;
}
>
>(new Map());

// Live-source registry — the dynamic counterpart to the boot-time
Expand Down Expand Up @@ -467,7 +477,7 @@ export const ProviderRegistryLive = Layer.effect(
provider: ServerProvider,
) {
const maintenanceActionStates = yield* Ref.get(maintenanceActionStatesRef);
const updateState = maintenanceActionStates.get(provider.instanceId)?.update;
const updateState = maintenanceActionStates.get(provider.instanceId)?.state;
if (!updateState) {
const { updateState: _updateState, ...providerWithoutUpdateState } = provider;
return providerWithoutUpdateState;
Expand Down Expand Up @@ -547,23 +557,23 @@ export const ProviderRegistryLive = Layer.effect(
const setProviderMaintenanceActionState = Effect.fn("setProviderMaintenanceActionState")(
function* (input: {
readonly instanceId: ProviderInstanceId;
readonly action: "update";
readonly action: ProviderMaintenanceActionKind;
readonly state: ServerProviderUpdateState | null;
}) {
yield* Ref.update(maintenanceActionStatesRef, (previous) => {
const previousActions = previous.get(input.instanceId);
const nextActions = { ...previousActions };
if (input.state === null || input.state.status === "idle") {
delete nextActions[input.action];
} else {
nextActions[input.action] = input.state;
const isCleared = input.state === null || input.state.status === "idle";
const current = previous.get(input.instanceId);
// Clearing only retires this action's own state; a different action
// that has since taken over the instance keeps reporting.
if (isCleared && current?.action !== input.action) {
return previous;
}

const next = new Map(previous);
if (Object.keys(nextActions).length === 0) {
if (isCleared || input.state === null) {
next.delete(input.instanceId);
} else {
next.set(input.instanceId, nextActions);
next.set(input.instanceId, { action: input.action, state: input.state });
}
return next;
});
Expand Down
10 changes: 6 additions & 4 deletions apps/server/src/provider/Services/ProviderRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import type * as Effect from "effect/Effect";
import type * as Stream from "effect/Stream";
import type { ProviderMaintenanceCapabilities } from "../providerMaintenance.ts";

export type ProviderMaintenanceActionKind = "update";
export type ProviderMaintenanceActionKind = "update" | "install";

export interface ProviderRegistryShape {
/**
Expand Down Expand Up @@ -73,9 +73,11 @@ export interface ProviderRegistryShape {

/**
* Apply volatile maintenance-action state to one configured instance.
* This state is never persisted to disk. Today only update actions are
* projected onto `ServerProvider.updateState`; install/auth actions can
* extend this action map without adding driver-scoped APIs.
* This state is never persisted to disk. An instance runs at most one
* maintenance action at a time (they share the runner's per-instance lock),
* so the latest action's state is the one projected onto
* `ServerProvider.updateState`; a later action replaces an earlier one's
* state rather than competing with it.
*/
readonly setProviderMaintenanceActionState: (input: {
readonly instanceId: ProviderInstanceId;
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/provider/makeManagedServerProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const maintenanceCapabilities = {

lockKey: "npm-global",
},
install: null,
manualUpdateCommand: null,
advisoryMessage: null,
} as const;
Expand Down
128 changes: 113 additions & 15 deletions apps/server/src/provider/providerMaintenance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ import {
} from "./providerMaintenance.ts";

const driver = (value: string) => ProviderDriverKind.make(value);
const noManualUpdate = {
const noInstallOrManualUpdate = {
install: null,
manualUpdateCommand: null,
advisoryMessage: null,
} as const;
Expand All @@ -31,6 +32,25 @@ const makeTempDir = Effect.fn("makeTempDir")(function* (name: string) {
return path.join(os.tmpdir(), `${name}-${id}`);
});

const WINDOWS_PATHEXT = ".COM;.EXE;.BAT;.CMD";

/**
* Put an executable named `name` in `dir` for the platform the test is
* actually running on, so PATH lookups behave the way they would in
* production. Returns the directory, for use as a PATH entry.
*/
function writeCommandShim(dir: string, name: string): string {
mkdirSync(dir, { recursive: true });
if (process.platform === "win32") {
writeFileSync(path.join(dir, `${name}.cmd`), "@echo off\r\n");
return dir;
}
const commandPath = path.join(dir, name);
writeFileSync(commandPath, "#!/bin/sh\n");
chmodSync(commandPath, 0o755);
return dir;
}

function linkPackageCommand(input: {
readonly packageBinDir: string;
readonly packageBinPath: string;
Expand Down Expand Up @@ -177,7 +197,7 @@ describe("providerMaintenance", () => {

lockKey: "static-tool",
},
...noManualUpdate,
...noInstallOrManualUpdate,
});
});

Expand Down Expand Up @@ -212,7 +232,7 @@ describe("providerMaintenance", () => {

lockKey: "vite-plus-global",
},
...noManualUpdate,
...noInstallOrManualUpdate,
});
}),
);
Expand Down Expand Up @@ -247,7 +267,7 @@ describe("providerMaintenance", () => {

lockKey: "bun-global",
},
...noManualUpdate,
...noInstallOrManualUpdate,
});
}),
);
Expand Down Expand Up @@ -283,7 +303,7 @@ describe("providerMaintenance", () => {

lockKey: "pnpm-global",
},
...noManualUpdate,
...noInstallOrManualUpdate,
});
}),
);
Expand Down Expand Up @@ -316,7 +336,7 @@ describe("providerMaintenance", () => {

environmentPatch: { NPM_CONFIG_PREFIX: npmPrefix },
},
...noManualUpdate,
...noInstallOrManualUpdate,
});
});

Expand Down Expand Up @@ -363,7 +383,7 @@ describe("providerMaintenance", () => {

lockKey: "homebrew",
},
...noManualUpdate,
...noInstallOrManualUpdate,
});
});

Expand Down Expand Up @@ -398,7 +418,7 @@ describe("providerMaintenance", () => {

lockKey: "native-package-tool-native",
},
...noManualUpdate,
...noInstallOrManualUpdate,
});
}),
);
Expand Down Expand Up @@ -431,6 +451,7 @@ describe("providerMaintenance", () => {

lockKey: "native-package-tool-installer-win32",
},
install: null,
manualUpdateCommand: null,
advisoryMessage:
"Run the native-package-tool Windows installer instead of native-package-tool update.",
Expand All @@ -456,7 +477,7 @@ describe("providerMaintenance", () => {

lockKey: "native-package-tool-native",
},
...noManualUpdate,
...noInstallOrManualUpdate,
});
});

Expand Down Expand Up @@ -517,11 +538,88 @@ describe("providerMaintenance", () => {

lockKey: "scoped-package-tool-native",
},
...noManualUpdate,
...noInstallOrManualUpdate,
});
}),
);

it.effect("derives an npm global install for a provider CLI that resolves nowhere on PATH", () =>
Effect.gen(function* () {
const tempDir = yield* makeTempDir("t3-npm-install-capabilities");
const npmBinDir = writeCommandShim(path.join(tempDir, "npm-bin"), "npm");

expect(
packageToolUpdate.resolve({
binaryPath: "package-tool",
platform: process.platform,
env: { PATH: npmBinDir, PATHEXT: WINDOWS_PATHEXT },
}).install,
).toEqual({
command: "npm install -g @example/package-tool@latest",
executable: "npm",
args: ["install", "-g", "@example/package-tool@latest"],
lockKey: "npm-global",
});
}),
);

it.effect("offers no install capability when npm itself is missing", () =>
Effect.gen(function* () {
const tempDir = yield* makeTempDir("t3-npm-install-capabilities-missing");
const emptyBinDir = writeCommandShim(path.join(tempDir, "empty-bin"), "unrelated-tool");

expect(
packageToolUpdate.resolve({
binaryPath: "package-tool",
platform: process.platform,
env: { PATH: emptyBinDir, PATHEXT: WINDOWS_PATHEXT },
}).install,
).toBeNull();
}),
);

it.effect("scopes the derived install to the configured npm prefix", () =>
Effect.gen(function* () {
const tempDir = yield* makeTempDir("t3-npm-install-capabilities-prefix");
const npmBinDir = writeCommandShim(path.join(tempDir, "npm-bin"), "npm");
const npmPrefix = path.join(tempDir, "npm prefix");

expect(
packageToolUpdate.resolve({
binaryPath: "package-tool",
platform: process.platform,
env: {
PATH: npmBinDir,
PATHEXT: WINDOWS_PATHEXT,
NPM_CONFIG_PREFIX: npmPrefix,
},
}).install,
).toEqual({
command: `npm --prefix "${npmPrefix}" install -g @example/package-tool@latest`,
executable: "npm",
args: ["install", "-g", "@example/package-tool@latest"],
lockKey: "npm-global",
environmentPatch: { NPM_CONFIG_PREFIX: npmPrefix },
});
}),
);

it.effect("keeps an installed provider free of an install capability", () =>
Effect.gen(function* () {
const tempDir = yield* makeTempDir("t3-npm-install-capabilities-installed");
const npmBinDir = writeCommandShim(path.join(tempDir, "npm-bin"), "npm");
writeCommandShim(npmBinDir, "package-tool");

expect(
packageToolUpdate.resolve({
binaryPath: "package-tool",
platform: process.platform,
env: { PATH: npmBinDir, PATHEXT: WINDOWS_PATHEXT },
}).install,
).toBeNull();
}),
);

it("switches native-package-tool to Homebrew updates when the binary resolves through Homebrew", () => {
expect(
nativePackageToolUpdate.resolve({
Expand All @@ -543,7 +641,7 @@ describe("providerMaintenance", () => {

lockKey: "homebrew",
},
...noManualUpdate,
...noInstallOrManualUpdate,
});
});

Expand All @@ -568,7 +666,7 @@ describe("providerMaintenance", () => {

lockKey: "homebrew",
},
...noManualUpdate,
...noInstallOrManualUpdate,
});
});

Expand Down Expand Up @@ -612,7 +710,7 @@ describe("providerMaintenance", () => {

lockKey: "npm-global",
},
...noManualUpdate,
...noInstallOrManualUpdate,
});
}),
);
Expand Down Expand Up @@ -661,7 +759,7 @@ describe("providerMaintenance", () => {

lockKey: "pnpm-global",
},
...noManualUpdate,
...noInstallOrManualUpdate,
});
}),
);
Expand All @@ -680,7 +778,7 @@ describe("providerMaintenance", () => {
provider: driver("packageTool"),
packageName: "@example/package-tool",
update: null,
...noManualUpdate,
...noInstallOrManualUpdate,
});
});
});
Loading
Loading