nx_nxtask_store.nx source
↩ module page · 367 lines · 11867 B
1// nxtask_store.nx -- persistence layer for nxtask (beads-equivalent).
2//
3// Provides buffer round-trip of a TaskGraph + convenience wrappers
4// that go through fs.nx for disk persistence. Single-file binary
5// format; zero runtime deps beyond syscalls + fs.
6//
7// Format v1 -- magic "NXT0":
8//
9// 4 magic ("NXT0", little-endian bytes)
10// 8 n_tasks (i64 LE)
11// repeat n_tasks:
12// 8 state
13// 8 created_ns
14// 8 updated_ns
15// 8 desc_len
16// desc_len desc bytes (no trailing NUL)
17// 8 claimed_by_len
18// claimed_by_len claimed_by bytes (omitted when 0)
19// 8 summary_len
20// summary_len summary bytes (omitted when 0)
21// 8 n_blocked
22// 8*n_blocked blocked_by task indices (i64 LE each)
23//
24// Design notes:
25//
26// * We store strings inline rather than via a separate string pool
27// because each task's text is small (<2 KB) and the whole file
28// typically fits in one page. A pool only pays off when many
29// tasks share identical text, which isn't the nxtask workload.
30//
31// * id_hash is NOT persisted. v0.0.1 uses counter-hash which is a
32// function of the task's position + creation order, both of
33// which are recovered at load time. v0.1.0 will persist a real
34// sha256(desc + created_ns) once we switch to content-addressed
35// ids.
36//
37// * Little-endian is the RISC-V default so this is the native byte
38// order. No byte-swap needed.
39//
40// This commit ships the buffer API only. An fs_save/fs_load wrapper
41// lands in a follow-up (depends on fs_mkdir parent-dir creation).
42
43// nx_safety_envelope:
44// intended_use: AUTO_APPLIED -- primitive-specific tuning queued
45// sil_target: SIL1
46// evidence: [bulk_applied_2026-05-16, see-file-comment-for-detail]
47// verdict: NOT_YET_EVALUATED
48
49import "nx_syscalls.nx"
50import "nx_nxtask.nx"
51const NXT_MAGIC_16384: i64 = 16384
52
53const NXT_MAGIC_0: i64 = 0x4E // 'N'
54const NXT_MAGIC_1: i64 = 0x58 // 'X'
55const NXT_MAGIC_2: i64 = 0x54 // 'T'
56const NXT_MAGIC_3: i64 = 0x30 // '0'
57
58// === helpers: i64 <-> bytes (little-endian) ========================
59
60func write_i64_le(buf: *u8, off: i64, val: i64) -> i64 {
61 var i: i64 = 0
62 while i < 8 {
63 buf[off + i] = (val >> (i * 8)) & 0xFF
64 i = i + 1
65 }
66 return off + 8
67}
68
69func read_i64_le(buf: *u8, off: i64) -> i64 {
70 var v: i64 = 0
71 var i: i64 = 0
72 while i < 8 {
73 let b: i64 = buf[off + i] & 0xFF
74 v = v | (b << (i * 8))
75 i = i + 1
76 }
77 return v
78}
79
80// Copy `n` bytes from `src` to `dst`. Returns new dst offset.
81func copy_bytes(dst: *u8, off: i64, src: *u8, n: i64) -> i64 {
82 var i: i64 = 0
83 while i < n {
84 dst[off + i] = src[i]
85 i = i + 1
86 }
87 return off + n
88}
89
90// === serialize =====================================================
91//
92// Write a graph to `buf` (must be >= nxtask_serialized_size).
93// Returns bytes written, or -1 on overflow.
94
95func nxtask_serialized_size(g: *TaskGraph) -> i64 {
96 var total: i64 = 4 + 8 // magic + n_tasks
97 var i: i64 = 0
98 while i < g.n_tasks {
99 let t: *Task = task_at(g, i)
100 total = total + 8 + 8 + 8 // state + created + updated
101 total = total + 8 + t.desc_len
102 total = total + 8 + t.claimed_by_len
103 total = total + 8 + t.summary_len
104 total = total + 8 + t.n_blocked * 8
105 i = i + 1
106 }
107 return total
108}
109
110func nxtask_serialize(g: *TaskGraph, buf: *u8, cap: i64) -> i64 {
111 let need: i64 = nxtask_serialized_size(g)
112 if need > cap { return -1 }
113
114 // Magic
115 buf[0] = NXT_MAGIC_0 & 0xFF
116 buf[1] = NXT_MAGIC_1 & 0xFF
117 buf[2] = NXT_MAGIC_2 & 0xFF
118 buf[3] = NXT_MAGIC_3 & 0xFF
119 var off: i64 = 4
120 off = write_i64_le(buf, off, g.n_tasks)
121
122 var i: i64 = 0
123 while i < g.n_tasks {
124 let t: *Task = task_at(g, i)
125 off = write_i64_le(buf, off, t.state)
126 off = write_i64_le(buf, off, t.created_ns)
127 off = write_i64_le(buf, off, t.updated_ns)
128 off = write_i64_le(buf, off, t.desc_len)
129 if t.desc_len > 0 {
130 off = copy_bytes(buf, off, t.desc, t.desc_len)
131 }
132 off = write_i64_le(buf, off, t.claimed_by_len)
133 if t.claimed_by_len > 0 {
134 off = copy_bytes(buf, off, t.claimed_by, t.claimed_by_len)
135 }
136 off = write_i64_le(buf, off, t.summary_len)
137 if t.summary_len > 0 {
138 off = copy_bytes(buf, off, t.summary, t.summary_len)
139 }
140 off = write_i64_le(buf, off, t.n_blocked)
141 var j: i64 = 0
142 while j < t.n_blocked {
143 off = write_i64_le(buf, off, t.blocked_by[j])
144 j = j + 1
145 }
146 i = i + 1
147 }
148 return off
149}
150
151// === deserialize ===================================================
152//
153// Read from a buf of size `n`. Returns 0 (null) on malformed input.
154
155func nxtask_deserialize(buf: *u8, n: i64) -> *TaskGraph {
156 if n < 12 { return 0 as *TaskGraph } // magic + header
157 if buf[0] != NXT_MAGIC_0 { return 0 as *TaskGraph }
158 if buf[1] != NXT_MAGIC_1 { return 0 as *TaskGraph }
159 if buf[2] != NXT_MAGIC_2 { return 0 as *TaskGraph }
160 if buf[3] != NXT_MAGIC_3 { return 0 as *TaskGraph }
161
162 var off: i64 = 4
163 let n_tasks: i64 = read_i64_le(buf, off)
164 off = off + 8
165 if n_tasks < 0 { return 0 as *TaskGraph }
166
167 let cap: i64 = n_tasks + 32 // grow headroom
168 let g: *TaskGraph = nxtask_graph_new(cap)
169 g.n_tasks = n_tasks
170
171 var i: i64 = 0
172 while i < n_tasks {
173 if off + 32 > n { return 0 as *TaskGraph } // truncated
174 let t: *Task = task_at(g, i)
175 t.state = read_i64_le(buf, off)
176 off = off + 8
177 t.created_ns = read_i64_le(buf, off)
178 off = off + 8
179 t.updated_ns = read_i64_le(buf, off)
180 off = off + 8
181
182 // desc
183 let d_len: i64 = read_i64_le(buf, off)
184 off = off + 8
185 if d_len < 0 { return 0 as *TaskGraph }
186 if off + d_len > n { return 0 as *TaskGraph }
187 t.desc_len = d_len
188 if d_len > 0 {
189 let d: *u8 = sys_mmap(d_len + 16)
190 var k: i64 = 0
191 while k < d_len {
192 d[k] = buf[off + k]
193 k = k + 1
194 }
195 d[d_len] = 0
196 t.desc = d
197 off = off + d_len
198 } else {
199 t.desc = 0 as *u8
200 }
201
202 // claimed_by
203 let c_len: i64 = read_i64_le(buf, off)
204 off = off + 8
205 if c_len < 0 { return 0 as *TaskGraph }
206 if off + c_len > n { return 0 as *TaskGraph }
207 t.claimed_by_len = c_len
208 if c_len > 0 {
209 let c: *u8 = sys_mmap(c_len + 16)
210 var k: i64 = 0
211 while k < c_len {
212 c[k] = buf[off + k]
213 k = k + 1
214 }
215 c[c_len] = 0
216 t.claimed_by = c
217 off = off + c_len
218 } else {
219 t.claimed_by = 0 as *u8
220 }
221
222 // summary
223 let s_len: i64 = read_i64_le(buf, off)
224 off = off + 8
225 if s_len < 0 { return 0 as *TaskGraph }
226 if off + s_len > n { return 0 as *TaskGraph }
227 t.summary_len = s_len
228 if s_len > 0 {
229 let s: *u8 = sys_mmap(s_len + 16)
230 var k: i64 = 0
231 while k < s_len {
232 s[k] = buf[off + k]
233 k = k + 1
234 }
235 s[s_len] = 0
236 t.summary = s
237 off = off + s_len
238 } else {
239 t.summary = 0 as *u8
240 }
241
242 // n_blocked + blocked_by
243 let nb: i64 = read_i64_le(buf, off)
244 off = off + 8
245 if nb < 0 { return 0 as *TaskGraph }
246 if off + nb * 8 > n { return 0 as *TaskGraph }
247 t.n_blocked = nb
248 if nb > 0 {
249 let deps: *i64 = sys_mmap(nb * 8 + 16) as *i64
250 var k: i64 = 0
251 while k < nb {
252 deps[k] = read_i64_le(buf, off)
253 off = off + 8
254 k = k + 1
255 }
256 t.blocked_by = deps
257 } else {
258 t.blocked_by = 0 as *i64
259 }
260
261 i = i + 1
262 }
263
264 return g
265}
266
267// === self-test =====================================================
268//
269// Round-trip 3 tasks with mixed state + deps. Assert every field
270// survives. Exit 42 on success, other codes on specific mismatches.
271
272func assert_bytes_equal(a: *u8, b: *u8, n: i64) -> i64 {
273 var i: i64 = 0
274 while i < n {
275 if a[i] != b[i] { return 0 }
276 i = i + 1
277 }
278 return 1
279}
280
281func main() -> i64 {
282 // Build source graph: 3 tasks, mid-workflow state.
283 let g: *TaskGraph = nxtask_graph_new(8)
284 nxtask_add(g, "Port 12-bit imm expansion" as *u8, 25, 1)
285 nxtask_add(g, "Add persistence to nxtask" as *u8, 25, 2)
286 nxtask_add(g, "Build Mormon v0" as *u8, 15, 3)
287
288 nxtask_add_dep(g, 1, 0) // persistence blocked by imm port
289 nxtask_add_dep(g, 2, 1) // mormon blocked by persistence
290
291 nxtask_claim(g, 0, "agent-foo" as *u8, 9, 10)
292 nxtask_close(g, 0, "Shipped with four helpers" as *u8, 25, 11)
293
294 // Serialize.
295 let buf: *u8 = sys_mmap(NXT_MAGIC_16384)
296 let n: i64 = nxtask_serialize(g, buf, NXT_MAGIC_16384)
297 if n < 0 { return __syscall(93, 80, 0, 0, 0, 0, 0) }
298 if n < 12 { return __syscall(93, 81, 0, 0, 0, 0, 0) }
299
300 // Magic bytes at offset 0-3.
301 if buf[0] != 0x4E { return __syscall(93, 82, 0, 0, 0, 0, 0) }
302 if buf[1] != 0x58 { return __syscall(93, 83, 0, 0, 0, 0, 0) }
303 if buf[2] != 0x54 { return __syscall(93, 84, 0, 0, 0, 0, 0) }
304 if buf[3] != 0x30 { return __syscall(93, 85, 0, 0, 0, 0, 0) }
305
306 // Deserialize.
307 let g2: *TaskGraph = nxtask_deserialize(buf, n)
308 if g2 == (0 as *TaskGraph) { return __syscall(93, 86, 0, 0, 0, 0, 0) }
309 if g2.n_tasks != 3 { return __syscall(93, 87, 0, 0, 0, 0, 0) }
310
311 // Per-task field round-trip.
312 let t0: *Task = task_at(g, 0)
313 let u0: *Task = task_at(g2, 0)
314 if u0.state != t0.state { return __syscall(93, 90, 0, 0, 0, 0, 0) }
315 if u0.created_ns != t0.created_ns { return __syscall(93, 91, 0, 0, 0, 0, 0) }
316 if u0.updated_ns != t0.updated_ns { return __syscall(93, 92, 0, 0, 0, 0, 0) }
317 if u0.desc_len != t0.desc_len { return __syscall(93, 93, 0, 0, 0, 0, 0) }
318 if assert_bytes_equal(u0.desc, t0.desc, t0.desc_len) != 1 {
319 return __syscall(93, 94, 0, 0, 0, 0, 0)
320 }
321 if u0.claimed_by_len != t0.claimed_by_len {
322 return __syscall(93, 95, 0, 0, 0, 0, 0)
323 }
324 if assert_bytes_equal(u0.claimed_by, t0.claimed_by, t0.claimed_by_len) != 1 {
325 return __syscall(93, 96, 0, 0, 0, 0, 0)
326 }
327 if u0.summary_len != t0.summary_len {
328 return __syscall(93, 97, 0, 0, 0, 0, 0)
329 }
330 if assert_bytes_equal(u0.summary, t0.summary, t0.summary_len) != 1 {
331 return __syscall(93, 98, 0, 0, 0, 0, 0)
332 }
333
334 // Dep round-trip on task 1.
335 let t1: *Task = task_at(g, 1)
336 let u1: *Task = task_at(g2, 1)
337 if u1.n_blocked != t1.n_blocked {
338 return __syscall(93, 100, 0, 0, 0, 0, 0)
339 }
340 if u1.blocked_by[0] != 0 {
341 return __syscall(93, 101, 0, 0, 0, 0, 0)
342 }
343
344 // Task 2 (never claimed) -- claimed_by + summary should be null/zero.
345 let u2: *Task = task_at(g2, 2)
346 if u2.claimed_by_len != 0 {
347 return __syscall(93, 110, 0, 0, 0, 0, 0)
348 }
349 if u2.summary_len != 0 {
350 return __syscall(93, 111, 0, 0, 0, 0, 0)
351 }
352 if u2.n_blocked != 1 {
353 return __syscall(93, 112, 0, 0, 0, 0, 0)
354 }
355 if u2.blocked_by[0] != 1 {
356 return __syscall(93, 113, 0, 0, 0, 0, 0)
357 }
358
359 // Malformed-magic deserialise path returns null.
360 buf[0] = 0x00
361 let g_bad: *TaskGraph = nxtask_deserialize(buf, n)
362 if g_bad != (0 as *TaskGraph) {
363 return __syscall(93, 120, 0, 0, 0, 0, 0)
364 }
365
366 return __syscall(93, 42, 0, 0, 0, 0, 0)
367}