Skip to content

Commit f7fb7ef

Browse files
feat(sessions): hardened opencode exact-id lookup — direct by-id row query (archived+child included, errors propagate)
Ports opencode-by-id-query.ts, replacing the #583 parent-walk. Full row (title/timestamps) feeds the resolve match; a missing/locked/corrupt DB is Err — provider unavailable ≠ not found. (SYNC-06) 🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier) Co-Authored-By: Amplifier <[email protected]>
1 parent bbef61f commit f7fb7ef

7 files changed

Lines changed: 411 additions & 379 deletions

File tree

crates/freshell-server/src/main.rs

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1229,30 +1229,34 @@ async fn main() -> ExitCode {
12291229
// `session-indexer.ts:1159-1161`).
12301230
session_metadata: session_metadata_store.clone(),
12311231
// opencode `ses_*` exact-id fallback: the SAME data home the
1232-
// OpencodeSource uses. KNOWN DIVERGENCE (see resolve.rs module
1233-
// doc): still the retired parent-walk (Task 4 replaces it with
1234-
// the direct row query) and read errors are still mapped to an
1235-
// `Ok(None)` miss instead of `Err(ProviderFailure)` — the full
1236-
// health channel is wired in Task 6.
1232+
// OpencodeSource uses, answered by the hardened direct by-id row
1233+
// query (`opencode_session_row_by_id`, Node's
1234+
// `opencode-by-id-query.ts`) — archived + child sessions
1235+
// included, full row (title/lastActivityAt) returned. KNOWN
1236+
// DIVERGENCE (see resolve.rs module doc): read errors are still
1237+
// mapped to an `Ok(None)` miss instead of `Err(ProviderFailure)`
1238+
// — the full health channel is wired in Task 6.
12371239
opencode_session_by_id: Some(std::sync::Arc::new(
12381240
|session_id: &str| -> Result<
12391241
Option<freshell_sessions::resume_resolve::OpencodeByIdHit>,
12401242
freshell_sessions::resume_resolve::ProviderFailure,
12411243
> {
1244+
use freshell_sessions::resume_resolve::OpencodeByIdHit;
12421245
let data_home = freshell_sessions::parse::default_opencode_data_home();
1243-
Ok(freshell_sessions::parse::opencode_session_directory_by_id(
1246+
match freshell_sessions::parse::opencode_session_row_by_id(
12441247
&data_home, session_id,
1245-
)
1246-
.ok()
1247-
.flatten()
1248-
.map(|hit| {
1249-
freshell_sessions::resume_resolve::OpencodeByIdHit {
1250-
session_id: session_id.to_string(),
1251-
cwd: hit.directory,
1252-
title: None,
1253-
last_activity_at: None,
1254-
}
1255-
}))
1248+
) {
1249+
Ok(row) => Ok(row.map(|r| OpencodeByIdHit {
1250+
session_id: r.session_id,
1251+
cwd: r.cwd,
1252+
title: r.title,
1253+
last_activity_at: r.last_activity_at,
1254+
})),
1255+
// TASK-6 upgrades this to Err(ProviderFailure{..})
1256+
// once the wire carries providerErrors; until then a
1257+
// read failure stays a miss.
1258+
Err(_) => Ok(None),
1259+
}
12561260
},
12571261
)),
12581262
// claude transcript exact-id fallback: the SAME ordered-roots scan

crates/freshell-server/src/resolve.rs

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,11 @@
1919
//! merge yet. The fallbacks wired in `main.rs` also still map read errors
2020
//! to an `Ok(None)` MISS, never an `Err(ProviderFailure)`, so `degraded`
2121
//! is unreachable in production until Task 6 rewires them.
22-
//! - opencode by-id fallback runs the RETIRED parent-walk
23-
//! (`resolveOpencodeSessionRoots` port), not Node's hardened direct row
24-
//! query (`providers/opencode-by-id-query.ts`) — plan Task 4: orphaned/
25-
//! cyclic child rows miss where Node hits, a legacy-schema DB universally
26-
//! hits any full-shape `ses_*` id where Node hits only real rows, and the
27-
//! wired hits omit Node's `title`/`lastActivityAt` (the core's
28-
//! `OpencodeByIdHit` already carries them).
22+
//! - the opencode by-id fallback now runs Node's hardened direct row query
23+
//! (`opencode_session_row_by_id`, `providers/opencode-by-id-query.ts` —
24+
//! archived + child rows hit, `title`/`lastActivityAt` returned), but its
25+
//! read errors are still mapped to an `Ok(None)` miss (see above) — the
26+
//! `Err(ProviderFailure)` rewire is plan Task 6.
2927
//! - the claude fallback's `locate_transcript` never probes Node's
3028
//! `<project>/<parent>/subagents/<id>.jsonl` layout (subagent child
3129
//! transcripts miss) — the checked locator is plan Task 6; its cwd read IS
@@ -720,8 +718,8 @@ mod tests {
720718
Ok(Some(freshell_sessions::resume_resolve::OpencodeByIdHit {
721719
session_id: id.to_string(),
722720
cwd: Some("/repo/beta".to_string()),
723-
title: None,
724-
last_activity_at: None,
721+
title: Some("beta".to_string()),
722+
last_activity_at: Some(1234),
725723
}))
726724
}));
727725
let (status, body) = post(st, serde_json::json!({ "input": unknown }), true).await;
@@ -733,6 +731,8 @@ mod tests {
733731
"sessionId": unknown,
734732
"cwd": "/repo/beta",
735733
"sessionType": "opencode",
734+
"title": "beta",
735+
"lastActivityAt": 1234,
736736
"matchKind": "exact"
737737
}])
738738
);

