- README: shipped concurrency/HTTP/WebSockets sat in the roadmap as "not yet available"; "no package manager" contradicted [deps]; the deps example would not have compiled (the key IS the module name) - runtime/README: leads with wovm, wo-rt.c demoted to a historical section; dropped 2 nonexistent recipes, crates/rt, @gc refcounting, 13 suites -> 18 - employee + log-watcher READMEs claimed "does not compile"; both are gates - error catalog: +10 emitted codes incl WO-E250, the only diagnostic the shipped query surface raises; recorded why the sweep rotted - language-surface: group-by parses, then the typechecker refuses it - 00-code-review + 00-link-audit re-run; history kept, not rewritten - 48 dead Rust-era exploration links de-linked rather than re-pointed (their prose names the retired plan by number); successor map -> discarded.md - 08-project-structure: compiler/plan/ never existed; corpus has 9 dirs, 5 empty - releasing.md: dropped a --draft step the workflow never had - new docs/00-doc-audit.md: findings + disposition, incl one row where the audit was wrong and the doc it accused was right - status folders removed: 34 stories flat, status only in frontmatter; 252 links recomputed from resolved paths; board/board-views/structure retaught - story 24 -> in-progress, since frontmatter is now the only truth - new iteration 38: fs mutation verbs + net.connect, the two capability families no iteration owned - new iteration 39: gofiber/fiber v3.5.0 parity study. The ledger called CSRF/sessions unblocked by iteration 34's HMAC, but the runtime has no source of randomness at all - linkcheck skips .dev/.superpowers: 0 broken paths, 0 bad anchors Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
8.3 KiB
log-watcher — the systems-track sample workload
The Haxe original (~/projects/log-watcher, ~1,200 lines compiled to C++)
ported file for file, per the approved
systems-track design
(Part 4). A single-binary systems daemon: log-tail watcher, cron.d
supervisor, flock/pgrep probes, hand-rolled MCP-over-HTTP server, JSONL
detection sink. Program mode (fn main, blocking legal, one shard) plus the
stdlib modules it needs — fs, proc, net, time, json, env — carry
all of it; read each .wo next to its .hx sibling.
Status: shipped — the systems track's acceptance gate. Run it with
just log-watcher(just log-watcher::build/::soak 60for the rest). Landed with iteration 7 on 2026-08-15: executable, not merely compilable — zero ASan leaks in all three modes, SIGTERM ends parked syscalls, fds flat,LW_SOAKgate. The sample existed to force the grammar it uses (the blog/ecommerce/pricing precedent), and every form it needed —use,typedef, standalone union aliases (type CronResult = …),pub(read),switch,try— is now shipped surface.
The mapping
.wo file |
.hx sibling |
carries | could not express |
|---|---|---|---|
main.wo |
Main.hx | subcommand dispatch; config decode into a typedef with ?fields |
— |
logtail.wo |
LogTail.hx | TailState record, bounded tail reads, rotation-by-inode, strict/relaxed classification, last_lines |
— |
watcher.wo |
Watcher.hx | the per-log state machine: ALERT/CLEAR rule, cron done()/result(), CronResult union |
— |
cron.wo |
Cron.hx | cron.d parse (aliases, redirect target, flock path), next-fire scan with the Vixie dom/dow OR quirk | — |
supervisor.wo |
Supervisor.hx | the 250 ms event loop: rescan, collapse, pre-fire lock probe, completions, detections sink | — |
probes.wo |
Flock.hx, Pgrep.hx | proc.run exit-code probes as static fns, safe-direction fallbacks |
— |
mcp.wo |
Mcp.hx, Tools.hx | typed request/response records; pure handle(req) -> resp kept socket-free; serve loop over net |
— |
The spec's Part 4 table folds Supervisor.hx into the other files;
supervisor.wo stays separate because the original is a separate file and
the loop is the program. MiniLog.hx and its two tools
(load_log_db/query_log_db) are deliberately absent: the spec's
out-of-scope list assigns "sqlite-equivalent embedded SQL over RAM" to the
DB engine — a future sample wires MiniLog's idea to select.
What the port deletes
| Haxe | Why it's gone |
|---|---|
import sys.FileSystem / sys.io.File / sys.io.FileSeek |
one use fs; read_at takes the offset — no seek state, no open/close bookkeeping (RAII: handle drop = close) |
LogTail.newState() |
record field defaults; construction is the brace literal |
Util.hx (say = println + flush) |
print flushes on newline (spec Part 2) |
loadConfig's Dynamic field-poking |
one json.decode(raw) as FileConfig — typed, ?fields, nil on mismatch |
try … catch (e:Dynamic) probes |
try … catch (e) over the trap system; expected absence is ?T/nil instead |
#if portable linker pragma |
woc build is a static single binary by doctrine |
socket try c.close() catch dance |
connection is an owned value; scope end closes it |
while(true) with no way out |
env.stopping() — SIGTERM/SIGINT land as a flag, no signal callbacks |
What this sample forces (spec follow-ups)
The spec's five modules cover the I/O; writing real code forced these additions, in shrinking order of importance:
time.local(ms) -> {year, month, day, hour, minute, dow}andtime.iso(ms) -> Text— cron next-fire needs calendar decomposition in the host timezone; the detections sink needs an ISO stamp. The spec'stimesketch (now/mono/sleep) cannot express cron.json.Value— an opaque, re-encodable JSON value. JSON-RPC echoesidback verbatim (number | string | null); no record type can hold it. Decode-target position only, like theasrule.fs.statgainsdir: Bool— cron.d scanning must skip subdirectories;{size, inode, mtime}cannot.- Text builtins:
len,substr(s, start, len),split,split_ws,trim,starts_with,ends_with,index_of,last_index_of,to_lower,byte_at,char_of,parse_int,"${…}"interpolation. No regex module exists — the original's five ERegs are hand-rolled scans (seeclassify_loose,is_env_line,find_log,sanitize). - Collection builtins:
push,pop(returns the removed element),shift,slice,reverse,sort,join;mapindex (returns?V),has,remove,for k, v in m.
Deliberate divergences
| Original | Port | Why |
|---|---|---|
Float seconds everywhere |
Int milliseconds |
no Float scalar in the language; ms-as-Int matches the runtime (and Money's minor-units precedent) |
config services entries: string or {path} object |
strings only | typed decode; the object form was never used in the deployed config |
| tool schemas built per call | one JSON const |
static data is static; the wire bytes are identical |
JSON-RPC envelope via Json.stringify of a Dynamic |
typed sub-records json.encoded into a concatenated envelope |
typed json has no heterogeneous-object builder; id passes through as json.Value |
list_logs mtime: seconds (float) |
ms (int) | consistency with every other timestamp in the port |
Memory model: why this sample has zero @gc and zero @table
The Haxe original transcompiles to C++ (bin/src/*.cpp, hxcpp target),
which makes the contrast measurable — there, everything is @gc:
- Every object is GC-heap:
new Watcher_objbehindhx::ObjectPtr, withHX_DEFINE_STACK_FRAMEon every method so the collector can scan roots. - Every typedef is a
Dynamichash object:TailStatehas no C++ struct — the hot poll path doesthis->st->__Field(HX_("lastLevel",…)), a hashed string lookup per field access, every 2 s, per watched file (LogTail.cpphas 34Dynamicsites,Supervisor.cpp91,Mcp.cpp123). - Allocation per poll:
poll()returns a fresh anonymous GC object each call.
The port's ownership graph is a pure tree, so MVS covers all of it with
owned values and @gc earns its keep nowhere:
| Object | Owner | wo semantics |
|---|---|---|
TailState |
its Watcher (field) |
owned value; the __Field hash lookup becomes a fixed-offset load |
Watcher |
Supervisor.services or .active — never both |
owned in one container; remove() IS the destructor — the Haxe kill-self comment, now literal |
CronWatch |
scheduled map |
owned; iteration mutates via borrow |
PollResult, ParseResult, every record |
callee frame | stack lifetime, DROP at scope end, zero heap |
Tools, Mcp |
main → Mcp.tools |
owned chain |
hxcpp pays GC on 100% of these objects; MVS pays on 0%. @gc would only
enter if a shape changed — one Watcher aliased by two live registries at
once, or a shared mutable cache aliased across requests (the OOP spec's
PriceCache pattern). log-watcher has neither.
@table is also correctly absent: it configures storage for engine-bound
classes, and program mode has no DB engine (sub-project 3). The C++ shows
exactly where it lands later — MiniLog.cpp's in-memory sqlite,
entries(id, path, seq, level, body, byteOffset) + idx_level, which is
the future sample the spec names (wiring MiniLog's idea to select):
@table(name: "entries", index: [path, seq]) on a LogEntry class turns
load_log_db/query_log_db into plain insert/select. Same for the
detections JSONL → a @table type with insert replacing
open-append-close. Until the engine links, annotating anything here would
claim storage that doesn't exist.
Try it (when the track ships)
woc build docs/examples/log-watcher # single static binary
log-watcher watch /var/log/myapp.log 10 2 # ALERT/CLEAR on stdout
log-watcher run /etc/cron.d config.json # supervisor
log-watcher mcp /etc/cron.d config.json # MCP on 127.0.0.1:<port>
Acceptance (spec): compiles; the tail state machine, cron next-fire, and
MCP handle pass fixtures ported from the Haxe test suite; watch detects
an error-final quiet period against a growing tempfile, end to end.