diff --git a/.gitignore b/.gitignore index cd8d950..35f9c3d 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,5 @@ scripts/ tests/fixtures/ .agents/ skills-lock.json +data/*.db +data/*.db-* diff --git a/api/routes.py b/api/routes.py index 8787600..7ece4da 100644 --- a/api/routes.py +++ b/api/routes.py @@ -1,40 +1,45 @@ from __future__ import annotations import asyncio import logging +import os import secrets -import traceback import uuid -from datetime import datetime +from datetime import datetime, timezone from functools import partial -from fastapi import APIRouter, File, HTTPException, Request, UploadFile -from fastapi.datastructures import FormData -from fastapi.responses import Response +from fastapi import APIRouter, HTTPException, Request logger = logging.getLogger(__name__) -from core.models import SkillResponse, InitRequest, InitResponse +import storage +from core.models import ( + CreateInstanceRequest, + CreateInstanceResponse, + OperatorConfig, + SkillResponse, +) from skills.router import SkillRouter -from skills.confidential_data_procurement.ingest import store_authorized_download, get_download_bytes router = APIRouter() -# Instance-scoped in-memory stores -_instances: dict[str, dict] = {} -# instance_id -> {skill_name, config, threshold, conversation[], triggered} -_submissions: dict[str, dict] = {} -# instance_id -> {submission_id -> raw_dict} +_DURATION_UNITS = {"w": 604800, "d": 86400, "h": 3600, "m": 60, "s": 1} -_results: dict[str, dict] = {} -# instance_id -> {submission_id -> result_dict} -_tokens: dict[str, dict] = {} -# token_string -> {instance_id, role, submission_ids: set[str]} - -_registrations: dict[str, dict] = {} -# instance_id -> {supabase_user_id -> token_string} -# prevents a single Supabase identity from registering twice for the same instance +def _parse_duration(spec: str) -> int: + """Parse a duration string like '1w', '3d', '12h', '30m' into seconds.""" + if not spec or len(spec) < 2: + raise ValueError(f"invalid duration: {spec!r}") + unit = spec[-1].lower() + if unit not in _DURATION_UNITS: + raise ValueError(f"unknown duration unit {unit!r}; use one of w/d/h/m/s") + try: + n = int(spec[:-1]) + except ValueError as e: + raise ValueError(f"invalid duration number: {spec!r}") from e + if n <= 0: + raise ValueError(f"duration must be positive: {spec!r}") + return n * _DURATION_UNITS[unit] _skill_router = SkillRouter() @@ -44,130 +49,170 @@ def register_skills(): from skills.hackathon_novelty import skill_card as hackathon_card _skill_router.register(hackathon_card) - from skills.confidential_data_procurement import skill_card as procurement_card - _skill_router.register(procurement_card) - # --- Helpers --- def _resolve_token(request: Request) -> dict: - """Read X-Instance-Token header and resolve to {instance_id, role}.""" - token = request.headers.get("X-Instance-Token") + """Resolve an instance token from either Authorization: Bearer or X-Instance-Token. + + Bearer is the canonical convention used by the agent skill. X-Instance-Token is preserved + for the web UI.""" + token: str | None = None + auth = request.headers.get("Authorization") or request.headers.get("authorization") + if auth and auth.startswith("Bearer "): + token = auth[len("Bearer "):].strip() if not token: - raise HTTPException(status_code=401, detail="X-Instance-Token header required") - if token not in _tokens: + token = request.headers.get("X-Instance-Token") + if not token: + raise HTTPException(status_code=401, detail="Authorization (Bearer) or X-Instance-Token header required") + info = storage.get_token(token) + if info is None: raise HTTPException(status_code=403, detail="Invalid or expired token") - return _tokens[token] + info["_raw_token"] = token + return info async def _run_pipeline(instance_id: str) -> int: """Validate submissions, invoke skill pipeline, store results. Returns result count.""" - inst = _instances[instance_id] + inst = storage.get_instance(instance_id) + if inst is None: + raise HTTPException(status_code=404, detail="Instance not found") card = _skill_router.get_card(inst["skill_name"]) - subs = _submissions.get(instance_id, {}) + subs = storage.list_submissions(instance_id) try: inputs = [card.input_model(**s) for s in subs.values()] except Exception as e: raise HTTPException(status_code=422, detail=f"Submission validation failed: {e}") + config = OperatorConfig(**inst["config"]) if isinstance(inst["config"], dict) else inst["config"] + loop = asyncio.get_event_loop() response: SkillResponse = await loop.run_in_executor( None, - partial(_skill_router.invoke, inst["skill_name"], inputs=inputs, params=inst["config"]), + partial(_skill_router.invoke, inst["skill_name"], inputs=inputs, params=config), ) - _results.setdefault(instance_id, {}) for r in response.results: - _results[instance_id][r["submission_id"]] = r + storage.upsert_result(instance_id, r["submission_id"], r) - inst["triggered"] = True + storage.set_instance_triggered(instance_id, True) + + snapshot = _build_snapshot(response.results) + storage.record_evaluation_run( + instance_id=instance_id, + submission_count=len(response.results), + snapshot=snapshot, + ) return len(response.results) +def _build_snapshot(results: list[dict]) -> dict: + """Aggregate stats captured per evaluation tick for the dashboard timeline.""" + cluster_counts: dict[str, int] = {} + track_counts: dict[str, int] = {} + collisions = 0 + for r in results: + c = r.get("cluster_label") + if c: + cluster_counts[c] = cluster_counts.get(c, 0) + 1 + t = r.get("best_fit_track") + if t: + track_counts[t] = track_counts.get(t, 0) + 1 + collisions += len(r.get("name_collisions") or []) + # Top-3 clusters and tracks for compactness + top_clusters = sorted(cluster_counts.items(), key=lambda kv: kv[1], reverse=True)[:3] + top_tracks = sorted(track_counts.items(), key=lambda kv: kv[1], reverse=True)[:3] + return { + "top_clusters": [{"label": k, "count": v} for k, v in top_clusters], + "top_tracks": [{"track": k, "count": v} for k, v in top_tracks], + "name_collision_pairs": collisions // 2, # each collision counted twice (once per side) + } + + # --- Endpoints --- -@router.post("/init") -async def init_instance(body: InitRequest): +@router.post("/instances") +def create_instance_endpoint(body: CreateInstanceRequest, request: Request) -> CreateInstanceResponse: """ - Conversational operator onboarding loop. - - First call: instance_id=None — creates instance, starts conversation. - Subsequent calls: include instance_id to continue the conversation. - The skill's init_handler owns all onboarding logic (prompts, LLM calls, config extraction). - Returns status='configuring' (skill needs more info) or status='ready' (tokens issued). + Create a new hackathon novelty instance. + Returns the unique enclave URL the operator shares with participants and an + admin token for the operator dashboard. """ - if body.instance_id is None: - try: - card = _skill_router.get_card(body.skill_name) - except KeyError: - raise HTTPException(status_code=404, detail=f"Skill '{body.skill_name}' not found") - if card.init_handler is None: - raise HTTPException(status_code=400, detail=f"Skill '{body.skill_name}' does not support conversational setup") - instance_id = str(uuid.uuid4()) - _instances[instance_id] = { - "skill_name": body.skill_name, - "config": None, - "threshold": card.config.get("min_submissions", 5), - "conversation": [], - "triggered": False, - } - _submissions[instance_id] = {} - _results[instance_id] = {} - else: - instance_id = body.instance_id - if instance_id not in _instances: - raise HTTPException(status_code=404, detail="Instance not found") - card = _skill_router.get_card(_instances[instance_id]["skill_name"]) - - inst = _instances[instance_id] - - # Delegate entirely to the skill's init_handler — sync call wrapped in executor - loop = asyncio.get_event_loop() - result = await loop.run_in_executor( - None, card.init_handler, body.message, inst["conversation"] - ) + now = datetime.now(timezone.utc) + end = body.end_date if body.end_date.tzinfo else body.end_date.replace(tzinfo=timezone.utc) + if end <= now: + raise HTTPException(status_code=422, detail="end_date must be in the future") - # Store updated conversation returned by the handler - inst["conversation"] = result["conversation"] + try: + freq_seconds = _parse_duration(body.evaluation_frequency) + except ValueError as e: + raise HTTPException(status_code=422, detail=str(e)) - if result["status"] == "ready": - inst["config"] = result["config"] - inst["config"].instance_id = instance_id - inst["threshold"] = result.get("threshold", inst["threshold"]) + instance_id = str(uuid.uuid4()) + tracks_dump = [t.model_dump() for t in body.tracks] + config = OperatorConfig( + criteria={"originality": 0.5, "feasibility": 0.5}, + guidelines="", + instance_id=instance_id, + tracks=tracks_dump, + ) + storage.create_instance( + instance_id=instance_id, + skill_name="hackathon_novelty", + config=config.model_dump(), + threshold=999_999, # threshold-trigger disabled; phase 5 scheduler drives evaluation + name=body.name, + end_date=end.isoformat(), + evaluation_frequency_seconds=freq_seconds, + tracks=tracks_dump, + ) - admin_token = secrets.token_urlsafe(16) - _tokens[admin_token] = {"instance_id": instance_id, "role": "admin", "submission_ids": set()} + admin_token = secrets.token_urlsafe(16) + storage.create_token(admin_token, instance_id, role="admin") - return InitResponse( - instance_id=instance_id, - status="ready", - message=result["message"], - admin_token=admin_token, - ) + # Spin up the scheduler loop for this instance immediately. + from infra import scheduler + scheduler.start_instance(instance_id) - return InitResponse( + base = os.environ.get("CONCLAVE_PUBLIC_URL", str(request.base_url).rstrip("/")) + return CreateInstanceResponse( instance_id=instance_id, - status="configuring", - message=result["message"], + admin_token=admin_token, + enclave_url=base, ) @router.post("/register") def register_user(body: dict): """ - Issue a unique user token for a specific instance. - Participants call this with the instance_id provided by the operator. - Each call returns a fresh token — ownership of submitted results is tracked per token. + Issue a unique user token for a specific instance (legacy shape used by the web UI). + Returns {user_token}. New integrations should use POST /generate-token. """ instance_id = body.get("instance_id", "").strip() - if not instance_id or instance_id not in _instances: + if not instance_id or not storage.has_instance(instance_id): raise HTTPException(status_code=404, detail="Instance not found") token = secrets.token_urlsafe(16) - _tokens[token] = {"instance_id": instance_id, "role": "user", "submission_ids": set()} + storage.create_token(token, instance_id, role="user") return {"user_token": token} +@router.post("/generate-token") +def generate_token(body: dict): + """ + Issue a participant token for an instance. + Canonical endpoint for the agent skill — mirrors Colosseum Copilot's PAT issuance. + URL-as-access-control: anyone with the unique enclave URL can mint a token. + Sybil prevention is intentionally deferred (see plans/conclave_skill_plan.md). + """ + instance_id = body.get("instance_id", "").strip() + if not instance_id or not storage.has_instance(instance_id): + raise HTTPException(status_code=404, detail="Instance not found") + token = secrets.token_urlsafe(16) + storage.create_token(token, instance_id, role="user") + return {"token": token, "expires_at": None} + + @router.post("/auth/send-otp") def auth_send_otp(body: dict): """ @@ -184,7 +229,7 @@ def auth_send_otp(body: dict): if not email: raise HTTPException(status_code=422, detail="email is required") - if not instance_id or instance_id not in _instances: + if not instance_id or not storage.has_instance(instance_id): raise HTTPException(status_code=404, detail="Instance not found") try: @@ -213,7 +258,7 @@ def auth_verify_token(body: dict): if not access_token: raise HTTPException(status_code=422, detail="access_token is required") - if not instance_id or instance_id not in _instances: + if not instance_id or not storage.has_instance(instance_id): raise HTTPException(status_code=404, detail="Instance not found") try: @@ -229,13 +274,13 @@ def auth_verify_token(body: dict): except Exception as e: raise HTTPException(status_code=401, detail=f"Token validation failed: {e}") - instance_reg = _registrations.setdefault(instance_id, {}) - if supabase_user_id in instance_reg: - return {"user_token": instance_reg[supabase_user_id]} + existing = storage.get_registration_token(instance_id, supabase_user_id) + if existing: + return {"user_token": existing} user_token = secrets.token_urlsafe(16) - _tokens[user_token] = {"instance_id": instance_id, "role": "user", "submission_ids": set(), "supabase_user_id": supabase_user_id} - instance_reg[supabase_user_id] = user_token + storage.create_token(user_token, instance_id, role="user", supabase_user_id=supabase_user_id) + storage.set_registration_token(instance_id, supabase_user_id, user_token) return {"user_token": user_token} @@ -256,7 +301,7 @@ def auth_verify_otp(body: dict): if not email or not token: raise HTTPException(status_code=422, detail="email and token are required") - if not instance_id or instance_id not in _instances: + if not instance_id or not storage.has_instance(instance_id): raise HTTPException(status_code=404, detail="Instance not found") try: @@ -264,15 +309,13 @@ def auth_verify_otp(body: dict): except Exception as e: raise HTTPException(status_code=401, detail=f"OTP verification failed: {e}") - # Idempotent: return existing token if this user already registered for this instance - instance_reg = _registrations.setdefault(instance_id, {}) - if supabase_user_id in instance_reg: - existing_token = instance_reg[supabase_user_id] - return {"user_token": existing_token} + existing = storage.get_registration_token(instance_id, supabase_user_id) + if existing: + return {"user_token": existing} user_token = secrets.token_urlsafe(16) - _tokens[user_token] = {"instance_id": instance_id, "role": "user", "submission_ids": set(), "supabase_user_id": supabase_user_id} - instance_reg[supabase_user_id] = user_token + storage.create_token(user_token, instance_id, role="user", supabase_user_id=supabase_user_id) + storage.set_registration_token(instance_id, supabase_user_id, user_token) return {"user_token": user_token} @@ -286,25 +329,24 @@ def get_me(request: Request): @router.get("/instances/{instance_id}") def get_instance(instance_id: str): """Check if an instance exists. Used by the frontend to validate a participant URL.""" - if instance_id not in _instances: + inst = storage.get_instance(instance_id) + if inst is None: raise HTTPException(status_code=404, detail="Instance not found or expired") - inst = _instances[instance_id] return { "instance_id": instance_id, "skill_name": inst["skill_name"], "triggered": inst["triggered"], - "submissions": len(_submissions.get(instance_id, {})), + "submissions": storage.count_submissions(instance_id), "threshold": inst["threshold"], } @router.get("/health") def health(): - total_subs = sum(len(s) for s in _submissions.values()) return { "status": "ok", - "instances": len(_instances), - "submissions": total_subs, + "instances": storage.count_instances(), + "submissions": storage.count_submissions(), "skills": _skill_router.list_skills(), } @@ -318,7 +360,10 @@ async def submit(submission: dict, request: Request): """ token_info = _resolve_token(request) instance_id = token_info["instance_id"] - skill_name = _instances[instance_id]["skill_name"] + inst = storage.get_instance(instance_id) + if inst is None: + raise HTTPException(status_code=404, detail="Instance not found") + skill_name = inst["skill_name"] card = _skill_router.get_card(skill_name) try: @@ -330,29 +375,17 @@ async def submit(submission: dict, request: Request): submission = validated.model_dump() # ensure stored dict is normalized submission["_submitted_at"] = datetime.utcnow().isoformat() + "Z" - _submissions[instance_id][sid] = submission - token_info["submission_ids"].add(sid) - count = len(_submissions[instance_id]) - threshold = _instances[instance_id]["threshold"] - - # CONCURRENCY NOTE: This threshold check is not atomic. Concurrent submissions could - # both see count >= threshold and trigger _run_pipeline twice. This is a non-issue in - # the current deployment model — the TEE container runs single-worker uvicorn which - # serializes all requests. If deployment changes to allow concurrent request handling, - # add a per-instance asyncio.Lock around this check. - if count >= threshold: - await _run_pipeline(instance_id) - return { - "submission_id": sid, - "status": "received_analysis_complete", - "submissions_count": count, - } + storage.upsert_submission(instance_id, sid, submission) + storage.add_submission_to_token(token_info["_raw_token"], sid) + count = storage.count_submissions(instance_id) + # Pipeline triggering moved to the scheduler (phase 5). /submit is now + # purely an ingest endpoint. Operators can still call POST /trigger to + # force an evaluation. return { "submission_id": sid, - "status": "received_pending", + "status": "received", "submissions_count": count, - "threshold": threshold, } @@ -371,141 +404,25 @@ def get_submissions(request: Request): raise HTTPException(status_code=403, detail="Only admin can view submission metadata") instance_id = token_info["instance_id"] - subs = _submissions.get(instance_id, {}) + subs = storage.list_submissions(instance_id) meta = [] for sub in subs.values(): + idea_text = sub.get("idea_text") or "" + first_line = idea_text.split("\n", 1)[0].strip() + title = first_line[:80] if first_line else "" meta.append({ "submission_id": sub.get("submission_id", ""), "submitted_at": sub.get("_submitted_at"), - "has_text": bool(sub.get("idea_text")), + "has_text": bool(idea_text), "has_file": bool(sub.get("idea_file")), "has_repo": bool(sub.get("repo_summary")), + "idea_title_or_summary": title, }) return {"submissions": meta} -@router.post("/upload") -async def upload_file(request: Request): - """ - Generic file upload — delegates entirely to the skill's upload_handler. - Skills that need file upload declare upload_handler on their SkillCard. - The skill owns all parsing, storage, and validation logic. - - Returns whatever the skill's upload_handler returns (e.g. {"dataset_id": "..."}). - """ - token_info = _resolve_token(request) - instance_id = token_info["instance_id"] - card = _skill_router.get_card(_instances[instance_id]["skill_name"]) - - if card.upload_handler is None: - raise HTTPException( - status_code=400, - detail=f"Skill '{_instances[instance_id]['skill_name']}' does not support file upload", - ) - - try: - form: FormData = await request.form() - logger.info("upload: form fields received: %s", list(form.keys())) - for key in form.keys(): - val = form.get(key) - if hasattr(val, "filename"): - logger.info(" field=%s filename=%s content_type=%s", key, val.filename, val.content_type) - else: - logger.info(" field=%s value=%r", key, val) - loop = asyncio.get_running_loop() - result = await loop.run_in_executor(None, card.upload_handler, form, instance_id) - except ValueError as e: - logger.warning("upload: validation error: %s", e) - raise HTTPException(status_code=422, detail=str(e)) - except Exception as e: - logger.error("upload: unexpected error:\n%s", traceback.format_exc()) - raise HTTPException(status_code=500, detail=f"Upload failed: {e}") - - return result - - -@router.post("/respond") -async def respond_to_result(body: dict, request: Request): - """ - Deal response — delegates entirely to the skill's respond_handler. - Skills that support renegotiation declare respond_handler on their SkillCard. - - Body: { - "submission_id": str, - "action": "accept" | "reject" | "renegotiate", - "revised_value": float | null # only when action="renegotiate" - } - Returns: {"settlement_status": str, ...any extra fields the skill returns} - """ - token_info = _resolve_token(request) - instance_id = token_info["instance_id"] - role = token_info["role"] - card = _skill_router.get_card(_instances[instance_id]["skill_name"]) - - if card.respond_handler is None: - raise HTTPException( - status_code=400, - detail=f"Skill '{_instances[instance_id]['skill_name']}' does not support deal responses", - ) - - submission_id = body.get("submission_id") - if not submission_id: - raise HTTPException(status_code=422, detail="submission_id is required") - - instance_results = _results.get(instance_id, {}) - if submission_id not in instance_results: - raise HTTPException(status_code=404, detail="Result not found or not yet available") - - action = body.get("action") - if action not in ("accept", "reject", "renegotiate"): - raise HTTPException(status_code=422, detail="action must be 'accept', 'reject', or 'renegotiate'") - - try: - loop = asyncio.get_running_loop() - updated = await loop.run_in_executor( - None, - card.respond_handler, - instance_results[submission_id], - action, - body.get("revised_value"), - "buyer" if role == "admin" else "supplier", - _instances[instance_id]["config"], - ) - except ValueError as e: - raise HTTPException(status_code=422, detail=str(e)) - - # If deal just became authorized, index the CSV bytes under the release token. - # Note: _dataset_id is stripped by guardrails, so look it up from _submissions instead. - if updated.get("settlement_status") == "authorized" and updated.get("release_token"): - sub_record = _submissions.get(instance_id, {}).get(submission_id, {}) - dataset_id = sub_record.get("dataset_id") - if dataset_id: - store_authorized_download(updated["release_token"], dataset_id) - - _results[instance_id][submission_id] = updated - return {"settlement_status": updated.get("settlement_status")} - - -@router.get("/download/{token}") -async def download_dataset(token: str): - """ - Download the authorized dataset CSV using the release token. - The token itself is the bearer credential — no X-Instance-Token needed. - Only issued after both parties accept the deal (settlement_status='authorized'). - """ - try: - csv_bytes = get_download_bytes(token) - except KeyError: - raise HTTPException(status_code=404, detail="Invalid or expired download token") - return Response( - content=csv_bytes, - media_type="text/csv", - headers={"Content-Disposition": f"attachment; filename=\"dataset_{token[:8]}.csv\""}, - ) - - @router.post("/trigger") async def trigger(request: Request): """Manual pipeline trigger. Admin only. Uses stored instance config.""" @@ -514,7 +431,7 @@ async def trigger(request: Request): raise HTTPException(status_code=403, detail="Only admin can trigger manually") instance_id = token_info["instance_id"] - if not _submissions.get(instance_id): + if storage.count_submissions(instance_id) == 0: raise HTTPException(status_code=400, detail="No submissions to analyze") count = await _run_pipeline(instance_id) @@ -529,7 +446,83 @@ def get_all_results(request: Request): raise HTTPException(status_code=403, detail="Only admin can view all results") instance_id = token_info["instance_id"] - return {"results": list(_results.get(instance_id, {}).values())} + return {"results": storage.list_results(instance_id)} + + +@router.get("/cohort/aggregates") +def cohort_aggregates(request: Request): + """Operator-only cohort summary: cluster + track distribution, collision count, + cohort size, last-evaluation timestamp.""" + token_info = _resolve_token(request) + if token_info["role"] != "admin": + raise HTTPException(status_code=403, detail="Only admin can view cohort aggregates") + + instance_id = token_info["instance_id"] + results = storage.list_results(instance_id) + + cluster_counts: dict[str, int] = {} + track_counts: dict[str, int] = {} + collisions = 0 + last_at = None + for r in results: + c = r.get("cluster_label") + if c: + cluster_counts[c] = cluster_counts.get(c, 0) + 1 + t = r.get("best_fit_track") + if t: + track_counts[t] = track_counts.get(t, 0) + 1 + collisions += len(r.get("name_collisions") or []) + + runs = storage.list_evaluation_runs(instance_id) + if runs: + last_at = runs[-1]["ran_at"] + + return { + "cohort_size": storage.count_submissions(instance_id), + "last_evaluation_at": last_at, + "cluster_distribution": [ + {"label": k, "count": v} + for k, v in sorted(cluster_counts.items(), key=lambda kv: kv[1], reverse=True) + ], + "track_distribution": [ + {"track": k, "count": v} + for k, v in sorted(track_counts.items(), key=lambda kv: kv[1], reverse=True) + ], + "name_collision_pairs": collisions // 2, + } + + +@router.get("/cohort/timeline") +def cohort_timeline(request: Request): + """Operator-only history of evaluation ticks for this instance.""" + token_info = _resolve_token(request) + if token_info["role"] != "admin": + raise HTTPException(status_code=403, detail="Only admin can view cohort timeline") + return {"runs": storage.list_evaluation_runs(token_info["instance_id"])} + + +@router.get("/attestations") +def list_attestations(request: Request): + """Public-readable list of on-chain attestations for this instance. + + Anyone with a valid token (admin or user) can read so participants can + verify the enclave published the final report they received.""" + token_info = _resolve_token(request) + return {"attestations": storage.list_attestations(token_info["instance_id"])} + + +@router.post("/attestations/publish") +async def publish_attestation_now(request: Request): + """Admin-only: force an immediate attestation publish over the current cohort. + Useful for the demo path when waiting for end_date isn't practical.""" + token_info = _resolve_token(request) + if token_info["role"] != "admin": + raise HTTPException(status_code=403, detail="Only admin can publish attestations") + instance_id = token_info["instance_id"] + from infra.scheduler import _publish_final_attestation + await _publish_final_attestation(instance_id) + runs = storage.list_attestations(instance_id) + return {"latest": runs[-1] if runs else None} @router.get("/results/{submission_id}") @@ -543,21 +536,20 @@ def get_results(submission_id: str, request: Request): instance_id = token_info["instance_id"] role = token_info["role"] - instance_results = _results.get(instance_id, {}) - - if submission_id not in instance_results: + result = storage.get_result(instance_id, submission_id) + if result is None: raise HTTPException(status_code=404, detail="Result not found or not yet available") if role == "user": if submission_id not in token_info["submission_ids"]: raise HTTPException(status_code=403, detail="Access denied: submission not owned by this token") # Participant view: filtered to skill-declared user_output_keys - card = _skill_router.get_card(_instances[instance_id]["skill_name"]) - result = instance_results[submission_id] + inst = storage.get_instance(instance_id) + card = _skill_router.get_card(inst["skill_name"]) return {k: result[k] for k in card.user_output_keys if k in result} # admin: unrestricted access within the instance - return instance_results[submission_id] + return result @router.get("/skills") diff --git a/client/apps/web/app/access/page.tsx b/client/apps/web/app/access/page.tsx deleted file mode 100644 index 0547951..0000000 --- a/client/apps/web/app/access/page.tsx +++ /dev/null @@ -1,118 +0,0 @@ -"use client" - -import * as React from "react" -import { useRouter } from "next/navigation" -import { Lock, ShieldCheck, CircleNotch, ArrowRight } from "@phosphor-icons/react" -import { api, ApiError } from "@/lib/api" -import Link from "next/link" - -export default function AccessPage() { - const router = useRouter() - const [token, setToken] = React.useState("") - const [loading, setLoading] = React.useState(false) - const [error, setError] = React.useState("") - - async function handleAccess() { - const t = token.trim() - if (!t) return - setLoading(true) - setError("") - try { - const { instance_id, role } = await api.resolveToken(t) - if (role !== "admin") { - setError("This token doesn't have admin access. Make sure you're using the admin token issued at setup.") - setLoading(false) - return - } - // Persist token in the same format the dashboard expects - const existing = localStorage.getItem(`ndai_instance_${instance_id}`) - const data = existing ? JSON.parse(existing) : {} - localStorage.setItem( - `ndai_instance_${instance_id}`, - JSON.stringify({ ...data, instance_id, admin_token: t }), - ) - router.push(`/dashboard/${instance_id}`) - } catch (err) { - if (err instanceof ApiError && err.status === 403) { - setError("Invalid or expired token.") - } else { - setError("Could not reach the enclave. Make sure the server is running.") - } - setLoading(false) - } - } - - return ( -
- {/* Nav */} -
-
- -
- -
- NDAI - - - Create new instance - -
-
- - {/* Content */} -
-
- {/* Header */} -
-
- -
-

