Run imports and deletes on the server, and say so on an event
Leaving the import screen used to cancel the batch it had started: the
request was the work, so the coroutine that owned it died with the screen
and coming back showed no sign anything had happened. A half-imported
session is the expensive kind of missing -- the row is back looking
untouched, and taking it again is the second `--resume` the import path
exists to prevent.
So the work runs on the server now. Delete and a new per-session import both
answer 202 and spawn the work, and `session::pending` is the record of it:
what is running, and how the last attempt failed. The phone reads that two
ways and needs both. Every row of the listing carries `pending` and `error`,
which is what a phone that was asleep, out of range or freshly opened has to
go on; `GET /setups/{id}/importable/events` streams the changes, which is
what makes a screen somebody is watching change by itself.
Neither alone is enough, and that is not theoretical. A broadcast has no
memory, so an operation that started and finished while the stream was still
connecting was one nothing would ever be said about -- with responses held
back far enough to make it visible, one row of a pair of deletes cleared and
the other sat on "waiting" for good. The screen now asks again after a
handover when anything still looks outstanding, and takes its row states
from that answer rather than from what it remembers.
The single tap still waits, because "take me to it" needs the session that
was made and 202 does not carry one. Both paths go through the same `spawn`
so they cannot drift about what importing means.
Resolving one importable session no longer lists every one of them:
`import::find` is the same script with one glob narrower, which takes the
import seed off the 3.7-second full scan that `delete` came off earlier.
The SSE connection and its framing are now `Sse`, shared with the session
transcript stream rather than written a second time.
This commit is contained in:
1 parent
b172c464ea
commit
3c159fa1e1
12 files changed
+992
-176
No files matched your search
@@ -166,7 +166,53 @@ pub async fn list(transport: &Transport) -> Result<Vec<Importable>> {
|
||||
// its text in a list, so that reading lost twenty rows rather than
|
||||
// two. Excluding `tool_use_id` keeps both shapes of a real message
|
||||
// and drops the one that is not.
|
||||
let script = r#"
|
||||
let script = listing_script(r#""$HOME"/.claude/projects/*/*.jsonl"#);
|
||||
let launch = Launch::new("sh", vec!["-c".to_string(), script], None);
|
||||
parse_listing(&transport.capture(&launch).await?)
|
||||
}
|
||||
|
||||
/// The same listing, for one session named by id.
|
||||
///
|
||||
/// Importing needs everything a row holds -- the path to follow, how many
|
||||
/// lines have already been written, what it is called, where it was working
|
||||
/// and whether something else has it open -- and used to get them by
|
||||
/// listing *every* session and searching the result. That is a full read of
|
||||
/// every transcript on the machine, seconds of it, to answer a question
|
||||
/// about one file; a batch of imports paid it once each. Same script, same
|
||||
/// parsing, one glob narrower.
|
||||
pub async fn find(transport: &Transport, id: &str) -> Result<Option<Importable>> {
|
||||
if !is_session_id(id) {
|
||||
return Ok(None);
|
||||
}
|
||||
let script = listing_script(r#""$HOME"/.claude/projects/*/"$1".jsonl"#);
|
||||
let launch = Launch::new(
|
||||
"sh",
|
||||
vec!["-c".to_string(), script, "sh".to_string(), id.to_string()],
|
||||
None,
|
||||
);
|
||||
Ok(parse_listing(&transport.capture(&launch).await?)?
|
||||
.into_iter()
|
||||
.find(|candidate| candidate.id == id))
|
||||
}
|
||||
|
||||
/// What the machine is asked, over whichever set of files `glob` names.
|
||||
///
|
||||
/// One script with the glob substituted rather than two that drift: the
|
||||
/// per-file half decides what a row *is*, and a row has to mean the same
|
||||
/// thing whether it arrived from a listing or from a lookup. The glob is
|
||||
/// this module's own text; the only thing that ever crosses from outside is
|
||||
/// the id, which stays an argument (`$1`) and is checked by
|
||||
/// [`is_session_id`] first.
|
||||
fn listing_script(glob: &str) -> String {
|
||||
// `replace` rather than `format!`: this is shell, so it is full of
|
||||
// braces -- `${s##*/}`, an awk program, the `{[^}]*` that finds a usage
|
||||
// record -- and every one of them would have to be doubled to survive a
|
||||
// format string. Doubling braces inside a script is exactly the kind of
|
||||
// edit that looks right and changes what the shell runs.
|
||||
SCRIPT.replace("{glob}", glob)
|
||||
}
|
||||
|
||||
const SCRIPT: &str = r#"
|
||||
if [ -d "$HOME/.claude/sessions" ]; then
|
||||
printf 'LIVEKNOWN\n'
|
||||
for s in "$HOME"/.claude/sessions/*.json; do
|
||||
@@ -180,7 +226,7 @@ if [ -d "$HOME/.claude/sessions" ]; then
|
||||
[ -n "$sid" ] && printf 'LIVE\t%s\n' "$sid"
|
||||
done
|
||||
fi
|
||||
for f in "$HOME"/.claude/projects/*/*.jsonl; do
|
||||
for f in {glob}; do
|
||||
[ -f "$f" ] || continue
|
||||
printf '%s\t%s\t%s\t%s\t%s\t' "$(stat -c %Y "$f" 2>/dev/null || echo 0)" \
|
||||
"$(wc -l < "$f")" "$(stat -c %s "$f" 2>/dev/null || echo 0)" \
|
||||
@@ -190,9 +236,9 @@ for f in "$HOME"/.claude/projects/*/*.jsonl; do
|
||||
printf '\n'
|
||||
done
|
||||
"#;
|
||||
let launch = Launch::new("sh", vec!["-c".to_string(), script.to_string()], None);
|
||||
let found = transport.capture(&launch).await?;
|
||||
|
||||
/// Rows out of what [`listing_script`] printed, with `in_use` filled in.
|
||||
fn parse_listing(found: &str) -> Result<Vec<Importable>> {
|
||||
let mut live = std::collections::HashSet::new();
|
||||
let mut checkable = false;
|
||||
for line in found.lines() {
|
||||
|
||||
Reference in new issue
Block a user