aps-agent/server/app/sessions.ts

137 lines
4.8 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 会话存储(A3.7:内存态 + SQLite 持久化)。
*
* - 持久部分(会话、消息、幂等键)写进 SQLite:重启后仍可查(ADR-0007)
* - 运行态部分(事件总线、进行中的运行)留在内存:进程重启后由平台历史恢复(架构设计 5.1)
* ADR-0003:个人会话只读,写操作必须在项目会话内——创建时就把住。
*/
import { randomUUID } from 'node:crypto';
import type { ChatMessage, SessionSummary } from '@aps/contracts';
import type { SessionRepository } from '../core/storage/sessions.js';
import { badRequest, notFound } from './errors.js';
import type { Subject } from './subject.js';
import { EventBus } from './events.js';
export type RunState = 'idle' | 'running' | 'cancelling' | 'cancelled';
export interface RunHandle {
runId: string;
abort: AbortController;
}
export interface SessionRecord {
summary: SessionSummary;
/** 会话归属者:引擎回调时的身份来源(ADR-0003:写操作必须在项目会话内) */
owner: Subject;
messages: ChatMessage[];
bus: EventBus;
state: RunState;
runs: Map<string, RunHandle>;
/** 幂等键 → 已受理的消息与运行(同一 key 重试不产生第二个 run) */
accepted: Map<string, { messageId: string; runId: string }>;
}
export interface CreateSessionInput {
scope: 'personal' | 'project';
projectId?: string | undefined;
title?: string | undefined;
}
export function newId(prefix: string): string {
return `${prefix}-${randomUUID()}`;
}
export class SessionStore {
private readonly sessions = new Map<string, SessionRecord>();
constructor(private readonly repository?: SessionRepository) {}
create(input: CreateSessionInput, owner: Subject): SessionRecord {
if (input.scope === 'project' && !input.projectId) throw badRequest('项目会话必须带 projectId(ADR-0003)');
if (input.scope === 'personal' && input.projectId) throw badRequest('个人会话不能带 projectId');
const summary: SessionSummary = {
sessionId: newId('s'),
projectId: input.scope === 'project' ? (input.projectId ?? null) : null,
scope: input.scope,
status: 'active',
title: input.title ?? (input.scope === 'project' ? '新会话' : '个人会话'),
createdAt: Date.now(),
};
const record: SessionRecord = {
summary,
owner,
messages: [],
bus: new EventBus(),
state: 'idle',
runs: new Map(),
accepted: new Map(),
};
this.sessions.set(summary.sessionId, record);
this.repository?.insertSession({ ...summary, owner });
return record;
}
get(sessionId: string): SessionRecord | undefined {
const cached = this.sessions.get(sessionId);
if (cached) return cached;
// 进程重启后按库里的记录“补建”运行态:历史与归属都从存储恢复
const stored = this.repository?.getSession(sessionId);
if (!stored) return undefined;
const { owner, ...summary } = stored;
const record: SessionRecord = {
summary,
owner,
messages: this.repository?.listMessages(sessionId) ?? [],
bus: new EventBus(),
state: 'idle',
runs: new Map(),
accepted: new Map(),
};
this.sessions.set(sessionId, record);
return record;
}
/** 追加消息:内存与库一起写 */
appendMessage(sessionId: string, message: ChatMessage): void {
const record = this.require(sessionId);
record.messages.push(message);
this.repository?.appendMessage(sessionId, message);
}
/** 幂等键:受理时记录,重试与重启后都能命中 */
rememberIdempotencyKey(sessionId: string, key: string, messageId: string, runId: string): void {
this.require(sessionId).accepted.set(key, { messageId, runId });
this.repository?.rememberIdempotencyKey(key, sessionId, messageId, runId);
}
findIdempotencyKey(sessionId: string, key: string): { messageId: string; runId: string } | undefined {
const cached = this.require(sessionId).accepted.get(key);
if (cached) return cached;
const stored = this.repository?.findByIdempotencyKey(key);
if (stored && stored.sessionId === sessionId) {
const value = { messageId: stored.messageId, runId: stored.runId };
this.require(sessionId).accepted.set(key, value);
return value;
}
return undefined;
}
require(sessionId: string): SessionRecord {
const record = this.get(sessionId);
if (!record) throw notFound(`会话 ${sessionId}`);
return record;
}
list(projectId?: string): SessionSummary[] {
if (this.repository) {
return this.repository.listSessions(projectId).map(({ owner: _owner, ...summary }) => summary);
}
return [...this.sessions.values()]
.filter((record) => (projectId ? record.summary.projectId === projectId : true))
.map((record) => record.summary)
.sort((a, b) => b.createdAt - a.createdAt);
}
}