- Access your dashboard -

-

- Paste the admin token you received when you created the instance. -

-
- - {/* Card */} -
-
- - { setToken(e.target.value); setError("") }} - onKeyDown={(e) => e.key === "Enter" && handleAccess()} - placeholder="adm_…" - autoFocus - className="w-full rounded-xl border border-[#d2d2d7] bg-[#f5f5f7] px-4 py-3 text-sm font-mono text-[#1d1d1f] placeholder:text-[#aeaeb2] focus:outline-none focus:border-primary/50 focus:ring-1 focus:ring-primary/20 transition-all" - /> - {error && ( -

{error}

- )} -
- - -
- -

- Don't have a token?{" "} - - Create an instance - -

-
-
-
- ) -} diff --git a/client/apps/web/app/dashboard/[id]/page.tsx b/client/apps/web/app/dashboard/[id]/page.tsx index d6d65d1..ea19966 100644 --- a/client/apps/web/app/dashboard/[id]/page.tsx +++ b/client/apps/web/app/dashboard/[id]/page.tsx @@ -2,718 +2,641 @@ import * as React from "react" import { use } from "react" +import Link from "next/link" import { - Copy, - Check, - Lightning, - ArrowCounterClockwise, - Export, - ShieldCheck, - CaretDown, + ArrowLeft, + ArrowsClockwise, + Cube, + ClockClockwise, + CheckCircle, + Warning, + CircleNotch, } from "@phosphor-icons/react" -import { EnclaveSigBadge } from "@/components/enclave-sig-badge" -import { StatusPill } from "@/components/status-pill" -import { FieldCell, ResultExpandedRow } from "@/components/result-renderer" -import { HardConstraintsCard } from "@/components/hard-constraints-card" -import { MilestoneBreakdown } from "@/components/milestone-breakdown" -import { ProcurementScorecard } from "@/components/procurement-scorecard" -import { NegotiationPanel } from "@/components/negotiation-panel" -import { ReleaseTokenCard } from "@/components/release-token-card" -import { api } from "@/lib/api" -import type { DisplayMap, NoveltyResult, ProcurementResult, SubmissionMeta } from "@/lib/types" -import { cn } from "@workspace/ui/lib/utils" -import Link from "next/link" -import { ArrowLeft } from "@phosphor-icons/react" -type Tab = "overview" | "submissions" | "results" | "deals" | "traces" +import { api, ApiError } from "@/lib/api" +import { Laurel, SpqrSeal } from "@/components/seal-marks" +import type { + Attestation, + CohortAggregates, + CohortTimelineEntry, + NoveltyResult, + StoredInstance, + SubmissionMeta, +} from "@/lib/types" + +type Tab = "cohort" | "submissions" | "attestations" + +interface DashboardData { + aggregates: CohortAggregates + timeline: CohortTimelineEntry[] + submissions: SubmissionMeta[] + results: NoveltyResult[] + attestations: Attestation[] +} export default function DashboardPage({ params }: { params: Promise<{ id: string }> }) { - const { id } = use(params) - const [tab, setTab] = React.useState("overview") - const [adminToken, setAdminToken] = React.useState(null) - const [tokenInput, setTokenInput] = React.useState("") - const [isProcurement, setIsProcurement] = React.useState(false) - - // Hackathon state - const [results, setResults] = React.useState([]) - const [display, setDisplay] = React.useState({}) - const [submissionMetas, setSubmissionMetas] = React.useState([]) - const [triggering, setTriggering] = React.useState(false) - const [triggered, setTriggered] = React.useState(false) - - // Procurement state - const [procResults, setProcResults] = React.useState([]) - const [dealActions, setDealActions] = React.useState>({}) - - const [subCount, setSubCount] = React.useState(0) - const [threshold, setThreshold] = React.useState(5) - const [copied, setCopied] = React.useState(false) + const { id: instanceId } = use(params) + const [token, setToken] = React.useState(null) + const [data, setData] = React.useState(null) + const [loading, setLoading] = React.useState(false) + const [error, setError] = React.useState(null) + const [tab, setTab] = React.useState("cohort") + const [busy, setBusy] = React.useState<"trigger" | "publish" | null>(null) + const [instanceName, setInstanceName] = React.useState("") React.useEffect(() => { - const raw = localStorage.getItem(`ndai_instance_${id}`) - if (raw) { - const data = JSON.parse(raw) - setAdminToken(data.admin_token) - } - }, [id]) - - React.useEffect(() => { - async function fetchStatus() { - try { - const inst = await api.checkInstance(id) - setSubCount(inst.submissions) - setThreshold(inst.threshold) - if (inst.triggered) setTriggered(true) - const proc = inst.skill_name === "confidential_data_procurement" - setIsProcurement(proc) - if (!proc && inst.skill_name) { - api.getSkill(inst.skill_name).then((card) => { - if (card.user_display) setDisplay(card.user_display) - }).catch(() => {}) - } - } catch { - // ignore + try { + const stored = JSON.parse(localStorage.getItem("conclave.instances") || "[]") as StoredInstance[] + const match = stored.find((s) => s.instance_id === instanceId) + if (match) { + setToken(match.admin_token) + setInstanceName(match.name) } + } catch { + // ignore + } + }, [instanceId]) + + const load = React.useCallback(async () => { + if (!token) return + setLoading(true) + setError(null) + try { + const [agg, timeline, subs, results, atts] = await Promise.all([ + api.cohortAggregates(token), + api.cohortTimeline(token), + api.listSubmissions(token), + api.listResults(token), + api.listAttestations(token), + ]) + setData({ + aggregates: agg, + timeline: timeline.runs, + submissions: subs.submissions, + results: results.results, + attestations: atts.attestations, + }) + } catch (e) { + setError(e instanceof ApiError ? `${e.status}: ${e.message}` : String(e)) + } finally { + setLoading(false) } - fetchStatus() - const interval = setInterval(fetchStatus, 10000) - return () => clearInterval(interval) - }, [id]) + }, [token]) React.useEffect(() => { - if (!adminToken) return - api.checkInstance(id).then((inst) => { - if (inst.skill_name === "confidential_data_procurement") { - api.getProcurementResults(adminToken).then((r) => { - if (r.results.length > 0) setProcResults(r.results) - }).catch(() => {}) - } else { - api.getAllResults(adminToken).then((r) => { - if (r.results.length > 0) { - setResults(r.results) - setTriggered(true) - } - }).catch(() => {}) - api.getSubmissions(adminToken).then((r) => { - if (r.submissions.length > 0) setSubmissionMetas(r.submissions) - }).catch(() => {}) - } - }).catch(() => {}) - }, [adminToken]) - - async function runAnalysis() { - if (!adminToken) return - setTriggering(true) - await api.trigger(adminToken) - const [r, s] = await Promise.all([ - api.getAllResults(adminToken), - api.getSubmissions(adminToken), - ]) - setResults(r.results) - setSubmissionMetas(s.submissions) - setTriggered(true) - setTriggering(false) - } - - async function copyLink() { - const url = `${window.location.origin}/i/${id}` - await navigator.clipboard.writeText(url) - setCopied(true) - setTimeout(() => setCopied(false), 2000) - } - - async function handleAccept(procResult: ProcurementResult) { - if (!adminToken) return - await api.acceptDeal(adminToken, procResult.submission_id) - const token = await api.getReleaseToken(adminToken, procResult.submission_id) - setDealActions((a) => ({ ...a, [procResult.submission_id]: "accepted" })) - setProcResults((rs) => - rs.map((r) => - r.submission_id === procResult.submission_id - ? { ...r, release_token: token, negotiation: { ...r.negotiation, state: "accepted" }, settlement: { state: "authorized", amount: r.proposed_payment } } - : r, - ), - ) - } - - async function handleReject(procResult: ProcurementResult) { - if (!adminToken) return - await api.rejectDeal(adminToken, procResult.submission_id) - setDealActions((a) => ({ ...a, [procResult.submission_id]: "rejected" })) - setProcResults((rs) => - rs.map((r) => - r.submission_id === procResult.submission_id - ? { ...r, negotiation: { ...r.negotiation, state: "rejected" }, settlement: { state: "failed" } } - : r, - ), - ) + if (token) void load() + }, [token, load]) + + async function triggerEvaluation() { + if (!token) return + setBusy("trigger") + try { + await api.triggerPipeline(token) + await load() + } catch (e) { + setError(e instanceof ApiError ? `${e.status}: ${e.message}` : String(e)) + } finally { + setBusy(null) + } } - async function handleRenegotiate(procResult: ProcurementResult, revisedBudget: number) { - if (!adminToken) return - await api.requestNegotiation(adminToken, procResult.submission_id, revisedBudget) - setDealActions((a) => ({ ...a, [procResult.submission_id]: "renegotiating" })) - setProcResults((rs) => - rs.map((r) => - r.submission_id === procResult.submission_id - ? { ...r, negotiation: { state: "requested_by_buyer", revised_budget: revisedBudget, used: true } } - : r, - ), - ) + async function publishAttestation() { + if (!token) return + setBusy("publish") + try { + await api.publishAttestation(token) + await load() + } catch (e) { + setError(e instanceof ApiError ? `${e.status}: ${e.message}` : String(e)) + } finally { + setBusy(null) + } } - // Token gate - if (!adminToken) { - return ( -
-
-
- -
-

Enter admin token

-

- Paste the admin token issued when you created this instance. -

- setTokenInput(e.target.value)} - placeholder="adm_…" - className="w-full rounded-xl border border-[#d2d2d7] bg-[#f5f5f7] px-4 py-2.5 text-sm font-mono text-[#1d1d1f] placeholder:text-[#aeaeb2] focus:outline-none focus:border-primary/50 focus:ring-1 focus:ring-primary/20 mb-3 transition-all" - /> - -
-
- ) + if (!token) { + return } - const participantUrl = `${typeof window !== "undefined" ? window.location.origin : ""}/i/${id}` - const status = triggered || procResults.length > 0 - ? "complete" - : subCount >= threshold - ? "analyzing" - : "accepting" - - // Tabs vary by protocol - const tabs: Tab[] = isProcurement - ? ["overview", "submissions", "deals", "traces"] - : ["overview", "submissions", "results", "traces"] - - // Procurement deal stats - const acceptedDeals = procResults.filter((r) => r.negotiation.state === "accepted").length - const pendingDeals = procResults.filter((r) => r.negotiation.state === "none").length - const totalPayment = procResults - .filter((r) => r.settlement.state === "authorized") - .reduce((sum, r) => sum + (r.settlement.amount ?? 0), 0) - return ( -
- {/* Top bar */} -
-
-
-
- - - -
-
-

