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:
@@ -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