feat: support MCP elicitation and rebuild release
This commit is contained in:
300
lib/gitea-workflow-store.js
Normal file
300
lib/gitea-workflow-store.js
Normal 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,
|
||||
};
|
||||
Reference in New Issue
Block a user