「企业级 ArtifactStore(任务化/热插拔/可观测/可恢复)」完整代码集合:包含我们上面讨论到的全部能力:
✅ ArtifactStoreTool 协议(可替换/可注入)
✅ FileArtifactStoreAdvanced 文件实现(落盘)
✅ Booting 语义(boot record,支持任务恢复)
✅ TTL 过期清理
✅ 去重(hash dedupe)
✅ Index(SQLite)(可查询、可迁移、可扩展)
✅ refcount(payload 引用计数,删除/过期都能安全回收 payload)
✅ gzip(标准 gzip header+footer)
✅ AES-GCM(CryptoKit)(combined)
✅ GC 兜底(清理 index 异常/历史遗留)
✅ metrics(save/load/cleanup/gc 的耗时/字节数/删除数/去重命中)
✅ Key 规范(TaskId 目录 + StepId Key)
你只需要把下面这些文件照着放进项目即可(例如 AIChats/Sources/AIChats/Infra/ArtifactStore/)。
0) 目录结构
Infra/ArtifactStore/
├── ArtifactModels.swift
├── ArtifactHash.swift
├── ArtifactKey.swift
├── ArtifactMetrics.swift
├── ArtifactStoreTool.swift
├── ArtifactCompression.swift
├── ArtifactCrypto.swift
├── KeychainKeyProvider.swift
├── ArtifactIndexSQLite.swift
└── FileArtifactStoreAdvanced.swift1) ArtifactModels.swift
import Foundation
public enum ArtifactKind: String, Codable, Sendable {
case result
case eventLog
case snapshot
case toolOutput
case debug
}
public struct ArtifactPolicy: Sendable {
public var ttl: TimeInterval?
public var enableDedupe: Bool
public var schemaVersion: Int
public var tags: [String: String]
public init(
ttl: TimeInterval? = nil,
enableDedupe: Bool = true,
schemaVersion: Int = 1,
tags: [String: String] = [:]
) {
self.ttl = ttl
self.enableDedupe = enableDedupe
self.schemaVersion = schemaVersion
self.tags = tags
}
}
public struct ArtifactMeta: Codable, Sendable {
public var id: String
public var taskId: String
public var kind: ArtifactKind
public var schemaVersion: Int
public var createdAt: Date
public var expiresAt: Date?
public var payloadHash: String
public var payloadRelativePath: String
public var metaRelativePath: String
public var tags: [String: String]
}
public struct SavedArtifact: Sendable {
public var meta: ArtifactMeta
public var metaURL: URL
public var payloadURL: URL
public var deduped: Bool
}
public struct BootRecord: Codable, Sendable {
public enum Status: String, Codable, Sendable {
case booting
case finished
case failed
case cancelled
}
public var taskId: String
public var taskType: String
public var startedAt: Date
public var updatedAt: Date
public var status: Status
public var note: String?
public var tags: [String: String]
}2) ArtifactHash.swift
import Foundation
import CryptoKit
public enum ArtifactHash {
public static func sha256Hex(_ data: Data) -> String {
let digest = SHA256.hash(data: data)
return digest.map { String(format: "%02x", $0) }.joined()
}
}
public enum PathSanitizer {
public static func safePathComponent(_ raw: String) -> String {
let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_."))
return raw.unicodeScalars.map { allowed.contains($0) ? Character($0) : "_" }
.reduce(into: "") { $0.append($1) }
}
}3) ArtifactKey.swift(Key 规范:包含 StepId)
import Foundation
public struct ArtifactKey: Sendable, Hashable, CustomStringConvertible {
public let stepId: String
public let name: String
public let domain: String?
public init(stepId: String, name: String, domain: String? = nil) {
self.stepId = stepId
self.name = name
self.domain = domain
}
public var normalized: String {
let safeStep = PathSanitizer.safePathComponent(stepId)
let safeName = PathSanitizer.safePathComponent(name)
if let domain, !domain.isEmpty {
let safeDomain = PathSanitizer.safePathComponent(domain)
return "\(safeDomain).step.\(safeStep).\(safeName)"
} else {
return "step.\(safeStep).\(safeName)"
}
}
public var description: String { normalized }
}
public enum ArtifactKeyFactory {
public static func numeric(step: Int, width: Int = 4, name: String, domain: String? = nil) -> ArtifactKey {
let stepId = String(format: "%0\(width)d", step)
return ArtifactKey(stepId: stepId, name: name, domain: domain)
}
public static func tool(toolId: String, index: Int, name: String) -> ArtifactKey {
ArtifactKey(stepId: "\(toolId).\(index)", name: name, domain: "tool")
}
public static func system(name: String) -> ArtifactKey {
ArtifactKey(stepId: "system", name: name, domain: "system")
}
}4) ArtifactMetrics.swift(可观测)
import Foundation
public enum ArtifactOperation: String, Sendable {
case save
case load
case cleanupExpired
case garbageCollect
}
public struct ArtifactMetrics: Sendable {
public let op: ArtifactOperation
public let taskId: String?
public let kind: ArtifactKind?
public let key: String?
public let startedAt: Date
public let durationMs: Double
public let bytesWritten: Int
public let bytesRead: Int
public let dedupeHit: Bool
public let filesDeleted: Int
public init(
op: ArtifactOperation,
taskId: String? = nil,
kind: ArtifactKind? = nil,
key: String? = nil,
startedAt: Date,
durationMs: Double,
bytesWritten: Int = 0,
bytesRead: Int = 0,
dedupeHit: Bool = false,
filesDeleted: Int = 0
) {
self.op = op
self.taskId = taskId
self.kind = kind
self.key = key
self.startedAt = startedAt
self.durationMs = durationMs
self.bytesWritten = bytesWritten
self.bytesRead = bytesRead
self.dedupeHit = dedupeHit
self.filesDeleted = filesDeleted
}
}
public protocol ArtifactMetricsSink: Sendable {
func emit(_ m: ArtifactMetrics)
}
public struct ConsoleMetricsSink: ArtifactMetricsSink {
public init() {}
public func emit(_ m: ArtifactMetrics) {
print("[ArtifactMetrics] op=\(m.op.rawValue) ms=\(String(format:"%.2f", m.durationMs)) " +
"task=\(m.taskId ?? "-") kind=\(m.kind?.rawValue ?? "-") key=\(m.key ?? "-") " +
"w=\(m.bytesWritten) r=\(m.bytesRead) dedupe=\(m.dedupeHit) deleted=\(m.filesDeleted)")
}
}
public enum PerfClock {
public static func now() -> UInt64 { DispatchTime.now().uptimeNanoseconds }
public static func ms(since start: UInt64) -> Double {
let end = DispatchTime.now().uptimeNanoseconds
return Double(end - start) / 1_000_000.0
}
}5) ArtifactStoreTool.swift(协议)
import Foundation
public protocol ArtifactStoreTool: Sendable {
// Booting lifecycle
func bootStart(taskId: String, taskType: String, tags: [String: String]) async throws
func bootFinish(taskId: String, status: BootRecord.Status, note: String?) async throws
func listBootingTasks() async throws -> [BootRecord]
// Artifact CRUD
func saveCodableArtifact<T: Codable>(
_ payload: T,
taskId: String,
kind: ArtifactKind,
key: String,
policy: ArtifactPolicy
) async throws -> SavedArtifact
func loadCodableArtifact<T: Codable>(
_ type: T.Type,
taskId: String,
kind: ArtifactKind,
key: String
) async throws -> T
func loadArtifactMeta(taskId: String, kind: ArtifactKind, key: String) async throws -> ArtifactMeta
func deleteArtifact(taskId: String, kind: ArtifactKind, key: String) async throws
// Maintenance
func cleanupExpiredArtifacts() async throws
func garbageCollectPayloads() async throws
func storeRootURL() async -> URL
}6) ArtifactCompression.swift(标准 gzip)
import Foundation
import Compression
public enum ArtifactCompression {
public enum CompressionError: Error {
case failed
case invalidGzip
}
public static func gzipCompress(_ data: Data) throws -> Data {
let deflated = try deflate(data)
let crc = crc32(data)
let isize = UInt32(data.count & 0xFFFF_FFFF)
var out = Data()
out.reserveCapacity(10 + deflated.count + 8)
out.append(contentsOf: [
0x1f, 0x8b, // ID1, ID2
0x08, // CM = deflate
0x00, // FLG
0x00, 0x00, 0x00, 0x00, // MTIME
0x00, // XFL
0xff // OS
])
out.append(deflated)
out.append(contentsOf: withUnsafeBytes(of: crc.littleEndian, Array.init))
out.append(contentsOf: withUnsafeBytes(of: isize.littleEndian, Array.init))
return out
}
public static func gzipDecompress(_ gzip: Data) throws -> Data {
guard gzip.count >= 18 else { throw CompressionError.invalidGzip }
guard gzip[0] == 0x1f, gzip[1] == 0x8b, gzip[2] == 0x08 else {
throw CompressionError.invalidGzip
}
let flg = gzip[3]
guard flg == 0 else { throw CompressionError.invalidGzip }
let deflateStart = 10
let deflateEnd = gzip.count - 8
guard deflateEnd > deflateStart else { throw CompressionError.invalidGzip }
let deflated = gzip.subdata(in: deflateStart..<deflateEnd)
let inflated = try inflate(deflated)
let crcStored = UInt32(littleEndian: gzip.withUnsafeBytes { ptr in
ptr.load(fromByteOffset: gzip.count - 8, as: UInt32.self)
})
let isizeStored = UInt32(littleEndian: gzip.withUnsafeBytes { ptr in
ptr.load(fromByteOffset: gzip.count - 4, as: UInt32.self)
})
let crcCalc = crc32(inflated)
let isizeCalc = UInt32(inflated.count & 0xFFFF_FFFF)
guard crcStored == crcCalc, isizeStored == isizeCalc else {
throw CompressionError.invalidGzip
}
return inflated
}
private static func deflate(_ data: Data) throws -> Data {
try process(data, operation: COMPRESSION_STREAM_ENCODE)
}
private static func inflate(_ data: Data) throws -> Data {
try process(data, operation: COMPRESSION_STREAM_DECODE)
}
private static func process(_ data: Data, operation: compression_stream_operation) throws -> Data {
var stream = compression_stream()
var status = compression_stream_init(&stream, operation, COMPRESSION_ZLIB)
guard status != COMPRESSION_STATUS_ERROR else { throw CompressionError.failed }
defer { compression_stream_destroy(&stream) }
let dstBufferSize = 64 * 1024
let dstBuffer = UnsafeMutablePointer<UInt8>.allocate(capacity: dstBufferSize)
defer { dstBuffer.deallocate() }
var output = Data()
data.withUnsafeBytes { srcPtr in
stream.src_ptr = srcPtr.bindMemory(to: UInt8.self).baseAddress!
stream.src_size = data.count
stream.dst_ptr = dstBuffer
stream.dst_size = dstBufferSize
while true {
status = compression_stream_process(&stream, 0)
switch status {
case COMPRESSION_STATUS_OK, COMPRESSION_STATUS_END:
let produced = dstBufferSize - stream.dst_size
if produced > 0 {
output.append(dstBuffer, count: produced)
}
stream.dst_ptr = dstBuffer
stream.dst_size = dstBufferSize
if status == COMPRESSION_STATUS_END { return }
default:
return
}
}
}
if status == COMPRESSION_STATUS_ERROR { throw CompressionError.failed }
return output
}
private static func crc32(_ data: Data) -> UInt32 {
var crc: UInt32 = 0xFFFF_FFFF
for b in data {
crc ^= UInt32(b)
for _ in 0..<8 {
let mask = (crc & 1) != 0 ? 0xEDB8_8320 : 0
crc = (crc >> 1) ^ UInt32(mask)
}
}
return ~crc
}
}7) ArtifactCrypto.swift(AES-GCM + KeyProvider)
import Foundation
import CryptoKit
public protocol ArtifactKeyProvider: Sendable {
func symmetricKey() throws -> SymmetricKey
}
public struct InMemoryKeyProvider: ArtifactKeyProvider {
private let key: SymmetricKey
public init(seed: Data) {
self.key = SymmetricKey(data: seed)
}
public func symmetricKey() throws -> SymmetricKey { key }
}
public enum ArtifactCrypto {
public enum CryptoError: Error { case failed }
public static func encrypt(_ plaintext: Data, key: SymmetricKey) throws -> Data {
let sealed = try AES.GCM.seal(plaintext, using: key)
guard let combined = sealed.combined else { throw CryptoError.failed }
return combined
}
public static func decrypt(_ combined: Data, key: SymmetricKey) throws -> Data {
let box = try AES.GCM.SealedBox(combined: combined)
return try AES.GCM.open(box, using: key)
}
}8) KeychainKeyProvider.swift(生产级密钥)
import Foundation
import CryptoKit
import Security
public struct KeychainKeyProvider: ArtifactKeyProvider {
public enum KeychainError: Error {
case unexpectedStatus(OSStatus)
case invalidKeyData
}
private let service: String
private let account: String
public init(service: String = "AIChats.ArtifactStore", account: String = "aesgcm.symmetricKey") {
self.service = service
self.account = account
}
public func symmetricKey() throws -> SymmetricKey {
if let data = try loadKeyData() {
guard data.count == 32 else { throw KeychainError.invalidKeyData }
return SymmetricKey(data: data)
}
var bytes = [UInt8](repeating: 0, count: 32)
let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
guard status == errSecSuccess else { throw KeychainError.unexpectedStatus(status) }
let data = Data(bytes)
try saveKeyData(data)
return SymmetricKey(data: data)
}
private func loadKeyData() throws -> Data? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
if status == errSecItemNotFound { return nil }
guard status == errSecSuccess else { throw KeychainError.unexpectedStatus(status) }
guard let data = item as? Data else { throw KeychainError.invalidKeyData }
return data
}
private func saveKeyData(_ data: Data) throws {
let add: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
]
let addStatus = SecItemAdd(add as CFDictionary, nil)
if addStatus == errSecSuccess { return }
if addStatus == errSecDuplicateItem {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account
]
let update: [String: Any] = [kSecValueData as String: data]
let upStatus = SecItemUpdate(query as CFDictionary, update as CFDictionary)
guard upStatus == errSecSuccess else { throw KeychainError.unexpectedStatus(upStatus) }
return
}
throw KeychainError.unexpectedStatus(addStatus)
}
}9) ArtifactIndexSQLite.swift(Index + refcount + 查询 + 事务)
import Foundation
import SQLite3
public actor ArtifactIndexSQLite {
public enum IndexError: Error {
case openFailed
case execFailed(String)
case bindFailed
case stepFailed
case notFound
}
private var db: OpaquePointer?
public init(indexURL: URL) throws {
try open(at: indexURL)
try migrate()
}
deinit {
if let db { sqlite3_close(db) }
}
private func migrate() throws {
try exec("""
PRAGMA journal_mode=WAL;
PRAGMA synchronous=NORMAL;
""")
try exec("""
CREATE TABLE IF NOT EXISTS payloads (
hash TEXT PRIMARY KEY,
path TEXT NOT NULL,
size INTEGER NOT NULL,
refCount INTEGER NOT NULL,
createdAt REAL NOT NULL,
lastAccessAt REAL
);
""")
try exec("""
CREATE TABLE IF NOT EXISTS artifacts (
taskId TEXT NOT NULL,
kind TEXT NOT NULL,
key TEXT NOT NULL,
payloadHash TEXT NOT NULL,
metaPath TEXT NOT NULL,
createdAt REAL NOT NULL,
expiresAt REAL,
PRIMARY KEY(taskId, kind, key),
FOREIGN KEY(payloadHash) REFERENCES payloads(hash)
);
""")
try exec("CREATE INDEX IF NOT EXISTS idx_artifacts_expiresAt ON artifacts(expiresAt);")
try exec("CREATE INDEX IF NOT EXISTS idx_artifacts_payloadHash ON artifacts(payloadHash);")
}
// MARK: Transactions
public func beginTransaction() throws { try exec("BEGIN IMMEDIATE TRANSACTION;") }
public func commit() throws { try exec("COMMIT;") }
public func rollback() throws { try exec("ROLLBACK;") }
// MARK: Payload + refcount
public func ensurePayload(hash: String, path: String, size: Int) throws {
var stmt: OpaquePointer?
try prepare("""
INSERT INTO payloads(hash, path, size, refCount, createdAt)
VALUES(?, ?, ?, 0, ?)
ON CONFLICT(hash) DO NOTHING;
""", &stmt)
defer { sqlite3_finalize(stmt) }
try bindText(stmt, 1, hash)
try bindText(stmt, 2, path)
sqlite3_bind_int64(stmt, 3, sqlite3_int64(size))
sqlite3_bind_double(stmt, 4, Date().timeIntervalSince1970)
try stepDone(stmt)
}
public func incrementRef(hash: String) throws {
try exec("UPDATE payloads SET refCount = refCount + 1, lastAccessAt=\(Date().timeIntervalSince1970) WHERE hash='\(escape(hash))';")
}
public func decrementRef(hash: String) throws -> Int {
try exec("UPDATE payloads SET refCount = refCount - 1, lastAccessAt=\(Date().timeIntervalSince1970) WHERE hash='\(escape(hash))';")
return try payloadRefCount(hash: hash)
}
public func payloadPath(hash: String) throws -> String {
var stmt: OpaquePointer?
try prepare("SELECT path FROM payloads WHERE hash=? LIMIT 1;", &stmt)
defer { sqlite3_finalize(stmt) }
try bindText(stmt, 1, hash)
guard sqlite3_step(stmt) == SQLITE_ROW else { throw IndexError.notFound }
guard let cstr = sqlite3_column_text(stmt, 0) else { throw IndexError.notFound }
return String(cString: cstr)
}
public func payloadRefCount(hash: String) throws -> Int {
var stmt: OpaquePointer?
try prepare("SELECT refCount FROM payloads WHERE hash=? LIMIT 1;", &stmt)
defer { sqlite3_finalize(stmt) }
try bindText(stmt, 1, hash)
guard sqlite3_step(stmt) == SQLITE_ROW else { throw IndexError.notFound }
return Int(sqlite3_column_int64(stmt, 0))
}
public func deletePayloadRow(hash: String) throws {
try exec("DELETE FROM payloads WHERE hash='\(escape(hash))';")
}
// MARK: Artifacts
public func getArtifactPayloadHash(taskId: String, kind: String, key: String) throws -> String? {
var stmt: OpaquePointer?
try prepare("SELECT payloadHash FROM artifacts WHERE taskId=? AND kind=? AND key=? LIMIT 1;", &stmt)
defer { sqlite3_finalize(stmt) }
try bindText(stmt, 1, taskId)
try bindText(stmt, 2, kind)
try bindText(stmt, 3, key)
let rc = sqlite3_step(stmt)
if rc == SQLITE_ROW {
guard let cstr = sqlite3_column_text(stmt, 0) else { return nil }
return String(cString: cstr)
}
return nil
}
public func upsertArtifact(
taskId: String,
kind: String,
key: String,
payloadHash: String,
metaPath: String,
createdAt: Date,
expiresAt: Date?
) throws -> String? {
let oldHash = try getArtifactPayloadHash(taskId: taskId, kind: kind, key: key)
var stmt: OpaquePointer?
try prepare("""
INSERT INTO artifacts(taskId, kind, key, payloadHash, metaPath, createdAt, expiresAt)
VALUES(?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(taskId, kind, key) DO UPDATE SET
payloadHash=excluded.payloadHash,
metaPath=excluded.metaPath,
createdAt=excluded.createdAt,
expiresAt=excluded.expiresAt;
""", &stmt)
defer { sqlite3_finalize(stmt) }
try bindText(stmt, 1, taskId)
try bindText(stmt, 2, kind)
try bindText(stmt, 3, key)
try bindText(stmt, 4, payloadHash)
try bindText(stmt, 5, metaPath)
sqlite3_bind_double(stmt, 6, createdAt.timeIntervalSince1970)
if let expiresAt {
sqlite3_bind_double(stmt, 7, expiresAt.timeIntervalSince1970)
} else {
sqlite3_bind_null(stmt, 7)
}
try stepDone(stmt)
return oldHash
}
public func deleteArtifact(taskId: String, kind: String, key: String) throws -> String? {
let oldHash = try getArtifactPayloadHash(taskId: taskId, kind: kind, key: key)
guard oldHash != nil else { return nil }
var stmt: OpaquePointer?
try prepare("DELETE FROM artifacts WHERE taskId=? AND kind=? AND key=?;", &stmt)
defer { sqlite3_finalize(stmt) }
try bindText(stmt, 1, taskId)
try bindText(stmt, 2, kind)
try bindText(stmt, 3, key)
try stepDone(stmt)
return oldHash
}
public func expiredArtifacts(now: Date) throws -> [(String, String, String, String, String)] {
var stmt: OpaquePointer?
try prepare("""
SELECT taskId, kind, key, payloadHash, metaPath
FROM artifacts
WHERE expiresAt IS NOT NULL AND expiresAt <= ?
LIMIT 500;
""", &stmt)
defer { sqlite3_finalize(stmt) }
sqlite3_bind_double(stmt, 1, now.timeIntervalSince1970)
var result: [(String, String, String, String, String)] = []
while sqlite3_step(stmt) == SQLITE_ROW {
func col(_ i: Int) -> String { String(cString: sqlite3_column_text(stmt, i)) }
result.append((col(0), col(1), col(2), col(3), col(4)))
}
return result
}
// MARK: Query API
public struct ArtifactRow: Sendable {
public let taskId: String
public let kind: String
public let key: String
public let payloadHash: String
public let metaPath: String
public let createdAt: Date
public let expiresAt: Date?
}
public func listArtifacts(taskId: String) throws -> [ArtifactRow] {
var stmt: OpaquePointer?
try prepare("""
SELECT taskId, kind, key, payloadHash, metaPath, createdAt, expiresAt
FROM artifacts
WHERE taskId=?
ORDER BY createdAt DESC;
""", &stmt)
defer { sqlite3_finalize(stmt) }
try bindText(stmt, 1, taskId)
var rows: [ArtifactRow] = []
while sqlite3_step(stmt) == SQLITE_ROW {
rows.append(readArtifactRow(stmt))
}
return rows
}
public func latestArtifacts(kind: String, limit: Int) throws -> [ArtifactRow] {
var stmt: OpaquePointer?
try prepare("""
SELECT taskId, kind, key, payloadHash, metaPath, createdAt, expiresAt
FROM artifacts
WHERE kind=?
ORDER BY createdAt DESC
LIMIT ?;
""", &stmt)
defer { sqlite3_finalize(stmt) }
try bindText(stmt, 1, kind)
sqlite3_bind_int64(stmt, 2, sqlite3_int64(max(1, limit)))
var rows: [ArtifactRow] = []
while sqlite3_step(stmt) == SQLITE_ROW {
rows.append(readArtifactRow(stmt))
}
return rows
}
public func listTasks(kind: String, limit: Int) throws -> [String] {
// 简化:按 artifacts 中出现的 taskId 去重并限制数量
var stmt: OpaquePointer?
try prepare("""
SELECT DISTINCT taskId
FROM artifacts
WHERE kind=?
ORDER BY taskId DESC
LIMIT ?;
""", &stmt)
defer { sqlite3_finalize(stmt) }
try bindText(stmt, 1, kind)
sqlite3_bind_int64(stmt, 2, sqlite3_int64(max(1, limit)))
var ids: [String] = []
while sqlite3_step(stmt) == SQLITE_ROW {
if let c = sqlite3_column_text(stmt, 0) {
ids.append(String(cString: c))
}
}
return ids
}
// MARK: Internals
private func open(at url: URL) throws {
if sqlite3_open(url.path, &db) != SQLITE_OK {
throw IndexError.openFailed
}
}
private func exec(_ sql: String) throws {
var err: UnsafeMutablePointer<Int8>?
if sqlite3_exec(db, sql, nil, nil, &err) != SQLITE_OK {
let msg = err.map { String(cString: $0) } ?? "unknown"
sqlite3_free(err)
throw IndexError.execFailed(msg)
}
}
private func prepare(_ sql: String, _ stmt: inout OpaquePointer?) throws {
if sqlite3_prepare_v2(db, sql, -1, &stmt, nil) != SQLITE_OK {
throw IndexError.execFailed("prepare failed: \(sql)")
}
}
private func bindText(_ stmt: OpaquePointer?, _ idx: Int32, _ text: String) throws {
if sqlite3_bind_text(stmt, idx, text, -1, SQLITE_TRANSIENT) != SQLITE_OK {
throw IndexError.bindFailed
}
}
private func stepDone(_ stmt: OpaquePointer?) throws {
if sqlite3_step(stmt) != SQLITE_DONE {
throw IndexError.stepFailed
}
}
private func escape(_ s: String) -> String {
s.replacingOccurrences(of: "'", with: "''")
}
private func readArtifactRow(_ stmt: OpaquePointer?) -> ArtifactRow {
func text(_ i: Int32) -> String { String(cString: sqlite3_column_text(stmt, i)) }
func date(_ i: Int32) -> Date { Date(timeIntervalSince1970: sqlite3_column_double(stmt, i)) }
let taskId = text(0)
let kind = text(1)
let key = text(2)
let payloadHash = text(3)
let metaPath = text(4)
let createdAt = date(5)
let expiresAt: Date?
if sqlite3_column_type(stmt, 6) == SQLITE_NULL {
expiresAt = nil
} else {
expiresAt = date(6)
}
return ArtifactRow(
taskId: taskId,
kind: kind,
key: key,
payloadHash: payloadHash,
metaPath: metaPath,
createdAt: createdAt,
expiresAt: expiresAt
)
}
}10) FileArtifactStoreAdvanced.swift(总实现:booting/TTL/dedupe/index/refcount/gzip/aesgcm/GC/metrics/Key)
import Foundation
import CryptoKit
public struct PayloadEncoding: Codable, Sendable {
public var isGzipped: Bool
public var isEncrypted: Bool
public init(isGzipped: Bool, isEncrypted: Bool) {
self.isGzipped = isGzipped
self.isEncrypted = isEncrypted
}
}
/// 为了兼容你已有 ArtifactMeta,不强制改原结构:
/// meta 文件里存 { base: ArtifactMeta, encoding: PayloadEncoding }
public struct ArtifactMetaV2: Codable, Sendable {
public var base: ArtifactMeta
public var encoding: PayloadEncoding
}
public actor FileArtifactStoreAdvanced: ArtifactStoreTool {
public enum StoreError: Error {
case notFound
case bootRecordMissing
}
private let fm: FileManager
private let root: URL
private let bootsDir: URL
private let payloadsDir: URL
private let tasksDir: URL
private let encoder: JSONEncoder
private let decoder: JSONDecoder
private let metrics: ArtifactMetricsSink
private let keyProvider: ArtifactKeyProvider
private let index: ArtifactIndexSQLite
private let compressThresholdBytes: Int
private let defaultEncrypt: Bool
private let defaultCompress: Bool
public init(
folderName: String = "AIChatsArtifacts",
fm: FileManager = .default,
metrics: ArtifactMetricsSink = ConsoleMetricsSink(),
keyProvider: ArtifactKeyProvider = KeychainKeyProvider(),
indexFileName: String = "artifact_index.sqlite",
compressThresholdBytes: Int = 8 * 1024,
defaultEncrypt: Bool = true,
defaultCompress: Bool = true
) throws {
self.fm = fm
self.metrics = metrics
self.keyProvider = keyProvider
self.compressThresholdBytes = compressThresholdBytes
self.defaultEncrypt = defaultEncrypt
self.defaultCompress = defaultCompress
self.encoder = {
let e = JSONEncoder()
e.outputFormatting = [.prettyPrinted, .sortedKeys]
e.dateEncodingStrategy = .iso8601
return e
}()
self.decoder = {
let d = JSONDecoder()
d.dateDecodingStrategy = .iso8601
return d
}()
let support = try fm.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
self.root = support.appendingPathComponent(folderName, isDirectory: true)
self.bootsDir = root.appendingPathComponent("boots", isDirectory: true)
self.payloadsDir = root.appendingPathComponent("payloads", isDirectory: true)
self.tasksDir = root.appendingPathComponent("tasks", isDirectory: true)
try fm.createDirectory(at: root, withIntermediateDirectories: true)
try fm.createDirectory(at: bootsDir, withIntermediateDirectories: true)
try fm.createDirectory(at: payloadsDir, withIntermediateDirectories: true)
try fm.createDirectory(at: tasksDir, withIntermediateDirectories: true)
let indexURL = root.appendingPathComponent(indexFileName, isDirectory: false)
self.index = try ArtifactIndexSQLite(indexURL: indexURL)
}
public func storeRootURL() async -> URL { root }
// MARK: - Booting
public func bootStart(taskId: String, taskType: String, tags: [String : String]) async throws {
let tid = PathSanitizer.safePathComponent(taskId)
let url = bootsDir.appendingPathComponent("\(tid).json", isDirectory: false)
let now = Date()
let record = BootRecord(
taskId: taskId,
taskType: taskType,
startedAt: now,
updatedAt: now,
status: .booting,
note: nil,
tags: tags
)
let data = try encoder.encode(record)
try data.write(to: url, options: [.atomic])
}
public func bootFinish(taskId: String, status: BootRecord.Status, note: String?) async throws {
let tid = PathSanitizer.safePathComponent(taskId)
let url = bootsDir.appendingPathComponent("\(tid).json", isDirectory: false)
guard fm.fileExists(atPath: url.path) else { throw StoreError.bootRecordMissing }
let data = try Data(contentsOf: url)
var record = try decoder.decode(BootRecord.self, from: data)
record.status = status
record.updatedAt = Date()
record.note = note
let out = try encoder.encode(record)
try out.write(to: url, options: [.atomic])
}
public func listBootingTasks() async throws -> [BootRecord] {
let urls = (try? fm.contentsOfDirectory(at: bootsDir, includingPropertiesForKeys: nil)) ?? []
var records: [BootRecord] = []
for u in urls where u.pathExtension.lowercased() == "json" {
if let data = try? Data(contentsOf: u),
let r = try? decoder.decode(BootRecord.self, from: data),
r.status == .booting {
records.append(r)
}
}
return records.sorted(by: { $0.startedAt > $1.startedAt })
}
// MARK: - Key overloads (推荐)
public func saveCodableArtifact<T: Codable>(
_ payload: T,
taskId: String,
kind: ArtifactKind,
key: ArtifactKey,
policy: ArtifactPolicy
) async throws -> SavedArtifact {
try await saveCodableArtifact(payload, taskId: taskId, kind: kind, key: key.normalized, policy: policy)
}
public func loadCodableArtifact<T: Codable>(
_ type: T.Type,
taskId: String,
kind: ArtifactKind,
key: ArtifactKey
) async throws -> T {
try await loadCodableArtifact(type, taskId: taskId, kind: kind, key: key.normalized)
}
public func loadArtifactMeta(taskId: String, kind: ArtifactKind, key: ArtifactKey) async throws -> ArtifactMeta {
try await loadArtifactMeta(taskId: taskId, kind: kind, key: key.normalized)
}
public func deleteArtifact(taskId: String, kind: ArtifactKind, key: ArtifactKey) async throws {
try await deleteArtifact(taskId: taskId, kind: kind, key: key.normalized)
}
// MARK: - CRUD
public func saveCodableArtifact<T: Codable>(
_ payload: T,
taskId: String,
kind: ArtifactKind,
key: String,
policy: ArtifactPolicy
) async throws -> SavedArtifact {
let t0 = PerfClock.now()
let startedAt = Date()
// 1) encode JSON
let json = try encoder.encode(payload)
// 2) decide compress/encrypt via tags
let shouldCompress = boolTag(policy.tags, "compress", default: defaultCompress) && json.count >= compressThresholdBytes
let shouldEncrypt = boolTag(policy.tags, "encrypt", default: defaultEncrypt)
// 3) pipeline: gzip -> aesgcm
var data = json
var encoding = PayloadEncoding(isGzipped: false, isEncrypted: false)
if shouldCompress {
data = try ArtifactCompression.gzipCompress(data)
encoding.isGzipped = true
}
if shouldEncrypt {
let keyObj = try keyProvider.symmetricKey()
data = try ArtifactCrypto.encrypt(data, key: keyObj)
encoding.isEncrypted = true
}
// 4) hash on final bytes (dedupe stable)
let payloadHash = ArtifactHash.sha256Hex(data)
// 5) paths
let payloadURL = payloadsDir.appendingPathComponent("\(payloadHash).bin", isDirectory: false)
let tid = PathSanitizer.safePathComponent(taskId)
let knd = PathSanitizer.safePathComponent(kind.rawValue)
let safeKey = PathSanitizer.safePathComponent(key)
let taskKindDir = tasksDir
.appendingPathComponent(tid, isDirectory: true)
.appendingPathComponent(knd, isDirectory: true)
try fm.createDirectory(at: taskKindDir, withIntermediateDirectories: true)
let metaURL = taskKindDir.appendingPathComponent("\(safeKey).meta.json", isDirectory: false)
let now = Date()
let expiresAt: Date? = policy.ttl.map { now.addingTimeInterval($0) }
let artifactId = "\(tid)_\(knd)_\(safeKey)_\(payloadHash.prefix(12))"
let baseMeta = ArtifactMeta(
id: artifactId,
taskId: taskId,
kind: kind,
schemaVersion: policy.schemaVersion,
createdAt: now,
expiresAt: expiresAt,
payloadHash: payloadHash,
payloadRelativePath: relativePath(from: root, to: payloadURL),
metaRelativePath: relativePath(from: root, to: metaURL),
tags: policy.tags
)
let metaV2 = ArtifactMetaV2(base: baseMeta, encoding: encoding)
// 6) write payload (dedupe)
var deduped = false
if policy.enableDedupe, fm.fileExists(atPath: payloadURL.path) {
deduped = true
} else {
try data.write(to: payloadURL, options: [.atomic])
}
// 7) index transaction (refcount)
var oldHash: String?
do {
try index.beginTransaction()
try index.ensurePayload(hash: payloadHash, path: baseMeta.payloadRelativePath, size: data.count)
oldHash = try index.upsertArtifact(
taskId: taskId,
kind: kind.rawValue,
key: key,
payloadHash: payloadHash,
metaPath: baseMeta.metaRelativePath,
createdAt: now,
expiresAt: expiresAt
)
try index.incrementRef(hash: payloadHash)
// if replacing old hash, decrement old (file deletion after commit)
if let oldHash, oldHash != payloadHash {
_ = try index.decrementRef(hash: oldHash)
}
try index.commit()
} catch {
try? index.rollback()
// index failed -> remove newly written payload (best effort)
if !deduped {
try? fm.removeItem(at: payloadURL)
}
throw error
}
// 8) write meta (best effort, do not rollback index)
let metaData = try encoder.encode(metaV2)
do {
try metaData.write(to: metaURL, options: [.atomic])
} catch {
// 生产里你可以在这里发告警/日志,或做维护任务修复 meta
}
// 9) commit之后:如果 oldHash refcount <= 0 -> recycle
if let oldHash, oldHash != payloadHash {
if let rc = try? index.payloadRefCount(hash: oldHash), rc <= 0 {
try? removePayloadCompletely(hash: oldHash)
}
}
metrics.emit(.init(
op: .save,
taskId: taskId,
kind: kind,
key: key,
startedAt: startedAt,
durationMs: PerfClock.ms(since: t0),
bytesWritten: (deduped ? 0 : data.count) + metaData.count,
bytesRead: 0,
dedupeHit: deduped,
filesDeleted: 0
))
return SavedArtifact(meta: baseMeta, metaURL: metaURL, payloadURL: payloadURL, deduped: deduped)
}
public func loadCodableArtifact<T: Codable>(
_ type: T.Type,
taskId: String,
kind: ArtifactKind,
key: String
) async throws -> T {
let t0 = PerfClock.now()
let startedAt = Date()
let metaV2 = try await loadArtifactMetaV2(taskId: taskId, kind: kind, key: key)
let payloadURL = root.appendingPathComponent(metaV2.base.payloadRelativePath, isDirectory: false)
guard fm.fileExists(atPath: payloadURL.path) else { throw StoreError.notFound }
let payloadData = try Data(contentsOf: payloadURL)
var data = payloadData
if metaV2.encoding.isEncrypted {
let keyObj = try keyProvider.symmetricKey()
data = try ArtifactCrypto.decrypt(data, key: keyObj)
}
if metaV2.encoding.isGzipped {
data = try ArtifactCompression.gzipDecompress(data)
}
let value = try decoder.decode(T.self, from: data)
metrics.emit(.init(
op: .load,
taskId: taskId,
kind: kind,
key: key,
startedAt: startedAt,
durationMs: PerfClock.ms(since: t0),
bytesWritten: 0,
bytesRead: payloadData.count,
dedupeHit: false,
filesDeleted: 0
))
return value
}
public func loadArtifactMeta(taskId: String, kind: ArtifactKind, key: String) async throws -> ArtifactMeta {
let metaV2 = try await loadArtifactMetaV2(taskId: taskId, kind: kind, key: key)
return metaV2.base
}
public func deleteArtifact(taskId: String, kind: ArtifactKind, key: String) async throws {
// index delete -> returns old hash
let oldHash = try index.deleteArtifact(taskId: taskId, kind: kind.rawValue, key: key)
// remove meta file
let metaURL = metaURL(taskId: taskId, kind: kind, key: key)
if fm.fileExists(atPath: metaURL.path) {
try? fm.removeItem(at: metaURL)
}
// refcount -- and maybe recycle payload
if let oldHash {
let rc = (try? index.decrementRef(hash: oldHash)) ?? 0
if rc <= 0 {
try? removePayloadCompletely(hash: oldHash)
}
}
}
// MARK: - TTL cleanup
public func cleanupExpiredArtifacts() async throws {
let t0 = PerfClock.now()
let startedAt = Date()
var deleted = 0
let expired = try index.expiredArtifacts(now: Date())
for (taskId, kindRaw, key, payloadHash, metaPath) in expired {
_ = try index.deleteArtifact(taskId: taskId, kind: kindRaw, key: key)
deleted += 1
// remove meta
let metaURL = root.appendingPathComponent(metaPath, isDirectory: false)
try? fm.removeItem(at: metaURL)
// refcount--
let rc = (try? index.decrementRef(hash: payloadHash)) ?? 0
if rc <= 0 {
try? removePayloadCompletely(hash: payloadHash)
}
}
metrics.emit(.init(
op: .cleanupExpired,
startedAt: startedAt,
durationMs: PerfClock.ms(since: t0),
bytesWritten: 0,
bytesRead: 0,
dedupeHit: false,
filesDeleted: deleted
))
}
// MARK: - GC (fallback)
public func garbageCollectPayloads() async throws {
let t0 = PerfClock.now()
let startedAt = Date()
let files = (try? fm.contentsOfDirectory(at: payloadsDir, includingPropertiesForKeys: nil)) ?? []
var deleted = 0
for f in files where f.pathExtension.lowercased() == "bin" {
let hash = f.deletingPathExtension().lastPathComponent
do {
let rc = try index.payloadRefCount(hash: hash)
if rc <= 0 {
try? fm.removeItem(at: f)
try? index.deletePayloadRow(hash: hash)
deleted += 1
}
} catch {
// index doesn't know it -> remove file
try? fm.removeItem(at: f)
deleted += 1
}
}
metrics.emit(.init(
op: .garbageCollect,
startedAt: startedAt,
durationMs: PerfClock.ms(since: t0),
bytesWritten: 0,
bytesRead: 0,
dedupeHit: false,
filesDeleted: deleted
))
}
// MARK: - Index Query Helpers (optional convenience)
public func listArtifacts(taskId: String) async throws -> [ArtifactIndexSQLite.ArtifactRow] {
try index.listArtifacts(taskId: taskId)
}
public func latestArtifacts(kind: ArtifactKind, limit: Int) async throws -> [ArtifactIndexSQLite.ArtifactRow] {
try index.latestArtifacts(kind: kind.rawValue, limit: limit)
}
public func listRecentTasks(kind: ArtifactKind, limit: Int) async throws -> [String] {
try index.listTasks(kind: kind.rawValue, limit: limit)
}
// MARK: - Private helpers
private func loadArtifactMetaV2(taskId: String, kind: ArtifactKind, key: String) async throws -> ArtifactMetaV2 {
let metaURL = metaURL(taskId: taskId, kind: kind, key: key)
guard fm.fileExists(atPath: metaURL.path) else { throw StoreError.notFound }
let data = try Data(contentsOf: metaURL)
return try decoder.decode(ArtifactMetaV2.self, from: data)
}
private func metaURL(taskId: String, kind: ArtifactKind, key: String) -> URL {
let tid = PathSanitizer.safePathComponent(taskId)
let knd = PathSanitizer.safePathComponent(kind.rawValue)
let safeKey = PathSanitizer.safePathComponent(key)
return tasksDir
.appendingPathComponent(tid, isDirectory: true)
.appendingPathComponent(knd, isDirectory: true)
.appendingPathComponent("\(safeKey).meta.json", isDirectory: false)
}
private func removePayloadCompletely(hash: String) throws {
let rel = try index.payloadPath(hash: hash)
let url = root.appendingPathComponent(rel, isDirectory: false)
try? fm.removeItem(at: url)
try index.deletePayloadRow(hash: hash)
}
private func relativePath(from base: URL, to target: URL) -> String {
let basePath = base.standardizedFileURL.path
let targetPath = target.standardizedFileURL.path
if targetPath.hasPrefix(basePath) {
let rel = String(targetPath.dropFirst(basePath.count))
return rel.hasPrefix("/") ? String(rel.dropFirst()) : rel
}
return targetPath
}
private func boolTag(_ tags: [String: String], _ key: String, default def: Bool) -> Bool {
guard let v = tags[key]?.lowercased() else { return def }
if v == "1" || v == "true" || v == "yes" { return true }
if v == "0" || v == "false" || v == "no" { return false }
return def
}
}11) 使用示例(你直接复制到项目里跑)
A) 在任务执行里(booting + 保存 result + 保存 events)
import Foundation
struct ChatResult: Codable {
let messageId: String
let text: String
let model: String
}
struct TaskEvent: Codable {
let t: Date
let level: String
let msg: String
}
func runChatTask(store: ArtifactStoreTool, taskId: String) async throws {
try await store.bootStart(
taskId: taskId,
taskType: "chat.completion",
tags: ["feature": "AIChats"]
)
do {
let result = ChatResult(messageId: "m1", text: "hello", model: "gpt-4.1")
let events = [
TaskEvent(t: Date(), level: "info", msg: "started"),
TaskEvent(t: Date(), level: "info", msg: "finished")
]
let resultKey = ArtifactKeyFactory.numeric(step: 1, name: "final", domain: "chat")
_ = try await (store as? FileArtifactStoreAdvanced)?.saveCodableArtifact(
result,
taskId: taskId,
kind: .result,
key: resultKey,
policy: .init(
ttl: 60 * 60 * 24 * 7,
enableDedupe: true,
schemaVersion: 3,
tags: [
"encrypt": "1",
"compress": "1"
]
)
) ?? store.saveCodableArtifact(
result,
taskId: taskId,
kind: .result,
key: resultKey.normalized,
policy: .init(ttl: 60 * 60 * 24 * 7, enableDedupe: true, schemaVersion: 3, tags: ["encrypt": "1", "compress": "1"])
)
let evKey = ArtifactKeyFactory.numeric(step: 1, name: "events", domain: "chat")
_ = try await (store as? FileArtifactStoreAdvanced)?.saveCodableArtifact(
events,
taskId: taskId,
kind: .eventLog,
key: evKey,
policy: .init(ttl: 60 * 60 * 24 * 3, enableDedupe: true, schemaVersion: 3, tags: ["encrypt": "1", "compress": "1"])
) ?? store.saveCodableArtifact(
events,
taskId: taskId,
kind: .eventLog,
key: evKey.normalized,
policy: .init(ttl: 60 * 60 * 24 * 3, enableDedupe: true, schemaVersion: 3, tags: ["encrypt": "1", "compress": "1"])
)
try await store.bootFinish(taskId: taskId, status: .finished, note: nil)
} catch {
try await store.bootFinish(taskId: taskId, status: .failed, note: "\(error)")
throw error
}
}说明:
你如果只用 FileArtifactStoreAdvanced,就直接用它的 ArtifactKey 重载更干净。
我这里写了一个兼容写法,让你未来可以把 store 换成别的实现。
B) App 启动恢复未完成任务
func recoverOnLaunch(store: ArtifactStoreTool) async {
do {
let booting = try await store.listBootingTasks()
for r in booting {
print("未完成任务:", r.taskId, r.taskType, r.startedAt)
// 你可以选择自动重跑或标记 cancelled
// try await store.bootFinish(taskId: r.taskId, status: .cancelled, note: "Recovered by app")
}
} catch {
print("recover failed:", error)
}
}C) 维护:过期清理 + GC
func maintain(store: ArtifactStoreTool) async {
do {
try await store.cleanupExpiredArtifacts()
try await store.garbageCollectPayloads()
} catch {
print("maintain failed:", error)
}
}12) 你落地时可能遇到的 2 个“工程设置”注意点
SQLite3
这套用的是 import SQLite3。多数 iOS 工程默认可用。
如果你遇到链接错误:在 target 的 Build Phases → Link Binary With Libraries 加 libsqlite3.tbd。
Compression / CryptoKit / Security
iOS/macOS 都可用,一般不需要额外设置。
如果你希望我下一步再把它进一步“AIChats 任务化到极致”,我建议加一个模块:
ArtifactMaintenanceTask(也是任务/skill)
自检:index vs 文件一致性
修复:缺 meta、缺 payload 的悬挂引用
生成一份维护报告 artifact(便于你在 Developer Settings 里展示)
你说一句“把维护任务也给我”,我就按你现在的 store 结构直接补上(同样一整套可编译)。