Skip to content
Source
Sign inSign up

TriageService.ts

217 lines9.0 KB

1import {
2 type AuthClientPresentationMetadata,
3 ServerPrepareTriageError,
4 type ServerPrepareTriageInput,
5 type ServerPrepareTriageResult,
6} from "@t3tools/contracts";
7import * as Context from "effect/Context";
8import * as Effect from "effect/Effect";
9import * as FileSystem from "effect/FileSystem";
10import * as Layer from "effect/Layer";
11import * as Path from "effect/Path";
12​
13import packageJson from "../../package.json" with { type: "json" };
14import * as ServerConfig from "../config.ts";
15import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
16import { buildTriageAppPrompt, type TriageSourceClone } from "./triagePrompt.ts";
17import { triageSourceCacheDir, writeTriageSession } from "./triageSession.ts";
18​
19const T3CODE_REPOSITORY_URL = "https://github.com/pingdotgg/t3code";
20​
21// No tty: a credential prompt would hang until the timeout, so git must fail
22// instead. An empty GIT_ASKPASS also skips core.askPass and SSH_ASKPASS helpers.
23const GIT_ENV = {
24 GIT_TERMINAL_PROMPT: "0",
25 GIT_ASKPASS: "",
26 // English messages without overriding LC_CTYPE, which a global post-checkout hook inherits.
27 LANGUAGE: "C",
28} satisfies NodeJS.ProcessEnv;
29​
30/** Starts in-app triage sessions: the same files `t3 triage` writes, plus a source clone. */
31export class TriageService extends Context.Service<
32 TriageService,
33 {
34 readonly prepare: (
35 input: ServerPrepareTriageInput,
36 ) => Effect.Effect<ServerPrepareTriageResult, ServerPrepareTriageError>;
37 }
38>()("t3/triage/TriageService") {}
39​
40const describeClient = (client: AuthClientPresentationMetadata) =>
41 [
42 client.label ?? "an unknown client",
43 client.appVersion === undefined ? undefined : `version ${client.appVersion}`,
44 client.os === undefined ? undefined : `on ${client.os}`,
45 client.browser === undefined ? undefined : `in ${client.browser}`,
46 client.webDeployment === "hosted" ? "(app.t3.codes)" : undefined,
47 ]
48 .filter((part) => part !== undefined)
49 .join(" ");
50​
51/** Picks the release tag for `version`, or `main` when the build has no tag. */
52export const pickSourceRef = (lsRemoteOutput: string, version: string) => {
53 const commits = new Map(
54 lsRemoteOutput
55 .split("\n")
56 .map((line) => line.trim().split("\t"))
57 .filter((fields): fields is [string, string] => fields.length === 2)
58 .map(([commit, ref]) => [ref, commit]),
59 );
60 const tag = `v${version}`;
61 // Annotated tags list the tag object first and the commit as `^{}`.
62 const tagCommit = commits.get(`refs/tags/${tag}^{}`) ?? commits.get(`refs/tags/${tag}`);
63 if (tagCommit !== undefined) return { ref: tag, commit: tagCommit };
64 const mainCommit = commits.get("refs/heads/main");
65 return mainCommit === undefined ? undefined : { ref: "main", commit: mainCommit };
66};
67​
68const make = Effect.gen(function* () {
69 const config = yield* ServerConfig.ServerConfig;
70 const git = yield* GitVcsDriver.GitVcsDriver;
71 const fs = yield* FileSystem.FileSystem;
72 const path = yield* Path.Path;
73 const sourceDir = triageSourceCacheDir(config.baseDir, path);
74 // Beside the cache, not in it: the playbook has agents prune the cache, and a
75 // clone in progress must not look like a stale entry. Same filesystem, so the
76 // finished clone moves in with a rename.
77 const stagingDir = `${sourceDir}-staging`;
78 const serverVersion = packageJson.version;
79​
80 const runGit = (operation: string, cwd: string, args: ReadonlyArray<string>, timeoutMs: number) =>
81 git.execute({ operation: `TriageService.${operation}`, cwd, args, timeoutMs, env: GIT_ENV });
82​
83 // Clones outlive the request that started them: a fresh clone takes tens of
84 // seconds, and the user is writing their description meanwhile.
85 const serviceScope = yield* Effect.scope;
86 // Clones a previous server process left half done.
87 yield* fs.remove(stagingDir, { recursive: true }).pipe(Effect.ignore);
88​
89 // Fetches the exact commit `ls-remote` resolved, so the folder the session
90 // names is the one that appears even when main moves meanwhile.
91 const cloneSource = Effect.fn("TriageService.cloneSource")(function* (commit: string) {
92 yield* fs.makeDirectory(stagingDir, { recursive: true });
93 const staging = yield* fs.makeTempDirectory({ directory: stagingDir, prefix: "clone-" });
94 const directory = path.join(sourceDir, commit);
95 yield* Effect.gen(function* () {
96 yield* runGit("initSource", staging, ["init", "--quiet"], 10_000);
97 yield* runGit(
98 "addSourceRemote",
99 staging,
100 ["remote", "add", "origin", T3CODE_REPOSITORY_URL],
101 10_000,
102 );
103 yield* runGit(
104 "fetchSource",
105 staging,
106 ["fetch", "--depth", "1", "--filter=blob:none", "origin", commit],
107 600_000,
108 );
109 yield* runGit("checkoutSource", staging, ["checkout", "--quiet", "FETCH_HEAD"], 600_000);
110 yield* fs.rename(staging, directory).pipe(
111 // A concurrent session cloned the same commit first; keep theirs.
112 Effect.catch((error) =>
113 fs
114 .exists(directory)
115 .pipe(Effect.flatMap((exists) => (exists ? Effect.void : Effect.fail(error)))),
116 ),
117 );
118 }).pipe(
119 // Gone after a successful rename; otherwise a failed or losing clone.
120 Effect.ensuring(fs.remove(staging, { recursive: true }).pipe(Effect.ignore)),
121 );
122 });
123​
124 // Release tags never move, so one lookup serves every later session. Builds
125 // with no tag track main, which does move, so they look it up each time.
126 let releaseSource: { readonly ref: string; readonly commit: string } | undefined;
127 const resolveSource = Effect.fn("TriageService.resolveSource")(function* () {
128 if (releaseSource !== undefined) return releaseSource;
129 const tag = `refs/tags/v${serverVersion}`;
130 const listed = yield* runGit(
131 "resolveSourceRef",
132 sourceDir,
133 ["ls-remote", T3CODE_REPOSITORY_URL, tag, `${tag}^{}`, "refs/heads/main"],
134 // Triage still starts without source; a dead network should not stall it.
135 5_000,
136 );
137 const source = pickSourceRef(listed.stdout, serverVersion);
138 if (source?.ref !== "main") releaseSource = source;
139 return source;
140 });
141​
142 // Commits with a clone running, so sessions started meanwhile share it.
143 const cloning = new Set<string>();
144​
145 // The playbook's own layout: one folder per commit under the source cache, so
146 // the agent finds and reuses this clone instead of making another. The folder
147 // appears only once the clone is complete.
148 const startSourceClone = Effect.fn("TriageService.startSourceClone")(function* () {
149 yield* fs.makeDirectory(sourceDir, { recursive: true });
150 const source = yield* resolveSource();
151 if (source === undefined) {
152 return {
153 _tag: "failed",
154 serverVersion,
155 reason: "the repository listed neither the release tag nor main.",
156 } satisfies TriageSourceClone;
157 }
158 const directory = path.join(sourceDir, source.commit);
159 if (yield* fs.exists(directory)) {
160 // Our clones appear only when complete, but an agent may have cloned
161 // here by hand and stopped partway.
162 // Without its own .git, rev-parse would read a parent repository.
163 const head = (yield* fs.exists(path.join(directory, ".git")))
164 ? yield* runGit("checkSource", directory, ["rev-parse", "HEAD"], 10_000).pipe(
165 Effect.map((result) => result.stdout.trim()),
166 Effect.orElseSucceed(() => ""),
167 )
168 : "";
169 return head === source.commit
170 ? ({ _tag: "cloned", directory, serverVersion, ...source } as const)
171 : ({
172 _tag: "failed",
173 serverVersion,
174 reason: `${directory} exists but is not a complete checkout of ${source.commit}. Remove it first.`,
175 } satisfies TriageSourceClone);
176 }
177 if (!cloning.has(source.commit)) {
178 cloning.add(source.commit);
179 yield* cloneSource(source.commit).pipe(
180 Effect.ignoreCause({ log: true }),
181 Effect.ensuring(Effect.sync(() => cloning.delete(source.commit))),
182 Effect.forkIn(serviceScope),
183 );
184 }
185 return { _tag: "cloning", directory, serverVersion, ...source } as const;
186 });
187​
188 const prepare: TriageService["Service"]["prepare"] = Effect.fn("TriageService.prepare")(
189 function* (input) {
190 const sourceClone = yield* startSourceClone().pipe(
191 Effect.catch((error) =>
192 Effect.succeed<TriageSourceClone>({
193 _tag: "failed",
194 serverVersion,
195 reason: error.message,
196 }),
197 ),
198 );
199 const session = yield* writeTriageSession({
200 baseDir: config.baseDir,
201 paths: config,
202 entry: { kind: "app", client: describeClient(input.client) },
203 server: `running (pid ${String(process.pid)}, version ${serverVersion}); this session runs inside it`,
204 sourceClone,
205 }).pipe(
206 Effect.provideService(FileSystem.FileSystem, fs),
207 Effect.provideService(Path.Path, path),
208 Effect.mapError((cause) => new ServerPrepareTriageError({ cause })),
209 );
210 return { prompt: buildTriageAppPrompt(session.promptFilePath) };
211 },
212 );
213​
214 return TriageService.of({ prepare });
215});
216​
217export const layer = Layer.effect(TriageService, make);