470 lines
16 KiB
Swift
470 lines
16 KiB
Swift
import Foundation
|
|
import GRDB
|
|
|
|
/// GRDB-backed `SessionMetadataStore` (RUNTIME §4). WAL-mode SQLite on disk;
|
|
/// an in-memory queue for tests. Named to avoid clashing with GRDB's own
|
|
/// `Database` type used inside the read/write closures.
|
|
public final class GRDBMetadataStore: SessionMetadataStore {
|
|
private let writer: any DatabaseWriter
|
|
|
|
/// Opens (and migrates) the store. `path == nil` → an in-memory queue (tests);
|
|
/// a path → a WAL-mode `DatabasePool` under `Application Support/Nucleic`.
|
|
public init(path: String?) throws {
|
|
if let path {
|
|
try FileManager.default.createDirectory(
|
|
at: URL(fileURLWithPath: path).deletingLastPathComponent(),
|
|
withIntermediateDirectories: true)
|
|
var config = Configuration()
|
|
config.prepareDatabase { try $0.execute(sql: "PRAGMA foreign_keys = ON") }
|
|
writer = try DatabasePool(path: path, configuration: config)
|
|
} else {
|
|
writer = try DatabaseQueue()
|
|
}
|
|
try Self.migrator.migrate(writer)
|
|
}
|
|
|
|
// MARK: - Schema (RUNTIME §4; device table deferred to M4)
|
|
|
|
private static var migrator: DatabaseMigrator {
|
|
var migrator = DatabaseMigrator()
|
|
migrator.registerMigration("v1") { db in
|
|
try db.execute(sql: """
|
|
CREATE TABLE project (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
root_path TEXT NOT NULL,
|
|
default_branch TEXT NOT NULL,
|
|
default_backend TEXT,
|
|
worktree_base TEXT,
|
|
setup_script TEXT,
|
|
setup_policy TEXT NOT NULL DEFAULT 'block',
|
|
created_at DATETIME NOT NULL
|
|
);
|
|
""")
|
|
try db.execute(sql: """
|
|
CREATE TABLE session (
|
|
id TEXT PRIMARY KEY,
|
|
project_id TEXT NOT NULL,
|
|
backend TEXT NOT NULL,
|
|
backend_session_id TEXT,
|
|
title TEXT NOT NULL,
|
|
status TEXT NOT NULL,
|
|
worktree_path TEXT,
|
|
branch TEXT,
|
|
base_sha TEXT,
|
|
model TEXT,
|
|
last_seq INTEGER NOT NULL DEFAULT 0,
|
|
transcript_path TEXT NOT NULL,
|
|
native_transcript_path TEXT,
|
|
diff_files INTEGER, diff_added INTEGER, diff_removed INTEGER,
|
|
ahead INTEGER, behind INTEGER,
|
|
created_at DATETIME NOT NULL,
|
|
updated_at DATETIME NOT NULL
|
|
);
|
|
""")
|
|
try db.execute(sql: "CREATE INDEX idx_session_project ON session(project_id, updated_at);")
|
|
try db.execute(sql: """
|
|
CREATE TABLE approval (
|
|
id TEXT PRIMARY KEY,
|
|
session_id TEXT NOT NULL,
|
|
tool_call_id TEXT,
|
|
tool_name TEXT NOT NULL,
|
|
title TEXT NOT NULL,
|
|
risk TEXT NOT NULL,
|
|
input TEXT NOT NULL,
|
|
created_at DATETIME NOT NULL,
|
|
resolved_at DATETIME,
|
|
decision TEXT, decided_by TEXT
|
|
);
|
|
""")
|
|
try db.execute(sql:
|
|
"CREATE INDEX idx_approval_pending ON approval(session_id) WHERE resolved_at IS NULL;")
|
|
}
|
|
migrator.registerMigration("v2-effort") { db in
|
|
try db.execute(sql: "ALTER TABLE session ADD COLUMN effort TEXT;")
|
|
}
|
|
migrator.registerMigration("v3-flags") { db in
|
|
try db.execute(sql: "ALTER TABLE session ADD COLUMN auto INTEGER NOT NULL DEFAULT 0;")
|
|
try db.execute(sql: "ALTER TABLE session ADD COLUMN favorite INTEGER NOT NULL DEFAULT 0;")
|
|
try db.execute(sql: "ALTER TABLE session ADD COLUMN archived INTEGER NOT NULL DEFAULT 0;")
|
|
}
|
|
migrator.registerMigration("v4-summary") { db in
|
|
try db.execute(sql: "ALTER TABLE session ADD COLUMN summary TEXT;")
|
|
}
|
|
migrator.registerMigration("v5-todos") { db in
|
|
try db.execute(sql: """
|
|
CREATE TABLE todo (
|
|
id TEXT PRIMARY KEY,
|
|
text TEXT NOT NULL,
|
|
project_id TEXT,
|
|
status TEXT NOT NULL DEFAULT 'open',
|
|
dispatched_session_id TEXT,
|
|
created_at DATETIME NOT NULL,
|
|
updated_at DATETIME NOT NULL
|
|
);
|
|
""")
|
|
try db.execute(sql: "CREATE INDEX idx_todo_updated ON todo(updated_at);")
|
|
}
|
|
migrator.registerMigration("v6-todo-summary") { db in
|
|
try db.execute(sql: "ALTER TABLE todo ADD COLUMN summary TEXT;")
|
|
}
|
|
migrator.registerMigration("v7-sandbox") { db in
|
|
try db.execute(sql: "ALTER TABLE project ADD COLUMN sandbox_config TEXT;")
|
|
}
|
|
migrator.registerMigration("v8-disposition") { db in
|
|
try db.execute(sql: "ALTER TABLE session ADD COLUMN last_turn_disposition TEXT;")
|
|
}
|
|
migrator.registerMigration("v9-todo-triage") { db in
|
|
try db.execute(sql: "ALTER TABLE todo ADD COLUMN triage_level TEXT;")
|
|
try db.execute(sql: "ALTER TABLE todo ADD COLUMN triage_rank INTEGER;")
|
|
try db.execute(sql: "ALTER TABLE todo ADD COLUMN triage_reason TEXT;")
|
|
}
|
|
migrator.registerMigration("v10-autoship") { db in
|
|
try db.execute(sql: "ALTER TABLE session ADD COLUMN auto_ship INTEGER NOT NULL DEFAULT 0;")
|
|
}
|
|
migrator.registerMigration("v11-unseen-completion") { db in
|
|
try db.execute(sql: "ALTER TABLE session ADD COLUMN unseen_completion INTEGER NOT NULL DEFAULT 0;")
|
|
}
|
|
return migrator
|
|
}
|
|
|
|
// MARK: - Projects
|
|
|
|
public func saveProject(_ project: Project) async throws {
|
|
try await writer.write { db in
|
|
try ProjectRow(project).upsert(db)
|
|
}
|
|
}
|
|
|
|
public func loadProjects() async throws -> [Project] {
|
|
try await writer.read { db in
|
|
try ProjectRow.order(Column("name")).fetchAll(db).map { $0.toProject() }
|
|
}
|
|
}
|
|
|
|
public func deleteProject(id: ProjectID) async throws {
|
|
_ = try await writer.write { db in
|
|
try ProjectRow.deleteOne(db, key: id.rawValue)
|
|
}
|
|
}
|
|
|
|
// MARK: - Sessions
|
|
|
|
public func saveSession(_ session: Session) async throws {
|
|
try await writer.write { db in
|
|
try SessionRow(session).upsert(db)
|
|
}
|
|
}
|
|
|
|
public func loadSession(id: SessionID) async throws -> Session? {
|
|
try await writer.read { db in
|
|
try SessionRow.fetchOne(db, key: id.rawValue)?.toSession()
|
|
}
|
|
}
|
|
|
|
public func loadSessions(projectID: ProjectID) async throws -> [Session] {
|
|
try await writer.read { db in
|
|
try SessionRow
|
|
.filter(Column("project_id") == projectID.rawValue)
|
|
.order(Column("updated_at").desc)
|
|
.fetchAll(db).map { $0.toSession() }
|
|
}
|
|
}
|
|
|
|
public func deleteSession(id: SessionID) async throws {
|
|
_ = try await writer.write { db in
|
|
try SessionRow.deleteOne(db, key: id.rawValue)
|
|
}
|
|
}
|
|
|
|
// MARK: - Approvals
|
|
|
|
public func saveApproval(_ request: ApprovalRequest) async throws {
|
|
try await writer.write { db in
|
|
try ApprovalRow(request).upsert(db)
|
|
}
|
|
}
|
|
|
|
public func resolveApproval(_ resolved: ApprovalResolved) async throws {
|
|
let decisionJSON = try Self.encodeDecision(resolved.decision)
|
|
let id = resolved.id.rawValue
|
|
let decidedBy = resolved.decidedBy
|
|
let decidedAt = resolved.decidedAt
|
|
try await writer.write { db in
|
|
try db.execute(
|
|
sql: """
|
|
UPDATE approval SET resolved_at = :at, decision = :decision, decided_by = :by
|
|
WHERE id = :id
|
|
""",
|
|
arguments: ["at": decidedAt, "decision": decisionJSON, "by": decidedBy, "id": id])
|
|
}
|
|
}
|
|
|
|
public func pendingApprovals(sessionID: SessionID) async throws -> [ApprovalRequest] {
|
|
let sid = sessionID.rawValue
|
|
return try await writer.read { db in
|
|
try ApprovalRow
|
|
.filter(Column("session_id") == sid && Column("resolved_at") == nil)
|
|
.order(Column("created_at"))
|
|
.fetchAll(db)
|
|
.compactMap { try? $0.toRequest() }
|
|
}
|
|
}
|
|
|
|
// MARK: - Todos
|
|
|
|
public func saveTodo(_ todo: Todo) async throws {
|
|
try await writer.write { db in
|
|
try TodoRow(todo).upsert(db)
|
|
}
|
|
}
|
|
|
|
public func loadTodos() async throws -> [Todo] {
|
|
try await writer.read { db in
|
|
try TodoRow.order(Column("updated_at").desc).fetchAll(db).map { $0.toTodo() }
|
|
}
|
|
}
|
|
|
|
public func deleteTodo(id: TodoID) async throws {
|
|
_ = try await writer.write { db in
|
|
try TodoRow.deleteOne(db, key: id.rawValue)
|
|
}
|
|
}
|
|
|
|
// MARK: - Decision JSON helpers
|
|
|
|
static func encodeDecision(_ decision: Decision) throws -> String {
|
|
let encoder = JSONEncoder()
|
|
encoder.dateEncodingStrategy = .iso8601
|
|
return String(decoding: try encoder.encode(decision), as: UTF8.self)
|
|
}
|
|
}
|
|
|
|
// MARK: - Row records (1:1 with columns; converted to/from domain types)
|
|
|
|
private struct ProjectRow: Codable, FetchableRecord, PersistableRecord {
|
|
static let databaseTableName = "project"
|
|
var id: String
|
|
var name: String
|
|
var root_path: String
|
|
var default_branch: String
|
|
var default_backend: String?
|
|
var worktree_base: String?
|
|
var setup_script: String?
|
|
var setup_policy: String
|
|
var sandbox_config: String?
|
|
var created_at: Date
|
|
|
|
init(_ p: Project) {
|
|
id = p.id.rawValue
|
|
name = p.name
|
|
root_path = p.rootPath
|
|
default_branch = p.defaultBranch.value
|
|
default_backend = p.defaultBackend?.rawValue
|
|
worktree_base = p.worktreeBase
|
|
setup_script = p.setupScript
|
|
setup_policy = p.setupPolicy.rawValue
|
|
sandbox_config = p.sandbox.flatMap { sandbox in
|
|
(try? JSONEncoder().encode(sandbox)).map { String(decoding: $0, as: UTF8.self) }
|
|
}
|
|
created_at = p.createdAt
|
|
}
|
|
|
|
func toProject() -> Project {
|
|
Project(
|
|
id: ProjectID(rawValue: id),
|
|
name: name,
|
|
rootPath: root_path,
|
|
defaultBranch: GitRef(default_branch),
|
|
defaultBackend: default_backend.flatMap(BackendID.init(rawValue:)),
|
|
worktreeBase: worktree_base,
|
|
setupScript: setup_script,
|
|
setupPolicy: SetupPolicy(rawValue: setup_policy) ?? .block,
|
|
sandbox: sandbox_config.flatMap { json in
|
|
try? JSONDecoder().decode(ProjectSandbox.self, from: Data(json.utf8))
|
|
},
|
|
createdAt: created_at)
|
|
}
|
|
}
|
|
|
|
private struct SessionRow: Codable, FetchableRecord, PersistableRecord {
|
|
static let databaseTableName = "session"
|
|
var id: String
|
|
var project_id: String
|
|
var backend: String
|
|
var backend_session_id: String?
|
|
var title: String
|
|
var status: String
|
|
var worktree_path: String?
|
|
var branch: String?
|
|
var base_sha: String?
|
|
var model: String?
|
|
var effort: String?
|
|
var last_seq: Int64
|
|
var transcript_path: String
|
|
var native_transcript_path: String?
|
|
var diff_files: Int?
|
|
var diff_added: Int?
|
|
var diff_removed: Int?
|
|
var ahead: Int?
|
|
var behind: Int?
|
|
var auto: Bool
|
|
var auto_ship: Bool
|
|
var favorite: Bool
|
|
var archived: Bool
|
|
var unseen_completion: Bool
|
|
var summary: String?
|
|
var last_turn_disposition: String?
|
|
var created_at: Date
|
|
var updated_at: Date
|
|
|
|
init(_ s: Session) {
|
|
id = s.id.rawValue
|
|
project_id = s.projectID.rawValue
|
|
backend = s.backend.rawValue
|
|
backend_session_id = s.backendSessionID
|
|
title = s.title
|
|
status = s.status.rawValue
|
|
worktree_path = s.worktreePath
|
|
branch = s.branch
|
|
base_sha = s.baseSHA
|
|
model = s.model
|
|
effort = s.effort
|
|
last_seq = Int64(s.lastSeq)
|
|
transcript_path = s.transcriptPath
|
|
native_transcript_path = s.nativeTranscriptPath
|
|
diff_files = s.diffStat?.filesChanged
|
|
diff_added = s.diffStat?.added
|
|
diff_removed = s.diffStat?.removed
|
|
ahead = s.ahead
|
|
behind = s.behind
|
|
auto = s.auto
|
|
auto_ship = s.autoShip
|
|
favorite = s.favorite
|
|
archived = s.archived
|
|
unseen_completion = s.unseenCompletion
|
|
summary = s.summary
|
|
last_turn_disposition = s.lastTurnDisposition?.rawValue
|
|
created_at = s.createdAt
|
|
updated_at = s.updatedAt
|
|
}
|
|
|
|
func toSession() -> Session {
|
|
let diffStat = diff_files.map {
|
|
DiffStat(filesChanged: $0, added: diff_added ?? 0, removed: diff_removed ?? 0)
|
|
}
|
|
return Session(
|
|
id: SessionID(rawValue: id),
|
|
projectID: ProjectID(rawValue: project_id),
|
|
backend: BackendID(rawValue: backend) ?? .claudeCode,
|
|
backendSessionID: backend_session_id,
|
|
title: title,
|
|
status: SessionStatus(rawValue: status) ?? .idle,
|
|
worktreePath: worktree_path,
|
|
branch: branch,
|
|
baseSHA: base_sha,
|
|
model: model,
|
|
effort: effort,
|
|
lastSeq: UInt64(max(0, last_seq)),
|
|
transcriptPath: transcript_path,
|
|
nativeTranscriptPath: native_transcript_path,
|
|
diffStat: diffStat,
|
|
ahead: ahead,
|
|
behind: behind,
|
|
auto: auto,
|
|
autoShip: auto_ship,
|
|
favorite: favorite,
|
|
archived: archived,
|
|
unseenCompletion: unseen_completion,
|
|
summary: summary,
|
|
lastTurnDisposition: last_turn_disposition.flatMap(TurnDisposition.init(rawValue:)),
|
|
createdAt: created_at,
|
|
updatedAt: updated_at)
|
|
}
|
|
}
|
|
|
|
private struct TodoRow: Codable, FetchableRecord, PersistableRecord {
|
|
static let databaseTableName = "todo"
|
|
var id: String
|
|
var text: String
|
|
var project_id: String?
|
|
var status: String
|
|
var dispatched_session_id: String?
|
|
var summary: String?
|
|
var triage_level: String?
|
|
var triage_rank: Int?
|
|
var triage_reason: String?
|
|
var created_at: Date
|
|
var updated_at: Date
|
|
|
|
init(_ t: Todo) {
|
|
id = t.id.rawValue
|
|
text = t.text
|
|
project_id = t.projectID?.rawValue
|
|
status = t.status.rawValue
|
|
dispatched_session_id = t.dispatchedSessionID?.rawValue
|
|
summary = t.summary
|
|
triage_level = t.triageLevel?.rawValue
|
|
triage_rank = t.triageRank
|
|
triage_reason = t.triageReason
|
|
created_at = t.createdAt
|
|
updated_at = t.updatedAt
|
|
}
|
|
|
|
func toTodo() -> Todo {
|
|
Todo(
|
|
id: TodoID(rawValue: id),
|
|
text: text,
|
|
projectID: project_id.map(ProjectID.init(rawValue:)),
|
|
status: TodoStatus(rawValue: status) ?? .open,
|
|
dispatchedSessionID: dispatched_session_id.map(SessionID.init(rawValue:)),
|
|
summary: summary,
|
|
triageLevel: triage_level.flatMap(TriageLevel.init(rawValue:)),
|
|
triageRank: triage_rank,
|
|
triageReason: triage_reason,
|
|
createdAt: created_at,
|
|
updatedAt: updated_at)
|
|
}
|
|
}
|
|
|
|
private struct ApprovalRow: Codable, FetchableRecord, PersistableRecord {
|
|
static let databaseTableName = "approval"
|
|
var id: String
|
|
var session_id: String
|
|
var tool_call_id: String?
|
|
var tool_name: String
|
|
var title: String
|
|
var risk: String
|
|
var input: String // canonical JSON
|
|
var created_at: Date
|
|
var resolved_at: Date?
|
|
var decision: String?
|
|
var decided_by: String?
|
|
|
|
init(_ r: ApprovalRequest) {
|
|
id = r.id.rawValue
|
|
session_id = r.sessionID.rawValue
|
|
tool_call_id = r.toolCallID
|
|
tool_name = r.toolName
|
|
title = r.title
|
|
risk = r.risk.rawValue
|
|
input = r.input.canonicalString()
|
|
created_at = r.createdAt
|
|
resolved_at = nil
|
|
decision = nil
|
|
decided_by = nil
|
|
}
|
|
|
|
func toRequest() throws -> ApprovalRequest {
|
|
ApprovalRequest(
|
|
id: ApprovalID(rawValue: id),
|
|
sessionID: SessionID(rawValue: session_id),
|
|
toolCallID: tool_call_id,
|
|
toolName: tool_name,
|
|
input: (try? JSONValue(parsing: input)) ?? .null,
|
|
title: title,
|
|
risk: Risk(rawValue: risk) ?? .unknown,
|
|
createdAt: created_at)
|
|
}
|
|
}
|