docs/examples/log-watcher now COMPILES AND RUNS: `wovm lw.wob watch app.log 2 1`
tails a live file, classifies levels and fires its alert
("last entry is error, quiet for 2s"). corpus 71/0, woc 565/0, wovm gates green.
- program mode: the entry is `fn main` taking nothing or one `multi Text`;
runtime/src/main.c builds that list from the program's own arguments (not
the program name, not the image path) and the entry's return value is the
process exit code (low byte); the loader accepts a 0- or 1-arg free-fn entry
- `+` on Text is now WO-E201 pointing at `..`. This was a memory-safety hole,
not a style nit: the emitter lowered it to ADD on two heap pointers, and the
workload's own `out = out + char_of(c)` produced a wild pointer that
segfaulted the VM inside starts_with. Reported off confident types only
- `x == nil` / `x != nil` lower to EQ (a word compare), never EQS: nil is the
zero word and EQS dereferences its operands, so a nil guard would trap
instead of answering
- docs/examples/log-watcher: seven `+`-on-Text sites corrected to `..`
(logtail sanitize, mcp header/body/carry assembly, supervisor detections
line) — the sample was carrying the Haxe habit, and the language reserves
`+` for arithmetic by doctrine
- wovm CLI takes arguments after the image path (`wovm <file.wob> [args...]`)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
157 lines
6.2 KiB
Text
157 lines
6.2 KiB
Text
-- logtail.wo — LogTail.hx, file for file: bounded tail reads over `fs`.
|
|
-- One poll = read at most CHUNK new tail bytes, judge only complete lines
|
|
-- (plan 02: never scan a file front to back; torn final line held back).
|
|
--
|
|
-- The three Haxe imports (sys.FileSystem, sys.io.File, sys.io.FileSeek)
|
|
-- collapse into one `use fs`: stat carries the inode, read_at takes the
|
|
-- offset as a parameter (no seek state), and the file handle lives and dies
|
|
-- inside the builtin — RAII instead of the open/try/close dance.
|
|
|
|
use fs
|
|
|
|
-- LogTail.hx:6-11. Field defaults replace LogTail.newState(): construction
|
|
-- is the brace literal `TailState {}`, the defaults fill in.
|
|
typedef TailState = {
|
|
offset: Int = 0 -- next read position (past last complete line)
|
|
ino: Int = -1 -- inode at last poll, -1 before first sight
|
|
last_level: Text = "" -- level of the last complete entry, "" if none
|
|
last_newline_at: Int = 0 -- ms clock when complete lines last arrived
|
|
}
|
|
|
|
typedef PollResult = {
|
|
exists: Bool
|
|
new_lines: Int
|
|
bytes_read: Int
|
|
}
|
|
|
|
const CHUNK = 65536
|
|
|
|
-- `now` is injected (ms) so tests can drive synthetic time. LogTail.hx:28-75.
|
|
pub fn poll(path: Text, mut st: TailState, now: Int) -> PollResult {
|
|
let stat = fs.stat(path);
|
|
if stat == nil { return PollResult { exists: false, new_lines: 0, bytes_read: 0 }; }
|
|
|
|
if st.ino == -1 {
|
|
-- first sight: start at most CHUNK before EOF; a partial first line is
|
|
-- read as a continuation, which is acceptable
|
|
st.ino = stat.inode;
|
|
st.offset = 0;
|
|
if stat.size > CHUNK { st.offset = stat.size - CHUNK; }
|
|
} else if stat.inode != st.ino or stat.size < st.offset {
|
|
-- rotation (rename/recreate or truncate): restart from the top
|
|
st.ino = stat.inode;
|
|
st.offset = 0;
|
|
st.last_level = "";
|
|
}
|
|
if stat.size - st.offset > CHUNK { st.offset = stat.size - CHUNK; } -- burst: jump to tail
|
|
if stat.size <= st.offset { return PollResult { exists: true, new_lines: 0, bytes_read: 0 }; }
|
|
|
|
-- Shrunk between stat and read (the Haxe Eof catch): read_at returns what
|
|
-- is actually there; the next poll re-syncs.
|
|
let chunk = fs.read_at(path, st.offset, stat.size - st.offset);
|
|
if len(chunk) == 0 { return PollResult { exists: true, new_lines: 0, bytes_read: 0 }; }
|
|
|
|
-- only complete lines count: cut at the last newline, hold the rest
|
|
let nl = last_index_of(chunk, "\n");
|
|
if nl == -1 { return PollResult { exists: true, new_lines: 0, bytes_read: len(chunk) }; }
|
|
|
|
let lines = split(substr(chunk, 0, nl + 1), "\n");
|
|
pop(lines); -- empty piece after the final newline
|
|
st.offset = st.offset + nl + 1;
|
|
for line in lines {
|
|
-- relaxed rule (service-health): timestamped app logs must classify
|
|
-- too, or their errors never trip the alert rule
|
|
let lv = classify_loose(sanitize(line));
|
|
if lv != nil { st.last_level = lv; } -- else continuation: inherits
|
|
}
|
|
if len(lines) > 0 { st.last_newline_at = now; }
|
|
return PollResult { exists: true, new_lines: len(lines), bytes_read: len(chunk) };
|
|
}
|
|
|
|
-- Strict prefix rule for demo/test logs. LogTail.hx:77-82.
|
|
pub fn classify(line: Text) -> ?Text {
|
|
if starts_with(line, "info") { return "info"; }
|
|
if starts_with(line, "warn") { return "warn"; }
|
|
if starts_with(line, "error") { return "error"; }
|
|
return nil;
|
|
}
|
|
|
|
-- Real app logs put a timestamp first ("2026-07-22T15:40:00 - error: …");
|
|
-- the relaxed rule also accepts the level after a leading token. Haxe used
|
|
-- an EReg (LogTail.hx:87, ~/^\S+\s+-\s+(info|warn|error)\b/); with no regex
|
|
-- in the language the same rule is spelled out: token, lone dash, level
|
|
-- with a word boundary. Callers pass a sanitized line — ANSI codes hide
|
|
-- the prefix.
|
|
pub fn classify_loose(line: Text) -> ?Text {
|
|
let lv = classify(line);
|
|
if lv != nil { return lv; }
|
|
let tokens = split_ws(line);
|
|
if len(tokens) < 3 { return nil; }
|
|
if tokens[1] != "-" { return nil; }
|
|
return level_bounded(tokens[2]);
|
|
}
|
|
|
|
-- The regex's \b: "error:" and "error," carry the level; "errors" does not.
|
|
fn level_bounded(tok: Text) -> ?Text {
|
|
for lv in ["info", "warn", "error"] {
|
|
if starts_with(tok, lv) {
|
|
if len(tok) == len(lv) { return lv; }
|
|
if is_word_byte(byte_at(tok, len(lv))) { return nil; }
|
|
return lv;
|
|
}
|
|
}
|
|
return nil;
|
|
}
|
|
|
|
fn is_word_byte(c: Int) -> Bool {
|
|
if c >= 48 and c <= 57 { return true; } -- 0-9
|
|
if c >= 65 and c <= 90 { return true; } -- A-Z
|
|
if c >= 97 and c <= 122 { return true; } -- a-z
|
|
return c == 95; -- _
|
|
}
|
|
|
|
-- Tools.hx:24-34, moved here beside its heaviest caller (poll). Log lines
|
|
-- can carry ANSI color sequences and stray control bytes; they would break
|
|
-- classification and produce invalid JSON at the client. Strip ESC[…letter
|
|
-- sequences, drop other C0 bytes (tab stays). The Haxe EReg becomes an
|
|
-- explicit byte scan.
|
|
pub fn sanitize(line: Text) -> Text {
|
|
let out = "";
|
|
let i = 0;
|
|
while i < len(line) {
|
|
let c = byte_at(line, i);
|
|
if c == 27 and i + 1 < len(line) and byte_at(line, i + 1) == 91 {
|
|
i = i + 2; -- skip ESC [
|
|
while i < len(line) {
|
|
let f = byte_at(line, i);
|
|
i = i + 1;
|
|
if (f >= 65 and f <= 90) or (f >= 97 and f <= 122) { break; }
|
|
}
|
|
continue;
|
|
}
|
|
if c >= 32 or c == 9 { out = out .. char_of(c); }
|
|
i = i + 1;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
-- Last <= n complete lines from the final CHUNK bytes of the file. A
|
|
-- cut-off first line is acceptable (same as first-sight poll); an
|
|
-- unterminated final line is dropped. nil when the file is missing.
|
|
-- LogTail.hx:98-120.
|
|
pub fn last_lines(path: Text, n: Int) -> ?multi Text {
|
|
let stat = fs.stat(path);
|
|
if stat == nil { return nil; }
|
|
let start = 0;
|
|
if stat.size > CHUNK { start = stat.size - CHUNK; }
|
|
if stat.size - start <= 0 { return []; }
|
|
let chunk = fs.read_at(path, start, stat.size - start);
|
|
if len(chunk) == 0 { return []; }
|
|
let nl = last_index_of(chunk, "\n");
|
|
if nl == -1 { return []; }
|
|
let lines = split(substr(chunk, 0, nl + 1), "\n");
|
|
pop(lines); -- empty piece after the final newline
|
|
if start > 0 and len(lines) > 0 { shift(lines); } -- cut-off first line
|
|
if len(lines) > n { return slice(lines, len(lines) - n, len(lines)); }
|
|
return lines;
|
|
}
|