'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, };