nx_wiki_queue.nx source
↩ module page · 303 lines · 14228 B
1// nx_wiki_queue.nx -- the sovereign partnership work-queue.
2//
3// This closes the "trigger Claude partnership work" loop: a visitor submits
4// a task on a component article (POST /wiki/request), it is appended to an
5// append-only Nishi journal on disk, and GET /wiki/queue renders the live
6// log -- the durable, sovereign record of requested + completed work that a
7// Claude session drains and documents. All NishiLang, written + read by
8// Nishi I/O (sys_openat_append / sys_write / sys_read_file). No shell, no
9// TSV-glue, no external lib.
10//
11// NEW CAPABILITY vs the rest of the wiki: this is RENDERED AT REQUEST TIME
12// (it reads the live journal), so it runs in the daemon's forked child --
13// where per-request allocation is fine (the child exits and the OS reclaims
14// it), unlike the parent loop where everything must be hoisted.
15//
16// SECURITY (Cardinal 12, defensive at boundary): the task text is untrusted
17// public input. On write it is sanitized (control bytes + the field
18// separator replaced with spaces, length-capped); on render it is
19// HTML-escaped via the wiki render primitive. The record format uses the
20// 0x1F field separator (never present in sanitized text) so parsing is
21// unambiguous.
22//
23// COMPOSED BY: wiki/nx_wiki_routes (GET /wiki/queue, POST /wiki/request) and
24// bin/nx_sites_daemon (same, dispatched in the child).
25//
26// Status: V1. 2026-05-30.
27
28import "nx_syscalls.nx"
29import "wiki/nx_wiki_doc_render.nx"
30
31// ===== Verdicts =================================================
32const NX_WQ_OK: i64 = 0
33const NX_WQ_ERR: i64 = 3000
34
35// ===== Config (M7) =================================================
36const NX_WQ_LOG_PATH: *u8 = "/volume1/homes/elderwesto/nishihost/wiki_workqueue.log" as *u8
37const NX_WQ_FS: i64 = 0x1F // field separator
38const NX_WQ_REC: i64 = 0x0A // record separator (newline)
39const NX_WQ_MAX_TASK: i64 = 600 // cap on the task field
40const NX_WQ_MAX_SLUG: i64 = 96
41const NX_WQ_RENDER_CAP: i64 = 131072
42const NX_WQ_SCRATCH_CAP: i64 = 4096
43const NX_WQ_LINE_CAP: i64 = 1024
44const NX_WQ_PUTZ_CAP: i64 = 1048576
45const NX_WQ_STRLEN_CAP: i64 = 65536
46const NX_WQ_MAX_RENDER_RECS: i64 = 80 // bound the rendered list (TLS record budget)
47
48// ===== Small helpers =================================================
49func nx_wq_strlen(s: *u8) -> i64 {
50 if (s as i64) == 0 { return 0 }
51 var n: i64 = 0
52 while n < NX_WQ_STRLEN_CAP {
53 if s[n] == (0 as u8) { return n }
54 n = n + 1
55 }
56 return NX_WQ_STRLEN_CAP
57}
58
59func nx_wq_put_z(dst: *u8, off: *i64, cap: i64, s: *u8) -> i64 {
60 if (s as i64) == 0 { return 0 - NX_WQ_ERR }
61 var n: i64 = 0
62 while n < NX_WQ_PUTZ_CAP {
63 if s[n] == (0 as u8) {
64 if off[0] + n > cap { return 0 - NX_WQ_ERR }
65 var i: i64 = 0
66 while i < n { dst[off[0] + i] = s[i]; i = i + 1 }
67 off[0] = off[0] + n
68 return NX_WQ_OK
69 }
70 n = n + 1
71 }
72 return 0 - NX_WQ_ERR
73}
74
75// Copy src->dst replacing control bytes (<0x20) and the field separator
76// with a space; cap at max. Returns bytes written. (M5/M12.)
77func nx_wq_sanitize(dst: *u8, max: i64, src: *u8, src_n: i64) -> i64 {
78 var w: i64 = 0
79 var i: i64 = 0
80 while i < src_n {
81 if w >= max { i = src_n } else {
82 let b: i64 = src[i] as i64
83 if b < 0x20 { dst[w] = 0x20 as u8 } else {
84 if b == NX_WQ_FS { dst[w] = 0x20 as u8 } else { dst[w] = src[i] }
85 }
86 w = w + 1
87 i = i + 1
88 }
89 }
90 return w
91}
92
93func nx_wq_hexval(c: i64) -> i64 {
94 if c >= 0x30 { if c <= 0x39 { return c - 0x30 } } // 0-9
95 if c >= 0x61 { if c <= 0x66 { return c - 0x61 + 10 } } // a-f
96 if c >= 0x41 { if c <= 0x46 { return c - 0x41 + 10 } } // A-F
97 return 0 - 1
98}
99
100// URL-decode a x-www-form-urlencoded value: '+' -> space, %XX -> byte.
101func nx_wq_urldecode(src: *u8, src_n: i64, out: *u8, out_cap: i64) -> i64 {
102 var w: i64 = 0
103 var i: i64 = 0
104 while i < src_n {
105 if w >= out_cap { i = src_n } else {
106 let c: i64 = src[i] as i64
107 if c == 0x2B { out[w] = 0x20 as u8; w = w + 1; i = i + 1 } else {
108 if c == 0x25 {
109 if i + 2 < src_n {
110 let hi: i64 = nx_wq_hexval(src[i + 1] as i64)
111 let lo: i64 = nx_wq_hexval(src[i + 2] as i64)
112 if hi >= 0 { if lo >= 0 {
113 out[w] = ((hi * 16) + lo) as u8; w = w + 1; i = i + 3
114 } }
115 if hi < 0 { out[w] = src[i]; w = w + 1; i = i + 1 }
116 if hi >= 0 { if lo < 0 { out[w] = src[i]; w = w + 1; i = i + 1 } }
117 } else { out[w] = src[i]; w = w + 1; i = i + 1 }
118 } else { out[w] = src[i]; w = w + 1; i = i + 1 }
119 }
120 }
121 }
122 return w
123}
124
125// Find "key=" in body; URL-decode its value (until '&' or end) into out.
126// Returns decoded length, or 0 if not found. (First match; the form has
127// only slug+task so this is unambiguous.)
128func nx_wq_form_field(body: *u8, body_n: i64, key: *u8, key_n: i64,
129 out: *u8, out_cap: i64) -> i64 {
130 if body_n < key_n + 1 { return 0 }
131 var i: i64 = 0
132 let last: i64 = body_n - (key_n + 1)
133 while i <= last {
134 var matched: i64 = 1
135 var j: i64 = 0
136 while j < key_n {
137 if body[i + j] != key[j] { matched = 0; j = key_n } else { j = j + 1 }
138 }
139 if matched == 1 { if body[i + key_n] == (0x3D as u8) {
140 let vstart: i64 = i + key_n + 1
141 var e: i64 = vstart
142 while e < body_n {
143 if body[e] == (0x26 as u8) { return nx_wq_urldecode((body as i64 + vstart) as *u8, e - vstart, out, out_cap) }
144 e = e + 1
145 }
146 return nx_wq_urldecode((body as i64 + vstart) as *u8, body_n - vstart, out, out_cap)
147 } }
148 i = i + 1
149 }
150 return 0
151}
152
153// Append a record: kind <FS> slug <FS> sanitized-text <REC>. O_APPEND ->
154// atomic across concurrent forked children (record < PIPE_BUF). Graceful:
155// a failed open simply drops the record (never blocks serving).
156func nx_wq_append(kind: *u8, kind_n: i64, slug: *u8, slug_n: i64,
157 text: *u8, text_n: i64) -> i64 {
158 let fd: i64 = sys_openat_append(NX_WQ_LOG_PATH, 420)
159 if fd < 0 { return 0 - NX_WQ_ERR }
160 let line: *u8 = sys_mmap(NX_WQ_LINE_CAP)
161 var o: i64 = 0
162 var i: i64 = 0
163 while i < kind_n { if o < NX_WQ_LINE_CAP { line[o] = kind[i]; o = o + 1 }; i = i + 1 }
164 if o < NX_WQ_LINE_CAP { line[o] = NX_WQ_FS as u8; o = o + 1 }
165 o = o + nx_wq_sanitize((line as i64 + o) as *u8, NX_WQ_MAX_SLUG, slug, slug_n)
166 if o < NX_WQ_LINE_CAP { line[o] = NX_WQ_FS as u8; o = o + 1 }
167 o = o + nx_wq_sanitize((line as i64 + o) as *u8, NX_WQ_MAX_TASK, text, text_n)
168 if o < NX_WQ_LINE_CAP { line[o] = NX_WQ_REC as u8; o = o + 1 }
169 sys_write(fd, line, o)
170 sys_close(fd)
171 return NX_WQ_OK
172}
173
174// ===== HTTP wrap =================================================
175func nx_wq_finish_http(ctx: *NxWikiDocCtx, resp_buf: *u8, resp_cap: i64, out_resp_n: *i64) -> i64 {
176 let body_len: i64 = ctx.out_off
177 let off: *i64 = (sys_mmap(8)) as *i64
178 off[0] = 0
179 let rc1: i64 = nx_wq_put_z(resp_buf, off, resp_cap,
180 "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: " as *u8)
181 if rc1 != NX_WQ_OK { return rc1 }
182 var bl: i64 = body_len
183 if bl == 0 { if off[0] >= resp_cap { return 0 - NX_WQ_ERR } resp_buf[off[0]] = 0x30 as u8; off[0] = off[0] + 1 }
184 if bl > 0 {
185 let tmp: *u8 = sys_mmap(32)
186 var k: i64 = 0
187 while bl > 0 { if k >= 32 { return 0 - NX_WQ_ERR } tmp[k] = (0x30 + (bl % 10)) as u8; bl = bl / 10; k = k + 1 }
188 var j: i64 = k - 1
189 while j >= 0 { if off[0] >= resp_cap { return 0 - NX_WQ_ERR } resp_buf[off[0]] = tmp[j]; off[0] = off[0] + 1; j = j - 1 }
190 }
191 let rc2: i64 = nx_wq_put_z(resp_buf, off, resp_cap, "\r\n\r\n" as *u8)
192 if rc2 != NX_WQ_OK { return rc2 }
193 if off[0] + body_len > resp_cap { return 0 - NX_WQ_ERR }
194 var bi: i64 = 0
195 while bi < body_len { resp_buf[off[0] + bi] = ctx.out_buf[bi]; bi = bi + 1 }
196 off[0] = off[0] + body_len
197 out_resp_n[0] = off[0]
198 return NX_WQ_OK
199}
200
201// ===== POST /wiki/request -> append + 302 to /wiki/queue =================================================
202func nx_wiki_request_handle(body: *u8, body_n: i64,
203 resp_buf: *u8, resp_cap: i64, out_resp_n: *i64) -> i64 {
204 if (resp_buf as i64) == 0 { return 0 - NX_WQ_ERR }
205 out_resp_n[0] = 0
206 let slug: *u8 = sys_mmap(128)
207 var slug_n: i64 = nx_wq_form_field(body, body_n, "slug" as *u8, 4, slug, NX_WQ_MAX_SLUG)
208 if slug_n <= 0 {
209 let g: *u8 = "general" as *u8
210 var gi: i64 = 0
211 while gi < 7 { slug[gi] = g[gi]; gi = gi + 1 }
212 slug_n = 7
213 }
214 let task: *u8 = sys_mmap(NX_WQ_MAX_TASK + 16)
215 let task_n: i64 = nx_wq_form_field(body, body_n, "task" as *u8, 4, task, NX_WQ_MAX_TASK)
216 if task_n > 0 { nx_wq_append("REQ" as *u8, 3, slug, slug_n, task, task_n) }
217 // POST-redirect-GET so a refresh doesn't re-submit; shows the updated queue.
218 let off: *i64 = (sys_mmap(8)) as *i64
219 off[0] = 0
220 let rc: i64 = nx_wq_put_z(resp_buf, off, resp_cap,
221 "HTTP/1.1 303 See Other\r\nLocation: /wiki/queue\r\nContent-Length: 0\r\nConnection: keep-alive\r\n\r\n" as *u8)
222 if rc != NX_WQ_OK { return rc }
223 out_resp_n[0] = off[0]
224 return NX_WQ_OK
225}
226
227// ===== GET /wiki/queue -> read journal + render live =================================================
228func nx_wq_emit_row(ctx: *NxWikiDocCtx, data: *u8, ks: i64, ke: i64,
229 ss: i64, se: i64, ts: i64, te: i64) -> i64 {
230 var rc: i64 = nx_wiki_doc_write_z(ctx, "<tr><td>" as *u8); if rc != NX_WIKI_DOC_OK { return rc }
231 rc = nx_wiki_doc_write_escaped(ctx, (data as i64 + ks) as *u8, ke - ks); if rc != NX_WIKI_DOC_OK { return rc }
232 rc = nx_wiki_doc_write_z(ctx, "</td><td>" as *u8); if rc != NX_WIKI_DOC_OK { return rc }
233 rc = nx_wiki_doc_write_escaped(ctx, (data as i64 + ss) as *u8, se - ss); if rc != NX_WIKI_DOC_OK { return rc }
234 rc = nx_wiki_doc_write_z(ctx, "</td><td>" as *u8); if rc != NX_WIKI_DOC_OK { return rc }
235 rc = nx_wiki_doc_write_escaped(ctx, (data as i64 + ts) as *u8, te - ts); if rc != NX_WIKI_DOC_OK { return rc }
236 return nx_wiki_doc_write_z(ctx, "</td></tr>" as *u8)
237}
238
239func nx_wiki_queue_handle(resp_buf: *u8, resp_cap: i64, out_resp_n: *i64) -> i64 {
240 if (resp_buf as i64) == 0 { return 0 - NX_WQ_ERR }
241 out_resp_n[0] = 0
242 let render_buf: *u8 = sys_mmap(NX_WQ_RENDER_CAP)
243 let scratch: *u8 = sys_mmap(NX_WQ_SCRATCH_CAP)
244 let ctx: *NxWikiDocCtx = (sys_mmap(512)) as *NxWikiDocCtx
245 let srcph: *u8 = "work-queue" as *u8
246 let rc_ci: i64 = nx_wiki_doc_ctx_init(ctx, render_buf, NX_WQ_RENDER_CAP,
247 srcph, nx_wq_strlen(srcph),
248 0 as *u8, 0, 0, scratch, NX_WQ_SCRATCH_CAP)
249 if rc_ci != NX_WIKI_DOC_OK { return rc_ci }
250 let title: *u8 = "Partnership Work Queue" as *u8
251 let rc_h: i64 = nx_wiki_doc_write_header(ctx, title, nx_wq_strlen(title))
252 if rc_h != NX_WIKI_DOC_OK { return rc_h }
253 var rc: i64 = nx_wiki_doc_write_z(ctx, "<h1>Partnership Work Queue</h1><p class=\"status\">The sovereign, append-only record of human↔Claude work. Submit a task from any <a href=\"/wiki/components\">component article</a>; a Claude session drains the queue and appends what it built (local + global functionality). Served bits-up by the Nishi daemon.</p><form method=\"POST\" action=\"/wiki/request\"><p>Component: <input name=\"slug\" value=\"general\" size=\"16\"> Task: <input name=\"task\" size=\"48\" placeholder=\"e.g. wire the live crawl front-half\"> <button type=\"submit\">Request work</button></p></form>" as *u8)
254 if rc != NX_WIKI_DOC_OK { return rc }
255 // Read the journal.
256 let len_box: *i64 = (sys_mmap(8)) as *i64
257 len_box[0] = 0
258 let data: *u8 = sys_read_file(NX_WQ_LOG_PATH, len_box)
259 var data_n: i64 = 0
260 if (data as i64) != 0 { data_n = len_box[0] }
261 if data_n <= 0 {
262 rc = nx_wiki_doc_write_z(ctx, "<p class=\"informative\">No entries yet — be the first to request work above.</p>" as *u8)
263 if rc != NX_WIKI_DOC_OK { return rc }
264 } else {
265 rc = nx_wiki_doc_write_z(ctx, "<table><thead><tr><th>Kind</th><th>Component</th><th>Task / work</th></tr></thead><tbody>" as *u8)
266 if rc != NX_WIKI_DOC_OK { return rc }
267 // Parse records (split on REC); within each, two FS split 3 fields.
268 var i: i64 = 0
269 var recs: i64 = 0
270 while i < data_n {
271 if recs >= NX_WQ_MAX_RENDER_RECS { i = data_n } else {
272 let ks: i64 = i
273 // record end = first REC byte at/after ks (or data_n)
274 var rec_end: i64 = data_n
275 var p: i64 = ks
276 while p < data_n {
277 if data[p] == (NX_WQ_REC as u8) { rec_end = p; p = data_n } else { p = p + 1 }
278 }
279 // the two field separators within [ks, rec_end)
280 var fs1: i64 = 0 - 1
281 var fs2: i64 = 0 - 1
282 var q: i64 = ks
283 while q < rec_end {
284 if data[q] == (NX_WQ_FS as u8) {
285 if fs1 < 0 { fs1 = q } else { if fs2 < 0 { fs2 = q } }
286 }
287 q = q + 1
288 }
289 if fs1 > ks { if fs2 > fs1 {
290 let r: i64 = nx_wq_emit_row(ctx, data, ks, fs1, fs1 + 1, fs2, fs2 + 1, rec_end)
291 if r != NX_WIKI_DOC_OK { return r }
292 recs = recs + 1
293 } }
294 i = rec_end + 1
295 }
296 }
297 rc = nx_wiki_doc_write_z(ctx, "</tbody></table>" as *u8)
298 if rc != NX_WIKI_DOC_OK { return rc }
299 }
300 let rc_f: i64 = nx_wiki_doc_write_footer(ctx)
301 if rc_f != NX_WIKI_DOC_OK { return rc_f }
302 return nx_wq_finish_http(ctx, resp_buf, resp_cap, out_resp_n)
303}