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
8 changes: 8 additions & 0 deletions src/api/instances.zig
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const state_mod = @import("../core/state.zig");
const manager_mod = @import("../supervisor/manager.zig");
const paths_mod = @import("../core/paths.zig");
const registry = @import("../installer/registry.zig");
const orchestrator = @import("../installer/orchestrator.zig");
const downloader = @import("../installer/downloader.zig");
const platform = @import("../core/platform.zig");
const helpers = @import("helpers.zig");
Expand Down Expand Up @@ -4499,6 +4500,13 @@ pub fn handleImport(allocator: std.mem.Allocator, s: *state_mod.State, paths: pa
return helpers.serverError();
};

// Best effort: registration must also provision the component's declared
// UI modules, otherwise an import into an already-running hub still needs
// a restart (or a manual API call) before the UI is available.
if (registry.findKnownComponent(component)) |comp| {
orchestrator.ensureComponentUiModules(allocator, paths, comp);
}

const response_body = buildImportResponse(allocator, instance_name, source_dir) catch return helpers.serverError();
return jsonOk(response_body);
}
Expand Down
2 changes: 1 addition & 1 deletion src/installer/downloader.zig
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ pub fn download(allocator: std.mem.Allocator, url: []const u8, dest_path: []cons

const result = std_compat.process.Child.run(.{
.allocator = allocator,
.argv = &.{ "curl", "-sfL", "-o", tmp_path, url },
.argv = &.{ "curl", "-sfL", "--max-time", prereqs.curl_max_time_secs, "-o", tmp_path, url },
}) catch return error.DownloadFailed;
defer allocator.free(result.stdout);
defer allocator.free(result.stderr);
Expand Down
79 changes: 64 additions & 15 deletions src/installer/orchestrator.zig
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,9 @@ pub fn install(
error.StartFailed => return error.StartFailed,
};

// Best effort: a failed UI module install must not fail the instance.
ensureComponentUiModules(allocator, p, comp);

return .{
.version = version,
.instance_name = opts.instance_name,
Expand Down Expand Up @@ -1251,27 +1254,20 @@ pub fn installUiModule(
ui_mod: registry.UiModuleRef,
version: []const u8,
) !void {
const dest = try p.uiModule(allocator, ui_mod.name, version);
defer allocator.free(dest);

// A local sibling checkout is the authoritative source in dev setups, so
// refresh `dev-local` from it on every install. The build goes to staging
// and only replaces the live module on success — a failed npm run leaves
// the previously working module in place.
if (!builtin.is_test) {
if (findLocalUiModuleDir(allocator, ui_mod.name)) |module_dir| {
defer allocator.free(module_dir);

const dev_local_version = "dev-local";
const dev_local_dest = try p.uiModule(allocator, ui_mod.name, dev_local_version);
defer allocator.free(dev_local_dest);

std_compat.fs.deleteTreeAbsolute(dev_local_dest) catch |err| switch (err) {
error.FileNotFound => {},
else => {},
};

if (buildLocalUiModuleFromDir(allocator, module_dir, dev_local_dest)) return;
return error.DownloadFailed;
return installFromLocalSource(allocator, p, ui_mod.name, module_dir);
}
}

const dest = try p.uiModule(allocator, ui_mod.name, version);
defer allocator.free(dest);

// Skip if already installed
if (ui_modules_mod.isModuleInstalled(dest)) return;

Expand All @@ -1281,6 +1277,26 @@ pub fn installUiModule(
};
}

/// Build `module_dir` into staging and promote it over the `dev-local`
/// install. Staging is discarded on failure so an existing `module.js`
/// survives a broken build.
fn installFromLocalSource(
allocator: std.mem.Allocator,
p: paths_mod.Paths,
module_name: []const u8,
module_dir: []const u8,
) !void {
const dest = try p.uiModule(allocator, module_name, "dev-local");
defer allocator.free(dest);

// Clears staging only — `dest` stays untouched until the build succeeds.
const staging = try ui_modules_mod.prepareStaging(allocator, dest);
defer allocator.free(staging);

if (!buildLocalUiModuleFromDir(allocator, module_dir, staging)) return error.DownloadFailed;
try ui_modules_mod.promoteStagedModule(allocator, staging, dest);
}

/// Build a UI module from a local sibling repository.
/// Looks for ../{module_name}/ relative to CWD, runs `npm run build:module`,
/// and copies the dist/ output to dest_dir.
Expand Down Expand Up @@ -1358,6 +1374,39 @@ pub fn syncLocalUiModules(allocator: std.mem.Allocator, p: paths_mod.Paths) void
}
}