- {isProcurement ? "Confidential Data Procurement" : "Hackathon Novelty"} -

- -
-

{id}

-
-
+
+
-
- -
- - {/* ── OVERVIEW ── */} - {tab === "overview" && ( -
- {isProcurement ? ( - <> -
- {[ - { label: "Seller Submissions", value: subCount }, - { label: "Evaluated", value: procResults.length }, - { label: "Deals Closed", value: acceptedDeals }, - { label: "Total Settled", value: totalPayment > 0 ? `$${totalPayment.toLocaleString()}` : "—" }, - ].map(({ label, value }) => ( -
-

{label}

-

{value}

-
- ))} -
- - {procResults.length > 0 && ( -
-

Deal Pipeline

-
- {procResults.map((r) => ( -
- {r.submission_id} -
- ${r.proposed_payment.toLocaleString()} - -
-
- ))} -
-
- )} - - {procResults.length === 0 && ( -
-

No evaluated submissions yet.

-

- Sellers submit datasets via the seller link. Results appear here after the enclave evaluates each one. -

-
- )} - - ) : ( - <> -
- {[ - { label: "Submissions", value: subCount }, - { label: "Threshold", value: threshold }, - { label: "Analyzed", value: triggered ? results.length : "—" }, - { label: "Status", value: triggered ? "Complete" : "Open" }, - ].map(({ label, value }) => ( -
-

{label}

-

{value}

-
- ))} -
-
-
-

Submission progress

- {subCount} / {threshold} — analysis triggers at {threshold} -
-
-
-
-
-
- - {triggered && ( - - )} -
- - )} + {error && ( +
+ + {error}
)} - {/* ── SUBMISSIONS ── */} - {tab === "submissions" && ( -
-
- {isProcurement - ? "Dataset content is processed inside the enclave only. Raw rows are never visible here." - : "Submission content is processed inside the enclave only. You cannot read raw submissions."} -
-
- - - - {isProcurement - ? ["#", "Submitted at", "Dataset", "Eval status", "Score", "Payment"].map((h) => ( - - )) - : ["#", "Submitted at", "Text", "PDF", "GitHub", "Status"].map((h) => ( - - )) - } - - - - {isProcurement - ? procResults.length > 0 - ? procResults.map((r, i) => ( - - - - - - - - - )) - : Array.from({ length: subCount }).map((_, i) => ( - - - - - - - - - )) - : submissionMetas.length > 0 - ? submissionMetas.map((s, i) => ( - - - - - - - - - )) - : Array.from({ length: subCount }).map((_, i) => ( - - - - - - - - - )) - } - -
{h}{h}
{i + 1} - {new Date(Date.now() - i * 3600000).toLocaleString()} - {r.submission_id} - - - {(r.partial_score * 100).toFixed(0)} - - ${r.proposed_payment.toLocaleString()} -
{i + 1} - {new Date(Date.now() - i * 3600000).toLocaleString()} - - pending -
{i + 1} - {s.submitted_at ? new Date(s.submitted_at).toLocaleString() : "—"} - {s.has_text ? : }{s.has_file ? : }{s.has_repo ? : } - received -
{i + 1} - received -
-
-
- )} + - {/* ── DEALS (procurement) ── */} - {tab === "deals" && isProcurement && ( -
- {procResults.length === 0 ? ( -
-

No evaluated datasets yet.

-

Results appear here automatically after the enclave evaluates each seller submission.

-
- ) : ( - <> -
- - Results signed by enclave -
- {procResults.map((r) => ( - handleAccept(r)} - onReject={() => handleReject(r)} - onRenegotiate={(val) => handleRenegotiate(r, val)} - /> - ))} - - )} -
- )} + {data && tab === "cohort" && } + {data && tab === "submissions" && } + {data && tab === "attestations" && } - {/* ── RESULTS (hackathon) ── */} - {tab === "results" && !isProcurement && ( -
- {!triggered ? ( -
-

No results yet. Run analysis to see results.

- -
- ) : ( -
-
- - Results signed by enclave -
- -
- )} + {!data && !error && ( +
+ + Convening the chamber…
)} + +
+ ) +} - {/* ── TRACES ── */} - {tab === "traces" && ( -
-

Trace data will appear here after analysis runs.

-

- {isProcurement - ? "Traces show which evaluation tools ran, output filter pass/fail per constraint, and claim-verification results. No raw dataset content." - : "Traces show which tools Claude called per submission, output filter pass/fail, and jailbreak test results. They contain no raw submission content."} -

-
- )} +// --------------------------------------------------------------------------- +// Header / nav +// --------------------------------------------------------------------------- + +function Nav({ instanceName }: { instanceName: string }) { + return ( +
+
+ + + Back to the forum + +
+ + + {instanceName.toUpperCase()} + +
-
+ ) } // --------------------------------------------------------------------------- -// Procurement deal card +// Tabs // --------------------------------------------------------------------------- -function DealCard({ - result, - onAccept, - onReject, - onRenegotiate, +function Tabs({ + current, + onChange, + data, }: { - result: ProcurementResult - onAccept: () => void - onReject: () => void - onRenegotiate: (revisedBudget: number) => void + current: Tab + onChange: (t: Tab) => void + data: DashboardData | null }) { - const [expanded, setExpanded] = React.useState(false) + const tabs: { id: Tab; label: string; count?: number }[] = [ + { id: "cohort", label: "The Lists" }, + { id: "submissions", label: "Submissions", count: data?.submissions.length }, + { id: "attestations", label: "Seals", count: data?.attestations.length }, + ] + return ( +
+
+ {tabs.map((t) => ( + + ))} +
+
+ ) +} +// --------------------------------------------------------------------------- +// Cohort tab — "The Lists" +// --------------------------------------------------------------------------- + +function CohortTab({ data }: { data: DashboardData }) { + const { aggregates, timeline } = data return ( -
- {/* Header row */} -
setExpanded((v) => !v)} - > -
- {result.submission_id} - -
-
-
-