crates/freshell-sessions/src/parse/mod.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ pub mod opencode;
1111
pub use claude::{parse_session_content, ParseSessionOptions};
1212
pub use codex::parse_codex_session_content;
1313
pub use opencode::{
14-
default_opencode_data_home, opencode_session_directory_by_id, run_opencode_listing_query,
15-
session_exists_by_id, OpencodeDegrade, OpencodeListing, OpencodeListingResult,
16-
OpencodeProvider, OpencodeReadError, OpencodeSession, OpencodeSessionDirectory,
14+
default_opencode_data_home, opencode_session_row_by_id, run_opencode_listing_query,
15+
session_exists_by_id, OpencodeByIdError, OpencodeByIdRow, OpencodeDegrade, OpencodeListing,
16+
OpencodeListingResult, OpencodeProvider, OpencodeReadError, OpencodeSession,
1717
OpencodeSessionRow, THREE_VIEWS_MARKER_SQL_PATTERN,
1818
};

crates/freshell-sessions/src/parse/opencode.rs

Lines changed: 95 additions & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -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`

crates/freshell-sessions/src/resume_resolve.rs

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,17 +20,14 @@
2020
//! `provider_errors` are computed but DROPPED by `resolve.rs`, and there is
2121
//! no `unsearchedProviders`/`homeDir` field or scan-failure/warming
2222
//! readiness merge yet.
23-
//! - opencode by-id fallback WIRING (plan Task 4): the closure `main.rs`
24-
//! supplies ports the RETIRED parent-walk
25-
//! (`parse::opencode_session_directory_by_id`), not the hardened direct
26-
//! row query Node's fallback now uses
27-
//! (`server/coding-cli/providers/opencode-by-id-query.ts`). Consequences:
28-
//! orphaned/cyclic child rows are a Rust MISS where Node HITs; a
29-
//! legacy-schema DB (no `parent_id` column) is a Rust universal HIT for
30-
//! any full-shape `ses_*` id where Node hits only REAL rows; the wired
31-
//! hits omit the `title`/`lastActivityAt` Node's row query emits (the
32-
//! [`OpencodeByIdHit`] type below already carries them); and the closure
33-
//! maps read errors to `Ok(None)` misses instead of `Err(ProviderFailure)`.
23+
//! - opencode by-id fallback ERROR mapping (plan Task 6): the closure
24+
//! `main.rs` supplies runs the hardened direct row query
25+
//! (`parse::opencode_session_row_by_id`, Node's
26+
//! `server/coding-cli/providers/opencode-by-id-query.ts` — archived +
27+
//! child sessions included, full row with `title`/`lastActivityAt`
28+
//! returned), but still maps read errors to `Ok(None)` misses instead of
29+
//! `Err(ProviderFailure)` — `degraded` stays unreachable in production
30+
//! until Task 6 rewires it.
3431
//! - claude fallback WIRING (plan Task 6): the wired `locate_transcript`
3532
//! (`freshell-freshagent`) probes `<projects>/<project>/<subdir>/<id>.jsonl`
3633
//! and never Node's `<projects>/<project>/<parent>/subagents/<id>.jsonl`

0 commit comments

Comments
 (0)