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

319 lines
12 KiB
JavaScript

'use strict';
/**
* Gitea Workflow 领域模型。
*
* 该文件只负责无副作用的数据规范化、标识生成和状态机校验,便于
* Webhook、调度器、Codex App 适配器以及管理 API 共享同一套契约。
*/
const crypto = require('crypto');
const TASK_STATES = Object.freeze({
RECEIVED: 'received',
QUEUED: 'queued',
PREPARING: 'preparing',
RUNNING: 'running',
WAITING_USER: 'waiting_user',
VERIFYING_REPLY: 'verifying_reply',
RETRY_WAIT: 'retry_wait',
BLOCKED_WORKSPACE: 'blocked_workspace',
ABORTING: 'aborting',
SUCCEEDED: 'succeeded',
SUCCEEDED_WITH_REST_FALLBACK: 'succeeded_with_rest_fallback',
FAILED: 'failed',
FAILED_REPLY: 'failed_reply',
CANCELLED: 'cancelled',
ABORTED: 'aborted',
IGNORED: 'ignored',
DUPLICATE: 'duplicate',
REJECTED: 'rejected',
});
const TERMINAL_STATES = new Set([
TASK_STATES.SUCCEEDED,
TASK_STATES.SUCCEEDED_WITH_REST_FALLBACK,
TASK_STATES.FAILED,
TASK_STATES.FAILED_REPLY,
TASK_STATES.CANCELLED,
TASK_STATES.ABORTED,
TASK_STATES.IGNORED,
TASK_STATES.DUPLICATE,
TASK_STATES.REJECTED,
]);
const RUNNING_STATES = new Set([
TASK_STATES.PREPARING,
TASK_STATES.RUNNING,
TASK_STATES.ABORTING,
]);
const TASK_TRANSITIONS = Object.freeze({
received: new Set(['queued', 'ignored', 'duplicate', 'rejected']),
queued: new Set(['preparing', 'cancelled']),
preparing: new Set(['running', 'blocked_workspace', 'retry_wait', 'failed']),
running: new Set(['waiting_user', 'verifying_reply', 'retry_wait', 'blocked_workspace', 'aborting', 'failed']),
waiting_user: new Set(['queued', 'cancelled', 'failed']),
verifying_reply: new Set(['succeeded', 'succeeded_with_rest_fallback', 'retry_wait', 'failed_reply']),
retry_wait: new Set(['preparing', 'running', 'verifying_reply', 'failed']),
blocked_workspace: new Set(['queued', 'cancelled']),
aborting: new Set(['aborted', 'failed']),
});
const TURN_STATES = Object.freeze({
CREATED: 'created',
STARTING: 'starting',
RUNNING: 'running',
WAITING_USER: 'waiting_user',
COMPLETED: 'completed',
FAILED: 'failed',
ABORTING: 'aborting',
ABORTED: 'aborted',
});
function isObject(value) {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function clone(value) {
if (value === undefined) return undefined;
return JSON.parse(JSON.stringify(value));
}
function text(value, max = 1000) {
return typeof value === 'string' ? value.trim().slice(0, max) : '';
}
function integer(value, fallback = 0) {
return Number.isSafeInteger(value) ? value : (Number.isSafeInteger(Number(value)) ? Number(value) : fallback);
}
function iso(now = Date.now) {
const value = typeof now === 'function' ? now() : now;
const date = value instanceof Date ? value : new Date(value);
return Number.isFinite(date.getTime()) ? date.toISOString() : new Date().toISOString();
}
function id(prefix) {
return `${prefix}-${crypto.randomUUID()}`;
}
function digest(value, length = 24) {
return crypto.createHash('sha256').update(String(value)).digest('hex').slice(0, length);
}
function sessionKeyFor({ instanceId = 'default', owner, repo, kind, number } = {}) {
const normalizedKind = kind === 'pull_request' || kind === 'pr' ? 'pull_request' : 'issue';
const ownerName = text(owner, 160);
const repoName = text(repo, 160);
const issueNumber = integer(number, 0);
if (!ownerName || !repoName || issueNumber <= 0) throw new TypeError('sessionKey 缺少 owner/repo/kind/number');
return `${text(instanceId, 80) || 'default'}:${ownerName}/${repoName}:${normalizedKind}:${issueNumber}`;
}
function resourceKeyFor(input = {}) {
// 资源身份必须包含编号,才能在回帖确认时区分同一仓库的不同 Issue/PR。
return sessionKeyFor(input);
}
function repoKeyFor({ instanceId = 'default', owner, repo, name } = {}) {
const repoName = text(repo || name, 160);
const ownerName = text(owner, 160);
if (!ownerName || !repoName) throw new TypeError('repoKey 缺少 owner/repo');
return `${text(instanceId, 80) || 'default'}:${ownerName}/${repoName}`;
}
function normalizeConfig(input = {}, env = process.env) {
const gitea = isObject(input.gitea) ? input.gitea : {};
const codex = isObject(input.codex) ? input.codex : {};
const workspaceRoot = text(input.workspaceRoot || env.CC_WEB_GITEA_WORKSPACE_ROOT, 1000)
|| require('path').join(process.cwd(), 'gitea-workspaces');
return Object.freeze({
instanceId: text(input.instanceId || env.CC_WEB_GITEA_INSTANCE_ID, 80) || 'default',
botLogin: text(input.botLogin || env.CC_WEB_GITEA_BOT_LOGIN, 120) || 'ccweb-bot',
botId: input.botId ?? env.CC_WEB_GITEA_BOT_ID ?? null,
workspaceRoot,
gitea: Object.freeze({
host: text(gitea.host || input.giteaHost || env.CC_WEB_GITEA_HOST, 500),
defaultBranchOverride: text(gitea.defaultBranchOverride || input.defaultBranchOverride || env.CC_WEB_GITEA_DEFAULT_BRANCH, 200) || '',
token: String(gitea.token ?? input.giteaToken ?? env.CC_WEB_GITEA_TOKEN ?? ''),
webhookSecret: String(gitea.webhookSecret ?? input.webhookSecret ?? env.CC_WEB_GITEA_WEBHOOK_SECRET ?? ''),
}),
codex: Object.freeze({
command: text(codex.command || env.CC_WEB_CODEX_APP_COMMAND, 200) || 'codexapp',
mode: 'yolo',
model: text(codex.model || env.CC_WEB_CODEX_MODEL, 120) || null,
reasoningEffort: text(codex.reasoningEffort || env.CC_WEB_CODEX_REASONING_EFFORT, 80) || null,
developerInstructions: text(codex.developerInstructions, 4000) || null,
}),
});
}
function publicConfig(config) {
const value = clone(config || {});
if (value.gitea) {
value.gitea.token = value.gitea.token ? `sha256:${digest(value.gitea.token, 16)}` : null;
value.gitea.webhookSecret = value.gitea.webhookSecret ? `sha256:${digest(value.gitea.webhookSecret, 16)}` : null;
}
return value;
}
function createRepositoryRecord(input = {}, options = {}) {
const now = iso(options.now);
const owner = text(input.owner, 160);
const name = text(input.name || input.repo, 160);
if (!owner || !name) throw new TypeError('仓库记录缺少 owner/name');
return {
key: input.key || repoKeyFor({ instanceId: input.instanceId, owner, repo: name }),
instanceId: text(input.instanceId, 80) || 'default', owner, name,
fullName: `${owner}/${name}`,
cloneUrl: text(input.cloneUrl || input.clone_url, 600) || null,
defaultBranch: text(input.defaultBranch || input.default_branch, 200) || null,
workspacePath: text(input.workspacePath, 1200) || null,
lastFetchAt: input.lastFetchAt || null,
status: text(input.status, 40) || 'active',
enabled: input.enabled !== false && input.status !== 'disabled',
dirty: input.dirty === true,
dirtyReason: text(input.dirtyReason, 1000) || null,
dirtySessionKey: text(input.dirtySessionKey, 300) || null,
createdAt: input.createdAt || now,
updatedAt: input.updatedAt || now,
version: Math.max(0, integer(input.version, 0)),
activeTaskId: text(input.activeTaskId, 160) || null,
};
}
function createSessionRecord(input = {}, options = {}) {
const now = iso(options.now);
const sessionKey = input.sessionKey || sessionKeyFor(input);
return {
sessionKey,
instanceId: text(input.instanceId, 80) || 'default',
owner: text(input.owner, 160), repo: text(input.repo || input.name, 160),
kind: input.kind === 'pull_request' || input.kind === 'pr' ? 'pull_request' : 'issue',
number: integer(input.number, 0),
resourceKey: input.resourceKey || resourceKeyFor(input),
threadId: text(input.threadId, 240) || null,
ccwebSessionId: text(input.ccwebSessionId, 240) || null,
status: text(input.status, 40) || 'idle',
createdAt: input.createdAt || now, updatedAt: input.updatedAt || now,
lastTurnId: text(input.lastTurnId, 240) || null,
version: Math.max(0, integer(input.version, 0)),
};
}
function createTaskRecord(input = {}, options = {}) {
const now = iso(options.now);
const taskId = text(input.taskId || input.id, 180) || id('gitea-task');
const state = text(input.state || input.status, 40) || TASK_STATES.RECEIVED;
if (!Object.values(TASK_STATES).includes(state)) throw new TypeError(`未知任务状态: ${state}`);
return {
taskId,
deliveryKey: text(input.deliveryKey, 240) || null,
deliveryId: text(input.deliveryId, 180) || null,
repoKey: text(input.repoKey, 240) || null,
resourceKey: text(input.resourceKey, 300) || null,
sessionKey: text(input.sessionKey, 300) || null,
commentId: input.commentId ?? null,
actor: text(input.actor || input.author, 180) || null,
instruction: text(input.instruction || input.prompt, 8000),
state,
stateVersion: Math.max(0, integer(input.stateVersion, 0)),
attempt: Math.max(0, integer(input.attempt, 0)),
replyAttempt: Math.max(0, integer(input.replyAttempt, 0)),
threadId: text(input.threadId, 240) || null,
turnId: text(input.turnId, 240) || null,
turnState: text(input.turnState, 40) || null,
turnStartedAt: input.turnStartedAt || null,
turnUpdatedAt: input.turnUpdatedAt || null,
createdAt: input.createdAt || now, updatedAt: input.updatedAt || now,
nextRetryAt: input.nextRetryAt || null,
errorCode: text(input.errorCode, 160) || null,
errorMessage: text(input.errorMessage || input.error, 1200) || null,
metadata: isObject(input.metadata) ? clone(input.metadata) : {},
};
}
function createTurnRecord(input = {}, options = {}) {
const now = iso(options.now);
return {
turnId: text(input.turnId, 240) || id('gitea-turn'),
taskId: text(input.taskId, 180) || null,
sessionKey: text(input.sessionKey, 300) || null,
threadId: text(input.threadId, 240) || null,
state: text(input.state, 40) || TURN_STATES.CREATED,
startedAt: input.startedAt || null,
finishedAt: input.finishedAt || null,
lastEventSeq: Math.max(0, integer(input.lastEventSeq, 0)),
retryCount: Math.max(0, integer(input.retryCount, 0)),
createdAt: input.createdAt || now,
updatedAt: input.updatedAt || now,
};
}
function canTransition(from, to) {
if (from === to) return true;
return Boolean(TASK_TRANSITIONS[from]?.has(to));
}
function transitionTask(task, nextState, options = {}) {
const current = task?.state || task?.status;
if (!canTransition(current, nextState)) {
const error = new Error(`任务状态不可从 ${current} 转为 ${nextState}`);
error.code = 'invalid_task_transition';
error.fromState = current;
error.toState = nextState;
throw error;
}
const next = { ...clone(task), state: nextState, status: nextState,
stateVersion: integer(task.stateVersion, 0) + (current === nextState ? 0 : 1),
updatedAt: iso(options.now) };
if (options.turnId !== undefined) next.turnId = options.turnId;
if (options.errorCode !== undefined) next.errorCode = options.errorCode;
if (options.errorMessage !== undefined) next.errorMessage = options.errorMessage;
if (options.nextRetryAt !== undefined) next.nextRetryAt = options.nextRetryAt;
return next;
}
function recoverTasks(tasks, options = {}) {
const now = iso(options.now);
return (Array.isArray(tasks) ? tasks : []).map((task) => {
if (task.state === TASK_STATES.RUNNING || task.state === TASK_STATES.PREPARING) {
const retries = integer(task.attempt, 0);
if (retries < (options.maxRestartRetries ?? 1)) {
const recovered = transitionTask(task, TASK_STATES.RETRY_WAIT, { now: options.now, errorCode: 'interrupted_by_restart', errorMessage: '进程重启导致任务中断。', nextRetryAt: now });
recovered.attempt = retries + 1;
return recovered;
}
return transitionTask(task, TASK_STATES.FAILED, { now: options.now, errorCode: 'restart_retry_exhausted', errorMessage: '重启恢复重试次数已耗尽。' });
}
if (task.state === TASK_STATES.ABORTING) {
return transitionTask(task, TASK_STATES.FAILED, { now: options.now, errorCode: 'aborting_interrupted_by_restart', errorMessage: '中止确认期间进程重启。' });
}
return clone(task);
});
}
module.exports = {
TASK_STATES,
TERMINAL_STATES,
RUNNING_STATES,
TASK_TRANSITIONS,
TURN_STATES,
canTransition,
clone,
createRepositoryRecord,
createSessionRecord,
createTaskRecord,
createTurnRecord,
digest,
iso,
normalizeConfig,
publicConfig,
recoverTasks,
repoKeyFor,
resourceKeyFor,
sessionKeyFor,
transitionTask,
};