From cf034288e68d28f8751458379e2ab0808e1959c4 Mon Sep 17 00:00:00 2001 From: Marco0300 Date: Wed, 2 Sep 2026 18:12:21 +0200 Subject: [PATCH] add durable jobs and live job monitor --- README.md | 15 ++++- apps/api/README.md | 24 ++++++-- apps/api/app/main.py | 113 +++++++++++++++++++++++++++++++++++- apps/api/schema.sql | 34 +++++++++++ apps/api/tests/test_jobs.py | 82 ++++++++++++++++++++++++++ apps/web/README.md | 14 ++++- apps/web/app.js | 39 +++++++++++-- apps/web/index.html | 15 +++++ apps/web/smoke-test.html | 4 ++ apps/web/styles.css | 1 + docs/OPERATIONS.md | 14 ++++- docs/SECURITY.md | 5 ++ 12 files changed, 342 insertions(+), 18 deletions(-) create mode 100644 apps/api/tests/test_jobs.py diff --git a/README.md b/README.md index b5b9565..e35162d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Prospect Intelligence Platform -A safety-first Phase 3 vertical slice for **manual**, evidence-led prospect qualification. It stores tenant-owned businesses and their child intelligence records, keeps provenance with each evidence item, supports a review pipeline, and records operationally relevant changes. **Automated discovery, DNS/website scanning, and outreach are not part of this release. Automated outreach is disabled.** +A safety-first Phase 4 design/implementation boundary for **manual**, evidence-led prospect qualification and the future job/live-log workflow. The current runtime remains the Phase 3 manual vertical slice: it stores tenant-owned businesses and child intelligence records, keeps provenance with each evidence item, supports a review pipeline, and records operationally relevant changes. **Automated discovery, DNS/website scanning, and outreach are not part of this release. Automated outreach is disabled.** ## Included @@ -11,8 +11,9 @@ A safety-first Phase 3 vertical slice for **manual**, evidence-led prospect qual - Responsive static dashboard under `apps/web` with authenticated explorer filters, paginated results, detail review, manual intake, notes/pipeline context, evidence provenance, and browser-only CSV preview. - Docker Compose runtime with non-root containers, read-only filesystems, health checks, and a named SQLite data volume. - Browser authentication with server-side sessions and an optional first-run admin bootstrap. +- Phase 4 MVP job monitor and SQLite-backed job/event schema/API surface, with the production limitations documented below. -## Phase 3 workflow +## Current workflow and Phase 4 boundary 1. A permitted workspace member manually creates or reviews a prospect. 2. The business detail response is the aggregate record for that tenant; related intelligence/evidence rows are returned only through the tenant-scoped detail surface. @@ -22,6 +23,14 @@ A safety-first Phase 3 vertical slice for **manual**, evidence-led prospect qual The API applies the organization/tenant boundary server-side to list, detail, child-record, notes, pipeline, and audit reads and writes. Clients must use the returned pagination metadata and follow `next`/`previous` links or tokens rather than assuming that one response contains the whole tenant dataset. See `apps/api/README.md` for the route contract and limits. +### Phase 4 jobs/live logging contract + +The planned asynchronous contract is: create one tenant-scoped job, return a stable job identifier, and move it through `queued` → `running` → a terminal state (`succeeded`, `failed`, `cancelled`). Each accepted request should carry an idempotency key whose scope and request fingerprint prevent duplicate jobs while allowing a safe replay of the original result. A job should persist append-only events with a monotonically increasing per-job sequence number, timestamp, level/type, safe message, and job/tenant identifiers. + +Clients should poll a tenant-scoped job status/events endpoint using `after_sequence` (or an equivalent cursor), with bounded backoff and terminal-state handling. SSE is a planned low-latency delivery option, not a current implementation; polling remains the compatibility fallback. Cancellation and retry must be explicit, authorized controls: cancellation is cooperative and may finish as `cancelled` or report that the job is already terminal; retry creates a new attempt while retaining the original job/idempotency lineage and must not duplicate side effects. + +The current MVP has SQLite job/event persistence, job status/list/detail and event APIs, cancellation/retry controls, and a browser job monitor that polls while work is active. SSE is not implemented; it remains a future delivery optimization over the persisted cursor. There is no Redis/Celery worker: the current in-process worker is suitable only for development/pilot use and must not be treated as durable, horizontally scalable execution. + ## Run locally ```bash @@ -69,7 +78,7 @@ Authenticated browser requests use a server-side session cookie; login creates a ## Explicit non-goals and remaining limitations -This Phase 3 release still has no automated discovery, DNS resolution, website/HTTP scanning, enrichment scheduler, external source adapter, email/SMS sender, or outreach endpoint. CSV remains a browser/API preview flow and does not silently persist rows. SQLite and the named local volume are suitable for the pilot only; there is no production migration runner, queue, or tested backup/restore command. The development password fallback is PBKDF2 rather than production Argon2id. Before production, complete the gates in `docs/SECURITY.md` and `docs/OPERATIONS.md`, including MFA, TLS, CSRF protection, rate limiting, durable audit retention, migrations, approved source policy, SSRF-safe fetching if a future scanner is approved, and tested backups/restores. +This Phase 4 boundary still has no automated discovery, DNS resolution, website/HTTP scanning, enrichment scheduler, external source adapter, email/SMS sender, or outreach endpoint. CSV remains a browser/API preview flow and does not silently persist rows. SQLite, the in-process worker, and the named local volume are suitable for the pilot only; production migration, durable queue/worker leases, event retention/backup, SSE delivery, and tested backup/restore remain unfinished. Redis and Celery are not implemented. The development password fallback is PBKDF2 rather than production Argon2id. Before production, complete the gates in `docs/SECURITY.md` and `docs/OPERATIONS.md`, including MFA, TLS, CSRF protection, rate limiting, tenant-scoped job/event authorization, idempotent side-effect handling, durable audit/event retention, migrations, approved source policy, SSRF-safe fetching if a future scanner is approved, and tested backups/restores. ## Verification diff --git a/apps/api/README.md b/apps/api/README.md index 1192e92..f2e895e 100644 --- a/apps/api/README.md +++ b/apps/api/README.md @@ -1,6 +1,6 @@ -# Prospect Platform API — Phase 3 +# Prospect Platform API — Phase 4 boundary -Dependency-light JSON API for tenant-scoped, **manual** prospect workflows. Core domain rules use Python's standard library and persistence is SQLite. The API stores businesses plus child intelligence/evidence records, pipeline state, notes, and audit context. It never performs automated discovery, DNS/website scanning, or outreach. +Dependency-light JSON API for tenant-scoped, **manual** prospect workflows. Core domain rules use Python's standard library and persistence is SQLite. The API stores businesses plus child intelligence/evidence records, pipeline state, notes, and audit context, with the Phase 4 job/live-log contract described below. It never performs automated discovery, DNS/website scanning, or outreach. ## Run @@ -18,6 +18,22 @@ Set `PROSPECT_API_PORT` or pass `--port`; set `PROSPECT_API_DB` or pass `--db` t All protected endpoints require the server-side session cookie. Every query is constrained by the authenticated user's `organization_id`; IDs from another tenant behave as not found and must not disclose whether a record exists. +## Phase 4 jobs and live logging (target contract) + +The intended job resource has a stable `job_id`, tenant/creator metadata, operation/payload fingerprint, `status`, `attempt`, timestamps, cancellation state, and terminal error/result metadata. Its lifecycle is `queued` → `running` → exactly one terminal state: `succeeded`, `failed`, or `cancelled`. State transitions and worker messages must be persisted transactionally with tenant and job identifiers; terminal jobs are immutable except for controlled retention/redaction. + +### Idempotency and events + +Mutating job creation should require an idempotency key (for example, an `Idempotency-Key` header). The key must be scoped to the authenticated tenant and operation, stored with a request fingerprint, and return the original job/result for an exact replay. Reuse with a different payload must be rejected rather than creating a second job. Idempotency must cover side effects, not merely the HTTP response. + +Live events should be append-only and ordered per job with a durable integer `sequence`/cursor. A consumer can resume from the last acknowledged sequence, tolerate duplicate delivery, and detect gaps. Events must contain only safe operational detail; do not persist passwords, session cookies, API keys, or unnecessary prospect/contact data. + +### Control and delivery semantics + +The MVP job routes are `POST /api/v1/jobs`, `GET /api/v1/jobs`, `GET /api/v1/jobs/{id}`, `GET /api/v1/jobs/{id}/events`, `POST /api/v1/jobs/{id}/cancel`, and `POST /api/v1/jobs/{id}/retry`. They are tenant-scoped and backed by SQLite persistence. Cancellation is best effort and must be race-safe with workers; retry creates a new attempt/lineage and must not repeat completed side effects. Polling supports a bounded cursor and backoff in the web monitor. An SSE endpoint may stream the same persisted sequence events with `Last-Event-ID`, heartbeats, disconnect/reconnect support, and polling fallback, but SSE is not implemented in this MVP. + +The current runtime has no durable job queue and no Redis/Celery integration. Its in-process/SQLite worker is single-instance, non-durable across process loss, and unsuitable for horizontal scaling or guaranteed execution; it is pilot-only. + ### Health and workspace - `GET /api/v1/health/live` — unauthenticated liveness check. @@ -58,6 +74,6 @@ All SQL uses parameters and all responses are JSON. Scores include `score_versio List and child-record endpoints are deliberately bounded. For business lists, use `page` (starting at 1) and `page_size` within the server-enforced maximum; invalid values are rejected rather than allowing an unbounded query. Supported filters are applied inside the tenant-scoped query before pagination: `q`, `score_min`, `score_max`, `website_class`, and `pipeline_stage`. The UI's page and filter controls are convenience clients, not authorization controls. A filtered page is not a count of the entire unfiltered tenant unless the response explicitly says so. -## Remaining limitations +## Remaining limitations and production migration work -SQLite is a pilot store with no production migration runner, queue, scheduler, durable backup command, or tested restore workflow. Authentication currently uses a development password fallback and does not by itself provide production Argon2id, MFA, CSRF protection, rate limiting, or a complete retention-grade audit system. Automated discovery, DNS/HTTP scanning, and outreach remain explicitly out of scope. +SQLite is a pilot store with an MVP job/event schema but no production migration runner, durable queue, scheduler, worker lease/recovery, durable backup command, or tested restore workflow. Authentication currently uses a development password fallback and does not by itself provide production Argon2id, MFA, CSRF protection, rate limiting, or a complete retention-grade audit system. Automated discovery, DNS/HTTP scanning, and outreach remain explicitly out of scope. Production migration work must add schema/index hardening and retention policy, tenant-scoped authorization tests, transactional event sequencing, cancellation/retry semantics, observability, and a reviewed Postgres plus durable queue/worker design. Redis/Celery remain future options, not implemented dependencies. diff --git a/apps/api/app/main.py b/apps/api/app/main.py index d7dbeca..e9868e6 100644 --- a/apps/api/app/main.py +++ b/apps/api/app/main.py @@ -1,5 +1,5 @@ from __future__ import annotations -import argparse, hashlib, json, os, re, secrets, sqlite3, sys +import argparse, hashlib, json, os, re, secrets, sqlite3, sys, threading, time from datetime import datetime, timedelta, timezone from http.cookies import SimpleCookie from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer @@ -15,8 +15,23 @@ SCHEMA = Path(__file__).resolve().parents[1] / "schema.sql" SESSION_DAYS = 7 PBKDF2_ITERATIONS = 300_000 MUTATING_ROLES = {"owner", "admin", "researcher"} +JOB_TYPES = {"noop", "prospect_recalculate"} +JOB_PAGE_SIZE = 100 +SECRET_KEYS = {"password", "passwd", "secret", "token", "api_key", "apikey", "authorization", "credential", "private_key"} CHILD_TABLES = {"contacts": ("name", "email", "phone", "title", "do_not_contact"), "domains": ("domain", "kind"), "websites": ("url", "website_class"), "evidence": ("kind", "url", "claim"), "notes": ("body",)} +def redact(value): + if isinstance(value, dict): return {k: ("[REDACTED]" if str(k).lower() in SECRET_KEYS or any(s in str(k).lower() for s in ("password", "token", "secret", "api_key")) else redact(v)) for k,v in value.items()} + if isinstance(value, list): return [redact(v) for v in value[:100]] + if isinstance(value, str): return value[:2000] + return value + +def job_json(row): + result = row_json(row) + try: result["payload"] = json.loads(result.get("payload") or "{}") + except (ValueError, TypeError): result["payload"] = {} + return result + def hash_password(password: str, salt: bytes | None = None) -> tuple[str, str]: salt = salt or secrets.token_bytes(16); return hashlib.pbkdf2_hmac("sha256", password.encode(), salt, PBKDF2_ITERATIONS).hex(), salt.hex() def verify_password(password, encoded_hash, encoded_salt): @@ -95,6 +110,8 @@ class ApiHandler(BaseHTTPRequestHandler): if path=="/api/v1/dashboard/summary": row=db.execute("SELECT COUNT(*) businesses,COALESCE(AVG(score),0) average_score FROM businesses WHERE organization_id=?",(org,)).fetchone(); return self.send_json(200,{"organization_id":org,"businesses":row["businesses"],"average_score":round(row["average_score"],2),"suppressed":db.execute("SELECT COUNT(*) FROM suppressions WHERE organization_id=?",(org,)).fetchone()[0]}) if path=="/api/v1/businesses": return self.list_businesses(db,org,parse_qs(parsed.query)) + if path=="/api/v1/jobs": return self.list_jobs(db,org,parse_qs(parsed.query)) + if path.startswith("/api/v1/jobs/"): return self.get_job_route(db,org,path,parse_qs(parsed.query)) if path.startswith("/api/v1/businesses/"): bits=path.split("/"); ident=bits[4] if len(bits)>4 else "" if not ident.isdigit(): return self.send_json(404,{"error":"not_found"}) @@ -103,6 +120,59 @@ class ApiHandler(BaseHTTPRequestHandler): payload=row_json(row); payload.update(self.nested(db,int(ident),org)); return self.send_json(200,payload) return self.send_json(404,{"error":"not_found"}) finally: db.close() + def list_jobs(self, db, org, query): + try: limit=max(1,min(int(query.get("page_size",[50])[0]),JOB_PAGE_SIZE)); offset=max(0,int(query.get("offset",[0])[0])) + except (ValueError, TypeError): return self.send_json(400,{"error":"invalid_pagination"}) + rows=db.execute("SELECT * FROM jobs WHERE organization_id=? ORDER BY id DESC LIMIT ? OFFSET ?",(org,limit+1,offset)).fetchall(); more=len(rows)>limit + return self.send_json(200,{"organization_id":org,"items":[job_json(r) for r in rows[:limit]],"limit":limit,"offset":offset,"has_more":more}) + + def get_job_route(self, db, org, path, query): + bits=path.split("/") + if len(bits)<5 or not bits[4].isdigit(): return self.send_json(404,{"error":"not_found"}) + job=db.execute("SELECT * FROM jobs WHERE id=? AND organization_id=?",(int(bits[4]),org)).fetchone() + if not job:return self.send_json(404,{"error":"not_found"}) + if len(bits)==5:return self.send_json(200,job_json(job)) + if len(bits)==6 and bits[5]=="events": + try: after=max(0,int(query.get("after",[0])[0])) + except (ValueError,TypeError): return self.send_json(400,{"error":"invalid_sequence"}) + events=[row_json(r) for r in db.execute("SELECT * FROM job_events WHERE job_id=? AND organization_id=? AND sequence>? ORDER BY sequence",(job["id"],org,after))] + return self.send_json(200,{"items":events,"after":after}) + if len(bits)==7 and bits[5]=="events" and bits[6]=="stream": + try: after=max(0,int(query.get("after",[0])[0])) + except (ValueError,TypeError): return self.send_json(400,{"error":"invalid_sequence"}) + events=[row_json(r) for r in db.execute("SELECT * FROM job_events WHERE job_id=? AND organization_id=? AND sequence>? ORDER BY sequence",(job["id"],org,after))] + body=b"".join((b"event: "+str(e["event_type"]).encode()+b"\\ndata: "+json.dumps(e,sort_keys=True).encode()+b"\\n\\n") for e in events) + self.send_response(200);self.send_header("Content-Type","text/event-stream");self.send_header("Cache-Control","no-cache");self.send_header("Content-Length",str(len(body)));self.end_headers();self.wfile.write(body);return + return self.send_json(404,{"error":"not_found"}) + + def create_job(self, payload, db, user): + kind=str(payload.get("type","")).strip(); key=str(payload.get("idempotency_key","")).strip(); data=payload.get("payload",{}) + if kind not in JOB_TYPES:return self.send_json(400,{"error":"invalid_job_type"}) + if not key or len(key)>200 or not isinstance(data,dict):return self.send_json(400,{"error":"invalid_job_request"}) + safe=json.dumps(redact(data),sort_keys=True,separators=(",",":")); max_attempts=max(1,min(int(payload.get("max_attempts",3)),5)) if str(payload.get("max_attempts",3)).isdigit() else 3 + try: + cur=db.execute("INSERT INTO jobs(organization_id,idempotency_key,type,payload,max_attempts) VALUES(?,?,?,?,?)",(user["organization_id"],key,kind,safe,max_attempts)); jid=cur.lastrowid + self.add_job_event(db,jid,user["organization_id"],"queued","Job queued",0); self.audit(db,user,"job.created",str(jid)); db.commit(); getattr(self.server,"job_wakeup",threading.Event()).set() + return self.send_json(201,job_json(db.execute("SELECT * FROM jobs WHERE id=?",(jid,)).fetchone())) + except sqlite3.IntegrityError: + row=db.execute("SELECT * FROM jobs WHERE organization_id=? AND idempotency_key=?",(user["organization_id"],key)).fetchone(); return self.send_json(200,job_json(row)) + + def add_job_event(self, db, jid, org, event_type, message, progress=0, error_code=None): + seq=db.execute("SELECT COALESCE(MAX(sequence),0)+1 FROM job_events WHERE job_id=?",(jid,)).fetchone()[0] + db.execute("INSERT INTO job_events(job_id,organization_id,sequence,event_type,message,progress,error_code) VALUES(?,?,?,?,?,?,?)",(jid,org,seq,event_type,str(message)[:500],progress,error_code)); return seq + + def job_action(self, db, user, path): + bits=path.split("/"); jid=int(bits[4]) if len(bits)>4 and bits[4].isdigit() else -1; action=bits[5] if len(bits)>5 else "" + job=db.execute("SELECT * FROM jobs WHERE id=? AND organization_id=?",(jid,user["organization_id"])).fetchone() + if not job:return self.send_json(404,{"error":"not_found"}) + if action=="cancel": + if job["status"] in ("queued","running"): db.execute("UPDATE jobs SET status='cancelled',completed_at=CURRENT_TIMESTAMP,updated_at=CURRENT_TIMESTAMP WHERE id=?",(jid,));self.add_job_event(db,jid,user["organization_id"],"cancelled","Job cancelled",job["progress"]) + self.audit(db,user,"job.cancelled",str(jid));db.commit();return self.send_json(200,job_json(db.execute("SELECT * FROM jobs WHERE id=?",(jid,)).fetchone())) + if action=="retry": + if job["status"]!="failed":return self.send_json(409,{"error":"job_not_failed"}) + db.execute("UPDATE jobs SET status='queued',error_code=NULL,completed_at=NULL,updated_at=CURRENT_TIMESTAMP WHERE id=?",(jid,));self.add_job_event(db,jid,user["organization_id"],"retry","Job retry queued",job["progress"]);self.audit(db,user,"job.retried",str(jid));db.commit();getattr(self.server,"job_wakeup",threading.Event()).set();return self.send_json(200,job_json(db.execute("SELECT * FROM jobs WHERE id=?",(jid,)).fetchone())) + return self.send_json(404,{"error":"not_found"}) + def list_businesses(self,db,org,query): def number(name, default=None): raw=query.get(name,[None])[0] @@ -132,8 +202,13 @@ class ApiHandler(BaseHTTPRequestHandler): c=SimpleCookie();c.load(self.headers.get("Cookie",""));t=c.get("session"); if t:db.execute("DELETE FROM sessions WHERE token_hash=?",(hashlib.sha256(t.value.encode()).hexdigest(),)) self.audit(db,user,"logout");db.commit();return self.send_json(200,{"ok":True},{"Set-Cookie":self.auth_cookie("",0)}) + if path=="/api/v1/jobs": + if user["role"] not in MUTATING_ROLES:return self.send_json(403,{"error":"forbidden"}) + return self.create_job(self.read_json(),db,user) if user["role"] not in MUTATING_ROLES:return self.send_json(403,{"error":"forbidden"}) payload=self.read_json(); org=user["organization_id"] + if path.startswith("/api/v1/jobs/"): + return self.job_action(db,user,path) if path=="/api/v1/businesses":return self.create_business(payload,db,user) if path=="/api/v1/suppressions":return self.create_suppression(payload,db,user) if path=="/api/v1/imports/preview":return self.preview_import(payload,db,org) @@ -213,8 +288,42 @@ class ApiHandler(BaseHTTPRequestHandler): return self.send_json(200,{"accepted":len(accepted),"duplicates":len(rows)-len(normalized)+len(normalized)-len(accepted)-suppressed,"suppressed":suppressed,"rows":accepted}) def log_message(self,*_):pass +def _job_worker(server): + while not server.job_stop.is_set(): + db=connect(server.db_path) + try: + job=db.execute("SELECT * FROM jobs WHERE status='queued' ORDER BY id LIMIT 1").fetchone() + if not job: + db.close(); server.job_wakeup.wait(.1); server.job_wakeup.clear(); continue + changed=db.execute("UPDATE jobs SET status='running',attempts=attempts+1,started_at=COALESCE(started_at,CURRENT_TIMESTAMP),updated_at=CURRENT_TIMESTAMP WHERE id=? AND status='queued'",(job["id"],)).rowcount + if not changed: db.close(); continue + db.commit(); org=job["organization_id"]; jid=job["id"]; server_handler=object.__new__(ApiHandler) + server_handler.add_job_event(db,jid,org,"started","Job started",0); db.commit() + try: payload=json.loads(job["payload"] or "{}") + except ValueError: payload={} + try: steps=1 if job["type"]=="noop" else max(1,min(int(payload.get("steps",5)),20)) + except (ValueError,TypeError): steps=5 + cancelled=False + for i in range(steps): + time.sleep(.01) + fresh=db.execute("SELECT status FROM jobs WHERE id=?",(jid,)).fetchone() + if not fresh or fresh["status"]=="cancelled": cancelled=True; break + progress=int((i+1)*100/steps); db.execute("UPDATE jobs SET progress=?,updated_at=CURRENT_TIMESTAMP WHERE id=? AND status='running'",(progress,jid)); server_handler.add_job_event(db,jid,org,"progress",f"Job progress {progress}%",progress); db.commit() + if cancelled: continue + if payload.get("force_fail") or (payload.get("fail_once") and job["attempts"] == 0): + db.execute("UPDATE jobs SET status='failed',error_code='DEMO_FAILURE',completed_at=CURRENT_TIMESTAMP,updated_at=CURRENT_TIMESTAMP WHERE id=?",(jid,));server_handler.add_job_event(db,jid,org,"failed","Job failed",job["progress"],"DEMO_FAILURE") + else: + db.execute("UPDATE jobs SET status='succeeded',progress=100,completed_at=CURRENT_TIMESTAMP,updated_at=CURRENT_TIMESTAMP WHERE id=?",(jid,));server_handler.add_job_event(db,jid,org,"succeeded","Job completed",100) + db.commit() + finally: db.close() + def create_server(host="127.0.0.1",port=8000,db_path="prospects.db"): - server=ThreadingHTTPServer((host,port),ApiHandler);server.db_path=db_path;connect(db_path).close();return server + server=ThreadingHTTPServer((host,port),ApiHandler);server.db_path=db_path;connect(db_path).close();server.job_stop=threading.Event();server.job_wakeup=threading.Event();server.job_thread=threading.Thread(target=_job_worker,args=(server,),daemon=True);server.job_thread.start() + original_close=server.server_close + def close(): + server.job_stop.set();server.job_wakeup.set();server.job_thread.join(timeout=2);original_close() + server.server_close=close + return server if __name__=="__main__": parser=argparse.ArgumentParser();parser.add_argument("--host",default="127.0.0.1");parser.add_argument("--port",type=int,default=int(os.environ.get("PROSPECT_API_PORT","8000")));parser.add_argument("--db",default=os.environ.get("PROSPECT_API_DB","prospects.db"));args=parser.parse_args();server=create_server(args.host,args.port,args.db);print(f"Prospect API listening on http://{args.host}:{args.port}",flush=True) try:server.serve_forever() diff --git a/apps/api/schema.sql b/apps/api/schema.sql index 5844a23..956004e 100644 --- a/apps/api/schema.sql +++ b/apps/api/schema.sql @@ -80,3 +80,37 @@ CREATE INDEX IF NOT EXISTS idx_pipeline_business ON pipeline_entries(business_id CREATE INDEX IF NOT EXISTS idx_pipeline_stage ON pipeline_entries(organization_id,stage); CREATE INDEX IF NOT EXISTS idx_notes_business ON notes(business_id,created_at); CREATE INDEX IF NOT EXISTS idx_interactions_business ON interactions(business_id,created_at); + +-- Phase 4 durable background jobs (additive-safe for existing databases). +CREATE TABLE IF NOT EXISTS jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + organization_id TEXT NOT NULL REFERENCES organizations(id), + idempotency_key TEXT NOT NULL, + type TEXT NOT NULL, + payload TEXT NOT NULL DEFAULT '{}', + status TEXT NOT NULL DEFAULT 'queued' CHECK(status IN ('queued','running','succeeded','failed','cancelled')), + attempts INTEGER NOT NULL DEFAULT 0, + max_attempts INTEGER NOT NULL DEFAULT 3, + progress INTEGER NOT NULL DEFAULT 0 CHECK(progress BETWEEN 0 AND 100), + error_code TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + started_at TEXT, + completed_at TEXT, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(organization_id,idempotency_key) +); +CREATE INDEX IF NOT EXISTS idx_jobs_org_created ON jobs(organization_id,created_at DESC,id DESC); +CREATE INDEX IF NOT EXISTS idx_jobs_queue ON jobs(status,created_at,id); +CREATE TABLE IF NOT EXISTS job_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + job_id INTEGER NOT NULL REFERENCES jobs(id) ON DELETE CASCADE, + organization_id TEXT NOT NULL REFERENCES organizations(id), + sequence INTEGER NOT NULL, + event_type TEXT NOT NULL, + message TEXT NOT NULL DEFAULT '', + progress INTEGER NOT NULL DEFAULT 0 CHECK(progress BETWEEN 0 AND 100), + error_code TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(job_id,sequence) +); +CREATE INDEX IF NOT EXISTS idx_job_events_job_sequence ON job_events(job_id,sequence); diff --git a/apps/api/tests/test_jobs.py b/apps/api/tests/test_jobs.py new file mode 100644 index 0000000..89efcf6 --- /dev/null +++ b/apps/api/tests/test_jobs.py @@ -0,0 +1,82 @@ +import json +import os +import sqlite3 +import threading +import time +import unittest +from http.client import HTTPConnection +from tempfile import TemporaryDirectory + +from app.main import create_server, hash_password + + +class JobApiTests(unittest.TestCase): + def setUp(self): + self.tmp = TemporaryDirectory() + os.environ["BOOTSTRAP_ADMIN_EMAIL"] = "owner@example.test" + os.environ["BOOTSTRAP_ADMIN_PASSWORD"] = "development-password" + self.db_path = self.tmp.name + "/test.db" + self.server = create_server("127.0.0.1", 0, self.db_path) + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True); self.thread.start() + self.conn = HTTPConnection("127.0.0.1", self.server.server_port, timeout=3); self.cookie = None + self.request("POST", "/api/v1/auth/login", {"email":"owner@example.test", "password":"development-password"}) + + def tearDown(self): + self.server.shutdown(); self.server.server_close(); self.thread.join(timeout=2); self.tmp.cleanup() + + def request(self, method, path, payload=None): + body = json.dumps(payload).encode() if payload is not None else None + headers = {"Content-Type":"application/json"} if body else {} + if self.cookie: headers["Cookie"] = self.cookie + self.conn.request(method, path, body, headers); r = self.conn.getresponse() + c = r.getheader("Set-Cookie") + if c: self.cookie = c.split(";", 1)[0] + raw = r.read() + return r.status, (json.loads(raw) if raw and "json" in (r.getheader("Content-Type") or "") else raw.decode()) + + def test_create_is_idempotent_and_completes_with_sequence_events(self): + status, first = self.request("POST", "/api/v1/jobs", {"type":"noop", "payload":{"password":"hidden"}, "idempotency_key":"abc"}) + self.assertEqual(status, 201); self.assertEqual(first["status"], "queued") + self.assertNotIn("hidden", json.dumps(first)) + self.assertEqual(self.request("POST", "/api/v1/jobs", {"type":"noop", "payload":{"other":1}, "idempotency_key":"abc"})[1]["id"], first["id"]) + for _ in range(30): + status, job = self.request("GET", f"/api/v1/jobs/{first['id']}") + if job["status"] == "succeeded": break + time.sleep(.03) + self.assertEqual(job["status"], "succeeded") + status, events = self.request("GET", f"/api/v1/jobs/{first['id']}/events") + self.assertEqual(status, 200); seq = [e["sequence"] for e in events["items"]] + self.assertEqual(seq, list(range(1, len(seq)+1))); self.assertNotIn("hidden", json.dumps(events)) + + def test_cancel_retry_list_and_tenant_isolation(self): + _, job = self.request("POST", "/api/v1/jobs", {"type":"prospect_recalculate", "payload":{"steps":50}, "idempotency_key":"cancel-me"}) + status, cancelled = self.request("POST", f"/api/v1/jobs/{job['id']}/cancel"); self.assertEqual(status, 200) + self.assertIn(cancelled["status"], ("cancelled", "running")) + status, listing = self.request("GET", "/api/v1/jobs?page_size=2"); self.assertEqual(status, 200); self.assertLessEqual(len(listing["items"]), 2) + ph, salt = hash_password("other-password"); db = sqlite3.connect(self.db_path); db.execute("INSERT INTO organizations VALUES ('other-tenant','Other',CURRENT_TIMESTAMP)"); db.execute("INSERT INTO users (organization_id,email,password_hash,password_salt,role) VALUES (?,?,?,?,?)", ("other-tenant","other@example.test",ph,salt,"owner")); db.commit(); db.close() + self.cookie = None; self.request("POST", "/api/v1/auth/login", {"email":"other@example.test", "password":"other-password"}) + self.assertEqual(self.request("GET", f"/api/v1/jobs/{job['id']}")[0], 404) + + def test_failed_job_can_be_requeued_with_retry(self): + _, job = self.request("POST", "/api/v1/jobs", {"type":"noop", "payload":{"fail_once":True}, "idempotency_key":"retry-me"}) + for _ in range(30): + _, current = self.request("GET", f"/api/v1/jobs/{job['id']}") + if current["status"] == "failed": break + time.sleep(.03) + self.assertEqual(current["status"], "failed") + self.assertEqual(self.request("POST", f"/api/v1/jobs/{job['id']}/retry")[0], 200) + for _ in range(30): + _, current = self.request("GET", f"/api/v1/jobs/{job['id']}") + if current["status"] == "succeeded": break + time.sleep(.03) + self.assertEqual(current["status"], "succeeded") + + ph, salt = hash_password("viewer-password"); db = sqlite3.connect(self.db_path); db.execute("INSERT INTO users (organization_id,email,password_hash,password_salt,role) VALUES (?,?,?,?,?)", ("demo-tenant","viewer@example.test",ph,salt,"viewer")); db.commit(); db.close() + self.cookie = None; self.request("POST", "/api/v1/auth/login", {"email":"viewer@example.test", "password":"viewer-password"}) + self.assertEqual(self.request("POST", "/api/v1/jobs", {"type":"noop", "idempotency_key":"no"})[0], 403) + self.cookie = None; self.request("POST", "/api/v1/auth/login", {"email":"owner@example.test", "password":"development-password"}) + _, job = self.request("POST", "/api/v1/jobs", {"type":"noop", "idempotency_key":"audit"}) + db = sqlite3.connect(self.db_path); self.assertTrue(db.execute("SELECT 1 FROM audit_log WHERE action='job.created'").fetchone()); db.close() + + +if __name__ == "__main__": unittest.main() diff --git a/apps/web/README.md b/apps/web/README.md index af7f3ba..18d2265 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -1,6 +1,6 @@ -# ProspectOS web — Phase 3 +# ProspectOS web — Phase 4 boundary -Self-contained static frontend for the Prospect Platform API. There is no bundler or runtime dependency: serve this directory with any static HTTP server. The UI supports a manual, tenant-scoped review workflow; it does not discover prospects, scan DNS/websites, or send outreach. +Self-contained static frontend for the Prospect Platform API. There is no bundler or runtime dependency: serve this directory with any static HTTP server. The UI supports a manual, tenant-scoped review workflow and a Phase 4 MVP job monitor; it does not discover prospects, scan DNS/websites, or send outreach. ## Configure and run @@ -24,6 +24,14 @@ If not set, the UI uses `localStorage.prospect_api_base` when present; otherwise The API remains the source of truth for tenant isolation, pagination bounds, filters, pipeline transitions, notes, audit records, and suppression. See `apps/api/README.md` for the route contract. +## Phase 4 job/live-log UI contract + +A future job view should show `queued`, `running`, `succeeded`, `failed`, or `cancelled`, the current attempt, timestamps, safe error text, and a clear terminal state. It should display persisted events in sequence order, resume from the last cursor after refresh/reconnect, and tolerate duplicate events. Create/retry requests should send an idempotency key and show the returned job identity rather than starting duplicate work. + +The preferred live path is SSE backed by the persisted event cursor; polling with bounded backoff is the required fallback and should be used for browsers/proxies that do not support SSE. Cancel is a cooperative action with an explicit pending/terminal result; retry is available only when the API authorizes it and must be presented as a new attempt/lineage. The UI must never infer progress from timers or claim work completed because a request was accepted. + +The current client renders job status/counts, detail, structured errors, progress, and event timelines, and polls the jobs collection while queued/running work exists. It exposes authorized cancel/retry affordances based on the API response. There is no SSE client yet; polling is the current fallback and should remain available after SSE is introduced. The backend's SQLite/in-process worker is MVP-only. Do not add a Redis/Celery dependency by implication or label that worker production-ready. + ## Browser verification 1. Start the API from `apps/api` with `python3 app/main.py`. @@ -40,4 +48,4 @@ A zero-dependency static smoke page (`smoke-test.html`) checks the key DOM contr ## Remaining limitations -The static client has no background discovery, DNS/website scanner, enrichment scheduler, or outreach integration. It cannot make missing provenance authoritative and should display API-provided limitations rather than infer them. CSV preview is capped for display and is not an import workflow. Production deployment still requires the security and operations gates in `docs/SECURITY.md` and `docs/OPERATIONS.md`. +The static client has no background discovery, DNS/website scanner, enrichment scheduler, outreach integration, or SSE delivery. It cannot make missing provenance authoritative and should display API-provided limitations rather than infer them. CSV preview is capped for display and is not an import workflow. Production deployment still requires durable job execution, tenant-scoped controls, idempotency verification, SSE/polling verification, and the security and operations gates in `docs/SECURITY.md` and `docs/OPERATIONS.md`. diff --git a/apps/web/app.js b/apps/web/app.js index 110b145..4d517ac 100644 --- a/apps/web/app.js +++ b/apps/web/app.js @@ -15,7 +15,7 @@ async function request(path, options = {}) { const response=await fetch(endpoint(path),{...options,credentials:'include'}); if(response.status===401){showLogin('Your session has expired. Please sign in again.');throw new Error('unauthorized');} return response; } async function jsonRequest(path, options = {}) { const res=await request(path,options); const body=await res.json().catch(()=>({})); if(!res.ok)throw new Error(body.error||body.message||'Request failed'); return body; } function showLogin(message=''){currentUser=null;$('dashboardShell').hidden=true;$('loginScreen').hidden=false;$('loginMessage').textContent=message;$('loginMessage').className=`form-message${message?' error':''}`;} - function showDashboard(user){currentUser=user||{};const name=currentUser.name||currentUser.full_name||currentUser.email||'Workspace member';const role=currentUser.role||currentUser.roles?.[0]||'Member';$('userIdentity').textContent=`${name} · ${role}`;$('userAvatar').textContent=name.split(/\s+/).map(x=>x[0]).join('').slice(0,2).toUpperCase();$('loginScreen').hidden=true;$('dashboardShell').hidden=false;} + function showDashboard(user){currentUser=user||{};const name=currentUser.name||currentUser.full_name||currentUser.email||'Workspace member';const role=currentUser.role||currentUser.roles?.[0]||'Member';$('userIdentity').textContent=`${name} · ${role}`;$('userAvatar').textContent=name.split(/\s+/).map(x=>x[0]).join('').slice(0,2).toUpperCase();$('loginScreen').hidden=true;$('dashboardShell').hidden=false;updateJobPermissions();} function renderMetrics(summary){const total=Number(summary?.businesses??summary?.total??prospects.length),high=prospects.filter(p=>scoreFor(p)>=80).length,review=prospects.filter(p=>statusOf(p)==='review').length,fresh=prospects.length?Math.round(prospects.filter(p=>freshness(p).cls==='good').length/prospects.length*100):0;$('metricTotal').textContent=total;$('heroCount').textContent=`${total} prospects`;$('metricReview').textContent=summary?.needs_review??review;$('metricHigh').textContent=summary?.high_fit??high;$('metricFresh').textContent=`${summary?.freshness_under_7d??fresh}%`;} function filterValues(){return {q:$('searchInput').value.trim(),score:$('scoreFilter').value,status:$('statusFilter').value,website_class:$('websiteClassFilter').value,pipeline_stage:$('pipelineFilter').value};} function filtered(){const {q,score:sf,status:st,website_class:wc,pipeline_stage:ps}=filterValues();return prospects.filter(p=>{const s=scoreFor(p),text=`${p.name} ${p.website_domain||p.website||''} ${p.location||''}`.toLowerCase(),stage=p.pipeline_stage||p.pipeline?.stage||(Array.isArray(p.pipeline)?p.pipeline.at(-1)?.stage:'')||'new';return(!q||text.includes(q.toLowerCase()))&&(sf==='all'||(sf==='high'&&s>=80)||(sf==='medium'&&s>=60&&s<80)||(sf==='low'&&s<60))&&(st==='all'||statusOf(p)===st)&&(wc==='all'||(p.website_class||'missing')===wc)&&(ps==='all'||stage===ps);});} @@ -32,13 +32,44 @@ async function saveStage(form){const stage=new FormData(form).get('stage');if(!stage){message('pipelineMessage','Choose a pipeline stage.',true);return;}try{await jsonRequest(`/api/v1/businesses/${selectedId}/pipeline`,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({stage})});message('pipelineMessage','Pipeline stage updated.');await loadDetail(selectedId);await loadData();}catch(e){if(e.message!=='unauthorized')message('pipelineMessage',e.message,true);}} async function verify(){try{await jsonRequest(`/api/v1/businesses/${selectedId}/verify`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({verified:true})});message('verifyMessage','Prospect marked verified.');await loadDetail(selectedId);await loadData();}catch(e){if(e.message!=='unauthorized')message('verifyMessage',e.message,true);}} async function addProspect(event){event.preventDefault();const data=Object.fromEntries(new FormData(event.currentTarget).entries());const msg=$('formMessage');try{const body=await jsonRequest('/api/v1/businesses',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});prospects.unshift(body);msg.textContent='Added to review queue.';event.currentTarget.reset();renderMetrics(null);renderRows();}catch(e){if(e.message!=='unauthorized'){msg.textContent=e.message;msg.className='form-message error';}}} + let jobs = [], selectedJobId = null, jobPollTimer = null; + const jobStatuses = ['queued','running','succeeded','failed','cancelled']; + const jobRole = () => String(currentUser?.role || currentUser?.roles?.[0] || '').toLowerCase(); + const canManageJobs = () => ['admin','owner','operator','manager'].includes(jobRole()) || Boolean(currentUser?.permissions?.includes?.('jobs:manage')); + const jobStatus = job => String(job?.status || job?.state || 'queued').toLowerCase(); + const jobLabel = status => status.charAt(0).toUpperCase() + status.slice(1); + async function jobsRequest(path, options = {}) { return jsonRequest(path, options); } + function jobMessage(text, error = false) { const el = $('jobsMessage'); el.textContent = text || ''; el.className = `jobs-message${error ? ' error' : ''}`; } + function resetJobCounts() { jobStatuses.forEach(status => { const el = $(`jobCount${jobLabel(status)}`); if (el) el.textContent = '—'; }); } + function renderJobCounts(payload) { + const counts = payload?.counts || payload?.status_counts || payload?.summary || {}; + const derived = jobs.reduce((out, job) => { const status = jobStatus(job); if (jobStatuses.includes(status)) out[status] += 1; return out; }, {queued:0,running:0,succeeded:0,failed:0,cancelled:0}); + jobStatuses.forEach(status => { const value = counts[status] ?? counts[`${status}_count`] ?? derived[status]; $(`jobCount${jobLabel(status)}`).textContent = Number.isFinite(Number(value)) ? Number(value) : 0; }); + } + function renderJobsList() { + const list = $('jobsList'); + if (!jobs.length) { list.innerHTML = '
No jobs returned by the workspace.
'; return; } + list.innerHTML = jobs.map(job => { const status = jobStatus(job), progress = Number(job.progress ?? job.percent ?? 0); return ``; }).join(''); + } + const structuredError = job => job.error || job.structured_error || job.failure || (job.error_code ? {code:job.error_code,message:job.error_message || 'The job reported a structured error.'} : null); + const eventText = event => event.message || event.description || event.name || event.type || 'Job event'; + function renderJobDetail(job, events = job.events || job.event_timeline || []) { + const status = jobStatus(job), error = structuredError(job), progress = Math.max(0, Math.min(100, Number(job.progress ?? job.percent ?? 0))), canCancel = canManageJobs() && ['queued','running'].includes(status), canRetry = canManageJobs() && status === 'failed'; + $('jobDetailPanel').innerHTML = `