Score

-

{(result.partial_score * 100).toFixed(0)}

-
-
-

Proposed

-

${result.proposed_payment.toLocaleString()}

-
- -
+
+
+ + + +
+ +
+ ({ key: c.label, count: c.count }))} + /> + ({ key: c.track, count: c.count }))} + />
- {/* Expanded deal detail */} - {expanded && ( -
- - - - - {/* Claim results */} - {Object.keys(result.claim_results).length > 0 && ( -
-

Claim Verification

-
- {Object.entries(result.claim_results).map(([claim, passed]) => ( -
- - {passed ? "✓" : "✗"} +
+
+ +

LEDGER · DELIBERATIONS

+
+ {timeline.length === 0 ? ( +
+ The Conclave has not yet deliberated. Trigger a deliberation or wait for the scheduler tick. +
+ ) : ( +
+ {timeline.slice().reverse().map((entry) => ( +
+
+
+ + {entry.submission_count} - {claim} + submissions judged
- ))} +
+ {formatRelative(entry.ran_at)} · {new Date(entry.ran_at).toLocaleString()} +
+
+ {entry.snapshot && entry.snapshot.top_clusters.length > 0 && ( +
+
TOP FACTIONS
+ {entry.snapshot.top_clusters.slice(0, 3).map((c) => ( +
+ {c.label} · {c.count} +
+ ))} +
+ )}
-
- )} + ))} +
+ )} + +
+ ) +} - - - {result.release_token && } - - {result.enclave_signature && ( - - )} -
- )} +function Stat({ label, value, sub }: { label: string; value: string; sub?: string }) { + return ( +
+
{label}
+
{value}
+
+ {sub &&
{sub}
}
) } -function DealStatusBadge({ - state, - settlement, +function Distribution({ + title, + subtitle, + rows, }: { - state: string - settlement: string + title: string + subtitle?: string + rows: { key: string; count: number }[] }) { - if (settlement === "authorized") - return Settled - if (state === "accepted") - return Accepted - if (state === "rejected") - return Rejected - if (["requested_by_buyer", "requested_by_seller", "awaiting_counterparty", "renegotiation_submitted"].includes(state)) - return Renegotiating - return Pending decision + const max = rows.reduce((m, r) => Math.max(m, r.count), 0) || 1 + return ( +
+
+
{title}
+ {subtitle &&
{subtitle}
} +
+ {rows.length === 0 ? ( +
No data yet.
+ ) : ( +
+ {rows.map((r) => ( +
+
+ {r.key} + {r.count} +
+
+
+
+
+ ))} +
+ )} +
+ ) } // --------------------------------------------------------------------------- -// Hackathon results table (unchanged) +// Submissions tab // --------------------------------------------------------------------------- -function ResultsTable({ results, display }: { results: NoveltyResult[]; display: DisplayMap }) { - const colFields = Object.entries(display).filter(([, h]) => h.type !== "score_table") - const colCount = 1 + colFields.length +function SubmissionsTab({ data }: { data: DashboardData }) { + const { submissions, results } = data + const resultsById = new Map(results.map((r) => [r.submission_id, r])) + + if (submissions.length === 0) { + return ( +
+ The lists are empty. Share the enclave URL with participants. +
+ ) + } return ( -
+
- - - - {colFields.map(([key, hint]) => ( - - ))} + + + + + + + + - - {results.map((r) => ( - - ))} + + {submissions.map((s) => { + const r = resultsById.get(s.submission_id) + return ( + + + + + + + + + ) + })}
Submission ID{hint.label}
SubmissionTitle / summaryNoveltyBest disciplineFactionSubmitted
+ {s.submission_id.slice(0, 12)}… + + {s.idea_title_or_summary || } + + {r ? : pending} + + {r?.best_fit_track ?? } + + {r?.cluster_label ? `${r.cluster_label} · ${r.cluster_size}` : } + + {s.submitted_at ? formatRelative(s.submitted_at) : "—"} +
) } -function HackathonResultRow({ - result, - colFields, - colCount, - display, -}: { - result: NoveltyResult - colFields: [string, import("@/lib/types").DisplayHint][] - colCount: number - display: DisplayMap -}) { - const [expanded, setExpanded] = React.useState(false) - const row = result as unknown as Record +function NoveltyBadge({ value }: { value: number }) { + const pct = Math.round(value * 100) + const tone = + pct >= 70 + ? { bg: "#4a6a3a", fg: "#f0ead8" } + : pct >= 40 + ? { bg: "#c08a3e", fg: "#2a2018" } + : { bg: "#8b2317", fg: "#f0ead8" } + return ( + + {pct}% + + ) +} +// --------------------------------------------------------------------------- +// Attestations tab — "Seals" +// --------------------------------------------------------------------------- + +function AttestationsTab({ data }: { data: DashboardData }) { + const { attestations } = data + if (attestations.length === 0) { + return ( +
+ No seals yet. They are affixed at end_date or when you click + "Affix the Seal" above. +
+ ) + } return ( - <> - setExpanded((v) => !v)} - > - {result.submission_id} - {colFields.map(([key, hint]) => ( - - - - ))} - - {expanded && ( - - - - {result.enclave_signature && ( -
- +
+ {attestations.slice().reverse().map((a) => ( +
+
+
+
+ {a.status === "published" ? ( + + ) : ( +
+ ◯ +
+ )} +
+
+ +
+ Affixed {formatRelative(a.published_at)} · {a.chain} +
+
+ {a.explorer_url && ( + + View on Solana Explorer → + )} - - - )} - +
+ +
+
REPORT HASH (SHA-256)
+
+ {a.report_hash} +
+
+ {a.tx_sig && ( +
+
TRANSACTION SIGNATURE
+
+ {a.tx_sig} +
+
+ )} + {a.pubkey && ( +
+
ENCLAVE PUBKEY
+
+ {a.pubkey} +
+
+ )} + {a.error && ( +
+ {a.error} +
+ )} +
+ ))} +
+ ) +} + +function StatusPill({ status }: { status?: string }) { + if (status === "published") { + return ( + + Sealed on-chain + + ) + } + if (status === "local_only") { + return ( + + Local only — Solana not configured + + ) + } + if (status === "failed") { + return ( + + Seal broken + + ) + } + return ( + + {status ?? "unknown"} + + ) +} + +// --------------------------------------------------------------------------- +// Token prompt +// --------------------------------------------------------------------------- + +function TokenPrompt({ instanceId, onSubmit }: { instanceId: string; onSubmit: (token: string) => void }) { + const [value, setValue] = React.useState("") + return ( +
+
{ + e.preventDefault() + if (value.trim()) onSubmit(value.trim()) + }} + className="max-w-md w-full space-y-5 paper-card p-8" + > +
+ +
RESTRICTED · IMPERIAL TOKEN
+

