Files
nucleic/Tests/NucleicCoreTests/GRDBMetadataStoreTests.swift
T

542 lines
27 KiB
Swift

import Foundation
import Testing
@testable import NucleicCore
// `.isolatedContainerSettings`: `controllerPersistsThroughPipeline` starts a SessionController
// turn, whose spawn gate reads ContainerServiceSettings — pin it to a clean task-local suite.
@Suite("GRDBMetadataStore — in-memory SQLite", .isolatedContainerSettings)
struct GRDBMetadataStoreTests {
let now = Date(timeIntervalSince1970: 1_700_000_000)
func makeProject() -> Project {
Project(
id: .generate(), name: "demo", rootPath: "/repos/demo",
defaultBranch: "main", defaultBackend: .claudeCode,
worktreeBase: nil, setupScript: "npm install", setupPolicy: .warn, createdAt: now)
}
func makeSession(projectID: ProjectID) -> Session {
Session(
id: .generate(), projectID: projectID, backend: .claudeCode,
backendSessionID: "be-1", title: "fix bug", status: .running,
worktreePath: "/wt/x", branch: "nucleic/fix-bug", baseSHA: "abc123",
model: "opus", effort: "high", lastSeq: 7, transcriptPath: "/t/x.jsonl",
diffStat: DiffStat(filesChanged: 2, added: 10, removed: 3), ahead: 1, behind: 0,
createdAt: now, updatedAt: now)
}
@Test func projectRoundTrips() async throws {
let store = try GRDBMetadataStore(path: nil)
let project = makeProject()
try await store.saveProject(project)
let loaded = try await store.loadProjects()
#expect(loaded == [project])
}
@Test func nvrsionConfigRoundTrips() async throws {
let store = try GRDBMetadataStore(path: nil)
// Default project: no nvrsion config → nil (off).
let plain = makeProject()
try await store.saveProject(plain)
#expect(try await store.loadProjects().first(where: { $0.id == plain.id })?.nvrsion == nil)
// Project with an explicit nvrsion config round-trips every field (v20).
var project = makeProject()
project.nvrsion = ProjectNvrsion(
enabled: true, trunkBranch: "nucleic/trunk", prelandHook: "nvrsion-check",
keepWarmIdleSeconds: 9)
try await store.saveProject(project)
let loaded = try await store.loadProjects().first(where: { $0.id == project.id })?.nvrsion
#expect(loaded == ProjectNvrsion(
enabled: true, trunkBranch: "nucleic/trunk", prelandHook: "nvrsion-check",
keepWarmIdleSeconds: 9))
// A blank pre-land hook decodes back to nil (→ land immediately), and a missing
// trunk-branch falls back to the default — the tolerant decode (NVRSION §10).
let blankHook = #"{"enabled":true,"prelandHook":" ","keepWarmIdleSeconds":2}"#
let decoded = try JSONDecoder().decode(ProjectNvrsion.self, from: Data(blankHook.utf8))
#expect(decoded.resolvedPrelandHook == nil)
#expect(decoded.trunkBranch == ProjectNvrsion.defaultTrunkBranch)
}
@Test func nvrsionActiveGate() {
// A control-located repo with nvrsion on and the shared container → active.
let controlPath = (ProjectCloner.controlBase as NSString).appendingPathComponent("demo")
var control = Project(
id: .generate(), name: "demo", rootPath: controlPath, defaultBranch: "main",
nvrsion: ProjectNvrsion(enabled: true), createdAt: now)
#expect(control.isNucleicControlled)
#expect(control.usesSharedControlContainer)
#expect(control.nvrsionActive)
// Disabled → not active.
control.nvrsion = ProjectNvrsion(enabled: false)
#expect(!control.nvrsionActive)
// Enabled but per-session containers (no shared container) → not active in v0.
control.nvrsion = ProjectNvrsion(enabled: true)
control.sandbox = ProjectSandbox(enabled: true, perSessionContainers: true)
#expect(!control.usesSharedControlContainer)
#expect(!control.nvrsionActive)
// A non-control repo is never nvrsion-active, even with the flag on.
let outside = Project(
id: .generate(), name: "demo", rootPath: "/repos/demo", defaultBranch: "main",
nvrsion: ProjectNvrsion(enabled: true), createdAt: now)
#expect(!outside.isNucleicControlled)
#expect(!outside.nvrsionActive)
}
@Test func shipDestinationResolutionPrecedence() {
var project = makeProject() // defaultBranch == "main", no autoship override
var session = makeSession(projectID: project.id)
// No overrides, no parentRef → the project root repo's default branch.
session.parentRef = nil
#expect(session.shipDestination(in: project) == GitRef("main"))
// A nested child (parentRef set, no overrides) ships into its parent branch.
session.parentRef = "nucleic/parent"
#expect(session.shipDestination(in: project) == GitRef("nucleic/parent"))
// A project autoship branch overrides the parentRef fallback.
project.autoShipBranch = GitRef("staging")
#expect(session.shipDestination(in: project) == GitRef("staging"))
// A per-session override beats the project setting; blank/whitespace is ignored.
session.shipBranch = " "
#expect(session.shipDestination(in: project) == GitRef("staging"))
session.shipBranch = "release"
#expect(session.shipDestination(in: project) == GitRef("release"))
}
@Test func autoshipDestinationOverridesRoundTrip() async throws {
let store = try GRDBMetadataStore(path: nil)
// Default project: no autoship-branch override → nil (ships to the root repo).
let plain = makeProject()
try await store.saveProject(plain)
#expect(try await store.loadProjects().first?.autoShipBranch == nil)
// Project with an explicit autoship branch, and a session that overrides it.
var project = makeProject()
project.autoShipBranch = GitRef("staging")
try await store.saveProject(project)
#expect(try await store.loadProjects().first(where: { $0.id == project.id })?.autoShipBranch
== GitRef("staging"))
var session = makeSession(projectID: project.id)
session.shipBranch = "release"
try await store.saveSession(session)
let loaded = try await store.loadSession(id: session.id)
#expect(loaded?.shipBranch == "release") // v17 round-trip
#expect(loaded?.shipDestination(in: project) == GitRef("release")) // session override wins
// Clearing the session override falls back to the project's autoship branch.
session.shipBranch = nil
try await store.saveSession(session)
let reloaded = try await store.loadSession(id: session.id)
#expect(reloaded?.shipBranch == nil)
#expect(reloaded?.shipDestination(in: project) == GitRef("staging"))
}
@Test func pullRequestFieldsRoundTrip() async throws {
let store = try GRDBMetadataStore(path: nil)
let project = makeProject()
try await store.saveProject(project)
var session = makeSession(projectID: project.id)
#expect(session.pullRequestURL == nil)
#expect(session.pullRequestNumber == nil)
#expect(session.pullRequestBase == nil)
session.pullRequestURL = "https://github.com/acme/widgets/pull/42"
session.pullRequestNumber = 42
session.pullRequestBase = "main"
try await store.saveSession(session)
let loaded = try await store.loadSession(id: session.id)
#expect(loaded?.pullRequestURL == "https://github.com/acme/widgets/pull/42")
#expect(loaded?.pullRequestNumber == 42)
#expect(loaded?.pullRequestBase == "main")
// Clearing round-trips back to nil too.
session.pullRequestURL = nil
session.pullRequestNumber = nil
session.pullRequestBase = nil
try await store.saveSession(session)
let reloaded = try await store.loadSession(id: session.id)
#expect(reloaded?.pullRequestURL == nil)
#expect(reloaded?.pullRequestNumber == nil)
#expect(reloaded?.pullRequestBase == nil)
}
@Test func sessionUpsertAndLoad() async throws {
let store = try GRDBMetadataStore(path: nil)
let project = makeProject()
try await store.saveProject(project)
var session = makeSession(projectID: project.id)
try await store.saveSession(session)
let loaded = try await store.loadSession(id: session.id)
#expect(loaded == session)
#expect(loaded?.model == "opus")
#expect(loaded?.effort == "high") // v2 migration column
#expect(loaded?.lastTurnDisposition == nil) // v8 migration column, default null
#expect(loaded?.archivedAt == nil) // v16 migration column, default null
#expect(loaded?.spawnedBySessionID == nil) // v25 migration column, default null
#expect(loaded?.rootSpawnedBySessionID == nil) // v26 migration column, default null
#expect(loaded?.orchestraSequence == nil) // v39 migration column, default null
#expect(loaded?.orchestraBatch == nil) // v40 migration column, default null
#expect(loaded?.keywords == nil) // v31 migration column, default null
#expect(loaded?.contextSwitchMuted == false) // v37 migration column, default false
// Upsert: change status + lastSeq + diffstat + disposition + archive stamp.
session.status = .finished
session.lastSeq = 42
session.diffStat = DiffStat(filesChanged: 3, added: 20, removed: 5)
session.lastTurnDisposition = .completed
session.archived = true
session.archivedAt = now.addingTimeInterval(3600)
session.spawnedBySessionID = SessionID(rawValue: "supervisor-1")
session.rootSpawnedBySessionID = SessionID(rawValue: "root-1")
session.orchestraSequence = OrchestraSequencePlacement(
id: "sequence-1", name: "inspect then build", purpose: "ordered handoff",
step: 2, count: 3, dependsOnWorkerID: SessionID(rawValue: "worker-1"))
session.orchestraBatch = OrchestraBatchPlacement(
id: "batch-1", name: "implementation", purpose: "independent edits")
session.keywords = "auth, login, session"
session.contextSwitchMuted = true
try await store.saveSession(session)
let reloaded = try await store.loadSession(id: session.id)
#expect(reloaded?.keywords == "auth, login, session") // v31 round-trip
#expect(reloaded?.status == .finished)
#expect(reloaded?.lastSeq == 42)
#expect(reloaded?.diffStat == DiffStat(filesChanged: 3, added: 20, removed: 5))
#expect(reloaded?.lastTurnDisposition == .completed) // v8 round-trip
#expect(reloaded?.archivedAt == now.addingTimeInterval(3600)) // v16 round-trip
#expect(reloaded?.spawnedBySessionID == SessionID(rawValue: "supervisor-1")) // v25 round-trip
#expect(reloaded?.rootSpawnedBySessionID == SessionID(rawValue: "root-1")) // v26 round-trip
#expect(reloaded?.orchestraSequence == OrchestraSequencePlacement(
id: "sequence-1", name: "inspect then build", purpose: "ordered handoff",
step: 2, count: 3, dependsOnWorkerID: SessionID(rawValue: "worker-1")))
#expect(reloaded?.orchestraBatch == OrchestraBatchPlacement(
id: "batch-1", name: "implementation", purpose: "independent edits"))
#expect(reloaded?.contextSwitchMuted == true) // v37 round-trip
// Still a single row.
#expect(try await store.loadSessions(projectID: project.id).count == 1)
}
@Test func sessionsScopedToProjectAndDeletable() async throws {
let store = try GRDBMetadataStore(path: nil)
let p1 = makeProject()
let p2 = Project(name: "other", rootPath: "/repos/other", defaultBranch: "main", createdAt: now)
try await store.saveProject(p1)
try await store.saveProject(p2)
let s1 = makeSession(projectID: p1.id)
let s2 = makeSession(projectID: p2.id)
try await store.saveSession(s1)
try await store.saveSession(s2)
#expect(try await store.loadSessions(projectID: p1.id).map(\.id) == [s1.id])
try await store.deleteSession(id: s1.id)
#expect(try await store.loadSession(id: s1.id) == nil)
#expect(try await store.loadSession(id: s2.id) != nil)
}
@Test func todoRoundTripsAndDeletes() async throws {
let store = try GRDBMetadataStore(path: nil)
let project = makeProject()
try await store.saveProject(project)
let later = now.addingTimeInterval(60)
let a = Todo(text: "unassigned idea", createdAt: now, updatedAt: now)
let b = Todo(
text: "tagged idea with a much longer body", projectID: project.id, status: .dispatched,
dispatchedSessionID: .generate(), summary: "tagged idea", createdAt: later, updatedAt: later)
try await store.saveTodo(a)
try await store.saveTodo(b)
// Loaded most-recently-updated first.
let loaded = try await store.loadTodos()
#expect(loaded == [b, a])
#expect(loaded.first?.projectID == project.id)
#expect(loaded.first?.status == .dispatched)
#expect(loaded.first?.summary == "tagged idea") // v6 migration column
#expect(loaded.last?.summary == nil)
#expect(loaded.last?.projectID == nil)
// Upsert: same id, mutated fields → still one row.
var updated = a
updated.status = .done
updated.text = "edited"
try await store.saveTodo(updated)
let afterUpsert = try await store.loadTodos()
#expect(afterUpsert.count == 2)
#expect(afterUpsert.first(where: { $0.id == a.id })?.text == "edited")
try await store.deleteTodo(id: b.id)
#expect(try await store.loadTodos().map(\.id) == [a.id])
}
@Test func approvalPendingThenResolved() async throws {
let store = try GRDBMetadataStore(path: nil)
let session = makeSession(projectID: .generate())
let request = ApprovalRequest(
id: .generate(), sessionID: session.id, toolCallID: "tc1", toolName: "Write",
input: .object(["file_path": .string("/a.txt")]), title: "Write /a.txt",
risk: .write, createdAt: now)
try await store.saveApproval(request)
let pending = try await store.pendingApprovals(sessionID: session.id)
#expect(pending.count == 1)
#expect(pending.first?.toolName == "Write")
#expect(pending.first?.input["file_path"]?.stringValue == "/a.txt")
try await store.resolveApproval(ApprovalResolved(
id: request.id, decision: .allow(), decidedBy: "mac-ui", decidedAt: now))
#expect(try await store.pendingApprovals(sessionID: session.id).isEmpty)
}
@Test func persistsToDiskFileAndReopens() async throws {
let dir = (NSTemporaryDirectory() as NSString)
.appendingPathComponent("nucleic-db-\(UUID().uuidString)")
let path = (dir as NSString).appendingPathComponent("nucleic.sqlite")
defer { try? FileManager.default.removeItem(atPath: dir) }
let project = makeProject()
let session = makeSession(projectID: project.id)
do {
let store = try GRDBMetadataStore(path: path)
try await store.saveProject(project)
try await store.saveSession(session)
}
// Reopen a fresh handle on the same file — data survives.
let reopened = try GRDBMetadataStore(path: path)
#expect(try await reopened.loadSession(id: session.id) == session)
}
@Test func controllerPersistsThroughPipeline() async throws {
let store = try GRDBMetadataStore(path: nil)
let projectID = ProjectID.generate()
let dir = (NSTemporaryDirectory() as NSString)
.appendingPathComponent("nucleic-cp-\(UUID().uuidString)")
defer { try? FileManager.default.removeItem(atPath: dir) }
let url = URL(fileURLWithPath: (dir as NSString).appendingPathComponent("t.jsonl"))
let sessionID = SessionID.generate()
let writer = try TranscriptWriter(
url: url,
header: SessionHeader(sessionID: sessionID, backend: .claudeCode, worktree: "/tmp", createdAt: now))
let session = Session(
id: sessionID, projectID: projectID, backend: .claudeCode, title: "run",
transcriptPath: url.path, createdAt: now, updatedAt: now)
let backend = ScriptedBackend { e, _ in
e.emit(.sessionStarted(SessionStarted(
backendSessionID: "be-9", model: "m", cwd: "/tmp", toolNames: [])))
e.emit(.turnCompleted(TurnCompleted(stopReason: "end_turn", usage: nil)))
e.emit(.runFinished(RunFinished(outcome: .completed)))
}
let controller = SessionController(
session: session, backend: backend, transcript: writer,
metadataStore: store, now: { self.now })
await controller.start(prompt: AgentInput(text: "go"))
await controller.join()
let persisted = try await store.loadSession(id: sessionID)
#expect(persisted?.status == .finished)
#expect(persisted?.backendSessionID == "be-9")
#expect((persisted?.lastSeq ?? 0) >= 3)
}
@Test func loadAllSessionsSpansEveryProject() async throws {
let store = try GRDBMetadataStore(path: nil)
let p1 = makeProject()
let p2 = Project(name: "other", rootPath: "/repos/other", defaultBranch: "main", createdAt: now)
try await store.saveProject(p1)
try await store.saveProject(p2)
let s1 = makeSession(projectID: p1.id)
let s2 = makeSession(projectID: p2.id)
try await store.saveSession(s1)
try await store.saveSession(s2)
// One query across both projects — the launch/dashboard fast path.
#expect(Set(try await store.loadAllSessions().map(\.id)) == [s1.id, s2.id])
}
@Test func activityCacheRoundTripsAndUpserts() async throws {
let store = try GRDBMetadataStore(path: nil)
let project = makeProject()
let session = makeSession(projectID: project.id)
try await store.saveProject(project)
try await store.saveSession(session) // cache rows reference a real session (FK)
let day = Calendar.current.startOfDay(for: now)
// Carries both the message and the token (v22) series — round-trips intact. The
// omitted revert epoch defaults to 0 (a never-reverted session / pre-v33 entry).
let entry = ActivityCacheEntry(
lastSeq: 12, messageCount: 3, activityByDay: [day: 3],
tokenCount: 4_200, tokensByDay: [day: 4_200])
try await store.saveActivityCache(session.id, entry)
#expect(try await store.loadActivityCache()[session.id] == entry)
#expect(try await store.loadActivityCache()[session.id]?.revertEpoch == 0)
// Upsert by session id: the cursor + rollup are replaced, not duplicated. The revert
// epoch (v33) rides the row — a revert-then-regrow back to the same lastSeq must not
// read as fresh, so freshness compares both cursors.
let grown = ActivityCacheEntry(
lastSeq: 20, revertEpoch: 1, messageCount: 5, activityByDay: [day: 5],
tokenCount: 9_001, tokensByDay: [day: 9_001])
try await store.saveActivityCache(session.id, grown)
let reloaded = try await store.loadActivityCache()
#expect(reloaded.count == 1)
#expect(reloaded[session.id] == grown)
#expect(reloaded[session.id]?.revertEpoch == 1)
// The cache row cascades away with its session (ON DELETE CASCADE).
try await store.deleteSession(id: session.id)
#expect(try await store.loadActivityCache().isEmpty)
}
@Test func toolSummariesRoundTripAndUpsert() async throws {
let store = try GRDBMetadataStore(path: nil)
let project = makeProject()
let session = makeSession(projectID: project.id)
let other = makeSession(projectID: project.id)
try await store.saveProject(project)
try await store.saveSession(session) // cache rows reference a real session (FK)
try await store.saveSession(other)
let gist = ToolSummaryCacheEntry(toolCallID: "call-1", kind: .gist, line: "ran the tests")
let block = ToolSummaryCacheEntry(
toolCallID: "call-1", kind: .block, line: "Ran the test suite",
inputs: "call-1\ncall-2")
try await store.saveToolSummaries([gist, block], sessionID: session.id)
// A block line and a gist share a `toolCallID` (the block is keyed by its first call),
// so `kind` is part of the key — both rows survive.
let loaded = try await store.loadToolSummaries(sessionID: session.id)
#expect(Set(loaded.map(\.kind)) == [.gist, .block])
#expect(loaded.first { $0.kind == .block } == block)
#expect(loaded.first { $0.kind == .gist } == gist)
// Scoped per session: another chat's lines are never served here.
#expect(try await store.loadToolSummaries(sessionID: other.id).isEmpty)
// Re-merging a grown block replaces the line in place (upsert on the same key) and
// carries the wider input cursor, rather than duplicating the row.
let regrown = ToolSummaryCacheEntry(
toolCallID: "call-1", kind: .block, line: "Ran the tests, then linted",
inputs: "call-1\ncall-2\ncall-3")
try await store.saveToolSummaries([regrown], sessionID: session.id)
let after = try await store.loadToolSummaries(sessionID: session.id)
#expect(after.count == 2)
#expect(after.first { $0.kind == .block } == regrown)
// Pure cache — it cascades away with its session (ON DELETE CASCADE).
try await store.deleteSession(id: session.id)
#expect(try await store.loadToolSummaries(sessionID: session.id).isEmpty)
}
// MARK: - Session transfer (v23)
@Test func movedColumnsRoundTrip() async throws {
let store = try GRDBMetadataStore(path: nil)
let project = makeProject()
try await store.saveProject(project)
var session = makeSession(projectID: project.id)
try await store.saveSession(session)
#expect(try await store.loadSession(id: session.id)?.movedToDeviceID == nil) // v23 default null
// Tombstone in one write: archived + moved columns.
try await store.tombstoneSession(id: session.id, movedToDeviceID: "host-B", at: now.addingTimeInterval(60))
let tombstoned = try await store.loadSession(id: session.id)
#expect(tombstoned?.archived == true)
#expect(tombstoned?.archivedAt == now.addingTimeInterval(60))
#expect(tombstoned?.movedToDeviceID == "host-B")
#expect(tombstoned?.movedAt == now.addingTimeInterval(60))
// A moved session still round-trips its columns through the normal save path.
session.movedToDeviceID = "host-C"
session.movedAt = now
session.archived = true
try await store.saveSession(session)
#expect(try await store.loadSession(id: session.id)?.movedToDeviceID == "host-C")
}
@Test func transferLockLifecycle() async throws {
let store = try GRDBMetadataStore(path: nil)
let sid = SessionID.generate()
let lock = SessionTransferLock(
transferID: "xfer-1", sessionID: sid, direction: .outbound,
peerDeviceID: "host-B", state: .offering, createdAt: now, updatedAt: now)
try await store.beginTransfer(lock)
#expect(try await store.transfer(transferID: "xfer-1")?.state == .offering)
#expect(try await store.activeTransfer(sessionID: sid)?.transferID == "xfer-1")
#expect(try await store.allActiveTransfers().count == 1)
// Advance through the 2-phase-commit states.
try await store.updateTransferState(transferID: "xfer-1", to: .tombstoned, at: now.addingTimeInterval(1))
#expect(try await store.transfer(transferID: "xfer-1")?.state == .tombstoned)
#expect(try await store.activeTransfer(sessionID: sid)?.state == .tombstoned) // still active
// A terminal state drops it from the active set (but the row can persist for audit).
try await store.updateTransferState(transferID: "xfer-1", to: .committed, at: now.addingTimeInterval(2))
#expect(try await store.activeTransfer(sessionID: sid) == nil)
#expect(try await store.allActiveTransfers().isEmpty)
try await store.clearTransfer(transferID: "xfer-1")
#expect(try await store.transfer(transferID: "xfer-1") == nil)
}
@Test func transferLockRejectsSecondActiveForSameSession() async throws {
// The UNIQUE-while-active guard needs real FK/constraint enforcement — use a disk path.
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("nucleic-xfer-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: dir) }
let store = try GRDBMetadataStore(path: dir.appendingPathComponent("db.sqlite").path)
let sid = SessionID.generate()
try await store.beginTransfer(SessionTransferLock(
transferID: "a", sessionID: sid, direction: .outbound, peerDeviceID: "B",
state: .offering, createdAt: now, updatedAt: now))
// A second active transfer for the SAME session is rejected.
await #expect(throws: SessionTransferConflict(sessionID: sid)) {
try await store.beginTransfer(SessionTransferLock(
transferID: "b", sessionID: sid, direction: .outbound, peerDeviceID: "C",
state: .offering, createdAt: now, updatedAt: now))
}
// Once the first completes (terminal), a new transfer for the session is allowed.
try await store.updateTransferState(transferID: "a", to: .committed, at: now)
try await store.beginTransfer(SessionTransferLock(
transferID: "c", sessionID: sid, direction: .outbound, peerDeviceID: "D",
state: .offering, createdAt: now, updatedAt: now))
#expect(try await store.activeTransfer(sessionID: sid)?.transferID == "c")
}
@Test func activateTransferredSessionIsAtomicAndIdempotent() async throws {
let store = try GRDBMetadataStore(path: nil)
let project = makeProject()
try await store.saveProject(project)
let session = makeSession(projectID: project.id)
let sid = session.id
try await store.beginTransfer(SessionTransferLock(
transferID: "x", sessionID: sid, direction: .inbound, peerDeviceID: "A",
state: .ready, createdAt: now, updatedAt: now))
// Activate: inserts the session row AND flips the lock to activated, atomically.
try await store.activateTransferredSession(session, transferID: "x")
#expect(try await store.loadSession(id: sid) != nil)
#expect(try await store.transfer(transferID: "x")?.state == .activated)
#expect(try await store.activeTransfer(sessionID: sid) == nil) // no longer active
// A re-delivered commit is a no-op that still succeeds (idempotent).
try await store.activateTransferredSession(session, transferID: "x")
#expect(try await store.loadSessions(projectID: project.id).count == 1)
}
}