feat: support MCP elicitation and rebuild release

This commit is contained in:
shiyue
2026-08-24 17:39:41 +08:00
parent dd233a40e8
commit bd20a79d4b
69 changed files with 8978 additions and 13 deletions

300
lib/gitea-workflow-store.js Normal file
View File

@@ -0,0 +1,300 @@
'use strict';
/**
* Gitea Workflow 持久化仓库。
*
* 使用单个 JSON 文件保存 MVP 领域状态,所有写入都通过同目录临时文件
* + rename 完成,进程崩溃时不会留下半截 JSON。业务层可以替换为数据库
* 但应保持本文件暴露的幂等键和查询契约。
*/
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const domain = require('./gitea-workflow-domain');
const MAX_AUDITS = 10_000;
const MAX_DELIVERIES = 20_000;
function clone(value) {
return domain.clone(value);
}
function atomicWriteJson(filePath, value) {
const target = path.resolve(filePath);
fs.mkdirSync(path.dirname(target), { recursive: true });
const temp = `${target}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`;
fs.writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
try {
fs.renameSync(temp, target);
} finally {
try { if (fs.existsSync(temp)) fs.unlinkSync(temp); } catch {}
}
}
function emptyState() {
return {
schemaVersion: 1,
updatedAt: domain.iso(),
control: { paused: false, reason: null, actor: 'system', version: 0, updatedAt: domain.iso() },
repositories: {},
sessions: {},
tasks: {},
turns: {},
deliveries: {},
audits: [],
};
}
function sanitizeMetadata(value, depth = 0) {
if (depth > 4) return '[truncated]';
if (Array.isArray(value)) return value.slice(0, 100).map((item) => sanitizeMetadata(item, depth + 1));
if (!value || typeof value !== 'object') return typeof value === 'string' ? value.slice(0, 2000) : value;
const output = {};
for (const [key, item] of Object.entries(value)) {
if (/token|secret|password|authorization|api[-_]?key/i.test(key)) {
output[key] = '[redacted]';
} else {
output[key] = sanitizeMetadata(item, depth + 1);
}
}
return output;
}
class GiteaWorkflowStore {
constructor(options = {}) {
this.filePath = options.filePath ? path.resolve(options.filePath) : null;
this.clock = typeof options.now === 'function' ? options.now : Date.now;
this.state = emptyState();
this.load();
}
load() {
if (!this.filePath || !fs.existsSync(this.filePath)) return this.state;
try {
const parsed = JSON.parse(fs.readFileSync(this.filePath, 'utf8'));
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const fresh = emptyState();
this.state = {
...fresh,
...parsed,
control: { ...fresh.control, ...(parsed.control || {}) },
repositories: parsed.repositories && typeof parsed.repositories === 'object' ? parsed.repositories : {},
sessions: parsed.sessions && typeof parsed.sessions === 'object' ? parsed.sessions : {},
tasks: parsed.tasks && typeof parsed.tasks === 'object' ? parsed.tasks : {},
turns: parsed.turns && typeof parsed.turns === 'object' ? parsed.turns : {},
deliveries: parsed.deliveries && typeof parsed.deliveries === 'object' ? parsed.deliveries : {},
audits: Array.isArray(parsed.audits) ? parsed.audits : [],
};
}
} catch {
// 启动时损坏的状态文件不能让 Webhook 进程直接退出;保留空状态并在下次写入时修复。
this.state = emptyState();
}
return this.state;
}
persist() {
this.state.updatedAt = domain.iso(this.clock);
if (this.filePath) atomicWriteJson(this.filePath, this.state);
return this.snapshot();
}
snapshot() { return clone(this.state); }
getControl() { return clone(this.state.control); }
setPaused(paused, input = {}) {
const next = Boolean(paused);
const previous = this.state.control;
this.state.control = {
paused: next,
reason: typeof input.reason === 'string' ? input.reason.slice(0, 1000) : null,
actor: typeof input.actor === 'string' ? input.actor.slice(0, 160) : 'system',
version: Number(previous.version || 0) + (previous.paused === next ? 0 : 1),
updatedAt: domain.iso(this.clock),
};
this.persist();
return this.getControl();
}
upsertRepository(input) {
const key = input.key || domain.repoKeyFor(input);
const previous = this.state.repositories[key];
const record = domain.createRepositoryRecord({ ...previous, ...input, key }, { now: this.clock });
this.state.repositories[key] = record;
this.persist();
return clone(record);
}
getRepository(key) { return clone(this.state.repositories[String(key)] || null); }
listRepositories(query = {}) {
return Object.values(this.state.repositories).filter((item) => (
(!query.instanceId || item.instanceId === query.instanceId)
&& (!query.status || item.status === query.status)
&& (query.enabled === undefined ? true : item.enabled === Boolean(query.enabled))
)).map(clone);
}
upsertSession(input) {
const key = input.sessionKey || domain.sessionKeyFor(input);
const previous = this.state.sessions[key];
const record = domain.createSessionRecord({ ...previous, ...input, sessionKey: key }, { now: this.clock });
this.state.sessions[key] = record;
this.persist();
return clone(record);
}
getSession(key) { return clone(this.state.sessions[String(key)] || null); }
listSessions(query = {}) {
return Object.values(this.state.sessions).filter((item) => (
(!query.repoKey || item.resourceKey?.startsWith(String(query.repoKey)))
&& (!query.status || item.status === query.status)
)).map(clone);
}
createTask(input) {
const record = domain.createTaskRecord(input, { now: this.clock });
if (this.state.tasks[record.taskId]) return clone(this.state.tasks[record.taskId]);
this.state.tasks[record.taskId] = record;
this.persist();
return clone(record);
}
upsertTask(input) {
const id = String(input.taskId || input.id || '');
if (!id) throw new TypeError('任务缺少 taskId');
const previous = this.state.tasks[id];
const record = domain.createTaskRecord({ ...previous, ...input, taskId: id }, { now: this.clock });
this.state.tasks[id] = record;
this.persist();
return clone(record);
}
transitionTask(taskId, nextState, options = {}) {
const current = this.state.tasks[String(taskId)];
if (!current) return null;
if (options.expectedVersion !== undefined
&& Number(current.stateVersion || 0) !== Number(options.expectedVersion)) {
const error = new Error('任务版本已变化,请刷新后重试。');
error.code = 'version_conflict';
error.expectedVersion = options.expectedVersion;
error.actualVersion = current.stateVersion || 0;
throw error;
}
const next = domain.transitionTask(current, nextState, { ...options, now: options.now || this.clock });
this.state.tasks[String(taskId)] = next;
this.persist();
return clone(next);
}
getTask(taskId) { return clone(this.state.tasks[String(taskId)] || null); }
listTasks(query = {}) {
return Object.values(this.state.tasks).filter((item) => (
(!query.repoKey || item.repoKey === query.repoKey)
&& (!query.sessionKey || item.sessionKey === query.sessionKey)
&& (!query.resourceKey || item.resourceKey === query.resourceKey)
&& (!query.state && !query.status || item.state === (query.state || query.status))
)).sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt))).map(clone);
}
upsertTurn(input) {
const id = String(input.turnId || '');
if (!id) throw new TypeError('turn 缺少 turnId');
const record = { ...(this.state.turns[id] || {}), ...clone(input), turnId: id, updatedAt: domain.iso(this.clock) };
this.state.turns[id] = record;
this.persist();
return clone(record);
}
getTurn(turnId) { return clone(this.state.turns[String(turnId)] || null); }
listTurns(query = {}) {
return Object.values(this.state.turns).filter((item) => (!query.taskId || item.taskId === query.taskId)
&& (!query.sessionKey || item.sessionKey === query.sessionKey)).map(clone);
}
getDelivery(deliveryKey) { return clone(this.state.deliveries[String(deliveryKey)] || null); }
/** 原子语义由单进程事件循环保证;重复 delivery 永远返回原记录。 */
claimDelivery(deliveryKey, value = {}) {
const key = String(deliveryKey || '');
if (!key) throw new TypeError('deliveryKey 不能为空');
const existing = this.state.deliveries[key];
if (existing) return { duplicate: true, record: clone(existing) };
const record = {
deliveryKey: key,
deliveryId: value.deliveryId || key.split(':').slice(1).join(':'),
status: value.status || 'processing',
taskId: value.taskId || null,
createdAt: value.createdAt || domain.iso(this.clock),
updatedAt: value.updatedAt || domain.iso(this.clock),
metadata: sanitizeMetadata(value.metadata || {}),
};
this.state.deliveries[key] = record;
const keys = Object.keys(this.state.deliveries);
while (keys.length > MAX_DELIVERIES) delete this.state.deliveries[keys.shift()];
this.persist();
return { duplicate: false, record: clone(record) };
}
updateDelivery(deliveryKey, patch = {}) {
const key = String(deliveryKey);
if (!this.state.deliveries[key]) return null;
this.state.deliveries[key] = { ...this.state.deliveries[key], ...sanitizeMetadata(patch), updatedAt: domain.iso(this.clock) };
this.persist();
return clone(this.state.deliveries[key]);
}
appendAudit(input = {}) {
const item = {
eventId: input.eventId || crypto.randomUUID(),
taskId: input.taskId || null,
sessionKey: input.sessionKey || null,
repoKey: input.repoKey || null,
actor: input.actor || 'system',
action: input.action || 'unknown',
fromState: input.fromState || null,
toState: input.toState || null,
deliveryId: input.deliveryId || null,
turnId: input.turnId || null,
commentId: input.commentId ?? null,
errorCode: input.errorCode || null,
timestamp: input.timestamp || domain.iso(this.clock),
metadata: sanitizeMetadata(input.metadata || {}),
};
this.state.audits.push(item);
if (this.state.audits.length > MAX_AUDITS) this.state.audits.splice(0, this.state.audits.length - MAX_AUDITS);
this.persist();
return clone(item);
}
listAudits(query = {}) {
return this.state.audits.filter((item) => (!query.taskId || item.taskId === query.taskId)
&& (!query.sessionKey || item.sessionKey === query.sessionKey)
&& (!query.repoKey || item.repoKey === query.repoKey)
&& (!query.action || item.action === query.action)
&& (!query.from || String(item.timestamp) >= String(query.from))
&& (!query.to || String(item.timestamp) <= String(query.to))).map(clone);
}
recover(options = {}) {
const before = Object.values(this.state.tasks);
const after = domain.recoverTasks(before, { ...options, now: options.now || this.clock });
for (const task of after) this.state.tasks[task.taskId] = task;
this.persist();
return after.map(clone);
}
}
function createGiteaWorkflowStore(options) {
return new GiteaWorkflowStore(options);
}
module.exports = {
GiteaWorkflowStore,
atomicWriteJson,
createGiteaWorkflowStore,
emptyState,
sanitizeMetadata,
};