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
3 changes: 3 additions & 0 deletions .github/workflows/cmux-next-guards.yml
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,9 @@ jobs:
- name: The fast gates workflow and its merge and gate script
run: python3 tests/test_cmux_next_fast_gates.py

- name: Capture-only control methods are compiled out of release builds
run: python3 tests/test_cmux_next_debug_only_controls.py

- name: Every check path a merger reads matches a tracked file
run: python3 tests/test_check_paths.py

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ final class AppControl {
// Diagnostics for tagged DEV builds; a release build registers no debug.* method
// (ControlRouter drops them too: Configuration.allowsDebugMethods).
#if DEBUG
service.router.register(ChiefCaptureControl.methods(services: services))
service.router.register(DebugWindowsControl.methods(services) + [services.daemon.debugControlMethod { [weak services] in services?.windows.controllers.map { (id: $0.state.id, shows: $0.recoveryShows) } ?? [] }])
service.router.register([
.mainActor("debug.frames") { [probe = frameProbe] call in .value(probe.handle(call.params)) },
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
#if DEBUG
import CmuxNextBridge
import CmuxNextCompat
import CmuxNextControl
import CmuxNextSettings
import CmuxNextSidebar
import Foundation

/// `debug.chief_workspaces`: the Chief's sidebar group in a capture (DEV builds only; this file is
/// compiled out of release builds, `tests/test_cmux_next_debug_only_controls.py`). A capture slot
/// has the Chief owner the app starts, but nothing there starts subagents, so its group is empty
/// and draws nothing. This opens named workspaces on the local Chief machine through the same
/// path as a sidebar machine's New Workspace (`createWorkspace(on:)`), folds its group, and shows
/// a row's hover card.
///
/// Params, each optional: `open: [name]` opens them (waits for the Chief owner until the 60 s
/// deadline), each running `command` in `cwd` when given (a capture's still must not show the
/// mini's shell prompt or its home path);
/// `collapse: Bool` folds or opens the Chief group in the active window; `card: Int` shows the
/// hover card of the opened workspace at that index. Answers the opened workspace ids.
enum ChiefCaptureControl {
static func methods(services: AppServices) -> [ControlMethod] {
[
.async("debug.chief_workspaces") { [weak services] call in
guard let services else { throw ControlError(code: "unavailable", message: "no services") }
let names = (call.params["open"]?.arrayValue ?? []).compactMap(\.stringValue)
let command = call.params["command"]?.stringValue
let cwd = call.params["cwd"]?.stringValue
guard let chief = await localChief(services) else {
throw ControlError(code: "unavailable", message: "no local Chief machine connected")
}
let machineID = await MainActor.run { chief.machineID }
var ids: [String] = []
for name in names {
let window = await MainActor.run { services.windows.targetWindow(preferring: services.windows.active?.state.id) }
ids.append(try await services.windows.createWorkspace(WorkspaceSpawn(cwd: cwd, name: name, command: command), on: chief.daemon, into: window))
}
if let collapse = call.params["collapse"]?.boolValue {
try await MainActor.run { try fold(services, chief: machineID, collapse: collapse) }
}
if let index = call.params["card"]?.intValue {
guard ids.indices.contains(index) else { throw ControlError.invalidParams("card names no opened workspace") }
try await MainActor.run { try showCard(services, workspace: ids[index]) }
}
return .object(["machine": .string(machineID), "workspaces": .array(ids.map(JSONValue.string))])
}.withDeadline(.fixed(.seconds(60))),
]
}

/// The local Chief machine once its daemon is connected; nil when the call's deadline cancels
/// the wait (`ControlDeadline`).
@MainActor
private static func localChief(_ services: AppServices) async -> ServerMachineSession? {
for await chief in ObservationStream({ services.machines.servers.first { $0.reach.isLocalChief && $0.isConnected } })
where chief != nil { return chief }
return nil
}

@MainActor
private static func fold(_ services: AppServices, chief: String, collapse: Bool) throws {
guard let sidebar = services.windows.active?.sidebar else { throw ControlError(code: "unavailable", message: "no active window") }
let section = CmuxNextSidebar.SectionID.machine(CmuxNextSidebar.MachineID(chief))
guard let shown = sidebar.model.section(section) else { throw ControlError(code: "unavailable", message: "the Chief group is not in the sidebar") }
if shown.isCollapsed != collapse { sidebar.handle(.toggleCollapse(.section(section))) }
}

@MainActor
private static func showCard(_ services: AppServices, workspace: String) throws {
guard let window = services.windows.active,
window.sidebar.container.sidebarView.showHoverCard(for: SidebarWorkspaceID(workspace)) else {
throw ControlError(code: "unavailable", message: "the workspace's row is not shown")
}
}
}

private extension ServerMachineSession {
var isConnected: Bool {
if case .connected = daemon.store.connectionState { true } else { false }
}
}
#endif
45 changes: 45 additions & 0 deletions scripts/cmux-next/media/tours/chief-group.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
{
"title": "The Chief's workspaces",
"steps": [
{
"title": "Launch",
"wait": 10
},
{
"title": "Two Chief workspaces and a card",
"rpc": {
"method": "debug.chief_workspaces",
"params": {
"open": [
"Native workflows runtime",
"Sidebar group drag"
],
"card": 0,
"command": "cat",
"cwd": "/tmp"
}
},
"wait": 3
},
{
"title": "The Chief group, folded",
"rpc": {
"method": "debug.chief_workspaces",
"params": {
"collapse": true
}
},
"wait": 2
},
{
"title": "The Chief group, open",
"rpc": {
"method": "debug.chief_workspaces",
"params": {
"collapse": false
}
},
"wait": 2
}
]
}
4 changes: 4 additions & 0 deletions tests/test-execution.toml
Original file line number Diff line number Diff line change
Expand Up @@ -1557,6 +1557,10 @@ lane = "linux-guard"
path = "tests/test_cmux_next_base_autofix_finishes.py"
lane = "linux-guard"

[[test]]
path = "tests/test_cmux_next_debug_only_controls.py"
lane = "linux-guard"

[[test]]
path = "tests/test_nightly_next_feed_rollback.py"
lane = "linux-guard"
Expand Down
67 changes: 67 additions & 0 deletions tests/test_cmux_next_debug_only_controls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
#!/usr/bin/env python3
"""Capture-only control methods are compiled out of release builds, not just hidden at runtime.

debug.chief_workspaces opens workspaces on the local Chief machine for a capture (the Chief's
sidebar group). A release build must not contain it: its file is wrapped in `#if DEBUG` as a whole,
and its one registration sits inside AppControl's `#if DEBUG` block.
"""
from __future__ import annotations

import re
import unittest
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
APP = ROOT / "Packages/macOS/CmuxNext/Sources/CmuxNextApp"
# Each capture-only control: its file and the type AppControl registers.
DEBUG_ONLY = {"Server/ChiefCaptureControl.swift": "ChiefCaptureControl"}


def debug_ranges(lines: list[str]) -> list[tuple[int, int]]:
"""The line ranges compiled only in DEBUG: `#if DEBUG` up to its `#else`, `#elseif` or `#endif`
(nested #if blocks counted; an `#else` branch is not DEBUG-only)."""
ranges, stack = [], []
for number, line in enumerate(lines):
directive = line.strip()
if directive.startswith("#if"):
stack.append([number, directive == "#if DEBUG"])
elif directive.startswith(("#else", "#elseif")) and stack:
start, is_debug = stack[-1]
if is_debug:
ranges.append((start, number))
stack[-1][1] = False
elif directive.startswith("#endif") and stack:
start, is_debug = stack.pop()
if is_debug:
ranges.append((start, number))
return ranges


class DebugOnlyControls(unittest.TestCase):
def test_the_whole_file_is_compiled_out_of_release(self):
for relative in DEBUG_ONLY:
with self.subTest(file=relative):
lines = (APP / relative).read_text().splitlines()
code = [i for i, line in enumerate(lines) if line.strip() and not line.strip().startswith("//")]
self.assertEqual(lines[code[0]].strip(), "#if DEBUG", "the file must open with #if DEBUG")
self.assertEqual(lines[code[-1]].strip(), "#endif", "the file must close with #endif")
self.assertIn((code[0], code[-1]), debug_ranges(lines), "one #if DEBUG block must span the file")

def test_every_registration_is_inside_a_debug_block(self):
sources = list(APP.rglob("*.swift"))
for type_name in DEBUG_ONLY.values():
uses = re.compile(rf"\b{type_name}\.methods\(")
found = 0
for path in sources:
lines = path.read_text().splitlines()
ranges = debug_ranges(lines)
for number, line in enumerate(lines):
if uses.search(line):
found += 1
with self.subTest(type=type_name, at=f"{path.relative_to(ROOT)}:{number + 1}"):
self.assertTrue(any(a < number < b for a, b in ranges), "registered outside #if DEBUG")
self.assertGreater(found, 0, f"{type_name} is never registered")


if __name__ == "__main__":
unittest.main()