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
13 changes: 13 additions & 0 deletions apps/server/src/process/externalLauncher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as Stream from "effect/Stream";
import * as SubscriptionRef from "effect/SubscriptionRef";
import * as ChildProcess from "effect/unstable/process/ChildProcess";
import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";

Expand Down Expand Up @@ -304,7 +306,9 @@ const resolveAvailableEditors = Effect.fn("externalLauncher.resolveAvailableEdit
export class ExternalLauncher extends Context.Service<
ExternalLauncher,
{
readonly availableEditors: Effect.Effect<ReadonlyArray<EditorId>>;
readonly resolveAvailableEditors: () => Effect.Effect<ReadonlyArray<EditorId>>;
readonly streamAvailableEditors: Stream.Stream<ReadonlyArray<EditorId>>;
/** Launch a URL target in the default browser. */
readonly launchBrowser: (target: string) => Effect.Effect<void, ExternalLauncherError>;
/**
Expand Down Expand Up @@ -434,6 +438,7 @@ export const make = Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const availableEditors = yield* SubscriptionRef.make<ReadonlyArray<EditorId>>([]);

const provideCommandResolutionServices = <A, E, R>(
effect: Effect.Effect<A, E, R | FileSystem.FileSystem | Path.Path>,
Expand All @@ -443,8 +448,16 @@ export const make = Effect.gen(function* () {
Effect.provideService(Path.Path, path),
);

yield* provideCommandResolutionServices(resolveAvailableEditors()).pipe(
Effect.flatMap((editors) => SubscriptionRef.set(availableEditors, editors)),
Effect.ignoreCause({ log: true }),
Effect.forkScoped,
);

return ExternalLauncher.of({
availableEditors: SubscriptionRef.get(availableEditors),
resolveAvailableEditors: () => provideCommandResolutionServices(resolveAvailableEditors()),
streamAvailableEditors: SubscriptionRef.changes(availableEditors),
launchBrowser: (target) =>
launchBrowser(target).pipe(
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
Expand Down
38 changes: 38 additions & 0 deletions apps/server/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -569,7 +569,9 @@ const buildAppUnderTest = (options?: {
),
Layer.provide(
Layer.mock(ExternalLauncher.ExternalLauncher)({
availableEditors: Effect.succeed([]),
resolveAvailableEditors: () => Effect.succeed([]),
streamAvailableEditors: Stream.empty,
...options?.layers?.externalLauncher,
}),
),
Expand Down Expand Up @@ -4287,6 +4289,42 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);

it.effect("streams editor availability without blocking the config snapshot", () =>
Effect.gen(function* () {
yield* buildAppUnderTest({
layers: {
externalLauncher: {
availableEditors: Effect.succeed([]),
resolveAvailableEditors: () => Effect.never,
streamAvailableEditors: Stream.succeed(["vscode"]),
},
},
});

const wsUrl = yield* getWsServerUrl("/ws");
const events = yield* Effect.scoped(
withWsRpcClient(wsUrl, (client) =>
client[WS_METHODS.subscribeServerConfig]({}).pipe(
Stream.take(2),
Stream.runCollect,
Effect.timeout("1 second"),
),
),
);

const [first, second] = Array.from(events);
assert.equal(first?.type, "snapshot");
if (first?.type === "snapshot") {
assert.deepEqual(first.config.availableEditors, []);
}
assert.deepEqual(second, {
version: 1,
type: "availableEditorsUpdated",
payload: { availableEditors: ["vscode"] },
});
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);

it.effect("routes websocket rpc subscribeServerConfig emits provider status updates", () =>
Effect.gen(function* () {
const nextProviders = [
Expand Down
14 changes: 12 additions & 2 deletions apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1083,7 +1083,7 @@ const makeWsRpcLayer = (
keybindings: keybindingsConfig.keybindings,
issues: keybindingsConfig.issues,
providers,
availableEditors: yield* externalLauncher.resolveAvailableEditors(),
availableEditors: yield* externalLauncher.availableEditors,
observability: {
logsDirectoryPath: config.logsDir,
localTracingEnabled: true,
Expand Down Expand Up @@ -2011,14 +2011,24 @@ const makeWsRpcLayer = (
payload: { settings },
})),
);
const availableEditorsUpdates = externalLauncher.streamAvailableEditors.pipe(
Stream.map((availableEditors) => ({
version: 1 as const,
type: "availableEditorsUpdated" as const,
payload: { availableEditors },
})),
);

yield* providerRegistry
.refresh()
.pipe(Effect.ignoreCause({ log: true }), Effect.forkScoped);

const liveUpdates = Stream.merge(
keybindingsUpdates,
Stream.merge(providerStatuses, settingsUpdates),
Stream.merge(
providerStatuses,
Stream.merge(settingsUpdates, availableEditorsUpdates),
),
);

return Stream.concat(
Expand Down
18 changes: 18 additions & 0 deletions packages/client-runtime/src/state/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,24 @@ describe("server state projection", () => {
expect(result.latestEvent.type).toBe("settingsUpdated");
});

it("applies editor availability updates without replacing the config snapshot", () => {
const snapshot = applyServerConfigProjection(Option.none(), {
version: 1,
type: "snapshot",
config: CONFIG,
});
const availableEditors: ServerConfig["availableEditors"] = ["vscode"];
const projected = applyServerConfigProjection(snapshot, {
version: 1,
type: "availableEditorsUpdated",
payload: { availableEditors },
});

const result = Option.getOrThrow(projected);
expect(result.config.availableEditors).toBe(availableEditors);
expect(result.latestEvent.type).toBe("availableEditorsUpdated");
});

it("retains welcome when a ready event follows in the same stream chunk", () => {
const welcome = {
environment: {} as ServerLifecycleWelcomePayload["environment"],
Expand Down
9 changes: 9 additions & 0 deletions packages/client-runtime/src/state/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,15 @@ export function applyServerConfigProjection(
latestEvent: event,
source: "live",
}));
case "availableEditorsUpdated":
return Option.map(current, (projection) => ({
config: {
...projection.config,
availableEditors: event.payload.availableEditors,
},
latestEvent: event,
source: "live",
}));
}
}

Expand Down
15 changes: 15 additions & 0 deletions packages/contracts/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,12 @@ export const ServerConfigSettingsUpdatedPayload = Schema.Struct({
});
export type ServerConfigSettingsUpdatedPayload = typeof ServerConfigSettingsUpdatedPayload.Type;

export const ServerConfigAvailableEditorsUpdatedPayload = Schema.Struct({
availableEditors: Schema.Array(EditorId),
});
export type ServerConfigAvailableEditorsUpdatedPayload =
typeof ServerConfigAvailableEditorsUpdatedPayload.Type;

export const ServerConfigStreamSnapshotEvent = Schema.Struct({
version: Schema.Literal(1),
type: Schema.Literal("snapshot"),
Expand Down Expand Up @@ -506,11 +512,20 @@ export const ServerConfigStreamSettingsUpdatedEvent = Schema.Struct({
export type ServerConfigStreamSettingsUpdatedEvent =
typeof ServerConfigStreamSettingsUpdatedEvent.Type;

export const ServerConfigStreamAvailableEditorsUpdatedEvent = Schema.Struct({
version: Schema.Literal(1),
type: Schema.Literal("availableEditorsUpdated"),
payload: ServerConfigAvailableEditorsUpdatedPayload,
});
export type ServerConfigStreamAvailableEditorsUpdatedEvent =
typeof ServerConfigStreamAvailableEditorsUpdatedEvent.Type;

export const ServerConfigStreamEvent = Schema.Union([
ServerConfigStreamSnapshotEvent,
ServerConfigStreamKeybindingsUpdatedEvent,
ServerConfigStreamProviderStatusesEvent,
ServerConfigStreamSettingsUpdatedEvent,
ServerConfigStreamAvailableEditorsUpdatedEvent,
]);
export type ServerConfigStreamEvent = typeof ServerConfigStreamEvent.Type;

Expand Down
Loading