319 lines
12 KiB
JavaScript
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,
|
|
};
|