542 lines
27 KiB
Swift
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)
|
|
}
|
|
}
|