/// Install the component's declared UI modules, best effort. A failed install
/// never fails the caller — the module can still be installed via the API
/// endpoint afterwards.
///
/// Modules that already exist (in **any** version directory) are skipped, so
/// the two startup passes — `syncLocalUiModules` (local build) and
/// `syncMissingUiModules` (release download) — never build or fetch the same
/// module twice. `builtin.is_test` keeps the npm/network boundary offline; the
/// skip decision and the staged promotion it guards are covered by tests.
pub fn ensureComponentUiModules(allocator: std.mem.Allocator, p: paths_mod.Paths, comp: registry.KnownComponent) void {
for (comp.ui_modules) |ui_mod| {
if (ui_modules_mod.isInstalledUnderRoot(allocator, p.root, ui_mod.name)) continue;
if (builtin.is_test) continue;

installUiModule(allocator, p, ui_mod, "latest") catch |err| {
std.log.warn("ui module {s} auto-install failed: {s}", .{ ui_mod.name, @errorName(err) });
};
}
}

/// Download declared UI modules for components that have at least one
/// instance. Complements syncLocalUiModules (which builds from a local
/// sibling checkout) by covering release installs, so a fresh binary ends
/// up with a working chat UI without a manual API call.
pub fn syncMissingUiModules(allocator: std.mem.Allocator, p: paths_mod.Paths, s: *state_mod.State) void {
for (&registry.known_components) |*comp| {
if (comp.ui_modules.len == 0) continue;
const instances = s.instances.getPtr(comp.name) orelse continue;
if (instances.count() == 0) continue;
ensureComponentUiModules(allocator, p, comp.*);
}
}

/// Write content to a file at an absolute path, creating the file if needed.
fn writeFile(path: []const u8, content: []const u8) !void {
const file = try std_compat.fs.createFileAbsolute(path, .{});
Expand Down
5 changes: 5 additions & 0 deletions src/installer/prereqs.zig
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ const builtin = @import("builtin");

const log = std.log.scoped(.prereqs);

/// Wall-clock ceiling for every outbound curl invocation, in seconds.
/// Registry metadata fetches and artifact downloads run on the startup path,
/// so an unbounded request must never be able to hang the hub.
pub const curl_max_time_secs = "30";

pub const EnsureError = error{
ToolInstallFailed,
UnsupportedPlatform,
Expand Down
11 changes: 9 additions & 2 deletions src/installer/registry.zig
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ pub fn fetchLatestRelease(allocator: std.mem.Allocator, repo: []const u8) !std.j

const result = std_compat.process.Child.run(.{
.allocator = allocator,
.argv = &.{ "curl", "-sfL", "-H", "Accept: application/vnd.github+json", url },
.argv = &.{ "curl", "-sfL", "--max-time", prereqs.curl_max_time_secs, "-H", "Accept: application/vnd.github+json", url },
}) catch return error.FetchFailed;
defer allocator.free(result.stdout);
defer allocator.free(result.stderr);
Expand All @@ -225,7 +225,7 @@ pub fn fetchReleaseByTag(allocator: std.mem.Allocator, repo: []const u8, tag: []

const result = std_compat.process.Child.run(.{
.allocator = allocator,
.argv = &.{ "curl", "-sfL", "-H", "Accept: application/vnd.github+json", url },
.argv = &.{ "curl", "-sfL", "--max-time", prereqs.curl_max_time_secs, "-H", "Accept: application/vnd.github+json", url },
}) catch return error.FetchFailed;
defer allocator.free(result.stdout);
defer allocator.free(result.stderr);
Expand Down Expand Up @@ -390,3 +390,10 @@ test "findAssetForComponentPlatform matches swapped platform order" {
try std.testing.expect(asset != null);
try std.testing.expectEqualStrings("nullclaw-macos-aarch64.bin", asset.?.name);
}

test "nullclaw component declares nullclaw-chat-ui ui module" {
const comp = findKnownComponent("nullclaw") orelse return error.TestUnexpectedResult;
try std.testing.expectEqual(@as(usize, 1), comp.ui_modules.len);
try std.testing.expectEqualStrings("nullclaw-chat-ui", comp.ui_modules[0].name);
try std.testing.expectEqualStrings("nullclaw/nullclaw-chat-ui", comp.ui_modules[0].repo);
}
Loading
Loading