Skip to content

Commit 606fd05

Browse files
sanchitrkclaude
andcommitted
feat(desktop): keep provider status in the app server and simplify downtime notice
The app server now checks the servers at launch, on an interval (300s by default, set in Settings > Providers > Advanced, 0 turns it off), after any change made in the app, and when a call to the default server fails. It also watches the default server's process every 5s, so a server started or stopped outside the app shows within seconds. The status is pushed to the app over `servers.watch`, so the sidebar, composer and settings all read one state and never disagree. The sidebar and composer replace the server-down blocks and notices with a single "Open Provider Settings" button; queries that failed while the server was down load again once it is back. The splash stays over the mounted app until the first status arrives, then fades. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent f92d684 commit 606fd05

23 files changed

Lines changed: 687 additions & 396 deletions

File tree

‎apps/contract/src/contract.ts‎

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,15 @@ export const ServerStatus = Schema.Struct({
163163
});
164164
export type ServerStatus = typeof ServerStatus.Type;
165165

166+
/** How often every server is checked again in the background. */
167+
export const ServerHealth = Schema.Struct({
168+
/** Seconds between checks; 0 turns them off. */
169+
interval: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
170+
});
171+
export type ServerHealth = typeof ServerHealth.Type;
172+
173+
export const DEFAULT_SERVER_HEALTH: ServerHealth = { interval: 300 };
174+
166175
export const CliAction = Schema.Literals(["start", "restart", "stop"]);
167176
export type CliAction = typeof CliAction.Type;
168177

