Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion Sources/AgentPetCore/AgentSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ public struct AgentSession: Identifiable, Sendable, Equatable {
public var updatedAt: Date
/// When the session entered its current `state`; resets on state change.
public var stateSince: Date
/// When the session was first created (first `apply` event).
public var createdAt: Date

public init(
id: String,
Expand All @@ -24,7 +26,8 @@ public struct AgentSession: Identifiable, Sendable, Equatable {
message: String? = nil,
source: AgentSource,
updatedAt: Date,
stateSince: Date? = nil
stateSince: Date? = nil,
createdAt: Date? = nil
) {
self.id = id
self.agentKind = agentKind
Expand All @@ -35,5 +38,6 @@ public struct AgentSession: Identifiable, Sendable, Equatable {
self.source = source
self.updatedAt = updatedAt
self.stateSince = stateSince ?? updatedAt
self.createdAt = createdAt ?? updatedAt
}
}
36 changes: 36 additions & 0 deletions Sources/AgentPetCore/SessionArchive.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import Foundation

/// A snapshot of a completed agent session, persisted to disk for historical review.
public struct SessionArchive: Codable, Sendable {
public let sessionId: String
public let agentKind: AgentKind
public let project: String?
public let title: String?
public let message: String?
public let tokenCount: Int?
public let startedAt: Date
public let endedAt: Date
public let duration: TimeInterval

public init(
sessionId: String,
agentKind: AgentKind,
project: String?,
title: String?,
message: String?,
tokenCount: Int?,
startedAt: Date,
endedAt: Date,
duration: TimeInterval
) {
self.sessionId = sessionId
self.agentKind = agentKind
self.project = project
self.title = title
self.message = message
self.tokenCount = tokenCount
self.startedAt = startedAt
self.endedAt = endedAt
self.duration = duration
}
}
172 changes: 172 additions & 0 deletions Sources/AgentPetCore/SessionArchiveStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import Foundation

/// Persists completed agent sessions to daily JSON files under `baseURL`.
public final class SessionArchiveStore: @unchecked Sendable {

/// Default singleton backed by `~/.agentpet/history/`.
public static let shared: SessionArchiveStore = SessionArchiveStore(
baseURL: FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".agentpet/history", isDirectory: true)
)

/// Root directory where daily archive files are stored.
public let baseURL: URL

private let queue = DispatchQueue(label: "com.agentpet.SessionArchiveStore")
private var archivedIds = Set<String>()

private static let dateFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd"
f.timeZone = TimeZone(identifier: "UTC")
return f
}()

private static let encoder: JSONEncoder = {
let e = JSONEncoder()
e.dateEncodingStrategy = .iso8601
return e
}()

private static let decoder: JSONDecoder = {
let d = JSONDecoder()
d.dateDecodingStrategy = .iso8601
return d
}()

public init(baseURL: URL) {
self.baseURL = baseURL
queue.sync {
do {
try FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true)
} catch {
fputs("SessionArchiveStore: failed to create directory \(baseURL.path): \(error)\n", stderr)
}
self.archivedIds = self._rebuildDedupSet()
}
}

/// Persists a completed session to the daily archive file.
public func archive(_ session: AgentSession, startedAt: Date, endedAt: Date) {
queue.sync {
guard !archivedIds.contains(session.id) else { return }

let record = SessionArchive(
sessionId: session.id,
agentKind: session.agentKind,
project: session.project,
title: session.title,
message: session.message,
tokenCount: nil,
startedAt: startedAt,
endedAt: endedAt,
duration: endedAt.timeIntervalSince(startedAt)
)

let fileURL = self._fileURL(for: startedAt)
var existing = self._readRecords(from: fileURL)
existing.append(record)

do {
let data = try SessionArchiveStore.encoder.encode(existing)
try data.write(to: fileURL, options: .atomic)
archivedIds.insert(session.id)
} catch {
fputs("SessionArchiveStore: failed to write archive \(fileURL.path): \(error)\n", stderr)
}
}
}

/// Returns all archived records for the calendar day containing `date` (UTC).
public func records(for date: Date) -> [SessionArchive] {
queue.sync {
let fetched = self._readRecords(from: self._fileURL(for: date))
for r in fetched { archivedIds.insert(r.sessionId) }
return fetched
}
}

/// Returns all records from the calendar day of `date` (UTC) up to today.
public func allRecords(since date: Date) -> [SessionArchive] {
queue.sync {
guard var current = self._startOfDay(date) else { return [] }
let today = Date()
var result: [SessionArchive] = []

while current <= today {
result.append(contentsOf: self._readRecords(from: self._fileURL(for: current)))
guard let next = Self.utcCalendar.date(byAdding: .day, value: 1, to: current) else { break }
current = next
}
return result
}
}

