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({