Project homepage Mailing List  Warmcat.com  API Docs  Github Mirror 
    npro  
 Modern all-safe Rust Network Protocol library supporting h1, h2, h3, ws, wt sans-IO and with socket IO + tls
git clone https://npro.rs/repo/npro
 
root / READMEs / README-builder-env.md
Author[]Andy Green <andy@warmcat.com> 2026-09-10 07:23 UTC
Committer[]Andy Green <andy@warmcat.com> 2026-09-10 07:23 UTC
Tree3aeb6236a32600998ecf646288abe30e3bd65035   Raw Patch
 
assets: ad-hoc build dialog from the task context menu
assets: ad-hoc build dialog from the task context menu

Admins get an "Ad-hoc build from this task…" entry on the task context
menu.  The dialog it opens is prefilled from sai-web's cloneinfo reply:
branch defaulting to the most recently pushed "_" scratch branch (with the
hash it would build shown underneath), and the seed task's expanded build
steps for editing.  Submitting sends a taskclone; the resulting single-task
event shows up through the normal eventchange path.

Ad-hoc events are drawn with a dashed border in the sidebar list, the
tasks header (with an "ad-hoc" tag) and the event summary.

Everything is built with DOM calls and classes, no inline styles or
scripts, to stay within the strict CSP.  Asset version bumped.

