Skip to content
66 changes: 60 additions & 6 deletions src/api/components.zig
Original file line number Diff line number Diff line change
Expand Up @@ -62,18 +62,19 @@ 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 = dot-dir config exists without a managed NullHub instance
// standalone = dot-dir config exists and can be offered as an import source.
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(
"{{\"name\":\"{s}\",\"display_name\":\"{s}\",\"description\":\"{s}\",\"repo\":\"{s}\",\"alpha\":{s},\"installable\":{s},\"installed\":{s},\"standalone\":{s},\"instance_count\":{d}}}",
"{{\"name\":\"{s}\",\"display_name\":\"{s}\",\"description\":\"{s}\",\"repo\":\"{s}\",\"stage\":\"{s}\",\"alpha\":{s},\"installable\":{s},\"installed\":{s},\"standalone\":{s},\"instance_count\":{d}}}",
.{
comp.name,
comp.display_name,
comp.description,
comp.repo,
comp.stage,
if (comp.is_alpha) "true" else "false",
if (comp.installable) "true" else "false",
if (installed) "true" else "false",
Expand Down Expand Up @@ -234,15 +235,68 @@ test "handleList returns valid JSON with all known components" {
try std.testing.expect(std.mem.indexOf(u8, json, "\"nullclaw/nullwatch\"") != null);

// Verify structural fields
try std.testing.expect(std.mem.indexOf(u8, json, "\"stage\"") != null);
try std.testing.expect(std.mem.indexOf(u8, json, "\"alpha\"") != null);
try std.testing.expect(std.mem.indexOf(u8, json, "\"installable\"") != null);
try std.testing.expectEqual(@as(usize, 3), std.mem.count(u8, json, "\"installable\":true"));
try std.testing.expectEqual(@as(usize, 2), std.mem.count(u8, json, "\"alpha\":true"));
try std.testing.expectEqual(@as(usize, 2), std.mem.count(u8, json, "\"alpha\":false"));
try std.testing.expectEqual(@as(usize, 4), std.mem.count(u8, json, "\"installable\":true"));
try std.testing.expectEqual(@as(usize, 1), std.mem.count(u8, json, "\"stage\":\"alpha\""));
try std.testing.expectEqual(@as(usize, 2), std.mem.count(u8, json, "\"stage\":\"beta\""));
try std.testing.expectEqual(@as(usize, 1), std.mem.count(u8, json, "\"alpha\":true"));
try std.testing.expectEqual(@as(usize, 3), std.mem.count(u8, json, "\"alpha\":false"));
try std.testing.expect(std.mem.indexOf(u8, json, "\"installed\"") != null);
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\",\"display_name\":\"NullClaw\",\"description\":\"Autonomous AI agent runtime\",\"repo\":\"nullclaw/nullclaw\",\"stage\":\"\",\"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
95 changes: 76 additions & 19 deletions src/api/instance_runtime.zig
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
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");
const imported_standalone_storage_mode = "imported-standalone";

pub const Snapshot = struct {
status: manager_mod.Status,
Expand Down Expand Up @@ -102,30 +105,13 @@ fn normalizeHealthHost(allocator: std.mem.Allocator, host: []const u8) ![]u8 {
}

fn isImportedStandalone(
allocator: std.mem.Allocator,
paths: paths_mod.Paths,
component: []const u8,
name: []const u8,
entry: state_mod.InstanceEntry,
) bool {
const known = registry.findKnownComponent(component) orelse return false;
if (standalonePortConfigKey(component) == null) return false;
if (!isStandaloneLaunchMode(component, entry.launch_mode, known.default_launch_command)) return false;

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);
return std.mem.eql(u8, entry.storage_mode, imported_standalone_storage_mode) and entry.source_path.len > 0;
}

fn standalonePortConfigKey(component: []const u8) ?[]const u8 {
Expand Down Expand Up @@ -173,7 +159,7 @@ fn deriveImportedStandaloneSnapshot(
entry: state_mod.InstanceEntry,
manager_snapshot: ?Snapshot,
) ?Snapshot {
if (!isImportedStandalone(allocator, paths, component, name, entry)) return null;
if (!isImportedStandalone(component, entry)) return null;

const known = registry.findKnownComponent(component) orelse return null;
const port_key = standalonePortConfigKey(component) orelse return null;
Expand Down Expand Up @@ -257,3 +243,74 @@ 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 addr = try std_compat.net.Address.resolveIp("127.0.0.1", 0);
var server = try addr.listen(.{});
defer server.deinit();
const port = server.listen_address.in.getPort();

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();
const source_config_json = try std.fmt.allocPrint(allocator, "{{\"gateway\":{{\"port\":{d}}},\"host\":\"127.0.0.1\"}}", .{port});
defer allocator.free(source_config_json);
try source_config.writeAll(source_config_json);

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 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,
.storage_mode = imported_standalone_storage_mode,
.source_path = source_dir,
});

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