libs/runtime-overrides.lua
1-- {{{ runtime-overrides.lua
2-- One run's command-line choices, materialized to a file in RAM so that every
3-- short-lived child process of run.sh resolves them identically.
4--
5-- General description (for a CEO): run.sh launches a brand-new little program for
6-- each stage of the pipeline and shuts it down before the next. A choice the
7-- operator typed once -- "use THIS embedding model" -- only reached the stages
8-- run.sh happened to hand it to; the others quietly fell back to the default
9-- written in the config file. This module is a shared notepad in fast memory:
10-- run.sh writes the run's choices on it once at the start, and every stage reads
11-- the same note. It is rewritten from scratch on every run, so yesterday's note
12-- can never be mistaken for today's. An empty note (or a missing key) means "the
13-- operator chose nothing special here" -- so readers fall back to config.lua,
14-- exactly as before.
15--
16-- Format: a Lua file that returns a table, read back via dofile -- the same
17-- mechanism config.lua itself uses -- so there is no JSON dependency to carry.
18-- Why this design over an environment variable: an env var dies with the shell
19-- and reaches only children run.sh explicitly exports it to; a file is readable
20-- from any process, any working directory, and is inspectable with `cat`. The
21-- one hazard a file has and an env var does not -- staleness across runs -- is
22-- removed by run.sh overwriting it at startup (see scripts/write-run-overrides).
23-- }}}
24
25local M = {}
26
27-- {{{ Module state
28-- project_root: where tmp/ (and thus the notepad) lives. cache/loaded: the
29-- decoded table is read from disk at most once per process, then reused.
30local project_root = nil
31local cache = nil
32local loaded = false
33-- }}}
34
35-- {{{ local function resolve_root()
36-- Resolve the project root: an explicit set_project_root wins; otherwise infer
37-- it from package.path (the "/libs/?.lua" entry every caller installs), and only
38-- then fall back to the hard-coded path so a stray direct invocation still works.
39local function resolve_root()
40 if project_root then return project_root end
41 local path = package.path:match("([^;]+)/libs/%?%.lua")
42 project_root = path or "/mnt/mtwo/programming/ai-stuff/neocities-modernization"
43 return project_root
44end
45-- }}}
46
47-- {{{ function M.set_project_root(path)
48-- Idempotent: re-setting the same root is a no-op so callers can set it on every
49-- access without throwing away the per-process cache (and re-reading the file).
50function M.set_project_root(path)
51 if path == project_root then return end
52 project_root = path
53 cache = nil
54 loaded = false
55end
56-- }}}
57
58-- {{{ function M.path()
59-- The notepad lives in tmp/ (a tmpfs symlink): RAM, wiped on reboot -- which is
60-- exactly right, since one run's choices have no meaning after that run ends.
61function M.path()
62 return resolve_root() .. "/tmp/run-overrides.lua"
63end
64-- }}}
65
66-- {{{ local function serialize_value(value)
67-- Only the scalar kinds a CLI flag can carry are supported. Anything else is a
68-- programming error at the call site, so we error loudly rather than emit a file
69-- that would dofile() into something surprising.
70local function serialize_value(value)
71 local kind = type(value)
72 if kind == "string" then
73 return string.format("%q", value)
74 elseif kind == "number" or kind == "boolean" then
75 return tostring(value)
76 end
77 error("runtime-overrides: cannot serialize a value of type " .. kind)
78end
79-- }}}
80
81-- {{{ function M.write(overrides)
82-- Overwrite the notepad with exactly THIS run's overrides. Called once by run.sh
83-- at startup, so the file is never older than the current run -- a previous
84-- run's --model cannot survive into one that omits it. An empty table is a valid,
85-- meaningful result ("return {}"): a present-but-empty notepad says "this run set
86-- nothing special", and every reader then falls back to config.lua.
87--
88-- The tmp/ directory (a tmpfs symlink target wiped on reboot) must already exist;
89-- run.sh creates it just before calling the writer. If it does not, io.open
90-- returns nil and we error loudly rather than silently lose the note -- a missing
91-- notepad would reintroduce exactly the config-fallback bug this module fixes.
92function M.write(overrides)
93 overrides = overrides or {}
94 local lines = {}
95 for key, value in pairs(overrides) do
96 lines[#lines + 1] = string.format(" [%q] = %s,", key, serialize_value(value))
97 end
98 local body = table.concat({
99 "-- Auto-generated per run by run.sh (scripts/write-run-overrides).",
100 "-- This run's command-line choices, so every stage resolves them the same",
101 "-- way. Overwritten every run; safe to delete (the next run rewrites it).",
102 "return {",
103 table.concat(lines, "\n"),
104 "}",
105 "",
106 }, "\n")
107
108 local file, err = io.open(M.path(), "w")
109 if not file then
110 error("runtime-overrides: cannot write " .. M.path() .. ": " .. tostring(err))
111 end
112 file:write(body)
113 file:close()
114
115 -- Refresh the in-process view so a writer that also reads sees its own write.
116 loaded = false
117 cache = nil
118end
119-- }}}
120
121-- {{{ local function load()
122-- Read + decode the notepad at most once per process. A missing file (no run.sh
123-- wrote one -- e.g. a stage launched by hand) or a malformed one both resolve to
124-- an empty table, i.e. "no overrides", which is the safe, config-default answer.
125local function load()
126 if loaded then return cache end
127 loaded = true
128 local ok, result = pcall(dofile, M.path())
129 if ok and type(result) == "table" then
130 cache = result
131 else
132 cache = {}
133 end
134 return cache
135end
136-- }}}
137
138-- {{{ function M.get(key)
139-- Return the override for key, or nil when it was not set. An empty string is
140-- treated as "not set" so run.sh can pass `--model ""` (no override) without the
141-- writer needing to special-case it.
142function M.get(key)
143 local value = load()[key]
144 if value == nil or value == "" then return nil end
145 return value
146end
147-- }}}
148
149-- {{{ function M.all()
150-- The whole decoded table, for callers that want to inspect every override.
151function M.all()
152 return load()
153end
154-- }}}
155
156return M
157