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(""exec":["count.wasm"]"), "{page}"); |
|
| 7275 | + | assert!(page.contains("data-archive=\"/andy/wasm/archive?ref=main&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; } |