Documented in READMEs/README-adhoc-builds.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
diff --git a/READMEs/README-adhoc-builds.md b/READMEs/README-adhoc-builds.md new file mode 100644 index 0000000..8591316 --- /dev/null +++ b/READMEs/README-adhoc-builds.md @@ -0,0 +1,112 @@ +# Ad-hoc builds + +Normally every push to a watched repo makes the git hook POST a signed +notification carrying the tree's `.sai.json`, and sai-server expands that +into an event with one task per configuration per platform. + +Ad-hoc builds are the manual counterpart: from the web UI an admin picks an +existing task as a seed, optionally edits its build steps, chooses which +branch's head to build, and sai-server creates a new event containing just +that one task. It runs on the real builders like any other task, but it +doesn't count as CI for the branch. + +The intended workflow is + + - push the tree you want to test to a scratch branch whose name begins + with `_`, eg, `_temp` + + - in the web UI, find a recent event for the project, right-click the task + for the build dimension and platform you want, and choose + "Ad-hoc build from this task…" + + - the dialog defaults to the most recently pushed `_` branch; adjust the + build steps if needed and click Schedule + + - a new single-task event appears for the scratch branch, marked with a + dashed border + +## Scratch branches + +A branch whose name begins with `_` is treated as scratch: sai-server records +the push (repo, ref, hash) in its `pushes` table but does not schedule the +`.sai.json` for it. So pushing to `_temp` costs nothing on the builders, +and the ad-hoc dialog can offer "the head of `_temp` as last pushed". + +The git hook must still fire for `_` branches for this to work; it is +sai-server that decides not to CI them. + +The `pushes` table is updated for every authenticated notification, whatever +the ref, so an ad-hoc build can also be pointed at, eg, `refs/heads/main`. +For a ref with no recorded push (an installation that predates the table), +sai-server falls back to the newest non-deleted event on that ref. + +## What the new task inherits + +From the seed task: the platform, the `.sai.json` configuration name (build +dimension), package deps, artifacts list and log limit. So the new task is +shown and searched like any other task of that dimension. + +From the seed task's event: the repo name and its fetch / web URLs. The +browser never supplies a repo or a hash, only the seed task uuid, a ref and +the build script; sai-server resolves the ref to a hash itself. + +The build script is the seed's *expanded* per-platform script, ie, with the +configuration's `${cmake}` etc already substituted, which is what the builder +actually ran. Edit it freely; it is split into steps one per line, as usual. +It is limited to 4000 bytes. + +Everything else is fresh: uuids, artifact nonces, state. The seed's event is +not modified. + +## Authorization + +Only browsers whose websocket was established with the front-end lws-login +interceptor's admin verdict (`x-lws-login-admin: 1`, ie, the user holds the +service's admin grant or the `*` grant) see the context menu entry, and +sai-web refuses `com.warmcat.sai.cloneinfo` and `com.warmcat.sai.taskclone` +from anyone else, the same way it does for task reset and event delete. +See README-auth.md and the comments in `etc-sai-EXAMPLE/web/conf.d/unixskt`. + +## Effects on the rest of sai + +Events created this way have `adhoc = 1` in the `events` table (the column is +added at startup on existing databases). Ad-hoc events are + + - excluded from the notification dedupe on hash, so a later real push of the + same commit still gets its normal CI event + + - excluded from the project head status badge (`/status/<project>`) + + - shown with a dashed border in the sidebar event list, the event header + and the event summary, with an "ad-hoc" tag in the header + +They can be reset, deleted and inspected exactly like any other event. + +## Protocol + +Browser -> sai-web: + +``` +{ "schema": "com.warmcat.sai.cloneinfo", "uuid": "<seed task uuid>" } +``` + +sai-web -> browser (answered locally from the databases): + +``` +{ "schema": "com.warmcat.sai.cloneinfo", + "seed_uuid": "...", "repo_name": "...", "ref": "<seed event ref>", + "taskname": "...", "platform": "...", "build": "...", + "refs": [ { "ref": "refs/heads/_temp", "hash": "..." }, ... ] } +``` + +`refs` lists the project's `_` branches, most recently pushed first. + +Browser -> sai-web -> sai-server: + +``` +{ "schema": "com.warmcat.sai.taskclone", + "seed_uuid": "...", "ref": "refs/heads/_temp", "build": "..." } +``` + +sai-server announces the new event with the usual `sai-eventchange`, so it +appears in connected browsers without a reload. diff --git a/assets/index.html b/assets/index.html index 45b540b..1c4d949 100644 --- a/assets/index.html +++ b/assets/index.html @@ -2,9 +2,9 @@ <html lang="en"> <head> <meta charset=utf-8 http-equiv="Content-Language" content="en"/> - <link rel="stylesheet" type="text/css" href="sai.css?v=11"/> + <link rel="stylesheet" type="text/css" href="sai.css?v=12"/> <link rel="icon" href="sai-icon.svg" sizes="any" type="image/svg+xml"/> - <script type='text/javascript' src='sai.js?v=11'></script> + <script type='text/javascript' src='sai.js?v=12'></script> <script type='text/javascript' src='terminal-core.js'></script> <script type='text/javascript' src='sai-terminal.js'></script> <script type='text/javascript' src='lws-login.js'></script> diff --git a/assets/sai.css b/assets/sai.css index 6f498c6..804a9a1 100644 --- a/assets/sai.css +++ b/assets/sai.css @@ -1018,6 +1018,174 @@ body.overlay-active { overflow: hidden; } +/* + * Ad-hoc events: single-task events an admin seeded from an existing task. + * Marked with a dashed border wherever the event appears so they read as + * scratch builds rather than the state of the branch. + */ +.sb-event-row.adhoc { + border: 1px dashed #7c3aed; +} + +.sb-event-row.adhoc.selected { + border: 2px dashed #7c3aed; +} + +.event-tasks-header.adhoc { + border: 1px dashed #7c3aed; + border-radius: 4px; +} + +table.comp.adhoc { + border: 1px dashed #7c3aed; +} + +.adhoc-tag { + display: inline-block; + font-size: 7pt; + font-weight: normal; + color: #7c3aed; + border: 1px dashed #7c3aed; + border-radius: 3px; + padding: 0 4px; + margin-left: 4px; + vertical-align: middle; +} + +/* modal dialog, used by the ad-hoc build dialog */ + +.sai-modal-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.4); + z-index: 4000; + display: flex; + align-items: center; + justify-content: center; +} + +.sai-modal { + background: #ffffff; + border: 1px solid #94a3b8; + border-radius: 6px; + box-shadow: 0 6px 24px rgba(0, 0, 0, 0.3); + padding: 12px 16px; + width: 90%; + max-width: 760px; + max-height: 90%; + overflow: auto; + font-size: 9pt; + color: #1e293b; + display: flex; + flex-direction: column; + gap: 6px; +} + +.sai-modal-title { + font-size: 11pt; + font-weight: bold; +} + +.sai-modal-sub { + font-size: 8pt; + color: #64748b; +} + +/* the global label rule floats labels into a narrow column; undo that here */ +.sai-modal-label { + float: none; + display: block; + width: auto; + margin: 6px 0 0 0; + font-size: 8.5pt; + color: #334155; +} + +.sai-modal-row { + display: flex; + flex-direction: column; + gap: 2px; +} + +.sai-modal-input { + font-family: monospace; + font-size: 9pt; + padding: 3px 5px; + border: 1px solid #cbd5e1; + border-radius: 3px; +} + +.sai-modal-hash { + font-family: monospace; + font-size: 7.5pt; + color: #64748b; + min-height: 1.2em; +} + +.sai-modal-textarea { + font-family: monospace; + font-size: 8.5pt; + width: 100%; + box-sizing: border-box; + min-height: 220px; + resize: vertical; + border: 1px solid #cbd5e1; + border-radius: 3px; + padding: 4px; + white-space: pre; + overflow-wrap: normal; + overflow-x: auto; +} + +.sai-modal-note { + font-size: 7.5pt; + color: #64748b; + text-align: right; +} + +.sai-modal-note.over { + color: #df3030; +} + +.sai-modal-error { + font-size: 8pt; + color: #df3030; + min-height: 1.2em; +} + +.sai-modal-buttons { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 4px; +} + +.sai-modal-button { + font-size: 9pt; + padding: 4px 12px; + border: 1px solid #94a3b8; + border-radius: 3px; + background: #f1f5f9; + cursor: pointer; +} + +.sai-modal-button:hover { + background: #e2e8f0; +} + +.sai-modal-button.primary { + background: #7c3aed; + border-color: #6d28d9; + color: #ffffff; +} + +.sai-modal-button.primary:hover { + background: #6d28d9; +} + .spreadsheet { margin-top: 2px; font-size: 7pt; diff --git a/assets/sai.js b/assets/sai.js index 072056d..2cbacfb 100644 --- a/assets/sai.js +++ b/assets/sai.js @@ -1461,6 +1461,8 @@ function sai_event_summary_render(o, now_ut, reset_all_icon) s += " comp_pass"; if (e.state == 4 || e.state == 6) s += " comp_fail"; + if (e.adhoc) + s += " adhoc"; s += "\"><tr><td class=\"jumble\"><a href=\"/sai/?event=" + san(e.uuid) + "\"><img src=\"/sai/sai-event.svg\""; @@ -1742,6 +1744,8 @@ function render_sb_events() if (e.state == 3) stateClass = " comp_pass"; if (e.state == 4 || e.state == 6) stateClass = " comp_fail"; var sel = (e.uuid === selected_event_uuid) ? " selected" : ""; + if (e.adhoc) + stateClass += " adhoc"; s += "<div class=\"sb-event-row" + stateClass + sel + "\" data-uuid=\"" + san(e.uuid) + "\">"; /* single line: when + tag + status + progress bar */ @@ -1934,6 +1938,7 @@ function render_selected_event_tasks(o) { s += "<div class=\"event-tasks-header"; if (e.state == 3) s += " comp_pass"; if (e.state == 4 || e.state == 6) s += " comp_fail"; + if (e.adhoc) s += " adhoc"; s += "\">"; var refName = e.ref.replace("refs/heads/", "").replace("refs/tags/", ""); s += "<span class=\"event-tasks-title\">" + @@ -1942,6 +1947,7 @@ function render_selected_event_tasks(o) { " - " + sai_weburl_link(e, "/log?id=" + encodeURIComponent(e.hash), sai_event_hash_display(e.hash)) + + (e.adhoc ? " <span class=\"adhoc-tag\">ad-hoc</span>" : "") + "</span>"; /* admin-only restart-all / delete-event controls live here now */ if (!gitohashi_integ && auth_state === SaiAuthState.LOGGED_IN_GRANT_ADMIN) { @@ -2332,6 +2338,224 @@ function refresh_state(t) +/* + * Ad-hoc build dialog + * + * Opened from the cloneinfo reply to the task context menu entry. Lets the + * admin pick which branch's head to build (defaulting to the most recently + * pushed scratch "_" branch) and edit the build steps, then submits a + * taskclone which sai-server turns into a new single-task event. + * + * The strict CSP forbids inline styles and scripts, so everything is built + * with DOM calls and styled by classes in sai.css. + */ + +/* keep under the 4096-byte array on the server side, with margin for UTF-8 */ +const SAI_ADHOC_BUILD_MAXLEN = 4000; + +var sai_adhoc_dialog = null; + +function sai_adhoc_dialog_close() +{ + if (!sai_adhoc_dialog) + return; + + document.removeEventListener("keydown", sai_adhoc_dialog.onkey, true); + if (document.body.contains(sai_adhoc_dialog.overlay)) + document.body.removeChild(sai_adhoc_dialog.overlay); + sai_adhoc_dialog = null; +} + +function sai_adhoc_el(tag, cls, text) +{ + var el = document.createElement(tag); + + if (cls) + el.className = cls; + if (typeof text !== "undefined") + el.textContent = text; + + return el; +} + +/* "refs/heads/x" -> "x", tags and other refs left alone but shortened */ +function sai_adhoc_ref_short(ref) +{ + if (ref.startsWith("refs/heads/")) + return ref.substring(11); + + return ref; +} + +/* accept "x" or "heads/x" as shorthand for "refs/heads/x" */ +function sai_adhoc_ref_full(ref) +{ + ref = ref.trim(); + if (!ref.length) + return ""; + if (ref.startsWith("refs/")) + return ref; + if (ref.startsWith("heads/") || ref.startsWith("tags/")) + return "refs/" + ref; + + return "refs/heads/" + ref; +} + +function sai_adhoc_dialog_open(info) +{ + sai_adhoc_dialog_close(); + + var refs = info.refs || []; + var overlay = sai_adhoc_el("div", "sai-modal-overlay"); + var dlg = sai_adhoc_el("div", "sai-modal"); + var listid = "sai-adhoc-refs"; + + dlg.appendChild(sai_adhoc_el("div", "sai-modal-title", + "Ad-hoc build: " + info.taskname + " on " + + info.platform)); + dlg.appendChild(sai_adhoc_el("div", "sai-modal-sub", + info.repo_name + ", seeded from " + + sai_adhoc_ref_short(info.ref) + " task " + + info.seed_uuid.substring(32, 40))); + + /* branch to build the head of */ + + var lab = sai_adhoc_el("label", "sai-modal-label", "Build head of branch"); + lab.htmlFor = "sai-adhoc-ref"; + dlg.appendChild(lab); + + var refrow = sai_adhoc_el("div", "sai-modal-row"); + var refin = sai_adhoc_el("input", "sai-modal-input"); + refin.type = "text"; + refin.id = "sai-adhoc-ref"; + refin.setAttribute("list", listid); + refin.setAttribute("autocomplete", "off"); + refin.spellcheck = false; + refin.placeholder = "refs/heads/_scratch"; + + var dl = document.createElement("datalist"); + dl.id = listid; + refs.forEach(function(r) { + var opt = document.createElement("option"); + opt.value = r.ref; + opt.label = sai_adhoc_ref_short(r.ref) + " " + + r.hash.substring(0, 8); + dl.appendChild(opt); + }); + refrow.appendChild(refin); + refrow.appendChild(dl); + + var hashnote = sai_adhoc_el("div", "sai-modal-hash", ""); + refrow.appendChild(hashnote); + dlg.appendChild(refrow); + + /* the server resolves the ref itself; this is just a preview */ + var update_hash = function() { + var full = sai_adhoc_ref_full(refin.value); + var m = refs.find(function(r) { return r.ref === full; }); + + if (m) + hashnote.textContent = "last pushed: " + m.hash; + else if (full === info.ref) + hashnote.textContent = "same branch as the seed task"; + else + hashnote.textContent = "not a scratch branch: sai-server " + + "uses the newest hash it was " + + "notified of for this branch"; + }; + refin.addEventListener("input", update_hash); + + /* + * Default to the most recently pushed scratch branch, else the seed's + * own branch + */ + refin.value = refs.length ? refs[0].ref : info.ref; + update_hash(); + + /* build steps */ + + lab = sai_adhoc_el("label", "sai-modal-label", "Build steps (one per line)"); + lab.htmlFor = "sai-adhoc-build"; + dlg.appendChild(lab); + + var ta = sai_adhoc_el("textarea", "sai-modal-textarea"); + ta.id = "sai-adhoc-build"; + ta.spellcheck = false; + ta.maxLength = SAI_ADHOC_BUILD_MAXLEN; + ta.value = info.build || ""; + dlg.appendChild(ta); + + var counter = sai_adhoc_el("div", "sai-modal-note", ""); + var update_counter = function() { + var n = new TextEncoder().encode(ta.value).length; + + counter.textContent = n + " / " + SAI_ADHOC_BUILD_MAXLEN + " bytes"; + counter.classList.toggle("over", n > SAI_ADHOC_BUILD_MAXLEN); + }; + ta.addEventListener("input", update_counter); + update_counter(); + dlg.appendChild(counter); + + var errline = sai_adhoc_el("div", "sai-modal-error", ""); + dlg.appendChild(errline); + + /* buttons */ + + var btns = sai_adhoc_el("div", "sai-modal-buttons"); + var cancel = sai_adhoc_el("button", "sai-modal-button", "Cancel"); + cancel.type = "button"; + cancel.addEventListener("click", sai_adhoc_dialog_close); + var go = sai_adhoc_el("button", "sai-modal-button primary", "Schedule"); + go.type = "button"; + go.addEventListener("click", function() { + var ref = sai_adhoc_ref_full(refin.value); + var build = ta.value; + + if (!ref.length || !/^refs\/[A-Za-z0-9_.\/-]+$/.test(ref) || + ref.indexOf("..") !== -1) { + errline.textContent = "Branch must be a plain ref name like refs/heads/_scratch"; + return; + } + if (!build.trim().length) { + errline.textContent = "Build steps can't be empty"; + return; + } + if (new TextEncoder().encode(build).length > SAI_ADHOC_BUILD_MAXLEN) { + errline.textContent = "Build steps too long"; + return; + } + + sai.send(JSON.stringify({ + schema: "com.warmcat.sai.taskclone", + seed_uuid: info.seed_uuid, + ref: ref, + build: build + })); + sai_adhoc_dialog_close(); + }); + btns.appendChild(cancel); + btns.appendChild(go); + dlg.appendChild(btns); + + overlay.appendChild(dlg); + overlay.addEventListener("click", function(ev) { + if (ev.target === overlay) + sai_adhoc_dialog_close(); + }); + + var onkey = function(ev) { + if (ev.key === "Escape") { + ev.preventDefault(); + sai_adhoc_dialog_close(); + } + }; + document.addEventListener("keydown", onkey, true); + + sai_adhoc_dialog = { overlay: overlay, onkey: onkey }; + document.body.appendChild(overlay); + refin.focus(); +} + function createContextMenu(event, menuItems) { event.preventDefault(); @@ -3892,6 +4116,10 @@ function ws_open_sai() console.log("no spreadsheetContainer"); break; + case "com.warmcat.sai.cloneinfo": + sai_adhoc_dialog_open(jso); + break; + case "com.warmcat.sai.unauthorized": location.reload(); break; @@ -4475,6 +4703,20 @@ window.addEventListener("load", function() { } }, { + /* + * Seed a new single-task event from this + * one; sai-web answers with cloneinfo and + * we open the dialog from that + */ + label: "Ad-hoc build from this task…", + callback: () => { + sai.send(JSON.stringify({ + schema: "com.warmcat.sai.cloneinfo", + uuid: taskUuid + })); + } + }, + { label: "Remove all tries", callback: () => { sai.send(JSON.stringify({
Page fetched 0s ago, creation time: 5ms (vhost etag hits: 0%, cache hits: 0%)