diff --git a/CLAUDE.md b/CLAUDE.md index a86b93ee..7341923b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -495,6 +495,15 @@ fix flows to every page automatically; two hand-written HTML pages never will. `Studio._urlSuppress`. URL writing is NOT a protocol mutation — never route it through `pushUndo`. Any NEW shareable state must flow through `encodeApp()` + `Studio.updateUrl` (never hand-build `location.search`). + **`?repo=` alone is a tab-scoped repo override** (LAB-227 Phase 4): `Studio.sessionRepo` + is parsed in the classic shell and `courseSettings()` returns it ahead of the stored + `studio_gh_repo` and the default (`Studio.DEFAULT_COURSE_REPO` = the private + `reiserlab/arena-experiments`; the Pattern Designer's `DEFAULT_DATA_REPO` and the + dashboard's `DEFAULT_REPO` carry the same string, pinned by `tests/test-studio-shell.js`). + Every repo consumer reads `courseSettings().repo` — never `localStorage` directly — so the + override wins for previews, Open from Repo, vocab, replay AND saves; it is never written to + storage, `encodeApp({sessionRepo})` keeps it across the canonical rewrite, and typing a + repo in ⚙ Settings ends it. - **Top bar: Protocol ▾ vs ⚙ Settings.** `Protocol ▾` (`#fileMenu`, renamed from File) holds protocol ACTIONS only (new/open/replay/save/promote/copy/reset/soak). `⚙ Settings` (`#settingsMenu`) holds the Studio/bench settings kept in the browser, not the YAML: the diff --git a/arena_studio.html b/arena_studio.html index 883d0026..3678e5fa 100644 --- a/arena_studio.html +++ b/arena_studio.html @@ -2390,7 +2390,7 @@ drives stays visible here (clicking opens ⚙ Settings). Text/title set by syncRigContext(). --> rig — - + @@ -2420,7 +2420,7 @@ - + @@ -2472,12 +2472,12 @@ - + -
-
+
+
- +
→ writes a local .yaml file (sign in to GitHub to open a PR instead)
@@ -2681,7 +2681,7 @@

Sequence · test any single condition ▶ Clear - + @@ -3551,22 +3551,47 @@

Import error

Studio.ghToken = function () { return sessionStorage.getItem('studio_gh_pat') || localStorage.getItem('studio_gh_pat') || null; }; - // Default course-data repo — pre-filled in the sign-in Repo field and used - // by courseSettings when the bench hasn't set its own, so the course flow - // (save/open-course/runlogs/roster/genotypes) targets it out of the box. - // Overridable: type another owner/name and it's stored in localStorage. - Studio.DEFAULT_COURSE_REPO = 'reiserlab/cshl-2026-course'; + // Default data repo (LAB-227, 2026-11): the lab's PRIVATE reiserlab/arena-experiments — + // pre-filled in the Repo field and used by courseSettings when this browser has not + // stored its own, so saves / Open from Repo / run logs / roster / vocab target it out + // of the box. Overridable: type another owner/name (the course benches and rig03-sr + // still write to reiserlab/cshl-2026-course) and it is stored in localStorage. The + // Pattern Designer (DEFAULT_DATA_REPO) and the dashboard (DEFAULT_REPO) carry the + // same string; tests/test-studio-shell.js pins the three equal. + Studio.DEFAULT_COURSE_REPO = 'reiserlab/arena-experiments'; + // ?repo=owner/name is a SESSION override of the stored repo for THIS TAB only (never + // written to localStorage — the Pattern Designer has the same rule). Parsed here, in + // the classic shell, so it is in force before the page-start listings run (ww18 + // 2026-10-29: signed out, `?repo=` wired the DEFAULT repo's shared pattern previews + // because the override existed only for `?repo=&p=` loads). Every consumer of the + // "course repo" goes through courseSettings(), so the override wins everywhere — + // reads AND writes, signed in or out. Typing a repo in ⚙ Settings ends it. + Studio.sessionRepo = (function () { + try { + const r = new URLSearchParams(location.search).get('repo'); + return r && /^([A-Za-z0-9-]{1,39})\/([A-Za-z0-9._-]{1,100})$/.test(r) && !r.includes('..') + ? r + : null; + } catch (_) { + return null; + } + })(); Studio.courseSettings = function () { - const raw = (localStorage.getItem('studio_gh_repo') || Studio.DEFAULT_COURSE_REPO).trim(); + const raw = ( + Studio.sessionRepo || + localStorage.getItem('studio_gh_repo') || + Studio.DEFAULT_COURSE_REPO + ).trim(); const m = /^([A-Za-z0-9-]{1,39})\/([A-Za-z0-9._-]{1,100})$/.exec(raw); return { repo: m && !raw.includes('..') ? { owner: m[1], name: m[2], full: raw } : null, direct: localStorage.getItem('studio_gh_direct') === '1', archivePatterns: localStorage.getItem('studio_archive_patterns') === '1', - benchId: (localStorage.getItem('studio_bench_id') || '').trim() + benchId: (localStorage.getItem('studio_bench_id') || '').trim(), + repoFromUrl: !!Studio.sessionRepo // this tab follows its ?repo= link }; }; - // RIG-ID: the instructor-set bench id — namespaces course-repo protocol + // RIG-ID: the rig id (localStorage key `studio_bench_id` keeps its historical name) — namespaces course-repo protocol // saves and run-log commits. Identity comes from THIS (not the controller // MAC, which only cross-checks it against roster.yaml at connect). Object.defineProperty(Studio, 'rigId', { @@ -4035,7 +4060,7 @@

