feat: add backend server and service modules

Includes HTTP server with auth, static file serving, and API routes.
Services cover GPU monitoring, quota checking, Gitea repo listing,
workspace management, agent task orchestration, and settings persistence.
This commit is contained in:
2026-05-15 11:12:30 +08:00
parent 4fdd41a05a
commit 673a5f9f62
10 changed files with 872 additions and 0 deletions

109
src/config.js Normal file
View File

@@ -0,0 +1,109 @@
import { existsSync, readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
const DEFAULT_CONFIG_PATH = join(homedir(), '.config', 'local-kanban', 'config.json');
const DEFAULT_STATE_PATH = join(homedir(), '.config', 'local-kanban', 'state.json');
const DEFAULT_ENV_PATH = fileURLToPath(new URL('../.env', import.meta.url));
function splitList(value) {
return String(value || '')
.split(',')
.map((item) => item.trim())
.filter(Boolean);
}
function loadJsonConfig(configPath = process.env.KANBAN_CONFIG || DEFAULT_CONFIG_PATH) {
if (!existsSync(configPath)) return {};
return JSON.parse(readFileSync(configPath, 'utf8'));
}
function loadEnvFile(path) {
if (!existsSync(path)) return;
for (const rawLine of readFileSync(path, 'utf8').split('\n')) {
const line = rawLine.trim();
if (!line || line.startsWith('#') || !line.includes('=')) continue;
const [key, ...rest] = line.split('=');
const value = rest.join('=').trim().replace(/^['"]|['"]$/g, '');
if (key && !process.env[key]) process.env[key] = value;
}
}
function defaultQuotaSources() {
return [
{
id: 'ipads',
label: 'IPADS API',
type: 'command',
command: 'bash',
args: ['-lc', 'curl -sS -H "Authorization: Bearer $IPADS_API_KEY" http://tianx.ipads-lab.se.sjtu.edu.cn:8319/v1/usage | jq \'{remaining, usage}\''],
timeoutMs: 15000
}
];
}
function ensureCodexFullPermission(profile) {
if (profile.command !== 'codex') return profile;
const args = profile.args || [];
const cleanedArgs = args.filter((arg, index) => {
const previous = args[index - 1];
if (arg === '--ask-for-approval' || previous === '--ask-for-approval') return false;
if (arg === '-a' || previous === '-a') return false;
return true;
});
const nextArgs = cleanedArgs.filter((arg) => arg !== '--dangerously-bypass-approvals-and-sandbox');
if (!nextArgs.includes('exec')) nextArgs.push('exec');
const execIndex = nextArgs.indexOf('exec');
nextArgs.splice(execIndex, 0, '--dangerously-bypass-approvals-and-sandbox');
if (!nextArgs.includes('--sandbox')) nextArgs.splice(execIndex + 2, 0, '--sandbox', 'danger-full-access');
if (!nextArgs.includes('--cd')) nextArgs.push('--cd', '{workspace_path}');
return { ...profile, args: nextArgs };
}
function normalizeAgentProfiles(profiles) {
return profiles.map(ensureCodexFullPermission);
}
export function loadConfig() {
const envFiles = splitList(process.env.KANBAN_ENV_FILE).length
? splitList(process.env.KANBAN_ENV_FILE)
: [DEFAULT_ENV_PATH];
for (const envFile of envFiles) loadEnvFile(envFile);
const fileConfig = loadJsonConfig();
const stateConfig = loadJsonConfig(process.env.KANBAN_STATE || fileConfig.statePath || DEFAULT_STATE_PATH);
const gpuHosts = splitList(process.env.KANBAN_GPU_HOSTS).length
? splitList(process.env.KANBAN_GPU_HOSTS)
: stateConfig.gpuHosts || fileConfig.gpuHosts || ['dash0', 'dash1'];
return {
host: process.env.KANBAN_HOST || fileConfig.host || '127.0.0.1',
port: Number(process.env.KANBAN_PORT || fileConfig.port || 5180),
statePath: process.env.KANBAN_STATE || fileConfig.statePath || DEFAULT_STATE_PATH,
authSecureCookie: process.env.KANBAN_AUTH_SECURE_COOKIE === '1' || fileConfig.authSecureCookie || false,
requireHttps: process.env.KANBAN_REQUIRE_HTTPS === '1' || fileConfig.requireHttps || false,
gpuHosts,
sshTimeoutMs: Number(process.env.KANBAN_SSH_TIMEOUT_MS || fileConfig.sshTimeoutMs || 10000),
quotaSources: fileConfig.quotaSources || defaultQuotaSources(),
gitea: {
baseUrl: process.env.KANBAN_GITEA_BASE_URL || fileConfig.gitea?.baseUrl || 'http://gahow-pc.ipads-lab.se.sjtu.edu.cn:3300',
tokenEnv: process.env.KANBAN_GITEA_TOKEN_ENV || fileConfig.gitea?.tokenEnv || 'GITEA_TOKEN'
},
workspaceRoot: process.env.KANBAN_WORKSPACE_ROOT || fileConfig.workspaceRoot || join(homedir(), '.local', 'share', 'local-kanban', 'workspaces'),
agentProfiles: normalizeAgentProfiles(fileConfig.agentProfiles || [
{
id: 'codex-full',
label: 'Codex Full Permission',
command: 'codex',
args: [
'--dangerously-bypass-approvals-and-sandbox',
'exec',
'--sandbox',
'danger-full-access',
'--cd',
'{workspace_path}'
]
}
])
};
}

52
src/process.js Normal file
View File

@@ -0,0 +1,52 @@
import { spawn } from 'node:child_process';
export function runProcess(command, args = [], options = {}) {
const timeoutMs = options.timeoutMs || 10000;
return new Promise((resolve) => {
if (!command) {
resolve({ ok: false, code: null, stdout: '', stderr: 'Command is not configured.' });
return;
}
const child = spawn(command, args, {
cwd: options.cwd,
env: { ...process.env, ...(options.env || {}) },
shell: false,
stdio: ['ignore', 'pipe', 'pipe']
});
let stdout = '';
let stderr = '';
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
child.kill('SIGTERM');
setTimeout(() => child.kill('SIGKILL'), 1000).unref();
}, timeoutMs);
timer.unref();
child.stdout.on('data', (chunk) => {
stdout += chunk.toString();
});
child.stderr.on('data', (chunk) => {
stderr += chunk.toString();
});
child.on('error', (error) => {
clearTimeout(timer);
resolve({ ok: false, code: null, stdout, stderr: error.message });
});
child.on('close', (code) => {
clearTimeout(timer);
resolve({
ok: code === 0 && !timedOut,
code,
stdout: stdout.trim(),
stderr: timedOut ? `Timed out after ${timeoutMs}ms.` : stderr.trim()
});
});
});
}

171
src/server.js Normal file
View File

@@ -0,0 +1,171 @@
import { createReadStream, existsSync, statSync } from 'node:fs';
import { createServer } from 'node:http';
import { extname, join, normalize } from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadConfig } from './config.js';
import {
clearLoginFailures,
ensureAuth,
isAuthenticated,
loginAllowed,
loginHeaders,
logoutHeaders,
recordLoginFailure,
validateLoginToken
} from './services/auth.js';
import { collectGpuStatus } from './services/gpu.js';
import { collectQuotaStatus } from './services/quota.js';
import { listBranches, listRepos } from './services/repos.js';
import { listAgentProfiles, listTasks, startAgentTask } from './services/agents.js';
import { getSettings, updateGpuHosts } from './services/settings.js';
const root = join(fileURLToPath(new URL('..', import.meta.url)), 'public');
const config = loadConfig();
const auth = ensureAuth(config);
const contentTypes = {
'.html': 'text/html; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8'
};
function sendJson(res, status, body) {
res.writeHead(status, secureHeaders({ 'content-type': 'application/json; charset=utf-8' }));
res.end(JSON.stringify(body));
}
function sendJsonWithHeaders(res, status, headers, body) {
res.writeHead(status, secureHeaders({ ...headers, 'content-type': 'application/json; charset=utf-8' }));
res.end(JSON.stringify(body));
}
function secureHeaders(headers = {}) {
const hsts = config.authSecureCookie || config.requireHttps
? { 'strict-transport-security': 'max-age=31536000' }
: {};
return {
...hsts,
'cache-control': 'no-store',
'content-security-policy': "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'",
'permissions-policy': 'camera=(), microphone=(), geolocation=()',
'referrer-policy': 'no-referrer',
'x-content-type-options': 'nosniff',
'x-frame-options': 'DENY',
...headers
};
}
async function readJson(req) {
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
if (!chunks.length) return {};
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
}
function serveStatic(req, res) {
const url = new URL(req.url, 'http://localhost');
const requested = url.pathname === '/' ? '/index.html' : url.pathname === '/login' ? '/login.html' : url.pathname;
const path = normalize(join(root, requested));
if (!path.startsWith(root) || !existsSync(path) || !statSync(path).isFile()) {
res.writeHead(404);
res.end('Not found');
return;
}
res.writeHead(200, secureHeaders({ 'content-type': contentTypes[extname(path)] || 'application/octet-stream' }));
createReadStream(path).pipe(res);
}
function redirect(res, location) {
res.writeHead(302, secureHeaders({ location }));
res.end();
}
function requestIsHttps(req) {
return req.headers['x-forwarded-proto'] === 'https' || req.socket.encrypted;
}
async function route(req, res) {
const url = new URL(req.url, 'http://localhost');
try {
if (config.requireHttps && !requestIsHttps(req)) {
const host = req.headers.host || `localhost:${config.port}`;
return redirect(res, `https://${host}${req.url}`);
}
if (url.pathname === '/api/auth/login' && req.method === 'POST') {
if (!loginAllowed(req)) return sendJson(res, 429, { ok: false, error: 'Too many login attempts.' });
const payload = await readJson(req);
if (!validateLoginToken(payload.token, config)) {
recordLoginFailure(req);
return sendJson(res, 401, { ok: false, error: 'Invalid token.' });
}
clearLoginFailures(req);
return sendJsonWithHeaders(res, 200, loginHeaders(config), { ok: true });
}
if (url.pathname === '/login' || url.pathname === '/login.html' || url.pathname === '/styles.css' || url.pathname === '/login.js') return serveStatic(req, res);
if (!isAuthenticated(req, config)) {
if (url.pathname.startsWith('/api/')) return sendJson(res, 401, { ok: false, error: 'Authentication required.' });
return redirect(res, '/login');
}
if (url.pathname === '/api/auth/logout' && req.method === 'POST') {
return sendJsonWithHeaders(res, 200, logoutHeaders(), { ok: true });
}
if (url.pathname === '/api/health') return sendJson(res, 200, { ok: true });
if (url.pathname === '/api/settings' && req.method === 'GET') return sendJson(res, 200, getSettings(config));
if (url.pathname === '/api/settings/gpu-hosts' && req.method === 'PUT') {
const payload = await readJson(req);
return sendJson(res, 200, updateGpuHosts(config, payload.gpuHosts));
}
if (url.pathname === '/api/gpus') return sendJson(res, 200, await collectGpuStatus(config));
if (url.pathname === '/api/quotas') return sendJson(res, 200, await collectQuotaStatus(config));
if (url.pathname === '/api/repos') return sendJson(res, 200, await listRepos(config));
if (url.pathname === '/api/branches') return sendJson(res, 200, await listBranches(config, url.searchParams.get('repo')));
if (url.pathname === '/api/agent-profiles') return sendJson(res, 200, listAgentProfiles(config));
if (url.pathname === '/api/tasks' && req.method === 'GET') return sendJson(res, 200, listTasks(config));
if (url.pathname === '/api/tasks' && req.method === 'POST') {
const payload = await readJson(req);
return sendJson(res, 202, await startAgentTask(config, payload));
}
if (url.pathname === '/api/dashboard') {
const sections = await Promise.allSettled([
Promise.resolve(getSettings(config)),
collectGpuStatus(config),
collectQuotaStatus(config),
listRepos(config),
Promise.resolve(listAgentProfiles(config)),
Promise.resolve(listTasks(config))
]);
const [settings, gpus, quotas, repos, agentProfiles, tasks] = sections;
const errors = {};
const value = (result, fallback, key) => {
if (result.status === 'fulfilled') return result.value;
errors[key] = String(result.reason?.message || result.reason || 'unknown error');
return fallback;
};
return sendJson(res, 200, {
settings: value(settings, { gpuHosts: [] }, 'settings'),
gpus: value(gpus, [], 'gpus'),
quotas: value(quotas, [], 'quotas'),
repos: value(repos, [], 'repos'),
agentProfiles: value(agentProfiles, [], 'agentProfiles'),
tasks: value(tasks, [], 'tasks'),
errors
});
}
return serveStatic(req, res);
} catch (error) {
return sendJson(res, error.statusCode || 500, { ok: false, error: error.message });
}
}
export function createKanbanServer() {
return createServer(route);
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
createKanbanServer().listen(config.port, config.host, () => {
console.log(`Kanban is running at http://${config.host}:${config.port}`);
console.log(`Kanban auth token: ${auth.token}`);
console.log(`Auth token source: ${auth.source}; state file: ${config.statePath}`);
});
}