@@ -242,9 +251,24 @@ export const Api = RpcGroup.make(
242251
success: Schema.Void,
243252
error: DeskError,
244253
}),
245-
/** Every server, the built-in one first, then in the order added. */
246-
Rpc.make("servers.list", {
254+
/**
255+
* Every server, the built-in one first, then in the order added: as last checked, then again each
256+
* time a check finds a change.
257+
*/
258+
Rpc.make("servers.watch", {
247259
success: Schema.Array(ServerStatus),
260+
stream: true,
261+
}),
262+
/** Checks every server now; done once the new statuses are out. */
263+
Rpc.make("servers.check", {
264+
success: Schema.Void,
265+
}),
266+
Rpc.make("servers.health", {
267+
success: ServerHealth,
268+
}),
269+
Rpc.make("servers.setHealth", {
270+
payload: ServerHealth,
271+
success: Schema.Void,
248272
}),
249273
Rpc.make("servers.add", {
250274
payload: ServerAdd,

‎apps/server/src/cli.ts‎

Lines changed: 63 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/process";
44
import os from "node:os";
55
import path from "node:path";
66
import { Codework } from "./codework.ts";
7-
import {
8-
type CliAction,
9-
type CliInstall,
10-
type CliServer,
11-
type ServerAdd,
12-
ServersError,
13-
type ServerStatus,
14-
type ServerUpdate,
15-
} from "@codeworksh/contract";
7+
import { type CliAction, type CliInstall, type CliServer, ServersError, type ServerStatus } from "@codeworksh/contract";
168
import { Servers } from "./servers.ts";
179

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

126+
/** The server a home names, while its process lives. */
127+
export const liveAt = (home: string) =>
128+
Effect.gen(function* () {
129+
const registered = yield* Codework.registrationAt(home);
130+
return Option.isSome(registered) && (yield* alive(registered.value.pid)) ? registered : Option.none();
131+
});
132+
134133
/**
135134
* The server a home names. The default one is asked through the app's own connection, since a build
136135
* whose replies differ still answers `server.info`: a call the app depends on decides whether it
137136
* works with this app. Any other is taken from its registration and process.
138137
*/
139-
const serverAt = (home: string, isDefault: boolean) =>
138+
export const serverAt = (home: string, isDefault: boolean) =>
140139
Effect.gen(function* () {
141-
const registered = yield* Codework.registrationAt(home);
142-
if (Option.isNone(registered) || !(yield* alive(registered.value.pid))) return { _tag: "Stopped" } as const;
140+
const registered = yield* liveAt(home);
141+
if (Option.isNone(registered)) return { _tag: "Stopped" } as const;
143142
const { url, pid, version: registeredVersion } = registered.value;
144143
if (!isDefault) return { _tag: "Running", url, pid, version: registeredVersion } satisfies CliServer;
145144
const info = yield* Codework.call((client) => client("server.info", {})).pipe(Effect.option);
@@ -151,7 +150,7 @@ const serverAt = (home: string, isDefault: boolean) =>
151150

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

163-
export const handlers = {
164-
"servers.list": Effect.fn("servers.list")(function* () {
165-
const servers = yield* Servers.Service;
166-
const foundOnce = yield* Effect.cached(found);
167-
return yield* Effect.forEach(
168-
yield* servers.list,
169-
(row) =>
170-
Effect.gen(function* () {
171-
const home = servers.homeOf(row);
172-
const [cli, server] = yield* Effect.all(
173-
[executableOf(row, foundOnce), serverAt(home.path, row.isDefault)],
174-
{
175-
concurrency: "unbounded",
176-
},
177-
);
178-
return {
179-
id: row.id,
180-
kind: row.kind,
181-
name: row.name,
182-
isDefault: row.isDefault,
183-
home,
184-
cli,
185-
server,
186-
args: row.args,
187-
env: row.env.map(({ name, sensitive, value }) =>
188-
sensitive ? { name, sensitive } : { name, sensitive, value },
189-
),
190-
} satisfies ServerStatus;
191-
}),
192-
{ concurrency: "unbounded" },
193-
);
194-
}),
195-
196-
"servers.add": Effect.fn("servers.add")(function* (input: ServerAdd) {
197-
return yield* (yield* Servers.Service).add(input);
198-
}),
199-
200-
"servers.update": Effect.fn("servers.update")(function* (input: ServerUpdate) {
201-
const servers = yield* Servers.Service;
202-
const before = yield* servers.defaultHome;
203-
yield* servers.update(input);
204-
if ((yield* servers.defaultHome) !== before) yield* awaitConnection;
205-
}),
206-
207-
"servers.remove": Effect.fn("servers.remove")(function* ({ id }: { readonly id: string }) {
208-
yield* (yield* Servers.Service).remove(id);
209-
}),
210-
211-
"servers.setDefault": Effect.fn("servers.setDefault")(function* ({ id }: { readonly id: string }) {
212-
const servers = yield* Servers.Service;
213-
const before = yield* servers.defaultHome;
214-
yield* servers.setDefault(id);
215-
if ((yield* servers.defaultHome) !== before) yield* awaitConnection;
216-
}),
217-
218-
// The CLI owns a server's lifecycle; the app only asks it, with the server's variables and in its
219-
// home, which a variable never overrides.
220-
"servers.service": Effect.fn("servers.service")(function* ({
221-
id,
222-
action,
223-
}: {
224-
readonly id: string;
225-
readonly action: CliAction;
226-
}) {
227-
const servers = yield* Servers.Service;
228-
const row = yield* servers.get(id);
229-
const cli = yield* executableOf(row, found);
230-
if (cli === null || !("version" in cli))
231-
return yield* new ServersError({ message: "No working codework CLI was found." });
232-
const env = Object.fromEntries(row.env.map(({ name, value }) => [name, value]));
233-
// `stop` takes no server options, so the arguments go only to what starts a server.
234-
const args = action === "stop" ? [] : Servers.splitArgs(row.args);
235-
const { file, prefix } = row.executable === null ? { file: cli.path, prefix: [] } : split(row.executable);
236-
const { output, code } = yield* run(file, [...prefix, "service", action, ...args], "30 seconds", {
237-
...env,
238-
CODEWORK_HOME_DIR: servers.homeOf(row).path,
239-
}).pipe(
240-
Effect.mapError((error) => new ServersError({ message: `codework service ${action}: ${error.message}` })),
241-
);
242-
if (code !== 0) return yield* new ServersError({ message: `codework service ${action} ${reason(output, code)}` });
243-
if (row.isDefault && action !== "stop") yield* awaitConnection;
244-
}),
245-
};
162+
/** Every server and how it is set up, with its executable and process as found now. */
163+
export const statuses = Effect.gen(function* () {
164+
const servers = yield* Servers.Service;
165+
const foundOnce = yield* Effect.cached(found);
166+
return yield* Effect.forEach(
167+
yield* servers.list,
168+
(row) =>
169+
Effect.gen(function* () {
170+
const home = servers.homeOf(row);
171+
const [cli, server] = yield* Effect.all(
172+
[executableOf(row, foundOnce), serverAt(home.path, row.isDefault)],
173+
{
174+
concurrency: "unbounded",
175+
},
176+
);
177+
return {
178+
id: row.id,
179+
kind: row.kind,
180+
name: row.name,
181+
isDefault: row.isDefault,
182+
home,
183+
cli,
184+
server,
185+
args: row.args,
186+
env: row.env.map(({ name, sensitive, value }) =>
187+
sensitive ? { name, sensitive } : { name, sensitive, value },
188+
),
189+
} satisfies ServerStatus;
190+
}),
191+
{ concurrency: "unbounded" },
192+
);
193+
});
194+
195+
// The CLI owns a server's lifecycle; the app only asks it, with the server's variables and in its
196+
// home, which a variable never overrides.
197+
export const service = Effect.fn("Cli.service")(function* (row: Servers.Row, action: CliAction) {
198+
const servers = yield* Servers.Service;
199+
const cli = yield* executableOf(row, found);
200+
if (cli === null || !("version" in cli))
201+
return yield* new ServersError({ message: "No working codework CLI was found." });
202+
const env = Object.fromEntries(row.env.map(({ name, value }) => [name, value]));
203+
// `stop` takes no server options, so the arguments go only to what starts a server.
204+
const args = action === "stop" ? [] : Servers.splitArgs(row.args);
205+
const { file, prefix } = row.executable === null ? { file: cli.path, prefix: [] } : split(row.executable);
206+
const { output, code } = yield* run(file, [...prefix, "service", action, ...args], "30 seconds", {
207+
...env,
208+
CODEWORK_HOME_DIR: servers.homeOf(row).path,
209+
}).pipe(Effect.mapError((error) => new ServersError({ message: `codework service ${action}: ${error.message}` })));
210+
if (code !== 0) return yield* new ServersError({ message: `codework service ${action} ${reason(output, code)}` });
211+
if (row.isDefault && action !== "stop") yield* awaitConnection;
212+
});
246213

247214
export * as Cli from "./cli.ts";

‎apps/server/src/handlers.ts‎

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
11
import { Effect } from "effect";
22
import { Cli } from "./cli.ts";
33
import { Codework } from "./codework.ts";
4-
import { Contract, ModelsError, type Project } from "@codeworksh/contract";
4+
import {
5+
type CliAction,
6+
Contract,
7+
ModelsError,
8+
type Project,
9+
type ServerAdd,
10+
type ServerUpdate,
11+
} from "@codeworksh/contract";
512
import { Desk } from "./desk.ts";
613
import { Event } from "./event.ts";
14+
import { Health } from "./health.ts";
715
import { Kv } from "./kv.ts";
16+
import { Servers } from "./servers.ts";
817
import { Workspaces } from "./workspaces.ts";
918

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

4465
return Contract.Api.of({
4566
...workspaces,
4667
...desk,
47-
...Cli.handlers,
4868

4969
"projects.list": Effect.fn("projects.list")(function* () {
50-
const sessions = yield* Codework.call((client) => client("session.list", {}));
51-
return byProject(sessions);
70+
return byProject(yield* call((client) => client("session.list", {})));
5271
}),
5372

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

94+
"servers.watch": () => health.changes,
95+
"servers.check": () => health.check,
96+
"servers.health": () => health.settings,
97+
"servers.setHealth": (settings) => health.setSettings(settings),
98+
"servers.add": Effect.fn("servers.add")(function* (input: ServerAdd) {
99+
return yield* servers.add(input).pipe(Effect.tap(() => health.check));
100+
}),
101+
"servers.update": Effect.fn("servers.update")(function* (input: ServerUpdate) {
102+
yield* changingDefault(servers.update(input));
103+
yield* health.check;
104+
}),
105+
"servers.remove": Effect.fn("servers.remove")(function* ({ id }: { readonly id: string }) {
106+
yield* servers.remove(id);
107+
yield* health.check;
108+
}),
109+
"servers.setDefault": Effect.fn("servers.setDefault")(function* ({ id }: { readonly id: string }) {
110+
yield* changingDefault(servers.setDefault(id));
111+
yield* health.check;
112+
}),
113+
"servers.service": Effect.fn("servers.service")(function* ({
114+
id,
115+
action,
116+
}: {
117+
readonly id: string;
118+
readonly action: CliAction;
119+
}) {
120+
// Checked again whether it worked or not, so the status shows what the CLI left.
121+
yield* Cli.service(yield* servers.get(id), action).pipe(Effect.ensuring(health.check));
122+
}),
123+
75124
"kv.get": ({ package: pkg, key }) => kv.get(pkg, key),
76125
"kv.set": ({ package: pkg, key, value }) => kv.set(pkg, key, value),
77126

0 commit comments

Comments
 (0)