passiveworkers 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,370 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ council/net/coordinator_app.py — the networked coordinator (FastAPI, hardened)
4
+ ==============================================================================
5
+ The open-source, self-hostable hub. Holds the ledger, job queue, node registry, and
6
+ telemetry; routes tasks and settles credit. Provider-agnostic (all config via env).
7
+
8
+ AuthN/Z:
9
+ • X-PW-Token — shared operator token, required on every write endpoint.
10
+ • X-Node-Secret — per-node secret (minted at register, shown once). Required on
11
+ heartbeat/next/result; the node is identified FROM the secret, and a node may only
12
+ complete its OWN tasks (no task hijacking, no score/ledger forgery).
13
+
14
+ Endpoints:
15
+ POST /nodes/register {name,country,owner,answer_model,lens,can_judge,judge_model,profile}
16
+ → {node_id, node_secret} (secret shown once)
17
+ POST /nodes/heartbeat {load} [X-Node-Secret]
18
+ GET /tasks/next [X-Node-Secret] → task or 204
19
+ POST /tasks/{task_id}/result {…result…} [X-Node-Secret]
20
+ POST /jobs {asker, question} → {job_id, status}
21
+ GET /jobs/{job_id} → full job view
22
+ GET /status → telemetry (no node_id/IP leak)
23
+ GET /dashboard → live operator map
24
+ GET /healthz
25
+
26
+ Run: PW_TOKEN=… python -m council.net.coordinator_app
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import ipaddress
32
+ import threading
33
+
34
+ from fastapi import Body, FastAPI, Header, HTTPException, Request, Response
35
+ from fastapi.responses import HTMLResponse
36
+ from pydantic import BaseModel, Field
37
+
38
+ from council.net.app import APP_HTML
39
+ from council.net.baseline import generate_baseline
40
+ from council.net.config import CONFIG, JOB_TYPES
41
+ from council.net.dashboard import DASHBOARD_HTML
42
+ from council.net.store import Store
43
+
44
+
45
+ def _is_loopback(host: str) -> bool:
46
+ if host in ("localhost",):
47
+ return True
48
+ try:
49
+ return ipaddress.ip_address(host).is_loopback
50
+ except ValueError:
51
+ return False
52
+
53
+
54
+ def _startup_guard() -> None:
55
+ """Refuse to expose the coordinator publicly with a default/empty token."""
56
+ if not _is_loopback(CONFIG.host) and CONFIG.token in ("", "dev-token"):
57
+ raise RuntimeError(
58
+ f"refusing to bind {CONFIG.host} with a weak PW_TOKEN. Set a strong PW_TOKEN, "
59
+ "or bind 127.0.0.1 and front it with a tunnel/reverse proxy.")
60
+
61
+
62
+ _startup_guard()
63
+ app = FastAPI(title="Passive Workers — Council Coordinator")
64
+ store = Store()
65
+
66
+
67
+ def _auth(token: str | None) -> None:
68
+ if token != CONFIG.token:
69
+ raise HTTPException(status_code=401, detail="bad or missing X-PW-Token")
70
+
71
+
72
+ def _node_auth(secret: str | None) -> str:
73
+ node_id = store.node_for_secret(secret or "")
74
+ if not node_id:
75
+ raise HTTPException(status_code=401, detail="bad or missing X-Node-Secret")
76
+ return node_id
77
+
78
+
79
+ def _user_auth(secret: str | None) -> str:
80
+ handle = store.user_for_secret(secret or "")
81
+ if not handle:
82
+ raise HTTPException(status_code=401, detail="sign in first (bad or missing X-User-Secret)")
83
+ return handle
84
+
85
+
86
+ class RegisterBody(BaseModel):
87
+ name: str = Field("node", max_length=80)
88
+ country: str = Field("?", max_length=80)
89
+ owner: str = Field(..., max_length=80)
90
+ answer_model: str = Field("", max_length=80)
91
+ lens: str = Field("neutral", max_length=80)
92
+ can_judge: bool = False
93
+ judge_model: str = Field("", max_length=80)
94
+ machine_id: str = Field("?", max_length=120)
95
+ profile: dict = {}
96
+
97
+
98
+ class HeartbeatBody(BaseModel):
99
+ load: float = 0.0
100
+
101
+
102
+ class UserBody(BaseModel):
103
+ handle: str = Field(..., max_length=40)
104
+
105
+
106
+ class JobBody(BaseModel):
107
+ question: str = Field(..., max_length=4000)
108
+ minds: int | None = Field(default=None, ge=1, le=16) # responder dial (clamped to online)
109
+ type: str | None = Field(default=None, max_length=32) # job type (see GET /job-types)
110
+ items: list[str] | None = Field(default=None, max_length=200) # shard_map: the work items
111
+ requires: dict | None = None # capability gate, e.g. {"model": "qwen3:14b", "min_ram_gb": 16}
112
+ fetch: bool = False # shard_map: items are PUBLIC URLs to fetch+process (D15 rules)
113
+ context: str = Field(default="", max_length=4000) # assisted: bounded context for the operator
114
+ encrypt_to: str = Field(default="", max_length=100) # assisted: asker box pubkey for E2E file encryption (D23)
115
+
116
+
117
+ class FeedbackBody(BaseModel):
118
+ verdict: str = Field(..., max_length=12)
119
+
120
+
121
+ @app.get("/healthz")
122
+ def healthz():
123
+ return {"ok": True}
124
+
125
+
126
+ @app.post("/nodes/register")
127
+ def register(body: RegisterBody, request: Request, x_pw_token: str | None = Header(default=None)):
128
+ _auth(x_pw_token)
129
+ ip = request.client.host if request.client else ""
130
+ return store.register_node(body.model_dump(), ip=ip) # {node_id, node_secret}
131
+
132
+
133
+ @app.post("/nodes/heartbeat")
134
+ def heartbeat(body: HeartbeatBody, x_pw_token: str | None = Header(default=None),
135
+ x_node_secret: str | None = Header(default=None)):
136
+ _auth(x_pw_token)
137
+ node_id = _node_auth(x_node_secret)
138
+ store.heartbeat(node_id, body.load)
139
+ return {"ok": True}
140
+
141
+
142
+ @app.get("/tasks/next")
143
+ def next_task(x_pw_token: str | None = Header(default=None),
144
+ x_node_secret: str | None = Header(default=None)):
145
+ _auth(x_pw_token)
146
+ node_id = _node_auth(x_node_secret)
147
+ task = store.next_task(node_id)
148
+ if task is None:
149
+ return Response(status_code=204)
150
+ return task
151
+
152
+
153
+ @app.post("/tasks/{task_id}/result")
154
+ def task_result(task_id: str, result: dict, x_pw_token: str | None = Header(default=None),
155
+ x_node_secret: str | None = Header(default=None)):
156
+ _auth(x_pw_token)
157
+ node_id = _node_auth(x_node_secret)
158
+ accepted = store.complete_task(task_id, result, node_id=node_id)
159
+ if not accepted:
160
+ raise HTTPException(status_code=409, detail="task not yours, unknown, or already done")
161
+ return {"accepted": True}
162
+
163
+
164
+ @app.get("/tasks/offers")
165
+ def assisted_offers(x_pw_token: str | None = Header(default=None),
166
+ x_node_secret: str | None = Header(default=None)):
167
+ """Open assisted offers this operator may consent to (D21). Returns brief + bounded context."""
168
+ _auth(x_pw_token)
169
+ node_id = _node_auth(x_node_secret)
170
+ node = store.get_node(node_id)
171
+ if not node:
172
+ raise HTTPException(status_code=404, detail="unknown node")
173
+ return {"offers": store.assisted_offers(dict(node))}
174
+
175
+
176
+ @app.post("/tasks/{task_id}/accept")
177
+ def accept_assisted(task_id: str, x_pw_token: str | None = Header(default=None),
178
+ x_node_secret: str | None = Header(default=None)):
179
+ """Operator gives informed consent to + claims an assisted offer (D21)."""
180
+ _auth(x_pw_token)
181
+ node_id = _node_auth(x_node_secret)
182
+ node = store.get_node(node_id)
183
+ if not node:
184
+ raise HTTPException(status_code=404, detail="unknown node")
185
+ out = store.accept_assisted(task_id, node_id, node["owner"])
186
+ if not out.get("ok"):
187
+ raise HTTPException(status_code=409, detail=out.get("error", "cannot accept"))
188
+ return out
189
+
190
+
191
+ class DeliverBody(BaseModel):
192
+ deliverable: str = Field(..., max_length=200_000)
193
+ signature: str = Field(default="", max_length=200) # operator's Ed25519 sig (D23)
194
+ signer_pub: str = Field(default="", max_length=100) # operator's verify key (b64)
195
+
196
+
197
+ @app.post("/tasks/{task_id}/deliver")
198
+ def deliver_assisted(task_id: str, body: DeliverBody,
199
+ x_pw_token: str | None = Header(default=None),
200
+ x_node_secret: str | None = Header(default=None)):
201
+ """Operator returns the owned deliverable; the ledger settles (D21)."""
202
+ _auth(x_pw_token)
203
+ node_id = _node_auth(x_node_secret)
204
+ out = store.deliver_assisted(task_id, node_id, body.deliverable,
205
+ signature=body.signature, signer_pub=body.signer_pub)
206
+ if not out.get("ok"):
207
+ raise HTTPException(status_code=409, detail=out.get("error", "cannot deliver"))
208
+ return out
209
+
210
+
211
+ @app.post("/jobs/{job_id}/blobs/{blob_hash}")
212
+ def put_blob(job_id: str, blob_hash: str, request: Request, data: bytes = Body(default=b""),
213
+ x_pw_token: str | None = Header(default=None),
214
+ x_node_secret: str | None = Header(default=None)):
215
+ """Operator uploads a content-addressed chunk for a job it has claimed (D22)."""
216
+ _auth(x_pw_token)
217
+ node_id = _node_auth(x_node_secret)
218
+ # reject oversize by Content-Length BEFORE we trust the buffered body (DoS guard)
219
+ try:
220
+ if int(request.headers.get("content-length", 0)) > 512 * 1024:
221
+ raise HTTPException(status_code=413, detail="chunk too large")
222
+ except (TypeError, ValueError):
223
+ pass
224
+ if store.assisted_claimant(job_id) != node_id:
225
+ raise HTTPException(status_code=403, detail="not the claiming operator for this job")
226
+ out = store.put_blob(job_id, blob_hash, data)
227
+ if not out.get("ok"):
228
+ raise HTTPException(status_code=400, detail=out.get("error", "rejected"))
229
+ return out
230
+
231
+
232
+ class RateBody(BaseModel):
233
+ score: float = Field(..., ge=0, le=10)
234
+
235
+
236
+ @app.post("/jobs/{job_id}/rate")
237
+ def rate_assisted(job_id: str, body: RateBody, x_user_secret: str | None = Header(default=None)):
238
+ """The asker rates a completed assisted deliverable (0-10) → operator reputation (D24)."""
239
+ handle = _user_auth(x_user_secret)
240
+ out = store.rate_assisted(job_id, handle, body.score)
241
+ if not out.get("ok"):
242
+ raise HTTPException(status_code=409, detail=out.get("error", "cannot rate"))
243
+ return out
244
+
245
+
246
+ @app.get("/jobs/{job_id}/blob/{blob_hash}")
247
+ def get_blob(job_id: str, blob_hash: str, x_user_secret: str | None = Header(default=None)):
248
+ """The job's asker downloads a chunk to reassemble the deliverable (D22)."""
249
+ handle = _user_auth(x_user_secret)
250
+ if store.job_asker(job_id) != handle:
251
+ raise HTTPException(status_code=403, detail="not your job")
252
+ data = store.get_blob(job_id, blob_hash)
253
+ if data is None:
254
+ raise HTTPException(status_code=404, detail="no such blob")
255
+ return Response(content=data, media_type="application/octet-stream")
256
+
257
+
258
+ @app.post("/users")
259
+ def make_user(body: UserBody):
260
+ res = store.register_user(body.handle)
261
+ if res.get("error"):
262
+ raise HTTPException(status_code=409, detail=res["error"])
263
+ return res
264
+
265
+
266
+ @app.get("/me")
267
+ def me(x_user_secret: str | None = Header(default=None)):
268
+ return store.user_balance(_user_auth(x_user_secret))
269
+
270
+
271
+ def _baseline_async(job_id: str, question: str) -> None:
272
+ """Generate the independent single-model baseline.
273
+
274
+ API baseline → immediately (no local resources used). Local Ollama baseline → AFTER
275
+ the council job settles: on a CPU-only host the council's own inference and a 14B
276
+ baseline would fight for the same cores and both time out (measured: 99s idle vs
277
+ >300s contended). The app keeps polling a few minutes past 'done' to pick it up.
278
+ """
279
+ if not CONFIG.baseline_api_key:
280
+ import time
281
+ deadline = time.monotonic() + 900
282
+ while time.monotonic() < deadline:
283
+ st = store.job_status(job_id)
284
+ if st not in ("pending_answers", "judging"):
285
+ break
286
+ time.sleep(5)
287
+ data = generate_baseline(question)
288
+ if not data:
289
+ print(f"[baseline] none stored for job {job_id[:8]} (generation returned nothing)", flush=True)
290
+ return
291
+ try:
292
+ store.set_baseline(job_id, data)
293
+ print(f"[baseline] stored for job {job_id[:8]}: {data['model']} in {data['elapsed_s']}s", flush=True)
294
+ except Exception as e: # baseline is best-effort; never disturb the job — but say so
295
+ print(f"[baseline] store failed for job {job_id[:8]}: {type(e).__name__}: {e}", flush=True)
296
+
297
+
298
+ @app.get("/job-types")
299
+ def job_types():
300
+ """The marketplace catalog — what kinds of work computers can request here."""
301
+ per_mind = CONFIG.worker_pool / CONFIG.fleet_size
302
+ return {k: {"label": v["label"], "eta": v["eta"],
303
+ "price_per_mind": round(per_mind * v["pool_mult"], 1),
304
+ "judge_fee": CONFIG.judge_fee,
305
+ "deadline_s": v["deadline_s"]}
306
+ for k, v in JOB_TYPES.items()}
307
+
308
+
309
+ @app.post("/jobs")
310
+ def submit_job(body: JobBody, x_user_secret: str | None = Header(default=None)):
311
+ handle = _user_auth(x_user_secret)
312
+ out = store.create_job(handle, body.question, minds=body.minds,
313
+ job_type=body.type or "chat", items=body.items,
314
+ requires=body.requires, fetch=body.fetch, context=body.context,
315
+ encrypt_to=body.encrypt_to)
316
+ out["balance"] = store.user_balance(handle)
317
+ if out.get("status") == "pending_answers" and (body.type or "chat") != "shard_map":
318
+ # the honest single-model compare only makes sense for answer/report jobs —
319
+ # a one-shot model can't process a sharded item batch
320
+ threading.Thread(target=_baseline_async, args=(out["job_id"], body.question),
321
+ daemon=True, name="pw-baseline").start()
322
+ return out
323
+
324
+
325
+ @app.get("/jobs/mine")
326
+ def my_jobs(x_user_secret: str | None = Header(default=None)):
327
+ handle = _user_auth(x_user_secret)
328
+ return store.jobs_for_asker(handle)
329
+
330
+
331
+ @app.get("/jobs/{job_id}")
332
+ def get_job(job_id: str):
333
+ view = store.job_view(job_id)
334
+ if view is None:
335
+ raise HTTPException(status_code=404, detail="unknown job_id")
336
+ return view
337
+
338
+
339
+ @app.post("/jobs/{job_id}/feedback")
340
+ def feedback(job_id: str, body: FeedbackBody, x_user_secret: str | None = Header(default=None)):
341
+ who = _user_auth(x_user_secret)
342
+ if not store.record_feedback(job_id, body.verdict, who):
343
+ raise HTTPException(status_code=400, detail="bad verdict or unknown job")
344
+ return {"ok": True}
345
+
346
+
347
+ @app.get("/metrics")
348
+ def metrics():
349
+ return store.metrics()
350
+
351
+
352
+ @app.get("/status")
353
+ def status():
354
+ return store.status()
355
+
356
+
357
+ @app.get("/", response_class=HTMLResponse)
358
+ def home():
359
+ return APP_HTML
360
+
361
+
362
+ @app.get("/dashboard", response_class=HTMLResponse)
363
+ def dashboard():
364
+ return DASHBOARD_HTML
365
+
366
+
367
+ if __name__ == "__main__":
368
+ import uvicorn
369
+
370
+ uvicorn.run(app, host=CONFIG.host, port=CONFIG.port)
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ council/net/dashboard.py — the live operator map (v0)
4
+ =====================================================
5
+ A self-contained HTML page the coordinator serves at GET /dashboard. It polls /status
6
+ and shows the connected nodes on a world map (positioned by their country now;
7
+ real IP-geo is the documented next step), plus live load, model, reputation, and the
8
+ recent job flow. This is the "interactivity" view — see the network breathing.
9
+
10
+ No build step; Leaflet is loaded from a CDN by the browser. Positions use country
11
+ centroids (+ small deterministic jitter so co-located nodes don't overlap).
12
+ """
13
+
14
+ DASHBOARD_HTML = r"""<!doctype html>
15
+ <html lang="en">
16
+ <head>
17
+ <meta charset="utf-8"/>
18
+ <meta name="viewport" content="width=device-width, initial-scale=1"/>
19
+ <title>Passive Workers — Council Map</title>
20
+ <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
21
+ <style>
22
+ :root{--bg:#0b1020;--panel:#121a33;--ink:#e6ecff;--mut:#8aa0d0;--good:#36d399;--warn:#fbbd23;--bad:#f87272;}
23
+ *{box-sizing:border-box} html,body{margin:0;height:100%;background:var(--bg);color:var(--ink);
24
+ font:14px/1.45 -apple-system,Segoe UI,Roboto,sans-serif}
25
+ #wrap{display:grid;grid-template-columns:1fr 360px;height:100vh}
26
+ #map{height:100vh}
27
+ #side{background:var(--panel);overflow:auto;padding:16px;border-left:1px solid #21305e}
28
+ h1{font-size:16px;margin:0 0 2px} .sub{color:var(--mut);font-size:12px;margin-bottom:14px}
29
+ .card{background:#0f1730;border:1px solid #21305e;border-radius:10px;padding:10px 12px;margin-bottom:10px}
30
+ .row{display:flex;justify-content:space-between;gap:8px;align-items:center}
31
+ .pill{font-size:11px;padding:2px 7px;border-radius:999px;background:#1b2750;color:var(--mut)}
32
+ .dot{width:9px;height:9px;border-radius:50%;display:inline-block;margin-right:6px}
33
+ .muted{color:var(--mut)} .k{color:var(--mut)} b{color:#fff}
34
+ .stat{font-size:22px;font-weight:700} .statlbl{color:var(--mut);font-size:11px;text-transform:uppercase;letter-spacing:.04em}
35
+ .flex{display:flex;gap:18px} .ok{color:var(--good)} .no{color:var(--bad)}
36
+ .job{font-size:12px;border-left:3px solid #2a3a6e;padding:2px 0 2px 8px;margin:4px 0}
37
+ </style>
38
+ </head>
39
+ <body>
40
+ <div id="wrap">
41
+ <div id="map"></div>
42
+ <div id="side">
43
+ <h1>🌍 Council — live operator map</h1>
44
+ <div class="sub">Passive Workers · varied global intelligence · positions by country (IP-geo next)</div>
45
+ <div class="card"><div class="flex">
46
+ <div><div class="stat" id="nodeCount">–</div><div class="statlbl">nodes online</div></div>
47
+ <div><div class="stat" id="credTotal">–</div><div class="statlbl">credits</div></div>
48
+ <div><div class="stat" id="conserved">–</div><div class="statlbl">ledger</div></div>
49
+ </div></div>
50
+ <div id="nodes"></div>
51
+ <h1 style="font-size:13px;margin:16px 0 6px">Recent jobs</h1>
52
+ <div id="jobs"></div>
53
+ <div class="sub" id="updated" style="margin-top:12px"></div>
54
+ </div>
55
+ </div>
56
+ <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
57
+ <script>
58
+ const CENTROIDS = {
59
+ FI:[61.9,25.7],AE:[23.4,53.8],US:[39.8,-98.6],DE:[51.2,10.4],BR:[-14.2,-51.9],GB:[54,-2],
60
+ FR:[46.6,2.2],IN:[21,78],SG:[1.35,103.8],JP:[36.2,138.3],NL:[52.1,5.3],CA:[56,-106],
61
+ AU:[-25,133],ZA:[-29,24],NG:[9,8],KE:[0.2,37.9],EG:[26,30],SA:[24,45],IQ:[33,44],
62
+ TR:[39,35],RU:[61,105],CN:[35,105],KR:[36,128],ID:[-2,118],VN:[14,108],MX:[23,-102],
63
+ AR:[-38,-63],ES:[40,-3.7],IT:[42.8,12.8],SE:[62,15],PL:[52,19],"local":[20,0],"?":[0,-20]
64
+ };
65
+ function jitter(id){id=String(id||'');let h=0;for(const c of id)h=(h*31+c.charCodeAt(0))&255;return (h/255-0.5)*6;}
66
+ function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));}
67
+ function loadColor(l){l=l||0;return l<0.4?'#36d399':l<0.75?'#fbbd23':'#f87272';}
68
+ const map=L.map('map',{worldCopyJump:true}).setView([30,15],2);
69
+ L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
70
+ {maxZoom:8,attribution:'© OpenStreetMap © CARTO'}).addTo(map);
71
+ let markers=[];
72
+ async function tick(){
73
+ let d; try{ d=await (await fetch('/status',{cache:'no-store'})).json(); }catch(e){ return; }
74
+ const nodes=d.online_nodes||[];
75
+ document.getElementById('nodeCount').textContent=nodes.length;
76
+ document.getElementById('credTotal').textContent=Math.round(d.ledger_total||0);
77
+ const cons=document.getElementById('conserved');
78
+ cons.textContent=d.ledger_conserved?'✓ ok':'✗ drift';
79
+ cons.className='stat '+(d.ledger_conserved?'ok':'no');
80
+ markers.forEach(m=>map.removeLayer(m)); markers=[];
81
+ const list=document.getElementById('nodes'); list.innerHTML='';
82
+ for(const n of nodes){
83
+ const c=CENTROIDS[n.country]||CENTROIDS['?'];
84
+ const lat=c[0]+jitter(n.node_key), lng=c[1]+jitter(n.node_key+'x');
85
+ const col=loadColor(n.load), role=esc(n.answer_model||'judge');
86
+ const m=L.circleMarker([lat,lng],{radius:9,color:col,fillColor:col,fillOpacity:.85,weight:2}).addTo(map);
87
+ m.bindPopup(`<b>${esc(n.name)}</b> · ${esc(n.country)}<br>owner ${esc(n.owner)}<br>${role}`+
88
+ `<br>load ${(100*(n.load||0)).toFixed(0)}% · rep ${(+n.reputation||0)}/10`+
89
+ `<br>helped ${(+n.jobs_helped||0)} · seen ${(+n.age_s||0)}s ago`);
90
+ markers.push(m);
91
+ list.insertAdjacentHTML('beforeend',
92
+ `<div class="card"><div class="row"><div><span class="dot" style="background:${col}"></span>`+
93
+ `<b>${esc(n.name)}</b> <span class="muted">${esc(n.country)}</span></div>`+
94
+ `<span class="pill">rep ${(+n.reputation||0)}</span></div>`+
95
+ `<div class="row" style="margin-top:6px"><span class="k">${role}</span>`+
96
+ `<span class="muted">load ${(100*(n.load||0)).toFixed(0)}% · ${(+n.age_s||0)}s</span></div></div>`);
97
+ }
98
+ const jobs=d.recent_jobs||[]; const jb=document.getElementById('jobs'); jb.innerHTML='';
99
+ for(const j of jobs.slice(0,8)){
100
+ const col=j.status==='done'?'#36d399':j.status==='failed'?'#f87272':'#fbbd23';
101
+ jb.insertAdjacentHTML('beforeend',
102
+ `<div class="job" style="border-left-color:${col}"><b>${esc(j.asker)}</b> `+
103
+ `<span class="muted">${esc(j.status)}</span></div>`);
104
+ }
105
+ document.getElementById('updated').textContent='updated '+new Date().toLocaleTimeString();
106
+ }
107
+ tick(); setInterval(tick,3000);
108
+ </script>
109
+ </body>
110
+ </html>
111
+ """