Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
d005329
NEXT: tests for Cloud and peer chat uploads of any size (red)
teamleaderleo Oct 28, 2026
aceb28e
NEXT: Cloud and peer chat uploads take files of any size
teamleaderleo Oct 28, 2026
6d15ae7
NEXT: tests for page-decoded uploads of any size reaching Cloud and p…
teamleaderleo Oct 28, 2026
3e713c1
NEXT: the request decoder takes a staged upload of any size
teamleaderleo Oct 28, 2026
9f51ef1
cmux link access: test a sparse upload over 1 GiB in bounded parts (red)
teamleaderleo Oct 28, 2026
ab7d533
cmux link access: an upload takes a file of any size
teamleaderleo Oct 28, 2026
9e135f2
Keep staged uploads cancellable through transfer
teamleaderleo Oct 29, 2026
318c317
Keep peer cancellation test on main actor
teamleaderleo Oct 29, 2026
04ce5d2
Allow staged cancellation test to overlap finish
teamleaderleo Oct 29, 2026
4dc1c25
Keep peer transfer task with its stage
teamleaderleo Oct 29, 2026
7db0550
Cancel active staged uploads through terminal cleanup
teamleaderleo Oct 29, 2026
01c9051
Retain batch authorization through upload cleanup
teamleaderleo Oct 29, 2026
211f09e
Stop stage expiry while terminal cleanup owns it
teamleaderleo Oct 29, 2026
280240d
Keep active uploads bounded by idle progress
teamleaderleo Oct 29, 2026
53c2aa1
Bound upload reply and diagnostic capture
teamleaderleo Oct 29, 2026
c73b304
Fix upload regression test isolation and macro mutation
teamleaderleo Oct 29, 2026
ab48db1
Merge remote-tracking branch 'origin/feat-cmux-next' into h4-attach-u…
teamleaderleo Oct 29, 2026
94b9aeb
Merge remote-tracking branch 'origin/feat-cmux-next' into lane/carry-…
teamleaderleo Oct 31, 2026
472d2cd
Attachment cap guard: an exemption names only a cap its file still de…
teamleaderleo Oct 31, 2026
5824903
Attachment cap guard: drop the exemptions of caps that are gone
teamleaderleo Oct 31, 2026
77a24bb
NEXT: a pinned recording keeps its own in-memory budget; stages name …
teamleaderleo Oct 31, 2026
23b6a89
NEXT: Cloud and peer staged uploads share one store in CmuxNextAgentPane
teamleaderleo Oct 31, 2026
6ff0fa8
Merge remote-tracking branch 'origin/feat-cmux-next' into lane/carry-…
teamleaderleo Oct 31, 2026
f6edd0e
NEXT: Cloud uploads go one file per call; a late cancel takes a deliv…
teamleaderleo Oct 31, 2026
f35ace8
NEXT: a Cloud upload's cleanup tries every file; the undo test compar…
teamleaderleo Oct 31, 2026
b33d5cd
NEXT: removeUploads' doc says once that every id is tried
teamleaderleo Oct 31, 2026
2938e3a
Merge remote-tracking branch 'origin/feat-cmux-next' into lane/carry-…
teamleaderleo Oct 31, 2026
9d4b5d8
NEXT: intake's batch upload call keeps one formatter-stable line
teamleaderleo Oct 31, 2026
2058cd2
NEXT: the staged-upload undo test logs on a main-actor class; package…
teamleaderleo Oct 31, 2026
b3a0750
Merge origin/feat-cmux-next into lane/carry-27073-attach-uncap
teamleaderleo Nov 1, 2026
2d4e117
fix(attachments): drop the stale MAX_FILE_BYTES cap exemption
teamleaderleo Nov 1, 2026
7dd8f35
test(upload): a part in flight reports progress before its answer (red)
teamleaderleo Nov 1, 2026
5a7c47e
fix(upload): report progress while a part is in flight; idle outlasts…
teamleaderleo Nov 1, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ public nonisolated enum AgentPaneFilePutFailure: Error, Equatable, Sendable {
/// This Mac's acpmux or the peer's takes no files (a build from before `_acpmux/file_put`), or
/// the host has no acpmux: the chat takes no uploads and the composer keeps its own note.
case unsupported
/// Past ``AgentPaneUpload/maximumPeerFileBytes``.
/// The peer refused the file as too large (`filePut.tooLarge`); this Mac sets no size limit.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Removing the size guard routes files over 10 MiB to filePutChunks. A peer that only advertises filePut now gets .unsupported instead of .tooLarge, so the composer says the chat takes no uploads. Map unsupportedFeature(filePutChunksFeature) to .tooLarge on the chunked path, or keep the one-shot size cap for peers without that feature.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneFilePut.swift, line 9:

<comment>Removing the size guard routes files over 10 MiB to `filePutChunks`. A peer that only advertises `filePut` now gets `.unsupported` instead of `.tooLarge`, so the composer says the chat takes no uploads. Map `unsupportedFeature(filePutChunksFeature)` to `.tooLarge` on the chunked path, or keep the one-shot size cap for peers without that feature.</comment>

<file context>
@@ -6,7 +6,7 @@ public nonisolated enum AgentPaneFilePutFailure: Error, Equatable, Sendable {
     /// the host has no acpmux: the chat takes no uploads and the composer keeps its own note.
     case unsupported
-    /// Past ``AgentPaneUpload/maximumPeerFileBytes``.
+    /// The peer refused the file as too large (`filePut.tooLarge`); this Mac sets no size limit.
     case tooLarge
     /// No answer, a refusal, or a write that failed on the peer.
</file context>

case tooLarge
/// No answer, a refusal, or a write that failed on the peer.
case failed
Expand Down Expand Up @@ -45,7 +45,6 @@ extension AcpmuxStatusClient {
/// there. Throws ``AgentPaneFilePutFailure``.
@concurrent static func putFile(socketPath: String, sessionId: String, name: String, data: Data,
deadline: Duration = .seconds(120)) async throws -> String {
guard data.count <= AgentPaneUpload.maximumPeerFileBytes else { throw AgentPaneFilePutFailure.tooLarge }
if data.count <= legacyFilePutBytes {
return try await putFile(sessionId: sessionId, name: name, data: data) { params in
try await call(socketPath: socketPath, method: filePutMethod, params: params, deadline: deadline,
Expand All @@ -62,7 +61,6 @@ extension AcpmuxStatusClient {
@concurrent static func putFile(socketPath: String, sessionId: String, name: String, file: URL,
deadline: Duration = .seconds(120)) async throws -> String {
let size = (try FileManager.default.attributesOfItem(atPath: file.path)[.size] as? NSNumber)?.intValue ?? 0
guard size <= AgentPaneUpload.maximumPeerFileBytes else { throw AgentPaneFilePutFailure.tooLarge }
if size <= legacyFilePutBytes {
return try await putFile(socketPath: socketPath, sessionId: sessionId, name: name,
data: try Data(contentsOf: file), deadline: deadline)
Expand All @@ -76,7 +74,6 @@ extension AcpmuxStatusClient {
/// ``putFile(socketPath:sessionId:name:data:deadline:)`` with `send`, which runs the request.
static func putFile(sessionId: String, name: String, data: Data,
send: @Sendable (_ params: [String: any Sendable]) async throws -> [String: Any]) async throws -> String {
guard data.count <= AgentPaneUpload.maximumPeerFileBytes else { throw AgentPaneFilePutFailure.tooLarge }
let result: [String: Any]
do {
result = try await send(["sessionId": sessionId, "fileName": name, "data": data.base64EncodedString()])
Expand All @@ -91,7 +88,6 @@ extension AcpmuxStatusClient {
/// exchange, so a peer WebSocket never receives the entire base64 file in one frame.
static func putFileChunked(sessionId: String, name: String, data: Data,
send: @escaping @Sendable (_ method: String, _ params: [String: any Sendable]) async throws -> [String: Any]) async throws -> String {
guard data.count <= AgentPaneUpload.maximumPeerFileBytes else { throw AgentPaneFilePutFailure.tooLarge }
var upload: String?
let started: [String: Any]
do {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import CmuxNextCompat
package import CmuxNextWakeups
package import Foundation

/// The staged uploads (`chat.upload.begin/chunk/finish/cancel`, ``AgentPaneUploadStageRequest``) of
/// one kind of remote chat, a Cloud machine's or an ssh peer's. Each stage is an owner-only file of
/// any size that chunks append to; there is no size limit on a file or a batch. A gesture's batch
/// (``AgentPaneUploadBatch``) stays authorized while any of its stages lives. A stage's one transfer
/// owns its file: expiry skips it, and cancel stops it first and removes the file only after it
/// returned. One deadline removes abandoned stages and batches, with no sleeping task per stage
/// (plans/cmux-next/idle-wakeups.md). `Target` is where a stage goes.
@MainActor
package final class AgentPaneStagedUploads<Target> {
/// A stage's chunk: each write is this size at most, whatever the file's size.
package nonisolated static var chunkBytes: Int { 512 * 1024 }
/// How long a stage, or a batch with no live stage, waits for its next request.
package nonisolated static var lifetime: TimeInterval { 10 * 60 }

/// What a complete stage hands its transfer.
package struct Staged {
package let target: Target
package let file: URL
package let name: String
}

private struct Transfer {
let cancel: @MainActor () -> Void
let settled: @MainActor () async -> Void
}

private struct Stage {
var staged: Staged
/// Removed with the stage: the staged file, or a folder of its own that holds it.
var removing: URL
var size: Int
var received = 0
var expires: Date
var batch: String?
var transfer: Transfer?
var cleanup: Task<Void, Never>?
var idle: Bool { transfer == nil && cleanup == nil }
}

private struct Batch {
var target: Target
var authorization: AgentPaneUploadBatch
}

/// A stage whose transfer delivered it, kept for a cancel that comes after: the page may drop
/// the file once the answer is on its way (its x in that moment).
private struct Delivered {
var target: Target
var undo: @MainActor () async -> Void
var expires: Date
}

private var stages: [String: Stage] = [:]
private var batches: [String: Batch] = [:]
private var delivered: [String: Delivered] = [:]
private let expiry: DemandTimer

/// `owner` names the expiry deadline in the wakeup ledger.
package init(owner: String) {
expiry = DemandTimer(owner: owner)
}

// A generic main-actor class's isolated deinit crashes the Release optimizer (#23050, #23233);
// the DemandTimer cancels its own deadline when it goes.
nonisolated deinit {}

/// Whether a begin of `name`, `size` bytes, that starts the batch `manifest` (nil for a lone file
/// or a later file of a batch) is well formed. No size is too large.
package nonisolated static func acceptsBegin(name: String, size: Int, manifest: [AgentPaneUploadBatchEntry]?) -> Bool {
guard size >= 0 else { return false }
guard let manifest else { return true }
return manifest.first == AgentPaneUploadBatchEntry(name: name, size: size) && manifest.allSatisfy { $0.size >= 0 }
}

/// The target of batch `token` when it may begin `name`, `size` bytes next.
package func batchTarget(_ token: String, name: String, size: Int) -> Target? {
guard let batch = batches[token], batch.authorization.accepts(name: name, size: size) else { return nil }
return batch.target
}

/// Stages `name`, `size` bytes, for `target`: `make` creates its empty owner-only file for the new
/// token and answers the file and what removing the stage removes (nil: no stage). A later file
/// of batch `batchToken` uses that batch; a lone first file with a `manifest` of more than one
/// starts one. The stage's token and its batch's, or nil when the batch does not take it.
package func begin(name: String, size: Int, batchToken: String?, manifest: [AgentPaneUploadBatchEntry]?,
target: Target, work: BackgroundWork,
make: (_ token: String) -> (file: URL, removing: URL)?) -> (token: String, batchToken: String?)? {
if let batchToken, batchTarget(batchToken, name: name, size: size) == nil { return nil }
let token = UUID().uuidString.lowercased()
guard let made = make(token) else { return nil }
var batchID = batchToken
if batchID == nil, let manifest, manifest.count > 1 {
let id = UUID().uuidString.lowercased()
batches[id] = Batch(target: target, authorization: AgentPaneUploadBatch(manifest))
batchID = id
}
if let batchID { batches[batchID]?.authorization.begin(name: name, size: size, stage: token) }
stages[token] = Stage(staged: Staged(target: target, file: made.file, name: name), removing: made.removing,
size: size, expires: Date().addingTimeInterval(Self.lifetime), batch: batchID)
arm(work)
return (token, batchID)
}

/// Appends `data`, the bytes at `offset`, to stage `token` when `owns` its target and no transfer
/// or cleanup has it: the bytes received so far, or nil.
package func chunk(_ token: String, offset: Int, data: Data, owns: (Target) -> Bool, work: BackgroundWork) -> Int? {
guard var stage = stages[token], stage.idle, owns(stage.staged.target), stage.received == offset,
data.count <= Self.chunkBytes, data.count <= stage.size - offset else { return nil }
do {
let handle = try FileHandle(forWritingTo: stage.staged.file)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: chunk performs its file write synchronously on the main actor. Route the append through work.run(.userBlocking, mayBlock: .files, ...) like the unlink in remove, so the main thread does not block on disk I/O.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneStagedUploads.swift, line 114:

<comment>`chunk` performs its file write synchronously on the main actor. Route the append through `work.run(.userBlocking, mayBlock: .files, ...)` like the unlink in `remove`, so the main thread does not block on disk I/O.</comment>

<file context>
@@ -0,0 +1,208 @@
+        guard var stage = stages[token], stage.idle, owns(stage.staged.target), stage.received == offset,
+              data.count <= Self.chunkBytes, data.count <= stage.size - offset else { return nil }
+        do {
+            let handle = try FileHandle(forWritingTo: stage.staged.file)
+            try handle.seekToEnd(); try handle.write(contentsOf: data); try handle.close()
+        } catch { return nil }
</file context>

try handle.seekToEnd(); try handle.write(contentsOf: data); try handle.close()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: A failed or partial write leaves bytes beyond received, and the retry appends after them via seekToEnd(), which corrupts the staged file. Truncate to offset and write at that position, and close the handle on every path.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneStagedUploads.swift, line 115:

<comment>A failed or partial write leaves bytes beyond `received`, and the retry appends after them via `seekToEnd()`, which corrupts the staged file. Truncate to `offset` and write at that position, and close the handle on every path.</comment>

<file context>
@@ -0,0 +1,208 @@
+              data.count <= Self.chunkBytes, data.count <= stage.size - offset else { return nil }
+        do {
+            let handle = try FileHandle(forWritingTo: stage.staged.file)
+            try handle.seekToEnd(); try handle.write(contentsOf: data); try handle.close()
+        } catch { return nil }
+        stage.received += data.count
</file context>

} catch { return nil }
stage.received += data.count
stage.expires = Date().addingTimeInterval(Self.lifetime)
stages[token] = stage
arm(work)
return stage.received
}

/// Runs `transfer` once on stage `token`'s complete file, when `owns` its target, then removes the
/// stage: what `transfer` answered, or nil when the stage is not there, not complete or not idle.
/// Cancelling the caller cancels the transfer. `undo` names how to take back what an answer
/// delivered (nil: nothing to take back); a cancel within ``lifetime`` after it runs that.
package func finish<Answer: Sendable>(_ token: String, owns: (Target) -> Bool, work: BackgroundWork,
transfer: @escaping @MainActor (Staged) async -> Answer,
undo: @escaping @MainActor (Answer) -> (@MainActor () async -> Void)? = { _ in nil }) async -> Answer? {
guard let stage = stages[token], stage.idle, owns(stage.staged.target), stage.received == stage.size else { return nil }
let staged = stage.staged
// task-owner: the stage's one transfer; cancel() stops it, and the stage is removed only after it returns.
let task = Task { @MainActor [self] () -> Answer in
let answer = await transfer(staged)
if let undo = undo(answer) {
delivered[token] = Delivered(target: staged.target, undo: undo, expires: Date().addingTimeInterval(Self.lifetime))
}
await remove(token, work: work)
return answer
}
stages[token]?.transfer = Transfer(cancel: { task.cancel() }, settled: { _ = await task.value })
arm(work)
return await withTaskCancellationHandler(operation: { await task.value }, onCancel: { task.cancel() })
}

/// Drops stage `token` when `owns` its target: its transfer, if one runs, stops first; the staged
/// file is removed before this returns. A stage its transfer already delivered is taken back.
package func cancel(_ token: String, owns: (Target) -> Bool, work: BackgroundWork) async {
if let stage = stages[token] {
guard owns(stage.staged.target) else { return }
guard let transfer = stage.transfer else { await remove(token, work: work); return }
transfer.cancel()
await transfer.settled()
}
guard let done = delivered[token], owns(done.target) else { return }
delivered.removeValue(forKey: token)
arm(work)
await done.undo()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Late-delivery cleanup failures are reported as successful cancellations and cannot be retried because the delivery is removed before this undo completes. Make undo report failure and retain or otherwise surface the delivery when remote deletion fails.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneStagedUploads.swift, line 159:

<comment>Late-delivery cleanup failures are reported as successful cancellations and cannot be retried because the delivery is removed before this undo completes. Make undo report failure and retain or otherwise surface the delivery when remote deletion fails.</comment>

<file context>
@@ -0,0 +1,208 @@
+        guard let done = delivered[token], owns(done.target) else { return }
+        delivered.removeValue(forKey: token)
+        arm(work)
+        await done.undo()
+    }
+
</file context>

}

/// Removes the idle stages past their deadline, the batches no stage keeps alive, and forgets
/// deliveries too old to take back.
package func purge(work: BackgroundWork) async {
let now = Date()
for token in stages.filter({ $0.value.idle && $0.value.expires < now }).map(\.key) { await remove(token, work: work) }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The purge loop removes stages from a snapshot taken before its awaits, so a stage that begins transfer in the meantime still gets its file unlinked. Re-check idleness at each iteration before calling remove.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneStagedUploads.swift, line 166:

<comment>The purge loop removes stages from a snapshot taken before its awaits, so a stage that begins transfer in the meantime still gets its file unlinked. Re-check idleness at each iteration before calling `remove`.</comment>

<file context>
@@ -0,0 +1,208 @@
+    /// deliveries too old to take back.
+    package func purge(work: BackgroundWork) async {
+        let now = Date()
+        for token in stages.filter({ $0.value.idle && $0.value.expires < now }).map(\.key) { await remove(token, work: work) }
+        batches = batches.filter { $0.value.authorization.isAlive() }
+        delivered = delivered.filter { $0.value.expires >= now }
</file context>

batches = batches.filter { $0.value.authorization.isAlive() }
delivered = delivered.filter { $0.value.expires >= now }
}

private func remove(_ token: String, work: BackgroundWork) async {
if let cleanup = stages[token]?.cleanup { await cleanup.value; return }
guard let stage = stages[token] else { return }
let removing = stage.removing, batchID = stage.batch
// task-owner: one awaited unlink on the file lane, independent of a cancelled transfer;
// blockShutdown keeps it until the unlink completes.
let cleanup = Task(priority: WorkPriority.userBlocking.taskPriority) { @MainActor [self] in
_ = try? await work.run(.userBlocking, mayBlock: .files, shutdown: .blockShutdown) {
try? FileManager.checked.removeItem(at: removing)
}
stages.removeValue(forKey: token)
if let batchID {
batches[batchID]?.authorization.release(stage: token)
if batches[batchID]?.authorization.isComplete == true { batches.removeValue(forKey: batchID) }
}
arm(work)
}
stages[token]?.cleanup = cleanup
arm(work)
await cleanup.value
}

/// Arms the one deadline for the earliest idle stage's, ownerless batch's or delivery's expiry; nothing is
/// pending when there is none.
private func arm(_ work: BackgroundWork) {
let deadlines = stages.values.compactMap { $0.idle ? $0.expires : nil } + batches.values.compactMap { $0.authorization.abandonmentDeadline }
+ delivered.values.map(\.expires)
guard let next = deadlines.min() else { expiry.cancel(); return }
expiry.schedule(after: .seconds(max(1, next.timeIntervalSinceNow))) { @MainActor [weak self] in
// task-owner: one timer-fired cleanup, outside the timer's own task (which the rearm
// below cancels); it rearms after the awaited removals, no repeating task.
Task { @MainActor [weak self] in
await self?.purge(work: work)
self?.arm(work)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,40 @@ public nonisolated struct AgentPaneUploadBatchEntry: Sendable, Equatable {
}
}

/// A gesture authorizes an ordered batch. A live stage retains that authorization through both
/// local staging and remote transfer; only an ownerless batch has an abandonment deadline.
package nonisolated struct AgentPaneUploadBatch: Sendable {
private var remaining: [AgentPaneUploadBatchEntry]
private var owners: Set<String> = []
private var expires: Date
package static let lifetime: TimeInterval = 10 * 60

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This adds a second 10 * 60 lifetime for the same batch deadline that AgentPaneStagedUploads.lifetime already defines. Reuse one constant so the stage and batch expiries cannot diverge.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneUpload.swift, line 23:

<comment>This adds a second `10 * 60` lifetime for the same batch deadline that `AgentPaneStagedUploads.lifetime` already defines. Reuse one constant so the stage and batch expiries cannot diverge.</comment>

<file context>
@@ -14,6 +14,40 @@ public nonisolated struct AgentPaneUploadBatchEntry: Sendable, Equatable {
+    private var remaining: [AgentPaneUploadBatchEntry]
+    private var owners: Set<String> = []
+    private var expires: Date
+    package static let lifetime: TimeInterval = 10 * 60
+
+    package init(_ manifest: [AgentPaneUploadBatchEntry], now: Date = Date()) {
</file context>


package init(_ manifest: [AgentPaneUploadBatchEntry], now: Date = Date()) {
remaining = manifest
expires = now.addingTimeInterval(Self.lifetime)
}

package var abandonmentDeadline: Date? { owners.isEmpty ? expires : nil }
package var isComplete: Bool { remaining.isEmpty && owners.isEmpty }
package func isAlive(at now: Date = Date()) -> Bool { !owners.isEmpty || expires > now }
package func accepts(name: String, size: Int, at now: Date = Date()) -> Bool {
isAlive(at: now) && remaining.first == AgentPaneUploadBatchEntry(name: name, size: size)
}

@discardableResult
package mutating func begin(name: String, size: Int, stage: String, at now: Date = Date()) -> Bool {
guard accepts(name: name, size: size, at: now), !owners.contains(stage) else { return false }
remaining.removeFirst()
owners.insert(stage)
return true
}

package mutating func release(stage: String, at now: Date = Date()) {
guard owners.remove(stage) != nil, owners.isEmpty else { return }
expires = now.addingTimeInterval(Self.lifetime)
}
}

public nonisolated enum AgentPaneUploadStageRequest: Sendable, Equatable {
case begin(name: String, size: Int, batchToken: String?, batch: [AgentPaneUploadBatchEntry]?)
case chunk(token: String, offset: Int, data: Data)
Expand Down Expand Up @@ -42,27 +76,17 @@ public nonisolated struct AgentPaneUpload: Sendable, Equatable, CustomStringConv
public var debugDescription: String { description }
public var customMirror: Mirror { Mirror(self, children: [:]) }

/// Local and peer transports stream the bytes in bounded chunks. One GiB is also the native
/// daemon's abuse guard; Cloud uploads keep their smaller policy in ``CloudUploads``.
public static let maximumFileBytes = 1 * 1024 * 1024 * 1024
public static let maximumTotalBytes = 1 * 1024 * 1024 * 1024
public static let maximumPeerFileBytes = maximumFileBytes

/// The files of `chat.upload`'s params, nil when they break the contract: one or more files,
/// each with a name a folder holds as one file and base64 bytes, at most
/// ``maximumFileBytes`` each and ``maximumTotalBytes`` in all.
/// each with a name a folder holds as one file and base64 bytes, of any size. This one-frame
/// upload is the older page's; the page streams a file of any size through the staged upload
/// (``AgentPaneUploadStageRequest``) instead.
static func files(_ params: [String: Any]?) -> [AgentPaneUpload]? {
guard let params, Set(params.keys) == ["files"], let list = params["files"] as? [[String: Any]],
!list.isEmpty else { return nil }
var files: [AgentPaneUpload] = []
var total = 0
for item in list {
guard Set(item.keys) == ["name", "data"], let name = item["name"] as? String, isFileName(name),
let text = item["data"] as? String, text.utf8.count <= (maximumFileBytes / 3 + 1) * 4,
let data = Data(base64Encoded: text), data.count <= maximumFileBytes else { return nil }
let (sum, overflow) = total.addingReportingOverflow(data.count)
total = overflow ? Int.max : sum
guard total <= maximumTotalBytes else { return nil }
let text = item["data"] as? String, let data = Data(base64Encoded: text) else { return nil }
files.append(AgentPaneUpload(name: name, data: data))
}
return files
Expand Down
Loading
Loading