v0.9.5: a session owns a directory, and the store hands out uuids

A session's transcript lived beside its attachments, both named after an id
that was a timestamp. Two things fell out of that: on a workspace two
machines share, a timestamp is not an id (the same second on both is enough
to hand out the same name), and "delete this conversation" had to chase the
jsonl, the title and a directory in a shared attachments/ tree, guessing what
belonged to whom. A phone's app data has nobody to tidy it by hand.

Now one session is one directory:

  <workspace>/.agent/sessions/
    <uuid>/session.jsonl     the transcript
    <uuid>/title             the window title, one line
    <uuid>/attachments/      images and delivered files (img-…, art-…)
    <uuid>/sub-1.jsonl       an agent role's own transcript
    CURRENT                  the id the next run opens

- src/agent_store.c: new_id() makes a uuid v4 (16 bytes from /dev/urandom,
  clock+pid only as a fallback), dir_for_id/flat_for_id/nested_for_id/
  shape_of_id decide which shape an id has, so every reader takes either.
  An id is a directory name, so it is checked as one (no "/", no "..",
  nothing starting with ".", not CURRENT or attachments). path_for_id() keeps
  the shape a session already has and makes the directory for a new id, so
  nothing had to change in the callers. store_list() lists the directories
  that hold a transcript plus the flat files, newest first. delete() is one
  rm_tree of the session's directory, plus the old shared attachments/<id>
  and CURRENT when it pointed there.
- cst_agent_store_migrate() (--migrate-store, or plain cst-agent for a
  session): moves a flat store in — <id>.jsonl to <id>/session.jsonl,
  <id>.title in, the shared attachments/<id>/ to <id>/attachments/. rename
  first, copy when the filesystem says no, and counts what it moved, so a
  second run is a no-op that says "nothing to move". On the real workspace:
  6 sessions moved, the shared directory gone, --list-sessions reads them
  back unchanged, a second run moved 0.
- cst_agent_store_id_of_path(): the id of a session path. This is the bug the
  whole change had been hiding: cst_agent_session_id() took the id from the
  file name, and the new file name is session.jsonl -- every session would
  have called itself "session" (the sidebar, CURRENT, undo, an agent role's
  directory, and the metrics file, which now also goes through the helper).
- src/attach.c: a session that owns its directory keeps its payloads inside
  it (<id>/attachments/); only the flat layout needs the shared directory and
  the id stem. Attachments behave exactly as before otherwise.
- tests/test_store.c: uuid shape and uniqueness, a small buffer refused, a
  new session's path and directory, write/list/info/title read back, the
  payload landing in <id>/attachments/, delete taking the directory with it;
  then an old-layout store: readable before the move, moved byte for byte,
  its payload readable through the new path, listed once, a second migration
  a no-op, deletion covering the migrated payload. Also in the Makefile and
  CMakeLists test lists.
- app/src/ui/UiTest.cpp: the image directory check wanted "/attachments/",
  which the new layout has no trailing slash for.

Desktop: build clean, make check 35/35, --uitest all green, --selftest and
cst-app-selftest OK, and --selftest opens a session of the migrated workspace.

Version 0.9.5.
This commit is contained in:
WHC
2026-10-11 17:39:00 +08:00
parent 7299f39245
commit 08008a5a4c
14 changed files with 781 additions and 93 deletions
+32 -1
View File
@@ -33,6 +33,8 @@ static void usage(const char *argv0) {
" -N, --new start a new session\n"
" -R, --resume ID open a session by id or unique prefix\n"
" -L, --list-sessions list sessions and exit\n"
" --migrate-store move sessions written by an older version\n"
" into one directory per session, and exit\n"
" --list-models [P] what is known about each provider's models\n"
" (built-in, <config>/models.json, GET /models)\n"
" -A, --approval MODE ask | auto (default: edits run, shell in\n"
@@ -175,6 +177,25 @@ static int list_sessions(const char *ws) {
return 0;
}
/* Move a store written by an older version into one directory per session
* (--migrate-store). Idempotent: what is already a directory is left alone. */
static int migrate_store(const char *ws) {
int moved = 0, failed = 0;
char err[256] = "";
if (cst_agent_store_migrate(ws, &moved, &failed, err, sizeof(err)) != 0) {
fprintf(stderr, "cannot migrate %s/.agent/sessions: %s\n", ws,
err[0] ? err : "unknown error");
return 1;
}
printf("migrated %d session(s) into %s/.agent/sessions/<id>/\n", moved, ws);
if (failed)
printf("%d session(s) could not be moved; their files are untouched\n",
failed);
if (moved == 0)
printf("nothing to move (already one directory per session)\n");
return failed ? 1 : 0;
}
/* Exact id or unique prefix → id. */
static int resolve_session_id(const char *ws, const char *want, char *out,
size_t out_sz) {
@@ -338,7 +359,7 @@ int main(int argc, char **argv) {
int force_ask_key = 0;
int session_explicit = 0;
const char *prompt = NULL;
int new_session = 0, do_list = 0;
int new_session = 0, do_list = 0, do_migrate = 0;
const char *resume_id = NULL;
/* WebUI (--web): a second front end on the same engine. */
int web_mode = 0;
@@ -372,6 +393,7 @@ int main(int argc, char **argv) {
{"continue", no_argument, 0, 'c'},
{"resume", required_argument, 0, 'R'},
{"list-sessions", no_argument, 0, 'L'},
{"migrate-store", no_argument, 0, 0x107},
{"yolo", no_argument, 0, 'y'},
{"approval", required_argument, 0, 'A'},
{"confirm", required_argument, 0, 'C'},
@@ -528,6 +550,9 @@ int main(int argc, char **argv) {
web.open_browser = 1;
web_mode = 1;
break;
case 0x107:
do_migrate = 1;
break;
case 'V':
printf("cst-agent %s\n", CST_VERSION);
curl_global_cleanup();
@@ -539,6 +564,12 @@ int main(int argc, char **argv) {
}
}
if (do_migrate) {
int rc = migrate_store(cfg.workspace_root);
curl_global_cleanup();
return rc;
}
if (do_list) {
int rc = list_sessions(cfg.workspace_root);
curl_global_cleanup();