Present the seal

+

+ Paste the admin_token returned when you convened instance{" "} + {instanceId.slice(0, 12)}…. +

+
+ setValue(e.target.value)} + placeholder="admin token" + className="w-full rounded-sm border border-border bg-background px-3 py-2 text-sm font-mono focus:border-primary focus:outline-none" + autoFocus + /> + + + Back to the forum + +
+
) } + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function formatRelative(iso: string): string { + const t = new Date(iso).getTime() + const diff = Date.now() - t + if (Number.isNaN(diff)) return "—" + const sec = Math.round(diff / 1000) + if (sec < 60) return `${sec}s ago` + const min = Math.round(sec / 60) + if (min < 60) return `${min}m ago` + const hr = Math.round(min / 60) + if (hr < 24) return `${hr}h ago` + const d = Math.round(hr / 24) + return `${d}d ago` +} diff --git a/client/apps/web/app/i/[id]/page.tsx b/client/apps/web/app/i/[id]/page.tsx deleted file mode 100644 index 2f88dfe..0000000 --- a/client/apps/web/app/i/[id]/page.tsx +++ /dev/null @@ -1,964 +0,0 @@ -"use client" - -import * as React from "react" -import { use } from "react" -import { - Lock, - Check, - X, - GithubLogo, - FilePdf, - ArrowRight, - CircleNotch, - ShieldCheck, -} from "@phosphor-icons/react" -import { AttestationWidget } from "@/components/attestation-widget" -import { EnclaveSigBadge } from "@/components/enclave-sig-badge" -import { ResultDetail } from "@/components/result-renderer" -import { DatasetUploadCard } from "@/components/dataset-upload-card" -import { ProcurementScorecard } from "@/components/procurement-scorecard" -import { HardConstraintsCard } from "@/components/hard-constraints-card" -import { MilestoneBreakdown } from "@/components/milestone-breakdown" -import { NegotiationPanel } from "@/components/negotiation-panel" -import { ReleaseTokenCard } from "@/components/release-token-card" -import { api, ApiError } from "@/lib/api" -import type { - DisplayMap, - NoveltyResult, - ProcurementResult, - NegotiationStatus, - SellerClaim, - SubmitResponse, -} from "@/lib/types" -import { cn } from "@workspace/ui/lib/utils" -import { Suspense } from "react" - -type PageState = - | "login" - | "attest" - | "form" - | "pending" - | "results" - // Procurement-specific - | "uploading" - | "pending_evaluation" - | "evaluation_complete" - | "awaiting_negotiation" - | "released" - | "rejected" - -const TOKEN_CACHE_KEY = (instanceId: string) => `conclave_user_token_${instanceId}` - -function ParticipantContent({ id }: { id: string }) { - const [pageState, setPageState] = React.useState("login") - const [userToken, setUserToken] = React.useState("") - const [instanceMissing, setInstanceMissing] = React.useState(false) - const [isProcurement, setIsProcurement] = React.useState(false) - const [toast, setToast] = React.useState(null) - const [userIdentity, setUserIdentity] = React.useState(null) - - function showToast(msg: string) { - setToast(msg) - setTimeout(() => setToast(null), 4000) - } - - function handleAuthError(err: unknown) { - if (err instanceof ApiError && err.status === 403) { - localStorage.removeItem(TOKEN_CACHE_KEY(id)) - setUserToken("") - setPageState("login") - showToast("Your session has expired. Please log in again.") - } - } - - // --- OTP auth state --- - const [email, setEmail] = React.useState("") - const [otpCode, setOtpCode] = React.useState("") - const [otpSent, setOtpSent] = React.useState(false) - const [authLoading, setAuthLoading] = React.useState(false) - const [authError, setAuthError] = React.useState("") - - async function checkPriorSubmission(token: string, procurementMode: boolean) { - try { - const { submission_ids } = await api.getMySubmissions(token) - if (submission_ids.length === 0) { - setPageState("attest") - return - } - const sid = submission_ids[0]! - setSubmissionId(sid) - if (procurementMode) { - try { - const r = await api.getProcurementResult(token, sid) - setProcResult(r) - setPageState("evaluation_complete") - } catch { - setPageState("pending_evaluation") - } - } else { - try { - const r = await api.getOwnResult(token, sid) - setResult(r) - setPageState("results") - } catch { - const inst = await api.checkInstance(id) - setSubmitResponse({ - submission_id: sid, - status: "received_pending", - submissions_count: inst.submissions, - threshold: inst.threshold, - }) - setPageState("pending") - } - } - } catch (err) { - handleAuthError(err) - if (!(err instanceof ApiError && err.status === 403)) { - setPageState("attest") - } - } - } - - React.useEffect(() => { - const cached = localStorage.getItem(TOKEN_CACHE_KEY(id)) - - api.checkInstance(id).then(async (inst) => { - const proc = inst.skill_name === "confidential_data_procurement" - setIsProcurement(proc) - if (!proc && inst.skill_name) { - const card = await api.getSkill(inst.skill_name).catch(() => null) - if (card?.user_display) setSkillDisplay(card.user_display) - } - if (cached) { - setUserToken(cached) - await checkPriorSubmission(cached, proc) - } - }).catch(() => setInstanceMissing(true)) - - if (cached) { - return - } - import("@/lib/supabase").then(({ supabase }) => { - supabase.auth.getSession().then(async ({ data }) => { - const access_token = data.session?.access_token - if (!access_token) return - setAuthLoading(true) - const user = data.session?.user - if (user) { - const provider = user.app_metadata?.provider ?? "email" - if (provider === "github") { - setUserIdentity(`GitHub: ${user.user_metadata?.user_name ?? user.email}`) - } else if (provider === "google") { - setUserIdentity(`Google: ${user.email}`) - } else { - setUserIdentity(user.email ?? null) - } - } - try { - const { user_token } = await api.verifyToken(access_token, id) - saveToken(user_token) - const inst = await api.checkInstance(id) - const proc = inst.skill_name === "confidential_data_procurement" - if (!proc && inst.skill_name) { - const card = await api.getSkill(inst.skill_name).catch(() => null) - if (card?.user_display) setSkillDisplay(card.user_display) - } - await checkPriorSubmission(user_token, proc) - } catch (err) { - handleAuthError(err) - } - setAuthLoading(false) - }) - }) - }, [id]) - - function saveToken(token: string) { - setUserToken(token) - localStorage.setItem(TOKEN_CACHE_KEY(id), token) - } - - async function handleSendOtp() { - if (!email.trim()) return - setAuthLoading(true) - setAuthError("") - try { - await api.sendOtp(email.trim(), id) - setOtpSent(true) - } catch { - setAuthError("Failed to send OTP. Check the email and try again.") - } - setAuthLoading(false) - } - - async function handleVerifyOtp() { - if (!otpCode.trim()) return - setAuthLoading(true) - setAuthError("") - try { - const { user_token } = await api.verifyOtp(email.trim(), otpCode.trim(), id) - saveToken(user_token) - setUserIdentity(email.trim()) - setPageState("attest") - } catch { - setAuthError("Invalid or expired OTP. Try again.") - } - setAuthLoading(false) - } - - const [skillDisplay, setSkillDisplay] = React.useState({}) - - // Hackathon form state - const [ideaText, setIdeaText] = React.useState("") - const [repoUrl, setRepoUrl] = React.useState("") - const [repoSummary, setRepoSummary] = React.useState(null) - const [repoLoading, setRepoLoading] = React.useState(false) - const [githubConnected, setGithubConnected] = React.useState(false) - const [submitting, setSubmitting] = React.useState(false) - const [submitResponse, setSubmitResponse] = React.useState(null) - const [result, setResult] = React.useState(null) - const [submissionId, setSubmissionId] = React.useState("") - - // Procurement seller state - const [datasetName, setDatasetName] = React.useState("") - const [datasetReference, setDatasetReference] = React.useState("") - const [datasetFile, setDatasetFile] = React.useState(null) - const [metadataFile, setMetadataFile] = React.useState(null) - const [reservePrice, setReservePrice] = React.useState("") - const [sellerClaims, setSellerClaims] = React.useState([]) - const [sellerNote, setSellerNote] = React.useState("") - const [procResult, setProcResult] = React.useState(null) - - // Procurement polling — pending_evaluation: wait for first result - React.useEffect(() => { - if (pageState !== "pending_evaluation" || !userToken || !submissionId) return - const interval = setInterval(async () => { - try { - const r = await api.getProcurementResult(userToken, submissionId) - setProcResult(r) - setPageState("evaluation_complete") - clearInterval(interval) - } catch { - // Not ready yet - } - }, 8000) - return () => clearInterval(interval) - }, [pageState, userToken, submissionId]) - - // Procurement polling — evaluation_complete / awaiting_negotiation: refresh negotiation state - React.useEffect(() => { - if ( - (pageState !== "evaluation_complete" && pageState !== "awaiting_negotiation") || - !userToken || !submissionId - ) return - const interval = setInterval(async () => { - try { - const r = await api.getProcurementResult(userToken, submissionId) - setProcResult(r) - // Advance page state only on authorization (rejection is shown inline) - if (r.settlement.state === "authorized") setPageState("released") - else if (r.negotiation.state === "requested_by_buyer" && pageState !== "evaluation_complete") { - // Buyer responded — bring seller back to evaluation_complete to show action buttons - setPageState("evaluation_complete") - } - } catch { - // Ignore transient errors - } - }, 5000) - return () => clearInterval(interval) - }, [pageState, userToken, submissionId]) - - // Hackathon polling - React.useEffect(() => { - if (pageState !== "pending" || !submitResponse || !userToken) return - const interval = setInterval(async () => { - try { - const r = await api.getOwnResult(userToken, submissionId) - setResult(r) - setPageState("results") - clearInterval(interval) - } catch { - // Not ready yet - } - }, 8000) - return () => clearInterval(interval) - }, [pageState, submitResponse, userToken, submissionId]) - - async function fetchRepo() { - if (!repoUrl || !userToken) return - setRepoLoading(true) - try { - const r = await api.fetchRepo(userToken, repoUrl) - setRepoSummary(r.repo_summary) - } catch { - // ignore - } - setRepoLoading(false) - } - - async function handleHackathonSubmit() { - if (!ideaText.trim() || !userToken) return - setSubmitting(true) - const res = await api.submit(userToken, { - idea_text: ideaText, - repo_summary: repoSummary ?? "", - deck_text: "", - }) - setSubmissionId(res.submission_id) - setSubmitResponse(res) - setSubmitting(false) - setPageState("pending") - } - - async function handleDatasetSubmit() { - if (!datasetName.trim() || !reservePrice || !userToken || !datasetFile) return - setPageState("uploading") - try { - const res = await api.submitDataset( - userToken, - { - dataset_name: datasetName, - dataset_reference: datasetReference || undefined, - seller_claims: sellerClaims, - metadata: {}, - reserve_price: parseFloat(reservePrice.replace(/,/g, "")), - note: sellerNote || undefined, - }, - datasetFile, - metadataFile, - ) - setSubmissionId(res.submission_id) - setPageState("pending_evaluation") - } catch (err) { - handleAuthError(err) - if (!(err instanceof ApiError && err.status === 403)) { - showToast("Upload failed. Please check your file and try again.") - setPageState("form") - } - } - } - - async function handleAccept() { - if (!procResult || !userToken) return - await api.acceptDeal(userToken, procResult.submission_id) - const updated = await api.getProcurementResult(userToken, procResult.submission_id) - setProcResult(updated) - setPageState("released") - } - - async function handleReject() { - if (!procResult || !userToken) return - await api.rejectDeal(userToken, procResult.submission_id) - const updated = await api.getProcurementResult(userToken, procResult.submission_id) - setProcResult(updated) - setPageState("rejected") - } - - async function handleRenegotiate(revisedValue: number) { - if (!procResult || !userToken) return - await api.submitRenegotiation(userToken, procResult.submission_id, revisedValue) - const updated = await api.getProcurementResult(userToken, procResult.submission_id) - setProcResult(updated) - setPageState("awaiting_negotiation") - } - - async function handleLogout() { - const { supabase } = await import("@/lib/supabase") - await supabase.auth.signOut() - localStorage.removeItem(TOKEN_CACHE_KEY(id)) - setUserToken("") - setPageState("login") - setOtpSent(false) - setOtpCode("") - setEmail("") - } - - const canHackathonSubmit = ideaText.trim().length > 20 && !submitting - const canDatasetSubmit = - datasetName.trim().length > 0 && - reservePrice.trim().length > 0 && - !!datasetFile && - pageState === "form" - - if (instanceMissing) { - return ( -
-
-
- ⚠️ -
-

