修复设计计算错误并优化分享

This commit is contained in:
Lihatoo 2026-07-26 03:00:54 +08:00
parent dd21623cb5
commit 30cdd05914
5 changed files with 916 additions and 28 deletions

View file

@ -9,7 +9,7 @@ services:
- ./runtime/redis:/data
np-replica:
image: np-app:v4.7
image: 100.64.0.2:13000/lihato/np_app:v4.8
build:
context: .
dockerfile: Dockerfile
@ -45,7 +45,7 @@ services:
GOTO_NUM_THREADS: 4
np-worker:
image: np-app:v4.7
image: 100.64.0.2:13000/lihato/np_app:v4.8
container_name: np-worker
restart: always
mem_limit: 56g

539
service/account.py Normal file
View file

@ -0,0 +1,539 @@
import base64
import hashlib
import json
import os
import secrets
import sqlite3
import threading
import time
import zlib
from http.cookies import SimpleCookie
from pathlib import Path
from urllib.parse import urlencode
from urllib.request import Request, urlopen
def _json_blob(value):
if value is None:
return None
raw = json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
return sqlite3.Binary(zlib.compress(raw, level=6))
def _blob_json(value):
if value is None:
return None
return json.loads(zlib.decompress(value).decode("utf-8"))
def _payload_summary(payload):
payload = payload or {}
workflow = payload.get("workflow", "analysis")
mode = payload.get("mode", "tube")
model = payload.get("model") or {}
strands = payload.get("strands") or []
if workflow == "design":
complexes = payload.get("design_complexes") or payload.get("design_targets") or []
tube_sizes = [int(row.get("max_size", 0) or 0) for row in (payload.get("design_tubes") or [])]
max_size = max(tube_sizes, default=int((payload.get("design") or {}).get("off_target_max_size", 0) or 0))
else:
complexes = [line for line in str(payload.get("complexes_text") or "").splitlines() if line.strip()]
max_size = int((payload.get("tube") or {}).get("max_size", 0) or 0)
return {
"workflow": workflow,
"mode": mode,
"material": model.get("material", "rna"),
"celsius": float(model.get("celsius", 37) or 37),
"sodium": float(model.get("sodium", 0) or 0),
"magnesium": float(model.get("magnesium", 0) or 0),
"max_size": max_size,
"strand_count": len(strands),
"complex_count": len(complexes),
"compute": list(payload.get("compute") or (["design"] if workflow == "design" else [])),
"trials": int((payload.get("design") or {}).get("trials", 0) or 0),
"stop_condition": float((payload.get("design") or {}).get("stop_condition", 0) or 0),
"max_time_seconds": int((payload.get("design") or {}).get("max_time_seconds", 0) or 0),
}
class AccountStore:
def __init__(self, path):
self.path = Path(path)
self._init_lock = threading.Lock()
self._initialized = False
def _connect(self):
self.path.parent.mkdir(parents=True, exist_ok=True)
connection = sqlite3.connect(self.path, timeout=30)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA busy_timeout = 30000")
connection.execute("PRAGMA foreign_keys = ON")
return connection
def initialize(self):
with self._init_lock:
if self._initialized:
return
with self._connect() as connection:
connection.execute("PRAGMA journal_mode = WAL")
connection.execute("PRAGMA synchronous = NORMAL")
connection.executescript(
"""
CREATE TABLE IF NOT EXISTS users (
user_id TEXT PRIMARY KEY,
username TEXT NOT NULL,
email TEXT,
display_name TEXT,
groups_json TEXT NOT NULL DEFAULT '[]',
created_at REAL NOT NULL,
last_seen_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS jobs (
job_id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
status TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'job',
created_at REAL NOT NULL,
updated_at REAL NOT NULL,
elapsed_seconds REAL,
workflow TEXT NOT NULL,
mode TEXT NOT NULL,
material TEXT NOT NULL,
celsius REAL NOT NULL,
sodium REAL NOT NULL,
magnesium REAL NOT NULL,
max_size INTEGER NOT NULL DEFAULT 0,
strand_count INTEGER NOT NULL DEFAULT 0,
complex_count INTEGER NOT NULL DEFAULT 0,
compute_json TEXT NOT NULL DEFAULT '[]',
trials INTEGER NOT NULL DEFAULT 0,
stop_condition REAL NOT NULL DEFAULT 0,
max_time_seconds INTEGER NOT NULL DEFAULT 0,
payload_blob BLOB NOT NULL,
result_blob BLOB,
error_blob BLOB,
stored_bytes INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS jobs_user_created_idx ON jobs(user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS jobs_user_status_idx ON jobs(user_id, status, created_at DESC);
CREATE TABLE IF NOT EXISTS shares (
share_id TEXT PRIMARY KEY,
job_id TEXT NOT NULL REFERENCES jobs(job_id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
active INTEGER NOT NULL DEFAULT 1,
created_at REAL NOT NULL,
expires_at REAL,
last_access_at REAL,
access_count INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS shares_user_created_idx ON shares(user_id, created_at DESC);
"""
)
self._initialized = True
def upsert_user(self, user):
self.initialize()
now = time.time()
with self._connect() as connection:
connection.execute(
"""
INSERT INTO users(user_id, username, email, display_name, groups_json, created_at, last_seen_at)
VALUES(?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
username=excluded.username,
email=excluded.email,
display_name=excluded.display_name,
groups_json=excluded.groups_json,
last_seen_at=excluded.last_seen_at
""",
(
user["user_id"], user.get("username") or user["user_id"], user.get("email"),
user.get("display_name"), json.dumps(user.get("groups") or [], ensure_ascii=False), now, now,
),
)
def create_job(self, job_id, user, payload, status="queued", source="job", created_at=None, result=None, error=None):
self.upsert_user(user)
created_at = float(created_at or time.time())
summary = _payload_summary(payload)
payload_blob = _json_blob(payload)
result_blob = _json_blob(result)
error_blob = _json_blob(error)
stored_bytes = sum(len(item) for item in (payload_blob, result_blob, error_blob) if item is not None)
with self._connect() as connection:
connection.execute(
"""
INSERT OR IGNORE INTO jobs(
job_id, user_id, status, source, created_at, updated_at, workflow, mode, material,
celsius, sodium, magnesium, max_size, strand_count, complex_count, compute_json,
trials, stop_condition, max_time_seconds, payload_blob, result_blob, error_blob, stored_bytes
) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
job_id, user["user_id"], status, source, created_at, created_at,
summary["workflow"], summary["mode"], summary["material"], summary["celsius"],
summary["sodium"], summary["magnesium"], summary["max_size"], summary["strand_count"],
summary["complex_count"], json.dumps(summary["compute"]), summary["trials"],
summary["stop_condition"], summary["max_time_seconds"], payload_blob, result_blob,
error_blob, stored_bytes,
),
)
def update_job(self, job_id, status, *, result=None, error=None, elapsed_seconds=None):
self.initialize()
fields = ["status = ?", "updated_at = ?"]
values = [status, time.time()]
if result is not None:
fields.append("result_blob = ?")
values.append(_json_blob(result))
if error is not None:
fields.append("error_blob = ?")
values.append(_json_blob(error))
if elapsed_seconds is not None:
fields.append("elapsed_seconds = ?")
values.append(float(elapsed_seconds))
values.append(job_id)
with self._connect() as connection:
connection.execute(f"UPDATE jobs SET {', '.join(fields)} WHERE job_id = ?", values)
connection.execute(
"UPDATE jobs SET stored_bytes = length(payload_blob) + coalesce(length(result_blob), 0) + coalesce(length(error_blob), 0) WHERE job_id = ?",
(job_id,),
)
def owner_id(self, job_id):
self.initialize()
with self._connect() as connection:
row = connection.execute("SELECT user_id FROM jobs WHERE job_id = ?", (job_id,)).fetchone()
return row["user_id"] if row else None
def get_job(self, user_id, job_id, include_content=True):
self.initialize()
columns = "*" if include_content else "job_id,user_id,status,source,created_at,updated_at,elapsed_seconds,workflow,mode,material,celsius,sodium,magnesium,max_size,strand_count,complex_count,compute_json,trials,stop_condition,max_time_seconds,stored_bytes"
with self._connect() as connection:
row = connection.execute(f"SELECT {columns} FROM jobs WHERE job_id = ? AND user_id = ?", (job_id, user_id)).fetchone()
return self._job_row(row, include_content=include_content) if row else None
def list_jobs(self, user_id, filters=None):
self.initialize()
filters = filters or {}
limit = min(100, max(1, int(filters.get("limit", 30))))
offset = max(0, int(filters.get("offset", 0)))
clauses = ["user_id = ?"]
values = [user_id]
for field in ("status", "workflow", "mode", "material"):
value = str(filters.get(field) or "").strip()
if value:
clauses.append(f"{field} = ?")
values.append(value)
search = str(filters.get("q") or "").strip()
if search:
clauses.append("(job_id LIKE ? OR workflow LIKE ? OR mode LIKE ? OR material LIKE ?)")
values.extend([f"%{search}%"] * 4)
where = " AND ".join(clauses)
columns = "job_id,user_id,status,source,created_at,updated_at,elapsed_seconds,workflow,mode,material,celsius,sodium,magnesium,max_size,strand_count,complex_count,compute_json,trials,stop_condition,max_time_seconds,stored_bytes"
with self._connect() as connection:
total = connection.execute(f"SELECT count(*) AS count FROM jobs WHERE {where}", values).fetchone()["count"]
rows = connection.execute(
f"SELECT {columns} FROM jobs WHERE {where} ORDER BY created_at DESC LIMIT ? OFFSET ?",
[*values, limit, offset],
).fetchall()
return {"items": [self._job_row(row, include_content=False) for row in rows], "total": total, "limit": limit, "offset": offset}
def usage(self, user_id):
self.initialize()
with self._connect() as connection:
totals = connection.execute(
"""SELECT count(*) AS total_jobs,
coalesce(sum(CASE WHEN status='success' THEN 1 ELSE 0 END),0) AS success_jobs,
coalesce(sum(CASE WHEN status='error' THEN 1 ELSE 0 END),0) AS error_jobs,
coalesce(sum(CASE WHEN status IN ('queued','running','cancel_requested') THEN 1 ELSE 0 END),0) AS active_jobs,
coalesce(sum(elapsed_seconds),0) AS compute_seconds,
coalesce(sum(stored_bytes),0) AS stored_bytes FROM jobs WHERE user_id=?""",
(user_id,),
).fetchone()
shares = connection.execute("SELECT count(*) AS count FROM shares WHERE user_id=? AND active=1", (user_id,)).fetchone()["count"]
result = dict(totals)
result["active_shares"] = shares
return result
def import_history(self, user, entries):
imported = 0
for entry in entries:
payload = entry.get("payload") if isinstance(entry, dict) else None
if not isinstance(payload, dict):
continue
result = entry.get("result") if isinstance(entry.get("result"), dict) else None
raw_id = str(entry.get("id") or entry.get("job_id") or uuid_token())
job_id = f"import-{hashlib.sha256((user['user_id'] + ':' + raw_id).encode()).hexdigest()[:24]}"
created = entry.get("created_at") or entry.get("created_at_iso")
try:
created_at = float(created)
except (TypeError, ValueError):
try:
created_at = time.mktime(time.strptime(str(created).split(".")[0].replace("Z", ""), "%Y-%m-%dT%H:%M:%S"))
except (TypeError, ValueError):
created_at = time.time()
before = self.get_job(user["user_id"], job_id, include_content=False)
self.create_job(job_id, user, payload, status="success" if result else "input_only", source="import", created_at=created_at, result=result)
if before is None:
imported += 1
return imported
def delete_job(self, user_id, job_id):
self.initialize()
with self._connect() as connection:
row = connection.execute("SELECT status FROM jobs WHERE job_id=? AND user_id=?", (job_id, user_id)).fetchone()
if row is None:
return False
if row["status"] in {"queued", "running", "cancel_requested"}:
raise ValueError("Active jobs cannot be deleted.")
connection.execute("DELETE FROM jobs WHERE job_id=? AND user_id=?", (job_id, user_id))
return True
def create_share(self, user_id, job_id, expires_in=None):
job = self.get_job(user_id, job_id, include_content=False)
if job is None:
raise ValueError("Job not found.")
now = time.time()
expires_at = now + int(expires_in) if expires_in else None
share_id = uuid_token(18)
with self._connect() as connection:
connection.execute(
"INSERT INTO shares(share_id,job_id,user_id,active,created_at,expires_at) VALUES(?,?,?,?,?,?)",
(share_id, job_id, user_id, 1, now, expires_at),
)
return self.get_share_for_owner(user_id, share_id)
def share_metadata(self, share_id):
self.initialize()
now = time.time()
with self._connect() as connection:
row = connection.execute(
"""SELECT s.share_id, s.job_id, s.user_id, s.created_at, s.expires_at, s.active
FROM shares s WHERE s.share_id=? AND s.active=1 AND (s.expires_at IS NULL OR s.expires_at>?)""",
(share_id, now),
).fetchone()
return dict(row) if row else None
def record_share_access(self, share_id):
self.initialize()
now = time.time()
with self._connect() as connection:
connection.execute(
"UPDATE shares SET access_count=access_count+1,last_access_at=? WHERE share_id=?",
(now, share_id),
)
def list_shares(self, user_id):
self.initialize()
with self._connect() as connection:
rows = connection.execute(
"""SELECT s.*,j.status,j.workflow,j.mode,j.material,j.created_at AS job_created_at
FROM shares s JOIN jobs j ON j.job_id=s.job_id WHERE s.user_id=? ORDER BY s.created_at DESC""",
(user_id,),
).fetchall()
return [dict(row) for row in rows]
def get_share_for_owner(self, user_id, share_id):
self.initialize()
with self._connect() as connection:
row = connection.execute("SELECT * FROM shares WHERE share_id=? AND user_id=?", (share_id, user_id)).fetchone()
return dict(row) if row else None
def update_share(self, user_id, share_id, *, active=None, expires_in="unchanged"):
share = self.get_share_for_owner(user_id, share_id)
if share is None:
return None
fields = []
values = []
if active is not None:
fields.append("active=?")
values.append(1 if active else 0)
if expires_in != "unchanged":
fields.append("expires_at=?")
values.append(time.time() + int(expires_in) if expires_in else None)
if fields:
values.extend([share_id, user_id])
with self._connect() as connection:
connection.execute(f"UPDATE shares SET {', '.join(fields)} WHERE share_id=? AND user_id=?", values)
return self.get_share_for_owner(user_id, share_id)
def resolve_share(self, share_id, *, record_access=True):
self.initialize()
now = time.time()
with self._connect() as connection:
row = connection.execute(
"""SELECT
s.share_id, s.created_at AS share_created_at,
j.job_id, j.user_id, j.status, j.source, j.created_at, j.updated_at,
j.elapsed_seconds, j.workflow, j.mode, j.material, j.celsius, j.sodium,
j.magnesium, j.max_size, j.strand_count, j.complex_count, j.compute_json,
j.trials, j.stop_condition, j.max_time_seconds, j.payload_blob,
j.result_blob, j.error_blob, j.stored_bytes
FROM shares s JOIN jobs j ON j.job_id=s.job_id
WHERE s.share_id=? AND s.active=1 AND (s.expires_at IS NULL OR s.expires_at>?)""",
(share_id, now),
).fetchone()
if row is None:
return None
if record_access:
connection.execute("UPDATE shares SET access_count=access_count+1,last_access_at=? WHERE share_id=?", (now, share_id))
job = self._job_row(row, include_content=True)
return {
"id": share_id,
"share_id": share_id,
"job_id": job["job_id"],
"created_at": row["share_created_at"],
"status": job["status"],
"updated_at": job["updated_at"],
"elapsed_seconds": job["elapsed_seconds"],
"payload": job["payload"],
"result": job["result"],
"error": job["error"],
"result_summary": job["result_summary"],
}
@staticmethod
def _job_row(row, include_content):
item = dict(row)
item["compute"] = json.loads(item.pop("compute_json") or "[]")
item["result_summary"] = {
key: item[key] for key in (
"workflow", "mode", "material", "celsius", "sodium", "magnesium", "max_size",
"strand_count", "complex_count", "compute", "trials", "stop_condition", "max_time_seconds",
)
}
if include_content:
item["payload"] = _blob_json(item.pop("payload_blob"))
item["result"] = _blob_json(item.pop("result_blob"))
item["error"] = _blob_json(item.pop("error_blob"))
return item
def uuid_token(size=24):
return secrets.token_urlsafe(size)
class OIDCAuth:
def __init__(self, redis_factory):
self.redis_factory = redis_factory
self.issuer = os.environ.get("NP_OIDC_ISSUER", "https://auth.lihato.icu/application/o/nupack-account/").rstrip("/") + "/"
self.client_id = os.environ.get("NP_OIDC_CLIENT_ID", "np-replica-web")
self.redirect_uri = os.environ.get("NP_OIDC_REDIRECT_URI", "https://np.lihato.icu/auth/callback")
self.post_logout_uri = os.environ.get("NP_OIDC_POST_LOGOUT_URI", "https://np.lihato.icu/")
self.scopes = os.environ.get("NP_OIDC_SCOPES", "openid profile email").strip()
self.cookie_name = os.environ.get("NP_AUTH_COOKIE_NAME", "np_session")
self.session_ttl = int(os.environ.get("NP_AUTH_SESSION_TTL_SECONDS", str(7 * 86400)))
self.required = os.environ.get("NP_AUTH_REQUIRED", "1") != "0"
self._configuration = None
self._config_lock = threading.Lock()
def configuration(self):
with self._config_lock:
if self._configuration is None:
self._configuration = self._request_json(self.issuer + ".well-known/openid-configuration")
return self._configuration
@staticmethod
def _request_json(url, *, data=None, headers=None):
body = urlencode(data).encode("utf-8") if data is not None else None
request = Request(url, data=body, headers=headers or {})
with urlopen(request, timeout=15) as response:
return json.loads(response.read().decode("utf-8"))
def begin_login(self, next_path="/"):
if not next_path.startswith("/") or next_path.startswith("//"):
next_path = "/"
state = uuid_token(24)
verifier = uuid_token(48)
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
self.redis_factory().setex(
f"np_replica:oidc_state:{state}", 600,
json.dumps({"verifier": verifier, "next": next_path}),
)
config = self.configuration()
query = urlencode({
"client_id": self.client_id,
"response_type": "code",
"redirect_uri": self.redirect_uri,
"scope": self.scopes,
"state": state,
"code_challenge": challenge,
"code_challenge_method": "S256",
})
return f"{config['authorization_endpoint']}?{query}"
def complete_login(self, params):
state = (params.get("state") or [""])[0]
code = (params.get("code") or [""])[0]
if not state or not code:
raise ValueError("OIDC callback is missing code or state.")
key = f"np_replica:oidc_state:{state}"
client = self.redis_factory()
raw = client.get(key)
client.delete(key)
if raw is None:
raise ValueError("OIDC login state has expired.")
pending = json.loads(raw)
config = self.configuration()
tokens = self._request_json(config["token_endpoint"], data={
"grant_type": "authorization_code",
"client_id": self.client_id,
"code": code,
"redirect_uri": self.redirect_uri,
"code_verifier": pending["verifier"],
}, headers={"Content-Type": "application/x-www-form-urlencoded"})
claims = self._request_json(config["userinfo_endpoint"], headers={"Authorization": f"Bearer {tokens['access_token']}"})
subject = str(claims.get("sub") or "").strip()
if not subject:
raise ValueError("OIDC userinfo did not return a subject.")
user = {
"user_id": subject,
"username": claims.get("preferred_username") or claims.get("nickname") or claims.get("email") or subject,
"email": claims.get("email"),
"display_name": claims.get("name") or claims.get("preferred_username") or subject,
"groups": claims.get("groups") or [],
}
session_id = uuid_token(32)
client.setex(
f"np_replica:session:{session_id}", self.session_ttl,
json.dumps({"user": user, "id_token": tokens.get("id_token")}, ensure_ascii=False),
)
return session_id, user, pending.get("next") or "/"
def current_session(self, headers):
if not self.required:
user = {"user_id": "development", "username": "development", "email": None, "display_name": "Development", "groups": []}
return {"user": user, "session_id": None}
cookie = SimpleCookie()
cookie.load(headers.get("Cookie", ""))
morsel = cookie.get(self.cookie_name)
if morsel is None:
return None
session_id = morsel.value
raw = self.redis_factory().get(f"np_replica:session:{session_id}")
if raw is None:
return None
session = json.loads(raw)
session["session_id"] = session_id
return session
def logout_url(self, session):
config = self.configuration()
endpoint = config.get("end_session_endpoint")
if not endpoint:
return "/"
query = {"post_logout_redirect_uri": self.post_logout_uri}
if session and session.get("id_token"):
query["id_token_hint"] = session["id_token"]
return f"{endpoint}?{urlencode(query)}"
def delete_session(self, session):
if session and session.get("session_id"):
self.redis_factory().delete(f"np_replica:session:{session['session_id']}")
def cookie_header(self, session_id):
return f"{self.cookie_name}={session_id}; Path=/; Max-Age={self.session_ttl}; HttpOnly; Secure; SameSite=Lax"
def clear_cookie_header(self):
return f"{self.cookie_name}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax"

View file

@ -1703,6 +1703,13 @@ A+B</textarea>
<option value="true" data-i18n="design_wobble_allow"></option>
</select>
</label>
<label>
<span data-i18n="design_fixed_target_policy"></span>
<select id="designFixedTargetPolicy">
<option value="exclude_from_optimization" data-i18n="design_fixed_target_exclude"></option>
<option value="include" data-i18n="design_fixed_target_include"></option>
</select>
</label>
</div>
<div class="toggle-row" id="designTabToggle">
<button class="secondary panel-toggle active" type="button" data-design-tab="targets" data-i18n="design_tab_targets"></button>
@ -2221,6 +2228,9 @@ A+B</textarea>
design_wobble_label: "Wobble mutations",
design_wobble_allow: "允许",
design_wobble_prohibit: "禁止",
design_fixed_target_policy: "固定目标策略",
design_fixed_target_exclude: "剥离出优化",
design_fixed_target_include: "参与优化",
design_tab_targets: "Target Tubes",
design_tab_hard: "Hard Constraints",
design_tab_soft: "Soft Constraints",
@ -2440,6 +2450,7 @@ A+B</textarea>
design_ensemble_defect: "Ensemble defect",
design_trials_used: "试验数",
design_seed: "最佳种子",
design_fixed_excluded: "固定目标剥离",
design_constraint: "约束",
design_definition: "输入定义",
design_sequence: "设计序列",
@ -2568,6 +2579,9 @@ A+B</textarea>
design_wobble_label: "Wobble Mutations",
design_wobble_allow: "Allow",
design_wobble_prohibit: "Prohibit",
design_fixed_target_policy: "Fixed Target Policy",
design_fixed_target_exclude: "Exclude from Optimization",
design_fixed_target_include: "Include in Optimization",
design_tab_targets: "Target Tubes",
design_tab_hard: "Hard Constraints",
design_tab_soft: "Soft Constraints",
@ -2787,6 +2801,7 @@ A+B</textarea>
design_ensemble_defect: "Ensemble Defect",
design_trials_used: "Trials",
design_seed: "Best Seed",
design_fixed_excluded: "Fixed Targets Excluded",
design_constraint: "Constraint",
design_definition: "Input Definition",
design_sequence: "Designed Sequence",
@ -3803,9 +3818,14 @@ A+B</textarea>
const result = entry?.result && typeof entry.result === "object" ? entry.result : null;
return {
id: String(entry.id || entry.job_id || `${Date.now()}-${index}-${Math.random().toString(16).slice(2)}`),
job_id: entry.job_id || null,
status: entry.status || (result ? "success" : "input_only"),
created_at: entry.created_at_iso || entry.created_at || new Date().toISOString(),
updated_at: entry.updated_at || null,
elapsed_seconds: entry.elapsed_seconds ?? null,
payload,
result,
error: entry.error || null,
result_summary: buildHistorySummary(payload, entry.result_summary || {}),
};
}
@ -4050,6 +4070,7 @@ A+B</textarea>
document.getElementById("designSeed").value = payload.design?.seed ?? 0;
document.getElementById("designWobble").value = String(payload.design?.wobble_mutations ?? false);
document.getElementById("designMaxTimeHours").value = ((payload.design?.max_time_seconds ?? 0) / 3600);
document.getElementById("designFixedTargetPolicy").value = payload.design?.fixed_target_policy ?? "exclude_from_optimization";
const payloadCompute = Array.isArray(payload.compute) ? payload.compute : [];
document.querySelectorAll(".check input").forEach((input) => {
input.checked = payloadCompute.includes(input.value);
@ -4094,28 +4115,50 @@ A+B</textarea>
} else {
lastPayload = payload;
lastResult = null;
renderEmptyState();
if (["queued", "running", "cancel_requested"].includes(item.status)) {
const label = item.status === "queued"
? t("status_queued")
: (item.status === "cancel_requested" ? t("status_canceling") : t("status_polling"));
results.innerHTML = `<div class="result-card"><h3>${escapeHtml(label)}</h3><p>${t("history_job_id")}: ${escapeHtml(item.job_id || item.id || "-")}</p></div>`;
setStatus(formatStatus(label));
} else {
renderEmptyState();
}
}
}
function sharedJobIsActive(item) {
return ["queued", "running", "cancel_requested"].includes(item?.status);
}
async function fetchSharedItem(shareId, { poll = false } = {}) {
const suffix = poll ? "?poll=1" : "";
const { response, data } = await fetchJsonOrThrow(`/api/shares/${encodeURIComponent(shareId)}${suffix}`);
if (!response.ok || data.status !== "success") {
throw new Error(data?.error || `Share request failed with HTTP ${response.status}`);
}
const item = normalizeHistoryEntry(data.share, 0);
if (!item) throw new Error("Shared record is invalid.");
return item;
}
async function loadSharedHistoryFromUrl() {
const pathMatch = window.location.pathname.match(/^\/share\/([A-Za-z0-9_-]{8,128})$/);
const shareId = pathMatch?.[1] || new URLSearchParams(window.location.search).get("share");
if (!shareId) return;
document.body.classList.add("share-mode");
try {
const { response, data } = await fetchJsonOrThrow(`/api/shares/${encodeURIComponent(shareId)}`);
if (!response.ok || data.status !== "success") {
throw new Error(data?.error || `Share request failed with HTTP ${response.status}`);
}
const item = normalizeHistoryEntry(data.share, 0);
if (!item) throw new Error("Shared record is invalid.");
item.error = data.share.error || null;
let item = await fetchSharedItem(shareId);
applyHistoryItem(item);
document.querySelectorAll(".control-panel input, .control-panel select, .control-panel textarea, .control-panel button").forEach((node) => {
if (!node.closest(".toolbar") && !node.matches("[data-design-tab]")) node.disabled = true;
});
setStatus(formatStatus(t("history_shared_loaded")));
while (sharedJobIsActive(item)) {
await new Promise((resolve) => setTimeout(resolve, 1500));
item = await fetchSharedItem(shareId, { poll: true });
applyHistoryItem(item);
}
} catch (error) {
setStatus(`${t("history_share_failed")}: ${error.message}`, true);
}
@ -4133,7 +4176,7 @@ A+B</textarea>
<div class="compact-actions">
<button class="secondary" data-history-load="${item.job_id}" type="button">${t("history_load")}</button>
<button class="secondary" data-history-export="${item.job_id}" type="button">${t("history_export_one")}</button>
${["success", "error", "input_only", "canceled"].includes(item.status) ? `<button class="secondary" data-history-share="${item.job_id}" type="button">${t("history_share")}</button>` : ""}
<button class="secondary" data-history-share="${item.job_id}" type="button">${t("history_share")}</button>
${["queued", "running", "cancel_requested"].includes(item.status) ? "" : `<button class="secondary" data-history-delete="${item.job_id}" type="button">${t("history_delete")}</button>`}
</div>
</div>
@ -4329,6 +4372,7 @@ A+B</textarea>
seed: Number(document.getElementById("designSeed").value),
wobble_mutations: document.getElementById("designWobble").value === "true",
max_time_seconds: Math.round(Number(document.getElementById("designMaxTimeHours").value || 0) * 3600),
fixed_target_policy: document.getElementById("designFixedTargetPolicy").value,
},
hard_constraints: getHardConstraints(),
soft_constraints: getSoftConstraints(),
@ -4519,6 +4563,7 @@ A+B</textarea>
function renderDesignSummaryCard(result) {
const stats = result.design?.stats || {};
const excludedFixedCount = result.design?.optimization?.excluded_fixed_targets?.length || 0;
return `
<div class="result-card">
<h3>${t("design_summary_card")}</h3>
@ -4526,6 +4571,7 @@ A+B</textarea>
<div><strong>${t("design_ensemble_defect")}</strong><span class="metric-value">${Number(result.design?.ensemble_defect || 0).toFixed(6)}</span></div>
<div><strong>${t("design_trials_used")}</strong><span class="metric-value">${result.options?.trials ?? 1}</span></div>
<div><strong>${t("design_seed")}</strong><span class="metric-value">${stats.seed ?? "-"}</span></div>
<div><strong>${t("design_fixed_excluded")}</strong><span class="metric-value">${excludedFixedCount}</span></div>
<div><strong>${t("summary_elapsed")}</strong><span class="metric-value">${stats.design_time ? `${Number(stats.design_time).toFixed(3)} s` : "-"}</span></div>
</div>
<div class="compact-actions" style="margin-top: 12px;">
@ -6130,6 +6176,7 @@ A+B</textarea>
document.getElementById("designSeed").value = example.design?.seed ?? 0;
document.getElementById("designWobble").value = String(example.design?.wobble_mutations ?? false);
document.getElementById("designMaxTimeHours").value = ((example.design?.max_time_seconds ?? 0) / 3600);
document.getElementById("designFixedTargetPolicy").value = example.design?.fixed_target_policy ?? "exclude_from_optimization";
document.getElementById("complexesText").value = example.complexes_text;
const exampleCompute = Array.isArray(example.compute) ? example.compute : [];
document.querySelectorAll(".check input").forEach((input) => {

View file

@ -82,8 +82,11 @@ UNIT_SCALE = {
}
IUPAC_CODES = "ACGTUWSMKRYBDHVN"
IUPAC_CONSTRAINT_TOKEN = re.compile(rf"[{IUPAC_CODES}](?:\d+)?")
FIXED_BASES = set("ACGTU")
COMPLEMENT_BASE = str.maketrans({"A": "T", "C": "G", "G": "C", "T": "A", "U": "A"})
VALID_COMPUTE = {"pfunc", "pairs", "mfe", "sample", "subopt", "ensemble_size"}
FIXED_TARGET_POLICIES = {"exclude_from_optimization", "include"}
CANCEL_REQUESTED_STATUS = "cancel_requested"
CANCELED_STATUS = "canceled"
TERMINAL_JOB_STATUSES = {"success", "error", CANCELED_STATUS}
@ -245,6 +248,54 @@ def is_valid_iupac_constraint(sequence):
return True
def expand_iupac_constraint(sequence):
seq = normalize_design_sequence(sequence)
output = []
index = 0
while index < len(seq):
match = IUPAC_CONSTRAINT_TOKEN.match(seq, index)
if not match:
raise ValueError(f"Invalid sequence constraint near: {seq[index:]}")
token = match.group(0)
base = token[0]
count = int(token[1:] or "1")
output.append(base * count)
index = match.end()
return "".join(output)
def is_mutable_iupac_constraint(sequence):
expanded = expand_iupac_constraint(sequence)
return any(base not in FIXED_BASES for base in expanded)
def reverse_complement_fixed(sequence):
return sequence.upper().translate(COMPLEMENT_BASE)[::-1]
def parse_domain_tokens(text):
tokens = [token.strip() for token in re.split(r"[\s,]+", str(text or "").strip()) if token.strip()]
output = []
for token in tokens:
complement = False
domain_name = token
if token.startswith("~"):
complement = True
domain_name = token[1:]
elif token.endswith("*"):
complement = True
domain_name = token[:-1]
output.append((domain_name, complement))
return output
def get_mapping_value(mapping, key, default=None):
try:
return mapping[key]
except Exception:
return default
def ensure_unit_interval_limits(lower, upper, label):
if not (0 <= lower <= upper <= 1):
raise ValueError(f"{label} limits must satisfy 0 <= lower <= upper <= 1.")
@ -320,6 +371,7 @@ def parse_design_options(payload):
"seed": int(raw.get("seed", 0)),
"wobble_mutations": bool(raw.get("wobble_mutations", False)),
"max_time_seconds": int(raw.get("max_time_seconds", 0)),
"fixed_target_policy": str(raw.get("fixed_target_policy", "exclude_from_optimization")).strip().lower(),
}
if options["trials"] < 1 or options["trials"] > 8:
raise ValueError("design trials must be between 1 and 8.")
@ -331,6 +383,8 @@ def parse_design_options(payload):
raise ValueError("design stop_condition must be between 0 and 1.")
if options["max_time_seconds"] < 0:
raise ValueError("design max_time_seconds must be non-negative.")
if options["fixed_target_policy"] not in FIXED_TARGET_POLICIES:
raise ValueError("design fixed_target_policy must be exclude_from_optimization or include.")
return options
@ -388,11 +442,14 @@ def build_design_domains(domain_payload):
domain = Domain(sequence_constraint, name=name)
domain_map[name] = domain
mutable = is_mutable_iupac_constraint(sequence_constraint)
ordered.append(
{
"name": name,
"constraint": sequence_constraint,
"object": domain,
"mutable": mutable,
"fixed_sequence": None if mutable else expand_iupac_constraint(sequence_constraint),
}
)
return domain_map, ordered
@ -424,11 +481,12 @@ def parse_domain_composition(text, domain_map):
return domains
def build_design_strands(strand_payload, domain_map=None):
def build_design_strands(strand_payload, domain_map=None, domain_rows=None):
if not strand_payload:
raise ValueError("At least one design strand is required.")
domain_map = domain_map or {}
domain_info = {item["name"]: item for item in (domain_rows or [])}
use_domain_composition = bool(domain_map)
target_strand_map = {}
ordered = []
@ -457,11 +515,28 @@ def build_design_strands(strand_payload, domain_map=None):
target_strand = TargetStrand(strand_domains, name=name)
constraint_kind = "sequence_constraint"
constraint_value = sequence_constraint
mutable = is_mutable_iupac_constraint(sequence_constraint)
fixed_sequence = None if mutable else expand_iupac_constraint(sequence_constraint)
else:
inline_domain = None
target_strand = TargetStrand(strand_domains, name=name)
constraint_kind = "domain_composition"
constraint_value = raw_definition
fixed_parts = []
mutable = False
for domain_name, complement in parse_domain_tokens(raw_definition):
info = domain_info.get(domain_name)
if info is None:
mutable = True
fixed_parts = []
break
if info.get("mutable"):
mutable = True
fixed_parts = []
break
part = info.get("fixed_sequence") or ""
fixed_parts.append(reverse_complement_fixed(part) if complement else part)
fixed_sequence = None if mutable else "".join(fixed_parts)
else:
if not is_valid_iupac_constraint(sequence_constraint):
raise ValueError(f"Design strand {name} contains unsupported constraint characters.")
@ -470,6 +545,8 @@ def build_design_strands(strand_payload, domain_map=None):
target_strand = TargetStrand(strand_domains, name=name)
constraint_kind = "sequence_constraint"
constraint_value = sequence_constraint
mutable = is_mutable_iupac_constraint(sequence_constraint)
fixed_sequence = None if mutable else expand_iupac_constraint(sequence_constraint)
target_strand_map[name] = target_strand
ordered.append(
@ -481,6 +558,8 @@ def build_design_strands(strand_payload, domain_map=None):
"object": target_strand,
"domains": strand_domains,
"domain": inline_domain,
"mutable": mutable,
"fixed_sequence": fixed_sequence,
}
)
@ -728,13 +807,14 @@ def build_soft_constraints(payload, domain_map, strand_map, target_complex_map):
return constraints
def parse_design_complexes(payload, target_strand_map):
def parse_design_complexes(payload, target_strand_map, strand_rows=None):
target_rows = payload.get("design_complexes") or payload.get("design_targets") or []
if not target_rows:
raise ValueError("At least one design target complex is required.")
targets = []
target_complex_map = {}
strand_info = {item["name"]: item for item in (strand_rows or [])}
for idx, row in enumerate(target_rows, start=1):
name = (row.get("name") or f"target_{idx}").strip() or f"target_{idx}"
@ -761,11 +841,14 @@ def parse_design_complexes(payload, target_strand_map):
structure,
name=name,
)
mutable = any(strand_info.get(token, {}).get("mutable", True) for token in tokens)
target_payload = {
"name": name,
"strands": tokens,
"structure": structure,
"object": target_complex,
"mutable": mutable,
"optimization_status": "included",
}
targets.append(target_payload)
target_complex_map[name] = target_complex
@ -859,6 +942,21 @@ def parse_design_tubes(payload, target_rows, target_complex_map, default_max_siz
return ordered_rows, tubes
def payload_with_allowed_design_targets(payload, allowed_target_names):
allowed = set(allowed_target_names)
filtered_tubes = []
for row in payload.get("design_tubes") or []:
on_targets = [entry for entry in (row.get("on_targets") or []) if (entry.get("complex") or "").strip() in allowed]
if on_targets:
tube_row = dict(row)
tube_row["on_targets"] = on_targets
filtered_tubes.append(tube_row)
output = dict(payload)
output["design_tubes"] = filtered_tubes
return output
def validate_design_object_names(design_domains, design_strands, target_rows, tube_rows):
name_map = {}
for kind, rows in (
@ -1206,46 +1304,62 @@ def serialize_design_result(
ordered_domains,
ordered_strands,
):
analysis_map = getattr(design_result, "to_analysis", {}) or {}
designed_domains = []
designed_domain_map = getattr(design_result, "domains", {}) or {}
for item in ordered_domains:
designed_domain = designed_domain_map.get(item["object"])
domain_sequence = str(designed_domain) if designed_domain is not None else item.get("fixed_sequence")
designed_domains.append(
{
"name": item["name"],
"constraint": item["constraint"],
"sequence": str(designed_domain) if designed_domain is not None else None,
"length": len(str(designed_domain)) if designed_domain is not None else None,
"sequence": domain_sequence,
"length": len(domain_sequence) if domain_sequence is not None else None,
"mutable": bool(item.get("mutable", True)),
}
)
designed_strands = []
designed_strand_by_name = {}
for item in ordered_strands:
target_strand = item["object"]
analysis_strand = design_result.to_analysis[target_strand]
analysis_strand = get_mapping_value(analysis_map, target_strand)
sequence = str(analysis_strand) if analysis_strand is not None else item.get("fixed_sequence")
designed_strands.append(
{
"name": item["name"],
"constraint": item["constraint"],
"constraint_kind": item.get("constraint_kind", "sequence_constraint"),
"definition": item.get("definition", item["constraint"]),
"sequence": str(analysis_strand),
"length": len(str(analysis_strand)),
"sequence": sequence,
"length": len(sequence) if sequence is not None else None,
"mutable": bool(item.get("mutable", True)),
}
)
if sequence is not None:
designed_strand_by_name[item["name"]] = sequence
target_complexes = []
for target in target_rows:
target_complex = target["object"]
analysis_complex = design_result.to_analysis[target_complex]
analysis_complex = get_mapping_value(analysis_map, target_complex)
if analysis_complex is not None:
display = stringify_complex(analysis_complex)
sequence = flatten_sequence(analysis_complex)
else:
display = " + ".join(target["strands"])
sequence = "".join(designed_strand_by_name.get(name, "") for name in target["strands"])
target_complexes.append(
{
"name": target["name"],
"display": stringify_complex(analysis_complex),
"display": display,
"strand_names": list(target["strands"]),
"structure": target["structure"],
"sequence": flatten_sequence(analysis_complex),
"sequence": sequence,
"target_concentration_M": target.get("target_concentration_M"),
"optimization_status": target.get("optimization_status", "included"),
"mutable": bool(target.get("mutable", True)),
}
)
@ -1291,6 +1405,11 @@ def serialize_design_result(
"seed": design_options["seed"],
"wobble_mutations": design_options["wobble_mutations"],
"max_time_seconds": design_options["max_time_seconds"],
"fixed_target_policy": design_options["fixed_target_policy"],
},
"optimization": {
"included_targets": [row["name"] for row in target_rows if row.get("optimization_status") == "included"],
"excluded_fixed_targets": [row["name"] for row in target_rows if row.get("optimization_status") == "fixed_excluded"],
},
"target_tubes": [
{
@ -1346,22 +1465,42 @@ def run_job_payload(payload):
target_strand_map, design_strands = build_design_strands(
payload.get("strands") or [],
domain_map=design_domain_map,
domain_rows=design_domains,
)
target_rows, target_complex_map = parse_design_complexes(payload, target_strand_map)
target_rows, target_complex_map = parse_design_complexes(payload, target_strand_map, strand_rows=design_strands)
optimization_target_rows = target_rows
optimization_target_complex_map = target_complex_map
if design_options["fixed_target_policy"] == "exclude_from_optimization":
optimization_target_rows = [row for row in target_rows if row.get("mutable", True)]
fixed_names = {row["name"] for row in target_rows if not row.get("mutable", True)}
for row in target_rows:
if row["name"] in fixed_names:
row["optimization_status"] = "fixed_excluded"
optimization_target_complex_map = {
row["name"]: row["object"] for row in optimization_target_rows
}
if not optimization_target_rows:
raise ValueError("Design contains no mutable target complexes after fixed-target filtering.")
hard_constraints = build_hard_constraints(payload, design_domain_map, target_strand_map)
soft_constraints = build_soft_constraints(
payload,
design_domain_map,
target_strand_map,
target_complex_map,
optimization_target_complex_map,
)
tube_rows = []
design_tubes = []
if mode == "tube":
optimization_payload = payload
if design_options["fixed_target_policy"] == "exclude_from_optimization":
optimization_payload = payload_with_allowed_design_targets(
payload,
{row["name"] for row in optimization_target_rows},
)
tube_rows, design_tubes = parse_design_tubes(
payload,
target_rows,
target_complex_map,
optimization_payload,
optimization_target_rows,
optimization_target_complex_map,
design_options["off_target_max_size"],
)
validate_design_object_names(design_domains, design_strands, target_rows, tube_rows)
@ -1386,7 +1525,7 @@ def run_job_payload(payload):
)
elif mode == "complex":
design_job = complex_design(
complexes=[row["object"] for row in target_rows],
complexes=[row["object"] for row in optimization_target_rows],
model=model,
options=design_job_options,
hard_constraints=hard_constraints,
@ -1923,6 +2062,69 @@ def get_share(share_id):
return dict(item) if item else None
def public_share_payload(share_id, *, record_access=True):
metadata = ACCOUNT_STORE.share_metadata(share_id)
if metadata is None:
return get_share(share_id)
live = get_job_data(metadata["job_id"], include_payload=True)
if live is not None:
if record_access:
ACCOUNT_STORE.record_share_access(share_id)
payload = live.get("payload")
if payload is None:
account_job = ACCOUNT_STORE.get_job(metadata["user_id"], metadata["job_id"], include_content=True)
payload = account_job.get("payload") if account_job else None
result = live.get("result")
error = live.get("error")
return {
"id": share_id,
"share_id": share_id,
"job_id": metadata["job_id"],
"created_at": metadata["created_at"],
"status": live.get("status"),
"updated_at": live.get("updated_at"),
"elapsed_seconds": live.get("elapsed_seconds"),
"payload": payload,
"result": result,
"error": error,
"result_summary": build_history_summary(payload, live),
}
return ACCOUNT_STORE.resolve_share(share_id, record_access=record_access)
def build_history_summary(payload, fallback=None):
payload = payload or {}
fallback = fallback or {}
model = payload.get("model") or {}
workflow = payload.get("workflow") or fallback.get("workflow") or "analysis"
mode = payload.get("mode") or fallback.get("mode") or "tube"
strands = payload.get("strands") or []
if workflow == "design":
complexes = payload.get("design_complexes") or payload.get("design_targets") or []
tube_sizes = [int(row.get("max_size", 0) or 0) for row in (payload.get("design_tubes") or [])]
max_size = max(tube_sizes, default=int((payload.get("design") or {}).get("off_target_max_size", 0) or 0))
else:
complexes = [line for line in str(payload.get("complexes_text") or "").splitlines() if line.strip()]
max_size = int((payload.get("tube") or {}).get("max_size", 0) or 0)
return {
"workflow": workflow,
"mode": mode,
"material": model.get("material", fallback.get("material", "rna")),
"celsius": float(model.get("celsius", fallback.get("celsius", 37)) or 37),
"sodium": float(model.get("sodium", fallback.get("sodium", 0)) or 0),
"magnesium": float(model.get("magnesium", fallback.get("magnesium", 0)) or 0),
"max_size": max_size,
"strand_count": len(strands),
"complex_count": len(complexes),
"compute": list(payload.get("compute") or fallback.get("compute") or ([] if workflow != "design" else ["design"])),
"trials": int((payload.get("design") or {}).get("trials", fallback.get("trials", 0)) or 0),
"stop_condition": float((payload.get("design") or {}).get("stop_condition", fallback.get("stop_condition", 0)) or 0),
"max_time_seconds": int((payload.get("design") or {}).get("max_time_seconds", fallback.get("max_time_seconds", 0)) or 0),
}
def recover_interrupted_jobs():
client = redis_client()
recovered = 0
@ -2043,6 +2245,7 @@ EXAMPLE_PAYLOAD = {
"seed": 0,
"wobble_mutations": False,
"max_time_seconds": 0,
"fixed_target_policy": "exclude_from_optimization",
},
"design_domains": [
{"name": "a", "sequence": "N10"},
@ -2104,6 +2307,7 @@ DESIGN_TUBE_EXAMPLE_PAYLOAD = {
"seed": 1,
"wobble_mutations": False,
"max_time_seconds": 0,
"fixed_target_policy": "exclude_from_optimization",
},
"design_domains": [
{"name": "a", "sequence": "N10"},
@ -2329,7 +2533,7 @@ class AppHandler(BaseHTTPRequestHandler):
if parsed.path.startswith("/api/shares/"):
share_id = parsed.path.rsplit("/", 1)[-1]
share = ACCOUNT_STORE.resolve_share(share_id) or get_share(share_id)
share = public_share_payload(share_id, record_access=(parse_qs(parsed.query).get("poll") or ["0"])[0] != "1")
if share is None:
self._respond(*json_bytes({"error": "Share not found"}, status=HTTPStatus.NOT_FOUND))
return

98
service/test_account.py Normal file
View file

@ -0,0 +1,98 @@
import tempfile
import time
import unittest
from pathlib import Path
from service.account import AccountStore
def user(user_id):
return {
"user_id": user_id,
"username": user_id,
"display_name": user_id.title(),
"email": f"{user_id}@example.test",
"groups": [],
}
class AccountStoreTest(unittest.TestCase):
def setUp(self):
self.temporary_directory = tempfile.TemporaryDirectory()
self.store = AccountStore(Path(self.temporary_directory.name) / "account.sqlite3")
self.alice = user("alice")
self.bob = user("bob")
self.payload = {
"workflow": "analysis",
"mode": "tube",
"model": {"material": "rna", "celsius": 37, "sodium": 1, "magnesium": 0},
"strands": [{"name": "A", "sequence": "ACGU"}],
"complexes_text": "A",
"tube": {"max_size": 1},
"compute": ["mfe"],
}
def tearDown(self):
self.temporary_directory.cleanup()
def test_jobs_are_private_and_content_round_trips(self):
result = {"workflow": "analysis", "complex_results": [{"name": "A"}]}
self.store.create_job("job-1", self.alice, self.payload)
self.store.update_job("job-1", "success", result=result, elapsed_seconds=1.25)
self.assertIsNone(self.store.get_job(self.bob["user_id"], "job-1"))
item = self.store.get_job(self.alice["user_id"], "job-1")
self.assertEqual(item["payload"], self.payload)
self.assertEqual(item["result"], result)
self.assertEqual(item["elapsed_seconds"], 1.25)
self.assertEqual(self.store.list_jobs("alice")["total"], 1)
self.assertEqual(self.store.list_jobs("bob")["total"], 0)
def test_share_can_expire_and_be_disabled(self):
self.store.create_job("job-2", self.alice, self.payload, status="success", result={"ok": True})
share = self.store.create_share("alice", "job-2")
resolved = self.store.resolve_share(share["share_id"])
self.assertEqual(resolved["job_id"], "job-2")
self.assertEqual(resolved["result"], {"ok": True})
self.store.update_share("alice", share["share_id"], active=False)
self.assertIsNone(self.store.resolve_share(share["share_id"]))
self.assertIsNone(self.store.update_share("bob", share["share_id"], active=True))
expiring = self.store.create_share("alice", "job-2", expires_in=1)
with self.store._connect() as connection:
connection.execute(
"UPDATE shares SET expires_at=? WHERE share_id=?",
(time.time() - 1, expiring["share_id"]),
)
self.assertIsNone(self.store.resolve_share(expiring["share_id"]))
def test_running_job_can_be_shared_and_later_exposes_result(self):
self.store.create_job("job-running", self.alice, self.payload, status="running")
share = self.store.create_share("alice", "job-running")
resolved = self.store.resolve_share(share["share_id"])
self.assertEqual(resolved["job_id"], "job-running")
self.assertEqual(resolved["status"], "running")
self.assertIsNone(resolved["result"])
self.store.update_job("job-running", "success", result={"ok": True}, elapsed_seconds=2.5)
resolved = self.store.resolve_share(share["share_id"])
self.assertEqual(resolved["status"], "success")
self.assertEqual(resolved["result"], {"ok": True})
self.assertEqual(resolved["elapsed_seconds"], 2.5)
self.assertIsNone(self.store.update_share("bob", share["share_id"], active=False))
self.store.update_share("alice", share["share_id"], active=False)
self.assertIsNone(self.store.resolve_share(share["share_id"]))
def test_active_job_cannot_be_deleted(self):
self.store.create_job("job-3", self.alice, self.payload)
with self.assertRaises(ValueError):
self.store.delete_job("alice", "job-3")
self.store.update_job("job-3", "canceled", error={"message": "canceled"})
self.assertTrue(self.store.delete_job("alice", "job-3"))
if __name__ == "__main__":
unittest.main()