Skip to content

Commit f4ab9bd

Browse files
IAmJSDclaude
andcommitted
feat(web): start and stop managed VM instances through hooks
VM management solutions had no way to boot or shut down the machine hosting a T3 server from inside T3 Code, so users had to open the management app before every remote session. The server's settings gain nullable startHookUrl/stopHookUrl, delivered to clients inside the cached server config. On Settings -> Connections, Connect now POSTs the start hook first, renders its component form when the endpoint asks for input, polls until the instance reports ready with a 204, then runs the normal connect flow. Connected environments with a stop hook show a Stop button backed by a new server.runStopHook RPC; the server DELETEs the endpoint and clears the setting on a 404 so clients drop the control. Done by Claude Fable 5 on Claude Code. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 646f549 commit f4ab9bd

16 files changed

Lines changed: 1156 additions & 20 deletions

File tree

apps/server/src/auth/RpcAuthorization.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export const RPC_REQUIRED_SCOPES = {
3939
[WS_METHODS.serverRemoveKeybinding]: AuthOrchestrationOperateScope,
4040
[WS_METHODS.serverGetSettings]: AuthOrchestrationReadScope,
4141
[WS_METHODS.serverUpdateSettings]: AuthOrchestrationOperateScope,
42+
[WS_METHODS.serverRunStopHook]: AuthOrchestrationOperateScope,
4243
[WS_METHODS.serverDiscoverSourceControl]: AuthOrchestrationReadScope,
4344
[WS_METHODS.serverGetTraceDiagnostics]: AuthOrchestrationReadScope,
4445
[WS_METHODS.serverGetProcessDiagnostics]: AuthOrchestrationReadScope,
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { ServerStopHookError } from "@t3tools/contracts";
2+
import { assert, it } from "@effect/vitest";
3+
import * as Effect from "effect/Effect";
4+
import * as Layer from "effect/Layer";
5+
import * as Schema from "effect/Schema";
6+
import * as HttpClient from "effect/unstable/http/HttpClient";
7+
import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";
8+
9+
import * as InstanceHooks from "./instanceHooks.ts";
10+
import * as ServerSettings from "./serverSettings.ts";
11+
12+
interface RecordedHookRequest {
13+
readonly method: string;
14+
readonly url: string;
15+
}
16+
17+
const makeHookEndpointLayer = (requests: Array<RecordedHookRequest>, status: number) =>
18+
Layer.succeed(
19+
HttpClient.HttpClient,
20+
HttpClient.make((request) =>
21+
Effect.sync(() => {
22+
requests.push({ method: request.method, url: request.url });
23+
return HttpClientResponse.fromWeb(request, new Response(null, { status }));
24+
}),
25+
),
26+
);
27+
28+
const isStopHookError = Schema.is(ServerStopHookError);
29+
30+
it.effect("DELETEs the stop hook and reports the instance as stopping on 204", () =>
31+
Effect.gen(function* () {
32+
const requests: Array<RecordedHookRequest> = [];
33+
const result = yield* InstanceHooks.runStopHook.pipe(
34+
Effect.provide(
35+
Layer.mergeAll(
36+
makeHookEndpointLayer(requests, 204),
37+
ServerSettings.layerTest({ stopHookUrl: "https://mgmt.example.test/instances/1/stop" }),
38+
),
39+
),
40+
);
41+
assert.deepEqual(result, { outcome: "stopped" });
42+
assert.deepEqual(requests, [
43+
{ method: "DELETE", url: "https://mgmt.example.test/instances/1/stop" },
44+
]);
45+
}),
46+
);
47+
48+
it.effect("clears the stop hook setting when the endpoint is gone", () =>
49+
Effect.gen(function* () {
50+
const requests: Array<RecordedHookRequest> = [];
51+
const settingsLayer = ServerSettings.layerTest({
52+
stopHookUrl: "https://mgmt.example.test/instances/1/stop",
53+
});
54+
const result = yield* Effect.gen(function* () {
55+
const outcome = yield* InstanceHooks.runStopHook.pipe(
56+
Effect.provide(makeHookEndpointLayer(requests, 404)),
57+
);
58+
const settings = yield* (yield* ServerSettings.ServerSettingsService).getSettings;
59+
return { outcome, stopHookUrl: settings.stopHookUrl };
60+
}).pipe(Effect.provide(settingsLayer));
61+
assert.deepEqual(result.outcome, { outcome: "gone" });
62+
assert.equal(result.stopHookUrl, null);
63+
}),
64+
);
65+
66+
it.effect("fails when no stop hook is configured", () =>
67+
Effect.gen(function* () {
68+
const requests: Array<RecordedHookRequest> = [];
69+
const failure = yield* InstanceHooks.runStopHook.pipe(
70+
Effect.provide(
71+
Layer.mergeAll(makeHookEndpointLayer(requests, 204), ServerSettings.layerTest()),
72+
),
73+
Effect.flip,
74+
);
75+
assert.isTrue(isStopHookError(failure));
76+
assert.equal(isStopHookError(failure) ? failure.reason : null, "not-configured");
77+
assert.deepEqual(requests, []);
78+
}),
79+
);
80+
81+
it.effect("surfaces unexpected statuses without clearing the hook", () =>
82+
Effect.gen(function* () {
83+
const requests: Array<RecordedHookRequest> = [];
84+
const settingsLayer = ServerSettings.layerTest({
85+
stopHookUrl: "https://mgmt.example.test/instances/1/stop",
86+
});
87+
const result = yield* Effect.gen(function* () {
88+
const failure = yield* InstanceHooks.runStopHook.pipe(
89+
Effect.provide(makeHookEndpointLayer(requests, 500)),
90+
Effect.flip,
91+
);
92+
const settings = yield* (yield* ServerSettings.ServerSettingsService).getSettings;
93+
return { failure, stopHookUrl: settings.stopHookUrl };
94+
}).pipe(Effect.provide(settingsLayer));
95+
assert.isTrue(isStopHookError(result.failure));
96+
assert.equal(
97+
isStopHookError(result.failure) ? result.failure.reason : null,
98+
"unexpected-status",
99+
);
100+
assert.equal(result.stopHookUrl, "https://mgmt.example.test/instances/1/stop");
101+
}),
102+
);

apps/server/src/instanceHooks.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import * as Effect from "effect/Effect";
2+
import * as HttpClient from "effect/unstable/http/HttpClient";
3+
import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
4+
import { ServerStopHookError, type ServerStopHookResult } from "@t3tools/contracts";
5+
6+
import * as ServerSettings from "./serverSettings.ts";
7+
8+
const STOP_HOOK_TIMEOUT = "20 seconds";
9+
10+
/**
11+
* Run the configured stop hook: DELETE the management endpoint that stops
12+
* this instance. A 204 reports the instance as stopping. A 404 means the
13+
* hook no longer exists, so the setting is cleared and clients drop their
14+
* stop controls with it.
15+
*/
16+
export const runStopHook = Effect.gen(function* () {
17+
const serverSettings = yield* ServerSettings.ServerSettingsService;
18+
const httpClient = yield* HttpClient.HttpClient;
19+
const settings = yield* serverSettings.getSettings;
20+
if (settings.stopHookUrl === null) {
21+
return yield* new ServerStopHookError({ reason: "not-configured" });
22+
}
23+
const response = yield* httpClient.execute(HttpClientRequest.delete(settings.stopHookUrl)).pipe(
24+
Effect.timeout(STOP_HOOK_TIMEOUT),
25+
Effect.mapError(
26+
(error) => new ServerStopHookError({ reason: "request-failed", detail: String(error) }),
27+
),
28+
);
29+
if (response.status === 204) {
30+
return { outcome: "stopped" } satisfies ServerStopHookResult;
31+
}
32+
if (response.status === 404) {
33+
yield* serverSettings.updateSettings({ stopHookUrl: null });
34+
return { outcome: "gone" } satisfies ServerStopHookResult;
35+
}
36+
return yield* new ServerStopHookError({ reason: "unexpected-status", status: response.status });
37+
});

apps/server/src/ws.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,12 @@ import {
5959
WsRpcGroup,
6060
} from "@t3tools/contracts";
6161
import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings";
62-
import { HttpRouter, HttpServerRequest, HttpServerRespondable } from "effect/unstable/http";
62+
import {
63+
HttpClient,
64+
HttpRouter,
65+
HttpServerRequest,
66+
HttpServerRespondable,
67+
} from "effect/unstable/http";
6368
import { RpcSerialization, RpcServer } from "effect/unstable/rpc";
6469

6570
import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts";
@@ -83,6 +88,7 @@ import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner
8388
import * as ServerSelfUpdate from "./cloud/selfUpdate.ts";
8489
import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts";
8590
import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts";
91+
import * as InstanceHooks from "./instanceHooks.ts";
8692
import * as ServerSettings from "./serverSettings.ts";
8793
import * as TerminalManager from "./terminal/Manager.ts";
8894
import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts";
@@ -372,6 +378,7 @@ const makeWsRpcLayer = (
372378
const config = yield* ServerConfig.ServerConfig;
373379
const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents;
374380
const serverSettings = yield* ServerSettings.ServerSettingsService;
381+
const stopHookHttpClient = yield* HttpClient.HttpClient;
375382
const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup;
376383
const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
377384
const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
@@ -1490,6 +1497,17 @@ const makeWsRpcLayer = (
14901497
"rpc.aggregate": "server",
14911498
},
14921499
),
1500+
[WS_METHODS.serverRunStopHook]: (_input) =>
1501+
observeRpcEffect(
1502+
WS_METHODS.serverRunStopHook,
1503+
InstanceHooks.runStopHook.pipe(
1504+
Effect.provideService(ServerSettings.ServerSettingsService, serverSettings),
1505+
Effect.provideService(HttpClient.HttpClient, stopHookHttpClient),
1506+
),
1507+
{
1508+
"rpc.aggregate": "server",
1509+
},
1510+
),
14931511
[WS_METHODS.serverDiscoverSourceControl]: (_input) =>
14941512
observeRpcEffect(
14951513
WS_METHODS.serverDiscoverSourceControl,

0 commit comments

Comments
 (0)