Import error

// outcome is shown in the modal so a push is never silent/mysterious. showUploadModal('Uploading run log…', { busy: true, msg: 'preparing…' }); if (!cs.benchId) { - showUploadModal('Run log not committed', { error: true, msg: 'Bench id not set — see instructor (⚙ Settings → Bench id).' }); + showUploadModal('Run log not committed', { error: true, msg: 'Rig id not set — set it in ⚙ Settings → Rig id (🔓 to unlock).' }); return; } const bridge = Studio.session && Studio.session.bridge; @@ -4683,6 +4708,9 @@

Import error

// validated ?repo= load or an in-app course-repo open/save. repo: repoRef ? repoRef.repo : null, repoPath: repoRef ? repoRef.path : null, + // A bare ?repo= (tab-scoped repo override, see courseSettings) survives the + // canonical rewrite so a refresh keeps the tab on the requested repo. + sessionRepo: Studio.sessionRepo || null, // #135: only an EXPLICIT session-rig selection is shareable; a rig // derived from the loaded protocol is redundant with `p` (clean URL). rigKey: Studio.currentRig && Studio.currentRig.explicit ? Studio.currentRig.name : null, @@ -8872,14 +8900,14 @@

Import error

if (!cs.benchId) return; const mine = entries.find((p) => p && p.rig_id === cs.benchId); if (!mine) { - Studio.rawLog('course roster: no entry for bench id "' + cs.benchId + '" — experimenter not pre-filled', 'warn'); + Studio.rawLog('course roster: no entry for rig id "' + cs.benchId + '" — experimenter not pre-filled', 'warn'); return; } // Pre-fill (typo-proofing) — never clobber something already typed. const exp = $('mExp'); if (exp && !exp.value.trim() && (mine.id || mine.name)) { exp.value = mine.id || mine.name; - Studio.rawLog('experimenter pre-filled from roster: ' + exp.value + ' (bench ' + cs.benchId + ')', 'info'); + Studio.rawLog('experimenter pre-filled from roster: ' + exp.value + ' (rig ' + cs.benchId + ')', 'info'); Studio.updateRunGate(); } // MAC cross-check — bench id claims bench03 but the controller matches @@ -8890,8 +8918,8 @@

Import error

if (norm(mac) !== norm(mine.mac)) { chip.style.display = 'inline-block'; chip.title = 'Controller MAC ' + mac + ' ≠ roster entry for ' + cs.benchId + ' (' + mine.mac + - ') — a wrong bench id would namespace saves/run-logs under another bench. See instructor.'; - Studio.rawLog('⚠ roster: bench id ' + cs.benchId + ' lists MAC ' + mine.mac + ' but the controller reports ' + mac, 'warn'); + ') — a wrong rig id would namespace saves/run-logs under another rig.'; + Studio.rawLog('⚠ roster: rig id ' + cs.benchId + ' lists MAC ' + mine.mac + ' but the controller reports ' + mac, 'warn'); } } } @@ -8901,17 +8929,36 @@

Import error

// `genotypes:` list, same shape as configs/metadata/genotypes.yaml) becomes // the fly-genotype vocabulary for the session — replacing the lab-wide list // and repointing the "↗ source" link. Independent of bench id / roster. +// Per-rig vocabularies (LAB-227 Phase 4): a rig may keep its own list at +// protocols// (its protocol folder, already a readable prefix in +// js/studio-github.js); the repo ROOT file is the lab-wide fallback. A missing +// rig-level file is the normal case and stays silent — only a root miss is +// reported by the caller. Returns {ok, path, bytes} or {ok:false, status}. +async function readCourseVocabFile(cs, token, file) { + const paths = []; + if (cs.benchId && /^[A-Za-z0-9._-]+$/.test(cs.benchId)) paths.push('protocols/' + cs.benchId + '/' + file); + paths.push(file); + let last = null; + for (const path of paths) { + const res = await GH.runBytes(fetch, GH.reqGetContentsRaw(cs.repo.owner, cs.repo.name, path, null, token)); + if (res.ok) return { ok: true, path: path, bytes: res.bytes }; + last = res; + } + return { ok: false, status: last ? last.status : 0 }; +} async function fetchCourseGenotypes() { const cs = Studio.courseSettings(); const token = ghToken(); - if (!cs.repo || !token) return; // no course repo — keep the lab-wide list + if (!cs.repo || !token) return; // no data repo — keep the lab-wide list let list; + let src; try { - const res = await GH.runBytes(fetch, GH.reqGetContentsRaw(cs.repo.owner, cs.repo.name, 'genotypes.yaml', null, token)); + const res = await readCourseVocabFile(cs, token, 'genotypes.yaml'); if (!res.ok) { Studio.rawLog('course genotypes: no genotypes.yaml in ' + cs.repo.full + ' (HTTP ' + res.status + ') — using the lab list', 'warn'); return; } + src = res.path; const data = YAML.parse(new TextDecoder().decode(res.bytes)); list = (data && data.genotypes) || []; } catch (e) { @@ -8922,10 +8969,10 @@