Instance not found

-

- This submission link is invalid or has expired. Ask the organizer for a fresh link. -

-
-
- ) - } - - const procurementSteps = [ - "Login", "Verify", "Submit", "Evaluating", "Result", - ] as const - const procurementStateOrder: PageState[] = [ - "login", "attest", "form", "pending_evaluation", "evaluation_complete", - ] - - const hackathonSteps = ["Login", "Verify", "Submit", "Wait", "Results"] as const - const hackathonStateOrder: PageState[] = ["login", "attest", "form", "pending", "results"] - - const steps = isProcurement ? procurementSteps : hackathonSteps - const stateOrder = isProcurement ? procurementStateOrder : hackathonStateOrder - - return ( -
- {toast && ( -
- - {toast} -
- )} - - {pageState !== "login" && ( -
- {userIdentity && ( - {userIdentity} - )} - -
- )} - -
- {/* Header */} -
-
- - {isProcurement ? "Accepting datasets" : "Accepting submissions"} -
-

- {isProcurement ? "Confidential Data Procurement" : "Hackathon Novelty Scoring"} -

-

- {isProcurement - ? "Submit your dataset for confidential evaluation. Raw rows never leave the enclave before agreement." - : "Submit your idea for anonymous novelty scoring. Your data stays inside the enclave."} -

-
- - {/* Progress steps — hide for terminal procurement states */} - {!["released", "rejected", "awaiting_negotiation"].includes(pageState) && ( -
- {steps.map((label, i) => { - const done = stateOrder.indexOf(pageState) > i - const active = stateOrder.indexOf(pageState) === i - return ( - -
- - {done ? "✓" : i + 1} - - {label} -
- {i < steps.length - 1 && } -
- ) - })} -
- )} - - {/* ── LOGIN ── */} - {pageState === "login" && ( -
- {authLoading ? ( -
- -
- ) : !otpSent ? ( - <> - - -
-
- or -
-
- setEmail(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && handleSendOtp()} - placeholder="you@example.com" - className="w-full rounded-xl border border-[#d2d2d7] bg-white px-4 py-2.5 text-sm text-[#1d1d1f] placeholder:text-[#aeaeb2] focus:outline-none focus:border-primary/50 focus:ring-1 focus:ring-primary/20 transition-all" - /> - {authError &&

{authError}

} - -

- We'll email you a 6-digit code. No password needed. -

- - ) : ( - <> -

- Code sent to {email} -

- setOtpCode(e.target.value.replace(/\D/g, "").slice(0, 6))} - onKeyDown={(e) => e.key === "Enter" && handleVerifyOtp()} - placeholder="123456" - className="w-full rounded-xl border border-[#d2d2d7] bg-white px-4 py-2.5 text-sm font-mono text-center tracking-widest text-[#1d1d1f] placeholder:text-[#aeaeb2] focus:outline-none focus:border-primary/50 focus:ring-1 focus:ring-primary/20 transition-all" - /> - {authError &&

{authError}

} - - - - )} -
- )} - - {/* ── ATTEST ── */} - {pageState === "attest" && ( -
- setPageState("form")} /> -

- The submission form unlocks after enclave verification. -

-
- )} - - {/* ── FORM — Hackathon ── */} - {pageState === "form" && !isProcurement && ( -
-
- Enclave verified -
-
-
- - {ideaText.length > 0 && ( - - Secured - - )} -
-