Skip to content
Source
Sign inSign up

service.ts

486 lines19.2 KB

1import * as NodeUtil from "node:util";
2​
3import * as HostProcess from "@t3tools/shared/HostProcess";
4import * as Console from "effect/Console";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Layer from "effect/Layer";
8import * as Path from "effect/Path";
9import { Command, Flag, GlobalFlag, Prompt } from "effect/cli";
10import { FetchHttpClient } from "effect/http";
11​
12import packageJson from "../../package.json" with { type: "json" };
13import * as BootService from "../cloud/bootService.ts";
14import * as ServiceProtection from "../cloud/serviceProtection.ts";
15import { compareExactServiceVersions, SERVICE_ENV_FILE } from "../cloud/serviceProtocol.ts";
16import * as ServerConfig from "../config.ts";
17import * as ProcessRunner from "../processRunner.ts";
18import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts";
19import { resolveEntryPath } from "./invocation.ts";
20​
21export const layer = (config: ServerConfig.ServerConfig["Service"]) =>
22 BootService.layer({
23 baseDir: config.baseDir,
24 logsDir: config.logsDir,
25 cliVersion: packageJson.version,
26 }).pipe(Layer.provide(ProcessRunner.layer), Layer.provide(FetchHttpClient.layer));
27​
28export type ServiceReconcileResult =
29 | {
30 readonly changed: false;
31 readonly status: BootService.BootServiceStatus;
32 }
33 | {
34 readonly changed: true;
35 readonly previouslyInstalled: boolean;
36 readonly plan: BootService.BootServicePlan;
37 };
38​
39const SERVICE_ENV_KEY = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/u;
40​
41/**
42 * Sets or removes (`undefined`) entries in `service.env` text and keeps every
43 * other line, including comments, as the user wrote it.
44 */
45export const upsertServiceEnv = (
46 contents: string,
47 entries: Readonly<Record<string, string | undefined>>,
48): string => {
49 const written = new Set<string>();
50 const source = contents.split("\n");
51 const lines: string[] = [];
52 for (let index = 0, offset = 0; index < source.length;) {
53 const line = source[index]!;
54 const match = SERVICE_ENV_KEY.exec(line);
55 let end = index + 1;
56 if (match !== null) {
57 // util.parseEnv lets a quoted value run to its closing quote across lines,
58 // skipping only spaces, tabs and CRs before the opening quote.
59 const rest = line.slice(match[0].length).replace(/^[ \t\r]+/u, "");
60 const quote = rest[0];
61 if ((quote === '"' || quote === "'" || quote === "`") && !rest.includes(quote, 1)) {
62 const closing = contents.indexOf(quote, offset + line.length);
63 if (closing !== -1) {
64 end += contents.slice(offset + line.length, closing).split("\n").length - 1;
65 }
66 }
67 }
68 const span = source.slice(index, end);
69 offset += span.reduce((length, part) => length + part.length + 1, 0);
70 index = end;
71 const key = match?.[1];
72 if (key === undefined || !Object.hasOwn(entries, key)) {
73 lines.push(span.join("\n"));
74 continue;
75 }
76 const value = entries[key];
77 if (value === undefined || written.has(key)) continue;
78 written.add(key);
79 lines.push(`${key}=${value}`);
80 }
81 while (lines.at(-1) === "") lines.pop();
82 for (const [key, value] of Object.entries(entries)) {
83 if (value !== undefined && !written.has(key)) lines.push(`${key}=${value}`);
84 }
85 return lines.length === 0 ? "" : `${lines.join("\n")}\n`;
86};
87​
88/** `service.env` under `baseDir`, parsed as the service launcher parses it. */
89export const readServiceEnv = Effect.fn("cli.service.read_env")(function* (baseDir: string) {
90 const fs = yield* FileSystem.FileSystem;
91 const path = (yield* Path.Path).join(baseDir, SERVICE_ENV_FILE);
92 const contents = (yield* fs.exists(path)) ? yield* fs.readFileString(path) : "";
93 const entries = Object.entries(NodeUtil.parseEnv(contents)).flatMap(([key, value]) =>
94 value === undefined ? [] : [[key, value] as const],
95 );
96 return { path, contents, entries: Object.fromEntries(entries) };
97});
98​
99export const writeServiceEnv = Effect.fn("cli.service.write_env")(function* (
100 baseDir: string,
101 entries: Readonly<Record<string, string | undefined>>,
102) {
103 const fs = yield* FileSystem.FileSystem;
104 const current = yield* readServiceEnv(baseDir);
105 const next = upsertServiceEnv(current.contents, entries);
106 if (next === current.contents) return;
107 yield* fs.makeDirectory(baseDir, { recursive: true });
108 yield* fs.writeFileString(current.path, next);
109});
110​
111export const reconcileService = Effect.fn("cli.service.reconcile")(function* (options?: {
112 readonly allowDowngrade?: boolean;
113 readonly start?: boolean;
114}) {
115 const { baseDir } = yield* ServerConfig.ServerConfig;
116 const service = yield* BootService.BootService;
117 const status = yield* service.status;
118 // Before the downgrade check, which reads this home's state, not the service's.
119 if (status.otherBaseDir !== undefined) {
120 return yield* new BootService.BootServiceOtherBaseDirError({
121 installedBaseDir: status.otherBaseDir,
122 baseDir,
123 });
124 }
125 if (status.installed && status.current) {
126 return { changed: false, status } satisfies ServiceReconcileResult;
127 }
128 if (status.supported && !status.installed) {
129 // A new service listens on this machine only, until `t3 up` opens the local network.
130 const { entries } = yield* readServiceEnv(baseDir);
131 if (entries.T3CODE_LAN === undefined && entries.T3CODE_HOST === undefined) {
132 yield* writeServiceEnv(baseDir, { T3CODE_LAN: "false" });
133 }
134 }
135 if (
136 status.installedVersion !== undefined &&
137 options?.allowDowngrade !== true &&
138 compareExactServiceVersions(packageJson.version, status.installedVersion) < 0
139 ) {
140 return yield* new BootService.BootServiceDowngradeRefusedError({
141 installedVersion: status.installedVersion,
142 targetVersion: packageJson.version,
143 });
144 }
145 const plan = yield* service.install(options);
146 return {
147 changed: true,
148 previouslyInstalled: status.installed,
149 plan,
150 } satisfies ServiceReconcileResult;
151});
152​
153export function formatServiceChange(
154 result: Extract<ServiceReconcileResult, { readonly changed: true }>,
155): string {
156 if (result.plan.handedOff === true) {
157 return `Service update to t3@${packageJson.version} queued. This session will disconnect.\nLog: ${result.plan.logPath}`;
158 }
159 return `Service ${result.previouslyInstalled ? "updated" : "installed"} · t3@${packageJson.version}\nLog: ${result.plan.logPath}`;
161​
162function formatProtection(
163 protection: ServiceProtection.ServiceProtectionState | undefined,
164): ReadonlyArray<string> {
165 if (protection === undefined) return [];
166 return [
167 protection.unprotected === undefined
168 ? ` Memory protection: on${protection.setByProtect ? " (t3 service protect)" : ""}`
169 : ` Memory protection: off, ${protection.unprotected} grants none. Run \`t3 service protect\`.`,
170 ...(protection.oomScoreAdj === undefined ? [] : [` OOM score: ${protection.oomScoreAdj}`]),
171 ];
173​
174function formatServiceStatus(
175 status: BootService.BootServiceStatus,
176 cliVersion: string,
177 protection?: ServiceProtection.ServiceProtectionState,
178): string {
179 if (!status.supported) {
180 return "T3 Code service\n Status: unavailable on this machine\n Supported on: Linux with systemd, macOS with launchd, Windows with Task Scheduler";
181 }
182 if (!status.installed) {
183 return "T3 Code service\n Status: not installed\n Next: Run `t3 service install`.";
184 }
185 if (status.otherBaseDir !== undefined) {
186 return [
187 "T3 Code service",
188 " Status: installed for another T3 home",
189 ` Base dir: ${status.otherBaseDir}`,
190 ` Next: Pass \`${BootService.formatBootServiceBaseDirOption(status.otherBaseDir)}\` to manage it.`,
191 ].join("\n");
192 }
193 const installedVersion = status.installedVersion ?? cliVersion;
194 const problems = (status.problems ?? []).map(
195 (problem) => ` [${problem}] ${BootService.formatBootServiceProblem(problem)}`,
196 );
197 if (
198 !status.current &&
199 status.installedVersion !== undefined &&
200 compareExactServiceVersions(status.installedVersion, cliVersion) > 0
201 ) {
202 return [
203 "T3 Code service",
204 ` Status: installed · t3@${installedVersion} (newer than this t3@${cliVersion} CLI)`,
205 ` Unit: ${status.unitPath}`,
206 ` Logs: ${status.logPath}`,
207 ...formatProtection(protection),
208 ...problems,
209 ` Next: Run \`t3 update ${installedVersion}\` to match it, or pass \`--allow-downgrade\` to \`t3 service install\` explicitly.`,
210 ].join("\n");
211 }
212 return [
213 "T3 Code service",
214 ` Status: ${status.current ? `installed · t3@${installedVersion}` : "needs an update or repair"}`,
215 ` Unit: ${status.unitPath}`,
216 ` Logs: ${status.logPath}`,
217 ...formatProtection(protection),
218 ...problems,
219 ...(status.current ? [] : [" Next: Run `t3 service install` to repair it."]),
220 ].join("\n");
222​
223const runServiceCommand = Effect.fn("cli.service.run")(function* <A, E, R>(
224 flags: { readonly baseDir: Parameters<typeof resolveCliAuthConfig>[0]["baseDir"] },
225 run: Effect.Effect<A, E, R>,
226) {
227 const logLevel = yield* GlobalFlag.LogLevel;
228 const config = yield* resolveCliAuthConfig(flags, logLevel);
229 return yield* run.pipe(Effect.provide(Layer.merge(layer(config), ServerConfig.layer(config))));
230});
231​
232const serviceReconcileFlags = {
233 ...projectLocationFlags,
234 allowDowngrade: Flag.Boolean("allow-downgrade").pipe(
235 Flag.withDescription("Allow replacing a newer installed service with this older CLI version."),
236 Flag.withDefault(false),
237 ),
238};
239​
240const serviceInstallCommand = Command.make("install", serviceReconcileFlags).pipe(
241 Command.withDescription(
242 "Install T3 Code as a background service for this user. `t3 up` also chooses how your other devices reach it.",
243 ),
244 Command.withHandler((flags) =>
245 runServiceCommand(
246 flags,
247 Effect.gen(function* () {
248 const result = yield* reconcileService({ allowDowngrade: flags.allowDowngrade });
249 if (!result.changed) {
250 yield* Console.log(
251 `T3 Code service is already installed with t3@${packageJson.version} (${yield* resolveEntryPath}).`,
252 );
253 return;
254 }
255 yield* Console.log(formatServiceChange(result));
256 if (!result.previouslyInstalled) {
257 yield* Console.log("Run `t3 up` to choose how your other devices reach it.");
258 }
259 }),
260 ),
261 ),
262);
263​
264const serviceUpdateCommand = Command.make("update", serviceReconcileFlags).pipe(
265 Command.withDescription("Deprecated. Run `t3 update` to move to a newer release."),
266 Command.unlisted,
267 Command.withHandler((flags) =>
268 runServiceCommand(
269 flags,
270 Effect.gen(function* () {
271 yield* Console.log(
272 "`t3 service update` is deprecated: run `t3 update` to move to a newer release, or `t3 service install` to repair the service. Repairing now.",
273 );
274 const result = yield* reconcileService({ allowDowngrade: flags.allowDowngrade });
275 if (!result.changed) {
276 yield* Console.log(
277 `T3 Code service is already using t3@${packageJson.version} (${yield* resolveEntryPath}).`,
278 );
279 return;
280 }
281 yield* Console.log(formatServiceChange(result));
282 }),
283 ),
284 ),
285);
286​
287const serviceRestartCommand = Command.make("restart", projectLocationFlags).pipe(
288 Command.withDescription(
289 "Restart the background service. Picks up a version installed by `t3 update` that was not restarted at the time.",
290 ),
291 Command.withHandler((flags) =>
292 runServiceCommand(
293 flags,
294 Effect.gen(function* () {
295 const service = yield* BootService.BootService;
296 const status = yield* service.status;
297 const outcome = yield* service.restart;
298 const onVersion =
299 status.installedVersion === undefined ? "" : ` on t3@${status.installedVersion}`;
300 yield* Console.log(
301 outcome === "restarted"
302 ? `Restarted the T3 Code service${onVersion}.`
303 : outcome === "queued"
304 ? `Queued a restart of the T3 Code service${onVersion}. This session runs inside the service and disconnects when it goes down.\nLogs: ${status.logPath}`
305 : status.otherBaseDir !== undefined
306 ? `The T3 Code service serves ${status.otherBaseDir}, so it was not restarted. Pass \`${BootService.formatBootServiceBaseDirOption(status.otherBaseDir)}\` to restart it.`
307 : "T3 Code service is not installed.",
308 );
309 }),
310 ),
311 ),
312);
313​
314const serviceUninstallCommand = Command.make("uninstall", projectLocationFlags).pipe(
315 Command.withDescription("Stop and remove the T3 Code background service."),
316 Command.withHandler((flags) =>
317 runServiceCommand(
318 flags,
319 Effect.gen(function* () {
320 const service = yield* BootService.BootService;
321 const path = yield* Path.Path;
322 const { baseDir } = yield* ServerConfig.ServerConfig;
323 // An unreadable unit stays removable; only a unit known to serve another home is kept.
324 const installedBaseDir = yield* service.status.pipe(
325 Effect.map((status) => status.installedBaseDir),
326 Effect.orElseSucceed(() => undefined),
327 );
328 if (
329 installedBaseDir !== undefined &&
330 path.resolve(installedBaseDir) !== path.resolve(baseDir)
331 ) {
332 yield* Console.log(
333 `The T3 Code service serves ${installedBaseDir}, so it was not removed. Pass \`${BootService.formatBootServiceBaseDirOption(installedBaseDir)}\` to remove it.`,
334 );
335 return;
336 }
337 const removed = yield* service.uninstall;
338 yield* Console.log(
339 removed ? "Removed the T3 Code service." : "T3 Code service is not installed.",
340 );
341 }),
342 ),
343 ),
344);
345​
346const serviceStatusCommand = Command.make("status", projectLocationFlags).pipe(
347 Command.withDescription("Show whether the T3 Code background service is installed."),
348 Command.withHandler((flags) =>
349 runServiceCommand(
350 flags,
351 Effect.gen(function* () {
352 const service = yield* BootService.BootService;
353 const path = yield* Path.Path;
354 const status = yield* service.status;
355 const protection =
356 status.installed && (yield* HostProcess.Platform) === "linux"
357 ? yield* ServiceProtection.readProtection(path.dirname(status.unitPath)).pipe(
358 Effect.provide(ProcessRunner.layer),
359 )
360 : undefined;
361 yield* Console.log(formatServiceStatus(status, packageJson.version, protection));
362 }),
363 ),
364 ),
365);
366​
367const formatDropIns = (dropIns: ReadonlyArray<ServiceProtection.DropIn>) =>
368 dropIns.map(
369 ({ path, contents }) => ` ${path}: ${contents.trimEnd().split("\n").slice(1).join(" ")}`,
370 );
371​
372const confirm = (yes: boolean, message: string) =>
373 yes ? Effect.succeed(true) : Prompt.run(Prompt.Confirm({ message, initial: false }));
374​
375const serviceProtectCommand = Command.make("protect", {
376 ...projectLocationFlags,
377 undo: Flag.Boolean("undo").pipe(
378 Flag.withDescription("Remove the protection this command set up."),
379 Flag.withDefault(false),
380 ),
381 yes: Flag.Boolean("yes").pipe(
382 Flag.withDescription("Answer yes to every question, including SSH and Tailscale."),
383 Flag.withDefault(false),
384 ),
385}).pipe(
386 Command.withDescription(
387 "Protect the T3 Code server from agents under memory pressure (Linux with systemd). Asks for sudo.",
388 ),
389 Command.withHandler((flags) =>
390 Effect.gen(function* () {
391 const unsupported = "Service protection needs Linux with systemd.";
392 if ((yield* HostProcess.Platform) !== "linux") return yield* Console.log(unsupported);
393 // Before resolving config, which creates T3 directories that root would then own.
394 if ((yield* HostProcess.UserId) === 0) {
395 return yield* Console.log(
396 "Run `t3 service protect` as the user that runs T3 Code, without sudo. It asks for sudo when it needs root.",
397 );
398 }
399 return yield* runServiceCommand(
400 flags,
401 Effect.gen(function* () {
402 const uid = yield* HostProcess.UserId;
403 const remote = yield* ServiceProtection.detectRemoteAccessUnits;
404 const { unitPath } = yield* (yield* BootService.BootService).status;
405 if (uid === undefined || remote === undefined || unitPath === "") {
406 return yield* Console.log(unsupported);
407 }
408 const userUnitDir = (yield* Path.Path).dirname(unitPath);
409​
410 if (flags.undo) {
411 const installed = yield* ServiceProtection.installedProtection(
412 ServiceProtection.protectionPlan({
413 uid,
414 userUnitDir,
415 remoteAccessUnits: ServiceProtection.REMOTE_ACCESS_UNITS,
416 }),
417 );
418 if (installed === undefined) {
419 return yield* Console.log("T3 Code service protection is not set up.");
420 }
421 yield* Console.log(
422 [
423 "Removes:",
424 ...[...installed.system, ...installed.user, installed.managerDefaults].map(
425 ({ path, shared }) =>
426 ` ${path}${shared === true ? " (kept while another user is protected)" : ""}`,
427 ),
428 ].join("\n"),
429 );
430 if (!(yield* confirm(flags.yes, "Remove T3 Code service protection?"))) return;
431 yield* ServiceProtection.removeProtection(installed);
432 return yield* Console.log(
433 "Removed T3 Code service protection. The server's OOM score returns to normal after `t3 service restart`.",
434 );
435 }
436​
437 yield* Console.log(
438 [
439 "Keeps T3 Code running when agents fill this machine's memory:",
440 " - reserves memory for the T3 Code server, so the kernel takes memory from agents first",
441 " - makes the out-of-memory killer pick agents before the server",
442 "It adds systemd drop-in files and changes nothing else. Undo with `t3 service protect --undo`.",
443 ].join("\n"),
444 );
445 const names = remote.map((unit) => unit.replace(/\.service$/, "")).join(" and ");
446 const protectRemote =
447 remote.length > 0 &&
448 (yield* confirm(
449 flags.yes,
450 `Also protect ${names} so you can still reach this machine under memory pressure?`,
451 ));
452 const plan = ServiceProtection.protectionPlan({
453 uid,
454 userUnitDir,
455 remoteAccessUnits: protectRemote ? remote : [],
456 });
457 yield* Console.log(
458 [
459 "Drop-ins it writes, as root through sudo:",
460 ...formatDropIns(plan.system),
461 "As you:",
462 ...formatDropIns([plan.managerDefaults, ...plan.user]),
463 ].join("\n"),
464 );
465 if (!(yield* confirm(flags.yes, "Apply these settings?"))) return;
466 yield* ServiceProtection.applyProtection(plan);
467 yield* Console.log(
468 "Protected T3 Code. Memory protection applies now; the server takes its OOM score after `t3 service restart`. Undo with `t3 service protect --undo`.",
469 );
470 }).pipe(Effect.provide(ProcessRunner.layer)),
471 );
472 }),
473 ),
474);
475​
476export const serviceCommand = Command.make("service").pipe(
477 Command.withDescription("Manage the T3 Code background service."),
478 Command.withSubcommands([
479 serviceInstallCommand,
480 serviceRestartCommand,
481 serviceUninstallCommand,
482 serviceStatusCommand,
483 serviceProtectCommand,
484 serviceUpdateCommand,
485 ]),
486);