Import error

renderGenoVocab(list); const link = $('mGenoSrc'); if (link) { - link.href = courseFileUrl(cs, 'genotypes.yaml'); - link.title = 'Course genotype list (' + cs.repo.full + '/genotypes.yaml)'; + link.href = courseFileUrl(cs, src); + link.title = 'Genotype list (' + cs.repo.full + '/' + src + ')'; } - Studio.rawLog('genotype list from the course repo (' + cs.repo.full + '/genotypes.yaml, ' + list.length + ' entries)', 'info'); + Studio.rawLog('genotype list from the data repo (' + cs.repo.full + '/' + src + ', ' + list.length + ' entries)', 'info'); } // ---- generic course-repo vocab (CLAUDE.md rule: connected repo is the source of @@ -8940,12 +8987,14 @@

Import error

const token = ghToken(); if (!cs.repo || !token) return; // not connected to a repo — keep the lab list let list; + let src; try { - const res = await GH.runBytes(fetch, GH.reqGetContentsRaw(cs.repo.owner, cs.repo.name, file, null, token)); + const res = await readCourseVocabFile(cs, token, file); // protocols// first, then root if (!res.ok) { Studio.rawLog('course ' + key + ': no ' + file + ' in ' + cs.repo.full + ' (HTTP ' + res.status + ') — using the lab list', 'warn'); return; } + src = res.path; const data = YAML.parse(new TextDecoder().decode(res.bytes)); list = (data && data[key]) || []; } catch (e) { @@ -8956,10 +9005,10 @@

Import error

apply(list); const link = $(srcId); if (link) { - link.href = courseFileUrl(cs, file); - link.title = 'Course ' + key + ' list (' + cs.repo.full + '/' + file + ')'; + link.href = courseFileUrl(cs, src); + link.title = key + ' list (' + cs.repo.full + '/' + src + ')'; } - Studio.rawLog(key + ' list from the course repo (' + cs.repo.full + '/' + file + ', ' + list.length + ' entries)', 'info'); + Studio.rawLog(key + ' list from the data repo (' + cs.repo.full + '/' + src + ', ' + list.length + ' entries)', 'info'); } // ---- pattern set / name picker (Console) + Run pattern resolution ---- @@ -11471,7 +11520,7 @@

Import error

if (!token) { Studio.showBanner('Sign in to GitHub first.'); return; } if (!cs.repo) { Studio.showBanner('Set the target repo (owner/name) in ⚙ Settings first.'); return; } if (!cs.benchId) { - Studio.showBanner('Course-repo save blocked: bench id not set — see instructor (⚙ Settings → Bench id).'); + Studio.showBanner('Repo save blocked: rig id not set — set it in ⚙ Settings → Rig id (🔓 to unlock).'); return; } if (!ensureSaveFilename(doc)) return; @@ -11685,7 +11734,7 @@

Import error

