feat(cli): add execution_mode config setting for local/remote/auto preference

Add execution_mode enum (local/remote/auto) to config for persistent
control over command execution behavior. Removes --local/--remote flags
from commands to simplify user workflow - no need to check server
connection status manually.

Changes:
- config.zig: Add ExecutionMode enum, execution_mode field, parsing/serialization
- mode.zig: Update detect() to check execution_mode == .local
- init.zig: Add --mode flag (local/remote/auto) for setting during init
- info.zig: Use config execution_mode, removed --local/--remote flags
- run.zig: Use config execution_mode, removed --local/--remote flags
- exec/mod.zig: Use config execution_mode, removed --local/--remote flags

Priority order for determining execution mode:
1. Config setting (execution_mode: local/remote/auto)
2. Auto-detect only if config is 'auto'

Users set mode once during init:
  ml init --mode=local     # Always use local
  ml init --mode=remote    # Always use remote
  ml init --mode=auto      # Auto-detect (default)
This commit is contained in:
Jeremie Fraeys 2026-03-05 12:18:30 -05:00
parent cf8115c670
commit a36a5e4522
No known key found for this signature in database
6 changed files with 57 additions and 55 deletions

View file

@ -29,8 +29,6 @@ pub const ExecOptions = struct {
pub fn run(allocator: std.mem.Allocator, args: []const []const u8) !void {
var flags = core.flags.CommonFlags{};
var priority: u8 = 5;
var force_local = false;
var force_remote = false;
// Find "--" separator
var sep_index: ?usize = null;
@ -73,10 +71,6 @@ pub fn run(allocator: std.mem.Allocator, args: []const []const u8) !void {
i += 1;
} else if (std.mem.eql(u8, arg, "--dry-run")) {
options.dry_run = true;
} else if (std.mem.eql(u8, arg, "--local")) {
force_local = true;
} else if (std.mem.eql(u8, arg, "--remote")) {
force_remote = true;
} else if (std.mem.eql(u8, arg, "--hypothesis") and i + 1 < pre.len) {
options.hypothesis = pre[i + 1];
i += 1;
@ -128,18 +122,16 @@ pub fn run(allocator: std.mem.Allocator, args: []const []const u8) !void {
mut.deinit(allocator);
}
// Determine execution mode
// Determine execution mode: config > auto-detect
var exec_mode: ExecMode = undefined;
if (force_local) {
if (cfg.execution_mode == .local) {
exec_mode = .local;
} else if (force_remote) {
} else if (cfg.execution_mode == .remote) {
exec_mode = .remote;
} else {
// Auto-detect
const mode_result = try mode.detect(allocator, cfg);
exec_mode = if (mode.isOnline(mode_result.mode)) .remote else .local;
if (mode_result.warning) |warn| {
std.log.info("{s}", .{warn});
}
@ -182,10 +174,8 @@ fn printUsage() !void {
\\ --gpu <n> GPU devices requested (default: 0)
\\ --gpu-memory <spec> GPU memory spec
\\
\\Execution mode:
\\ --local Force local execution
\\ --remote Force remote (fail if offline)
\\ (auto-detect if neither flag set)
\\Execution mode is controlled by execution_mode setting in config.
\\Use 'ml init --mode=local|remote|auto' to change.
\\
\\Research context:
\\ --hypothesis <text> What you're testing

View file

@ -10,16 +10,12 @@ const common = @import("common.zig");
pub const Options = struct {
json: bool = false,
base: ?[]const u8 = null,
local: bool = false,
remote: bool = false,
};
pub fn run(allocator: std.mem.Allocator, args: []const []const u8) !void {
var flags = core.flags.CommonFlags{};
var base: ?[]const u8 = null;
var target_path: ?[]const u8 = null;
var force_local = false;
var force_remote = false;
var i: usize = 0;
while (i < args.len) : (i += 1) {
@ -29,10 +25,6 @@ pub fn run(allocator: std.mem.Allocator, args: []const []const u8) !void {
} else if (std.mem.eql(u8, arg, "--base") and i + 1 < args.len) {
base = args[i + 1];
i += 1;
} else if (std.mem.eql(u8, arg, "--local")) {
force_local = true;
} else if (std.mem.eql(u8, arg, "--remote")) {
force_remote = true;
} else if (std.mem.startsWith(u8, arg, "--help")) {
return printUsage();
} else if (std.mem.startsWith(u8, arg, "--")) {
@ -57,9 +49,8 @@ pub fn run(allocator: std.mem.Allocator, args: []const []const u8) !void {
mut_cfg.deinit(allocator);
}
// Determine execution mode
const mode_result = try mode.detect(allocator, cfg);
const use_remote = if (force_local) false else if (force_remote) true else mode.isOnline(mode_result.mode);
// Determine execution mode: config > auto-detect
const use_remote = if (cfg.execution_mode == .local) false else if (cfg.execution_mode == .remote) true else mode.isOnline((try mode.detect(allocator, cfg)).mode);
if (use_remote) {
// Try remote query first
@ -226,12 +217,10 @@ fn displayRunInfo(allocator: std.mem.Allocator, root: std.json.ObjectMap, manife
fn printUsage() !void {
std.debug.print("Usage:\n", .{});
std.debug.print("\tml info <run_dir_or_manifest_path_or_id> [--json] [--base <path>] [--local] [--remote]\n", .{});
std.debug.print("\tml info <run_dir_or_manifest_path_or_id> [--json] [--base <path>]\n", .{});
std.debug.print("\nOptions:\n", .{});
std.debug.print("\t--json\t\tOutput machine-readable JSON\n", .{});
std.debug.print("\t--base <path>\tBase path for resolving run manifests\n", .{});
std.debug.print("\t--local\t\tForce local manifest lookup\n", .{});
std.debug.print("\t--remote\tForce remote server query (fails if offline)\n", .{});
}
test "resolveManifestPath uses run_manifest.json for directories" {

View file

@ -20,10 +20,22 @@ pub fn run(allocator: std.mem.Allocator, args: []const []const u8) !void {
const cli_artifact_path = core.flags.parseKVFlag(remaining.items, "artifact-path");
const cli_sync_uri = core.flags.parseKVFlag(remaining.items, "sync-uri");
const force_local = core.flags.parseBoolFlag(remaining.items, "local");
const cli_mode = core.flags.parseKVFlag(remaining.items, "mode");
var cfg = try Config.loadWithOverrides(allocator, cli_tracking_uri, cli_artifact_path, cli_sync_uri);
defer cfg.deinit(allocator);
// Apply mode preference from --mode flag
if (cli_mode) |mode| {
if (std.mem.eql(u8, mode, "local")) {
cfg.execution_mode = .local;
} else if (std.mem.eql(u8, mode, "remote")) {
cfg.execution_mode = .remote;
} else {
cfg.execution_mode = .auto;
}
}
// Print resolved config
std.debug.print("Resolved config:\n", .{});
std.debug.print("\ttracking_uri = {s}", .{cfg.tracking_uri});
@ -42,6 +54,11 @@ pub fn run(allocator: std.mem.Allocator, args: []const []const u8) !void {
std.debug.print("\n", .{});
}
std.debug.print("\tsync_uri = {s}\n", .{if (cfg.sync_uri.len > 0) cfg.sync_uri else "(not set)"});
std.debug.print("\texecution_mode = {s}\n", .{switch (cfg.execution_mode) {
.local => "local",
.remote => "remote",
.auto => "auto",
}});
std.debug.print("\n", .{});
// Default path: create config only (no DB speculatively)
@ -102,6 +119,7 @@ fn printUsage() void {
std.debug.print("Initialize FetchML configuration\n\n", .{});
std.debug.print("Options:\n", .{});
std.debug.print("\t--local\t\t\tCreate local database now (default: config only)\n", .{});
std.debug.print("\t--mode MODE\t\tDefault execution mode: local, remote, or auto (default: auto)\n", .{});
std.debug.print("\t--tracking-uri URI\tSQLite database path (e.g., sqlite://./fetch_ml.db)\n", .{});
std.debug.print("\t--artifact-path PATH\tArtifacts directory (default: ./experiments/)\n", .{});
std.debug.print("\t--sync-uri URI\t\tServer to sync with (e.g., wss://ml.company.com/ws)\n", .{});

View file

@ -32,8 +32,6 @@ pub const RunOptions = struct {
/// Unified run command - transparently handles local and remote execution
pub fn execute(allocator: std.mem.Allocator, args: []const []const u8) !void {
var flags = core.flags.CommonFlags{};
var force_local = false;
var force_remote = false;
// Find "--" separator
var sep_index: ?usize = null;
@ -80,10 +78,6 @@ pub fn execute(allocator: std.mem.Allocator, args: []const []const u8) !void {
options.validate = true;
} else if (std.mem.eql(u8, arg, "--explain")) {
options.explain = true;
} else if (std.mem.eql(u8, arg, "--local")) {
force_local = true;
} else if (std.mem.eql(u8, arg, "--remote")) {
force_remote = true;
} else if (std.mem.eql(u8, arg, "--force")) {
options.force = true;
} else if (std.mem.eql(u8, arg, "--hypothesis") and i + 1 < pre.len) {
@ -135,12 +129,12 @@ pub fn execute(allocator: std.mem.Allocator, args: []const []const u8) !void {
mut.deinit(allocator);
}
// Determine execution mode
// Determine execution mode: config > auto-detect
var run_mode: RunMode = undefined;
if (force_local) {
if (cfg.execution_mode == .local) {
run_mode = .local;
} else if (force_remote) {
} else if (cfg.execution_mode == .remote) {
run_mode = .remote;
} else {
const mode_result = try mode.detect(allocator, cfg);
@ -203,13 +197,12 @@ fn explainJob(allocator: std.mem.Allocator, job_name: []const u8, options: *cons
fn printUsage() !void {
std.debug.print("Usage: ml run <job_name> [options] [-- <args>]\n", .{});
std.debug.print("\nUnified run command - handles both local and remote execution.\n", .{});
std.debug.print("Execution mode is controlled by execution_mode setting in config.\n", .{});
std.debug.print("\nOptions:\n", .{});
std.debug.print(" --priority <1-10> Job priority (default: 5)\n", .{});
std.debug.print(" --cpu <n> CPU cores requested (default: 1)\n", .{});
std.debug.print(" --memory <n> Memory GB requested (default: 4)\n", .{});
std.debug.print(" --gpu <n> GPU devices requested (default: 0)\n", .{});
std.debug.print(" --local Force local execution\n", .{});
std.debug.print(" --remote Force remote execution\n", .{});
std.debug.print(" --dry-run Show what would happen\n", .{});
std.debug.print(" --validate Validate job without running\n", .{});
std.debug.print(" --explain Explain what will happen\n", .{});

View file

@ -1,6 +1,12 @@
const std = @import("std");
const security = @import("security.zig");
pub const ExecutionMode = enum {
local,
remote,
auto,
};
pub const ExperimentConfig = struct {
name: []const u8,
entrypoint: []const u8,
@ -15,8 +21,6 @@ pub const Config = struct {
artifact_path: []const u8,
// Sync target URI (for pushing local runs to server)
sync_uri: []const u8,
// Force local mode regardless of server config
force_local: bool,
// Experiment configuration ([experiment] section)
experiment: ?ExperimentConfig,
@ -33,6 +37,9 @@ pub const Config = struct {
default_gpu: u8,
default_gpu_memory: ?[]const u8,
// Execution mode preference (local, remote, auto)
execution_mode: ExecutionMode,
// CLI behavior defaults
default_dry_run: bool,
default_validate: bool,
@ -135,7 +142,6 @@ pub const Config = struct {
.tracking_uri = try allocator.dupe(u8, "sqlite://./fetch_ml.db"),
.artifact_path = try allocator.dupe(u8, "./experiments/"),
.sync_uri = try allocator.dupe(u8, ""),
.force_local = false,
.experiment = null,
.worker_host = try allocator.dupe(u8, ""),
.worker_user = try allocator.dupe(u8, ""),
@ -146,6 +152,7 @@ pub const Config = struct {
.default_memory = 8,
.default_gpu = 0,
.default_gpu_memory = null,
.execution_mode = .auto,
.default_dry_run = false,
.default_validate = false,
.default_json = false,
@ -162,7 +169,6 @@ pub const Config = struct {
.tracking_uri = "",
.artifact_path = "",
.sync_uri = "",
.force_local = false,
.experiment = null,
.worker_host = "",
.worker_user = "",
@ -173,6 +179,7 @@ pub const Config = struct {
.default_memory = 8,
.default_gpu = 0,
.default_gpu_memory = null,
.execution_mode = .auto,
.default_dry_run = false,
.default_validate = false,
.default_json = false,
@ -219,8 +226,6 @@ pub const Config = struct {
config.artifact_path = try allocator.dupe(u8, value);
} else if (std.mem.eql(u8, key, "sync_uri")) {
config.sync_uri = try allocator.dupe(u8, value);
} else if (std.mem.eql(u8, key, "force_local")) {
config.force_local = std.mem.eql(u8, value, "true");
} else if (std.mem.eql(u8, key, "worker_host")) {
config.worker_host = try allocator.dupe(u8, value);
} else if (std.mem.eql(u8, key, "worker_user")) {
@ -247,8 +252,14 @@ pub const Config = struct {
config.default_validate = std.mem.eql(u8, value, "true");
} else if (std.mem.eql(u8, key, "default_json")) {
config.default_json = std.mem.eql(u8, value, "true");
} else if (std.mem.eql(u8, key, "default_priority")) {
config.default_priority = try std.fmt.parseInt(u8, value, 10);
} else if (std.mem.eql(u8, key, "execution_mode")) {
if (std.mem.eql(u8, value, "local")) {
config.execution_mode = .local;
} else if (std.mem.eql(u8, value, "remote")) {
config.execution_mode = .remote;
} else {
config.execution_mode = .auto;
}
}
}
}
@ -291,7 +302,6 @@ pub const Config = struct {
\\tracking_uri = "{s}"
\\artifact_path = "{s}"
\\sync_uri = "{s}"
\\force_local = {s}
\\{s}
\\# Server config (for runner mode)
\\worker_host = "{s}"
@ -306,6 +316,7 @@ pub const Config = struct {
\\default_gpu = {d}
\\{s}
\\# CLI behavior defaults
\\execution_mode = "{s}"
\\default_dry_run = {s}
\\default_validate = {s}
\\default_json = {s}
@ -315,7 +326,6 @@ pub const Config = struct {
self.tracking_uri,
self.artifact_path,
self.sync_uri,
if (self.force_local) "true" else "false",
if (self.experiment) |exp| try std.fmt.allocPrint(allocator,
\\n[experiment]\nname = "{s}"\nentrypoint = "{s}"\n
, .{ exp.name, exp.entrypoint }) else "",
@ -330,6 +340,11 @@ pub const Config = struct {
if (self.default_gpu_memory) |gpu_mem| try std.fmt.allocPrint(allocator,
\\default_gpu_memory = "{s}"\n
, .{gpu_mem}) else "",
switch (self.execution_mode) {
.local => "local",
.remote => "remote",
.auto => "auto",
},
if (self.default_dry_run) "true" else "false",
if (self.default_validate) "true" else "false",
if (self.default_json) "true" else "false",
@ -367,9 +382,6 @@ pub const Config = struct {
if (other.sync_uri.len > 0) {
self.sync_uri = other.sync_uri;
}
if (other.force_local) {
self.force_local = other.force_local;
}
if (other.experiment) |exp| {
if (self.experiment == null) {
self.experiment = exp;

View file

@ -32,8 +32,8 @@ pub fn detect(allocator: std.mem.Allocator, cfg: Config) !DetectionResult {
}
}
// Priority 2: force_local in config
if (cfg.force_local) {
// Priority 2: execution_mode in config
if (cfg.execution_mode == .local) {
return .{ .mode = .offline, .warning = null };
}