Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions app/modules/AgentHubCore/Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion app/modules/AgentHubCore/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ let package = Package(
.package(path: "../AgentHubGitHub"),
.package(path: "../Storybook"),
.package(path: "../SimulatorPreview"),
.package(url: "https://github.com/jamesrochabrun/Canvas", exact: "1.3.0"),
.package(url: "https://github.com/jamesrochabrun/Canvas", exact: "1.3.2"),
.package(url: "https://github.com/jamesrochabrun/PierreDiffsSwift", exact: "1.2.2"),
.package(url: "https://github.com/jamesrochabrun/SwiftTerm", exact: "1.13.0-agenthub.8"),
.package(url: "https://github.com/gonzalezreal/swift-markdown-ui", from: "2.0.0"),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
//
// InspectorTweakResult.swift
// AgentHub
//

enum InspectorTweakResult: Equatable, Sendable {
case applied
case noChanges
case conflict
}

enum InspectorTweakPolicy: Equatable, Sendable {
case flexible
case additive
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
//
// TweakWorkspaceTransaction.swift
// AgentHub
//

import Foundation

struct TweakWorkspaceTransaction: Sendable {
let rootURL: URL
let workingFileURL: URL
let targetFileURL: URL
let baseContents: Data
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
//
// TweakWorkspaceCoordinator.swift
// AgentHub
//

import Canvas
import Foundation

protocol TweakWorkspaceCoordinating: Sendable {
func prepare(targetFileURL: URL) async throws -> TweakWorkspaceTransaction
func finish(
_ transaction: TweakWorkspaceTransaction,
policy: InspectorTweakPolicy
) async throws -> InspectorTweakResult
func discard(_ transaction: TweakWorkspaceTransaction) async
}

actor TweakWorkspaceCoordinator: TweakWorkspaceCoordinating {
private let fileManager: FileManager
private let temporaryRootURL: URL

init(
fileManager: FileManager = .default,
temporaryRootURL: URL? = nil
) {
self.fileManager = fileManager
self.temporaryRootURL = temporaryRootURL
?? fileManager.temporaryDirectory.appendingPathComponent("AgentHub-Tweaks", isDirectory: true)
}

func prepare(targetFileURL: URL) async throws -> TweakWorkspaceTransaction {
let targetURL = targetFileURL.standardizedFileURL.resolvingSymlinksInPath()
var isDirectory: ObjCBool = false
guard fileManager.fileExists(atPath: targetURL.path, isDirectory: &isDirectory) else {
throw TweakWorkspaceError.missingTarget
}
guard !isDirectory.boolValue else {
throw TweakWorkspaceError.unsupportedTarget
}

let baseContents = try Data(contentsOf: targetURL)
let rootURL = temporaryRootURL.appendingPathComponent(UUID().uuidString, isDirectory: true)
try fileManager.createDirectory(at: rootURL, withIntermediateDirectories: true)
let workingFileURL = rootURL.appendingPathComponent(targetURL.lastPathComponent)
try baseContents.write(to: workingFileURL, options: .atomic)

return TweakWorkspaceTransaction(
rootURL: rootURL,
workingFileURL: workingFileURL,
targetFileURL: targetURL,
baseContents: baseContents
)
}

func finish(
_ transaction: TweakWorkspaceTransaction,
policy: InspectorTweakPolicy
) async throws -> InspectorTweakResult {
defer { try? fileManager.removeItem(at: transaction.rootURL) }

let generatedContents = try Data(contentsOf: transaction.workingFileURL)
guard generatedContents != transaction.baseContents else {
return .noChanges
}

if policy == .additive {
try validateCumulativeTweaks(
baseContents: transaction.baseContents,
generatedContents: generatedContents
)
}

guard fileManager.fileExists(atPath: transaction.targetFileURL.path) else {
return .conflict
}
let currentContents = try Data(contentsOf: transaction.targetFileURL)
guard currentContents == transaction.baseContents else {
return .conflict
}

try generatedContents.write(to: transaction.targetFileURL, options: .atomic)
return .applied
}

func discard(_ transaction: TweakWorkspaceTransaction) async {
try? fileManager.removeItem(at: transaction.rootURL)
}

private func validateCumulativeTweaks(
baseContents: Data,
generatedContents: Data
) throws {
let baseSource = String(decoding: baseContents, as: UTF8.self)
guard let baseNames = try? TweakPropsSourceEditor.parsePropNames(fromSource: baseSource),
!baseNames.isEmpty else {
return
}

let generatedSource = String(decoding: generatedContents, as: UTF8.self)
guard let generatedNames = try? TweakPropsSourceEditor.parsePropNames(fromSource: generatedSource),
generatedNames.count == Set(generatedNames).count,
Set(baseNames).isSubset(of: Set(generatedNames)),
let baseProps = try? TweakPropsSourceEditor.parseProps(fromSource: baseSource),
let generatedProps = try? TweakPropsSourceEditor.parseProps(fromSource: generatedSource) else {
throw TweakWorkspaceError.invalidGeneratedTweaks
}

let generatedPropsByName = Dictionary(uniqueKeysWithValues: generatedProps.map { ($0.name, $0) })
guard baseProps.allSatisfy({ generatedPropsByName[$0.name] == $0 }) else {
throw TweakWorkspaceError.invalidGeneratedTweaks
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
//
// TweakWorkspaceError.swift
// AgentHub
//

import Foundation

enum TweakWorkspaceError: LocalizedError {
case missingTarget
case unsupportedTarget
case invalidGeneratedTweaks

var errorDescription: String? {
switch self {
case .missingTarget:
return "The preview file is no longer available."
case .unsupportedTarget:
return "Tweaks can only be added to a regular design file."
case .invalidGeneratedTweaks:
return "The generated update did not preserve the existing tweak controls. No changes were applied."
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,117 +2,111 @@
// TweaksDefaultsWriteCoordinator.swift
// AgentHub
//
// Debounced persistence for Canvas tweakable-prop values. Live changes are
// applied in the WKWebView immediately; this coordinator makes direct-file
// HTML/SVG previews keep the latest value as the next declared default.
// Explicitly promotes live Canvas tweak values to source defaults after
// verifying that the source still matches the preview's loaded baseline.
//

import Canvas
import Foundation

enum TweaksDefaultsWriteOutcome: Equatable, Sendable {
case written
case noChange
case invalidPropName
case propNotDeclared
case editFailed(String)
protocol TweaksDefaultsWriting: Sendable {
func saveDefaults(
props: [TweakProp],
filePath: String,
projectPath: String
) async throws
}

actor TweaksDefaultsWriteCoordinator {
static let debounceDuration: Duration = .milliseconds(350)
actor TweaksDefaultsWriteCoordinator: TweaksDefaultsWriting {
static let reloadSuppressionDuration: TimeInterval = 1.5

private let fileService: any ProjectFileServiceProtocol
private let debounceDuration: Duration
private var pendingWriteTask: Task<Void, Never>?

init(
fileService: any ProjectFileServiceProtocol = ProjectFileService.shared,
debounceDuration: Duration = TweaksDefaultsWriteCoordinator.debounceDuration
) {
init(fileService: any ProjectFileServiceProtocol = ProjectFileService.shared) {
self.fileService = fileService
self.debounceDuration = debounceDuration
}

deinit {
pendingWriteTask?.cancel()
}

func scheduleValueWrite(
propName: String,
value: TweakPropValue,
filePath: String,
projectPath: String
) {
pendingWriteTask?.cancel()
pendingWriteTask = Task { [debounceDuration] in
try? await Task.sleep(for: debounceDuration)
guard !Task.isCancelled else { return }
_ = await self.writeValue(
propName: propName,
value: value,
filePath: filePath,
projectPath: projectPath
)
static func resolveFilePath(previewURL: URL, projectPath: String) -> String? {
if previewURL.isFileURL {
return previewURL.standardizedFileURL.resolvingSymlinksInPath().path
}
guard let scheme = previewURL.scheme?.lowercased(),
scheme == "http" || scheme == "https" else {
return nil
}
}

func cancelPendingWrite() {
pendingWriteTask?.cancel()
pendingWriteTask = nil
var relativePath = previewURL.path
if relativePath.isEmpty || relativePath == "/" {
relativePath = "/index.html"
} else if previewURL.hasDirectoryPath {
relativePath += "/index.html"
}
return (projectPath as NSString).appendingPathComponent(relativePath)
}

func writeValue(
propName: String,
value: TweakPropValue,
func saveDefaults(
props: [TweakProp],
filePath: String,
projectPath: String
) async -> TweaksDefaultsWriteOutcome {
guard Self.isValidPropName(propName) else { return .invalidPropName }
) async throws {
let changedProps = props.filter { $0.value != $0.defaultValue }
guard !changedProps.isEmpty else { return }

let source: String
do {
source = try await fileService.readFile(at: filePath, projectPath: projectPath)
} catch {
return .editFailed("Could not read \(filePath): \(error.localizedDescription)")
throw TweaksDefaultsWriteError.cannotReadFile
}

do {
let declaredNames = try TweakPropsSourceEditor.parsePropNames(fromSource: source)
guard declaredNames.contains(propName) else { return .propNotDeclared }
guard let diskNames = try? TweakPropsSourceEditor.parsePropNames(fromSource: source),
diskNames == props.map(\.name),
let diskProps = try? TweakPropsSourceEditor.parseProps(fromSource: source) else {
throw TweaksDefaultsWriteError.sourceChanged
}

let edited = try TweakPropsSourceEditor.applyingValueEdit(
propName: propName,
newValue: value,
toSource: source
)
guard edited != source else { return .noChange }
let diskPropsByName = Dictionary(uniqueKeysWithValues: diskProps.map { ($0.name, $0) })
for prop in props {
guard let diskProp = diskPropsByName[prop.name] else {
throw TweaksDefaultsWriteError.unsupportedValue(prop.name)
}
guard diskProp == sourceBaseline(for: prop) else {
throw TweaksDefaultsWriteError.sourceChanged
}
}

let verifiedProps = try TweakPropsSourceEditor.parseProps(fromSource: edited)
guard verifiedProps.contains(where: { $0.name == propName && $0.value == value }) else {
return .editFailed("Edited source failed tweak prop verification")
var edited = source
for prop in changedProps {
do {
edited = try TweakPropsSourceEditor.applyingValueEdit(
propName: prop.name,
newValue: prop.value,
toSource: edited
)
} catch {
throw TweaksDefaultsWriteError.unsupportedValue(prop.name)
}
}
guard edited != source else { return }

do {
try await fileService.writeFile(at: filePath, content: edited, projectPath: projectPath)
return .written
} catch TweakPropsSourceEditorError.propNotFound {
return .propNotDeclared
} catch {
return .editFailed("Tweak default edit rejected: \(error)")
throw TweaksDefaultsWriteError.writeFailed(error.localizedDescription)
}
}

static func isValidPropName(_ name: String) -> Bool {
guard !name.isEmpty, name.count <= 80 else { return false }
return name.unicodeScalars.allSatisfy { scalar in
switch scalar.value {
case 48...57, 65...90, 97...122:
return true
case 36, 45, 95:
return true
default:
return false
}
}
private func sourceBaseline(for prop: TweakProp) -> TweakProp {
TweakProp(
name: prop.name,
label: prop.label,
type: prop.type,
minimum: prop.minimum,
maximum: prop.maximum,
step: prop.step,
options: prop.options,
value: prop.defaultValue,
defaultValue: prop.defaultValue
)
}
}
Loading
Loading