const saveAs = $('fmSaveAs'); if (direct) { $('ghDest').textContent = '→ commits straight to ' + cs.repo.full + ' /protocols/' + - (cs.benchId || '⚠ set bench id') + '/ (default branch, no PR)'; + (cs.benchId || '⚠ set rig id') + '/ (default branch, no PR)'; $('fmSave').textContent = 'Save → course repo'; if (saveAs) saveAs.textContent = 'Save as → course repo…'; } else if (pr) { @@ -11737,19 +11786,27 @@

Import error

const direct = $('ghDirect'); const bench = $('ghBenchId'); if (!repoIn || !direct || !bench) return; - repoIn.value = localStorage.getItem('studio_gh_repo') || Studio.DEFAULT_COURSE_REPO; + repoIn.value = Studio.sessionRepo || localStorage.getItem('studio_gh_repo') || Studio.DEFAULT_COURSE_REPO; + if (Studio.sessionRepo) { + repoIn.title += ' — this tab follows its ?repo= link (' + Studio.sessionRepo + '); typing another repo ends that'; + } direct.checked = localStorage.getItem('studio_gh_direct') === '1'; bench.value = localStorage.getItem('studio_bench_id') || ''; repoIn.addEventListener('change', () => { const v = repoIn.value.trim(); if (v && !(window.StudioUrlState && window.StudioUrlState.isSafeRepo(v))) { - Studio.showBanner('Repo must be owner/name — e.g. reiserlab/cshl-2026-course.'); - repoIn.value = localStorage.getItem('studio_gh_repo') || Studio.DEFAULT_COURSE_REPO; + Studio.showBanner('Repo must be owner/name — e.g. reiserlab/arena-experiments.'); + repoIn.value = Studio.sessionRepo || localStorage.getItem('studio_gh_repo') || Studio.DEFAULT_COURSE_REPO; return; } // '' (not removeItem): an explicit clear must survive reloads — only a - // never-set key gets the course-repo default seed above. + // never-set key gets the data-repo default seed above. localStorage.setItem('studio_gh_repo', v); + // Typing a repo ends this tab's ?repo= override (and drops it from the URL). + if (Studio.sessionRepo) { + Studio.sessionRepo = null; + if (Studio.updateUrl) Studio.updateUrl({ push: false }); + } updateSaveLabel(); // Repo changed → re-pull that repo's metadata (roster/genotypes/links). if (Studio.refreshCourseMeta) Studio.refreshCourseMeta(); @@ -11794,13 +11851,13 @@

Import error

if (signIn) { signIn.classList.toggle('gh-signin-while-locked', locked); signIn.title = locked - ? 'Sign in for course-repo read access; repo, bench, and write settings remain instructor-locked' + ? 'Sign in for data-repo read access; repo, rig id, and write settings remain locked (🔓 to edit)' : 'Sign in to GitHub with a personal access token (fine-grained for org members; classic for the shared course account) — see docs/development/data-repo-token-runbook.md'; } if (ghLock) { ghLock.textContent = locked ? '🔒' : '🔓'; ghLock.title = locked - ? 'Bench GitHub settings are LOCKED (kiosk-safe) — click to unlock and edit the token / repo / bench id' + ? 'Bench GitHub settings are LOCKED (kiosk-safe) — click to unlock and edit the token / repo / rig id' : 'Bench GitHub settings UNLOCKED — set them, then click to re-lock (locks again on reload)'; } } @@ -12235,7 +12292,15 @@

Import error

try { const res = await GH.run(fetch, GH.reqGetContents(repo.owner, repo.name, 'patterns', null, token)); if (gen !== sharedPreviewGen) return; // a newer listing (repo change / sign-in) was requested - if (!res.ok || !Array.isArray(res.data)) return; + if (!res.ok || !Array.isArray(res.data)) { + // Signed out, a private repo (the lab default) answers 404 — say so once, so a + // missing thumbnail is traceable to the sign-in state, not to the pattern. + if (!token && res && [401, 403, 404].includes(res.status)) { + Studio.rawLog('shared pattern library: ' + repo.full + '/patterns/ is not readable signed out (HTTP ' + + res.status + ') — sign in if this repository is private', 'info'); + } + return; + } const entries = res.data .filter((f) => f && f.type === 'file' && /\.pat$/i.test(f.name)) .map((f) => [f.name, () => repoFileBytes(repo, f.path)]); @@ -12246,7 +12311,7 @@

Import error

const sel = $('cPatName'); if (sel && sel.value) renderPatThumb(sel.value); if (Studio.editor && Studio.editor.repaintLibrary) Studio.editor.repaintLibrary(); - Studio.rawLog('course repo: ' + n + ' shared pattern preview(s) wired from patterns/', 'info'); + Studio.rawLog('shared pattern library: ' + n + ' preview(s) wired from ' + repo.full + '/patterns/', 'info'); } } catch (_) { /* missing/private/unavailable shared library — previews stay best-effort */ } } @@ -12489,7 +12554,7 @@

Import error

const token = ghToken(); const doc = Studio.currentDoc; if (!cs.repo || !token) { Studio.showBanner('Course repo + GitHub sign-in required to promote.'); return; } - if (!cs.benchId) { Studio.showBanner('Promote blocked: bench id not set — see instructor.'); return; } + if (!cs.benchId) { Studio.showBanner('Promote blocked: rig id not set (⚙ Settings → Rig id).'); return; } if (!doc || Studio.isUntitledDoc(doc)) { Studio.showBanner('Open (and save) a protocol first.'); return; } const base = GH.slug((doc.filename || 'protocol.yaml').replace(/\.ya?ml$/i, '')); const srcYaml = 'protocols/' + cs.benchId + '/' + base + '.yaml'; @@ -18085,7 +18150,7 @@

