@@ -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+
122140fn 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+
131205fn 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+
31733334test "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