Files
cc-web/lib/gitea-workflow-management.js

539 lines
24 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict';
/**
* Gitea Workflow 管理面适配器。
*
* 该模块不实现 Webhook、调度器或 Codex turn只把核心 workflow service
* 暴露为稳定的查询/控制契约,并在核心服务尚未挂接时提供可恢复的只读状态。
*/
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const TERMINAL_STATES = new Set([
'succeeded', 'succeeded_with_rest_fallback', 'failed', 'failed_reply',
'cancelled', 'aborted', 'ignored', 'duplicate', 'rejected',
]);
const RUNNING_STATES = new Set(['preparing', 'running', 'aborting']);
function nowIso() {
return new Date().toISOString();
}
function clone(value) {
return value == null ? value : JSON.parse(JSON.stringify(value));
}
function safeText(value, max = 2000) {
return String(value == null ? '' : value).slice(0, max);
}
function requestId(value) {
const text = safeText(value, 120).trim();
return text || crypto.randomUUID();
}
function stripGlobalConcurrency(settings = {}) {
const next = { ...settings };
delete next.maxConcurrency;
return next;
}
function createEmptyState() {
return {
version: 1,
control: {
globalPaused: false,
pauseReason: null,
updatedBy: 'system',
updatedAt: nowIso(),
version: 0,
},
repositories: [],
tasks: [],
audits: [],
logs: [],
controlRequests: {},
settings: {
host: '', botLogin: 'ccweb-bot', workspaceRoot: '', defaultBranch: '',
webhookSecretConfigured: false, botTokenConfigured: false,
updatedAt: nowIso(), updatedBy: 'system',
},
updatedAt: nowIso(),
};
}
function readState(filePath) {
if (!filePath) return createEmptyState();
try {
if (!fs.existsSync(filePath)) return createEmptyState();
const stat = fs.statSync(filePath);
if (stat.size > 8 * 1024 * 1024) throw new Error('workflow 管理状态文件超过 8MB 上限');
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
return {
...createEmptyState(),
...parsed,
control: { ...createEmptyState().control, ...(parsed.control || {}) },
repositories: Array.isArray(parsed.repositories) ? parsed.repositories : [],
tasks: Array.isArray(parsed.tasks) ? parsed.tasks : [],
audits: Array.isArray(parsed.audits) ? parsed.audits : [],
logs: Array.isArray(parsed.logs) ? parsed.logs : [],
controlRequests: parsed.controlRequests && typeof parsed.controlRequests === 'object' ? parsed.controlRequests : {},
settings: stripGlobalConcurrency({ ...createEmptyState().settings, ...(parsed.settings || {}) }),
};
} catch {
return createEmptyState();
}
}
function writeState(filePath, state) {
if (!filePath) return;
fs.mkdirSync(path.dirname(filePath), { recursive: true });
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
fs.writeFileSync(tempPath, JSON.stringify(state, null, 2), { mode: 0o600 });
fs.renameSync(tempPath, filePath);
}
function normalizeRepository(repository = {}) {
const status = String(repository.status || (repository.enabled === false ? 'disabled' : 'active'));
return {
repoKey: safeText(repository.repoKey || repository.key || '', 240),
instanceId: safeText(repository.instanceId || 'default', 80),
owner: safeText(repository.owner || '', 120),
name: safeText(repository.name || repository.repo || '', 160),
cloneUrl: safeText(repository.cloneUrl || '', 500),
workspacePath: safeText(repository.workspacePath || repository.path || '', 1000),
defaultBranch: safeText(repository.defaultBranch || repository.default_branch || '', 160),
status,
enabled: status !== 'disabled',
registeredAt: repository.registeredAt || repository.createdAt || null,
lastFetchAt: repository.lastFetchAt || null,
dirty: !!(repository.dirty || repository.workspaceDirty || status === 'blocked'),
dirtyReason: safeText(repository.dirtyReason || repository.blockedReason || repository.workspaceReason || '', 1000) || null,
dirtySessionKey: safeText(repository.dirtySessionKey || '', 300) || null,
activeTaskId: safeText(repository.activeTaskId || '', 120) || null,
queuedCount: Number(repository.queuedCount || 0),
version: Number(repository.version || 0),
};
}
function normalizeTask(task = {}) {
const state = safeText(task.state || task.status || 'queued', 64) || 'queued';
return {
taskId: safeText(task.taskId || task.id || '', 120),
repoKey: safeText(task.repoKey || '', 240),
resourceKey: safeText(task.resourceKey || '', 300),
sessionKey: safeText(task.sessionKey || '', 300),
commentId: task.commentId ?? null,
author: safeText(task.author || task.actor || '', 160),
instruction: safeText(task.instruction || task.prompt || '', 4000),
state,
stateLabel: safeText(task.stateLabel || '', 120) || null,
attempt: Number(task.attempt || 0),
replyAttempt: Number(task.replyAttempt || 0),
threadId: safeText(task.threadId || '', 240) || null,
turnId: safeText(task.turnId || '', 240) || null,
turnState: safeText(task.turnState || task.turn?.status || '', 80) || null,
turnStartedAt: task.turnStartedAt || task.turn?.startedAt || null,
turnUpdatedAt: task.turnUpdatedAt || task.turn?.updatedAt || null,
logs: Array.isArray(task.logs) ? task.logs.slice(-100).map((entry) => ({
timestamp: entry.timestamp || entry.ts || null,
level: safeText(entry.level || 'info', 20),
message: safeText(entry.message || entry.event || '', 1000),
})) : [],
dirtyReason: safeText(task.dirtyReason || task.workspaceReason || (state === 'blocked_workspace' ? task.errorMessage || task.error : ''), 1000) || null,
errorCode: safeText(task.errorCode || '', 120) || null,
errorMessage: safeText(task.errorMessage || task.error || '', 1000) || null,
createdAt: task.createdAt || null,
updatedAt: task.updatedAt || null,
nextRetryAt: task.nextRetryAt || null,
terminal: TERMINAL_STATES.has(state),
};
}
function normalizeAudit(entry = {}) {
return {
eventId: safeText(entry.eventId || entry.id || '', 120),
taskId: safeText(entry.taskId || '', 120) || null,
repoKey: safeText(entry.repoKey || '', 240) || null,
action: safeText(entry.action || entry.event || '', 160),
actor: safeText(entry.actor || entry.operator || 'system', 160),
reason: safeText(entry.reason || '', 1000) || null,
fromState: safeText(entry.fromState || '', 64) || null,
toState: safeText(entry.toState || '', 64) || null,
deliveryId: safeText(entry.deliveryId || '', 160) || null,
turnId: safeText(entry.turnId || '', 240) || null,
commentId: entry.commentId ?? null,
errorCode: safeText(entry.errorCode || '', 120) || null,
timestamp: entry.timestamp || entry.createdAt || entry.ts || null,
metadata: entry.metadata && typeof entry.metadata === 'object' ? clone(entry.metadata) : {},
};
}
function normalizeLog(entry = {}) {
return {
timestamp: entry.timestamp || entry.ts || null,
level: safeText(entry.level || 'info', 20),
taskId: safeText(entry.taskId || '', 120) || null,
repoKey: safeText(entry.repoKey || '', 240) || null,
event: safeText(entry.event || entry.action || '', 160),
message: safeText(entry.message || entry.error || '', 1600),
};
}
function pickService(service, names, ...args) {
if (!service) return undefined;
for (const name of names) {
if (typeof service[name] !== 'function') continue;
return service[name](...args);
}
return undefined;
}
function collectionItems(value) {
if (Array.isArray(value)) return value;
if (Array.isArray(value?.items)) return value.items;
return [];
}
function createGiteaWorkflowManagement(options = {}) {
const statePath = options.statePath || null;
let service = options.workflowService || null;
const clock = options.now || nowIso;
let state = readState(statePath);
const secretsPath = options.secretsPath || null;
const onConfigUpdate = typeof options.onConfigUpdate === 'function' ? options.onConfigUpdate : null;
function readSecrets() {
if (!secretsPath) return {};
try { return JSON.parse(fs.readFileSync(secretsPath, 'utf8')); } catch { return {}; }
}
function writeSecrets(next) {
if (!secretsPath) return;
fs.mkdirSync(path.dirname(secretsPath), { recursive: true });
const temp = `${secretsPath}.${process.pid}.${Date.now()}.tmp`;
fs.writeFileSync(temp, JSON.stringify(next, null, 2), { mode: 0o600 });
fs.renameSync(temp, secretsPath);
}
function configuration() {
const settings = stripGlobalConcurrency({ ...state.settings });
const secrets = readSecrets();
settings.webhookSecretConfigured = Boolean(secrets.webhookSecret || settings.webhookSecretConfigured);
settings.botTokenConfigured = Boolean(secrets.botToken || settings.botTokenConfigured);
return { settings, secrets: { webhookSecretConfigured: settings.webhookSecretConfigured, botTokenConfigured: settings.botTokenConfigured } };
}
function runtimeConfiguration() {
const publicConfig = configuration();
const secrets = readSecrets();
return { settings: stripGlobalConcurrency(publicConfig.settings), secrets: { webhookSecret: secrets.webhookSecret || '', botToken: secrets.botToken || '' } };
}
function updateConfiguration(input = {}) {
const actor = safeText(input.actor || 'admin', 160);
const reason = safeText(input.reason || '', 1000);
if (!reason) throw Object.assign(new Error('配置变更必须填写操作原因。'), { statusCode: 400, code: 'reason_required' });
const current = runtimeConfiguration();
const requestedWorkspace = safeText(input.workspaceRoot ?? state.settings.workspaceRoot, 1200);
const requestedHost = safeText(input.host ?? state.settings.host, 500);
if (requestedHost) {
let parsedHost;
try { parsedHost = new URL(requestedHost); } catch {
throw Object.assign(new Error('Gitea 地址必须是完整的 http/https URL。'), { statusCode: 400, code: 'invalid_gitea_host' });
}
if (!['http:', 'https:'].includes(parsedHost.protocol) || parsedHost.username || parsedHost.password) {
throw Object.assign(new Error('Gitea 地址只允许 http/https且不能携带凭据。'), { statusCode: 400, code: 'invalid_gitea_host' });
}
}
if (requestedWorkspace && (!path.isAbsolute(requestedWorkspace) || path.resolve(requestedWorkspace) === path.parse(requestedWorkspace).root)) {
throw Object.assign(new Error('工作区根目录必须是非根的绝对路径。'), { statusCode: 400, code: 'invalid_workspace_root' });
}
const nextSettings = {
...state.settings,
host: requestedHost,
botLogin: safeText(input.botLogin ?? state.settings.botLogin, 120) || 'ccweb-bot',
workspaceRoot: requestedWorkspace,
defaultBranch: safeText(input.defaultBranch ?? state.settings.defaultBranch, 200),
updatedAt: clock(), updatedBy: actor,
};
const secrets = { ...current.secrets };
if (Object.prototype.hasOwnProperty.call(input, 'webhookSecret') && String(input.webhookSecret || '').trim()) secrets.webhookSecret = String(input.webhookSecret).trim();
if (Object.prototype.hasOwnProperty.call(input, 'botToken') && String(input.botToken || '').trim()) secrets.botToken = String(input.botToken).trim();
nextSettings.webhookSecretConfigured = Boolean(secrets.webhookSecret);
nextSettings.botTokenConfigured = Boolean(secrets.botToken);
state.settings = stripGlobalConcurrency(nextSettings);
writeSecrets(secrets);
appendAudit({ action: 'admin.configuration_updated', actor, reason, metadata: { changed: Object.keys(input).filter((key) => !['webhookSecret', 'botToken', 'maxConcurrency'].includes(key)) } });
if (onConfigUpdate) onConfigUpdate({ settings: clone(state.settings), secrets: clone(secrets) });
persist();
return { settings: clone(state.settings), secrets: { webhookSecretConfigured: nextSettings.webhookSecretConfigured, botTokenConfigured: nextSettings.botTokenConfigured } };
}
function persist() {
state.updatedAt = clock();
writeState(statePath, state);
}
function appendAudit({ action, actor = 'system', reason = '', taskId = null, repoKey = null, fromState = null, toState = null, metadata = {} }) {
const item = normalizeAudit({
eventId: crypto.randomUUID(), taskId, repoKey, action, actor, reason,
fromState, toState, metadata, timestamp: clock(),
});
state.audits.push(item);
state.audits = state.audits.slice(-2000);
persist();
return item;
}
function sourceOverview() {
const value = pickService(service, ['getManagementOverview', 'getOverview', 'overview']);
return value && typeof value === 'object' ? value : null;
}
function snapshot() {
const overview = sourceOverview() || {};
const repositories = pickService(service, ['listRepositories', 'getRepositories']) || overview.repositories || state.repositories;
const tasks = pickService(service, ['listTasks', 'getTasks'], {}) || overview.tasks || state.tasks;
const auditSource = pickService(service, ['listAudit', 'listAudits', 'getAudit']);
const logSource = pickService(service, ['listLogs', 'getLogs']);
const audits = auditSource
? [...state.audits, ...collectionItems(auditSource)]
: (overview.audits || state.audits);
const logs = logSource
? [...state.logs, ...collectionItems(logSource)]
: (overview.logs || state.logs);
const normalizedTasks = collectionItems(tasks).map(normalizeTask);
const normalizedRepos = collectionItems(repositories).map(normalizeRepository);
const tasksByRepo = new Map();
for (const task of normalizedTasks) {
if (!task.repoKey) continue;
const list = tasksByRepo.get(task.repoKey) || [];
list.push(task);
tasksByRepo.set(task.repoKey, list);
}
for (const repo of normalizedRepos) {
const repoTasks = tasksByRepo.get(repo.repoKey) || [];
repo.queuedCount = repoTasks.filter((task) => task.state === 'queued' || task.state === 'retry_wait').length;
repo.activeTaskId = repo.activeTaskId || repoTasks.find((task) => RUNNING_STATES.has(task.state))?.taskId || null;
const blockedTask = repoTasks.find((task) => task.state === 'blocked_workspace');
if (blockedTask) {
repo.dirty = true;
repo.dirtyReason = repo.dirtyReason || blockedTask.dirtyReason || blockedTask.errorMessage || '工作区阻塞';
repo.status = 'blocked';
}
}
const runningCount = normalizedTasks.filter((task) => RUNNING_STATES.has(task.state)).length;
const queuedCount = normalizedTasks.filter((task) => task.state === 'queued' || task.state === 'retry_wait').length;
const control = {
...state.control,
...(overview.control || {}),
globalPaused: overview.globalPaused ?? overview.control?.globalPaused ?? state.control.globalPaused,
};
const taskLogs = normalizedTasks.flatMap((task) => task.logs.map((entry) => ({ ...entry, taskId: task.taskId, repoKey: task.repoKey })));
const normalizedLogs = (Array.isArray(logs) ? logs : []).map(normalizeLog);
const auditLogs = collectionItems(audits).map((entry) => ({
timestamp: entry.timestamp || entry.createdAt || entry.ts || null,
level: entry.errorCode ? 'warn' : 'info',
taskId: entry.taskId || null,
repoKey: entry.repoKey || null,
event: entry.action || entry.event || 'workflow',
message: [entry.action || entry.event || 'workflow', entry.errorCode, entry.toState].filter(Boolean).join(' · '),
})).map(normalizeLog);
return {
ok: true,
available: !!service,
generatedAt: clock(),
control,
summary: {
running: Number(overview.summary?.running ?? runningCount),
queued: Number(overview.summary?.queued ?? queuedCount),
repositories: normalizedRepos.length,
blocked: normalizedRepos.filter((repo) => repo.dirty || repo.status === 'blocked').length,
},
repositories: normalizedRepos,
tasks: normalizedTasks,
audits: Array.from(new Map(collectionItems(audits).map(normalizeAudit)
.map((entry) => [entry.eventId || `${entry.timestamp}:${entry.action}`, entry])).values()).slice(-200),
logs: [...normalizedLogs, ...taskLogs.map(normalizeLog), ...auditLogs].slice(-300),
};
}
function listTasks(query = {}) {
const all = snapshot().tasks;
const filtered = all.filter((task) => (!query.repoKey || task.repoKey === query.repoKey)
&& (!query.resourceKey || task.resourceKey === query.resourceKey)
&& (!query.state || task.state === query.state));
const offset = Math.max(0, Number.parseInt(query.offset || 0, 10) || 0);
const limit = Math.min(200, Math.max(1, Number.parseInt(query.limit || 50, 10) || 50));
return { items: filtered.slice(offset, offset + limit), total: filtered.length, offset, limit };
}
function resolveTask(taskId) {
const serviceTask = pickService(service, ['getTask', 'findTask'], taskId);
if (serviceTask) return normalizeTask(serviceTask);
return snapshot().tasks.find((task) => task.taskId === taskId) || null;
}
function controlAction(action, input = {}) {
const actor = safeText(input.actor || 'admin', 160);
const reason = safeText(input.reason || '', 1000);
if (!reason) return { ok: false, statusCode: 400, code: 'reason_required', message: '控制操作必须填写原因。' };
const rid = requestId(input.requestId);
if (state.controlRequests[rid]) {
return { ...clone(state.controlRequests[rid]), idempotent: true, overview: snapshot() };
}
const previous = snapshot();
const methodNames = {
pause: ['pause', 'pauseAll', 'setGlobalPaused'],
resume: ['resume', 'resumeAll', 'setGlobalPaused'],
disable: ['disableRepository', 'setRepositoryEnabled', 'setRepositoryStatus'],
enable: ['enableRepository', 'setRepositoryEnabled', 'setRepositoryStatus'],
cancel: ['cancelTask', 'cancelQueuedTask'],
abort: ['abortTask', 'abortTurn', 'cancelRunningTask'],
}[action] || [];
const serviceArgs = action === 'pause' || action === 'resume'
? [{ actor, reason, requestId: rid }]
: [{ ...input, actor, reason, requestId: rid }];
let result;
try {
result = pickService(service, methodNames, ...serviceArgs);
} catch (error) {
return { ok: false, statusCode: 503, code: 'workflow_service_error', message: safeText(error.message || error, 500) };
}
if (!service) return { ok: false, statusCode: 503, code: 'workflow_service_unavailable', message: 'Workflow 核心服务尚未挂接,控制操作未执行。' };
if (result === undefined) return { ok: false, statusCode: 503, code: 'workflow_control_unsupported', message: `核心服务未提供 ${action} 操作。` };
if (result && result.ok === false) {
return { ...clone(result), ok: false, statusCode: result.code === 'task_not_found' || result.code === 'repository_not_found' ? 404 : 400 };
}
const taskId = input.taskId || null;
const repoKey = input.repoKey || null;
appendAudit({
action: `admin.${action}`, actor, reason, taskId, repoKey,
fromState: taskId ? resolveTask(taskId)?.state : null,
toState: taskId ? resolveTask(taskId)?.state : null,
metadata: { requestId: rid, result: result && typeof result === 'object' ? result : null },
});
const response = { ok: true, requestId: rid, action, result: result === true ? {} : clone(result), previous: previous.summary };
state.controlRequests[rid] = response;
const requestIds = Object.keys(state.controlRequests);
while (requestIds.length > 500) delete state.controlRequests[requestIds.shift()];
persist();
return { ...clone(response), overview: snapshot() };
}
return Object.freeze({
overview: snapshot,
listTasks,
getTask(taskId) { return resolveTask(taskId); },
listAudit(query = {}) {
const items = snapshot().audits.filter((entry) => (!query.taskId || entry.taskId === query.taskId)
&& (!query.repoKey || entry.repoKey === query.repoKey));
return { items: items.slice(-Math.min(500, Number(query.limit || 200))), total: items.length };
},
control: controlAction,
configuration,
runtimeConfiguration,
updateConfiguration,
appendAudit,
setState(next) {
const cloned = clone(next) || {};
const nextState = { ...state, ...cloned };
if (cloned.settings) nextState.settings = stripGlobalConcurrency({ ...state.settings, ...cloned.settings });
state = nextState;
persist();
},
setWorkflowService(nextService) { service = nextService || null; },
getState() { return clone(state); },
});
}
function readRequestBody(req, maxBytes = 256 * 1024) {
return new Promise((resolve, reject) => {
let total = 0;
const chunks = [];
req.on('data', (chunk) => {
total += chunk.length;
if (total > maxBytes) {
reject(Object.assign(new Error('请求体过大'), { statusCode: 413 }));
req.destroy();
return;
}
chunks.push(chunk);
});
req.on('end', () => {
try {
const text = Buffer.concat(chunks).toString('utf8').trim();
resolve(text ? JSON.parse(text) : {});
} catch (error) {
reject(Object.assign(new Error('请求体不是有效 JSON'), { statusCode: 400, cause: error }));
}
});
req.on('error', reject);
});
}
function sendJson(res, statusCode, payload) {
if (res.headersSent) return;
res.writeHead(statusCode, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-cache' });
res.end(JSON.stringify(payload));
}
async function handleGiteaWorkflowManagementApi(req, res, url, management, options = {}) {
const pathname = url.pathname.replace(/\/+$/, '') || '/';
if (typeof options.authenticate === 'function' && !options.authenticate(req)) {
return sendJson(res, 401, { ok: false, code: 'unauthorized', message: 'Not authenticated' });
}
if (!management) return sendJson(res, 503, { ok: false, code: 'workflow_service_unavailable', message: 'Workflow 管理服务不可用。' });
try {
if (req.method === 'GET' && pathname === '/api/gitea-workflow/overview') return sendJson(res, 200, management.overview());
if (req.method === 'GET' && pathname === '/api/gitea-workflow/config') return sendJson(res, 200, { ok: true, ...management.configuration() });
if (req.method === 'PUT' && pathname === '/api/gitea-workflow/config') {
const body = await readRequestBody(req);
return sendJson(res, 200, { ok: true, ...management.updateConfiguration(body) });
}
if (req.method === 'GET' && pathname === '/api/gitea-workflow/tasks') {
return sendJson(res, 200, { ok: true, ...management.listTasks(Object.fromEntries(url.searchParams.entries())) });
}
const taskMatch = pathname.match(/^\/api\/gitea-workflow\/tasks\/([^/]+)$/);
if (req.method === 'GET' && taskMatch) {
const task = management.getTask(decodeURIComponent(taskMatch[1]));
return task ? sendJson(res, 200, { ok: true, task }) : sendJson(res, 404, { ok: false, code: 'task_not_found' });
}
if (req.method === 'GET' && pathname === '/api/gitea-workflow/audit') {
return sendJson(res, 200, { ok: true, ...management.listAudit(Object.fromEntries(url.searchParams.entries())) });
}
const controlMatch = pathname.match(/^\/api\/gitea-workflow\/control\/(pause|resume)$/);
if (req.method === 'POST' && controlMatch) {
const body = await readRequestBody(req);
const result = management.control(controlMatch[1], body);
return sendJson(res, result.statusCode || (result.ok ? 200 : 400), result);
}
const repoMatch = pathname.match(/^\/api\/gitea-workflow\/repos\/(.+)\/(disable|enable)$/);
if (req.method === 'POST' && repoMatch) {
const body = await readRequestBody(req);
const result = management.control(repoMatch[2], { ...body, repoKey: decodeURIComponent(repoMatch[1]) });
return sendJson(res, result.statusCode || (result.ok ? 200 : 400), result);
}
const taskControlMatch = pathname.match(/^\/api\/gitea-workflow\/tasks\/([^/]+)\/(cancel|abort)$/);
if (req.method === 'POST' && taskControlMatch) {
const body = await readRequestBody(req);
const result = management.control(taskControlMatch[2], { ...body, taskId: decodeURIComponent(taskControlMatch[1]) });
return sendJson(res, result.statusCode || (result.ok ? 200 : 400), result);
}
return sendJson(res, 404, { ok: false, code: 'not_found' });
} catch (error) {
return sendJson(res, error.statusCode || 500, {
ok: false,
code: error.code || 'workflow_api_error',
message: safeText(error.message || error, 500),
});
}
}
module.exports = {
createGiteaWorkflowManagement,
handleGiteaWorkflowManagementApi,
normalizeRepository,
normalizeTask,
normalizeAudit,
};