Skip to main content

sqlite_graphrag/commands/
pending.rs

1//! GAP-001 (v1.0.82): `pending` subcommand — inspect and manage the
2//! three-stage `remember` checkpoint queue persisted in `pending_memories`.
3//!
4//! ## Subcommands
5//! - `pending list [--status <STATUS>]` — show entries by status
6//! - `pending show <pending_id>` — show one entry in full
7//! - `pending cleanup --staged-cleanup-after <SECONDS>` — remove old abandoned
8//!
9//! The `pending` table is the durable footprint of the v1.0.82 staging pipeline
10//! (Stage A → B → C). When a host crashes between Stage B and Stage C the entry
11//! stays in `embedding_done` (or `embedding_in_progress`) and can be inspected
12//! or cleaned via this subcommand.
13
14use clap::{Args, Subcommand};
15use serde::Serialize;
16
17use crate::errors::AppError;
18use crate::output::emit_json_compact;
19use crate::paths::AppPaths;
20use crate::storage::connection::open_rw;
21use crate::storage::pending_memories::{self, PendingMemory, PendingStatus};
22
23#[derive(Debug, Args)]
24#[command(after_long_help = "EXAMPLES:\n  \
25    # List all entries currently waiting for embedding (Stage A done, Stage B pending)\n  \
26    sqlite-graphrag pending list --status validated --json\n\n  \
27    # Show the full record of pending_id 42\n  \
28    sqlite-graphrag pending show 42 --json\n\n  \
29    # Clean up entries abandoned for >24h (86400 seconds)\n  \
30    sqlite-graphrag pending cleanup --staged-cleanup-after 86400 --yes")]
31/// Pending args.
32pub struct PendingArgs {
33    /// Cmd.
34    #[command(subcommand)]
35    pub cmd: PendingCmd,
36}
37
38/// Pending cmd.
39#[derive(Debug, Subcommand)]
40pub enum PendingCmd {
41    /// List entries by status (defaults to all non-committed).
42    List(PendingListArgs),
43    /// Show one entry in full (includes body, entities_json, embedding_dim).
44    Show(PendingShowArgs),
45    /// Remove entries older than `--staged-cleanup-after` seconds.
46    Cleanup(PendingCleanupArgs),
47}
48
49/// Pending list args.
50#[derive(Debug, Args)]
51pub struct PendingListArgs {
52    /// Filter by status: validated | embedding_in_progress | embedding_done |
53    /// committed | abandoned | failed. Default: all.
54    #[arg(long, value_enum)]
55    pub status: Option<PendingStatusArg>,
56    /// Maximum number of entries to return. Default: 100.
57    #[arg(long, default_value_t = 100)]
58    pub limit: usize,
59    /// GAP-E2E-010b (v1.0.89): explicit database path override. Defaults to
60    /// the path resolved by `AppPaths::resolve(None)` when omitted. Honors
61    /// flag `--db` / XDG `db.path`.
62    #[arg(long)]
63    pub db: Option<String>,
64    /// JSON output (always on; accepted for CLI consistency).
65    #[arg(long, hide = true)]
66    pub json: bool,
67}
68
69/// Pending status arg.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
71#[value(rename_all = "snake_case")]
72pub enum PendingStatusArg {
73    /// Validated variant.
74    Validated,
75    /// Embedding in progress variant.
76    EmbeddingInProgress,
77    /// Embedding done variant.
78    EmbeddingDone,
79    /// Committed variant.
80    Committed,
81    /// Abandoned variant.
82    Abandoned,
83    /// Failed variant.
84    Failed,
85}
86
87impl From<PendingStatusArg> for PendingStatus {
88    fn from(value: PendingStatusArg) -> Self {
89        match value {
90            PendingStatusArg::Validated => Self::Validated,
91            PendingStatusArg::EmbeddingInProgress => Self::EmbeddingInProgress,
92            PendingStatusArg::EmbeddingDone => Self::EmbeddingDone,
93            PendingStatusArg::Committed => Self::Committed,
94            PendingStatusArg::Abandoned => Self::Abandoned,
95            PendingStatusArg::Failed => Self::Failed,
96        }
97    }
98}
99
100/// Pending show args.
101#[derive(Debug, Args)]
102pub struct PendingShowArgs {
103    /// Pending id returned by `remember --stage-only`.
104    pub pending_id: i64,
105    /// GAP-E2E-010b (v1.0.89): explicit database path override. Defaults to
106    /// the path resolved by `AppPaths::resolve(None)` when omitted. Honors
107    /// flag `--db` / XDG `db.path`.
108    #[arg(long)]
109    pub db: Option<String>,
110    /// JSON output (always on; accepted for CLI consistency).
111    #[arg(long, hide = true)]
112    pub json: bool,
113}
114
115/// Pending cleanup args.
116#[derive(Debug, Args)]
117pub struct PendingCleanupArgs {
118    /// Age in seconds after which an entry is eligible for cleanup.
119    #[arg(long, default_value_t = 86400)]
120    pub staged_cleanup_after: u64,
121    /// Skip the interactive confirmation prompt.
122    #[arg(long)]
123    pub yes: bool,
124    /// Dry-run: list what would be removed without touching the database.
125    #[arg(long)]
126    pub dry_run: bool,
127    /// Explicit database path override. Defaults to the path resolved by
128    /// `AppPaths::resolve(None)` when omitted (`--db` / XDG `db.path`).
129    #[arg(long)]
130    pub db: Option<String>,
131    /// JSON output (always on; accepted for CLI consistency).
132    #[arg(long, hide = true)]
133    pub json: bool,
134}
135
136#[derive(Serialize)]
137struct PendingListEntry {
138    pending_id: i64,
139    name: String,
140    namespace: String,
141    memory_type: String,
142    status: String,
143    attempt_count: i32,
144    last_error: Option<String>,
145    embedding_dim: Option<i32>,
146    created_at: i64,
147    updated_at: i64,
148}
149
150impl From<&PendingMemory> for PendingListEntry {
151    fn from(p: &PendingMemory) -> Self {
152        Self {
153            pending_id: p.pending_id,
154            name: p.name.clone(),
155            namespace: p.namespace.clone(),
156            memory_type: p.memory_type.clone(),
157            status: p.status.as_str().to_string(),
158            attempt_count: p.attempt_count,
159            last_error: p.last_error.clone(),
160            embedding_dim: p.embedding_dim,
161            created_at: p.created_at,
162            updated_at: p.updated_at,
163        }
164    }
165}
166
167#[derive(Serialize)]
168struct PendingListOutput {
169    action: &'static str,
170    filter_status: Option<String>,
171    count: usize,
172    entries: Vec<PendingListEntry>,
173    elapsed_ms: u64,
174}
175
176#[derive(Serialize)]
177struct PendingShowOutput {
178    action: &'static str,
179    entry: PendingMemory,
180    elapsed_ms: u64,
181}
182
183#[derive(Serialize)]
184struct PendingCleanupOutput {
185    action: &'static str,
186    dry_run: bool,
187    staged_cleanup_after_secs: u64,
188    candidates: usize,
189    removed: usize,
190    elapsed_ms: u64,
191    yes: bool,
192}
193
194/// Run.
195pub fn run(args: PendingArgs) -> Result<(), AppError> {
196    match args.cmd {
197        PendingCmd::List(a) => run_list(a),
198        PendingCmd::Show(a) => run_show(a),
199        PendingCmd::Cleanup(a) => run_cleanup(a),
200    }
201}
202
203fn open_conn(db_override: Option<&str>) -> Result<(AppPaths, rusqlite::Connection), AppError> {
204    // GAP-E2E-010b (v1.0.89): honor `--db <PATH>` for parity with the
205    // rest of the CLI surface. `AppPaths::resolve` accepts the same value
206    // passed by callers of other subcommands, keeping path semantics
207    // consistent across the entire command surface.
208    let paths = AppPaths::resolve(db_override)?;
209    let conn = open_rw(&paths.db)?;
210    Ok((paths, conn))
211}
212
213fn run_list(args: PendingListArgs) -> Result<(), AppError> {
214    let start = std::time::Instant::now();
215    let (_paths, conn) = open_conn(args.db.as_deref())?;
216
217    // If a status filter was provided, query that single status. Otherwise return
218    // all six buckets so the operator can see the full staging landscape.
219    let entries: Vec<PendingMemory> = if let Some(status) = args.status {
220        pending_memories::list_by_status(&conn, status.into(), args.limit)?
221    } else {
222        let mut all = Vec::new();
223        for status in [
224            PendingStatus::EmbeddingInProgress,
225            PendingStatus::EmbeddingDone,
226            PendingStatus::Validated,
227            PendingStatus::Abandoned,
228            PendingStatus::Failed,
229        ] {
230            let mut bucket = pending_memories::list_by_status(&conn, status, args.limit)?;
231            all.append(&mut bucket);
232        }
233        all.truncate(args.limit);
234        all
235    };
236
237    let count = entries.len();
238    let entries_out: Vec<PendingListEntry> = entries.iter().map(PendingListEntry::from).collect();
239    let output = PendingListOutput {
240        action: "pending_list",
241        filter_status: args.status.map(|s| {
242            match s {
243                PendingStatusArg::Validated => "validated",
244                PendingStatusArg::EmbeddingInProgress => "embedding_in_progress",
245                PendingStatusArg::EmbeddingDone => "embedding_done",
246                PendingStatusArg::Committed => "committed",
247                PendingStatusArg::Abandoned => "abandoned",
248                PendingStatusArg::Failed => "failed",
249            }
250            .to_string()
251        }),
252        count,
253        entries: entries_out,
254        elapsed_ms: start.elapsed().as_millis() as u64,
255    };
256    emit_json_compact(&output)
257}
258
259fn run_show(args: PendingShowArgs) -> Result<(), AppError> {
260    let start = std::time::Instant::now();
261    let (_paths, conn) = open_conn(args.db.as_deref())?;
262    let entry = pending_memories::find_by_id(&conn, args.pending_id)?.ok_or_else(|| {
263        AppError::NotFound(format!(
264            "pending_id {} not found in pending_memories",
265            args.pending_id
266        ))
267    })?;
268    let output = PendingShowOutput {
269        action: "pending_show",
270        entry,
271        elapsed_ms: start.elapsed().as_millis() as u64,
272    };
273    emit_json_compact(&output)
274}
275
276fn run_cleanup(args: PendingCleanupArgs) -> Result<(), AppError> {
277    let start = std::time::Instant::now();
278    let (_paths, conn) = open_conn(args.db.as_deref())?;
279
280    // Count candidates first so dry-run is non-mutating.
281    let candidates = pending_memories::list_by_status(&conn, PendingStatus::Abandoned, 100_000)?
282        .into_iter()
283        .filter(|p| {
284            let now = chrono::Utc::now().timestamp();
285            now - p.updated_at >= args.staged_cleanup_after as i64
286        })
287        .count();
288
289    let removed = if args.dry_run {
290        0
291    } else {
292        pending_memories::cleanup_older_than(&conn, args.staged_cleanup_after as i64)?
293    };
294
295    let output = PendingCleanupOutput {
296        action: if args.dry_run {
297            "pending_cleanup_dry_run"
298        } else {
299            "pending_cleanup"
300        },
301        dry_run: args.dry_run,
302        staged_cleanup_after_secs: args.staged_cleanup_after,
303        candidates,
304        removed,
305        elapsed_ms: start.elapsed().as_millis() as u64,
306        yes: args.yes,
307    };
308    emit_json_compact(&output)
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    #[test]
316    fn status_arg_round_trip_all_variants() {
317        for arg in [
318            PendingStatusArg::Validated,
319            PendingStatusArg::EmbeddingInProgress,
320            PendingStatusArg::EmbeddingDone,
321            PendingStatusArg::Committed,
322            PendingStatusArg::Abandoned,
323            PendingStatusArg::Failed,
324        ] {
325            let status: PendingStatus = arg.into();
326            assert_eq!(status.as_str(), arg_to_str(arg));
327        }
328    }
329
330    fn arg_to_str(arg: PendingStatusArg) -> &'static str {
331        match arg {
332            PendingStatusArg::Validated => "validated",
333            PendingStatusArg::EmbeddingInProgress => "embedding_in_progress",
334            PendingStatusArg::EmbeddingDone => "embedding_done",
335            PendingStatusArg::Committed => "committed",
336            PendingStatusArg::Abandoned => "abandoned",
337            PendingStatusArg::Failed => "failed",
338        }
339    }
340}