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:
+1
-1
@@ -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
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -273,6 +273,7 @@ cst-agent --headless --prompt 'List files'
|
||||
| `-N` / `--new` | 开新会话 |
|
||||
| `-R ID` / `--resume ID` | 打开指定会话(完整 id 或唯一前缀) |
|
||||
| `-L` / `--list-sessions` | 列出会话(时间、条数、标题)后退出 |
|
||||
| `--migrate-store` | 把旧版本的会话(扁平 `<id>.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` 可指定别的目录)。
|
||||
|
||||
会话落盘:`<workspace>/.agent/sessions/<id>.jsonl` + `CURRENT`;`.agent/` 自带 `.gitignore`,不会被误提交。
|
||||
会话落盘:一个会话一个目录 `<workspace>/.agent/sessions/<id>/`(`session.jsonl` 转录、`title`、`attachments/`、子 agent 的 `sub-N.jsonl`)+ `CURRENT` 指向下次打开的会话;id 是 uuid,两台机器共用一份工作目录也不会撞。旧版本写的扁平会话仍然可读,`--migrate-store` 一次搬进新布局(幂等)。`.agent/` 自带 `.gitignore`,不会被误提交。
|
||||
|
||||
### 内置工具
|
||||
|
||||
|
||||
+10
-6
@@ -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/<id>.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/<id>/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 `<id>.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/<id>/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
|
||||
|
||||
@@ -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: <sessions>/<id>/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);
|
||||
|
||||
+1
-1
@@ -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/<id>/session.jsonl` 的行一致,另加的字段见下) |
|
||||
| GET | `/api/sessions` | `{current, sessions:[{id,title,messages,mtime}]}` |
|
||||
| POST | `/api/sessions/new` | `{}` → `{id}` |
|
||||
| POST | `/api/sessions/switch` | `{"id":"…"}` |
|
||||
|
||||
@@ -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
|
||||
* (<id>/session.jsonl, with its attachments and sub-agent transcripts inside
|
||||
* it); an id that still has the older flat file (<id>.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: <dir>/<id>.jsonl (the flat layout) or
|
||||
* <dir>/<id>/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 <id>/session.jsonl, the title and the shared attachment
|
||||
* directory (<id>.title, attachments/<id>/) 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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
+2
-10
@@ -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) {
|
||||
|
||||
+394
-50
@@ -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).
|
||||
*
|
||||
* <workspace>/.agent/sessions/
|
||||
* <uuid>/session.jsonl the transcript
|
||||
* <uuid>/title the window title, one line
|
||||
* <uuid>/attachments/ what the messages carry: images, delivered
|
||||
* files (<id>.png, art-<id>.md, ...)
|
||||
* <uuid>/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.
|
||||
*
|
||||
* <uuid>.jsonl
|
||||
* <uuid>.title
|
||||
* attachments/<uuid>/... (shared by every session)
|
||||
* <uuid>/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;
|
||||
}
|
||||
|
||||
/* <sessions>/<id>, 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);
|
||||
}
|
||||
|
||||
/* <sessions>/<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) {
|
||||
/* <id>/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 <id>.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%s", id, ext);
|
||||
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;
|
||||
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;
|
||||
struct stat st;
|
||||
if (stat(p, &st) != 0)
|
||||
} else if (nested_for_id(workspace, id, p, sizeof(p)) != 0) {
|
||||
return -1;
|
||||
info->mtime = (long long)st.st_mtime;
|
||||
}
|
||||
os_stat_t st;
|
||||
if (os_lstat(p, &st) != 0)
|
||||
return -1;
|
||||
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 <id>/ */
|
||||
if (side_path(workspace, id, "", p, sizeof(p)) == 0) {
|
||||
os_dir_t *d = os_dir_open(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 (strncmp(de->name, "sub-", 4) == 0 &&
|
||||
os_path_join(f, sizeof(f), p, de->name) == 0)
|
||||
remove(f);
|
||||
if (os_path_join(f, sizeof(f), path, de->name) == 0)
|
||||
rm_tree(f);
|
||||
}
|
||||
os_dir_close(d);
|
||||
rmdir(p);
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
+12
-2
@@ -260,8 +260,13 @@ int cst_attach_dimensions(const void *data, size_t n, int *w, int *h) {
|
||||
|
||||
/* --- storage ------------------------------------------------------------- */
|
||||
|
||||
/* <session dir>/attachments/<session stem> — 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:
|
||||
* <uuid>/session.jsonl -> <uuid>/attachments/ (the store's shape
|
||||
* since v0.9.5)
|
||||
* <id>.jsonl -> <dir>/attachments/<id>/ (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,7 +294,12 @@ 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];
|
||||
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)
|
||||
|
||||
+32
-1
@@ -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();
|
||||
|
||||
+5
-11
@@ -1,6 +1,7 @@
|
||||
#include "cst_metrics.h"
|
||||
|
||||
#include "cJSON.h"
|
||||
#include "cst_agent.h"
|
||||
#include "os_compat.h"
|
||||
|
||||
#include <stdio.h>
|
||||
@@ -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 (<id>.jsonl, or
|
||||
* <id>/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);
|
||||
|
||||
@@ -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 <ws>/.agent/sessions/<id>/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
|
||||
* <id>.jsonl + <id>.title + attachments/<id>/), 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 <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
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 <id>/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 <id>/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 <id>.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 <id>/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;
|
||||
}
|
||||
Reference in New Issue
Block a user