diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..3ae9491 --- /dev/null +++ b/src/config.js @@ -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}' + ] + } + ]) + }; +} diff --git a/src/process.js b/src/process.js new file mode 100644 index 0000000..84a778c --- /dev/null +++ b/src/process.js @@ -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() + }); + }); + }); +} diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..a3c0cf9 --- /dev/null +++ b/src/server.js @@ -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}`); + }); +} diff --git a/src/services/agents.js b/src/services/agents.js new file mode 100644 index 0000000..bfc7bb7 --- /dev/null +++ b/src/services/agents.js @@ -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; +} diff --git a/src/services/auth.js b/src/services/auth.js new file mode 100644 index 0000000..69bea6b --- /dev/null +++ b/src/services/auth.js @@ -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 || ''); +} diff --git a/src/services/gpu.js b/src/services/gpu.js new file mode 100644 index 0000000..ae44476 --- /dev/null +++ b/src/services/gpu.js @@ -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); +} diff --git a/src/services/quota.js b/src/services/quota.js new file mode 100644 index 0000000..8b23d41 --- /dev/null +++ b/src/services/quota.js @@ -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); +} diff --git a/src/services/repos.js b/src/services/repos.js new file mode 100644 index 0000000..3ea8c52 --- /dev/null +++ b/src/services/repos.js @@ -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); +} diff --git a/src/services/settings.js b/src/services/settings.js new file mode 100644 index 0000000..03af53c --- /dev/null +++ b/src/services/settings.js @@ -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); +} diff --git a/src/services/workspace.js b/src/services/workspace.js new file mode 100644 index 0000000..24a3e54 --- /dev/null +++ b/src/services/workspace.js @@ -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; +}