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
44 changes: 31 additions & 13 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,33 +13,51 @@

jobs:
build:
runs-on: macos-26
runs-on: xcode-27
timeout-minutes: 60

# Debug signs with the maintainer's team (project.yml), which a runner
# doesn't have; the test bundles build ad-hoc instead.
env:
SIGNING_OVERRIDE: CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO DEVELOPMENT_TEAM=

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Install XcodeGen
run: brew install xcodegen
# The real-pipeline tests drive /opt/homebrew/bin/tmux.
- name: Install XcodeGen and tmux
run: brew install xcodegen tmux

# Flotilla.icon is an Icon Composer bundle (specular/refractivity
# features); actool on GitHub's Xcode 26.6 runners crashes compiling it
# (works fine locally on Xcode 27). Move it out of the way — both
# targets that reference it mark that source `optional: true` — and
# point the macOS app at the plain FlotillaFallback.appiconset instead,
# so CI validates a real build without needing Xcode 27.
- name: Use fallback app icon for CI
run: |
mv Flotilla.icon Flotilla.icon.disabled-for-ci
sed -i '' 's/ASSETCATALOG_COMPILER_APPICON_NAME: Flotilla$/ASSETCATALOG_COMPILER_APPICON_NAME: FlotillaFallback/' project.yml
# SwiftTerm compiles a Metal shader; the xcode-27 image ships without
# the Metal toolchain component.
- name: Install Metal toolchain
run: xcodebuild -downloadComponent MetalToolchain

- name: Generate Xcode project
run: xcodegen generate

- name: Run package tests
run: make test-packages

# Release only: it's what ships, and Xcode compiles Debug on every Run.
- name: Build (Release)
run: make build-release

- name: Run app unit tests
# A hung test fails after 2 minutes instead of eating the job timeout.
run: >
make test SIGNING_OVERRIDE="$SIGNING_OVERRIDE"
TEST_FLAGS="-test-timeouts-enabled YES -maximum-test-execution-time-allowance 120"

