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.
- council/__init__.py +1 -0
- council/artifacts.py +161 -0
- council/batch.py +84 -0
- council/cli.py +54 -0
- council/coordinator.py +133 -0
- council/crypto.py +133 -0
- council/fidelity.py +197 -0
- council/judge.py +393 -0
- council/ledger.py +230 -0
- council/library.py +431 -0
- council/local.py +228 -0
- council/mcp_server.py +87 -0
- council/net/__init__.py +1 -0
- council/net/agent.py +231 -0
- council/net/app.py +390 -0
- council/net/baseline.py +86 -0
- council/net/config.py +79 -0
- council/net/coordinator_app.py +370 -0
- council/net/dashboard.py +111 -0
- council/net/store.py +964 -0
- council/net/submit.py +102 -0
- council/operator.py +412 -0
- council/research.py +520 -0
- council/researcher.py +300 -0
- council/retrieval.py +80 -0
- council/run_demo.py +175 -0
- council/sanitize.py +78 -0
- council/serve.py +183 -0
- council/trust.py +168 -0
- council/worker.py +123 -0
- passiveworkers-0.1.0.dist-info/METADATA +269 -0
- passiveworkers-0.1.0.dist-info/RECORD +36 -0
- passiveworkers-0.1.0.dist-info/WHEEL +5 -0
- passiveworkers-0.1.0.dist-info/entry_points.txt +2 -0
- passiveworkers-0.1.0.dist-info/licenses/LICENSE +21 -0
- passiveworkers-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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)
|
council/net/dashboard.py
ADDED
|
@@ -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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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
|
+
"""
|