Spaces:
Running
Running
github-actions[bot] commited on
Commit ·
7443b93
1
Parent(s): 0bdb418
Deploy df060e3
Browse filesThe construct is the noun; the tools are verbs on it
Source: https://github.com/WINTER4000/turingDNA/commit/df060e3d534f6dad3fd12bf6cc4e4820f3d0da68
- dee/auth.py +258 -0
- dee/server.py +141 -0
- dee/static/app.css +69 -0
- dee/static/app.js +33 -9
- dee/static/context.js +359 -0
- dee/static/index.html +20 -2
- tests/test_construct_context.py +259 -0
dee/auth.py
CHANGED
|
@@ -3026,3 +3026,261 @@ def edit_memory(user_id: Optional[str], memory_id: str, fact: str) -> Dict[str,
|
|
| 3026 |
def forget_memory(user_id: Optional[str], memory_id: str) -> Dict[str, Any]:
|
| 3027 |
"""Delete outright. Memory the user cannot remove isn't theirs."""
|
| 3028 |
return _memory_write(user_id or "", memory_id, "DELETE")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3026 |
def forget_memory(user_id: Optional[str], memory_id: str) -> Dict[str, Any]:
|
| 3027 |
"""Delete outright. Memory the user cannot remove isn't theirs."""
|
| 3028 |
return _memory_write(user_id or "", memory_id, "DELETE")
|
| 3029 |
+
|
| 3030 |
+
|
| 3031 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 3032 |
+
# Constructs — the thing every tool operates ON
|
| 3033 |
+
# ═══════════════════════════════════════════════════════════════════════════
|
| 3034 |
+
# Migration 0012 created this table for exactly this purpose and nothing ever
|
| 3035 |
+
# read or wrote it. Meanwhile /api/mission reconstructed "constructs" on every
|
| 3036 |
+
# request by grouping artifacts on a NORMALISED NAME STRING — which merges
|
| 3037 |
+
# every artifact left at its default name ("Plasmid", "Library", …) into one
|
| 3038 |
+
# card, and splits a construct in two the moment you rename something.
|
| 3039 |
+
#
|
| 3040 |
+
# The point of wiring it now is not storage. It is that once a construct is
|
| 3041 |
+
# the thing the user has selected, every tool call can carry its id, and the
|
| 3042 |
+
# question "what work belongs together?" stops being a guess reconstructed
|
| 3043 |
+
# afterwards and becomes something recorded at the moment it happened.
|
| 3044 |
+
#
|
| 3045 |
+
# NOTE on the *_ids arrays: they are a bag, not a graph — they say a construct
|
| 3046 |
+
# HAS these artifacts, never that one was derived from another. Real lineage
|
| 3047 |
+
# (which region of which plasmid produced which guide set) needs its own edge
|
| 3048 |
+
# table; this is the grouping layer beneath it, not a substitute for it.
|
| 3049 |
+
|
| 3050 |
+
_CONSTRUCTS_LIST_LIMIT = 100
|
| 3051 |
+
_CONSTRUCT_SEQ_MAX = 400_000
|
| 3052 |
+
_CONSTRUCT_ARTIFACT_KINDS = {"plasmid": "plasmid_ids",
|
| 3053 |
+
"crispr": "crispr_ids",
|
| 3054 |
+
"primer": "primer_ids"}
|
| 3055 |
+
_CONSTRUCT_PHASES = ("Design", "Build", "Edit", "Learn")
|
| 3056 |
+
|
| 3057 |
+
|
| 3058 |
+
def _valid_uuid(value: str) -> bool:
|
| 3059 |
+
import re as _re
|
| 3060 |
+
return bool(_re.match(r"^[0-9a-fA-F-]{36}$", value or ""))
|
| 3061 |
+
|
| 3062 |
+
|
| 3063 |
+
def save_construct(
|
| 3064 |
+
user_id: Optional[str], *,
|
| 3065 |
+
name: str, sequence_hash: str, wt_identifier: str = "",
|
| 3066 |
+
wt_protein: str = "", sequence_dna: str = "", phase: str = "Design",
|
| 3067 |
+
meta: Optional[Dict[str, Any]] = None,
|
| 3068 |
+
) -> Dict[str, Any]:
|
| 3069 |
+
"""Create a construct. Returns {ok, id} or {ok:False, error}.
|
| 3070 |
+
|
| 3071 |
+
Callers should prefer :func:`find_or_create_construct` — a user pasting the
|
| 3072 |
+
same sequence twice means "the thing I am already working on", not a second
|
| 3073 |
+
project, and silently creating duplicates is how a workspace becomes a mess
|
| 3074 |
+
the user has to tidy.
|
| 3075 |
+
"""
|
| 3076 |
+
if not user_id:
|
| 3077 |
+
return {"ok": False, "error": "Sign in to keep projects."}
|
| 3078 |
+
if not (SUPABASE_URL and SUPABASE_SERVICE_KEY):
|
| 3079 |
+
return {"ok": False, "error": "Projects aren't configured on this server."}
|
| 3080 |
+
if not sequence_hash:
|
| 3081 |
+
return {"ok": False, "error": "A construct needs a sequence to group on."}
|
| 3082 |
+
from datetime import datetime, timezone, timedelta
|
| 3083 |
+
expires_at = (None if has_pro_plan(user_id) else
|
| 3084 |
+
(datetime.now(timezone.utc)
|
| 3085 |
+
+ timedelta(days=FREE_LIBRARY_TTL_DAYS)).isoformat())
|
| 3086 |
+
payload = {
|
| 3087 |
+
"user_id": user_id,
|
| 3088 |
+
"name": (name or "Untitled construct")[:120],
|
| 3089 |
+
"sequence_hash": sequence_hash[:64],
|
| 3090 |
+
"wt_identifier": (wt_identifier or "")[:200] or None,
|
| 3091 |
+
"wt_protein": (wt_protein or "")[:_CONSTRUCT_SEQ_MAX],
|
| 3092 |
+
"sequence_dna": (sequence_dna or "")[:_CONSTRUCT_SEQ_MAX],
|
| 3093 |
+
"phase": phase if phase in _CONSTRUCT_PHASES else "Design",
|
| 3094 |
+
"meta": meta or {},
|
| 3095 |
+
"expires_at": expires_at,
|
| 3096 |
+
}
|
| 3097 |
+
import urllib.request
|
| 3098 |
+
import urllib.error
|
| 3099 |
+
try:
|
| 3100 |
+
req = urllib.request.Request(
|
| 3101 |
+
f"{SUPABASE_URL}/rest/v1/constructs",
|
| 3102 |
+
data=json.dumps(payload).encode("utf-8"),
|
| 3103 |
+
method="POST",
|
| 3104 |
+
headers=_user_pgrst_headers({"Content-Type": "application/json",
|
| 3105 |
+
"Prefer": "return=representation"}),
|
| 3106 |
+
)
|
| 3107 |
+
with urllib.request.urlopen(req, timeout=8.0) as resp:
|
| 3108 |
+
rows = json.loads(resp.read().decode("utf-8"))
|
| 3109 |
+
row = rows[0] if isinstance(rows, list) and rows else {}
|
| 3110 |
+
return {"ok": True, "id": row.get("id"), "construct": row}
|
| 3111 |
+
except urllib.error.HTTPError as exc:
|
| 3112 |
+
body = ""
|
| 3113 |
+
try:
|
| 3114 |
+
body = exc.read().decode("utf-8")[:200]
|
| 3115 |
+
except Exception: # noqa: BLE001
|
| 3116 |
+
pass
|
| 3117 |
+
logger.warning("constructs insert HTTP %s: %s", exc.code, body)
|
| 3118 |
+
if exc.code in (404, 406) or "PGRST" in body:
|
| 3119 |
+
return {"ok": False,
|
| 3120 |
+
"error": "Projects aren't enabled yet (database migration pending)."}
|
| 3121 |
+
return {"ok": False, "error": "Couldn't create the project."}
|
| 3122 |
+
except Exception as exc: # noqa: BLE001
|
| 3123 |
+
logger.warning("constructs insert failed: %s", exc)
|
| 3124 |
+
return {"ok": False, "error": "Couldn't create the project."}
|
| 3125 |
+
|
| 3126 |
+
|
| 3127 |
+
def list_constructs(user_id: Optional[str]) -> list:
|
| 3128 |
+
"""The user's constructs, newest-touched first. Metadata only — the DNA
|
| 3129 |
+
and protein are deliberately excluded so the switcher stays small."""
|
| 3130 |
+
if not user_id or not (SUPABASE_URL and SUPABASE_SERVICE_KEY):
|
| 3131 |
+
return []
|
| 3132 |
+
import urllib.request
|
| 3133 |
+
try:
|
| 3134 |
+
url = (f"{SUPABASE_URL}/rest/v1/constructs"
|
| 3135 |
+
f"?user_id=eq.{user_id}"
|
| 3136 |
+
f"&select=id,name,created_at,updated_at,phase,sequence_hash,"
|
| 3137 |
+
f"wt_identifier,library_id,plasmid_ids,crispr_ids,primer_ids"
|
| 3138 |
+
f"&order=updated_at.desc&limit={_CONSTRUCTS_LIST_LIMIT}")
|
| 3139 |
+
req = urllib.request.Request(url, method="GET", headers=_user_pgrst_headers())
|
| 3140 |
+
with urllib.request.urlopen(req, timeout=6.0) as resp:
|
| 3141 |
+
return json.loads(resp.read().decode("utf-8")) or []
|
| 3142 |
+
except Exception as exc: # noqa: BLE001
|
| 3143 |
+
logger.warning("constructs list failed for %s: %s", user_id, exc)
|
| 3144 |
+
return []
|
| 3145 |
+
|
| 3146 |
+
|
| 3147 |
+
def get_construct(user_id: Optional[str], construct_id: str) -> Optional[Dict[str, Any]]:
|
| 3148 |
+
"""One construct in full, ownership enforced."""
|
| 3149 |
+
if not user_id or not (SUPABASE_URL and SUPABASE_SERVICE_KEY):
|
| 3150 |
+
return None
|
| 3151 |
+
if not _valid_uuid(construct_id):
|
| 3152 |
+
return None
|
| 3153 |
+
import urllib.request
|
| 3154 |
+
try:
|
| 3155 |
+
url = (f"{SUPABASE_URL}/rest/v1/constructs"
|
| 3156 |
+
f"?id=eq.{construct_id}&user_id=eq.{user_id}&select=*&limit=1")
|
| 3157 |
+
req = urllib.request.Request(url, method="GET", headers=_user_pgrst_headers())
|
| 3158 |
+
with urllib.request.urlopen(req, timeout=6.0) as resp:
|
| 3159 |
+
rows = json.loads(resp.read().decode("utf-8"))
|
| 3160 |
+
return rows[0] if isinstance(rows, list) and rows else None
|
| 3161 |
+
except Exception as exc: # noqa: BLE001
|
| 3162 |
+
logger.warning("constructs get failed: %s", exc)
|
| 3163 |
+
return None
|
| 3164 |
+
|
| 3165 |
+
|
| 3166 |
+
def find_construct_by_hash(user_id: Optional[str], sequence_hash: str
|
| 3167 |
+
) -> Optional[Dict[str, Any]]:
|
| 3168 |
+
"""The construct already grouping this sequence, if any.
|
| 3169 |
+
|
| 3170 |
+
This is the whole reason `sequence_hash` is on the table: the same sequence
|
| 3171 |
+
means the same piece of work, regardless of what the user called it. It is
|
| 3172 |
+
the honest version of the name-string matching /api/mission does today.
|
| 3173 |
+
"""
|
| 3174 |
+
if not user_id or not sequence_hash or not (SUPABASE_URL and SUPABASE_SERVICE_KEY):
|
| 3175 |
+
return None
|
| 3176 |
+
import urllib.request
|
| 3177 |
+
import urllib.parse
|
| 3178 |
+
try:
|
| 3179 |
+
url = (f"{SUPABASE_URL}/rest/v1/constructs"
|
| 3180 |
+
f"?user_id=eq.{user_id}"
|
| 3181 |
+
f"&sequence_hash=eq.{urllib.parse.quote(sequence_hash[:64])}"
|
| 3182 |
+
f"&select=*&order=updated_at.desc&limit=1")
|
| 3183 |
+
req = urllib.request.Request(url, method="GET", headers=_user_pgrst_headers())
|
| 3184 |
+
with urllib.request.urlopen(req, timeout=6.0) as resp:
|
| 3185 |
+
rows = json.loads(resp.read().decode("utf-8"))
|
| 3186 |
+
return rows[0] if isinstance(rows, list) and rows else None
|
| 3187 |
+
except Exception as exc: # noqa: BLE001
|
| 3188 |
+
logger.warning("constructs find-by-hash failed: %s", exc)
|
| 3189 |
+
return None
|
| 3190 |
+
|
| 3191 |
+
|
| 3192 |
+
def update_construct(user_id: Optional[str], construct_id: str,
|
| 3193 |
+
**fields: Any) -> Dict[str, Any]:
|
| 3194 |
+
"""Patch a construct. Only a fixed set of fields is writable, so a stray
|
| 3195 |
+
key from a request body can never reach the row."""
|
| 3196 |
+
if not user_id or not (SUPABASE_URL and SUPABASE_SERVICE_KEY):
|
| 3197 |
+
return {"ok": False, "error": "Not configured."}
|
| 3198 |
+
if not _valid_uuid(construct_id):
|
| 3199 |
+
return {"ok": False, "error": "Bad id."}
|
| 3200 |
+
allowed = {"name", "phase", "library_id", "plasmid_ids", "crispr_ids",
|
| 3201 |
+
"primer_ids", "meta", "wt_protein", "sequence_dna",
|
| 3202 |
+
"wt_identifier"}
|
| 3203 |
+
payload = {k: v for k, v in fields.items() if k in allowed and v is not None}
|
| 3204 |
+
if "name" in payload:
|
| 3205 |
+
payload["name"] = str(payload["name"])[:120] or "Untitled construct"
|
| 3206 |
+
if "phase" in payload and payload["phase"] not in _CONSTRUCT_PHASES:
|
| 3207 |
+
payload.pop("phase")
|
| 3208 |
+
if not payload:
|
| 3209 |
+
return {"ok": False, "error": "Nothing to update."}
|
| 3210 |
+
from datetime import datetime, timezone
|
| 3211 |
+
payload["updated_at"] = datetime.now(timezone.utc).isoformat()
|
| 3212 |
+
import urllib.request
|
| 3213 |
+
try:
|
| 3214 |
+
url = (f"{SUPABASE_URL}/rest/v1/constructs"
|
| 3215 |
+
f"?id=eq.{construct_id}&user_id=eq.{user_id}")
|
| 3216 |
+
req = urllib.request.Request(
|
| 3217 |
+
url, data=json.dumps(payload).encode("utf-8"), method="PATCH",
|
| 3218 |
+
headers=_user_pgrst_headers({"Content-Type": "application/json",
|
| 3219 |
+
"Prefer": "return=representation"}))
|
| 3220 |
+
with urllib.request.urlopen(req, timeout=6.0) as resp:
|
| 3221 |
+
rows = json.loads(resp.read().decode("utf-8"))
|
| 3222 |
+
return {"ok": True,
|
| 3223 |
+
"construct": rows[0] if isinstance(rows, list) and rows else None}
|
| 3224 |
+
except Exception as exc: # noqa: BLE001
|
| 3225 |
+
logger.warning("constructs update failed: %s", exc)
|
| 3226 |
+
return {"ok": False, "error": "Couldn't update the project."}
|
| 3227 |
+
|
| 3228 |
+
|
| 3229 |
+
def delete_construct(user_id: Optional[str], construct_id: str) -> Dict[str, Any]:
|
| 3230 |
+
"""Delete the grouping only. The artifacts it referenced are NOT touched:
|
| 3231 |
+
deleting a project must never silently destroy a plasmid the user still
|
| 3232 |
+
has open in another tab."""
|
| 3233 |
+
if not user_id or not (SUPABASE_URL and SUPABASE_SERVICE_KEY):
|
| 3234 |
+
return {"ok": False, "error": "Not configured."}
|
| 3235 |
+
if not _valid_uuid(construct_id):
|
| 3236 |
+
return {"ok": False, "error": "Bad id."}
|
| 3237 |
+
import urllib.request
|
| 3238 |
+
try:
|
| 3239 |
+
url = (f"{SUPABASE_URL}/rest/v1/constructs"
|
| 3240 |
+
f"?id=eq.{construct_id}&user_id=eq.{user_id}")
|
| 3241 |
+
req = urllib.request.Request(
|
| 3242 |
+
url, method="DELETE",
|
| 3243 |
+
headers=_user_pgrst_headers({"Prefer": "return=minimal"}))
|
| 3244 |
+
with urllib.request.urlopen(req, timeout=6.0):
|
| 3245 |
+
return {"ok": True}
|
| 3246 |
+
except Exception as exc: # noqa: BLE001
|
| 3247 |
+
logger.warning("constructs delete failed: %s", exc)
|
| 3248 |
+
return {"ok": False, "error": "Couldn't delete."}
|
| 3249 |
+
|
| 3250 |
+
|
| 3251 |
+
def attach_artifact(user_id: Optional[str], construct_id: str,
|
| 3252 |
+
kind: str, artifact_id: str) -> Dict[str, Any]:
|
| 3253 |
+
"""Record that an artifact belongs to a construct, and advance the phase.
|
| 3254 |
+
|
| 3255 |
+
Read-modify-write rather than an atomic array append, because PostgREST
|
| 3256 |
+
has no append primitive. Two tool runs finishing in the same instant could
|
| 3257 |
+
drop one id — acceptable for a single user's own work, and the loss is a
|
| 3258 |
+
missing cross-reference rather than a lost artifact, which still exists in
|
| 3259 |
+
its own table. Worth replacing when the lineage edge table lands, since
|
| 3260 |
+
that wants insert-only rows anyway.
|
| 3261 |
+
|
| 3262 |
+
Never raises: attribution failing must not fail the tool run the user
|
| 3263 |
+
actually asked for.
|
| 3264 |
+
"""
|
| 3265 |
+
col = _CONSTRUCT_ARTIFACT_KINDS.get(kind)
|
| 3266 |
+
if not user_id or not col or not _valid_uuid(construct_id) \
|
| 3267 |
+
or not _valid_uuid(artifact_id):
|
| 3268 |
+
return {"ok": False, "error": "Bad attach request."}
|
| 3269 |
+
row = get_construct(user_id, construct_id)
|
| 3270 |
+
if not row:
|
| 3271 |
+
return {"ok": False, "error": "No such project."}
|
| 3272 |
+
current = row.get(col) or []
|
| 3273 |
+
if artifact_id in current:
|
| 3274 |
+
return {"ok": True, "already": True}
|
| 3275 |
+
updated = (current + [artifact_id])[-200:]
|
| 3276 |
+
# Phase only ever moves forward. A user opening the CRISPR tool after
|
| 3277 |
+
# logging results should not drag the project back from Learn to Edit.
|
| 3278 |
+
phase_for = {"plasmid": "Build", "primer": "Build", "crispr": "Edit"}
|
| 3279 |
+
want = phase_for.get(kind)
|
| 3280 |
+
fields: Dict[str, Any] = {col: updated}
|
| 3281 |
+
if want:
|
| 3282 |
+
cur_i = _CONSTRUCT_PHASES.index(row.get("phase") or "Design") \
|
| 3283 |
+
if (row.get("phase") in _CONSTRUCT_PHASES) else 0
|
| 3284 |
+
if _CONSTRUCT_PHASES.index(want) > cur_i:
|
| 3285 |
+
fields["phase"] = want
|
| 3286 |
+
return update_construct(user_id, construct_id, **fields)
|
dee/server.py
CHANGED
|
@@ -826,6 +826,8 @@ _RL_RULES = [
|
|
| 826 |
("/api/dna/generate", (6, 60)), # autoregressive — priciest
|
| 827 |
("/api/compiler", (30, 60)), # pure logic; probes DNA reach
|
| 828 |
("/api/dna", (12, 60)),
|
|
|
|
|
|
|
| 829 |
("/api/plasmid", (60, 60)),
|
| 830 |
("/api/ping", (60, 60)), # dwell heartbeat — its own
|
| 831 |
# bucket so ~2/min never eats
|
|
@@ -2198,6 +2200,25 @@ def create_app() -> Flask:
|
|
| 2198 |
return jsonify({"error": "Alignment failed — check the two sequences."}), 500
|
| 2199 |
return jsonify({"ok": True, **result})
|
| 2200 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2201 |
@app.post("/api/plasmid/save")
|
| 2202 |
def plasmid_save() -> Response:
|
| 2203 |
auth, denied = _plasmid_signin_guard()
|
|
@@ -2218,6 +2239,7 @@ def create_app() -> Flask:
|
|
| 2218 |
source=str(body.get("source", "") or "")[:16] or None,
|
| 2219 |
sequence=data["sequence"], features=data["features"],
|
| 2220 |
)
|
|
|
|
| 2221 |
_auth.cleanup_expired_plasmids_async(auth.user_id)
|
| 2222 |
return jsonify(result), (200 if result.get("ok") else 502)
|
| 2223 |
|
|
@@ -2761,6 +2783,7 @@ def create_app() -> Flask:
|
|
| 2761 |
input_length=input_length,
|
| 2762 |
guides=guides,
|
| 2763 |
)
|
|
|
|
| 2764 |
_auth.cleanup_expired_crispr_designs_async(auth.user_id) # lazy sweep
|
| 2765 |
return jsonify(result), (200 if result.get("ok") else 502)
|
| 2766 |
|
|
@@ -2815,6 +2838,7 @@ def create_app() -> Flask:
|
|
| 2815 |
n_primers=n_primers,
|
| 2816 |
result=result_obj,
|
| 2817 |
)
|
|
|
|
| 2818 |
_auth.cleanup_expired_primer_analyses_async(auth.user_id) # lazy sweep
|
| 2819 |
return jsonify(result), (200 if result.get("ok") else 502)
|
| 2820 |
|
|
@@ -3646,6 +3670,123 @@ def create_app() -> Flask:
|
|
| 3646 |
data["ok"] = True
|
| 3647 |
return _public_cors(jsonify(data))
|
| 3648 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3649 |
@app.get("/api/mission")
|
| 3650 |
def mission() -> Response:
|
| 3651 |
"""Command-center rollup for the Mission Control home — ONE call that
|
|
|
|
| 826 |
("/api/dna/generate", (6, 60)), # autoregressive — priciest
|
| 827 |
("/api/compiler", (30, 60)), # pure logic; probes DNA reach
|
| 828 |
("/api/dna", (12, 60)),
|
| 829 |
+
("/api/constructs", (90, 60)), # cheap CRUD; the
|
| 830 |
+
# switcher polls it
|
| 831 |
("/api/plasmid", (60, 60)),
|
| 832 |
("/api/ping", (60, 60)), # dwell heartbeat — its own
|
| 833 |
# bucket so ~2/min never eats
|
|
|
|
| 2200 |
return jsonify({"error": "Alignment failed — check the two sequences."}), 500
|
| 2201 |
return jsonify({"ok": True, **result})
|
| 2202 |
|
| 2203 |
+
# ── Attribution ───────────────────────────────────────────────────────
|
| 2204 |
+
# The whole point of the construct being the selected object: when a tool
|
| 2205 |
+
# saves something and the client sent the id it was working under, the
|
| 2206 |
+
# link is RECORDED at the moment it happened rather than reconstructed
|
| 2207 |
+
# later by matching name strings.
|
| 2208 |
+
#
|
| 2209 |
+
# Deliberately best-effort and silent. Attribution failing must never fail
|
| 2210 |
+
# the save the user actually asked for — the artifact still exists in its
|
| 2211 |
+
# own table, it is only the cross-reference that is missing.
|
| 2212 |
+
def _attribute(auth, body, kind, result):
|
| 2213 |
+
try:
|
| 2214 |
+
cid = str((body or {}).get("construct_id") or "").strip()
|
| 2215 |
+
new_id = (result or {}).get("id")
|
| 2216 |
+
if not cid or not new_id or auth.anonymous:
|
| 2217 |
+
return
|
| 2218 |
+
_auth.attach_artifact(auth.user_id, cid, kind, str(new_id))
|
| 2219 |
+
except Exception: # noqa: BLE001
|
| 2220 |
+
app.logger.warning("attribution failed for %s", kind, exc_info=True)
|
| 2221 |
+
|
| 2222 |
@app.post("/api/plasmid/save")
|
| 2223 |
def plasmid_save() -> Response:
|
| 2224 |
auth, denied = _plasmid_signin_guard()
|
|
|
|
| 2239 |
source=str(body.get("source", "") or "")[:16] or None,
|
| 2240 |
sequence=data["sequence"], features=data["features"],
|
| 2241 |
)
|
| 2242 |
+
_attribute(auth, body, "plasmid", result)
|
| 2243 |
_auth.cleanup_expired_plasmids_async(auth.user_id)
|
| 2244 |
return jsonify(result), (200 if result.get("ok") else 502)
|
| 2245 |
|
|
|
|
| 2783 |
input_length=input_length,
|
| 2784 |
guides=guides,
|
| 2785 |
)
|
| 2786 |
+
_attribute(auth, body, "crispr", result)
|
| 2787 |
_auth.cleanup_expired_crispr_designs_async(auth.user_id) # lazy sweep
|
| 2788 |
return jsonify(result), (200 if result.get("ok") else 502)
|
| 2789 |
|
|
|
|
| 2838 |
n_primers=n_primers,
|
| 2839 |
result=result_obj,
|
| 2840 |
)
|
| 2841 |
+
_attribute(auth, body, "primer", result)
|
| 2842 |
_auth.cleanup_expired_primer_analyses_async(auth.user_id) # lazy sweep
|
| 2843 |
return jsonify(result), (200 if result.get("ok") else 502)
|
| 2844 |
|
|
|
|
| 3670 |
data["ok"] = True
|
| 3671 |
return _public_cors(jsonify(data))
|
| 3672 |
|
| 3673 |
+
# ── Constructs: the object every tool operates on ────────────────────
|
| 3674 |
+
# The inversion. Until now a tool was a destination you pasted a sequence
|
| 3675 |
+
# into, and each of the eight tool views had its own empty input. Nothing
|
| 3676 |
+
# carried across them, so "which project does this guide set belong to?"
|
| 3677 |
+
# could only ever be reconstructed afterwards by matching name strings.
|
| 3678 |
+
#
|
| 3679 |
+
# A construct is the noun. The tools become verbs applied to it. Once the
|
| 3680 |
+
# client holds a selected construct and sends its id with every tool call,
|
| 3681 |
+
# attribution stops being a reconstruction and becomes a record.
|
| 3682 |
+
#
|
| 3683 |
+
# SIGNED OUT these endpoints all 401 by design, and the client keeps its
|
| 3684 |
+
# selection purely in local storage. Public tool access was a deliberate
|
| 3685 |
+
# decision and must not regress: nothing here may become a precondition
|
| 3686 |
+
# for running a tool.
|
| 3687 |
+
def _construct_out(row):
|
| 3688 |
+
"""Trim a row for the client. The full DNA/protein is only sent by the
|
| 3689 |
+
single-construct GET — the switcher must not ship megabytes."""
|
| 3690 |
+
if not row:
|
| 3691 |
+
return None
|
| 3692 |
+
return {
|
| 3693 |
+
"id": row.get("id"), "name": row.get("name"),
|
| 3694 |
+
"phase": row.get("phase") or "Design",
|
| 3695 |
+
"sequence_hash": row.get("sequence_hash"),
|
| 3696 |
+
"wt_identifier": row.get("wt_identifier"),
|
| 3697 |
+
"created_at": row.get("created_at"),
|
| 3698 |
+
"updated_at": row.get("updated_at"),
|
| 3699 |
+
"n_plasmids": len(row.get("plasmid_ids") or []),
|
| 3700 |
+
"n_crispr": len(row.get("crispr_ids") or []),
|
| 3701 |
+
"n_primers": len(row.get("primer_ids") or []),
|
| 3702 |
+
"library_id": row.get("library_id"),
|
| 3703 |
+
}
|
| 3704 |
+
|
| 3705 |
+
@app.get("/api/constructs")
|
| 3706 |
+
def constructs_list() -> Response:
|
| 3707 |
+
auth = _auth.get_auth()
|
| 3708 |
+
if auth.anonymous:
|
| 3709 |
+
# Not an error. A signed-out visitor has no saved projects and the
|
| 3710 |
+
# UI shows its local-only selection instead.
|
| 3711 |
+
return jsonify({"ok": True, "gated": True, "constructs": []})
|
| 3712 |
+
rows = _auth.list_constructs(auth.user_id)
|
| 3713 |
+
return jsonify({"ok": True, "gated": False,
|
| 3714 |
+
"constructs": [_construct_out(r) for r in rows]})
|
| 3715 |
+
|
| 3716 |
+
@app.post("/api/constructs")
|
| 3717 |
+
def constructs_create() -> Response:
|
| 3718 |
+
"""Create, or return the one already grouping this sequence.
|
| 3719 |
+
|
| 3720 |
+
Pasting the same sequence twice means "the thing I am already working
|
| 3721 |
+
on", not a second project. Silently creating a duplicate is how a
|
| 3722 |
+
workspace turns into a list the user has to tidy, so the hash decides.
|
| 3723 |
+
"""
|
| 3724 |
+
auth = _auth.get_auth()
|
| 3725 |
+
if auth.anonymous:
|
| 3726 |
+
return jsonify({"error": "Sign in to keep projects.",
|
| 3727 |
+
"kind": "signin_required"}), 401
|
| 3728 |
+
body = request.get_json(force=True, silent=True) or {}
|
| 3729 |
+
dna = "".join(str(body.get("sequence_dna") or "").split()).upper()
|
| 3730 |
+
protein = "".join(str(body.get("wt_protein") or "").split()).upper()
|
| 3731 |
+
basis = dna or protein
|
| 3732 |
+
if not basis:
|
| 3733 |
+
return jsonify({"error": "A project needs a sequence."}), 400
|
| 3734 |
+
seq_hash = _hash_sequence(basis)
|
| 3735 |
+
|
| 3736 |
+
existing = _auth.find_construct_by_hash(auth.user_id, seq_hash)
|
| 3737 |
+
if existing:
|
| 3738 |
+
return jsonify({"ok": True, "reused": True,
|
| 3739 |
+
"construct": _construct_out(existing)})
|
| 3740 |
+
|
| 3741 |
+
res = _auth.save_construct(
|
| 3742 |
+
auth.user_id,
|
| 3743 |
+
name=str(body.get("name") or "").strip() or "Untitled construct",
|
| 3744 |
+
sequence_hash=seq_hash,
|
| 3745 |
+
wt_identifier=str(body.get("wt_identifier") or ""),
|
| 3746 |
+
wt_protein=protein, sequence_dna=dna,
|
| 3747 |
+
phase=str(body.get("phase") or "Design"))
|
| 3748 |
+
if not res.get("ok"):
|
| 3749 |
+
return jsonify({"error": res.get("error") or "Couldn't create."}), 400
|
| 3750 |
+
return jsonify({"ok": True, "reused": False,
|
| 3751 |
+
"construct": _construct_out(res.get("construct"))})
|
| 3752 |
+
|
| 3753 |
+
@app.get("/api/constructs/<construct_id>")
|
| 3754 |
+
def constructs_get(construct_id: str) -> Response:
|
| 3755 |
+
auth = _auth.get_auth()
|
| 3756 |
+
if auth.anonymous:
|
| 3757 |
+
return jsonify({"error": "Sign in.", "kind": "signin_required"}), 401
|
| 3758 |
+
row = _auth.get_construct(auth.user_id, construct_id)
|
| 3759 |
+
if not row:
|
| 3760 |
+
return jsonify({"error": "Not found."}), 404
|
| 3761 |
+
out = _construct_out(row)
|
| 3762 |
+
# Only here, and only for the one the user actually selected.
|
| 3763 |
+
out["sequence_dna"] = row.get("sequence_dna") or ""
|
| 3764 |
+
out["wt_protein"] = row.get("wt_protein") or ""
|
| 3765 |
+
return jsonify({"ok": True, "construct": out})
|
| 3766 |
+
|
| 3767 |
+
@app.patch("/api/constructs/<construct_id>")
|
| 3768 |
+
def constructs_patch(construct_id: str) -> Response:
|
| 3769 |
+
auth = _auth.get_auth()
|
| 3770 |
+
if auth.anonymous:
|
| 3771 |
+
return jsonify({"error": "Sign in.", "kind": "signin_required"}), 401
|
| 3772 |
+
body = request.get_json(force=True, silent=True) or {}
|
| 3773 |
+
res = _auth.update_construct(
|
| 3774 |
+
auth.user_id, construct_id,
|
| 3775 |
+
**{k: body[k] for k in ("name", "phase") if k in body})
|
| 3776 |
+
if not res.get("ok"):
|
| 3777 |
+
return jsonify({"error": res.get("error") or "Couldn't update."}), 400
|
| 3778 |
+
return jsonify({"ok": True, "construct": _construct_out(res.get("construct"))})
|
| 3779 |
+
|
| 3780 |
+
@app.delete("/api/constructs/<construct_id>")
|
| 3781 |
+
def constructs_delete(construct_id: str) -> Response:
|
| 3782 |
+
auth = _auth.get_auth()
|
| 3783 |
+
if auth.anonymous:
|
| 3784 |
+
return jsonify({"error": "Sign in.", "kind": "signin_required"}), 401
|
| 3785 |
+
res = _auth.delete_construct(auth.user_id, construct_id)
|
| 3786 |
+
if not res.get("ok"):
|
| 3787 |
+
return jsonify({"error": res.get("error") or "Couldn't delete."}), 400
|
| 3788 |
+
return jsonify({"ok": True})
|
| 3789 |
+
|
| 3790 |
@app.get("/api/mission")
|
| 3791 |
def mission() -> Response:
|
| 3792 |
"""Command-center rollup for the Mission Control home — ONE call that
|
dee/static/app.css
CHANGED
|
@@ -10160,3 +10160,72 @@ body.de-agent-run .dna-edit-actions { display: none; }
|
|
| 10160 |
.tc-arch { flex-wrap: wrap; }
|
| 10161 |
.tc-arch-seg { flex-basis: 48%; }
|
| 10162 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10160 |
.tc-arch { flex-wrap: wrap; }
|
| 10161 |
.tc-arch-seg { flex-basis: 48%; }
|
| 10162 |
}
|
| 10163 |
+
|
| 10164 |
+
/* ── The active construct: chip + switcher ──────────────────────────────
|
| 10165 |
+
The topbar used to name the TOOL you were in. Now, when something is
|
| 10166 |
+
selected, it names what you are working ON and the tool is demoted to the
|
| 10167 |
+
subtitle. With nothing selected these rules are inert and the topbar is
|
| 10168 |
+
exactly what it was — which is what a signed-out visitor still gets. */
|
| 10169 |
+
.ctx-chip {
|
| 10170 |
+
display: inline-flex; align-items: center; gap: 8px;
|
| 10171 |
+
background: none; border: 1px solid var(--line); border-radius: 999px;
|
| 10172 |
+
padding: 4px 12px 4px 10px; cursor: pointer; max-width: 100%;
|
| 10173 |
+
min-height: 32px; color: var(--ink-strong);
|
| 10174 |
+
font-family: inherit; transition: border-color .15s, background .15s;
|
| 10175 |
+
}
|
| 10176 |
+
/* `hidden` must win. An explicit display: beats the UA's [hidden] rule, so
|
| 10177 |
+
without this the chip renders even with nothing selected — which is exactly
|
| 10178 |
+
the signed-out first-run screen. Caught by tests/test_hidden_attribute.py,
|
| 10179 |
+
which exists because this has bitten the codebase before. */
|
| 10180 |
+
.ctx-chip[hidden] { display: none; }
|
| 10181 |
+
.ctx-chip:hover { border-color: var(--ink-faint); background: var(--bg-subtle); }
|
| 10182 |
+
.ctx-chip:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }
|
| 10183 |
+
.ctx-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0;
|
| 10184 |
+
background: var(--ink-faint); }
|
| 10185 |
+
[data-theme="dark"] .ctx-dot { background: var(--glow); }
|
| 10186 |
+
.ctx-name { font-size: 14px; font-weight: 600; letter-spacing: -0.01em;
|
| 10187 |
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
| 10188 |
+
.ctx-meta { font-size: 11px; color: var(--ink-faint); white-space: nowrap; }
|
| 10189 |
+
.ctx-caret { font-size: 9px; color: var(--ink-faint); flex-shrink: 0; }
|
| 10190 |
+
|
| 10191 |
+
/* The demotion. Selecting a construct makes the tool line secondary rather
|
| 10192 |
+
than hiding it — you still need to know which tool you're holding. */
|
| 10193 |
+
body[data-ctx="on"] .topbar-title { display: none; }
|
| 10194 |
+
body[data-ctx="on"] .topbar-sub { font-size: 11.5px; color: var(--ink-faint); }
|
| 10195 |
+
|
| 10196 |
+
.ctx-menu {
|
| 10197 |
+
position: absolute; z-index: 60; top: 52px; left: 56px;
|
| 10198 |
+
min-width: 290px; max-width: min(420px, calc(100vw - 32px));
|
| 10199 |
+
max-height: min(60vh, 460px); overflow-y: auto;
|
| 10200 |
+
background: var(--bg-card); border: 1px solid var(--line);
|
| 10201 |
+
border-radius: 10px; box-shadow: var(--elev-4); padding: 6px;
|
| 10202 |
+
}
|
| 10203 |
+
.ctx-menu-hd { font-size: 10px; letter-spacing: .10em; text-transform: uppercase;
|
| 10204 |
+
color: var(--ink-faint); padding: 8px 10px 6px; }
|
| 10205 |
+
.ctx-menu-empty { font-size: 12px; color: var(--ink-faint); padding: 4px 10px 10px;
|
| 10206 |
+
line-height: 1.5; }
|
| 10207 |
+
.ctx-menu-sep { height: 1px; background: var(--line); margin: 6px 0; }
|
| 10208 |
+
.ctx-item, .ctx-act {
|
| 10209 |
+
display: flex; width: 100%; align-items: baseline; gap: 10px;
|
| 10210 |
+
background: none; border: 0; border-radius: 7px; cursor: pointer;
|
| 10211 |
+
padding: 9px 10px; text-align: left; color: var(--ink-strong);
|
| 10212 |
+
font-family: inherit; font-size: 13px; min-height: 40px;
|
| 10213 |
+
}
|
| 10214 |
+
.ctx-item:hover, .ctx-act:hover { background: var(--bg-hover); }
|
| 10215 |
+
.ctx-item.is-on { background: var(--bg-subtle); }
|
| 10216 |
+
.ctx-item.is-on .ctx-item-nm::after { content: ' · current'; color: var(--ink-faint);
|
| 10217 |
+
font-size: 11px; font-weight: 400; }
|
| 10218 |
+
.ctx-item-nm { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis;
|
| 10219 |
+
white-space: nowrap; font-weight: 500; }
|
| 10220 |
+
.ctx-item-mt { font-size: 10.5px; color: var(--ink-faint); flex-shrink: 0; }
|
| 10221 |
+
.ctx-act { color: var(--ink-soft); font-size: 12.5px; }
|
| 10222 |
+
|
| 10223 |
+
@media (max-width: 720px) {
|
| 10224 |
+
.ctx-name { font-size: 13px; }
|
| 10225 |
+
.ctx-meta { font-size: 12px; }
|
| 10226 |
+
.ctx-caret { font-size: 12px; }
|
| 10227 |
+
.ctx-menu { left: 12px; right: 12px; min-width: 0; top: 56px; }
|
| 10228 |
+
.ctx-item, .ctx-act, .ctx-menu-hd, .ctx-menu-empty,
|
| 10229 |
+
.ctx-item-mt { font-size: 12px; }
|
| 10230 |
+
body[data-ctx="on"] .topbar-sub { font-size: 12px; }
|
| 10231 |
+
}
|
dee/static/app.js
CHANGED
|
@@ -473,26 +473,47 @@ function showRoute(name) {
|
|
| 473 |
const href = (a.getAttribute('href') || '').replace('#', '').toLowerCase();
|
| 474 |
a.classList.toggle('active', href === name);
|
| 475 |
});
|
| 476 |
-
// Contextual topbar
|
| 477 |
-
//
|
| 478 |
-
|
| 479 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 480 |
mission: ['Mission control', 'Your constructs and the loop'],
|
| 481 |
structure: ['Structure', 'Predicted 3-D model of your target'],
|
| 482 |
plasmid: ['Plasmid Editor', 'Map, annotate & clone your construct'],
|
| 483 |
design: ['Directed Evolution', 'ESM-2 variant libraries from a wild-type'],
|
| 484 |
-
|
|
|
|
|
|
|
| 485 |
primers: ['Primer Analysis', 'Score & rank your candidate PCR primers'],
|
| 486 |
docs: ['Documentation', 'How TuringDNA works'],
|
| 487 |
-
turing: ['Turing', 'Talk to the engine
|
| 488 |
};
|
| 489 |
-
const tb =
|
| 490 |
const tTitle = document.getElementById('topbarTitle');
|
| 491 |
const tSub = document.getElementById('topbarSub');
|
| 492 |
if (tTitle) tTitle.textContent = tb[0];
|
| 493 |
-
|
| 494 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 495 |
}
|
|
|
|
| 496 |
|
| 497 |
window.addEventListener('hashchange', () => showRoute(currentRoute()));
|
| 498 |
showRoute(currentRoute());
|
|
@@ -9630,6 +9651,7 @@ function runOracle(opts){
|
|
| 9630 |
function sendToCrispr(seq, label) {
|
| 9631 |
seq = (seq || '').toUpperCase().replace(/[^ACGTN]/g, '');
|
| 9632 |
if (seq.length < 23) { _toast('Need at least ~23 bp to design CRISPR guides.'); return; }
|
|
|
|
| 9633 |
const ta = document.getElementById('crisprInput');
|
| 9634 |
if (!ta) return;
|
| 9635 |
ta.value = seq; ta.dispatchEvent(new Event('input', { bubbles: true }));
|
|
@@ -9641,6 +9663,7 @@ function runOracle(opts){
|
|
| 9641 |
function sendToPrimers(seq, label) {
|
| 9642 |
seq = (seq || '').toUpperCase().replace(/[^ACGTN]/g, '');
|
| 9643 |
if (!seq) { _toast('Nothing to send.'); return; }
|
|
|
|
| 9644 |
const t = document.getElementById('primerTemplate');
|
| 9645 |
if (!t) return;
|
| 9646 |
t.value = seq; t.dispatchEvent(new Event('input', { bubbles: true }));
|
|
@@ -9655,6 +9678,7 @@ function runOracle(opts){
|
|
| 9655 |
// posture as the CRISPR / Primer hand-offs.
|
| 9656 |
seq = (seq || '').toUpperCase().replace(/[^ACGTN]/g, '');
|
| 9657 |
if (seq.length < 30) { _toast('Need ≥30 bp of coding sequence to evolve a protein.'); return; }
|
|
|
|
| 9658 |
const ta = document.getElementById('pasteArea');
|
| 9659 |
if (!ta) return;
|
| 9660 |
ta.value = seq; ta.dispatchEvent(new Event('input', { bubbles: true }));
|
|
|
|
| 473 |
const href = (a.getAttribute('href') || '').replace('#', '').toLowerCase();
|
| 474 |
a.classList.toggle('active', href === name);
|
| 475 |
});
|
| 476 |
+
// Contextual topbar. The map moved to _applyTopbar — it now has two
|
| 477 |
+
// triggers (route change AND construct change) and two copies would drift.
|
| 478 |
+
_applyTopbar(name);
|
| 479 |
+
// Carry the sequence into whichever tool the user just opened. Only fills
|
| 480 |
+
// an input they left empty — see rule 3 in context.js.
|
| 481 |
+
if (window.TDContext) window.TDContext.prefill(name);
|
| 482 |
+
window.scrollTo({ top: 0, behavior: 'instant' });
|
| 483 |
+
}
|
| 484 |
+
|
| 485 |
+
// Topbar labelling. Extracted from showRoute because it has two triggers,
|
| 486 |
+
// not one: the route changing AND the construct changing. Inline in showRoute
|
| 487 |
+
// it only ran on navigation, so adopting a construct while already on a view
|
| 488 |
+
// left the tool line un-demoted.
|
| 489 |
+
let _lastRoute = 'mission';
|
| 490 |
+
function _applyTopbar(name) {
|
| 491 |
+
_lastRoute = name || _lastRoute;
|
| 492 |
+
const TB = {
|
| 493 |
mission: ['Mission control', 'Your constructs and the loop'],
|
| 494 |
structure: ['Structure', 'Predicted 3-D model of your target'],
|
| 495 |
plasmid: ['Plasmid Editor', 'Map, annotate & clone your construct'],
|
| 496 |
design: ['Directed Evolution', 'ESM-2 variant libraries from a wild-type'],
|
| 497 |
+
dna: ['DNA Design', 'Score & generate DNA with Evo 2'],
|
| 498 |
+
compiler: ['Therapeutic Compiler', 'Variant \u2192 editing strategy'],
|
| 499 |
+
crispr: ['CRISPR', 'Guide RNA design \u2014 knockout & base editing'],
|
| 500 |
primers: ['Primer Analysis', 'Score & rank your candidate PCR primers'],
|
| 501 |
docs: ['Documentation', 'How TuringDNA works'],
|
| 502 |
+
turing: ['Turing', 'Talk to the engine \u2014 it runs your tools'],
|
| 503 |
};
|
| 504 |
+
const tb = TB[_lastRoute] || ['Engine', ''];
|
| 505 |
const tTitle = document.getElementById('topbarTitle');
|
| 506 |
const tSub = document.getElementById('topbarSub');
|
| 507 |
if (tTitle) tTitle.textContent = tb[0];
|
| 508 |
+
// With a construct selected the tool line is DEMOTED to a subtitle under
|
| 509 |
+
// the construct's name: the answer to "where am I?" is the thing you are
|
| 510 |
+
// working on, not the tool you happen to be holding. Nothing selected and
|
| 511 |
+
// this is byte-for-byte the old topbar.
|
| 512 |
+
const ctx = window.TDContext && window.TDContext.get();
|
| 513 |
+
if (tSub) tSub.textContent = ctx ? (tb[1] ? tb[0] + ' \u00b7 ' + tb[1] : tb[0])
|
| 514 |
+
: tb[1];
|
| 515 |
}
|
| 516 |
+
if (window.TDContext) window.TDContext.subscribe(() => _applyTopbar());
|
| 517 |
|
| 518 |
window.addEventListener('hashchange', () => showRoute(currentRoute()));
|
| 519 |
showRoute(currentRoute());
|
|
|
|
| 9651 |
function sendToCrispr(seq, label) {
|
| 9652 |
seq = (seq || '').toUpperCase().replace(/[^ACGTN]/g, '');
|
| 9653 |
if (seq.length < 23) { _toast('Need at least ~23 bp to design CRISPR guides.'); return; }
|
| 9654 |
+
if (window.TDContext) window.TDContext.adopt(seq, label, { quiet: true });
|
| 9655 |
const ta = document.getElementById('crisprInput');
|
| 9656 |
if (!ta) return;
|
| 9657 |
ta.value = seq; ta.dispatchEvent(new Event('input', { bubbles: true }));
|
|
|
|
| 9663 |
function sendToPrimers(seq, label) {
|
| 9664 |
seq = (seq || '').toUpperCase().replace(/[^ACGTN]/g, '');
|
| 9665 |
if (!seq) { _toast('Nothing to send.'); return; }
|
| 9666 |
+
if (window.TDContext) window.TDContext.adopt(seq, label, { quiet: true });
|
| 9667 |
const t = document.getElementById('primerTemplate');
|
| 9668 |
if (!t) return;
|
| 9669 |
t.value = seq; t.dispatchEvent(new Event('input', { bubbles: true }));
|
|
|
|
| 9678 |
// posture as the CRISPR / Primer hand-offs.
|
| 9679 |
seq = (seq || '').toUpperCase().replace(/[^ACGTN]/g, '');
|
| 9680 |
if (seq.length < 30) { _toast('Need ≥30 bp of coding sequence to evolve a protein.'); return; }
|
| 9681 |
+
if (window.TDContext) window.TDContext.adopt(seq, label, { quiet: true });
|
| 9682 |
const ta = document.getElementById('pasteArea');
|
| 9683 |
if (!ta) return;
|
| 9684 |
ta.value = seq; ta.dispatchEvent(new Event('input', { bubbles: true }));
|
dee/static/context.js
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ═══════════════════════════════════════════════════════════════════════
|
| 2 |
+
TDContext — the active construct.
|
| 3 |
+
|
| 4 |
+
THE INVERSION THIS FILE EXISTS FOR
|
| 5 |
+
----------------------------------
|
| 6 |
+
Until now a tool was a DESTINATION. Eight routes, eight views, each with
|
| 7 |
+
its own empty input box, each asking for the sequence again. The "hand-off"
|
| 8 |
+
between them was a string copied from one <textarea> into another, and the
|
| 9 |
+
backend never learned which project any run belonged to — so "what work
|
| 10 |
+
belongs together?" could only be reconstructed afterwards by matching name
|
| 11 |
+
strings, which merges every artifact left at its default name and splits a
|
| 12 |
+
project the moment you rename something.
|
| 13 |
+
|
| 14 |
+
Here the construct is the noun and the tools are verbs applied to it. One
|
| 15 |
+
selection, held in one place, carried across every view and attached to
|
| 16 |
+
every request. Attribution stops being a reconstruction and becomes a
|
| 17 |
+
record — which is also what makes a real lineage graph possible later,
|
| 18 |
+
without hand-instrumenting each hand-off path.
|
| 19 |
+
|
| 20 |
+
THREE RULES, and each one is a way this could become worse than what it
|
| 21 |
+
replaces:
|
| 22 |
+
|
| 23 |
+
1. SIGNED OUT IT MUST BEHAVE EXACTLY AS BEFORE. Public tool access was a
|
| 24 |
+
deliberate decision. A visitor with no account keeps a purely local
|
| 25 |
+
selection; nothing here may become a precondition for running a tool.
|
| 26 |
+
2. IT MUST NEVER BLOCK. Pasting a new sequence switches the context
|
| 27 |
+
silently. The moment this can say "no", it is modal, and modal is worse
|
| 28 |
+
than scattered.
|
| 29 |
+
3. IT MUST NEVER CLOBBER. Pre-filling only ever touches an input the user
|
| 30 |
+
has left EMPTY. Overwriting something they typed would be the single
|
| 31 |
+
fastest way to make people distrust the whole idea.
|
| 32 |
+
═══════════════════════════════════════════════════════════════════════ */
|
| 33 |
+
(function () {
|
| 34 |
+
'use strict';
|
| 35 |
+
|
| 36 |
+
var LS_KEY = 'td.construct.v1';
|
| 37 |
+
var listeners = [];
|
| 38 |
+
var active = null; // {id?, name, dna, protein, identifier, phase}
|
| 39 |
+
var known = []; // server-side list, for the switcher
|
| 40 |
+
|
| 41 |
+
/* ── state ──────────────────────────────────────────────────────── */
|
| 42 |
+
function load() {
|
| 43 |
+
try {
|
| 44 |
+
var raw = localStorage.getItem(LS_KEY);
|
| 45 |
+
active = raw ? JSON.parse(raw) : null;
|
| 46 |
+
} catch (e) { active = null; }
|
| 47 |
+
}
|
| 48 |
+
function persist() {
|
| 49 |
+
try {
|
| 50 |
+
if (active) localStorage.setItem(LS_KEY, JSON.stringify(active));
|
| 51 |
+
else localStorage.removeItem(LS_KEY);
|
| 52 |
+
} catch (e) { /* private mode — the session still works, just not across reloads */ }
|
| 53 |
+
}
|
| 54 |
+
function notify() {
|
| 55 |
+
listeners.forEach(function (fn) {
|
| 56 |
+
try { fn(active); } catch (e) { console.error(e); }
|
| 57 |
+
});
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
function get() { return active; }
|
| 61 |
+
function id() { return active && active.id ? active.id : null; }
|
| 62 |
+
|
| 63 |
+
function set(c, opts) {
|
| 64 |
+
active = c || null;
|
| 65 |
+
persist();
|
| 66 |
+
notify();
|
| 67 |
+
if (!(opts && opts.quiet) && active) {
|
| 68 |
+
toast('Working on ' + active.name);
|
| 69 |
+
}
|
| 70 |
+
}
|
| 71 |
+
function clear() { set(null); notify(); }
|
| 72 |
+
function subscribe(fn) { listeners.push(fn); try { fn(active); } catch (e) {} }
|
| 73 |
+
|
| 74 |
+
function toast(msg) {
|
| 75 |
+
if (typeof window.showToast === 'function') window.showToast(msg, 'info');
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
/* ── adopting a sequence ────────────────────────────────────────── */
|
| 79 |
+
// Called whenever a sequence enters the app by ANY route — pasted, fetched,
|
| 80 |
+
// imported, or handed over from another tool. Switches silently; creates
|
| 81 |
+
// server-side only when signed in. Rule 2: this never refuses.
|
| 82 |
+
function adopt(seq, label, opts) {
|
| 83 |
+
opts = opts || {};
|
| 84 |
+
var dna = String(seq || '').replace(/\s+/g, '').toUpperCase();
|
| 85 |
+
if (dna.length < 12) return Promise.resolve(active);
|
| 86 |
+
|
| 87 |
+
// Same sequence = same work. Do not spawn a second project for it.
|
| 88 |
+
if (active && active.dna === dna) return Promise.resolve(active);
|
| 89 |
+
|
| 90 |
+
var local = {
|
| 91 |
+
id: null,
|
| 92 |
+
name: label || defaultName(dna),
|
| 93 |
+
dna: dna,
|
| 94 |
+
protein: opts.protein || '',
|
| 95 |
+
identifier: opts.identifier || '',
|
| 96 |
+
phase: 'Design',
|
| 97 |
+
};
|
| 98 |
+
set(local, { quiet: !!opts.quiet });
|
| 99 |
+
|
| 100 |
+
// Signed out this is where it ends, and that is a complete experience:
|
| 101 |
+
// the selection still threads every view for this session.
|
| 102 |
+
return fetch('/api/constructs', {
|
| 103 |
+
method: 'POST',
|
| 104 |
+
headers: { 'Content-Type': 'application/json' },
|
| 105 |
+
body: JSON.stringify({
|
| 106 |
+
sequence_dna: dna, wt_protein: local.protein,
|
| 107 |
+
name: local.name, wt_identifier: local.identifier,
|
| 108 |
+
}),
|
| 109 |
+
}).then(function (r) {
|
| 110 |
+
if (!r.ok) return null; // 401 signed out — expected, not an error
|
| 111 |
+
return r.json();
|
| 112 |
+
}).then(function (d) {
|
| 113 |
+
if (!d || !d.ok || !d.construct) return active;
|
| 114 |
+
// Server wins on identity, the local copy wins on payload: the
|
| 115 |
+
// list endpoint deliberately does not ship the sequence back.
|
| 116 |
+
active = Object.assign({}, local, {
|
| 117 |
+
id: d.construct.id,
|
| 118 |
+
name: d.construct.name || local.name,
|
| 119 |
+
phase: d.construct.phase || 'Design',
|
| 120 |
+
});
|
| 121 |
+
persist(); notify(); refreshList();
|
| 122 |
+
return active;
|
| 123 |
+
}).catch(function () { return active; });
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
function defaultName(dna) {
|
| 127 |
+
// Never "Untitled". A name you cannot tell apart from four others is
|
| 128 |
+
// the failure mode the name-string grouping already suffers from.
|
| 129 |
+
return 'Construct ' + dna.slice(0, 6) + '…' + dna.slice(-4);
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
/* ── the server-side list, for the switcher ─────────────────────── */
|
| 133 |
+
function refreshList() {
|
| 134 |
+
return fetch('/api/constructs').then(function (r) {
|
| 135 |
+
return r.ok ? r.json() : null;
|
| 136 |
+
}).then(function (d) {
|
| 137 |
+
known = (d && d.constructs) || [];
|
| 138 |
+
notify();
|
| 139 |
+
return known;
|
| 140 |
+
}).catch(function () { return known; });
|
| 141 |
+
}
|
| 142 |
+
function list() { return known; }
|
| 143 |
+
|
| 144 |
+
function open(constructId) {
|
| 145 |
+
return fetch('/api/constructs/' + encodeURIComponent(constructId))
|
| 146 |
+
.then(function (r) { return r.ok ? r.json() : null; })
|
| 147 |
+
.then(function (d) {
|
| 148 |
+
if (!d || !d.construct) return null;
|
| 149 |
+
var c = d.construct;
|
| 150 |
+
set({
|
| 151 |
+
id: c.id, name: c.name, dna: c.sequence_dna || '',
|
| 152 |
+
protein: c.wt_protein || '', identifier: c.wt_identifier || '',
|
| 153 |
+
phase: c.phase || 'Design',
|
| 154 |
+
});
|
| 155 |
+
return active;
|
| 156 |
+
}).catch(function () { return null; });
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
function rename(name) {
|
| 160 |
+
if (!active) return Promise.resolve(null);
|
| 161 |
+
active.name = String(name || '').slice(0, 120) || active.name;
|
| 162 |
+
persist(); notify();
|
| 163 |
+
if (!active.id) return Promise.resolve(active);
|
| 164 |
+
return fetch('/api/constructs/' + encodeURIComponent(active.id), {
|
| 165 |
+
method: 'PATCH',
|
| 166 |
+
headers: { 'Content-Type': 'application/json' },
|
| 167 |
+
body: JSON.stringify({ name: active.name }),
|
| 168 |
+
}).then(function () { refreshList(); return active; })
|
| 169 |
+
.catch(function () { return active; });
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
/* ── the interceptor ────────────────────────────────────────────── */
|
| 173 |
+
// ONE place, not twenty. Editing every tool's fetch call by hand would
|
| 174 |
+
// have meant instrumenting ~20 sites and still missing whichever one gets
|
| 175 |
+
// added next month — and a lineage with a silent hole in it is worse than
|
| 176 |
+
// no lineage, because you cannot tell which is which.
|
| 177 |
+
var TOOL_PREFIXES = ['/api/crispr', '/api/primers', '/api/plasmid',
|
| 178 |
+
'/api/design', '/api/dna', '/api/compiler',
|
| 179 |
+
'/api/de/', '/api/align', '/api/structure'];
|
| 180 |
+
var nativeFetch = window.fetch.bind(window);
|
| 181 |
+
|
| 182 |
+
function isToolCall(url) {
|
| 183 |
+
var u = String(url || '');
|
| 184 |
+
for (var i = 0; i < TOOL_PREFIXES.length; i++) {
|
| 185 |
+
if (u.indexOf(TOOL_PREFIXES[i]) === 0) return true;
|
| 186 |
+
}
|
| 187 |
+
return false;
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
window.fetch = function (input, init) {
|
| 191 |
+
try {
|
| 192 |
+
var url = (typeof input === 'string') ? input
|
| 193 |
+
: (input && input.url) || '';
|
| 194 |
+
var cid = id();
|
| 195 |
+
if (cid && init && init.method &&
|
| 196 |
+
String(init.method).toUpperCase() === 'POST' &&
|
| 197 |
+
isToolCall(url) && typeof init.body === 'string') {
|
| 198 |
+
var body = JSON.parse(init.body);
|
| 199 |
+
if (body && typeof body === 'object' && !Array.isArray(body)
|
| 200 |
+
&& !body.construct_id) {
|
| 201 |
+
body.construct_id = cid;
|
| 202 |
+
init = Object.assign({}, init, { body: JSON.stringify(body) });
|
| 203 |
+
}
|
| 204 |
+
}
|
| 205 |
+
} catch (e) {
|
| 206 |
+
// A body that is not JSON, or is not ours to touch. Attribution is
|
| 207 |
+
// never worth breaking the request the user actually made.
|
| 208 |
+
}
|
| 209 |
+
return nativeFetch(input, init);
|
| 210 |
+
};
|
| 211 |
+
|
| 212 |
+
/* ── pre-filling a tool view ────────────────────────────────────── */
|
| 213 |
+
// Rule 3: only ever fills an input the user left EMPTY.
|
| 214 |
+
var TOOL_INPUTS = {
|
| 215 |
+
crispr: 'crisprInput',
|
| 216 |
+
primers: 'primerTemplate',
|
| 217 |
+
design: 'pasteArea',
|
| 218 |
+
compiler: 'tcWindow',
|
| 219 |
+
dna: 'dnaReference',
|
| 220 |
+
plasmid: 'plasmidInput',
|
| 221 |
+
};
|
| 222 |
+
|
| 223 |
+
function prefill(route) {
|
| 224 |
+
if (!active || !active.dna) return false;
|
| 225 |
+
var elId = TOOL_INPUTS[route];
|
| 226 |
+
if (!elId) return false;
|
| 227 |
+
var el = document.getElementById(elId);
|
| 228 |
+
if (!el || (el.value || '').trim()) return false; // never clobber
|
| 229 |
+
el.value = active.dna;
|
| 230 |
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
| 231 |
+
return true;
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
load();
|
| 235 |
+
window.TDContext = {
|
| 236 |
+
get: get, id: id, set: set, clear: clear, subscribe: subscribe,
|
| 237 |
+
adopt: adopt, open: open, rename: rename, list: list,
|
| 238 |
+
refreshList: refreshList, prefill: prefill,
|
| 239 |
+
TOOL_INPUTS: TOOL_INPUTS,
|
| 240 |
+
};
|
| 241 |
+
|
| 242 |
+
// The list is only meaningful signed in; a 401 here is normal and silent.
|
| 243 |
+
if (document.readyState === 'loading') {
|
| 244 |
+
document.addEventListener('DOMContentLoaded', function () { refreshList(); });
|
| 245 |
+
} else { refreshList(); }
|
| 246 |
+
}());
|
| 247 |
+
|
| 248 |
+
/* ═══════════════════════════════════════════════════════════════════════
|
| 249 |
+
The chip + switcher.
|
| 250 |
+
|
| 251 |
+
Rule 3 has a UI corollary: switching and clearing must be one click and
|
| 252 |
+
always visible. A context you cannot see or change is worse than no
|
| 253 |
+
context — you end up editing the wrong object and only find out later.
|
| 254 |
+
═══════════════════════════════════════════════════════════════════════ */
|
| 255 |
+
(function () {
|
| 256 |
+
'use strict';
|
| 257 |
+
var C = window.TDContext;
|
| 258 |
+
if (!C) return;
|
| 259 |
+
|
| 260 |
+
function esc(s) {
|
| 261 |
+
return String(s == null ? '' : s).replace(/[&<>"']/g, function (m) {
|
| 262 |
+
return ({ '&': '&', '<': '<', '>': '>',
|
| 263 |
+
'"': '"', "'": ''' })[m];
|
| 264 |
+
});
|
| 265 |
+
}
|
| 266 |
+
function bp(n) { return n ? n.toLocaleString() + ' bp' : ''; }
|
| 267 |
+
|
| 268 |
+
function init() {
|
| 269 |
+
var chip = document.getElementById('ctxChip');
|
| 270 |
+
var menu = document.getElementById('ctxMenu');
|
| 271 |
+
if (!chip || !menu) return;
|
| 272 |
+
|
| 273 |
+
C.subscribe(function (c) {
|
| 274 |
+
chip.hidden = !c;
|
| 275 |
+
document.body.setAttribute('data-ctx', c ? 'on' : 'off');
|
| 276 |
+
if (!c) return;
|
| 277 |
+
var nm = document.getElementById('ctxName');
|
| 278 |
+
var mt = document.getElementById('ctxMeta');
|
| 279 |
+
if (nm) nm.textContent = c.name || 'Construct';
|
| 280 |
+
if (mt) {
|
| 281 |
+
// The un-saved case is stated, not hidden. A signed-out user
|
| 282 |
+
// whose selection lives only in this browser should know that.
|
| 283 |
+
mt.textContent = [bp((c.dna || '').length),
|
| 284 |
+
c.phase || '',
|
| 285 |
+
c.id ? '' : 'this browser only']
|
| 286 |
+
.filter(Boolean).join(' · ');
|
| 287 |
+
}
|
| 288 |
+
});
|
| 289 |
+
|
| 290 |
+
function close() {
|
| 291 |
+
menu.hidden = true;
|
| 292 |
+
chip.setAttribute('aria-expanded', 'false');
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
function render() {
|
| 296 |
+
var cur = C.get();
|
| 297 |
+
var rows = C.list() || [];
|
| 298 |
+
var h = '<div class="ctx-menu-hd">Constructs</div>';
|
| 299 |
+
if (!rows.length) {
|
| 300 |
+
h += '<div class="ctx-menu-empty">No saved projects yet. '
|
| 301 |
+
+ 'Paste or fetch a sequence and it becomes one.</div>';
|
| 302 |
+
}
|
| 303 |
+
rows.forEach(function (r) {
|
| 304 |
+
var on = cur && cur.id === r.id;
|
| 305 |
+
h += '<button class="ctx-item' + (on ? ' is-on' : '') + '" role="menuitem"'
|
| 306 |
+
+ ' data-open="' + esc(r.id) + '">'
|
| 307 |
+
+ '<span class="ctx-item-nm">' + esc(r.name) + '</span>'
|
| 308 |
+
+ '<span class="ctx-item-mt mono">' + esc(r.phase || 'Design')
|
| 309 |
+
+ (r.n_plasmids ? ' · ' + r.n_plasmids + 'p' : '')
|
| 310 |
+
+ (r.n_crispr ? ' · ' + r.n_crispr + 'g' : '')
|
| 311 |
+
+ (r.n_primers ? ' · ' + r.n_primers + 'pr' : '')
|
| 312 |
+
+ '</span></button>';
|
| 313 |
+
});
|
| 314 |
+
h += '<div class="ctx-menu-sep"></div>';
|
| 315 |
+
if (cur) {
|
| 316 |
+
h += '<button class="ctx-act" role="menuitem" data-rename="1">Rename…</button>';
|
| 317 |
+
h += '<button class="ctx-act" role="menuitem" data-clear="1">'
|
| 318 |
+
+ 'Work without a construct</button>';
|
| 319 |
+
}
|
| 320 |
+
menu.innerHTML = h;
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
chip.addEventListener('click', function (e) {
|
| 324 |
+
e.stopPropagation();
|
| 325 |
+
if (!menu.hidden) { close(); return; }
|
| 326 |
+
render();
|
| 327 |
+
menu.hidden = false;
|
| 328 |
+
chip.setAttribute('aria-expanded', 'true');
|
| 329 |
+
C.refreshList().then(function () { if (!menu.hidden) render(); });
|
| 330 |
+
});
|
| 331 |
+
|
| 332 |
+
menu.addEventListener('click', function (e) {
|
| 333 |
+
var t = e.target.closest('[data-open],[data-clear],[data-rename]');
|
| 334 |
+
if (!t) return;
|
| 335 |
+
e.stopPropagation();
|
| 336 |
+
if (t.dataset.open) { C.open(t.dataset.open); close(); return; }
|
| 337 |
+
if (t.dataset.clear) {
|
| 338 |
+
// Deliberately NOT called "delete". Clearing the selection
|
| 339 |
+
// must never read as destroying the work.
|
| 340 |
+
C.clear(); close(); return;
|
| 341 |
+
}
|
| 342 |
+
if (t.dataset.rename) {
|
| 343 |
+
var cur = C.get();
|
| 344 |
+
var next = window.prompt('Rename this construct', cur && cur.name);
|
| 345 |
+
if (next) C.rename(next);
|
| 346 |
+
close();
|
| 347 |
+
}
|
| 348 |
+
});
|
| 349 |
+
|
| 350 |
+
document.addEventListener('click', function () { if (!menu.hidden) close(); });
|
| 351 |
+
document.addEventListener('keydown', function (e) {
|
| 352 |
+
if (e.key === 'Escape' && !menu.hidden) close();
|
| 353 |
+
});
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
if (document.readyState === 'loading') {
|
| 357 |
+
document.addEventListener('DOMContentLoaded', init);
|
| 358 |
+
} else { init(); }
|
| 359 |
+
}());
|
dee/static/index.html
CHANGED
|
@@ -112,7 +112,7 @@
|
|
| 112 |
<!-- ?v= query bumps invalidate browser + iframe asset caches when app.css /
|
| 113 |
app.js change. Bump these numbers whenever you ship a frontend update —
|
| 114 |
without them, users keep getting the stale file for up to a week. -->
|
| 115 |
-
<link rel="stylesheet" href="/static/app.css?v=20260818-
|
| 116 |
<!-- The work catalog + the draggable rail. Kept out of app.css so two new
|
| 117 |
self-contained surfaces stay reviewable; every colour is an app.css
|
| 118 |
token, so both themes work with nothing added. -->
|
|
@@ -496,10 +496,27 @@
|
|
| 496 |
<line x1="3" y1="18" x2="21" y2="18"/>
|
| 497 |
</svg>
|
| 498 |
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 499 |
<div class="topbar-titles">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 500 |
<h1 class="topbar-title" id="topbarTitle">Plasmid Editor</h1>
|
| 501 |
<span class="topbar-sub" id="topbarSub">Map, annotate & clone your construct</span>
|
| 502 |
</div>
|
|
|
|
| 503 |
<!-- Theme toggle relocated to the sidebar footer, by the account chip
|
| 504 |
(see #themeToggle in the aside) — it now stays reachable in the bench
|
| 505 |
view, where this topbar is hidden. -->
|
|
@@ -2852,7 +2869,8 @@
|
|
| 2852 |
<!-- Cloning reference data must load before app.js so the Designer
|
| 2853 |
can read VECTORS / ENZYMES / CLONING_METHODS / TAGS / LINKERS. -->
|
| 2854 |
<script src="/static/cloning_db.js?v=20260530-ui-polish" defer></script>
|
| 2855 |
-
<script src="/static/
|
|
|
|
| 2856 |
<!-- The decision trace, BEFORE cockpit.js: applyEvent calls TDTrace.push
|
| 2857 |
on the very first event, and both are `defer`, so document order is
|
| 2858 |
load order. Loading it after would drop the opening events of a
|
|
|
|
| 112 |
<!-- ?v= query bumps invalidate browser + iframe asset caches when app.css /
|
| 113 |
app.js change. Bump these numbers whenever you ship a frontend update —
|
| 114 |
without them, users keep getting the stale file for up to a week. -->
|
| 115 |
+
<link rel="stylesheet" href="/static/app.css?v=20260818-ctx2" />
|
| 116 |
<!-- The work catalog + the draggable rail. Kept out of app.css so two new
|
| 117 |
self-contained surfaces stay reviewable; every colour is an app.css
|
| 118 |
token, so both themes work with nothing added. -->
|
|
|
|
| 496 |
<line x1="3" y1="18" x2="21" y2="18"/>
|
| 497 |
</svg>
|
| 498 |
</button>
|
| 499 |
+
<!-- The construct chip. THE inversion, in markup: when a
|
| 500 |
+
construct is selected it becomes the primary line and the
|
| 501 |
+
tool name is demoted to a subtitle — you are working on a
|
| 502 |
+
thing, using a tool, not visiting a tool. With nothing
|
| 503 |
+
selected the chip is hidden and the tool title takes the
|
| 504 |
+
lead, which is exactly today's behaviour and therefore
|
| 505 |
+
exactly what a signed-out visitor still gets. -->
|
| 506 |
<div class="topbar-titles">
|
| 507 |
+
<button class="ctx-chip" id="ctxChip" type="button" hidden
|
| 508 |
+
aria-haspopup="menu" aria-expanded="false"
|
| 509 |
+
aria-controls="ctxMenu"
|
| 510 |
+
title="Switch or clear the construct every tool is working on">
|
| 511 |
+
<span class="ctx-dot" aria-hidden="true"></span>
|
| 512 |
+
<span class="ctx-name" id="ctxName">—</span>
|
| 513 |
+
<span class="ctx-meta mono" id="ctxMeta"></span>
|
| 514 |
+
<span class="ctx-caret" aria-hidden="true">▾</span>
|
| 515 |
+
</button>
|
| 516 |
<h1 class="topbar-title" id="topbarTitle">Plasmid Editor</h1>
|
| 517 |
<span class="topbar-sub" id="topbarSub">Map, annotate & clone your construct</span>
|
| 518 |
</div>
|
| 519 |
+
<div class="ctx-menu" id="ctxMenu" role="menu" hidden></div>
|
| 520 |
<!-- Theme toggle relocated to the sidebar footer, by the account chip
|
| 521 |
(see #themeToggle in the aside) — it now stays reachable in the bench
|
| 522 |
view, where this topbar is hidden. -->
|
|
|
|
| 2869 |
<!-- Cloning reference data must load before app.js so the Designer
|
| 2870 |
can read VECTORS / ENZYMES / CLONING_METHODS / TAGS / LINKERS. -->
|
| 2871 |
<script src="/static/cloning_db.js?v=20260530-ui-polish" defer></script>
|
| 2872 |
+
<script src="/static/context.js?v=20260818-ctx2" defer></script>
|
| 2873 |
+
<script src="/static/app.js?v=20260818-ctx2" defer></script>
|
| 2874 |
<!-- The decision trace, BEFORE cockpit.js: applyEvent calls TDTrace.push
|
| 2875 |
on the very first event, and both are `defer`, so document order is
|
| 2876 |
load order. Loading it after would drop the opening events of a
|
tests/test_construct_context.py
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The construct as the object every tool operates on.
|
| 2 |
+
|
| 3 |
+
THE INVERSION UNDER TEST
|
| 4 |
+
------------------------
|
| 5 |
+
A tool used to be a destination: eight routes, eight empty inputs, and a
|
| 6 |
+
"hand-off" that copied a string from one <textarea> into another. Nothing
|
| 7 |
+
carried across them and the backend never learned which project a run belonged
|
| 8 |
+
to, so /api/mission had to reconstruct "constructs" by grouping artifacts on a
|
| 9 |
+
normalised NAME STRING — which merges everything left at a default name and
|
| 10 |
+
splits a project the moment you rename something.
|
| 11 |
+
|
| 12 |
+
These tests pin the three properties that decide whether the replacement is
|
| 13 |
+
better than what it replaces, because each is a way it could be worse:
|
| 14 |
+
|
| 15 |
+
1. SIGNED OUT, NOTHING CHANGES. Public tool access was deliberate. If any of
|
| 16 |
+
this becomes a precondition for running a tool, the change is a
|
| 17 |
+
regression no matter how tidy the workspace looks.
|
| 18 |
+
2. THE SAME SEQUENCE IS THE SAME PROJECT. Pasting twice must not spawn a
|
| 19 |
+
duplicate — that is how a workspace becomes a list the user has to tidy.
|
| 20 |
+
3. ATTRIBUTION IS BEST-EFFORT AND NEVER FATAL. A failed cross-reference must
|
| 21 |
+
not fail the save the user actually asked for.
|
| 22 |
+
"""
|
| 23 |
+
import json
|
| 24 |
+
|
| 25 |
+
import pytest
|
| 26 |
+
|
| 27 |
+
from dee import auth as dee_auth
|
| 28 |
+
from dee import server
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _client():
|
| 32 |
+
app = server.create_app()
|
| 33 |
+
app.config.update(TESTING=True)
|
| 34 |
+
return app.test_client()
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class _Auth:
|
| 38 |
+
def __init__(self, user_id=None):
|
| 39 |
+
self.user_id = user_id
|
| 40 |
+
self.anonymous = user_id is None
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
# --------------------------------------------------------------------------- #
|
| 44 |
+
# 1. Signed out, nothing changes
|
| 45 |
+
# --------------------------------------------------------------------------- #
|
| 46 |
+
def test_listing_constructs_signed_out_is_an_empty_gate_not_an_error(monkeypatch):
|
| 47 |
+
"""A 200 with gated:true, not a 401. The switcher has to render for a
|
| 48 |
+
signed-out visitor without treating them as a failure."""
|
| 49 |
+
monkeypatch.setattr(server._auth, "get_auth", lambda: _Auth(None))
|
| 50 |
+
body = _client().get("/api/constructs").get_json()
|
| 51 |
+
assert body == {"ok": True, "gated": True, "constructs": []}
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
@pytest.mark.parametrize("method,path", [
|
| 55 |
+
("post", "/api/constructs"),
|
| 56 |
+
("get", "/api/constructs/11111111-1111-1111-1111-111111111111"),
|
| 57 |
+
("patch", "/api/constructs/11111111-1111-1111-1111-111111111111"),
|
| 58 |
+
("delete", "/api/constructs/11111111-1111-1111-1111-111111111111"),
|
| 59 |
+
])
|
| 60 |
+
def test_writing_constructs_signed_out_is_refused_with_a_signin_kind(
|
| 61 |
+
monkeypatch, method, path):
|
| 62 |
+
monkeypatch.setattr(server._auth, "get_auth", lambda: _Auth(None))
|
| 63 |
+
cl = _client()
|
| 64 |
+
res = getattr(cl, method)(path, json={"sequence_dna": "ACGT" * 20})
|
| 65 |
+
assert res.status_code == 401
|
| 66 |
+
assert res.get_json().get("kind") == "signin_required"
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def test_the_client_keeps_its_own_selection_when_signed_out():
|
| 70 |
+
"""context.js must not depend on the server for the signed-out path — the
|
| 71 |
+
selection lives in localStorage and still threads every view."""
|
| 72 |
+
with open("dee/static/context.js", encoding="utf-8") as fh:
|
| 73 |
+
src = fh.read()
|
| 74 |
+
assert "localStorage" in src
|
| 75 |
+
# adopt() posts, but a non-ok response must be swallowed, not thrown.
|
| 76 |
+
assert "if (!r.ok) return null;" in src
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def test_context_never_blocks_a_tool_run():
|
| 80 |
+
"""Rule 2. There must be no code path where a missing construct prevents
|
| 81 |
+
a request — the interceptor only ever ADDS a field."""
|
| 82 |
+
with open("dee/static/context.js", encoding="utf-8") as fh:
|
| 83 |
+
src = fh.read()
|
| 84 |
+
assert "return nativeFetch(input, init);" in src
|
| 85 |
+
# Exactly ONE call site, so no early bail-out can creep in later: every
|
| 86 |
+
# path through the wrapper ends at the same return.
|
| 87 |
+
assert src.count("nativeFetch(") == 1
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def test_prefill_never_clobbers_what_the_user_typed():
|
| 91 |
+
"""Rule 3. Overwriting a filled input is the fastest way to make the whole
|
| 92 |
+
idea untrustworthy."""
|
| 93 |
+
with open("dee/static/context.js", encoding="utf-8") as fh:
|
| 94 |
+
src = fh.read()
|
| 95 |
+
assert "if (!el || (el.value || '').trim()) return false; // never clobber" in src
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
# --------------------------------------------------------------------------- #
|
| 99 |
+
# 2. The same sequence is the same project
|
| 100 |
+
# --------------------------------------------------------------------------- #
|
| 101 |
+
def test_posting_the_same_sequence_twice_reuses_the_construct(monkeypatch):
|
| 102 |
+
monkeypatch.setattr(server._auth, "get_auth", lambda: _Auth("u1"))
|
| 103 |
+
existing = {"id": "abc", "name": "MC1R", "phase": "Build",
|
| 104 |
+
"sequence_hash": "deadbeef"}
|
| 105 |
+
monkeypatch.setattr(server._auth, "find_construct_by_hash",
|
| 106 |
+
lambda uid, h: existing)
|
| 107 |
+
called = {"n": 0}
|
| 108 |
+
|
| 109 |
+
def _never(*a, **k):
|
| 110 |
+
called["n"] += 1
|
| 111 |
+
return {"ok": True, "id": "new"}
|
| 112 |
+
monkeypatch.setattr(server._auth, "save_construct", _never)
|
| 113 |
+
|
| 114 |
+
body = _client().post("/api/constructs",
|
| 115 |
+
json={"sequence_dna": "ACGT" * 20}).get_json()
|
| 116 |
+
assert body["ok"] is True and body["reused"] is True
|
| 117 |
+
assert body["construct"]["id"] == "abc"
|
| 118 |
+
assert called["n"] == 0, "a duplicate project must not be created"
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def test_a_construct_needs_a_sequence(monkeypatch):
|
| 122 |
+
monkeypatch.setattr(server._auth, "get_auth", lambda: _Auth("u1"))
|
| 123 |
+
res = _client().post("/api/constructs", json={"name": "no sequence"})
|
| 124 |
+
assert res.status_code == 400
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def test_the_list_payload_does_not_ship_the_sequence(monkeypatch):
|
| 128 |
+
"""The switcher renders every project. Shipping the DNA with each would
|
| 129 |
+
make a routine dropdown megabytes."""
|
| 130 |
+
monkeypatch.setattr(server._auth, "get_auth", lambda: _Auth("u1"))
|
| 131 |
+
monkeypatch.setattr(server._auth, "list_constructs", lambda uid: [{
|
| 132 |
+
"id": "a", "name": "N", "phase": "Design", "sequence_hash": "h",
|
| 133 |
+
"sequence_dna": "ACGT" * 5000, "wt_protein": "MKV" * 100,
|
| 134 |
+
"plasmid_ids": ["p1"], "crispr_ids": [], "primer_ids": [],
|
| 135 |
+
}])
|
| 136 |
+
row = _client().get("/api/constructs").get_json()["constructs"][0]
|
| 137 |
+
assert "sequence_dna" not in row and "wt_protein" not in row
|
| 138 |
+
assert row["n_plasmids"] == 1 and row["n_crispr"] == 0
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def test_the_single_get_does_ship_the_sequence(monkeypatch):
|
| 142 |
+
"""...because that one is the construct the user actually selected, and
|
| 143 |
+
the tools need it to pre-fill."""
|
| 144 |
+
monkeypatch.setattr(server._auth, "get_auth", lambda: _Auth("u1"))
|
| 145 |
+
monkeypatch.setattr(server._auth, "get_construct", lambda uid, cid: {
|
| 146 |
+
"id": cid, "name": "N", "phase": "Design",
|
| 147 |
+
"sequence_dna": "ACGTACGT", "wt_protein": "MKV",
|
| 148 |
+
})
|
| 149 |
+
c = _client().get(
|
| 150 |
+
"/api/constructs/11111111-1111-1111-1111-111111111111"
|
| 151 |
+
).get_json()["construct"]
|
| 152 |
+
assert c["sequence_dna"] == "ACGTACGT" and c["wt_protein"] == "MKV"
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
# --------------------------------------------------------------------------- #
|
| 156 |
+
# 3. Attribution is best-effort and never fatal
|
| 157 |
+
# --------------------------------------------------------------------------- #
|
| 158 |
+
def test_attach_rejects_a_bad_kind_or_id():
|
| 159 |
+
assert dee_auth.attach_artifact("u1", "not-a-uuid", "plasmid", "x")["ok"] is False
|
| 160 |
+
good = "11111111-1111-1111-1111-111111111111"
|
| 161 |
+
assert dee_auth.attach_artifact("u1", good, "nonsense", good)["ok"] is False
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def test_phase_only_ever_moves_forward(monkeypatch):
|
| 165 |
+
"""Opening the CRISPR tool after logging results must not drag a project
|
| 166 |
+
back from Learn to Edit."""
|
| 167 |
+
good = "11111111-1111-1111-1111-111111111111"
|
| 168 |
+
monkeypatch.setattr(dee_auth, "get_construct", lambda uid, cid: {
|
| 169 |
+
"id": cid, "phase": "Learn", "crispr_ids": []})
|
| 170 |
+
seen = {}
|
| 171 |
+
|
| 172 |
+
def _upd(uid, cid, **fields):
|
| 173 |
+
seen.update(fields)
|
| 174 |
+
return {"ok": True, "construct": {}}
|
| 175 |
+
monkeypatch.setattr(dee_auth, "update_construct", _upd)
|
| 176 |
+
|
| 177 |
+
dee_auth.attach_artifact("u1", good, "crispr", good)
|
| 178 |
+
assert seen["crispr_ids"] == [good]
|
| 179 |
+
assert "phase" not in seen, "Learn must not regress to Edit"
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def test_phase_advances_when_it_should(monkeypatch):
|
| 183 |
+
good = "11111111-1111-1111-1111-111111111111"
|
| 184 |
+
monkeypatch.setattr(dee_auth, "get_construct", lambda uid, cid: {
|
| 185 |
+
"id": cid, "phase": "Design", "crispr_ids": []})
|
| 186 |
+
seen = {}
|
| 187 |
+
monkeypatch.setattr(dee_auth, "update_construct",
|
| 188 |
+
lambda uid, cid, **f: (seen.update(f),
|
| 189 |
+
{"ok": True, "construct": {}})[1])
|
| 190 |
+
dee_auth.attach_artifact("u1", good, "crispr", good)
|
| 191 |
+
assert seen["phase"] == "Edit"
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def test_attaching_the_same_artifact_twice_is_a_no_op(monkeypatch):
|
| 195 |
+
good = "11111111-1111-1111-1111-111111111111"
|
| 196 |
+
monkeypatch.setattr(dee_auth, "get_construct", lambda uid, cid: {
|
| 197 |
+
"id": cid, "phase": "Build", "plasmid_ids": [good]})
|
| 198 |
+
monkeypatch.setattr(dee_auth, "update_construct",
|
| 199 |
+
lambda *a, **k: pytest.fail("should not write"))
|
| 200 |
+
assert dee_auth.attach_artifact("u1", good, "plasmid", good)["already"] is True
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def test_a_failing_attribution_does_not_fail_the_save(monkeypatch):
|
| 204 |
+
"""The artifact still exists in its own table; only the cross-reference is
|
| 205 |
+
missing. Losing the save instead would be strictly worse."""
|
| 206 |
+
monkeypatch.setattr(server._auth, "get_auth", lambda: _Auth("u1"))
|
| 207 |
+
monkeypatch.setattr(server._auth, "save_plasmid",
|
| 208 |
+
lambda *a, **k: {"ok": True, "id": "p-1"})
|
| 209 |
+
monkeypatch.setattr(server._auth, "cleanup_expired_plasmids_async",
|
| 210 |
+
lambda uid: None)
|
| 211 |
+
|
| 212 |
+
def _boom(*a, **k):
|
| 213 |
+
raise RuntimeError("supabase down")
|
| 214 |
+
monkeypatch.setattr(server._auth, "attach_artifact", _boom)
|
| 215 |
+
|
| 216 |
+
res = _client().post("/api/plasmid/save", json={
|
| 217 |
+
"name": "p", "topology": "circular", "sequence": "ACGT" * 30,
|
| 218 |
+
"features": [], "construct_id": "11111111-1111-1111-1111-111111111111",
|
| 219 |
+
})
|
| 220 |
+
assert res.status_code == 200 and res.get_json()["ok"] is True
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def test_user_id_cannot_be_passed_as_a_field_at_all():
|
| 224 |
+
"""The strongest version of the check: `user_id` is a positional parameter
|
| 225 |
+
of update_construct, so a caller splatting a request body that contains it
|
| 226 |
+
gets a TypeError rather than a silent ownership change. Asserted because
|
| 227 |
+
it is a property of the signature that a future refactor to **kwargs would
|
| 228 |
+
quietly remove."""
|
| 229 |
+
good = "11111111-1111-1111-1111-111111111111"
|
| 230 |
+
with pytest.raises(TypeError):
|
| 231 |
+
dee_auth.update_construct("u1", good, **{"user_id": "attacker"})
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def test_update_ignores_fields_the_client_should_not_write(monkeypatch):
|
| 235 |
+
"""Everything outside the allow-list is dropped before the row is touched
|
| 236 |
+
— a TTL or an id from a request body must never reach it."""
|
| 237 |
+
good = "11111111-1111-1111-1111-111111111111"
|
| 238 |
+
captured = {}
|
| 239 |
+
import urllib.request
|
| 240 |
+
|
| 241 |
+
class _Resp:
|
| 242 |
+
def __enter__(self): return self
|
| 243 |
+
def __exit__(self, *a): return False
|
| 244 |
+
def read(self): return b"[]"
|
| 245 |
+
|
| 246 |
+
def _fake(req, timeout=0):
|
| 247 |
+
captured["body"] = json.loads(req.data.decode())
|
| 248 |
+
return _Resp()
|
| 249 |
+
monkeypatch.setattr(dee_auth, "SUPABASE_URL", "https://x")
|
| 250 |
+
monkeypatch.setattr(dee_auth, "SUPABASE_SERVICE_KEY", "k")
|
| 251 |
+
monkeypatch.setattr(urllib.request, "urlopen", _fake)
|
| 252 |
+
|
| 253 |
+
dee_auth.update_construct("u1", good, name="ok", expires_at="never",
|
| 254 |
+
id="other", sequence_hash="forged", phase="Build")
|
| 255 |
+
assert "expires_at" not in captured["body"]
|
| 256 |
+
assert "id" not in captured["body"]
|
| 257 |
+
assert "sequence_hash" not in captured["body"]
|
| 258 |
+
assert captured["body"]["name"] == "ok"
|
| 259 |
+
assert captured["body"]["phase"] == "Build"
|