Files

249 lines
12 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import AgentKit
import Foundation
import TermindApplication
import TermindDomain
nonisolated public struct SFTPCopyTool: AgentToolDefinition, AgentToolSchemaBuilding {
public static let presenter = AgentToolDetailPresenter(
id: "builtin.sftp_copy", present: present, presentArguments: presentArguments
)
public let hosts: AgentHostResolver
public let authentication: AgentSSHAuthenticationRegistry
public let connections: AgentConnectionPool
public init(
hosts: AgentHostResolver, authentication: AgentSSHAuthenticationRegistry, connections: AgentConnectionPool
) {
self.hosts = hosts
self.authentication = authentication
self.connections = connections
}
public var descriptor: AgentToolDescriptor {
Self.descriptor(
TermindToolName.sftpCopy,
"Copy a regular file or directory recursively between hosts using streamed SFTP. "
+ "Preserves names and empty directories, not permissions or timestamps. Never deletes the source.",
properties: [
"source_host_id": Self.string(), "source_path": Self.string(max: 4_096),
"destination_host_id": Self.string(),
"destination_path": Self.described(Self.string(max: 4_096), "An existing destination directory."),
"conflict_policy": Self.described(
Self.enumeration(["skip", "replace", "keep_both"]),
"Defaults to skip. Same-name directories merge. Replace may delete conflicting target directories."
),
],
required: ["source_host_id", "source_path", "destination_host_id", "destination_path"],
target: .host, approvalPolicy: .ask, concurrency: .sequential,
presentation: .init(
symbol: "doc.on.doc", activity: .semanticArgument(key: "source_path", fallback: .remoteFile),
output: .json, actionKind: .copy
)
)
}
private struct Request: Sendable {
var sourceHostID: UUID
var sourcePath: String
var destinationHostID: UUID
var destinationPath: String
var policy: TransferConflictPolicy
init(_ invocation: AgentToolInvocation) throws {
let arguments = try Arguments(invocation)
sourceHostID = try arguments.uuid("source_host_id")
destinationHostID = try arguments.uuid("destination_host_id")
sourcePath = try AgentToolSupport.normalizeRemotePath(arguments.string("source_path"))
destinationPath = try AgentToolSupport.normalizeRemotePath(arguments.string("destination_path"))
switch arguments.optionalString("conflict_policy") ?? "skip" {
case "skip": policy = .skip
case "replace": policy = .replace
case "keep_both": policy = .keepBoth
default: throw AgentToolError.invalidArguments("Unknown SFTP conflict policy.")
}
let name = (sourcePath as NSString).lastPathComponent
let target = destinationPath == "/" ? "/\(name)" : destinationPath + "/" + name
guard sourcePath != "/", sourceHostID != destinationHostID || target != sourcePath else {
throw AgentToolError.invalidPath
}
}
}
public func preflight(_ invocation: AgentToolInvocation) async throws -> AgentToolPreflight {
let request = try Request(invocation)
let listing = await hosts.listing().hosts
guard let source = listing.first(where: { $0.id == request.sourceHostID }),
let destination = listing.first(where: { $0.id == request.destinationHostID })
else { throw AgentToolError.invalidHost }
var checked = invocation
checked.targetHostID = request.destinationHostID
return AgentToolPreflight(
invocation: checked, approvalPolicy: .ask,
reasons: [
"\(source.displayName) (\(source.id)): \(request.sourcePath) → "
+ "\(destination.displayName) (\(destination.id)): \(request.destinationPath)"
],
executionMetadata: [
"source_host_name": .string(source.displayName),
"destination_host_name": .string(destination.displayName),
]
)
}
public func execute(
_ invocation: AgentToolInvocation, context: AgentToolExecutionContext
) async throws -> AgentToolResult {
let request = try Request(invocation)
let cancellation = TransferCancellationToken()
return try await withTaskCancellationHandler {
let permit = try await connections.sftpConcurrencyScheduler.acquire(
anchorHostID: request.destinationHostID,
hostIDs: [request.sourceHostID, request.destinationHostID],
source: .agentRun(context.runID),
byteCount: nil
)
do {
let result = try await authentication.withSFTP(
hostID: request.sourceHostID, invocationID: invocation.id,
interaction: context.sshInteraction, pool: connections, cancellation: cancellation,
runID: context.runID, schedulesDataTransfer: true, concurrencyPermit: permit
) { source in
if request.sourceHostID == request.destinationHostID,
permit.schedulingMode == .channelsOnly
{
return await performCopy(
source: source, destination: source,
request: request, invocation: invocation,
context: context, cancellation: cancellation)
}
return try await authentication.withSFTP(
hostID: request.destinationHostID, invocationID: invocation.id,
interaction: context.sshInteraction, pool: connections, cancellation: cancellation,
runID: context.runID, schedulesDataTransfer: true, concurrencyPermit: permit
) { destination in
await performCopy(
source: source, destination: destination,
request: request, invocation: invocation,
context: context, cancellation: cancellation)
}
}
await permit.release()
return result
} catch {
await permit.release()
throw error
}
} onCancel: {
cancellation.cancel()
}
}
private func performCopy(
source: any SFTPChannelProtocol,
destination: any SFTPChannelProtocol,
request: Request,
invocation: AgentToolInvocation,
context: AgentToolExecutionContext,
cancellation: TransferCancellationToken
) async -> AgentToolResult {
let outcome = await SFTPRemoteCopyOperation.run(
sourcePath: request.sourcePath, destinationPath: request.destinationPath,
sameHost: request.sourceHostID == request.destinationHostID,
source: source, destination: destination, policy: request.policy,
cancellation: cancellation
) { outcome, progress in
context.report(Self.copyResult(invocation, request, context, outcome, progress: progress))
}
return Self.copyResult(invocation, request, context, outcome)
}
private static func copyResult(
_ invocation: AgentToolInvocation, _ request: Request, _ context: AgentToolExecutionContext,
_ outcome: SFTPRemoteCopyOperation.Outcome, progress: SFTPProgress? = nil
) -> AgentToolResult {
var object: [String: AgentJSONValue] = [
"source_host_id": .string(request.sourceHostID.uuidString), "source_path": .string(request.sourcePath),
"destination_host_id": .string(request.destinationHostID.uuidString),
"destination_path": .string(request.destinationPath),
"actual_destination_path": .string(outcome.actualDestinationPath),
"conflict_policy": .string(request.policy == .keepBoth ? "keep_both" : request.policy.rawValue),
"copied_files": .number(Double(outcome.copiedFiles)),
"created_directories": .number(Double(outcome.createdDirectories)),
"skipped_items": .number(Double(outcome.skippedItems)), "bytes": .number(Double(outcome.bytes)),
"completed_bytes": .number(Double(outcome.bytes + (progress?.completedBytes ?? 0))),
"total_bytes": .number(Double(outcome.totalBytes)),
"bytes_per_second": .number(Double(progress?.bytesPerSecond ?? 0)),
]
for key in ["source_host_name", "destination_host_name"] { object[key] = context.preflightMetadata[key] }
if let error = outcome.error { object["error"] = .string(error) }
if let path = outcome.failedPath { object["failed_path"] = .string(path) }
var result = Self.result(invocation, .object(object))
result.isError = outcome.error != nil
return result
}
private static func fields(_ object: [String: AgentJSONValue], locale: Locale) -> [AgentToolDetail.Item] {
func field(_ label: String.LocalizationValue, _ value: String) -> AgentToolDetail.Item {
.field(
.init(label: AppLocalization.string(label, locale: locale), value: value, isMonospaced: true))
}
var items: [AgentToolDetail.Item] = []
let values: [(String.LocalizationValue, String)] = [
("Source host", "source_host_id"), ("Source path", "source_path"),
("Destination host", "destination_host_id"), ("Destination path", "destination_path"),
("Actual destination", "actual_destination_path"),
]
for (label, key) in values {
if let value = object[key]?.stringValue {
let name =
key == "source_host_id"
? object["source_host_name"]?.stringValue
: key == "destination_host_id" ? object["destination_host_name"]?.stringValue : nil
items.append(field(label, name.map { "\($0) (\(value))" } ?? value))
}
}
let policy = object["conflict_policy"]?.stringValue ?? "skip"
let policyLabel: String.LocalizationValue =
policy == "replace" ? "Replace" : policy == "keep_both" ? "Keep Both" : "Skip"
items.append(field("Conflict policy", AppLocalization.string(policyLabel, locale: locale)))
if policy == "replace" {
items.append(
.message(
AppLocalization.string("Overwrite may delete conflicting target directories.", locale: locale),
.warning
))
}
let counts: [(String.LocalizationValue, String)] = [
("Copied files", "copied_files"), ("Created directories", "created_directories"),
("Skipped items", "skipped_items"),
]
for (label, key) in counts {
if let value = object[key]?.integerValue { items.append(field(label, String(value))) }
}
for (label, key) in [
(String.LocalizationValue("Completed"), "completed_bytes"), ("Total", "total_bytes"),
] {
if let value = object[key]?.integerValue {
items.append(field(label, AgentToolDetailFormatting.byteCount(value)))
}
}
if let value = object["bytes_per_second"]?.integerValue {
items.append(field("Transfer speed", AgentToolDetailFormatting.byteCount(value) + "/s"))
}
if let path = object["failed_path"]?.stringValue { items.append(field("Failed path", path)) }
if let error = object["error"]?.stringValue { items.append(.message(error, .failure)) }
return items
}
public static func present(_ input: AgentToolDetailInput) -> [AgentToolDetail.Item] {
AgentToolDetailFormatting.objectItems(input) { fields($0, locale: input.locale) }
}
public static func presentArguments(_ input: AgentToolArgumentDetailInput) -> [AgentToolDetail.Item] {
fields(input.arguments, locale: input.locale)
}
}