Skip to content

Commit 524cdb7

Browse files
Resolve native CLI identifiers across complete saved history
1 parent 33b680e commit 524cdb7

8 files changed

Lines changed: 160 additions & 25 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

33
## 0.20.0 — native development (unreleased)
44

5+
- CLI conversation and job IDs resolve against the full saved history instead
6+
of recent-list limits. Old records remain available for continuation, export,
7+
rename and job inspection; default conversation selection is project-scoped.
8+
Indexed lookup avoids loading unrelated job results just to resolve a prefix.
9+
510
- Native `tui` frontend shares the Rust engine with desktop and CLI: Unicode
611
input, conversation/model/project pickers, task modes, queued follow-ups,
712
explicit approvals, tool cards and paged saved history. Terminal-owned workflows

‎docs/NATIVE_CLI.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,12 @@ shadowcode config ui.theme dark
112112
shadowcode health --test-model
113113
```
114114

115+
Conversation and job IDs (including unique prefixes) resolve against the complete
116+
profile history, independently of recent-list limits. Ambiguous prefixes are
117+
rejected. Selecting the default conversation searches the current project directly,
118+
even when other projects have more than 10,000 newer conversations. Job lookup
119+
reads only the matching record instead of transferring recent jobs and their results.
120+
115121
Exports traverse the full saved history within the service's 32 MB limit.
116122
File output uses an atomic write; redirected stdout preserves exact export
117123
bytes. Human display on a terminal strips control characters. With global

‎docs/NATIVE_VERIFICATION.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,19 @@ regression tests verify compact-dialog scrolling/resizing and trust confirmation
273273
bound to the displayed project; all ten library tests pass after that fix. Broader
274274
real-terminal resize coverage remains open.
275275

276+
## Full-history identifier resolution
277+
278+
The CLI history regression adds 10,050 newer conversations and 1,100 newer jobs
279+
with more than 10 MB of unrelated result content to a disposable stopped profile.
280+
Through a restarted shared engine, full and unique-prefix IDs still retrieve the
281+
older job, rename/export the older conversation, and choose the correct default
282+
conversation in a project whose history falls outside the old global list limit.
283+
Ambiguous prefixes fail. The actual executable passes **16 CLI scenario groups
284+
with 30 model requests**. The foundation suite passes all 17 tests, including
285+
indexed ID resolution without deserializing job payloads. Clippy and the native
286+
executable build pass. General job-list payload sizing and desktop long-history
287+
display remain separate stress gates.
288+
276289
## Native CLI
277290

278291
`scripts/test-native-cli.mjs` exercises the actual executable with `DISPLAY` and

‎native/core/src/cli/mod.rs‎

Lines changed: 39 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -107,26 +107,47 @@ fn unique(rows: &[Value], prefix: &str, label: &str) -> Result<String> {
107107
);
108108
Ok(found[0].into())
109109
}
110-
async fn sessions(backend: &Backend) -> Result<Vec<Value>> {
111-
Ok(backend
112-
.call("GET", "/api/sessions?limit=10000", Value::Null)
113-
.await?["sessions"]
114-
.as_array()
115-
.context("Session list missing")?
116-
.clone())
110+
async fn resolve_id(backend: &Backend, kind: &str, prefix: &str) -> Result<String> {
111+
let result = backend
112+
.call(
113+
"GET",
114+
query("/api/resolve", &[("kind", kind), ("prefix", prefix)])?,
115+
Value::Null,
116+
)
117+
.await?;
118+
Ok(result["id"]
119+
.as_str()
120+
.context("Resolved identifier missing")?
121+
.into())
117122
}
118123
async fn session(backend: &Backend, id: Option<&str>, workspace: &Path) -> Result<String> {
119-
let rows = sessions(backend).await?;
120124
if let Some(id) = id {
121-
unique(&rows, id, "conversation")
122-
} else {
123-
rows.iter()
124-
.find(|row| row["workspace"].as_str() == workspace.to_str())
125-
.and_then(|row| row["id"].as_str())
126-
.map(str::to_owned)
127-
.context("No conversation in this project; supply --session or run a task first")
125+
return resolve_id(backend, "session", id).await;
128126
}
127+
let rows = backend
128+
.call(
129+
"GET",
130+
query(
131+
"/api/sessions",
132+
&[
133+
(
134+
"workspace",
135+
workspace.to_str().context("Workspace must be UTF-8")?,
136+
),
137+
("limit", "1"),
138+
],
139+
)?,
140+
Value::Null,
141+
)
142+
.await?;
143+
rows["sessions"]
144+
.as_array()
145+
.and_then(|rows| rows.first())
146+
.and_then(|row| row["id"].as_str())
147+
.map(str::to_owned)
148+
.context("No conversation in this project; supply --session or run a task first")
129149
}
150+
130151
async fn task(
131152
backend: &Backend,
132153
workspace: &Path,
@@ -797,13 +818,7 @@ async fn execute(backend: &Backend, workspace: &Path, options: &Options) -> Resu
797818
delete,
798819
} => {
799820
if rename.is_some() || *delete {
800-
let id = unique(
801-
&sessions(backend).await?,
802-
search
803-
.as_deref()
804-
.context("Provide a conversation ID prefix")?,
805-
"conversation",
806-
)?;
821+
let id = resolve_id(backend,"session",search.as_deref().context("Provide a conversation ID prefix")?).await?;
807822
backend
808823
.call(
809824
if *delete { "DELETE" } else { "PATCH" },
@@ -861,9 +876,8 @@ async fn execute(backend: &Backend, workspace: &Path, options: &Options) -> Resu
861876
cancel,
862877
interactive,
863878
} => {
864-
let rows = backend.call("GET", "/api/jobs", Value::Null).await?;
865879
if let Some(id) = id {
866-
let id = unique(rows["jobs"].as_array().context("Jobs missing")?, id, "job")?;
880+
let id = resolve_id(backend,"job",id).await?;
867881
let job = backend
868882
.call(
869883
if *cancel { "POST" } else { "GET" },
@@ -891,7 +905,7 @@ async fn execute(backend: &Backend, workspace: &Path, options: &Options) -> Resu
891905
!watch_job && !cancel,
892906
"Supply a job ID with --watch or --cancel"
893907
);
894-
rows
908+
backend.call("GET", "/api/jobs", Value::Null).await?
895909
}
896910
}
897911
Command::Approvals {

‎native/core/src/service.rs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,9 @@ impl Service {
183183
let text = |key: &str| body[key].as_str().unwrap_or("");
184184
let q = |key: &str| query.get(key).map(String::as_str).unwrap_or("");
185185
match (request.method.as_str(), path) {
186+
("GET", "/api/resolve") => {
187+
return Ok(json!({"id":store.resolve_id(q("kind"),q("prefix"))?}))
188+
}
186189
#[cfg(unix)]
187190
("GET", "/api/plugins") => {
188191
return crate::plugins::catalog(

‎native/core/src/store.rs‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,36 @@ impl Store {
224224
.into_iter()
225225
.next())
226226
}
227+
/// Resolve identifiers against the complete indexed history, never a recent
228+
/// list of potentially large job payloads. Two rows suffice for ambiguity.
229+
pub fn resolve_id(&self, kind: &str, prefix: &str) -> Result<String> {
230+
ensure!(
231+
!prefix.is_empty()
232+
&& prefix.len() <= 128
233+
&& prefix
234+
.bytes()
235+
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_')),
236+
"Provide a valid ID or ID prefix"
237+
);
238+
let table = match kind {
239+
"session" => "sessions",
240+
"job" => "desktop_jobs",
241+
_ => anyhow::bail!("Unknown identifier kind"),
242+
};
243+
let rows = self.query(
244+
&format!("SELECT id FROM {table} WHERE id>=? AND id<? ORDER BY id LIMIT 2"),
245+
params![prefix, format!("{prefix}~")],
246+
)?;
247+
ensure!(!rows.is_empty(), "No {kind} matches {prefix}");
248+
ensure!(
249+
rows.len() == 1,
250+
"Choose a unique {kind} ID prefix; at least two matches found"
251+
);
252+
Ok(rows[0]["id"]
253+
.as_str()
254+
.context("Stored identifier missing")?
255+
.into())
256+
}
227257
pub fn sessions(&self, search: &str, limit: usize) -> Result<Vec<Value>> {
228258
self.sessions_in(search, limit, None)
229259
}

‎native/core/tests/foundations.rs‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -518,3 +518,48 @@ fn search_respects_ignored_files_and_result_limits() {
518518
.iter()
519519
.all(|v| v["path"] == "src/test.rs"));
520520
}
521+
522+
#[test]
523+
fn identifier_resolution_uses_all_history_and_rejects_ambiguous_or_invalid_prefixes() {
524+
let root = tempfile::tempdir().unwrap();
525+
let path = root.path().join("history.db");
526+
let store = Store::open(&path).unwrap();
527+
let mut db = rusqlite::Connection::open(&path).unwrap();
528+
let tx = db.transaction().unwrap();
529+
tx.execute("INSERT INTO sessions(id,workspace,created_at,updated_at,status,title) VALUES('old-session','/old',1,1,'idle','Older conversation')",[]).unwrap();
530+
tx.execute(
531+
"INSERT INTO desktop_jobs(id,payload) VALUES('old-job','not decoded by ID lookup')",
532+
[],
533+
)
534+
.unwrap();
535+
tx.execute("WITH RECURSIVE n(i) AS (VALUES(1) UNION ALL SELECT i+1 FROM n WHERE i<10050) INSERT INTO sessions(id,workspace,created_at,updated_at,status,title) SELECT printf('new-%05d',i),'/new',i+1,i+1,'idle','Newer conversation' FROM n",[]).unwrap();
536+
tx.execute("WITH RECURSIVE n(i) AS (VALUES(1) UNION ALL SELECT i+1 FROM n WHERE i<1100) INSERT INTO desktop_jobs(id,payload) SELECT printf('new-job-%05d',i),'not decoded by ID lookup' FROM n",[]).unwrap();
537+
tx.commit().unwrap();
538+
assert_eq!(store.resolve_id("session", "old-").unwrap(), "old-session");
539+
assert_eq!(store.resolve_id("job", "old-job").unwrap(), "old-job");
540+
assert!(store
541+
.resolve_id("session", "new-")
542+
.unwrap_err()
543+
.to_string()
544+
.contains("unique"));
545+
assert!(store
546+
.resolve_id("job", "new-job-")
547+
.unwrap_err()
548+
.to_string()
549+
.contains("unique"));
550+
assert!(store
551+
.resolve_id("job", "missing")
552+
.unwrap_err()
553+
.to_string()
554+
.contains("No job"));
555+
for bad in ["", "%", "_ OR 1=1", "../job", "a/b"] {
556+
assert!(store.resolve_id("job", bad).is_err());
557+
}
558+
assert!(store.resolve_id("events", "old-job").is_err());
559+
assert_eq!(
560+
store
561+
.sessions_in("", 1, Some(std::path::Path::new("/old")))
562+
.unwrap()[0]["id"],
563+
"old-session"
564+
);
565+
}

‎scripts/test-native-cli.mjs‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,25 @@ try {
373373
assert.ok((await cli(["background", "list"])).tasks.every(t => !["RUNNING", "STARTING", "STOPPING"].includes(t.status)));
374374
assert.equal(await readFile(path.join(project, "unexpected")).then(() => true, () => false), false);
375375
checks.push("remote command disconnect and persistent-owner shutdown clean process groups and release the profile");
376+
// Seed only this disposable, stopped profile. Recent-list limits must not hide
377+
// older IDs or force fetching multi-megabyte job results to resolve a prefix.
378+
const historyDb = new DatabaseSync(path.join(profile, "state/shadow-agent.db"));
379+
const oldProject=path.join(scratch,"old-project"); await mkdir(oldProject);
380+
historyDb.exec("BEGIN");
381+
historyDb.prepare("INSERT INTO sessions(id,workspace,created_at,updated_at,status,title) VALUES(?,?,1,1,'idle','Old project history')").run("old-project-session",oldProject);
382+
historyDb.exec("WITH RECURSIVE n(i) AS (VALUES(1) UNION ALL SELECT i+1 FROM n WHERE i<10050) INSERT INTO sessions(id,workspace,created_at,updated_at,status,title) SELECT printf('noise-session-%05d',i),'/noise',9999999999,9999999999,'idle','Newer fixture' FROM n");
383+
const putHistoryJob=historyDb.prepare("INSERT INTO desktop_jobs(id,payload) VALUES(?,?)");
384+
for(let i=0;i<1100;i++){const id=`noise-job-${i.toString().padStart(5,'0')}`;putHistoryJob.run(id,JSON.stringify({id,workspace:project,session_id:task.session_id,task_id:task.task_id,status:"completed",result:{summary:"x".repeat(9000)}}));}
385+
historyDb.exec("COMMIT");historyDb.close();
386+
server=launch(["--json","serve"]);await until("History owner",()=>server.output.stderr.includes("serving"));
387+
assert.equal((await cli(["jobs",task.id])).id,task.id);
388+
assert.equal((await cli(["jobs",task.id.slice(0,12)])).id,task.id);
389+
await cli(["sessions",task.session_id.slice(0,12),"--rename","Old history remains reachable"]);
390+
assert.equal((await cli(["export","--session",task.session_id.slice(0,12)])).content.includes("CLI completed"),true);
391+
assert.ok((await cli(["export"],0,{workspace:oldProject})).content.includes("Old project history"));
392+
assert.match((await cli(["jobs","noise-job"],1)).error,/unique/);
393+
await interrupt(server,"SIGTERM");await finish(server);
394+
checks.push("indexed full-history IDs and project-default selection beyond 10,000 sessions and 1,000 large jobs");
376395
await writeFile(path.join(artifacts, "result.json"), JSON.stringify({ passed: true, binary, modelRequests: requests, checks }, null, 2));
377396
console.log(`Native CLI passed ${checks.length} scenario groups with ${requests} model requests.`);
378397
} catch (error) {

0 commit comments

Comments
 (0)