diff --git a/CMakeLists.txt b/CMakeLists.txt index dfe54ba..8b55bce 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -370,7 +370,7 @@ endforeach() # Tests that link the whole core (no TUI). set(_CST_CORE_TESTS test_llm_body test_prefix_stable test_sse_decode - test_apply_patch test_session_roundtrip test_attach + test_apply_patch test_session_roundtrip test_store test_attach test_artifacts test_shell test_diff test_tools_fs test_registry test_undo test_config test_models test_model_registry test_sandbox diff --git a/Makefile b/Makefile index 37c8629..5f37b56 100644 --- a/Makefile +++ b/Makefile @@ -139,6 +139,7 @@ TESTS = \ tests/test_sse_decode \ tests/test_apply_patch \ tests/test_session_roundtrip \ + tests/test_store \ tests/test_attach \ tests/test_artifacts \ tests/test_pdf_text \ diff --git a/README.md b/README.md index 2fd202a..b2e2166 100644 --- a/README.md +++ b/README.md @@ -273,6 +273,7 @@ cst-agent --headless --prompt 'List files' | `-N` / `--new` | 开新会话 | | `-R ID` / `--resume ID` | 打开指定会话(完整 id 或唯一前缀) | | `-L` / `--list-sessions` | 列出会话(时间、条数、标题)后退出 | +| `--migrate-store` | 把旧版本的会话(扁平 `.jsonl`)搬进新布局后退出 | | `-A MODE` / `--approval MODE` | 审批模式 `ask` / `auto`(默认)/ `yolo`,见下文 | | `-y` / `--yolo` | 等同 `--approval yolo`,只对本次运行有效 | | `-C LIST` / `--confirm LIST` | 需要确认的类别:`shell,write,net` 的任意组合或 `none` | @@ -283,7 +284,7 @@ cst-agent --headless --prompt 'List files' 工作目录默认就是当前目录(`-w DIR` 可指定别的目录)。 -会话落盘:`/.agent/sessions/.jsonl` + `CURRENT`;`.agent/` 自带 `.gitignore`,不会被误提交。 +会话落盘:一个会话一个目录 `/.agent/sessions//`(`session.jsonl` 转录、`title`、`attachments/`、子 agent 的 `sub-N.jsonl`)+ `CURRENT` 指向下次打开的会话;id 是 uuid,两台机器共用一份工作目录也不会撞。旧版本写的扁平会话仍然可读,`--migrate-store` 一次搬进新布局(幂等)。`.agent/` 自带 `.gitignore`,不会被误提交。 ### 内置工具 diff --git a/app/README.md b/app/README.md index 3221533..2598ef1 100644 --- a/app/README.md +++ b/app/README.md @@ -94,10 +94,13 @@ QT_QPA_PLATFORM=offscreen ./build-app/cst-agent-app --uitest ## Sessions -A GUI session is a file in the workspace's store (`.agent/sessions/.jsonl`), -the same one the CLI, the TUI and the WebUI use — `AgentCore::open()` resolves -the workspace's current session before the engine starts, so nothing is written -next to the process's working directory. +A GUI session is a directory in the workspace's store +(`.agent/sessions//session.jsonl`, with its `title`, its `attachments/` and +its sub-agent transcripts beside it), the same one the CLI, the TUI and the +WebUI use — `AgentCore::open()` resolves the workspace's current session before +the engine starts, so nothing is written next to the process's working +directory. A store an older version wrote (flat `.jsonl` files) still +reads; `cst-agent --migrate-store` moves it in. - The sidebar lists the store (newest first): title or first prompt, message count, "3h ago"; the open one is highlighted. `+ new` starts a session, `×` @@ -202,8 +205,9 @@ The next prompt can carry files, and they arrive three ways: intercepts the keystroke when there is an image. `AgentCore::attachFile()` / `attachClipboard()` call `cst_agent_attach_file()` / -`cst_agent_attach_bytes()`: the engine validates the file, copies it next to -the session (`.agent/sessions/`), and the next `run_turn` carries it — the +`cst_agent_attach_bytes()`: the engine validates the file, copies it into the +session's own directory (`.agent/sessions//attachments/`), and the next +`run_turn` carries it — the window only shows what the engine's `pending` list says, one chip per file in the engine's own words ("note.txt (text/plain, 21 B)"), with "clear all" (`cst_agent_pending_clear`). A prompt consumes the list, so the chips go away diff --git a/app/src/ui/UiTest.cpp b/app/src/ui/UiTest.cpp index a621130..c66d1b6 100644 --- a/app/src/ui/UiTest.cpp +++ b/app/src/ui/UiTest.cpp @@ -733,7 +733,9 @@ int runUiTest(AgentCore &core, const QString &size) { /* --- the image provider: only the session's own files ----------------- */ { const QString dir = core.attachmentDir(); - check(!dir.isEmpty() && dir.contains(QLatin1String("/attachments/")), + /* one directory per session since v0.9.5: //attachments */ + check(!dir.isEmpty() && dir.endsWith(QLatin1String("/attachments")) && + dir.contains(QLatin1String("/sessions/")), QStringLiteral("the agent names its image directory (%1)").arg(dir)); QImage pic(32, 16, QImage::Format_RGB32); pic.fill(Qt::darkGreen); diff --git a/docs/webui.md b/docs/webui.md index 6c00d23..3828fc0 100644 --- a/docs/webui.md +++ b/docs/webui.md @@ -116,7 +116,7 @@ completion_tokens,cached_tokens,cache_miss_tokens,cost,priced,currency}` | 方法 | 路径 | 说明 | |---|---|---| | GET | `/api/state` | 上面那份快照 | -| GET | `/api/session?from=N` | 会话消息(`{messages:[…]}`,形状与 `.agent/sessions/*.jsonl` 一致,另加的字段见下) | +| GET | `/api/session?from=N` | 会话消息(`{messages:[…]}`,形状与转录 `.agent/sessions//session.jsonl` 的行一致,另加的字段见下) | | GET | `/api/sessions` | `{current, sessions:[{id,title,messages,mtime}]}` | | POST | `/api/sessions/new` | `{}` → `{id}` | | POST | `/api/sessions/switch` | `{"id":"…"}` | diff --git a/include/cst_agent.h b/include/cst_agent.h index 6d367d2..47db86f 100644 --- a/include/cst_agent.h +++ b/include/cst_agent.h @@ -167,7 +167,14 @@ void cst_session_unlock(cst_session_t *s); /* --- workspace .agent/sessions store (OpenCode-like) --- */ int cst_agent_store_ensure(const char *workspace); int cst_agent_store_dir(const char *workspace, char *out, size_t out_sz); +/* A fresh session id: a uuid v4, so two machines sharing a workspace cannot + * hand out the same one (a timestamp can). id_sz >= 37. */ int cst_agent_store_new_id(char *id_out, size_t id_sz); +/* The transcript of an id. A session owns a directory under the store + * (/session.jsonl, with its attachments and sub-agent transcripts inside + * it); an id that still has the older flat file (.jsonl) keeps it until + * cst_agent_store_migrate() moves it. A new id gets its directory made here, + * so the caller can open the file next. */ int cst_agent_store_path_for_id(const char *workspace, const char *id, char *out, size_t out_sz); int cst_agent_store_set_current(const char *workspace, const char *id); @@ -177,6 +184,10 @@ int cst_agent_store_resolve_default(const char *workspace, char *path_out, size_t path_sz, char *id_out, size_t id_sz); int cst_agent_store_list(const char *workspace, char ids[][128], int max_ids); +/* The id a session path belongs to: /.jsonl (the flat layout) or + * //session.jsonl (a session's own directory). 0 / -1. */ +int cst_agent_store_id_of_path(const char *session_path, char *out, + size_t out_sz); typedef struct { char id[128]; long long mtime; @@ -189,6 +200,13 @@ int cst_agent_store_info(const char *workspace, const char *id, int cst_agent_store_title_set(const char *workspace, const char *id, const char *title); int cst_agent_store_delete(const char *workspace, const char *id); +/* Move every flat session of the store into the per-session layout: the + * transcript to /session.jsonl, the title and the shared attachment + * directory (.title, attachments//) along with it. Sessions that are + * already directories are left alone, so running it twice is harmless. + * *moved_out / *failed_out (optional) count what happened; 0 / -1. */ +int cst_agent_store_migrate(const char *workspace, int *moved_out, + int *failed_out, char *err, size_t err_sz); /* --- tools --- */ typedef struct { diff --git a/include/cst_version.h b/include/cst_version.h index 4753463..8a1fc75 100644 --- a/include/cst_version.h +++ b/include/cst_version.h @@ -3,7 +3,7 @@ #define CST_VERSION_MAJOR 0 #define CST_VERSION_MINOR 9 -#define CST_VERSION_PATCH 4 +#define CST_VERSION_PATCH 5 #define CST_VERSION_STR_(x) #x #define CST_VERSION_STR(x) CST_VERSION_STR_(x) diff --git a/src/agent_loop.c b/src/agent_loop.c index 7885f2c..8c8149e 100644 --- a/src/agent_loop.c +++ b/src/agent_loop.c @@ -2047,16 +2047,8 @@ void cst_agent_session_id(const cst_agent_t *a, char *out, size_t out_sz) { out[0] = 0; if (!a) return; - const char *p = a->cfg.session_path; - const char *sl = strrchr(p, '/'); - const char *base = sl ? sl + 1 : p; - size_t n = strlen(base); - if (n >= out_sz) - n = out_sz - 1; - memcpy(out, base, n); - out[n] = 0; - if (n > 6 && strcmp(out + n - 6, ".jsonl") == 0) - out[n - 6] = 0; + if (cst_agent_store_id_of_path(a->cfg.session_path, out, out_sz) != 0) + out[0] = 0; } const char *cst_agent_session_path(const cst_agent_t *a) { diff --git a/src/agent_store.c b/src/agent_store.c index 7877109..a199ba8 100644 --- a/src/agent_store.c +++ b/src/agent_store.c @@ -23,6 +23,29 @@ #define OS_F_OK F_OK #endif +/* --- the store's shape ---------------------------------------------------- + * A session owns a directory: everything the session keeps is inside it, so + * "delete this conversation" is one rmdir and nothing has to be found by + * guesswork (a phone's app data directory has no user to tidy it by hand). + * + * /.agent/sessions/ + * /session.jsonl the transcript + * /title the window title, one line + * /attachments/ what the messages carry: images, delivered + * files (.png, art-.md, ...) + * /sub-1.jsonl a sub-agent's own transcript + * CURRENT the id the next run opens + * + * Sessions written before this layout are flat, with one shared attachment + * directory, and keep working: every reader here takes either shape and + * cst_agent_store_migrate() (--migrate-store) moves them in. + * + * .jsonl + * .title + * attachments//... (shared by every session) + * /sub-1.jsonl (already per session) + */ + int cst_agent_store_dir(const char *workspace, char *out, size_t out_sz) { if (!workspace || !out || out_sz < 16) return -1; @@ -53,24 +76,140 @@ int cst_agent_store_ensure(const char *workspace) { return 0; } -static void make_id(char *out, size_t n) { - time_t now = time(NULL); - struct tm tm; - os_localtime(&now, &tm); - static unsigned seq; - unsigned r = (unsigned)(os_getpid() ^ (unsigned)now) + 0x9e37u * seq++; - snprintf(out, n, "%04d%02d%02d-%02d%02d%02d-%04x", tm.tm_year + 1900, - tm.tm_mon + 1, tm.tm_mday, tm.tm_hour, tm.tm_min, tm.tm_sec, - r & 0xffff); +/* An id is a directory name: anything that could leave the store, and the two + * names the store itself uses, is not one. */ +static int id_ok(const char *id) { + if (!id || !id[0] || id[0] == '.') + return 0; + for (const char *p = id; *p; p++) { + char c = *p; + if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.')) + return 0; + } + if (strstr(id, "..")) + return 0; + return strcmp(id, "CURRENT") != 0 && strcmp(id, "attachments") != 0; } +/* 16 random bytes, whatever the platform offers; the clock and the pid are + * the fallback (a collision only costs a reopen, not data). */ +static void random_bytes(unsigned char *out, size_t n) { + FILE *f = fopen("/dev/urandom", "rb"); + if (f) { + size_t got = fread(out, 1, n, f); + fclose(f); + if (got == n) + return; + } + static unsigned seq; + unsigned long long x = (unsigned long long)time(NULL) ^ + ((unsigned long long)os_getpid() << 20) ^ + ((unsigned long long)++seq << 8); + for (size_t i = 0; i < n; i++) { + x = x * 6364136223846793005ULL + 1442695040888963407ULL; + out[i] = (unsigned char)(x >> 33); + } +} + +/* A uuid v4: unique across machines and time, which a timestamp is not (two + * phones, one synced workspace, and the ids have to stay apart). */ int cst_agent_store_new_id(char *id_out, size_t id_sz) { - if (!id_out || id_sz < 24) + if (!id_out || id_sz < 37) return -1; - make_id(id_out, id_sz); + unsigned char b[16]; + random_bytes(b, sizeof(b)); + b[6] = (unsigned char)((b[6] & 0x0f) | 0x40); /* version 4 */ + b[8] = (unsigned char)((b[8] & 0x3f) | 0x80); /* variant 1 */ + snprintf(id_out, id_sz, + "%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-" + "%02x%02x%02x%02x%02x%02x", + b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7], b[8], b[9], b[10], + b[11], b[12], b[13], b[14], b[15]); return 0; } +/* /, the session's own directory (not made here). */ +static int dir_for_id(const char *workspace, const char *id, char *out, + size_t out_sz) { + if (!workspace || !id_ok(id)) + return -1; + char dir[OS_PATH_MAX]; + if (cst_agent_store_dir(workspace, dir, sizeof(dir)) != 0) + return -1; + return os_path_join(out, out_sz, dir, id); +} + +/* /.jsonl -- the shape before per-session directories. */ +static int flat_for_id(const char *workspace, const char *id, char *out, + size_t out_sz) { + if (!workspace || !id_ok(id)) + return -1; + char dir[OS_PATH_MAX], leaf[192]; + if (cst_agent_store_dir(workspace, dir, sizeof(dir)) != 0) + return -1; + snprintf(leaf, sizeof(leaf), "%s.jsonl", id); + return os_path_join(out, out_sz, dir, leaf); +} + +/* The transcript of an id in the per-session layout. */ +static int nested_for_id(const char *workspace, const char *id, char *out, + size_t out_sz) { + char d[OS_PATH_MAX]; + if (dir_for_id(workspace, id, d, sizeof(d)) != 0) + return -1; + return os_path_join(out, out_sz, d, "session.jsonl"); +} + +int cst_agent_store_id_of_path(const char *session_path, char *out, + size_t out_sz) { + if (!session_path || !session_path[0] || !out || out_sz < 2) + return -1; + const char *base = session_path; + for (const char *p = session_path; *p; p++) + if (*p == '/' || *p == '\\') + base = p + 1; + size_t n = strlen(base); + if (n > 6 && strcmp(base + n - 6, ".jsonl") == 0) { + size_t stem = n - 6; + if (stem == 7 && strncmp(base, "session", 7) == 0) { + /* /session.jsonl: the id is the directory the file sits in. */ + if (base == session_path) + return -1; /* a bare "session.jsonl" has no session to name */ + const char *end = base - 1; /* the separator before the leaf */ + const char *start = end; + while (start > session_path && start[-1] != '/' && start[-1] != '\\') + start--; + size_t dn = (size_t)(end - start); + if (!dn || dn >= out_sz) + return -1; + memcpy(out, start, dn); + out[dn] = 0; + return 0; + } + n = stem; + } + if (!n || n >= out_sz) + return -1; + memcpy(out, base, n); + out[n] = 0; + return 0; +} + +/* Which shape this id has. 1 = its own directory, 0 = flat, -1 = neither + * (and not a usable id). */ +static int shape_of_id(const char *workspace, const char *id) { + char p[OS_PATH_MAX]; + os_stat_t st; + if (nested_for_id(workspace, id, p, sizeof(p)) == 0 && + os_lstat(p, &st) == 0 && !st.is_dir) + return 1; + if (flat_for_id(workspace, id, p, sizeof(p)) == 0 && os_lstat(p, &st) == 0 && + !st.is_dir) + return 0; + return -1; +} + int cst_agent_store_path_for_id(const char *workspace, const char *id, char *out, size_t out_sz) { if (!workspace || !id || !out) @@ -80,12 +219,19 @@ int cst_agent_store_path_for_id(const char *workspace, const char *id, snprintf(out, out_sz, "%s", id); return 0; } - char dir[OS_PATH_MAX]; - char leaf[160]; - if (cst_agent_store_dir(workspace, dir, sizeof(dir)) != 0) + int shape = shape_of_id(workspace, id); + if (shape == 0) + return flat_for_id(workspace, id, out, out_sz); + if (nested_for_id(workspace, id, out, out_sz) != 0) return -1; - snprintf(leaf, sizeof(leaf), "%s.jsonl", id); - return os_path_join(out, out_sz, dir, leaf); + if (shape < 0) { + /* A new session: its directory is made here, so whoever opens the + * transcript next has a place to put it (and its attachments). */ + char d[OS_PATH_MAX]; + if (dir_for_id(workspace, id, d, sizeof(d)) == 0) + os_mkdir_p(d, 0700); + } + return 0; } int cst_agent_store_set_current(const char *workspace, const char *id) { @@ -164,24 +310,42 @@ int cst_agent_store_list(const char *workspace, char ids[][128], int max_ids) { return 0; typedef struct { char id[128]; - time_t mtime; + double mtime; } ent_t; ent_t *ents = calloc((size_t)max_ids * 4, sizeof(ent_t)); int n = 0, cap = max_ids * 4; const os_dirent_t *de; while ((de = os_dir_next(d)) != NULL) { + if (n >= cap) + break; + if (de->is_dir) { + /* a per-session directory: only one that holds a transcript counts */ + if (!id_ok(de->name)) + continue; + char p[OS_PATH_MAX]; + if (nested_for_id(workspace, de->name, p, sizeof(p)) != 0) + continue; + os_stat_t st; + if (os_lstat(p, &st) != 0 || st.is_dir) + continue; + snprintf(ents[n].id, sizeof(ents[n].id), "%s", de->name); + ents[n].mtime = st.mtime; + n++; + continue; + } size_t len = strlen(de->name); if (len < 7 || strcmp(de->name + len - 6, ".jsonl") != 0) continue; - if (n >= cap) - break; - snprintf(ents[n].id, sizeof(ents[n].id), "%.*s", (int)(len - 6), - de->name); + char id[128]; + snprintf(id, sizeof(id), "%.*s", (int)(len - 6), de->name); + if (!id_ok(id)) + continue; char full[OS_PATH_MAX]; if (os_path_join(full, sizeof(full), dir, de->name) != 0) continue; - struct stat st; - ents[n].mtime = (stat(full, &st) == 0) ? st.st_mtime : 0; + os_stat_t st; + snprintf(ents[n].id, sizeof(ents[n].id), "%s", id); + ents[n].mtime = os_lstat(full, &st) == 0 ? st.mtime : 0; n++; } os_dir_close(d); @@ -201,20 +365,28 @@ int cst_agent_store_list(const char *workspace, char ids[][128], int max_ids) { return out_n; } -static int side_path(const char *workspace, const char *id, const char *ext, - char *out, size_t out_sz) { - char dir[OS_PATH_MAX], leaf[192]; - if (!id || !id[0] || strchr(id, '/') || strchr(id, '\\') || - cst_agent_store_dir(workspace, dir, sizeof(dir)) != 0) +/* The title of a session: inside its directory in the new layout, a .title + * beside the transcript in the old one. */ +static int title_for_id(const char *workspace, const char *id, char *out, + size_t out_sz) { + if (shape_of_id(workspace, id) == 0) { + char dir[OS_PATH_MAX]; + char leaf[192]; + if (cst_agent_store_dir(workspace, dir, sizeof(dir)) != 0) + return -1; + snprintf(leaf, sizeof(leaf), "%s.title", id); + return os_path_join(out, out_sz, dir, leaf); + } + char d[OS_PATH_MAX]; + if (dir_for_id(workspace, id, d, sizeof(d)) != 0) return -1; - snprintf(leaf, sizeof(leaf), "%s%s", id, ext); - return os_path_join(out, out_sz, dir, leaf); + return os_path_join(out, out_sz, d, "title"); } int cst_agent_store_title_set(const char *workspace, const char *id, const char *title) { char p[OS_PATH_MAX]; - if (side_path(workspace, id, ".title", p, sizeof(p)) != 0) + if (title_for_id(workspace, id, p, sizeof(p)) != 0) return -1; if (!title || !title[0]) return remove(p) == 0 || errno == ENOENT ? 0 : -1; @@ -247,14 +419,18 @@ int cst_agent_store_info(const char *workspace, const char *id, memset(info, 0, sizeof(*info)); snprintf(info->id, sizeof(info->id), "%s", id ? id : ""); char p[OS_PATH_MAX]; - if (side_path(workspace, id, ".jsonl", p, sizeof(p)) != 0) + if (shape_of_id(workspace, id) == 0) { + if (flat_for_id(workspace, id, p, sizeof(p)) != 0) + return -1; + } else if (nested_for_id(workspace, id, p, sizeof(p)) != 0) { return -1; - struct stat st; - if (stat(p, &st) != 0) + } + os_stat_t st; + if (os_lstat(p, &st) != 0) return -1; - info->mtime = (long long)st.st_mtime; + info->mtime = (long long)st.mtime; char tp[OS_PATH_MAX]; - if (side_path(workspace, id, ".title", tp, sizeof(tp)) == 0) { + if (title_for_id(workspace, id, tp, sizeof(tp)) == 0) { FILE *tf = fopen(tp, "rb"); if (tf) { if (fgets(info->title, sizeof(info->title), tf)) { @@ -304,36 +480,204 @@ int cst_agent_store_info(const char *workspace, const char *id, return 0; } -int cst_agent_store_delete(const char *workspace, const char *id) { - char p[OS_PATH_MAX]; - if (side_path(workspace, id, ".jsonl", p, sizeof(p)) != 0) +/* Remove a tree (a session's directory: its transcript, its attachments and + * its sub-agent files). Links are removed, never followed. */ +static int rm_tree(const char *path) { + os_stat_t st; + if (os_lstat(path, &st) != 0) return -1; - if (remove(p) != 0) - return -1; - if (side_path(workspace, id, ".title", p, sizeof(p)) == 0) - remove(p); - /* sub-agent transcripts live in / */ - if (side_path(workspace, id, "", p, sizeof(p)) == 0) { - os_dir_t *d = os_dir_open(p); - if (d) { - const os_dirent_t *de; - while ((de = os_dir_next(d)) != NULL) { - char f[OS_PATH_MAX]; - if (strncmp(de->name, "sub-", 4) == 0 && - os_path_join(f, sizeof(f), p, de->name) == 0) - remove(f); - } - os_dir_close(d); - rmdir(p); + if (!st.is_dir) + return remove(path) == 0 ? 0 : -1; + os_dir_t *d = os_dir_open(path); + if (d) { + const os_dirent_t *de; + while ((de = os_dir_next(d)) != NULL) { + char f[OS_PATH_MAX]; + if (os_path_join(f, sizeof(f), path, de->name) == 0) + rm_tree(f); } + os_dir_close(d); + } + return rmdir(path) == 0 ? 0 : -1; +} + +int cst_agent_store_delete(const char *workspace, const char *id) { + if (!id_ok(id)) + return -1; + char p[OS_PATH_MAX]; + int found = 0; + /* both shapes, whichever this session has (or had) */ + if (flat_for_id(workspace, id, p, sizeof(p)) == 0 && remove(p) == 0) + found = 1; + if (nested_for_id(workspace, id, p, sizeof(p)) == 0 && remove(p) == 0) + found = 1; + if (!found) + return -1; + if (title_for_id(workspace, id, p, sizeof(p)) == 0) + remove(p); + /* the session's own directory: attachments, sub-agent transcripts */ + if (dir_for_id(workspace, id, p, sizeof(p)) == 0) + rm_tree(p); + /* and the shared attachment directory of the old layout */ + char dir[OS_PATH_MAX]; + if (cst_agent_store_dir(workspace, dir, sizeof(dir)) == 0 && + os_path_join(p, sizeof(p), dir, "attachments") == 0) { + char one[OS_PATH_MAX]; + if (os_path_join(one, sizeof(one), p, id) == 0) + rm_tree(one); } char cur[128]; if (cst_agent_store_get_current(workspace, cur, sizeof(cur)) == 0 && strcmp(cur, id) == 0) { - char dir[OS_PATH_MAX]; if (cst_agent_store_dir(workspace, dir, sizeof(dir)) == 0 && os_path_join(p, sizeof(p), dir, "CURRENT") == 0) remove(p); } return 0; } + +/* Move a directory: rename if the OS likes it, otherwise copy file by file + * (a synced workspace can be odd about renames). */ +static int move_file(const char *from, const char *to) { + if (os_rename_replace(from, to) == 0) + return 0; + FILE *in = fopen(from, "rb"); + if (!in) + return -1; + FILE *out = fopen(to, "wb"); + if (!out) { + fclose(in); + return -1; + } + char buf[16384]; + size_t n; + int rc = 0; + while ((n = fread(buf, 1, sizeof(buf), in)) > 0) { + if (fwrite(buf, 1, n, out) != n) { + rc = -1; + break; + } + } + fclose(in); + if (fclose(out) != 0) + rc = -1; + if (rc == 0) + remove(from); + return rc; +} + +static int move_tree(const char *from, const char *to) { + os_stat_t st; + if (os_lstat(from, &st) != 0) + return -1; + if (!st.is_dir) + return move_file(from, to); + if (os_mkdir_p(to, 0700) != 0) + return -1; + os_dir_t *d = os_dir_open(from); + if (!d) + return -1; + int rc = 0; + const os_dirent_t *de; + while ((de = os_dir_next(d)) != NULL) { + char f[OS_PATH_MAX], t[OS_PATH_MAX]; + if (os_path_join(f, sizeof(f), from, de->name) != 0 || + os_path_join(t, sizeof(t), to, de->name) != 0 || move_tree(f, t) != 0) + rc = -1; + } + os_dir_close(d); + if (rc == 0) + rmdir(from); + return rc; +} + +int cst_agent_store_migrate(const char *workspace, int *moved_out, + int *failed_out, char *err, size_t err_sz) { + char dummy[8]; + if (!err || !err_sz) { + err = dummy; + err_sz = sizeof(dummy); + } + err[0] = 0; + if (moved_out) + *moved_out = 0; + if (failed_out) + *failed_out = 0; + if (!workspace || !workspace[0]) { + snprintf(err, err_sz, "no workspace"); + return -1; + } + if (cst_agent_store_ensure(workspace) != 0) { + snprintf(err, err_sz, "cannot use the store under %s", workspace); + return -1; + } + char dir[OS_PATH_MAX]; + if (cst_agent_store_dir(workspace, dir, sizeof(dir)) != 0) { + snprintf(err, err_sz, "no sessions directory"); + return -1; + } + os_dir_t *d = os_dir_open(dir); + if (!d) { + snprintf(err, err_sz, "cannot read %s", dir); + return -1; + } + int moved = 0, failed = 0; + const os_dirent_t *de; + while ((de = os_dir_next(d)) != NULL) { + if (de->is_dir) + continue; + size_t len = strlen(de->name); + if (len < 7 || strcmp(de->name + len - 6, ".jsonl") != 0) + continue; + char id[128]; + snprintf(id, sizeof(id), "%.*s", (int)(len - 6), de->name); + if (!id_ok(id)) + continue; + char nested_dir[OS_PATH_MAX], nested[OS_PATH_MAX], flat[OS_PATH_MAX]; + if (dir_for_id(workspace, id, nested_dir, sizeof(nested_dir)) != 0 || + nested_for_id(workspace, id, nested, sizeof(nested)) != 0 || + flat_for_id(workspace, id, flat, sizeof(flat)) != 0) { + failed++; + continue; + } + /* already moved (a second run, or a session that never was flat) */ + os_stat_t st; + if (os_lstat(nested, &st) == 0) + continue; + if (os_mkdir_p(nested_dir, 0700) != 0 || move_file(flat, nested) != 0) { + failed++; + continue; + } + /* the title and the attachments that belong to this session */ + char sdir[OS_PATH_MAX]; + if (cst_agent_store_dir(workspace, sdir, sizeof(sdir)) == 0) { + char t_from[OS_PATH_MAX], t_to[OS_PATH_MAX]; + char leaf[192]; + snprintf(leaf, sizeof(leaf), "%s.title", id); + if (os_path_join(t_from, sizeof(t_from), sdir, leaf) == 0 && + os_path_join(t_to, sizeof(t_to), nested_dir, "title") == 0) + move_file(t_from, t_to); /* no title is not a failure */ + } + char att_dir[OS_PATH_MAX], att_one[OS_PATH_MAX]; + char a_from[OS_PATH_MAX], a_to[OS_PATH_MAX]; + if (cst_agent_store_dir(workspace, att_dir, sizeof(att_dir)) == 0 && + os_path_join(att_one, sizeof(att_one), att_dir, "attachments") == 0 && + os_path_join(a_from, sizeof(a_from), att_one, id) == 0 && + os_path_join(a_to, sizeof(a_to), nested_dir, "attachments") == 0) { + os_stat_t ast; + if (os_lstat(a_from, &ast) == 0 && ast.is_dir) + move_tree(a_from, a_to); + } + moved++; + } + os_dir_close(d); + /* the shared directory itself is empty now (and has no other purpose) */ + char att[OS_PATH_MAX]; + if (os_path_join(att, sizeof(att), dir, "attachments") == 0) + rmdir(att); /* fails while another session still uses it: fine */ + if (moved_out) + *moved_out = moved; + if (failed_out) + *failed_out = failed; + return 0; +} diff --git a/src/attach.c b/src/attach.c index fa191eb..e10780f 100644 --- a/src/attach.c +++ b/src/attach.c @@ -260,8 +260,13 @@ int cst_attach_dimensions(const void *data, size_t n, int *w, int *h) { /* --- storage ------------------------------------------------------------- */ -/* /attachments/ — beside the JSONL so moving the - * workspace keeps the two together. */ +/* Where a session's payloads live, beside its JSONL so moving the workspace + * keeps the two together: + * /session.jsonl -> /attachments/ (the store's shape + * since v0.9.5) + * .jsonl -> /attachments// (older sessions; the + * migration moves them) + * The directory is made here: every store() writes into it next. */ int cst_attach_dir(const char *session_path, char *out, size_t out_sz) { if (!session_path || !session_path[0]) return -1; @@ -289,8 +294,13 @@ int cst_attach_dir(const char *session_path, char *out, size_t out_sz) { char *dot = strstr(leaf, ".jsonl"); if (dot) *dot = 0; + /* A session that owns its directory keeps its payloads directly inside it; + * only the flat layout needs the shared directory and its stem. */ char rel[320]; - snprintf(rel, sizeof(rel), "attachments/%s", leaf[0] ? leaf : "session"); + if (strcmp(leaf, "session") == 0) + snprintf(rel, sizeof(rel), "attachments"); + else + snprintf(rel, sizeof(rel), "attachments/%s", leaf[0] ? leaf : "session"); char full[OS_PATH_MAX]; if (os_path_join(full, sizeof(full), dir, rel) != 0) return -1; diff --git a/src/main.c b/src/main.c index a4c4923..be4d2ed 100644 --- a/src/main.c +++ b/src/main.c @@ -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, /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//\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(); diff --git a/src/metrics.c b/src/metrics.c index 34fde34..3a072f1 100644 --- a/src/metrics.c +++ b/src/metrics.c @@ -1,6 +1,7 @@ #include "cst_metrics.h" #include "cJSON.h" +#include "cst_agent.h" #include "os_compat.h" #include @@ -31,18 +32,11 @@ cst_metrics_t *cst_metrics_open(const char *workspace, const char *session_path) os_mkdir(agent, 0755); os_path_join(dir, sizeof(dir), agent, "metrics"); os_mkdir(dir, 0755); - const char *base = session_path ? session_path : "session.jsonl"; - const char *s1 = strrchr(base, '/'); - const char *s2 = strrchr(base, '\\'); - if (s2 && (!s1 || s2 > s1)) - s1 = s2; - if (s1) - base = s1 + 1; + /* One file per session: the id of the transcript's path (.jsonl, or + * /session.jsonl in the store's own layout). */ char leaf[256]; - snprintf(leaf, sizeof(leaf), "%s", base); - size_t n = strlen(leaf); - if (n > 6 && strcmp(leaf + n - 6, ".jsonl") == 0) - leaf[n - 6] = 0; + if (cst_agent_store_id_of_path(session_path, leaf, sizeof(leaf)) != 0) + snprintf(leaf, sizeof(leaf), "session"); char file[300]; snprintf(file, sizeof(file), "%s.jsonl", leaf); os_path_join(m->path, sizeof(m->path), dir, file); diff --git a/tests/test_store.c b/tests/test_store.c new file mode 100644 index 0000000..1e07580 --- /dev/null +++ b/tests/test_store.c @@ -0,0 +1,291 @@ +/* The session store: one directory per session, and the way in for stores + * written before that. + * + * What has to hold: + * - an id is a uuid v4 (two machines on one synced workspace must not hand + * out the same one); + * - a new session gets /.agent/sessions//session.jsonl, and its + * attachments land in that directory, not in a shared one; + * - a store from an older version keeps working exactly as it was (flat + * .jsonl + .title + attachments//), and --migrate-store moves + * it into the new shape without losing the transcript, the title or a + * single attachment byte; + * - deleting a session takes its whole directory with it. + */ +#define _POSIX_C_SOURCE 200809L + +#include "cst_agent.h" +#include "cst_attach.h" +#include "cst_test_util.h" +#include "os_compat.h" + +#include +#include +#include + +static int nfail; +static char g_ws[1024]; + +#define EXPECT(cond, msg) \ + do { \ + if (!(cond)) { \ + fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, msg); \ + nfail++; \ + } \ + } while (0) + +static void wfile(const char *path, const char *data) { + FILE *f = fopen(path, "wb"); + if (!f) { + fprintf(stderr, "cannot write %s\n", path); + exit(1); + } + fputs(data, f); + fclose(f); +} + +static int exists(const char *path) { + os_stat_t st; + return os_lstat(path, &st) == 0; +} + +static void test_ids(void) { + static char a[64], b[64]; + EXPECT(cst_agent_store_new_id(a, sizeof(a)) == 0, "an id is made"); + EXPECT(cst_agent_store_new_id(b, sizeof(b)) == 0, "and another one"); + EXPECT(strcmp(a, b) != 0, "two ids differ"); + EXPECT(strlen(a) == 36, "the id is 36 characters (uuid)"); + EXPECT(a[8] == '-' && a[13] == '-' && a[18] == '-' && a[23] == '-', + "with the uuid dashes where they belong"); + EXPECT(a[14] == '4', "version 4"); + EXPECT(strchr("89ab", a[19]) != NULL, "the variant bits say rfc 4122"); + char small[16]; + EXPECT(cst_agent_store_new_id(small, sizeof(small)) != 0, + "a buffer too small for a uuid is refused"); +} + +static void test_new_session(void) { + char id[64]; + EXPECT(cst_agent_store_new_id(id, sizeof(id)) == 0, "id for a new session"); + char path[1200]; + EXPECT(cst_agent_store_path_for_id(g_ws, id, path, sizeof(path)) == 0, + "the store names the transcript"); + char want[1200]; + snprintf(want, sizeof(want), "%s/.agent/sessions/%s/session.jsonl", g_ws, id); + EXPECT(strcmp(path, want) == 0, "it is /session.jsonl"); + char want_dir[1200]; + snprintf(want_dir, sizeof(want_dir), "%s/.agent/sessions/%s", g_ws, id); + EXPECT(exists(want_dir), "and the session's directory was made for it"); + /* the id is the directory, not the file name: a path in this layout must + * not make every session call itself "session" */ + char back[128]; + EXPECT(cst_agent_store_id_of_path(path, back, sizeof(back)) == 0 && + strcmp(back, id) == 0, + "the transcript's path names its session"); + + /* the engine opens it like any other file */ + cst_session_t *s = cst_session_open(path); + EXPECT(s != NULL, "the transcript opens"); + cst_message_t m; + memset(&m, 0, sizeof(m)); + m.role = CST_ROLE_USER; + m.content = (char *)"what is this?"; + EXPECT(cst_session_append(s, &m) == 0, "a message goes in"); + cst_session_close(s); + + static char ids[16][128]; + int n = cst_agent_store_list(g_ws, ids, 16); + int found = 0; + for (int i = 0; i < n; i++) + if (strcmp(ids[i], id) == 0) + found = 1; + EXPECT(found, "the store lists it"); + + cst_session_info_t in; + EXPECT(cst_agent_store_info(g_ws, id, &in) == 0, "its info reads back"); + EXPECT(in.messages == 1, "one message in it"); + EXPECT(strcmp(in.first_user, "what is this?") == 0, "the first user line"); + EXPECT(cst_agent_store_title_set(g_ws, id, "about the store") == 0, + "a title can be set"); + char tp[1200]; + snprintf(tp, sizeof(tp), "%s/.agent/sessions/%s/title", g_ws, id); + EXPECT(exists(tp), "the title lives inside the session directory"); + memset(&in, 0, sizeof(in)); + EXPECT(cst_agent_store_info(g_ws, id, &in) == 0 && + strcmp(in.title, "about the store") == 0, + "and comes back"); + + /* attachments: inside the session's own directory (import_bytes takes an + * image, so the payload says PNG) */ + char err[256] = ""; + static const char png[] = "\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR"; + cst_attachment_t *a = cst_attach_import_bytes(path, "shot.png", png, + sizeof(png) - 1, err, + sizeof(err)); + EXPECT(a != NULL, "a payload is stored"); + if (a) { + char ap[1200]; + EXPECT(cst_attach_full_path(path, a, ap, sizeof(ap)) == 0, + "its path resolves"); + snprintf(want, sizeof(want), "%s/.agent/sessions/%s/attachments/", g_ws, id); + EXPECT(strncmp(ap, want, strlen(want)) == 0, + "which is inside /attachments/"); + const char *tail = strstr(ap, "/attachments/"); + EXPECT(tail && strchr(tail + strlen("/attachments/"), '/') == NULL, + "with no second stem in the path"); + cst_attach_free(a); + } + + /* deleting takes the directory (and the payload) with it */ + EXPECT(cst_agent_store_delete(g_ws, id) == 0, "the session is deleted"); + char dir[1200]; + snprintf(dir, sizeof(dir), "%s/.agent/sessions/%s", g_ws, id); + EXPECT(!exists(dir), "and its directory is gone, payload and all"); +} + +/* A store the way an older version wrote one. */ +static void write_flat_session(const char *id, const char *title, + const char *first, const char *payload, + char *path_out, size_t path_sz) { + char dir[1200], p[1200]; + snprintf(dir, sizeof(dir), "%s/.agent/sessions", g_ws); + os_mkdir_p(dir, 0755); + snprintf(p, sizeof(p), "%s/%s.jsonl", dir, id); + wfile(p, first); + if (title) { + snprintf(p, sizeof(p), "%s/%s.title", dir, id); + wfile(p, title); + } + if (payload) { + snprintf(p, sizeof(p), "%s/attachments/%s", dir, id); + os_mkdir_p(p, 0700); + snprintf(p, sizeof(p), "%s/attachments/%s/art-1.md", dir, id); + wfile(p, payload); + } + snprintf(path_out, path_sz, "%s/%s.jsonl", dir, id); +} + +static void test_old_store(void) { + const char *id = "20260101-101010-abcd"; + char flat[1200]; + write_flat_session(id, "the old one\n", + "{\"role\":\"user\",\"content\":\"from the old layout\"}\n", + "an artifact body\n", flat, sizeof(flat)); + + /* before migrating: read exactly as before */ + char path[1200]; + EXPECT(cst_agent_store_path_for_id(g_ws, id, path, sizeof(path)) == 0, + "the old session resolves"); + EXPECT(strcmp(path, flat) == 0, "to its flat file, untouched"); + cst_session_info_t in; + EXPECT(cst_agent_store_info(g_ws, id, &in) == 0 && + strcmp(in.title, "the old one") == 0, + "its title is read from .title"); + EXPECT(strcmp(in.first_user, "from the old layout") == 0, + "and its transcript"); + static char ids[16][128]; + int n = cst_agent_store_list(g_ws, ids, 16), found = 0; + for (int i = 0; i < n; i++) + if (strcmp(ids[i], id) == 0) + found = 1; + EXPECT(found, "the store lists it"); + + /* a payload of that session sits in the shared directory */ + char old_payload[1200]; + snprintf(old_payload, sizeof(old_payload), + "%s/.agent/sessions/attachments/%s/art-1.md", g_ws, id); + EXPECT(exists(old_payload), "its artifact is in the shared directory"); + + int moved = 0, failed = 0; + char err[256] = ""; + EXPECT(cst_agent_store_migrate(g_ws, &moved, &failed, err, sizeof(err)) == 0, + "the store migrates"); + EXPECT(moved == 1 && failed == 0, "one session moved, none failed"); + + char nested[1200]; + snprintf(nested, sizeof(nested), "%s/.agent/sessions/%s/session.jsonl", g_ws, + id); + EXPECT(exists(nested), "the transcript is /session.jsonl now"); + char back[128]; + EXPECT(cst_agent_store_id_of_path(nested, back, sizeof(back)) == 0 && + strcmp(back, id) == 0, + "and names the same session after the move"); + EXPECT(!exists(flat), "the flat file is gone"); + char tp[1200]; + snprintf(tp, sizeof(tp), "%s/.agent/sessions/%s/title", g_ws, id); + EXPECT(exists(tp), "the title moved inside"); + char new_payload[1200]; + snprintf(new_payload, sizeof(new_payload), + "%s/.agent/sessions/%s/attachments/art-1.md", g_ws, id); + EXPECT(exists(new_payload), "the artifact moved into the session directory"); + EXPECT(!exists(old_payload), "and is not in the shared one any more"); + { + FILE *f = fopen(new_payload, "rb"); + char body[64] = ""; + if (f) { + fgets(body, sizeof(body), f); + fclose(f); + } + EXPECT(strcmp(body, "an artifact body\n") == 0, "byte for byte"); + } + + /* the store still sees exactly one session of that id, now nested */ + EXPECT(cst_agent_store_path_for_id(g_ws, id, path, sizeof(path)) == 0 && + strcmp(path, nested) == 0, + "the id now resolves to the directory form"); + memset(&in, 0, sizeof(in)); + EXPECT(cst_agent_store_info(g_ws, id, &in) == 0 && + strcmp(in.title, "the old one") == 0 && + strcmp(in.first_user, "from the old layout") == 0, + "and reads the same session back"); + n = cst_agent_store_list(g_ws, ids, 16); + int count = 0; + for (int i = 0; i < n; i++) + if (strcmp(ids[i], id) == 0) + count++; + EXPECT(count == 1, "listed once, not twice"); + + /* the attachment of a migrated session is readable through the new path */ + cst_attachment_t a; + memset(&a, 0, sizeof(a)); + snprintf(a.rel, sizeof(a.rel), "art-1.md"); + size_t len = 0; + char *body = cst_attach_read(nested, &a, &len, err, sizeof(err)); + EXPECT(body && len == 17 && strncmp(body, "an artifact body", 16) == 0, + "the migrated payload reads back through the session"); + free(body); + + /* running it again changes nothing */ + moved = -1; + failed = -1; + EXPECT(cst_agent_store_migrate(g_ws, &moved, &failed, err, sizeof(err)) == 0, + "a second migration runs"); + EXPECT(moved == 0 && failed == 0, "and finds nothing left to move"); + EXPECT(exists(nested), "the session is still there"); + + EXPECT(cst_agent_store_delete(g_ws, id) == 0, "delete a migrated session"); + EXPECT(!exists(nested) && !exists(new_payload), "takes the payload with it"); +} + +int main(void) { + if (test_tmpdir(g_ws, sizeof(g_ws), "cst-store") != 0) { + perror("tmpdir"); + return 1; + } + if (cst_agent_store_ensure(g_ws) != 0) { + fprintf(stderr, "cannot make the store\n"); + return 1; + } + + test_ids(); + test_new_session(); + test_old_store(); + + test_rmrf(g_ws); + if (nfail) { + fprintf(stderr, "%d check(s) failed\n", nfail); + return 1; + } + printf("test_store: OK\n"); + return 0; +}