Skip to content
Open
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
58 changes: 56 additions & 2 deletions src/api/components.zig
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,9 @@ fn buildListJson(allocator: std.mem.Allocator, s: *state_mod.State) ![]const u8
// Count managed instances from state
const instance_count = countInstancesFromState(s, comp.name);

// standalone = has dot-dir config but not yet imported into nullhub
// standalone is a lightweight hint that the default dot-dir install exists.
const has_dot_dir = hasStandaloneInstall(allocator, comp.name);
const standalone = has_dot_dir and instance_count == 0;
const standalone = has_dot_dir;
const installed = has_dot_dir or instance_count > 0;

try buf.print(
Expand Down Expand Up @@ -233,6 +233,60 @@ test "handleList returns valid JSON with all known components" {
try std.testing.expect(std.mem.indexOf(u8, json, "\"instance_count\"") != null);
}

test "handleList keeps standalone hint when default install is already managed" {
if (comptime builtin.os.tag == .windows) return error.SkipZigTest;

const allocator = std.testing.allocator;
var fixture = try test_helpers.TempPaths.init(allocator);
defer fixture.deinit();
const state_path = try fixture.paths.state(allocator);
defer allocator.free(state_path);
var s = state_mod.State.init(allocator, state_path);
defer s.deinit();

try s.addInstance("nullclaw", "default", .{ .version = "1.0.0" });

const home_dir = try fixture.path(allocator, "home");
defer allocator.free(home_dir);
try std_compat.fs.makeDirAbsolute(home_dir) catch |err| switch (err) {
error.PathAlreadyExists => {},
else => return err,
};

const dot_dir = try std.fs.path.join(allocator, &.{ home_dir, ".nullclaw" });
defer allocator.free(dot_dir);
try std_compat.fs.makeDirAbsolute(dot_dir) catch |err| switch (err) {
error.PathAlreadyExists => {},
else => return err,
};

const config_path = try std.fs.path.join(allocator, &.{ dot_dir, "config.json" });
defer allocator.free(config_path);
const file = try std_compat.fs.createFileAbsolute(config_path, .{ .truncate = true });
defer file.close();
try file.writeAll("{}\n");

const previous_home = std_compat.process.getEnvVarOwned(allocator, "HOME") catch null;
defer if (previous_home) |value| allocator.free(value);
defer if (builtin.os.tag != .windows) {
if (previous_home) |value| {
_ = std.c.setenv("HOME", value.ptr, 1);
} else {
_ = std.c.unsetenv("HOME");
}
};
if (std.c.setenv("HOME", home_dir.ptr, 1) != 0) return error.Unexpected;

const json = try handleList(allocator, &s);
defer allocator.free(json);

try std.testing.expect(std.mem.indexOf(u8, json, "\"name\":\"nullclaw\"") != null);
try std.testing.expect(std.mem.indexOf(u8, json, "\"name\":\"nullclaw\"",) == null);
try std.testing.expect(std.mem.indexOf(u8, json, "\"name\":\"nullclaw\",\"display_name\":\"NullClaw\"") != null);
try std.testing.expect(std.mem.indexOf(u8, json, "\"name\":\"nullclaw\",\"display_name\":\"NullClaw\",\"description\"") != null);
try std.testing.expect(std.mem.indexOf(u8, json, "\"name\":\"nullclaw\",\"display_name\":\"NullClaw\",\"description\":\"Autonomous AI agent runtime\",\"repo\":\"nullclaw/nullclaw\",\"alpha\":false,\"installable\":true,\"installed\":true,\"standalone\":true,\"instance_count\":1") != null);
}

test "handleManifest returns null for non-cached manifest" {
const allocator = std.testing.allocator;

Expand Down
85 changes: 73 additions & 12 deletions src/api/instance_runtime.zig
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
const std = @import("std");
const builtin = @import("builtin");
const std_compat = @import("compat");
const state_mod = @import("../core/state.zig");
const manager_mod = @import("../supervisor/manager.zig");
const paths_mod = @import("../core/paths.zig");
const health_mod = @import("../supervisor/health.zig");
const registry = @import("../installer/registry.zig");
const test_helpers = @import("../test_helpers.zig");

pub const Snapshot = struct {
status: manager_mod.Status,
Expand Down Expand Up @@ -114,18 +116,12 @@ fn isImportedStandalone(

const inst_dir = paths.instanceDir(allocator, component, name) catch return false;
defer allocator.free(inst_dir);
const real_dir = std_compat.fs.realpathAlloc(allocator, inst_dir) catch return false;
defer allocator.free(real_dir);

const home = std_compat.process.getEnvVarOwned(allocator, "HOME") catch
std_compat.process.getEnvVarOwned(allocator, "USERPROFILE") catch return false;
defer allocator.free(home);
const standalone_root = std.fmt.allocPrint(allocator, "{s}/.{s}", .{ home, component }) catch return false;
defer allocator.free(standalone_root);
const real_standalone_root = std_compat.fs.realpathAlloc(allocator, standalone_root) catch return false;
defer allocator.free(real_standalone_root);

return std.mem.eql(u8, real_dir, real_standalone_root);
if (std_compat.fs.realpathAlloc(allocator, inst_dir)) |real_dir| {
defer allocator.free(real_dir);
return !std.mem.eql(u8, real_dir, inst_dir);
} else |_| {
return false;
}
}

fn standalonePortConfigKey(component: []const u8) ?[]const u8 {
Expand Down Expand Up @@ -257,3 +253,68 @@ test "readPortFromConfig accepts string ports" {
defer allocator.free(normalized);
try std.testing.expectEqualStrings("127.0.0.1", normalized);
}

test "resolve treats custom-path imported standalone as running when health passes" {
if (builtin.os.tag == .windows) return error.SkipZigTest;

const HealthServerCtx = struct {
server: *std_compat.net.Server,

fn run(ctx: @This()) void {
var conn = ctx.server.accept() catch return;
defer conn.stream.close();

var buf: [1024]u8 = undefined;
_ = conn.stream.read(&buf) catch return;
conn.stream.writeAll(
"HTTP/1.1 200 OK\r\n" ++
"Content-Type: application/json\r\n" ++
"Content-Length: 15\r\n" ++
"Connection: close\r\n\r\n" ++
"{\"status\":\"ok\"}",
) catch return;
}
};

const allocator = std.testing.allocator;
var fixture = try test_helpers.TempPaths.init(allocator);
defer fixture.deinit();
try fixture.paths.ensureDirs();

const source_dir = try fixture.path(allocator, "custom-nullclaw-home");
defer allocator.free(source_dir);
try std_compat.fs.makeDirAbsolute(source_dir);

const source_config_path = try std.fs.path.join(allocator, &.{ source_dir, "config.json" });
defer allocator.free(source_config_path);
const source_config = try std_compat.fs.createFileAbsolute(source_config_path, .{ .truncate = true });
defer source_config.close();
try source_config.writeAll("{\"gateway\":{\"port\":43129},\"host\":\"127.0.0.1\"}");

const inst_parent = try std.fs.path.join(allocator, &.{ fixture.paths.root, "instances", "nullclaw" });
defer allocator.free(inst_parent);
try std_compat.fs.makeDirAbsolute(inst_parent);

const inst_dir = try fixture.paths.instanceDir(allocator, "nullclaw", "imported");
defer allocator.free(inst_dir);
try std_compat.fs.symLinkAbsolute(source_dir, inst_dir, .{ .is_directory = true });

const addr = try std_compat.net.Address.resolveIp("127.0.0.1", 43129);
var server = try addr.listen(.{});
defer server.deinit();
const thread = try std.Thread.spawn(.{}, HealthServerCtx.run, .{.{ .server = &server }});
defer thread.join();

var manager = manager_mod.Manager.init(allocator, fixture.paths);
defer manager.deinit();

const snapshot = resolve(allocator, fixture.paths, &manager, "nullclaw", "imported", .{
.version = "dev-local",
.auto_start = false,
.launch_mode = "gateway",
.verbose = false,
});

try std.testing.expectEqual(manager_mod.Status.running, snapshot.status);
try std.testing.expectEqual(@as(u16, 43129), snapshot.port);
}
Loading
Loading