JOB DETAIL

${esc(job.name || job.type || `Job ${job.id}`)}

ID ${esc(job.id)}

${esc(jobLabel(status))}
${status === 'running' ? `${progress}% complete` : jobLabel(status)}${esc(job.progress_message || job.message || '')}
${error ? `` : ''}
${canCancel ? '' : ''}${canRetry ? '' : ''}${!canCancel && !canRetry && !canManageJobs() ? 'Your role cannot change jobs.' : ''}

Event timeline

${Array.isArray(events) && events.length ? `
    ${events.map(event => `
  1. ${esc(eventText(event))}${esc(event.created_at || event.timestamp || event.at || '')}${event.progress != null ? `${esc(event.progress)}%` : ''}
  2. `).join('')}
` : '

No events returned yet.

'}
`; + } + async function loadJobDetail(id) { selectedJobId = id; renderJobsList(); $('jobDetailPanel').innerHTML = '
Loading job detail…
'; try { const job = await jobsRequest(`/api/v1/jobs/${encodeURIComponent(id)}`); let events = job.events || job.event_timeline; if (!events) { const eventPayload = await jobsRequest(`/api/v1/jobs/${encodeURIComponent(id)}/events`); events = eventPayload.events || eventPayload.items || eventPayload; } renderJobDetail(job, events); } catch (error) { if (error.message !== 'unauthorized') $('jobDetailPanel').innerHTML = ``; } } + async function loadJobs({silent = false} = {}) { if (!silent) { jobMessage('Loading jobs…'); resetJobCounts(); } try { const payload = await jobsRequest('/api/v1/jobs'); jobs = Array.isArray(payload) ? payload : (payload.jobs || payload.items || []); renderJobCounts(payload); renderJobsList(); $('jobsUpdatedAt').textContent = `Updated ${new Date().toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'})}`; jobMessage(''); const active = jobs.some(job => ['queued','running'].includes(jobStatus(job))); if (active && !jobPollTimer) jobPollTimer = setInterval(() => loadJobs({silent:true}), 5000); if (!active && jobPollTimer) { clearInterval(jobPollTimer); jobPollTimer = null; } if (selectedJobId) { const selected = jobs.find(job => String(job.id) === String(selectedJobId)); if (selected) await loadJobDetail(selectedJobId); } } catch (error) { jobs = []; resetJobCounts(); renderJobsList(); if (error.message !== 'unauthorized') jobMessage(error.message || 'Unable to load jobs.', true); } } + async function startDemoJob() { if (!canManageJobs()) { jobMessage('Your role is not permitted to start jobs.', true); return; } const button = $('startDemoJobBtn'); button.disabled = true; jobMessage('Starting demo job…'); try { const job = await jobsRequest('/api/v1/jobs', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({type:'noop', payload:{}, idempotency_key:`demo-${Date.now()}-${Math.random().toString(36).slice(2)}`})}); await loadJobs({silent:true}); if (job?.id) await loadJobDetail(job.id); jobMessage('Demo job started.'); } catch (error) { if (error.message !== 'unauthorized') jobMessage(error.message || 'Unable to start demo job.', true); } finally { button.disabled = !canManageJobs(); } } + async function jobAction(action) { const job = jobs.find(item => String(item.id) === String(selectedJobId)); if (!job || !canManageJobs()) return; const endpointPath = action === 'cancel' ? `/api/v1/jobs/${encodeURIComponent(job.id)}/cancel` : `/api/v1/jobs/${encodeURIComponent(job.id)}/retry`; const label = action === 'cancel' ? 'cancel' : 'retry'; try { await jobsRequest(endpointPath, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({})}); jobMessage(`Job ${label} requested.`); await loadJobs({silent:true}); await loadJobDetail(job.id); } catch (error) { if (error.message !== 'unauthorized') jobMessage(error.message || `Unable to ${label} job.`, true); } } + function updateJobPermissions() { if ($('startDemoJobBtn')) $('startDemoJobBtn').disabled = !canManageJobs(); } + function parseCsv(text){const lines=text.trim().split(/\r?\n/).filter(Boolean),cells=line=>line.match(/("[^"]*(?:""[^"]*)*"|[^,]+)(?=,|$)/g)?.map(x=>x.replace(/^"|"$/g,'').replaceAll('""','"'))||[];if(!lines.length)return[];const headers=cells(lines[0]);return lines.slice(1,11).map(l=>Object.fromEntries(cells(l).map((v,i)=>[headers[i]||`column_${i+1}`,v])));} function renderCsv(rows){if(!rows.length){$('csvPreview').innerHTML='

No data rows found

';return;}const h=Object.keys(rows[0]);$('csvPreview').className='csv-table';$('csvPreview').innerHTML=`${h.map(x=>``).join('')}${rows.map(r=>`${h.map(x=>``).join('')}`).join('')}
${esc(x)}
${esc(r[x])}
Showing up to 10 rows · Preview only; nothing added yet.`;} async function login(event){event.preventDefault();const form=event.currentTarget,messageEl=$('loginMessage'),data=Object.fromEntries(new FormData(form).entries());messageEl.textContent='Signing in…';messageEl.className='form-message';try{const res=await fetch(endpoint('/api/v1/auth/login'),{method:'POST',headers:{'Content-Type':'application/json'},credentials:'include',body:JSON.stringify(data)});const body=await res.json().catch(()=>({}));if(!res.ok)throw new Error(body.error||'Invalid email or password.');await bootstrap();}catch(e){if(e.message!=='unauthorized'){messageEl.textContent=e.message;messageEl.className='form-message error';}}} async function logout(){try{await fetch(endpoint('/api/v1/auth/logout'),{method:'POST',credentials:'include'});}finally{showLogin('You have been signed out.');$('loginForm').reset();}} - async function bootstrap(){try{const res=await fetch(endpoint('/api/v1/auth/me'),{credentials:'include'});if(res.status===401){showLogin();return;}if(!res.ok)throw new Error('Could not verify session.');const user=await res.json();showDashboard(user.user||user);await loadData();}catch(e){if(e.message!=='unauthorized')showLogin('Unable to connect to the workspace. Try again.');}} + async function bootstrap(){try{const res=await fetch(endpoint('/api/v1/auth/me'),{credentials:'include'});if(res.status===401){showLogin();return;}if(!res.ok)throw new Error('Could not verify session.');const user=await res.json();showDashboard(user.user||user);await loadData();await loadJobs();}catch(e){if(e.message!=='unauthorized')showLogin('Unable to connect to the workspace. Try again.');}} document.addEventListener('submit',e=>{if(e.target.id==='contactForm')saveContact(e.target);if(e.target.id==='noteForm')saveNote(e.target);if(e.target.id==='pipelineForm')saveStage(e.target);}); - document.addEventListener('click',e=>{if(e.target.id==='verifyBtn')verify();if(e.target.id==='retryDetailBtn'&&selectedId)loadDetail(selectedId);}); - $('loginForm').addEventListener('submit',login);$('logoutBtn').addEventListener('click',logout);$('searchInput').addEventListener('input',()=>{page=1;renderRows();});['scoreFilter','statusFilter','websiteClassFilter','pipelineFilter'].forEach(id=>$(id).addEventListener('change',()=>{page=1;loadData();}));$('pageSize').addEventListener('change',e=>{pageSize=Number(e.target.value);page=1;loadData();});$('nextPageBtn').addEventListener('click',()=>{if(hasNextPage){page+=1;loadData();}});$('refreshBtn').addEventListener('click',loadData);$('addForm').addEventListener('submit',addProspect);$('csvInput').addEventListener('change',e=>{const file=e.target.files[0];if(file){const reader=new FileReader();reader.onload=()=>renderCsv(parseCsv(reader.result));reader.readAsText(file);}});$('menuBtn').addEventListener('click',()=>document.querySelector('.sidebar').classList.toggle('open'));document.querySelectorAll('[data-scroll]').forEach(b=>b.addEventListener('click',()=>document.querySelector(b.dataset.scroll)?.scrollIntoView())); + document.addEventListener('click',e=>{if(e.target.id==='verifyBtn')verify();if(e.target.id==='retryDetailBtn'&&selectedId)loadDetail(selectedId);if(e.target.id==='retryJobDetailBtn'&&selectedJobId)loadJobDetail(selectedJobId);if(e.target.id==='cancelJobBtn')jobAction('cancel');if(e.target.id==='retryJobBtn')jobAction('retry');const row=e.target.closest?.('[data-job-id]');if(row)loadJobDetail(row.dataset.jobId);}); + $('loginForm').addEventListener('submit',login);$('logoutBtn').addEventListener('click',logout);$('searchInput').addEventListener('input',()=>{page=1;renderRows();});['scoreFilter','statusFilter','websiteClassFilter','pipelineFilter'].forEach(id=>$(id).addEventListener('change',()=>{page=1;loadData();}));$('pageSize').addEventListener('change',e=>{pageSize=Number(e.target.value);page=1;loadData();});$('nextPageBtn').addEventListener('click',()=>{if(hasNextPage){page+=1;loadData();}});$('refreshBtn').addEventListener('click',loadData);$('jobsRefreshBtn').addEventListener('click',()=>loadJobs());$('startDemoJobBtn').addEventListener('click',startDemoJob);$('addForm').addEventListener('submit',addProspect);$('csvInput').addEventListener('change',e=>{const file=e.target.files[0];if(file){const reader=new FileReader();reader.onload=()=>renderCsv(parseCsv(reader.result));reader.readAsText(file);}});$('menuBtn').addEventListener('click',()=>document.querySelector('.sidebar').classList.toggle('open'));document.querySelectorAll('[data-scroll]').forEach(b=>b.addEventListener('click',()=>document.querySelector(b.dataset.scroll)?.scrollIntoView())); bootstrap(); })(); diff --git a/apps/web/index.html b/apps/web/index.html index d1eea89..fcfb36d 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -30,6 +30,7 @@ Dashboard Prospect explorer Add prospects + Jobs @@ -51,6 +52,20 @@ +
+
+

OPERATIONS

Job monitor

Track authenticated workspace jobs and their progress. No job data is shown until the API responds.

+
+
+
+
+
Queued
Running
Succeeded
Failed
Cancelled
+
+
+

QUEUE

Recent jobs

Not loaded
Sign in to load jobs from the workspace.
+ +
+

INTAKE

Add a prospect

Manual entry

BULK INTAKE

CSV preview

Preview rows before adding them to your review queue.

No file selected

CSV stays in your browser until you confirm.
diff --git a/apps/web/smoke-test.html b/apps/web/smoke-test.html index 8cca9f2..0798d3c 100644 --- a/apps/web/smoke-test.html +++ b/apps/web/smoke-test.html @@ -20,5 +20,9 @@ frame.onload=async()=>{const d=frame.contentDocument;const js=await fetch('app.j ['No hardcoded credentials or demo fallback',()=>!js.includes('demoProspects')&&!/password.{0,30}['\"][^'\"]+['\"]/.test(js)], ['Safety copy and blocked outreach preserved',()=>js.includes('disabled-action')&&js.includes('Suppressed records cannot be contacted.')&&js.includes('Review this prospect before outreach is available.')], ['No outreach/send controls',()=>!Array.from(d.querySelectorAll('button')).some(x=>/outreach|send/i.test(x.textContent)&&!x.disabled)] + ,['Jobs navigation and monitor',()=>!!d.querySelector('[data-nav="jobs"]')&&!!d.querySelector('#jobs')&&!!d.querySelector('#jobsList')] + ,['Job status counts and controls',()=>['jobCountQueued','jobCountRunning','jobCountSucceeded','jobCountFailed','jobCountCancelled','startDemoJobBtn','jobsRefreshBtn'].every(id=>!!d.querySelector('#'+id))] + ,['Authenticated jobs API contract',()=>js.includes("/api/v1/jobs")&&js.includes('jobsRequest')&&js.includes('/cancel')&&js.includes('/retry')] + ,['Job detail timeline and structured states',()=>!!d.querySelector('#jobDetailPanel')&&js.includes('event_timeline')&&js.includes('structured_error')&&js.includes('progress')] ];let passed=0;document.querySelector('#checks').innerHTML=checks.map(([name,test])=>{const ok=test();if(ok)passed++;return `
  • ${ok?'PASS':'FAIL'} — ${name}
  • `}).join('');document.querySelector('#summary').textContent=`${passed}/${checks.length} checks passed`;}; diff --git a/apps/web/styles.css b/apps/web/styles.css index 7691e24..081c14d 100644 --- a/apps/web/styles.css +++ b/apps/web/styles.css @@ -1,2 +1,3 @@ :root{--ink:#172033;--muted:#6d7890;--line:#e7eaf1;--surface:#fff;--bg:#f7f8fb;--violet:#6756e8;--violet-soft:#efedff;--green:#16845b;--green-soft:#e5f7ef;--amber:#b87513;--amber-soft:#fff3dd;--red:#b84d55;--red-soft:#fff0f1;--shadow:0 10px 30px rgba(33,36,75,.05)}*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.45 Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}.app-shell{display:flex;min-height:100vh}.sidebar{width:238px;background:#17152e;color:#e5e4f2;display:flex;flex-direction:column;padding:28px 16px;position:fixed;inset:0 auto 0 0}.brand{color:#fff;display:flex;align-items:center;gap:10px;text-decoration:none;font-size:20px;font-weight:750;padding:0 14px 44px;letter-spacing:-.5px}.brand-mark{width:27px;height:27px;border-radius:8px;background:#7263f3;display:grid;place-items:center;font-size:16px}.brand-light{font-weight:400;color:#a7a5c1}.nav-item{display:flex;align-items:center;gap:13px;color:#a8a7bd;text-decoration:none;padding:12px 15px;border-radius:9px;margin:3px 0}.nav-item span{font-size:20px;width:18px;text-align:center}.nav-item.active,.nav-item:hover{color:#fff;background:#2a2749}.sidebar-foot{margin-top:auto;border-top:1px solid #302d4b;padding:20px 14px 4px;display:flex;gap:9px;align-items:flex-start;font-size:12px}.sidebar-foot small{display:block;color:#85839e;margin-top:3px}.live-dot{background:#45d99c;width:7px;height:7px;border-radius:50%;margin-top:5px;box-shadow:0 0 0 4px #23463d}.main{margin-left:238px;flex:1;min-width:0}.topbar{height:76px;background:#fff;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;padding:0 4.5%;color:var(--muted)}.crumb span{padding:0 10px;color:#c2c5ce}.top-actions{display:flex;align-items:center;gap:22px}.api-status{color:#7d8494;font-size:12px}.api-status.live{color:var(--green)}.icon-button{border:0;background:none;color:#7c8497;font-size:21px;cursor:pointer}.avatar{width:33px;height:33px;border-radius:50%;display:grid;place-items:center;background:#e5e2ff;color:#5648c8;font-weight:700;font-size:11px}.content{max-width:1450px;margin:auto;padding:40px 4.5% 28px}.hero{display:flex;justify-content:space-between;align-items:end;margin-bottom:30px}.eyebrow{color:#8d94a4;font-size:10px;letter-spacing:1.6px;font-weight:750;margin:0 0 9px}.hero h1{font-size:30px;letter-spacing:-1px;margin:0 0 7px}.hero h1 span{color:#7666f1}.hero-sub{color:var(--muted);margin:0}.hero-sub strong{color:var(--ink)}.button{border:0;border-radius:8px;padding:10px 15px;font-weight:650;cursor:pointer;white-space:nowrap}.primary{background:var(--violet);color:#fff;box-shadow:0 6px 14px #6756e833}.primary:hover{background:#5848d7}.ghost{background:#fff;border:1px solid var(--line);color:#5d6678}.ghost:hover{border-color:#bcb6ff;color:var(--violet)}.metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin-bottom:24px}.metric-card,.panel{background:var(--surface);border:1px solid var(--line);border-radius:12px;box-shadow:var(--shadow)}.metric-card{padding:20px;display:flex;gap:14px;min-height:130px}.metric-icon{height:40px;width:40px;border-radius:11px;display:grid;place-items:center;font-size:22px}.violet{background:var(--violet-soft);color:var(--violet)}.amber{background:var(--amber-soft);color:var(--amber)}.green{background:var(--green-soft);color:var(--green)}.blue{background:#e9f2ff;color:#3e80d5}.metric-card p{margin:2px 0 5px;color:var(--muted);font-size:12px}.metric-card h2{margin:0 0 6px;font-size:26px;letter-spacing:-1px}.trend{font-size:11px;font-weight:700}.trend em{font-style:normal;font-weight:400;color:#a2a8b5}.up{color:var(--green)}.neutral{color:#8b93a2}.workspace-grid{display:grid;grid-template-columns:minmax(0,1.6fr) minmax(290px,.75fr);gap:18px;margin-bottom:24px}.panel{padding:24px}.panel-heading{display:flex;justify-content:space-between;align-items:start;margin-bottom:20px}.panel h2{font-size:18px;margin:0;letter-spacing:-.3px}.filters{display:grid;grid-template-columns:minmax(160px,1fr) 150px 150px;gap:9px;margin-bottom:16px}.search-wrap{display:flex;align-items:center;border:1px solid var(--line);border-radius:8px;background:#fff;color:#a2a8b5;padding:0 11px}.search-wrap input{border:0;outline:0;padding:10px 8px;width:100%;font:inherit;color:var(--ink);background:transparent}.filters select{border:1px solid var(--line);border-radius:8px;padding:0 10px;color:#596478;background:#fff;font:inherit}.table-meta{color:#9299a8;font-size:11px;display:flex;justify-content:space-between;margin:0 0 9px}.legend{display:flex;gap:6px;align-items:center}.legend-dot{width:7px;height:7px;border-radius:50%;display:inline-block;margin-left:8px}.high-dot{background:#52bf93}.review-dot{background:#e8a84f}.table-scroll{overflow-x:auto}table{border-collapse:collapse;width:100%;min-width:650px}th{text-align:left;color:#9aa1af;font-size:10px;letter-spacing:.6px;text-transform:uppercase;font-weight:700;padding:11px 8px;border-bottom:1px solid var(--line)}td{padding:15px 8px;border-bottom:1px solid #f0f1f5;vertical-align:middle;color:#485367;font-size:12px}tbody tr{cursor:pointer;transition:background .15s}tbody tr:hover,tbody tr.selected{background:#faf9ff}td:first-child{color:var(--ink);font-weight:700;font-size:13px}.company-sub{display:block;font-size:11px;color:#99a0ae;font-weight:400;margin-top:2px}.score{display:inline-flex;align-items:center;gap:5px;border-radius:15px;padding:4px 8px;font-weight:750;font-size:11px}.score.high{color:var(--green);background:var(--green-soft)}.score.medium{color:var(--amber);background:var(--amber-soft)}.score.low{color:#788193;background:#eef0f4}.evidence{color:#596478}.evidence strong{display:block;color:var(--ink);font-size:12px}.fresh{font-size:11px}.fresh.good{color:var(--green)}.fresh.stale{color:var(--amber)}.status{font-size:10px;border-radius:4px;padding:4px 6px;font-weight:700}.status.review{background:var(--amber-soft);color:var(--amber)}.status.reviewed{background:var(--green-soft);color:var(--green)}.status.suppressed{background:var(--red-soft);color:var(--red)}.row-arrow{font-size:18px;color:#aeb4c0}.detail-panel{min-height:420px}.empty-detail{text-align:center;color:var(--muted);padding:55px 20px}.empty-icon{display:grid;place-items:center;margin:auto auto 17px;background:var(--violet-soft);color:var(--violet);width:48px;height:48px;border-radius:50%;font-size:23px}.empty-detail h3{color:var(--ink);margin:0 0 8px}.empty-detail p{margin:auto;max-width:220px;font-size:12px}.detail-head{display:flex;justify-content:space-between;gap:10px}.detail-head h3{margin:0;font-size:19px}.detail-domain{color:#949baa;font-size:12px;margin:3px 0 20px}.detail-score{display:flex;align-items:center;justify-content:space-between;background:#f9f8ff;padding:14px;border-radius:9px;margin-bottom:18px}.detail-score b{font-size:27px;color:var(--violet)}.detail-score small{display:block;color:var(--muted)}.detail-block{border-top:1px solid var(--line);padding:15px 0}.detail-block h4{font-size:10px;color:#8c94a4;text-transform:uppercase;letter-spacing:1px;margin:0 0 10px}.detail-block p{font-size:12px;margin:5px 0;color:#556176}.evidence-line{display:flex;justify-content:space-between;gap:10px}.confidence{color:var(--violet);font-weight:700}.disabled-action{width:100%;margin-top:5px;color:#a0a6b3;background:#f0f1f4;cursor:not-allowed}.disabled-reason{font-size:11px;color:var(--red);margin:8px 0 0}.lower-grid{display:grid;grid-template-columns:1.3fr 1fr;gap:18px}.small-label,.optional{color:#9ca3b1;font-size:11px;font-weight:400}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}label{display:block;color:#596478;font-size:12px;font-weight:650}label input{display:block;width:100%;margin-top:7px;border:1px solid var(--line);border-radius:7px;padding:10px 11px;font:inherit;outline:0}label input:focus{border-color:#9489f5;box-shadow:0 0 0 3px #eeeaff}.form-footer{display:flex;align-items:center;justify-content:space-between;margin-top:20px}.form-message{font-size:11px;color:var(--green);margin:0}.form-message.error{color:var(--red)}.muted{color:var(--muted);font-size:12px}.csv-empty{border:1px dashed #d9dce8;border-radius:9px;text-align:center;padding:22px;color:#adb3c0}.csv-empty span{font-size:26px}.csv-empty p{margin:4px 0;font-size:12px;color:#737d90}.csv-empty small{font-size:10px}.csv-table{max-height:150px;overflow:auto;font-size:11px}.csv-table table{min-width:400px}.csv-table th,.csv-table td{padding:7px}.upload-label{display:inline-block}footer{display:flex;justify-content:space-between;color:#a0a6b2;font-size:11px;padding:30px 2px 0}footer a{color:var(--violet);text-decoration:none}.mobile-menu{display:none;border:0;background:transparent;font-size:21px;color:var(--ink)}@media(max-width:1050px){.metrics{grid-template-columns:repeat(2,1fr)}.workspace-grid{grid-template-columns:1fr}.detail-panel{min-height:auto}.lower-grid{grid-template-columns:1fr}}@media(max-width:700px){.sidebar{transform:translateX(-100%);transition:transform .2s;z-index:5;width:230px}.sidebar.open{transform:translateX(0)}.main{margin-left:0}.topbar{padding:0 20px}.mobile-menu{display:block}.crumb{font-size:12px}.top-actions{gap:12px}.api-status{display:none}.content{padding:28px 16px}.hero{align-items:start;gap:18px;flex-direction:column}.hero h1{font-size:25px}.metrics{grid-template-columns:1fr 1fr;gap:10px}.metric-card{padding:15px;min-height:112px;gap:9px}.metric-icon{width:34px;height:34px;font-size:18px}.metric-card h2{font-size:22px}.panel{padding:18px}.filters{grid-template-columns:1fr;gap:8px}.filters select{height:38px}.table-meta{align-items:start;gap:8px;flex-direction:column}.legend{display:none}.form-grid{grid-template-columns:1fr}.form-footer{align-items:start;gap:14px;flex-direction:column}.form-footer .button{width:100%}footer{flex-direction:column;gap:5px}}.login-screen{min-height:100vh;display:grid;place-items:center;padding:24px;background:radial-gradient(circle at 15% 10%,#efedff 0,transparent 34%),var(--bg)}.login-card{width:min(100%,430px);padding:42px 40px;background:var(--surface);border:1px solid var(--line);border-radius:18px;box-shadow:0 24px 70px rgba(33,36,75,.11)}.login-brand{padding:0;margin-bottom:42px}.login-card h1{margin:0;font-size:32px;letter-spacing:-1px}.login-subtitle{color:var(--muted);margin:8px 0 28px}.login-card form{display:grid;gap:17px}.login-card label input{padding:12px}.login-submit{width:100%;display:flex;justify-content:center;gap:9px;margin-top:3px;padding:12px}.login-note{color:#9299a8;text-align:center;font-size:11px;margin:22px 0 0}.user-identity{color:#596478;font-size:12px;font-weight:650}.logout-button{border:1px solid var(--line);border-radius:7px;background:#fff;color:#596478;font:inherit;font-size:11px;font-weight:650;padding:7px 10px;cursor:pointer}.logout-button:hover{border-color:#bcb6ff;color:var(--violet)}[hidden]{display:none!important}@media(max-width:700px){.login-card{padding:32px 22px}.login-brand{margin-bottom:32px}.user-identity{display:none}.logout-button{padding:6px 8px}} .detail-list{margin:.5rem 0 1rem;padding-left:1.2rem}.detail-list li{margin:.25rem 0}.detail-block{border-top:1px solid var(--line);padding:14px 0}.detail-block h4{margin:0 0 9px}.count{color:var(--muted);font-size:12px;font-weight:400}.compact{padding:7px 10px;font-size:12px}.inline-form,.compact-form{display:flex;gap:7px;flex-wrap:wrap;align-items:center}.compact-form input,.compact-form textarea,.inline-form select{border:1px solid var(--line);border-radius:7px;padding:8px;font:inherit;min-width:0;flex:1}.compact-form textarea{flex-basis:100%;resize:vertical}.detail-loading,.detail-error{padding:28px 4px;color:var(--muted)}.detail-error h3{color:var(--ink)}.review-status{margin:0 0 10px}.page-size{display:flex;align-items:center;gap:5px;color:var(--muted);font-size:12px}.page-size select{border:1px solid var(--line);border-radius:6px;padding:5px}.form-message.error{color:var(--red)}@media(max-width:900px){.filters{grid-template-columns:1fr 1fr}.table-meta{flex-wrap:wrap}.detail-panel{min-width:0}}@media(max-width:700px){.filters{grid-template-columns:1fr}.inline-form,.compact-form{align-items:stretch;flex-direction:column}.inline-form>* ,.compact-form>*{width:100%}} +.jobs-section{margin-top:28px;scroll-margin-top:24px}.jobs-header{display:flex;align-items:flex-start;justify-content:space-between;gap:24px}.jobs-header h2{margin:.15rem 0 .25rem}.jobs-header p{margin:.25rem 0 0}.jobs-actions{display:flex;gap:8px;flex-wrap:wrap}.jobs-message{min-height:22px;padding:8px 2px;color:var(--green)}.jobs-message.error{color:var(--red)}.job-counts{display:grid;grid-template-columns:repeat(5,1fr);gap:12px;margin:0 0 14px}.job-count{background:var(--surface);border:1px solid var(--line);border-left:4px solid var(--violet);border-radius:10px;padding:14px 16px;box-shadow:var(--shadow)}.job-count span{display:block;color:var(--muted);font-size:12px}.job-count strong{display:block;font-size:25px;margin-top:4px}.job-count.queued{border-left-color:var(--amber)}.job-count.running{border-left-color:#4d8bd8}.job-count.succeeded{border-left-color:var(--green)}.job-count.failed{border-left-color:var(--red)}.job-count.cancelled{border-left-color:#8d879c}.jobs-grid{display:grid;grid-template-columns:minmax(0,1.05fr) minmax(320px,.95fr);gap:18px}.jobs-list,.job-detail{min-height:320px}.jobs-list-body{border-top:1px solid var(--line)}.job-empty{padding:38px 18px;color:var(--muted);text-align:center}.job-row{display:grid;grid-template-columns:minmax(0,1fr) auto 42px;gap:12px;align-items:center;width:100%;padding:14px 16px;border:0;border-bottom:1px solid var(--line);background:transparent;color:inherit;text-align:left;cursor:pointer;font:inherit}.job-row:hover,.job-row.selected{background:var(--violet-soft)}.job-row-main{display:flex;flex-direction:column;gap:3px;min-width:0}.job-row-main strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.job-row-main small,.job-row-progress{color:var(--muted);font-size:12px}.job-row-state{border-radius:999px;padding:4px 8px;font-size:11px;font-weight:700;white-space:nowrap;background:var(--violet-soft);color:var(--violet)}.job-row-state.queued{background:var(--amber-soft);color:var(--amber)}.job-row-state.running{background:#e8f1ff;color:#3269ad}.job-row-state.succeeded{background:var(--green-soft);color:var(--green)}.job-row-state.failed{background:var(--red-soft);color:var(--red)}.job-row-state.cancelled{background:#f0eef4;color:#716a7c}.job-detail{padding:22px}.job-detail-head{display:flex;justify-content:space-between;gap:12px;border-bottom:1px solid var(--line);padding-bottom:16px}.job-detail-head h3{margin:.15rem 0}.job-progress{padding:18px 0}.job-progress-meta{display:flex;justify-content:space-between;gap:12px;font-size:12px;color:var(--muted)}.job-progress-meta strong{color:var(--ink)}.progress-track{height:8px;background:#eef0f5;border-radius:999px;overflow:hidden;margin-top:10px}.progress-track span{display:block;height:100%;background:var(--violet);border-radius:inherit;transition:width .25s}.structured-error{background:var(--red-soft);border:1px solid #f2cdd0;border-radius:8px;padding:12px;margin:4px 0 16px;color:var(--red)}.structured-error p{margin:5px 0}.structured-error pre{white-space:pre-wrap;font-size:11px;margin:8px 0 0}.job-detail-actions{display:flex;gap:8px;min-height:34px}.event-timeline{border-top:1px solid var(--line);margin-top:16px;padding-top:16px}.event-timeline h4{margin:0}.event-timeline ol{list-style:none;padding:0;margin:12px 0 0}.event-timeline li{display:flex;gap:10px;position:relative;padding:0 0 15px}.event-timeline li:not(:last-child):before{content:"";position:absolute;left:4px;top:10px;bottom:0;border-left:1px solid var(--line)}.timeline-dot{z-index:1;width:9px;height:9px;margin-top:4px;border-radius:50%;background:var(--violet);flex:none}.event-timeline li div{display:flex;flex-direction:column;gap:2px}.event-timeline small,.timeline-progress{font-size:11px;color:var(--muted)}@media(max-width:900px){.jobs-grid{grid-template-columns:1fr}.job-counts{grid-template-columns:repeat(3,1fr)}}@media(max-width:700px){.jobs-header{flex-direction:column}.job-counts{grid-template-columns:repeat(2,1fr)}.job-row{grid-template-columns:minmax(0,1fr) auto}.job-row-progress{display:none}} diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 69f1522..4ebfea8 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -31,6 +31,14 @@ Phase 3 is a human-operated prospect workflow. Operators manually create a busin There is no automated discovery job, DNS/website scanner, enrichment worker, or outreach worker to monitor in this release. CSV is preview-only; do not describe a preview as an import or assume that rows were persisted. +## Phase 4 jobs and live logging + +The Phase 4 MVP provides SQLite-backed job status/detail/event routes and a browser monitor. A job moves `queued` → `running` → `succeeded`/`failed`/`cancelled`, retains its attempt and tenant identity, and appends per-job events with a monotonic sequence cursor. Operators inspect status and replay events by polling; SSE may provide lower-latency delivery but is not implemented and must replay from the persisted cursor and fall back to polling after disconnects. + +Creation must use a tenant-scoped idempotency key and request fingerprint. A repeated identical request returns the existing job/attempt; a conflicting payload is rejected. Cancellation is cooperative and race-safe, while retry is an explicit authorized new attempt linked to the original job and must not repeat completed side effects. Do not treat HTTP acceptance as completion, and do not reconstruct history from ephemeral container logs. + +The MVP has no SSE handler, durable queue, or worker process in Compose; its in-process worker and SQLite job/event tables are pilot-only. Process loss can lose work, there is no durable lease/recovery or horizontal coordination, and it must not be presented as production execution. Redis and Celery are not implemented. + ## Configuration and deployment Copy `.env.example` for local development. Production values must be supplied by the deployment environment, never committed. `BOOTSTRAP_ADMIN_EMAIL` and `BOOTSTRAP_ADMIN_PASSWORD` are optional API environment variables for first-run admin provisioning only; set them together through a secret store or protected deployment environment, remove them immediately after successful bootstrap, and rotate the password. Do not put real values in Compose files, CI variables visible to logs, images, or committed `.env` files. @@ -56,6 +64,8 @@ Inspect the volume with `docker volume inspect prospect-platform-api-data`; do n For the current MVP there is no database migration or backup command. If runtime data is material, stop writes first and snapshot/copy the volume using an approved host backup process. Protect business, child intelligence, notes, provenance, and audit data with encryption and access controls, test a restore into an isolated environment, and document the result. Define retention/deletion rules that cover source references and notes as well as contact fields. +When jobs are introduced, backups and retention must include job definitions, idempotency records, terminal results/errors, and persisted sequence events. Verify that restoring a database preserves event ordering/cursors and does not cause a retried worker to repeat side effects. Define event redaction and retention separately from short-lived delivery connections. + Recommended starting policy for a future production data store: - daily encrypted backups, with at least 30 days of retention; @@ -75,9 +85,9 @@ Do not run `docker compose down -v` on a data-bearing environment: it removes th - **Build failure:** run `docker compose build --no-cache` from a reviewed checkout and check Docker daemon/network status. - **Unexpected outbound traffic:** stop the stack, preserve logs/metadata, and investigate. The MVP has no outreach worker and must not send automated messages. -## Scaling path +## Production migration and scaling path -Adding Postgres, Redis, workers, schedulers, discovery adapters, or scanners requires explicit readiness checks, migrations, queue durability/idempotency, secret injection, network segmentation, metrics/alerts, backup/restore procedures, provenance/source governance, and an operational owner. Do not add them as an implicit Compose dependency: this MVP is intentionally runnable without external Postgres or Redis, and no automated discovery or outreach may be inferred from the scaling path. +Before production, complete a migration from SQLite to a reviewed production database, add schema/indexes for jobs/idempotency/events, implement transactional sequence assignment and tenant authorization, and prove cancellation/retry/lease recovery under concurrency. Add durable queue/worker operations, metrics and alerts for queue age, failures, retries, cancellation latency, event lag/gaps, and SSE connections; define backup/restore and event-retention drills. Redis, Celery, Postgres, schedulers, discovery adapters, and scanners are possible future components—not implicit Compose dependencies and not implemented by this MVP. No automated discovery or outreach may be inferred from the scaling path. ## Incident checklist diff --git a/docs/SECURITY.md b/docs/SECURITY.md index d2ca13f..886b201 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -11,6 +11,8 @@ - No credentials are committed. `.env.example` contains non-secret names and local defaults only. - Authentication uses server-side sessions for browser clients. The session identifier is carried in an `HttpOnly` cookie; logout/revocation must invalidate the server-side session. Health endpoints are deliberately public and must remain usable without a session. - Containers run as an unprivileged user, drop Linux capabilities, use `no-new-privileges`, and use read-only root filesystems. The API data volume is the only intended writable persistent location. +- Phase 4 job/live-log controls are not enabled in the current runtime. If added, job IDs, idempotency keys, status, cancellation, retries, and event cursors must all be authorized against the authenticated organization; never accept a job or child identifier as authorization by itself. +- Persisted job events must be append-only, sequence-ordered per job, replayable from a cursor, and redacted to safe operational data. Never emit credentials, session cookies, API keys, full request bodies, or unnecessary contact/prospect data in polling responses, SSE frames, logs, or error details. ## Known limitations before production @@ -24,6 +26,9 @@ 8. **Secrets:** inject production secrets from a secret manager or orchestrator secret store. Do not place them in images, Compose files, source, CI logs, or committed `.env` files. Remove bootstrap variables after first-run provisioning. 9. **Transport and perimeter:** terminate TLS at a trusted ingress, restrict exposed ports, add network policy, and place admin surfaces behind appropriate access controls. 10. **Data protection:** define retention and deletion rules for prospect/contact data and provenance, restrict volume access, encrypt backups, and maintain a tested access/audit trail. +11. **Jobs and idempotency:** require tenant-scoped idempotency keys for side-effecting job creation and bind each key to a request fingerprint; reject conflicting reuse and make retries safe against duplicate side effects. Persist lifecycle transitions transactionally and define lease/timeout/recovery behavior. +12. **Live delivery:** SSE, if introduced, must authenticate before opening the stream, enforce tenant scope on every replay query, bound event/backlog size, support `Last-Event-ID`/cursor replay, send heartbeats, and provide polling fallback. Treat event-stream connections as untrusted clients and avoid cross-tenant timing/detail leaks. +13. **Worker boundary:** the current SQLite/in-process MVP is not durable or horizontally safe. A production worker migration requires reviewed queue semantics, leases, visibility timeouts, dead-letter handling, concurrency limits, cancellation races, metrics, and deployment isolation. Redis/Celery are not implemented today. ## Source and contact policy