539 lines
24 KiB
JavaScript
539 lines
24 KiB
JavaScript
'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,
|
||
};
|