2026-09-24 15:09:37 +08:00
|
|
|
|
/**
|
2026-09-28 16:03:12 +08:00
|
|
|
|
* 会话存储(A3.7:内存态 + SQLite 持久化)。
|
2026-09-24 15:09:37 +08:00
|
|
|
|
*
|
2026-09-28 16:03:12 +08:00
|
|
|
|
* - 持久部分(会话、消息、幂等键)写进 SQLite:重启后仍可查(ADR-0007)
|
|
|
|
|
|
* - 运行态部分(事件总线、进行中的运行)留在内存:进程重启后由平台历史恢复(架构设计 5.1)
|
|
|
|
|
|
* ADR-0003:个人会话只读,写操作必须在项目会话内——创建时就把住。
|
2026-09-24 15:09:37 +08:00
|
|
|
|
*/
|
|
|
|
|
|
import { randomUUID } from 'node:crypto';
|
|
|
|
|
|
|
|
|
|
|
|
import type { ChatMessage, SessionSummary } from '@aps/contracts';
|
|
|
|
|
|
|
2026-09-28 16:03:12 +08:00
|
|
|
|
import type { SessionRepository } from '../core/storage/sessions.js';
|
2026-09-24 15:09:37 +08:00
|
|
|
|
import { badRequest, notFound } from './errors.js';
|
2026-09-24 15:50:07 +08:00
|
|
|
|
import type { Subject } from './subject.js';
|
2026-09-24 15:09:37 +08:00
|
|
|
|
import { EventBus } from './events.js';
|
|
|
|
|
|
|
|
|
|
|
|
export type RunState = 'idle' | 'running' | 'cancelling' | 'cancelled';
|
|
|
|
|
|
|
|
|
|
|
|
export interface RunHandle {
|
|
|
|
|
|
runId: string;
|
|
|
|
|
|
abort: AbortController;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export interface SessionRecord {
|
|
|
|
|
|
summary: SessionSummary;
|
2026-09-24 15:50:07 +08:00
|
|
|
|
/** 会话归属者:引擎回调时的身份来源(ADR-0003:写操作必须在项目会话内) */
|
|
|
|
|
|
owner: Subject;
|
2026-09-24 15:09:37 +08:00
|
|
|
|
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>();
|
|
|
|
|
|
|
2026-09-28 16:03:12 +08:00
|
|
|
|
constructor(private readonly repository?: SessionRepository) {}
|
|
|
|
|
|
|
2026-09-24 15:50:07 +08:00
|
|
|
|
create(input: CreateSessionInput, owner: Subject): SessionRecord {
|
2026-09-24 15:09:37 +08:00
|
|
|
|
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,
|
2026-09-24 15:50:07 +08:00
|
|
|
|
owner,
|
2026-09-24 15:09:37 +08:00
|
|
|
|
messages: [],
|
|
|
|
|
|
bus: new EventBus(),
|
|
|
|
|
|
state: 'idle',
|
|
|
|
|
|
runs: new Map(),
|
|
|
|
|
|
accepted: new Map(),
|
|
|
|
|
|
};
|
|
|
|
|
|
this.sessions.set(summary.sessionId, record);
|
2026-09-28 16:03:12 +08:00
|
|
|
|
this.repository?.insertSession({ ...summary, owner });
|
2026-09-24 15:09:37 +08:00
|
|
|
|
return record;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
get(sessionId: string): SessionRecord | undefined {
|
2026-09-28 16:03:12 +08:00
|
|
|
|
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;
|
2026-09-24 15:09:37 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
require(sessionId: string): SessionRecord {
|
|
|
|
|
|
const record = this.get(sessionId);
|
|
|
|
|
|
if (!record) throw notFound(`会话 ${sessionId}`);
|
|
|
|
|
|
return record;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
list(projectId?: string): SessionSummary[] {
|
2026-09-28 16:03:12 +08:00
|
|
|
|
if (this.repository) {
|
|
|
|
|
|
return this.repository.listSessions(projectId).map(({ owner: _owner, ...summary }) => summary);
|
|
|
|
|
|
}
|
2026-09-24 15:09:37 +08:00
|
|
|
|
return [...this.sessions.values()]
|
|
|
|
|
|
.filter((record) => (projectId ? record.summary.projectId === projectId : true))
|
|
|
|
|
|
.map((record) => record.summary)
|
|
|
|
|
|
.sort((a, b) => b.createdAt - a.createdAt);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|