Never undo settings applied on the page: sync from NA Ingest, rebase stale drafts, guard the installer
- config.json is NA Ingest's live settings: the READY hours (Tue 14-22, Wed 9-19, Sun 5-14) and the auto-fix hours (Tue 14-22, Wed 9-21, Sun 6:30-14) set on the settings page. - Settings page: a draft carries what its page started from. A tab that loaded the settings before they changed (an Apply elsewhere, a program update) now has only its own edits put on top of the live settings; before, it saved its whole older copy and Apply quietly undid the newer values (it took ready.prop_host out twice today). - install.sh refuses (exit 4) to replace live settings that came from an Apply (their fingerprint is a history entry's sha_after) unless --replace-config; --keep-config still updates code only. - Tests pin their own auto-fix and READY hours, so the campus can change its own; the exit-timeout test expects 310 s (the Tascam outlet power-cycle). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+1622
-1608
File diff suppressed because it is too large
Load Diff
+27
@@ -4,6 +4,7 @@
|
||||
#
|
||||
# zsh install.sh install / update and (re)start
|
||||
# zsh install.sh --keep-config update the code but leave the live config.json as it is
|
||||
# zsh install.sh --replace-config replace the live config even when it came from an Apply on the settings page
|
||||
# zsh install.sh --site <id> first install of a campus: its config from sites/<id>/, else sites/_template/
|
||||
# zsh install.sh --rollback put the previous code back (kept in prev/) and restart
|
||||
# zsh install.sh --remove stop it and remove the LaunchAgent (files stay)
|
||||
@@ -26,6 +27,7 @@ for arg in "$@"; do
|
||||
if [[ $SITE_NEXT == 1 ]]; then SITE="$arg"; SITE_NEXT=0; continue; fi
|
||||
case "$arg" in
|
||||
--keep-config) KEEP_CONFIG=1 ;;
|
||||
--replace-config) REPLACE_CONFIG=1 ;;
|
||||
--site) SITE_NEXT=1 ;;
|
||||
--remove|--rollback|--now) ;;
|
||||
*) echo "unknown option $arg" >&2; exit 2 ;;
|
||||
@@ -151,6 +153,31 @@ then
|
||||
exit 3
|
||||
fi
|
||||
|
||||
# 0b. Never undo an Apply. Live settings that came from the settings page (their fingerprint is the
|
||||
# "sha_after" of an entry in history/) and differ from the config this would install were changed on
|
||||
# the page since this folder was made: replacing them would quietly undo that (2026-10-15: READY hours
|
||||
# set on the page went back to the folder's defaults). Copy them into the folder first, or say so.
|
||||
if [[ $KEEP_CONFIG == 0 && ${REPLACE_CONFIG:-0} == 0 && -f "$DEST/config.json" && -d "$DEST/history" ]] \
|
||||
&& ! cmp -s "${SRC_CONFIG:-$SRC/config.json}" "$DEST/config.json" \
|
||||
&& /usr/bin/python3 -c '
|
||||
import glob, hashlib, json, os, sys
|
||||
d = sys.argv[1]
|
||||
live = hashlib.sha256(open(os.path.join(d, "config.json"), "rb").read()).hexdigest()
|
||||
for m in glob.glob(os.path.join(d, "history", "*.meta.json")):
|
||||
try:
|
||||
meta = json.load(open(m))
|
||||
except ValueError:
|
||||
continue
|
||||
if meta.get("sha_after") == live:
|
||||
print("the live settings come from the settings page (%s, %s): not replacing them." % (meta.get("by", "?"), os.path.basename(m)[:15]))
|
||||
sys.exit(0)
|
||||
sys.exit(1)' "$DEST"
|
||||
then
|
||||
echo "Copy them into this folder first (scp checklist-ingest:'Library/Application Support/status-panel/config.json' config.json)," >&2
|
||||
echo "or run with --keep-config (code only) or --replace-config (overwrite them)." >&2
|
||||
exit 4
|
||||
fi
|
||||
|
||||
# 1. The config that will run, checked with the code that will run it.
|
||||
mkdir -p "$DEST"
|
||||
if [[ $KEEP_CONFIG == 1 && -f "$DEST/config.json" ]]; then
|
||||
|
||||
+11
-1
@@ -863,12 +863,22 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
return
|
||||
if path == "/api/draft" and self.command == "PUT":
|
||||
cfg = schema.unmask(body.get("config") or {}, site.load())
|
||||
# A page that loaded the settings before they changed here (an Apply from another tab, a
|
||||
# program update) sends what it started from: only its own changes are put on top of the
|
||||
# live settings, so newer values are never quietly undone (2026-10-15).
|
||||
rebased = False
|
||||
if body.get("base") and body["base"] != site.cfg_sha and isinstance(body.get("base_config"), dict):
|
||||
base_cfg = schema.unmask(body["base_config"], site.load())
|
||||
cfg, _ = applying.rebase({"config": cfg}, site.load(), base_cfg)
|
||||
rebased = True
|
||||
log.info("draft from %s rebased: the page started from %s, the settings are now %s",
|
||||
who, str(body["base"])[:10], (site.cfg_sha or "")[:10])
|
||||
draft, conflict = site.draft.update(cfg, who, site.cfg_sha, body.get("expect"))
|
||||
if conflict:
|
||||
changed = applying.changed_paths(cfg, conflict["config"])
|
||||
return self.send(409, {"error": "%s changed this a moment ago" % conflict.get("by", "someone"), "draft": self.masked_draft(conflict),
|
||||
"changed": [applying.words_for(p, a, b, cfg, conflict["config"])["where"] for p, a, b in changed[:5]]})
|
||||
return self.send(200, {"draft": self.masked_draft(draft)})
|
||||
return self.send(200, {"draft": self.masked_draft(draft), "rebased": rebased, "live_sha": site.cfg_sha})
|
||||
if path == "/api/draft" and self.command == "DELETE":
|
||||
site.draft.clear()
|
||||
self.discard_atem()
|
||||
|
||||
+12
-2
@@ -77,12 +77,15 @@
|
||||
async function loadAll() {
|
||||
const [c, s, d, b] = await Promise.all([api("/api/config"), api("/api/schema"), api("/api/draft"), api("/api/builders")]);
|
||||
state.live = c.config; state.liveSha = c.sha; state.schema = s; state.draft = d.draft; state.draftStale = !!d.stale; state.atemPending = !!d.atem_pending; state.builders = b;
|
||||
// What this page's draft was made from: a save carries it, so the Mac can put the change on top of
|
||||
// settings that moved on meanwhile (an Apply elsewhere, a program update) instead of undoing them.
|
||||
if (state.draft && !state.draft.base && !d.stale) { state.draft.base = c.sha; state.draft.baseConfig = deep(c.config); }
|
||||
document.getElementById("sitename").textContent = (state.live.site || {}).name || "";
|
||||
badge();
|
||||
}
|
||||
function ensureDraft() {
|
||||
if (!needEditing()) return false;
|
||||
if (!state.draft) state.draft = { config: deep(state.live), version: null, by: state.name };
|
||||
if (!state.draft) state.draft = { config: deep(state.live), version: null, by: state.name, base: state.liveSha, baseConfig: deep(state.live) };
|
||||
return true;
|
||||
}
|
||||
function changed() {
|
||||
@@ -110,8 +113,15 @@
|
||||
state.saving = new Promise((done) => {
|
||||
state.saveTimer = setTimeout(async () => {
|
||||
try {
|
||||
const r = await post("/api/draft", { config: state.draft.config, expect: state.draft.version }, "PUT");
|
||||
const r = await post("/api/draft", { config: state.draft.config, expect: state.draft.version, base: state.draft.base, base_config: state.draft.base ? state.draft.baseConfig : undefined }, "PUT");
|
||||
state.draft.version = r.draft.version; state.draft.by = r.draft.by; state.draft.started = r.draft.started; state.draft.started_by = r.draft.started_by;
|
||||
if (r.rebased) { // the settings moved on since this page loaded: keep both
|
||||
const c = await api("/api/config");
|
||||
state.live = c.config; state.liveSha = c.sha;
|
||||
state.draft.config = r.draft.config; state.draft.base = c.sha; state.draft.baseConfig = deep(c.config);
|
||||
badge(); route();
|
||||
alertBox("The settings changed on the Mac since this page loaded (an Apply elsewhere, or a program update). Your changes were put on top of the new settings; nothing of either was lost.");
|
||||
} else if (r.live_sha) { state.draft.base = r.live_sha; }
|
||||
} catch (e) {
|
||||
if (e.status === 409) { alertBox(`${e.body.error}: ${(e.body.changed || []).join(", ") || "the draft"}. The page now shows their version.`); state.draft = e.body.draft; route(); }
|
||||
else if (e.status === 401 || e.status === 403) { alertBox(e.message); state.editing = null; state.name = null; store.set("sp-code", null); showEditing(); }
|
||||
|
||||
@@ -19,6 +19,8 @@ sp.log.addHandler(logging.NullHandler())
|
||||
sp.log.propagate = False
|
||||
|
||||
CONFIG = sp.load_config(os.path.join(ROOT, "config.json"))
|
||||
# The tests pin their own READY hours (the campus sets its own on the settings page).
|
||||
CONFIG["ready"]["due"] = [{"days": ["sat"], "start": "12:00", "end": "23:59"}, {"days": ["sun"], "start": "00:00", "end": "13:30"}]
|
||||
RC = CONFIG["ready"]
|
||||
WEDNESDAY = datetime(2026, 10, 14, 15, 0).timestamp() # the week before the plan: gaps are amber
|
||||
SATURDAY = datetime(2026, 10, 17, 16, 0).timestamp() # inside "due": gaps are red
|
||||
|
||||
@@ -295,7 +295,9 @@ class TestSchemaMatchesCode(unittest.TestCase):
|
||||
def test_phrases(self):
|
||||
cfg = sp.load_config(os.path.join(ROOT, "config.json"))
|
||||
p = schema.phrases(cfg)
|
||||
self.assertEqual(p["auto_fix"], "may run by itself on Sundays 06:30-13:30")
|
||||
self.assertEqual(p["auto_fix"], "may run by itself " + sp.windows_phrase(cfg["auto_fix"]["windows"])) # the campus's own hours
|
||||
self.assertEqual(schema.phrases(dict(cfg, auto_fix={"windows": [{"days": ["sun"], "start": "06:30", "end": "13:30"}]}))["auto_fix"],
|
||||
"may run by itself on Sundays 06:30-13:30")
|
||||
self.assertEqual(p["live"], "changes wait while a HyperDeck is recording")
|
||||
self.assertEqual(p["hold"], "HOLD 2s")
|
||||
self.assertEqual(sp.windows_phrase([{"days": ["sat", "sun"], "start": "18:00", "end": "00:30"}]),
|
||||
|
||||
@@ -291,6 +291,27 @@ class TestServer(unittest.TestCase):
|
||||
self.assertEqual(len(v["changes"]), 1); self.assertTrue(v["changes"][0]["text"].startswith("computer Lighting PC › notes"))
|
||||
self.put("/api/draft", {}, method="DELETE")
|
||||
|
||||
def test_a_page_that_loaded_before_a_change_never_undoes_it(self):
|
||||
"""2026-10-15: a tab loaded the settings, a program update then added ready.prop_host, the tab
|
||||
changed one window and saved its whole (older) copy - Apply took prop_host out again. Now the
|
||||
page says what it started from and only its own change goes on top of the live settings."""
|
||||
import hashlib
|
||||
before = self.site.load(); base_sha = self.site.cfg_sha
|
||||
newer = json.loads(json.dumps(before)); newer["debounce_seconds"] = (before.get("debounce_seconds") or 3) + 2
|
||||
sp.write_json(self.site.config_path, newer) # changed under the page
|
||||
self.site.load()
|
||||
page = json.loads(json.dumps(before)); page["flap"] = dict(before.get("flap") or {}, count=7) # the page's own edit
|
||||
st, r = self.put("/api/draft", {"config": page, "base": base_sha, "base_config": before}, who="christian")
|
||||
self.assertTrue(r["rebased"])
|
||||
d = self.site.draft.read()["config"]
|
||||
self.assertEqual(d["flap"]["count"], 7) # the page's change
|
||||
self.assertEqual(d["debounce_seconds"], newer["debounce_seconds"]) # the newer value kept
|
||||
st, r = self.put("/api/draft", {"config": d, "base": self.site.cfg_sha, "base_config": d}, who="christian")
|
||||
self.assertFalse(r["rebased"]) # up to date: nothing to do
|
||||
self.put("/api/draft", {}, method="DELETE")
|
||||
sp.write_json(self.site.config_path, before)
|
||||
self.site.load()
|
||||
|
||||
def test_probe_is_refused_while_a_fix_runs(self):
|
||||
json.dump({"fix-x": {"key": "hd2"}}, open(os.path.join(self.data, "fixes-running.json"), "w"))
|
||||
try:
|
||||
|
||||
@@ -30,6 +30,8 @@ CONFIG["tiles"] = [dict(t, label="SERVICE") if t["id"] == "service" else t for t
|
||||
# A Sunday 08:00 (inside the service window) and a Wednesday 15:00 (outside it).
|
||||
SUNDAY = datetime(2026, 10, 11, 8, 0).timestamp()
|
||||
CONFIG["auto_fix"]["log_dir"] = tempfile.mkdtemp(prefix="sp-autolog-") # never the real Desktop
|
||||
# ...and their own auto-fix hours, so the campus can change its own on the settings page.
|
||||
CONFIG["auto_fix"]["windows"] = [{"days": ["wed"], "start": "16:00", "end": "21:00"}, {"days": ["sun"], "start": "06:30", "end": "13:30"}]
|
||||
WEDNESDAY = datetime(2026, 10, 7, 15, 0).timestamp()
|
||||
|
||||
|
||||
@@ -1447,7 +1449,8 @@ class TestFixes(unittest.TestCase):
|
||||
for t in self.engine.fix_threads:
|
||||
t.join(2)
|
||||
self.assertEqual(self.engine.running, {})
|
||||
self.assertEqual(sp.exit_timeout(CONFIG), 90) # the longest fix + its verify + 10
|
||||
# the longest fix + its verify + 10: the Tascam outlet power-cycle (timeout 300)
|
||||
self.assertEqual(sp.exit_timeout(CONFIG), 310)
|
||||
|
||||
def test_a_hyperdeck_switched_off_is_always_switched_back_on(self):
|
||||
calls = []
|
||||
|
||||
Reference in New Issue
Block a user