162
src/services/agents.js Normal file
View File

@@ -0,0 +1,162 @@
import { randomUUID } from 'node:crypto';
import { spawn } from 'node:child_process';
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';
import { prepareWorkspace } from './workspace.js';
const tasks = new Map();
let loadedStatePath = null;
let persistTimer = null;
const PERSIST_DEBOUNCE_MS = 500;
function readState(path) {
if (!existsSync(path)) return {};
return JSON.parse(readFileSync(path, 'utf8'));
}
function writeState(path, state) {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
chmodSync(path, 0o600);
}
function loadTasks(config) {
if (loadedStatePath === config.statePath) return;
tasks.clear();
const state = readState(config.statePath);
for (const task of state.tasks || []) tasks.set(task.id, task);
loadedStatePath = config.statePath;
}
function persistTasksNow(config) {
const state = readState(config.statePath);
const list = [...tasks.values()]
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
.slice(0, 200);
writeState(config.statePath, { ...state, tasks: list });
}
function persistTasks(config, { immediate = false } = {}) {
if (immediate) {
if (persistTimer) {
clearTimeout(persistTimer);
persistTimer = null;
}
persistTasksNow(config);
return;
}
if (persistTimer) return;
persistTimer = setTimeout(() => {
persistTimer = null;
try { persistTasksNow(config); } catch {}
}, PERSIST_DEBOUNCE_MS);
persistTimer.unref?.();
}
function parseSessionId(text) {
const match = String(text || '').match(/session id:\s*([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i);
return match ? match[1] : null;
}
export function listAgentProfiles(config) {
return config.agentProfiles || [];
}
export function listTasks(config) {
loadTasks(config);
return [...tasks.values()].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}
function expandArgs(args, values) {
return args.map((arg) => String(arg)
.replaceAll('{workspace_path}', values.workspace)
.replaceAll('{repo_full_name}', values.repoFullName)
.replaceAll('{branch}', values.branch));
}
export async function startAgentTask(config, payload) {
loadTasks(config);
const profile = (config.agentProfiles || []).find((item) => item.id === payload.profileId);
if (!profile) {
const error = new Error('Unknown agent profile.');
error.statusCode = 400;
throw error;
}
if (!payload.repo || !payload.branch || !payload.prompt) {
const error = new Error('repo, branch, and prompt are required.');
error.statusCode = 400;
throw error;
}
const workspace = await prepareWorkspace(config, payload.repo, payload.branch);
const isResume = Boolean(payload.resumeTaskId);
const previous = isResume ? tasks.get(payload.resumeTaskId) : null;
if (isResume && !previous?.sessionId) {
const error = new Error('Selected task does not have a recorded Codex session id.');
error.statusCode = 400;
throw error;
}
const baseArgs = expandArgs(profile.args || [], {
workspace,
repoFullName: payload.repo.fullName,
branch: payload.branch
});
const args = isResume
? [...baseArgs, 'resume', previous.sessionId, payload.prompt]
: [...baseArgs, payload.prompt];
const id = randomUUID();
const task = {
id,
parentTaskId: previous?.id || null,
profileId: profile.id,
profileLabel: profile.label || profile.id,
repoFullName: payload.repo.fullName,
branch: payload.branch,
workspace,
prompt: payload.prompt,
command: [profile.command, ...args].join(' '),
sessionId: previous?.sessionId || null,
status: 'running',
createdAt: new Date().toISOString(),
finishedAt: null,
exitCode: null,
stdout: '',
stderr: ''
};
tasks.set(id, task);
persistTasks(config, { immediate: true });
const child = spawn(profile.command, args, {
cwd: workspace,
env: process.env,
shell: false,
stdio: ['ignore', 'pipe', 'pipe']
});
child.stdout.on('data', (chunk) => {
const text = chunk.toString();
task.stdout = `${task.stdout}${text}`.slice(-20000);
task.sessionId ||= parseSessionId(text) || parseSessionId(task.stdout);
persistTasks(config);
});
child.stderr.on('data', (chunk) => {
const text = chunk.toString();
task.stderr = `${task.stderr}${text}`.slice(-20000);
task.sessionId ||= parseSessionId(text) || parseSessionId(task.stderr);
persistTasks(config);
});
child.on('error', (error) => {
task.status = 'failed';
task.stderr = `${task.stderr}\n${error.message}`.trim();
task.finishedAt = new Date().toISOString();
persistTasks(config, { immediate: true });
});
child.on('close', (code) => {
task.status = code === 0 ? 'completed' : 'failed';
task.exitCode = code;
task.sessionId ||= parseSessionId(task.stdout) || parseSessionId(task.stderr);
task.finishedAt = new Date().toISOString();
persistTasks(config, { immediate: true });
});
return task;
}

116
src/services/auth.js Normal file
View File

@@ -0,0 +1,116 @@
import { randomBytes, timingSafeEqual, createHash } from 'node:crypto';
import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from 'node:fs';
import { dirname } from 'node:path';
const COOKIE_NAME = 'kanban_session';
const attempts = new Map();
const LOGIN_WINDOW_MS = 5 * 60 * 1000;
const MAX_LOGIN_FAILURES = 8;
function readState(path) {
if (!existsSync(path)) return {};
return JSON.parse(readFileSync(path, 'utf8'));
}
function writeState(path, state) {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
chmodSync(path, 0o600);
}
function sha256(value) {
return createHash('sha256').update(value).digest('hex');
}
function safeEqualHex(a, b) {
if (!a || !b || a.length !== b.length) return false;
return timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex'));
}
function parseCookies(header) {
return Object.fromEntries(String(header || '').split(';').map((part) => {
const [key, ...value] = part.trim().split('=');
return [key, decodeURIComponent(value.join('='))];
}).filter(([key]) => key));
}
export function clientIp(req) {
const forwarded = String(req.headers['x-forwarded-for'] || '').split(',')[0].trim();
return forwarded || req.socket.remoteAddress || 'unknown';
}
export function loginAllowed(req) {
const now = Date.now();
const key = clientIp(req);
const record = attempts.get(key);
if (!record || now - record.firstAt > LOGIN_WINDOW_MS) return true;
return record.count < MAX_LOGIN_FAILURES;
}
export function recordLoginFailure(req) {
const now = Date.now();
const key = clientIp(req);
const record = attempts.get(key);
if (!record || now - record.firstAt > LOGIN_WINDOW_MS) {
attempts.set(key, { count: 1, firstAt: now });
return;
}
record.count += 1;
}
export function clearLoginFailures(req) {
attempts.delete(clientIp(req));
}
export function ensureAuth(config) {
const state = readState(config.statePath);
if (process.env.KANBAN_AUTH_TOKEN) {
config.auth = {
tokenHash: sha256(process.env.KANBAN_AUTH_TOKEN),
sessionSecret: state.sessionSecret || randomBytes(32).toString('hex'),
token: process.env.KANBAN_AUTH_TOKEN,
source: 'env'
};
} else {
const token = state.authToken || randomBytes(32).toString('base64url');
config.auth = {
tokenHash: sha256(token),
sessionSecret: state.sessionSecret || randomBytes(32).toString('hex'),
token,
source: state.authToken ? 'state' : 'generated'
};
}
writeState(config.statePath, {
...state,
authToken: process.env.KANBAN_AUTH_TOKEN ? state.authToken : config.auth.token,
sessionSecret: config.auth.sessionSecret
});
return config.auth;
}
export function isAuthenticated(req, config) {
if (!config.auth?.tokenHash) return false;
const auth = req.headers.authorization || '';
if (auth.startsWith('Bearer ')) {
return safeEqualHex(sha256(auth.slice('Bearer '.length).trim()), config.auth.tokenHash);
}
const cookies = parseCookies(req.headers.cookie);
return safeEqualHex(sha256(cookies[COOKIE_NAME] || ''), config.auth.tokenHash);
}
export function loginHeaders(config) {
const secure = config.authSecureCookie ? ' Secure;' : '';
return {
'set-cookie': `${COOKIE_NAME}=${encodeURIComponent(config.auth.token)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=2592000;${secure}`
};
}
export function logoutHeaders() {
return {
'set-cookie': `${COOKIE_NAME}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0`
};
}
export function validateLoginToken(token, config) {
return safeEqualHex(sha256(String(token || '')), config.auth?.tokenHash || '');
}

49
src/services/gpu.js Normal file
View File

@@ -0,0 +1,49 @@
import { runProcess } from '../process.js';
const NVIDIA_QUERY = [
'--query-gpu=index,name,memory.used,memory.total,utilization.gpu',
'--format=csv,noheader,nounits'
];
function parseGpuLine(line) {
const [index, name, memoryUsed, memoryTotal, utilization] = line.split(',').map((part) => part.trim());
return {
index: Number(index),
name,
memoryUsedMiB: Number(memoryUsed),
memoryTotalMiB: Number(memoryTotal),
gpuUtilizationPercent: Number(utilization)
};
}
export function parseNvidiaSmiCsv(output) {
return output
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map(parseGpuLine)
.filter((gpu) => Number.isFinite(gpu.index));
}
export async function collectGpuStatus(config) {
const hosts = config.gpuHosts || [];
const checks = hosts.map(async (host) => {
const result = await runProcess('ssh', [
'-o', 'BatchMode=yes',
'-o', `ConnectTimeout=${Math.max(1, Math.ceil((config.sshTimeoutMs || 10000) / 1000))}`,
host,
'nvidia-smi',
...NVIDIA_QUERY
], { timeoutMs: config.sshTimeoutMs || 10000 });
return {
host,
ok: result.ok,
checkedAt: new Date().toISOString(),
gpus: result.ok ? parseNvidiaSmiCsv(result.stdout) : [],
error: result.ok ? null : result.stderr || result.stdout || 'Unable to query GPU status.'
};
});
return Promise.all(checks);
}

58
src/services/quota.js Normal file
View File

@@ -0,0 +1,58 @@
import { runProcess } from '../process.js';
function tryParseJson(text) {
try {
return JSON.parse(text);
} catch {
return null;
}
}
function firstNumber(value) {
if (typeof value === 'number') return value;
if (value && typeof value === 'object') {
return firstNumber(value.actual_cost ?? value.cost ?? value.total ?? value.used ?? value.usage ?? value.value ?? value.total_tokens);
}
if (typeof value !== 'string') return null;
const match = value.match(/-?\d+(?:\.\d+)?/);
return match ? Number(match[0]) : null;
}
export function summarizeQuotaOutput(stdout) {
const json = tryParseJson(stdout);
if (json && typeof json === 'object') {
const remaining = firstNumber(json.remaining ?? json.remain ?? json.left);
const used = firstNumber(json.usage ?? json.used);
const limit = firstNumber(json.limit ?? json.total);
return { kind: 'json', remaining, used, limit, raw: json };
}
const remainingMatch = stdout.match(/(?:remaining|remain|left)\D+(\d+(?:\.\d+)?)/i);
const usedMatch = stdout.match(/(?:usage|used)\D+(\d+(?:\.\d+)?)/i);
const limitMatch = stdout.match(/(?:limit|total)\D+(\d+(?:\.\d+)?)/i);
return {
kind: 'text',
remaining: remainingMatch ? Number(remainingMatch[1]) : null,
used: usedMatch ? Number(usedMatch[1]) : null,
limit: limitMatch ? Number(limitMatch[1]) : null,
raw: stdout
};
}
export async function collectQuotaStatus(config) {
const sources = config.quotaSources || [];
const checks = sources.map(async (source) => {
const args = Array.isArray(source.args) ? source.args : [];
const result = await runProcess(source.command, args, { timeoutMs: source.timeoutMs || 15000 });
return {
id: source.id,
label: source.label || source.id,
ok: result.ok,
checkedAt: new Date().toISOString(),
summary: result.ok ? summarizeQuotaOutput(result.stdout) : null,
error: result.ok ? null : result.stderr || result.stdout || 'Unable to query quota.'
};
});
return Promise.all(checks);
}

83
src/services/repos.js Normal file
View File

@@ -0,0 +1,83 @@
function giteaToken(config) {
const token = process.env[config.gitea?.tokenEnv || 'GITEA_TOKEN'];
if (!token) {
const error = new Error(`Missing Gitea token env var: ${config.gitea?.tokenEnv || 'GITEA_TOKEN'}`);
error.statusCode = 500;
throw error;
}
return token;
}
function apiUrl(config, path) {
const baseUrl = String(config.gitea?.baseUrl || '').replace(/\/$/, '');
return `${baseUrl}/api/v1${path}`;
}
async function giteaGet(config, path, params = {}) {
const url = new URL(apiUrl(config, path));
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null) url.searchParams.set(key, String(value));
}
const response = await fetch(url, {
headers: {
authorization: `token ${giteaToken(config)}`,
accept: 'application/json'
}
});
const text = await response.text();
if (!response.ok) {
const error = new Error(`Gitea API ${path} returned ${response.status}: ${text}`);
error.statusCode = response.status;
throw error;
}
return text ? JSON.parse(text) : null;
}
function repoFromPayload(item, baseUrl) {
const owner = item.owner?.login || item.owner?.username || item.owner?.name || item.full_name?.split('/')[0] || '';
const name = item.name || item.full_name?.split('/')[1] || '';
const fullName = item.full_name || `${owner}/${name}`;
return {
id: fullName,
owner,
name,
fullName,
cloneUrl: item.clone_url || `${String(baseUrl).replace(/\/$/, '')}/${fullName}.git`,
htmlUrl: item.html_url || '',
defaultBranch: item.default_branch || 'main',
private: Boolean(item.private),
description: item.description || ''
};
}
export async function listRepos(config) {
const user = await giteaGet(config, '/user');
const username = user.login || user.username;
const repos = [];
let page = 1;
const limit = 50;
while (true) {
const payload = await giteaGet(config, '/user/repos', { page, limit });
if (!Array.isArray(payload) || payload.length === 0) break;
for (const item of payload) {
const repo = repoFromPayload(item, config.gitea.baseUrl);
if (!username || repo.owner.toLowerCase() === String(username).toLowerCase()) repos.push(repo);
}
if (payload.length < limit) break;
page += 1;
}
return repos.sort((a, b) => a.fullName.localeCompare(b.fullName));
}
export async function listBranches(config, fullName) {
if (!fullName || !fullName.includes('/')) {
const error = new Error('repo full name is required.');
error.statusCode = 400;
throw error;
}
const payload = await giteaGet(config, `/repos/${fullName}/branches`, { limit: 100 });
return (Array.isArray(payload) ? payload : []).map((branch) => ({
name: branch.name,
commitId: branch.commit?.id || branch.commit?.sha || null
})).filter((branch) => branch.name);
}

