diff --git a/src/api/components.zig b/src/api/components.zig index 048bd83..7998c86 100644 --- a/src/api/components.zig +++ b/src/api/components.zig @@ -68,13 +68,14 @@ fn buildListJson(allocator: std.mem.Allocator, s: *state_mod.State) ![]const u8 const installed = has_dot_dir or instance_count > 0; try buf.print( - "{{\"name\":\"{s}\",\"display_name\":\"{s}\",\"description\":\"{s}\",\"repo\":\"{s}\",\"alpha\":{s},\"installed\":{s},\"standalone\":{s},\"instance_count\":{d}}}", + "{{\"name\":\"{s}\",\"display_name\":\"{s}\",\"description\":\"{s}\",\"repo\":\"{s}\",\"alpha\":{s},\"installable\":{s},\"installed\":{s},\"standalone\":{s},\"instance_count\":{d}}}", .{ comp.name, comp.display_name, comp.description, comp.repo, if (comp.is_alpha) "true" else "false", + if (comp.installable) "true" else "false", if (installed) "true" else "false", if (standalone) "true" else "false", instance_count, @@ -218,12 +219,14 @@ test "handleList returns valid JSON with all known components" { // Verify repo fields try std.testing.expect(std.mem.indexOf(u8, json, "\"nullclaw/nullclaw\"") != null); - try std.testing.expect(std.mem.indexOf(u8, json, "\"nullclaw/NullBoiler\"") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"nullclaw/nullboiler\"") != null); try std.testing.expect(std.mem.indexOf(u8, json, "\"nullclaw/nulltickets\"") != null); try std.testing.expect(std.mem.indexOf(u8, json, "\"nullclaw/nullwatch\"") != null); // Verify structural fields 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.expect(std.mem.indexOf(u8, json, "\"installed\"") != null); diff --git a/src/api/instance_runtime.zig b/src/api/instance_runtime.zig index 054179c..d33d033 100644 --- a/src/api/instance_runtime.zig +++ b/src/api/instance_runtime.zig @@ -130,13 +130,23 @@ fn isImportedStandalone( fn standalonePortConfigKey(component: []const u8) ?[]const u8 { if (std.mem.eql(u8, component, "nullclaw")) return "gateway.port"; - if (std.mem.eql(u8, component, "nullwatch")) return "port"; + if (std.mem.eql(u8, component, "nullwatch") or + std.mem.eql(u8, component, "nullboiler") or + std.mem.eql(u8, component, "nulltickets")) + { + return "port"; + } return null; } fn isStandaloneLaunchMode(component: []const u8, launch_mode: []const u8, default_launch_mode: []const u8) bool { if (standalonePortConfigKey(component) == null) return false; if (std.mem.eql(u8, launch_mode, default_launch_mode)) return true; + if ((std.mem.eql(u8, component, "nullboiler") or std.mem.eql(u8, component, "nulltickets")) and + (std.mem.eql(u8, launch_mode, component) or std.mem.eql(u8, launch_mode, "serve"))) + { + return true; + } if (std.mem.eql(u8, component, "nullwatch")) { return std.mem.eql(u8, launch_mode, "gateway") or std.mem.eql(u8, launch_mode, "nullwatch"); @@ -167,7 +177,10 @@ fn deriveImportedStandaloneSnapshot( const known = registry.findKnownComponent(component) orelse return null; const port_key = standalonePortConfigKey(component) orelse return null; - const port = readPortFromConfig(allocator, paths, component, name, port_key) orelse known.default_port; + const port = readPortFromConfig(allocator, paths, component, name, port_key) orelse + readPortFromConfig(allocator, paths, component, name, "gateway.port") orelse + readPortFromConfig(allocator, paths, component, name, "port") orelse + known.default_port; if (port == 0) return null; const configured_host = readStringFromConfig(allocator, paths, component, name, "host") orelse @@ -204,13 +217,17 @@ pub fn resolve( test "standalone runtime metadata covers nullclaw and nullwatch" { try std.testing.expectEqualStrings("gateway.port", standalonePortConfigKey("nullclaw").?); try std.testing.expectEqualStrings("port", standalonePortConfigKey("nullwatch").?); - try std.testing.expect(standalonePortConfigKey("nullboiler") == null); + try std.testing.expectEqualStrings("port", standalonePortConfigKey("nullboiler").?); + try std.testing.expectEqualStrings("port", standalonePortConfigKey("nulltickets").?); try std.testing.expect(isStandaloneLaunchMode("nullclaw", "gateway", "gateway")); try std.testing.expect(isStandaloneLaunchMode("nullwatch", "serve", "serve")); try std.testing.expect(isStandaloneLaunchMode("nullwatch", "gateway", "serve")); try std.testing.expect(isStandaloneLaunchMode("nullwatch", "nullwatch", "serve")); - try std.testing.expect(!isStandaloneLaunchMode("nullboiler", "gateway", "gateway")); + try std.testing.expect(isStandaloneLaunchMode("nullboiler", "server", "server")); + try std.testing.expect(isStandaloneLaunchMode("nullboiler", "nullboiler", "server")); + try std.testing.expect(isStandaloneLaunchMode("nulltickets", "serve", "server")); + try std.testing.expect(!isStandaloneLaunchMode("nullboiler", "gateway", "server")); } test "readPortFromConfig accepts string ports" { diff --git a/src/api/instances.zig b/src/api/instances.zig index b93ed95..1b61e59 100644 --- a/src/api/instances.zig +++ b/src/api/instances.zig @@ -4,6 +4,9 @@ const builtin = @import("builtin"); 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 downloader = @import("../installer/downloader.zig"); +const platform = @import("../core/platform.zig"); const helpers = @import("helpers.zig"); const local_binary = @import("../core/local_binary.zig"); const component_cli = @import("../core/component_cli.zig"); @@ -16,9 +19,6 @@ const nullclaw_web_channel = @import("../core/nullclaw_web_channel.zig"); const query_api = @import("query.zig"); const test_helpers = @import("../test_helpers.zig"); const instance_runtime = @import("instance_runtime.zig"); -const registry = @import("../installer/registry.zig"); -const downloader = @import("../installer/downloader.zig"); -const platform = @import("../core/platform.zig"); const ApiResponse = helpers.ApiResponse; const appendEscaped = helpers.appendEscaped; @@ -27,9 +27,6 @@ const notFound = helpers.notFound; const badRequest = helpers.badRequest; const methodNotAllowed = helpers.methodNotAllowed; -const default_tracker_prompt_template = - "Task {{task.id}}: {{task.title}}\n\n{{task.description}}\n\nMetadata:\n{{task.metadata}}"; - // ─── Helpers ───────────────────────────────────────────────────────────────── fn defaultLaunchModeForComponent(component: []const u8) []const u8 { @@ -216,6 +213,249 @@ fn buildInstanceUrl(allocator: std.mem.Allocator, port: u16, path: []const u8) ? return std.fmt.allocPrint(allocator, "http://127.0.0.1:{d}{s}", .{ port, path }) catch null; } +const NullTicketsActionRequest = struct { + method: ?[]const u8 = null, + path: []const u8, + payload: ?std.json.Value = null, + bearer_token: ?[]const u8 = null, +}; + +fn parseNullTicketsActionMethod(method: []const u8) ?std.http.Method { + if (std.mem.eql(u8, method, "GET")) return .GET; + if (std.mem.eql(u8, method, "POST")) return .POST; + if (std.mem.eql(u8, method, "DELETE")) return .DELETE; + return null; +} + +fn actionStatus(code: u10) []const u8 { + return switch (code) { + 200 => "200 OK", + 201 => "201 Created", + 204 => "204 No Content", + 400 => "400 Bad Request", + 401 => "401 Unauthorized", + 403 => "403 Forbidden", + 404 => "404 Not Found", + 405 => "405 Method Not Allowed", + 409 => "409 Conflict", + 410 => "410 Gone", + 422 => "422 Unprocessable Entity", + 500 => "500 Internal Server Error", + 502 => "502 Bad Gateway", + 503 => "503 Service Unavailable", + else => if (code >= 200 and code < 300) "200 OK" else if (code >= 400 and code < 500) "400 Bad Request" else "500 Internal Server Error", + }; +} + +fn hasUnsafeActionPathByte(path: []const u8) bool { + for (path) |ch| { + if (ch <= 0x20 or ch == 0x7f) return true; + } + return false; +} + +fn hasSinglePathTail(path: []const u8, prefix: []const u8) bool { + if (!std.mem.startsWith(u8, path, prefix)) return false; + const tail = path[prefix.len..]; + return tail.len > 0 and std.mem.indexOfScalar(u8, tail, '/') == null; +} + +fn isTaskSubpath(path: []const u8, suffix: []const u8) bool { + if (!std.mem.startsWith(u8, path, "/tasks/")) return false; + if (!std.mem.endsWith(u8, path, suffix)) return false; + const tail = path["/tasks/".len .. path.len - suffix.len]; + return tail.len > 0 and std.mem.indexOfScalar(u8, tail, '/') == null; +} + +fn isTaskAssignmentTarget(path: []const u8) bool { + if (!std.mem.startsWith(u8, path, "/tasks/")) return false; + const tail = path["/tasks/".len..]; + const slash = std.mem.indexOfScalar(u8, tail, '/') orelse return false; + const task_id = tail[0..slash]; + const rest = tail[slash + 1 ..]; + if (task_id.len == 0) return false; + if (!std.mem.startsWith(u8, rest, "assignments/")) return false; + const agent_id = rest["assignments/".len..]; + return agent_id.len > 0 and std.mem.indexOfScalar(u8, agent_id, '/') == null; +} + +fn isRunSubpath(path: []const u8, suffix: []const u8) bool { + if (!std.mem.startsWith(u8, path, "/runs/")) return false; + if (!std.mem.endsWith(u8, path, suffix)) return false; + const tail = path["/runs/".len .. path.len - suffix.len]; + return tail.len > 0 and std.mem.indexOfScalar(u8, tail, '/') == null; +} + +fn isLeaseHeartbeatTarget(path: []const u8) bool { + if (!std.mem.startsWith(u8, path, "/leases/")) return false; + if (!std.mem.endsWith(u8, path, "/heartbeat")) return false; + const lease_id = path["/leases/".len .. path.len - "/heartbeat".len]; + return lease_id.len > 0 and std.mem.indexOfScalar(u8, lease_id, '/') == null; +} + +const NullTicketsActionAuthMode = enum { + instance_token, + lease_token, +}; + +fn classifyNullTicketsAction(method: std.http.Method, path: []const u8) ?NullTicketsActionAuthMode { + if (path.len == 0 or path.len > 2048) return null; + if (path[0] != '/') return null; + if (std.mem.startsWith(u8, path, "//")) return null; + if (std.mem.indexOfScalar(u8, path, '#') != null) return null; + if (hasUnsafeActionPathByte(path)) return null; + + const clean = stripQuery(path); + return switch (method) { + .GET => if (std.mem.eql(u8, clean, "/pipelines") or + hasSinglePathTail(clean, "/pipelines/") or + std.mem.eql(u8, clean, "/tasks") or + hasSinglePathTail(clean, "/tasks/") or + isTaskSubpath(clean, "/run-state") or + isTaskSubpath(clean, "/dependencies") or + isTaskSubpath(clean, "/assignments") or + isRunSubpath(clean, "/events") or + std.mem.eql(u8, clean, "/artifacts") or + std.mem.eql(u8, clean, "/ops/queue")) .instance_token else null, + .POST => blk: { + if (path.len != clean.len) break :blk null; + if (isLeaseHeartbeatTarget(clean) or + isRunSubpath(clean, "/events") or + isRunSubpath(clean, "/transition") or + isRunSubpath(clean, "/fail")) + { + break :blk .lease_token; + } + if (std.mem.eql(u8, clean, "/pipelines") or + std.mem.eql(u8, clean, "/tasks") or + std.mem.eql(u8, clean, "/tasks/bulk") or + isTaskSubpath(clean, "/dependencies") or + isTaskSubpath(clean, "/assignments") or + std.mem.eql(u8, clean, "/leases/claim") or + std.mem.eql(u8, clean, "/artifacts")) + { + break :blk .instance_token; + } + break :blk null; + }, + .DELETE => if (path.len == clean.len and isTaskAssignmentTarget(clean)) .instance_token else null, + else => null, + }; +} + +fn isAllowedNullTicketsAction(method: std.http.Method, path: []const u8) bool { + return classifyNullTicketsAction(method, path) != null; +} + +fn nullTicketsForwardedToken( + auth_mode: NullTicketsActionAuthMode, + instance_token: ?[]const u8, + request_bearer_token: ?[]const u8, +) ?[]const u8 { + return switch (auth_mode) { + .instance_token => instance_token, + .lease_token => blk: { + const token = request_bearer_token orelse break :blk null; + break :blk if (token.len > 0) token else null; + }, + }; +} + +fn handleNullTicketsAction( + allocator: std.mem.Allocator, + s: *state_mod.State, + manager: *manager_mod.Manager, + mutex: *std_compat.sync.Mutex, + paths: paths_mod.Paths, + component: []const u8, + name: []const u8, + body: []const u8, +) ApiResponse { + if (!std.mem.eql(u8, component, "nulltickets")) { + return badRequest("{\"error\":\"tickets actions are only supported for nulltickets\"}"); + } + + var parsed = std.json.parseFromSlice(NullTicketsActionRequest, allocator, body, .{ + .allocate = .alloc_always, + .ignore_unknown_fields = true, + }) catch return badRequest("{\"error\":\"invalid JSON body\"}"); + defer parsed.deinit(); + + const method_name = parsed.value.method orelse "GET"; + const http_method = parseNullTicketsActionMethod(method_name) orelse + return methodNotAllowed(); + const auth_mode = classifyNullTicketsAction(http_method, parsed.value.path) orelse { + return badRequest("{\"error\":\"unsupported nulltickets action\"}"); + }; + + var tickets_cfg = blk: { + mutex.lock(); + defer mutex.unlock(); + _ = s.getInstance(component, name) orelse return notFound(); + const runtime = manager.getStatus("nulltickets", name) orelse + return conflict("{\"error\":\"nulltickets instance is not running\"}"); + if (runtime.status != .running) { + return conflict("{\"error\":\"nulltickets instance is not running\"}"); + } + break :blk integration_mod.loadNullTicketsConfig(allocator, paths, name) catch null orelse return notFound(); + }; + defer integration_mod.deinitNullTicketsConfig(allocator, &tickets_cfg); + + var payload_json: ?[]u8 = null; + defer if (payload_json) |value| allocator.free(value); + if (parsed.value.payload) |payload| { + payload_json = std.json.Stringify.valueAlloc(allocator, payload, .{ + .emit_null_optional_fields = false, + }) catch return helpers.serverError(); + } + + const url = buildInstanceUrl(allocator, tickets_cfg.port, parsed.value.path) orelse return helpers.serverError(); + defer allocator.free(url); + + var auth_header: ?[]const u8 = null; + defer if (auth_header) |value| allocator.free(value); + var header_buf: [2]std.http.Header = undefined; + var header_count: usize = 0; + const forwarded_token = nullTicketsForwardedToken(auth_mode, tickets_cfg.api_token, parsed.value.bearer_token); + if (forwarded_token) |token| { + auth_header = std.fmt.allocPrint(allocator, "Bearer {s}", .{token}) catch return helpers.serverError(); + header_buf[header_count] = .{ .name = "Authorization", .value = auth_header.? }; + header_count += 1; + } + if (payload_json != null) { + header_buf[header_count] = .{ .name = "Content-Type", .value = "application/json" }; + header_count += 1; + } + + var client: std.http.Client = .{ .allocator = allocator, .io = std_compat.io() }; + defer client.deinit(); + + var response_body: std.Io.Writer.Allocating = .init(allocator); + defer response_body.deinit(); + + const result = client.fetch(.{ + .location = .{ .url = url }, + .method = http_method, + .payload = if (payload_json) |value| value else null, + .response_writer = &response_body.writer, + .extra_headers = header_buf[0..header_count], + }) catch { + return .{ + .status = "502 Bad Gateway", + .content_type = "application/json", + .body = "{\"error\":\"NullTickets unreachable\"}", + }; + }; + + const response_bytes = response_body.toOwnedSlice() catch return helpers.serverError(); + const status_code: u10 = @intFromEnum(result.status); + return .{ + .status = actionStatus(status_code), + .content_type = "application/json", + .body = response_bytes, + }; +} + fn getStatusLocked( mutex: *std_compat.sync.Mutex, manager: *manager_mod.Manager, @@ -466,13 +706,18 @@ fn fetchPipelineSummaries(allocator: std.mem.Allocator, url: []const u8, bearer_ .ignore_unknown_fields = true, }) catch return null; defer parsed.deinit(); - if (parsed.value != .array) return null; + const pipeline_items = pipelineItemsFromValue(parsed.value) orelse return null; var list: std.ArrayListUnmanaged(PipelineSummary) = .empty; - errdefer deinitPipelineSummaries(allocator, list.items); + var summaries_owned = false; + defer { + if (!summaries_owned) { + for (list.items) |summary| deinitPipelineSummary(allocator, summary); + } + } defer list.deinit(allocator); - for (parsed.value.array.items) |item| { + for (pipeline_items) |item| { const summary = parsePipelineSummary(allocator, item) catch continue; list.append(allocator, summary) catch { deinitPipelineSummary(allocator, summary); @@ -480,20 +725,66 @@ fn fetchPipelineSummaries(allocator: std.mem.Allocator, url: []const u8, bearer_ }; } - return list.toOwnedSlice(allocator) catch null; + const summaries = list.toOwnedSlice(allocator) catch return null; + summaries_owned = true; + return summaries; +} + +fn pipelineItemsFromValue(value: std.json.Value) ?[]const std.json.Value { + return switch (value) { + .array => |array| array.items, + .object => |object| blk: { + if (object.get("pipelines")) |pipelines| { + if (pipelines == .array) break :blk pipelines.array.items; + } + if (object.get("items")) |items| { + if (items == .array) break :blk items.array.items; + } + break :blk null; + }, + else => null, + }; } fn parsePipelineSummary(allocator: std.mem.Allocator, value: std.json.Value) !PipelineSummary { if (value != .object) return error.InvalidPipelineSummary; const obj = value.object; - const definition = obj.get("definition") orelse return error.InvalidPipelineSummary; - if (definition != .object) return error.InvalidPipelineSummary; + var parsed_definition: ?std.json.Parsed(std.json.Value) = null; + defer if (parsed_definition) |*parsed| parsed.deinit(); + const definition = try pipelineDefinitionValue( + allocator, + obj.get("definition") orelse obj.get("definition_json") orelse return error.InvalidPipelineSummary, + &parsed_definition, + ); - return .{ - .id = try allocator.dupe(u8, jsonStringOrEmpty(obj, "id")), - .name = try allocator.dupe(u8, jsonStringOrEmpty(obj, "name")), - .roles = try collectPipelineRoles(allocator, definition), - .triggers = try collectPipelineTriggers(allocator, definition), + const id = try allocator.dupe(u8, jsonStringOrEmpty(obj, "id")); + errdefer allocator.free(id); + const name = try allocator.dupe(u8, jsonStringOrEmpty(obj, "name")); + errdefer allocator.free(name); + const roles = try collectPipelineRoles(allocator, definition); + errdefer freeStringList(allocator, roles); + const triggers = try collectPipelineTriggers(allocator, definition); + + return .{ .id = id, .name = name, .roles = roles, .triggers = triggers }; +} + +fn pipelineDefinitionValue( + allocator: std.mem.Allocator, + value: std.json.Value, + parsed_out: *?std.json.Parsed(std.json.Value), +) !std.json.Value { + return switch (value) { + .object => value, + .string => |raw| blk: { + parsed_out.* = try std.json.parseFromSlice(std.json.Value, allocator, raw, .{ + .allocate = .alloc_always, + .ignore_unknown_fields = true, + }); + const parsed = &parsed_out.*.?; + if (parsed.value != .object) return error.InvalidPipelineSummary; + break :blk parsed.value; + }, + else => error.InvalidPipelineSummary, }; } @@ -503,6 +794,7 @@ fn collectPipelineRoles(allocator: std.mem.Allocator, definition: std.json.Value if (states_val != .object) return allocator.alloc([]const u8, 0); var list: std.ArrayListUnmanaged([]const u8) = .empty; + errdefer for (list.items) |role| allocator.free(role); defer list.deinit(allocator); var it = states_val.object.iterator(); @@ -521,6 +813,7 @@ fn collectPipelineTriggers(allocator: std.mem.Allocator, definition: std.json.Va if (transitions_val != .array) return allocator.alloc([]const u8, 0); var list: std.ArrayListUnmanaged([]const u8) = .empty; + errdefer for (list.items) |trigger| allocator.free(trigger); defer list.deinit(allocator); for (transitions_val.array.items) |transition| { @@ -536,16 +829,21 @@ fn appendUniqueString(allocator: std.mem.Allocator, list: *std.ArrayListUnmanage for (list.items) |existing| { if (std.mem.eql(u8, existing, value)) return; } - try list.append(allocator, try allocator.dupe(u8, value)); + const owned = try allocator.dupe(u8, value); + errdefer allocator.free(owned); + try list.append(allocator, owned); +} + +fn freeStringList(allocator: std.mem.Allocator, values: []const []const u8) void { + for (values) |value| allocator.free(value); + allocator.free(@constCast(values)); } fn deinitPipelineSummary(allocator: std.mem.Allocator, summary: PipelineSummary) void { allocator.free(summary.id); allocator.free(summary.name); - for (summary.roles) |role| allocator.free(role); - allocator.free(summary.roles); - for (summary.triggers) |trigger| allocator.free(trigger); - allocator.free(summary.triggers); + freeStringList(allocator, summary.roles); + freeStringList(allocator, summary.triggers); } fn deinitPipelineSummaries(allocator: std.mem.Allocator, summaries: []const PipelineSummary) void { @@ -573,52 +871,17 @@ fn ensurePath(path: []const u8) !void { try std_compat.fs.cwd().makePath(path); } -fn ensureObjectField( - allocator: std.mem.Allocator, - parent: *std.json.ObjectMap, - key: []const u8, -) !*std.json.ObjectMap { - if (parent.getPtr(key)) |value_ptr| { - if (value_ptr.* != .object) { - value_ptr.* = .{ .object = .empty }; - } - return &value_ptr.object; - } - - try parent.put(allocator, key, .{ .object = .empty }); - return &parent.getPtr(key).?.object; -} - -fn resolvePathFromConfig(allocator: std.mem.Allocator, config_path: []const u8, value: []const u8) ![]const u8 { - if (value.len == 0 or std.fs.path.isAbsolute(value)) return allocator.dupe(u8, value); - const config_dir = std.fs.path.dirname(config_path) orelse return error.InvalidPath; - return std.fs.path.resolve(allocator, &.{ config_dir, value }); -} - -fn isNullHubManagedWorkflow( - allocator: std.mem.Allocator, - workflow_path: []const u8, -) bool { - const file = std_compat.fs.openFileAbsolute(workflow_path, .{}) catch return false; - defer file.close(); - - const bytes = file.readToEndAlloc(allocator, 1024 * 1024) catch return false; - defer allocator.free(bytes); - - const parsed = std.json.parseFromSlice(struct { - id: []const u8 = "", - execution: []const u8 = "", - prompt_template: ?[]const u8 = null, - }, allocator, bytes, .{ - .allocate = .alloc_always, - .ignore_unknown_fields = true, - }) catch return false; - defer parsed.deinit(); +fn writeJsonConfigValue(allocator: std.mem.Allocator, config_path: []const u8, value: std.json.Value) !void { + const rendered = try std.json.Stringify.valueAlloc(allocator, value, .{ + .whitespace = .indent_2, + .emit_null_optional_fields = false, + }); + defer allocator.free(rendered); - return std.mem.startsWith(u8, parsed.value.id, "wf-") and - std.mem.eql(u8, parsed.value.execution, "subprocess") and - parsed.value.prompt_template != null and - std.mem.eql(u8, parsed.value.prompt_template.?, default_tracker_prompt_template); + const out = try std_compat.fs.createFileAbsolute(config_path, .{ .truncate = true }); + defer out.close(); + try out.writeAll(rendered); + try out.writeAll("\n"); } const ProviderHealthConfig = struct { @@ -1353,6 +1616,14 @@ fn jsonCliConflict( }; } +fn conflict(body: []const u8) ApiResponse { + return .{ + .status = "409 Conflict", + .content_type = "application/json", + .body = body, + }; +} + const ParsedCronPath = struct { component: []const u8, name: []const u8, @@ -2059,9 +2330,11 @@ pub fn handleStart(allocator: std.mem.Allocator, s: *state_mod.State, manager: * const bin_path = start_binary.path; const current_version = start_binary.version; - // Read manifest from binary to get health endpoint and port - var health_endpoint: []const u8 = "/health"; - var port: u16 = 0; + // Read manifest from binary to get health endpoint and port, falling back + // to registry/config defaults for older binaries without manifest support. + const known_component = registry.findKnownComponent(component); + var health_endpoint: []const u8 = if (known_component) |known| known.default_health_endpoint else "/health"; + var port: u16 = if (known_component) |known| known.default_port else 0; var port_from_config: []const u8 = ""; var manifest_launch_command: []const u8 = ""; var manifest_launch_mode: ?[]const u8 = null; @@ -2092,7 +2365,7 @@ pub fn handleStart(allocator: std.mem.Allocator, s: *state_mod.State, manager: * (std.mem.eql(u8, launch_cmd, manifest_launch_command) and !std.mem.eql(u8, launch_cmd, mode)) or isLegacyDefaultLaunchMode(component, launch_cmd); if (should_normalize_launch) { - launch_cmd = mode; + launch_cmd = registry.normalizeLaunchCommand(component, mode); _ = s.updateInstance(component, name, .{ .version = current_version, .auto_start = entry.auto_start, @@ -2104,14 +2377,22 @@ pub fn handleStart(allocator: std.mem.Allocator, s: *state_mod.State, manager: * } } - // Try to read actual port from instance config.json using port_from_config key + // Try to read actual port from instance config.json using port_from_config key. + // If manifest probing failed, fall back to the common service port keys. if (port_from_config.len > 0) { if (instance_runtime.readPortFromConfig(allocator, paths, component, name, port_from_config)) |config_port| { port = config_port; } + } else { + if (instance_runtime.readPortFromConfig(allocator, paths, component, name, "port")) |config_port| { + port = config_port; + } else if (instance_runtime.readPortFromConfig(allocator, paths, component, name, "gateway.port")) |config_port| { + port = config_port; + } } - var launch = launch_args_mod.resolve(allocator, launch_cmd, launch_verbose) catch return badRequest("{\"error\":\"invalid launch_mode\"}"); + const normalized_launch_cmd = registry.normalizeLaunchCommand(component, launch_cmd); + var launch = launch_args_mod.resolve(allocator, normalized_launch_cmd, launch_verbose) catch return badRequest("{\"error\":\"invalid launch_mode\"}"); defer launch.deinit(); // The launch-mode helper decides whether this mode should be supervised via // an HTTP health endpoint or process liveness only. @@ -3227,105 +3508,440 @@ pub fn handleSkills(allocator: std.mem.Allocator, s: *state_mod.State, paths: pa }); } -/// DELETE /api/instances/{component}/{name} -pub fn handleDelete(allocator: std.mem.Allocator, s: *state_mod.State, manager: *manager_mod.Manager, paths: paths_mod.Paths, component: []const u8, name: []const u8) ApiResponse { - const existing = s.getInstance(component, name) orelse return notFound(); - const rollback_version = allocator.dupe(u8, existing.version) catch return helpers.serverError(); - defer allocator.free(rollback_version); - const rollback_launch_mode = allocator.dupe(u8, existing.launch_mode) catch return helpers.serverError(); - defer allocator.free(rollback_launch_mode); - - const inst_dir = paths.instanceDir(allocator, component, name) catch return helpers.serverError(); - defer allocator.free(inst_dir); - - manager.stopInstance(component, name) catch {}; - const hidden_inst_dir = hideInstanceDirForDelete(allocator, inst_dir) catch return helpers.serverError(); - defer if (hidden_inst_dir) |path| allocator.free(path); +const DeleteDependent = struct { + component: []const u8, + name: []const u8, + relation: []const u8, +}; - if (!s.removeInstance(component, name)) { - if (hidden_inst_dir) |path| { - std_compat.fs.renameAbsolute(path, inst_dir) catch {}; - } - return notFound(); +const DeleteDependencyList = struct { + items: std.ArrayListUnmanaged(DeleteDependent) = .empty, + + fn append( + self: *DeleteDependencyList, + allocator: std.mem.Allocator, + component: []const u8, + name: []const u8, + relation: []const u8, + ) !void { + const owned_name = try allocator.dupe(u8, name); + errdefer allocator.free(owned_name); + try self.items.append(allocator, .{ + .component = component, + .name = owned_name, + .relation = relation, + }); } - s.save() catch { - _ = s.addInstance(component, name, .{ - .version = rollback_version, - .auto_start = existing.auto_start, - .launch_mode = rollback_launch_mode, - .verbose = existing.verbose, - }) catch {}; - _ = s.save() catch {}; - if (hidden_inst_dir) |path| { - std_compat.fs.renameAbsolute(path, inst_dir) catch {}; - } - return helpers.serverError(); - }; - if (hidden_inst_dir) |path| { - std_compat.fs.deleteTreeAbsolute(path) catch |err| { - std.log.warn("deleted instance {s}/{s} but failed to clean hidden dir '{s}': {s}", .{ - component, - name, - path, - @errorName(err), - }); - }; + fn deinit(self: *DeleteDependencyList, allocator: std.mem.Allocator) void { + for (self.items.items) |dep| allocator.free(dep.name); + self.items.deinit(allocator); + self.* = .{}; } +}; - return jsonOk("{\"status\":\"deleted\"}"); -} +const DeleteImpact = struct { + dependents: DeleteDependencyList = .{}, + nullwatch: ?integration_mod.NullWatchConfig = null, + nulltickets: ?integration_mod.NullTicketsConfig = null, -fn hideInstanceDirForDelete(allocator: std.mem.Allocator, inst_dir: []const u8) !?[]const u8 { - std_compat.fs.accessAbsolute(inst_dir, .{}) catch |err| switch (err) { - error.FileNotFound => return null, - else => return err, - }; + fn deinit(self: *DeleteImpact, allocator: std.mem.Allocator) void { + self.dependents.deinit(allocator); + if (self.nullwatch) |*cfg| integration_mod.deinitNullWatchConfig(allocator, cfg); + if (self.nulltickets) |*cfg| integration_mod.deinitNullTicketsConfig(allocator, cfg); + self.* = .{}; + } +}; - const parent = std.fs.path.dirname(inst_dir) orelse return error.InvalidPath; - const base = std.fs.path.basename(inst_dir); - const ts = @as(u64, @intCast(@max(0, std_compat.time.milliTimestamp()))); +fn collectDeleteImpact( + allocator: std.mem.Allocator, + s: *state_mod.State, + paths: paths_mod.Paths, + component: []const u8, + name: []const u8, +) !DeleteImpact { + var impact: DeleteImpact = .{}; + errdefer impact.deinit(allocator); - var attempt: u32 = 0; - while (attempt < 1024) : (attempt += 1) { - const hidden_path = try std.fmt.allocPrint(allocator, "{s}/.{s}.deleted-{d}-{d}", .{ - parent, - base, - ts, - attempt, - }); - errdefer allocator.free(hidden_path); + if (std.mem.eql(u8, component, "nullwatch")) { + impact.nullwatch = try integration_mod.loadNullWatchConfig(allocator, paths, name) orelse return impact; + const watch_cfg = impact.nullwatch.?; - std_compat.fs.renameAbsolute(inst_dir, hidden_path) catch |err| switch (err) { - error.FileNotFound => return null, - else => return err, - }; - return hidden_path; + if (try s.instanceNames("nullclaw")) |claw_names| { + defer s.allocator.free(claw_names); + for (claw_names) |claw_name| { + var link = integration_mod.loadNullClawTelemetryLink(allocator, paths, claw_name) catch |err| switch (err) { + error.NotFound => continue, + else => return err, + }; + defer link.deinit(allocator); + + if (integration_mod.findNullWatchByEndpoint(&.{watch_cfg}, link.endpoint) != null) { + try impact.dependents.append(allocator, "nullclaw", claw_name, "telemetry"); + } + } + } + } else if (std.mem.eql(u8, component, "nulltickets")) { + impact.nulltickets = try integration_mod.loadNullTicketsConfig(allocator, paths, name) orelse return impact; + const tickets_cfg = impact.nulltickets.?; + + if (try s.instanceNames("nullboiler")) |boiler_names| { + defer s.allocator.free(boiler_names); + for (boiler_names) |boiler_name| { + var boiler_cfg = try integration_mod.loadNullBoilerConfig(allocator, paths, boiler_name) orelse continue; + defer integration_mod.deinitNullBoilerConfig(allocator, &boiler_cfg); + + if (integration_mod.matchNullTicketsTarget(boiler_cfg, &.{tickets_cfg}) != null) { + try impact.dependents.append(allocator, "nullboiler", boiler_name, "tracker"); + } + } + } } - return error.PathAlreadyExists; + return impact; } -/// POST /api/instances/{component}/import — import a standalone installation. -/// Copies config and data from ~/.{component}/ into the nullhub instance directory. -/// The binary will be downloaded via the normal install flow on first start. -pub fn handleImport(allocator: std.mem.Allocator, s: *state_mod.State, paths: paths_mod.Paths, component: []const u8) ApiResponse { - const home = std_compat.process.getEnvVarOwned(allocator, "HOME") catch blk: { - if (builtin.os.tag == .windows) { - break :blk std_compat.process.getEnvVarOwned(allocator, "USERPROFILE") catch return helpers.serverError(); - } - return helpers.serverError(); +fn deleteDependencyConflict(allocator: std.mem.Allocator, dependents: []const DeleteDependent) ApiResponse { + const body = std.json.Stringify.valueAlloc(allocator, .{ + .@"error" = "instance has dependent links", + .force_required = true, + .dependents = dependents, + }, .{}) catch return helpers.serverError(); + + return .{ + .status = "409 Conflict", + .content_type = "application/json", + .body = body, }; - defer allocator.free(home); +} - // 1. Verify standalone dir exists - const dot_dir = std.fmt.allocPrint(allocator, "{s}/.{s}", .{ home, component }) catch return helpers.serverError(); +fn unlinkNullClawTelemetryForDeletedWatch( + allocator: std.mem.Allocator, + paths: paths_mod.Paths, + claw_name: []const u8, + watch_cfg: integration_mod.NullWatchConfig, +) !bool { + var link = integration_mod.loadNullClawTelemetryLink(allocator, paths, claw_name) catch |err| switch (err) { + error.NotFound => return false, + else => return err, + }; + defer link.deinit(allocator); + if (integration_mod.findNullWatchByEndpoint(&.{watch_cfg}, link.endpoint) == null) return false; + + const config_path = try paths.instanceConfig(allocator, "nullclaw", claw_name); + defer allocator.free(config_path); + const file = std_compat.fs.openFileAbsolute(config_path, .{}) catch |err| switch (err) { + error.FileNotFound => return false, + else => return err, + }; + defer file.close(); + + const config_bytes = try file.readToEndAlloc(allocator, 1024 * 1024); + defer allocator.free(config_bytes); + + var parsed_config = try std.json.parseFromSlice(std.json.Value, allocator, config_bytes, .{ + .allocate = .alloc_always, + .ignore_unknown_fields = true, + }); + defer parsed_config.deinit(); + if (parsed_config.value != .object) return error.InvalidConfig; + + if (parsed_config.value.object.getPtr("diagnostics")) |diagnostics_value| { + if (diagnostics_value.* == .object) { + if (jsonString(diagnostics_value.object, "backend")) |backend| { + if (std.mem.eql(u8, backend, "otel") or std.mem.eql(u8, backend, "otlp")) { + try diagnostics_value.object.put(allocator, "backend", .{ .string = "jsonl" }); + } + } + _ = diagnostics_value.object.swapRemove("otel"); + } + } + + try writeJsonConfigValue(allocator, config_path, parsed_config.value); + return true; +} + +fn unlinkNullBoilerTrackerForDeletedTickets( + allocator: std.mem.Allocator, + paths: paths_mod.Paths, + boiler_name: []const u8, + tickets_cfg: integration_mod.NullTicketsConfig, +) !bool { + var boiler_cfg = try integration_mod.loadNullBoilerConfig(allocator, paths, boiler_name) orelse return false; + defer integration_mod.deinitNullBoilerConfig(allocator, &boiler_cfg); + if (integration_mod.matchNullTicketsTarget(boiler_cfg, &.{tickets_cfg}) == null) return false; + + const config_path = try paths.instanceConfig(allocator, "nullboiler", boiler_name); + defer allocator.free(config_path); + const file = std_compat.fs.openFileAbsolute(config_path, .{}) catch |err| switch (err) { + error.FileNotFound => return false, + else => return err, + }; + defer file.close(); + + const config_bytes = try file.readToEndAlloc(allocator, 1024 * 1024); + defer allocator.free(config_bytes); + + var parsed_config = try std.json.parseFromSlice(std.json.Value, allocator, config_bytes, .{ + .allocate = .alloc_always, + .ignore_unknown_fields = true, + }); + defer parsed_config.deinit(); + if (parsed_config.value != .object) return error.InvalidConfig; + + _ = parsed_config.value.object.swapRemove("tracker"); + try writeJsonConfigValue(allocator, config_path, parsed_config.value); + return true; +} + +fn unlinkDeleteImpact(allocator: std.mem.Allocator, paths: paths_mod.Paths, component: []const u8, impact: DeleteImpact) !void { + if (std.mem.eql(u8, component, "nullwatch")) { + const watch_cfg = impact.nullwatch orelse return; + for (impact.dependents.items.items) |dep| { + if (!std.mem.eql(u8, dep.component, "nullclaw")) continue; + _ = try unlinkNullClawTelemetryForDeletedWatch(allocator, paths, dep.name, watch_cfg); + } + } else if (std.mem.eql(u8, component, "nulltickets")) { + const tickets_cfg = impact.nulltickets orelse return; + for (impact.dependents.items.items) |dep| { + if (!std.mem.eql(u8, dep.component, "nullboiler")) continue; + _ = try unlinkNullBoilerTrackerForDeletedTickets(allocator, paths, dep.name, tickets_cfg); + } + } +} + +fn restartRunningDeleteDependents( + allocator: std.mem.Allocator, + s: *state_mod.State, + manager: *manager_mod.Manager, + paths: paths_mod.Paths, + impact: DeleteImpact, +) void { + for (impact.dependents.items.items) |dep| { + const status = manager.getStatus(dep.component, dep.name) orelse continue; + if (status.status != .running) continue; + + const resp = handleRestart(allocator, s, manager, paths, dep.component, dep.name, ""); + if (!std.mem.eql(u8, resp.status, "200 OK")) { + std.log.warn("unlinked dependent {s}/{s} but failed to restart after delete: {s}", .{ + dep.component, + dep.name, + resp.status, + }); + } + } +} + +fn restoreDeletedInstance( + s: *state_mod.State, + component: []const u8, + name: []const u8, + rollback_version: []const u8, + rollback_auto_start: bool, + rollback_launch_mode: []const u8, + rollback_verbose: bool, + inst_dir: []const u8, + hidden_inst_dir: ?[]const u8, +) void { + _ = s.addInstance(component, name, .{ + .version = rollback_version, + .auto_start = rollback_auto_start, + .launch_mode = rollback_launch_mode, + .verbose = rollback_verbose, + }) catch {}; + _ = s.save() catch {}; + if (hidden_inst_dir) |path| { + std_compat.fs.renameAbsolute(path, inst_dir) catch {}; + } +} + +/// DELETE /api/instances/{component}/{name} +pub fn handleDelete( + allocator: std.mem.Allocator, + s: *state_mod.State, + manager: *manager_mod.Manager, + paths: paths_mod.Paths, + component: []const u8, + name: []const u8, + target: []const u8, +) ApiResponse { + const existing = s.getInstance(component, name) orelse return notFound(); + const rollback_version = allocator.dupe(u8, existing.version) catch return helpers.serverError(); + defer allocator.free(rollback_version); + const rollback_auto_start = existing.auto_start; + const rollback_launch_mode = allocator.dupe(u8, existing.launch_mode) catch return helpers.serverError(); + defer allocator.free(rollback_launch_mode); + const rollback_verbose = existing.verbose; + + var delete_impact = collectDeleteImpact(allocator, s, paths, component, name) catch return helpers.serverError(); + defer delete_impact.deinit(allocator); + const force = query_api.boolValue(target, "force"); + if (delete_impact.dependents.items.items.len > 0 and !force) { + return deleteDependencyConflict(allocator, delete_impact.dependents.items.items); + } + + const inst_dir = paths.instanceDir(allocator, component, name) catch return helpers.serverError(); + defer allocator.free(inst_dir); + + manager.stopInstance(component, name) catch {}; + const hidden_inst_dir = hideInstanceDirForDelete(allocator, inst_dir) catch return helpers.serverError(); + defer if (hidden_inst_dir) |path| allocator.free(path); + + if (!s.removeInstance(component, name)) { + if (hidden_inst_dir) |path| { + std_compat.fs.renameAbsolute(path, inst_dir) catch {}; + } + return notFound(); + } + s.save() catch { + restoreDeletedInstance(s, component, name, rollback_version, rollback_auto_start, rollback_launch_mode, rollback_verbose, inst_dir, hidden_inst_dir); + return helpers.serverError(); + }; + + if (delete_impact.dependents.items.items.len > 0) { + unlinkDeleteImpact(allocator, paths, component, delete_impact) catch { + restoreDeletedInstance(s, component, name, rollback_version, rollback_auto_start, rollback_launch_mode, rollback_verbose, inst_dir, hidden_inst_dir); + _ = s.save() catch {}; + return helpers.serverError(); + }; + restartRunningDeleteDependents(allocator, s, manager, paths, delete_impact); + } + + if (hidden_inst_dir) |path| { + std_compat.fs.deleteTreeAbsolute(path) catch |err| { + std.log.warn("deleted instance {s}/{s} but failed to clean hidden dir '{s}': {s}", .{ + component, + name, + path, + @errorName(err), + }); + }; + } + + return jsonOk("{\"status\":\"deleted\"}"); +} + +fn hideInstanceDirForDelete(allocator: std.mem.Allocator, inst_dir: []const u8) !?[]const u8 { + std_compat.fs.accessAbsolute(inst_dir, .{}) catch |err| switch (err) { + error.FileNotFound => return null, + else => return err, + }; + + const parent = std.fs.path.dirname(inst_dir) orelse return error.InvalidPath; + const base = std.fs.path.basename(inst_dir); + const ts = @as(u64, @intCast(@max(0, std_compat.time.milliTimestamp()))); + + var attempt: u32 = 0; + while (attempt < 1024) : (attempt += 1) { + const hidden_path = try std.fmt.allocPrint(allocator, "{s}/.{s}.deleted-{d}-{d}", .{ + parent, + base, + ts, + attempt, + }); + errdefer allocator.free(hidden_path); + + std_compat.fs.renameAbsolute(inst_dir, hidden_path) catch |err| switch (err) { + error.FileNotFound => return null, + else => return err, + }; + return hidden_path; + } + + return error.PathAlreadyExists; +} + +fn findInstalledBinaryVersion(allocator: std.mem.Allocator, paths: paths_mod.Paths, component: []const u8) ?[]const u8 { + const bin_dir = std.fmt.allocPrint(allocator, "{s}/bin", .{paths.root}) catch return null; + defer allocator.free(bin_dir); + + var dir = std_compat.fs.openDirAbsolute(bin_dir, .{ .iterate = true }) catch return null; + defer dir.close(); + + const prefix = std.fmt.allocPrint(allocator, "{s}-", .{component}) catch return null; + defer allocator.free(prefix); + + var best_version: ?[]const u8 = null; + var it = dir.iterate(); + while (it.next() catch null) |entry| { + if (entry.kind != .file) continue; + if (!std.mem.startsWith(u8, entry.name, prefix)) continue; + + var version = entry.name[prefix.len..]; + if (builtin.os.tag == .windows and std.mem.endsWith(u8, version, ".exe")) { + version = version[0 .. version.len - 4]; + } + if (version.len == 0) continue; + + const candidate_path = std.fs.path.join(allocator, &.{ bin_dir, entry.name }) catch continue; + defer allocator.free(candidate_path); + std_compat.fs.accessAbsolute(candidate_path, .{}) catch continue; + + if (best_version == null or std.mem.order(u8, version, best_version.?) == .gt) { + const owned_version = allocator.dupe(u8, version) catch continue; + if (best_version) |owned| allocator.free(owned); + best_version = owned_version; + } + } + + return best_version; +} + +fn downloadLatestBinaryVersion(allocator: std.mem.Allocator, paths: paths_mod.Paths, component: []const u8) ?[]const u8 { + const known = registry.findKnownComponent(component) orelse return null; + var release = registry.fetchLatestRelease(allocator, known.repo) catch return null; + defer release.deinit(); + + const platform_key = comptime platform.detect().toString(); + const asset = registry.findAssetForComponentPlatform(allocator, release.value, component, platform_key) orelse return null; + + paths.ensureDirs() catch return null; + const bin_path = paths.binary(allocator, component, release.value.tag_name) catch return null; + defer allocator.free(bin_path); + + downloader.downloadIfMissing(allocator, asset.browser_download_url, bin_path) catch return null; + return allocator.dupe(u8, release.value.tag_name) catch null; +} + +fn resolveImportBinaryVersion(allocator: std.mem.Allocator, paths: paths_mod.Paths, component: []const u8) ?[]const u8 { + if (local_binary.stageDevLocal(allocator, paths, component)) |dest_bin| { + allocator.free(dest_bin); + return allocator.dupe(u8, local_binary.dev_local_version) catch null; + } + if (findInstalledBinaryVersion(allocator, paths, component)) |version| return version; + return downloadLatestBinaryVersion(allocator, paths, component); +} + +/// POST /api/instances/{component}/import — import a standalone installation. +/// Copies config and data from ~/.{component}/ into the nullhub instance directory. +/// A runnable binary is staged during import so the managed instance can start. +pub fn handleImport(allocator: std.mem.Allocator, s: *state_mod.State, paths: paths_mod.Paths, component: []const u8) ApiResponse { + if (s.getInstance(component, "default") != null) { + return conflict("{\"error\":\"default instance already exists\"}"); + } + + const home = std_compat.process.getEnvVarOwned(allocator, "HOME") catch blk: { + if (builtin.os.tag == .windows) { + break :blk std_compat.process.getEnvVarOwned(allocator, "USERPROFILE") catch return helpers.serverError(); + } + return helpers.serverError(); + }; + defer allocator.free(home); + + // 1. Verify standalone dir exists + const dot_dir = std.fmt.allocPrint(allocator, "{s}/.{s}", .{ home, component }) catch return helpers.serverError(); defer allocator.free(dot_dir); std_compat.fs.accessAbsolute(dot_dir, .{}) catch return notFound(); // 2. Create instance directory structure const inst_dir = paths.instanceDir(allocator, component, "default") catch return helpers.serverError(); defer allocator.free(inst_dir); + if (std_compat.fs.accessAbsolute(inst_dir, .{})) |_| { + return conflict("{\"error\":\"default instance directory already exists\"}"); + } else |err| switch (err) { + error.FileNotFound => {}, + else => return helpers.serverError(), + } // Ensure parent component dir exists const comp_dir = std.fs.path.join(allocator, &.{ paths.root, "instances", component }) catch return helpers.serverError(); @@ -3335,30 +3951,30 @@ pub fn handleImport(allocator: std.mem.Allocator, s: *state_mod.State, paths: pa else => return helpers.serverError(), }; - // 3. Symlink the entire standalone dir as the instance dir + // 3. Stage or reuse a runnable binary before mutating the instance path. + const version = resolveImportBinaryVersion(allocator, paths, component) orelse return helpers.serverError(); + defer allocator.free(version); + + // 4. Symlink the entire standalone dir as the instance dir // ~/.nullclaw → ~/.nullhub/instances/nullclaw/default // This preserves all data in place (config, auth, workspace, state, logs) - std_compat.fs.deleteFileAbsolute(inst_dir) catch {}; - std_compat.fs.deleteTreeAbsolute(inst_dir) catch {}; std_compat.fs.symLinkAbsolute(dot_dir, inst_dir, .{ .is_directory = true }) catch return helpers.serverError(); - // 4. Stage binary from local dev build or leave for download on start. - const version = blk: { - if (local_binary.stageDevLocal(allocator, paths, component)) |dest_bin| { - allocator.free(dest_bin); - break :blk local_binary.dev_local_version; - } - break :blk "standalone"; - }; - // 5. Register in state s.addInstance(component, "default", .{ .version = version, .auto_start = false, .launch_mode = defaultLaunchModeForComponent(component), .verbose = false, - }) catch return helpers.serverError(); - s.save() catch return helpers.serverError(); + }) catch { + std_compat.fs.deleteFileAbsolute(inst_dir) catch {}; + return helpers.serverError(); + }; + s.save() catch { + _ = s.removeInstance(component, "default"); + std_compat.fs.deleteFileAbsolute(inst_dir) catch {}; + return helpers.serverError(); + }; return jsonOk("{\"status\":\"imported\",\"instance\":\"default\"}"); } @@ -3381,7 +3997,7 @@ pub fn handlePatch(s: *state_mod.State, component: []const u8, name: []const u8, defer parsed.deinit(); const new_auto_start = parsed.value.auto_start orelse entry.auto_start; - const new_launch_mode = parsed.value.launch_mode orelse entry.launch_mode; + const new_launch_mode = registry.normalizeLaunchCommand(component, parsed.value.launch_mode orelse entry.launch_mode); const new_verbose = parsed.value.verbose orelse entry.verbose; var validated_launch = launch_args_mod.resolve(s.allocator, new_launch_mode, new_verbose) catch @@ -3537,8 +4153,9 @@ fn handleIntegrationGet( pipelines = &.{}; } + const boiler_runtime = getStatusLocked(mutex, manager, "nullboiler", name); var tracker_status = blk: { - const status = getStatusLocked(mutex, manager, "nullboiler", name) orelse break :blk null; + const status = boiler_runtime orelse break :blk null; if (status.status != .running) break :blk null; const url = buildInstanceUrl(allocator, boiler_cfg.port, "/tracker/status") orelse break :blk null; defer allocator.free(url); @@ -3558,7 +4175,19 @@ fn handleIntegrationGet( const body = std.json.Stringify.valueAlloc(allocator, .{ .kind = "nullboiler", + .instance = .{ + .name = boiler_cfg.name, + .port = boiler_cfg.port, + .running = if (boiler_runtime) |status| status.status == .running else false, + .token_configured = boiler_cfg.api_token != null, + }, .configured = boiler_cfg.tracker != null, + .configured_tracker = if (boiler_cfg.tracker) |tracker| .{ + .url = tracker.url, + .agent_id = tracker.agent_id, + .token_configured = tracker.api_token != null, + .max_concurrent_tasks = tracker.max_concurrent_tasks, + } else null, .linked_tracker = if (linked) |tracker| .{ .name = tracker.name, .port = tracker.port, @@ -3630,6 +4259,7 @@ fn handleIntegrationGet( break :blk fetchJsonValue(allocator, url, tickets_cfg.api_token); }; defer if (queue) |*value| value.deinit(allocator); + const tickets_runtime = getStatusLocked(mutex, manager, "nulltickets", name); var linked_boiler_views: std.ArrayListUnmanaged(LinkedBoilerView) = .empty; defer linked_boiler_views.deinit(allocator); @@ -3643,6 +4273,12 @@ fn handleIntegrationGet( const body = std.json.Stringify.valueAlloc(allocator, .{ .kind = "nulltickets", + .instance = .{ + .name = tickets_cfg.name, + .port = tickets_cfg.port, + .running = if (tickets_runtime) |status| status.status == .running else false, + .token_configured = tickets_cfg.api_token != null, + }, .queue = if (queue) |value| value.parsed.value else null, .linked_boilers = linked_boiler_views.items, }, .{ .emit_null_optional_fields = false }) catch return helpers.serverError(); @@ -3677,6 +4313,84 @@ fn linkNullClawTelemetry( return jsonOk("{\"status\":\"linked\"}"); } +const NullBoilerLinkRequest = struct { + tickets: integration_mod.NullTicketsConfig, + pipeline_id: []const u8, + claim_role: []const u8, + success_trigger: []const u8, + max_concurrent_tasks: ?u32 = null, + + fn deinit(self: *NullBoilerLinkRequest, allocator: std.mem.Allocator) void { + allocator.free(self.success_trigger); + allocator.free(self.claim_role); + allocator.free(self.pipeline_id); + integration_mod.deinitNullTicketsConfig(allocator, &self.tickets); + self.* = undefined; + } +}; + +const NullBoilerLinkRequestError = error{ + InvalidJson, + TrackerInstanceRequired, + PipelineIdRequired, + TrackerNotFound, + OutOfMemory, +}; + +fn parseNullBoilerLinkRequest( + allocator: std.mem.Allocator, + paths: paths_mod.Paths, + body: []const u8, +) NullBoilerLinkRequestError!NullBoilerLinkRequest { + const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{ + .allocate = .alloc_always, + .ignore_unknown_fields = true, + }) catch return error.InvalidJson; + defer parsed.deinit(); + if (parsed.value != .object) return error.InvalidJson; + + const obj = parsed.value.object; + const tracker_name = jsonString(obj, "tracker_instance") orelse return error.TrackerInstanceRequired; + if (tracker_name.len == 0) return error.TrackerInstanceRequired; + const pipeline_id = jsonString(obj, "pipeline_id") orelse return error.PipelineIdRequired; + if (pipeline_id.len == 0) return error.PipelineIdRequired; + + var tickets = (integration_mod.loadNullTicketsConfig(allocator, paths, tracker_name) catch null) orelse + return error.TrackerNotFound; + errdefer integration_mod.deinitNullTicketsConfig(allocator, &tickets); + + const pipeline_id_owned = try allocator.dupe(u8, pipeline_id); + errdefer allocator.free(pipeline_id_owned); + const claim_role = try allocator.dupe(u8, nonEmptyJsonStringOrDefault(obj, "claim_role", "coder")); + errdefer allocator.free(claim_role); + const success_trigger = try allocator.dupe(u8, nonEmptyJsonStringOrDefault(obj, "success_trigger", "complete")); + + return .{ + .tickets = tickets, + .pipeline_id = pipeline_id_owned, + .claim_role = claim_role, + .success_trigger = success_trigger, + .max_concurrent_tasks = parseOptionalPositiveU32(obj.get("max_concurrent_tasks")), + }; +} + +fn nonEmptyJsonStringOrDefault(obj: std.json.ObjectMap, key: []const u8, default_value: []const u8) []const u8 { + const value = jsonString(obj, key) orelse return default_value; + return if (value.len > 0) value else default_value; +} + +fn parseOptionalPositiveU32(value: ?std.json.Value) ?u32 { + const raw = value orelse return null; + return switch (raw) { + .integer => if (raw.integer > 0 and raw.integer <= std.math.maxInt(u32)) @as(?u32, @intCast(raw.integer)) else null, + .string => |text| blk: { + const parsed = std.fmt.parseInt(u32, text, 10) catch break :blk null; + break :blk if (parsed > 0) parsed else null; + }, + else => null, + }; +} + fn handleIntegrationPost( allocator: std.mem.Allocator, s: *state_mod.State, @@ -3734,52 +4448,14 @@ fn handleIntegrationPost( if (!std.mem.eql(u8, component, "nullboiler")) return badRequest("{\"error\":\"integration updates are only supported for nullclaw, nullwatch, and nullboiler\"}"); - const tracker_cfg = blk: { - const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{ - .allocate = .alloc_always, - .ignore_unknown_fields = true, - }) catch return badRequest("{\"error\":\"invalid JSON body\"}"); - defer parsed.deinit(); - if (parsed.value != .object) return badRequest("{\"error\":\"invalid JSON body\"}"); - const tracker_name = if (parsed.value.object.get("tracker_instance")) |value| - if (value == .string and value.string.len > 0) value.string else null - else - null; - if (tracker_name == null) return badRequest("{\"error\":\"tracker_instance is required\"}"); - const pipeline_id = if (parsed.value.object.get("pipeline_id")) |value| - if (value == .string and value.string.len > 0) value.string else null - else - null; - if (pipeline_id == null) return badRequest("{\"error\":\"pipeline_id is required\"}"); - const cfg = integration_mod.loadNullTicketsConfig(allocator, paths, tracker_name.?) catch null orelse return notFound(); - break :blk .{ - .tickets = cfg, - .pipeline_id = pipeline_id.?, - .claim_role = if (parsed.value.object.get("claim_role")) |value| - if (value == .string and value.string.len > 0) value.string else "coder" - else - "coder", - .success_trigger = if (parsed.value.object.get("success_trigger")) |value| - if (value == .string and value.string.len > 0) value.string else "complete" - else - "complete", - .max_concurrent_tasks = if (parsed.value.object.get("max_concurrent_tasks")) |value| - switch (value) { - .integer => if (value.integer > 0 and value.integer <= std.math.maxInt(u32)) @as(?u32, @intCast(value.integer)) else null, - .string => std.fmt.parseInt(u32, value.string, 10) catch null, - else => null, - } - else - null, - }; + var tracker_cfg = parseNullBoilerLinkRequest(allocator, paths, body) catch |err| switch (err) { + error.InvalidJson => return badRequest("{\"error\":\"invalid JSON body\"}"), + error.TrackerInstanceRequired => return badRequest("{\"error\":\"tracker_instance is required\"}"), + error.PipelineIdRequired => return badRequest("{\"error\":\"pipeline_id is required\"}"), + error.TrackerNotFound => return notFound(), + error.OutOfMemory => return helpers.serverError(), }; - defer { - var owned_cfg = tracker_cfg.tickets; - integration_mod.deinitNullTicketsConfig(allocator, &owned_cfg); - } - - var existing = integration_mod.loadNullBoilerConfig(allocator, paths, name) catch null orelse return notFound(); - defer integration_mod.deinitNullBoilerConfig(allocator, &existing); + defer tracker_cfg.deinit(allocator); const tracker_runtime = getStatusLocked(mutex, manager, "nulltickets", tracker_cfg.tickets.name); if (tracker_runtime != null and tracker_runtime.?.status == .running) { @@ -3805,74 +4481,16 @@ fn handleIntegrationPost( } } - const config_path = paths.instanceConfig(allocator, "nullboiler", name) catch return helpers.serverError(); - defer allocator.free(config_path); - const file = std_compat.fs.openFileAbsolute(config_path, .{}) catch return helpers.serverError(); - defer file.close(); - const config_bytes = file.readToEndAlloc(allocator, 1024 * 1024) catch return helpers.serverError(); - defer allocator.free(config_bytes); - - var parsed_config = std.json.parseFromSlice(std.json.Value, allocator, config_bytes, .{ - .allocate = .alloc_always, - .ignore_unknown_fields = true, - }) catch return helpers.serverError(); - defer parsed_config.deinit(); - if (parsed_config.value != .object) return helpers.serverError(); - - const tracker_map = ensureObjectField(allocator, &parsed_config.value.object, "tracker") catch return helpers.serverError(); - const tracker_url = std.fmt.allocPrint(allocator, "http://127.0.0.1:{d}", .{tracker_cfg.tickets.port}) catch return helpers.serverError(); - tracker_map.put(allocator, "url", .{ .string = tracker_url }) catch return helpers.serverError(); - if (tracker_cfg.tickets.api_token) |token| { - tracker_map.put(allocator, "api_token", .{ .string = token }) catch return helpers.serverError(); - } else { - _ = tracker_map.swapRemove("api_token"); - } - if (jsonString(tracker_map.*, "agent_id")) |agent_id| { - if (agent_id.len == 0) { - tracker_map.put(allocator, "agent_id", .{ .string = if (existing.tracker) |tracker| tracker.agent_id else name }) catch return helpers.serverError(); - } - } else { - tracker_map.put(allocator, "agent_id", .{ .string = if (existing.tracker) |tracker| tracker.agent_id else name }) catch return helpers.serverError(); - } - if (jsonString(tracker_map.*, "workflows_dir")) |workflows_dir| { - if (workflows_dir.len == 0) { - tracker_map.put(allocator, "workflows_dir", .{ .string = "workflows" }) catch return helpers.serverError(); - } - } else { - tracker_map.put(allocator, "workflows_dir", .{ .string = "workflows" }) catch return helpers.serverError(); - } - - const concurrency_map = ensureObjectField(allocator, tracker_map, "concurrency") catch return helpers.serverError(); - if (tracker_cfg.max_concurrent_tasks) |max_concurrent_tasks| { - concurrency_map.put(allocator, "max_concurrent_tasks", .{ .integer = max_concurrent_tasks }) catch return helpers.serverError(); - } else if (concurrency_map.get("max_concurrent_tasks") == null) { - concurrency_map.put(allocator, "max_concurrent_tasks", .{ .integer = if (existing.tracker) |tracker| tracker.max_concurrent_tasks else 1 }) catch return helpers.serverError(); - } - - const workflows_dir_value = jsonStringOrEmpty(tracker_map.*, "workflows_dir"); - const rendered = std.json.Stringify.valueAlloc(allocator, parsed_config.value, .{ - .whitespace = .indent_2, - .emit_null_optional_fields = false, - }) catch return helpers.serverError(); - defer allocator.free(rendered); - - const out = std_compat.fs.createFileAbsolute(config_path, .{ .truncate = true }) catch return helpers.serverError(); - defer out.close(); - out.writeAll(rendered) catch return helpers.serverError(); - out.writeAll("\n") catch return helpers.serverError(); - - const workflows_dir = resolvePathFromConfig(allocator, config_path, workflows_dir_value) catch return helpers.serverError(); - defer allocator.free(workflows_dir); - - ensureTrackerWorkflowFile( - allocator, - config_path, - workflows_dir, - if (existing.tracker) |tracker| if (tracker.workflow) |workflow| workflow.file_name else null else null, - tracker_cfg.pipeline_id, - tracker_cfg.claim_role, - tracker_cfg.success_trigger, - ) catch return helpers.serverError(); + integration_mod.linkNullBoilerToNullTickets(allocator, paths, name, .{ + .tickets = tracker_cfg.tickets, + .pipeline_id = tracker_cfg.pipeline_id, + .claim_role = tracker_cfg.claim_role, + .success_trigger = tracker_cfg.success_trigger, + .max_concurrent_tasks = tracker_cfg.max_concurrent_tasks, + }) catch |err| switch (err) { + error.NotFound => return notFound(), + else => return helpers.serverError(), + }; if (getStatusLocked(mutex, manager, "nullboiler", name)) |status| { if (status.status == .running) { @@ -3885,60 +4503,6 @@ fn handleIntegrationPost( return jsonOk("{\"status\":\"linked\"}"); } -fn ensureTrackerWorkflowFile( - allocator: std.mem.Allocator, - config_path: []const u8, - workflows_dir: []const u8, - previous_workflow_file: ?[]const u8, - pipeline_id: []const u8, - claim_role: []const u8, - success_trigger: []const u8, -) !void { - try ensurePath(workflows_dir); - - if (previous_workflow_file) |file_name| { - if (!std.mem.eql(u8, file_name, integration_mod.managed_workflow_file_name)) { - const previous_path = try std.fs.path.join(allocator, &.{ workflows_dir, file_name }); - defer allocator.free(previous_path); - if (isNullHubManagedWorkflow(allocator, previous_path)) { - std_compat.fs.deleteFileAbsolute(previous_path) catch {}; - } - } - } - - const config_dir = std.fs.path.dirname(config_path) orelse return error.InvalidPath; - const legacy_path = try std.fs.path.join(allocator, &.{ config_dir, integration_mod.legacy_workflow_file_name }); - defer allocator.free(legacy_path); - std_compat.fs.deleteFileAbsolute(legacy_path) catch {}; - - const legacy_workflows_path = try std.fs.path.join(allocator, &.{ workflows_dir, integration_mod.legacy_workflow_file_name }); - defer allocator.free(legacy_workflows_path); - std_compat.fs.deleteFileAbsolute(legacy_workflows_path) catch {}; - - const workflow_path = try std.fs.path.join(allocator, &.{ workflows_dir, integration_mod.managed_workflow_file_name }); - defer allocator.free(workflow_path); - - const rendered = try std.json.Stringify.valueAlloc(allocator, .{ - .id = try std.fmt.allocPrint(allocator, "wf-{s}-{s}", .{ pipeline_id, claim_role }), - .pipeline_id = pipeline_id, - .claim_roles = &.{claim_role}, - .execution = "subprocess", - .prompt_template = default_tracker_prompt_template, - .on_success = .{ - .transition_to = success_trigger, - }, - }, .{ - .whitespace = .indent_2, - .emit_null_optional_fields = false, - }); - defer allocator.free(rendered); - - const file_out = try std_compat.fs.createFileAbsolute(workflow_path, .{ .truncate = true }); - defer file_out.close(); - try file_out.writeAll(rendered); - try file_out.writeAll("\n"); -} - // ─── Top-level dispatcher ──────────────────────────────────────────────────── pub fn isIntegrationPath(target: []const u8) bool { @@ -3946,6 +4510,11 @@ pub fn isIntegrationPath(target: []const u8) bool { return parsed.action != null and std.mem.eql(u8, parsed.action.?, "integration"); } +pub fn isTicketsActionPath(target: []const u8) bool { + const parsed = parsePath(target) orelse return false; + return parsed.action != null and std.mem.eql(u8, parsed.action.?, "tickets"); +} + /// Route an `/api/instances` request. Called from server.zig. /// `method` is the HTTP verb, `target` is the full request path, /// `body` is the (possibly empty) request body. @@ -4134,6 +4703,10 @@ pub fn dispatch( if (std.mem.eql(u8, method, "POST")) return handleIntegrationPost(allocator, s, manager, mutex, paths, parsed.component, parsed.name, body); return methodNotAllowed(); } + if (std.mem.eql(u8, action, "tickets")) { + if (!std.mem.eql(u8, method, "POST")) return methodNotAllowed(); + return handleNullTicketsAction(allocator, s, manager, mutex, paths, parsed.component, parsed.name, body); + } // Remaining actions are POST-only. if (!std.mem.eql(u8, method, "POST")) return methodNotAllowed(); @@ -4152,7 +4725,7 @@ pub fn dispatch( // No action — CRUD on the instance itself. if (std.mem.eql(u8, method, "GET")) return handleGet(allocator, s, manager, paths, parsed.component, parsed.name); - if (std.mem.eql(u8, method, "DELETE")) return handleDelete(allocator, s, manager, paths, parsed.component, parsed.name); + if (std.mem.eql(u8, method, "DELETE")) return handleDelete(allocator, s, manager, paths, parsed.component, parsed.name, target); if (std.mem.eql(u8, method, "PATCH")) return handlePatch(s, parsed.component, parsed.name, body); return methodNotAllowed(); @@ -4192,6 +4765,44 @@ test "component default launch mode uses registry metadata" { try std.testing.expect(!isLegacyDefaultLaunchMode("nullclaw", "gateway")); } +test "pipeline summaries accept wrapped lists and JSON string definitions" { + const allocator = std.testing.allocator; + const raw = + \\{ + \\ "pipelines": [{ + \\ "id": "pipe-dev", + \\ "name": "Development", + \\ "definition": "{\"states\":{\"claim\":{\"agent_role\":\"reviewer\"},\"build\":{\"agent_role\":\"coder\"}},\"transitions\":[{\"trigger\":\"complete\"},{\"trigger\":\"needs_review\"}]}" + \\ }] + \\} + ; + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, raw, .{ + .allocate = .alloc_always, + .ignore_unknown_fields = true, + }); + defer parsed.deinit(); + + const items = pipelineItemsFromValue(parsed.value) orelse @panic("pipelines missing"); + try std.testing.expectEqual(@as(usize, 1), items.len); + + const summary = try parsePipelineSummary(allocator, items[0]); + defer deinitPipelineSummary(allocator, summary); + try std.testing.expectEqualStrings("pipe-dev", summary.id); + try std.testing.expectEqualStrings("Development", summary.name); + try std.testing.expect(pipelineContainsString(summary.roles, "reviewer")); + try std.testing.expect(pipelineContainsString(summary.roles, "coder")); + try std.testing.expect(pipelineContainsString(summary.triggers, "complete")); + try std.testing.expect(pipelineContainsString(summary.triggers, "needs_review")); +} + +test "parseOptionalPositiveU32 rejects zero values" { + try std.testing.expect(parseOptionalPositiveU32(std.json.Value{ .integer = 0 }) == null); + try std.testing.expect(parseOptionalPositiveU32(std.json.Value{ .string = "0" }) == null); + try std.testing.expect(parseOptionalPositiveU32(std.json.Value{ .string = "" }) == null); + try std.testing.expectEqual(@as(?u32, 4), parseOptionalPositiveU32(std.json.Value{ .integer = 4 })); + try std.testing.expectEqual(@as(?u32, 5), parseOptionalPositiveU32(std.json.Value{ .string = "5" })); +} + fn writeTestInstanceConfig( allocator: std.mem.Allocator, paths: paths_mod.Paths, @@ -4379,6 +4990,59 @@ test "parseAnyHttpStatusCode extracts first valid http code" { try std.testing.expectEqual(@as(?u16, null), parseAnyHttpStatusCode("not-a-code")); } +test "isAllowedNullTicketsAction allows only safe tracker actions" { + try std.testing.expect(isAllowedNullTicketsAction(.GET, "/pipelines")); + try std.testing.expect(isAllowedNullTicketsAction(.POST, "/pipelines")); + try std.testing.expect(isAllowedNullTicketsAction(.GET, "/tasks?limit=8")); + try std.testing.expect(isAllowedNullTicketsAction(.GET, "/tasks/task-a/dependencies")); + try std.testing.expect(isAllowedNullTicketsAction(.POST, "/tasks/task-a/assignments")); + try std.testing.expect(isAllowedNullTicketsAction(.DELETE, "/tasks/task-a/assignments/agent-a")); + try std.testing.expect(isAllowedNullTicketsAction(.GET, "/ops/queue")); + try std.testing.expect(isAllowedNullTicketsAction(.POST, "/tasks")); + try std.testing.expect(isAllowedNullTicketsAction(.POST, "/leases/claim")); + try std.testing.expect(isAllowedNullTicketsAction(.POST, "/leases/lease-a/heartbeat")); + try std.testing.expect(isAllowedNullTicketsAction(.GET, "/runs/run-a/events?limit=20")); + try std.testing.expect(isAllowedNullTicketsAction(.POST, "/runs/run-a/events")); + try std.testing.expect(isAllowedNullTicketsAction(.POST, "/runs/run-a/transition")); + try std.testing.expect(isAllowedNullTicketsAction(.POST, "/runs/run-a/fail")); + try std.testing.expect(isAllowedNullTicketsAction(.GET, "/artifacts?task_id=task-a")); + try std.testing.expect(isAllowedNullTicketsAction(.POST, "/artifacts")); + + try std.testing.expect(!isAllowedNullTicketsAction(.POST, "/store/default/key")); + try std.testing.expect(!isAllowedNullTicketsAction(.DELETE, "/tasks/task-a")); + try std.testing.expect(!isAllowedNullTicketsAction(.GET, "http://127.0.0.1:1/tasks")); + try std.testing.expect(!isAllowedNullTicketsAction(.GET, "/tasks\n/evil")); + try std.testing.expect(!isAllowedNullTicketsAction(.POST, "/tasks?limit=1")); + try std.testing.expect(!isAllowedNullTicketsAction(.POST, "/runs/run-a/events?limit=1")); + try std.testing.expect(!isAllowedNullTicketsAction(.POST, "/leases/lease-a/heartbeat?ttl=1")); +} + +test "classifyNullTicketsAction separates instance and lease scoped auth" { + try std.testing.expectEqual(NullTicketsActionAuthMode.instance_token, classifyNullTicketsAction(.GET, "/tasks?limit=8").?); + try std.testing.expectEqual(NullTicketsActionAuthMode.instance_token, classifyNullTicketsAction(.POST, "/leases/claim").?); + try std.testing.expectEqual(NullTicketsActionAuthMode.lease_token, classifyNullTicketsAction(.POST, "/leases/lease-a/heartbeat").?); + try std.testing.expectEqual(NullTicketsActionAuthMode.lease_token, classifyNullTicketsAction(.POST, "/runs/run-a/events").?); + try std.testing.expect(classifyNullTicketsAction(.POST, "/runs/run-a/events?limit=1") == null); + try std.testing.expect(classifyNullTicketsAction(.POST, "/store/default/key") == null); +} + +test "nullTicketsForwardedToken does not mix admin and lease credentials" { + try std.testing.expectEqualStrings( + "admin-token", + nullTicketsForwardedToken(.instance_token, "admin-token", "lease-token").?, + ); + try std.testing.expectEqualStrings( + "lease-token", + nullTicketsForwardedToken(.lease_token, "admin-token", "lease-token").?, + ); + try std.testing.expect(nullTicketsForwardedToken(.lease_token, "admin-token", null) == null); + try std.testing.expect(nullTicketsForwardedToken(.lease_token, "admin-token", "") == null); +} + +test "actionStatus preserves expired lease status" { + try std.testing.expectEqualStrings("410 Gone", actionStatus(410)); +} + test "classifyProbeFailure maps status codes" { const unauthorized = classifyProbeFailure(401, "", ""); try std.testing.expectEqualStrings("invalid_api_key", unauthorized.reason); @@ -4792,7 +5456,7 @@ test "handleDelete removes instance" { try s.addInstance("nullclaw", "my-agent", .{ .version = "1.0.0" }); - const resp = handleDelete(allocator, &s, &mctx.manager, mctx.paths, "nullclaw", "my-agent"); + const resp = handleDelete(allocator, &s, &mctx.manager, mctx.paths, "nullclaw", "my-agent", "/api/instances/nullclaw/my-agent"); try std.testing.expectEqualStrings("200 OK", resp.status); try std.testing.expectEqualStrings("{\"status\":\"deleted\"}", resp.body); @@ -4817,7 +5481,7 @@ test "handleDelete removes instance directory from active path" { const inst_dir = try mctx.paths.instanceDir(allocator, "nullclaw", "my-agent"); defer allocator.free(inst_dir); - const resp = handleDelete(allocator, &s, &mctx.manager, mctx.paths, "nullclaw", "my-agent"); + const resp = handleDelete(allocator, &s, &mctx.manager, mctx.paths, "nullclaw", "my-agent", "/api/instances/nullclaw/my-agent"); try std.testing.expectEqualStrings("200 OK", resp.status); std_compat.fs.accessAbsolute(inst_dir, .{}) catch |err| switch (err) { @@ -4846,7 +5510,7 @@ test "handleDelete restores instance when state save fails" { const inst_dir = try mctx.paths.instanceDir(allocator, "nullclaw", "my-agent"); defer allocator.free(inst_dir); - const resp = handleDelete(allocator, &s, &mctx.manager, mctx.paths, "nullclaw", "my-agent"); + const resp = handleDelete(allocator, &s, &mctx.manager, mctx.paths, "nullclaw", "my-agent", "/api/instances/nullclaw/my-agent"); try std.testing.expectEqualStrings("500 Internal Server Error", resp.status); try std.testing.expect(s.getInstance("nullclaw", "my-agent") != null); try std_compat.fs.accessAbsolute(inst_dir, .{}); @@ -4863,10 +5527,127 @@ test "handleDelete returns 404 for missing instance" { var mctx = TestManagerCtx.init(allocator); defer mctx.deinit(allocator); - const resp = handleDelete(allocator, &s, &mctx.manager, mctx.paths, "nope", "nope"); + const resp = handleDelete(allocator, &s, &mctx.manager, mctx.paths, "nope", "nope", "/api/instances/nope/nope"); try std.testing.expectEqualStrings("404 Not Found", resp.status); } +test "handleDelete blocks nulltickets while nullboiler is linked" { + const allocator = std.testing.allocator; + var state_fixture = try test_helpers.TempPaths.init(allocator); + defer state_fixture.deinit(); + const state_path = try state_fixture.paths.state(allocator); + defer allocator.free(state_path); + var s = state_mod.State.init(allocator, state_path); + defer s.deinit(); + var mctx = TestManagerCtx.init(allocator); + defer mctx.deinit(allocator); + + try s.addInstance("nulltickets", "tracker-a", .{ .version = "1.0.0" }); + try s.addInstance("nullboiler", "boiler-a", .{ .version = "1.0.0" }); + try writeTestInstanceConfig(allocator, mctx.paths, "nulltickets", "tracker-a", "{\"port\":7711,\"api_token\":\"admin-token\"}"); + try writeTestInstanceConfig(allocator, mctx.paths, "nullboiler", "boiler-a", "{\"port\":8811,\"tracker\":{\"url\":\"http://127.0.0.1:7711\",\"api_token\":\"admin-token\"}}"); + + const resp = handleDelete(allocator, &s, &mctx.manager, mctx.paths, "nulltickets", "tracker-a", "/api/instances/nulltickets/tracker-a"); + try std.testing.expectEqualStrings("409 Conflict", resp.status); + defer allocator.free(resp.body); + try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"force_required\":true") != null); + try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"name\":\"boiler-a\"") != null); + try std.testing.expect(s.getInstance("nulltickets", "tracker-a") != null); +} + +test "handleDelete force unlinks nullboiler before deleting nulltickets" { + const allocator = std.testing.allocator; + var state_fixture = try test_helpers.TempPaths.init(allocator); + defer state_fixture.deinit(); + const state_path = try state_fixture.paths.state(allocator); + defer allocator.free(state_path); + var s = state_mod.State.init(allocator, state_path); + defer s.deinit(); + var mctx = TestManagerCtx.init(allocator); + defer mctx.deinit(allocator); + + try s.addInstance("nulltickets", "tracker-a", .{ .version = "1.0.0" }); + try s.addInstance("nullboiler", "boiler-a", .{ .version = "1.0.0" }); + try writeTestInstanceConfig(allocator, mctx.paths, "nulltickets", "tracker-a", "{\"port\":7711,\"api_token\":\"admin-token\"}"); + try writeTestInstanceConfig(allocator, mctx.paths, "nullboiler", "boiler-a", "{\"port\":8811,\"tracker\":{\"url\":\"http://127.0.0.1:7711\",\"api_token\":\"admin-token\",\"agent_id\":\"worker\"}}"); + + const resp = handleDelete(allocator, &s, &mctx.manager, mctx.paths, "nulltickets", "tracker-a", "/api/instances/nulltickets/tracker-a?force=1"); + try std.testing.expectEqualStrings("200 OK", resp.status); + try std.testing.expect(s.getInstance("nulltickets", "tracker-a") == null); + try std.testing.expect(s.getInstance("nullboiler", "boiler-a") != null); + + const config_path = try mctx.paths.instanceConfig(allocator, "nullboiler", "boiler-a"); + defer allocator.free(config_path); + const config_bytes = try std.fs.readFileAbsolute(allocator, config_path, 1024 * 1024); + defer allocator.free(config_bytes); + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, config_bytes, .{ + .allocate = .alloc_always, + .ignore_unknown_fields = true, + }); + defer parsed.deinit(); + try std.testing.expect(parsed.value.object.get("tracker") == null); +} + +test "handleDelete blocks nullwatch while nullclaw telemetry is linked" { + const allocator = std.testing.allocator; + var state_fixture = try test_helpers.TempPaths.init(allocator); + defer state_fixture.deinit(); + const state_path = try state_fixture.paths.state(allocator); + defer allocator.free(state_path); + var s = state_mod.State.init(allocator, state_path); + defer s.deinit(); + var mctx = TestManagerCtx.init(allocator); + defer mctx.deinit(allocator); + + try s.addInstance("nullwatch", "observer-a", .{ .version = "1.0.0" }); + try s.addInstance("nullclaw", "agent-a", .{ .version = "1.0.0" }); + try writeTestInstanceConfig(allocator, mctx.paths, "nullwatch", "observer-a", "{\"port\":7712,\"api_token\":\"watch-token\"}"); + try writeTestInstanceConfig(allocator, mctx.paths, "nullclaw", "agent-a", "{\"diagnostics\":{\"backend\":\"otel\",\"otel\":{\"endpoint\":\"http://127.0.0.1:7712\",\"service_name\":\"nullclaw/agent-a\"}}}"); + + const resp = handleDelete(allocator, &s, &mctx.manager, mctx.paths, "nullwatch", "observer-a", "/api/instances/nullwatch/observer-a"); + try std.testing.expectEqualStrings("409 Conflict", resp.status); + defer allocator.free(resp.body); + try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"force_required\":true") != null); + try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"name\":\"agent-a\"") != null); + try std.testing.expect(s.getInstance("nullwatch", "observer-a") != null); +} + +test "handleDelete force unlinks nullclaw telemetry before deleting nullwatch" { + const allocator = std.testing.allocator; + var state_fixture = try test_helpers.TempPaths.init(allocator); + defer state_fixture.deinit(); + const state_path = try state_fixture.paths.state(allocator); + defer allocator.free(state_path); + var s = state_mod.State.init(allocator, state_path); + defer s.deinit(); + var mctx = TestManagerCtx.init(allocator); + defer mctx.deinit(allocator); + + try s.addInstance("nullwatch", "observer-a", .{ .version = "1.0.0" }); + try s.addInstance("nullclaw", "agent-a", .{ .version = "1.0.0" }); + try writeTestInstanceConfig(allocator, mctx.paths, "nullwatch", "observer-a", "{\"port\":7712,\"api_token\":\"watch-token\"}"); + try writeTestInstanceConfig(allocator, mctx.paths, "nullclaw", "agent-a", "{\"diagnostics\":{\"backend\":\"otel\",\"log_tool_calls\":true,\"otel\":{\"endpoint\":\"http://127.0.0.1:7712\",\"service_name\":\"nullclaw/agent-a\",\"headers\":{\"Authorization\":\"Bearer watch-token\"}}}}"); + + const resp = handleDelete(allocator, &s, &mctx.manager, mctx.paths, "nullwatch", "observer-a", "/api/instances/nullwatch/observer-a?force=1"); + try std.testing.expectEqualStrings("200 OK", resp.status); + try std.testing.expect(s.getInstance("nullwatch", "observer-a") == null); + try std.testing.expect(s.getInstance("nullclaw", "agent-a") != null); + + const config_path = try mctx.paths.instanceConfig(allocator, "nullclaw", "agent-a"); + defer allocator.free(config_path); + const config_bytes = try std.fs.readFileAbsolute(allocator, config_path, 1024 * 1024); + defer allocator.free(config_bytes); + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, config_bytes, .{ + .allocate = .alloc_always, + .ignore_unknown_fields = true, + }); + defer parsed.deinit(); + const diagnostics = parsed.value.object.get("diagnostics").?.object; + try std.testing.expectEqualStrings("jsonl", diagnostics.get("backend").?.string); + try std.testing.expect(diagnostics.get("log_tool_calls").?.bool); + try std.testing.expect(diagnostics.get("otel") == null); +} + test "handlePatch updates auto_start" { const allocator = std.testing.allocator; var state_fixture = try test_helpers.TempPaths.init(allocator); @@ -4931,6 +5712,24 @@ test "handlePatch updates launch_mode" { try std.testing.expectEqualStrings("agent", entry.launch_mode); } +test "handlePatch normalizes service component launch_mode" { + const allocator = std.testing.allocator; + var state_fixture = try test_helpers.TempPaths.init(allocator); + defer state_fixture.deinit(); + const state_path = try state_fixture.paths.state(allocator); + defer allocator.free(state_path); + var s = state_mod.State.init(allocator, state_path); + defer s.deinit(); + + try s.addInstance("nullboiler", "default", .{ .version = "1.0.0", .launch_mode = "server" }); + + const resp = handlePatch(&s, "nullboiler", "default", "{\"launch_mode\":\"nullboiler\"}"); + try std.testing.expectEqualStrings("200 OK", resp.status); + + const entry = s.getInstance("nullboiler", "default").?; + try std.testing.expectEqualStrings("server", entry.launch_mode); +} + test "handlePatch rejects invalid launch_mode" { const allocator = std.testing.allocator; var state_fixture = try test_helpers.TempPaths.init(allocator); @@ -5537,6 +6336,47 @@ test "dispatch routes GET integration action for linked nullboiler" { try std.testing.expectEqual(@as(i64, 2), current_link.get("max_concurrent_tasks").?.integer); } +test "dispatch routes nulltickets tickets action to managed instances only" { + const allocator = std.testing.allocator; + var state_fixture = try test_helpers.TempPaths.init(allocator); + defer state_fixture.deinit(); + const state_path = try state_fixture.paths.state(allocator); + defer allocator.free(state_path); + var s = state_mod.State.init(allocator, state_path); + defer s.deinit(); + var mctx = TestManagerCtx.init(allocator); + defer mctx.deinit(allocator); + + try s.addInstance("nulltickets", "tracker-a", .{ .version = "1.0.0" }); + try writeTestInstanceConfig(allocator, mctx.paths, "nulltickets", "tracker-a", "{\"port\":7711,\"api_token\":\"admin-token\"}"); + + const resp = dispatch( + allocator, + &s, + &mctx.manager, + &mctx.mutex, + mctx.paths, + "POST", + "/api/instances/nulltickets/tracker-a/tickets", + "{\"method\":\"GET\",\"path\":\"/tasks?limit=8\"}", + ).?; + + try std.testing.expectEqualStrings("409 Conflict", resp.status); + try std.testing.expectEqualStrings("{\"error\":\"nulltickets instance is not running\"}", resp.body); + + const unsupported = dispatch( + allocator, + &s, + &mctx.manager, + &mctx.mutex, + mctx.paths, + "POST", + "/api/instances/nulltickets/tracker-a/tickets", + "{\"method\":\"POST\",\"path\":\"/store/default/key\"}", + ).?; + try std.testing.expectEqualStrings("400 Bad Request", unsupported.status); +} + test "dispatch routes GET integration action for nullclaw nullwatch telemetry" { const allocator = std.testing.allocator; var state_fixture = try test_helpers.TempPaths.init(allocator); @@ -5854,7 +6694,7 @@ test "dispatch integration relink preserves advanced tracker config and custom w .pipeline_id = "pipe-old", .claim_roles = &.{"coder"}, .execution = "subprocess", - .prompt_template = default_tracker_prompt_template, + .prompt_template = integration_mod.default_tracker_prompt_template, .on_success = .{ .transition_to = "complete", }, diff --git a/src/api/orchestration.zig b/src/api/orchestration.zig index 8de6b06..d6a5684 100644 --- a/src/api/orchestration.zig +++ b/src/api/orchestration.zig @@ -2,6 +2,7 @@ const std = @import("std"); const std_compat = @import("compat"); const http_proxy = @import("proxy.zig"); const Allocator = std.mem.Allocator; +const query_api = @import("query.zig"); const Response = http_proxy.Response; @@ -35,11 +36,13 @@ const Backend = enum { }; pub fn isProxyPath(target: []const u8) bool { - return http_proxy.isPathInNamespace(target, prefix); + const clean = query_api.stripTarget(target); + return http_proxy.isPathInNamespace(clean, prefix); } fn isStorePath(target: []const u8) bool { - return std.mem.eql(u8, target, store_prefix) or std.mem.startsWith(u8, target, store_prefix ++ "/"); + const clean = query_api.stripTarget(target); + return std.mem.eql(u8, clean, store_prefix) or std.mem.startsWith(u8, clean, store_prefix ++ "/"); } const ProxyTarget = struct { @@ -53,6 +56,26 @@ fn backendForPath(target: []const u8) ?Backend { return if (isStorePath(target)) .tickets else .boiler; } +pub fn requestedTicketsInstance(allocator: Allocator, target: []const u8) !?[]u8 { + if (!isStorePath(target)) return null; + const value = (try query_api.valueAlloc(allocator, target, "tickets_instance")) orelse return null; + if (value.len == 0) { + allocator.free(value); + return null; + } + return value; +} + +pub fn requestedBoilerInstance(allocator: Allocator, target: []const u8) !?[]u8 { + if (!isProxyPath(target) or isStorePath(target)) return null; + const value = (try query_api.valueAlloc(allocator, target, "boiler_instance")) orelse return null; + if (value.len == 0) { + allocator.free(value); + return null; + } + return value; +} + fn resolveProxyTarget(target: []const u8, cfg: Config) ?ProxyTarget { const backend = backendForPath(target) orelse return null; return switch (backend) { @@ -87,7 +110,11 @@ pub fn handle(allocator: Allocator, method: []const u8, target: []const u8, body const resolved = resolveProxyTarget(target, cfg) orelse return .{ .status = "503 Service Unavailable", .content_type = "application/json", .body = backend.notConfiguredBody() }; - const proxied_path = target[prefix.len..]; + var forwarded = forwardedTarget(allocator, target) catch + return .{ .status = "500 Internal Server Error", .content_type = "application/json", .body = "{\"error\":\"internal error\"}" }; + defer forwarded.deinit(allocator); + + const proxied_path = forwarded.value[prefix.len..]; const path = if (proxied_path.len == 0) "/" else proxied_path; return http_proxy.forward(allocator, .{ @@ -100,6 +127,47 @@ pub fn handle(allocator: Allocator, method: []const u8, target: []const u8, body }); } +const ForwardedTarget = struct { + value: []const u8, + owned: bool = false, + + fn deinit(self: *ForwardedTarget, allocator: Allocator) void { + if (self.owned) allocator.free(self.value); + self.* = .{ .value = "" }; + } +}; + +fn forwardedTarget(allocator: Allocator, target: []const u8) !ForwardedTarget { + const qmark = std.mem.indexOfScalar(u8, target, '?') orelse return .{ .value = target }; + var stripped_any = false; + var buf = std.array_list.Managed(u8).init(allocator); + errdefer buf.deinit(); + + try buf.appendSlice(target[0..qmark]); + var wrote_query = false; + var params = std.mem.splitScalar(u8, target[qmark + 1 ..], '&'); + while (params.next()) |param| { + if (isHubProxyParam(param)) { + stripped_any = true; + continue; + } + try buf.append(if (wrote_query) '&' else '?'); + wrote_query = true; + try buf.appendSlice(param); + } + + if (!stripped_any) { + buf.deinit(); + return .{ .value = target }; + } + return .{ .value = try buf.toOwnedSlice(), .owned = true }; +} + +fn isHubProxyParam(param: []const u8) bool { + const key = if (std.mem.indexOfScalar(u8, param, '=')) |eq| param[0..eq] else param; + return std.mem.eql(u8, key, "tickets_instance") or std.mem.eql(u8, key, "boiler_instance"); +} + const TestUpstream = struct { allocator: Allocator, ctx: *Context, @@ -114,7 +182,7 @@ const TestUpstream = struct { while (!ctx.stop_flag.load(.acquire)) { var conn = ctx.server.accept() catch |err| switch (err) { error.WouldBlock => { - std.time.sleep(10 * std.time.ns_per_ms); + std_compat.thread.sleep(10 * std.time.ns_per_ms); continue; }, else => return, @@ -142,7 +210,7 @@ const TestUpstream = struct { }; const addr = try std_compat.net.Address.resolveIp("127.0.0.1", 0); - ctx.server = try addr.listen(.{ .force_nonblocking = true }); + ctx.server = try addr.listen(.{}); errdefer ctx.server.deinit(); const thread = try std.Thread.spawn(.{}, Context.run, .{ctx}); @@ -169,16 +237,44 @@ const TestUpstream = struct { test "isProxyPath matches orchestration namespace" { try std.testing.expect(isProxyPath("/api/orchestration")); + try std.testing.expect(isProxyPath("/api/orchestration?tickets_instance=tracker-a")); try std.testing.expect(isProxyPath("/api/orchestration/runs")); try std.testing.expect(isProxyPath("/api/orchestration/store/search")); try std.testing.expect(!isProxyPath("/api/instances")); } test "backendForPath routes store requests to tickets backend" { - try std.testing.expectEqual(Backend.tickets, backendForPath("/api/orchestration/store/search").?); + try std.testing.expectEqual(Backend.tickets, backendForPath("/api/orchestration/store/search?tickets_instance=tracker-a").?); try std.testing.expectEqual(Backend.boiler, backendForPath("/api/orchestration/runs").?); } +test "requestedTicketsInstance decodes store target selection" { + const allocator = std.testing.allocator; + const value = (try requestedTicketsInstance(allocator, "/api/orchestration/store/ns?tickets_instance=tracker%20a")).?; + defer allocator.free(value); + try std.testing.expectEqualStrings("tracker a", value); + try std.testing.expect(try requestedTicketsInstance(allocator, "/api/orchestration/runs?tickets_instance=tracker-a") == null); +} + +test "requestedBoilerInstance decodes orchestration target selection" { + const allocator = std.testing.allocator; + const value = (try requestedBoilerInstance(allocator, "/api/orchestration/workflows?boiler_instance=boiler%20a")).?; + defer allocator.free(value); + try std.testing.expectEqualStrings("boiler a", value); + try std.testing.expect(try requestedBoilerInstance(allocator, "/api/orchestration/store/ns?boiler_instance=boiler-a") == null); +} + +test "forwardedTarget strips hub-only proxy params" { + const allocator = std.testing.allocator; + var forwarded = try forwardedTarget(allocator, "/api/orchestration/store/search?q=tasks&tickets_instance=tracker-a&limit=10"); + defer forwarded.deinit(allocator); + try std.testing.expectEqualStrings("/api/orchestration/store/search?q=tasks&limit=10", forwarded.value); + + var boiler_forwarded = try forwardedTarget(allocator, "/api/orchestration/runs?boiler_instance=boiler-a&status=running"); + defer boiler_forwarded.deinit(allocator); + try std.testing.expectEqualStrings("/api/orchestration/runs?status=running", boiler_forwarded.value); +} + test "handle routes store paths to NullTickets config" { const resp = handle(std.testing.allocator, "GET", "/api/orchestration/store/search", "", .{ .boiler_url = "http://127.0.0.1:8080", @@ -213,7 +309,7 @@ test "handle passes through upstream 409 status and body" { if (comptime @import("builtin").os.tag == .windows) return error.SkipZigTest; const allocator = std.testing.allocator; - var upstream = try TestUpstream.start(allocator, "HTTP/1.1 409 Conflict\r\nContent-Type: application/json\r\nContent-Length: 19\r\n\r\n{\"error\":\"conflict\"}"); + var upstream = try TestUpstream.start(allocator, "HTTP/1.1 409 Conflict\r\nContent-Type: application/json\r\nContent-Length: 20\r\n\r\n{\"error\":\"conflict\"}"); defer upstream.deinit(); const base_url = try upstream.baseUrl(allocator); diff --git a/src/api/updates.zig b/src/api/updates.zig index 66f0750..8b73fbf 100644 --- a/src/api/updates.zig +++ b/src/api/updates.zig @@ -58,13 +58,14 @@ fn versionsEqual(a: []const u8, b: []const u8) bool { } fn normalizedLaunchModeForUpdate(component: []const u8, launch_mode: []const u8, known: registry.KnownComponent) []const u8 { - if (!std.mem.eql(u8, known.default_launch_command, "gateway") and std.mem.eql(u8, launch_mode, "gateway")) { + const normalized = registry.normalizeLaunchCommand(component, launch_mode); + if (!std.mem.eql(u8, known.default_launch_command, "gateway") and std.mem.eql(u8, normalized, "gateway")) { return known.default_launch_command; } - if (std.mem.eql(u8, component, "nullwatch") and std.mem.eql(u8, launch_mode, "nullwatch")) { + if (std.mem.eql(u8, component, "nullwatch") and std.mem.eql(u8, normalized, "nullwatch")) { return known.default_launch_command; } - return launch_mode; + return normalized; } fn fetchLatestTagForComponent(allocator: std.mem.Allocator, component: []const u8) ?[]u8 { diff --git a/src/api/wizard.zig b/src/api/wizard.zig index bd6f530..bf620e3 100644 --- a/src/api/wizard.zig +++ b/src/api/wizard.zig @@ -142,25 +142,40 @@ fn compareVersionTags(a: []const u8, b: []const u8) std.math.Order { /// Returns the manifest JSON directly (the component owns its wizard definition). /// Returns null if the component is unknown or binary not found. /// Caller owns the returned memory. -pub fn handleGetWizard(allocator: std.mem.Allocator, component_name: []const u8, paths: paths_mod.Paths, state: *state_mod.State) ?[]const u8 { +pub fn handleGetWizard( + allocator: std.mem.Allocator, + component_name: []const u8, + target: []const u8, + paths: paths_mod.Paths, + state: *state_mod.State, +) ?[]const u8 { // Verify the component is known if (registry.findKnownComponent(component_name) == null) return null; + const requested_version = query.valueAlloc(allocator, target, "version") catch null; + defer if (requested_version) |value| allocator.free(value); + const explicit_version = if (requested_version) |value| + value.len > 0 and !std.mem.eql(u8, value, "latest") + else + false; + // Try existing binary first - if (findOrFetchComponentBinary(allocator, component_name, paths)) |bin_path| { + if (findOrFetchComponentBinaryForVersion(allocator, component_name, paths, requested_version)) |bin_path| { defer allocator.free(bin_path); if (component_cli.exportManifest(allocator, bin_path)) |json| { return augmentWizardManifest(allocator, component_name, json, state, paths) orelse json; } else |_| {} - // Existing binary doesn't support --export-manifest, try fetching latest } - // Download latest release and retry - if (fetchLatestComponentBinary(allocator, component_name, paths)) |bin_path| { - defer allocator.free(bin_path); - if (component_cli.exportManifest(allocator, bin_path)) |json| { - return augmentWizardManifest(allocator, component_name, json, state, paths) orelse json; - } else |_| {} + // Existing local/latest binary may be too old to export a manifest. For + // unpinned wizard requests, force a latest release fetch and retry. + if (!explicit_version) { + if (fetchLatestComponentBinary(allocator, component_name, paths)) |bin_path| { + defer allocator.free(bin_path); + if (component_cli.exportManifest(allocator, bin_path)) |json| { + return augmentWizardManifest(allocator, component_name, json, state, paths) orelse json; + } else |_| {} + } } return allocator.dupe(u8, "{\"error\":\"no compatible version found, check GitHub releases\"}") catch null; @@ -284,6 +299,150 @@ fn isPortFree(port: u16) bool { return true; } +fn wizardHasStep(steps: []const manifest_mod.WizardStep, id: []const u8) bool { + for (steps) |step| { + if (std.mem.eql(u8, step.id, id)) return true; + } + return false; +} + +fn wizardStepIndex(steps: []const manifest_mod.WizardStep, id: []const u8) ?usize { + for (steps, 0..) |step, idx| { + if (std.mem.eql(u8, step.id, id)) return idx; + } + return null; +} + +fn missingNullBoilerLinkStepCount(steps: []const manifest_mod.WizardStep) usize { + var count: usize = 0; + if (!wizardHasStep(steps, "tracker_instance")) count += 1; + if (!wizardHasStep(steps, "tracker_pipeline_id")) count += 1; + if (!wizardHasStep(steps, "tracker_claim_role")) count += 1; + if (!wizardHasStep(steps, "tracker_success_trigger")) count += 1; + if (!wizardHasStep(steps, "tracker_max_concurrent_tasks")) count += 1; + return count; +} + +fn isNullBoilerTrackerStep(step_id: []const u8) bool { + return std.mem.eql(u8, step_id, "tracker_enabled") or + std.mem.startsWith(u8, step_id, "tracker_"); +} + +fn nullBoilerLinkInsertIndex(steps: []const manifest_mod.WizardStep) usize { + for (steps, 0..) |step, idx| { + if (isNullBoilerTrackerStep(step.id)) return idx; + } + return steps.len; +} + +fn appendMissingNullBoilerLinkSteps( + out_steps: []manifest_mod.WizardStep, + next_step: *usize, + base_steps: []const manifest_mod.WizardStep, + trackers: []const integration_mod.NullTicketsConfig, + options: []const manifest_mod.StepOption, +) void { + if (!wizardHasStep(base_steps, "tracker_instance")) { + out_steps[next_step.*] = .{ + .id = "tracker_instance", + .title = "Link NullTickets", + .description = "Auto-connect this NullBoiler instance to a local NullTickets tracker", + .type = .select, + .required = false, + .options = options, + .default_value = if (trackers.len == 1) trackers[0].name else "", + }; + next_step.* += 1; + } + if (!wizardHasStep(base_steps, "tracker_pipeline_id")) { + out_steps[next_step.*] = .{ + .id = "tracker_pipeline_id", + .title = "Tracker Pipeline", + .description = "Pipeline id this NullBoiler should claim tasks from.", + .type = .text, + .required = true, + .condition = .{ .step = "tracker_instance", .not_equals = "" }, + }; + next_step.* += 1; + } + if (!wizardHasStep(base_steps, "tracker_claim_role")) { + out_steps[next_step.*] = .{ + .id = "tracker_claim_role", + .title = "Claim Role", + .description = "Role this worker claims from the selected pipeline.", + .type = .text, + .required = false, + .default_value = "coder", + .condition = .{ .step = "tracker_instance", .not_equals = "" }, + }; + next_step.* += 1; + } + if (!wizardHasStep(base_steps, "tracker_success_trigger")) { + out_steps[next_step.*] = .{ + .id = "tracker_success_trigger", + .title = "Success Trigger", + .description = "Transition to apply when a task completes successfully.", + .type = .text, + .required = false, + .default_value = "complete", + .condition = .{ .step = "tracker_instance", .not_equals = "" }, + }; + next_step.* += 1; + } + if (!wizardHasStep(base_steps, "tracker_max_concurrent_tasks")) { + out_steps[next_step.*] = .{ + .id = "tracker_max_concurrent_tasks", + .title = "Tracker Concurrency", + .description = "Maximum NullTickets tasks this worker may run at once.", + .type = .number, + .required = false, + .default_value = "1", + .condition = .{ .step = "tracker_instance", .not_equals = "" }, + }; + next_step.* += 1; + } +} + +fn isManagedServiceComponent(component_name: []const u8) bool { + return std.mem.eql(u8, component_name, "nullboiler") or + std.mem.eql(u8, component_name, "nulltickets") or + std.mem.eql(u8, component_name, "nullwatch"); +} + +fn managedServiceDefaultPort(component_name: []const u8, manifest: manifest_mod.Manifest) u16 { + if (manifest.ports.len > 0) return manifest.ports[0].default; + return if (registry.findKnownComponent(component_name)) |known| known.default_port else 0; +} + +fn servicePortDefaultOverride( + allocator: std.mem.Allocator, + component_name: []const u8, + manifest: manifest_mod.Manifest, + state: *state_mod.State, + paths: paths_mod.Paths, +) ?[]const u8 { + if (!isManagedServiceComponent(component_name)) return null; + if (!wizardHasStep(manifest.wizard.steps, "port")) return null; + + const default_port = managedServiceDefaultPort(component_name, manifest); + if (default_port == 0) return null; + + const next_port = orchestrator.findNextAvailablePort(allocator, default_port, paths, state); + const rendered = std.fmt.allocPrint(allocator, "{d}", .{next_port}) catch return null; + errdefer allocator.free(rendered); + + for (manifest.wizard.steps) |step| { + if (!std.mem.eql(u8, step.id, "port")) continue; + if (std.mem.eql(u8, step.default_value, rendered)) { + allocator.free(rendered); + return null; + } + return rendered; + } + allocator.free(rendered); + return null; +} + fn augmentWizardManifest( allocator: std.mem.Allocator, component_name: []const u8, @@ -294,16 +453,26 @@ fn augmentWizardManifest( if (std.mem.eql(u8, component_name, "nullclaw")) { return null; } - if (!std.mem.eql(u8, component_name, "nullboiler")) return null; - - const trackers = integration_mod.listNullTickets(allocator, state, paths) catch return null; - defer integration_mod.deinitNullTicketsConfigs(allocator, trackers); - if (trackers.len == 0) return null; + if (!isManagedServiceComponent(component_name)) return null; const parsed = manifest_mod.parseManifest(allocator, manifest_json) catch return null; defer parsed.deinit(); const base = parsed.value; + + var trackers: []integration_mod.NullTicketsConfig = &.{}; + var owns_trackers = false; + defer if (owns_trackers) integration_mod.deinitNullTicketsConfigs(allocator, trackers); + if (std.mem.eql(u8, component_name, "nullboiler")) { + trackers = integration_mod.listNullTickets(allocator, state, paths) catch return null; + owns_trackers = true; + } + + const missing_link_steps = if (trackers.len > 0) missingNullBoilerLinkStepCount(base.wizard.steps) else 0; + const port_default = servicePortDefaultOverride(allocator, component_name, base, state, paths); + defer if (port_default) |value| allocator.free(value); + if (missing_link_steps == 0 and port_default == null) return null; + const options = allocator.alloc(manifest_mod.StepOption, trackers.len + 1) catch return null; defer allocator.free(options); options[0] = .{ @@ -324,18 +493,31 @@ fn augmentWizardManifest( for (options[1..]) |option| allocator.free(option.description); } - const steps = allocator.alloc(manifest_mod.WizardStep, base.wizard.steps.len + 1) catch return null; + const steps = allocator.alloc(manifest_mod.WizardStep, base.wizard.steps.len + missing_link_steps) catch return null; defer allocator.free(steps); - @memcpy(steps[0..base.wizard.steps.len], base.wizard.steps); - steps[base.wizard.steps.len] = .{ - .id = "tracker_instance", - .title = "Link NullTickets", - .description = "Auto-connect this NullBoiler instance to a local NullTickets tracker", - .type = .select, - .required = false, - .options = options, - .default_value = if (trackers.len == 1) trackers[0].name else "", - }; + const insert_index = if (missing_link_steps > 0) nullBoilerLinkInsertIndex(base.wizard.steps) else base.wizard.steps.len; + var next_step: usize = 0; + var inserted_link_steps = false; + for (base.wizard.steps, 0..) |step, idx| { + if (!inserted_link_steps and idx == insert_index) { + appendMissingNullBoilerLinkSteps(steps, &next_step, base.wizard.steps, trackers, options); + inserted_link_steps = true; + } + steps[next_step] = step; + next_step += 1; + } + if (!inserted_link_steps and next_step < steps.len) { + appendMissingNullBoilerLinkSteps(steps, &next_step, base.wizard.steps, trackers, options); + } + if (port_default) |value| { + for (steps) |*step| { + if (std.mem.eql(u8, step.id, "port")) { + step.default_value = value; + break; + } + } + } + std.debug.assert(next_step == steps.len); var manifest = base; manifest.wizard.steps = steps; @@ -350,40 +532,71 @@ fn prepareWizardBody( ) ?[]const u8 { if (!std.mem.eql(u8, component_name, "nullboiler")) return null; - const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{ + var parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{ .allocate = .alloc_always, .ignore_unknown_fields = true, }) catch return null; defer parsed.deinit(); if (parsed.value != .object) return null; + const json_allocator = parsed.arena.allocator(); const tracker_instance = if (parsed.value.object.get("tracker_instance")) |value| if (value == .string and value.string.len > 0) value.string else null else null; - if (tracker_instance == null) return null; + if (tracker_instance == null) { + if (parsed.value.object.get("tracker_instance") == null) return null; + parsed.value.object.put(json_allocator, "tracker_enabled", .{ .string = "false" }) catch return null; + removeNullBoilerTrackerAnswerFields(&parsed.value.object); + return std.json.Stringify.valueAlloc(allocator, parsed.value, .{}) catch return null; + } var tracker_cfg = (integration_mod.loadNullTicketsConfig(allocator, paths, tracker_instance.?) catch return null) orelse return null; defer integration_mod.deinitNullTicketsConfig(allocator, &tracker_cfg); - var root = parsed.value.object; - const tracker_url = std.fmt.allocPrint(allocator, "http://127.0.0.1:{d}", .{tracker_cfg.port}) catch return null; - root.put(allocator, "tracker_enabled", .{ .string = "true" }) catch return null; - root.put(allocator, "tracker_url", .{ .string = tracker_url }) catch return null; + var root = &parsed.value.object; + const tracker_url = std.fmt.allocPrint(json_allocator, "http://127.0.0.1:{d}", .{tracker_cfg.port}) catch return null; + root.put(json_allocator, "tracker_enabled", .{ .string = "true" }) catch return null; + root.put(json_allocator, "tracker_url", .{ .string = tracker_url }) catch return null; if (tracker_cfg.api_token) |token| { - root.put(allocator, "tracker_api_token", .{ .string = token }) catch return null; - } - if (!root.contains("tracker_claim_role")) { - root.put(allocator, "tracker_claim_role", .{ .string = "coder" }) catch return null; + const owned_token = json_allocator.dupe(u8, token) catch return null; + root.put(json_allocator, "tracker_api_token", .{ .string = owned_token }) catch return null; + } else { + _ = root.swapRemove("tracker_api_token"); } + putDefaultStringIfMissingOrEmpty(json_allocator, root, "tracker_claim_role", "coder") catch return null; + putDefaultStringIfMissingOrEmpty(json_allocator, root, "tracker_success_trigger", "complete") catch return null; + putDefaultStringIfMissingOrEmpty(json_allocator, root, "tracker_max_concurrent_tasks", "1") catch return null; return std.json.Stringify.valueAlloc(allocator, parsed.value, .{}) catch return null; } +fn removeNullBoilerTrackerAnswerFields(root: *std.json.ObjectMap) void { + _ = root.swapRemove("tracker_url"); + _ = root.swapRemove("tracker_api_token"); + _ = root.swapRemove("tracker_pipeline_id"); + _ = root.swapRemove("tracker_claim_role"); + _ = root.swapRemove("tracker_success_trigger"); + _ = root.swapRemove("tracker_max_concurrent_tasks"); +} + +fn putDefaultStringIfMissingOrEmpty( + allocator: std.mem.Allocator, + root: *std.json.ObjectMap, + key: []const u8, + value: []const u8, +) !void { + if (root.get(key)) |existing| { + if (existing != .string or existing.string.len > 0) return; + } + try root.put(allocator, key, .{ .string = value }); +} + fn validateWizardBodyForInstall( allocator: std.mem.Allocator, component_name: []const u8, body: []const u8, + paths: paths_mod.Paths, ) ?[]const u8 { if (!std.mem.eql(u8, component_name, "nullboiler")) return null; @@ -394,11 +607,26 @@ fn validateWizardBodyForInstall( defer parsed.deinit(); if (parsed.value != .object) return allocator.dupe(u8, "{\"error\":\"invalid JSON body\"}") catch null; + const tracker_instance = if (parsed.value.object.get("tracker_instance")) |value| + if (value == .string and value.string.len > 0) value.string else null + else + null; const tracker_enabled = if (parsed.value.object.get("tracker_enabled")) |value| - value == .string and std.mem.eql(u8, value.string, "true") + (value == .string and std.mem.eql(u8, value.string, "true")) or + (value == .bool and value.bool) else false; - if (!tracker_enabled) return null; + const tracker_requested = tracker_enabled or tracker_instance != null; + if (!tracker_requested) return null; + + if (tracker_instance == null) { + return allocator.dupe(u8, "{\"error\":\"tracker_instance is required when tracker mode is enabled\"}") catch null; + } + if (tracker_instance) |name| { + var tracker_cfg = (integration_mod.loadNullTicketsConfig(allocator, paths, name) catch null) orelse + return allocator.dupe(u8, "{\"error\":\"tracker_instance was not found\"}") catch null; + integration_mod.deinitNullTicketsConfig(allocator, &tracker_cfg); + } const pipeline_id = if (parsed.value.object.get("tracker_pipeline_id")) |value| value == .string and value.string.len > 0 @@ -470,16 +698,71 @@ fn findOrFetchComponentBinary(allocator: std.mem.Allocator, component: []const u return fetchLatestComponentBinary(allocator, component, paths); } +fn findExactInstalledComponentBinary( + allocator: std.mem.Allocator, + component: []const u8, + paths: paths_mod.Paths, + version: []const u8, +) ?[]const u8 { + const bin_path = paths.binary(allocator, component, version) catch return null; + if (std_compat.fs.openFileAbsolute(bin_path, .{})) |file| { + file.close(); + return bin_path; + } else |_| { + allocator.free(bin_path); + return null; + } +} + +fn findOrFetchComponentBinaryForVersion( + allocator: std.mem.Allocator, + component: []const u8, + paths: paths_mod.Paths, + requested_version: ?[]const u8, +) ?[]const u8 { + const version = requested_version orelse return findOrFetchComponentBinary(allocator, component, paths); + if (version.len == 0 or std.mem.eql(u8, version, "latest")) { + return findOrFetchComponentBinary(allocator, component, paths); + } + if (findExactInstalledComponentBinary(allocator, component, paths, version)) |bin| { + return bin; + } + if (builtin.is_test) return null; + return fetchComponentBinaryByTag(allocator, component, paths, version); +} + fn fetchLatestComponentBinary(allocator: std.mem.Allocator, component: []const u8, paths: paths_mod.Paths) ?[]const u8 { const known = registry.findKnownComponent(component) orelse return null; var release = registry.fetchLatestRelease(allocator, known.repo) catch return null; defer release.deinit(); + return fetchComponentBinaryFromRelease(allocator, component, paths, release.value); +} + +fn fetchComponentBinaryByTag( + allocator: std.mem.Allocator, + component: []const u8, + paths: paths_mod.Paths, + version: []const u8, +) ?[]const u8 { + const known = registry.findKnownComponent(component) orelse return null; + var release = registry.fetchReleaseByTag(allocator, known.repo, version) catch return null; + defer release.deinit(); + + return fetchComponentBinaryFromRelease(allocator, component, paths, release.value); +} + +fn fetchComponentBinaryFromRelease( + allocator: std.mem.Allocator, + component: []const u8, + paths: paths_mod.Paths, + release: registry.ReleaseInfo, +) ?[]const u8 { const platform_key = comptime platform.detect().toString(); - const asset = registry.findAssetForComponentPlatform(allocator, release.value, component, platform_key) orelse return null; + const asset = registry.findAssetForComponentPlatform(allocator, release, component, platform_key) orelse return null; paths.ensureDirs() catch return null; - const bin_path = paths.binary(allocator, component, release.value.tag_name) catch return null; + const bin_path = paths.binary(allocator, component, release.tag_name) catch return null; downloader.downloadIfMissing(allocator, asset.browser_download_url, bin_path) catch { allocator.free(bin_path); @@ -518,7 +801,7 @@ pub fn handlePostWizard( const effective_body = prepareWizardBody(allocator, component_name, body, paths) orelse body; defer if (effective_body.ptr != body.ptr) allocator.free(effective_body); - if (validateWizardBodyForInstall(allocator, component_name, effective_body)) |json| { + if (validateWizardBodyForInstall(allocator, component_name, effective_body, paths)) |json| { return json; } @@ -1098,6 +1381,27 @@ test "findInstalledComponentBinary finds binary in bin directory" { try std.testing.expectEqualStrings(bin_path, found.?); } +test "findOrFetchComponentBinaryForVersion uses exact installed version" { + const allocator = std.testing.allocator; + var fixture = try test_helpers.TempPaths.init(allocator); + defer fixture.deinit(); + try fixture.paths.ensureDirs(); + + const bin_path = try fixture.paths.binary(allocator, "nullboiler", "v2.0.0"); + defer allocator.free(bin_path); + + { + const file = try std_compat.fs.createFileAbsolute(bin_path, .{}); + defer file.close(); + try file.writeAll("#!/bin/sh\n"); + } + + const found = findOrFetchComponentBinaryForVersion(allocator, "nullboiler", fixture.paths, "v2.0.0"); + try std.testing.expect(found != null); + defer allocator.free(found.?); + try std.testing.expectEqualStrings(bin_path, found.?); +} + test "handleGetWizard returns null for unknown component" { const allocator = std.testing.allocator; var fixture = try test_helpers.TempPaths.init(allocator); @@ -1106,7 +1410,7 @@ test "handleGetWizard returns null for unknown component" { defer allocator.free(state_path); var state = state_mod.State.init(allocator, state_path); defer state.deinit(); - const result = handleGetWizard(allocator, "nonexistent", fixture.paths, &state); + const result = handleGetWizard(allocator, "nonexistent", "/api/wizard/nonexistent", fixture.paths, &state); try std.testing.expect(result == null); } @@ -1119,10 +1423,296 @@ test "handleGetWizard returns null when no binary found" { var state = state_mod.State.init(allocator, state_path); defer state.deinit(); // nullclaw is a known component but there's no binary in test dirs - const result = handleGetWizard(allocator, "nullclaw", fixture.paths, &state); + const result = handleGetWizard(allocator, "nullclaw", "/api/wizard/nullclaw", fixture.paths, &state); try std.testing.expect(result == null); } +test "augmentWizardManifest adds complete nullboiler tracker setup" { + const allocator = std.testing.allocator; + var fixture = try test_helpers.TempPaths.init(allocator); + defer fixture.deinit(); + try fixture.paths.ensureDirs(); + + const state_path = try fixture.paths.state(allocator); + defer allocator.free(state_path); + var state = state_mod.State.init(allocator, state_path); + defer state.deinit(); + try state.addInstance("nulltickets", "tracker-a", .{ .version = "v1.0.0" }); + + const inst_dir = try fixture.paths.instanceDir(allocator, "nulltickets", "tracker-a"); + defer allocator.free(inst_dir); + try std.fs.makePathAbsolute(inst_dir); + const config_path = try fixture.paths.instanceConfig(allocator, "nulltickets", "tracker-a"); + defer allocator.free(config_path); + { + const file = try std_compat.fs.createFileAbsolute(config_path, .{ .truncate = true }); + defer file.close(); + try file.writeAll("{\"port\":7711}\n"); + } + + const manifest_json = + \\{ + \\ "schema_version": 1, + \\ "name": "nullboiler", + \\ "display_name": "NullBoiler", + \\ "description": "Orchestrator", + \\ "icon": "workflow", + \\ "repo": "nullclaw/nullboiler", + \\ "platforms": {}, + \\ "launch": { "command": "server" }, + \\ "health": { "endpoint": "/health", "port_from_config": "port" }, + \\ "ports": [{ "name": "api", "config_key": "port", "default": 8080, "protocol": "http" }], + \\ "wizard": { "steps": [] }, + \\ "depends_on": [], + \\ "connects_to": [] + \\} + ; + + const rendered = augmentWizardManifest(allocator, "nullboiler", manifest_json, &state, fixture.paths) orelse + @panic("augmentWizardManifest"); + defer allocator.free(rendered); + + const parsed = try manifest_mod.parseManifest(allocator, rendered); + defer parsed.deinit(); + try std.testing.expect(wizardHasStep(parsed.value.wizard.steps, "tracker_instance")); + try std.testing.expect(wizardHasStep(parsed.value.wizard.steps, "tracker_pipeline_id")); + try std.testing.expect(wizardHasStep(parsed.value.wizard.steps, "tracker_claim_role")); + try std.testing.expect(wizardHasStep(parsed.value.wizard.steps, "tracker_success_trigger")); + try std.testing.expect(wizardHasStep(parsed.value.wizard.steps, "tracker_max_concurrent_tasks")); + try std.testing.expectEqual(@as(usize, 5), parsed.value.wizard.steps.len); + try std.testing.expectEqualStrings("tracker-a", parsed.value.wizard.steps[0].default_value); +} + +test "augmentWizardManifest inserts nullboiler tracker selector before tracker settings" { + const allocator = std.testing.allocator; + var fixture = try test_helpers.TempPaths.init(allocator); + defer fixture.deinit(); + try fixture.paths.ensureDirs(); + + const state_path = try fixture.paths.state(allocator); + defer allocator.free(state_path); + var state = state_mod.State.init(allocator, state_path); + defer state.deinit(); + try state.addInstance("nulltickets", "tracker-a", .{ .version = "v1.0.0" }); + + const inst_dir = try fixture.paths.instanceDir(allocator, "nulltickets", "tracker-a"); + defer allocator.free(inst_dir); + try std.fs.makePathAbsolute(inst_dir); + const config_path = try fixture.paths.instanceConfig(allocator, "nulltickets", "tracker-a"); + defer allocator.free(config_path); + { + const file = try std_compat.fs.createFileAbsolute(config_path, .{ .truncate = true }); + defer file.close(); + try file.writeAll("{\"port\":7711}\n"); + } + + const manifest_json = + \\{ + \\ "schema_version": 1, + \\ "name": "nullboiler", + \\ "display_name": "NullBoiler", + \\ "description": "Orchestrator", + \\ "icon": "workflow", + \\ "repo": "nullclaw/nullboiler", + \\ "platforms": {}, + \\ "launch": { "command": "server" }, + \\ "health": { "endpoint": "/health", "port_from_config": "port" }, + \\ "ports": [{ "name": "api", "config_key": "port", "default": 8080, "protocol": "http" }], + \\ "wizard": { "steps": [ + \\ { "id": "port", "title": "API Port", "type": "number" }, + \\ { "id": "tracker_enabled", "title": "Enable Tracker", "type": "toggle", "required": false }, + \\ { "id": "tracker_url", "title": "Tracker URL", "type": "text" }, + \\ { "id": "tracker_api_token", "title": "Tracker Token", "type": "secret", "required": false }, + \\ { "id": "tracker_pipeline_id", "title": "Pipeline", "type": "text" }, + \\ { "id": "tracker_claim_role", "title": "Claim Role", "type": "text" }, + \\ { "id": "tracker_success_trigger", "title": "Success Trigger", "type": "text" }, + \\ { "id": "tracker_max_concurrent_tasks", "title": "Max Tasks", "type": "number" } + \\ ] }, + \\ "depends_on": [], + \\ "connects_to": [] + \\} + ; + + const rendered = augmentWizardManifest(allocator, "nullboiler", manifest_json, &state, fixture.paths) orelse + @panic("augmentWizardManifest"); + defer allocator.free(rendered); + + const parsed = try manifest_mod.parseManifest(allocator, rendered); + defer parsed.deinit(); + + const selector_idx = wizardStepIndex(parsed.value.wizard.steps, "tracker_instance") orelse + @panic("tracker_instance missing"); + const enabled_idx = wizardStepIndex(parsed.value.wizard.steps, "tracker_enabled") orelse + @panic("tracker_enabled missing"); + try std.testing.expect(selector_idx < enabled_idx); + try std.testing.expectEqual(@as(usize, 9), parsed.value.wizard.steps.len); +} + +test "augmentWizardManifest picks next port for additional nulltickets instance" { + const allocator = std.testing.allocator; + var fixture = try test_helpers.TempPaths.init(allocator); + defer fixture.deinit(); + try fixture.paths.ensureDirs(); + + const state_path = try fixture.paths.state(allocator); + defer allocator.free(state_path); + var state = state_mod.State.init(allocator, state_path); + defer state.deinit(); + try state.addInstance("nulltickets", "tracker-a", .{ .version = "v1.0.0" }); + + const inst_dir = try fixture.paths.instanceDir(allocator, "nulltickets", "tracker-a"); + defer allocator.free(inst_dir); + try std.fs.makePathAbsolute(inst_dir); + const config_path = try fixture.paths.instanceConfig(allocator, "nulltickets", "tracker-a"); + defer allocator.free(config_path); + { + const file = try std_compat.fs.createFileAbsolute(config_path, .{ .truncate = true }); + defer file.close(); + try file.writeAll("{\"port\":43000}\n"); + } + + const manifest_json = + \\{ + \\ "schema_version": 1, + \\ "name": "nulltickets", + \\ "display_name": "NullTickets", + \\ "description": "Tracker", + \\ "icon": "tickets", + \\ "repo": "nullclaw/nulltickets", + \\ "platforms": {}, + \\ "launch": { "command": "server" }, + \\ "health": { "endpoint": "/health", "port_from_config": "port" }, + \\ "ports": [{ "name": "api", "config_key": "port", "default": 43000, "protocol": "http" }], + \\ "wizard": { "steps": [ + \\ { "id": "port", "title": "Port", "type": "number", "default_value": "43000" } + \\ ] }, + \\ "depends_on": [], + \\ "connects_to": [] + \\} + ; + + const rendered = augmentWizardManifest(allocator, "nulltickets", manifest_json, &state, fixture.paths) orelse + @panic("augmentWizardManifest"); + defer allocator.free(rendered); + + const parsed = try manifest_mod.parseManifest(allocator, rendered); + defer parsed.deinit(); + try std.testing.expectEqual(@as(usize, 1), parsed.value.wizard.steps.len); + try std.testing.expectEqualStrings("port", parsed.value.wizard.steps[0].id); + const port = try std.fmt.parseInt(u16, parsed.value.wizard.steps[0].default_value, 10); + try std.testing.expect(port > 43000); +} + +test "augmentWizardManifest picks next port for additional nullboiler instance" { + const allocator = std.testing.allocator; + var fixture = try test_helpers.TempPaths.init(allocator); + defer fixture.deinit(); + try fixture.paths.ensureDirs(); + + const state_path = try fixture.paths.state(allocator); + defer allocator.free(state_path); + var state = state_mod.State.init(allocator, state_path); + defer state.deinit(); + try state.addInstance("nullboiler", "worker-a", .{ .version = "v1.0.0" }); + + const inst_dir = try fixture.paths.instanceDir(allocator, "nullboiler", "worker-a"); + defer allocator.free(inst_dir); + try std.fs.makePathAbsolute(inst_dir); + const config_path = try fixture.paths.instanceConfig(allocator, "nullboiler", "worker-a"); + defer allocator.free(config_path); + { + const file = try std_compat.fs.createFileAbsolute(config_path, .{ .truncate = true }); + defer file.close(); + try file.writeAll("{\"port\":44000}\n"); + } + + const manifest_json = + \\{ + \\ "schema_version": 1, + \\ "name": "nullboiler", + \\ "display_name": "NullBoiler", + \\ "description": "Orchestrator", + \\ "icon": "workflow", + \\ "repo": "nullclaw/nullboiler", + \\ "platforms": {}, + \\ "launch": { "command": "server" }, + \\ "health": { "endpoint": "/health", "port_from_config": "port" }, + \\ "ports": [{ "name": "api", "config_key": "port", "default": 44000, "protocol": "http" }], + \\ "wizard": { "steps": [ + \\ { "id": "port", "title": "Port", "type": "number", "default_value": "44000" } + \\ ] }, + \\ "depends_on": [], + \\ "connects_to": [] + \\} + ; + + const rendered = augmentWizardManifest(allocator, "nullboiler", manifest_json, &state, fixture.paths) orelse + @panic("augmentWizardManifest"); + defer allocator.free(rendered); + + const parsed = try manifest_mod.parseManifest(allocator, rendered); + defer parsed.deinit(); + try std.testing.expectEqual(@as(usize, 1), parsed.value.wizard.steps.len); + try std.testing.expectEqualStrings("port", parsed.value.wizard.steps[0].id); + const port = try std.fmt.parseInt(u16, parsed.value.wizard.steps[0].default_value, 10); + try std.testing.expect(port > 44000); +} + +test "augmentWizardManifest picks next port for additional nullwatch instance" { + const allocator = std.testing.allocator; + var fixture = try test_helpers.TempPaths.init(allocator); + defer fixture.deinit(); + try fixture.paths.ensureDirs(); + + const state_path = try fixture.paths.state(allocator); + defer allocator.free(state_path); + var state = state_mod.State.init(allocator, state_path); + defer state.deinit(); + try state.addInstance("nullwatch", "watch-a", .{ .version = "v1.0.0" }); + + const inst_dir = try fixture.paths.instanceDir(allocator, "nullwatch", "watch-a"); + defer allocator.free(inst_dir); + try std.fs.makePathAbsolute(inst_dir); + const config_path = try fixture.paths.instanceConfig(allocator, "nullwatch", "watch-a"); + defer allocator.free(config_path); + { + const file = try std_compat.fs.createFileAbsolute(config_path, .{ .truncate = true }); + defer file.close(); + try file.writeAll("{\"port\":45000}\n"); + } + + const manifest_json = + \\{ + \\ "schema_version": 1, + \\ "name": "nullwatch", + \\ "display_name": "NullWatch", + \\ "description": "Observability", + \\ "icon": "watch", + \\ "repo": "nullclaw/nullwatch", + \\ "platforms": {}, + \\ "launch": { "command": "serve" }, + \\ "health": { "endpoint": "/health", "port_from_config": "port" }, + \\ "ports": [{ "name": "api", "config_key": "port", "default": 45000, "protocol": "http" }], + \\ "wizard": { "steps": [ + \\ { "id": "port", "title": "Port", "type": "number", "default_value": "45000" } + \\ ] }, + \\ "depends_on": [], + \\ "connects_to": [] + \\} + ; + + const rendered = augmentWizardManifest(allocator, "nullwatch", manifest_json, &state, fixture.paths) orelse + @panic("augmentWizardManifest"); + defer allocator.free(rendered); + + const parsed = try manifest_mod.parseManifest(allocator, rendered); + defer parsed.deinit(); + try std.testing.expectEqual(@as(usize, 1), parsed.value.wizard.steps.len); + try std.testing.expectEqualStrings("port", parsed.value.wizard.steps[0].id); + const port = try std.fmt.parseInt(u16, parsed.value.wizard.steps[0].default_value, 10); + try std.testing.expect(port > 45000); +} + test "prepareWizardBody injects tracker settings for nullboiler" { const allocator = std.testing.allocator; var fixture = try test_helpers.TempPaths.init(allocator); @@ -1160,6 +1750,161 @@ test "prepareWizardBody injects tracker settings for nullboiler" { try std.testing.expectEqualStrings("http://127.0.0.1:7711", obj.get("tracker_url").?.string); try std.testing.expectEqualStrings("secret-token", obj.get("tracker_api_token").?.string); try std.testing.expectEqualStrings("coder", obj.get("tracker_claim_role").?.string); + try std.testing.expectEqualStrings("complete", obj.get("tracker_success_trigger").?.string); + try std.testing.expectEqualStrings("1", obj.get("tracker_max_concurrent_tasks").?.string); +} + +test "prepareWizardBody disables stale nullboiler tracker flag when no local tracker is selected" { + const allocator = std.testing.allocator; + var fixture = try test_helpers.TempPaths.init(allocator); + defer fixture.deinit(); + + const rendered = prepareWizardBody( + allocator, + "nullboiler", + "{\"instance_name\":\"worker-a\",\"tracker_instance\":\"\",\"tracker_enabled\":\"true\",\"tracker_url\":\"http://127.0.0.1:7700\",\"tracker_api_token\":\"old\",\"tracker_pipeline_id\":\"pipe-dev\",\"tracker_claim_role\":\"coder\",\"tracker_success_trigger\":\"complete\",\"tracker_max_concurrent_tasks\":\"4\"}", + fixture.paths, + ) orelse @panic("prepareWizardBody"); + defer allocator.free(rendered); + + const parsed = std.json.parseFromSlice(std.json.Value, allocator, rendered, .{ + .allocate = .alloc_always, + .ignore_unknown_fields = true, + }) catch @panic("parseFromSlice"); + defer parsed.deinit(); + + const obj = parsed.value.object; + try std.testing.expectEqualStrings("false", obj.get("tracker_enabled").?.string); + try std.testing.expect(obj.get("tracker_url") == null); + try std.testing.expect(obj.get("tracker_api_token") == null); + try std.testing.expect(obj.get("tracker_pipeline_id") == null); + try std.testing.expect(obj.get("tracker_claim_role") == null); + try std.testing.expect(obj.get("tracker_success_trigger") == null); + try std.testing.expect(obj.get("tracker_max_concurrent_tasks") == null); +} + +test "prepareWizardBody defaults empty nullboiler tracker settings" { + const allocator = std.testing.allocator; + var fixture = try test_helpers.TempPaths.init(allocator); + defer fixture.deinit(); + fixture.paths.ensureDirs() catch @panic("ensureDirs"); + + const inst_dir = fixture.paths.instanceDir(allocator, "nulltickets", "tracker-a") catch @panic("instanceDir"); + defer allocator.free(inst_dir); + std.fs.makePathAbsolute(inst_dir) catch @panic("makePathAbsolute"); + + const config_path = fixture.paths.instanceConfig(allocator, "nulltickets", "tracker-a") catch @panic("instanceConfig"); + defer allocator.free(config_path); + { + const file = std_compat.fs.createFileAbsolute(config_path, .{ .truncate = true }) catch @panic("createFileAbsolute"); + defer file.close(); + file.writeAll("{\"port\":7711}\n") catch @panic("writeAll"); + } + + const rendered = prepareWizardBody( + allocator, + "nullboiler", + "{\"instance_name\":\"worker-a\",\"tracker_instance\":\"tracker-a\",\"tracker_claim_role\":\"\",\"tracker_success_trigger\":\"\",\"tracker_max_concurrent_tasks\":\"\"}", + fixture.paths, + ) orelse @panic("prepareWizardBody"); + defer allocator.free(rendered); + + const parsed = std.json.parseFromSlice(std.json.Value, allocator, rendered, .{ + .allocate = .alloc_always, + .ignore_unknown_fields = true, + }) catch @panic("parseFromSlice"); + defer parsed.deinit(); + + const obj = parsed.value.object; + try std.testing.expectEqualStrings("coder", obj.get("tracker_claim_role").?.string); + try std.testing.expectEqualStrings("complete", obj.get("tracker_success_trigger").?.string); + try std.testing.expectEqualStrings("1", obj.get("tracker_max_concurrent_tasks").?.string); +} + +test "prepareWizardBody removes stale token when selected nulltickets has no token" { + const allocator = std.testing.allocator; + var fixture = try test_helpers.TempPaths.init(allocator); + defer fixture.deinit(); + fixture.paths.ensureDirs() catch @panic("ensureDirs"); + + const inst_dir = fixture.paths.instanceDir(allocator, "nulltickets", "tracker-a") catch @panic("instanceDir"); + defer allocator.free(inst_dir); + std.fs.makePathAbsolute(inst_dir) catch @panic("makePathAbsolute"); + + const config_path = fixture.paths.instanceConfig(allocator, "nulltickets", "tracker-a") catch @panic("instanceConfig"); + defer allocator.free(config_path); + { + const file = std_compat.fs.createFileAbsolute(config_path, .{ .truncate = true }) catch @panic("createFileAbsolute"); + defer file.close(); + file.writeAll("{\"port\":7711}\n") catch @panic("writeAll"); + } + + const rendered = prepareWizardBody( + allocator, + "nullboiler", + "{\"instance_name\":\"worker-a\",\"tracker_instance\":\"tracker-a\",\"tracker_api_token\":\"stale\"}", + fixture.paths, + ) orelse @panic("prepareWizardBody"); + defer allocator.free(rendered); + + const parsed = std.json.parseFromSlice(std.json.Value, allocator, rendered, .{ + .allocate = .alloc_always, + .ignore_unknown_fields = true, + }) catch @panic("parseFromSlice"); + defer parsed.deinit(); + + const obj = parsed.value.object; + try std.testing.expectEqualStrings("true", obj.get("tracker_enabled").?.string); + try std.testing.expectEqualStrings("http://127.0.0.1:7711", obj.get("tracker_url").?.string); + try std.testing.expect(obj.get("tracker_api_token") == null); +} + +test "validateWizardBodyForInstall rejects missing nullboiler tracker instance" { + const allocator = std.testing.allocator; + var fixture = try test_helpers.TempPaths.init(allocator); + defer fixture.deinit(); + + const rendered = validateWizardBodyForInstall( + allocator, + "nullboiler", + "{\"tracker_enabled\":\"true\",\"tracker_instance\":\"missing\",\"tracker_pipeline_id\":\"pipe-dev\"}", + fixture.paths, + ) orelse @panic("validateWizardBodyForInstall"); + defer allocator.free(rendered); + + try std.testing.expectEqualStrings("{\"error\":\"tracker_instance was not found\"}", rendered); +} + +test "validateWizardBodyForInstall validates nullboiler tracker instance without explicit enabled flag" { + const allocator = std.testing.allocator; + var fixture = try test_helpers.TempPaths.init(allocator); + defer fixture.deinit(); + + const rendered = validateWizardBodyForInstall( + allocator, + "nullboiler", + "{\"tracker_instance\":\"missing\",\"tracker_pipeline_id\":\"pipe-dev\"}", + fixture.paths, + ) orelse @panic("validateWizardBodyForInstall"); + defer allocator.free(rendered); + + try std.testing.expectEqualStrings("{\"error\":\"tracker_instance was not found\"}", rendered); +} + +test "validateWizardBodyForInstall requires nullboiler tracker instance when tracker enabled" { + const allocator = std.testing.allocator; + var fixture = try test_helpers.TempPaths.init(allocator); + defer fixture.deinit(); + + const rendered = validateWizardBodyForInstall( + allocator, + "nullboiler", + "{\"tracker_enabled\":\"true\",\"tracker_pipeline_id\":\"pipe-dev\"}", + fixture.paths, + ) orelse @panic("validateWizardBodyForInstall"); + defer allocator.free(rendered); + + try std.testing.expectEqualStrings("{\"error\":\"tracker_instance is required when tracker mode is enabled\"}", rendered); } test "handlePostWizard returns null for unknown component" { diff --git a/src/core/integration.zig b/src/core/integration.zig index 3ca96de..ffc29f0 100644 --- a/src/core/integration.zig +++ b/src/core/integration.zig @@ -2,6 +2,7 @@ const std = @import("std"); const std_compat = @import("compat"); const paths_mod = @import("paths.zig"); const state_mod = @import("state.zig"); +const test_helpers = @import("../test_helpers.zig"); pub const NullTicketsConfig = struct { name: []const u8, @@ -25,6 +26,8 @@ pub const NullBoilerWorkflowConfig = struct { pub const managed_workflow_file_name = "nullhub-tracker-workflow.json"; pub const legacy_workflow_file_name = "tracker-workflow.json"; +pub const default_tracker_prompt_template = + "Task {{task.id}}: {{task.title}}\n\n{{task.description}}\n\nMetadata:\n{{task.metadata}}"; pub const NullBoilerTrackerConfig = struct { url: []const u8, @@ -42,6 +45,14 @@ pub const NullBoilerConfig = struct { tracker: ?NullBoilerTrackerConfig = null, }; +pub const NullBoilerTrackerLinkOptions = struct { + tickets: NullTicketsConfig, + pipeline_id: []const u8, + claim_role: []const u8, + success_trigger: []const u8, + max_concurrent_tasks: ?u32 = null, +}; + pub const NullClawTelemetryLink = struct { configured: bool = false, endpoint: ?[]u8 = null, @@ -172,22 +183,44 @@ pub fn loadNullBoilerConfig(allocator: std.mem.Allocator, paths: paths_mod.Paths const config_dir = std.fs.path.dirname(config_path) orelse return null; - return .{ + var cfg = NullBoilerConfig{ .name = try allocator.dupe(u8, name), .port = parsed.value.port, - .api_token = if (parsed.value.api_token) |token| try allocator.dupe(u8, token) else null, - .tracker = if (parsed.value.tracker) |tracker| blk: { - const workflows_dir = try resolveRelativePath(allocator, config_dir, tracker.workflows_dir); - const workflow = try loadPrimaryWorkflowConfig(allocator, workflows_dir); - break :blk .{ - .url = try allocator.dupe(u8, tracker.url), - .api_token = if (tracker.api_token) |token| try allocator.dupe(u8, token) else null, - .agent_id = try allocator.dupe(u8, tracker.agent_id), - .workflows_dir = workflows_dir, - .max_concurrent_tasks = tracker.concurrency.max_concurrent_tasks, - .workflow = workflow, - }; - } else null, + }; + errdefer deinitNullBoilerConfig(allocator, &cfg); + if (parsed.value.api_token) |token| { + cfg.api_token = try allocator.dupe(u8, token); + } + + if (parsed.value.tracker) |tracker| { + cfg.tracker = try loadNullBoilerTrackerConfig(allocator, config_dir, tracker); + } + + return cfg; +} + +fn loadNullBoilerTrackerConfig( + allocator: std.mem.Allocator, + config_dir: []const u8, + tracker: NullBoilerTrackerConfigFile, +) !NullBoilerTrackerConfig { + const tracker_url = try allocator.dupe(u8, tracker.url orelse ""); + errdefer allocator.free(tracker_url); + const tracker_api_token = if (tracker.api_token) |token| try allocator.dupe(u8, token) else null; + errdefer if (tracker_api_token) |token| allocator.free(token); + const tracker_agent_id = try allocator.dupe(u8, tracker.agent_id); + errdefer allocator.free(tracker_agent_id); + const tracker_workflows_dir = try resolveRelativePath(allocator, config_dir, tracker.workflows_dir); + errdefer allocator.free(tracker_workflows_dir); + const tracker_workflow = try loadPrimaryWorkflowConfig(allocator, tracker_workflows_dir); + + return .{ + .url = tracker_url, + .api_token = tracker_api_token, + .agent_id = tracker_agent_id, + .workflows_dir = tracker_workflows_dir, + .max_concurrent_tasks = tracker.concurrency.max_concurrent_tasks, + .workflow = tracker_workflow, }; } @@ -341,6 +374,78 @@ pub fn linkNullClawToNullWatch( try out.writeAll("\n"); } +pub fn linkNullBoilerToNullTickets( + allocator: std.mem.Allocator, + paths: paths_mod.Paths, + boiler_name: []const u8, + options: NullBoilerTrackerLinkOptions, +) !void { + var existing = try loadNullBoilerConfig(allocator, paths, boiler_name) orelse return error.NotFound; + defer deinitNullBoilerConfig(allocator, &existing); + + const config_path = try paths.instanceConfig(allocator, "nullboiler", boiler_name); + defer allocator.free(config_path); + + const original_config_bytes = blk: { + const file = std_compat.fs.openFileAbsolute(config_path, .{}) catch |err| switch (err) { + error.FileNotFound => return error.NotFound, + else => return err, + }; + defer file.close(); + break :blk try file.readToEndAlloc(allocator, 1024 * 1024); + }; + defer allocator.free(original_config_bytes); + + var parsed_config = try std.json.parseFromSlice(std.json.Value, allocator, original_config_bytes, .{ + .allocate = .alloc_always, + .ignore_unknown_fields = true, + }); + defer parsed_config.deinit(); + if (parsed_config.value != .object) return error.InvalidConfig; + + const config_allocator = parsed_config.arena.allocator(); + const tracker_map = try ensureObjectField(config_allocator, &parsed_config.value.object, "tracker"); + + const tracker_url = try std.fmt.allocPrint(config_allocator, "http://127.0.0.1:{d}", .{options.tickets.port}); + try tracker_map.put(config_allocator, "url", .{ .string = tracker_url }); + if (options.tickets.api_token) |token| { + try tracker_map.put(config_allocator, "api_token", .{ .string = try config_allocator.dupe(u8, token) }); + } else { + _ = tracker_map.swapRemove("api_token"); + } + + const default_agent_id = if (existing.tracker) |tracker| tracker.agent_id else boiler_name; + const agent_id = nonEmptyJsonStringOrDefault(tracker_map.*, "agent_id", default_agent_id); + try tracker_map.put(config_allocator, "agent_id", .{ .string = try config_allocator.dupe(u8, agent_id) }); + + const workflows_dir_config = nonEmptyJsonStringOrDefault(tracker_map.*, "workflows_dir", "workflows"); + try tracker_map.put(config_allocator, "workflows_dir", .{ .string = try config_allocator.dupe(u8, workflows_dir_config) }); + + const concurrency_map = try ensureObjectField(config_allocator, tracker_map, "concurrency"); + if (options.max_concurrent_tasks) |max_concurrent_tasks| { + try concurrency_map.put(config_allocator, "max_concurrent_tasks", .{ .integer = max_concurrent_tasks }); + } else if (concurrency_map.get("max_concurrent_tasks") == null) { + try concurrency_map.put(config_allocator, "max_concurrent_tasks", .{ .integer = if (existing.tracker) |tracker| tracker.max_concurrent_tasks else 1 }); + } + + const workflows_dir = try resolvePathFromConfigPath(allocator, config_path, workflows_dir_config); + defer allocator.free(workflows_dir); + + try writeJsonConfigValue(allocator, config_path, parsed_config.value); + + ensureNullBoilerTrackerWorkflowFile( + allocator, + config_path, + workflows_dir, + options.pipeline_id, + options.claim_role, + options.success_trigger, + ) catch |err| { + writeBytes(config_path, original_config_bytes) catch {}; + return err; + }; +} + pub fn findNullWatchByEndpoint(watches: []const NullWatchConfig, endpoint: ?[]const u8) ?NullWatchConfig { const value = endpoint orelse return null; const port = nullWatchEndpointPort(value) orelse return null; @@ -441,6 +546,11 @@ fn jsonString(obj: std.json.ObjectMap, key: []const u8) ?[]const u8 { return if (value == .string) value.string else null; } +fn nonEmptyJsonStringOrDefault(obj: std.json.ObjectMap, key: []const u8, default_value: []const u8) []const u8 { + const value = jsonString(obj, key) orelse return default_value; + return if (value.len > 0) value else default_value; +} + fn ensureObjectField( allocator: std.mem.Allocator, parent: *std.json.ObjectMap, @@ -457,6 +567,140 @@ fn ensureObjectField( return &parent.getPtr(key).?.object; } +fn writeBytes(path: []const u8, bytes: []const u8) !void { + const out = try std_compat.fs.createFileAbsolute(path, .{ .truncate = true }); + defer out.close(); + try out.writeAll(bytes); +} + +fn writeJsonConfigValue(allocator: std.mem.Allocator, config_path: []const u8, value: std.json.Value) !void { + const rendered = try std.json.Stringify.valueAlloc(allocator, value, .{ + .whitespace = .indent_2, + .emit_null_optional_fields = false, + }); + defer allocator.free(rendered); + + const out = try std_compat.fs.createFileAbsolute(config_path, .{ .truncate = true }); + defer out.close(); + try out.writeAll(rendered); + try out.writeAll("\n"); +} + +fn resolvePathFromConfigPath(allocator: std.mem.Allocator, config_path: []const u8, value: []const u8) ![]const u8 { + if (value.len == 0 or std.fs.path.isAbsolute(value)) return allocator.dupe(u8, value); + const config_dir = std.fs.path.dirname(config_path) orelse return error.InvalidPath; + return std.fs.path.resolve(allocator, &.{ config_dir, value }); +} + +fn ensurePath(path: []const u8) !void { + if (path.len == 0) return error.InvalidPath; + try std_compat.fs.cwd().makePath(path); +} + +fn ensureNullBoilerTrackerWorkflowFile( + allocator: std.mem.Allocator, + config_path: []const u8, + workflows_dir: []const u8, + pipeline_id: []const u8, + claim_role: []const u8, + success_trigger: []const u8, +) !void { + try ensurePath(workflows_dir); + + const workflow_path = try std.fs.path.join(allocator, &.{ workflows_dir, managed_workflow_file_name }); + defer allocator.free(workflow_path); + + const workflow_id = try std.fmt.allocPrint(allocator, "wf-{s}-{s}", .{ pipeline_id, claim_role }); + defer allocator.free(workflow_id); + + const rendered = try std.json.Stringify.valueAlloc(allocator, .{ + .id = workflow_id, + .pipeline_id = pipeline_id, + .claim_roles = &.{claim_role}, + .execution = "subprocess", + .prompt_template = default_tracker_prompt_template, + .on_success = .{ + .transition_to = success_trigger, + }, + }, .{ + .whitespace = .indent_2, + .emit_null_optional_fields = false, + }); + defer allocator.free(rendered); + + try writeTextFileAtomically(allocator, workflow_path, rendered); + + deleteStaleNullHubManagedWorkflows(allocator, workflows_dir) catch {}; + + const config_dir = std.fs.path.dirname(config_path) orelse return error.InvalidPath; + const legacy_path = try std.fs.path.join(allocator, &.{ config_dir, legacy_workflow_file_name }); + defer allocator.free(legacy_path); + std_compat.fs.deleteFileAbsolute(legacy_path) catch {}; + + const legacy_workflows_path = try std.fs.path.join(allocator, &.{ workflows_dir, legacy_workflow_file_name }); + defer allocator.free(legacy_workflows_path); + std_compat.fs.deleteFileAbsolute(legacy_workflows_path) catch {}; +} + +fn writeTextFileAtomically(allocator: std.mem.Allocator, path: []const u8, contents: []const u8) !void { + const tmp_path = try std.fmt.allocPrint(allocator, "{s}.tmp", .{path}); + defer allocator.free(tmp_path); + errdefer std_compat.fs.deleteFileAbsolute(tmp_path) catch {}; + + { + const file_out = try std_compat.fs.createFileAbsolute(tmp_path, .{ .truncate = true }); + defer file_out.close(); + try file_out.writeAll(contents); + try file_out.writeAll("\n"); + } + + try std_compat.fs.renameAbsolute(tmp_path, path); +} + +fn deleteStaleNullHubManagedWorkflows(allocator: std.mem.Allocator, workflows_dir: []const u8) !void { + var dir = try std_compat.fs.openDirAbsolute(workflows_dir, .{ .iterate = true }); + defer dir.close(); + + var it = dir.iterate(); + while (try it.next()) |entry| { + if (entry.kind != .file) continue; + if (!std.mem.endsWith(u8, entry.name, ".json")) continue; + if (std.mem.eql(u8, entry.name, managed_workflow_file_name)) continue; + + const workflow_path = try std.fs.path.join(allocator, &.{ workflows_dir, entry.name }); + if (isNullHubManagedWorkflow(allocator, workflow_path)) { + std_compat.fs.deleteFileAbsolute(workflow_path) catch {}; + } + allocator.free(workflow_path); + } +} + +fn isNullHubManagedWorkflow( + allocator: std.mem.Allocator, + workflow_path: []const u8, +) bool { + const file = std_compat.fs.openFileAbsolute(workflow_path, .{}) catch return false; + defer file.close(); + + const bytes = file.readToEndAlloc(allocator, 1024 * 1024) catch return false; + defer allocator.free(bytes); + + const parsed = std.json.parseFromSlice(struct { + id: []const u8 = "", + execution: []const u8 = "", + prompt_template: ?[]const u8 = null, + }, allocator, bytes, .{ + .allocate = .alloc_always, + .ignore_unknown_fields = true, + }) catch return false; + defer parsed.deinit(); + + return std.mem.startsWith(u8, parsed.value.id, "wf-") and + std.mem.eql(u8, parsed.value.execution, "subprocess") and + parsed.value.prompt_template != null and + std.mem.eql(u8, parsed.value.prompt_template.?, default_tracker_prompt_template); +} + fn loadPrimaryWorkflowConfig(allocator: std.mem.Allocator, workflows_dir: []const u8) !?NullBoilerWorkflowConfig { var dir = std_compat.fs.openDirAbsolute(workflows_dir, .{ .iterate = true }) catch return null; defer dir.close(); @@ -499,11 +743,19 @@ fn loadWorkflowConfigFromFile(allocator: std.mem.Allocator, workflows_dir: []con }) catch return null; defer parsed.deinit(); + const file_name_owned = try allocator.dupe(u8, file_name); + errdefer allocator.free(file_name_owned); + const pipeline_id = try allocator.dupe(u8, parsed.value.pipeline_id); + errdefer allocator.free(pipeline_id); + const claim_role = try allocator.dupe(u8, if (parsed.value.claim_roles.len > 0) parsed.value.claim_roles[0] else ""); + errdefer allocator.free(claim_role); + const success_trigger = try allocator.dupe(u8, if (parsed.value.on_success) |cfg| cfg.transition_to else ""); + return .{ - .file_name = try allocator.dupe(u8, file_name), - .pipeline_id = try allocator.dupe(u8, parsed.value.pipeline_id), - .claim_role = try allocator.dupe(u8, if (parsed.value.claim_roles.len > 0) parsed.value.claim_roles[0] else ""), - .success_trigger = try allocator.dupe(u8, if (parsed.value.on_success) |cfg| cfg.transition_to else ""), + .file_name = file_name_owned, + .pipeline_id = pipeline_id, + .claim_role = claim_role, + .success_trigger = success_trigger, }; } @@ -523,18 +775,20 @@ const NullWatchConfigFile = struct { api_token: ?[]const u8 = null, }; +const NullBoilerTrackerConfigFile = struct { + url: ?[]const u8 = null, + api_token: ?[]const u8 = null, + agent_id: []const u8 = "nullboiler", + concurrency: struct { + max_concurrent_tasks: u32 = 10, + } = .{}, + workflows_dir: []const u8 = "workflows", +}; + const NullBoilerConfigFile = struct { port: u16 = 8080, api_token: ?[]const u8 = null, - tracker: ?struct { - url: []const u8, - api_token: ?[]const u8 = null, - agent_id: []const u8 = "nullboiler", - concurrency: struct { - max_concurrent_tasks: u32 = 10, - } = .{}, - workflows_dir: []const u8 = "workflows", - } = null, + tracker: ?NullBoilerTrackerConfigFile = null, }; const WorkflowFile = struct { @@ -544,3 +798,236 @@ const WorkflowFile = struct { transition_to: []const u8 = "", } = null, }; + +test "loadNullBoilerConfig accepts tracker without url" { + const allocator = std.testing.allocator; + var fixture = try test_helpers.TempPaths.init(allocator); + defer fixture.deinit(); + try fixture.paths.ensureDirs(); + + const inst_dir = try fixture.paths.instanceDir(allocator, "nullboiler", "worker-a"); + defer allocator.free(inst_dir); + try std.fs.makePathAbsolute(inst_dir); + + const config_path = try fixture.paths.instanceConfig(allocator, "nullboiler", "worker-a"); + defer allocator.free(config_path); + const file = try std_compat.fs.createFileAbsolute(config_path, .{ .truncate = true }); + defer file.close(); + try file.writeAll("{\"port\":8811,\"tracker\":{\"agent_id\":\"worker-a\"}}\n"); + + var cfg = (try loadNullBoilerConfig(allocator, fixture.paths, "worker-a")).?; + defer deinitNullBoilerConfig(allocator, &cfg); + + try std.testing.expectEqual(@as(u16, 8811), cfg.port); + try std.testing.expect(cfg.tracker != null); + try std.testing.expectEqualStrings("", cfg.tracker.?.url); + try std.testing.expectEqualStrings("worker-a", cfg.tracker.?.agent_id); +} + +test "linkNullBoilerToNullTickets preserves custom tracker config and replaces generated workflows" { + const allocator = std.testing.allocator; + var fixture = try test_helpers.TempPaths.init(allocator); + defer fixture.deinit(); + try fixture.paths.ensureDirs(); + + const inst_dir = try fixture.paths.instanceDir(allocator, "nullboiler", "worker-a"); + defer allocator.free(inst_dir); + try ensurePath(inst_dir); + + const config_path = try fixture.paths.instanceConfig(allocator, "nullboiler", "worker-a"); + defer allocator.free(config_path); + { + const file = try std_compat.fs.createFileAbsolute(config_path, .{ .truncate = true }); + defer file.close(); + try file.writeAll( + "{\"port\":8811,\"tracker\":{\"url\":\"http://127.0.0.1:7701\",\"api_token\":\"old-token\",\"agent_id\":\"custom-agent\",\"workflows_dir\":\"custom-workflows\",\"poll_interval_ms\":9000,\"concurrency\":{\"max_concurrent_tasks\":7,\"per_pipeline\":{\"pipe-old\":2}}}}\n", + ); + } + + const workflows_dir = try std.fs.path.join(allocator, &.{ inst_dir, "custom-workflows" }); + defer allocator.free(workflows_dir); + try ensurePath(workflows_dir); + + const manual_workflow_path = try std.fs.path.join(allocator, &.{ workflows_dir, "manual.json" }); + defer allocator.free(manual_workflow_path); + { + const file = try std_compat.fs.createFileAbsolute(manual_workflow_path, .{ .truncate = true }); + defer file.close(); + try file.writeAll( + \\{ + \\ "id": "wf-manual", + \\ "pipeline_id": "pipe-manual", + \\ "claim_roles": ["reviewer"], + \\ "execution": "subprocess", + \\ "prompt_template": "Manual workflow", + \\ "on_success": { "transition_to": "approved" } + \\} + \\ + ); + } + + const stale_workflow_path = try std.fs.path.join(allocator, &.{ workflows_dir, "pipe-old.json" }); + defer allocator.free(stale_workflow_path); + { + const rendered = try std.json.Stringify.valueAlloc(allocator, .{ + .id = "wf-pipe-old-coder", + .pipeline_id = "pipe-old", + .claim_roles = &.{"coder"}, + .execution = "subprocess", + .prompt_template = default_tracker_prompt_template, + .on_success = .{ + .transition_to = "complete", + }, + }, .{ + .whitespace = .indent_2, + .emit_null_optional_fields = false, + }); + defer allocator.free(rendered); + + const file = try std_compat.fs.createFileAbsolute(stale_workflow_path, .{ .truncate = true }); + defer file.close(); + try file.writeAll(rendered); + try file.writeAll("\n"); + } + + try linkNullBoilerToNullTickets(allocator, fixture.paths, "worker-a", .{ + .tickets = .{ .name = "tracker-a", .port = 7711, .api_token = "admin-token" }, + .pipeline_id = "pipe-dev", + .claim_role = "reviewer", + .success_trigger = "complete", + .max_concurrent_tasks = null, + }); + + const config_bytes = try std.fs.readFileAbsolute(allocator, config_path, 1024 * 1024); + defer allocator.free(config_bytes); + try std.testing.expect(std.mem.indexOf(u8, config_bytes, "\"url\": \"http://127.0.0.1:7711\"") != null); + try std.testing.expect(std.mem.indexOf(u8, config_bytes, "\"api_token\": \"admin-token\"") != null); + try std.testing.expect(std.mem.indexOf(u8, config_bytes, "\"agent_id\": \"custom-agent\"") != null); + try std.testing.expect(std.mem.indexOf(u8, config_bytes, "\"workflows_dir\": \"custom-workflows\"") != null); + try std.testing.expect(std.mem.indexOf(u8, config_bytes, "\"poll_interval_ms\": 9000") != null); + try std.testing.expect(std.mem.indexOf(u8, config_bytes, "\"per_pipeline\"") != null); + + const managed_workflow_path = try std.fs.path.join(allocator, &.{ workflows_dir, managed_workflow_file_name }); + defer allocator.free(managed_workflow_path); + const managed_bytes = try std.fs.readFileAbsolute(allocator, managed_workflow_path, 1024 * 1024); + defer allocator.free(managed_bytes); + try std.testing.expect(std.mem.indexOf(u8, managed_bytes, "\"pipeline_id\": \"pipe-dev\"") != null); + try std.testing.expect(std.mem.indexOf(u8, managed_bytes, "\"reviewer\"") != null); + + const manual_file = try std_compat.fs.openFileAbsolute(manual_workflow_path, .{}); + manual_file.close(); + try std.testing.expectError(error.FileNotFound, std_compat.fs.openFileAbsolute(stale_workflow_path, .{})); +} + +test "linkNullBoilerToNullTickets restores config when workflow generation fails" { + const allocator = std.testing.allocator; + var fixture = try test_helpers.TempPaths.init(allocator); + defer fixture.deinit(); + try fixture.paths.ensureDirs(); + + const inst_dir = try fixture.paths.instanceDir(allocator, "nullboiler", "worker-a"); + defer allocator.free(inst_dir); + try ensurePath(inst_dir); + + const config_path = try fixture.paths.instanceConfig(allocator, "nullboiler", "worker-a"); + defer allocator.free(config_path); + const original_config = + "{\"port\":8811,\"tracker\":{\"url\":\"http://127.0.0.1:7701\",\"workflows_dir\":\"blocked-workflows\",\"concurrency\":{\"max_concurrent_tasks\":4}}}\n"; + { + const file = try std_compat.fs.createFileAbsolute(config_path, .{ .truncate = true }); + defer file.close(); + try file.writeAll(original_config); + } + + const blocked_path = try std.fs.path.join(allocator, &.{ inst_dir, "blocked-workflows" }); + defer allocator.free(blocked_path); + { + const file = try std_compat.fs.createFileAbsolute(blocked_path, .{ .truncate = true }); + defer file.close(); + try file.writeAll("not a directory\n"); + } + + linkNullBoilerToNullTickets(allocator, fixture.paths, "worker-a", .{ + .tickets = .{ .name = "tracker-a", .port = 7711, .api_token = null }, + .pipeline_id = "pipe-dev", + .claim_role = "coder", + .success_trigger = "complete", + .max_concurrent_tasks = 2, + }) catch { + const restored = try std.fs.readFileAbsolute(allocator, config_path, 1024 * 1024); + defer allocator.free(restored); + try std.testing.expectEqualStrings(original_config, restored); + return; + }; + return error.ExpectedWorkflowGenerationFailure; +} + +test "linkNullBoilerToNullTickets keeps stale workflow when replacement write fails" { + const allocator = std.testing.allocator; + var fixture = try test_helpers.TempPaths.init(allocator); + defer fixture.deinit(); + try fixture.paths.ensureDirs(); + + const inst_dir = try fixture.paths.instanceDir(allocator, "nullboiler", "worker-a"); + defer allocator.free(inst_dir); + try ensurePath(inst_dir); + + const config_path = try fixture.paths.instanceConfig(allocator, "nullboiler", "worker-a"); + defer allocator.free(config_path); + const original_config = + "{\"port\":8811,\"tracker\":{\"url\":\"http://127.0.0.1:7701\",\"workflows_dir\":\"workflows\",\"concurrency\":{\"max_concurrent_tasks\":4}}}\n"; + { + const file = try std_compat.fs.createFileAbsolute(config_path, .{ .truncate = true }); + defer file.close(); + try file.writeAll(original_config); + } + + const workflows_dir = try std.fs.path.join(allocator, &.{ inst_dir, "workflows" }); + defer allocator.free(workflows_dir); + try ensurePath(workflows_dir); + + const stale_workflow_path = try std.fs.path.join(allocator, &.{ workflows_dir, "pipe-old.json" }); + defer allocator.free(stale_workflow_path); + { + const rendered = try std.json.Stringify.valueAlloc(allocator, .{ + .id = "wf-pipe-old-coder", + .pipeline_id = "pipe-old", + .claim_roles = &.{"coder"}, + .execution = "subprocess", + .prompt_template = default_tracker_prompt_template, + .on_success = .{ + .transition_to = "complete", + }, + }, .{ + .whitespace = .indent_2, + .emit_null_optional_fields = false, + }); + defer allocator.free(rendered); + + const file = try std_compat.fs.createFileAbsolute(stale_workflow_path, .{ .truncate = true }); + defer file.close(); + try file.writeAll(rendered); + try file.writeAll("\n"); + } + + const managed_workflow_path = try std.fs.path.join(allocator, &.{ workflows_dir, managed_workflow_file_name }); + defer allocator.free(managed_workflow_path); + try ensurePath(managed_workflow_path); + + linkNullBoilerToNullTickets(allocator, fixture.paths, "worker-a", .{ + .tickets = .{ .name = "tracker-a", .port = 7711, .api_token = null }, + .pipeline_id = "pipe-dev", + .claim_role = "coder", + .success_trigger = "complete", + .max_concurrent_tasks = 2, + }) catch { + const restored = try std.fs.readFileAbsolute(allocator, config_path, 1024 * 1024); + defer allocator.free(restored); + try std.testing.expectEqualStrings(original_config, restored); + + const stale_file = try std_compat.fs.openFileAbsolute(stale_workflow_path, .{}); + stale_file.close(); + return; + }; + return error.ExpectedWorkflowGenerationFailure; +} diff --git a/src/core/launch_args.zig b/src/core/launch_args.zig index b800983..65f6735 100644 --- a/src/core/launch_args.zig +++ b/src/core/launch_args.zig @@ -36,6 +36,16 @@ pub fn resolve( try appendOwnedToken(allocator, &list, "start"); } + if (list.items.len == 1 and std.mem.eql(u8, list.items[0], "server")) { + deinitOwnedArgList(allocator, &list); + return .{ + .allocator = allocator, + .raw_mode = launch_mode, + .primary_command = "server", + .argv = &.{}, + }; + } + if (verbose) { try appendOwnedToken(allocator, &list, "--verbose"); } @@ -200,7 +210,9 @@ fn deinitOwnedArgList(allocator: std.mem.Allocator, list: *std.ArrayListUnmanage } fn usesHttpHealthChecksCommand(command: []const u8) bool { - return std.mem.eql(u8, command, "serve") or std.mem.eql(u8, command, "gateway"); + return std.mem.eql(u8, command, "serve") or + std.mem.eql(u8, command, "server") or + std.mem.eql(u8, command, "gateway"); } pub fn primaryLaunchCommand(launch_mode: []const u8) []const u8 { @@ -300,6 +312,16 @@ test "resolve rejects empty launch mode" { try std.testing.expectError(error.InvalidLaunchMode, resolve(std.testing.allocator, " \t", false)); } +test "resolve maps server mode to no child args and HTTP health checks" { + const allocator = std.testing.allocator; + var resolved = try resolve(allocator, "server", false); + defer resolved.deinit(); + + try std.testing.expectEqual(@as(usize, 0), resolved.argv.len); + try std.testing.expectEqualStrings("server", resolved.primary_command); + try std.testing.expect(resolved.usesHttpHealthChecks()); +} + test "resolve rejects unterminated quoted launch mode" { try std.testing.expectError(error.InvalidLaunchMode, resolve(std.testing.allocator, "agent \"unterminated", false)); } diff --git a/src/installer/orchestrator.zig b/src/installer/orchestrator.zig index d9b276f..b257d9b 100644 --- a/src/installer/orchestrator.zig +++ b/src/installer/orchestrator.zig @@ -194,10 +194,10 @@ pub fn install( parsed_manifest = manifest_mod.parseManifest(allocator, json) catch null; } else |_| {} - // Use parsed manifest values or fall back to registry defaults + // Use parsed manifest values or fall back to registry defaults. var owned_launch_command: ?[]const u8 = null; defer if (owned_launch_command) |value| allocator.free(value); - const launch_command = if (parsed_manifest) |pm| blk: { + const raw_launch_command = if (parsed_manifest) |pm| blk: { owned_launch_command = launch_args_mod.fromManifestLaunch( allocator, opts.component, @@ -206,6 +206,7 @@ pub fn install( ) catch null; break :blk owned_launch_command orelse comp.default_launch_command; } else comp.default_launch_command; + const launch_command = registry.normalizeLaunchCommand(opts.component, raw_launch_command); const health_endpoint = if (parsed_manifest) |pm| pm.value.health.endpoint else comp.default_health_endpoint; const default_port = if (parsed_manifest) |pm| (if (pm.value.ports.len > 0) pm.value.ports[0].default else comp.default_port) else comp.default_port; defer if (parsed_manifest) |pm| pm.deinit(); @@ -485,7 +486,7 @@ fn persistAndStartInstance( }; } -fn findNextAvailablePort( +pub fn findNextAvailablePort( allocator: std.mem.Allocator, start: u16, paths: paths_mod.Paths, @@ -541,7 +542,8 @@ fn readConfiguredInstancePort( const config_path = paths.instanceConfig(allocator, component, instance_name) catch return null; defer allocator.free(config_path); - const manifest_info = readManifestPortInfo(allocator, paths, component, version); + var manifest_info = readManifestPortInfo(allocator, paths, component, version); + defer manifest_info.deinit(allocator); if (manifest_info.port_from_config.len > 0) { if (readPortFromConfigPath(allocator, config_path, manifest_info.port_from_config)) |port| { return port; @@ -555,7 +557,13 @@ fn readConfiguredInstancePort( const ManifestPortInfo = struct { port_from_config: []const u8 = "", + owns_port_from_config: bool = false, default_port: ?u16 = null, + + fn deinit(self: *ManifestPortInfo, allocator: std.mem.Allocator) void { + if (self.owns_port_from_config) allocator.free(self.port_from_config); + self.* = .{}; + } }; fn readManifestPortInfo( @@ -579,8 +587,14 @@ fn readManifestPortInfo( }; defer parsed_manifest.deinit(); + const raw_port_from_config = parsed_manifest.value.health.port_from_config; + const port_from_config = if (raw_port_from_config.len > 0) + allocator.dupe(u8, raw_port_from_config) catch "" + else + ""; return .{ - .port_from_config = parsed_manifest.value.health.port_from_config, + .port_from_config = port_from_config, + .owns_port_from_config = port_from_config.len > 0, .default_port = if (parsed_manifest.value.ports.len > 0) parsed_manifest.value.ports[0].default else null, }; } diff --git a/src/installer/registry.zig b/src/installer/registry.zig index f795216..68ae0a6 100644 --- a/src/installer/registry.zig +++ b/src/installer/registry.zig @@ -16,6 +16,7 @@ pub const KnownComponent = struct { description: []const u8, repo: []const u8, is_alpha: bool = false, + installable: bool = true, default_launch_command: []const u8 = "gateway", default_health_endpoint: []const u8 = "/health", default_port: u16 = 3000, @@ -38,8 +39,10 @@ pub const known_components = [_]KnownComponent{ .name = "nullboiler", .display_name = "NullBoiler", .description = "DAG-based workflow orchestrator. Chains agents into multi-step pipelines with branching, loops, and parallel execution. Turns NullClaw agents into teams.", - .repo = "nullclaw/NullBoiler", + .repo = "nullclaw/nullboiler", .is_alpha = true, + .default_launch_command = "server", + .default_port = 8080, }, .{ .name = "nulltickets", @@ -47,6 +50,8 @@ pub const known_components = [_]KnownComponent{ .description = "Task and issue tracker for AI agents. Project management that agents can read, create, and update autonomously via API.", .repo = "nullclaw/nulltickets", .is_alpha = true, + .default_launch_command = "server", + .default_port = 7700, }, .{ .name = "nullwatch", @@ -68,6 +73,19 @@ pub fn findKnownComponent(name: []const u8) ?KnownComponent { return null; } +/// NullBoiler and NullTickets expose long-lived API services as the default +/// process. Their manifests historically named the binary as the launch +/// command; NullHub stores the service mode as `server` so process supervision +/// can use HTTP health checks without passing a component-name argument. +pub fn normalizeLaunchCommand(component: []const u8, command: []const u8) []const u8 { + if ((std.mem.eql(u8, component, "nullboiler") or std.mem.eql(u8, component, "nulltickets")) and + (std.mem.eql(u8, command, component) or std.mem.eql(u8, command, "serve"))) + { + return "server"; + } + return command; +} + // ─── URL builders ──────────────────────────────────────────────────────────── /// Build the GitHub API URL for the latest release of a repository. @@ -243,7 +261,9 @@ test "findKnownComponent returns nullclaw" { test "findKnownComponent returns nullboiler" { const comp = findKnownComponent("nullboiler"); try std.testing.expect(comp != null); - try std.testing.expectEqualStrings("nullclaw/NullBoiler", comp.?.repo); + try std.testing.expectEqualStrings("nullclaw/nullboiler", comp.?.repo); + try std.testing.expectEqualStrings("server", comp.?.default_launch_command); + try std.testing.expectEqual(@as(u16, 8080), comp.?.default_port); } test "findKnownComponent returns nullwatch" { @@ -258,6 +278,12 @@ test "findKnownComponent returns null for unknown" { try std.testing.expect(findKnownComponent("nonexistent") == null); } +test "normalizeLaunchCommand maps service component binary names to server mode" { + try std.testing.expectEqualStrings("server", normalizeLaunchCommand("nullboiler", "nullboiler")); + try std.testing.expectEqualStrings("server", normalizeLaunchCommand("nulltickets", "nulltickets")); + try std.testing.expectEqualStrings("gateway", normalizeLaunchCommand("nullclaw", "gateway")); +} + test "buildReleasesUrl" { const allocator = std.testing.allocator; const url = try buildReleasesUrl(allocator, "nullclaw/nullclaw"); diff --git a/src/main.zig b/src/main.zig index a4b5372..fd2ddc3 100644 --- a/src/main.zig +++ b/src/main.zig @@ -87,31 +87,18 @@ pub fn main(init: std.process.Init) !void { } std.process.exit(1); }, - .install => |opts| { - std.debug.print("install {s}", .{opts.component}); - if (opts.name) |n| std.debug.print(" --name {s}", .{n}); - if (opts.version) |v| std.debug.print(" --version {s}", .{v}); - std.debug.print(" (not yet implemented)\n", .{}); - }, - .start => |ref| std.debug.print("start {s}/{s} (not yet implemented)\n", .{ ref.component, ref.name }), - .stop => |ref| std.debug.print("stop {s}/{s} (not yet implemented)\n", .{ ref.component, ref.name }), - .restart => |ref| std.debug.print("restart {s}/{s} (not yet implemented)\n", .{ ref.component, ref.name }), - .start_all => std.debug.print("start-all (not yet implemented)\n", .{}), - .stop_all => std.debug.print("stop-all (not yet implemented)\n", .{}), - .logs => |opts| { - std.debug.print("logs {s}/{s}", .{ opts.instance.component, opts.instance.name }); - if (opts.follow) std.debug.print(" -f", .{}); - std.debug.print(" --lines {d} (not yet implemented)\n", .{opts.lines}); - }, - .check_updates => std.debug.print("check-updates (not yet implemented)\n", .{}), - .update => |ref| std.debug.print("update {s}/{s} (not yet implemented)\n", .{ ref.component, ref.name }), - .update_all => std.debug.print("update-all (not yet implemented)\n", .{}), - .config => |opts| { - std.debug.print("config {s}/{s}", .{ opts.instance.component, opts.instance.name }); - if (opts.edit) std.debug.print(" --edit", .{}); - std.debug.print(" (not yet implemented)\n", .{}); - }, - .wizard => |opts| std.debug.print("wizard {s} (not yet implemented)\n", .{opts.component}), + .install => |opts| runInstallCommand(allocator, opts), + .start => |ref| runInstanceAction(allocator, ref, "start"), + .stop => |ref| runInstanceAction(allocator, ref, "stop"), + .restart => |ref| runInstanceAction(allocator, ref, "restart"), + .start_all => runBulkInstanceAction(allocator, "start"), + .stop_all => runBulkInstanceAction(allocator, "stop"), + .logs => |opts| runLogsCommand(allocator, opts), + .check_updates => runApiChecked(allocator, .{ .method = "GET", .target = "/api/updates", .pretty = true }), + .update => |ref| runInstanceAction(allocator, ref, "update"), + .update_all => runBulkInstanceAction(allocator, "update"), + .config => |opts| runConfigCommand(allocator, opts), + .wizard => |opts| runWizardCommand(allocator, opts), .service => |sc| handleServiceCommand(allocator, sc) catch |err| { const any_err: anyerror = err; switch (any_err) { @@ -130,11 +117,7 @@ pub fn main(init: std.process.Init) !void { } std.process.exit(1); }, - .uninstall => |opts| { - std.debug.print("uninstall {s}/{s}", .{ opts.instance.component, opts.instance.name }); - if (opts.remove_data) std.debug.print(" --remove-data", .{}); - std.debug.print(" (not yet implemented)\n", .{}); - }, + .uninstall => |opts| runUninstallCommand(allocator, opts), .add_source => |opts| std.debug.print("add-source {s} (not yet implemented)\n", .{opts.repo}), .report => |opts| report_cli.run(allocator, opts) catch |err| { const any_err: anyerror = err; @@ -151,6 +134,157 @@ pub fn main(init: std.process.Init) !void { } } +fn runApiChecked(allocator: std.mem.Allocator, opts: cli.ApiOptions) void { + api_cli.run(allocator, opts) catch |err| { + printApiError(opts, err); + std.process.exit(1); + }; +} + +fn printApiError(opts: cli.ApiOptions, err: anyerror) void { + switch (err) { + error.InvalidMethod => std.debug.print("Invalid HTTP method: {s}\n", .{opts.method}), + error.InvalidTarget => std.debug.print("Invalid API target: {s}\n", .{opts.target}), + error.FileNotFound => std.debug.print("Body file not found.\n", .{}), + error.ConnectionRefused => std.debug.print("nullhub is not running on http://{s}:{d}\n", .{ opts.host, opts.port }), + error.RequestFailed => {}, + else => std.debug.print("API request failed: {s}\n", .{@errorName(err)}), + } +} + +fn instanceActionTarget(allocator: std.mem.Allocator, ref: cli.InstanceRef, action: []const u8) ![]u8 { + return std.fmt.allocPrint(allocator, "/api/instances/{s}/{s}/{s}", .{ ref.component, ref.name, action }); +} + +fn runInstanceAction(allocator: std.mem.Allocator, ref: cli.InstanceRef, action: []const u8) void { + const target = instanceActionTarget(allocator, ref, action) catch { + std.debug.print("failed to build API target\n", .{}); + std.process.exit(1); + }; + defer allocator.free(target); + runApiChecked(allocator, .{ .method = "POST", .target = target, .pretty = true }); +} + +fn runInstallCommand(allocator: std.mem.Allocator, opts: cli.InstallOptions) void { + const target = std.fmt.allocPrint(allocator, "/api/wizard/{s}", .{opts.component}) catch { + std.debug.print("failed to build API target\n", .{}); + std.process.exit(1); + }; + defer allocator.free(target); + + const body = std.json.Stringify.valueAlloc(allocator, .{ + .instance_name = opts.name orelse "default", + .version = opts.version orelse "latest", + }, .{}) catch { + std.debug.print("failed to build install request\n", .{}); + std.process.exit(1); + }; + defer allocator.free(body); + + runApiChecked(allocator, .{ .method = "POST", .target = target, .body = body, .pretty = true }); +} + +fn runWizardCommand(allocator: std.mem.Allocator, opts: cli.WizardOptions) void { + const target = std.fmt.allocPrint(allocator, "/api/wizard/{s}", .{opts.component}) catch { + std.debug.print("failed to build API target\n", .{}); + std.process.exit(1); + }; + defer allocator.free(target); + runApiChecked(allocator, .{ .method = "GET", .target = target, .pretty = true }); +} + +fn runLogsCommand(allocator: std.mem.Allocator, opts: cli.LogsOptions) void { + if (opts.follow) { + std.debug.print("nullhub logs -f is not stream-backed yet; showing current logs.\n", .{}); + } + const target = std.fmt.allocPrint( + allocator, + "/api/instances/{s}/{s}/logs?lines={d}", + .{ opts.instance.component, opts.instance.name, opts.lines }, + ) catch { + std.debug.print("failed to build API target\n", .{}); + std.process.exit(1); + }; + defer allocator.free(target); + runApiChecked(allocator, .{ .method = "GET", .target = target }); +} + +fn runConfigCommand(allocator: std.mem.Allocator, opts: cli.ConfigOptions) void { + if (opts.edit) { + std.debug.print("nullhub config --edit is not stream-backed yet; showing current config.\n", .{}); + } + const target = std.fmt.allocPrint( + allocator, + "/api/instances/{s}/{s}/config", + .{ opts.instance.component, opts.instance.name }, + ) catch { + std.debug.print("failed to build API target\n", .{}); + std.process.exit(1); + }; + defer allocator.free(target); + runApiChecked(allocator, .{ .method = "GET", .target = target, .pretty = true }); +} + +fn runUninstallCommand(allocator: std.mem.Allocator, opts: cli.UninstallOptions) void { + _ = opts.remove_data; + const target = std.fmt.allocPrint( + allocator, + "/api/instances/{s}/{s}", + .{ opts.instance.component, opts.instance.name }, + ) catch { + std.debug.print("failed to build API target\n", .{}); + std.process.exit(1); + }; + defer allocator.free(target); + runApiChecked(allocator, .{ .method = "DELETE", .target = target, .pretty = true }); +} + +fn runBulkInstanceAction(allocator: std.mem.Allocator, action: []const u8) void { + var result = api_cli.execute(allocator, .{ .method = "GET", .target = "/api/instances" }) catch |err| { + printApiError(.{ .method = "GET", .target = "/api/instances" }, err); + std.process.exit(1); + }; + defer result.deinit(allocator); + + const code = @intFromEnum(result.status); + if (code < 200 or code >= 300) { + if (result.body.len > 0) printStdout(result.body) catch {}; + std.debug.print("HTTP {d}\n", .{code}); + std.process.exit(1); + } + + const parsed = std.json.parseFromSlice(std.json.Value, allocator, result.body, .{ + .allocate = .alloc_always, + .ignore_unknown_fields = true, + }) catch { + std.debug.print("Invalid /api/instances response.\n", .{}); + std.process.exit(1); + }; + defer parsed.deinit(); + + const instances_value = if (parsed.value == .object) parsed.value.object.get("instances") else null; + if (instances_value == null or instances_value.? != .object) { + std.debug.print("Invalid /api/instances response.\n", .{}); + std.process.exit(1); + } + + var count: usize = 0; + var comp_it = instances_value.?.object.iterator(); + while (comp_it.next()) |comp_entry| { + if (comp_entry.value_ptr.* != .object) continue; + var inst_it = comp_entry.value_ptr.object.iterator(); + while (inst_it.next()) |inst_entry| { + const ref = cli.InstanceRef{ .component = comp_entry.key_ptr.*, .name = inst_entry.key_ptr.* }; + runInstanceAction(allocator, ref, action); + count += 1; + } + } + + if (count == 0) { + printStdout("No instances.\n") catch {}; + } +} + fn handleServiceCommand(allocator: std.mem.Allocator, command: cli.ServiceCommand) !void { switch (command) { .install => { diff --git a/src/server.zig b/src/server.zig index abbdb15..e4a3215 100644 --- a/src/server.zig +++ b/src/server.zig @@ -188,13 +188,14 @@ pub const Server = struct { fn normalizedLaunchModeForRestore(component: []const u8, launch_mode: []const u8) []const u8 { const known = registry.findKnownComponent(component) orelse return launch_mode; - if (!std.mem.eql(u8, known.default_launch_command, "gateway") and std.mem.eql(u8, launch_mode, "gateway")) { + const normalized = registry.normalizeLaunchCommand(component, launch_mode); + if (!std.mem.eql(u8, known.default_launch_command, "gateway") and std.mem.eql(u8, normalized, "gateway")) { return known.default_launch_command; } - if (std.mem.eql(u8, component, "nullwatch") and std.mem.eql(u8, launch_mode, "nullwatch")) { + if (std.mem.eql(u8, component, "nullwatch") and std.mem.eql(u8, normalized, "nullwatch")) { return known.default_launch_command; } - return launch_mode; + return normalized; } fn terminatePersistedRuntime( @@ -581,6 +582,87 @@ pub const Server = struct { return getEnv("NULLTICKETS_TOKEN"); } + const ManagedBackendConfig = struct { + url: []u8, + token: ?[]u8 = null, + + fn deinit(self: *ManagedBackendConfig, allocator: std.mem.Allocator) void { + allocator.free(self.url); + if (self.token) |token| allocator.free(token); + self.* = undefined; + } + }; + + fn resolveManagedBackend(self: *Server, allocator: std.mem.Allocator, component: []const u8, requested_name: ?[]const u8) ?ManagedBackendConfig { + self.mutex.lock(); + defer self.mutex.unlock(); + + if (std.mem.eql(u8, component, "nullboiler")) { + const configs = integration_mod.listNullBoilers(allocator, self.state, self.paths) catch return null; + defer integration_mod.deinitNullBoilerConfigs(allocator, configs); + return self.resolveManagedBackendFromConfigs(allocator, "nullboiler", requested_name, configs); + } + + if (std.mem.eql(u8, component, "nulltickets")) { + const configs = integration_mod.listNullTickets(allocator, self.state, self.paths) catch return null; + defer integration_mod.deinitNullTicketsConfigs(allocator, configs); + return self.resolveManagedBackendFromConfigs(allocator, "nulltickets", requested_name, configs); + } + + return null; + } + + fn resolveManagedBackendFromConfigs(self: *Server, allocator: std.mem.Allocator, component: []const u8, requested_name: ?[]const u8, configs: anytype) ?ManagedBackendConfig { + if (configs.len == 0) return null; + if (requested_name) |wanted| { + for (configs) |cfg| { + if (std.mem.eql(u8, cfg.name, wanted)) { + return managedBackendFromConfig(allocator, cfg.port, cfg.api_token); + } + } + return null; + } + + const selected = self.selectManagedBackendIndex(component, configs); + return managedBackendFromConfig(allocator, configs[selected].port, configs[selected].api_token); + } + + fn selectManagedBackendIndex(self: *Server, component: []const u8, configs: anytype) usize { + var fallback: usize = 0; + for (configs, 0..) |cfg, idx| { + if (std.mem.eql(u8, cfg.name, "default")) fallback = idx; + const status = self.manager.getStatus(component, cfg.name) orelse continue; + if (status.status == .running) return idx; + } + return fallback; + } + + fn managedBackendFromConfig(allocator: std.mem.Allocator, port: u16, token: ?[]const u8) ?ManagedBackendConfig { + const url = std.fmt.allocPrint(allocator, "http://127.0.0.1:{d}", .{port}) catch return null; + const owned_token = if (token) |value| allocator.dupe(u8, value) catch { + allocator.free(url); + return null; + } else null; + return .{ + .url = url, + .token = owned_token, + }; + } + + fn shouldResolveManagedBackend(env_url: ?[]const u8, requested_name: ?[]const u8) bool { + return requested_name != null or env_url == null; + } + + fn selectBackendUrl(env_url: ?[]const u8, managed: ?ManagedBackendConfig, requested_name: ?[]const u8) ?[]const u8 { + if (requested_name != null) return if (managed) |cfg| cfg.url else null; + return env_url orelse if (managed) |cfg| cfg.url else null; + } + + fn selectBackendToken(env_token: ?[]const u8, managed: ?ManagedBackendConfig, requested_name: ?[]const u8) ?[]const u8 { + if (requested_name != null) return if (managed) |cfg| cfg.token else null; + return env_token orelse if (managed) |cfg| cfg.token else null; + } + const WatchTarget = struct { url: ?[]const u8 = null, url_owned: bool = false, @@ -705,6 +787,8 @@ pub const Server = struct { fn routeWithoutServerMutex(target: []const u8) bool { return instances_api.isIntegrationPath(target) or + instances_api.isTicketsActionPath(target) or + logs_api.isLogsPath(target) or orchestration_api.isProxyPath(target) or observability_api.isProxyPath(target); } @@ -1018,7 +1102,7 @@ pub const Server = struct { if (wizard_api.isWizardPath(target)) { if (wizard_api.extractComponentName(target)) |comp_name| { if (std.mem.eql(u8, method, "GET")) { - if (wizard_api.handleGetWizard(allocator, comp_name, self.paths, self.state)) |json| { + if (wizard_api.handleGetWizard(allocator, comp_name, target, self.paths, self.state)) |json| { const status = if (std.mem.indexOf(u8, json, "\"error\"") != null) "400 Bad Request" else @@ -1275,11 +1359,31 @@ pub const Server = struct { } if (orchestration_api.isProxyPath(target)) { + const env_boiler_url = self.getBoilerUrl(); + const env_boiler_token = self.getBoilerToken(); + const env_tickets_url = self.getTicketsUrl(); + const env_tickets_token = self.getTicketsToken(); + const requested_boiler = orchestration_api.requestedBoilerInstance(allocator, target) catch null; + defer if (requested_boiler) |value| allocator.free(value); + const requested_tickets = orchestration_api.requestedTicketsInstance(allocator, target) catch null; + defer if (requested_tickets) |value| allocator.free(value); + + var managed_boiler = if (shouldResolveManagedBackend(env_boiler_url, requested_boiler)) + self.resolveManagedBackend(allocator, "nullboiler", requested_boiler) + else + null; + defer if (managed_boiler) |*cfg| cfg.deinit(allocator); + var managed_tickets = if (shouldResolveManagedBackend(env_tickets_url, requested_tickets)) + self.resolveManagedBackend(allocator, "nulltickets", requested_tickets) + else + null; + defer if (managed_tickets) |*cfg| cfg.deinit(allocator); + const resp = orchestration_api.handle(allocator, method, target, body, .{ - .boiler_url = self.getBoilerUrl(), - .boiler_token = self.getBoilerToken(), - .tickets_url = self.getTicketsUrl(), - .tickets_token = self.getTicketsToken(), + .boiler_url = selectBackendUrl(env_boiler_url, managed_boiler, requested_boiler), + .boiler_token = selectBackendToken(env_boiler_token, managed_boiler, requested_boiler), + .tickets_url = selectBackendUrl(env_tickets_url, managed_tickets, requested_tickets), + .tickets_token = selectBackendToken(env_tickets_token, managed_tickets, requested_tickets), }); return .{ .status = resp.status, .content_type = resp.content_type, .body = resp.body }; } @@ -2084,9 +2188,37 @@ test "routeWithoutServerMutex keeps orchestration proxy requests off global lock try std.testing.expect(Server.routeWithoutServerMutex("/api/orchestration/store/search")); try std.testing.expect(Server.routeWithoutServerMutex("/api/observability/v1/runs")); try std.testing.expect(Server.routeWithoutServerMutex("/api/instances/nullclaw/demo/logs")); + try std.testing.expect(Server.routeWithoutServerMutex("/api/instances/nulltickets/tracker-a/tickets")); try std.testing.expect(!Server.routeWithoutServerMutex("/api/components")); } +test "explicit managed orchestration backend selection overrides env fallback" { + const allocator = std.testing.allocator; + var managed = Server.ManagedBackendConfig{ + .url = try allocator.dupe(u8, "http://127.0.0.1:8081"), + .token = try allocator.dupe(u8, "managed-token"), + }; + defer managed.deinit(allocator); + + try std.testing.expect(Server.shouldResolveManagedBackend("http://env.example", "worker-a")); + try std.testing.expectEqualStrings( + "http://127.0.0.1:8081", + Server.selectBackendUrl("http://env.example", managed, "worker-a").?, + ); + try std.testing.expectEqualStrings( + "managed-token", + Server.selectBackendToken("env-token", managed, "worker-a").?, + ); + try std.testing.expectEqualStrings( + "http://env.example", + Server.selectBackendUrl("http://env.example", managed, null).?, + ); + try std.testing.expectEqualStrings( + "env-token", + Server.selectBackendToken("env-token", managed, null).?, + ); +} + test "managed NullWatch target is discovered from supervisor state" { const allocator = std.testing.allocator; var ctx = TestContext.init(allocator); diff --git a/ui/src/lib/api/client.ts b/ui/src/lib/api/client.ts index 47d9b3e..6ed8d3d 100644 --- a/ui/src/lib/api/client.ts +++ b/ui/src/lib/api/client.ts @@ -1,4 +1,6 @@ import { createOrchestrationApi } from '$lib/api/orchestration'; +import { createNullTicketsApi } from '$lib/api/nulltickets'; +import { encodePathSegment } from '$lib/orchestration/routes'; const BASE = '/api'; @@ -12,7 +14,7 @@ function withQuery(path: string, params: Record(path: string, options?: RequestInit): Promise { const res = await fetch(`${BASE}${path}`, { @@ -39,7 +48,10 @@ async function request(path: string, options?: RequestInit): Promise { : typeof body?.error === 'string' ? body.error : body?.error?.message || `HTTP ${res.status}`; - throw new Error(errMsg); + const error = new Error(errMsg) as ApiRequestError; + error.status = res.status; + error.body = body; + throw error; } if (res.status === 204) return undefined as T; const text = await res.text(); @@ -53,7 +65,8 @@ export const api = { request(`/usage?window=${window}`), getComponents: () => request('/components'), getInstances: () => request('/instances'), - getWizard: (component: string) => request(`/wizard/${component}`), + getWizard: (component: string, version = '') => + request(withQuery(`/wizard/${component}`, { version })), getVersions: (component: string) => request(`/wizard/${component}/versions`), getWizardModels: (component: string, provider: string, apiKey = '') => request(`/wizard/${component}/models`, { @@ -80,8 +93,10 @@ export const api = { method: 'POST', body: options ? JSON.stringify(options) : undefined }), - deleteInstance: (c: string, n: string) => - request(`/instances/${c}/${n}`, { method: 'DELETE' }), + deleteInstance: (c: string, n: string, options?: InstanceDeleteOptions) => + request(withQuery(`/instances/${c}/${n}`, { force: options?.force ? 1 : undefined }), { + method: 'DELETE' + }), getConfig: (c: string, n: string) => request(`/instances/${c}/${n}/config`), getProviderHealth: (c: string, n: string) => request(`/instances/${c}/${n}/provider-health`), @@ -141,6 +156,12 @@ export const api = { method: 'POST', body: JSON.stringify(payload), }), + ...createNullTicketsApi((c, n, payload) => + request(`/instances/${c}/${n}/tickets`, { + method: 'POST', + body: JSON.stringify(payload), + }), + ), putConfig: (c: string, n: string, config: any) => request(`/instances/${c}/${n}/config`, { method: 'PUT', body: JSON.stringify(config) }), getLogs: (c: string, n: string, lines = 100, source: LogSource = 'instance') => diff --git a/ui/src/lib/api/nulltickets.ts b/ui/src/lib/api/nulltickets.ts new file mode 100644 index 0000000..deeeac6 --- /dev/null +++ b/ui/src/lib/api/nulltickets.ts @@ -0,0 +1,162 @@ +import { encodePathSegment } from '$lib/orchestration/routes'; + +export type NullTicketsHttpMethod = 'GET' | 'POST' | 'DELETE'; + +export type NullTicketsActionRequest = { + method?: NullTicketsHttpMethod; + path: string; + payload?: any; + bearer_token?: string; +}; + +type NullTicketsActionFn = (component: string, name: string, payload: NullTicketsActionRequest) => Promise; +type QueryValue = string | number | boolean | null | undefined; + +function withNullTicketsQuery(path: string, params: Record): string { + const query = Object.entries(params) + .filter(([, value]) => value !== null && value !== undefined && value !== '') + .map(([key, value]) => { + let encodedValue = encodeURIComponent(String(value)); + if (key === 'cursor') encodedValue = encodedValue.replace(/%3A/gi, ':'); + return `${encodeURIComponent(key)}=${encodedValue}`; + }) + .join('&'); + return query ? `${path}?${query}` : path; +} + +const ticketsPaths = { + pipelines: () => '/pipelines', + pipeline: (pipelineId: string) => `/pipelines/${encodePathSegment(pipelineId)}`, + taskCollection: () => '/tasks', + tasks: (params?: { pipelineId?: string; stage?: string; limit?: number; cursor?: string }) => + withNullTicketsQuery('/tasks', { + pipeline_id: params?.pipelineId, + stage: params?.stage, + limit: params?.limit, + cursor: params?.cursor, + }), + task: (taskId: string) => `/tasks/${encodePathSegment(taskId)}`, + taskBulk: () => '/tasks/bulk', + taskDependencies: (taskId: string) => `/tasks/${encodePathSegment(taskId)}/dependencies`, + taskAssignments: (taskId: string) => `/tasks/${encodePathSegment(taskId)}/assignments`, + taskAssignment: (taskId: string, agentId: string) => + `/tasks/${encodePathSegment(taskId)}/assignments/${encodePathSegment(agentId)}`, + taskRunState: (taskId: string) => `/tasks/${encodePathSegment(taskId)}/run-state`, + leasesClaim: () => '/leases/claim', + leaseHeartbeat: (leaseId: string) => `/leases/${encodePathSegment(leaseId)}/heartbeat`, + runEvents: (runId: string, params?: { limit?: number; cursor?: string }) => + withNullTicketsQuery(`/runs/${encodePathSegment(runId)}/events`, { + limit: params?.limit, + cursor: params?.cursor, + }), + runEventTarget: (runId: string) => `/runs/${encodePathSegment(runId)}/events`, + runTransition: (runId: string) => `/runs/${encodePathSegment(runId)}/transition`, + runFail: (runId: string) => `/runs/${encodePathSegment(runId)}/fail`, + artifacts: (params?: { taskId?: string; runId?: string; limit?: number; cursor?: string }) => + withNullTicketsQuery('/artifacts', { + task_id: params?.taskId, + run_id: params?.runId, + limit: params?.limit, + cursor: params?.cursor, + }), + artifactCollection: () => '/artifacts', +}; + +export function createNullTicketsApi(action: NullTicketsActionFn) { + return { + nullTicketsAction: action, + nullTicketsPipelines: (c: string, n: string) => + action(c, n, { method: 'GET', path: ticketsPaths.pipelines() }), + nullTicketsTasks: ( + c: string, + n: string, + params?: { pipelineId?: string; stage?: string; limit?: number; cursor?: string }, + ) => + action(c, n, { + method: 'GET', + path: ticketsPaths.tasks(params), + }), + nullTicketsCreateTask: (c: string, n: string, payload: any) => + action(c, n, { method: 'POST', path: ticketsPaths.taskCollection(), payload }), + nullTicketsBulkCreateTasks: (c: string, n: string, tasks: any[]) => + action(c, n, { method: 'POST', path: ticketsPaths.taskBulk(), payload: { tasks } }), + nullTicketsClaimTask: (c: string, n: string, payload: any) => + action(c, n, { method: 'POST', path: ticketsPaths.leasesClaim(), payload }), + nullTicketsHeartbeatLease: (c: string, n: string, leaseId: string, bearerToken: string) => + action(c, n, { + method: 'POST', + path: ticketsPaths.leaseHeartbeat(leaseId), + bearer_token: bearerToken, + }), + nullTicketsCreatePipeline: (c: string, n: string, payload: any) => + action(c, n, { method: 'POST', path: ticketsPaths.pipelines(), payload }), + nullTicketsGetPipeline: (c: string, n: string, pipelineId: string) => + action(c, n, { method: 'GET', path: ticketsPaths.pipeline(pipelineId) }), + nullTicketsGetTask: (c: string, n: string, taskId: string) => + action(c, n, { method: 'GET', path: ticketsPaths.task(taskId) }), + nullTicketsAssignTask: (c: string, n: string, taskId: string, payload: any) => + action(c, n, { + method: 'POST', + path: ticketsPaths.taskAssignments(taskId), + payload, + }), + nullTicketsUnassignTask: (c: string, n: string, taskId: string, agentId: string) => + action(c, n, { + method: 'DELETE', + path: ticketsPaths.taskAssignment(taskId, agentId), + }), + nullTicketsAddDependency: (c: string, n: string, taskId: string, payload: any) => + action(c, n, { + method: 'POST', + path: ticketsPaths.taskDependencies(taskId), + payload, + }), + nullTicketsGetRunState: (c: string, n: string, taskId: string) => + action(c, n, { + method: 'GET', + path: ticketsPaths.taskRunState(taskId), + }), + nullTicketsRunEvents: ( + c: string, + n: string, + runId: string, + params?: { limit?: number; cursor?: string }, + ) => + action(c, n, { + method: 'GET', + path: ticketsPaths.runEvents(runId, params), + }), + nullTicketsAddRunEvent: (c: string, n: string, runId: string, payload: any, bearerToken: string) => + action(c, n, { + method: 'POST', + path: ticketsPaths.runEventTarget(runId), + payload, + bearer_token: bearerToken, + }), + nullTicketsTransitionRun: (c: string, n: string, runId: string, payload: any, bearerToken: string) => + action(c, n, { + method: 'POST', + path: ticketsPaths.runTransition(runId), + payload, + bearer_token: bearerToken, + }), + nullTicketsFailRun: (c: string, n: string, runId: string, payload: any, bearerToken: string) => + action(c, n, { + method: 'POST', + path: ticketsPaths.runFail(runId), + payload, + bearer_token: bearerToken, + }), + nullTicketsArtifacts: ( + c: string, + n: string, + params?: { taskId?: string; runId?: string; limit?: number; cursor?: string }, + ) => + action(c, n, { + method: 'GET', + path: ticketsPaths.artifacts(params), + }), + nullTicketsCreateArtifact: (c: string, n: string, payload: any) => + action(c, n, { method: 'POST', path: ticketsPaths.artifactCollection(), payload }), + }; +} diff --git a/ui/src/lib/api/orchestration.ts b/ui/src/lib/api/orchestration.ts index bc8139c..7f39c96 100644 --- a/ui/src/lib/api/orchestration.ts +++ b/ui/src/lib/api/orchestration.ts @@ -1,10 +1,35 @@ import { orchestrationApiPaths } from '$lib/orchestration/routes'; +import { getSelectedBoilerInstance } from '$lib/orchestration/backendSelection'; type RequestFn = (path: string, options?: RequestInit) => Promise; type WithQueryFn = ( path: string, params: Record, ) => string; +type QueryParams = Record; +type BoilerOptions = { boilerInstance?: string }; +type RunListParams = { + status?: string; + workflow_id?: string; + limit?: number; + offset?: number; + boilerInstance?: string; +}; + +export type RunListPage = { + items: any[]; + limit?: number; + offset?: number; + nextOffset?: number; + hasMore: boolean; +}; + +export type RunStreamHandle = { + close: () => void; + readonly closed: boolean; +}; + +const orchestrationStorePrefix = '/orchestration/store'; function msToIso(ms: number | undefined | null): string | undefined { if (ms == null) return undefined; @@ -55,6 +80,21 @@ function normalizeRun(raw: any): any { }; } +function normalizeRunListPage(raw: any): RunListPage { + const list = Array.isArray(raw) ? raw : raw?.items ?? raw?.runs ?? []; + return { + items: (list || []).map(normalizeRun), + limit: typeof raw?.limit === 'number' ? raw.limit : undefined, + offset: typeof raw?.offset === 'number' ? raw.offset : undefined, + nextOffset: typeof raw?.next_offset === 'number' + ? raw.next_offset + : typeof raw?.nextOffset === 'number' + ? raw.nextOffset + : undefined, + hasMore: Boolean(raw?.has_more ?? raw?.hasMore), + }; +} + function normalizeCheckpoint(raw: any): any { if (!raw) return raw; return { @@ -97,40 +137,88 @@ function normalizeStreamEvent(raw: any): { type: string; data: any; timestamp?: } export function createOrchestrationApi(request: RequestFn, withQuery: WithQueryFn) { + function withBoilerQuery(path: string, params: QueryParams = {}, boilerInstance?: string) { + const selectedBoiler = path.startsWith(orchestrationStorePrefix) + ? '' + : boilerInstance ?? getSelectedBoilerInstance(); + return withQuery(path, { ...params, boiler_instance: selectedBoiler || undefined }); + } + + async function listRunsPage(params?: RunListParams): Promise { + const { boilerInstance, ...query } = params ?? {}; + const raw = await request(withBoilerQuery(orchestrationApiPaths.runs(), query, boilerInstance)); + return normalizeRunListPage(raw); + } + return { - listWorkflows: async () => { - const raw = await request(orchestrationApiPaths.workflows()); + listWorkflows: async (options?: BoilerOptions) => { + const raw = await request(withBoilerQuery(orchestrationApiPaths.workflows(), {}, options?.boilerInstance)); const list = Array.isArray(raw) ? raw : raw?.items ?? []; return list.map(normalizeWorkflow); }, - getWorkflow: async (id: string) => normalizeWorkflow(await request(orchestrationApiPaths.workflow(id))), - createWorkflow: (data: any) => request(orchestrationApiPaths.workflows(), { method: 'POST', body: JSON.stringify(data) }), - updateWorkflow: (id: string, data: any) => request(orchestrationApiPaths.workflow(id), { method: 'PUT', body: JSON.stringify(data) }), - deleteWorkflow: (id: string) => request(orchestrationApiPaths.workflow(id), { method: 'DELETE' }), - validateWorkflow: async (id: string) => normalizeValidation(await request(orchestrationApiPaths.workflowValidate(id), { method: 'POST' })), - runWorkflow: (id: string, input: any) => request(orchestrationApiPaths.workflowRun(id), { method: 'POST', body: JSON.stringify(input) }), - listRuns: async (params?: { status?: string; workflow_id?: string }) => { - const raw = await request(withQuery(orchestrationApiPaths.runs(), params ?? {})); - const list = Array.isArray(raw) ? raw : raw?.items ?? []; - return list.map(normalizeRun); - }, - getRun: async (id: string) => normalizeRun(await request(orchestrationApiPaths.run(id))), - cancelRun: (id: string) => request(orchestrationApiPaths.runCancel(id), { method: 'POST' }), - resumeRun: (id: string, updates: any) => request(orchestrationApiPaths.runResume(id), { method: 'POST', body: JSON.stringify({ state_updates: updates }) }), - forkRun: (checkpointId: string, overrides?: any) => request(orchestrationApiPaths.runsFork(), { method: 'POST', body: JSON.stringify({ checkpoint_id: checkpointId, state_overrides: overrides }) }), - replayRun: (id: string, checkpointId: string) => request(orchestrationApiPaths.runReplay(id), { method: 'POST', body: JSON.stringify({ from_checkpoint_id: checkpointId }) }), - injectState: (id: string, updates: any, afterStep?: string) => request(orchestrationApiPaths.runState(id), { method: 'POST', body: JSON.stringify({ updates, apply_after_step: afterStep }) }), - listCheckpoints: async (runId: string) => { - const cps = await request(orchestrationApiPaths.runCheckpoints(runId)); + getWorkflow: async (id: string, options?: BoilerOptions) => + normalizeWorkflow(await request(withBoilerQuery(orchestrationApiPaths.workflow(id), {}, options?.boilerInstance))), + createWorkflow: (data: any, options?: BoilerOptions) => + request(withBoilerQuery(orchestrationApiPaths.workflows(), {}, options?.boilerInstance), { method: 'POST', body: JSON.stringify(data) }), + updateWorkflow: (id: string, data: any, options?: BoilerOptions) => + request(withBoilerQuery(orchestrationApiPaths.workflow(id), {}, options?.boilerInstance), { method: 'PUT', body: JSON.stringify(data) }), + deleteWorkflow: (id: string, options?: BoilerOptions) => + request(withBoilerQuery(orchestrationApiPaths.workflow(id), {}, options?.boilerInstance), { method: 'DELETE' }), + validateWorkflow: async (id: string, options?: BoilerOptions) => + normalizeValidation(await request(withBoilerQuery(orchestrationApiPaths.workflowValidate(id), {}, options?.boilerInstance), { method: 'POST' })), + runWorkflow: (id: string, input: any, options?: BoilerOptions) => + request(withBoilerQuery(orchestrationApiPaths.workflowRun(id), {}, options?.boilerInstance), { method: 'POST', body: JSON.stringify(input) }), + listRunsPage, + listRuns: async (params?: RunListParams) => (await listRunsPage(params)).items, + getRun: async (id: string, options?: BoilerOptions) => + normalizeRun(await request(withBoilerQuery(orchestrationApiPaths.run(id), {}, options?.boilerInstance))), + cancelRun: (id: string, options?: BoilerOptions) => + request(withBoilerQuery(orchestrationApiPaths.runCancel(id), {}, options?.boilerInstance), { method: 'POST' }), + retryRun: (id: string, options?: BoilerOptions) => + request(withBoilerQuery(orchestrationApiPaths.runRetry(id), {}, options?.boilerInstance), { method: 'POST' }), + resumeRun: (id: string, updates: any, options?: BoilerOptions) => + request(withBoilerQuery(orchestrationApiPaths.runResume(id), {}, options?.boilerInstance), { method: 'POST', body: JSON.stringify({ state_updates: updates }) }), + forkRun: (checkpointId: string, overrides?: any, options?: BoilerOptions) => + request(withBoilerQuery(orchestrationApiPaths.runsFork(), {}, options?.boilerInstance), { method: 'POST', body: JSON.stringify({ checkpoint_id: checkpointId, state_overrides: overrides }) }), + replayRun: (id: string, checkpointId: string, options?: BoilerOptions) => + request(withBoilerQuery(orchestrationApiPaths.runReplay(id), {}, options?.boilerInstance), { method: 'POST', body: JSON.stringify({ from_checkpoint_id: checkpointId }) }), + injectState: (id: string, updates: any, afterStep?: string, options?: BoilerOptions) => + request(withBoilerQuery(orchestrationApiPaths.runState(id), {}, options?.boilerInstance), { method: 'POST', body: JSON.stringify({ updates, apply_after_step: afterStep }) }), + listCheckpoints: async (runId: string, options?: BoilerOptions) => { + const cps = await request(withBoilerQuery(orchestrationApiPaths.runCheckpoints(runId), {}, options?.boilerInstance)); return (cps || []).map(normalizeCheckpoint); }, - getCheckpoint: async (runId: string, cpId: string) => normalizeCheckpoint(await request(orchestrationApiPaths.runCheckpoint(runId, cpId))), - storeList: (namespace: string) => request(orchestrationApiPaths.storeNamespace(namespace)), - storeGet: (namespace: string, key: string) => request(orchestrationApiPaths.storeEntry(namespace, key)), - storePut: (namespace: string, key: string, value: any) => request(orchestrationApiPaths.storeEntry(namespace, key), { method: 'PUT', body: JSON.stringify({ value }) }), - storeDelete: (namespace: string, key: string) => request(orchestrationApiPaths.storeEntry(namespace, key), { method: 'DELETE' }), - streamRun: (runId: string, onEvent: (event: { type: string; data: any; timestamp?: number }) => void) => { + getCheckpoint: async (runId: string, cpId: string, options?: BoilerOptions) => + normalizeCheckpoint(await request(withBoilerQuery(orchestrationApiPaths.runCheckpoint(runId, cpId), {}, options?.boilerInstance))), + getBoilerTrackerStatus: (options?: BoilerOptions) => + request(withBoilerQuery(orchestrationApiPaths.trackerStatus(), {}, options?.boilerInstance)), + getBoilerTrackerTasks: (options?: BoilerOptions) => + request(withBoilerQuery(orchestrationApiPaths.trackerTasks(), {}, options?.boilerInstance)), + getBoilerTrackerStats: (options?: BoilerOptions) => + request(withBoilerQuery(orchestrationApiPaths.trackerStats(), {}, options?.boilerInstance)), + refreshBoilerTracker: (options?: BoilerOptions) => + request(withBoilerQuery(orchestrationApiPaths.trackerRefresh(), {}, options?.boilerInstance), { method: 'POST' }), + listWorkers: (options?: BoilerOptions) => + request(withBoilerQuery(orchestrationApiPaths.workers(), {}, options?.boilerInstance)), + registerWorker: (data: any, options?: BoilerOptions) => + request(withBoilerQuery(orchestrationApiPaths.workers(), {}, options?.boilerInstance), { method: 'POST', body: JSON.stringify(data) }), + deleteWorker: (id: string, options?: BoilerOptions) => + request(withBoilerQuery(orchestrationApiPaths.worker(id), {}, options?.boilerInstance), { method: 'DELETE' }), + storeList: (namespace: string, ticketsInstance?: string) => + request(withQuery(orchestrationApiPaths.storeNamespace(namespace), { tickets_instance: ticketsInstance })), + storeGet: (namespace: string, key: string, ticketsInstance?: string) => + request(withQuery(orchestrationApiPaths.storeEntry(namespace, key), { tickets_instance: ticketsInstance })), + storePut: (namespace: string, key: string, value: any, ticketsInstance?: string) => + request(withQuery(orchestrationApiPaths.storeEntry(namespace, key), { tickets_instance: ticketsInstance }), { method: 'PUT', body: JSON.stringify({ value }) }), + storeDelete: (namespace: string, key: string, ticketsInstance?: string) => + request(withQuery(orchestrationApiPaths.storeEntry(namespace, key), { tickets_instance: ticketsInstance }), { method: 'DELETE' }), + streamRun: ( + runId: string, + onEvent: (event: { type: string; data: any; timestamp?: number }) => void, + options?: BoilerOptions, + ) => { let active = true; + let closed = false; let deliveredInitialSnapshot = false; let afterSeq = 0; @@ -142,9 +230,9 @@ export function createOrchestrationApi(request: RequestFn, withQuery: WithQueryF const poll = async () => { while (active) { try { - const res = await request(withQuery(orchestrationApiPaths.runStream(runId), { + const res = await request(withBoilerQuery(orchestrationApiPaths.runStream(runId), { after_seq: afterSeq > 0 ? afterSeq : undefined, - })); + }, options?.boilerInstance)); if (!active) break; if (res?.stream_events) { for (const ev of res.stream_events) emitEvent(ev); @@ -157,6 +245,7 @@ export function createOrchestrationApi(request: RequestFn, withQuery: WithQueryF afterSeq = Math.max(afterSeq, res.next_stream_seq); } if (res?.status && ['completed', 'failed', 'cancelled'].includes(res.status)) { + active = false; break; } } catch { @@ -166,10 +255,19 @@ export function createOrchestrationApi(request: RequestFn, withQuery: WithQueryF if (!active) break; await new Promise(r => setTimeout(r, 1000)); } + closed = true; }; void poll(); - return { close: () => { active = false; } } as EventSource; + return { + close: () => { + active = false; + closed = true; + }, + get closed() { + return closed; + }, + }; }, }; } diff --git a/ui/src/lib/components/ComponentCard.svelte b/ui/src/lib/components/ComponentCard.svelte index ac14204..34a5d83 100644 --- a/ui/src/lib/components/ComponentCard.svelte +++ b/ui/src/lib/components/ComponentCard.svelte @@ -6,13 +6,14 @@ displayName = "", description = "", alpha = false, + installable = true, installed = false, standalone = false, instanceCount = 0, } = $props(); let importing = $state(false); let imported = $state(false); - let comingSoon = $derived(alpha && !installed && !standalone); + let comingSoon = $derived(!installable && !installed && !standalone); async function handleImport(e: MouseEvent) { e.preventDefault(); diff --git a/ui/src/lib/components/NullBoilerPanel.svelte b/ui/src/lib/components/NullBoilerPanel.svelte new file mode 100644 index 0000000..e5f9638 --- /dev/null +++ b/ui/src/lib/components/NullBoilerPanel.svelte @@ -0,0 +1,1864 @@ + + +
+ {#if !running} +
Instance is stopped.
+ {:else} +
+
+ + + + + +
+
+ + +
+
+ + {#if error} +
{error}
+ {/if} + {#if message} +
{message}
+ {/if} + + {#if panelView === "workflows"} +
+
+
+

Workflows

+ {workflows.length} +
+
+ {#if workflows.length === 0} +
No workflows
+ {:else} + {#each workflows as workflow} + + {/each} + {/if} +
+
+ + +
+ + Open Full Page + +
+ +
+
+

Selected Workflow

+
+ {#if selectedWorkflow} +
+
+ {workflowName(selectedWorkflow)} + {workflowId(selectedWorkflow)} +
+
+
Nodes{nodeCount(selectedWorkflow)}
+
Edges{edgeCount(selectedWorkflow)}
+
Version{selectedWorkflow.version ?? 1}
+
Updated{formatTime(selectedWorkflow.updated_at || selectedWorkflow.updated_at_ms)}
+
+
+ +
+
+ + +
+
+ {:else} +
No workflow selected
+ {/if} +
+
+ {:else if panelView === "editor"} +
+
+
+

{workflowEditorMode === "create" ? "Create Workflow" : "Workflow Editor"}

+ {#if workflowParseError} + Invalid JSON + {:else} + JSON OK + {/if} +
+ + {#if workflowParseError} +
{workflowParseError}
+ {/if} +
+ +
+
+

Preview & Actions

+ {workflowEditorMode} +
+
+ +
+
+ + + +
+ {#if validationResult} +
+ {validationResult.valid ? "Workflow is valid" : "Workflow has validation errors"} +
+ {#if validationResult.errors?.length} +
{jsonPreview(validationResult.errors)}
+ {/if} + {#if validationResult.mermaid} +
{validationResult.mermaid}
+ {/if} + {/if} + + +
+
+ {:else if panelView === "runs"} +
+
+
+

Runs

+ {runs.length} +
+
+ + + + +
+
+ running {runCounts.running || 0} + completed {runCounts.completed || 0} + failed {runCounts.failed || 0} + interrupted {runCounts.interrupted || 0} +
+
+ {#if runs.length === 0} +
No runs
+ {:else} + {#each runs as run} + + {/each} + {/if} +
+ {#if runsHasMore} + + {/if} +
+ +
+
+

Run Detail

+ {#if detailLoading}Loading{/if} +
+ {#if selectedRun} +
+
+ {runTitle(selectedRun)} + {selectedRun.id} +
+
+
Status{selectedRun.status || "-"}
+
Duration{formatDuration(selectedRun)}
+
Created{formatTime(selectedRun.created_at)}
+
Updated{formatTime(selectedRun.updated_at)}
+
+ {#if selectedRun.interrupt_message || selectedRun.error_text} +
{selectedRun.interrupt_message || selectedRun.error_text}
+ {/if} +
+ + + + Open Full Page + +
+
+
+ +
+
+ +
+
+
+ +
+
+ {:else} +
No run selected
+ {/if} +
+ +
+
+

Run Control

+ {#if selectedRunId}{selectedRunId}{/if} +
+ {#if selectedRun} +
+ + + + + +
+
+
+
Checkpoints
+ +
+
+ +
+
+ + {#if !checkpointOverridesValid} +
Invalid JSON
+ {/if} +
+ + +
+
+
+ {:else} +
Select a run to control it.
+ {/if} +
+
+ {:else if panelView === "workers"} +
+
+
+

Workers

+ {workers.length} +
+
+ {#if workers.length === 0} +
No workers registered
+ {:else} + {#each workers as worker} + + {/each} + {/if} +
+
+ + +
+
+ +
+
+

Worker Detail

+
+ {#if selectedWorker} +
+
+ {workerTitle(selectedWorker)} + {selectedWorker.url || "-"} +
+
+
Protocol{selectedWorker.protocol || "-"}
+
Status{selectedWorker.status || "-"}
+
Model{selectedWorker.model || "-"}
+
Failures{selectedWorker.consecutive_failures ?? 0}
+
+ {#if selectedWorker.last_error_text} +
{selectedWorker.last_error_text}
+ {/if} +
{jsonPreview(selectedWorker)}
+
+ {:else} +
No worker selected
+ {/if} +
+ +
+
+

Register Worker

+
+
+ + + + + + + + +
+
+
+ {:else} +
+
+
+

Tracker

+ {trackerTasks.length} +
+ {#if trackerStatus} +
+
Running{trackerStatus.running_count || trackerStats?.running || 0}
+
Completed{trackerStatus.completed_count || trackerStats?.completed || 0}
+
Failed{trackerStatus.failed_count || trackerStats?.failed || 0}
+
Max{trackerStatus.max_concurrent || trackerStats?.max_concurrent || 0}
+
+ + {:else} +
Tracker is not configured or not reachable.
+ {/if} +
+ {#if trackerTasks.length === 0} +
No running tracker tasks
+ {:else} + {#each trackerTasks as task} + + {/each} + {/if} +
+
+ +
+
+

Task Detail

+
+ {#if selectedTrackerTask} +
+
+ {selectedTrackerTask.task_title || trackerTaskId(selectedTrackerTask)} + {trackerTaskId(selectedTrackerTask)} +
+
+
Pipeline{selectedTrackerTask.pipeline_id || "-"}
+
Role{selectedTrackerTask.agent_role || "-"}
+
State{selectedTrackerTask.state || "-"}
+
Execution{selectedTrackerTask.execution || "-"}
+
+
{jsonPreview(selectedTrackerTask)}
+
+ {:else} +
No tracker task selected
+ {/if} +
+
+ {/if} + {/if} +
+ + diff --git a/ui/src/lib/components/NullTicketsPanel.svelte b/ui/src/lib/components/NullTicketsPanel.svelte new file mode 100644 index 0000000..e1ea6d9 --- /dev/null +++ b/ui/src/lib/components/NullTicketsPanel.svelte @@ -0,0 +1,1822 @@ + + +
+ {#if !running} +
Instance is stopped.
+ {:else} +
+
+ + + + + +
+ +
+ + {#if error} +
{error}
+ {/if} + {#if message} +
{message}
+ {/if} + + {#if panelView === "tasks"} +
+
+
+

Tasks

+ {tasks.length} +
+
+ + + + +
+ +
+ {#if tasks.length === 0} +
No tasks
+ {:else} + {#each tasks as task} + + {/each} + {/if} +
+ {#if nextCursor} + + {/if} +
+ +
+
+

Task Detail

+ {#if selectedTaskLoading}Loading{/if} +
+ {#if selectedTask} +
+
+ {taskTitle(selectedTask)} + {selectedTask.id} +
+
+
Stage{selectedTask.stage || "-"}
+
Pipeline{selectedTask.pipeline_id || "-"}
+
Priority{selectedTask.priority ?? 0}
+
Version{selectedTask.task_version ?? "-"}
+
+ {#if selectedRun} +
+
Run{runId(selectedRun)}
+
Status{selectedRun.status || "-"}
+
Agent{selectedRun.agent_id || "-"}
+
Attempt{selectedRun.attempt ?? "-"}
+
+ {/if} + {#if selectedTask.description} +

{selectedTask.description}

+ {/if} +
+
+ Assignments + {#if activeTaskAssignments.length > 0} +
+ {#each activeTaskAssignments as assignment} + + {/each} +
+ {:else} + None + {/if} +
+
+ Dependencies + {#if taskDependencies.length > 0} +
+ {#each taskDependencies as dep} + + {dep.depends_on_task_id || dep.task_id || dep} + + {/each} +
+ {:else} + None + {/if} +
+
+ {#if taskTransitions.length > 0} +
+ Transitions +
+ {#each taskTransitions as transition} + + {transition.trigger || "-"} -> {transition.to || transition.new_stage || "-"} + + {/each} +
+
+ {/if} +
{jsonPreview(selectedTask.metadata)}
+
+ + + + + + +
+
+ {:else} +
No task selected
+ {/if} +
+ +
+
+

Create Task

+
+
+ + + + + + + + +
+
+ + +
+
+
+ {:else if panelView === "pipelines"} +
+
+
+

Pipelines

+ {pipelines.length} +
+
+ {#each pipelines as pipeline} + + {/each} +
+
+ +
+
+

Definition

+
+ {#if selectedPipeline} +
+
+ {pipelineName(selectedPipeline)} + {pipelineId(selectedPipeline)} +
+
{jsonPreview(selectedPipeline.definition)}
+
+ {:else} +
No pipeline selected
+ {/if} +
+ +
+
+

Create Pipeline

+
+
+ + + +
+
+
+ {:else if panelView === "queue"} +
+
+
+

Queue

+ {queueRoles.length} +
+
+
+ Role + Claimable + Failed + Stuck + Oldest +
+ {#if queueRoles.length === 0} +
No queue stats
+ {:else} + {#each queueRoles as role} + + {/each} + {/if} +
+
+ +
+
+

Claim

+
+
+ + + + +
+ {#if claimed?.task} +
+ {taskTitle(claimed.task)} + {claimed.lease_id} +
+ {/if} +
+
+ {:else if panelView === "runs"} +
+
+
+

Run

+ {#if selectedRunId}{selectedRunId}{/if} +
+ {#if selectedRun} +
+
+
Status{selectedRun.status || "-"}
+
Task{selectedRun.task_id || selectedTaskId || "-"}
+
Agent{selectedRun.agent_id || "-"}
+
Role{selectedRun.agent_role || "-"}
+
Started{formatTime(selectedRun.started_at_ms)}
+
Ended{formatTime(selectedRun.ended_at_ms)}
+
+
+ + + +
+ {#if heartbeatExpiresAt} + Lease expires {formatTime(heartbeatExpiresAt)} + {/if} + {#if taskTransitions.length > 0} +
+ Available Transitions +
+ {#each taskTransitions as transition} + + {/each} +
+
+ {/if} +
+ + + + +
+
+ + + +
+
+ {:else} +
Select or claim a task with a run.
+ {/if} +
+ +
+
+

Events

+ {runEvents.length} +
+
+ + +
+
+ {#if runEvents.length === 0} +
No events
+ {:else} + {#each runEvents as event} +
+
+ {event.kind || "event"} + #{event.id ?? "-"} / {formatTime(event.ts_ms)} +
+
{jsonPreview(event.data)}
+
+ {/each} + {/if} +
+ {#if runEventsCursor} + + {/if} +
+ + + +
+
+
+ {:else} +
+
+
+

Artifacts

+ {artifacts.length} +
+
+
+ Scope +
+ + + +
+
+ {#if artifactScope === "custom"} + + + {/if} + + +
+
+ {#if artifacts.length === 0} +
No artifacts
+ {:else} + {#each artifacts as artifact} +
+
+ {artifact.kind || "artifact"} + {artifact.id || "-"} / {formatTime(artifact.created_at_ms)} +
+ {artifact.uri || "-"} + + task {artifact.task_id || "-"} / run {artifact.run_id || "-"} / {artifact.size_bytes ?? "-"} bytes + +
{jsonPreview(artifact.meta)}
+
+ {/each} + {/if} +
+ {#if artifactsCursor} + + {/if} +
+ +
+
+

Create Artifact

+ {artifactScopeLabel()} +
+
+ + + + + + +
+
+
+ {/if} + {/if} +
+ + diff --git a/ui/src/lib/components/Sidebar.svelte b/ui/src/lib/components/Sidebar.svelte index a9beadc..0bf74a9 100644 --- a/ui/src/lib/components/Sidebar.svelte +++ b/ui/src/lib/components/Sidebar.svelte @@ -2,12 +2,36 @@ import { page } from "$app/stores"; import { onMount } from "svelte"; import { api } from "$lib/api/client"; - import { orchestrationUiRoutes } from "$lib/orchestration/routes"; + import { orchestrationUiRoutes, routePath } from "$lib/orchestration/routes"; + import { + BOILER_INSTANCE_CHANGE_EVENT, + TICKETS_INSTANCE_CHANGE_EVENT, + } from "$lib/orchestration/backendSelection"; let instances = $state>({}); let installedComponents = $state>({}); let currentPath = $derived($page.url.pathname); - let showOrchestration = $derived(Boolean(installedComponents["nullboiler"]?.installed)); + let showBoilerOrchestration = $derived(Boolean(installedComponents["nullboiler"]?.installed)); + let showTicketsStore = $derived(Boolean(installedComponents["nulltickets"]?.installed)); + let showOrchestration = $derived(showBoilerOrchestration || showTicketsStore); + let boilerSelectionVersion = $state(0); + let ticketsSelectionVersion = $state(0); + let orchestrationDashboardHref = $derived.by(() => { + boilerSelectionVersion; + return orchestrationUiRoutes.dashboard(); + }); + let orchestrationWorkflowsHref = $derived.by(() => { + boilerSelectionVersion; + return orchestrationUiRoutes.workflows(); + }); + let orchestrationRunsHref = $derived.by(() => { + boilerSelectionVersion; + return orchestrationUiRoutes.runs(); + }); + let orchestrationStoreHref = $derived.by(() => { + ticketsSelectionVersion; + return orchestrationUiRoutes.store(); + }); async function loadSidebarState() { const [statusResult, componentsResult] = await Promise.allSettled([ @@ -29,7 +53,19 @@ onMount(() => { void loadSidebarState(); const interval = setInterval(loadSidebarState, 5000); - return () => clearInterval(interval); + const refreshBoilerLinks = () => { + boilerSelectionVersion += 1; + }; + const refreshTicketsLinks = () => { + ticketsSelectionVersion += 1; + }; + globalThis.addEventListener?.(BOILER_INSTANCE_CHANGE_EVENT, refreshBoilerLinks); + globalThis.addEventListener?.(TICKETS_INSTANCE_CHANGE_EVENT, refreshTicketsLinks); + return () => { + clearInterval(interval); + globalThis.removeEventListener?.(BOILER_INSTANCE_CHANGE_EVENT, refreshBoilerLinks); + globalThis.removeEventListener?.(TICKETS_INSTANCE_CHANGE_EVENT, refreshTicketsLinks); + }; }); @@ -68,10 +104,14 @@ {#if showOrchestration} {/if} diff --git a/ui/src/lib/components/WizardRenderer.svelte b/ui/src/lib/components/WizardRenderer.svelte index 7abf21b..2aa04fe 100644 --- a/ui/src/lib/components/WizardRenderer.svelte +++ b/ui/src/lib/components/WizardRenderer.svelte @@ -8,10 +8,12 @@ let { component = "", steps = [], + onVersionChange = (_version: string) => {}, onComplete, } = $props<{ component: string; steps: any[]; + onVersionChange?: (version: string) => void; onComplete?: () => void; }>(); @@ -23,6 +25,7 @@ let versions = $state([]); let selectedVersion = $state("latest"); let channels = $state>>>({}); + let showAdvanced = $state(false); const instanceNameId = "wizard-instance-name"; // Validation state @@ -71,16 +74,34 @@ versions = Array.isArray(data) ? data : []; if (versions.length > 0) { const rec = versions.find((v: any) => v.recommended); - selectedVersion = rec?.value || versions[0].value; + setSelectedVersion(rec?.value || versions[0].value); } }) .catch(() => { versions = [{ value: "latest", label: "latest", recommended: true }]; - selectedVersion = "latest"; + setSelectedVersion("latest"); }); } }); + function setSelectedVersion(version: string) { + const next = version || "latest"; + if (selectedVersion === next) return; + selectedVersion = next; + resetWizardInputs(); + onVersionChange(next); + } + + function resetWizardInputs() { + answers = {}; + channels = {}; + providerValidationResults = []; + channelValidationResults = []; + validationError = ""; + validationWarning = ""; + showAdvanced = false; + } + // Apply default values from steps $effect(() => { for (const step of steps) { @@ -94,8 +115,11 @@ }); $effect(() => { - if (component === "nullboiler" && (answers["tracker_instance"] || "").length > 0) { + if (component !== "nullboiler" || !("tracker_instance" in answers)) return; + if ((answers["tracker_instance"] || "").length > 0) { answers["tracker_enabled"] = "true"; + } else if (answers["tracker_enabled"] === "true") { + answers["tracker_enabled"] = "false"; } }); @@ -117,11 +141,16 @@ function isStepVisible(step: any): boolean { if (!step.condition) return true; const ref = answers[step.condition.step] || ""; - if (step.condition.equals) return ref === step.condition.equals; - if (step.condition.not_equals) return ref !== step.condition.not_equals; - if (step.condition.contains) + if (step.condition.equals !== undefined && step.condition.equals !== null) { + return ref === step.condition.equals; + } + if (step.condition.not_equals !== undefined && step.condition.not_equals !== null) { + return ref !== step.condition.not_equals; + } + if (step.condition.contains !== undefined && step.condition.contains !== null) { return ref.split(",").includes(step.condition.contains); - if (step.condition.not_in) { + } + if (step.condition.not_in !== undefined && step.condition.not_in !== null) { const excluded = step.condition.not_in.split(","); return !excluded.includes(ref); } @@ -152,8 +181,6 @@ ), ); - let showAdvanced = $state(false); - let providerStep = $derived(steps.find((s) => s.id === "provider")); let hasChannelsPage = $derived(component === "nullclaw"); let pageKinds = $derived( @@ -415,7 +442,11 @@ {#if versions.length > 0}
- setSelectedVersion(e.currentTarget.value)} + > {#each versions as v, i}