Move tool audit persistence into shared layers

This commit is contained in:
Kaede Akino committed 2026-09-23 21:21:28 +08:00
1 parent 487cfa5a35
commit e4f00922ce
11 files changed
+440 -145

No files matched your search

@@ -0,0 +1,110 @@
import Foundation
import SwiftData
import TermindApplication
import TermindDomain
import TermindPersistence
import Testing
@Suite
@MainActor
struct ToolAuditRepositoryTests {
@Test
func inMemoryRepositoryPreservesAuditBehavior() throws {
let repository = InMemoryToolAuditRepository()
let recoveryRepository = InMemoryToolAuditRepository()
try exerciseStorageBehavior(repository: repository, freshRepository: { repository })
try exerciseRecoveryBehavior(repository: recoveryRepository, freshRepository: { recoveryRepository })
}
@Test
func swiftDataRepositoryPersistsAuditBehavior() throws {
let container = try TermindPersistence.makeContainer(inMemory: true)
let repository = SwiftDataToolAuditRepository(context: ModelContext(container))
try exerciseStorageBehavior(
repository: repository,
freshRepository: { SwiftDataToolAuditRepository(context: ModelContext(container)) }
)
try exerciseRecoveryBehavior(
repository: SwiftDataToolAuditRepository(context: ModelContext(container)),
freshRepository: { SwiftDataToolAuditRepository(context: ModelContext(container)) }
)
}
private func exerciseStorageBehavior(
repository: any ToolAuditRepository,
freshRepository: () -> any ToolAuditRepository
) throws {
let oldest = call(summary: "oldest", startedAt: 10)
let newest = call(summary: "newest", startedAt: 30)
var middle = call(summary: "middle", startedAt: 20)
try repository.insert(oldest, limit: 2)
try repository.insert(middle, limit: 2)
try repository.insert(newest, limit: 2)
let reloaded = freshRepository()
#expect(try reloaded.entries().map(\.id) == [newest.id, middle.id])
#expect(try reloaded.value(id: oldest.id) == nil)
middle.summary = "updated"
middle.status = .ok
middle.finishedAt = Date(timeIntervalSince1970: 25)
try reloaded.update(middle)
let storedMiddle = try freshRepository().value(id: middle.id)
let updated = try #require(storedMiddle)
#expect(updated == middle)
try reloaded.clear()
#expect(try freshRepository().entries().isEmpty)
}
private func exerciseRecoveryBehavior(
repository: any ToolAuditRepository,
freshRepository: () -> any ToolAuditRepository
) throws {
let running = call(summary: "running", status: .running, startedAt: 100)
let okFinishedAt = Date(timeIntervalSince1970: 210)
let errorFinishedAt = Date(timeIntervalSince1970: 220)
let deniedFinishedAt = Date(timeIntervalSince1970: 230)
let ok = call(summary: "ok", status: .ok, startedAt: 200, finishedAt: okFinishedAt)
let error = call(summary: "error", status: .error, startedAt: 201, finishedAt: errorFinishedAt)
let denied = call(summary: "denied", status: .denied, startedAt: 202, finishedAt: deniedFinishedAt)
for value in [running, ok, error, denied] {
try repository.insert(value, limit: 10)
}
try repository.recoverInterruptedCalls()
let reloaded = freshRepository()
let storedRunning = try reloaded.value(id: running.id)
let recoveredRunning = try #require(storedRunning)
#expect(recoveredRunning.status == .error)
#expect(recoveredRunning.finishedAt != nil)
let storedOK = try reloaded.value(id: ok.id)
let unchangedOK = try #require(storedOK)
#expect(unchangedOK == ok)
let storedError = try reloaded.value(id: error.id)
let unchangedError = try #require(storedError)
#expect(unchangedError == error)
let storedDenied = try reloaded.value(id: denied.id)
let unchangedDenied = try #require(storedDenied)
#expect(unchangedDenied == denied)
}
private func call(
summary: String,
status: ExternalToolCall.Status = .running,
startedAt: TimeInterval,
finishedAt: Date? = nil
) -> ExternalToolCall {
ExternalToolCall(
runID: UUID(), clientName: "Client", toolName: "list_hosts",
summary: summary, risk: .readOnly, status: status,
startedAt: Date(timeIntervalSince1970: startedAt), finishedAt: finishedAt
)
}
}
@@ -2,7 +2,7 @@ import Foundation
import TermindDomain
@MainActor
protocol ToolAuditRepository: AnyObject {
public protocol ToolAuditRepository: AnyObject {
func entries() throws -> [ExternalToolCall]
func clear() throws
func insert(_ value: ExternalToolCall, limit: Int) throws
@@ -12,34 +12,34 @@ protocol ToolAuditRepository: AnyObject {
}
@MainActor
final class InMemoryToolAuditRepository: ToolAuditRepository {
public final class InMemoryToolAuditRepository: ToolAuditRepository {
private var values: [ExternalToolCall]
init(entries: [ExternalToolCall] = []) { values = entries }
public init(entries: [ExternalToolCall] = []) { values = entries }
func entries() throws -> [ExternalToolCall] {
public func entries() throws -> [ExternalToolCall] {
values.sorted { $0.startedAt > $1.startedAt }
}
func clear() throws { values.removeAll() }
public func clear() throws { values.removeAll() }
func insert(_ value: ExternalToolCall, limit: Int) throws {
public func insert(_ value: ExternalToolCall, limit: Int) throws {
values.append(value)
values.sort { $0.startedAt > $1.startedAt }
if values.count > limit { values.removeLast(values.count - limit) }
}
func update(_ value: ExternalToolCall) throws {
public func update(_ value: ExternalToolCall) throws {
if let index = values.firstIndex(where: { $0.id == value.id }) {
values[index] = value
}
}
func value(id: ExternalToolCall.ID) throws -> ExternalToolCall? {
public func value(id: ExternalToolCall.ID) throws -> ExternalToolCall? {
values.first { $0.id == id }
}
func recoverInterruptedCalls() throws {
public func recoverInterruptedCalls() throws {
for index in values.indices where values[index].status == .running {
values[index].status = .error
values[index].finishedAt = .now
@@ -0,0 +1,51 @@
import Foundation
import SwiftData
import TermindDomain
@Model
public final class PersistentExternalToolCall {
@Attribute(.unique) public var id: UUID = UUID()
public var runID: UUID = UUID()
public var clientName = ""
public var toolName = ""
public var hostID: UUID?
public var hostDisplayName: String?
public var summary = ""
public var risk = ExternalToolCall.Risk.readOnly.rawValue
public var status = ExternalToolCall.Status.running.rawValue
public var exitCode: Int?
public var bytesRead: Int?
public var bytesWritten: Int?
public var startedAt = Date(timeIntervalSince1970: 0)
public var finishedAt: Date?
public init(_ value: ExternalToolCall) { apply(value) }
public func apply(_ value: ExternalToolCall) {
id = value.id
runID = value.runID
clientName = value.clientName
toolName = value.toolName
hostID = value.hostID
hostDisplayName = value.hostDisplayName
summary = value.summary
risk = value.risk.rawValue
status = value.status.rawValue
exitCode = value.exitCode
bytesRead = value.bytesRead
bytesWritten = value.bytesWritten
startedAt = value.startedAt
finishedAt = value.finishedAt
}
public var value: ExternalToolCall {
ExternalToolCall(
id: id, runID: runID, clientName: clientName, toolName: toolName,
hostID: hostID, hostDisplayName: hostDisplayName, summary: summary,
risk: ExternalToolCall.Risk(rawValue: risk) ?? .readOnly,
status: ExternalToolCall.Status(rawValue: status) ?? .error,
exitCode: exitCode, bytesRead: bytesRead, bytesWritten: bytesWritten,
startedAt: startedAt, finishedAt: finishedAt
)
}
}
@@ -1,51 +1,67 @@
import Foundation
import SwiftData
import TermindApplication
import TermindDomain
@Model
public final class PersistentExternalToolCall {
@Attribute(.unique) public var id: UUID = UUID()
public var runID: UUID = UUID()
public var clientName = ""
public var toolName = ""
public var hostID: UUID?
public var hostDisplayName: String?
public var summary = ""
public var risk = ExternalToolCall.Risk.readOnly.rawValue
public var status = ExternalToolCall.Status.running.rawValue
public var exitCode: Int?
public var bytesRead: Int?
public var bytesWritten: Int?
public var startedAt = Date(timeIntervalSince1970: 0)
public var finishedAt: Date?
@MainActor
public final class SwiftDataToolAuditRepository: ToolAuditRepository {
private let context: ModelContext
public init(_ value: ExternalToolCall) { apply(value) }
public init(context: ModelContext) { self.context = context }
public func apply(_ value: ExternalToolCall) {
id = value.id
runID = value.runID
clientName = value.clientName
toolName = value.toolName
hostID = value.hostID
hostDisplayName = value.hostDisplayName
summary = value.summary
risk = value.risk.rawValue
status = value.status.rawValue
exitCode = value.exitCode
bytesRead = value.bytesRead
bytesWritten = value.bytesWritten
startedAt = value.startedAt
finishedAt = value.finishedAt
public func entries() throws -> [ExternalToolCall] {
try context.fetch(
FetchDescriptor<PersistentExternalToolCall>(
sortBy: [SortDescriptor(\.startedAt, order: .reverse)]
)
).map(\.value)
}
public var value: ExternalToolCall {
ExternalToolCall(
id: id, runID: runID, clientName: clientName, toolName: toolName,
hostID: hostID, hostDisplayName: hostDisplayName, summary: summary,
risk: ExternalToolCall.Risk(rawValue: risk) ?? .readOnly,
status: ExternalToolCall.Status(rawValue: status) ?? .error,
exitCode: exitCode, bytesRead: bytesRead, bytesWritten: bytesWritten,
startedAt: startedAt, finishedAt: finishedAt
public func clear() throws {
try context.commit { try context.delete(model: PersistentExternalToolCall.self) }
}
public func insert(_ value: ExternalToolCall, limit: Int) throws {
try context.commit {
var descriptor = FetchDescriptor<PersistentExternalToolCall>(
sortBy: [SortDescriptor(\.startedAt, order: .reverse)]
)
descriptor.fetchOffset = max(0, limit - 1)
for old in try context.fetch(descriptor) { context.delete(old) }
context.insert(PersistentExternalToolCall(value))
}
}
public func update(_ value: ExternalToolCall) throws {
try context.commit {
let id = value.id
var descriptor = FetchDescriptor<PersistentExternalToolCall>(
predicate: #Predicate { $0.id == id }
)
descriptor.fetchLimit = 1
try context.fetch(descriptor).first?.apply(value)
}
}
public func value(id: ExternalToolCall.ID) throws -> ExternalToolCall? {
var descriptor = FetchDescriptor<PersistentExternalToolCall>(
predicate: #Predicate { $0.id == id }
)
descriptor.fetchLimit = 1
return try context.fetch(descriptor).first?.value
}
public func recoverInterruptedCalls() throws {
let running = ExternalToolCall.Status.running.rawValue
try context.commit {
let rows = try context.fetch(
FetchDescriptor<PersistentExternalToolCall>(
predicate: #Predicate { $0.status == running }
))
for row in rows {
row.status = ExternalToolCall.Status.error.rawValue
row.finishedAt = .now
}
}
}
}
+16
View File
@@ -0,0 +1,16 @@
#!/bin/bash
set -euo pipefail
script_dir=$(cd "$(dirname "$0")" && pwd)
developer_dir=$(xcode-select -p)
if swift_executable=$(xcrun --find swift 2>/dev/null); then
:
else
swift_executable="$developer_dir/Toolchains/XcodeDefault.xctoolchain/usr/bin/swift"
fi
swift_host="$(dirname "$(dirname "$swift_executable")")/lib/swift/host"
macos_sdk="$developer_dir/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk"
exec "$swift_executable" -sdk "$macos_sdk" -I "$swift_host" -L "$swift_host" \
-lSwiftParser -lSwiftSyntax -Xlinker -rpath -Xlinker "$swift_host" \
"$script_dir/swiftdata-repository-declarations.swift" "$1"
@@ -0,0 +1,76 @@
import Foundation
import SwiftParser
import SwiftSyntax
final class SwiftDataRepositoryDeclarationVisitor: SyntaxVisitor {
private static let namePattern = try! NSRegularExpression(
pattern: #"^SwiftData[A-Za-z0-9_]*Repository$"#
)
private(set) var containsDeclaration = false
init() {
super.init(viewMode: .sourceAccurate)
}
override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind {
record(node.name)
return .visitChildren
}
override func visit(_ node: StructDeclSyntax) -> SyntaxVisitorContinueKind {
record(node.name)
return .visitChildren
}
override func visit(_ node: EnumDeclSyntax) -> SyntaxVisitorContinueKind {
record(node.name)
return .visitChildren
}
override func visit(_ node: ActorDeclSyntax) -> SyntaxVisitorContinueKind {
record(node.name)
return .visitChildren
}
override func visit(_ node: ProtocolDeclSyntax) -> SyntaxVisitorContinueKind {
record(node.name)
return .visitChildren
}
override func visit(_ node: TypeAliasDeclSyntax) -> SyntaxVisitorContinueKind {
record(node.name)
return .visitChildren
}
private func record(_ token: TokenSyntax) {
var name = token.text
if name.count >= 2, name.first == "`", name.last == "`" {
name.removeFirst()
name.removeLast()
}
let range = NSRange(name.startIndex..<name.endIndex, in: name)
if Self.namePattern.firstMatch(in: name, range: range) != nil {
containsDeclaration = true
}
}
}
guard CommandLine.arguments.count == 2 else {
throw CocoaError(.fileReadInvalidFileName)
}
let requestURL = URL(fileURLWithPath: CommandLine.arguments[1])
let request = try JSONDecoder().decode([String].self, from: Data(contentsOf: requestURL))
let results = try request.map { source in
let tree = Parser.parse(source: source)
guard !tree.hasError else {
throw CocoaError(.fileReadCorruptFile)
}
let visitor = SwiftDataRepositoryDeclarationVisitor()
visitor.walk(tree)
return visitor.containsDeclaration
}
let output = try JSONEncoder().encode(results)
try FileHandle.standardOutput.write(contentsOf: output)
try FileHandle.standardOutput.write(contentsOf: Data("\n".utf8))
+1 -1
View File
@@ -98,7 +98,7 @@ struct TermindApp: App {
currentDeviceID: connectionHistoryDeviceID,
currentDeviceName: Self.computerName)
let externalToolCalls = ToolAuditModel(
modelContext: container.mainContext,
repository: SwiftDataToolAuditRepository(context: container.mainContext),
hostName: { [weak hosts] id in hosts?.host(id)?.name }
)
let proAccess: ProAccessController =
@@ -1,5 +1,6 @@
import AgentKit
import Foundation
import TermindApplication
import TermindDomain
import TermindPresentation
@@ -1,14 +0,0 @@
import SwiftData
@MainActor
extension ModelContext {
func commit(_ operation: () throws -> Void) throws {
do {
try operation()
try save()
} catch {
rollback()
throw error
}
}
}
@@ -1,81 +0,0 @@
import Foundation
import SwiftData
import TermindDomain
import TermindPersistence
import struct TermindDomain.Host
@MainActor
final class SwiftDataToolAuditRepository: ToolAuditRepository {
private let context: ModelContext
init(context: ModelContext) { self.context = context }
func entries() throws -> [ExternalToolCall] {
try context.fetch(
FetchDescriptor<PersistentExternalToolCall>(
sortBy: [SortDescriptor(\.startedAt, order: .reverse)]
)
).map(\.value)
}
func clear() throws {
try context.commit { try context.delete(model: PersistentExternalToolCall.self) }
}
func insert(_ value: ExternalToolCall, limit: Int) throws {
try context.commit {
var descriptor = FetchDescriptor<PersistentExternalToolCall>(
sortBy: [SortDescriptor(\.startedAt, order: .reverse)]
)
descriptor.fetchOffset = max(0, limit - 1)
for old in try context.fetch(descriptor) { context.delete(old) }
context.insert(PersistentExternalToolCall(value))
}
}
func update(_ value: ExternalToolCall) throws {
try context.commit {
let id = value.id
var descriptor = FetchDescriptor<PersistentExternalToolCall>(
predicate: #Predicate { $0.id == id }
)
descriptor.fetchLimit = 1
try context.fetch(descriptor).first?.apply(value)
}
}
func value(id: ExternalToolCall.ID) throws -> ExternalToolCall? {
var descriptor = FetchDescriptor<PersistentExternalToolCall>(
predicate: #Predicate { $0.id == id }
)
descriptor.fetchLimit = 1
return try context.fetch(descriptor).first?.value
}
func recoverInterruptedCalls() throws {
let running = ExternalToolCall.Status.running.rawValue
try context.commit {
let rows = try context.fetch(
FetchDescriptor<PersistentExternalToolCall>(
predicate: #Predicate { $0.status == running }
))
for row in rows {
row.status = ExternalToolCall.Status.error.rawValue
row.finishedAt = .now
}
}
}
}
extension ToolAuditModel {
convenience init(
modelContext: ModelContext,
hostName: @escaping (Host.ID) -> String? = { _ in nil }
) {
self.init(
repository: SwiftDataToolAuditRepository(context: modelContext),
hostName: hostName
)
}
}
+120
View File
@@ -249,6 +249,78 @@ struct ArchitectureBoundaryTests {
#expect(!models.isEmpty)
}
@Test
func swiftDataRepositoriesHaveOneSharedOwner() throws {
let forbiddenFiles = try [
"", "../TFI", "../Frameworks/TermindDomain", "../Frameworks/TermindApplication",
"../Frameworks/TermindInfrastructure", "../Frameworks/TermindRemoteInfrastructure",
"../Frameworks/TermindPresentation", "../TermindMCPBridge", "../Benchmarks/PortForwarding",
"../TFIWidgets", "../WorkspaceActivitySupport",
].flatMap {
try swiftFiles(in: $0)
}
let persistenceFiles = try swiftFiles(in: "../Frameworks/TermindPersistence")
let files = forbiddenFiles + persistenceFiles
let sources = try files.map { try String(contentsOf: $0, encoding: .utf8) }
let declarations = try swiftDataRepositoryDeclarations(in: sources)
for (file, containsDeclaration) in zip(forbiddenFiles, declarations.prefix(forbiddenFiles.count)) {
#expect(
!containsDeclaration,
"\(file.path) declares a SwiftData repository outside TermindPersistence"
)
}
#expect(declarations.dropFirst(forbiddenFiles.count).contains(true))
}
@Test
func recognizesSwiftDataRepositoryDeclarations() throws {
let sources = [
"final class SwiftDataFooRepository {}",
"@MainActor public final class SwiftDataFooRepository {}",
"@MainActor\nfinal class SwiftDataFooRepository {}",
"@MainActor @Observable final class SwiftDataFooRepository {}",
"@available(macOS 15, *) final class SwiftDataFooRepository {}",
"actor SwiftDataFooRepository {}",
"struct SwiftDataFooRepository {}",
"typealias SwiftDataFooRepository = String",
"final class `SwiftDataFooRepository` {}",
"// class SwiftDataDecoyRepository {}\nfinal class SwiftDataFooRepository {}",
"let text = \"class SwiftDataDecoyRepository {}\"\nfinal class SwiftDataFooRepository {}",
"final class /* shared persistence owner */ SwiftDataFooRepository {}",
#"let text = "\({ () -> String in class SwiftDataFooRepository {}; return "" }())""#,
]
let declarations = try swiftDataRepositoryDeclarations(in: sources)
for (source, containsDeclaration) in zip(sources, declarations) {
#expect(containsDeclaration, "Expected a declaration in: \(source)")
}
}
@Test
func ignoresSwiftDataRepositoryReferencesAndTextualNearMatches() throws {
let sources = [
"let repository = SwiftDataFooRepository()",
"let repository: SwiftDataFooRepository",
"final class NotSwiftDataFooRepository {}",
"final class SwiftDataFooRepositoryFactory {}",
"// final class SwiftDataFooRepository {}",
"/* struct SwiftDataFooRepository {} */",
"let text = \"actor SwiftDataFooRepository {}\"",
"/// protocol SwiftDataFooRepository {}",
"/* outer /* enum SwiftDataFooRepository {} */ still outer */",
##"""
let text = """
class SwiftDataFooRepository {}
"""
"""##,
##"let text = #"typealias SwiftDataFooRepository = String"#"##,
]
let declarations = try swiftDataRepositoryDeclarations(in: sources)
for (source, containsDeclaration) in zip(sources, declarations) {
#expect(!containsDeclaration, "Expected no declaration in: \(source)")
}
}
private var sourceRoot: URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
@@ -297,6 +369,54 @@ struct ArchitectureBoundaryTests {
}
}
private func swiftDataRepositoryDeclarations(in sources: [String]) throws -> [Bool] {
let request = FileManager.default.temporaryDirectory.appending(
path: "termind-swiftdata-repository-declarations-\(UUID().uuidString).json"
)
try JSONEncoder().encode(sources).write(to: request, options: .atomic)
defer { try? FileManager.default.removeItem(at: request) }
let repositoryRoot = sourceRoot.deletingLastPathComponent().standardizedFileURL
let executable = repositoryRoot.appending(path: "Scripts/swiftdata-repository-declarations.sh")
let process = Process()
let standardOutput = Pipe()
let standardError = Pipe()
process.executableURL = executable
process.arguments = [request.path]
process.standardOutput = standardOutput
process.standardError = standardError
try process.run()
let output = standardOutput.fileHandleForReading.readDataToEndOfFile()
let errorOutput = standardError.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
let errorMessage = String(decoding: errorOutput, as: UTF8.self)
guard process.terminationReason == .exit, process.terminationStatus == 0 else {
throw SwiftDataRepositoryDeclarationToolError(
message: "Parser exited with status \(process.terminationStatus): \(errorMessage)"
)
}
guard errorOutput.isEmpty else {
throw SwiftDataRepositoryDeclarationToolError(
message: "Parser wrote to stderr: \(errorMessage)"
)
}
let declarations = try JSONDecoder().decode([Bool].self, from: output)
guard declarations.count == sources.count else {
throw SwiftDataRepositoryDeclarationToolError(
message: "Parser returned \(declarations.count) results for \(sources.count) sources"
)
}
return declarations
}
private struct SwiftDataRepositoryDeclarationToolError: LocalizedError {
let message: String
var errorDescription: String? { message }
}
private func swiftFiles(in directory: String) throws -> [URL] {
let root = sourceRoot.appending(path: directory, directoryHint: .isDirectory)
let keys: [URLResourceKey] = [.isRegularFileKey]