27
src/services/settings.js Normal file
View File

@@ -0,0 +1,27 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';
function readState(path) {
if (!existsSync(path)) return {};
return JSON.parse(readFileSync(path, 'utf8'));
}
function writeState(path, state) {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
}
export function getSettings(config) {
const state = readState(config.statePath);
return {
gpuHosts: state.gpuHosts || config.gpuHosts || []
};
}
export function updateGpuHosts(config, hosts) {
const gpuHosts = [...new Set((hosts || []).map((host) => String(host).trim()).filter(Boolean))];
const state = { ...readState(config.statePath), gpuHosts };
writeState(config.statePath, state);
config.gpuHosts = gpuHosts;
return getSettings(config);
}

45
src/services/workspace.js Normal file
View File

@@ -0,0 +1,45 @@
import { existsSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { runProcess } from '../process.js';
function safeSegment(value) {
return String(value || '')
.replace(/[^a-zA-Z0-9._-]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 80) || 'workspace';
}
async function git(args, cwd, timeoutMs = 120000) {
const result = await runProcess('git', args, { cwd, timeoutMs });
if (!result.ok) {
throw new Error(`git ${args.join(' ')} failed: ${result.stderr || result.stdout}`);
}
return result;
}
export async function prepareWorkspace(config, repo, branch) {
if (!repo?.cloneUrl || !repo?.fullName) {
const error = new Error('repo cloneUrl and fullName are required.');
error.statusCode = 400;
throw error;
}
const branchName = branch || repo.defaultBranch || 'main';
const root = config.workspaceRoot;
const workspace = join(root, safeSegment(repo.fullName), safeSegment(branchName));
mkdirSync(root, { recursive: true });
if (!existsSync(workspace)) {
mkdirSync(join(root, safeSegment(repo.fullName)), { recursive: true });
await git(['clone', repo.cloneUrl, workspace], root);
}
await git(['fetch', 'origin'], workspace);
const checkout = await runProcess('git', ['checkout', branchName], { cwd: workspace, timeoutMs: 60000 });
if (!checkout.ok) {
await git(['checkout', '-B', branchName, `origin/${branchName}`], workspace, 60000);
}
const pull = await runProcess('git', ['pull', '--ff-only', 'origin', branchName], { cwd: workspace, timeoutMs: 60000 });
if (!pull.ok) {
throw new Error(`git pull --ff-only origin ${branchName} failed: ${pull.stderr || pull.stdout}`);
}
return workspace;
}