Skip to content

Commit 4783573

Browse files
authored
Merge pull request #10 from nullclaw/feat/orchestration
Feat/orchestration
2 parents e1b7a69 + 175a045 commit 4783573

9 files changed

Lines changed: 537 additions & 64 deletions

File tree

src/api/providers.zig

Lines changed: 36 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -105,14 +105,10 @@ pub fn handleCreate(
105105
.validated_with = component_name,
106106
});
107107

108-
// Update validated_at on the just-added provider
108+
// Record both the last successful validation and the latest validation attempt.
109109
const providers = state.savedProviders();
110110
const new_id = providers[providers.len - 1].id;
111-
const now = try nowIso8601(allocator);
112-
defer allocator.free(now);
113-
_ = try state.updateSavedProvider(new_id, .{ .validated_at = now });
114-
115-
try state.save();
111+
try persistValidationAttempt(allocator, state, new_id, component_name, true);
116112

117113
// Return the saved provider
118114
const sp = state.getSavedProvider(new_id).?;
@@ -162,7 +158,14 @@ pub fn handleUpdate(
162158

163159
const probe_result = probeProvider(allocator, component_name, bin_path, existing.provider, effective_key, effective_model, "");
164160
defer probe_result.deinit(allocator);
161+
const now = try nowIso8601(allocator);
162+
defer allocator.free(now);
165163
if (!probe_result.live_ok) {
164+
_ = try state.updateSavedProvider(id, .{
165+
.last_validation_at = now,
166+
.last_validation_ok = false,
167+
});
168+
try state.save();
166169
var buf = std.array_list.Managed(u8).init(allocator);
167170
errdefer buf.deinit();
168171
try buf.appendSlice("{\"error\":\"Provider validation failed: ");
@@ -171,15 +174,14 @@ pub fn handleUpdate(
171174
return buf.toOwnedSlice();
172175
}
173176

174-
const now = nowIso8601(allocator) catch "";
175-
defer if (now.len > 0) allocator.free(now);
176-
177177
_ = try state.updateSavedProvider(id, .{
178178
.name = parsed.value.name,
179179
.api_key = parsed.value.api_key,
180180
.model = parsed.value.model,
181181
.validated_at = now,
182182
.validated_with = component_name,
183+
.last_validation_at = now,
184+
.last_validation_ok = true,
183185
});
184186
} else {
185187
// Name-only update
@@ -224,12 +226,7 @@ pub fn handleValidate(
224226
const probe_result = probeProvider(allocator, component_name, bin_path, existing.provider, existing.api_key, existing.model, "");
225227
defer probe_result.deinit(allocator);
226228

227-
if (probe_result.live_ok) {
228-
const now = try nowIso8601(allocator);
229-
defer allocator.free(now);
230-
_ = try state.updateSavedProvider(id, .{ .validated_at = now, .validated_with = component_name });
231-
try state.save();
232-
}
229+
try persistValidationAttempt(allocator, state, id, component_name, probe_result.live_ok);
233230

234231
var buf = std.array_list.Managed(u8).init(allocator);
235232
errdefer buf.deinit();
@@ -289,6 +286,25 @@ fn maskApiKey(buf: *std.array_list.Managed(u8), key: []const u8) !void {
289286
}
290287
}
291288

289+
fn persistValidationAttempt(
290+
allocator: std.mem.Allocator,
291+
state: *state_mod.State,
292+
id: u32,
293+
component_name: []const u8,
294+
live_ok: bool,
295+
) !void {
296+
const now = try nowIso8601(allocator);
297+
defer allocator.free(now);
298+
299+
_ = try state.updateSavedProvider(id, .{
300+
.validated_at = if (live_ok) now else null,
301+
.validated_with = if (live_ok) component_name else null,
302+
.last_validation_at = now,
303+
.last_validation_ok = live_ok,
304+
});
305+
try state.save();
306+
}
307+
292308
fn appendProviderJson(buf: *std.array_list.Managed(u8), sp: state_mod.SavedProvider, reveal: bool) !void {
293309
try buf.appendSlice("{\"id\":\"sp_");
294310
var id_buf: [16]u8 = undefined;
@@ -311,7 +327,11 @@ fn appendProviderJson(buf: *std.array_list.Managed(u8), sp: state_mod.SavedProvi
311327
try appendEscaped(buf, sp.validated_at);
312328
try buf.appendSlice("\",\"validated_with\":\"");
313329
try appendEscaped(buf, sp.validated_with);
314-
try buf.appendSlice("\"}");
330+
try buf.appendSlice("\",\"last_validation_at\":\"");
331+
try appendEscaped(buf, sp.last_validation_at);
332+
try buf.appendSlice("\",\"last_validation_ok\":");
333+
try buf.appendSlice(if (sp.last_validation_ok) "true" else "false");
334+
try buf.appendSlice("}");
315335
}
316336

317337
pub fn nowIso8601(allocator: std.mem.Allocator) ![]const u8 {

src/api/wizard.zig

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -661,7 +661,23 @@ pub fn handleValidateProviders(
661661
var did_save = false;
662662
for (parsed.value.providers, 0..) |prov, idx| {
663663
if (idx < probe_results.items.len and probe_results.items[idx].live_ok) {
664-
if (!state.hasSavedProvider(prov.provider, prov.api_key, prov.model)) {
664+
const now = providers_api.nowIso8601(allocator) catch "";
665+
defer if (now.len > 0) allocator.free(now);
666+
667+
if (state.findSavedProviderId(prov.provider, prov.api_key, prov.model)) |existing_id| {
668+
if (now.len > 0) {
669+
_ = state.updateSavedProvider(existing_id, .{
670+
.validated_at = now,
671+
.validated_with = component_name,
672+
.last_validation_at = now,
673+
.last_validation_ok = true,
674+
}) catch {
675+
saved_providers_warning = "validated providers could not be fully saved";
676+
continue;
677+
};
678+
did_save = true;
679+
}
680+
} else {
665681
state.addSavedProvider(.{
666682
.provider = prov.provider,
667683
.api_key = prov.api_key,
@@ -671,18 +687,20 @@ pub fn handleValidateProviders(
671687
saved_providers_warning = "validated providers could not be saved";
672688
continue;
673689
};
674-
// Set validated_at on the just-added provider
690+
// Set both the last successful validation and the latest validation attempt.
675691
const providers_list = state.savedProviders();
676692
if (providers_list.len > 0) {
677693
const new_id = providers_list[providers_list.len - 1].id;
678-
const now = providers_api.nowIso8601(allocator) catch "";
679694
if (now.len > 0) {
680-
_ = state.updateSavedProvider(new_id, .{ .validated_at = now }) catch {
695+
_ = state.updateSavedProvider(new_id, .{
696+
.validated_at = now,
697+
.validated_with = component_name,
698+
.last_validation_at = now,
699+
.last_validation_ok = true,
700+
}) catch {
681701
saved_providers_warning = "validated providers could not be fully saved";
682-
allocator.free(now);
683702
continue;
684703
};
685-
allocator.free(now);
686704
}
687705
}
688706
did_save = true;

src/api_cli.zig

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
const std = @import("std");
2+
const cli = @import("cli.zig");
3+
4+
pub const ExecuteError = error{
5+
InvalidMethod,
6+
InvalidTarget,
7+
};
8+
9+
pub const Result = struct {
10+
status: std.http.Status,
11+
body: []u8,
12+
13+
pub fn deinit(self: *Result, allocator: std.mem.Allocator) void {
14+
allocator.free(self.body);
15+
self.* = undefined;
16+
}
17+
};
18+
19+
pub fn run(allocator: std.mem.Allocator, opts: cli.ApiOptions) !void {
20+
var result = try execute(allocator, opts);
21+
defer result.deinit(allocator);
22+
23+
const formatted = if (opts.pretty)
24+
try prettyBody(allocator, result.body)
25+
else
26+
try allocator.dupe(u8, result.body);
27+
defer allocator.free(formatted);
28+
29+
if (formatted.len > 0) {
30+
try writeAll(std.fs.File.stdout(), formatted);
31+
if (formatted[formatted.len - 1] != '\n') {
32+
try writeAll(std.fs.File.stdout(), "\n");
33+
}
34+
}
35+
36+
const code = @intFromEnum(result.status);
37+
if (code < 200 or code >= 300) {
38+
var buf: [64]u8 = undefined;
39+
const line = try std.fmt.bufPrint(&buf, "HTTP {d}\n", .{code});
40+
try writeAll(std.fs.File.stderr(), line);
41+
return error.RequestFailed;
42+
}
43+
}
44+
45+
pub fn execute(allocator: std.mem.Allocator, opts: cli.ApiOptions) !Result {
46+
const method = parseMethod(opts.method) orelse return ExecuteError.InvalidMethod;
47+
const target = try normalizeTargetAlloc(allocator, opts.target);
48+
defer allocator.free(target);
49+
50+
const url = try std.fmt.allocPrint(allocator, "http://{s}:{d}{s}", .{ opts.host, opts.port, target });
51+
defer allocator.free(url);
52+
53+
const request_body = try loadBodyAlloc(allocator, opts);
54+
defer if (request_body.owned) allocator.free(request_body.bytes);
55+
56+
var auth_header: ?[]u8 = null;
57+
defer if (auth_header) |value| allocator.free(value);
58+
59+
var header_storage: [2]std.http.Header = undefined;
60+
var header_count: usize = 0;
61+
if (request_body.bytes.len > 0) {
62+
header_storage[header_count] = .{ .name = "Content-Type", .value = opts.content_type };
63+
header_count += 1;
64+
}
65+
if (opts.token) |token| {
66+
auth_header = try std.fmt.allocPrint(allocator, "Bearer {s}", .{token});
67+
header_storage[header_count] = .{ .name = "Authorization", .value = auth_header.? };
68+
header_count += 1;
69+
}
70+
71+
var client: std.http.Client = .{ .allocator = allocator };
72+
defer client.deinit();
73+
74+
var response_body: std.io.Writer.Allocating = .init(allocator);
75+
defer response_body.deinit();
76+
77+
const result = try client.fetch(.{
78+
.location = .{ .url = url },
79+
.method = method,
80+
.payload = payloadForFetch(method, request_body.bytes),
81+
.response_writer = &response_body.writer,
82+
.extra_headers = header_storage[0..header_count],
83+
});
84+
85+
return .{
86+
.status = result.status,
87+
.body = try response_body.toOwnedSlice(),
88+
};
89+
}
90+
91+
fn writeAll(file: std.fs.File, bytes: []const u8) !void {
92+
var buf: [4096]u8 = undefined;
93+
var writer = file.writer(&buf);
94+
try writer.interface.writeAll(bytes);
95+
try writer.interface.flush();
96+
}
97+
98+
fn parseMethod(raw: []const u8) ?std.http.Method {
99+
if (std.ascii.eqlIgnoreCase(raw, "GET")) return .GET;
100+
if (std.ascii.eqlIgnoreCase(raw, "POST")) return .POST;
101+
if (std.ascii.eqlIgnoreCase(raw, "PUT")) return .PUT;
102+
if (std.ascii.eqlIgnoreCase(raw, "DELETE")) return .DELETE;
103+
if (std.ascii.eqlIgnoreCase(raw, "PATCH")) return .PATCH;
104+
if (std.ascii.eqlIgnoreCase(raw, "HEAD")) return .HEAD;
105+
if (std.ascii.eqlIgnoreCase(raw, "OPTIONS")) return .OPTIONS;
106+
return null;
107+
}
108+
109+
fn normalizeTargetAlloc(allocator: std.mem.Allocator, raw: []const u8) ![]u8 {
110+
if (raw.len == 0) return ExecuteError.InvalidTarget;
111+
if (std.mem.startsWith(u8, raw, "http://") or std.mem.startsWith(u8, raw, "https://")) {
112+
return ExecuteError.InvalidTarget;
113+
}
114+
if (raw[0] == '/') return allocator.dupe(u8, raw);
115+
if (std.mem.startsWith(u8, raw, "api/")) return std.fmt.allocPrint(allocator, "/{s}", .{raw});
116+
if (std.mem.eql(u8, raw, "health")) return allocator.dupe(u8, "/health");
117+
return std.fmt.allocPrint(allocator, "/api/{s}", .{raw});
118+
}
119+
120+
const LoadedBody = struct {
121+
bytes: []const u8,
122+
owned: bool = false,
123+
};
124+
125+
fn loadBodyAlloc(allocator: std.mem.Allocator, opts: cli.ApiOptions) !LoadedBody {
126+
if (opts.body_file) |path| {
127+
if (std.mem.eql(u8, path, "-")) {
128+
const bytes = try std.fs.File.stdin().readToEndAlloc(allocator, 8 * 1024 * 1024);
129+
return .{ .bytes = bytes, .owned = true };
130+
}
131+
const file = try std.fs.cwd().openFile(path, .{});
132+
defer file.close();
133+
const bytes = try file.readToEndAlloc(allocator, 8 * 1024 * 1024);
134+
return .{ .bytes = bytes, .owned = true };
135+
}
136+
if (opts.body) |body| return .{ .bytes = body, .owned = false };
137+
return .{ .bytes = "", .owned = false };
138+
}
139+
140+
fn payloadForFetch(method: std.http.Method, body: []const u8) ?[]const u8 {
141+
if (body.len > 0) return body;
142+
if (method.requestHasBody()) return body;
143+
return null;
144+
}
145+
146+
fn prettyBody(allocator: std.mem.Allocator, body: []const u8) ![]u8 {
147+
if (body.len == 0) return allocator.dupe(u8, body);
148+
149+
const parsed = std.json.parseFromSlice(std.json.Value, allocator, body, .{
150+
.allocate = .alloc_always,
151+
.ignore_unknown_fields = true,
152+
}) catch return allocator.dupe(u8, body);
153+
defer parsed.deinit();
154+
155+
return std.json.Stringify.valueAlloc(allocator, parsed.value, .{
156+
.whitespace = .indent_2,
157+
});
158+
}
159+
160+
test "normalizeTargetAlloc keeps explicit API path" {
161+
const value = try normalizeTargetAlloc(std.testing.allocator, "/api/status");
162+
defer std.testing.allocator.free(value);
163+
try std.testing.expectEqualStrings("/api/status", value);
164+
}
165+
166+
test "normalizeTargetAlloc prefixes api namespace" {
167+
const value = try normalizeTargetAlloc(std.testing.allocator, "instances/nullclaw/demo");
168+
defer std.testing.allocator.free(value);
169+
try std.testing.expectEqualStrings("/api/instances/nullclaw/demo", value);
170+
}
171+
172+
test "normalizeTargetAlloc supports health shorthand" {
173+
const value = try normalizeTargetAlloc(std.testing.allocator, "health");
174+
defer std.testing.allocator.free(value);
175+
try std.testing.expectEqualStrings("/health", value);
176+
}
177+
178+
test "parseMethod accepts common verbs case-insensitively" {
179+
try std.testing.expectEqual(std.http.Method.DELETE, parseMethod("delete").?);
180+
try std.testing.expectEqual(std.http.Method.PATCH, parseMethod("PATCH").?);
181+
try std.testing.expect(parseMethod("TRACE") == null);
182+
}
183+
184+
test "prettyBody indents JSON output" {
185+
const value = try prettyBody(std.testing.allocator, "{\"ok\":true}");
186+
defer std.testing.allocator.free(value);
187+
try std.testing.expect(std.mem.indexOf(u8, value, "\n") != null);
188+
try std.testing.expect(std.mem.indexOf(u8, value, " \"ok\"") != null);
189+
}
190+
191+
test "payloadForFetch keeps empty body for POST" {
192+
try std.testing.expect(payloadForFetch(.POST, "") != null);
193+
try std.testing.expect(payloadForFetch(.GET, "") == null);
194+
}

0 commit comments

Comments
 (0)