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:
+1621
-1607
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 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 --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 --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 --rollback put the previous code back (kept in prev/) and restart
|
||||||
# zsh install.sh --remove stop it and remove the LaunchAgent (files stay)
|
# 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
|
if [[ $SITE_NEXT == 1 ]]; then SITE="$arg"; SITE_NEXT=0; continue; fi
|
||||||
case "$arg" in
|
case "$arg" in
|
||||||
--keep-config) KEEP_CONFIG=1 ;;
|
--keep-config) KEEP_CONFIG=1 ;;
|
||||||
|
--replace-config) REPLACE_CONFIG=1 ;;
|
||||||
--site) SITE_NEXT=1 ;;
|
--site) SITE_NEXT=1 ;;
|
||||||
--remove|--rollback|--now) ;;
|
--remove|--rollback|--now) ;;
|
||||||
*) echo "unknown option $arg" >&2; exit 2 ;;
|
*) echo "unknown option $arg" >&2; exit 2 ;;
|
||||||
@@ -151,6 +153,31 @@ then
|
|||||||
exit 3
|
exit 3
|
||||||
fi
|
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.
|
# 1. The config that will run, checked with the code that will run it.
|
||||||
mkdir -p "$DEST"
|
mkdir -p "$DEST"
|
||||||
if [[ $KEEP_CONFIG == 1 && -f "$DEST/config.json" ]]; then
|
if [[ $KEEP_CONFIG == 1 && -f "$DEST/config.json" ]]; then
|
||||||
|
|||||||
+11
-1
@@ -863,12 +863,22 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|||||||
return
|
return
|
||||||
if path == "/api/draft" and self.command == "PUT":
|
if path == "/api/draft" and self.command == "PUT":
|
||||||
cfg = schema.unmask(body.get("config") or {}, site.load())
|
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"))
|
draft, conflict = site.draft.update(cfg, who, site.cfg_sha, body.get("expect"))
|
||||||
if conflict:
|
if conflict:
|
||||||
changed = applying.changed_paths(cfg, conflict["config"])
|
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),
|
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]]})
|
"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":
|
if path == "/api/draft" and self.command == "DELETE":
|
||||||
site.draft.clear()
|
site.draft.clear()
|
||||||
self.discard_atem()
|
self.discard_atem()
|
||||||
|
|||||||
+12
-2
@@ -77,12 +77,15 @@
|
|||||||
async function loadAll() {
|
async function loadAll() {
|
||||||
const [c, s, d, b] = await Promise.all([api("/api/config"), api("/api/schema"), api("/api/draft"), api("/api/builders")]);
|
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;
|
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 || "";
|
document.getElementById("sitename").textContent = (state.live.site || {}).name || "";
|
||||||
badge();
|
badge();
|
||||||
}
|
}
|
||||||
function ensureDraft() {
|
function ensureDraft() {
|
||||||
if (!needEditing()) return false;
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
function changed() {
|
function changed() {
|
||||||
@@ -110,8 +113,15 @@
|
|||||||
state.saving = new Promise((done) => {
|
state.saving = new Promise((done) => {
|
||||||
state.saveTimer = setTimeout(async () => {
|
state.saveTimer = setTimeout(async () => {
|
||||||
try {
|
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;
|
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) {
|
} 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(); }
|
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(); }
|
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
|
sp.log.propagate = False
|
||||||
|
|
||||||
CONFIG = sp.load_config(os.path.join(ROOT, "config.json"))
|
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"]
|
RC = CONFIG["ready"]
|
||||||
WEDNESDAY = datetime(2026, 10, 14, 15, 0).timestamp() # the week before the plan: gaps are amber
|
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
|
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):
|
def test_phrases(self):
|
||||||
cfg = sp.load_config(os.path.join(ROOT, "config.json"))
|
cfg = sp.load_config(os.path.join(ROOT, "config.json"))
|
||||||
p = schema.phrases(cfg)
|
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["live"], "changes wait while a HyperDeck is recording")
|
||||||
self.assertEqual(p["hold"], "HOLD 2s")
|
self.assertEqual(p["hold"], "HOLD 2s")
|
||||||
self.assertEqual(sp.windows_phrase([{"days": ["sat", "sun"], "start": "18:00", "end": "00:30"}]),
|
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.assertEqual(len(v["changes"]), 1); self.assertTrue(v["changes"][0]["text"].startswith("computer Lighting PC › notes"))
|
||||||
self.put("/api/draft", {}, method="DELETE")
|
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):
|
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"))
|
json.dump({"fix-x": {"key": "hd2"}}, open(os.path.join(self.data, "fixes-running.json"), "w"))
|
||||||
try:
|
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).
|
# A Sunday 08:00 (inside the service window) and a Wednesday 15:00 (outside it).
|
||||||
SUNDAY = datetime(2026, 10, 11, 8, 0).timestamp()
|
SUNDAY = datetime(2026, 10, 11, 8, 0).timestamp()
|
||||||
CONFIG["auto_fix"]["log_dir"] = tempfile.mkdtemp(prefix="sp-autolog-") # never the real Desktop
|
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()
|
WEDNESDAY = datetime(2026, 10, 7, 15, 0).timestamp()
|
||||||
|
|
||||||
|
|
||||||
@@ -1447,7 +1449,8 @@ class TestFixes(unittest.TestCase):
|
|||||||
for t in self.engine.fix_threads:
|
for t in self.engine.fix_threads:
|
||||||
t.join(2)
|
t.join(2)
|
||||||
self.assertEqual(self.engine.running, {})
|
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):
|
def test_a_hyperdeck_switched_off_is_always_switched_back_on(self):
|
||||||
calls = []
|
calls = []
|
||||||
|
|||||||
Reference in New Issue
Block a user