Swadhin Biswas feat(api): add runner, analytics, packages, and security services · 3/6/2026
28f7954
143 lines · 4.4 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
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143

import fs from 'node:fs/promises';
import path from 'node:path';
import pino from 'pino';

const logger = pino({
    name: 'runner-client',
    transport: {
        target: 'pino-pretty',
        options: {
            colorize: true
        }
    }
});

interface RunnerConfig {
    apiUrl: string;
    token: string; // Registration token
    name: string;
    workDir: string;
}

interface RunnerCredentials {
    id: string;
    token: string; // Private runner token
}

export class RunnerClient {
    private config: RunnerConfig;
    private credentials: RunnerCredentials | null = null;
    private credsPath: string;

    constructor(config: RunnerConfig) {
        this.config = config;
        this.credsPath = path.join(config.workDir, '.runner-credentials.json');
    }

    async init() {
        try {
            const data = await fs.readFile(this.credsPath, 'utf-8');
            this.credentials = JSON.parse(data);
            logger.info({ id: this.credentials?.id }, "Loaded existing runner credentials");
        } catch (e) {
            logger.info("No existing credentials found, waiting for registration");
        }
    }

    async register() {
        if (this.credentials) return;

        logger.info("Registering runner...");
        try {
            const res = await fetch(`${this.config.apiUrl}/api/actions/runners/register`, {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({
                    token: this.config.token,
                    name: this.config.name,
                    os: process.platform,
                    arch: process.arch,
                    version: '1.0.0'
                })
            });

            if (!res.ok) {
                throw new Error(`Registration failed: ${res.statusText} ${await res.text()}`);
            }

            const data = await res.json();
            if (data.success) {
                this.credentials = {
                    id: data.data.id,
                    token: data.data.token
                };
                await fs.writeFile(this.credsPath, JSON.stringify(this.credentials, null, 2));
                logger.info("Runner registered successfully");
            } else {
                throw new Error(data.error || "Unknown registration error");
            }
        } catch (error) {
            logger.error(error, "Failed to register runner");
            throw error;
        }
    }

    async poll() {
        if (!this.credentials) throw new Error("Not registered");

        try {
            const res = await fetch(`${this.config.apiUrl}/api/actions/runners/poll`, {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({
                    runnerId: this.credentials.id,
                    secret: this.credentials.token
                })
            });

            if (res.status === 404) return null; // No job

            if (!res.ok) {
                logger.warn(`Poll error: ${res.status} ${res.statusText}`);
                return null;
            }

            const data = await res.json();
            if (data.success) {
                return data.data; // { id, name, stepId, stepName, run }
            }
        } catch (error) {
            logger.error(error, "Poll failed");
        }
        return null;
    }

    async completeJob(jobId: string, result: { success: boolean, logs: string, exitCode: number, stepId?: string | null }) {
        if (!this.credentials) return;

        try {
            const res = await fetch(`${this.config.apiUrl}/api/actions/runners/job/${jobId}/complete`, {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({
                    runnerId: this.credentials.id,
                    secret: this.credentials.token,
                    stepId: result.stepId || undefined,
                    status: result.success ? 'success' : 'failure',
                    exitCode: result.exitCode,
                    logs: result.logs
                })
            });

            if (!res.ok) {
                logger.error(`Failed to report job completion: ${res.statusText}`);
            } else {
                logger.info({ jobId, success: result.success }, "Job completed successfully");
            }
        } catch (error) {
            logger.error(error, "Failed to complete job");
        }
    }
}