OXYGENHUB

Run a pipeline's WASM jobs in the browser tab (refs andymccall/oxygenflow#80); runner builds shared with the swarm (refs andymccall/oxygenflow#84); 0.9.15 c26267b3aba6

Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 0caa65b1b111
When
· 2026-10-28 22:38 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
c26267b3aba6b2028df7ba79af75cc09348b112980d46d1e78b209cd446d5497
Depends on
1ec29691b67a 1edac4b68c6d 36e5a9c15a84 42fa95540468 5c6e92fd8d71 8475176972e8 8501fd5815b9 8933faff2043 b5668108487f d23464c0ac60 d7050703d2f4 e97500b70f9a fac6853c4e46
Depended on by
1062a0f8e985 40a43921ef42 27322cd2755a 2f79b17e089a
In bookmarks
main
In tags
v0.9.15 v0.9.16 v0.9.19 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24
Edits
22 atoms, 3 tree operations, 14 files

Browse the tree at this change

added assets/wasi-worker.js +400 −0
1 +
// OxygenFlow's WASM tier in a browser tab (oxygenflow#80): this worker
2 +
// unpacks a repository's zip archive into memory and runs a job's WASI
3 +
// preview 1 modules over it, as `flow` runs them with wasmtime: the files at
4 +
// /src (and as the current directory), the job's arguments and environment,
5 +
// stdout and stderr streamed back line by line, and the exit code.
6 +
//
7 +
// Messages in: { archive: ArrayBuffer, exec: [module, ...args], env: {k: v} }.
8 +
// Messages out: { kind: "note"|"out"|"err", text }, then
9 +
// { kind: "done", code, ms } or { kind: "failed", text }.
10 +
"use strict";
11 +
12 +
const E = { SUCCESS: 0, ACCES: 2, BADF: 8, EXIST: 20, INVAL: 28, ISDIR: 31, NOENT: 44, NOSYS: 52, NOTDIR: 54, NOTEMPTY: 55, NOTSUP: 58 };
13 +
const FT = { UNKNOWN: 0, CHAR: 2, DIR: 3, FILE: 4 };
14 +
const O = { CREAT: 1, DIRECTORY: 2, EXCL: 4, TRUNC: 8 };
15 +
const FD_APPEND = 1;
16 +
const RIGHTS = 0x1fffffffn;
17 +
18 +
class Exit {
19 +
  constructor(code) { this.code = code; }
20 +
}
21 +
22 +
// --- The archive -----------------------------------------------------------
23 +
24 +
// A zip's files by path, the first directory (the archive's prefix) taken
25 +
// off. Read through the central directory, so sizes written after the data
26 +
// are known; stored and deflated entries; symbolic links are left out.
27 +
async function unzip(buf) {
28 +
  const b = new DataView(buf);
29 +
  const bytes = new Uint8Array(buf);
30 +
  let end = -1;
31 +
  for (let i = buf.byteLength - 22; i >= Math.max(0, buf.byteLength - 65557); i--) {
32 +
    if (b.getUint32(i, true) === 0x06054b50) { end = i; break; }
33 +
  }
34 +
  if (end < 0) throw new Error("the archive is not a zip file");
35 +
  const count = b.getUint16(end + 10, true);
36 +
  let at = b.getUint32(end + 16, true);
37 +
  if (at === 0xffffffff) throw new Error("the archive is too large for this page (zip64)");
38 +
  const files = new Map();
39 +
  const dec = new TextDecoder();
40 +
  for (let n = 0; n < count; n++) {
41 +
    if (b.getUint32(at, true) !== 0x02014b50) throw new Error("the archive's directory is damaged");
42 +
    const method = b.getUint16(at + 10, true);
43 +
    const csize = b.getUint32(at + 20, true);
44 +
    const nlen = b.getUint16(at + 28, true), xlen = b.getUint16(at + 30, true), clen = b.getUint16(at + 32, true);
45 +
    const mode = b.getUint32(at + 38, true) >>> 16;
46 +
    const local = b.getUint32(at + 42, true);
47 +
    const name = dec.decode(bytes.subarray(at + 46, at + 46 + nlen));
48 +
    at += 46 + nlen + xlen + clen;
49 +
    if (name.endsWith("/") || (mode & 0o170000) === 0o120000) continue;
50 +
    const data = local + 30 + b.getUint16(local + 26, true) + b.getUint16(local + 28, true);
51 +
    const raw = bytes.subarray(data, data + csize);
52 +
    let content;
53 +
    if (method === 0) content = raw.slice();
54 +
    else if (method === 8) content = new Uint8Array(await new Response(new Blob([raw]).stream().pipeThrough(new DecompressionStream("deflate-raw"))).arrayBuffer());
55 +
    else throw new Error(`${name}: compression method ${method} is not read here`);
56 +
    const slash = name.indexOf("/");
57 +
    files.set(slash < 0 ? name : name.slice(slash + 1), content);
58 +
  }
59 +
  return files;
60 +
}
61 +
62 +
// --- The file system ---------------------------------------------------------
63 +
64 +
class Fs {
65 +
  constructor(files) {
66 +
    this.files = files; // path -> Uint8Array
67 +
    this.dirs = new Set([""]);
68 +
    for (const p of files.keys()) this.addParents(p);
69 +
  }
70 +
  addParents(p) {
71 +
    const parts = p.split("/");
72 +
    for (let i = 1; i < parts.length; i++) this.dirs.add(parts.slice(0, i).join("/"));
73 +
  }
74 +
  // `rel` against directory `dir`, or null when it leaves the root.
75 +
  join(dir, rel) {
76 +
    const out = rel.startsWith("/") ? [] : dir ? dir.split("/") : [];
77 +
    for (const part of rel.split("/")) {
78 +
      if (part === "" || part === ".") continue;
79 +
      if (part === "..") { if (!out.length) return null; out.pop(); } else out.push(part);
80 +
    }
81 +
    return out.join("/");
82 +
  }
83 +
  kind(p) {
84 +
    if (this.files.has(p)) return FT.FILE;
85 +
    if (this.dirs.has(p)) return FT.DIR;
86 +
    return null;
87 +
  }
88 +
  list(dir) {
89 +
    const prefix = dir ? dir + "/" : "";
90 +
    const names = new Set();
91 +
    for (const p of [...this.files.keys(), ...this.dirs]) {
92 +
      if (p !== dir && p.startsWith(prefix)) names.add(p.slice(prefix.length).split("/")[0]);
93 +
    }
94 +
    return [...names].sort();
95 +
  }
96 +
}
97 +
98 +
// The modules a job's first `exec` word names: one path, or a `*` pattern
99 +
// in its file name, as `flow` resolves them.
100 +
function modules(fs, program) {
101 +
  const rel = program.replace(/^\/src\//, "").replace(/^\.\//, "");
102 +
  if (!rel || rel.startsWith("/") || rel.split("/").includes("..")) throw new Error(`module ${program}: give a path inside the job's files`);
103 +
  const cut = rel.lastIndexOf("/");
104 +
  const dir = cut < 0 ? "" : rel.slice(0, cut), last = rel.slice(cut + 1);
105 +
  if (dir.includes("*")) throw new Error(`module ${program}: only the file name may hold *`);
106 +
  if (!last.includes("*")) {
107 +
    if (!fs.files.has(rel)) throw new Error(`module ${rel} is not among the repository's files (a build makes it?)`);
108 +
    return [rel];
109 +
  }
110 +
  const re = new RegExp("^" + last.split("*").map((s) => s.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join(".*") + "$");
111 +
  const found = fs.list(dir).filter((n) => re.test(n) && fs.files.has(dir ? `${dir}/${n}` : n)).map((n) => (dir ? `${dir}/${n}` : n));
112 +
  if (!found.length) throw new Error(`no module matches ${program}`);
113 +
  return found;
114 +
}
115 +
116 +
// --- WASI preview 1 ------------------------------------------------------------
117 +
118 +
function wasi(fs, argv, env, say) {
119 +
  let memory;
120 +
  const view = () => new DataView(memory.buffer);
121 +
  const u8 = () => new Uint8Array(memory.buffer);
122 +
  const enc = new TextEncoder(), dec = new TextDecoder();
123 +
  const str = (ptr, len) => dec.decode(u8().subarray(ptr, ptr + len));
124 +
  // Open files and directories: 0-2 the streams, 3 /src, 4 ".".
125 +
  const fds = new Map([
126 +
    [0, { stream: "in" }],
127 +
    [1, { stream: "out", partial: "" }],
128 +
    [2, { stream: "err", partial: "" }],
129 +
    [3, { dir: "", preopen: "/src" }],
130 +
    [4, { dir: "", preopen: "." }],
131 +
  ]);
132 +
  let next = 5;
133 +
  const emit = (fd, text) => {
134 +
    const f = fds.get(fd);
135 +
    f.partial += text;
136 +
    let nl;
137 +
    while ((nl = f.partial.indexOf("\n")) >= 0) {
138 +
      say(f.stream, f.partial.slice(0, nl));
139 +
      f.partial = f.partial.slice(nl + 1);
140 +
    }
141 +
  };
142 +
  const flush = () => { for (const fd of [1, 2]) { const f = fds.get(fd); if (f.partial) { say(f.stream, f.partial); f.partial = ""; } } };
143 +
  const filestat = (ptr, kind, size) => {
144 +
    const v = view();
145 +
    for (let i = 0; i < 64; i += 8) v.setBigUint64(ptr + i, 0n, true);
146 +
    v.setUint8(ptr + 16, kind);
147 +
    v.setBigUint64(ptr + 24, 1n, true);
148 +
    v.setBigUint64(ptr + 32, BigInt(size), true);
149 +
  };
150 +
  const iovs = (ptr, n) => {
151 +
    const v = view(), out = [];
152 +
    for (let i = 0; i < n; i++) out.push([v.getUint32(ptr + i * 8, true), v.getUint32(ptr + i * 8 + 4, true)]);
153 +
    return out;
154 +
  };
155 +
  const strings = (list) => list.map((s) => enc.encode(s + "\0"));
156 +
  const sizes = (list, countPtr, sizePtr) => {
157 +
    const v = view(), all = strings(list);
158 +
    v.setUint32(countPtr, all.length, true);
159 +
    v.setUint32(sizePtr, all.reduce((n, s) => n + s.length, 0), true);
160 +
    return E.SUCCESS;
161 +
  };
162 +
  const fill = (list, ptrs, buf) => {
163 +
    const v = view(), m = u8();
164 +
    for (const s of strings(list)) { v.setUint32(ptrs, buf, true); ptrs += 4; m.set(s, buf); buf += s.length; }
165 +
    return E.SUCCESS;
166 +
  };
167 +
  const envList = Object.entries(env).map(([k, v]) => `${k}=${v}`);
168 +
  const imports = {
169 +
    args_sizes_get: (c, s) => sizes(argv, c, s),
170 +
    args_get: (p, b) => fill(argv, p, b),
171 +
    environ_sizes_get: (c, s) => sizes(envList, c, s),
172 +
    environ_get: (p, b) => fill(envList, p, b),
173 +
    clock_time_get: (_id, _precision, out) => { view().setBigUint64(out, BigInt(Math.round(performance.timeOrigin + performance.now())) * 1000000n, true); return E.SUCCESS; },
174 +
    clock_res_get: (_id, out) => { view().setBigUint64(out, 1000n, true); return E.SUCCESS; },
175 +
    random_get: (ptr, len) => { crypto.getRandomValues(u8().subarray(ptr, ptr + len)); return E.SUCCESS; },
176 +
    sched_yield: () => E.SUCCESS,
177 +
    proc_exit: (code) => { throw new Exit(code); },
178 +
    fd_prestat_get: (fd, ptr) => {
179 +
      const f = fds.get(fd);
180 +
      if (!f || !f.preopen) return E.BADF;
181 +
      const v = view();
182 +
      v.setUint8(ptr, 0);
183 +
      v.setUint32(ptr + 4, enc.encode(f.preopen).length, true);
184 +
      return E.SUCCESS;
185 +
    },
186 +
    fd_prestat_dir_name: (fd, ptr, len) => {
187 +
      const f = fds.get(fd);
188 +
      if (!f || !f.preopen) return E.BADF;
189 +
      u8().set(enc.encode(f.preopen).subarray(0, len), ptr);
190 +
      return E.SUCCESS;
191 +
    },
192 +
    fd_fdstat_get: (fd, ptr) => {
193 +
      const f = fds.get(fd);
194 +
      if (!f) return E.BADF;
195 +
      const v = view();
196 +
      v.setUint8(ptr, f.stream ? FT.CHAR : f.dir !== undefined ? FT.DIR : FT.FILE);
197 +
      v.setUint16(ptr + 2, f.append ? FD_APPEND : 0, true);
198 +
      v.setBigUint64(ptr + 8, RIGHTS, true);
199 +
      v.setBigUint64(ptr + 16, RIGHTS, true);
200 +
      return E.SUCCESS;
201 +
    },
202 +
    fd_fdstat_set_flags: () => E.SUCCESS,
203 +
    fd_filestat_get: (fd, ptr) => {
204 +
      const f = fds.get(fd);
205 +
      if (!f) return E.BADF;
206 +
      if (f.stream) filestat(ptr, FT.CHAR, 0);
207 +
      else if (f.dir !== undefined) filestat(ptr, FT.DIR, 0);
208 +
      else filestat(ptr, FT.FILE, fs.files.get(f.path).length);
209 +
      return E.SUCCESS;
210 +
    },
211 +
    path_filestat_get: (fd, _flags, p, len, ptr) => {
212 +
      const d = fds.get(fd);
213 +
      if (!d || d.dir === undefined) return E.BADF;
214 +
      const path = fs.join(d.dir, str(p, len));
215 +
      if (path === null) return E.ACCES;
216 +
      const kind = fs.kind(path);
217 +
      if (kind === null) return E.NOENT;
218 +
      filestat(ptr, kind, kind === FT.FILE ? fs.files.get(path).length : 0);
219 +
      return E.SUCCESS;
220 +
    },
221 +
    path_open: (fd, _dirflags, p, len, oflags, _rb, _ri, fdflags, out) => {
222 +
      const d = fds.get(fd);
223 +
      if (!d || d.dir === undefined) return E.BADF;
224 +
      const path = fs.join(d.dir, str(p, len));
225 +
      if (path === null) return E.ACCES;
226 +
      let kind = fs.kind(path);
227 +
      if (oflags & O.DIRECTORY) {
228 +
        if (kind === null) return E.NOENT;
229 +
        if (kind !== FT.DIR) return E.NOTDIR;
230 +
      }
231 +
      if (kind === null) {
232 +
        if (!(oflags & O.CREAT)) return E.NOENT;
233 +
        const parent = path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "";
234 +
        if (fs.kind(parent) !== FT.DIR) return E.NOENT;
235 +
        fs.files.set(path, new Uint8Array(0));
236 +
        kind = FT.FILE;
237 +
      } else if (oflags & O.EXCL) return E.EXIST;
238 +
      if (kind === FT.DIR) fds.set(next, { dir: path, entries: null });
239 +
      else {
240 +
        if (oflags & O.TRUNC) fs.files.set(path, new Uint8Array(0));
241 +
        fds.set(next, { path, pos: 0, append: !!(fdflags & FD_APPEND) });
242 +
      }
243 +
      view().setUint32(out, next, true);
244 +
      next += 1;
245 +
      return E.SUCCESS;
246 +
    },
247 +
    path_create_directory: (fd, p, len) => {
248 +
      const d = fds.get(fd);
249 +
      if (!d || d.dir === undefined) return E.BADF;
250 +
      const path = fs.join(d.dir, str(p, len));
251 +
      if (path === null) return E.ACCES;
252 +
      if (fs.kind(path) !== null) return E.EXIST;
253 +
      fs.dirs.add(path);
254 +
      return E.SUCCESS;
255 +
    },
256 +
    path_unlink_file: (fd, p, len) => {
257 +
      const d = fds.get(fd);
258 +
      if (!d || d.dir === undefined) return E.BADF;
259 +
      const path = fs.join(d.dir, str(p, len));
260 +
      if (path === null || !fs.files.has(path)) return E.NOENT;
261 +
      fs.files.delete(path);
262 +
      return E.SUCCESS;
263 +
    },
264 +
    fd_close: (fd) => {
265 +
      if (!fds.has(fd) || fd < 3) return fds.has(fd) ? E.SUCCESS : E.BADF;
266 +
      fds.delete(fd);
267 +
      return E.SUCCESS;
268 +
    },
269 +
    fd_read: (fd, ptr, n, out) => {
270 +
      const f = fds.get(fd);
271 +
      if (!f) return E.BADF;
272 +
      if (f.dir !== undefined) return E.ISDIR;
273 +
      let total = 0;
274 +
      if (!f.stream) {
275 +
        const data = fs.files.get(f.path), m = u8();
276 +
        for (const [buf, len] of iovs(ptr, n)) {
277 +
          const k = Math.min(len, data.length - f.pos);
278 +
          if (k <= 0) break;
279 +
          m.set(data.subarray(f.pos, f.pos + k), buf);
280 +
          f.pos += k;
281 +
          total += k;
282 +
        }
283 +
      }
284 +
      view().setUint32(out, total, true);
285 +
      return E.SUCCESS;
286 +
    },
287 +
    fd_write: (fd, ptr, n, out) => {
288 +
      const f = fds.get(fd);
289 +
      if (!f) return E.BADF;
290 +
      if (f.dir !== undefined) return E.ISDIR;
291 +
      let total = 0;
292 +
      const m = u8();
293 +
      for (const [buf, len] of iovs(ptr, n)) {
294 +
        const chunk = m.slice(buf, buf + len);
295 +
        if (f.stream === "out" || f.stream === "err") emit(fd, dec.decode(chunk, { stream: true }));
296 +
        else if (!f.stream) {
297 +
          const old = fs.files.get(f.path);
298 +
          const at = f.append ? old.length : f.pos;
299 +
          const grown = new Uint8Array(Math.max(old.length, at + len));
300 +
          grown.set(old);
301 +
          grown.set(chunk, at);
302 +
          fs.files.set(f.path, grown);
303 +
          f.pos = at + len;
304 +
        }
305 +
        total += len;
306 +
      }
307 +
      view().setUint32(out, total, true);
308 +
      return E.SUCCESS;
309 +
    },
310 +
    fd_seek: (fd, offset, whence, out) => {
311 +
      const f = fds.get(fd);
312 +
      if (!f || f.stream || f.dir !== undefined) return E.BADF;
313 +
      const size = fs.files.get(f.path).length;
314 +
      const base = whence === 0 ? 0 : whence === 1 ? f.pos : size;
315 +
      const pos = base + Number(offset);
316 +
      if (pos < 0) return E.INVAL;
317 +
      f.pos = pos;
318 +
      view().setBigUint64(out, BigInt(pos), true);
319 +
      return E.SUCCESS;
320 +
    },
321 +
    fd_tell: (fd, out) => {
322 +
      const f = fds.get(fd);
323 +
      if (!f || f.stream || f.dir !== undefined) return E.BADF;
324 +
      view().setBigUint64(out, BigInt(f.pos), true);
325 +
      return E.SUCCESS;
326 +
    },
327 +
    fd_readdir: (fd, buf, len, cookie, out) => {
328 +
      const d = fds.get(fd);
329 +
      if (!d || d.dir === undefined) return E.BADF;
330 +
      if (!d.entries) d.entries = [".", "..", ...fs.list(d.dir)];
331 +
      const v = view(), m = u8();
332 +
      let used = 0;
333 +
      for (let i = Number(cookie); i < d.entries.length; i++) {
334 +
        const name = d.entries[i];
335 +
        const bytes = enc.encode(name);
336 +
        const child = name === "." || name === ".." ? d.dir : fs.join(d.dir, name);
337 +
        const head = new DataView(new ArrayBuffer(24));
338 +
        head.setBigUint64(0, BigInt(i + 1), true);
339 +
        head.setBigUint64(8, BigInt(i + 1), true);
340 +
        head.setUint32(16, bytes.length, true);
341 +
        head.setUint8(20, name === "." || name === ".." ? FT.DIR : fs.kind(child) ?? FT.UNKNOWN);
342 +
        const entry = new Uint8Array(24 + bytes.length);
343 +
        entry.set(new Uint8Array(head.buffer));
344 +
        entry.set(bytes, 24);
345 +
        const room = len - used;
346 +
        m.set(entry.subarray(0, Math.min(room, entry.length)), buf + used);
347 +
        used += Math.min(room, entry.length);
348 +
        if (used >= len) break;
349 +
      }
350 +
      v.setUint32(out, used, true);
351 +
      return E.SUCCESS;
352 +
    },
353 +
  };
354 +
  return { imports, flush, bind: (mem) => { memory = mem; } };
355 +
}
356 +
357 +
// --- A job -------------------------------------------------------------------------
358 +
359 +
async function run({ archive, exec, env }) {
360 +
  const say = (kind, text) => postMessage({ kind, text });
361 +
  const started = performance.now();
362 +
  const fs = new Fs(await unzip(archive));
363 +
  say("note", `${fs.files.size} files unpacked in ${Math.round(performance.now() - started)} ms`);
364 +
  const list = modules(fs, exec[0]);
365 +
  let first = 0;
366 +
  for (const path of list) {
367 +
    say("note", `running ${path}`);
368 +
    const name = path.slice(path.lastIndexOf("/") + 1);
369 +
    const w = wasi(fs, [name, ...exec.slice(1)], { ...env, PWD: "/src" }, say);
370 +
    const module = await WebAssembly.compile(fs.files.get(path));
371 +
    const imports = { wasi_snapshot_preview1: {} };
372 +
    // What the shim lacks answers ENOSYS rather than refusing the module.
373 +
    for (const imp of WebAssembly.Module.imports(module)) {
374 +
      if (imp.kind !== "function") continue;
375 +
      imports[imp.module] ??= {};
376 +
      if (imp.module === "wasi_snapshot_preview1" && w.imports[imp.name]) imports[imp.module][imp.name] = w.imports[imp.name];
377 +
      else imports[imp.module][imp.name] ??= () => E.NOSYS;
378 +
    }
379 +
    const instance = await WebAssembly.instantiate(module, imports);
380 +
    w.bind(instance.exports.memory);
381 +
    let code = 0;
382 +
    try {
383 +
      instance.exports._start();
384 +
    } catch (e) {
385 +
      if (e instanceof Exit) code = e.code;
386 +
      else {
387 +
        w.flush();
388 +
        say("err", `${path} trapped: ${e.message}`);
389 +
        code = 134;
390 +
      }
391 +
    }
392 +
    w.flush();
393 +
    if (code !== 0 && first === 0) first = code;
394 +
  }
395 +
  postMessage({ kind: "done", code: first, ms: Math.round(performance.now() - started) });
396 +
}
397 +
398 +
onmessage = (m) => {
399 +
  run(m.data).catch((e) => postMessage({ kind: "failed", text: e.message }));
400 +
};
added assets/wasm-tab.js +85 −0
1 +
// "Run in this tab" (oxygenflow#80): each WASM job of the pipeline in a
2 +
// worker of its own (wasi-worker.js), over the bookmark's files fetched once
3 +
// as a zip. A job shows its output as it comes, its exit code and its time.
4 +
"use strict";
5 +
(() => {
6 +
  const root = document.getElementById("tab-runner");
7 +
  if (!root) return;
8 +
  const jobs = JSON.parse(root.dataset.jobs);
9 +
  const status = document.getElementById("tab-status");
10 +
  const list = document.getElementById("tab-jobs");
11 +
  let archive = null;
12 +
  const fetchArchive = () => {
13 +
    archive ??= fetch(root.dataset.archive, { credentials: "same-origin" }).then((r) => {
14 +
      if (!r.ok) throw new Error(`the files did not come (HTTP ${r.status})`);
15 +
      return r.arrayBuffer();
16 +
    });
17 +
    return archive;
18 +
  };
19 +
  const el = (tag, props, ...kids) => {
20 +
    const e = Object.assign(document.createElement(tag), props);
21 +
    e.append(...kids);
22 +
    return e;
23 +
  };
24 +
  const rows = jobs.map((job) => {
25 +
    const state = el("span", { className: "small mute", textContent: "not run" });
26 +
    const out = el("pre", { className: "snippet", hidden: true });
27 +
    const run = el("button", { type: "button", textContent: "Run" });
28 +
    const stop = el("button", { type: "button", textContent: "Stop", hidden: true });
29 +
    const box = el(
30 +
      "div",
31 +
      { className: "tab-job" },
32 +
      el("h3", {}, job.name, " ", state),
33 +
      el("p", { className: "small mute" }, el("code", { textContent: job.exec.join(" ") })),
34 +
      el("p", {}, run, " ", stop),
35 +
      out,
36 +
    );
37 +
    list.append(box);
38 +
    let worker = null;
39 +
    const line = (cls, text) => {
40 +
      out.hidden = false;
41 +
      out.append(el("span", { className: cls, textContent: text + "\n" }));
42 +
      out.scrollTop = out.scrollHeight;
43 +
    };
44 +
    const go = () =>
45 +
      new Promise((resolve) => {
46 +
        out.replaceChildren();
47 +
        state.textContent = "fetching the files…";
48 +
        state.className = "small mute";
49 +
        run.disabled = true;
50 +
        stop.hidden = false;
51 +
        const finish = (text, cls, ok) => {
52 +
          state.textContent = text;
53 +
          state.className = "small " + cls;
54 +
          run.disabled = false;
55 +
          stop.hidden = true;
56 +
          worker?.terminate();
57 +
          worker = null;
58 +
          resolve(ok);
59 +
        };
60 +
        stop.onclick = () => finish("stopped", "bad", false);
61 +
        fetchArchive()
62 +
          .then((buf) => {
63 +
            state.textContent = "running…";
64 +
            worker = new Worker(root.dataset.worker);
65 +
            worker.onmessage = ({ data: m }) => {
66 +
              if (m.kind === "done") finish(m.code === 0 ? `passed in ${m.ms} ms` : `failed, exit ${m.code}, in ${m.ms} ms`, m.code === 0 ? "ok" : "bad", m.code === 0);
67 +
              else if (m.kind === "failed") finish(`could not run: ${m.text}`, "bad", false);
68 +
              else line(m.kind === "out" ? "" : m.kind === "err" ? "bad" : "mute", m.text);
69 +
            };
70 +
            worker.onerror = (e) => finish(`the worker stopped: ${e.message}`, "bad", false);
71 +
            worker.postMessage({ archive: buf.slice(0), exec: job.exec, env: job.env });
72 +
          })
73 +
          .catch((e) => finish(e.message, "bad", false));
74 +
      });
75 +
    run.onclick = () => go();
76 +
    return go;
77 +
  });
78 +
  document.getElementById("tab-run-all").onclick = async (e) => {
79 +
    e.target.disabled = true;
80 +
    let passed = 0;
81 +
    for (const go of rows) if (await go()) passed += 1;
82 +
    status.textContent = `${passed} of ${rows.length} passed`;
83 +
    e.target.disabled = false;
84 +
  };
85 +
})();
added templates/flow_tab.html +20 −0
1 +
{% extends "base.html" %}
2 +
{% import "repo_tabs.html" as m %}
3 +
{% block title %}Run in this tab · {{ repo.owner }}/{{ repo.name }}{% endblock %}
4 +
{% block bodyattrs %} data-repo="{{ repo.url }}"{% endblock %}
5 +
{% block tabs %}{% call m::tabs(repo, "flow") %}{% endblock %}
6 +
{% block content %}
7 +
<div class="head"><h1>Run in this tab <small><a href="{{ repo.url }}/flow">Flow</a> · WASM jobs, in your browser</small></h1>
8 +
<form class="inline" method="get" action="{{ repo.url }}/flow/tab"><label for="tab-bookmark" class="small mute">Jobs of</label> <select id="tab-bookmark" name="bookmark">{% for b in bookmarks %}<option value="{{ b }}"{% if *b == bookmark %} selected{% endif %}>{{ b }}</option>{% endfor %}</select> <button type="submit">Show</button></form>
9 +
</div>
10 +
<p class="small mute">The pipeline's jobs in a <code>runtime "wasm"</code> sandbox run here as a runner's WASM tier runs them: this tab fetches {{ bookmark }}'s files, and a worker runs each job's modules with them at <code>/src</code>, its arguments and environment, with no network and nothing of your computer. Nothing is reported to the hub: the tab shows the tier works in a browser, before a tab can take jobs as a runner (OxygenFlow #80).</p>
11 +
{% if let Some(p) = problem %}
12 +
<section class="panel"><p>{{ p }}</p></section>
13 +
{% else %}
14 +
<section class="panel" id="tab-runner" data-jobs="{{ jobs_json }}" data-archive="{{ archive }}" data-worker="/static/wasi-worker.js?v={{ shell.assets() }}">
15 +
  <p><button type="button" class="primary-btn" id="tab-run-all">Run all {{ jobs }}</button> <span class="small mute" id="tab-status"></span></p>
16 +
  <div id="tab-jobs"></div>
17 +
</section>
18 +
<script src="/static/wasm-tab.js?v={{ shell.assets() }}" defer></script>
19 +
{% endif %}
20 +
{% endblock %}
modified CHANGELOG.md +5 −0
…
1 unchanged lines
2 2
3 3
OxygenHub runs at https://oxygenhub.io; each deploy's build is on the About page and at `/api/v1/version`.
4 4
5 +
## 0.9.15 (2026-10-28)
6 +
7 +
- **Run a pipeline's WASM jobs in your browser (oxygenflow#80, first part).** Flow › *run in this tab* (`/OWNER/NAME/flow/tab`) lists the jobs of a bookmark's pipeline whose sandbox is `runtime "wasm"` and runs them in this tab, as a runner's WASM tier does: a worker fetches the bookmark's files as a zip once, unpacks them in memory and runs each job's modules with a WASI preview 1 shim, the files at `/src` and as the current directory, the job's arguments and environment, its output shown as it comes, its exit code and time; Run all, Run and Stop per job. Nothing is claimed or reported: it shows the tier in a browser before a tab can take jobs as a runner.
8 +
- **Runner builds through the swarm (oxygenflow#84).** The first time the hub offers a runner build, it cuts the release asset into its image chunks, as an image named by the build's BLAKE3, so a runner in a swarm (OxygenFlow 0.5.2) fetches it from its site's peers and the hub together and the hub serves it about once per site; Admin › Runners › Images lists it as "runner build flow VERSION".
9 +
5 10
## 0.9.14 (2026-10-28)
6 11
7 12
- **Read a Flow job's report over the API and with `oh` (#348).** A report a job published could only be read on its web page. Now `GET /api/v1/repos/OWNER/NAME/flow/runs/N/jobs/JOB/report` gives it with its metrics, context and files, `…/report/PATH` one file as published (`data/frames.csv`), `…/jobs/JOB/reports` every report the job published, `GET …/reports` the newest run of each report (`?bookmark=`), and `GET …/reports/NAME` one run by `?id=` (the id the web page carries) or the newest on a bookmark, with `…/files/PATH`. Files come sandboxed, so a page in one runs nothing on the hub; errors name the jobs, reports and files there are. `oh run report RUN JOB` prints its `report.toml`, `--list` what it holds, `--file PATH [-o FILE]` one file; `oh report list` and `oh report show NAME` do the same by name.
…
411 unchanged lines
modified Cargo.lock +3 −3
…
1459 unchanged lines
1460 1460
1461 1461
[[package]]
1462 1462
name = "hub-api"
1463 -
version = "0.9.14"
1463 +
version = "0.9.15"
1464 1464
dependencies = [
1465 1465
 "schemars",
1466 1466
 "serde",
…
797 unchanged lines
2264 2264
2265 2265
[[package]]
2266 2266
name = "oh"
2267 -
version = "0.9.14"
2267 +
version = "0.9.15"
2268 2268
dependencies = [
2269 2269
 "anyhow",
2270 2270
 "axum",
…
86 unchanged lines
2357 2357
2358 2358
[[package]]
2359 2359
name = "oxygenhub"
2360 -
version = "0.9.14"
2360 +
version = "0.9.15"
2361 2361
dependencies = [
2362 2362
 "ammonia",
2363 2363
 "anyhow",
…
2513 unchanged lines
modified Cargo.toml +1 −1
…
6 unchanged lines
7 7
resolver = "2"
8 8
9 9
[workspace.package]
10 -
version = "0.9.14"
10 +
version = "0.9.15"
11 11
edition = "2021"
12 12
license = "MIT OR Apache-2.0"
13 13
authors = ["Andy McCall <[email protected]>"]
…
60 unchanged lines
modified crates/hub/src/flow.rs +3 −0
…
1523 unchanged lines
1524 1524
    let policy = levels.resolve(&me);
1525 1525
    let hour = time::OffsetDateTime::now_utc().hour();
1526 1526
    let (offer, note) = crate::runner_updates::offer(&app.db, &levels, &me, &policy, hour, db::now_ms(), true).await?;
1527 +
    if let Some(o) = &offer {
1528 +
        crate::runner_updates::share(&app, o);
1529 +
    }
1527 1530
    Ok(Json(flow_core::api::UpdateAnswer { policy, offer, note }))
1528 1531
}
1529 1532
…
1609 unchanged lines
modified crates/hub/src/runner_updates.rs +49 −0
…
19 unchanged lines
20 20
pub const SOAK_JOBS: i64 = 5;
21 21
pub const SOAK_HOURS: i64 = 24;
22 22
23 +
/// Share an offered build with the swarm (oxygenflow#84): cut the release
24 +
/// asset into the hub's image chunks and keep its manifest, named, as an
25 +
/// image is, by the build's BLAKE3, so a runner in a swarm fetches it from
26 +
/// its site's peers and the hub together, as it fetches images, and the
27 +
/// hub serves it about once per site. Done once, in the background, the
28 +
/// first time the build is offered; a runner asking before it is done
29 +
/// fetches the build over HTTP.
30 +
pub fn share(app: &crate::Shared, offer: &UpdateOffer) {
31 +
    static SHARING: std::sync::Mutex<BTreeSet<String>> = std::sync::Mutex::new(BTreeSet::new());
32 +
    let store = app.images();
33 +
    let hash = offer.blake3.clone();
34 +
    if !jet::images::is_hash(&hash) || store.manifest(&hash).is_some() {
35 +
        return;
36 +
    }
37 +
    if !SHARING.lock().unwrap_or_else(|p| p.into_inner()).insert(hash.clone()) {
38 +
        return;
39 +
    }
40 +
    let (app, offer) = (app.clone(), offer.clone());
41 +
    tokio::spawn(async move {
42 +
        let path = crate::web::releases::blob_path(&app, &hash);
43 +
        let arch = offer.asset.rsplit('-').next().map(|a| a.trim_end_matches(".exe").to_string());
44 +
        let want = hash.clone();
45 +
        let made = tokio::task::spawn_blocking(move || -> Result<jet::images::Manifest, String> {
46 +
            let m = jet::images::scan_file(&path, arch, |c, bytes| Ok(store.put_chunk(c.hash(), bytes)?))
47 +
                .map_err(|e| format!("reading {}: {e}", path.display()))?;
48 +
            if m.hash != want {
49 +
                return Err(format!("the asset hashes to {}, not {}", &m.hash[..12], &want[..12]));
50 +
            }
51 +
            store.verify_and_keep(&m)?;
52 +
            Ok(m)
53 +
        })
54 +
        .await
55 +
        .unwrap_or_else(|e| Err(e.to_string()));
56 +
        match made {
57 +
            Ok(m) => match crate::images::record(&app.db, &m, &format!("runner build flow {}", offer.version)).await {
58 +
                Ok(()) => tracing::info!(
59 +
                    "runner updates: flow {} ({}) shared with the swarm: {} chunks",
60 +
                    offer.version,
61 +
                    offer.asset,
62 +
                    m.chunks.len()
63 +
                ),
64 +
                Err(e) => tracing::warn!("runner updates: recording flow {} for the swarm: {e}", offer.version),
65 +
            },
66 +
            Err(e) => tracing::warn!("runner updates: sharing flow {} ({}) with the swarm: {e}", offer.version, offer.asset),
67 +
        }
68 +
        SHARING.lock().unwrap_or_else(|p| p.into_inner()).remove(&hash);
69 +
    });
70 +
}
71 +
23 72
/// A runner's place, 0 to 99, for percentage groups: a stable hash of its
24 73
/// id, so a group's share is the same machines every rollout, and a
25 74
/// smaller share sits inside a larger one.
…
453 unchanged lines
modified crates/hub/src/web/flow.rs +97 −0
…
127 unchanged lines
128 128
    }))
129 129
}
130 130
131 +
/// A WASM job as the tab runner runs it (oxygenflow#80).
132 +
#[derive(serde::Serialize)]
133 +
pub struct TabJob {
134 +
    name: String,
135 +
    exec: Vec<String>,
136 +
    env: std::collections::BTreeMap<String, String>,
137 +
    memory_mib: u32,
138 +
}
139 +
140 +
#[derive(Template)]
141 +
#[template(path = "flow_tab.html")]
142 +
pub struct TabPage {
143 +
    shell: Shell,
144 +
    repo: RepoShell,
145 +
    bookmark: String,
146 +
    bookmarks: Vec<String>,
147 +
    /// The pipeline's WASM jobs, as JSON for the page's script.
148 +
    jobs_json: String,
149 +
    jobs: usize,
150 +
    /// Why there is nothing to run: no pipeline, one that does not read,
151 +
    /// or no WASM job in it.
152 +
    problem: Option<String>,
153 +
    archive: String,
154 +
}
155 +
156 +
#[derive(Deserialize)]
157 +
pub struct TabQuery {
158 +
    bookmark: Option<String>,
159 +
}
160 +
161 +
/// The WASM jobs of a pipeline (`runtime "wasm"`), each with the
162 +
/// environment its sandbox and itself give it.
163 +
pub fn wasm_jobs(p: &flow_core::Pipeline) -> Vec<TabJob> {
164 +
    p.jobs
165 +
        .values()
166 +
        .filter_map(|j| {
167 +
            let sb = p.sandboxes.get(&j.sandbox)?;
168 +
            (sb.runtime == flow_core::Runtime::Wasm && !j.exec.is_empty()).then(|| {
169 +
                let mut env = sb.env.clone();
170 +
                env.extend(j.env.clone());
171 +
                TabJob { name: j.name.clone(), exec: j.exec.clone(), env, memory_mib: sb.memory_mib }
172 +
            })
173 +
        })
174 +
        .collect()
175 +
}
176 +
177 +
/// `GET /{owner}/{name}/flow/tab`: run the pipeline's WASM jobs in this
178 +
/// browser tab (oxygenflow#80, first part): a worker unpacks the
179 +
/// bookmark's files and runs each job's modules with a WASI shim, as a
180 +
/// runner's WASM tier does. Nothing is claimed or reported: it shows the
181 +
/// tier works in a tab, before the tab becomes a runner.
182 +
pub async fn tab(
183 +
    State(app): State<Shared>,
184 +
    viewer: Viewer,
185 +
    Path((owner, name)): Path<(String, String)>,
186 +
    Query(q): Query<TabQuery>,
187 +
) -> Result<Page<TabPage>, Error> {
188 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/flow/tab"), &viewer);
189 +
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
190 +
    let bookmarks = bookmark_names(&app, &row).await;
191 +
    let bookmark = q.bookmark.filter(|b| bookmarks.contains(b)).unwrap_or_else(|| row.default_bookmark.clone());
192 +
    let b = bookmark.clone();
193 +
    let text = app
194 +
        .repos
195 +
        .read(&repo, move |r, cache| {
196 +
            let Some(st) = r.view()?.bookmarks.get(&b).copied() else { return Ok(None) };
197 +
            let heads = r.store().get_state(&st)?.heads;
198 +
            let loaded = cache.load(r, &heads)?;
199 +
            Ok(match loaded.snapshot.files.get(flow_core::PIPELINE_PATH).map(|f| &f.content) {
200 +
                Some(oxy_core::state::FileContent::Text { bytes, .. }) => Some(String::from_utf8_lossy(bytes).into_owned()),
201 +
                _ => None,
202 +
            })
203 +
        })
204 +
        .await
205 +
        .unwrap_or(None);
206 +
    let (jobs, problem) = match text.map(|t| flow_core::Pipeline::parse(&t)) {
207 +
        None => (vec![], Some(format!("{bookmark} has no {}.", flow_core::PIPELINE_PATH))),
208 +
        Some(Err(e)) => (vec![], Some(format!("The pipeline on {bookmark} does not read: {e}"))),
209 +
        Some(Ok(p)) => {
210 +
            let jobs = wasm_jobs(&p);
211 +
            let problem =
212 +
                jobs.is_empty().then(|| format!("The pipeline on {bookmark} has no WASM jobs (a sandbox with runtime \"wasm\")."));
213 +
            (jobs, problem)
214 +
        }
215 +
    };
216 +
    Ok(Page(TabPage {
217 +
        shell,
218 +
        repo: RepoShell::new(&app, &row, "flow").with_role(role),
219 +
        archive: format!("/{owner}/{name}/archive?ref={}&format=zip", urlencoding(&bookmark)),
220 +
        bookmark,
221 +
        bookmarks,
222 +
        jobs: jobs.len(),
223 +
        jobs_json: serde_json::to_string(&jobs).unwrap_or_else(|_| "[]".into()),
224 +
        problem,
225 +
    }))
226 +
}
227 +
131 228
/// The repository's bookmarks, its default first.
132 229
async fn bookmark_names(app: &Shared, row: &db::repos::RepoRow) -> Vec<String> {
133 230
    let Some(repo) = crate::repos::RepoName::new(&row.owner, &row.name) else { return vec![] };
…
860 unchanged lines
modified crates/hub/src/web/mod.rs +1 −0
…
316 unchanged lines
317 317
        .route("/{owner}/{name}/newfile/{r}", get(edit::new_form))
318 318
        .route("/{owner}/{name}/flow", get(flow::runs))
319 319
        .route("/{owner}/{name}/flow/badge.svg", get(flow::badge))
320 +
        .route("/{owner}/{name}/flow/tab", get(flow::tab))
320 321
        .route("/{owner}/{name}/tests", get(test_reports::overview))
321 322
        .route("/{owner}/{name}/tests/case", get(test_reports::case))
322 323
        .route("/{owner}/{name}/reports", get(reports::overview))
…
643 unchanged lines
modified crates/hub/src/web/statics.rs +2 −0
…
97 unchanged lines
98 98
        mime: "text/javascript; charset=utf-8",
99 99
        bytes: include_bytes!("../../../../assets/vendor/mermaid.min.js"),
100 100
    },
101 +
    Asset { path: "wasi-worker.js", mime: "text/javascript; charset=utf-8", bytes: include_bytes!("../../../../assets/wasi-worker.js") },
102 +
    Asset { path: "wasm-tab.js", mime: "text/javascript; charset=utf-8", bytes: include_bytes!("../../../../assets/wasm-tab.js") },
101 103
    Asset {
102 104
        path: "vendor/easymde.min.css",
103 105
        mime: "text/css; charset=utf-8",
…
30 unchanged lines
modified crates/hub/tests/flow.rs +41 −1
…
6970 unchanged lines
6971 6971
    // A release of builds, and the site taking them from it.
6972 6972
    let repo = db::repos::get(&h.app.db, "andy", "oxygenflow").await.unwrap().unwrap();
6973 6973
    let rel = db::releases::create(&h.app.db, repo.id, "v0.2.0", "0.2.0", "", false, None).await.unwrap();
6974 -
    db::releases::add_asset(&h.app.db, rel.id, "flow-linux-x86_64", 42, &"ab".repeat(32), "application/octet-stream").await.unwrap();
6974 +
    let build: Vec<u8> = (0..300_000u32).map(|i| (i.wrapping_mul(2654435761) >> 13) as u8).collect();
6975 +
    let build_hash = blake3::hash(&build).to_hex().to_string();
6976 +
    let at = h.app.config.data.join("assets").join(&build_hash[..2]);
6977 +
    std::fs::create_dir_all(&at).unwrap();
6978 +
    std::fs::write(at.join(&build_hash), &build).unwrap();
6979 +
    db::releases::add_asset(&h.app.db, rel.id, "flow-linux-x86_64", build.len() as i64, &build_hash, "application/octet-stream")
6980 +
        .await
6981 +
        .unwrap();
6975 6982
    let (_, page) = web("POST", "/admin/runners/updates/source", Some(format!("_csrf={csrf}&repo=andy%2Fnope"))).await;
6976 6983
    assert!(page.contains("There is no repository"), "{page}");
6977 6984
    let (_, page) = web("POST", "/admin/runners/updates/source", Some(format!("_csrf={csrf}&repo=andy%2Foxygenflow"))).await;
…
14 unchanged lines
6992 6999
    assert!(offer.url.starts_with("/andy/oxygenflow/asset/") && !offer.older, "{offer:?}");
6993 7000
    assert_eq!(a.policy.from[0], "group canary");
6994 7001
    assert!(ask(1).await.offer.is_none(), "narvi is not in the group");
7002 +
    // Offered, the build is shared with the swarm (oxygenflow#84): an image
7003 +
    // named by its BLAKE3 whose chunks make it, for runners to fetch.
7004 +
    let mut shared = None;
7005 +
    for _ in 0..100 {
7006 +
        shared = h.app.images().manifest(&build_hash);
7007 +
        if shared.is_some() {
7008 +
            break;
7009 +
        }
7010 +
        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
7011 +
    }
7012 +
    let m = shared.expect("the build is shared with the swarm");
7013 +
    assert_eq!((m.size, m.arch.as_deref()), (build.len() as u64, Some("x86_64")));
7014 +
    let mut whole = vec![];
7015 +
    for c in &m.chunks {
7016 +
        whole.extend(h.app.images().chunk(c.hash()).unwrap());
7017 +
    }
7018 +
    assert_eq!(whole, build);
7019 +
    let kept = oxygenhub::images::kept(&h.app.db).await.unwrap();
7020 +
    assert!(kept.iter().any(|k| k.hash == build_hash && k.uploader == "runner build flow 0.2.0"));
6995 7021
6996 7022
    // The site's dry run reaches plex; narvi's own setting pins its version.
6997 7023
    let (_, page) = web("POST", "/admin/runners/updates/policy", Some(format!("_csrf={csrf}&scope=site&updates=off&dry_run=on"))).await;
…
242 unchanged lines
7240 7266
    let id = serde_json::from_str::<serde_json::Value>(&body).unwrap()["job_id"].as_i64().unwrap();
7241 7267
    let placed = db::flow::job(&h.app.db, id).await.unwrap().unwrap().placed;
7242 7268
    assert!(placed.starts_with("idle") && !placed.contains("image"), "{placed}");
7269 +
7270 +
    // oxygenflow#80: the page that runs it in a browser tab lists it, with
7271 +
    // the zip of the bookmark's files its worker reads, and the worker.
7272 +
    let (st, page) = call(&h.app, "GET", "/andy/wasm/flow/tab", None, None).await;
7273 +
    assert_eq!(st, StatusCode::OK, "{page}");
7274 +
    assert!(page.contains("Run all 1") && page.contains("&#34;exec&#34;:[&#34;count.wasm&#34;]"), "{page}");
7275 +
    assert!(page.contains("data-archive=\"/andy/wasm/archive?ref=main&#38;format=zip\""), "{page}");
7276 +
    let (st, zip) = get_bytes(&h.app, "/andy/wasm/archive?ref=main&format=zip").await;
7277 +
    assert!(st == StatusCode::OK && zip.starts_with(b"PK"), "{st}");
7278 +
    assert_eq!(get_bytes(&h.app, "/static/wasi-worker.js").await.0, StatusCode::OK);
7279 +
    let (_, runs) = call(&h.app, "GET", "/andy/wasm/flow", None, None).await;
7280 +
    assert!(runs.contains("/andy/wasm/flow/tab"), "linked from Flow");
7281 +
    let (_, none) = call(&h.app, "GET", "/andy/wasm/flow/tab?bookmark=nope", None, None).await;
7282 +
    assert!(none.contains("Run all 1"), "an unknown bookmark shows the default's");
7243 7283
}
7244 7284
7245 7285
/// oxygenhub#322: an organisation's owners set their own runners' updates,
…
98 unchanged lines
modified templates/flow_runs.html +1 −1
…
3 unchanged lines
4 4
{% block bodyattrs %} data-repo="{{ repo.url }}"{% endblock %}
5 5
{% block tabs %}{% call m::tabs(repo, "flow") %}{% endblock %}
6 6
{% block content %}
7 -
<div class="head"><h1>Flow <small>pipeline runs · <a href="{{ repo.url }}/deployments">deployments</a> · <a href="{{ repo.url }}/insights" target="_blank" rel="noopener" title="Pickup time, jobs, durations and runners, charted">charts ↗</a></small></h1>
7 +
<div class="head"><h1>Flow <small>pipeline runs · <a href="{{ repo.url }}/deployments">deployments</a> · <a href="{{ repo.url }}/flow/tab" title="Run the pipeline's WASM jobs in your browser">run in this tab</a> · <a href="{{ repo.url }}/insights" target="_blank" rel="noopener" title="Pickup time, jobs, durations and runners, charted">charts ↗</a></small></h1>
8 8
{% if can_run %}<form class="inline" method="post" action="{{ repo.url }}/flow/start"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><label for="run-bookmark" class="small mute">Run the pipeline of</label> <select id="run-bookmark" name="bookmark">{% for b in bookmarks %}<option value="{{ b }}">{{ b }}</option>{% endfor %}</select> <button type="submit" class="primary-btn">Run pipeline</button></form>{% endif %}
9 9
</div>
10 10
{% if let Some(w) = why_not %}<p class="small mute">{{ w }}</p>{% endif %}
…
42 unchanged lines
modified templates/style.css +7 −0
…
1478 unchanged lines
1479 1479
label.pol input[type=text], label.pol input[type=number], label.pol select { width: auto; margin: 0; }
1480 1480
td .pol-form { flex-wrap: nowrap; gap: 4px 8px; }
1481 1481
.update-group h3 small { font-weight: normal; }
1482 +
/* Run in this tab (oxygenflow#80) */
1483 +
.tab-job { border-top: 1px solid var(--line); padding: 8px 0; }
1484 +
.tab-job:first-child { border-top: 0; }
1485 +
.tab-job h3 { margin: 0; font-size: 15px; }
1486 +
.tab-job .ok { color: var(--ok); }
1487 +
.tab-job .bad { color: var(--bad); }
1488 +
.tab-job pre { max-height: 24rem; overflow: auto; }