writeonce/docs/examples/log-watcher
shoney.arickathil 4f570a74e6 feat(compiler): ?T forced handling — WO-E211/E212/E213 + narrowing (iter 5)
The type system now keeps its nullability promise: a `?T` value cannot be
used, stored, or dereferenced as a plain `T` without narrowing. The canonical
evidence probe (return b.v where v: ?Int, fn -> Int) that compiled clean for
months now fails with WO-E211.

- WO-E211 (un-narrowed use): arithmetic and </<=/>/>= operands, and/or
  operands (?Bool), interpolation segments, for-iterables, and returns whose
  declared type is not nullable.
- WO-E212 (boundary): nil or ?T stored into a non-nullable slot — annotated
  let, assignment to a confidently-typed local (cenv, never the placeholder
  env — a placeholder target must stay silent) or a resolvable class field.
- WO-E213 (deref): field/index access through a possibly-nil base.
- Narrowing (locals only — a field place can be re-assigned between check
  and use, so chains bind to a local first): `if x != nil { }` narrows the
  branch; a DIVERGING then-branch (`if x == nil { return }`) narrows after
  the if; `x != nil and x.n > 3` narrows and/or right operands
  (short-circuit); `while x != nil` narrows the body. The narrow is
  un-applied when an else-less then-env leaks out un-diverged (the existing
  env-leak convention must not leak the narrow).
- No false positives by construction: env/cenv types are declared or
  confidently inferred; the placeholder fallbacks are plain scalars, never
  ?T. The whole golden suite passed untouched (540/0).
- Samples updated to the bind-then-narrow idiom (log-watcher config decode +
  supervisor lock/next_fire, gc-cycle ring print) — 22 genuine unnarrowed-nil
  sites; employee needed zero changes. All acceptances green.
- Corpus: compile-fail/{nullable-unnarrowed-use,nullable-nil-into-plain,
  nullable-deref-unchecked} + run/nullable-narrowing (all four forms) — 83/0.
- Catalog: E211/E212/E213 move from "Reserved, not yet emitted" to the main
  table; nullable-types-implementation.md status flipped to ENFORCED
  (historical record kept); plan 8 Task 6 ticked (boxed scalar cells
  superseded by WO_NIL_SCALAR); board updated.

Verified: woc-test 540/0 + test_diag 14/0; oop-e2e 83/0; oop-accept ALL MET;
log-watcher 7/0; employee 8/0; gc-cycle ring prints + reclaims.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 17:47:13 +02:00
..
cron.wo docs: log-watcher program 2026-08-10 09:26:07 +02:00
justfile fix(log-watcher): portable wo.toml — no pinned runtime path 2026-08-18 01:19:20 +02:00
logtail.wo feat: program mode (argv + exit code); reject + on Text; nil compares by word 2026-08-14 17:18:01 +02:00
main.wo feat(compiler): ?T forced handling — WO-E211/E212/E213 + narrowing (iter 5) 2026-08-19 17:47:13 +02:00
mcp.wo feat(compiler): ?T forced handling — WO-E211/E212/E213 + narrowing (iter 5) 2026-08-19 17:47:13 +02:00
probes.wo docs: log-watcher program 2026-08-10 09:26:07 +02:00
README.md docs: log-watcher program 2026-08-10 09:26:07 +02:00
supervisor.wo feat(compiler): ?T forced handling — WO-E211/E212/E213 + narrowing (iter 5) 2026-08-19 17:47:13 +02:00
watcher.wo docs: log-watcher program 2026-08-10 09:26:07 +02:00
wo.toml fix(log-watcher): portable wo.toml — no pinned runtime path 2026-08-18 01:19:20 +02:00

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 five builtin stdlib modules — fs, proc, net, time, json — carry all of it; read each .wo next to its .hx sibling.

Status: design artifact — the spec's forcing function. The systems track is approved, pre-implementation. Today's woc (milestone 1) recovers the class/fn skeletons in these files (--dump-ast lists every Watcher method) but diagnoses the adopted surface as WO-E101: use, typedef, standalone union aliases (type CronResult = …), pub(read), switch, try. This sample exists to force that grammar (the blog/ecommerce/pricing precedent) and becomes the track's acceptance test: it compiles and detects a real silent death when the track ships.

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:

  1. time.local(ms) -> {year, month, day, hour, minute, dow} and time.iso(ms) -> Text — cron next-fire needs calendar decomposition in the host timezone; the detections sink needs an ISO stamp. The spec's time sketch (now/mono/sleep) cannot express cron.
  2. json.Value — an opaque, re-encodable JSON value. JSON-RPC echoes id back verbatim (number | string | null); no record type can hold it. Decode-target position only, like the as rule.
  3. fs.stat gains dir: Bool — cron.d scanning must skip subdirectories; {size, inode, mtime} cannot.
  4. 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 (see classify_loose, is_env_line, find_log, sanitize).
  5. Collection builtins: push, pop (returns the removed element), shift, slice, reverse, sort, join; map index (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:

  1. Every object is GC-heap: new Watcher_obj behind hx::ObjectPtr, with HX_DEFINE_STACK_FRAME on every method so the collector can scan roots.
  2. Every typedef is a Dynamic hash object: TailState has no C++ struct — the hot poll path does this->st->__Field(HX_("lastLevel",…)), a hashed string lookup per field access, every 2 s, per watched file (LogTail.cpp has 34 Dynamic sites, Supervisor.cpp 91, Mcp.cpp 123).
  3. 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.