mirror of
https://github.com/samiyev/puaros.git
synced 2025-12-28 07:16:53 +05:00
feat(ipuaro): add session management (v0.10.0)
- Add ISessionStorage interface and RedisSessionStorage implementation - Add ContextManager for token budget and compression - Add StartSession, HandleMessage, UndoChange use cases - Update CHANGELOG and TODO documentation - 88 new tests (1174 total), 97.73% coverage
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
import type { ISessionStorage, SessionListItem } from "../../domain/services/ISessionStorage.js"
|
||||
import { type ContextState, Session, type SessionStats } from "../../domain/entities/Session.js"
|
||||
import type { ChatMessage } from "../../domain/value-objects/ChatMessage.js"
|
||||
import type { UndoEntry } from "../../domain/value-objects/UndoEntry.js"
|
||||
import { MAX_UNDO_STACK_SIZE } from "../../domain/constants/index.js"
|
||||
import { IpuaroError } from "../../shared/errors/IpuaroError.js"
|
||||
import { RedisClient } from "./RedisClient.js"
|
||||
import { SessionFields, SessionKeys } from "./schema.js"
|
||||
|
||||
/**
|
||||
* Redis implementation of ISessionStorage.
|
||||
* Stores session data in Redis hashes and lists.
|
||||
*/
|
||||
export class RedisSessionStorage implements ISessionStorage {
|
||||
private readonly client: RedisClient
|
||||
|
||||
constructor(client: RedisClient) {
|
||||
this.client = client
|
||||
}
|
||||
|
||||
async saveSession(session: Session): Promise<void> {
|
||||
const redis = this.getRedis()
|
||||
const dataKey = SessionKeys.data(session.id)
|
||||
|
||||
const pipeline = redis.pipeline()
|
||||
|
||||
pipeline.hset(dataKey, SessionFields.projectName, session.projectName)
|
||||
pipeline.hset(dataKey, SessionFields.createdAt, String(session.createdAt))
|
||||
pipeline.hset(dataKey, SessionFields.lastActivityAt, String(session.lastActivityAt))
|
||||
pipeline.hset(dataKey, SessionFields.history, JSON.stringify(session.history))
|
||||
pipeline.hset(dataKey, SessionFields.context, JSON.stringify(session.context))
|
||||
pipeline.hset(dataKey, SessionFields.stats, JSON.stringify(session.stats))
|
||||
pipeline.hset(dataKey, SessionFields.inputHistory, JSON.stringify(session.inputHistory))
|
||||
|
||||
await this.addToSessionsList(session.id)
|
||||
|
||||
await pipeline.exec()
|
||||
}
|
||||
|
||||
async loadSession(sessionId: string): Promise<Session | null> {
|
||||
const redis = this.getRedis()
|
||||
const dataKey = SessionKeys.data(sessionId)
|
||||
|
||||
const data = await redis.hgetall(dataKey)
|
||||
if (!data || Object.keys(data).length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const session = new Session(
|
||||
sessionId,
|
||||
data[SessionFields.projectName],
|
||||
Number(data[SessionFields.createdAt]),
|
||||
)
|
||||
|
||||
session.lastActivityAt = Number(data[SessionFields.lastActivityAt])
|
||||
session.history = this.parseJSON(data[SessionFields.history], "history") as ChatMessage[]
|
||||
session.context = this.parseJSON(data[SessionFields.context], "context") as ContextState
|
||||
session.stats = this.parseJSON(data[SessionFields.stats], "stats") as SessionStats
|
||||
session.inputHistory = this.parseJSON(
|
||||
data[SessionFields.inputHistory],
|
||||
"inputHistory",
|
||||
) as string[]
|
||||
|
||||
const undoStack = await this.getUndoStack(sessionId)
|
||||
for (const entry of undoStack) {
|
||||
session.undoStack.push(entry)
|
||||
}
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
async deleteSession(sessionId: string): Promise<void> {
|
||||
const redis = this.getRedis()
|
||||
|
||||
await Promise.all([
|
||||
redis.del(SessionKeys.data(sessionId)),
|
||||
redis.del(SessionKeys.undo(sessionId)),
|
||||
redis.lrem(SessionKeys.list, 0, sessionId),
|
||||
])
|
||||
}
|
||||
|
||||
async listSessions(projectName?: string): Promise<SessionListItem[]> {
|
||||
const redis = this.getRedis()
|
||||
const sessionIds = await redis.lrange(SessionKeys.list, 0, -1)
|
||||
|
||||
const sessions: SessionListItem[] = []
|
||||
|
||||
for (const id of sessionIds) {
|
||||
const data = await redis.hgetall(SessionKeys.data(id))
|
||||
if (!data || Object.keys(data).length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const sessionProjectName = data[SessionFields.projectName]
|
||||
if (projectName && sessionProjectName !== projectName) {
|
||||
continue
|
||||
}
|
||||
|
||||
const history = this.parseJSON(data[SessionFields.history], "history") as ChatMessage[]
|
||||
|
||||
sessions.push({
|
||||
id,
|
||||
projectName: sessionProjectName,
|
||||
createdAt: Number(data[SessionFields.createdAt]),
|
||||
lastActivityAt: Number(data[SessionFields.lastActivityAt]),
|
||||
messageCount: history.length,
|
||||
})
|
||||
}
|
||||
|
||||
sessions.sort((a, b) => b.lastActivityAt - a.lastActivityAt)
|
||||
|
||||
return sessions
|
||||
}
|
||||
|
||||
async getLatestSession(projectName: string): Promise<Session | null> {
|
||||
const sessions = await this.listSessions(projectName)
|
||||
if (sessions.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return this.loadSession(sessions[0].id)
|
||||
}
|
||||
|
||||
async sessionExists(sessionId: string): Promise<boolean> {
|
||||
const redis = this.getRedis()
|
||||
const exists = await redis.exists(SessionKeys.data(sessionId))
|
||||
return exists === 1
|
||||
}
|
||||
|
||||
async pushUndoEntry(sessionId: string, entry: UndoEntry): Promise<void> {
|
||||
const redis = this.getRedis()
|
||||
const undoKey = SessionKeys.undo(sessionId)
|
||||
|
||||
await redis.rpush(undoKey, JSON.stringify(entry))
|
||||
|
||||
const length = await redis.llen(undoKey)
|
||||
if (length > MAX_UNDO_STACK_SIZE) {
|
||||
await redis.lpop(undoKey)
|
||||
}
|
||||
}
|
||||
|
||||
async popUndoEntry(sessionId: string): Promise<UndoEntry | null> {
|
||||
const redis = this.getRedis()
|
||||
const undoKey = SessionKeys.undo(sessionId)
|
||||
|
||||
const data = await redis.rpop(undoKey)
|
||||
if (!data) {
|
||||
return null
|
||||
}
|
||||
|
||||
return this.parseJSON(data, "UndoEntry") as UndoEntry
|
||||
}
|
||||
|
||||
async getUndoStack(sessionId: string): Promise<UndoEntry[]> {
|
||||
const redis = this.getRedis()
|
||||
const undoKey = SessionKeys.undo(sessionId)
|
||||
|
||||
const entries = await redis.lrange(undoKey, 0, -1)
|
||||
return entries.map((entry) => this.parseJSON(entry, "UndoEntry") as UndoEntry)
|
||||
}
|
||||
|
||||
async touchSession(sessionId: string): Promise<void> {
|
||||
const redis = this.getRedis()
|
||||
await redis.hset(
|
||||
SessionKeys.data(sessionId),
|
||||
SessionFields.lastActivityAt,
|
||||
String(Date.now()),
|
||||
)
|
||||
}
|
||||
|
||||
async clearAllSessions(): Promise<void> {
|
||||
const redis = this.getRedis()
|
||||
const sessionIds = await redis.lrange(SessionKeys.list, 0, -1)
|
||||
|
||||
const pipeline = redis.pipeline()
|
||||
for (const id of sessionIds) {
|
||||
pipeline.del(SessionKeys.data(id))
|
||||
pipeline.del(SessionKeys.undo(id))
|
||||
}
|
||||
pipeline.del(SessionKeys.list)
|
||||
|
||||
await pipeline.exec()
|
||||
}
|
||||
|
||||
private async addToSessionsList(sessionId: string): Promise<void> {
|
||||
const redis = this.getRedis()
|
||||
|
||||
const exists = await redis.lpos(SessionKeys.list, sessionId)
|
||||
if (exists === null) {
|
||||
await redis.lpush(SessionKeys.list, sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
private getRedis(): ReturnType<RedisClient["getClient"]> {
|
||||
return this.client.getClient()
|
||||
}
|
||||
|
||||
private parseJSON(data: string | undefined, type: string): unknown {
|
||||
if (!data) {
|
||||
if (type === "history" || type === "inputHistory") {
|
||||
return []
|
||||
}
|
||||
if (type === "context") {
|
||||
return { filesInContext: [], tokenUsage: 0, needsCompression: false }
|
||||
}
|
||||
if (type === "stats") {
|
||||
return {
|
||||
totalTokens: 0,
|
||||
totalTimeMs: 0,
|
||||
toolCalls: 0,
|
||||
editsApplied: 0,
|
||||
editsRejected: 0,
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(data) as unknown
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error"
|
||||
throw IpuaroError.parse(`Failed to parse ${type}: ${message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// Storage module exports
|
||||
export { RedisClient } from "./RedisClient.js"
|
||||
export { RedisStorage } from "./RedisStorage.js"
|
||||
export { RedisSessionStorage } from "./RedisSessionStorage.js"
|
||||
export {
|
||||
ProjectKeys,
|
||||
SessionKeys,
|
||||
|
||||
@@ -150,12 +150,9 @@ export class RunTestsTool implements ITool {
|
||||
return createSuccessResult(callId, result, Date.now() - startTime)
|
||||
} catch (error) {
|
||||
return this.handleExecError(
|
||||
callId,
|
||||
runner,
|
||||
command,
|
||||
{ callId, runner, command, startTime },
|
||||
error,
|
||||
execStartTime,
|
||||
startTime,
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -168,25 +165,37 @@ export class RunTestsTool implements ITool {
|
||||
* Detect which test runner is available in the project.
|
||||
*/
|
||||
async detectTestRunner(projectRoot: string): Promise<TestRunner | null> {
|
||||
if (await this.hasFile(projectRoot, "vitest.config.ts")) {
|
||||
return "vitest"
|
||||
}
|
||||
if (await this.hasFile(projectRoot, "vitest.config.js")) {
|
||||
return "vitest"
|
||||
}
|
||||
if (await this.hasFile(projectRoot, "vitest.config.mts")) {
|
||||
return "vitest"
|
||||
}
|
||||
if (await this.hasFile(projectRoot, "jest.config.js")) {
|
||||
return "jest"
|
||||
}
|
||||
if (await this.hasFile(projectRoot, "jest.config.ts")) {
|
||||
return "jest"
|
||||
}
|
||||
if (await this.hasFile(projectRoot, "jest.config.json")) {
|
||||
return "jest"
|
||||
const configRunner = await this.detectByConfigFile(projectRoot)
|
||||
if (configRunner) {
|
||||
return configRunner
|
||||
}
|
||||
|
||||
return this.detectByPackageJson(projectRoot)
|
||||
}
|
||||
|
||||
private async detectByConfigFile(projectRoot: string): Promise<TestRunner | null> {
|
||||
const configFiles: { files: string[]; runner: TestRunner }[] = [
|
||||
{
|
||||
files: ["vitest.config.ts", "vitest.config.js", "vitest.config.mts"],
|
||||
runner: "vitest",
|
||||
},
|
||||
{
|
||||
files: ["jest.config.js", "jest.config.ts", "jest.config.json"],
|
||||
runner: "jest",
|
||||
},
|
||||
]
|
||||
|
||||
for (const { files, runner } of configFiles) {
|
||||
for (const file of files) {
|
||||
if (await this.hasFile(projectRoot, file)) {
|
||||
return runner
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private async detectByPackageJson(projectRoot: string): Promise<TestRunner | null> {
|
||||
const packageJsonPath = path.join(projectRoot, "package.json")
|
||||
try {
|
||||
const content = await this.fsReadFile(packageJsonPath, "utf-8")
|
||||
@@ -196,23 +205,22 @@ export class RunTestsTool implements ITool {
|
||||
dependencies?: Record<string, string>
|
||||
}
|
||||
|
||||
if (pkg.devDependencies?.vitest || pkg.dependencies?.vitest) {
|
||||
const deps = { ...pkg.devDependencies, ...pkg.dependencies }
|
||||
if (deps.vitest) {
|
||||
return "vitest"
|
||||
}
|
||||
if (pkg.devDependencies?.jest || pkg.dependencies?.jest) {
|
||||
if (deps.jest) {
|
||||
return "jest"
|
||||
}
|
||||
if (pkg.devDependencies?.mocha || pkg.dependencies?.mocha) {
|
||||
if (deps.mocha) {
|
||||
return "mocha"
|
||||
}
|
||||
|
||||
if (pkg.scripts?.test) {
|
||||
return "npm"
|
||||
}
|
||||
} catch {
|
||||
// package.json doesn't exist or is invalid
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -220,63 +228,69 @@ export class RunTestsTool implements ITool {
|
||||
* Build the test command based on runner and options.
|
||||
*/
|
||||
buildCommand(runner: TestRunner, testPath?: string, filter?: string, watch?: boolean): string {
|
||||
const parts: string[] = []
|
||||
|
||||
switch (runner) {
|
||||
case "vitest":
|
||||
parts.push("npx vitest")
|
||||
if (!watch) {
|
||||
parts.push("run")
|
||||
}
|
||||
if (testPath) {
|
||||
parts.push(testPath)
|
||||
}
|
||||
if (filter) {
|
||||
parts.push("-t", `"${filter}"`)
|
||||
}
|
||||
break
|
||||
|
||||
case "jest":
|
||||
parts.push("npx jest")
|
||||
if (testPath) {
|
||||
parts.push(testPath)
|
||||
}
|
||||
if (filter) {
|
||||
parts.push("-t", `"${filter}"`)
|
||||
}
|
||||
if (watch) {
|
||||
parts.push("--watch")
|
||||
}
|
||||
break
|
||||
|
||||
case "mocha":
|
||||
parts.push("npx mocha")
|
||||
if (testPath) {
|
||||
parts.push(testPath)
|
||||
}
|
||||
if (filter) {
|
||||
parts.push("--grep", `"${filter}"`)
|
||||
}
|
||||
if (watch) {
|
||||
parts.push("--watch")
|
||||
}
|
||||
break
|
||||
|
||||
case "npm":
|
||||
parts.push("npm test")
|
||||
if (testPath || filter) {
|
||||
parts.push("--")
|
||||
if (testPath) {
|
||||
parts.push(testPath)
|
||||
}
|
||||
if (filter) {
|
||||
parts.push(`"${filter}"`)
|
||||
}
|
||||
}
|
||||
break
|
||||
const builders: Record<TestRunner, () => string[]> = {
|
||||
vitest: () => this.buildVitestCommand(testPath, filter, watch),
|
||||
jest: () => this.buildJestCommand(testPath, filter, watch),
|
||||
mocha: () => this.buildMochaCommand(testPath, filter, watch),
|
||||
npm: () => this.buildNpmCommand(testPath, filter),
|
||||
}
|
||||
return builders[runner]().join(" ")
|
||||
}
|
||||
|
||||
return parts.join(" ")
|
||||
private buildVitestCommand(testPath?: string, filter?: string, watch?: boolean): string[] {
|
||||
const parts = ["npx vitest"]
|
||||
if (!watch) {
|
||||
parts.push("run")
|
||||
}
|
||||
if (testPath) {
|
||||
parts.push(testPath)
|
||||
}
|
||||
if (filter) {
|
||||
parts.push("-t", `"${filter}"`)
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
private buildJestCommand(testPath?: string, filter?: string, watch?: boolean): string[] {
|
||||
const parts = ["npx jest"]
|
||||
if (testPath) {
|
||||
parts.push(testPath)
|
||||
}
|
||||
if (filter) {
|
||||
parts.push("-t", `"${filter}"`)
|
||||
}
|
||||
if (watch) {
|
||||
parts.push("--watch")
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
private buildMochaCommand(testPath?: string, filter?: string, watch?: boolean): string[] {
|
||||
const parts = ["npx mocha"]
|
||||
if (testPath) {
|
||||
parts.push(testPath)
|
||||
}
|
||||
if (filter) {
|
||||
parts.push("--grep", `"${filter}"`)
|
||||
}
|
||||
if (watch) {
|
||||
parts.push("--watch")
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
private buildNpmCommand(testPath?: string, filter?: string): string[] {
|
||||
const parts = ["npm test"]
|
||||
if (testPath || filter) {
|
||||
parts.push("--")
|
||||
if (testPath) {
|
||||
parts.push(testPath)
|
||||
}
|
||||
if (filter) {
|
||||
parts.push(`"${filter}"`)
|
||||
}
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -295,13 +309,11 @@ export class RunTestsTool implements ITool {
|
||||
* Handle exec errors and return appropriate result.
|
||||
*/
|
||||
private handleExecError(
|
||||
callId: string,
|
||||
runner: TestRunner,
|
||||
command: string,
|
||||
ctx: { callId: string; runner: TestRunner; command: string; startTime: number },
|
||||
error: unknown,
|
||||
execStartTime: number,
|
||||
startTime: number,
|
||||
): ToolResult {
|
||||
const { callId, runner, command, startTime } = ctx
|
||||
const durationMs = Date.now() - execStartTime
|
||||
|
||||
if (this.isExecError(error)) {
|
||||
|
||||
Reference in New Issue
Block a user