Skip to content

Commit 850d2f2

Browse files
committed
feat: extend nullboiler tracker install config
1 parent 319271f commit 850d2f2

2 files changed

Lines changed: 171 additions & 6 deletions

File tree

src/export_manifest.zig

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,18 @@ pub fn run() !void {
3737
\\ { "id": "tracker_claim_role", "title": "Claim Role", "description": "NullTickets stage role this workflow claims", "type": "text", "required": true, "default_value": "coder", "condition": { "step": "tracker_enabled", "equals": "true" }, "options": [] },
3838
\\ { "id": "tracker_agent_id", "title": "Agent ID", "description": "Stable worker identity in NullTickets", "type": "text", "required": false, "condition": { "step": "tracker_enabled", "equals": "true" }, "options": [] },
3939
\\ { "id": "tracker_success_trigger", "title": "Success Trigger", "description": "Transition trigger sent to NullTickets after a successful run", "type": "text", "required": true, "default_value": "complete", "condition": { "step": "tracker_enabled", "equals": "true" }, "options": [] },
40-
\\ { "id": "tracker_max_concurrent_tasks", "title": "Max Concurrent Tasks", "type": "number", "required": false, "default_value": "1", "condition": { "step": "tracker_enabled", "equals": "true" }, "options": [] }
40+
\\ { "id": "tracker_max_concurrent_tasks", "title": "Max Concurrent Tasks", "type": "number", "required": false, "default_value": "1", "condition": { "step": "tracker_enabled", "equals": "true" }, "options": [] },
41+
\\ { "id": "tracker_poll_interval_ms", "title": "Tracker Poll Interval", "description": "How often NullBoiler polls NullTickets for work", "type": "number", "required": false, "default_value": "10000", "condition": { "step": "tracker_enabled", "equals": "true" }, "options": [], "advanced": true },
42+
\\ { "id": "tracker_lease_ttl_ms", "title": "Lease TTL", "description": "Requested lease duration in milliseconds", "type": "number", "required": false, "default_value": "60000", "condition": { "step": "tracker_enabled", "equals": "true" }, "options": [], "advanced": true },
43+
\\ { "id": "tracker_heartbeat_interval_ms", "title": "Heartbeat Interval", "description": "Lease heartbeat interval in milliseconds", "type": "number", "required": false, "default_value": "30000", "condition": { "step": "tracker_enabled", "equals": "true" }, "options": [], "advanced": true },
44+
\\ { "id": "tracker_stall_timeout_ms", "title": "Stall Timeout", "description": "Fail execution if the subprocess stays idle longer than this", "type": "number", "required": false, "default_value": "300000", "condition": { "step": "tracker_enabled", "equals": "true" }, "options": [], "advanced": true },
45+
\\ { "id": "tracker_workspace_root", "title": "Workspace Root", "description": "Root directory for per-task workspaces", "type": "text", "required": false, "default_value": "workspaces", "condition": { "step": "tracker_enabled", "equals": "true" }, "options": [], "advanced": true },
46+
\\ { "id": "tracker_subprocess_command", "title": "Subprocess Command", "description": "Command used to spawn the task executor", "type": "text", "required": false, "default_value": "nullclaw", "condition": { "step": "tracker_enabled", "equals": "true" }, "options": [], "advanced": true },
47+
\\ { "id": "tracker_subprocess_base_port", "title": "Subprocess Base Port", "description": "First port reserved for spawned task subprocesses", "type": "number", "required": false, "default_value": "9200", "condition": { "step": "tracker_enabled", "equals": "true" }, "options": [], "advanced": true },
48+
\\ { "id": "tracker_subprocess_health_check_retries", "title": "Health Check Retries", "description": "Retries before marking a spawned executor unhealthy", "type": "number", "required": false, "default_value": "10", "condition": { "step": "tracker_enabled", "equals": "true" }, "options": [], "advanced": true },
49+
\\ { "id": "tracker_subprocess_max_turns", "title": "Subprocess Max Turns", "description": "Maximum interaction turns per claimed task", "type": "number", "required": false, "default_value": "20", "condition": { "step": "tracker_enabled", "equals": "true" }, "options": [], "advanced": true },
50+
\\ { "id": "tracker_subprocess_turn_timeout_ms", "title": "Turn Timeout", "description": "Max duration of one task turn in milliseconds", "type": "number", "required": false, "default_value": "600000", "condition": { "step": "tracker_enabled", "equals": "true" }, "options": [], "advanced": true },
51+
\\ { "id": "tracker_subprocess_continuation_prompt", "title": "Continuation Prompt", "description": "Prompt sent for follow-up turns after the first task prompt", "type": "text", "required": false, "default_value": "Continue working on this task. Your previous context is preserved.", "condition": { "step": "tracker_enabled", "equals": "true" }, "options": [], "advanced": true }
4152
\\ ] },
4253
\\ "depends_on": [],
4354
\\ "connects_to": [

src/from_json.zig

Lines changed: 159 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
const std = @import("std");
2+
const builtin = @import("builtin");
23

34
pub fn run(allocator: std.mem.Allocator, args: []const []const u8) !void {
45
if (args.len == 0) {
@@ -33,6 +34,17 @@ pub fn run(allocator: std.mem.Allocator, args: []const []const u8) !void {
3334
const tracker_agent_id = getString(obj, "tracker_agent_id") orelse getString(obj, "instance_name") orelse "nullboiler";
3435
const tracker_success_trigger = getString(obj, "tracker_success_trigger") orelse "complete";
3536
const tracker_max_concurrent_tasks = getU32(obj, "tracker_max_concurrent_tasks") orelse 1;
37+
const tracker_poll_interval_ms = getU32(obj, "tracker_poll_interval_ms") orelse 10000;
38+
const tracker_lease_ttl_ms = getU32(obj, "tracker_lease_ttl_ms") orelse 60000;
39+
const tracker_heartbeat_interval_ms = getU32(obj, "tracker_heartbeat_interval_ms") orelse 30000;
40+
const tracker_stall_timeout_ms = getU32(obj, "tracker_stall_timeout_ms") orelse 300000;
41+
const tracker_workspace_root = getString(obj, "tracker_workspace_root") orelse "workspaces";
42+
const tracker_subprocess_command = getString(obj, "tracker_subprocess_command") orelse "nullclaw";
43+
const tracker_subprocess_base_port = getU16(obj, "tracker_subprocess_base_port") orelse 9200;
44+
const tracker_subprocess_health_check_retries = getU32(obj, "tracker_subprocess_health_check_retries") orelse 10;
45+
const tracker_subprocess_max_turns = getU32(obj, "tracker_subprocess_max_turns") orelse 20;
46+
const tracker_subprocess_turn_timeout_ms = getU32(obj, "tracker_subprocess_turn_timeout_ms") orelse 600000;
47+
const tracker_subprocess_continuation_prompt = getString(obj, "tracker_subprocess_continuation_prompt") orelse "Continue working on this task. Your previous context is preserved.";
3648
const workflows_dir = "workflows";
3749

3850
if (tracker_enabled and tracker_url != null and tracker_pipeline_id == null) {
@@ -52,10 +64,22 @@ pub fn run(allocator: std.mem.Allocator, args: []const []const u8) !void {
5264
.concurrency = .{
5365
.max_concurrent_tasks = tracker_max_concurrent_tasks,
5466
},
55-
.poll_interval_ms = 5000,
56-
.lease_ttl_ms = 120000,
57-
.heartbeat_interval_ms = 30000,
67+
.poll_interval_ms = tracker_poll_interval_ms,
68+
.stall_timeout_ms = tracker_stall_timeout_ms,
69+
.lease_ttl_ms = tracker_lease_ttl_ms,
70+
.heartbeat_interval_ms = tracker_heartbeat_interval_ms,
5871
.workflows_dir = workflows_dir,
72+
.workspace = .{
73+
.root = tracker_workspace_root,
74+
},
75+
.subprocess = .{
76+
.command = tracker_subprocess_command,
77+
.base_port = tracker_subprocess_base_port,
78+
.health_check_retries = tracker_subprocess_health_check_retries,
79+
.max_turns = tracker_subprocess_max_turns,
80+
.turn_timeout_ms = tracker_subprocess_turn_timeout_ms,
81+
.continuation_prompt = tracker_subprocess_continuation_prompt,
82+
},
5983
},
6084
}, .{ .whitespace = .indent_2, .emit_null_optional_fields = false })
6185
else
@@ -79,8 +103,10 @@ pub fn run(allocator: std.mem.Allocator, args: []const []const u8) !void {
79103
try writeFileAtHome(allocator, home, workflow_rel_path, workflow_json);
80104
}
81105

82-
const stdout = std.fs.File.stdout();
83-
try stdout.writeAll("{\"status\":\"ok\"}\n");
106+
if (!builtin.is_test) {
107+
const stdout = std.fs.File.stdout();
108+
try stdout.writeAll("{\"status\":\"ok\"}\n");
109+
}
84110
}
85111

86112
fn buildDefaultWorkflow(
@@ -203,3 +229,131 @@ fn sanitizeFileComponent(allocator: std.mem.Allocator, value: []const u8) ![]con
203229
}
204230
return out;
205231
}
232+
233+
test "run writes tracker config and workflow with advanced settings" {
234+
var tmp = std.testing.tmpDir(.{});
235+
defer tmp.cleanup();
236+
237+
const home = try tmp.dir.realpathAlloc(std.testing.allocator, ".");
238+
defer std.testing.allocator.free(home);
239+
240+
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
241+
defer arena.deinit();
242+
243+
const input = try std.json.Stringify.valueAlloc(arena.allocator(), .{
244+
.home = home,
245+
.port = 9091,
246+
.db_path = "tracker.db",
247+
.api_token = "local-token",
248+
.instance_name = "boiler-a",
249+
.tracker_enabled = "true",
250+
.tracker_url = "http://127.0.0.1:7700",
251+
.tracker_api_token = "tracker-secret",
252+
.tracker_pipeline_id = "pipeline.dev",
253+
.tracker_claim_role = "reviewer",
254+
.tracker_success_trigger = "ship",
255+
.tracker_max_concurrent_tasks = "2",
256+
.tracker_poll_interval_ms = "15000",
257+
.tracker_lease_ttl_ms = "90000",
258+
.tracker_heartbeat_interval_ms = "45000",
259+
.tracker_stall_timeout_ms = "123000",
260+
.tracker_workspace_root = "workspaces",
261+
.tracker_subprocess_command = "nullclaw",
262+
.tracker_subprocess_base_port = "9300",
263+
.tracker_subprocess_health_check_retries = "7",
264+
.tracker_subprocess_max_turns = "11",
265+
.tracker_subprocess_turn_timeout_ms = "700000",
266+
.tracker_subprocess_continuation_prompt = "Keep going.",
267+
}, .{});
268+
269+
try run(arena.allocator(), &.{input});
270+
271+
const config_path = try std.fs.path.join(std.testing.allocator, &.{ home, "config.json" });
272+
defer std.testing.allocator.free(config_path);
273+
const config_file = try std.fs.openFileAbsolute(config_path, .{});
274+
defer config_file.close();
275+
const config_bytes = try config_file.readToEndAlloc(arena.allocator(), 64 * 1024);
276+
277+
const ConfigFile = struct {
278+
port: u16,
279+
db: []const u8,
280+
api_token: ?[]const u8 = null,
281+
tracker: struct {
282+
url: []const u8,
283+
api_token: ?[]const u8 = null,
284+
agent_id: []const u8,
285+
poll_interval_ms: u32,
286+
stall_timeout_ms: u32,
287+
lease_ttl_ms: u32,
288+
heartbeat_interval_ms: u32,
289+
workflows_dir: []const u8,
290+
concurrency: struct {
291+
max_concurrent_tasks: u32,
292+
},
293+
workspace: struct {
294+
root: []const u8,
295+
},
296+
subprocess: struct {
297+
command: []const u8,
298+
base_port: u16,
299+
health_check_retries: u32,
300+
max_turns: u32,
301+
turn_timeout_ms: u32,
302+
continuation_prompt: []const u8,
303+
},
304+
},
305+
};
306+
307+
const parsed_cfg = try std.json.parseFromSlice(ConfigFile, arena.allocator(), config_bytes, .{
308+
.allocate = .alloc_always,
309+
.ignore_unknown_fields = true,
310+
});
311+
defer parsed_cfg.deinit();
312+
313+
try std.testing.expectEqual(@as(u16, 9091), parsed_cfg.value.port);
314+
try std.testing.expectEqualStrings("tracker.db", parsed_cfg.value.db);
315+
try std.testing.expectEqualStrings("local-token", parsed_cfg.value.api_token.?);
316+
try std.testing.expectEqualStrings("http://127.0.0.1:7700", parsed_cfg.value.tracker.url);
317+
try std.testing.expectEqualStrings("tracker-secret", parsed_cfg.value.tracker.api_token.?);
318+
try std.testing.expectEqualStrings("boiler-a", parsed_cfg.value.tracker.agent_id);
319+
try std.testing.expectEqual(@as(u32, 2), parsed_cfg.value.tracker.concurrency.max_concurrent_tasks);
320+
try std.testing.expectEqual(@as(u32, 15000), parsed_cfg.value.tracker.poll_interval_ms);
321+
try std.testing.expectEqual(@as(u32, 123000), parsed_cfg.value.tracker.stall_timeout_ms);
322+
try std.testing.expectEqual(@as(u32, 90000), parsed_cfg.value.tracker.lease_ttl_ms);
323+
try std.testing.expectEqual(@as(u32, 45000), parsed_cfg.value.tracker.heartbeat_interval_ms);
324+
try std.testing.expectEqualStrings("workflows", parsed_cfg.value.tracker.workflows_dir);
325+
try std.testing.expectEqualStrings("workspaces", parsed_cfg.value.tracker.workspace.root);
326+
try std.testing.expectEqualStrings("nullclaw", parsed_cfg.value.tracker.subprocess.command);
327+
try std.testing.expectEqual(@as(u16, 9300), parsed_cfg.value.tracker.subprocess.base_port);
328+
try std.testing.expectEqual(@as(u32, 7), parsed_cfg.value.tracker.subprocess.health_check_retries);
329+
try std.testing.expectEqual(@as(u32, 11), parsed_cfg.value.tracker.subprocess.max_turns);
330+
try std.testing.expectEqual(@as(u32, 700000), parsed_cfg.value.tracker.subprocess.turn_timeout_ms);
331+
try std.testing.expectEqualStrings("Keep going.", parsed_cfg.value.tracker.subprocess.continuation_prompt);
332+
333+
const workflow_path = try std.fs.path.join(std.testing.allocator, &.{ home, "workflows", "pipeline.dev.json" });
334+
defer std.testing.allocator.free(workflow_path);
335+
const workflow_file = try std.fs.openFileAbsolute(workflow_path, .{});
336+
defer workflow_file.close();
337+
const workflow_bytes = try workflow_file.readToEndAlloc(arena.allocator(), 16 * 1024);
338+
339+
const WorkflowFile = struct {
340+
pipeline_id: []const u8,
341+
claim_roles: []const []const u8,
342+
execution: []const u8,
343+
on_success: struct {
344+
transition_to: []const u8,
345+
},
346+
};
347+
348+
const parsed_workflow = try std.json.parseFromSlice(WorkflowFile, arena.allocator(), workflow_bytes, .{
349+
.allocate = .alloc_always,
350+
.ignore_unknown_fields = true,
351+
});
352+
defer parsed_workflow.deinit();
353+
354+
try std.testing.expectEqualStrings("pipeline.dev", parsed_workflow.value.pipeline_id);
355+
try std.testing.expectEqual(@as(usize, 1), parsed_workflow.value.claim_roles.len);
356+
try std.testing.expectEqualStrings("reviewer", parsed_workflow.value.claim_roles[0]);
357+
try std.testing.expectEqualStrings("subprocess", parsed_workflow.value.execution);
358+
try std.testing.expectEqualStrings("ship", parsed_workflow.value.on_success.transition_to);
359+
}

0 commit comments

Comments
 (0)