Import error

psEl.commitBtn.disabled = n === 0 || invalid > 0 || !dir; psEl.commitBtn.title = dir ? 'Direct-commit the set to ' + dir + '/ in the course repo (identical files skipped; a different file under an existing name refuses)' - : 'Save the protocol to the course repo first — the set commits into its _patterns/ folder (needs sign-in + bench id)'; + : 'Save the protocol to the course repo first — the set commits into its _patterns/ folder (needs sign-in + rig id)'; } } @@ -18131,7 +18196,7 @@

Import error

const token = ghToken(); if (!token || !cs.repo) { showError('Not signed in', 'Sign in to GitHub and set the course repo (⚙ Settings) first.'); return; } const dir = psProtoDir(); - if (!dir) { showError('No destination', 'Save the protocol to the course repo first — the set commits next to its YAML (needs a bench id).'); return; } + if (!dir) { showError('No destination', 'Save the protocol to the course repo first — the set commits next to its YAML (needs a rig id).'); return; } let bundle; try { bundle = window.PatternSet.buildBundle(psState.set); } catch (e) { showError('Set not exportable', e.message || String(e)); return; } @@ -18640,7 +18705,7 @@

Import error

// The two save-mode checkboxes: put the help on the whole label row. (function(){ var pr=document.getElementById('ghPR'), di=document.getElementById('ghDirect'); if(pr&&pr.closest('label')) pr.closest('label').setAttribute('data-help','Saving opens a Pull Request for review instead of writing directly.'); - if(di&&di.closest('label')) di.closest('label').setAttribute('data-help','Saving commits straight to the repo’s main branch — the usual course setup (each bench writes only its own files).'); + if(di&&di.closest('label')) di.closest('label').setAttribute('data-help','Saving commits straight to the repo’s main branch — the usual rig setup (each rig writes only its own files).'); })(); // Panel titles: student text beside the always-on engineer hover. var PANEL_HELP = { @@ -18731,7 +18796,10 @@

Import error

var q=[]; var arena=window.Studio && Studio.currentRig && Studio.currentRig.arenaConfig; if(arena) q.push('arena='+encodeURIComponent(arena)); - var repo=(localStorage.getItem('studio_gh_repo')||'').trim(); + // The effective data repo — default, stored, or this tab's ?repo= override — so the + // Designer's "Open from Repo" lists the same repo the Studio is on. + var cs=window.Studio && Studio.courseSettings ? Studio.courseSettings() : null; + var repo=cs && cs.repo ? cs.repo.full : ''; if(repo) q.push('repo='+encodeURIComponent(repo)); return 'pattern_editor.html'+(q.length?'?'+q.join('&'):''); } diff --git a/dashboard/data-browser/README.md b/dashboard/data-browser/README.md index 44010916..07157e87 100644 --- a/dashboard/data-browser/README.md +++ b/dashboard/data-browser/README.md @@ -128,9 +128,11 @@ relative-heading pages instead of failing import. ## Public page, private data -The dashboard itself is public. Runlogs live in `reiserlab/cshl-2026-course`, -which was private during the course and has been public since 2026-08 (reads work -signed-out; writes still need a token). +The dashboard itself is public. Runlogs live in the lab's private data repo +`reiserlab/arena-experiments` (the default in the repo field since LAB-227 Phase 4; +every read needs a token). The 2026 course's logs stay in `reiserlab/cshl-2026-course`, +public since 2026-08 (reads work signed-out) — type that repo into the field to browse +them. The sign-in flow uses: diff --git a/dashboard/data-browser/app.js b/dashboard/data-browser/app.js index 8242ac58..a3e16528 100644 --- a/dashboard/data-browser/app.js +++ b/dashboard/data-browser/app.js @@ -289,7 +289,7 @@ function updateGithubUi(user) { const repo = G.parseRepo(els.githubRepoInput.value); els.courseRepoLink.href = `https://github.com/${repo.full}`; } catch (_) { - els.courseRepoLink.href = 'https://github.com/reiserlab/cshl-2026-course'; + els.courseRepoLink.href = `https://github.com/${G.DEFAULT_REPO}`; } } @@ -951,7 +951,7 @@ async function browseGithub() { function normalizeDirectoryUrl(value) { const url = new URL( - A.safeText(value).trim() || '/cshl-2026-course/runlogs/', + A.safeText(value).trim() || '/arena-experiments/runlogs/', window.location.href ); if (!url.pathname.endsWith('/')) url.pathname += '/'; diff --git a/dashboard/data-browser/github-repo.js b/dashboard/data-browser/github-repo.js index fd3b1ada..aa2f70b5 100644 --- a/dashboard/data-browser/github-repo.js +++ b/dashboard/data-browser/github-repo.js @@ -7,7 +7,10 @@ const REPO_KEY = 'studio_gh_repo'; const BENCH_KEY = 'studio_bench_id'; const FOLDERS_KEY_PREFIX = 'dashboard_runlog_folders:'; - const DEFAULT_REPO = 'reiserlab/cshl-2026-course'; + // The lab's private data repo (LAB-227) — same default as Arena Studio and the + // Pattern Designer; the 2026 course's logs stay in reiserlab/cshl-2026-course + // (type it into the repo field). tests/test-studio-shell.js pins the three equal. + const DEFAULT_REPO = 'reiserlab/arena-experiments'; // Run logs are committed as `.jsonl.gz` since Studio v0.72; fetchRaw reads // BYTES and lets the shared format module inflate (magic-detected, so a raw // `.jsonl` still works). Vendored byte-identical copy of js/runlog-format.js. diff --git a/dashboard/data-browser/index.html b/dashboard/data-browser/index.html index 46a89c53..a6a226d2 100644 --- a/dashboard/data-browser/index.html +++ b/dashboard/data-browser/index.html @@ -21,7 +21,7 @@