/// Deletes daily archive files older than `days` days.
/// Uses the most recent archive file's date (or today if none) as the reference point.
public func pruneOlderThan(days: Int) {
queue.sync {
guard let contents = try? FileManager.default.contentsOfDirectory(
at: baseURL, includingPropertiesForKeys: nil
) else { return }

let jsonFiles = contents.filter { $0.pathExtension == "json" }
let names = jsonFiles.map { $0.deletingPathExtension().lastPathComponent }

// Find the latest archive date to use as reference; fall back to today
let referenceString = names.max() ?? SessionArchiveStore.dateFormatter.string(from: Date())
guard let referenceDate = SessionArchiveStore.dateFormatter.date(from: referenceString) else { return }

let cutoffDate = referenceDate.addingTimeInterval(-TimeInterval(days) * 86_400)
let cutoffString = SessionArchiveStore.dateFormatter.string(from: cutoffDate)

for url in jsonFiles {
let name = url.deletingPathExtension().lastPathComponent
if name < cutoffString {
try? FileManager.default.removeItem(at: url)
}
}
}
}

// MARK: - Private helpers (must be called inside queue)

private static let utcCalendar: Calendar = {
var c = Calendar(identifier: .gregorian)
c.timeZone = TimeZone(identifier: "UTC")!
return c
}()

/// Returns midnight UTC on the same day as `date`, or `nil` if the calendar conversion fails.
private func _startOfDay(_ date: Date) -> Date? {
var components = Self.utcCalendar.dateComponents([.year, .month, .day], from: date)
components.hour = 0
components.minute = 0
components.second = 0
components.nanosecond = 0
return Self.utcCalendar.date(from: components)
}

private func _fileURL(for date: Date) -> URL {
let name = SessionArchiveStore.dateFormatter.string(from: date)
return baseURL.appendingPathComponent("\(name).json")
}

private func _readRecords(from url: URL) -> [SessionArchive] {
guard let data = try? Data(contentsOf: url) else { return [] }
return (try? SessionArchiveStore.decoder.decode([SessionArchive].self, from: data)) ?? []
}

private func _rebuildDedupSet() -> Set<String> {
guard let contents = try? FileManager.default.contentsOfDirectory(
at: baseURL, includingPropertiesForKeys: nil
) else { return [] }

var ids = Set<String>()
for url in contents where url.pathExtension == "json" {
let records = _readRecords(from: url)
for r in records { ids.insert(r.sessionId) }
}
return ids
}
}
8 changes: 8 additions & 0 deletions Sources/AgentPetCore/SessionStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ public final class SessionStore {
/// anything, so a quiet/abandoned one shouldn't linger as "running".
public var staleRegisteredAfter: TimeInterval

public var archiveStore: SessionArchiveStore?

private var byID: [String: AgentSession] = [:]

public init(doneToIdleAfter: TimeInterval = 30,
Expand Down Expand Up @@ -70,6 +72,9 @@ public final class SessionStore {
// A session-end event (agent quit/closed) removes the session at once,
// so it doesn't linger as "done" until the idle timeout.
if StateMapper.isSessionEnd(for: event.agentKind, eventName: event.eventName) {
if let session = byID[event.sessionId] {
archiveStore?.archive(session, startedAt: session.createdAt, endedAt: now)
}
byID.removeValue(forKey: event.sessionId)
return nil
}
Expand Down Expand Up @@ -116,14 +121,17 @@ public final class SessionStore {
}
case .idle:
if quiet >= removeIdleAfter {
archiveStore?.archive(session, startedAt: session.createdAt, endedAt: now)
byID.removeValue(forKey: id)
}
case .registered:
if quiet >= staleRegisteredAfter {
archiveStore?.archive(session, startedAt: session.createdAt, endedAt: now)
byID.removeValue(forKey: id)
}
case .working, .waiting:
if quiet >= staleActiveAfter {
archiveStore?.archive(session, startedAt: session.createdAt, endedAt: now)
byID.removeValue(forKey: id)
}
}
Expand Down
6 changes: 5 additions & 1 deletion Sources/App/AppDaemon.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ final class AppDaemon: ObservableObject {

@Published private(set) var sessions: [AgentSession] = []

private let store = SessionStore()
private let store: SessionStore = {
let s = SessionStore()
s.archiveStore = SessionArchiveStore.shared
return s
}()
private let server = EventSocketServer(path: AgentPetPaths.socketPath)
private var pruneTimer: Timer?

Expand Down
Loading
Loading