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