@blogic-cz/agent-tools 0.14.14 → 0.14.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/db-tool/service.ts +0 -1
- package/src/k8s-tool/service.ts +0 -1
- package/src/session-tool/codex.ts +237 -0
- package/src/session-tool/config.ts +4 -1
- package/src/session-tool/index.ts +1 -1
- package/src/session-tool/service.ts +100 -62
- package/src/session-tool/types.ts +4 -1
- package/src/shared/path.ts +15 -0
- package/src/shared/prerequisites/runtime.ts +468 -75
- package/src/shared/prerequisites/types.ts +29 -1
package/package.json
CHANGED
package/src/db-tool/service.ts
CHANGED
package/src/k8s-tool/service.ts
CHANGED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { Glob } from "bun";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
|
|
4
|
+
import type { MessageSummary } from "./types";
|
|
5
|
+
|
|
6
|
+
import { SessionReadError, SessionStorageNotFoundError, type SessionError } from "./errors";
|
|
7
|
+
|
|
8
|
+
export type CodexContentBlock =
|
|
9
|
+
| { type: "input_text"; text: string }
|
|
10
|
+
| { type: "output_text"; text: string }
|
|
11
|
+
| { type: string; text?: string };
|
|
12
|
+
|
|
13
|
+
export type CodexRecord =
|
|
14
|
+
| {
|
|
15
|
+
type: "session_meta";
|
|
16
|
+
timestamp: string;
|
|
17
|
+
payload: { id: string; cwd?: string; timestamp?: string };
|
|
18
|
+
}
|
|
19
|
+
| {
|
|
20
|
+
type: "event_msg";
|
|
21
|
+
timestamp: string;
|
|
22
|
+
payload: { type: "thread_name_updated"; thread_name: string };
|
|
23
|
+
}
|
|
24
|
+
| {
|
|
25
|
+
type: "response_item";
|
|
26
|
+
timestamp: string;
|
|
27
|
+
payload: {
|
|
28
|
+
type: "message";
|
|
29
|
+
role: "user" | "assistant" | "developer" | string;
|
|
30
|
+
content: ReadonlyArray<CodexContentBlock>;
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
35
|
+
typeof value === "object" && value !== null;
|
|
36
|
+
|
|
37
|
+
export const parseCodexLine = (line: string): CodexRecord | null => {
|
|
38
|
+
let parsed: unknown;
|
|
39
|
+
try {
|
|
40
|
+
parsed = JSON.parse(line);
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (
|
|
46
|
+
!isRecord(parsed) ||
|
|
47
|
+
typeof parsed.type !== "string" ||
|
|
48
|
+
typeof parsed.timestamp !== "string"
|
|
49
|
+
) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const payload = parsed.payload;
|
|
54
|
+
if (!isRecord(payload)) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (parsed.type === "session_meta" && typeof payload.id === "string") {
|
|
59
|
+
return {
|
|
60
|
+
type: "session_meta",
|
|
61
|
+
timestamp: parsed.timestamp,
|
|
62
|
+
payload: {
|
|
63
|
+
id: payload.id,
|
|
64
|
+
cwd: typeof payload.cwd === "string" ? payload.cwd : undefined,
|
|
65
|
+
timestamp: typeof payload.timestamp === "string" ? payload.timestamp : undefined,
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (
|
|
71
|
+
parsed.type === "event_msg" &&
|
|
72
|
+
payload.type === "thread_name_updated" &&
|
|
73
|
+
typeof payload.thread_name === "string"
|
|
74
|
+
) {
|
|
75
|
+
return {
|
|
76
|
+
type: "event_msg",
|
|
77
|
+
timestamp: parsed.timestamp,
|
|
78
|
+
payload: { type: "thread_name_updated", thread_name: payload.thread_name },
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (
|
|
83
|
+
parsed.type === "response_item" &&
|
|
84
|
+
payload.type === "message" &&
|
|
85
|
+
typeof payload.role === "string" &&
|
|
86
|
+
Array.isArray(payload.content)
|
|
87
|
+
) {
|
|
88
|
+
return {
|
|
89
|
+
type: "response_item",
|
|
90
|
+
timestamp: parsed.timestamp,
|
|
91
|
+
payload: {
|
|
92
|
+
type: "message",
|
|
93
|
+
role: payload.role,
|
|
94
|
+
content: (payload.content as unknown[]).filter(
|
|
95
|
+
(item): item is CodexContentBlock =>
|
|
96
|
+
isRecord(item) && typeof (item as Record<string, unknown>).type === "string",
|
|
97
|
+
),
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return null;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export const extractCodexText = (content: ReadonlyArray<CodexContentBlock>): string =>
|
|
106
|
+
content
|
|
107
|
+
.filter(
|
|
108
|
+
(block): block is CodexContentBlock & { text: string } =>
|
|
109
|
+
(block.type === "input_text" || block.type === "output_text") &&
|
|
110
|
+
typeof block.text === "string",
|
|
111
|
+
)
|
|
112
|
+
.map((block) => block.text)
|
|
113
|
+
.join("\n");
|
|
114
|
+
|
|
115
|
+
export const extractCodexTitle = (records: ReadonlyArray<CodexRecord>): string => {
|
|
116
|
+
const named = records.find((record) => record.type === "event_msg");
|
|
117
|
+
if (named !== undefined && named.type === "event_msg") {
|
|
118
|
+
return named.payload.thread_name;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const firstUser = records.find(
|
|
122
|
+
(record) => record.type === "response_item" && record.payload.role === "user",
|
|
123
|
+
);
|
|
124
|
+
if (firstUser !== undefined && firstUser.type === "response_item") {
|
|
125
|
+
return extractCodexText(firstUser.payload.content).slice(0, 100);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return "Untitled session";
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const getSessionIdFromFile = (filePath: string): string => {
|
|
132
|
+
const fileName = filePath.split("/").pop() ?? "";
|
|
133
|
+
const match =
|
|
134
|
+
/rollout-.*?-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/u.exec(
|
|
135
|
+
fileName,
|
|
136
|
+
);
|
|
137
|
+
return match?.[1] ?? fileName.replace(/\.jsonl$/u, "");
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
export const getCodexSessionId = getSessionIdFromFile;
|
|
141
|
+
|
|
142
|
+
const walkSessionFiles = (basePath: string): Promise<string[]> => {
|
|
143
|
+
const glob = new Glob("*/*/*/rollout-*.jsonl");
|
|
144
|
+
return Array.fromAsync(glob.scan({ cwd: basePath, absolute: true }));
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const readSessionMeta = async (
|
|
148
|
+
sessionFile: string,
|
|
149
|
+
): Promise<{ id: string; cwd?: string } | null> => {
|
|
150
|
+
try {
|
|
151
|
+
const text = await Bun.file(sessionFile).text();
|
|
152
|
+
const firstLine = text.split("\n")[0] ?? "";
|
|
153
|
+
const record = parseCodexLine(firstLine);
|
|
154
|
+
if (record !== null && record.type === "session_meta") {
|
|
155
|
+
return { id: record.payload.id, cwd: record.payload.cwd };
|
|
156
|
+
}
|
|
157
|
+
return null;
|
|
158
|
+
} catch {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
export const getCodexSessions = (
|
|
164
|
+
basePath: string,
|
|
165
|
+
projectDir: string | null,
|
|
166
|
+
): Effect.Effect<string[], SessionError> =>
|
|
167
|
+
Effect.tryPromise({
|
|
168
|
+
try: async () => {
|
|
169
|
+
const allFiles = await walkSessionFiles(basePath);
|
|
170
|
+
if (projectDir === null) {
|
|
171
|
+
return allFiles;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const metas = await Promise.all(allFiles.map((file) => readSessionMeta(file)));
|
|
175
|
+
return allFiles.filter((_, i) => metas[i] !== null && metas[i]?.cwd === projectDir);
|
|
176
|
+
},
|
|
177
|
+
catch: (error) =>
|
|
178
|
+
new SessionStorageNotFoundError({
|
|
179
|
+
message: error instanceof Error ? error.message : "Codex storage directory not found",
|
|
180
|
+
path: basePath,
|
|
181
|
+
}),
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
export const readCodexMessages = (
|
|
185
|
+
sessionFiles: string[],
|
|
186
|
+
): Effect.Effect<MessageSummary[], SessionError> =>
|
|
187
|
+
Effect.tryPromise({
|
|
188
|
+
try: async () => {
|
|
189
|
+
const summaries: MessageSummary[] = [];
|
|
190
|
+
|
|
191
|
+
for (const sessionFile of sessionFiles) {
|
|
192
|
+
let fileContent: string;
|
|
193
|
+
try {
|
|
194
|
+
// eslint-disable-next-line eslint/no-await-in-loop -- sequential file read keeps memory bounded
|
|
195
|
+
fileContent = await Bun.file(sessionFile).text();
|
|
196
|
+
} catch {
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const records = fileContent
|
|
201
|
+
.split(/\r?\n/u)
|
|
202
|
+
.map((line) => line.trim())
|
|
203
|
+
.filter((line) => line.length > 0)
|
|
204
|
+
.map(parseCodexLine)
|
|
205
|
+
.filter((record): record is CodexRecord => record !== null);
|
|
206
|
+
|
|
207
|
+
const title = extractCodexTitle(records);
|
|
208
|
+
const sessionID = getSessionIdFromFile(sessionFile);
|
|
209
|
+
|
|
210
|
+
for (const record of records) {
|
|
211
|
+
if (record.type !== "response_item") continue;
|
|
212
|
+
if (record.payload.role !== "user" && record.payload.role !== "assistant") continue;
|
|
213
|
+
|
|
214
|
+
const body = extractCodexText(record.payload.content);
|
|
215
|
+
if (body.length === 0) continue;
|
|
216
|
+
|
|
217
|
+
const createdTimestamp = new Date(record.timestamp).getTime();
|
|
218
|
+
summaries.push({
|
|
219
|
+
sessionID,
|
|
220
|
+
id: `${sessionID}:${record.timestamp}`,
|
|
221
|
+
title,
|
|
222
|
+
body,
|
|
223
|
+
created: Number.isFinite(createdTimestamp) ? createdTimestamp : 0,
|
|
224
|
+
role: record.payload.role,
|
|
225
|
+
source: "codex",
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return summaries.toSorted((left, right) => right.created - left.created);
|
|
231
|
+
},
|
|
232
|
+
catch: (error) =>
|
|
233
|
+
new SessionReadError({
|
|
234
|
+
message: error instanceof Error ? error.message : "Failed to read Codex sessions",
|
|
235
|
+
source: "codex",
|
|
236
|
+
}),
|
|
237
|
+
});
|
|
@@ -44,6 +44,7 @@ export class ResolvedPaths extends Context.Service<
|
|
|
44
44
|
readonly messagesPath: string;
|
|
45
45
|
readonly sessionsPath: string;
|
|
46
46
|
readonly claudeCodePath: string | null;
|
|
47
|
+
readonly codexPath: string | null;
|
|
47
48
|
}
|
|
48
49
|
>()("@agent-tools/ResolvedPaths") {}
|
|
49
50
|
|
|
@@ -54,6 +55,8 @@ export const ResolvedPathsLayer = Layer.effect(
|
|
|
54
55
|
const sessionsPath = yield* resolveSessionsPath;
|
|
55
56
|
const claudeCodeBasePath = join(homedir(), ".claude/projects");
|
|
56
57
|
const claudeCodePath = existsSync(claudeCodeBasePath) ? claudeCodeBasePath : null;
|
|
57
|
-
|
|
58
|
+
const codexBasePath = join(homedir(), ".codex/sessions");
|
|
59
|
+
const codexPath = existsSync(codexBasePath) ? codexBasePath : null;
|
|
60
|
+
return { messagesPath, sessionsPath, claudeCodePath, codexPath };
|
|
58
61
|
}),
|
|
59
62
|
);
|
|
@@ -22,7 +22,7 @@ import { formatDate, SessionService, SessionServiceLayer, truncate } from "./ser
|
|
|
22
22
|
const AppLayer = SessionServiceLayer.pipe(Layer.provideMerge(ResolvedPathsLayer));
|
|
23
23
|
|
|
24
24
|
const sourceOption = Flag.string("source").pipe(
|
|
25
|
-
Flag.withDescription("Filter by source: all, opencode, claude-code"),
|
|
25
|
+
Flag.withDescription("Filter by source: all, opencode, claude-code, codex"),
|
|
26
26
|
Flag.withDefault("all"),
|
|
27
27
|
);
|
|
28
28
|
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { Context, Effect, Layer } from "effect";
|
|
2
2
|
import { readdir } from "node:fs/promises";
|
|
3
3
|
|
|
4
|
-
import type { MessageSummary, SessionInfo } from "./types";
|
|
4
|
+
import type { MessageSummary, SessionInfo, SessionSource } from "./types";
|
|
5
5
|
|
|
6
6
|
import { getClaudeCodeSessions, readClaudeCodeMessages } from "./claude-code";
|
|
7
|
+
import { getCodexSessions, getCodexSessionId, readCodexMessages } from "./codex";
|
|
7
8
|
import { ResolvedPaths } from "./config";
|
|
8
9
|
import { SessionReadError, SessionStorageNotFoundError, type SessionError } from "./errors";
|
|
9
10
|
|
|
@@ -38,7 +39,11 @@ export const truncate = (value: string, maxLen: number): string => {
|
|
|
38
39
|
|
|
39
40
|
type FileEntry = { filePath: string; content: string };
|
|
40
41
|
|
|
41
|
-
type SourceFilter =
|
|
42
|
+
type SourceFilter = ReadonlySet<SessionSource>;
|
|
43
|
+
|
|
44
|
+
const ALL_SOURCES: SourceFilter = new Set<SessionSource>(["opencode", "claude-code", "codex"]);
|
|
45
|
+
const UUID_SOURCES: SourceFilter = new Set<SessionSource>(["claude-code", "codex"]);
|
|
46
|
+
const OPENCODE_ONLY: SourceFilter = new Set<SessionSource>(["opencode"]);
|
|
42
47
|
|
|
43
48
|
const UUID_SESSION_ID_REGEX =
|
|
44
49
|
/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/u;
|
|
@@ -50,23 +55,23 @@ const getSessionIdFromClaudeFile = (filePath: string): string => {
|
|
|
50
55
|
|
|
51
56
|
const detectSourceFilter = (filterSessions: Set<string> | null): SourceFilter => {
|
|
52
57
|
if (filterSessions === null || filterSessions.size !== 1) {
|
|
53
|
-
return
|
|
58
|
+
return ALL_SOURCES;
|
|
54
59
|
}
|
|
55
60
|
|
|
56
61
|
const sessionId = filterSessions.values().next().value;
|
|
57
62
|
if (typeof sessionId !== "string") {
|
|
58
|
-
return
|
|
63
|
+
return ALL_SOURCES;
|
|
59
64
|
}
|
|
60
65
|
|
|
61
66
|
if (sessionId.startsWith("ses_")) {
|
|
62
|
-
return
|
|
67
|
+
return OPENCODE_ONLY;
|
|
63
68
|
}
|
|
64
69
|
|
|
65
70
|
if (UUID_SESSION_ID_REGEX.test(sessionId)) {
|
|
66
|
-
return
|
|
71
|
+
return UUID_SOURCES;
|
|
67
72
|
}
|
|
68
73
|
|
|
69
|
-
return
|
|
74
|
+
return ALL_SOURCES;
|
|
70
75
|
};
|
|
71
76
|
|
|
72
77
|
/**
|
|
@@ -196,10 +201,26 @@ export class SessionService extends Context.Service<
|
|
|
196
201
|
),
|
|
197
202
|
);
|
|
198
203
|
|
|
204
|
+
const codexSessions =
|
|
205
|
+
paths.codexPath === null
|
|
206
|
+
? new Set<string>()
|
|
207
|
+
: yield* getCodexSessions(paths.codexPath, projectDir).pipe(
|
|
208
|
+
Effect.map(
|
|
209
|
+
(files) =>
|
|
210
|
+
new Set<string>(files.map((filePath) => getCodexSessionId(filePath))),
|
|
211
|
+
),
|
|
212
|
+
Effect.catchTag("SessionStorageNotFoundError", () =>
|
|
213
|
+
Effect.succeed(new Set<string>()),
|
|
214
|
+
),
|
|
215
|
+
);
|
|
216
|
+
|
|
199
217
|
const matchingSessions = new Set<string>(opencodeSessions);
|
|
200
218
|
for (const sessionId of claudeSessions) {
|
|
201
219
|
matchingSessions.add(sessionId);
|
|
202
220
|
}
|
|
221
|
+
for (const sessionId of codexSessions) {
|
|
222
|
+
matchingSessions.add(sessionId);
|
|
223
|
+
}
|
|
203
224
|
|
|
204
225
|
return matchingSessions;
|
|
205
226
|
}),
|
|
@@ -209,65 +230,64 @@ export class SessionService extends Context.Service<
|
|
|
209
230
|
) {
|
|
210
231
|
const sourceFilter = detectSourceFilter(filterSessions);
|
|
211
232
|
|
|
212
|
-
const opencodeSummaries =
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
continue;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
summaries.push({
|
|
255
|
-
sessionID: parsed.sessionID ?? sessionId,
|
|
256
|
-
id: parsed.id ?? filePath.split("/").pop()?.replace(".json", "") ?? "",
|
|
257
|
-
title: parsed.summary.title,
|
|
258
|
-
body: parsed.summary.body ?? "",
|
|
259
|
-
created: parsed.time?.created ?? 0,
|
|
260
|
-
role: parsed.role ?? "unknown",
|
|
261
|
-
source: "opencode",
|
|
262
|
-
});
|
|
233
|
+
const opencodeSummaries = !sourceFilter.has("opencode")
|
|
234
|
+
? []
|
|
235
|
+
: yield* Effect.gen(function* () {
|
|
236
|
+
const sessionDirs = yield* Effect.tryPromise({
|
|
237
|
+
try: async () => {
|
|
238
|
+
const dirs = await readdir(paths.messagesPath);
|
|
239
|
+
return dirs
|
|
240
|
+
.filter((name) => name.startsWith("ses_"))
|
|
241
|
+
.filter((name) => filterSessions === null || filterSessions.has(name));
|
|
242
|
+
},
|
|
243
|
+
catch: () =>
|
|
244
|
+
new SessionStorageNotFoundError({
|
|
245
|
+
message: "Message storage directory not found",
|
|
246
|
+
path: paths.messagesPath,
|
|
247
|
+
}),
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
const summaries: MessageSummary[] = [];
|
|
251
|
+
|
|
252
|
+
for (const sessionId of sessionDirs) {
|
|
253
|
+
const sessionPath = `${paths.messagesPath}/${sessionId}`;
|
|
254
|
+
const files = yield* readJsonFilesFlat(sessionPath);
|
|
255
|
+
|
|
256
|
+
for (const { filePath, content } of files) {
|
|
257
|
+
const parsed = parseJson<{
|
|
258
|
+
id?: string;
|
|
259
|
+
role?: string;
|
|
260
|
+
sessionID?: string;
|
|
261
|
+
summary?: {
|
|
262
|
+
body?: string;
|
|
263
|
+
title?: string;
|
|
264
|
+
};
|
|
265
|
+
time?: {
|
|
266
|
+
created?: number;
|
|
267
|
+
};
|
|
268
|
+
}>(content);
|
|
269
|
+
|
|
270
|
+
if (parsed === null || parsed.summary?.title === undefined) {
|
|
271
|
+
continue;
|
|
263
272
|
}
|
|
273
|
+
|
|
274
|
+
summaries.push({
|
|
275
|
+
sessionID: parsed.sessionID ?? sessionId,
|
|
276
|
+
id: parsed.id ?? filePath.split("/").pop()?.replace(".json", "") ?? "",
|
|
277
|
+
title: parsed.summary.title,
|
|
278
|
+
body: parsed.summary.body ?? "",
|
|
279
|
+
created: parsed.time?.created ?? 0,
|
|
280
|
+
role: parsed.role ?? "unknown",
|
|
281
|
+
source: "opencode",
|
|
282
|
+
});
|
|
264
283
|
}
|
|
284
|
+
}
|
|
265
285
|
|
|
266
|
-
|
|
267
|
-
|
|
286
|
+
return summaries;
|
|
287
|
+
}).pipe(Effect.catchTag("SessionStorageNotFoundError", () => Effect.succeed([])));
|
|
268
288
|
|
|
269
289
|
const claudeSummaries =
|
|
270
|
-
sourceFilter
|
|
290
|
+
!sourceFilter.has("claude-code") || paths.claudeCodePath === null
|
|
271
291
|
? []
|
|
272
292
|
: yield* getClaudeCodeSessions(paths.claudeCodePath, null).pipe(
|
|
273
293
|
Effect.map((sessionFiles) =>
|
|
@@ -281,7 +301,25 @@ export class SessionService extends Context.Service<
|
|
|
281
301
|
Effect.catchTag("SessionStorageNotFoundError", () => Effect.succeed([])),
|
|
282
302
|
);
|
|
283
303
|
|
|
284
|
-
const
|
|
304
|
+
const codexSummaries =
|
|
305
|
+
!sourceFilter.has("codex") || paths.codexPath === null
|
|
306
|
+
? []
|
|
307
|
+
: yield* getCodexSessions(paths.codexPath, null).pipe(
|
|
308
|
+
Effect.map((sessionFiles) =>
|
|
309
|
+
filterSessions === null
|
|
310
|
+
? sessionFiles
|
|
311
|
+
: sessionFiles.filter((sessionFile) =>
|
|
312
|
+
filterSessions.has(getCodexSessionId(sessionFile)),
|
|
313
|
+
),
|
|
314
|
+
),
|
|
315
|
+
Effect.flatMap(readCodexMessages),
|
|
316
|
+
Effect.catchTags({
|
|
317
|
+
SessionStorageNotFoundError: () => Effect.succeed([]),
|
|
318
|
+
SessionReadError: () => Effect.succeed([]),
|
|
319
|
+
}),
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
const summaries = [...opencodeSummaries, ...claudeSummaries, ...codexSummaries];
|
|
285
323
|
|
|
286
324
|
return (
|
|
287
325
|
summaries as MessageSummary[] & {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
|
|
1
3
|
import type { OutputFormat } from "#shared";
|
|
2
4
|
|
|
3
5
|
export type { OutputFormat };
|
|
@@ -8,7 +10,8 @@ export type SessionInfo = {
|
|
|
8
10
|
projectID: string;
|
|
9
11
|
};
|
|
10
12
|
|
|
11
|
-
export
|
|
13
|
+
export const SessionSourceLiterals = Schema.Literals(["opencode", "claude-code", "codex"]);
|
|
14
|
+
export type SessionSource = Schema.Schema.Type<typeof SessionSourceLiterals>;
|
|
12
15
|
|
|
13
16
|
export type MessageSummary = {
|
|
14
17
|
sessionID: string;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
const trimSlashes = (segment: string) => segment.replace(/^\/+|\/+$/g, "");
|
|
2
|
+
|
|
3
|
+
export const joinPath = (first: string, ...rest: readonly string[]) => {
|
|
4
|
+
const isAbsolute = first.startsWith("/");
|
|
5
|
+
const joined = [first, ...rest]
|
|
6
|
+
.map(trimSlashes)
|
|
7
|
+
.filter((segment) => segment !== "")
|
|
8
|
+
.join("/");
|
|
9
|
+
|
|
10
|
+
if (!isAbsolute) {
|
|
11
|
+
return joined;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return joined === "" ? "/" : `/${joined}`;
|
|
15
|
+
};
|
|
@@ -1,15 +1,364 @@
|
|
|
1
|
+
// Synchronous node:fs calls keep the cross-process lock/lease critical section atomic;
|
|
2
|
+
// Bun does not provide equivalent synchronous directory primitives for this use case.
|
|
3
|
+
import { mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
4
|
+
|
|
1
5
|
import { Clock, Duration, Effect, Result } from "effect";
|
|
2
6
|
import { ChildProcess } from "effect/unstable/process";
|
|
3
7
|
|
|
4
8
|
import type { AgentToolsConfig, ProfilePrerequisites } from "#config/types";
|
|
5
|
-
import type {
|
|
6
|
-
|
|
9
|
+
import type {
|
|
10
|
+
PrerequisiteCommandRunner,
|
|
11
|
+
ResolvedVpnDriver,
|
|
12
|
+
VpnCleanupPolicy,
|
|
13
|
+
VpnLease,
|
|
14
|
+
VpnLeaseHandle,
|
|
15
|
+
VpnLockOwner,
|
|
16
|
+
VpnStartState,
|
|
17
|
+
} from "#shared/prerequisites/types";
|
|
18
|
+
|
|
19
|
+
import { joinPath } from "#shared/path";
|
|
7
20
|
import { normalizeProfilePrerequisites } from "#shared/prerequisites/config";
|
|
8
21
|
import { PrerequisiteRunError } from "#shared/prerequisites/errors";
|
|
9
22
|
import { missingVpnToolHint, resolveVpnDriverConfig } from "#shared/prerequisites/vpn";
|
|
10
23
|
|
|
11
24
|
const readEnv = (name: string) => Bun.env[name];
|
|
12
25
|
|
|
26
|
+
const DEFAULT_LEASE_TTL_MS = 10 * 60 * 1000;
|
|
27
|
+
const LOCK_STALE_MS = 30_000;
|
|
28
|
+
const LOCK_RETRY_MS = 25;
|
|
29
|
+
const LOCK_TIMEOUT_BUFFER_MS = 5_000;
|
|
30
|
+
|
|
31
|
+
const getRuntimeRoot = () =>
|
|
32
|
+
readEnv("AGENT_TOOLS_RUNTIME_DIR") ??
|
|
33
|
+
joinPath(readEnv("TMPDIR") ?? readEnv("TEMP") ?? readEnv("TMP") ?? "/tmp", "agent-tools");
|
|
34
|
+
|
|
35
|
+
const getDriverIdentity = (driver: ResolvedVpnDriver) => {
|
|
36
|
+
if (driver.type === "macos-scutil") {
|
|
37
|
+
return { type: driver.type, platform: driver.platform, serviceName: driver.serviceName };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (driver.type === "linux-nmcli") {
|
|
41
|
+
return { type: driver.type, platform: driver.platform, connectionName: driver.connectionName };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return { type: driver.type, platform: driver.platform, entryName: driver.entryName };
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const getDriverLeaseKey = (driver: ResolvedVpnDriver) =>
|
|
48
|
+
Bun.hash(JSON.stringify(getDriverIdentity(driver))).toString(16);
|
|
49
|
+
|
|
50
|
+
const makeLeaseHandle = (
|
|
51
|
+
driver: ResolvedVpnDriver,
|
|
52
|
+
ttlMs: number,
|
|
53
|
+
lockTimeoutMs: number,
|
|
54
|
+
): VpnLeaseHandle => {
|
|
55
|
+
const key = getDriverLeaseKey(driver);
|
|
56
|
+
const directory = joinPath(getRuntimeRoot(), "vpn-prerequisites", key);
|
|
57
|
+
return {
|
|
58
|
+
directory,
|
|
59
|
+
leasePath: joinPath(directory, `lease-${process.pid}.json`),
|
|
60
|
+
statePath: joinPath(directory, "started.json"),
|
|
61
|
+
lockPath: joinPath(directory, "lock"),
|
|
62
|
+
ttlMs,
|
|
63
|
+
lockTimeoutMs,
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const getErrorMessage = (error: unknown) =>
|
|
68
|
+
error instanceof Error ? error.message : String(error);
|
|
69
|
+
|
|
70
|
+
const hasErrorCode = (error: unknown, code: string) =>
|
|
71
|
+
typeof error === "object" && error !== null && "code" in error && error.code === code;
|
|
72
|
+
|
|
73
|
+
const fsError = (message: string, error: unknown) =>
|
|
74
|
+
new PrerequisiteRunError({
|
|
75
|
+
message: `${message}: ${getErrorMessage(error)}`,
|
|
76
|
+
hint: "Retry the command. If this repeats, remove stale files under the agent-tools runtime directory.",
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const syncFs = <A>(message: string, operation: () => A) =>
|
|
80
|
+
Effect.try({
|
|
81
|
+
try: operation,
|
|
82
|
+
catch: (error) => fsError(message, error),
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const readJsonFile = (path: string): unknown | undefined => {
|
|
86
|
+
try {
|
|
87
|
+
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
88
|
+
return parsed;
|
|
89
|
+
} catch (error) {
|
|
90
|
+
void error;
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
type JsonObject = { readonly [key: string]: unknown };
|
|
96
|
+
|
|
97
|
+
const isJsonObject = (value: unknown): value is JsonObject =>
|
|
98
|
+
typeof value === "object" && value !== null;
|
|
99
|
+
|
|
100
|
+
const isFiniteNumber = (value: unknown): value is number =>
|
|
101
|
+
typeof value === "number" && Number.isFinite(value);
|
|
102
|
+
|
|
103
|
+
const isVpnLease = (value: unknown): value is VpnLease =>
|
|
104
|
+
isJsonObject(value) &&
|
|
105
|
+
isFiniteNumber(value.pid) &&
|
|
106
|
+
isFiniteNumber(value.createdAt) &&
|
|
107
|
+
isFiniteNumber(value.updatedAt);
|
|
108
|
+
|
|
109
|
+
const isVpnStartState = (value: unknown): value is VpnStartState =>
|
|
110
|
+
isJsonObject(value) && isFiniteNumber(value.pid) && isFiniteNumber(value.startedAt);
|
|
111
|
+
|
|
112
|
+
const isVpnLockOwner = (value: unknown): value is VpnLockOwner =>
|
|
113
|
+
isJsonObject(value) && isFiniteNumber(value.pid) && isFiniteNumber(value.createdAt);
|
|
114
|
+
|
|
115
|
+
const readVpnLease = (path: string) => {
|
|
116
|
+
const parsed = readJsonFile(path);
|
|
117
|
+
return isVpnLease(parsed) ? parsed : undefined;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const readVpnStartState = (path: string) => {
|
|
121
|
+
const parsed = readJsonFile(path);
|
|
122
|
+
return isVpnStartState(parsed) ? parsed : undefined;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const readVpnLockOwner = (path: string) => {
|
|
126
|
+
const parsed = readJsonFile(path);
|
|
127
|
+
return isVpnLockOwner(parsed) ? parsed : undefined;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const isPidLive = (pid: number) => {
|
|
131
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
process.kill(pid, 0);
|
|
137
|
+
return true;
|
|
138
|
+
} catch (error) {
|
|
139
|
+
return error instanceof Error && "code" in error && error.code === "EPERM";
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const isLeaseLive = (lease: VpnLease | undefined, now: number, ttlMs: number) => {
|
|
144
|
+
if (!lease) {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (isPidLive(lease.pid)) {
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return now - lease.updatedAt <= ttlMs;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const pruneStaleLeases = (handle: VpnLeaseHandle, now: number) =>
|
|
156
|
+
syncFs("Failed to prune VPN prerequisite lease files", () => {
|
|
157
|
+
for (const entry of readdirSync(handle.directory, { withFileTypes: true })) {
|
|
158
|
+
if (!entry.isFile() || !entry.name.startsWith("lease-") || !entry.name.endsWith(".json")) {
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const leasePath = joinPath(handle.directory, entry.name);
|
|
163
|
+
const lease = readVpnLease(leasePath);
|
|
164
|
+
if (!isLeaseLive(lease, now, handle.ttlMs)) {
|
|
165
|
+
rmSync(leasePath, { force: true });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const hasOtherLiveLeases = (handle: VpnLeaseHandle, now: number) =>
|
|
171
|
+
Effect.gen(function* () {
|
|
172
|
+
yield* pruneStaleLeases(handle, now);
|
|
173
|
+
|
|
174
|
+
return yield* syncFs("Failed to inspect VPN prerequisite lease files", () => {
|
|
175
|
+
for (const entry of readdirSync(handle.directory, { withFileTypes: true })) {
|
|
176
|
+
if (!entry.isFile() || !entry.name.startsWith("lease-") || !entry.name.endsWith(".json")) {
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const leasePath = joinPath(handle.directory, entry.name);
|
|
181
|
+
if (leasePath === handle.leasePath) {
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const lease = readVpnLease(leasePath);
|
|
186
|
+
if (isLeaseLive(lease, now, handle.ttlMs)) {
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return false;
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const writeLease = (handle: VpnLeaseHandle, now: number) =>
|
|
196
|
+
syncFs("Failed to write VPN prerequisite lease", () => {
|
|
197
|
+
mkdirSync(handle.directory, { recursive: true });
|
|
198
|
+
const existingLease = readVpnLease(handle.leasePath);
|
|
199
|
+
const lease: VpnLease = {
|
|
200
|
+
pid: process.pid,
|
|
201
|
+
createdAt: existingLease?.createdAt ?? now,
|
|
202
|
+
updatedAt: now,
|
|
203
|
+
};
|
|
204
|
+
writeFileSync(handle.leasePath, JSON.stringify(lease));
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
const writeStartState = (handle: VpnLeaseHandle, now: number) =>
|
|
208
|
+
syncFs("Failed to write VPN prerequisite start state", () => {
|
|
209
|
+
const state: VpnStartState = { pid: process.pid, startedAt: now };
|
|
210
|
+
writeFileSync(handle.statePath, JSON.stringify(state));
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const readStartState = (handle: VpnLeaseHandle) =>
|
|
214
|
+
syncFs("Failed to read VPN prerequisite start state", () => readVpnStartState(handle.statePath));
|
|
215
|
+
|
|
216
|
+
const removeOwnLease = (handle: VpnLeaseHandle) =>
|
|
217
|
+
syncFs("Failed to remove VPN prerequisite lease", () => {
|
|
218
|
+
rmSync(handle.leasePath, { force: true });
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
const removeStartState = (handle: VpnLeaseHandle) =>
|
|
222
|
+
syncFs("Failed to remove VPN prerequisite start state", () => {
|
|
223
|
+
rmSync(handle.statePath, { force: true });
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const getLockDirectoryAgeMs = (handle: VpnLeaseHandle, now: number) =>
|
|
227
|
+
syncFs("Failed to inspect VPN prerequisite lease lock", () => {
|
|
228
|
+
let stats: ReturnType<typeof statSync>;
|
|
229
|
+
try {
|
|
230
|
+
stats = statSync(handle.lockPath);
|
|
231
|
+
} catch (error) {
|
|
232
|
+
if (hasErrorCode(error, "ENOENT")) {
|
|
233
|
+
return undefined;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
throw error;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return now - stats.mtimeMs;
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
const isLockOwnerStale = (
|
|
243
|
+
owner: VpnLockOwner | undefined,
|
|
244
|
+
lockAgeMs: number,
|
|
245
|
+
timeoutMs: number,
|
|
246
|
+
) => {
|
|
247
|
+
const staleThresholdMs = Math.max(LOCK_STALE_MS, timeoutMs);
|
|
248
|
+
if (!owner) {
|
|
249
|
+
return lockAgeMs > staleThresholdMs;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return !isPidLive(owner.pid) && lockAgeMs > staleThresholdMs;
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const acquireFileLock = (handle: VpnLeaseHandle) =>
|
|
256
|
+
Effect.gen(function* () {
|
|
257
|
+
yield* syncFs("Failed to create VPN prerequisite lease directory", () => {
|
|
258
|
+
mkdirSync(handle.directory, { recursive: true });
|
|
259
|
+
});
|
|
260
|
+
const start = yield* Clock.currentTimeMillis;
|
|
261
|
+
|
|
262
|
+
while (true) {
|
|
263
|
+
const now = yield* Clock.currentTimeMillis;
|
|
264
|
+
let lockError: unknown;
|
|
265
|
+
try {
|
|
266
|
+
mkdirSync(handle.lockPath);
|
|
267
|
+
writeFileSync(
|
|
268
|
+
joinPath(handle.lockPath, "owner.json"),
|
|
269
|
+
`{"pid":${process.pid},"createdAt":${Number(now)}}`,
|
|
270
|
+
);
|
|
271
|
+
return;
|
|
272
|
+
} catch (error) {
|
|
273
|
+
lockError = error;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (!hasErrorCode(lockError, "EEXIST")) {
|
|
277
|
+
return yield* fsError("Failed to acquire VPN prerequisite lease lock", lockError);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const lockOwner = readVpnLockOwner(joinPath(handle.lockPath, "owner.json"));
|
|
281
|
+
const lockAgeMs = yield* getLockDirectoryAgeMs(handle, Number(now));
|
|
282
|
+
if (lockAgeMs === undefined) {
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (isLockOwnerStale(lockOwner, lockAgeMs, handle.lockTimeoutMs)) {
|
|
287
|
+
yield* syncFs("Failed to remove stale VPN prerequisite lease lock", () => {
|
|
288
|
+
rmSync(handle.lockPath, { recursive: true, force: true });
|
|
289
|
+
});
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (Number(now) - Number(start) > handle.lockTimeoutMs) {
|
|
294
|
+
return yield* new PrerequisiteRunError({
|
|
295
|
+
message: "Timed out while waiting for VPN prerequisite lease lock.",
|
|
296
|
+
hint: "Retry the command. If this repeats, remove stale files under the agent-tools runtime directory.",
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
yield* Effect.sleep(Duration.millis(LOCK_RETRY_MS));
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
const releaseFileLock = (handle: VpnLeaseHandle) =>
|
|
305
|
+
syncFs("Failed to release VPN prerequisite lease lock", () => {
|
|
306
|
+
rmSync(handle.lockPath, { recursive: true, force: true });
|
|
307
|
+
}).pipe(Effect.ignore);
|
|
308
|
+
|
|
309
|
+
const withVpnLeaseLock = <A, E>(
|
|
310
|
+
handle: VpnLeaseHandle,
|
|
311
|
+
effect: Effect.Effect<A, E, never>,
|
|
312
|
+
): Effect.Effect<A, E | PrerequisiteRunError, never> =>
|
|
313
|
+
Effect.acquireRelease(acquireFileLock(handle), () => releaseFileLock(handle)).pipe(
|
|
314
|
+
Effect.flatMap(() => effect),
|
|
315
|
+
Effect.scoped,
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
type HeldVpnLease = {
|
|
319
|
+
readonly handle: VpnLeaseHandle;
|
|
320
|
+
readonly driver: ResolvedVpnDriver;
|
|
321
|
+
readonly cleanup: VpnCleanupPolicy;
|
|
322
|
+
readonly cooldownMs: number;
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
const cleanupHeldLeases = <CommandError>(
|
|
326
|
+
heldLeases: readonly HeldVpnLease[],
|
|
327
|
+
runCommand: PrerequisiteCommandRunner<CommandError>,
|
|
328
|
+
) =>
|
|
329
|
+
Effect.gen(function* () {
|
|
330
|
+
for (const held of heldLeases.toReversed()) {
|
|
331
|
+
if (held.cooldownMs > 0) {
|
|
332
|
+
yield* Effect.sleep(Duration.millis(held.cooldownMs));
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
yield* withVpnLeaseLock(
|
|
336
|
+
held.handle,
|
|
337
|
+
Effect.gen(function* () {
|
|
338
|
+
const now = yield* Clock.currentTimeMillis;
|
|
339
|
+
yield* removeOwnLease(held.handle);
|
|
340
|
+
if (held.cleanup === "leave-running") {
|
|
341
|
+
// Treat the agent-started VPN as intentionally adopted so later default runs do not stop it.
|
|
342
|
+
yield* removeStartState(held.handle);
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const state = yield* readStartState(held.handle);
|
|
347
|
+
const hasOtherLeases = yield* hasOtherLiveLeases(held.handle, Number(now));
|
|
348
|
+
const shouldStop = state !== undefined && !hasOtherLeases;
|
|
349
|
+
|
|
350
|
+
if (!shouldStop) {
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const stopCommand = makeVpnCommand(held.driver, "stop");
|
|
355
|
+
yield* runCommand(stopCommand.command, stopCommand.label).pipe(Effect.ignore);
|
|
356
|
+
yield* removeStartState(held.handle);
|
|
357
|
+
}),
|
|
358
|
+
).pipe(Effect.ignore);
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
|
|
13
362
|
const makeVpnCommand = (driver: ResolvedVpnDriver, action: "status" | "start" | "stop") => {
|
|
14
363
|
if (driver.type === "macos-scutil") {
|
|
15
364
|
const secret = driver.secretEnvVar ? readEnv(driver.secretEnvVar) : undefined;
|
|
@@ -145,87 +494,131 @@ export const runWithProfilePrerequisites = <A, E, CommandError>(
|
|
|
145
494
|
}
|
|
146
495
|
|
|
147
496
|
const prerequisiteResult = yield* Effect.gen(function* () {
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
const driverResolution = resolveVpnDriverConfig(vpnConfig);
|
|
160
|
-
if (!driverResolution.success) {
|
|
161
|
-
return yield* new PrerequisiteRunError({
|
|
162
|
-
message: driverResolution.error,
|
|
163
|
-
hint: driverResolution.hint,
|
|
164
|
-
});
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
const driver = driverResolution.driver;
|
|
168
|
-
const wasConnected = yield* isVpnConnected(driver, runCommand);
|
|
169
|
-
if (wasConnected) {
|
|
170
|
-
continue;
|
|
171
|
-
}
|
|
497
|
+
const heldLeases: HeldVpnLease[] = [];
|
|
498
|
+
const acquirePrerequisites = Effect.gen(function* () {
|
|
499
|
+
for (const prerequisite of vpnPrerequisites) {
|
|
500
|
+
const vpnConfig = config.vpns?.[prerequisite.key];
|
|
501
|
+
if (!vpnConfig) {
|
|
502
|
+
return yield* new PrerequisiteRunError({
|
|
503
|
+
message: `VPN prerequisite "${prerequisite.key}" is not defined.`,
|
|
504
|
+
hint: `Add vpns.${prerequisite.key} to agent-tools.json5 or remove the prerequisite.`,
|
|
505
|
+
});
|
|
506
|
+
}
|
|
172
507
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
hint: `Set ${driver.secretEnvVar} before running this tool or remove secretEnvVar from the VPN config.`,
|
|
181
|
-
});
|
|
182
|
-
}
|
|
508
|
+
const driverResolution = resolveVpnDriverConfig(vpnConfig);
|
|
509
|
+
if (!driverResolution.success) {
|
|
510
|
+
return yield* new PrerequisiteRunError({
|
|
511
|
+
message: driverResolution.error,
|
|
512
|
+
hint: driverResolution.hint,
|
|
513
|
+
});
|
|
514
|
+
}
|
|
183
515
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
)
|
|
193
|
-
|
|
516
|
+
const driver = driverResolution.driver;
|
|
517
|
+
const cleanup: VpnCleanupPolicy =
|
|
518
|
+
prerequisite.cleanup ?? vpnConfig.defaultCleanup ?? "stop-if-started";
|
|
519
|
+
const connectTimeoutMs = vpnConfig.connectTimeoutMs ?? 30000;
|
|
520
|
+
const handle = makeLeaseHandle(
|
|
521
|
+
driver,
|
|
522
|
+
vpnConfig.leaseTtlMs ?? DEFAULT_LEASE_TTL_MS,
|
|
523
|
+
connectTimeoutMs + LOCK_TIMEOUT_BUFFER_MS,
|
|
524
|
+
);
|
|
525
|
+
|
|
526
|
+
const acquisitionResult = yield* withVpnLeaseLock(
|
|
527
|
+
handle,
|
|
528
|
+
Effect.gen(function* () {
|
|
529
|
+
const result = yield* Effect.gen(function* () {
|
|
530
|
+
const now = yield* Clock.currentTimeMillis;
|
|
531
|
+
yield* writeLease(handle, Number(now));
|
|
532
|
+
yield* pruneStaleLeases(handle, Number(now));
|
|
533
|
+
|
|
534
|
+
const wasConnected = yield* isVpnConnected(driver, runCommand);
|
|
535
|
+
if (wasConnected) {
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (
|
|
540
|
+
driver.type === "macos-scutil" &&
|
|
541
|
+
driver.secretEnvVar &&
|
|
542
|
+
!readEnv(driver.secretEnvVar)
|
|
543
|
+
) {
|
|
544
|
+
return yield* new PrerequisiteRunError({
|
|
545
|
+
message: `VPN secret environment variable "${driver.secretEnvVar}" is not set.`,
|
|
546
|
+
hint: `Set ${driver.secretEnvVar} before running this tool or remove secretEnvVar from the VPN config.`,
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const startCommand = makeVpnCommand(driver, "start");
|
|
551
|
+
const startResult = yield* runCommand(
|
|
552
|
+
startCommand.command,
|
|
553
|
+
startCommand.label,
|
|
554
|
+
).pipe(
|
|
555
|
+
Effect.mapError(
|
|
556
|
+
() =>
|
|
557
|
+
new PrerequisiteRunError({
|
|
558
|
+
message: `Failed to start VPN prerequisite "${prerequisite.key}".`,
|
|
559
|
+
hint: missingVpnToolHint(driver),
|
|
560
|
+
}),
|
|
561
|
+
),
|
|
562
|
+
);
|
|
563
|
+
|
|
564
|
+
if (startResult.exitCode !== 0) {
|
|
565
|
+
const stderr = startResult.stderr.trim();
|
|
566
|
+
return yield* new PrerequisiteRunError({
|
|
567
|
+
message:
|
|
568
|
+
stderr !== ""
|
|
569
|
+
? stderr
|
|
570
|
+
: `Failed to start VPN prerequisite "${prerequisite.key}".`,
|
|
571
|
+
hint: missingVpnToolHint(driver),
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const ready = yield* waitForVpn(driver, connectTimeoutMs, runCommand);
|
|
576
|
+
if (!ready) {
|
|
577
|
+
return yield* new PrerequisiteRunError({
|
|
578
|
+
message: `VPN prerequisite "${prerequisite.key}" did not connect within timeout.`,
|
|
579
|
+
hint: missingVpnToolHint(driver),
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
if (cleanup === "stop-if-started") {
|
|
584
|
+
const connectedAt = yield* Clock.currentTimeMillis;
|
|
585
|
+
yield* writeStartState(handle, Number(connectedAt));
|
|
586
|
+
}
|
|
587
|
+
}).pipe(Effect.result);
|
|
588
|
+
|
|
589
|
+
if (Result.isFailure(result)) {
|
|
590
|
+
yield* removeOwnLease(handle).pipe(Effect.ignore);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
return result;
|
|
594
|
+
}),
|
|
595
|
+
).pipe(
|
|
596
|
+
Effect.mapError((error) =>
|
|
597
|
+
error instanceof PrerequisiteRunError
|
|
598
|
+
? error
|
|
599
|
+
: new PrerequisiteRunError({
|
|
600
|
+
message: `Failed to coordinate VPN prerequisite "${prerequisite.key}".`,
|
|
601
|
+
hint: missingVpnToolHint(driver),
|
|
602
|
+
}),
|
|
603
|
+
),
|
|
604
|
+
);
|
|
605
|
+
|
|
606
|
+
if (Result.isFailure(acquisitionResult)) {
|
|
607
|
+
return yield* Effect.fail(acquisitionResult.failure);
|
|
608
|
+
}
|
|
194
609
|
|
|
195
|
-
|
|
196
|
-
const stderr = startResult.stderr.trim();
|
|
197
|
-
return yield* new PrerequisiteRunError({
|
|
198
|
-
message:
|
|
199
|
-
stderr !== "" ? stderr : `Failed to start VPN prerequisite "${prerequisite.key}".`,
|
|
200
|
-
hint: missingVpnToolHint(driver),
|
|
201
|
-
});
|
|
610
|
+
heldLeases.push({ handle, driver, cleanup, cooldownMs: vpnConfig.cooldownMs ?? 0 });
|
|
202
611
|
}
|
|
612
|
+
});
|
|
203
613
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
return yield* new PrerequisiteRunError({
|
|
207
|
-
message: `VPN prerequisite "${prerequisite.key}" did not connect within timeout.`,
|
|
208
|
-
hint: missingVpnToolHint(driver),
|
|
209
|
-
});
|
|
210
|
-
}
|
|
614
|
+
const acquireResult = yield* acquirePrerequisites.pipe(Effect.result);
|
|
615
|
+
const cleanup = cleanupHeldLeases(heldLeases, runCommand);
|
|
211
616
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
}
|
|
617
|
+
if (Result.isFailure(acquireResult)) {
|
|
618
|
+
yield* cleanup.pipe(Effect.ignore);
|
|
619
|
+
return yield* Effect.fail(acquireResult.failure);
|
|
216
620
|
}
|
|
217
621
|
|
|
218
|
-
const cleanup = Effect.gen(function* () {
|
|
219
|
-
for (const started of startedDrivers.toReversed()) {
|
|
220
|
-
if (started.cooldownMs > 0) {
|
|
221
|
-
yield* Effect.sleep(Duration.millis(started.cooldownMs));
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
const stopCommand = makeVpnCommand(started.driver, "stop");
|
|
225
|
-
yield* runCommand(stopCommand.command, stopCommand.label).pipe(Effect.ignore);
|
|
226
|
-
}
|
|
227
|
-
});
|
|
228
|
-
|
|
229
622
|
return yield* effect.pipe(Effect.ensuring(cleanup));
|
|
230
623
|
}).pipe(Effect.result);
|
|
231
624
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { Schema, type Effect } from "effect";
|
|
2
2
|
import type { ChildProcess } from "effect/unstable/process";
|
|
3
3
|
|
|
4
4
|
import type {
|
|
@@ -23,6 +23,9 @@ export type VpnDriverResolution =
|
|
|
23
23
|
| { success: true; driver: ResolvedVpnDriver }
|
|
24
24
|
| { success: false; error: string; hint: string };
|
|
25
25
|
|
|
26
|
+
export const VpnCleanupPolicy = Schema.Literals(["leave-running", "stop-if-started"]);
|
|
27
|
+
export type VpnCleanupPolicy = Schema.Schema.Type<typeof VpnCleanupPolicy>;
|
|
28
|
+
|
|
26
29
|
export type PrerequisiteCommandResult = {
|
|
27
30
|
readonly stdout: string;
|
|
28
31
|
readonly stderr: string;
|
|
@@ -33,3 +36,28 @@ export type PrerequisiteCommandRunner<E> = (
|
|
|
33
36
|
command: ChildProcess.Command,
|
|
34
37
|
label: string,
|
|
35
38
|
) => Effect.Effect<PrerequisiteCommandResult, E, never>;
|
|
39
|
+
|
|
40
|
+
export type VpnLease = {
|
|
41
|
+
readonly pid: number;
|
|
42
|
+
readonly createdAt: number;
|
|
43
|
+
readonly updatedAt: number;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type VpnStartState = {
|
|
47
|
+
readonly pid: number;
|
|
48
|
+
readonly startedAt: number;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export type VpnLockOwner = {
|
|
52
|
+
readonly pid: number;
|
|
53
|
+
readonly createdAt: number;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export type VpnLeaseHandle = {
|
|
57
|
+
readonly directory: string;
|
|
58
|
+
readonly leasePath: string;
|
|
59
|
+
readonly statePath: string;
|
|
60
|
+
readonly lockPath: string;
|
|
61
|
+
readonly ttlMs: number;
|
|
62
|
+
readonly lockTimeoutMs: number;
|
|
63
|
+
};
|