The rewritten cst_agent_store_info() counted a transcript line that ran past a whole 64 KiB read block a second time, when the newline ending its tail arrived: a session with one very long line (a tool that read a file back) reported one message too many -- exactly what the sidebar shows the person. - src/agent_store.c: the tail's newline ends a line that was already counted. - tests/test_store.c: test_info_scan() holds the scan down -- a 70 KiB line is one line, the first user message comes from the head of the file, and a user line past the window is not called the first one. - tests/test_tools_ext.c: a byte budget stops the session_search walk, says so in the result, and never reports a match it did not reach; the same query without a budget finds it. make check and make asan are green.
717 lines
22 KiB
C
717 lines
22 KiB
C
#ifndef _DEFAULT_SOURCE
|
|
#define _DEFAULT_SOURCE
|
|
#endif
|
|
|
|
#include "cJSON.h"
|
|
#include "cst_agent.h"
|
|
#include "os_compat.h"
|
|
|
|
#include <errno.h>
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include <sys/stat.h>
|
|
#include <time.h>
|
|
|
|
#ifdef _WIN32
|
|
#include <io.h>
|
|
#define OS_ACCESS _access
|
|
#define OS_F_OK 0
|
|
#else
|
|
#include <unistd.h>
|
|
#define OS_ACCESS access
|
|
#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;
|
|
return os_path_join(out, out_sz, workspace, ".agent/sessions");
|
|
}
|
|
|
|
int cst_agent_store_ensure(const char *workspace) {
|
|
char base[OS_PATH_MAX];
|
|
char sess[OS_PATH_MAX];
|
|
if (cst_agent_store_dir(workspace, sess, sizeof(sess)) != 0)
|
|
return -1;
|
|
if (os_path_join(base, sizeof(base), workspace, ".agent") != 0)
|
|
return -1;
|
|
if (os_mkdir(base, 0755) != 0)
|
|
return -1;
|
|
if (os_mkdir(sess, 0755) != 0)
|
|
return -1;
|
|
/* keep sessions, undo snapshots and exports out of git */
|
|
char gi[OS_PATH_MAX];
|
|
if (os_path_join(gi, sizeof(gi), base, ".gitignore") == 0 &&
|
|
OS_ACCESS(gi, OS_F_OK) != 0) {
|
|
FILE *fp = fopen(gi, "wb");
|
|
if (fp) {
|
|
fputs("*\n", fp);
|
|
fclose(fp);
|
|
}
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
/* 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 < 37)
|
|
return -1;
|
|
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)
|
|
return -1;
|
|
/* Accept bare id or full path. */
|
|
if (strchr(id, '/') || strchr(id, '\\')) {
|
|
snprintf(out, out_sz, "%s", id);
|
|
return 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;
|
|
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) {
|
|
if (cst_agent_store_ensure(workspace) != 0)
|
|
return -1;
|
|
char dir[OS_PATH_MAX];
|
|
char path[OS_PATH_MAX];
|
|
if (cst_agent_store_dir(workspace, dir, sizeof(dir)) != 0)
|
|
return -1;
|
|
if (os_path_join(path, sizeof(path), dir, "CURRENT") != 0)
|
|
return -1;
|
|
FILE *fp = fopen(path, "wb");
|
|
if (!fp)
|
|
return -1;
|
|
fprintf(fp, "%s\n", id);
|
|
fclose(fp);
|
|
return 0;
|
|
}
|
|
|
|
int cst_agent_store_get_current(const char *workspace, char *id_out,
|
|
size_t id_sz) {
|
|
char dir[OS_PATH_MAX];
|
|
char path[OS_PATH_MAX];
|
|
if (cst_agent_store_dir(workspace, dir, sizeof(dir)) != 0)
|
|
return -1;
|
|
if (os_path_join(path, sizeof(path), dir, "CURRENT") != 0)
|
|
return -1;
|
|
FILE *fp = fopen(path, "rb");
|
|
if (!fp)
|
|
return -1;
|
|
if (!fgets(id_out, (int)id_sz, fp)) {
|
|
fclose(fp);
|
|
return -1;
|
|
}
|
|
fclose(fp);
|
|
size_t n = strlen(id_out);
|
|
while (n > 0 && (id_out[n - 1] == '\n' || id_out[n - 1] == '\r'))
|
|
id_out[--n] = 0;
|
|
return id_out[0] ? 0 : -1;
|
|
}
|
|
|
|
int cst_agent_store_resolve_default(const char *workspace, char *path_out,
|
|
size_t path_sz, char *id_out,
|
|
size_t id_sz) {
|
|
if (cst_agent_store_ensure(workspace) != 0)
|
|
return -1;
|
|
char id[128];
|
|
if (cst_agent_store_get_current(workspace, id, sizeof(id)) == 0) {
|
|
char p[OS_PATH_MAX];
|
|
if (cst_agent_store_path_for_id(workspace, id, p, sizeof(p)) == 0 &&
|
|
OS_ACCESS(p, OS_F_OK) == 0) {
|
|
snprintf(path_out, path_sz, "%s", p);
|
|
if (id_out && id_sz)
|
|
snprintf(id_out, id_sz, "%s", id);
|
|
return 0;
|
|
}
|
|
}
|
|
if (cst_agent_store_new_id(id, sizeof(id)) != 0)
|
|
return -1;
|
|
if (cst_agent_store_path_for_id(workspace, id, path_out, path_sz) != 0)
|
|
return -1;
|
|
cst_agent_store_set_current(workspace, id);
|
|
if (id_out && id_sz)
|
|
snprintf(id_out, id_sz, "%s", id);
|
|
return 0;
|
|
}
|
|
|
|
int cst_agent_store_list(const char *workspace, char ids[][128], int max_ids) {
|
|
if (!workspace || !ids || max_ids <= 0)
|
|
return 0;
|
|
char dir[OS_PATH_MAX];
|
|
if (cst_agent_store_dir(workspace, dir, sizeof(dir)) != 0)
|
|
return 0;
|
|
os_dir_t *d = os_dir_open(dir);
|
|
if (!d)
|
|
return 0;
|
|
typedef struct {
|
|
char id[128];
|
|
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;
|
|
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;
|
|
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);
|
|
for (int i = 0; i < n; i++) {
|
|
for (int j = i + 1; j < n; j++) {
|
|
if (ents[j].mtime > ents[i].mtime) {
|
|
ent_t tmp = ents[i];
|
|
ents[i] = ents[j];
|
|
ents[j] = tmp;
|
|
}
|
|
}
|
|
}
|
|
int out_n = n < max_ids ? n : max_ids;
|
|
for (int i = 0; i < out_n; i++)
|
|
snprintf(ids[i], 128, "%s", ents[i].id);
|
|
free(ents);
|
|
return out_n;
|
|
}
|
|
|
|
/* 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.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 (title_for_id(workspace, id, p, sizeof(p)) != 0)
|
|
return -1;
|
|
if (!title || !title[0])
|
|
return remove(p) == 0 || errno == ENOENT ? 0 : -1;
|
|
FILE *fp = fopen(p, "wb");
|
|
if (!fp)
|
|
return -1;
|
|
for (const char *c = title; *c; c++)
|
|
fputc(*c == '\n' || *c == '\r' ? ' ' : *c, fp);
|
|
fputc('\n', fp);
|
|
return fclose(fp) == 0 ? 0 : -1;
|
|
}
|
|
|
|
/* Cut to n bytes without splitting a UTF-8 sequence; newlines → spaces. */
|
|
static void clip_line(char *dst, size_t n, const char *src) {
|
|
size_t i = 0;
|
|
while (src[i] && i + 1 < n) {
|
|
dst[i] = (src[i] == '\n' || src[i] == '\r' || src[i] == '\t') ? ' '
|
|
: src[i];
|
|
i++;
|
|
}
|
|
while (i > 0 && src[i] && ((unsigned char)src[i] & 0xC0) == 0x80)
|
|
i--;
|
|
dst[i] = 0;
|
|
}
|
|
|
|
int cst_agent_store_info(const char *workspace, const char *id,
|
|
cst_session_info_t *info) {
|
|
if (!info)
|
|
return -1;
|
|
memset(info, 0, sizeof(*info));
|
|
snprintf(info->id, sizeof(info->id), "%s", id ? id : "");
|
|
char p[OS_PATH_MAX];
|
|
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;
|
|
}
|
|
os_stat_t st;
|
|
if (os_lstat(p, &st) != 0)
|
|
return -1;
|
|
info->mtime = (long long)st.mtime;
|
|
char tp[OS_PATH_MAX];
|
|
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)) {
|
|
size_t n = strlen(info->title);
|
|
while (n && (info->title[n - 1] == '\n' || info->title[n - 1] == '\r'))
|
|
info->title[--n] = 0;
|
|
}
|
|
fclose(tf);
|
|
}
|
|
}
|
|
FILE *fp = fopen(p, "rb");
|
|
if (!fp)
|
|
return 0;
|
|
/* Reading a transcript must stay cheap: the session list asks for this for
|
|
* every stored session, on the GUI thread, every time the store changes.
|
|
* So a line is only ever *parsed* while the first user message is still
|
|
* missing and only within the first kFirstUserBytes; everything else is
|
|
* counted by looking for newlines, which costs a memchr per block instead
|
|
* of a cJSON tree per line. */
|
|
enum { kBlock = 1 << 16, kFirstUserBytes = 1 << 18 };
|
|
char *buf = malloc(kBlock);
|
|
if (!buf) {
|
|
fclose(fp);
|
|
return 0;
|
|
}
|
|
size_t have = 0; /* bytes in buf that belong to the current line */
|
|
size_t seen = 0; /* bytes already consumed by finished lines */
|
|
int slice = 0; /* the current line did not fit: its tail is dropped */
|
|
int want_first = 1; /* still looking for the first user message */
|
|
for (;;) {
|
|
size_t got = fread(buf + have, 1, kBlock - have, fp);
|
|
size_t total = have + got;
|
|
size_t at = 0;
|
|
for (;;) {
|
|
char *nl = memchr(buf + at, '\n', total - at);
|
|
if (!nl)
|
|
break;
|
|
const size_t len = (size_t)(nl - (buf + at));
|
|
/* A line counts once. A line that ran past a whole block was counted
|
|
* when its tail was dropped, so the newline that ends it is not a line
|
|
* of its own. */
|
|
if (len > 0 && !slice)
|
|
info->messages++;
|
|
if (!slice && len > 0 && want_first && seen < kFirstUserBytes) {
|
|
*nl = 0; /* the parse reads a C string; the newline is ours */
|
|
cJSON *o = cJSON_Parse(buf + at);
|
|
const cJSON *r = cJSON_GetObjectItemCaseSensitive(o, "role");
|
|
const cJSON *c = cJSON_GetObjectItemCaseSensitive(o, "content");
|
|
const cJSON *k = cJSON_GetObjectItemCaseSensitive(
|
|
cJSON_GetObjectItemCaseSensitive(o, "meta"), "kind");
|
|
const int note = cJSON_IsString(k) && strcmp(k->valuestring, "note") == 0;
|
|
if (cJSON_IsString(r) && strcmp(r->valuestring, "user") == 0 && !note &&
|
|
cJSON_IsString(c) && c->valuestring[0]) {
|
|
clip_line(info->first_user, sizeof(info->first_user), c->valuestring);
|
|
want_first = 0;
|
|
}
|
|
cJSON_Delete(o);
|
|
}
|
|
seen += len + 1;
|
|
at += len + 1;
|
|
slice = 0;
|
|
}
|
|
have = total - at;
|
|
if (got == 0)
|
|
break; /* EOF */
|
|
if (have == kBlock) {
|
|
/* a line longer than one block: count it, drop its tail, keep going */
|
|
info->messages++;
|
|
seen += have;
|
|
have = 0;
|
|
slice = 1;
|
|
} else if (have > 0) {
|
|
memmove(buf, buf + at, have);
|
|
}
|
|
}
|
|
if (have > 0 && !slice)
|
|
info->messages++; /* the last line, without a newline */
|
|
free(buf);
|
|
fclose(fp);
|
|
return 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 (!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) {
|
|
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;
|
|
}
|