@@ -424,133 +424,122 @@ pub fn session_exists_by_id(data_home: &Path, session_id: &str) -> Result<bool,
424424 }
425425}
426426
427- /// A resume-resolve by-id fallback HIT: Node's `resolveOpencodeSessionRoots`
428- /// walk resolved the requested id.
429- #[ derive( Debug , Clone , PartialEq , Eq ) ]
430- pub struct OpencodeSessionDirectory {
431- /// The requested row's OWN `directory` column — the SPAWN cwd opencode
432- /// resumes in (`resolve-session.ts:77-84`: NOT the project root) — kept
433- /// only when TRUTHY (`opencode.ts:265-267, 281`). `None` for an empty or
434- /// NULL `directory` and for EVERY legacy-schema hit (Node's early return
435- /// never reads the row). `None` ⇒ the wire match OMITS `cwd`.
436- pub directory : Option < String > ,
427+ /// SHORT busy timeout (`opencode-by-id-query.ts:12`): a locked DB must fail
428+ /// FAST — the failure surfaces as provider-unavailable, never "not found".
429+ const OPENCODE_BYID_BUSY_TIMEOUT_MS : u64 = 500 ;
430+
431+ /// Code-PRESERVING error for the by-id query (the plain `OpencodeReadError`
432+ /// stays for its other consumers). Node's thrown sqlite errors carry a
433+ /// `.code` like `SQLITE_CANTOPEN` at the QUERY layer — but Node's production
434+ /// worker boundary then STRIPS it (`opencode-by-id.worker.ts:41-42`
435+ /// serializes only `{name, message}`; `opencode-by-id-runner.ts:103-106`
436+ /// rebuilds the Error without `.code`), so the code never reaches the wire.
437+ /// We keep the code HERE for structured logging and precise messages; the
438+ /// production closure (Task 6 Step 3b) deliberately maps it to
439+ /// `ProviderFailure { code: None, .. }` — wire parity is message-only for
440+ /// opencode.
441+ #[ derive( Debug , Clone , PartialEq ) ]
442+ pub struct OpencodeByIdError {
443+ pub code : Option < String > ,
444+ pub message : String ,
437445}
438446
439- /// One row of the walk: `(directory, parent_id)` for an id, `None` = no row.
440- type SessionRow = ( Option < String > , Option < String > ) ;
441-
442- fn fetch_session_row (
443- conn : & Connection ,
444- session_id : & str ,
445- ) -> Result < Option < SessionRow > , OpencodeReadError > {
446- match conn. query_row (
447- "SELECT directory, parent_id FROM session WHERE id = ?1" ,
448- rusqlite:: params![ session_id] ,
449- |row| {
450- Ok ( (
451- row. get :: < _ , Option < String > > ( 0 ) ?,
452- row. get :: < _ , Option < String > > ( 1 ) ?,
453- ) )
454- } ,
455- ) {
456- Ok ( row) => Ok ( Some ( row) ) ,
457- Err ( rusqlite:: Error :: QueryReturnedNoRows ) => Ok ( None ) ,
458- Err ( e) => Err ( OpencodeReadError ( e. to_string ( ) ) ) ,
447+ /// Map a rusqlite error to the Node-style `SQLITE_*` code name via
448+ /// `rusqlite::Error::sqlite_error_code()` (available in the pinned 0.31.0).
449+ fn by_id_err ( e : rusqlite:: Error ) -> OpencodeByIdError {
450+ use rusqlite:: ffi:: ErrorCode as C ;
451+ let code = e. sqlite_error_code ( ) . and_then ( |c| match c {
452+ C :: CannotOpen => Some ( "SQLITE_CANTOPEN" ) ,
453+ C :: DatabaseBusy => Some ( "SQLITE_BUSY" ) ,
454+ C :: DatabaseLocked => Some ( "SQLITE_LOCKED" ) ,
455+ C :: NotADatabase => Some ( "SQLITE_NOTADB" ) ,
456+ C :: PermissionDenied => Some ( "SQLITE_PERM" ) ,
457+ C :: ReadOnly => Some ( "SQLITE_READONLY" ) ,
458+ _ => None ,
459+ } ) ;
460+ OpencodeByIdError {
461+ code : code. map ( str:: to_string) ,
462+ message : e. to_string ( ) ,
459463 }
460464}
461465
462- /// Resume-resolve by-id lookup — a bug-for-bug port of Node's
463- /// `OpencodeProvider.resolveOpencodeSessionRoots`
464- /// (`server/coding-cli/providers/opencode.ts:239-323`). NOTE the Node
465- /// consumer has since moved on: the RETIRED pre-#586 resolve consumed this
466- /// walk directly; hardened Node resolves opencode ids via
467- /// `resolve-session.ts` → `resolve-fallbacks.ts` → the by-id worker
468- /// (`providers/opencode-by-id-query.ts`, a DIRECT row query). This walk
469- /// remains the Rust resolve fallback's interim lookup — a recorded
470- /// divergence, see `resume_resolve.rs`. This is deliberately NOT the attach-arm
471- /// existence probe: Node walks the `parent_id` chain, and every quirk of
472- /// that walk is wire-observable, so all are replicated:
473- ///
474- /// - LEGACY schema (`session` lacks `parent_id`, detected with the same
475- /// `PRAGMA table_info(session)` probe the listing uses): return a HIT with
476- /// `directory: None` for ANY requested id — Node returns early
477- /// (`opencode.ts:246-250`) with NO row query and NO existence check, so
478- /// even nonexistent ids hit and existing directories are never read.
479- /// - MODERN schema: fetch the requested row (missing row ⇒ `Ok(None)`);
480- /// keep its OWN `directory` only if non-empty (truthy filter,
481- /// `opencode.ts:265-267, 281`); then walk `parent_id` with a `seen` set —
482- /// a missing parent row (`opencode.ts:292-295`) or a cycle
483- /// (`opencode.ts:287-290`) marks the requested id unresolved ⇒ `Ok(None)`
484- /// even though the row exists; reaching a root (`parent_id` NULL) ⇒ HIT.
485- ///
486- /// Same read-only open and short busy timeout as [`session_exists_by_id`].
487- /// `Err` for ANY read failure — the resolve endpoint treats `Err` as a miss
488- /// (empty matches), never a 5xx (Node likewise degrades: 3 retries then all
489- /// ids unresolved, `opencode.ts:239-322`).
490- pub fn opencode_session_directory_by_id (
466+ /// The hardened exact-id row (`OpencodeSessionRow` subset the by-id query
467+ /// selects). `last_activity_at` floored to integer ms (REAL columns possible).
468+ #[ derive( Debug , Clone , PartialEq ) ]
469+ pub struct OpencodeByIdRow {
470+ pub session_id : String ,
471+ pub cwd : Option < String > ,
472+ pub title : Option < String > ,
473+ pub created_at : Option < i64 > ,
474+ pub last_activity_at : Option < i64 > ,
475+ pub project_path : Option < String > ,
476+ }
477+
478+ /// Hardened (#586) exact-id lookup — 1:1 port of
479+ /// `runOpencodeSessionByIdQuery` (`opencode-by-id-query.ts`). Deliberately
480+ /// includes ARCHIVED and CHILD sessions: an exact id pasted by the user must
481+ /// resolve even when the listing hides it. Errors PROPAGATE (a missing or
482+ /// unreadable DB file is `Err`, matching Node's throwing `DatabaseSync`
483+ /// open — provider unavailable ≠ not found).
484+ pub fn opencode_session_row_by_id (
491485 data_home : & Path ,
492486 session_id : & str ,
493- ) -> Result < Option < OpencodeSessionDirectory > , OpencodeReadError > {
487+ ) -> Result < Option < OpencodeByIdRow > , OpencodeByIdError > {
494488 let db_path = data_home. join ( "opencode.db" ) ;
495- if !db_path. exists ( ) {
496- return Ok ( None ) ;
497- }
498489 let conn = Connection :: open_with_flags (
499490 & db_path,
500491 OpenFlags :: SQLITE_OPEN_READ_ONLY | OpenFlags :: SQLITE_OPEN_URI ,
501492 )
502- . map_err ( |e| OpencodeReadError ( e . to_string ( ) ) ) ?;
493+ . map_err ( by_id_err ) ?;
503494 conn. busy_timeout ( std:: time:: Duration :: from_millis (
504- EXISTENCE_BY_ID_BUSY_TIMEOUT_MS ,
495+ OPENCODE_BYID_BUSY_TIMEOUT_MS ,
505496 ) )
506- . map_err ( |e| OpencodeReadError ( e . to_string ( ) ) ) ?;
497+ . map_err ( by_id_err ) ?;
507498
508- // PRAGMA table_info(session) -> hasParentId (same detection as the
509- // listing's `run_opencode_query_inner`).
510- let has_parent_id = {
499+ let table_names: std:: collections:: HashSet < String > = {
511500 let mut stmt = conn
512- . prepare ( "PRAGMA table_info(session)" )
513- . map_err ( |e| OpencodeReadError ( e. to_string ( ) ) ) ?;
514- let names = stmt
515- . query_map ( [ ] , |row| row. get :: < _ , String > ( 1 ) )
516- . map_err ( |e| OpencodeReadError ( e. to_string ( ) ) ) ?;
517- let mut found = false ;
518- for name in names {
519- if name. map_err ( |e| OpencodeReadError ( e. to_string ( ) ) ) ? == "parent_id" {
520- found = true ;
521- }
501+ . prepare ( "SELECT name FROM sqlite_master WHERE type = 'table'" )
502+ . map_err ( by_id_err) ?;
503+ let rows = stmt
504+ . query_map ( [ ] , |row| row. get :: < _ , String > ( 0 ) )
505+ . map_err ( by_id_err) ?;
506+ let mut set = std:: collections:: HashSet :: new ( ) ;
507+ for r in rows {
508+ set. insert ( r. map_err ( by_id_err) ?) ;
522509 }
523- found
510+ set
524511 } ;
525- if !has_parent_id {
526- // Node's legacy early return (`opencode.ts:246-250`): every requested
527- // id resolves as its own root — no row query, no existence check, no
528- // directory read. Bug-for-bug: nonexistent ids HIT, `cwd` omitted.
529- return Ok ( Some ( OpencodeSessionDirectory { directory : None } ) ) ;
530- }
531-
532- let Some ( ( directory, first_parent) ) = fetch_session_row ( & conn, session_id) ? else {
512+ if !table_names. contains ( "session" ) {
533513 return Ok ( None ) ;
514+ }
515+ let has_project = table_names. contains ( "project" ) ;
516+ let project_select = if has_project { "p.worktree" } else { "NULL" } ;
517+ let project_join = if has_project {
518+ "LEFT JOIN project p ON p.id = s.project_id"
519+ } else {
520+ ""
534521 } ;
535- // Truthy filter (`opencode.ts:265-267, 281`): empty string ⇒ no cwd.
536- let directory = directory. filter ( |d| !d. is_empty ( ) ) ;
537-
538- // Parent walk (`opencode.ts:283-303`): a missing parent or a cycle marks
539- // the REQUESTED id unresolved (`resolve-session.ts:66`) ⇒ miss, even
540- // though its own row exists and its directory was already collected.
541- let mut seen: std:: collections:: HashSet < String > = std:: collections:: HashSet :: new ( ) ;
542- seen. insert ( session_id. to_string ( ) ) ;
543- let mut parent = first_parent;
544- while let Some ( current) = parent {
545- if !seen. insert ( current. clone ( ) ) {
546- return Ok ( None ) ; // cycle guard (`opencode.ts:287-290`)
547- }
548- match fetch_session_row ( & conn, & current) ? {
549- None => return Ok ( None ) , // missing parent (`opencode.ts:292-295`)
550- Some ( ( _, next_parent) ) => parent = next_parent,
551- }
522+ let sql = format ! (
523+ "SELECT s.id, s.directory, s.title, s.time_created, s.time_updated, \
524+ {project_select} FROM session s {project_join} WHERE s.id = ?1 LIMIT 1"
525+ ) ;
526+ match conn. query_row ( & sql, rusqlite:: params![ session_id] , |row| {
527+ Ok ( OpencodeByIdRow {
528+ session_id : match row. get :: < _ , SqlValue > ( 0 ) ? {
529+ SqlValue :: Text ( s) => s,
530+ other => to_opt_string ( & other) . unwrap_or_default ( ) ,
531+ } ,
532+ cwd : to_opt_string ( & row. get :: < _ , SqlValue > ( 1 ) ?) ,
533+ title : to_opt_string ( & row. get :: < _ , SqlValue > ( 2 ) ?) ,
534+ created_at : to_opt_i64 ( & row. get :: < _ , SqlValue > ( 3 ) ?) ,
535+ last_activity_at : to_opt_i64 ( & row. get :: < _ , SqlValue > ( 4 ) ?) ,
536+ project_path : to_opt_string ( & row. get :: < _ , SqlValue > ( 5 ) ?) ,
537+ } )
538+ } ) {
539+ Ok ( row) => Ok ( Some ( row) ) ,
540+ Err ( rusqlite:: Error :: QueryReturnedNoRows ) => Ok ( None ) ,
541+ Err ( e) => Err ( by_id_err ( e) ) ,
552542 }
553- Ok ( Some ( OpencodeSessionDirectory { directory } ) )
554543}
555544
556545/// `defaultOpencodeDataHome` — `$XDG_DATA_HOME/opencode` -> win `LOCALAPPDATA/opencode`
0 commit comments