1import { DeviceOperationError } from "@t3tools/contracts";
2import * as Digest from "effect/crypto/Digest";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Hex from "effect/encoding/Hex";
6import * as Schema from "effect/Schema";
7import * as FileSystem from "effect/FileSystem";
8import * as Path from "effect/Path";
9import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
10
11import type { AgentDaemonEndpoint } from "./DeviceHost.ts";
12
13const encodeEndpoint = Schema.encodeEffect(
14 Schema.fromJsonString(
15 Schema.Struct({
16 daemonBaseUrl: Schema.String,
17 daemonAuthToken: Schema.String,
18 stateDir: Schema.optionalKey(Schema.String),
19 }),
20 ),
21);
22
23const key = Effect.fn("AgentDeviceTarget.key")(function* (value: string) {
24 const crypto = yield* Digest.Digest;
25 const digest = yield* crypto
26 .digest(Digest.sha256, new TextEncoder().encode(value))
27 .pipe(Effect.orDie);
28 return Hex.encode(digest).slice(0, 24);
29});
30
31/**
32 * A stable file per host lets forwarded endpoints change without retargeting
33 * other commands. Hosts with a daemon per device get a file per device.
34 */
35export const agentDeviceConfigPath = (
36 stateDir: string,
37 hostId: string,
38 path: Path.Path,
39 deviceId?: string,
40) =>
41 key(deviceId === undefined ? hostId : JSON.stringify([hostId, deviceId])).pipe(
42 Effect.map((hash) => path.join(stateDir, "device", "hosts", `${hash}.json`)),
43 );
44
45export const agentDeviceSession = (threadId: string, hostId: string, deviceId: string) =>
46 key(JSON.stringify([threadId, hostId, deviceId])).pipe(Effect.map((hash) => `t3-${hash}`));
47
48export const writeAgentDeviceConfig = Effect.fn("AgentDeviceTarget.writeConfig")(function* (
49 file: string,
50 endpoint: AgentDaemonEndpoint & { readonly stateDir?: string | undefined },
51) {
52 const fs = yield* FileSystem.FileSystem;
53 const path = yield* Path.Path;
54 yield* fs.makeDirectory(path.dirname(file), { recursive: true });
55 const content = yield* encodeEndpoint({
56 daemonBaseUrl: endpoint.baseUrl,
57 daemonAuthToken: endpoint.token,
58 ...(endpoint.stateDir === undefined ? {} : { stateDir: endpoint.stateDir }),
59 });
60 if ((yield* fs.readFileString(file).pipe(Effect.orElseSucceed(() => ""))) === content) return;
61 const temporary = yield* fs.makeTempFile({ directory: path.dirname(file), prefix: ".endpoint-" });
62 yield* Effect.gen(function* () {
63 yield* fs.chmod(temporary, 0o600);
64 yield* fs.writeFileString(temporary, content);
65 yield* fs.rename(temporary, file);
66 }).pipe(Effect.ensuring(fs.remove(temporary, { force: true }).pipe(Effect.ignore)));
67});
68
69const AgentCloseResult = Schema.Union([
70 Schema.Struct({ result: Schema.Struct({ ok: Schema.Literal(true) }) }),
71 Schema.Struct({
72 error: Schema.Struct({
73 data: Schema.Struct({ code: Schema.Literal("SESSION_NOT_FOUND") }),
74 }),
75 }),
76]);
77
78/** Ends an agent-device session on a running daemon; an already-released session succeeds. */
79export const releaseAgentDeviceSession =
80 (http: HttpClient.HttpClient, endpoint: AgentDaemonEndpoint) => (session: string) =>
81 HttpClientRequest.post(`${endpoint.baseUrl}/rpc`).pipe(
82 HttpClientRequest.bearerToken(endpoint.token),
83 HttpClientRequest.bodyJson({
84 jsonrpc: "2.0",
85 id: session,
86 method: "agent_device.command",
87 params: { command: "close", session, positionals: [], flags: {} },
88 }),
89 Effect.flatMap((request) => http.pipe(HttpClient.withScope).execute(request)),
90 // agent-device reports an already-released session as HTTP 404.
91 Effect.filterOrElse((response) => response.status === 404, HttpClientResponse.filterStatusOk),
92 Effect.flatMap(HttpClientResponse.schemaBodyJson(AgentCloseResult)),
93 Effect.scoped,
94 Effect.timeout(Duration.seconds(15)),
95 Effect.asVoid,
96 Effect.mapError(
97 (cause) =>
98 new DeviceOperationError({ operation: "close", reason: "request_failed", cause }),
99 ),
100 );