glab-agent 0.1.0

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.
@@ -0,0 +1,443 @@
1
+ import { execFile } from "node:child_process";
2
+ import { readFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { promisify } from "node:util";
5
+
6
+ import { readFileSync } from "node:fs";
7
+ import { getAgentNotePrefixes } from "./agent-provider.js";
8
+ import { loadAgentByName } from "./agent-config.js";
9
+ import type { LocalAgentState } from "./state-store.js";
10
+ import { loadConfigFromAgentDefinition } from "./watcher.js";
11
+
12
+ const execFileAsync = promisify(execFile);
13
+
14
+ export interface SmokeTestOptions {
15
+ waitFinish: boolean;
16
+ pollIntervalSeconds: number;
17
+ acceptTimeoutSeconds: number;
18
+ finishTimeoutSeconds: number;
19
+ titlePrefix: string;
20
+ }
21
+
22
+ interface GitlabUser {
23
+ id: number;
24
+ username: string;
25
+ name: string;
26
+ bot?: boolean;
27
+ }
28
+
29
+ interface GitlabIssue {
30
+ id: number;
31
+ iid: number;
32
+ title: string;
33
+ description?: string;
34
+ labels: string[];
35
+ web_url: string;
36
+ }
37
+
38
+ interface GitlabTodo {
39
+ id: number;
40
+ action_name: string;
41
+ target_type: string;
42
+ state: string;
43
+ target?: {
44
+ iid?: number;
45
+ };
46
+ }
47
+
48
+ interface GitlabNote {
49
+ id: number;
50
+ body: string;
51
+ }
52
+
53
+ interface GitlabLabelEvent {
54
+ action: string;
55
+ label?: {
56
+ name?: string;
57
+ };
58
+ }
59
+
60
+ interface AcceptanceResult {
61
+ observedTodoAction?: string;
62
+ state: LocalAgentState;
63
+ }
64
+
65
+ interface ExecResult {
66
+ stdout: string;
67
+ stderr: string;
68
+ }
69
+
70
+ function printUsage(): void {
71
+ console.log(`Usage: pnpm smoke-test [--wait-finish] [--poll-interval-seconds=N] [--accept-timeout-seconds=N] [--finish-timeout-seconds=N] [--title-prefix=TEXT]
72
+
73
+ Creates a real GitLab issue with the locally logged-in glab user, @mentions the bot user from .env,
74
+ starts the watcher if needed, and verifies the acceptance path. Add --wait-finish to keep polling
75
+ until the issue reaches In Review or a failure note is posted.`);
76
+ }
77
+
78
+ export function parseSmokeTestArgs(argv: string[]): SmokeTestOptions {
79
+ const options: SmokeTestOptions = {
80
+ waitFinish: false,
81
+ pollIntervalSeconds: 3,
82
+ acceptTimeoutSeconds: 180,
83
+ finishTimeoutSeconds: 900,
84
+ titlePrefix: "[smoke]"
85
+ };
86
+
87
+ for (const arg of argv) {
88
+ if (arg === "--") {
89
+ continue;
90
+ }
91
+
92
+ if (arg === "--help" || arg === "-h") {
93
+ printUsage();
94
+ process.exit(0);
95
+ }
96
+
97
+ if (arg === "--wait-finish") {
98
+ options.waitFinish = true;
99
+ continue;
100
+ }
101
+
102
+ if (arg.startsWith("--poll-interval-seconds=")) {
103
+ options.pollIntervalSeconds = parsePositiveInteger(arg, "--poll-interval-seconds");
104
+ continue;
105
+ }
106
+
107
+ if (arg.startsWith("--accept-timeout-seconds=")) {
108
+ options.acceptTimeoutSeconds = parsePositiveInteger(arg, "--accept-timeout-seconds");
109
+ continue;
110
+ }
111
+
112
+ if (arg.startsWith("--finish-timeout-seconds=")) {
113
+ options.finishTimeoutSeconds = parsePositiveInteger(arg, "--finish-timeout-seconds");
114
+ continue;
115
+ }
116
+
117
+ if (arg.startsWith("--title-prefix=")) {
118
+ options.titlePrefix = arg.slice("--title-prefix=".length).trim() || "[smoke]";
119
+ continue;
120
+ }
121
+
122
+ throw new Error(`Unsupported argument: ${arg}`);
123
+ }
124
+
125
+ return options;
126
+ }
127
+
128
+ function parsePositiveInteger(arg: string, flag: string): number {
129
+ const value = Number.parseInt(arg.slice(flag.length + 1), 10);
130
+
131
+ if (Number.isNaN(value) || value <= 0) {
132
+ throw new Error(`${flag} must be a positive integer.`);
133
+ }
134
+
135
+ return value;
136
+ }
137
+
138
+ export function buildSmokeIssueDescription(user: GitlabUser, bot: GitlabUser, runId: string): string {
139
+ return [
140
+ `Smoke test run: ${runId}`,
141
+ `Creator: @${user.username}`,
142
+ "",
143
+ "Please let the local GitLab To-Do watcher pick up this issue.",
144
+ "",
145
+ `@${bot.username}`
146
+ ].join("\n");
147
+ }
148
+
149
+ export function findTodoForIssue(todos: GitlabTodo[], issueIid: number): GitlabTodo | undefined {
150
+ return todos.find(
151
+ (todo) => todo.state === "pending" && todo.target_type === "Issue" && todo.target?.iid === issueIid
152
+ );
153
+ }
154
+
155
+ export function hasAddedLabelEvent(events: GitlabLabelEvent[], labelName: string): boolean {
156
+ return events.some((event) => event.action === "add" && event.label?.name === labelName);
157
+ }
158
+
159
+ function hasNote(notes: GitlabNote[], prefix: string): boolean {
160
+ return notes.some((note) => note.body.startsWith(prefix));
161
+ }
162
+
163
+ function getNote(notes: GitlabNote[], prefix: string): GitlabNote | undefined {
164
+ return notes.find((note) => note.body.startsWith(prefix));
165
+ }
166
+
167
+ async function execCommand(
168
+ file: string,
169
+ args: string[],
170
+ options: {
171
+ cwd?: string;
172
+ env?: NodeJS.ProcessEnv;
173
+ } = {}
174
+ ): Promise<ExecResult> {
175
+ try {
176
+ const { stdout, stderr } = await execFileAsync(file, args, {
177
+ cwd: options.cwd,
178
+ env: options.env,
179
+ encoding: "utf8",
180
+ maxBuffer: 10 * 1024 * 1024
181
+ });
182
+
183
+ return {
184
+ stdout: stdout.trim(),
185
+ stderr: stderr.trim()
186
+ };
187
+ } catch (error) {
188
+ const execError = error as {
189
+ stdout?: string;
190
+ stderr?: string;
191
+ message?: string;
192
+ };
193
+ const output = [execError.stdout, execError.stderr, execError.message].filter(Boolean).join("\n");
194
+ throw new Error(`${file} ${args.join(" ")} failed.\n${output}`.trim());
195
+ }
196
+ }
197
+
198
+ async function glabApiJson<T>(
199
+ host: string,
200
+ endpoint: string,
201
+ env: NodeJS.ProcessEnv,
202
+ extraArgs: string[] = []
203
+ ): Promise<T> {
204
+ const result = await execCommand("glab", ["api", ...extraArgs, endpoint], { env: { ...env, GITLAB_HOST: host } });
205
+
206
+ try {
207
+ return JSON.parse(result.stdout) as T;
208
+ } catch {
209
+ const details = [result.stdout, result.stderr].filter(Boolean).join("\n");
210
+ throw new Error(`glab api ${endpoint} returned non-JSON output.\n${details}`.trim());
211
+ }
212
+ }
213
+
214
+ function buildUserEnv(host: string): NodeJS.ProcessEnv {
215
+ const env: NodeJS.ProcessEnv = { ...process.env, GITLAB_HOST: host };
216
+ delete env.GITLAB_TOKEN;
217
+ return env;
218
+ }
219
+
220
+ function buildBotEnv(host: string, token: string): NodeJS.ProcessEnv {
221
+ return {
222
+ ...process.env,
223
+ GITLAB_HOST: host,
224
+ GITLAB_TOKEN: token
225
+ };
226
+ }
227
+
228
+ async function readState(statePath: string): Promise<LocalAgentState> {
229
+ try {
230
+ const content = await readFile(statePath, "utf8");
231
+ return JSON.parse(content) as LocalAgentState;
232
+ } catch (error) {
233
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
234
+ return {
235
+ processedTodoIds: [],
236
+ processedIssueIds: [],
237
+ notifiedBusyTodoIds: []
238
+ };
239
+ }
240
+
241
+ throw error;
242
+ }
243
+ }
244
+
245
+ async function sleep(milliseconds: number): Promise<void> {
246
+ await new Promise((resolve) => setTimeout(resolve, milliseconds));
247
+ }
248
+
249
+ function loadDotenv(filePath: string): void {
250
+ try {
251
+ const content = readFileSync(filePath, "utf8");
252
+ for (const line of content.split("\n")) {
253
+ const trimmed = line.trim();
254
+ if (!trimmed || trimmed.startsWith("#")) continue;
255
+ const eqIdx = trimmed.indexOf("=");
256
+ if (eqIdx <= 0) continue;
257
+ const key = trimmed.slice(0, eqIdx).trim();
258
+ const value = trimmed.slice(eqIdx + 1).trim();
259
+ if (!(key in process.env)) {
260
+ process.env[key] = value;
261
+ }
262
+ }
263
+ } catch {
264
+ // ignore
265
+ }
266
+ }
267
+
268
+ async function waitForAcceptance(
269
+ host: string,
270
+ projectId: number,
271
+ issueIid: number,
272
+ statePath: string,
273
+ userEnv: NodeJS.ProcessEnv,
274
+ botEnv: NodeJS.ProcessEnv,
275
+ notePrefixes: ReturnType<typeof getAgentNotePrefixes>,
276
+ options: SmokeTestOptions
277
+ ): Promise<AcceptanceResult> {
278
+ const deadline = Date.now() + options.acceptTimeoutSeconds * 1000;
279
+ let observedTodoAction: string | undefined;
280
+
281
+ while (Date.now() <= deadline) {
282
+ const [issue, notes, events, state, todos] = await Promise.all([
283
+ glabApiJson<GitlabIssue>(host, `projects/${projectId}/issues/${issueIid}`, userEnv),
284
+ glabApiJson<GitlabNote[]>(host, `projects/${projectId}/issues/${issueIid}/notes?per_page=20`, userEnv),
285
+ glabApiJson<GitlabLabelEvent[]>(
286
+ host,
287
+ `projects/${projectId}/issues/${issueIid}/resource_label_events?per_page=20`,
288
+ userEnv
289
+ ),
290
+ readState(statePath),
291
+ glabApiJson<GitlabTodo[]>(host, "todos?state=pending&per_page=100", botEnv)
292
+ ]);
293
+
294
+ const todo = findTodoForIssue(todos, issueIid);
295
+ if (!observedTodoAction && todo) {
296
+ observedTodoAction = todo.action_name;
297
+ }
298
+
299
+ const accepted = hasNote(notes, notePrefixes.accepted);
300
+ const inProgress = issue.labels.includes("In Progress") || hasAddedLabelEvent(events, "In Progress");
301
+ const stateMatched =
302
+ state.activeRun?.issueIid === issueIid ||
303
+ state.lastRun?.issueIid === issueIid ||
304
+ hasNote(notes, notePrefixes.completed) ||
305
+ hasNote(notes, notePrefixes.failed);
306
+
307
+ if (accepted && inProgress && stateMatched) {
308
+ return { observedTodoAction, state };
309
+ }
310
+
311
+ await sleep(options.pollIntervalSeconds * 1000);
312
+ }
313
+
314
+ throw new Error(`Timed out waiting for issue #${issueIid} to be accepted by the watcher.`);
315
+ }
316
+
317
+ async function waitForFinish(
318
+ host: string,
319
+ projectId: number,
320
+ issueIid: number,
321
+ statePath: string,
322
+ userEnv: NodeJS.ProcessEnv,
323
+ notePrefixes: ReturnType<typeof getAgentNotePrefixes>,
324
+ options: SmokeTestOptions
325
+ ): Promise<void> {
326
+ const deadline = Date.now() + options.finishTimeoutSeconds * 1000;
327
+
328
+ while (Date.now() <= deadline) {
329
+ const [issue, notes, events, state] = await Promise.all([
330
+ glabApiJson<GitlabIssue>(host, `projects/${projectId}/issues/${issueIid}`, userEnv),
331
+ glabApiJson<GitlabNote[]>(host, `projects/${projectId}/issues/${issueIid}/notes?per_page=20`, userEnv),
332
+ glabApiJson<GitlabLabelEvent[]>(
333
+ host,
334
+ `projects/${projectId}/issues/${issueIid}/resource_label_events?per_page=20`,
335
+ userEnv
336
+ ),
337
+ readState(statePath)
338
+ ]);
339
+
340
+ const completedNote = getNote(notes, notePrefixes.completed);
341
+ const failedNote = getNote(notes, notePrefixes.failed);
342
+ const inReview = issue.labels.includes("In Review") || hasAddedLabelEvent(events, "In Review");
343
+ const activeRunCleared = state.activeRun?.issueIid !== issueIid;
344
+
345
+ if (completedNote && inReview && activeRunCleared) {
346
+ return;
347
+ }
348
+
349
+ if (failedNote && activeRunCleared) {
350
+ throw new Error(`Watcher picked up issue #${issueIid}, but agent execution failed.\n${failedNote.body}`);
351
+ }
352
+
353
+ await sleep(options.pollIntervalSeconds * 1000);
354
+ }
355
+
356
+ throw new Error(`Timed out waiting for issue #${issueIid} to reach In Review.`);
357
+ }
358
+
359
+ export async function main(argv: string[] = process.argv.slice(2)): Promise<void> {
360
+ const options = parseSmokeTestArgs(argv);
361
+ const repoRoot = path.resolve(path.join(path.dirname(new URL(import.meta.url).pathname), "../.."));
362
+
363
+ loadDotenv(path.join(repoRoot, ".env"));
364
+
365
+ // Find first available agent definition
366
+ const agentsDir = path.join(repoRoot, "agents");
367
+ const { discoverAgents } = await import("./agent-config.js");
368
+ const agents = await discoverAgents(agentsDir);
369
+ if (agents.length === 0) {
370
+ throw new Error("No agent definitions found in agents/*.yaml. Create one first.");
371
+ }
372
+ const agentDef = agents[0];
373
+ const config = loadConfigFromAgentDefinition(agentDef);
374
+ const notePrefixes = getAgentNotePrefixes(config.agentProvider);
375
+ const userEnv = buildUserEnv(config.gitlabHost);
376
+ const botEnv = buildBotEnv(config.gitlabHost, config.gitlabToken);
377
+
378
+ const [user, bot] = await Promise.all([
379
+ glabApiJson<GitlabUser>(config.gitlabHost, "user", userEnv),
380
+ glabApiJson<GitlabUser>(config.gitlabHost, "user", botEnv)
381
+ ]);
382
+
383
+ const existingState = await readState(config.statePath);
384
+ if (existingState.activeRun) {
385
+ throw new Error(
386
+ `Watcher is already busy with issue #${existingState.activeRun.issueIid}. Wait for it to finish before running smoke-test.`
387
+ );
388
+ }
389
+
390
+ const runId = new Date().toISOString().replace(/[:.]/g, "-");
391
+ const title = `${options.titlePrefix} watcher smoke ${runId}`;
392
+ const description = buildSmokeIssueDescription(user, bot, runId);
393
+
394
+ const issue = await glabApiJson<GitlabIssue>(config.gitlabHost, `projects/${config.gitlabProjectId}/issues`, userEnv, [
395
+ "--method",
396
+ "POST",
397
+ "--raw-field",
398
+ `title=${title}`,
399
+ "--raw-field",
400
+ `description=${description}`,
401
+ "--raw-field",
402
+ "labels=Backlog"
403
+ ]);
404
+
405
+ console.log(`Created issue #${issue.iid}: ${issue.web_url}`);
406
+ console.log(`User: @${user.username}`);
407
+ console.log(`Bot: @${bot.username}`);
408
+
409
+ const acceptance = await waitForAcceptance(
410
+ config.gitlabHost,
411
+ config.gitlabProjectId!,
412
+ issue.iid,
413
+ config.statePath,
414
+ userEnv,
415
+ botEnv,
416
+ notePrefixes,
417
+ options
418
+ );
419
+
420
+ console.log(`Accepted issue #${issue.iid}.`);
421
+ if (acceptance.observedTodoAction) {
422
+ console.log(`Observed bot todo action: ${acceptance.observedTodoAction}`);
423
+ }
424
+
425
+ if (options.waitFinish) {
426
+ await waitForFinish(
427
+ config.gitlabHost,
428
+ config.gitlabProjectId!,
429
+ issue.iid,
430
+ config.statePath,
431
+ userEnv,
432
+ notePrefixes,
433
+ options
434
+ );
435
+ console.log(`Issue #${issue.iid} reached In Review.`);
436
+ } else {
437
+ console.log("Acceptance path verified. Re-run with --wait-finish to verify the full loop.");
438
+ }
439
+ }
440
+
441
+ if (import.meta.url === `file://${process.argv[1]}`) {
442
+ void main();
443
+ }
@@ -0,0 +1,186 @@
1
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ export interface ActiveRunState {
5
+ pid: number;
6
+ todoId: number;
7
+ issueId: number;
8
+ issueIid: number;
9
+ projectId?: number;
10
+ worktreePath: string;
11
+ branch: string;
12
+ startedAt: string;
13
+ }
14
+
15
+ export interface LastRunState {
16
+ status: "idle" | "running" | "completed" | "failed" | "recovered";
17
+ at: string;
18
+ issueIid?: number;
19
+ summary?: string;
20
+ branch?: string;
21
+ worktreePath?: string;
22
+ }
23
+
24
+ export interface RunHistoryEntry {
25
+ issueIid: number;
26
+ issueTitle?: string;
27
+ status: "completed" | "failed";
28
+ startedAt: string;
29
+ finishedAt: string;
30
+ branch?: string;
31
+ mrUrl?: string;
32
+ summary?: string;
33
+ }
34
+
35
+ export interface LocalAgentState {
36
+ processedTodoIds: number[];
37
+ processedIssueIds: number[];
38
+ notifiedBusyTodoIds: number[];
39
+ activeRun?: ActiveRunState;
40
+ /** Multi-concurrency: tracks all active runs when concurrency > 1 */
41
+ activeRuns?: ActiveRunState[];
42
+ lastRun?: LastRunState;
43
+ runHistory?: RunHistoryEntry[];
44
+ }
45
+
46
+ export interface StateStore {
47
+ load(): Promise<LocalAgentState>;
48
+ save(state: LocalAgentState): Promise<void>;
49
+ }
50
+
51
+ const HISTORY_LIMIT = 200;
52
+ const MAX_RUN_HISTORY = 100;
53
+
54
+ export function appendRunHistory(state: LocalAgentState, entry: RunHistoryEntry): void {
55
+ if (!state.runHistory) state.runHistory = [];
56
+ state.runHistory.push(entry);
57
+ if (state.runHistory.length > MAX_RUN_HISTORY) {
58
+ state.runHistory = state.runHistory.slice(-MAX_RUN_HISTORY);
59
+ }
60
+ }
61
+
62
+ export function createEmptyState(): LocalAgentState {
63
+ return {
64
+ processedTodoIds: [],
65
+ processedIssueIds: [],
66
+ notifiedBusyTodoIds: []
67
+ };
68
+ }
69
+
70
+ /** Get all currently active runs (unified view across single/multi mode) */
71
+ export function getActiveRuns(state: LocalAgentState): ActiveRunState[] {
72
+ if (state.activeRuns && state.activeRuns.length > 0) return state.activeRuns;
73
+ if (state.activeRun) return [state.activeRun];
74
+ return [];
75
+ }
76
+
77
+ /** Count active runs with live PIDs */
78
+ export function countActiveRuns(state: LocalAgentState, isPidAlive: (pid: number) => boolean): number {
79
+ return getActiveRuns(state).filter(r => isPidAlive(r.pid)).length;
80
+ }
81
+
82
+ /** Add a run to activeRuns (multi-concurrency mode) */
83
+ export function addActiveRun(state: LocalAgentState, run: ActiveRunState): void {
84
+ if (!state.activeRuns) state.activeRuns = [];
85
+ state.activeRuns.push(run);
86
+ // Keep activeRun in sync for backward compatibility (most recent)
87
+ state.activeRun = run;
88
+ }
89
+
90
+ /** Remove a run from activeRuns by issueIid */
91
+ export function removeActiveRun(state: LocalAgentState, issueIid: number): ActiveRunState | undefined {
92
+ if (!state.activeRuns) {
93
+ if (state.activeRun?.issueIid === issueIid) {
94
+ const removed = state.activeRun;
95
+ state.activeRun = undefined;
96
+ return removed;
97
+ }
98
+ return undefined;
99
+ }
100
+ const idx = state.activeRuns.findIndex(r => r.issueIid === issueIid);
101
+ if (idx < 0) return undefined;
102
+ const [removed] = state.activeRuns.splice(idx, 1);
103
+ // Sync activeRun: set to most recent remaining, or undefined
104
+ state.activeRun = state.activeRuns.length > 0 ? state.activeRuns[state.activeRuns.length - 1] : undefined;
105
+ return removed;
106
+ }
107
+
108
+ export function rememberIds(ids: number[], nextId: number): number[] {
109
+ const unique = ids.filter((id) => id !== nextId);
110
+ unique.push(nextId);
111
+ return unique.slice(Math.max(0, unique.length - HISTORY_LIMIT));
112
+ }
113
+
114
+ export class FileStateStore implements StateStore {
115
+ private readonly filePath: string;
116
+
117
+ constructor(filePath: string) {
118
+ this.filePath = filePath;
119
+ }
120
+
121
+ async load(): Promise<LocalAgentState> {
122
+ try {
123
+ const content = await readFile(this.filePath, "utf8");
124
+ return this.parseState(content);
125
+ } catch (error) {
126
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
127
+ return this.tryLoadBackup();
128
+ }
129
+ if (error instanceof SyntaxError) {
130
+ return this.tryLoadBackup();
131
+ }
132
+ throw error;
133
+ }
134
+ }
135
+
136
+ private parseState(content: string): LocalAgentState {
137
+ const parsed = JSON.parse(content) as Partial<LocalAgentState>;
138
+ return {
139
+ processedTodoIds: Array.isArray(parsed.processedTodoIds)
140
+ ? parsed.processedTodoIds.filter((item): item is number => typeof item === "number")
141
+ : [],
142
+ processedIssueIds: Array.isArray(parsed.processedIssueIds)
143
+ ? parsed.processedIssueIds.filter((item): item is number => typeof item === "number")
144
+ : [],
145
+ notifiedBusyTodoIds: Array.isArray(parsed.notifiedBusyTodoIds)
146
+ ? parsed.notifiedBusyTodoIds.filter((item): item is number => typeof item === "number")
147
+ : [],
148
+ activeRun: parsed.activeRun,
149
+ activeRuns: Array.isArray(parsed.activeRuns) ? parsed.activeRuns : undefined,
150
+ lastRun: parsed.lastRun,
151
+ runHistory: Array.isArray(parsed.runHistory) ? parsed.runHistory : undefined
152
+ };
153
+ }
154
+
155
+ private async tryLoadBackup(): Promise<LocalAgentState> {
156
+ const bakPath = this.filePath + ".bak";
157
+ try {
158
+ const content = await readFile(bakPath, "utf8");
159
+ console.warn(`[state-store] Primary state file corrupted or missing, recovered from backup: ${bakPath}`);
160
+ return this.parseState(content);
161
+ } catch {
162
+ console.error(`[state-store] Both primary and backup state files unavailable, starting fresh.`);
163
+ return createEmptyState();
164
+ }
165
+ }
166
+
167
+ async save(state: LocalAgentState): Promise<void> {
168
+ await mkdir(path.dirname(this.filePath), { recursive: true });
169
+ const content = JSON.stringify(state, null, 2);
170
+ const tmpPath = this.filePath + ".tmp";
171
+ const bakPath = this.filePath + ".bak";
172
+
173
+ // Write to temp file first
174
+ await writeFile(tmpPath, content, "utf8");
175
+
176
+ // Backup current file (ignore if not exists)
177
+ try {
178
+ await rename(this.filePath, bakPath);
179
+ } catch (error) {
180
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
181
+ }
182
+
183
+ // Atomic rename temp → target
184
+ await rename(tmpPath, this.filePath);
185
+ }
186
+ }
@@ -0,0 +1,37 @@
1
+ import type { GitlabClient } from "./gitlab-glab-client.js";
2
+ import type { Logger } from "./logger.js";
3
+
4
+ export interface TokenCheckResult {
5
+ valid: boolean;
6
+ username?: string;
7
+ error?: string;
8
+ }
9
+
10
+ /**
11
+ * Validate a GitLab token by calling the /user endpoint.
12
+ */
13
+ export async function validateToken(gitlabClient: GitlabClient): Promise<TokenCheckResult> {
14
+ try {
15
+ const user = await gitlabClient.getCurrentUser();
16
+ return { valid: true, username: user.username };
17
+ } catch (error) {
18
+ return { valid: false, error: String(error) };
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Validate token and log result. Returns true if valid.
24
+ */
25
+ export async function checkTokenWithLogging(
26
+ gitlabClient: GitlabClient,
27
+ agentName: string,
28
+ logger: Logger
29
+ ): Promise<boolean> {
30
+ const result = await validateToken(gitlabClient);
31
+ if (result.valid) {
32
+ logger.info(`[${agentName}] Token valid (authenticated as @${result.username})`);
33
+ return true;
34
+ }
35
+ logger.error(`[${agentName}] Token validation failed: ${result.error}`);
36
+ return false;
37
+ }