# The log only names failing tests; messages, attachments and test-host
# crash reports live in the result bundles.
- name: Upload test results
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: test-results
path: |
build/DerivedData/Logs/Test/*.xcresult
~/Library/Logs/DiagnosticReports/Flotilla*
retention-days: 7
9 changes: 1 addition & 8 deletions .github/workflows/release-dmg.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:
# Secrets live in the protected `release` environment: the job waits for
# the maintainer's approval and only runs for v* tags.
environment: release
runs-on: macos-26
runs-on: xcode-27
timeout-minutes: 45

# The `secrets` context isn't allowed in a step's `if:` condition, so
Expand All @@ -36,13 +36,6 @@ jobs:
- name: Install XcodeGen
run: brew install xcodegen

# See build.yml — actool on GitHub's Xcode 26.6 runners crashes on
# Flotilla.icon; use the plain fallback appiconset instead.
- name: Use fallback app icon for CI
run: |
mv Flotilla.icon Flotilla.icon.disabled-for-ci
sed -i '' 's/ASSETCATALOG_COMPILER_APPICON_NAME: Flotilla$/ASSETCATALOG_COMPILER_APPICON_NAME: FlotillaFallback/' project.yml

- name: Generate Xcode project
run: xcodegen generate

Expand Down
46 changes: 19 additions & 27 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -521,8 +521,17 @@ xcodebuild -project Flotilla.xcodeproj -scheme Flotilla -configuration Debug \
### CI/CD (GitHub Actions)

Pipeline at `.github/workflows/build.yml`:
1. Checkout → use the CI fallback app icon → generate project
1. Checkout → install XcodeGen, tmux and the Metal toolchain → generate project
2. Run standalone `CompanionKit` and `TerminalKit` SwiftPM tests → build Release
3. `make test` (FlotillaUnitTests) with a 2-minute per-test timeout
4. Upload the `.xcresult` bundles and any Flotilla crash reports as `test-results`

Both workflows run on GitHub's `xcode-27` image (a preview label), so CI and
releases use the same Xcode 27 as local builds and compile the real
`Flotilla.icon`. That image lacks the Metal toolchain SwiftTerm's shader needs,
and tmux, which the real-pipeline tests drive at `/opt/homebrew/bin/tmux`.
The log names failing tests but not their messages; read those from the
`test-results` artifact (`xcrun xcresulttool get test-results tests --path …`).

A newer push to the same ref cancels the in-flight run. There is no Debug
build (Xcode compiles it on every Run) and no CI archive or artifact; the
Expand All @@ -534,16 +543,12 @@ Publishing a GitHub Release archives the app with `make archive`, wraps `Flotill
(also uploaded as the `Flotilla-DMG` artifact). Notarization is not included
yet — Gatekeeper will still warn on first open until that is added.

The GitHub-hosted workflow deliberately does **not** run `make test` or
`make test-ui`. App unit-test compilation hits a Swift 6 region-based
isolation-checker compiler error in GitHub's Xcode 26.6 (`Task.detached` in
`CompanionHostTests`), while the developer's Xcode 27 toolchain compiles it.
UI tests are intentionally local/Xcode-only: macOS UI automation needs
Accessibility permission from the process driving the test (see
"UI Vocabulary Screenshot Pipeline" above), which a hosted runner cannot be
given. Run `make test` and the targeted `make test-ui` locally before merging
changes that affect application behavior or UI/accessibility. `make test-packages`
keeps CI exercising the two local packages that have standalone SwiftPM tests.
CI does not run `make test-ui` yet. UI automation itself works on the hosted
runner, but its display boots at 1024x768, narrower than the main window's
minimum width. Switching it to 1920x1080 (`displayplacer`) still leaves the
window 692 pt tall, and 11 of 30 UI tests fail on content that doesn't fit.
Run the targeted `make test-ui` locally before merging changes that affect
UI/accessibility.

Debug signs with the maintainer's own Apple Development team
(`DEVELOPMENT_TEAM: UWAHVC4JTL` in `project.yml`) so that Screen Recording
Expand Down Expand Up @@ -578,22 +583,9 @@ The certificate must belong to team `UWAHVC4JTL`, which the Makefile's
`ARCHIVE_SIGNING_OVERRIDE` names.

`Flotilla.icon` is an Icon Composer bundle using features (`specular-location`,
`refractivity`) that only Xcode 27 beta's `actool` parses correctly — the
maintainer's local Xcode. GitHub's hosted `macos-26` runners top out at Xcode
26.6 stable, whose `actool` crashes compiling it (`Could not open
"Flotilla.icon"` / `NSPlaceholderArray … nil object`), confirmed deterministic
across reruns. Both jobs' "Use fallback app icon for CI" step works around
this before generating the project: it moves `Flotilla.icon` aside (both
targets that reference it mark that source `optional: true` in `project.yml`,
so `xcodegen` tolerates its absence) and repoints
`ASSETCATALOG_COMPILER_APPICON_NAME` at the plain
`Flotilla/Resources/Assets.xcassets/FlotillaFallback.appiconset` instead — a
traditional multi-size PNG icon with no Icon Composer features, so it compiles
on any Xcode version. This only affects the ephemeral CI checkout; local
builds are untouched. XcodeGen does not support `${VAR:-default}`-style
templating in `project.yml` (it passes the literal string through unresolved),
which is why this is a plain `mv`/`sed` step in the workflow rather than an
env-var-driven source path.
`refractivity`) that Xcode 26.6's `actool` crashes on. CI used to swap in
`FlotillaFallback.appiconset` for that; on the `xcode-27` image it compiles
the real icon, so both workflows build it as-is.

## Design System

Expand Down
10 changes: 3 additions & 7 deletions Flotilla/Features/Session/SessionScreenReader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,18 +62,14 @@ struct ProcessTmuxPaneCapture: TmuxPaneCapturing {
let outputPipe = Pipe()
process.standardOutput = outputPipe
process.standardError = FileHandle.nullDevice
let exited: DispatchSemaphore
do {
try process.run()
exited = try process.runSignalingExit()
} catch {
return nil
}

let done = DispatchSemaphore(value: 0)
DispatchQueue.global().async {
process.waitUntilExit()
done.signal()
}
guard done.wait(timeout: .now() + 2) == .success else {
guard exited.wait(timeout: .now() + 2) == .success else {
process.terminate()
return nil
}
Expand Down
51 changes: 27 additions & 24 deletions Flotilla/Services/TmuxSessionWrapping.swift
Original file line number Diff line number Diff line change
Expand Up @@ -462,8 +462,9 @@ struct ProcessTmuxServerProbe: TmuxServerProbing {
let errorPipe = Pipe()
process.standardOutput = FileHandle.nullDevice
process.standardError = errorPipe
let exited: DispatchSemaphore
do {
try process.run()
exited = try process.runSignalingExit()
} catch {
// The probe itself failed to run — that says nothing about the
// server, so keep the existing behavior (try tmux).
Expand All @@ -473,12 +474,7 @@ struct ProcessTmuxServerProbe: TmuxServerProbing {
// A wedged server drops the client immediately, but a *hung* one
// must not block the caller: bound the wait, and treat a timeout as
// "not answering" too.
let done = DispatchSemaphore(value: 0)
DispatchQueue.global().async {
process.waitUntilExit()
done.signal()
}
guard done.wait(timeout: .now() + 2) == .success else {
guard exited.wait(timeout: .now() + 2) == .success else {
process.terminate()
return false
}
Expand Down Expand Up @@ -517,14 +513,8 @@ struct ProcessTmuxSessionTerminator: TmuxSessionTerminating {
// `new-session -A` with the same name, and an in-flight kill landing
// after that would kill the freshly created session. Only wait when
// the process actually started, with a bounded wait to prevent hangs.
guard (try? process.run()) != nil else { return }

let done = DispatchSemaphore(value: 0)
DispatchQueue.global(qos: .userInitiated).async {
process.waitUntilExit()
done.signal()
}
if done.wait(timeout: .now() + 2) != .success {
guard let exited = try? process.runSignalingExit() else { return }
if exited.wait(timeout: .now() + 2) != .success {
process.terminate()
}
}
Expand All @@ -539,8 +529,9 @@ struct ProcessTmuxSessionTerminator: TmuxSessionTerminating {
let outputPipe = Pipe()
process.standardOutput = outputPipe
process.standardError = FileHandle.nullDevice
let exited: DispatchSemaphore
do {
try process.run()
exited = try process.runSignalingExit()
} catch {
return []
}
Expand All @@ -549,7 +540,7 @@ struct ProcessTmuxSessionTerminator: TmuxSessionTerminating {
var data = Data()
DispatchQueue.global(qos: .userInitiated).async {
data = outputPipe.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
exited.wait()
done.signal()
}
guard done.wait(timeout: .now() + 2) == .success, process.terminationStatus == 0 else {
Expand Down Expand Up @@ -684,18 +675,14 @@ struct ProcessTmuxGoalDeliverer: TmuxGoalDelivering {
let outputPipe = Pipe()
process.standardOutput = outputPipe
process.standardError = FileHandle.nullDevice
let exited: DispatchSemaphore
do {
try process.run()
exited = try process.runSignalingExit()
} catch {
return nil
}

let done = DispatchSemaphore(value: 0)
DispatchQueue.global().async {
process.waitUntilExit()
done.signal()
}
guard done.wait(timeout: .now() + 2) == .success else {
guard exited.wait(timeout: .now() + 2) == .success else {
process.terminate()
return nil
}
Expand Down Expand Up @@ -729,3 +716,19 @@ private enum TmuxGoalDeliveryError: LocalizedError {
}
}
}

extension Process {
/// `run()`, returning a semaphore signalled once the process exits.
///
/// Bounded waits must hang off `terminationHandler`: parking a GCD worker
/// in `waitUntilExit()` instead can return seconds after the process has
/// exited. Measured: `tmux ls` exiting in under 20 ms still overran the
/// server probe's 2-second bound about once in 15 calls, so the probe
/// reported a healthy server as wedged and sessions launched without tmux.
func runSignalingExit() throws -> DispatchSemaphore {
let exited = DispatchSemaphore(value: 0)
terminationHandler = { _ in exited.signal() }
try run()
return exited
}
}
20 changes: 12 additions & 8 deletions FlotillaUnitTests/AgentManagedWorktreeAndTitleTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -219,10 +219,7 @@ final class AgentManagedWorktreeAndTitleTests: XCTestCase {

// Wait for the fallback to fire (it creates a stopgap worktree once
// the shortened threshold elapses with no descriptor on disk).
for _ in 0..<40 {
if !gitService.createWorktreeCalls.isEmpty { break }
try await Task.sleep(for: .milliseconds(20))
}
try await waitForWorktree(of: unwrappedID, in: store)
XCTAssertEqual(gitService.createWorktreeCalls.count, 1)
let stopgapSession = try XCTUnwrap(store.sessions.first(where: { $0.id == unwrappedID }))
XCTAssertNotNil(stopgapSession.worktree)
Expand Down Expand Up @@ -330,10 +327,7 @@ final class AgentManagedWorktreeAndTitleTests: XCTestCase {
let unwrappedID = try XCTUnwrap(sessionID)

// Wait for fallback worktree to be created
for _ in 0..<40 {
if !gitService.createWorktreeCalls.isEmpty { break }
try await Task.sleep(for: .milliseconds(20))
}
try await waitForWorktree(of: unwrappedID, in: store)
let fallbackWorktree = store.sessions.first(where: { $0.id == unwrappedID })?.worktree
XCTAssertNotNil(fallbackWorktree, "Fallback worktree should exist")

Expand Down Expand Up @@ -361,4 +355,14 @@ final class AgentManagedWorktreeAndTitleTests: XCTestCase {

AgentSelfReportCoordinator.clearDescriptor(for: unwrappedID, supportDirectory: supportDir)
}

/// The mock records `createWorktree` before returning, and the store
/// assigns the worktree only after that await resumes — so wait for the
/// session itself, not the recorded call.
private func waitForWorktree(of sessionID: UUID, in store: AppStore) async throws {
for _ in 0..<100 {
if store.sessions.first(where: { $0.id == sessionID })?.worktree != nil { return }
try await Task.sleep(for: .milliseconds(20))
}
}
}
26 changes: 16 additions & 10 deletions FlotillaUnitTests/HooksKitTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1079,6 +1079,15 @@ final class HookConfigurationWriterTests: XCTestCase {
}
}

/// Never the real `~/.config/opencode/plugins`.
private var openCodePluginsDirectory: URL {
workingDirectory.deletingLastPathComponent().appendingPathComponent("opencode-plugins", isDirectory: true)
}

private var openCodeWriter: HookConfigurationWriter {
HookConfigurationWriter(openCodeGlobalPluginsDirectory: openCodePluginsDirectory)
}

/// Never the real `~/.gemini/config/hooks.json`.
private var antigravityHooksFile: URL {
workingDirectory.deletingLastPathComponent().appendingPathComponent("gemini-config/hooks.json")
Expand Down Expand Up @@ -1353,7 +1362,7 @@ final class HookConfigurationWriterTests: XCTestCase {
XCTAssertEqual(try Data(contentsOf: configFile), existing)
}
func testOpenCodeRelaunchOverwritesRatherThanAccumulating() throws {
let writer = HookConfigurationWriter()
let writer = openCodeWriter
let sessionID = UUID()
for _ in 0..<3 {
_ = writer.configureHooks(
Expand All @@ -1364,13 +1373,12 @@ final class HookConfigurationWriterTests: XCTestCase {
)
}

let pluginsDirectory = workingDirectory.appendingPathComponent(".opencode/plugins", isDirectory: true)
let entries = try FileManager.default.contentsOfDirectory(atPath: pluginsDirectory.path)
let entries = try FileManager.default.contentsOfDirectory(atPath: openCodePluginsDirectory.path)
XCTAssertEqual(entries.count, 1, "relaunching must overwrite, not accumulate, the stable plugin file")
}

func testOpenCodeTwoSessionsShareOneEnvironmentRoutedPlugin() throws {
let writer = HookConfigurationWriter()
let writer = openCodeWriter
_ = writer.configureHooks(
for: .openCode,
sessionID: UUID(),
Expand All @@ -1384,18 +1392,18 @@ final class HookConfigurationWriterTests: XCTestCase {
supportDirectory: supportDirectory
)

let pluginsDirectory = workingDirectory.appendingPathComponent(".opencode/plugins", isDirectory: true)
let entries = try FileManager.default.contentsOfDirectory(atPath: pluginsDirectory.path)
let entries = try FileManager.default.contentsOfDirectory(atPath: openCodePluginsDirectory.path)
XCTAssertEqual(entries, ["flotilla-status.js"], "one stable plugin routes each process to its own event file")
}

func testConcurrentOpenCodeConfigurationKeepsOneStablePlugin() async throws {
let workingDirectory = try XCTUnwrap(workingDirectory)
let supportDirectory = try XCTUnwrap(supportDirectory)
let pluginsDirectory = openCodePluginsDirectory
let results = await withTaskGroup(of: Bool.self, returning: [Bool].self) { group in
for _ in 0..<12 {
group.addTask {
HookConfigurationWriter().configureHooks(
HookConfigurationWriter(openCodeGlobalPluginsDirectory: pluginsDirectory).configureHooks(
for: .openCode,
sessionID: UUID(),
workingDirectory: workingDirectory,
Expand All @@ -1408,8 +1416,6 @@ final class HookConfigurationWriterTests: XCTestCase {
return values
}
XCTAssertTrue(results.allSatisfy { $0 })

let pluginsDirectory = workingDirectory.appendingPathComponent(".opencode/plugins", isDirectory: true)
XCTAssertEqual(try FileManager.default.contentsOfDirectory(atPath: pluginsDirectory.path), ["flotilla-status.js"])
}

Expand All @@ -1421,7 +1427,7 @@ final class HookConfigurationWriterTests: XCTestCase {
try Data().write(to: legacy)
try Data().write(to: userPlugin)

XCTAssertTrue(HookConfigurationWriter().configureHooks(
XCTAssertTrue(openCodeWriter.configureHooks(
for: .openCode,
sessionID: UUID(),
workingDirectory: workingDirectory,
Expand Down
Loading
Loading