Skip to content

Commit 6bab84c

Browse files
committed
fix(onboarding): detect sqlite bootstrap from memory
1 parent 7a1e391 commit 6bab84c

File tree

1 file changed

+194
-33
lines changed

1 file changed

+194
-33
lines changed

src/api/instances.zig

Lines changed: 194 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,24 @@ const NullclawOnboardingStatus = struct {
119119
}
120120
};
121121

122+
const NullclawBootstrapMemoryProbe = struct {
123+
exists: bool = false,
124+
timestamp: ?[]u8 = null,
125+
126+
fn deinit(self: *NullclawBootstrapMemoryProbe, allocator: std.mem.Allocator) void {
127+
if (self.timestamp) |value| allocator.free(value);
128+
self.* = .{};
129+
}
130+
131+
fn takeTimestamp(self: *NullclawBootstrapMemoryProbe) ?[]u8 {
132+
const value = self.timestamp;
133+
self.timestamp = null;
134+
return value;
135+
}
136+
};
137+
138+
const nullclaw_bootstrap_memory_key = "__bootstrap.prompt.BOOTSTRAP.md";
139+
122140
fn fileExistsAbsolute(path: []const u8) bool {
123141
std.fs.accessAbsolute(path, .{}) catch return false;
124142
return true;
@@ -128,8 +146,65 @@ fn nullclawWorkspaceStatePath(allocator: std.mem.Allocator, workspace_dir: []con
128146
return std.fs.path.join(allocator, &.{ workspace_dir, ".nullclaw", "workspace-state.json" });
129147
}
130148

149+
fn probeNullclawBootstrapInMemory(
150+
allocator: std.mem.Allocator,
151+
s: *state_mod.State,
152+
paths: paths_mod.Paths,
153+
component: []const u8,
154+
name: []const u8,
155+
) NullclawBootstrapMemoryProbe {
156+
const entry = s.getInstance(component, name) orelse return .{};
157+
158+
const bin_path = paths.binary(allocator, component, entry.version) catch return .{};
159+
defer allocator.free(bin_path);
160+
std.fs.accessAbsolute(bin_path, .{}) catch return .{};
161+
162+
const inst_dir = paths.instanceDir(allocator, component, name) catch return .{};
163+
defer allocator.free(inst_dir);
164+
165+
const args = [_][]const u8{
166+
"memory",
167+
"get",
168+
nullclaw_bootstrap_memory_key,
169+
"--json",
170+
};
171+
const result = component_cli.runWithComponentHome(
172+
allocator,
173+
component,
174+
bin_path,
175+
&args,
176+
null,
177+
inst_dir,
178+
) catch return .{};
179+
defer allocator.free(result.stdout);
180+
defer allocator.free(result.stderr);
181+
182+
if (!result.success or !isLikelyJsonPayload(result.stdout)) return .{};
183+
184+
const parsed = std.json.parseFromSlice(std.json.Value, allocator, result.stdout, .{
185+
.allocate = .alloc_if_needed,
186+
.ignore_unknown_fields = true,
187+
}) catch return .{};
188+
defer parsed.deinit();
189+
190+
switch (parsed.value) {
191+
.null => return .{},
192+
.object => |obj| {
193+
var probe = NullclawBootstrapMemoryProbe{ .exists = true };
194+
if (obj.get("timestamp")) |timestamp_value| {
195+
if (timestamp_value == .string and timestamp_value.string.len > 0) {
196+
probe.timestamp = allocator.dupe(u8, timestamp_value.string) catch null;
197+
}
198+
}
199+
return probe;
200+
},
201+
else => return .{},
202+
}
203+
}
204+
131205
fn readNullclawOnboardingStatus(
132206
allocator: std.mem.Allocator,
207+
s: *state_mod.State,
133208
paths: paths_mod.Paths,
134209
component: []const u8,
135210
name: []const u8,
@@ -152,44 +227,48 @@ fn readNullclawOnboardingStatus(
152227
const state_path = try nullclawWorkspaceStatePath(allocator, workspace_dir);
153228
defer allocator.free(state_path);
154229

155-
const file = std.fs.openFileAbsolute(state_path, .{}) catch |err| switch (err) {
156-
error.FileNotFound => {
157-
status.pending = status.bootstrap_exists;
158-
return status;
159-
},
230+
const state_file = std.fs.openFileAbsolute(state_path, .{}) catch |err| switch (err) {
231+
error.FileNotFound => null,
160232
else => return err,
161233
};
162-
defer file.close();
163-
164-
const raw = file.readToEndAlloc(allocator, 64 * 1024) catch {
165-
status.pending = status.bootstrap_exists;
166-
return status;
167-
};
168-
defer allocator.free(raw);
234+
if (state_file) |file| {
235+
defer file.close();
236+
const raw = file.readToEndAlloc(allocator, 64 * 1024) catch null;
237+
if (raw) |state_raw| {
238+
defer allocator.free(state_raw);
239+
const parsed = std.json.parseFromSlice(struct {
240+
bootstrap_seeded_at: ?[]const u8 = null,
241+
bootstrapSeededAt: ?[]const u8 = null,
242+
onboarding_completed_at: ?[]const u8 = null,
243+
onboardingCompletedAt: ?[]const u8 = null,
244+
}, allocator, state_raw, .{
245+
.allocate = .alloc_if_needed,
246+
.ignore_unknown_fields = true,
247+
}) catch null;
248+
if (parsed) |state_parsed| {
249+
defer state_parsed.deinit();
250+
if (state_parsed.value.bootstrap_seeded_at orelse state_parsed.value.bootstrapSeededAt) |seeded| {
251+
status.bootstrap_seeded_at = try allocator.dupe(u8, seeded);
252+
}
253+
if (state_parsed.value.onboarding_completed_at orelse state_parsed.value.onboardingCompletedAt) |completed| {
254+
status.onboarding_completed_at = try allocator.dupe(u8, completed);
255+
}
256+
}
257+
}
258+
}
169259

170-
const parsed = std.json.parseFromSlice(struct {
171-
bootstrap_seeded_at: ?[]const u8 = null,
172-
bootstrapSeededAt: ?[]const u8 = null,
173-
onboarding_completed_at: ?[]const u8 = null,
174-
onboardingCompletedAt: ?[]const u8 = null,
175-
}, allocator, raw, .{
176-
.allocate = .alloc_if_needed,
177-
.ignore_unknown_fields = true,
178-
}) catch {
179-
status.pending = status.bootstrap_exists;
180-
return status;
181-
};
182-
defer parsed.deinit();
260+
status.completed = status.onboarding_completed_at != null and !status.bootstrap_exists;
183261

184-
if (parsed.value.bootstrap_seeded_at orelse parsed.value.bootstrapSeededAt) |seeded| {
185-
status.bootstrap_seeded_at = try allocator.dupe(u8, seeded);
186-
}
187-
if (parsed.value.onboarding_completed_at orelse parsed.value.onboardingCompletedAt) |completed| {
188-
status.onboarding_completed_at = try allocator.dupe(u8, completed);
262+
var bootstrap_probe = NullclawBootstrapMemoryProbe{};
263+
if (!status.completed and !status.bootstrap_exists) {
264+
bootstrap_probe = probeNullclawBootstrapInMemory(allocator, s, paths, component, name);
265+
if (status.bootstrap_seeded_at == null) {
266+
status.bootstrap_seeded_at = bootstrap_probe.takeTimestamp();
267+
}
189268
}
269+
defer bootstrap_probe.deinit(allocator);
190270

191-
status.completed = status.onboarding_completed_at != null and !status.bootstrap_exists;
192-
status.pending = status.bootstrap_exists or (status.bootstrap_seeded_at != null and !status.completed);
271+
status.pending = !status.completed and (status.bootstrap_exists or status.bootstrap_seeded_at != null or bootstrap_probe.exists);
193272
return status;
194273
}
195274

@@ -1829,7 +1908,7 @@ pub fn handleOnboarding(
18291908
) ApiResponse {
18301909
if (s.getInstance(component, name) == null) return notFound();
18311910

1832-
var status = readNullclawOnboardingStatus(allocator, paths, component, name) catch
1911+
var status = readNullclawOnboardingStatus(allocator, s, paths, component, name) catch
18331912
return helpers.serverError();
18341913
defer status.deinit(allocator);
18351914

@@ -3170,6 +3249,88 @@ test "handleOnboarding reports pending bootstrap from workspace state without di
31703249
try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"bootstrap_seeded_at\":\"2026-03-13T01:17:17Z\"") != null);
31713250
}
31723251

3252+
test "handleOnboarding falls back to CLI bootstrap memory for legacy sqlite workspace" {
3253+
const allocator = std.testing.allocator;
3254+
var s = state_mod.State.init(allocator, "/tmp/nullhub-test-instances-api.json");
3255+
defer s.deinit();
3256+
var mctx = TestManagerCtx.init(allocator);
3257+
defer mctx.deinit(allocator);
3258+
3259+
std.fs.deleteTreeAbsolute(mctx.paths.root) catch {};
3260+
defer std.fs.deleteTreeAbsolute(mctx.paths.root) catch {};
3261+
3262+
try s.addInstance("nullclaw", "legacy-agent", .{ .version = "1.0.3" });
3263+
const script =
3264+
\\#!/bin/sh
3265+
\\if [ "$1" = "memory" ] && [ "$2" = "get" ] && [ "$3" = "__bootstrap.prompt.BOOTSTRAP.md" ]; then
3266+
\\ if [ -z "$NULLCLAW_HOME" ]; then
3267+
\\ echo "missing home" >&2
3268+
\\ exit 1
3269+
\\ fi
3270+
\\ printf '%s\n' '{"key":"__bootstrap.prompt.BOOTSTRAP.md","category":"core","timestamp":"2026-03-13T02:37:27Z","content":"# bootstrap","session_id":null}'
3271+
\\ exit 0
3272+
\\fi
3273+
\\echo "unexpected args" >&2
3274+
\\exit 1
3275+
\\
3276+
;
3277+
try writeTestBinary(allocator, mctx.paths, "nullclaw", "1.0.3", script);
3278+
3279+
const inst_dir = try mctx.paths.instanceDir(allocator, "nullclaw", "legacy-agent");
3280+
defer allocator.free(inst_dir);
3281+
const workspace_dir = try std.fs.path.join(allocator, &.{ inst_dir, "workspace" });
3282+
defer allocator.free(workspace_dir);
3283+
try ensurePath(workspace_dir);
3284+
3285+
const resp = handleOnboarding(allocator, &s, mctx.paths, "nullclaw", "legacy-agent");
3286+
defer allocator.free(resp.body);
3287+
3288+
try std.testing.expectEqualStrings("200 OK", resp.status);
3289+
try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"bootstrap_exists\":false") != null);
3290+
try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"pending\":true") != null);
3291+
try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"completed\":false") != null);
3292+
try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"bootstrap_seeded_at\":\"2026-03-13T02:37:27Z\"") != null);
3293+
}
3294+
3295+
test "handleOnboarding stays idle when legacy sqlite bootstrap memory is absent" {
3296+
const allocator = std.testing.allocator;
3297+
var s = state_mod.State.init(allocator, "/tmp/nullhub-test-instances-api.json");
3298+
defer s.deinit();
3299+
var mctx = TestManagerCtx.init(allocator);
3300+
defer mctx.deinit(allocator);
3301+
3302+
std.fs.deleteTreeAbsolute(mctx.paths.root) catch {};
3303+
defer std.fs.deleteTreeAbsolute(mctx.paths.root) catch {};
3304+
3305+
try s.addInstance("nullclaw", "empty-agent", .{ .version = "1.0.4" });
3306+
const script =
3307+
\\#!/bin/sh
3308+
\\if [ "$1" = "memory" ] && [ "$2" = "get" ] && [ "$3" = "__bootstrap.prompt.BOOTSTRAP.md" ]; then
3309+
\\ printf '%s\n' 'null'
3310+
\\ exit 0
3311+
\\fi
3312+
\\echo "unexpected args" >&2
3313+
\\exit 1
3314+
\\
3315+
;
3316+
try writeTestBinary(allocator, mctx.paths, "nullclaw", "1.0.4", script);
3317+
3318+
const inst_dir = try mctx.paths.instanceDir(allocator, "nullclaw", "empty-agent");
3319+
defer allocator.free(inst_dir);
3320+
const workspace_dir = try std.fs.path.join(allocator, &.{ inst_dir, "workspace" });
3321+
defer allocator.free(workspace_dir);
3322+
try ensurePath(workspace_dir);
3323+
3324+
const resp = handleOnboarding(allocator, &s, mctx.paths, "nullclaw", "empty-agent");
3325+
defer allocator.free(resp.body);
3326+
3327+
try std.testing.expectEqualStrings("200 OK", resp.status);
3328+
try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"bootstrap_exists\":false") != null);
3329+
try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"pending\":false") != null);
3330+
try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"completed\":false") != null);
3331+
try std.testing.expect(std.mem.indexOf(u8, resp.body, "\"bootstrap_seeded_at\":null") != null);
3332+
}
3333+
31733334
test "dispatch routes GET onboarding action" {
31743335
const allocator = std.testing.allocator;
31753336
var s = state_mod.State.init(allocator, "/tmp/nullhub-test-instances-api.json");

0 commit comments

Comments
 (0)