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
28 changes: 26 additions & 2 deletions apps/contract/src/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,15 @@ export const ServerStatus = Schema.Struct({
});
export type ServerStatus = typeof ServerStatus.Type;

/** How often every server is checked again in the background. */
export const ServerHealth = Schema.Struct({
/** Seconds between checks; 0 turns them off. */
interval: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
});
export type ServerHealth = typeof ServerHealth.Type;

export const DEFAULT_SERVER_HEALTH: ServerHealth = { interval: 300 };

export const CliAction = Schema.Literals(["start", "restart", "stop"]);
export type CliAction = typeof CliAction.Type;

Expand Down Expand Up @@ -242,9 +251,24 @@ export const Api = RpcGroup.make(
success: Schema.Void,
error: DeskError,
}),
/** Every server, the built-in one first, then in the order added. */
Rpc.make("servers.list", {
/**
* Every server, the built-in one first, then in the order added: as last checked, then again each
* time a check finds a change.
*/
Rpc.make("servers.watch", {
success: Schema.Array(ServerStatus),
stream: true,
}),
/** Checks every server now; done once the new statuses are out. */
Rpc.make("servers.check", {
success: Schema.Void,
}),
Rpc.make("servers.health", {
success: ServerHealth,
}),
Rpc.make("servers.setHealth", {
payload: ServerHealth,
success: Schema.Void,
}),
Rpc.make("servers.add", {
payload: ServerAdd,
Expand Down
159 changes: 63 additions & 96 deletions apps/server/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/process";
import os from "node:os";
import path from "node:path";
import { Codework } from "./codework.ts";
import {
type CliAction,
type CliInstall,
type CliServer,
type ServerAdd,
ServersError,
type ServerStatus,
type ServerUpdate,
} from "@codeworksh/contract";
import { type CliAction, type CliInstall, type CliServer, ServersError, type ServerStatus } from "@codeworksh/contract";
import { Servers } from "./servers.ts";

// Where the install script puts the binary unless told otherwise.
Expand Down Expand Up @@ -131,15 +123,22 @@ const alive = (pid: number) =>
}
});

/** The server a home names, while its process lives. */
export const liveAt = (home: string) =>
Effect.gen(function* () {
const registered = yield* Codework.registrationAt(home);
return Option.isSome(registered) && (yield* alive(registered.value.pid)) ? registered : Option.none();
});

/**
* The server a home names. The default one is asked through the app's own connection, since a build
* whose replies differ still answers `server.info`: a call the app depends on decides whether it
* works with this app. Any other is taken from its registration and process.
*/
const serverAt = (home: string, isDefault: boolean) =>
export const serverAt = (home: string, isDefault: boolean) =>
Effect.gen(function* () {
const registered = yield* Codework.registrationAt(home);
if (Option.isNone(registered) || !(yield* alive(registered.value.pid))) return { _tag: "Stopped" } as const;
const registered = yield* liveAt(home);
if (Option.isNone(registered)) return { _tag: "Stopped" } as const;
const { url, pid, version: registeredVersion } = registered.value;
if (!isDefault) return { _tag: "Running", url, pid, version: registeredVersion } satisfies CliServer;
const info = yield* Codework.call((client) => client("server.info", {})).pipe(Effect.option);
Expand All @@ -151,7 +150,7 @@ const serverAt = (home: string, isDefault: boolean) =>

// The app's connection reconnects with a backoff; waiting until it reaches the server keeps the
// status read right after from calling a healthy server unreachable. Only when there is one to reach.
const awaitConnection = Effect.gen(function* () {
export const awaitConnection = Effect.gen(function* () {
if (Option.isNone(yield* Codework.registration)) return;
yield* Codework.call((client) => client("server.info", {})).pipe(
Effect.retry(Schedule.spaced("250 millis")),
Expand All @@ -160,88 +159,56 @@ const awaitConnection = Effect.gen(function* () {
);
});

export const handlers = {
"servers.list": Effect.fn("servers.list")(function* () {
const servers = yield* Servers.Service;
const foundOnce = yield* Effect.cached(found);
return yield* Effect.forEach(
yield* servers.list,
(row) =>
Effect.gen(function* () {
const home = servers.homeOf(row);
const [cli, server] = yield* Effect.all(
[executableOf(row, foundOnce), serverAt(home.path, row.isDefault)],
{
concurrency: "unbounded",
},
);
return {
id: row.id,
kind: row.kind,
name: row.name,
isDefault: row.isDefault,
home,
cli,
server,
args: row.args,
env: row.env.map(({ name, sensitive, value }) =>
sensitive ? { name, sensitive } : { name, sensitive, value },
),
} satisfies ServerStatus;
}),
{ concurrency: "unbounded" },
);
}),

"servers.add": Effect.fn("servers.add")(function* (input: ServerAdd) {
return yield* (yield* Servers.Service).add(input);
}),

"servers.update": Effect.fn("servers.update")(function* (input: ServerUpdate) {
const servers = yield* Servers.Service;
const before = yield* servers.defaultHome;
yield* servers.update(input);
if ((yield* servers.defaultHome) !== before) yield* awaitConnection;
}),

"servers.remove": Effect.fn("servers.remove")(function* ({ id }: { readonly id: string }) {
yield* (yield* Servers.Service).remove(id);
}),

"servers.setDefault": Effect.fn("servers.setDefault")(function* ({ id }: { readonly id: string }) {
const servers = yield* Servers.Service;
const before = yield* servers.defaultHome;
yield* servers.setDefault(id);
if ((yield* servers.defaultHome) !== before) yield* awaitConnection;
}),

// The CLI owns a server's lifecycle; the app only asks it, with the server's variables and in its
// home, which a variable never overrides.
"servers.service": Effect.fn("servers.service")(function* ({
id,
action,
}: {
readonly id: string;
readonly action: CliAction;
}) {
const servers = yield* Servers.Service;
const row = yield* servers.get(id);
const cli = yield* executableOf(row, found);
if (cli === null || !("version" in cli))
return yield* new ServersError({ message: "No working codework CLI was found." });
const env = Object.fromEntries(row.env.map(({ name, value }) => [name, value]));
// `stop` takes no server options, so the arguments go only to what starts a server.
const args = action === "stop" ? [] : Servers.splitArgs(row.args);
const { file, prefix } = row.executable === null ? { file: cli.path, prefix: [] } : split(row.executable);
const { output, code } = yield* run(file, [...prefix, "service", action, ...args], "30 seconds", {
...env,
CODEWORK_HOME_DIR: servers.homeOf(row).path,
}).pipe(
Effect.mapError((error) => new ServersError({ message: `codework service ${action}: ${error.message}` })),
);
if (code !== 0) return yield* new ServersError({ message: `codework service ${action} ${reason(output, code)}` });
if (row.isDefault && action !== "stop") yield* awaitConnection;
}),
};
/** Every server and how it is set up, with its executable and process as found now. */
export const statuses = Effect.gen(function* () {
const servers = yield* Servers.Service;
const foundOnce = yield* Effect.cached(found);
return yield* Effect.forEach(
yield* servers.list,
(row) =>
Effect.gen(function* () {
const home = servers.homeOf(row);
const [cli, server] = yield* Effect.all(
[executableOf(row, foundOnce), serverAt(home.path, row.isDefault)],
{
concurrency: "unbounded",
},
);
return {
id: row.id,
kind: row.kind,
name: row.name,
isDefault: row.isDefault,
home,
cli,
server,
args: row.args,
env: row.env.map(({ name, sensitive, value }) =>
sensitive ? { name, sensitive } : { name, sensitive, value },
),
} satisfies ServerStatus;
}),
{ concurrency: "unbounded" },
);
});

// The CLI owns a server's lifecycle; the app only asks it, with the server's variables and in its
// home, which a variable never overrides.
export const service = Effect.fn("Cli.service")(function* (row: Servers.Row, action: CliAction) {
const servers = yield* Servers.Service;
const cli = yield* executableOf(row, found);
if (cli === null || !("version" in cli))
return yield* new ServersError({ message: "No working codework CLI was found." });
const env = Object.fromEntries(row.env.map(({ name, value }) => [name, value]));
// `stop` takes no server options, so the arguments go only to what starts a server.
const args = action === "stop" ? [] : Servers.splitArgs(row.args);
const { file, prefix } = row.executable === null ? { file: cli.path, prefix: [] } : split(row.executable);
const { output, code } = yield* run(file, [...prefix, "service", action, ...args], "30 seconds", {
...env,
CODEWORK_HOME_DIR: servers.homeOf(row).path,
}).pipe(Effect.mapError((error) => new ServersError({ message: `codework service ${action}: ${error.message}` })));
if (code !== 0) return yield* new ServersError({ message: `codework service ${action} ${reason(output, code)}` });
if (row.isDefault && action !== "stop") yield* awaitConnection;
});

export * as Cli from "./cli.ts";
59 changes: 54 additions & 5 deletions apps/server/src/handlers.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
import { Effect } from "effect";
import { Cli } from "./cli.ts";
import { Codework } from "./codework.ts";
import { Contract, ModelsError, type Project } from "@codeworksh/contract";
import {
type CliAction,
Contract,
ModelsError,
type Project,
type ServerAdd,
type ServerUpdate,
} from "@codeworksh/contract";
import { Desk } from "./desk.ts";
import { Event } from "./event.ts";
import { Health } from "./health.ts";
import { Kv } from "./kv.ts";
import { Servers } from "./servers.ts";
import { Workspaces } from "./workspaces.ts";

/**
Expand Down Expand Up @@ -40,20 +49,30 @@ export const layer = Contract.Api.toLayer(
const desk = yield* Desk.handlers;
const events = yield* Event.Service;
const kv = yield* Kv.Service;
const servers = yield* Servers.Service;
const health = yield* Health.Service;
// A failed call is the first sign the default server went down.
const call = <A, E>(use: Parameters<typeof Codework.call<A, E>>[0]) =>
Codework.call(use).pipe(Effect.tapError(() => health.poke));
// A changed default reconnects the app; the status read after waits for it to reach the server.
const changingDefault = <A, E, R>(change: Effect.Effect<A, E, R>) =>
Effect.gen(function* () {
const before = yield* servers.defaultHome;
yield* change;
if ((yield* servers.defaultHome) !== before) yield* Cli.awaitConnection;
});

return Contract.Api.of({
...workspaces,
...desk,
...Cli.handlers,

"projects.list": Effect.fn("projects.list")(function* () {
const sessions = yield* Codework.call((client) => client("session.list", {}));
return byProject(sessions);
return byProject(yield* call((client) => client("session.list", {})));
}),

// The catalog's own errors are kept apart from an unreachable server, so their detail shows.
"models.list": Effect.fn("models.list")(function* ({ hostDir }) {
const listed = yield* Codework.call((client) =>
const listed = yield* call((client) =>
client("model.list", hostDir === undefined ? {} : { hostDir }).pipe(
Effect.map((list) => ({ list })),
Effect.catchTags({
Expand All @@ -72,6 +91,36 @@ export const layer = Contract.Api.toLayer(
return listed.list;
}),

"servers.watch": () => health.changes,
"servers.check": () => health.check,
"servers.health": () => health.settings,
"servers.setHealth": (settings) => health.setSettings(settings),
"servers.add": Effect.fn("servers.add")(function* (input: ServerAdd) {
return yield* servers.add(input).pipe(Effect.tap(() => health.check));
}),
"servers.update": Effect.fn("servers.update")(function* (input: ServerUpdate) {
yield* changingDefault(servers.update(input));
yield* health.check;
}),
"servers.remove": Effect.fn("servers.remove")(function* ({ id }: { readonly id: string }) {
yield* servers.remove(id);
yield* health.check;
}),
"servers.setDefault": Effect.fn("servers.setDefault")(function* ({ id }: { readonly id: string }) {
yield* changingDefault(servers.setDefault(id));
yield* health.check;
}),
"servers.service": Effect.fn("servers.service")(function* ({
id,
action,
}: {
readonly id: string;
readonly action: CliAction;
}) {
// Checked again whether it worked or not, so the status shows what the CLI left.
yield* Cli.service(yield* servers.get(id), action).pipe(Effect.ensuring(health.check));
}),

"kv.get": ({ package: pkg, key }) => kv.get(pkg, key),
"kv.set": ({ package: pkg, key, value }) => kv.set(pkg, key, value),

Expand Down
Loading
Loading