diff --git a/Cargo.lock b/Cargo.lock index aedd846..0bea5ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5417,6 +5417,7 @@ dependencies = [ "arrow", "axum", "bytes", + "chrono", "object_store", "parquet", "serde", diff --git a/crates/gateway/src/main.rs b/crates/gateway/src/main.rs index c7a3d39..6df5f9b 100644 --- a/crates/gateway/src/main.rs +++ b/crates/gateway/src/main.rs @@ -57,6 +57,7 @@ async fn main() { .nest("/vectors", vectord::service::router(vectord::service::VectorState { store: store.clone(), ai_client: ai_client.clone(), + job_tracker: vectord::jobs::JobTracker::new(), })) .nest("/workspaces", queryd::workspace_service::router(workspace_mgr)); diff --git a/crates/vectord/Cargo.toml b/crates/vectord/Cargo.toml index 65f0860..d20a898 100644 --- a/crates/vectord/Cargo.toml +++ b/crates/vectord/Cargo.toml @@ -16,3 +16,4 @@ bytes = { workspace = true } object_store = { workspace = true } parquet = { workspace = true } arrow = { workspace = true } +chrono = { workspace = true } diff --git a/crates/vectord/src/jobs.rs b/crates/vectord/src/jobs.rs new file mode 100644 index 0000000..3417d96 --- /dev/null +++ b/crates/vectord/src/jobs.rs @@ -0,0 +1,112 @@ +/// Background job system for long-running embedding tasks. +/// POST /vectors/index returns a job_id immediately. +/// GET /vectors/jobs/{id} returns progress. +/// Embedding runs in background via tokio::spawn. + +use serde::Serialize; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::RwLock; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum JobStatus { + Running, + Completed, + Failed, +} + +#[derive(Debug, Clone, Serialize)] +pub struct Job { + pub id: String, + pub status: JobStatus, + pub index_name: String, + pub total_chunks: usize, + pub embedded_chunks: usize, + pub progress_pct: f32, + pub storage_key: Option, + pub error: Option, + pub started_at: String, + pub completed_at: Option, + pub chunks_per_sec: f32, +} + +/// Shared progress tracker that background tasks update. +#[derive(Clone)] +pub struct JobTracker { + jobs: Arc>>, +} + +impl JobTracker { + pub fn new() -> Self { + Self { + jobs: Arc::new(RwLock::new(HashMap::new())), + } + } + + /// Register a new job. Returns the job ID. + pub async fn create(&self, index_name: &str, total_chunks: usize) -> String { + let id = format!("job-{}", chrono::Utc::now().timestamp_millis()); + let job = Job { + id: id.clone(), + status: JobStatus::Running, + index_name: index_name.to_string(), + total_chunks, + embedded_chunks: 0, + progress_pct: 0.0, + storage_key: None, + error: None, + started_at: chrono::Utc::now().to_rfc3339(), + completed_at: None, + chunks_per_sec: 0.0, + }; + self.jobs.write().await.insert(id.clone(), job); + id + } + + /// Update progress. + pub async fn update_progress(&self, id: &str, embedded: usize, rate: f32) { + let mut jobs = self.jobs.write().await; + if let Some(job) = jobs.get_mut(id) { + job.embedded_chunks = embedded; + job.progress_pct = if job.total_chunks > 0 { + (embedded as f32 / job.total_chunks as f32) * 100.0 + } else { + 0.0 + }; + job.chunks_per_sec = rate; + } + } + + /// Mark job as completed. + pub async fn complete(&self, id: &str, storage_key: String) { + let mut jobs = self.jobs.write().await; + if let Some(job) = jobs.get_mut(id) { + job.status = JobStatus::Completed; + job.embedded_chunks = job.total_chunks; + job.progress_pct = 100.0; + job.storage_key = Some(storage_key); + job.completed_at = Some(chrono::Utc::now().to_rfc3339()); + } + } + + /// Mark job as failed. + pub async fn fail(&self, id: &str, error: String) { + let mut jobs = self.jobs.write().await; + if let Some(job) = jobs.get_mut(id) { + job.status = JobStatus::Failed; + job.error = Some(error); + job.completed_at = Some(chrono::Utc::now().to_rfc3339()); + } + } + + /// Get job status. + pub async fn get(&self, id: &str) -> Option { + self.jobs.read().await.get(id).cloned() + } + + /// List all jobs. + pub async fn list(&self) -> Vec { + self.jobs.read().await.values().cloned().collect() + } +} diff --git a/crates/vectord/src/lib.rs b/crates/vectord/src/lib.rs index cd902f0..c2317e1 100644 --- a/crates/vectord/src/lib.rs +++ b/crates/vectord/src/lib.rs @@ -1,4 +1,5 @@ pub mod chunker; +pub mod jobs; pub mod store; pub mod search; pub mod rag; diff --git a/crates/vectord/src/service.rs b/crates/vectord/src/service.rs index efe633e..a1df5a4 100644 --- a/crates/vectord/src/service.rs +++ b/crates/vectord/src/service.rs @@ -1,6 +1,6 @@ use axum::{ Json, Router, - extract::State, + extract::{Path, State}, http::StatusCode, response::IntoResponse, routing::{get, post}, @@ -10,18 +10,21 @@ use serde::{Deserialize, Serialize}; use std::sync::Arc; use aibridge::client::{AiClient, EmbedRequest}; -use crate::{chunker, rag, search, store}; +use crate::{chunker, jobs, rag, search, store}; #[derive(Clone)] pub struct VectorState { pub store: Arc, pub ai_client: AiClient, + pub job_tracker: jobs::JobTracker, } pub fn router(state: VectorState) -> Router { Router::new() .route("/health", get(health)) .route("/index", post(create_index)) + .route("/jobs", get(list_jobs)) + .route("/jobs/{id}", get(get_job)) .route("/search", post(search_index)) .route("/rag", post(rag_query)) .with_state(state) @@ -31,19 +34,14 @@ async fn health() -> &'static str { "vectord ok" } -// --- Index creation: chunk text → embed → store --- +// --- Background Index Creation --- #[derive(Deserialize)] struct CreateIndexRequest { - /// Name for this vector index index_name: String, - /// Source identifier source: String, - /// List of documents to index documents: Vec, - /// Chunk size in characters (default 500) chunk_size: Option, - /// Overlap in characters (default 50) overlap: Option, } @@ -55,10 +53,11 @@ struct DocInput { #[derive(Serialize)] struct CreateIndexResponse { + job_id: String, index_name: String, documents: usize, chunks: usize, - storage_key: String, + message: String, } async fn create_index( @@ -68,9 +67,7 @@ async fn create_index( let chunk_size = req.chunk_size.unwrap_or(500); let overlap = req.overlap.unwrap_or(50); - tracing::info!("creating vector index '{}' from {} documents", req.index_name, req.documents.len()); - - // 1. Chunk all documents + // Chunk synchronously (fast) let doc_ids: Vec = req.documents.iter().map(|d| d.id.clone()).collect(); let texts: Vec = req.documents.iter().map(|d| d.text.clone()).collect(); let chunks = chunker::chunk_column(&req.source, &doc_ids, &texts, chunk_size, overlap); @@ -79,32 +76,100 @@ async fn create_index( return Err((StatusCode::BAD_REQUEST, "no text to index".to_string())); } - tracing::info!("{} documents → {} chunks", req.documents.len(), chunks.len()); + let n_docs = req.documents.len(); + let n_chunks = chunks.len(); + let index_name = req.index_name.clone(); - // 2. Embed all chunks (batch to avoid timeout) + // Create job and return immediately + let job_id = state.job_tracker.create(&index_name, n_chunks).await; + tracing::info!("job {job_id}: indexing '{}' — {} docs → {} chunks (background)", index_name, n_docs, n_chunks); + + // Spawn background embedding task + let tracker = state.job_tracker.clone(); + let ai_client = state.ai_client.clone(); + let obj_store = state.store.clone(); + let jid = job_id.clone(); + + tokio::spawn(async move { + let result = run_embedding_job(&jid, &index_name, &chunks, &ai_client, &obj_store, &tracker).await; + match result { + Ok(key) => { + tracker.complete(&jid, key).await; + tracing::info!("job {jid}: completed"); + } + Err(e) => { + tracker.fail(&jid, e.clone()).await; + tracing::error!("job {jid}: failed — {e}"); + } + } + }); + + Ok((StatusCode::ACCEPTED, Json(CreateIndexResponse { + job_id, + index_name: req.index_name, + documents: n_docs, + chunks: n_chunks, + message: format!("embedding {} chunks in background — poll /vectors/jobs/{{id}} for progress", n_chunks), + }))) +} + +/// Run the actual embedding work in background. +async fn run_embedding_job( + job_id: &str, + index_name: &str, + chunks: &[chunker::TextChunk], + ai_client: &AiClient, + store: &Arc, + tracker: &jobs::JobTracker, +) -> Result { let batch_size = 32; let mut all_vectors: Vec> = Vec::new(); + let start = std::time::Instant::now(); - for batch in chunks.chunks(batch_size) { + for (i, batch) in chunks.chunks(batch_size).enumerate() { let texts: Vec = batch.iter().map(|c| c.text.clone()).collect(); - let embed_resp = state.ai_client.embed(EmbedRequest { + + let embed_resp = ai_client.embed(EmbedRequest { texts, model: None, - }).await.map_err(|e| (StatusCode::BAD_GATEWAY, format!("embed error: {e}")))?; + }).await.map_err(|e| format!("embed batch {} error: {e}", i))?; + all_vectors.extend(embed_resp.embeddings); + + // Update progress + let elapsed = start.elapsed().as_secs_f32(); + let rate = if elapsed > 0.0 { all_vectors.len() as f32 / elapsed } else { 0.0 }; + tracker.update_progress(job_id, all_vectors.len(), rate).await; + + // Log every 100 batches + if (i + 1) % 100 == 0 { + let pct = (all_vectors.len() as f32 / chunks.len() as f32) * 100.0; + let eta = if rate > 0.0 { (chunks.len() - all_vectors.len()) as f32 / rate } else { 0.0 }; + tracing::info!("job {job_id}: {}/{} chunks ({pct:.0}%), {rate:.0}/sec, ETA {eta:.0}s", + all_vectors.len(), chunks.len()); + } } - // 3. Store - let key = store::store_embeddings(&state.store, &req.index_name, &chunks, &all_vectors) - .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?; + // Store + let key = store::store_embeddings(store, index_name, chunks, &all_vectors).await?; + Ok(key) +} - Ok((StatusCode::CREATED, Json(CreateIndexResponse { - index_name: req.index_name, - documents: req.documents.len(), - chunks: chunks.len(), - storage_key: key, - }))) +// --- Job Status --- + +async fn list_jobs(State(state): State) -> impl IntoResponse { + let jobs = state.job_tracker.list().await; + Json(jobs) +} + +async fn get_job( + State(state): State, + Path(id): Path, +) -> impl IntoResponse { + match state.job_tracker.get(&id).await { + Some(job) => Ok(Json(job)), + None => Err((StatusCode::NOT_FOUND, format!("job not found: {id}"))), + } } // --- Search --- @@ -128,7 +193,6 @@ async fn search_index( ) -> impl IntoResponse { let top_k = req.top_k.unwrap_or(5); - // Embed query let embed_resp = state.ai_client.embed(EmbedRequest { texts: vec![req.query.clone()], model: None, @@ -140,7 +204,6 @@ async fn search_index( let query_vec: Vec = embed_resp.embeddings[0].iter().map(|&x| x as f32).collect(); - // Load index and search let embeddings = store::load_embeddings(&state.store, &req.index_name) .await .map_err(|e| (StatusCode::NOT_FOUND, format!("index not found: {e}")))?; diff --git a/data/_catalog/manifests/03b65605-7cce-4a49-b338-4f19b0ff2ed5.json b/data/_catalog/manifests/03b65605-7cce-4a49-b338-4f19b0ff2ed5.json new file mode 100644 index 0000000..c429aae --- /dev/null +++ b/data/_catalog/manifests/03b65605-7cce-4a49-b338-4f19b0ff2ed5.json @@ -0,0 +1,15 @@ +{ + "id": "03b65605-7cce-4a49-b338-4f19b0ff2ed5", + "name": "call_log", + "schema_fingerprint": "auto", + "objects": [ + { + "bucket": "data", + "key": "datasets/call_log.parquet", + "size_bytes": 35951077, + "created_at": "2026-03-27T14:00:44.377704982Z" + } + ], + "created_at": "2026-03-27T14:00:44.377712082Z", + "updated_at": "2026-03-27T14:00:44.377712082Z" +} \ No newline at end of file diff --git a/data/_catalog/manifests/0e4feb1a-1421-46ac-8222-ba0f0bd6e13e.json b/data/_catalog/manifests/0e4feb1a-1421-46ac-8222-ba0f0bd6e13e.json new file mode 100644 index 0000000..4697205 --- /dev/null +++ b/data/_catalog/manifests/0e4feb1a-1421-46ac-8222-ba0f0bd6e13e.json @@ -0,0 +1,15 @@ +{ + "id": "0e4feb1a-1421-46ac-8222-ba0f0bd6e13e", + "name": "email_log", + "schema_fingerprint": "auto", + "objects": [ + { + "bucket": "data", + "key": "datasets/email_log.parquet", + "size_bytes": 16768671, + "created_at": "2026-03-27T14:00:46.272499334Z" + } + ], + "created_at": "2026-03-27T14:00:46.272507485Z", + "updated_at": "2026-03-27T14:00:46.272507485Z" +} \ No newline at end of file diff --git a/data/_catalog/manifests/142c4090-fd14-4065-8c06-d9721c14ec87.json b/data/_catalog/manifests/142c4090-fd14-4065-8c06-d9721c14ec87.json deleted file mode 100644 index c42650e..0000000 --- a/data/_catalog/manifests/142c4090-fd14-4065-8c06-d9721c14ec87.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "id": "142c4090-fd14-4065-8c06-d9721c14ec87", - "name": "candidates", - "schema_fingerprint": "auto", - "objects": [ - { - "bucket": "data", - "key": "datasets/candidates.parquet", - "size_bytes": 10592165, - "created_at": "2026-03-27T13:43:21.924470705Z" - } - ], - "created_at": "2026-03-27T13:43:21.924477421Z", - "updated_at": "2026-03-27T13:43:21.924477421Z" -} \ No newline at end of file diff --git a/data/_catalog/manifests/154cb8fe-5dcb-4d23-8ddb-c95b259757e9.json b/data/_catalog/manifests/154cb8fe-5dcb-4d23-8ddb-c95b259757e9.json new file mode 100644 index 0000000..36020c5 --- /dev/null +++ b/data/_catalog/manifests/154cb8fe-5dcb-4d23-8ddb-c95b259757e9.json @@ -0,0 +1,15 @@ +{ + "id": "154cb8fe-5dcb-4d23-8ddb-c95b259757e9", + "name": "timesheets", + "schema_fingerprint": "auto", + "objects": [ + { + "bucket": "data", + "key": "datasets/timesheets.parquet", + "size_bytes": 17539932, + "created_at": "2026-03-27T14:00:40.845373500Z" + } + ], + "created_at": "2026-03-27T14:00:40.845380446Z", + "updated_at": "2026-03-27T14:00:40.845380446Z" +} \ No newline at end of file diff --git a/data/_catalog/manifests/1e7a1b8d-6211-46b5-b030-02ac76f92564.json b/data/_catalog/manifests/1e7a1b8d-6211-46b5-b030-02ac76f92564.json deleted file mode 100644 index d159fa7..0000000 --- a/data/_catalog/manifests/1e7a1b8d-6211-46b5-b030-02ac76f92564.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "id": "1e7a1b8d-6211-46b5-b030-02ac76f92564", - "name": "email_log", - "schema_fingerprint": "auto", - "objects": [ - { - "bucket": "data", - "key": "datasets/email_log.parquet", - "size_bytes": 16768671, - "created_at": "2026-03-27T13:43:32.341429856Z" - } - ], - "created_at": "2026-03-27T13:43:32.341435388Z", - "updated_at": "2026-03-27T13:43:32.341435388Z" -} \ No newline at end of file diff --git a/data/_catalog/manifests/29c177bd-3728-428a-ab0f-95169aae1106.json b/data/_catalog/manifests/29c177bd-3728-428a-ab0f-95169aae1106.json deleted file mode 100644 index 0bc3fa4..0000000 --- a/data/_catalog/manifests/29c177bd-3728-428a-ab0f-95169aae1106.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "id": "29c177bd-3728-428a-ab0f-95169aae1106", - "name": "timesheets", - "schema_fingerprint": "auto", - "objects": [ - { - "bucket": "data", - "key": "datasets/timesheets.parquet", - "size_bytes": 17539932, - "created_at": "2026-03-27T13:43:26.951181242Z" - } - ], - "created_at": "2026-03-27T13:43:26.951188331Z", - "updated_at": "2026-03-27T13:43:26.951188331Z" -} \ No newline at end of file diff --git a/data/_catalog/manifests/812e7d9a-0f50-49c0-b121-4cf758c304d9.json b/data/_catalog/manifests/812e7d9a-0f50-49c0-b121-4cf758c304d9.json deleted file mode 100644 index 1a2c7fa..0000000 --- a/data/_catalog/manifests/812e7d9a-0f50-49c0-b121-4cf758c304d9.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "id": "812e7d9a-0f50-49c0-b121-4cf758c304d9", - "name": "placements", - "schema_fingerprint": "auto", - "objects": [ - { - "bucket": "data", - "key": "datasets/placements.parquet", - "size_bytes": 1213820, - "created_at": "2026-03-27T13:43:22.173146233Z" - } - ], - "created_at": "2026-03-27T13:43:22.173152301Z", - "updated_at": "2026-03-27T13:43:22.173152301Z" -} \ No newline at end of file diff --git a/data/_catalog/manifests/91413428-b4b1-44b3-bb8d-5cb326019879.json b/data/_catalog/manifests/91413428-b4b1-44b3-bb8d-5cb326019879.json deleted file mode 100644 index f38a50d..0000000 --- a/data/_catalog/manifests/91413428-b4b1-44b3-bb8d-5cb326019879.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "id": "91413428-b4b1-44b3-bb8d-5cb326019879", - "name": "job_orders", - "schema_fingerprint": "auto", - "objects": [ - { - "bucket": "data", - "key": "datasets/job_orders.parquet", - "size_bytes": 905534, - "created_at": "2026-03-27T13:43:22.036039453Z" - } - ], - "created_at": "2026-03-27T13:43:22.036045131Z", - "updated_at": "2026-03-27T13:43:22.036045131Z" -} \ No newline at end of file diff --git a/data/_catalog/manifests/9bb57bf9-2c19-42ed-84f4-83fd3c52b94a.json b/data/_catalog/manifests/9bb57bf9-2c19-42ed-84f4-83fd3c52b94a.json deleted file mode 100644 index 2505a5a..0000000 --- a/data/_catalog/manifests/9bb57bf9-2c19-42ed-84f4-83fd3c52b94a.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "id": "9bb57bf9-2c19-42ed-84f4-83fd3c52b94a", - "name": "clients", - "schema_fingerprint": "auto", - "objects": [ - { - "bucket": "data", - "key": "datasets/clients.parquet", - "size_bytes": 21971, - "created_at": "2026-03-27T13:43:21.933347525Z" - } - ], - "created_at": "2026-03-27T13:43:21.933351887Z", - "updated_at": "2026-03-27T13:43:21.933351887Z" -} \ No newline at end of file diff --git a/data/_catalog/manifests/d2ce2995-9c60-49c9-9b41-197020cebaae.json b/data/_catalog/manifests/d2ce2995-9c60-49c9-9b41-197020cebaae.json new file mode 100644 index 0000000..ce7d83c --- /dev/null +++ b/data/_catalog/manifests/d2ce2995-9c60-49c9-9b41-197020cebaae.json @@ -0,0 +1,15 @@ +{ + "id": "d2ce2995-9c60-49c9-9b41-197020cebaae", + "name": "placements", + "schema_fingerprint": "auto", + "objects": [ + { + "bucket": "data", + "key": "datasets/placements.parquet", + "size_bytes": 1213820, + "created_at": "2026-03-27T14:00:35.885543632Z" + } + ], + "created_at": "2026-03-27T14:00:35.885550623Z", + "updated_at": "2026-03-27T14:00:35.885550623Z" +} \ No newline at end of file diff --git a/data/_catalog/manifests/d8170213-d6af-4478-ae23-59f06fda3165.json b/data/_catalog/manifests/d8170213-d6af-4478-ae23-59f06fda3165.json new file mode 100644 index 0000000..7cc6c99 --- /dev/null +++ b/data/_catalog/manifests/d8170213-d6af-4478-ae23-59f06fda3165.json @@ -0,0 +1,15 @@ +{ + "id": "d8170213-d6af-4478-ae23-59f06fda3165", + "name": "job_orders", + "schema_fingerprint": "auto", + "objects": [ + { + "bucket": "data", + "key": "datasets/job_orders.parquet", + "size_bytes": 905534, + "created_at": "2026-03-27T14:00:35.780022147Z" + } + ], + "created_at": "2026-03-27T14:00:35.780029168Z", + "updated_at": "2026-03-27T14:00:35.780029168Z" +} \ No newline at end of file diff --git a/data/_catalog/manifests/e1607b56-a826-4826-845a-76918127c6bf.json b/data/_catalog/manifests/e1607b56-a826-4826-845a-76918127c6bf.json deleted file mode 100644 index 0cc4c39..0000000 --- a/data/_catalog/manifests/e1607b56-a826-4826-845a-76918127c6bf.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "id": "e1607b56-a826-4826-845a-76918127c6bf", - "name": "call_log", - "schema_fingerprint": "auto", - "objects": [ - { - "bucket": "data", - "key": "datasets/call_log.parquet", - "size_bytes": 35951077, - "created_at": "2026-03-27T13:43:30.485776088Z" - } - ], - "created_at": "2026-03-27T13:43:30.485783579Z", - "updated_at": "2026-03-27T13:43:30.485783579Z" -} \ No newline at end of file diff --git a/data/_catalog/manifests/e26d3633-a341-4229-9819-f287d98b788a.json b/data/_catalog/manifests/e26d3633-a341-4229-9819-f287d98b788a.json new file mode 100644 index 0000000..6e7695d --- /dev/null +++ b/data/_catalog/manifests/e26d3633-a341-4229-9819-f287d98b788a.json @@ -0,0 +1,15 @@ +{ + "id": "e26d3633-a341-4229-9819-f287d98b788a", + "name": "candidates", + "schema_fingerprint": "auto", + "objects": [ + { + "bucket": "data", + "key": "datasets/candidates.parquet", + "size_bytes": 10592165, + "created_at": "2026-03-27T14:00:35.662150713Z" + } + ], + "created_at": "2026-03-27T14:00:35.662162510Z", + "updated_at": "2026-03-27T14:00:35.662162510Z" +} \ No newline at end of file diff --git a/data/_catalog/manifests/e4b8441f-d729-4465-91fb-2ed5f481e65d.json b/data/_catalog/manifests/e4b8441f-d729-4465-91fb-2ed5f481e65d.json new file mode 100644 index 0000000..beca83a --- /dev/null +++ b/data/_catalog/manifests/e4b8441f-d729-4465-91fb-2ed5f481e65d.json @@ -0,0 +1,15 @@ +{ + "id": "e4b8441f-d729-4465-91fb-2ed5f481e65d", + "name": "clients", + "schema_fingerprint": "auto", + "objects": [ + { + "bucket": "data", + "key": "datasets/clients.parquet", + "size_bytes": 21971, + "created_at": "2026-03-27T14:00:35.670181596Z" + } + ], + "created_at": "2026-03-27T14:00:35.670184688Z", + "updated_at": "2026-03-27T14:00:35.670184688Z" +} \ No newline at end of file diff --git a/data/workspaces/ws-1774619041730.json b/data/workspaces/ws-1774619041730.json deleted file mode 100644 index d882dfe..0000000 --- a/data/workspaces/ws-1774619041730.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "id": "ws-1774619041730", - "name": "Apex Corp - .NET Developers Chicago", - "description": "Fill 5 .NET developer positions for Apex Corp, downtown Chicago, $65-85/hr bill rate", - "tier": "weekly", - "owner": "Sarah", - "previous_owners": [], - "created_at": "2026-03-27T13:44:01.730143708Z", - "updated_at": "2026-03-27T13:44:08.530268827Z", - "saved_searches": [ - { - "name": "Chicago .NET active candidates", - "sql": "SELECT candidate_id, first_name, last_name, phone, email, years_experience FROM candidates WHERE city = 'Chicago' AND skills LIKE '%.NET%' AND status = 'active' ORDER BY years_experience DESC", - "created_at": "2026-03-27T13:44:01.731891844Z" - }, - { - "name": "test", - "sql": "SELECT 1", - "created_at": "2026-03-27T13:44:08.530262069Z" - } - ], - "shortlist": [], - "activity": [ - { - "action": "search", - "detail": "saved search: Chicago .NET active candidates", - "timestamp": "2026-03-27T13:44:01.731898474Z", - "agent": "Sarah" - }, - { - "action": "search", - "detail": "saved search: test", - "timestamp": "2026-03-27T13:44:08.530268200Z", - "agent": "Sarah" - } - ], - "ingested_datasets": [], - "delta_keys": [], - "tags": [] -} \ No newline at end of file diff --git a/data/workspaces/ws-1774619071313.json b/data/workspaces/ws-1774619071313.json deleted file mode 100644 index d5c4097..0000000 --- a/data/workspaces/ws-1774619071313.json +++ /dev/null @@ -1,130 +0,0 @@ -{ - "id": "ws-1774619071313", - "name": "Apex Corp - .NET Developers Chicago", - "description": "Fill 5 .NET developer positions, downtown Chicago, $65-85/hr", - "tier": "weekly", - "owner": "Mike", - "previous_owners": [ - { - "from_agent": "Sarah", - "to_agent": "Mike", - "reason": "Sarah on PTO, Mike covering Apex account", - "timestamp": "2026-03-27T13:44:31.531544562Z" - } - ], - "created_at": "2026-03-27T13:44:31.313179900Z", - "updated_at": "2026-03-27T13:44:31.534554639Z", - "saved_searches": [ - { - "name": "Chicago .NET active", - "sql": "SELECT candidate_id, first_name, last_name, phone, years_experience FROM candidates WHERE city = 'Chicago' AND skills LIKE '%.NET%' AND status = 'active' ORDER BY years_experience DESC", - "created_at": "2026-03-27T13:44:31.314740279Z" - }, - { - "name": "High-bill .NET history", - "sql": "SELECT p.candidate_id, c.first_name, c.last_name, p.bill_rate FROM placements p JOIN candidates c ON p.candidate_id = c.candidate_id JOIN job_orders j ON p.job_order_id = j.job_order_id WHERE j.title LIKE '%.NET%' AND p.bill_rate > 60 ORDER BY p.bill_rate DESC LIMIT 20", - "created_at": "2026-03-27T13:44:31.315923201Z" - } - ], - "shortlist": [ - { - "dataset": "candidates", - "record_id": "CAND-006645", - "notes": "Joseph Hill — 30yr .NET exp", - "added_at": "2026-03-27T13:44:31.524757463Z", - "added_by": "Sarah" - }, - { - "dataset": "candidates", - "record_id": "CAND-020078", - "notes": "Jessica Jones — 30yr .NET exp", - "added_at": "2026-03-27T13:44:31.525965891Z", - "added_by": "Sarah" - }, - { - "dataset": "candidates", - "record_id": "CAND-015656", - "notes": "Barbara Wright — 30yr .NET exp", - "added_at": "2026-03-27T13:44:31.527152483Z", - "added_by": "Sarah" - }, - { - "dataset": "candidates", - "record_id": "CAND-00099", - "notes": "Mike found additional candidate via LinkedIn", - "added_at": "2026-03-27T13:44:31.534551709Z", - "added_by": "Mike" - } - ], - "activity": [ - { - "action": "search", - "detail": "saved search: Chicago .NET active", - "timestamp": "2026-03-27T13:44:31.314743876Z", - "agent": "Sarah" - }, - { - "action": "search", - "detail": "saved search: High-bill .NET history", - "timestamp": "2026-03-27T13:44:31.315925687Z", - "agent": "Sarah" - }, - { - "action": "shortlist", - "detail": "added CAND-006645 from candidates", - "timestamp": "2026-03-27T13:44:31.524762385Z", - "agent": "Sarah" - }, - { - "action": "shortlist", - "detail": "added CAND-020078 from candidates", - "timestamp": "2026-03-27T13:44:31.525968748Z", - "agent": "Sarah" - }, - { - "action": "shortlist", - "detail": "added CAND-015656 from candidates", - "timestamp": "2026-03-27T13:44:31.527155126Z", - "agent": "Sarah" - }, - { - "action": "call", - "detail": "Called top 3 candidates, 2 interested", - "timestamp": "2026-03-27T13:44:31.528254640Z", - "agent": "Sarah" - }, - { - "action": "email", - "detail": "Sent job descriptions to shortlist", - "timestamp": "2026-03-27T13:44:31.529452236Z", - "agent": "Sarah" - }, - { - "action": "update", - "detail": "Candidate CAND-00025 confirmed for Thursday interview", - "timestamp": "2026-03-27T13:44:31.530540919Z", - "agent": "Sarah" - }, - { - "action": "handoff", - "detail": "handed off to Mike — Sarah on PTO, Mike covering Apex account", - "timestamp": "2026-03-27T13:44:31.531546876Z", - "agent": "Mike" - }, - { - "action": "call", - "detail": "Followed up with CAND-00025, interview confirmed", - "timestamp": "2026-03-27T13:44:31.533529588Z", - "agent": "Mike" - }, - { - "action": "shortlist", - "detail": "added CAND-00099 from candidates", - "timestamp": "2026-03-27T13:44:31.534554347Z", - "agent": "Mike" - } - ], - "ingested_datasets": [], - "delta_keys": [], - "tags": [] -} \ No newline at end of file