aps-agent/server/app/service.ts

146 lines
5.8 KiB
TypeScript
Raw 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.

/**
* 应用服务:会话与任务编排、能力查询、事件推送(架构设计第 2 节)。
*
* 它持有会话与业务记录,但不做业务规则、不替引擎判断。
* A1 阶段用内存实现;A3.7 换成持久层后接口保持不变。
*/
import { PROTOCOL_VERSION, type CapabilitiesResponse, type CancelResponse, type CreateSessionRequest, type CreateSessionResponse, type HealthResponse, type MeResponse, type PlatformEvent, type ProjectListResponse, type SendMessageRequest, type SendMessageResponse, type SessionDetailResponse, type SessionListResponse } from '@aps/contracts';
import { capabilitiesFor } from './capabilities.js';
import { runEcho } from './echo-runtime.js';
import { badRequest, conflict } from './errors.js';
import { ProjectStore } from './projects.js';
import { newId, SessionStore, type SessionRecord } from './sessions.js';
import type { Subject } from './subject.js';
export interface ApplicationServiceOptions {
version?: string;
}
export class ApplicationService {
private readonly projects = new ProjectStore();
private readonly sessions = new SessionStore();
private readonly version: string;
constructor(options: ApplicationServiceOptions = {}) {
this.version = options.version ?? '0.1.0';
}
health(): HealthResponse {
return { status: 'ok', version: this.version, protocolVersion: PROTOCOL_VERSION };
}
me(subject: Subject): MeResponse {
return {
userId: subject.userId,
tenantId: subject.tenantId,
displayName: subject.displayName,
clientKind: subject.clientKind,
};
}
listProjects(): ProjectListResponse {
return { projects: this.projects.list() };
}
listSessions(projectId?: string): SessionListResponse {
return { sessions: this.sessions.list(projectId) };
}
createSession(request: CreateSessionRequest): CreateSessionResponse {
const scope = request.scope;
if (scope !== 'personal' && scope !== 'project') throw badRequest('scope 只能是 personal 或 project');
if (scope === 'project') {
const projectId = request.projectId;
if (!projectId) throw badRequest('项目会话必须带 projectId(ADR-0003)');
if (!this.projects.get(projectId)) throw badRequest(`项目 ${projectId} 不存在`);
}
const record = this.sessions.create({ scope, projectId: request.projectId, title: request.title });
return { session: record.summary };
}
getSession(sessionId: string): SessionDetailResponse {
const record = this.sessions.require(sessionId);
return { session: record.summary, messages: record.messages };
}
capabilities(subject: Subject, sessionId?: string): CapabilitiesResponse {
if (sessionId) this.sessions.require(sessionId); // 会话不存在直接 404
return { capabilities: capabilitiesFor(subject, sessionId) };
}
sendMessage(subject: Subject, sessionId: string, request: SendMessageRequest): SendMessageResponse {
const record = this.sessions.require(sessionId);
void subject; // A4.1 起用于策略判定,A1 阶段只做存在性校验
if (typeof request.text !== 'string' || request.text.trim() === '') throw badRequest('text 不能为空');
if (typeof request.idempotencyKey !== 'string' || request.idempotencyKey.length < 8) {
throw badRequest('idempotencyKey 至少 8 个字符');
}
// 幂等重试优先于「会话忙」判定:同一 key 的重试必须返回原结果,不能报冲突
const cached = record.accepted.get(request.idempotencyKey);
if (cached) return { accepted: true, messageId: cached.messageId, runId: cached.runId };
if (record.state !== 'idle') throw conflict('上一个运行还没结束,先取消或等它完成');
const messageId = newId('m');
const runId = newId('r');
record.accepted.set(request.idempotencyKey, { messageId, runId });
record.messages.push({ messageId, role: 'user', ts: Date.now(), text: request.text });
record.bus.publish({ sessionId, runId, type: 'message.user', data: { messageId, text: request.text } });
const abort = new AbortController();
record.runs.set(runId, { runId, abort });
record.state = 'running';
void this.runTurn(record, runId, messageId, request.text, abort.signal);
return { accepted: true, messageId, runId };
}
cancel(sessionId: string): CancelResponse {
const record = this.sessions.require(sessionId);
if (record.state !== 'running') return { state: 'idle' };
record.state = 'cancelling';
for (const run of record.runs.values()) run.abort.abort();
return { state: 'cancelling' };
}
/** 事件流保活:无事件时由入口层定时调用(协议文档第 5 节) */
heartbeat(sessionId: string): void {
this.sessions.require(sessionId).bus.publish({ sessionId, type: 'heartbeat', data: {} });
}
subscribe(sessionId: string, since: number, listener: (event: PlatformEvent) => void): () => void {
return this.sessions.require(sessionId).bus.subscribe(since, listener);
}
lastSeq(sessionId: string): number {
return this.sessions.require(sessionId).bus.lastSeq;
}
private async runTurn(record: SessionRecord, runId: string, messageId: string, text: string, signal: AbortSignal): Promise<void> {
const sessionId = record.summary.sessionId;
try {
const result = await runEcho({ sessionId, runId, messageId, text, bus: record.bus, signal });
record.messages.push({
messageId: `${messageId}-reply`,
role: 'assistant',
ts: Date.now(),
text: result.text,
});
} catch (error) {
record.bus.publish({
sessionId,
runId,
type: 'error',
data: {
code: 'engine.failed',
message: error instanceof Error ? error.message : '引擎执行失败',
retryable: true,
},
});
} finally {
record.runs.delete(runId);
if (record.state !== 'idle') record.state = 'idle';
}
}
}