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
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using ChilliCream.Nitro.CommandLine.Services.Mail;
using ChilliCream.Nitro.CommandLine.Services.Memory;
using ChilliCream.Nitro.CommandLine.Services.Notify;
using ChilliCream.Nitro.CommandLine.Services.Preferences;
using ChilliCream.Nitro.CommandLine.Services.Tasks;
using ChilliCream.Nitro.CommandLine.Services.Workspace;

Expand All @@ -27,6 +28,8 @@ public AgentCommand() : base("agent")
Subcommands.Add(new RegisterAgentCommand());
Subcommands.Add(new ListAgentCommand());
Subcommands.Add(new TakeoverAgentCommand());
Subcommands.Add(new BackupAgentCommand());
Subcommands.Add(new RestoreAgentCommand());
Subcommands.Add(new HookCommand());
Subcommands.Add(new HooksCommand());

Expand Down Expand Up @@ -60,6 +63,7 @@ private static async Task<int> ExecuteAsync(
var agentStore = services.GetRequiredService<IAgentStore>();
var timeProvider = services.GetRequiredService<TimeProvider>();
var mailWakeDaemonCoordinator = services.GetRequiredService<IMailWakeDaemonCoordinator>();
var boardPreferences = services.GetRequiredService<IBoardPreferencesStore>();

return await AgentTuiLauncher.RunAsync(
console,
Expand All @@ -70,6 +74,7 @@ private static async Task<int> ExecuteAsync(
timeProvider,
workspaceDirectory,
mailWakeDaemonCoordinator,
boardPreferences,
cancellationToken);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using ChilliCream.Nitro.CommandLine.Services.Mail;
using ChilliCream.Nitro.CommandLine.Services.Memory;
using ChilliCream.Nitro.CommandLine.Services.Notify;
using ChilliCream.Nitro.CommandLine.Services.Preferences;
using ChilliCream.Nitro.CommandLine.Services.Tasks;
using ChilliCream.Nitro.CommandLine.Services.Workspace;
using ChilliCream.Nitro.CommandLine.Tui.Agents;
Expand All @@ -22,8 +23,14 @@ namespace ChilliCream.Nitro.CommandLine.Commands.Agent;
internal static class AgentTuiLauncher
{
/// <summary>
/// Runs the TUI and owns its mail wake daemon. The board is an
/// observer: it takes no actor and refuses every write.
/// The longest the TUI waits on exit for the last board orientation change to be saved.
/// </summary>
private static readonly TimeSpan s_orientationSaveTimeout = TimeSpan.FromSeconds(2);

/// <summary>
/// Runs the TUI and owns its mail wake daemon, loading and saving the board's column
/// orientation through <paramref name="boardPreferences"/>. The board is an observer:
/// it takes no actor and refuses every write.
/// </summary>
public static Task<int> RunAsync(
INitroConsole console,
Expand All @@ -34,10 +41,11 @@ public static Task<int> RunAsync(
TimeProvider timeProvider,
string workspaceDirectory,
IMailWakeDaemonCoordinator mailWakeDaemonCoordinator,
IBoardPreferencesStore boardPreferences,
CancellationToken cancellationToken)
=> RunShellAsync(
console, taskStore, mailStore, memoryStore, agentStore,
timeProvider, workspaceDirectory, mailWakeDaemonCoordinator, cancellationToken);
timeProvider, workspaceDirectory, mailWakeDaemonCoordinator, boardPreferences, cancellationToken);

private static async Task<int> RunShellAsync(
INitroConsole console,
Expand All @@ -48,14 +56,19 @@ private static async Task<int> RunShellAsync(
TimeProvider timeProvider,
string workspaceDirectory,
IMailWakeDaemonCoordinator mailWakeDaemonCoordinator,
IBoardPreferencesStore boardPreferences,
CancellationToken cancellationToken)
{
// The saved choice is a small local file, so it is read even when the run is already cancelled.
var orientation = await boardPreferences.ReadOrientationAsync(CancellationToken.None);
var searchMode = new SearchMode(taskStore);
var treeView = new DependencyTreeView(taskStore, rootId: "");

using var quitCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);

var tabs = BuildTabs(taskStore, mailStore, memoryStore, agentStore, timeProvider);
var orientationWriter = new BoardOrientationWriter(boardPreferences);
var tabs = BuildTabs(
taskStore, mailStore, memoryStore, agentStore, timeProvider, orientationWriter, orientation);

var shell = new TuiShell(
tabs,
Expand Down Expand Up @@ -86,24 +99,35 @@ private static async Task<int> RunShellAsync(
{
// Stop background delivery even when the event loop fails.
await mailWakeDaemonCoordinator.StopAsync(CancellationToken.None);

// Persist the last orientation change before exit, bounded so a stuck disk cannot hang it.
await orientationWriter.DrainAsync(s_orientationSaveTimeout);
}

return ExitCodes.Success;
}

/// <summary>
/// Builds the Tasks, Mail, Agents, and Memory tabs in the order the
/// shell's tab strip renders them.
/// shell's tab strip renders them. The board starts in
/// <paramref name="boardOrientation"/> and hands each change to
/// <paramref name="orientationWriter"/>.
/// </summary>
internal static TuiTab[] BuildTabs(
ITaskStore taskStore,
IMailStore mailStore,
IMemoryStore memoryStore,
IAgentStore agentStore,
TimeProvider timeProvider)
TimeProvider timeProvider,
BoardOrientationWriter orientationWriter,
BoardOrientation boardOrientation)
{
var loader = new BoardDataLoader(taskStore, timeProvider);
var boardMode = new BoardMode(loader);
var boardMode = new BoardMode(loader, orientation: boardOrientation);

// A failed save keeps the in-memory choice, and saving never runs on the render loop.
boardMode.OrientationChanged += orientationWriter.Enqueue;

var tasksTab = new TuiTab("Tasks", mnemonic: 'T', boardMode, new KeyDispatcher(KeyMap.CreateDefaultGlobal()));

var mailTab = BuildMailTab(mailStore, agentStore, timeProvider);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
using ChilliCream.Nitro.CommandLine.Commands.Agent.Options;
using ChilliCream.Nitro.CommandLine.Helpers;
using ChilliCream.Nitro.CommandLine.Results;
using ChilliCream.Nitro.CommandLine.Services;
using ChilliCream.Nitro.CommandLine.Services.Workspace;

namespace ChilliCream.Nitro.CommandLine.Commands.Agent;

/// <summary>
/// Packs the project's <c>.nitro</c> directory and the repository's <c>.git/nitro</c>
/// directory into one zip archive.
/// </summary>
internal sealed class BackupAgentCommand : Command
{
public BackupAgentCommand() : base("backup")
{
Description = "Back up the agent workspace to a zip archive.";

Options.Add(Opt<ArchiveAgentOption>.Instance);
Options.Add(Opt<ForceBackupAgentOption>.Instance);
Options.Add(Opt<OptionalOutputFormatOption>.Instance);

this.AddExamples(
"agent backup --archive \"./nitro-backup.zip\"",
"agent backup --archive \"./nitro-backup.zip\" --force");

this.SetActionWithExceptionHandling(ExecuteAsync);
}

private static async Task<int> ExecuteAsync(
ICommandServices services,
ParseResult parseResult,
CancellationToken cancellationToken)
{
var console = services.GetRequiredService<INitroConsole>();
var fileSystem = services.GetRequiredService<IFileSystem>();
var timeProvider = services.GetRequiredService<TimeProvider>();
var resultHolder = services.GetRequiredService<IResultHolder>();

var currentDirectory = fileSystem.GetCurrentDirectory();
var archivePath = Path.GetFullPath(
parseResult.GetRequiredValue(Opt<ArchiveAgentOption>.Instance), currentDirectory);
var force = parseResult.GetValue(Opt<ForceBackupAgentOption>.Instance);

var (projectDirectory, gitWorkspaceDirectory) = ResolveDirectories(fileSystem, currentDirectory);

if (projectDirectory is null && gitWorkspaceDirectory is null)
{
throw ThrowHelper.NoAgentWorkspaceToBackUp();
}

var summary = await AgentWorkspaceArchive.WriteAsync(
archivePath,
projectDirectory,
gitWorkspaceDirectory,
force,
timeProvider,
cancellationToken);

if (console.IsHumanReadable)
{
var roots = string.Join(" and ", summary.Roots.Select(root => $"'{root}'"));
var files = summary.FileCount == 1 ? "file" : "files";

console.OkLine($"Backed up {summary.FileCount} {files} from {roots} to '{summary.Archive.EscapeMarkup()}'.");

return ExitCodes.Success;
}

resultHolder.SetResult(new ObjectResult(summary));

return ExitCodes.Success;
}

/// <summary>
/// Resolves the project <c>.nitro</c> directory and the git workspace directory to back
/// up, each null when it does not exist.
/// </summary>
private static (string? ProjectDirectory, string? GitWorkspaceDirectory) ResolveDirectories(
IFileSystem fileSystem,
string currentDirectory)
{
var (projectDirectory, gitWorkspaceDirectory) =
AgentWorkspaceArchive.ResolveDirectories(fileSystem, currentDirectory);

return (
fileSystem.DirectoryExists(projectDirectory) ? projectDirectory : null,
gitWorkspaceDirectory is not null && fileSystem.DirectoryExists(gitWorkspaceDirectory)
? gitWorkspaceDirectory
: null);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace ChilliCream.Nitro.CommandLine.Commands.Agent.Options;

internal sealed class ArchiveAgentOption : Option<string>
{
public ArchiveAgentOption() : base("--archive")
{
Description = "The path of the workspace archive (.zip), relative to the current directory";
Required = true;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace ChilliCream.Nitro.CommandLine.Commands.Agent.Options;

internal sealed class ForceBackupAgentOption : Option<bool>
{
public ForceBackupAgentOption() : base("--force")
{
Description = "Overwrite the archive if it already exists";
Required = false;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace ChilliCream.Nitro.CommandLine.Commands.Agent.Options;

internal sealed class ForceRestoreAgentOption : Option<bool>
{
public ForceRestoreAgentOption() : base("--force")
{
Description = "Restore without confirmation, even while other agents use the workspace or their activity cannot be checked";
Required = false;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace ChilliCream.Nitro.CommandLine.Commands.Agent.Options;

internal sealed class RestoreActorOption : Option<string>
{
public RestoreActorOption() : base("--actor")
{
Description = "The acting agent, which does not block the restore; allocate one with `nitro agent login`";
Required = false;
}
}
Loading
Loading