diff --git a/src/installer/orchestrator.zig b/src/installer/orchestrator.zig index ac1b190..9fba2d4 100644 --- a/src/installer/orchestrator.zig +++ b/src/installer/orchestrator.zig @@ -281,19 +281,40 @@ pub fn install( defer launch.deinit(); const effective_port = launch.effectiveHealthPort(runtime_port); - // 6. Register in state.json - s.addInstance(opts.component, opts.instance_name, .{ - .version = version, - .auto_start = true, - .launch_mode = launch_command, - .verbose = false, - }) catch return error.StateError; - s.save() catch return error.StateError; - - // 7. Start process via Manager - mgr.startInstance( + persistAndStartInstance( + s, opts.component, opts.instance_name, + version, + launch_command, + struct { + fn call( + ctx: *anyopaque, + component: []const u8, + name: []const u8, + binary_path: []const u8, + launch_args: []const []const u8, + port_arg: u16, + health: []const u8, + working_dir: []const u8, + config_path: []const u8, + primary_command: []const u8, + ) anyerror!void { + const manager: *manager_mod.Manager = @ptrCast(@alignCast(ctx)); + return manager.startInstance( + component, + name, + binary_path, + launch_args, + port_arg, + health, + working_dir, + config_path, + primary_command, + ); + } + }.call, + @ptrCast(mgr), bin_path, launch.argv, effective_port, @@ -301,7 +322,10 @@ pub fn install( inst_dir, "", launch.primary_command, - ) catch return error.StartFailed; + ) catch |err| switch (err) { + error.StateError => return error.StateError, + error.StartFailed => return error.StartFailed, + }; return .{ .version = version, @@ -342,6 +366,62 @@ fn resolveConfiguredPort( return findNextAvailablePort(allocator, default_port, paths, state); } +fn persistAndStartInstance( + s: *state_mod.State, + component: []const u8, + name: []const u8, + version: []const u8, + launch_mode: []const u8, + startFn: *const fn ( + ctx: *anyopaque, + component: []const u8, + name: []const u8, + binary_path: []const u8, + launch_args: []const []const u8, + port: u16, + health_endpoint: []const u8, + working_dir: []const u8, + config_path: []const u8, + primary_command: []const u8, + ) anyerror!void, + ctx: *anyopaque, + binary_path: []const u8, + launch_args: []const []const u8, + port: u16, + health_endpoint: []const u8, + working_dir: []const u8, + config_path: []const u8, + primary_command: []const u8, +) error{ StateError, StartFailed }!void { + s.addInstance(component, name, .{ + .version = version, + .auto_start = true, + .launch_mode = launch_mode, + .verbose = false, + }) catch return error.StateError; + s.save() catch { + _ = s.removeInstance(component, name); + return error.StateError; + }; + + startFn( + ctx, + component, + name, + binary_path, + launch_args, + port, + health_endpoint, + working_dir, + config_path, + primary_command, + ) catch { + _ = s.removeInstance(component, name); + s.save() catch return error.StateError; + return error.StartFailed; + }; +} + fn findNextAvailablePort( allocator: std.mem.Allocator, start: u16, @@ -992,3 +1072,63 @@ test "injectHomeField adds home to JSON object" { try std.testing.expect(std.mem.indexOf(u8, result, "\"home\":\"/tmp/inst\"") != null); try std.testing.expect(std.mem.indexOf(u8, result, "\"provider\":\"openrouter\"") != null); } + +test "persistAndStartInstance rolls back state entry when start fails" { + const allocator = std.testing.allocator; + var tmp_dir = std.testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + + const root = try std_compat.fs.Dir.wrap(tmp_dir.dir).realpathAlloc(allocator, "."); + defer allocator.free(root); + + var p = try paths_mod.Paths.init(allocator, root); + defer p.deinit(allocator); + + const state_path = try p.state(allocator); + defer allocator.free(state_path); + var s = state_mod.State.init(allocator, state_path); + defer s.deinit(); + + const instances_dir = try std.fs.path.join(allocator, &.{ root, "instances" }); + defer allocator.free(instances_dir); + try std_compat.fs.makeDirAbsolute(instances_dir); + + const start_result = persistAndStartInstance( + &s, + "nullclaw", + "demo", + "1.0.0", + "gateway", + struct { + fn call( + _: *anyopaque, + _: []const u8, + _: []const u8, + _: []const u8, + _: []const []const u8, + _: u16, + _: []const u8, + _: []const u8, + _: []const u8, + _: []const u8, + ) anyerror!void { + return error.SpawnFailed; + } + }.call, + undefined, + "/tmp/fake-binary", + &.{"--help"}, + 0, + "/health", + root, + "", + "gateway", + ); + try std.testing.expectError(error.StartFailed, start_result); + + try std.testing.expect(s.getInstance("nullclaw", "demo") == null); + + var reloaded = try state_mod.State.load(allocator, state_path); + defer reloaded.deinit(); + try std.testing.expect(reloaded.getInstance("nullclaw", "demo") == null); +}