Course Data Dashboard

- Course repo + Data repo
No data loaded @@ -50,7 +50,7 @@

Data

- + @@ -60,7 +60,7 @@

Data

Local server repository
- +
diff --git a/docs/development/data-repo-token-runbook.md b/docs/development/data-repo-token-runbook.md index 5549bb17..79025e23 100644 --- a/docs/development/data-repo-token-runbook.md +++ b/docs/development/data-repo-token-runbook.md @@ -1,9 +1,13 @@ # Data-repo tokens — renewal and setup runbook Arena Studio, the Pattern Designer and the data-browser dashboard write protocols, -patterns and run logs to a GitHub **data repo** (the CSHL course repo today; the lab's -`reiserlab/arena-experiments` next) using a personal access token pasted once per -browser (⚙ Settings → GitHub → **Sign in…**). The token is stored in that browser only +patterns and run logs to a GitHub **data repo** — the lab's private +`reiserlab/arena-experiments` by default (LAB-227 Phase 4, 2026-11); the CSHL course +benches and `rig03-sr` type `reiserlab/cshl-2026-course` into the Repo field instead — +using a personal access token pasted once per browser (⚙ Settings → GitHub → +**Sign in…**). A `?repo=owner/name` link overrides the stored repo for that tab only. +The rig's folder name is its **Rig id** (⚙ Settings; the course benches use +`bench01`…`bench07`). The token is stored in that browser only (`studio_gh_pat`, sessionStorage or localStorage) and sent only as an `Authorization` header. This page is the operational how-to; design background is in `studio-github-save-proposal.md`, the course-week checklist in @@ -38,9 +42,10 @@ the stored token; a save or run-log commit made with a dead token falls back to `ghp_…` value once; store it in the lab password manager. 4. On each bench browser (served from `http://127.0.0.1:8000/`, not `file://`): ⚙ Settings → **Sign in…** → paste → **YES** to "Remember this token" → 🛡 advanced - (instructor password) → 🔓 in the GitHub block → **Repo** `reiserlab/cshl-2026-course`, - **Bench id** `bench01`…`bench07` (must match `roster.yaml`), tick **Commit - directly to default branch** → 🔒. Connect the arena; the roster pre-fills the + (instructor password) → 🔓 in the GitHub block → **Repo** `reiserlab/cshl-2026-course` + (the field is pre-filled with the lab repo — overwrite it), **Rig id** + `bench01`…`bench07` (must match `roster.yaml`), tick **Commit directly to default + branch** → 🔒. Connect the arena; the roster pre-fills the experimenter. 5. Check: the run log shows `ages list from the course repo (… 5 entries)` and no "unreadable" lines; a test save lands in `protocols//`. @@ -63,8 +68,10 @@ the stored token; a save or run-log commit made with a dead token falls back to 6. In the Studio: ⚙ Settings → **Sign in…** → paste. Answer **YES** to "Remember" only on a rig computer you control or your own laptop; answer **NO** (session-only) on a shared or borrowed machine. **Sign out** clears the token everywhere. -7. Set **Repo** and your **Rig id** once (🔓 needed); saves go to `protocols//`, - run logs to `runlogs//`, and your commits carry your GitHub identity. +7. Set your **Rig id** once (🔓 needed; **Repo** is already `reiserlab/arena-experiments`); + saves go to `protocols//`, run logs to `runlogs//`, and your commits + carry your GitHub identity. A rig may keep its own genotype / age / sex / fly-number + lists at `protocols//.yaml`; the Studio reads those before the root files. Sharing across the lab needs no extra permission: everyone with a token sees every rig's folder, **Protocol ▾ → Promote to shared…** copies a protocol and its patterns into diff --git a/js/studio-replay.js b/js/studio-replay.js index 66f4233f..9da649fc 100644 --- a/js/studio-replay.js +++ b/js/studio-replay.js @@ -1246,7 +1246,18 @@ setProtoLine('Downloading ' + name + ' from ' + repo.full + '…'); const bytes = await readRepoBytes(repo, path); if (token !== R.loadToken) return false; - if (!bytes) throw new Error(path + ' was not found in ' + repo.full + '.'); + // Signed out, GitHub answers 404 for a private repo exactly as for a + // missing file — say so (the lab's default data repo is private). + if (!bytes) { + throw new Error( + path + + ' was not found in ' + + repo.full + + (ghToken() + ? '.' + : ' (HTTP 404) — sign in if this repository is private.') + ); + } const text = Fmt ? await Fmt.readRunlogText(bytes) : new TextDecoder().decode(bytes); @@ -2111,7 +2122,11 @@ if (!hit) { if (Studio.showBanner) Studio.showBanner( - 'Replay: run ' + s.runId + ' was not found in ' + repo.full + '.' + 'Replay: run ' + + s.runId + + ' was not found in ' + + repo.full + + (ghToken() ? '.' : ' — sign in if this repository is private.') ); return false; } diff --git a/js/studio-url-state.js b/js/studio-url-state.js index 25fb8800..c649979d 100644 --- a/js/studio-url-state.js +++ b/js/studio-url-state.js @@ -240,8 +240,9 @@ * is bench identity, NOT doc state — emitted whenever present (the caller * only passes an explicit selection), regardless of doc locality. `repo` * is emitted ONLY alongside a valid repo-path `p` (a repo ref without a - * document to point at is bench localStorage config, not URL state). - * @param {object} state {mode, p, lib, set, rig, repo, source?: 'local'|'committed'} + * document to point at is bench localStorage config, not URL state) — + * except `sessionRepo`, the tab-scoped override a bare ?repo= link set. + * @param {object} state {mode, p, lib, set, rig, repo, sessionRepo, source?: 'local'|'committed'} */ function encode(state) { const s = state || {}; @@ -261,6 +262,11 @@ } else if (!local && isSafeKey(s.p)) { params.set('p', s.p); } + // sessionRepo: the tab-scoped repo override (a bare ?repo=, Studio.sessionRepo) — + // kept so a refresh keeps the tab on the requested repo; only when no repo-bound + // doc or replay already names the repo above. + if (!replayMode && !repoMode && isSafeRepo(s.sessionRepo)) + params.set('repo', s.sessionRepo); if (!replayMode && isSafeKey(s.lib)) params.set('lib', s.lib); if (!replayMode && !local && isSafeKey(s.set)) params.set('set', s.set); if (isSafeKey(s.rig)) params.set('rig', s.rig); @@ -293,7 +299,10 @@ * `advanced` must be true ONLY when advanced mode is active AND was * URL-requested (the caller enforces this; a remembered-unlocked browser * passes false to keep a clean URL, mirroring the derived-rig rule). - * @param {object} app {mode, protocolKey, rigKey, repo, repoPath, advanced} + * `sessionRepo` is the tab-scoped repo override (Studio.sessionRepo, from a + * bare ?repo= link): emitted as `repo` only when no repo-bound doc or replay + * owns the link. + * @param {object} app {mode, protocolKey, rigKey, repo, repoPath, sessionRepo, advanced} * @returns {string} query string (leading '?', or '' when all defaults) */ function encodeApp(app) { @@ -323,6 +332,7 @@ return encode({ mode: a.mode, p: a.protocolKey || undefined, + sessionRepo: a.sessionRepo || undefined, rig: a.rigKey || undefined, advanced: a.advanced || undefined, soak: a.soak || undefined, diff --git a/pattern_editor.html b/pattern_editor.html index 934cba83..8d67ec6f 100644 --- a/pattern_editor.html +++ b/pattern_editor.html @@ -2551,7 +2551,7 @@ A protocol's pattern folder - protocols/<bench-id>/<protocol>_patterns/ — travels with that protocol (needs a bench id) + protocols/<rig-id>/<protocol>_patterns/ — travels with that protocol (needs a rig id) @@ -3582,9 +3582,13 @@ // ============================================================ let sessionRepo = null; // ?repo= override for this tab let _urlArenaLock = false; // ?arena= handover locks the config after initUI + // Same default as the Studio's DEFAULT_COURSE_REPO (LAB-227): the lab's private + // data repo, so a fresh browser lists it in Open from Repo… / Save to Repo… + // without a Studio visit; a stored repo or a ?repo= link overrides it. + const DEFAULT_DATA_REPO = 'reiserlab/arena-experiments'; function repoSettings() { - const raw = (sessionRepo || localStorage.getItem('studio_gh_repo') || '').trim(); + const raw = (sessionRepo || localStorage.getItem('studio_gh_repo') || DEFAULT_DATA_REPO).trim(); const rm = /^([A-Za-z0-9-]{1,39})\/([A-Za-z0-9._-]{1,100})$/.exec(raw); const ok = rm && !raw.includes('..'); return { @@ -3791,13 +3795,13 @@ if (!built) return; _pendingRepoSave = built; document.getElementById('repoSaveRepoLine').textContent = - 'Repo: ' + cs.repoRaw + (cs.benchId ? ' · bench ' + cs.benchId : ''); + 'Repo: ' + cs.repoRaw + (cs.benchId ? ' · rig ' + cs.benchId : ''); const protoRadio = document.getElementById('destProtocol'); const protoInput = document.getElementById('destProtoName'); protoRadio.disabled = !cs.benchId; protoRadio.closest('.dest-row').title = cs.benchId ? 'Commits next to a protocol so they travel together' - : 'Needs a bench id (set in Arena Studio → ⚙ Settings) — course benches only'; + : 'Needs a rig id (set in Arena Studio → ⚙ Settings → Rig id)'; if (!cs.benchId) document.getElementById('destLibrary').checked = true; protoInput.disabled = !protoRadio.checked; updateRepoSavePath(); diff --git a/tests/test-studio-shell.js b/tests/test-studio-shell.js index 793049e2..f5c7792d 100644 --- a/tests/test-studio-shell.js +++ b/tests/test-studio-shell.js @@ -17,6 +17,9 @@ const path = require('path'); const root = path.join(__dirname, '..'); const studio = fs.readFileSync(path.join(root, 'arena_studio.html'), 'utf8'); const replay = fs.readFileSync(path.join(root, 'js/studio-replay.js'), 'utf8'); +const designer = fs.readFileSync(path.join(root, 'pattern_editor.html'), 'utf8'); +const dashRepo = fs.readFileSync(path.join(root, 'dashboard/data-browser/github-repo.js'), 'utf8'); +const dashIndex = fs.readFileSync(path.join(root, 'dashboard/data-browser/index.html'), 'utf8'); let totalChecks = 0; let failures = 0; @@ -407,6 +410,76 @@ check( !/:root\[data-ui-theme="night"\] (img|canvas)\b/.test(studio) && studio.includes('filter:url(#uiNightRed)') ); +console.log('=== data repo defaults + rig id (LAB-227 Phase 4) ==='); +// ONE default string across the three tools: the lab's private data repo. The course +// repo stays reachable by typing it (course benches + rig03-sr still write there). +const DATA_REPO = 'reiserlab/arena-experiments'; +check( + 'Studio, Pattern Designer and dashboard default to the lab data repo', + studio.includes("Studio.DEFAULT_COURSE_REPO = '" + DATA_REPO + "';") && + designer.includes("const DEFAULT_DATA_REPO = '" + DATA_REPO + "';") && + dashRepo.includes("const DEFAULT_REPO = '" + DATA_REPO + "';") +); +check( + 'Studio Repo field + dashboard repo field show the data repo, not the course repo', + /id="ghRepoInput" placeholder="reiserlab\/arena-experiments"/.test(studio) && + /id="githubRepoInput" type="text" value="reiserlab\/arena-experiments"/.test(dashIndex) && + dashIndex.includes('href="https://github.com/reiserlab/arena-experiments"') +); +check( + 'the Pattern Designer falls back to the same default when no repo is stored', + designer.includes( + "(sessionRepo || localStorage.getItem('studio_gh_repo') || DEFAULT_DATA_REPO).trim()" + ) +); +check( + '?repo= is a tab-scoped override applied in courseSettings (wins signed in or out)', + studio.includes('Studio.sessionRepo = (function () {') && + studio.includes( + "Studio.sessionRepo ||\n localStorage.getItem('studio_gh_repo') ||\n Studio.DEFAULT_COURSE_REPO" + ) && + studio.includes('sessionRepo: Studio.sessionRepo || null,') && + !studio.includes("localStorage.setItem('studio_gh_repo', Studio.sessionRepo") +); +check( + 'typing a repo in ⚙ Settings ends the ?repo= override', + studio.includes('Studio.sessionRepo = null;') +); +check( + 'the shared-pattern listing names the repo it came from', + studio.includes( + "'shared pattern library: ' + n + ' preview(s) wired from ' + repo.full + '/patterns/'" + ) && !studio.includes("'course repo: ' + n + ' shared pattern preview(s)") +); +check( + 'Patterns ↗ passes the effective repo (default, stored, or ?repo=)', + studio.includes( + 'var cs=window.Studio && Studio.courseSettings ? Studio.courseSettings() : null;' + ) && studio.includes("var repo=cs && cs.repo ? cs.repo.full : '';") +); +check( + 'UI says "Rig id" (the storage key studio_bench_id keeps its name)', + /