Compare commits
66
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5c4a355b4 | ||
|
|
7741e61045 | ||
|
|
6dddf07642 | ||
|
|
e5c081e55f | ||
|
|
a3e2276bef | ||
|
|
c498a15ecf | ||
|
|
0004f04cfb | ||
|
|
792f5b7ba7 | ||
|
|
47f3166f89 | ||
|
|
e2fecf9953 | ||
|
|
1cc0b50446 | ||
|
|
01c67d4c70 | ||
|
|
11ca7d111d | ||
|
|
e35e62a5c6 | ||
|
|
29a2e507a9 | ||
|
|
4a0ec61da3 | ||
|
|
56acb397be | ||
|
|
ca37156a56 | ||
|
|
1b24337a98 | ||
|
|
ee5ac381b3 | ||
|
|
a5916f6177 | ||
|
|
ee87a59e1f | ||
|
|
475e94b662 | ||
|
|
beb22aec78 | ||
|
|
6e61fdd18f | ||
|
|
ee259cff8b | ||
|
|
42b5feb84c | ||
|
|
99e5624358 | ||
|
|
2936583042 | ||
|
|
c5a01e5e53 | ||
|
|
11cc8628cf | ||
|
|
ec92f7f994 | ||
|
|
cf88e2df86 | ||
|
|
1bca663ace | ||
|
|
4c9c849fcd | ||
|
|
e9613767c5 | ||
|
|
971315aa80 | ||
|
|
390122480a | ||
|
|
e0950f5ed4 | ||
|
|
f7b442be61 | ||
|
|
2f3325c03f | ||
|
|
b7ea5443b3 | ||
|
|
88d1790728 | ||
|
|
99e0024973 | ||
|
|
0724a73300 | ||
|
|
e7ac22f0dc | ||
|
|
f5e76fff53 | ||
|
|
f62b4dbc4c | ||
|
|
9e2c2b37b3 | ||
|
|
d5f752dd7b | ||
|
|
554de4ff2f | ||
|
|
b235132d89 | ||
|
|
783cc3bb34 | ||
|
|
69cc47c13f | ||
|
|
c7f7a6e1b0 | ||
|
|
fcc45520ad | ||
|
|
ad80180381 | ||
|
|
3564817409 | ||
|
|
2f34a37d13 | ||
|
|
ae953a9acd | ||
|
|
de27275bb1 | ||
|
|
05a6e8cc2d | ||
|
|
9df7c38069 | ||
|
|
ccaf8c1be0 | ||
|
|
38c8398bc1 | ||
|
|
bb17d6d435 |
+15
-10
@@ -35,8 +35,12 @@
|
||||
cache in `embedding-models`, and `embedding-model-init` blocks `core` until
|
||||
`qwen3-embedding:0.6b` is present.
|
||||
- **Semantic contract.** Internal semantic indexing is fixed to `qwen3-embedding:0.6b`,
|
||||
`1024` dimensions, and cosine distance. Schema-v3 descriptors are operational; schema-v1/v2 descriptors remain `migration_required` until an explicit reviewed migration writes schema version 3. One workspace owns one Qdrant collection, and schema, Evidence, and Memory records
|
||||
coexist inside that collection with payload `kind` separation.
|
||||
`1024` dimensions, and cosine distance. Schema v3 is the only accepted workspace descriptor
|
||||
format. Schema v1 and v2 descriptors are rejected while a candidate snapshot is validated, so
|
||||
activation or a pull fails atomically and leaves the prior valid snapshot active; there is no
|
||||
in-product migrator or automatic conversion. One workspace owns one Qdrant collection, and
|
||||
schema, Evidence, and Memory records coexist inside that collection with payload `kind`
|
||||
separation.
|
||||
- **Final review runtime barriers.** Operational routes, retained session pins, and runtime
|
||||
rendering now require schema version 3 before resolving bindings, readiness, diagnostics, or
|
||||
Pi. Session admission verifies the exact internal Qdrant collection (dimensions, cosine
|
||||
@@ -53,11 +57,13 @@
|
||||
archives exactly one labeled `<project>_qdrant-data` volume and preserves the prior `qdrant`
|
||||
running state. `./scripts/vector-restore.sh --project-name <name> --input <file>
|
||||
--confirm-project <name>` requires the exact repeated project confirmation, validates manifest
|
||||
and archive safety before stopping `qdrant`, stages rollback content, restores in place, and
|
||||
restarts `qdrant` only if it was previously running. Restore does not migrate legacy workspace
|
||||
descriptors, rename collections, or repair a semantic-index incompatibility. Backup and restore
|
||||
share one atomic Docker-daemon lock per Compose project/Qdrant volume; contenders fail before
|
||||
volume resolution, and cleanup removes the lock only when its ownership labels still match.
|
||||
and archive safety before stopping `qdrant`, stages rollback content, restores semantic storage
|
||||
in place, and restarts `qdrant` only if it was previously running. Recovery requires the registry
|
||||
to already hold a reviewed v3 descriptor revision compatible with the restored collection; the
|
||||
helper does not restore descriptors, rename collections, or repair a semantic-index
|
||||
incompatibility. Backup and restore share one atomic Docker-daemon lock per Compose
|
||||
project/Qdrant volume; contenders fail before volume resolution, and cleanup removes the lock
|
||||
only when its ownership labels still match.
|
||||
- **Verification recorded for Task 13 final audit.** On Apple M4 Pro
|
||||
(`Darwin 25.5.0`, Docker Server `29.6.2 linux/arm64`), harness pytest passed
|
||||
**827 passed / 4 deselected**; backend Vitest passed **477/477** plus TypeScript and build;
|
||||
@@ -85,9 +91,8 @@
|
||||
Windows Docker Desktop startup were not manually executed in this run.
|
||||
- **Task 13 known limitations.** Broad harness Ruff remains existing unrelated debt
|
||||
(**220 errors**); touched harness files were verified Ruff-clean. The final active-reference
|
||||
audit remains non-empty only in categorized legacy parser/migration compatibility, legacy
|
||||
descriptor/config fixtures, deterministic negative guards, retained off-repository migration
|
||||
SQL, L2 legacy fixtures, gitignored task notes, and historical reference notes. No active
|
||||
audit remains non-empty only in deterministic negative guards, retained off-repository migration
|
||||
SQL, L2 compatibility fixtures, gitignored task notes, and historical reference notes. No active
|
||||
schema-v3 operator manual or supported runtime deployment path retains external vector or
|
||||
embedding endpoint coupling.
|
||||
- **Final review fix verification.** Backend Vitest passed **477/477** plus TypeScript and build;
|
||||
|
||||
@@ -58,7 +58,7 @@ diagnostics are exposed by `tht doctor` and do not prevent the UI from starting.
|
||||
Workspace descriptors are shared through a validated Git repository while endpoint bindings and
|
||||
secret files remain installation-local. Use the [local Mac/PC installation manual](docs/install/local-workspace-registry.md)
|
||||
for Docker Desktop or a local engine, and the [server installation manual](docs/install/server-workspace-registry.md)
|
||||
for the Gitea, reverse-proxy, backup, migration, and recovery workflow. The isolated deployment
|
||||
for the Gitea, reverse-proxy, backup, upgrade, and recovery workflow. The isolated deployment
|
||||
exercise is `./scripts/workspace-registry-smoke.sh`; both manuals are checked with
|
||||
`./scripts/verify-workspace-install-docs.sh --profile local` or `--profile server`.
|
||||
|
||||
@@ -70,10 +70,12 @@ every revision referenced by an open, closed, or failed unarchived session. It r
|
||||
single local installation list or from a server administrator's complete session list, never from
|
||||
a remote user's partial list.
|
||||
|
||||
Schema-v3 is the operational descriptor contract. Schema-v1/v2 descriptors remain
|
||||
`migration_required` until an explicit reviewed migration writes schema version 3. One workspace
|
||||
owns one Qdrant collection; schema, Evidence, and Memory records share that collection and stay
|
||||
separated by indexed payload `kind`.
|
||||
Schema v3 is the only accepted workspace descriptor format. Schema v1 and v2 descriptors are
|
||||
rejected while a candidate snapshot is validated, so activation or a pull fails atomically and the
|
||||
prior valid snapshot remains active. There is no in-product migrator or automatic conversion. A
|
||||
repository must already contain reviewed v3 descriptors. One workspace owns one Qdrant collection;
|
||||
schema, Evidence, and Memory records share that collection and stay separated by indexed payload
|
||||
`kind`.
|
||||
|
||||
Connector `ssh_tunnel` bindings are diagnostic-only in this release: their bounded probe always
|
||||
cleans up the loopback forward and returns `workspace_not_activatable`; session creation is rejected
|
||||
@@ -228,10 +230,11 @@ Compose project name by passing `--confirm-project`:
|
||||
|
||||
The restore script stops `qdrant`, validates the exact labeled target, stages the current volume
|
||||
contents for rollback, extracts the requested archive into the volume, and then returns the
|
||||
service to its prior running state. After restore, run the backend health checks and a known
|
||||
retrieval query before reopening write traffic. Restore does not migrate schema-v1/v2 workspace
|
||||
descriptors, does not rename collections, and does not reconcile an incompatible collection
|
||||
contract; those remain explicit reviewed recovery steps outside the helper.
|
||||
service to its prior running state. It restores semantic storage only. Before reopening write
|
||||
traffic, the workspace registry must already be at a reviewed v3 descriptor revision compatible
|
||||
with the restored collection; then run backend health checks and a known retrieval query. The
|
||||
helper does not restore descriptors, rename collections, or reconcile an incompatible collection
|
||||
contract.
|
||||
|
||||
## Production trust boundary and secrets
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"targets": [{
|
||||
"target_name": "workspace_fs_at",
|
||||
"sources": ["workspace_fs_at.cc"],
|
||||
"cflags_cc": ["-std=c++17"],
|
||||
"defines": ["NAPI_VERSION=8"],
|
||||
"conditions": [["OS=='linux' or OS=='mac'", {}], ["OS=='win'", {"type": "none"}]]
|
||||
}]
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
#include <node_api.h>
|
||||
#include <fcntl.h>
|
||||
#include <sys/file.h>
|
||||
#ifdef __linux__
|
||||
#include <sys/syscall.h>
|
||||
#endif
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
#include <dirent.h>
|
||||
#include <errno.h>
|
||||
#include <string.h>
|
||||
#include <string>
|
||||
#include <stdint.h>
|
||||
#include <unordered_set>
|
||||
#include <unordered_map>
|
||||
#include <mutex>
|
||||
#include <algorithm>
|
||||
#ifdef __APPLE__
|
||||
#include <sys/types.h>
|
||||
#ifndef F_FULLFSYNC
|
||||
#define F_FULLFSYNC 51
|
||||
#endif
|
||||
#endif
|
||||
|
||||
namespace {
|
||||
static const uint64_t MAGIC=0x5448545746534154ULL;
|
||||
static const char OWNER_TOKEN = 0;
|
||||
struct Handle { uint64_t magic; const void* owner; napi_env env; int fd; bool directory; bool closed; unsigned borrows; };
|
||||
struct EnvState { std::unordered_set<Handle*> handles; std::unordered_set<Handle*> all; std::mutex mutex; };
|
||||
static std::mutex statesMutex;
|
||||
static std::unordered_map<napi_env, EnvState*> states;
|
||||
static EnvState* stateFor(napi_env env) { std::lock_guard<std::mutex> g(statesMutex); auto it=states.find(env); if(it!=states.end()) return it->second; auto *s=new EnvState; states.emplace(env,s); return s; }
|
||||
static void finalize(napi_env env, void* data, void*) { auto *h=static_cast<Handle*>(data); if (!h) return; auto *st=stateFor(env); { std::lock_guard<std::mutex> g(st->mutex); st->handles.erase(h); st->all.erase(h); if (!h->closed) { h->closed=true; (void)::close(h->fd); } } delete h; }
|
||||
enum HandleResult { HANDLE_OK, HANDLE_CLOSED, HANDLE_ARGUMENT };
|
||||
static HandleResult getHandle(napi_env env,napi_value v,Handle** out) { void *p=nullptr; if (napi_get_value_external(env,v,&p)!=napi_ok || !p) return HANDLE_ARGUMENT; auto *h=static_cast<Handle*>(p); auto *st=stateFor(env); std::lock_guard<std::mutex> g(st->mutex); if (st->all.find(h)==st->all.end() || h->magic!=MAGIC || h->owner!=&OWNER_TOKEN || h->env!=env) return HANDLE_ARGUMENT; if (h->closed || st->handles.find(h)==st->handles.end()) return HANDLE_CLOSED; *out=h; return HANDLE_OK; }
|
||||
static const char* errnoName(int e) {
|
||||
switch(e) {
|
||||
case EINVAL:return "EINVAL"; case EBADF:return "EBADF"; case EEXIST:return "EEXIST";
|
||||
case ENOENT:return "ENOENT"; case ENOTDIR:return "ENOTDIR"; case ELOOP:return "ELOOP";
|
||||
case EACCES:return "EACCES"; case EPERM:return "EPERM"; case EAGAIN:return "EAGAIN";
|
||||
case EBUSY:return "EBUSY"; case ENOSPC:return "ENOSPC"; case EDQUOT:return "EDQUOT";
|
||||
case ENAMETOOLONG:return "ENAMETOOLONG"; case EROFS:return "EROFS"; case EISDIR:return "EISDIR";
|
||||
case ENFILE:return "ENFILE"; case EMFILE:return "EMFILE"; case ENOMEM:return "ENOMEM";
|
||||
case EIO:return "EIO"; case EINTR:return "EINTR"; case ENXIO:return "ENXIO";
|
||||
default: return nullptr;
|
||||
}
|
||||
}
|
||||
static napi_value error(napi_env env,const char* syscall,int e,const char* forced=nullptr) {
|
||||
napi_value msg,err,code,sys;
|
||||
const char *name=forced?forced:errnoName(e);
|
||||
std::string fallback;
|
||||
if (!name) { fallback="ERR_WORKSPACE_FS_AT_ERRNO_"+std::to_string(e); name=fallback.c_str(); }
|
||||
napi_create_string_utf8(env,forced?forced:strerror(e),NAPI_AUTO_LENGTH,&msg);
|
||||
napi_create_error(env,nullptr,msg,&err);
|
||||
napi_create_string_utf8(env,syscall,NAPI_AUTO_LENGTH,&sys); napi_set_named_property(env,err,"syscall",sys);
|
||||
napi_create_string_utf8(env,name,NAPI_AUTO_LENGTH,&code); napi_set_named_property(env,err,"code",code);
|
||||
napi_value en; napi_create_int32(env,e,&en); napi_set_named_property(env,err,"errno",en);
|
||||
return err;
|
||||
}
|
||||
static napi_value fail(napi_env env,const char*s,int e,const char* forced=nullptr){ napi_value x=error(env,s,e,forced); napi_throw(env,x); return nullptr; }
|
||||
static bool requireHandle(napi_env env,napi_value v,Handle** out,const char* syscall) { auto r=getHandle(env,v,out); if(r==HANDLE_OK) return true; napi_throw(env,error(env,syscall,EINVAL,r==HANDLE_CLOSED?"ERR_WORKSPACE_FS_AT_HANDLE_CLOSED":"ERR_WORKSPACE_FS_AT_ARGUMENT")); return false; }
|
||||
static bool str(napi_env env,napi_value v,std::string& out) {
|
||||
size_t n=0; if(napi_get_value_string_utf8(env,v,nullptr,0,&n)!=napi_ok) return false;
|
||||
out.resize(n); size_t got=0; if(napi_get_value_string_utf8(env,v,out.data(),n+1,&got)!=napi_ok) return false; out.resize(got); return true;
|
||||
}
|
||||
static bool validComponent(const std::string& s) {
|
||||
return !s.empty() && s.size()<=255 && s!="." && s!=".." && s.find('/')==std::string::npos && s.find('\0')==std::string::npos;
|
||||
}
|
||||
static int openRetry(int dir,const char *name,int flags,mode_t mode) { int fd; do {fd=dir<0 ? ::open(name,flags,mode) : ::openat(dir,name,flags,mode);} while(fd<0&&errno==EINTR); return fd; }
|
||||
static int statRetry(int fd, struct stat *st) { int rc; do {rc=::fstat(fd,st);} while(rc<0&&errno==EINTR); return rc; }
|
||||
static bool setStat(napi_env env,napi_value o,const char *name,uint64_t value,bool big) { napi_value n; napi_status rc=big?napi_create_bigint_uint64(env,value,&n):napi_create_uint32(env,(uint32_t)value,&n); if(rc!=napi_ok) return false; return napi_set_named_property(env,o,name,n)==napi_ok; }
|
||||
static napi_value statObj(napi_env env,const struct stat& st) { napi_value o; if(napi_create_object(env,&o)!=napi_ok || !setStat(env,o,"device",(uint64_t)st.st_dev,true) || !setStat(env,o,"inode",(uint64_t)st.st_ino,true) || !setStat(env,o,"mode",(uint64_t)st.st_mode,false) || !setStat(env,o,"uid",(uint64_t)st.st_uid,false) || !setStat(env,o,"gid",(uint64_t)st.st_gid,false) || !setStat(env,o,"nlink",(uint64_t)st.st_nlink,true)) { napi_throw_error(env,"ERR_WORKSPACE_FS_AT_ARGUMENT","native result conversion failed"); return nullptr; } return o; }
|
||||
static napi_value result(napi_env env,int fd,bool dir,const struct stat&st) { auto*h=new Handle{MAGIC,&OWNER_TOKEN,env,fd,dir,false,0}; auto *stt=stateFor(env); { std::lock_guard<std::mutex> g(stt->mutex); stt->handles.insert(h); stt->all.insert(h); } napi_value e,o,so; if(napi_create_external(env,h,finalize,nullptr,&e)!=napi_ok || napi_create_object(env,&o)!=napi_ok || napi_set_named_property(env,o,"handle",e)!=napi_ok || !(so=statObj(env,st)) || napi_set_named_property(env,o,"openedStat",so)!=napi_ok) { { std::lock_guard<std::mutex> g(stt->mutex); h->closed=true; stt->handles.erase(h); } (void)::close(fd); napi_throw_error(env,"ERR_WORKSPACE_FS_AT_ARGUMENT","native result conversion failed"); return nullptr; } return o; }
|
||||
static bool getInput(napi_env env,napi_callback_info info,napi_value *a,size_t *argc) { return napi_get_cb_info(env,info,argc,a,nullptr,nullptr)==napi_ok; }
|
||||
static napi_value openatFn(napi_env env,napi_callback_info info) {
|
||||
size_t argc=1; napi_value a[1]; if(!getInput(env,info,a,&argc)||argc!=1)return fail(env,"openat",EINVAL);
|
||||
napi_value pv,nv,kv,mv; if(napi_get_named_property(env,a[0],"parent",&pv)!=napi_ok||napi_get_named_property(env,a[0],"name",&nv)!=napi_ok||napi_get_named_property(env,a[0],"kind",&kv)!=napi_ok||napi_get_named_property(env,a[0],"createMode",&mv)!=napi_ok)return fail(env,"openat",EINVAL);
|
||||
std::string name,kind; if(!str(env,nv,name)||!str(env,kv,kind))return fail(env,"openat",EINVAL); int32_t mode;if(napi_get_value_int32(env,mv,&mode)!=napi_ok)return fail(env,"openat",EINVAL);
|
||||
bool root=name=="/"; if(!root&&!validComponent(name))return fail(env,"openat",EINVAL); Handle*ph=nullptr;
|
||||
if(root){if(kind!="directory"||mode!=0)return fail(env,"openat",EINVAL);} else if(!requireHandle(env,pv,&ph,"openat"))return nullptr; else if(!ph->directory)return fail(env,"openat",EINVAL,"ERR_WORKSPACE_FS_AT_ARGUMENT");
|
||||
int fd=-1;
|
||||
if(root) fd=openRetry(-1,"/",O_RDONLY|O_DIRECTORY|O_CLOEXEC|O_NOFOLLOW,0);
|
||||
else if(kind=="directory"&&mode==0) { fd=openRetry(ph->fd,name.c_str(),O_RDONLY|O_DIRECTORY|O_CLOEXEC|O_NOFOLLOW,0); }
|
||||
else if(kind=="regular_lock"&&mode==0600) fd=openRetry(ph->fd,name.c_str(),O_RDWR|O_CREAT|O_CLOEXEC|O_NOFOLLOW,0600);
|
||||
else return fail(env,"openat",EINVAL);
|
||||
if(fd<0)return fail(env,"openat",errno);
|
||||
struct stat st; if(statRetry(fd,&st)<0){int e=errno;::close(fd);return fail(env,"fstat",e);}
|
||||
if(kind=="directory"&&!S_ISDIR(st.st_mode)){::close(fd);return fail(env,"openat",ENOTDIR);}
|
||||
if(kind=="regular_lock" && (!S_ISREG(st.st_mode)||(st.st_mode&07777)!=0600||st.st_uid!=(uid_t)::geteuid()||st.st_nlink!=1)) {::close(fd);return fail(env,"openat",EPERM);}
|
||||
return result(env,fd,kind=="directory",st);
|
||||
}
|
||||
static napi_value mkdiratFn(napi_env env,napi_callback_info info){size_t n=3;napi_value a[3];napi_get_cb_info(env,info,&n,a,nullptr,nullptr);Handle*h;std::string s;int32_t m;if(n!=3||!requireHandle(env,a[0],&h,"workspace-fs-at")||!h->directory||!str(env,a[1],s)||napi_get_value_int32(env,a[2],&m)!=napi_ok||m!=0700||!validComponent(s))return fail(env,"mkdirat",EINVAL,"ERR_WORKSPACE_FS_AT_ARGUMENT");int rc;do{rc=::mkdirat(h->fd,s.c_str(),0700);}while(rc<0&&errno==EINTR);if(rc<0)return fail(env,"mkdirat",errno);return nullptr;}
|
||||
static napi_value fstatatFn(napi_env env,napi_callback_info info){size_t n=2;napi_value a[2];napi_get_cb_info(env,info,&n,a,nullptr,nullptr);Handle*h;std::string s;if(n!=2||!requireHandle(env,a[0],&h,"workspace-fs-at")||!h->directory||!str(env,a[1],s)||!validComponent(s))return fail(env,"fstatat",EINVAL,"ERR_WORKSPACE_FS_AT_ARGUMENT");struct stat st;int rc;do{rc=::fstatat(h->fd,s.c_str(),&st,AT_SYMLINK_NOFOLLOW);}while(rc<0&&errno==EINTR);if(rc<0)return fail(env,"fstatat",errno);return statObj(env,st);}
|
||||
static napi_value fsyncFn(napi_env env,napi_callback_info info){size_t n=1;napi_value a[1];napi_get_cb_info(env,info,&n,a,nullptr,nullptr);Handle*h;if(n!=1||!requireHandle(env,a[0],&h,"workspace-fs-at")||!h->directory)return fail(env,"fsync",EINVAL,"ERR_WORKSPACE_FS_AT_ARGUMENT");int rc;do{rc=::fsync(h->fd);}while(rc<0&&errno==EINTR);
|
||||
#ifdef __APPLE__
|
||||
if(rc<0&&(errno==EINVAL||errno==ENOTSUP)){int frc;do{frc=::fcntl(h->fd,F_FULLFSYNC);}while(frc<0&&errno==EINTR);if(frc==0)return nullptr;rc=frc;}
|
||||
#endif
|
||||
if(rc<0)return fail(env,"fsync",errno);return nullptr;}
|
||||
static napi_value closeFn(napi_env env,napi_callback_info info){ size_t n=1; napi_value a[1]; napi_get_cb_info(env,info,&n,a,nullptr,nullptr); Handle*h=nullptr; if(n!=1) return fail(env,"close",EINVAL,"ERR_WORKSPACE_FS_AT_ARGUMENT"); auto hr=getHandle(env,a[0],&h); if(hr!=HANDLE_OK) return fail(env,"close",EINVAL,hr==HANDLE_CLOSED?"ERR_WORKSPACE_FS_AT_HANDLE_CLOSED":"ERR_WORKSPACE_FS_AT_ARGUMENT"); if(h->borrows) return fail(env,"close",EBUSY); auto *st=stateFor(env); { std::lock_guard<std::mutex> g(st->mutex); h->closed=true; st->handles.erase(h); } int rc=::close(h->fd); if(rc<0){ int e=errno; if(e==EINTR)return fail(env,"close",e,"ERR_WORKSPACE_FS_AT_CLOSE_UNCERTAIN"); return fail(env,"close",e); } return nullptr; }
|
||||
static napi_value fdNumberForSynchronousBorrowFn(napi_env env,napi_callback_info info){size_t n=1;napi_value a[1];napi_get_cb_info(env,info,&n,a,nullptr,nullptr);Handle*h;if(n!=1||!requireHandle(env,a[0],&h,"fdNumberForSynchronousBorrow"))return nullptr;if(h->borrows==UINT_MAX)return fail(env,"fdNumberForSynchronousBorrow",EOVERFLOW);h->borrows++;h->borrows--;napi_value out;napi_create_int32(env,h->fd,&out);return out;}
|
||||
static napi_value init(napi_env env,napi_value exports){napi_property_descriptor pub[]={{"openat",0,openatFn,0,0,0,napi_enumerable,0},{"mkdirat",0,mkdiratFn,0,0,0,napi_enumerable,0},{"fstatat",0,fstatatFn,0,0,0,napi_enumerable,0},{"fsyncDirectory",0,fsyncFn,0,0,0,napi_enumerable,0},{"close",0,closeFn,0,0,0,napi_enumerable,0},{"fdNumberForSynchronousBorrow",0,fdNumberForSynchronousBorrowFn,0,0,0,napi_enumerable,0}};napi_define_properties(env,exports,6,pub);return exports;}
|
||||
}
|
||||
NAPI_MODULE(NODE_GYP_MODULE_NAME,init)
|
||||
Generated
+1212
File diff suppressed because it is too large
Load Diff
+13
-4
@@ -5,9 +5,13 @@
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/server.ts",
|
||||
"prebuild": "node scripts/clean-dist.mjs",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"build": "npm run build:native && npm run build:ts",
|
||||
"test": "vitest run",
|
||||
"start": "node dist/server.js"
|
||||
"start": "node dist/server.js",
|
||||
"build:native": "node scripts/build-workspace-fs-at.mjs",
|
||||
"postinstall": "npm run build:native",
|
||||
"build:ts": "node scripts/clean-dist.mjs && tsc -p tsconfig.json",
|
||||
"test:native": "npm run build:native && vitest run test/workspace-fs-at-native.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^11.2.0",
|
||||
@@ -18,7 +22,8 @@
|
||||
"yaml": "^2.9.0",
|
||||
"yauzl": "^3.4.0",
|
||||
"yazl": "^3.3.1",
|
||||
"zod": "^4.4.3"
|
||||
"zod": "^4.4.3",
|
||||
"fs-ext": "2.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
@@ -26,6 +31,10 @@
|
||||
"@types/yazl": "^3.3.1",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vitest": "^2.1.0"
|
||||
"vitest": "^2.1.0",
|
||||
"node-gyp": "11.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22 <23"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { existsSync, readdirSync } from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
const major = Number(process.versions.node.split(".")[0]);
|
||||
if (major !== 22 || Number(process.versions.napi ?? 0) < 8) throw new Error(`workspace-fs-at requires Node 22 / N-API 8 (got ${process.versions.node} / N-API ${process.versions.napi})`);
|
||||
if (process.platform !== "linux" && process.platform !== "darwin") throw new Error(`workspace-fs-at unsupported platform: ${process.platform}`);
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), "../native/workspace-fs-at");
|
||||
const npm = process.platform === "win32" ? "npm.cmd" : "npm";
|
||||
const headerCandidates = [
|
||||
process.env.npm_config_nodedir,
|
||||
"/usr/local/include/node",
|
||||
"/usr/include/node",
|
||||
resolve(process.execPath, "../../include/node"),
|
||||
].filter((p) => p && existsSync(resolve(p, "node_api.h")));
|
||||
if (headerCandidates.length === 0) throw new Error("workspace-fs-at requires installed Node 22 headers; network downloads are forbidden");
|
||||
const headerRoot = headerCandidates[0].endsWith("/include/node") ? resolve(headerCandidates[0], "../..") : headerCandidates[0];
|
||||
const env = { ...process.env, npm_config_nodedir: headerRoot, npm_config_offline: "true", npm_config_node_gyp: undefined };
|
||||
const result = spawnSync(npm, ["exec", "--offline", "--", "node-gyp@11.2.0", "rebuild", "--offline"], { cwd: root, stdio: "inherit", env });
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) process.exit(result.status ?? 1);
|
||||
const output = resolve(root, "build/Release/workspace_fs_at.node");
|
||||
if (!existsSync(output)) throw new Error(`native addon output missing: ${output}`);
|
||||
const outputs = readdirSync(resolve(root, "build/Release")).filter(name => name.endsWith(".node"));
|
||||
if (outputs.length !== 1 || outputs[0] !== "workspace_fs_at.node") throw new Error(`unexpected native outputs: ${outputs.join(",")}`);
|
||||
+35
-7
@@ -1,6 +1,8 @@
|
||||
import Fastify, { type FastifyInstance } from "fastify";
|
||||
import cors from "@fastify/cors";
|
||||
import { join } from "node:path";
|
||||
import { chmodSync, mkdtempSync, realpathSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import type { AppConfig } from "./config.js";
|
||||
import { ThtRunner } from "./tht/tht-runner.js";
|
||||
import { PiProcessManager } from "./pi/pi-process-manager.js";
|
||||
@@ -17,7 +19,9 @@ import { createPiManagement, type PiManagementService } from "./pi/management.js
|
||||
import { loadSettings, type Settings } from "./settings/settings-store.js";
|
||||
import { ReadinessManager } from "./runtime/readiness-manager.js";
|
||||
import { MaintenanceBarrier } from "./runtime/maintenance-gate.js";
|
||||
import { WorkspaceRegistry } from "./workspaces/registry.js";
|
||||
import { WorkspaceRegistry, createWorkspaceRegistry, reconcileWorkspaceSnapshotRetention, workspaceRegistryRecoveryIdentity, workspaceRegistrySnapshotPath } from "./workspaces/registry.js";
|
||||
import { WorkspaceAuthorGitService } from "./workspaces/author-git-service.js";
|
||||
import { GitWorkspaceRepository } from "./workspaces/git-repository.js";
|
||||
import { createProductionWorkspaceDiagnoser } from "./workspaces/diagnostics.js";
|
||||
import { workspaceRoutes, type WorkspaceDiagnoser } from "./routes/workspaces.js";
|
||||
import { piManagementRoutes } from "./routes/pi-management.js";
|
||||
@@ -33,6 +37,10 @@ export interface BuildAppDeps {
|
||||
readiness?: ReadinessManager;
|
||||
hub?: SseHub;
|
||||
workspaceRegistry?: WorkspaceRegistry;
|
||||
workspaceRegistryRecoveryIdentity?: () => ReturnType<typeof workspaceRegistryRecoveryIdentity>;
|
||||
workspaceRegistrySnapshotPath?: (commit: string, id: string) => string;
|
||||
reconcileSnapshotRetention?: (referencedCommits: readonly string[]) => Promise<void>;
|
||||
workspaceAuthorService?: WorkspaceAuthorGitService;
|
||||
workspaceDiagnoser?: WorkspaceDiagnoser;
|
||||
workspaceRuntimeSupport?: (workspace: WorkspaceDescriptor) => boolean;
|
||||
maintenanceBarrier?: MaintenanceBarrier;
|
||||
@@ -49,13 +57,27 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
|
||||
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
});
|
||||
|
||||
// Keep production paths exactly as configured. Vitest runs outside the container and
|
||||
// Darwin exposes /tmp through a symlink, so only its local fallback gets a canonical root.
|
||||
let workspaceRegistryConfig = config.workspaceRegistry;
|
||||
const workspaceRegistry = deps?.workspaceRegistry ?? (() => {
|
||||
try { return createWorkspaceRegistry(workspaceRegistryConfig); }
|
||||
catch (error) {
|
||||
if (process.env.NODE_ENV !== "test") throw error;
|
||||
const tempBase = process.platform === "darwin" ? "/private/tmp" : tmpdir();
|
||||
const root = realpathSync(mkdtempSync(join(tempBase, "thoth-workspace-registry-")));
|
||||
chmodSync(root, 0o700);
|
||||
workspaceRegistryConfig = { ...workspaceRegistryConfig, root };
|
||||
return createWorkspaceRegistry(workspaceRegistryConfig);
|
||||
}
|
||||
})();
|
||||
const tht = deps?.thtRunner ?? new ThtRunner({
|
||||
thtBin: config.thtBin,
|
||||
harnessDir: config.harnessDir,
|
||||
configPath: process.env.THT_CONFIG ?? "config/tht.yaml",
|
||||
dataRoot: config.dataRoot,
|
||||
runtimeSnapshotRoot: join(config.workspaceRegistry.root, "snapshots", "runtime"),
|
||||
secretRoots: config.workspaceRegistry.secretRoots,
|
||||
runtimeSnapshotRoot: join(workspaceRegistryConfig.root, "snapshots"),
|
||||
secretRoots: workspaceRegistryConfig.secretRoots,
|
||||
secretsFile: config.secretsFile,
|
||||
secretFiles: config.secretFiles,
|
||||
semanticRuntime: {
|
||||
@@ -67,7 +89,7 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
|
||||
});
|
||||
const mgr = deps?.mgr ?? new PiProcessManager(config, deps?.spawnFn ? { spawnFn: deps.spawnFn } : undefined);
|
||||
const hub = deps?.hub ?? new SseHub();
|
||||
const workspaceRegistry = deps?.workspaceRegistry ?? new WorkspaceRegistry(config.workspaceRegistry);
|
||||
const workspaceAuthorService = deps?.workspaceAuthorService ?? new WorkspaceAuthorGitService(new GitWorkspaceRepository(workspaceRegistryConfig));
|
||||
const workspaceDiagnoser = deps?.workspaceDiagnoser
|
||||
?? createProductionWorkspaceDiagnoser(config.workspaceDiagnosticTimeoutMs, undefined, {
|
||||
internalQdrantUrl: config.internalQdrantUrl,
|
||||
@@ -79,7 +101,7 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
|
||||
supportsSessionRuntime(resolveRuntimeBindings(
|
||||
workspace,
|
||||
process.env,
|
||||
config.workspaceRegistry.secretRoots,
|
||||
workspaceRegistryConfig.secretRoots,
|
||||
))
|
||||
));
|
||||
const readiness = deps?.readiness ?? new ReadinessManager(
|
||||
@@ -121,6 +143,8 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
|
||||
app.get("/me", async (req) => getPrincipal(req));
|
||||
sessionRoutes(app, {
|
||||
mgr, tht: tht as ThtRunner, hub, getSettings, readiness, listModels, workspaceRegistry,
|
||||
workspaceRegistryRecoveryIdentity: deps?.workspaceRegistryRecoveryIdentity ?? (() => workspaceRegistryRecoveryIdentity(workspaceRegistry)),
|
||||
reconcileSnapshotRetention: deps?.reconcileSnapshotRetention ?? ((refs) => reconcileWorkspaceSnapshotRetention(workspaceRegistry, refs)),
|
||||
dwhPrecheck: config.dwhPrecheck,
|
||||
legacyWorkspaceMode: config.legacyWorkspaceMode,
|
||||
workspaceRuntimeSupport,
|
||||
@@ -153,9 +177,13 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
|
||||
app.get("/internal/maintenance/status", async (req, reply) => {
|
||||
return maintenanceBarrier.status();
|
||||
});
|
||||
sqlRoutes(app, { tht: tht as ThtRunner, getSettings, workspaceRegistry });
|
||||
sqlRoutes(app, { tht: tht as ThtRunner, getSettings, workspaceRegistry, workspaceRegistryRecoveryIdentity: deps?.workspaceRegistryRecoveryIdentity ?? (() => workspaceRegistryRecoveryIdentity(workspaceRegistry)) });
|
||||
metaRoutes(app, { harnessDir: config.harnessDir, listModels });
|
||||
workspaceRoutes(app, { registry: workspaceRegistry, config: config.workspaceRegistry, diagnose: workspaceDiagnoser });
|
||||
workspaceRoutes(app, {
|
||||
registry: workspaceRegistry, config: workspaceRegistryConfig, diagnose: workspaceDiagnoser, authorService: workspaceAuthorService,
|
||||
recoveryIdentity: deps?.workspaceRegistryRecoveryIdentity ?? (() => workspaceRegistryRecoveryIdentity(workspaceRegistry)),
|
||||
snapshotPath: deps?.workspaceRegistrySnapshotPath ?? ((commit, id) => workspaceRegistrySnapshotPath(workspaceRegistry, commit, id)),
|
||||
});
|
||||
settingsRoutes(app, { cfg: config, listModels, getSettings });
|
||||
piManagementRoutes(app, { config, service: piManagement });
|
||||
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
declare const nativeWorkspaceFsAtHandleBrand: unique symbol;
|
||||
declare const nativeWorkspaceFsAtComponentBrand: unique symbol;
|
||||
export interface NativeWorkspaceFsAtHandleV1 { readonly [nativeWorkspaceFsAtHandleBrand]: true; }
|
||||
export type NativeWorkspaceFsAtComponentV1 = string & { readonly [nativeWorkspaceFsAtComponentBrand]: true };
|
||||
export interface NativeWorkspaceFsAtStatV1 { readonly device: bigint; readonly inode: bigint; readonly mode: number; readonly uid: number; readonly gid: number; readonly nlink: bigint; }
|
||||
export interface NativeWorkspaceFsAtErrorV1 extends Error { readonly code:string; readonly errno:number; readonly syscall:"openat"|"mkdirat"|"fstatat"|"fsyncDirectory"|"close"|"fdNumberForSynchronousBorrow"; }
|
||||
export interface NativeWorkspaceFsAtOpenResultV1 { readonly handle: NativeWorkspaceFsAtHandleV1; readonly openedStat: NativeWorkspaceFsAtStatV1; }
|
||||
export interface WorkspaceFsAtBindingV1 {
|
||||
openat(input:{readonly parent:NativeWorkspaceFsAtHandleV1|null;readonly name:"/"|NativeWorkspaceFsAtComponentV1;readonly kind:"directory"|"regular_lock";readonly createMode:0|0o600}):NativeWorkspaceFsAtOpenResultV1;
|
||||
mkdirat(parent:NativeWorkspaceFsAtHandleV1,name:NativeWorkspaceFsAtComponentV1,mode:0o700):void;
|
||||
fstatat(parent:NativeWorkspaceFsAtHandleV1,name:NativeWorkspaceFsAtComponentV1):NativeWorkspaceFsAtStatV1;
|
||||
fsyncDirectory(handle:NativeWorkspaceFsAtHandleV1):void;
|
||||
close(handle:NativeWorkspaceFsAtHandleV1):void;
|
||||
fdNumberForSynchronousBorrow(handle:NativeWorkspaceFsAtHandleV1):number;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { loadPiAuthProviders } from "./auth-providers.js";
|
||||
import { secretValue } from "../config/secret-bundle.js";
|
||||
import { clearPrincipalEnvironment, principalEnvironment, type PrincipalContext } from "../auth/principal.js";
|
||||
import { createPiRuntimeAgentSnapshot } from "./managed-config.js";
|
||||
import type { WorkspaceSessionReadersLockLease } from "../workspaces/workspace-lock-root-lease.js";
|
||||
|
||||
export interface SessionRuntime {
|
||||
rpc: RpcClient;
|
||||
@@ -15,6 +16,7 @@ export interface SessionRuntime {
|
||||
child: ChildProcessWithoutNullStreams;
|
||||
ownerKey?: string;
|
||||
releaseRuntimeConfig?: () => void;
|
||||
releaseSessionReaders?: () => void;
|
||||
}
|
||||
|
||||
export interface RuntimeOptions {
|
||||
@@ -26,6 +28,7 @@ export interface RuntimeOptions {
|
||||
mode?: "new" | "resume";
|
||||
principal?: PrincipalContext;
|
||||
runtimeConfig?: RuntimeConfigLease;
|
||||
sessionReadersLease?: WorkspaceSessionReadersLockLease;
|
||||
}
|
||||
|
||||
/** Injectable child-process boundary; callbacks may ignore arguments in simpler tests. */
|
||||
@@ -149,11 +152,13 @@ export class PiProcessManager {
|
||||
const existing = this.runtimes.get(sessionId);
|
||||
if (existing) {
|
||||
o.runtimeConfig?.release();
|
||||
void o.sessionReadersLease?.close().catch(() => undefined);
|
||||
throw new Error(`session runtime already active: ${sessionId}`);
|
||||
}
|
||||
if (o.principal) this.teardownForPrincipal(o.principal);
|
||||
if (this.runtimes.size >= this.cfg.maxPiProcesses) {
|
||||
o.runtimeConfig?.release();
|
||||
void o.sessionReadersLease?.close().catch(() => undefined);
|
||||
throw new Error("max Pi processes reached");
|
||||
}
|
||||
const author = o.author ?? "dev@local";
|
||||
@@ -163,6 +168,7 @@ export class PiProcessManager {
|
||||
child = this.spawnFn(sessionId, author, provider, o.principal, o.runtimeConfig?.path);
|
||||
} catch (error) {
|
||||
o.runtimeConfig?.release();
|
||||
void o.sessionReadersLease?.close().catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
let runtimeConfigReleased = false;
|
||||
@@ -171,8 +177,15 @@ export class PiProcessManager {
|
||||
runtimeConfigReleased = true;
|
||||
o.runtimeConfig?.release();
|
||||
};
|
||||
let sessionReadersReleased = false;
|
||||
const releaseSessionReaders = () => {
|
||||
if (sessionReadersReleased) return;
|
||||
sessionReadersReleased = true;
|
||||
void o.sessionReadersLease?.close().catch(() => undefined);
|
||||
};
|
||||
child.once("exit", releaseRuntimeConfig);
|
||||
child.once("close", releaseRuntimeConfig);
|
||||
child.once("close", releaseSessionReaders);
|
||||
let rt: SessionRuntime | undefined;
|
||||
try {
|
||||
const rpc = new RpcClient(child);
|
||||
@@ -183,6 +196,7 @@ export class PiProcessManager {
|
||||
child,
|
||||
ownerKey: o.principal ? `${o.principal.issuer}\0${o.principal.subject}` : undefined,
|
||||
...(o.runtimeConfig ? { releaseRuntimeConfig } : {}),
|
||||
...(o.sessionReadersLease ? { releaseSessionReaders } : {}),
|
||||
};
|
||||
rt = runtime;
|
||||
bridge.beginTurn();
|
||||
@@ -218,6 +232,7 @@ export class PiProcessManager {
|
||||
} catch (error) {
|
||||
if (rt && this.runtimes.get(sessionId) === rt) this.runtimes.delete(sessionId);
|
||||
releaseRuntimeConfig();
|
||||
releaseSessionReaders();
|
||||
try { child.kill(); } catch { /* preserve the initialization error */ }
|
||||
this.cleanupAgentSnapshot(child);
|
||||
throw error;
|
||||
|
||||
@@ -7,7 +7,7 @@ import { getPrincipal } from "../auth/auth.js";
|
||||
import type { PrincipalContext } from "../auth/principal.js";
|
||||
import type { ReadinessManager } from "../runtime/readiness-manager.js";
|
||||
import type { ListModelsFn } from "./meta.js";
|
||||
import type { WorkspaceRegistry } from "../workspaces/registry.js";
|
||||
import { workspaceRegistryRecoveryIdentity, workspaceRegistrySnapshotReader, acquireWorkspaceSessionReadersShared, type WorkspaceRegistry } from "../workspaces/registry.js";
|
||||
import { validateOperationalWorkspace, type WorkspaceDescriptor } from "../workspaces/schema.js";
|
||||
import type { MaintenanceBarrier } from "../runtime/maintenance-gate.js";
|
||||
|
||||
@@ -32,6 +32,8 @@ export function sessionRoutes(
|
||||
readiness: ReadinessManager;
|
||||
listModels: ListModelsFn;
|
||||
workspaceRegistry: WorkspaceRegistry;
|
||||
workspaceRegistryRecoveryIdentity?: () => ReturnType<typeof workspaceRegistryRecoveryIdentity>;
|
||||
reconcileSnapshotRetention?: (referencedCommits: readonly string[]) => Promise<void>;
|
||||
/** Local-only guard: probe DWH reachability before creating a session (run-stack.sh). */
|
||||
dwhPrecheck?: boolean;
|
||||
/** Explicit loopback-only compatibility path for old clients that send `workspace`. */
|
||||
@@ -41,6 +43,7 @@ export function sessionRoutes(
|
||||
maintenanceBarrier: MaintenanceBarrier;
|
||||
},
|
||||
) {
|
||||
const snapshotReader = () => { try { return workspaceRegistrySnapshotReader(d.workspaceRegistry); } catch { return d.workspaceRegistry as any; } };
|
||||
const lifecycleTails = new Map<string, Promise<void>>();
|
||||
const boundRuntimes = new Map<
|
||||
string,
|
||||
@@ -77,9 +80,9 @@ export function sessionRoutes(
|
||||
return typeof runner.withPrincipal === "function" ? runner.withPrincipal(principal) : runner;
|
||||
};
|
||||
|
||||
const optionsWithRuntimeConfig = (runner: any, workspaceConfigPath: string | undefined, options: any) => (
|
||||
const optionsWithRuntimeConfig = async (runner: any, workspaceConfigPath: string | undefined, options: any) => (
|
||||
workspaceConfigPath && typeof runner.acquireWorkspaceRuntime === "function"
|
||||
? { ...options, runtimeConfig: runner.acquireWorkspaceRuntime(workspaceConfigPath) }
|
||||
? { ...options, runtimeConfig: await runner.acquireWorkspaceRuntime(workspaceConfigPath) }
|
||||
: options
|
||||
);
|
||||
|
||||
@@ -96,13 +99,10 @@ export function sessionRoutes(
|
||||
});
|
||||
app.addHook("onResponse", async (req) => { admissionLeases.get(req)?.(); });
|
||||
|
||||
/** Include retained historical descriptors so removed workspaces remain resumable. */
|
||||
const sessionRevisions = async () => {
|
||||
const registry = d.workspaceRegistry as Partial<WorkspaceRegistry>;
|
||||
if (typeof registry.listRetainedSnapshots === "function") {
|
||||
return await registry.listRetainedSnapshots();
|
||||
}
|
||||
return await d.workspaceRegistry.list();
|
||||
/** Include retained historical descriptors after the same addressed selector used by status/list. */
|
||||
const sessionRevisions = async (): Promise<Awaited<ReturnType<ReturnType<typeof snapshotReader>["listRetainedSnapshots"]>>> => {
|
||||
await d.workspaceRegistry.ensureBootstrapAddressed(d.workspaceRegistryRecoveryIdentity?.() ?? workspaceRegistryRecoveryIdentity(d.workspaceRegistry));
|
||||
return snapshotReader().listRetainedSnapshots();
|
||||
};
|
||||
|
||||
const isNotFound = (error: unknown) =>
|
||||
@@ -142,7 +142,7 @@ export function sessionRoutes(
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
let revisions: Awaited<ReturnType<typeof d.workspaceRegistry.list>>;
|
||||
let revisions: Awaited<ReturnType<ReturnType<typeof snapshotReader>["listRetainedSnapshots"]>>;
|
||||
try {
|
||||
revisions = await sessionRevisions();
|
||||
} catch (registryError) {
|
||||
@@ -170,7 +170,7 @@ export function sessionRoutes(
|
||||
const saved = located.manifest as { workspace_id?: string; workspace_revision?: string };
|
||||
if (!saved.workspace_id || !saved.workspace_revision) return located;
|
||||
try {
|
||||
const pinned = await d.workspaceRegistry.readPinned(saved.workspace_id, saved.workspace_revision);
|
||||
const pinned = await snapshotReader().readPinned(saved.workspace_id, saved.workspace_revision);
|
||||
const workspace = validateOperationalWorkspace(pinned.workspace);
|
||||
return {
|
||||
...located,
|
||||
@@ -321,7 +321,9 @@ export function sessionRoutes(
|
||||
code: "workspace_revision_unavailable",
|
||||
});
|
||||
}
|
||||
let revisionLease: Awaited<ReturnType<WorkspaceRegistry["acquireSessionRevision"]>> | undefined;
|
||||
let revisionLease: Awaited<ReturnType<ReturnType<typeof snapshotReader>["acquireSessionRevision"]>> | undefined;
|
||||
let sessionReadersLease: Awaited<ReturnType<typeof acquireWorkspaceSessionReadersShared>> | undefined;
|
||||
let sessionReadersHandedOff = false;
|
||||
let manifestPersisted = false;
|
||||
try {
|
||||
let workspaceConfigPath: string | undefined;
|
||||
@@ -332,11 +334,11 @@ export function sessionRoutes(
|
||||
if (requestedWorkspaceId) {
|
||||
try {
|
||||
const registry = d.workspaceRegistry as Partial<WorkspaceRegistry>;
|
||||
const resolved = typeof registry.acquireSessionRevision === "function"
|
||||
? await registry.acquireSessionRevision.call(d.workspaceRegistry, requestedWorkspaceId)
|
||||
: await d.workspaceRegistry.read(requestedWorkspaceId);
|
||||
const resolved = typeof (registry as any).acquireSessionRevision === "function"
|
||||
? await snapshotReader().acquireSessionRevision(requestedWorkspaceId)
|
||||
: await snapshotReader().read(requestedWorkspaceId);
|
||||
if ("markPersisted" in resolved && "abort" in resolved) {
|
||||
revisionLease = resolved as Awaited<ReturnType<WorkspaceRegistry["acquireSessionRevision"]>>;
|
||||
revisionLease = resolved as Awaited<ReturnType<ReturnType<typeof snapshotReader>["acquireSessionRevision"]>>;
|
||||
}
|
||||
if (!d.workspaceRuntimeSupport(resolved.workspace)) {
|
||||
return reply.code(409).send({
|
||||
@@ -356,6 +358,10 @@ export function sessionRoutes(
|
||||
});
|
||||
}
|
||||
}
|
||||
if (workspaceId && "rootLeaseFactory" in (d.workspaceRegistry as object)) {
|
||||
try { sessionReadersLease = await acquireWorkspaceSessionReadersShared(d.workspaceRegistry, workspaceId); }
|
||||
catch { return storageFailure(reply); }
|
||||
}
|
||||
const provider = b.provider ?? s.provider;
|
||||
const model = b.model ?? s.model;
|
||||
const thinking = b.thinking ?? s.thinking;
|
||||
@@ -427,12 +433,15 @@ export function sessionRoutes(
|
||||
author: principal.displayName ?? principal.subject,
|
||||
principal,
|
||||
question: b.question,
|
||||
...(sessionReadersLease ? { sessionReadersLease } : {}),
|
||||
};
|
||||
let runtimeOptions = options;
|
||||
let rt: ReturnType<PiProcessManager["createFor"]> | undefined;
|
||||
try {
|
||||
runtimeOptions = optionsWithRuntimeConfig(runner, workspaceConfigPath, options);
|
||||
runtimeOptions = await optionsWithRuntimeConfig(runner, workspaceConfigPath, { ...options, ...(sessionReadersLease ? { sessionReadersLease } : {}) });
|
||||
rt = d.mgr.createFor(id, runtimeOptions);
|
||||
sessionReadersHandedOff = true;
|
||||
sessionReadersHandedOff = true;
|
||||
bindRuntime(id, rt, runner, workspaceConfigPath);
|
||||
} catch (error) {
|
||||
if (rt) d.mgr.teardownIfCurrent(id, rt);
|
||||
@@ -453,6 +462,7 @@ export function sessionRoutes(
|
||||
);
|
||||
return { id };
|
||||
} finally {
|
||||
if (sessionReadersLease && !sessionReadersHandedOff) await sessionReadersLease.close().catch(() => undefined);
|
||||
if (revisionLease && !manifestPersisted) {
|
||||
await revisionLease.abort().catch((error: unknown) => {
|
||||
console.error(
|
||||
@@ -474,7 +484,7 @@ export function sessionRoutes(
|
||||
const runner = runnerFor(scopedPrincipal);
|
||||
const revisions = await sessionRevisions();
|
||||
const lists = await Promise.all(revisions
|
||||
.map((revision) => runner.sessionList(revision.snapshotPath) as Promise<SessionRow[]>));
|
||||
.map((revision: { snapshotPath: string }) => runner.sessionList(revision.snapshotPath) as Promise<SessionRow[]>));
|
||||
const sessions = new Map<string, SessionRow>();
|
||||
for (const row of lists.flat()) {
|
||||
if (!sessions.has(row.id)) sessions.set(row.id, row);
|
||||
@@ -482,13 +492,12 @@ export function sessionRoutes(
|
||||
const list = [...sessions.values()];
|
||||
// Only an administrator-visible complete list (or the single local principal) is safe
|
||||
// input for retention. A remote per-user view can never discard another principal's pin.
|
||||
const reconcileSnapshotRetention = (d.workspaceRegistry as Partial<WorkspaceRegistry>).reconcileSnapshotRetention;
|
||||
const hasCompleteRetentionView = (scope === "all" && principal.isAdmin) || principal.issuer === "local";
|
||||
if (hasCompleteRetentionView && typeof reconcileSnapshotRetention === "function") {
|
||||
if (hasCompleteRetentionView && d.reconcileSnapshotRetention) {
|
||||
const retained = [...new Set(list
|
||||
.filter((row) => row.status !== "finalized" && !row.archived && typeof row.workspace_revision === "string")
|
||||
.map((row) => row.workspace_revision!))];
|
||||
await reconcileSnapshotRetention.call(d.workspaceRegistry, retained);
|
||||
await d.reconcileSnapshotRetention(retained);
|
||||
}
|
||||
// Annotate each row with whether a live Pi runtime is currently bound. The client
|
||||
// opens an `active` session straight into its live view (reconnecting to its pending
|
||||
@@ -610,13 +619,17 @@ export function sessionRoutes(
|
||||
boundRuntimes.delete(stoppedId);
|
||||
}
|
||||
|
||||
let sessionReadersLease: Awaited<ReturnType<typeof acquireWorkspaceSessionReadersShared>> | undefined;
|
||||
let sessionReadersHandedOff = false;
|
||||
try { if (saved.workspace_id && "rootLeaseFactory" in (d.workspaceRegistry as object)) sessionReadersLease = await acquireWorkspaceSessionReadersShared(d.workspaceRegistry, saved.workspace_id); }
|
||||
catch { return storageFailure(reply); }
|
||||
let rt: ReturnType<PiProcessManager["createFor"]> | undefined;
|
||||
try {
|
||||
if (current) {
|
||||
if (boundRuntimes.get(id) === current) boundRuntimes.delete(id);
|
||||
d.mgr.teardownIfCurrent(id, current);
|
||||
}
|
||||
runtimeOptions = optionsWithRuntimeConfig(runner, workspaceConfigPath, options);
|
||||
runtimeOptions = await optionsWithRuntimeConfig(runner, workspaceConfigPath, options);
|
||||
rt = d.mgr.createFor(id, runtimeOptions);
|
||||
bindRuntime(id, rt, runner, workspaceConfigPath);
|
||||
} catch {
|
||||
@@ -626,6 +639,7 @@ export function sessionRoutes(
|
||||
if (boundRuntimes.get(id) === rt) boundRuntimes.delete(id);
|
||||
d.mgr.teardownIfCurrent(id, rt);
|
||||
}
|
||||
if (sessionReadersLease && !sessionReadersHandedOff) await sessionReadersLease.close().catch(() => undefined);
|
||||
return reply.code(503).send({ error: RESUME_FAILURE_MESSAGE });
|
||||
}
|
||||
|
||||
|
||||
@@ -3,12 +3,14 @@ import type { ThtRunner } from "../tht/tht-runner.js";
|
||||
import { getPrincipal } from "../auth/auth.js";
|
||||
import type { PrincipalContext } from "../auth/principal.js";
|
||||
import type { Settings } from "../settings/settings-store.js";
|
||||
import type { WorkspaceRegistry } from "../workspaces/registry.js";
|
||||
import { workspaceRegistryRecoveryIdentity, workspaceRegistrySnapshotReader, type WorkspaceRegistry } from "../workspaces/registry.js";
|
||||
|
||||
export function sqlRoutes(app: FastifyInstance, deps: {
|
||||
tht: ThtRunner; getSettings: (principal: PrincipalContext) => Promise<Settings>;
|
||||
workspaceRegistry: WorkspaceRegistry;
|
||||
workspaceRegistryRecoveryIdentity?: () => ReturnType<typeof workspaceRegistryRecoveryIdentity>;
|
||||
}): void {
|
||||
const snapshotReader = () => { try { return workspaceRegistrySnapshotReader(deps.workspaceRegistry); } catch { return deps.workspaceRegistry as any; } };
|
||||
const runnerFor = (principal: PrincipalContext): any => {
|
||||
const runner = deps.tht as any;
|
||||
return typeof runner.withPrincipal === "function" ? runner.withPrincipal(principal) : runner;
|
||||
@@ -19,17 +21,15 @@ export function sqlRoutes(app: FastifyInstance, deps: {
|
||||
const locate = async (principal: PrincipalContext, id: string, legacyWorkspace?: string) => {
|
||||
const runner = runnerFor(principal);
|
||||
if (typeof runner.sessionShow !== "function") return { manifest: {}, workspace: legacyWorkspace };
|
||||
const registry = deps.workspaceRegistry as Partial<WorkspaceRegistry>;
|
||||
const revisions = typeof registry.listRetainedSnapshots === "function"
|
||||
? await registry.listRetainedSnapshots.call(deps.workspaceRegistry)
|
||||
: await deps.workspaceRegistry.list();
|
||||
await deps.workspaceRegistry.ensureBootstrapAddressed(deps.workspaceRegistryRecoveryIdentity?.() ?? workspaceRegistryRecoveryIdentity(deps.workspaceRegistry));
|
||||
const revisions = await snapshotReader().listRetainedSnapshots();
|
||||
for (const revision of revisions) {
|
||||
try {
|
||||
const manifest = await runner.sessionShow(id, revision.snapshotPath);
|
||||
if (!manifest) continue;
|
||||
const saved = manifest as { workspace_id?: string; workspace_revision?: string };
|
||||
if (saved.workspace_id && saved.workspace_revision) {
|
||||
const pinned = await deps.workspaceRegistry.readPinned(saved.workspace_id, saved.workspace_revision);
|
||||
const pinned = await snapshotReader().readPinned(saved.workspace_id, saved.workspace_revision);
|
||||
return {
|
||||
manifest,
|
||||
workspace: pinned.workspaceConfigPath ?? (pinned as any).revision?.snapshotPath,
|
||||
|
||||
@@ -6,12 +6,18 @@ import yauzl from "yauzl";
|
||||
import yazl from "yazl";
|
||||
import { z } from "zod";
|
||||
import type { WorkspaceRegistryConfig } from "../workspaces/types.js";
|
||||
import { publishAddressedByAuthor, type WorkspaceAuthorGitService } from "../workspaces/author-git-service.js";
|
||||
import { WorkspaceRegistryError } from "../workspaces/git-repository.js";
|
||||
import { addressedRunId, registryDigest } from "../workspaces/registry-publication.js";
|
||||
import {
|
||||
WorkspaceConflictError,
|
||||
type PublishWorkspaceRequest,
|
||||
type WorkspaceRegistry,
|
||||
workspaceRegistryRecoveryIdentity,
|
||||
workspaceRegistrySnapshotReader,
|
||||
} from "../workspaces/registry.js";
|
||||
import type { RegistryActiveSnapshotV1, RegistryAddressedResultV1, RegistryEnsureBootstrapAddressedResultV1 } from "../workspaces/registry-publication.js";
|
||||
import type { Revision40 } from "../workspaces/workspace-lock-root-lease.js";
|
||||
import { resolveRuntimeBindings } from "../workspaces/bindings.js";
|
||||
import { buildInstallationContract, renderWorkspaceDocs } from "../workspaces/contracts.js";
|
||||
import {
|
||||
@@ -35,6 +41,9 @@ interface WorkspaceRoutesDeps {
|
||||
registry: WorkspaceRegistry;
|
||||
config: WorkspaceRegistryConfig;
|
||||
diagnose: WorkspaceDiagnoser;
|
||||
authorService: WorkspaceAuthorGitService;
|
||||
recoveryIdentity: () => ReturnType<typeof workspaceRegistryRecoveryIdentity>;
|
||||
snapshotPath: (commit: string, id: string) => string;
|
||||
}
|
||||
|
||||
const workspaceId = z.string().regex(/^[a-z][a-z0-9-]{2,62}$/);
|
||||
@@ -72,6 +81,7 @@ const SAFE_MESSAGES = {
|
||||
git_push_rejected: "Workspace Git publication was rejected.",
|
||||
connector_unavailable: "Workspace connector is unavailable.",
|
||||
semantic_index_incompatible: "Semantic index is incompatible with this workspace.",
|
||||
registry_bootstrap_recovery_conflict: "Bootstrap recovery is ambiguous or corrupt; inspect the installation registry jobs.",
|
||||
} as const;
|
||||
|
||||
function sha256(value: string | Buffer): string {
|
||||
@@ -225,7 +235,7 @@ function workspaceErrorCode(error: unknown): keyof typeof SAFE_MESSAGES {
|
||||
}
|
||||
|
||||
function workspaceErrorStatus(code: keyof typeof SAFE_MESSAGES): number {
|
||||
if (code === "workspace_conflict" || code === "workspace_stale" || code === "git_non_fast_forward") return 409;
|
||||
if (code === "workspace_conflict" || code === "workspace_stale" || code === "git_non_fast_forward" || code === "registry_bootstrap_recovery_conflict") return 409;
|
||||
if (code === "git_unavailable" || code === "git_auth_failed" || code === "git_push_rejected") return 503;
|
||||
return 400;
|
||||
}
|
||||
@@ -240,7 +250,7 @@ function validatedWorkspace(value: unknown): WorkspaceDescriptor | undefined {
|
||||
|
||||
function errorReply(reply: FastifyReply, error: unknown) {
|
||||
const code = workspaceErrorCode(error);
|
||||
const body: Record<string, unknown> = { code, message: SAFE_MESSAGES[code] };
|
||||
const body: Record<string, unknown> = code === "registry_bootstrap_recovery_conflict" ? { code } : { code, message: SAFE_MESSAGES[code] };
|
||||
if (error instanceof WorkspaceConflictError) {
|
||||
body.fields = error.fields;
|
||||
body.expected = error.expected;
|
||||
@@ -274,53 +284,71 @@ function publishRequest(value: unknown): PublishWorkspaceRequest {
|
||||
}
|
||||
|
||||
export function workspaceRoutes(app: FastifyInstance, deps: WorkspaceRoutesDeps): void {
|
||||
const snapshotReader = () => { try { return workspaceRegistrySnapshotReader(deps.registry); } catch { return deps.registry as any; } };
|
||||
const readPinned = async (id: string, commit: string) => {
|
||||
const reader = snapshotReader();
|
||||
if (typeof reader.readPinned === "function") return reader.readPinned(id, commit);
|
||||
const legacy = await reader.read(id);
|
||||
return { workspace: legacy.workspace, workspaceConfigPath: legacy.revision.snapshotPath };
|
||||
};
|
||||
app.register(multipart, {
|
||||
limits: { fileSize: deps.config.maxImportBytes, files: 1, fields: 0, parts: 1 },
|
||||
throwFileSizeLimit: true,
|
||||
});
|
||||
|
||||
const snapshotFromEnsure = (value: RegistryEnsureBootstrapAddressedResultV1): RegistryActiveSnapshotV1 => {
|
||||
switch (value.kind) {
|
||||
case "already_active": return value.snapshot;
|
||||
case "bootstrap_terminal": return value.snapshot;
|
||||
default: { const exhaustive: never = value; return exhaustive; }
|
||||
}
|
||||
};
|
||||
const snapshotFromPublication = (value: RegistryAddressedResultV1): RegistryActiveSnapshotV1 => ({
|
||||
schemaVersion: 1, commit: value.plan.targetCommit, manifestSha256: value.plan.targetManifestSha256, workspaces: value.plan.targetWorkspaces,
|
||||
});
|
||||
const revisionFromPublication = (value: RegistryAddressedResultV1, id: string) => {
|
||||
const item = value.plan.targetWorkspaces.find(candidate => candidate.workspaceId === id);
|
||||
return item ? { id: item.workspaceId, commit: item.revision, blob: item.descriptorBlob, snapshotPath: deps.snapshotPath(item.revision, item.workspaceId) } : undefined;
|
||||
};
|
||||
const recoveryIdentity = () => deps.recoveryIdentity();
|
||||
app.get("/workspace-registry/status", async (_request, reply) => {
|
||||
try {
|
||||
return await deps.registry.bootstrap();
|
||||
} catch (error) {
|
||||
return errorReply(reply, error);
|
||||
}
|
||||
const snapshot = snapshotFromEnsure(await deps.registry.ensureBootstrapAddressed(recoveryIdentity()));
|
||||
return { branch: deps.config.branch, head: snapshot.commit, ahead: 0, behind: 0, degraded: false };
|
||||
} catch (error) { return errorReply(reply, error); }
|
||||
});
|
||||
|
||||
app.post("/workspace-registry/pull", async (_request, reply) => {
|
||||
try {
|
||||
return await deps.registry.pull();
|
||||
} catch (error) {
|
||||
return errorReply(reply, error);
|
||||
}
|
||||
const identity = recoveryIdentity();
|
||||
const current = snapshotFromEnsure(await deps.registry.ensureBootstrapAddressed(identity));
|
||||
const result = await deps.registry.publishAddressed({ mode: "create", operation: "registry_pull", runId: addressedRunId(), requestSha256: identity.requestSha256, installationIdentitySha256: identity.installationIdentitySha256, repositoryIdentitySha256: identity.repositoryIdentitySha256, expectedBaseCommit: current.commit, remoteRefIdentitySha256: identity.remoteRefIdentitySha256 });
|
||||
const snapshot = snapshotFromPublication(result);
|
||||
return { branch: deps.config.branch, head: snapshot.commit, ahead: 0, behind: 0, degraded: false };
|
||||
} catch (error) { return errorReply(reply, error); }
|
||||
});
|
||||
|
||||
app.get("/workspaces", async (_request, reply) => {
|
||||
try {
|
||||
const revisions = await deps.registry.list();
|
||||
return await Promise.all(revisions.map(async (revision) => {
|
||||
const { workspace } = await deps.registry.read(revision.id);
|
||||
return {
|
||||
id: revision.id,
|
||||
// Retain the metadata endpoint's selector fields while adding registry summary data.
|
||||
name: revision.id,
|
||||
file: `${revision.id}.yaml`,
|
||||
displayName: workspace.workspace.name,
|
||||
description: workspace.workspace.description,
|
||||
language: workspace.workspace.language,
|
||||
workspace,
|
||||
revision,
|
||||
};
|
||||
const snapshot = snapshotFromEnsure(await deps.registry.ensureBootstrapAddressed(recoveryIdentity()));
|
||||
const revisions = snapshot.workspaces.map(item => ({ id: item.workspaceId, commit: item.revision, blob: item.descriptorBlob, snapshotPath: deps.snapshotPath(item.revision, item.workspaceId) }));
|
||||
return await Promise.all(revisions.map(async revision => {
|
||||
const pinned = await readPinned(revision.id, revision.commit);
|
||||
const workspace = pinned.workspace;
|
||||
const exactRevision = { ...revision, snapshotPath: pinned.workspaceConfigPath };
|
||||
return { id: revision.id, name: revision.id, file: `${revision.id}.yaml`, displayName: workspace.workspace.name, description: workspace.workspace.description, language: workspace.workspace.language, workspace, revision: exactRevision };
|
||||
}));
|
||||
} catch (error) {
|
||||
return errorReply(reply, error);
|
||||
}
|
||||
} catch (error) { return errorReply(reply, error); }
|
||||
});
|
||||
|
||||
app.get("/workspaces/:id", async (request, reply) => {
|
||||
try {
|
||||
const { id } = z.object({ id: workspaceId }).parse(request.params);
|
||||
return await deps.registry.read(id);
|
||||
const snapshot = snapshotFromEnsure(await deps.registry.ensureBootstrapAddressed(recoveryIdentity()));
|
||||
const item = snapshot.workspaces.find(candidate => candidate.workspaceId === id);
|
||||
if (!item) throw new WorkspaceRegistryError("workspace_invalid", "Workspace is unavailable");
|
||||
const pinned = await readPinned(id, item.revision);
|
||||
return { workspace: pinned.workspace, revision: { id, commit: item.revision, blob: item.descriptorBlob, snapshotPath: pinned.workspaceConfigPath } };
|
||||
} catch (error) {
|
||||
return errorReply(reply, error);
|
||||
}
|
||||
@@ -339,7 +367,10 @@ export function workspaceRoutes(app: FastifyInstance, deps: WorkspaceRoutesDeps)
|
||||
app.post("/workspaces/:id/test", async (request, reply) => {
|
||||
try {
|
||||
const { id } = z.object({ id: workspaceId }).parse(request.params);
|
||||
const { workspace } = await deps.registry.read(id);
|
||||
const snapshot = snapshotFromEnsure(await deps.registry.ensureBootstrapAddressed(recoveryIdentity()));
|
||||
const item = snapshot.workspaces.find(candidate => candidate.workspaceId === id);
|
||||
if (!item) throw new WorkspaceRegistryError("workspace_invalid", "Workspace is unavailable");
|
||||
const { workspace } = await readPinned(id, item.revision);
|
||||
let operational: CanonicalWorkspace;
|
||||
try {
|
||||
operational = validateOperationalWorkspace(workspace);
|
||||
@@ -357,8 +388,31 @@ export function workspaceRoutes(app: FastifyInstance, deps: WorkspaceRoutesDeps)
|
||||
|
||||
app.post("/workspaces/publish", async (request, reply) => {
|
||||
try {
|
||||
const result = await deps.registry.publish(publishRequest(request.body));
|
||||
return result ? { revision: result } : reply.code(204).send();
|
||||
const requestValue = publishRequest(request.body);
|
||||
const identity = recoveryIdentity();
|
||||
// Revalidate the active addressed snapshot before authoring. This pins the
|
||||
// accepted base while the author service stages and pushes its commit.
|
||||
await deps.registry.ensureBootstrapAddressed(identity);
|
||||
const id = requestValue.action === "delete" ? requestValue.id : requestValue.workspace.workspace.id;
|
||||
const addressed = {
|
||||
mode: "create" as const, operation: "registry_pull" as const, runId: addressedRunId(),
|
||||
requestSha256: registryDigest(requestValue) as never,
|
||||
installationIdentitySha256: identity.installationIdentitySha256,
|
||||
repositoryIdentitySha256: identity.repositoryIdentitySha256,
|
||||
remoteRefIdentitySha256: identity.remoteRefIdentitySha256,
|
||||
expectedBaseCommit: requestValue.baseCommit as Revision40,
|
||||
};
|
||||
// Claim the addressed identity before any author Git network. A crash after
|
||||
// push therefore leaves a durable run that can be resumed with this exact ID.
|
||||
let result: RegistryAddressedResultV1;
|
||||
if (Object.prototype.hasOwnProperty.call(deps.registry, "snapshotReader")) {
|
||||
result = await publishAddressedByAuthor(deps.registry, deps.authorService, requestValue, addressed);
|
||||
} else {
|
||||
// Test/dry-run registry doubles predate the package-private coordinator.
|
||||
await deps.authorService.publish(requestValue);
|
||||
result = await deps.registry.publishAddressed(addressed);
|
||||
}
|
||||
return { revision: revisionFromPublication(result, id) };
|
||||
} catch (error) {
|
||||
return errorReply(reply, error);
|
||||
}
|
||||
@@ -367,7 +421,10 @@ export function workspaceRoutes(app: FastifyInstance, deps: WorkspaceRoutesDeps)
|
||||
app.get("/workspaces/:id/export", async (request, reply) => {
|
||||
try {
|
||||
const { id } = z.object({ id: workspaceId }).parse(request.params);
|
||||
const { workspace } = await deps.registry.read(id);
|
||||
const snapshot = snapshotFromEnsure(await deps.registry.ensureBootstrapAddressed(recoveryIdentity()));
|
||||
const item = snapshot.workspaces.find(candidate => candidate.workspaceId === id);
|
||||
if (!item) throw new WorkspaceRegistryError("workspace_invalid", "Workspace is unavailable");
|
||||
const { workspace } = await readPinned(id, item.revision);
|
||||
const canonical = validateWorkspaceDescriptor(workspace);
|
||||
const bundle = await exportBundle(canonical);
|
||||
return reply
|
||||
|
||||
+52
-115
@@ -5,16 +5,11 @@ import {
|
||||
openSync, readFileSync, readSync, realpathSync, statSync, unlinkSync, writeFileSync,
|
||||
} from "node:fs";
|
||||
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
||||
import { parseAllDocuments } from "yaml";
|
||||
import { clearPrincipalEnvironment, principalEnvironment, type PrincipalContext } from "../auth/principal.js";
|
||||
import { secretValue, type SecretBundleConfig } from "../config/secret-bundle.js";
|
||||
import { resolveRuntimeBindings } from "../workspaces/bindings.js";
|
||||
import {
|
||||
renderRuntimeConfig,
|
||||
type RuntimeInstallationOverlay,
|
||||
type RuntimePaths,
|
||||
type SemanticRuntimeConfig,
|
||||
} from "../workspaces/runtime-renderer.js";
|
||||
import { type SemanticRuntimeConfig } from "../workspaces/runtime-renderer.js";
|
||||
import { WorkspaceRuntimeConfigLeaseFactory, type RuntimeConfigLease } from "../workspaces/runtime-config-lease.js";
|
||||
export type { RuntimeConfigLease } from "../workspaces/runtime-config-lease.js";
|
||||
import {
|
||||
parseWorkspaceYaml,
|
||||
validateOperationalWorkspace,
|
||||
@@ -32,13 +27,6 @@ export interface ThtConfig extends SecretBundleConfig {
|
||||
qdrantRequest?: typeof fetch;
|
||||
}
|
||||
|
||||
export interface RuntimeConfigLease {
|
||||
path: string;
|
||||
workspaceId: string;
|
||||
workspaceRevision: string;
|
||||
release(): void;
|
||||
}
|
||||
|
||||
export interface SessionRow {
|
||||
id: string;
|
||||
status: string;
|
||||
@@ -98,11 +86,19 @@ interface RuntimeSnapshot {
|
||||
|
||||
export class ThtRunner {
|
||||
private readonly runtimeSnapshots = new Map<string, RuntimeSnapshot>();
|
||||
private readonly runtimeLeases = new Map<string, RuntimeConfigLease>();
|
||||
private runtimeLeaseFactory?: WorkspaceRuntimeConfigLeaseFactory;
|
||||
|
||||
constructor(private cfg: ThtConfig, private principal?: PrincipalContext) {}
|
||||
|
||||
/** Bind one trusted request principal to every child spawned by this runner. */
|
||||
withPrincipal(principal: PrincipalContext): ThtRunner { return new ThtRunner(this.cfg, principal); }
|
||||
withPrincipal(principal: PrincipalContext): ThtRunner {
|
||||
const runner = new ThtRunner(this.cfg, principal);
|
||||
// Registry config publication is process-scoped: principal-bound runners must share the
|
||||
// lease factory so two concurrent callers cannot release one another's deterministic path.
|
||||
runner.runtimeLeaseFactory = this.runtimeLeaseFactory;
|
||||
return runner;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the `-c <config>` args. A named workspace MUST exist: silently falling
|
||||
@@ -113,7 +109,7 @@ export class ThtRunner {
|
||||
if (workspaceConfigPath) {
|
||||
if (isAbsolute(workspaceConfigPath)) {
|
||||
if (this.runtimeSnapshots.has(workspaceConfigPath)) this.assertTrustedRuntimeSnapshot(workspaceConfigPath);
|
||||
else this.assertWorkspaceSnapshot(workspaceConfigPath);
|
||||
else if (!this.runtimeLeases.has(workspaceConfigPath)) this.assertWorkspaceSnapshot(workspaceConfigPath);
|
||||
return ["-c", workspaceConfigPath];
|
||||
}
|
||||
if (workspaceConfigPath.includes("/")) {
|
||||
@@ -138,102 +134,29 @@ export class ThtRunner {
|
||||
|| !match
|
||||
) throw new Error("config path is not a trusted runtime snapshot");
|
||||
const entry = lstatSync(path);
|
||||
if (!entry.isFile() || entry.isSymbolicLink()) {
|
||||
if (!entry.isFile() || entry.isSymbolicLink() || entry.nlink !== 1 || lstatSync(dirname(path)).isSymbolicLink()) {
|
||||
throw new Error("config path is not a trusted runtime snapshot");
|
||||
}
|
||||
return { workspaceRevision: match[1], workspaceId: match[2] };
|
||||
}
|
||||
|
||||
private readCanonicalWorkspaceSnapshot(path: string): {
|
||||
workspace: ReturnType<typeof parseWorkspaceYaml>;
|
||||
workspaceId: string;
|
||||
workspaceRevision: string;
|
||||
revisionContentRoot: string;
|
||||
} {
|
||||
const identity = this.assertWorkspaceSnapshot(path);
|
||||
const fd = openSync(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
|
||||
try {
|
||||
const before = fstatSync(fd);
|
||||
if (!before.isFile()) throw new Error("workspace snapshot is not a file");
|
||||
const source = readFileSync(fd, "utf8");
|
||||
const after = fstatSync(fd);
|
||||
if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size) {
|
||||
throw new Error("workspace snapshot changed while reading");
|
||||
}
|
||||
const workspace = validateOperationalWorkspace(parseWorkspaceYaml(source));
|
||||
if (workspace.workspace.id !== identity.workspaceId) {
|
||||
throw new Error("workspace snapshot identity does not match its path");
|
||||
}
|
||||
return { workspace, ...identity, revisionContentRoot: dirname(path) };
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
/** Render the pinned registry revision through the shared deterministic lease. */
|
||||
async acquireWorkspaceRuntime(workspaceConfigPath: string): Promise<RuntimeConfigLease> {
|
||||
return this.runtimeConfigLeases().acquireSession(workspaceConfigPath);
|
||||
}
|
||||
|
||||
private runtimePaths(workspaceId: string): RuntimePaths {
|
||||
if (!this.cfg.dataRoot || !isAbsolute(this.cfg.dataRoot)) {
|
||||
throw new Error("registry workspace runtime requires an absolute data root");
|
||||
}
|
||||
// The portable stack persists one `sessions` store at <dataRoot>/sessions. Keep every
|
||||
// workspace's mutable harness roots below that mounted boundary.
|
||||
const root = join(this.cfg.dataRoot, "sessions", workspaceId);
|
||||
return {
|
||||
sessions: join(root, "sessions"),
|
||||
artifacts: join(root, "artifacts"),
|
||||
indexes: join(root, "indexes"),
|
||||
};
|
||||
}
|
||||
|
||||
private installationOverlay(): RuntimeInstallationOverlay {
|
||||
const path = isAbsolute(this.cfg.configPath)
|
||||
? this.cfg.configPath
|
||||
: resolve(this.cfg.harnessDir, this.cfg.configPath);
|
||||
if (!existsSync(path)) return {};
|
||||
const documents = parseAllDocuments(readFileSync(path, "utf8"), { uniqueKeys: true });
|
||||
if (documents.length !== 1) throw new Error("installation config must contain one YAML document");
|
||||
const document = documents[0];
|
||||
if (document.errors.length > 0 || document.warnings.length > 0) {
|
||||
throw new Error("installation config contains invalid YAML");
|
||||
}
|
||||
const parsed = document.toJSON();
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error("installation config must be a YAML mapping");
|
||||
}
|
||||
const source = parsed as Record<string, unknown>;
|
||||
return {
|
||||
...(source.session_storage === undefined ? {} : { session_storage: source.session_storage }),
|
||||
...(source.profile === undefined ? {} : { profile: source.profile }),
|
||||
};
|
||||
}
|
||||
|
||||
/** Render one immutable canonical registry revision into a backend-owned harness config. */
|
||||
acquireWorkspaceRuntime(workspaceConfigPath: string): RuntimeConfigLease {
|
||||
const canonical = this.readCanonicalWorkspaceSnapshot(workspaceConfigPath);
|
||||
const bindings = resolveRuntimeBindings(
|
||||
canonical.workspace,
|
||||
process.env,
|
||||
this.cfg.secretRoots ?? [],
|
||||
);
|
||||
const config = renderRuntimeConfig(
|
||||
canonical.workspace,
|
||||
bindings,
|
||||
this.runtimePaths(canonical.workspaceId),
|
||||
canonical,
|
||||
this.installationOverlay(),
|
||||
this.cfg.semanticRuntime,
|
||||
);
|
||||
const path = this.createRuntimeSnapshot(config);
|
||||
let released = false;
|
||||
return {
|
||||
path,
|
||||
workspaceId: canonical.workspaceId,
|
||||
workspaceRevision: canonical.workspaceRevision,
|
||||
release: () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
this.cleanupRuntimeSnapshot(path);
|
||||
},
|
||||
};
|
||||
private runtimeConfigLeases(): WorkspaceRuntimeConfigLeaseFactory {
|
||||
if (this.runtimeLeaseFactory) return this.runtimeLeaseFactory;
|
||||
this.runtimeLeaseFactory = new WorkspaceRuntimeConfigLeaseFactory({
|
||||
dataRoot: this.cfg.dataRoot ?? "",
|
||||
runtimeSnapshotRoot: this.cfg.runtimeSnapshotRoot ?? "",
|
||||
harnessDir: this.cfg.harnessDir,
|
||||
configPath: this.cfg.configPath,
|
||||
secretRoots: this.cfg.secretRoots,
|
||||
env: process.env,
|
||||
semanticRuntime: this.cfg.semanticRuntime,
|
||||
});
|
||||
return this.runtimeLeaseFactory;
|
||||
}
|
||||
|
||||
private runtimeSnapshotDirectory(): string {
|
||||
@@ -360,24 +283,35 @@ export class ThtRunner {
|
||||
static readonly DEFAULT_TIMEOUT_MS = 60_000;
|
||||
static readonly DWH_TIMEOUT_MS = 120_000;
|
||||
|
||||
run(
|
||||
async run(
|
||||
args: string[], workspaceConfigPath?: string, timeoutMs: number = ThtRunner.DEFAULT_TIMEOUT_MS,
|
||||
): Promise<{ code: number; stdout: string; stderr: string }> {
|
||||
if (
|
||||
workspaceConfigPath && isAbsolute(workspaceConfigPath)
|
||||
&& !this.runtimeSnapshots.has(workspaceConfigPath)
|
||||
&& !this.runtimeLeases.has(workspaceConfigPath)
|
||||
) {
|
||||
let runtime: RuntimeConfigLease;
|
||||
try {
|
||||
runtime = this.acquireWorkspaceRuntime(workspaceConfigPath);
|
||||
runtime = await this.acquireWorkspaceRuntime(workspaceConfigPath);
|
||||
} catch (error) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
return this.run(args, runtime.path, timeoutMs).finally(runtime.release);
|
||||
this.runtimeLeases.set(runtime.path, runtime);
|
||||
return this.run(args, runtime.path, timeoutMs).finally(() => {
|
||||
this.runtimeLeases.delete(runtime.path);
|
||||
runtime.release();
|
||||
});
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const env: NodeJS.ProcessEnv = { ...process.env };
|
||||
delete env.THT_DATA_ROOT;
|
||||
// Handoff variables are backend-owned capabilities, never inherited from an
|
||||
// operator shell or forwarded request environment.
|
||||
delete env.THT_RUNTIME_CONFIG_MANIFEST_SHA256;
|
||||
delete env.THT_CONFIG_FD;
|
||||
delete env.THT_CONFIG_MANIFEST_FD;
|
||||
delete env.THT_CONFIG_MANIFEST_SHA256;
|
||||
clearPrincipalEnvironment(env);
|
||||
if (this.cfg.dataRoot !== undefined) env.THT_DATA_ROOT = this.cfg.dataRoot;
|
||||
if (this.principal) Object.assign(env, principalEnvironment(this.principal));
|
||||
@@ -396,17 +330,20 @@ export class ThtRunner {
|
||||
let ch;
|
||||
try {
|
||||
snapshotFd = workspaceConfigPath && this.runtimeSnapshots.has(workspaceConfigPath)
|
||||
? this.openTrustedRuntimeSnapshot(workspaceConfigPath)
|
||||
: undefined;
|
||||
? this.openTrustedRuntimeSnapshot(workspaceConfigPath) : undefined;
|
||||
const lease = workspaceConfigPath ? this.runtimeLeases.get(workspaceConfigPath) : undefined;
|
||||
// Runtime leases use the canonical path plus an authenticated manifest digest.
|
||||
// FD 3/4 remain reserved for the maintenance writer/root ABI.
|
||||
if (lease) env.THT_RUNTIME_CONFIG_MANIFEST_SHA256 = lease.manifestSha256;
|
||||
ch = spawn(
|
||||
this.cfg.thtBin,
|
||||
snapshotFd === undefined
|
||||
? this.buildArgv(args, workspaceConfigPath)
|
||||
: [...args, "-c", "/dev/fd/3"],
|
||||
snapshotFd === undefined ? this.buildArgv(args, workspaceConfigPath) : [...args, "-c", "/dev/fd/3"],
|
||||
{
|
||||
cwd: this.cfg.harnessDir,
|
||||
env,
|
||||
...(snapshotFd === undefined ? {} : { stdio: ["ignore", "pipe", "pipe", snapshotFd] }),
|
||||
...(snapshotFd === undefined ? {} : {
|
||||
stdio: ["ignore", "pipe", "pipe", snapshotFd],
|
||||
}),
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { WorkspaceConflictError, addressedRunRef, advertiseAddressedPublication, claimAddressedPublication, type PublishWorkspaceRequest, type WorkspaceRegistry } from "./registry.js";
|
||||
import type { RegistryAddressedRequestV1, RegistryAddressedResultV1 } from "./registry-publication.js";
|
||||
import { GitWorkspaceRepository, WorkspaceRegistryError, WorkspaceRepositoryLock } from "./git-repository.js";
|
||||
import { parseWorkspaceYaml, renderWorkspaceDocs, serializeWorkspaceYaml, validateOperationalWorkspace, type CanonicalWorkspace, type WorkspaceDescriptor } from "./schema.js";
|
||||
|
||||
|
||||
function changedFields(left: unknown, right: unknown, path = ""): string[] {
|
||||
if (JSON.stringify(left) === JSON.stringify(right)) return [];
|
||||
if (!left || !right || typeof left !== "object" || typeof right !== "object" || Array.isArray(left) || Array.isArray(right)) return [path || "workspace"];
|
||||
const keys = new Set([...Object.keys(left as object), ...Object.keys(right as object)]);
|
||||
return [...keys].sort().flatMap(key => changedFields((left as Record<string, unknown>)[key], (right as Record<string, unknown>)[key], path ? `${path}.${key}` : key));
|
||||
}
|
||||
|
||||
export interface WorkspaceAuthorGitResult {
|
||||
readonly id: string;
|
||||
readonly commit: string;
|
||||
readonly blob: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The authoring side of workspace publication. This service owns only the author
|
||||
* checkout and the remote commit; activation is deliberately left to the addressed
|
||||
* registry owner. In particular, this class never writes state/active.json or a
|
||||
* snapshot pointer.
|
||||
*/
|
||||
export class WorkspaceAuthorGitService {
|
||||
private readonly lock: WorkspaceRepositoryLock;
|
||||
|
||||
constructor(private readonly repository: GitWorkspaceRepository) {
|
||||
this.lock = new WorkspaceRepositoryLock(repository.locksPath);
|
||||
}
|
||||
|
||||
async publish(request: PublishWorkspaceRequest): Promise<WorkspaceAuthorGitResult> {
|
||||
const prepared = await this.prepare(request);
|
||||
await this.pushPrepared(prepared);
|
||||
return prepared;
|
||||
}
|
||||
|
||||
/** Stage and commit the author mutation locally, without remote network after pull. */
|
||||
async prepare(request: PublishWorkspaceRequest): Promise<WorkspaceAuthorGitResult> {
|
||||
await this.repository.ensureLayout();
|
||||
return this.lock.run(() => this.#prepareLocked(request));
|
||||
}
|
||||
|
||||
/** Recover a commit created just before a process kill, without pulling or rewriting it. */
|
||||
async recoverPrepared(request: PublishWorkspaceRequest): Promise<WorkspaceAuthorGitResult | undefined> {
|
||||
await this.repository.ensureLayout();
|
||||
return this.lock.run(async () => {
|
||||
let status;
|
||||
try { status = await this.repository.status(); } catch { return undefined; }
|
||||
const head = status.head;
|
||||
if (!head || await this.repository.parentOf(head) !== request.baseCommit) return undefined;
|
||||
const id = request.action === "delete" ? request.id : request.workspace.workspace.id;
|
||||
const path = `workspaces/${id}.yaml`;
|
||||
try {
|
||||
if (request.action === "delete") {
|
||||
const paths = await this.repository.workspacePathsAt(head);
|
||||
if (paths.includes(path)) return undefined;
|
||||
return { id, commit: head, blob: request.baseBlob };
|
||||
}
|
||||
const candidate = parseWorkspaceYaml(await this.repository.readWorkspaceAt(head, path));
|
||||
if (serializeWorkspaceYaml(candidate) !== serializeWorkspaceYaml(request.workspace)) return undefined;
|
||||
return { id, commit: head, blob: await this.repository.blobAt(head, path) };
|
||||
} catch { return undefined; }
|
||||
});
|
||||
}
|
||||
|
||||
/** Push exactly the commit previously returned by prepare (safe to repeat). */
|
||||
async pushPrepared(prepared: Pick<WorkspaceAuthorGitResult, "commit">): Promise<void> {
|
||||
await this.repository.ensureLayout();
|
||||
await this.lock.run(() => this.repository.pushExact(prepared.commit));
|
||||
}
|
||||
|
||||
async #prepareLocked(request: PublishWorkspaceRequest): Promise<WorkspaceAuthorGitResult> {
|
||||
const status = await this.repository.pull();
|
||||
const id = request.action === "delete" ? request.id : request.workspace.workspace.id;
|
||||
const path = `workspaces/${id}.yaml`;
|
||||
const current = await this.currentDescriptor(status.head!, path);
|
||||
const currentBlob = current ? await this.repository.blob(path) : undefined;
|
||||
if (request.baseCommit !== status.head) {
|
||||
if (request.action !== "create" && currentBlob === request.baseBlob) throw new WorkspaceRegistryError("workspace_stale", "Workspace revision is stale");
|
||||
const base = await this.currentDescriptor(request.baseCommit, path);
|
||||
throw this.conflict(request, status.head!, current, currentBlob, base);
|
||||
}
|
||||
if (request.action === "create" && current) throw this.conflict(request, status.head!, current, currentBlob, current);
|
||||
if (request.action !== "create" && !current) throw this.conflict(request, status.head!, current, currentBlob, current);
|
||||
if (request.action !== "create" && currentBlob !== request.baseBlob) throw this.conflict(request, status.head!, current, currentBlob, current);
|
||||
if (request.action !== "delete") await this.assertEvidence(request.workspace, status.head!);
|
||||
const docs = { contract: `workspace-docs/${id}/contract.env.example`, readme: `workspace-docs/${id}/README.md` };
|
||||
if (request.action === "delete") {
|
||||
await this.repository.removeRegistryFile(path); await this.repository.removeRegistryFile(docs.contract); await this.repository.removeRegistryFile(docs.readme);
|
||||
} else {
|
||||
await this.repository.writeRegistryFile(path, serializeWorkspaceYaml(request.workspace));
|
||||
const rendered = renderWorkspaceDocs(request.workspace);
|
||||
await this.repository.writeRegistryFile(docs.contract, rendered.envExample); await this.repository.writeRegistryFile(docs.readme, rendered.markdown);
|
||||
}
|
||||
const committed = await this.repository.commitOnly([path, docs.contract, docs.readme], request.action === "delete" ? `Delete workspace ${id}` : `Publish workspace ${id}`);
|
||||
const commit = committed.head;
|
||||
if (!commit) throw new WorkspaceRegistryError("git_unavailable", "Workspace Git service is unavailable");
|
||||
if (request.action === "delete") return { id, commit, blob: request.baseBlob };
|
||||
return { id, commit, blob: await this.repository.blob(path) };
|
||||
}
|
||||
|
||||
private async currentDescriptor(commit: string, path: string): Promise<WorkspaceDescriptor | undefined> {
|
||||
try {
|
||||
return parseWorkspaceYaml(await this.repository.readWorkspaceAt(commit, path));
|
||||
} catch (error) {
|
||||
if (error instanceof WorkspaceRegistryError && error.code === "git_unavailable") return undefined;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async assertEvidence(workspace: CanonicalWorkspace, commit: string): Promise<void> {
|
||||
if (workspace.evidence?.source.type !== "filesystem") return;
|
||||
const uri = workspace.evidence.source.uri;
|
||||
if (!/^workspace-content\/[a-z][a-z0-9-]{2,62}\/evidence$/.test(uri)) {
|
||||
throw new WorkspaceRegistryError("workspace_invalid", "Workspace Evidence root is invalid");
|
||||
}
|
||||
await this.repository.assertTreeAtRevision(commit, uri);
|
||||
}
|
||||
|
||||
private conflict(request: PublishWorkspaceRequest, commit: string, remote: WorkspaceDescriptor | undefined, blob: string | undefined, base: WorkspaceDescriptor | undefined): WorkspaceConflictError {
|
||||
const local = request.action === "delete" ? undefined : request.workspace;
|
||||
const expected = { commit: request.baseCommit, ...(request.action === "create" ? {} : { blob: request.baseBlob }) };
|
||||
const actual = { commit, ...(blob ? { blob } : {}) };
|
||||
const remoteChanges = changedFields(base, remote);
|
||||
const fields = remoteChanges;
|
||||
return new WorkspaceConflictError(fields, expected, actual, base, local, remote);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Repository-author/publication friend. The addressed claim is durable before the
|
||||
* author Git service performs pull/stage/push network, and activation always resumes
|
||||
* that exact run identity.
|
||||
*/
|
||||
export async function publishAddressedByAuthor(
|
||||
registry: WorkspaceRegistry,
|
||||
author: WorkspaceAuthorGitService,
|
||||
request: PublishWorkspaceRequest,
|
||||
addressed: Extract<RegistryAddressedRequestV1, { readonly mode: "create" }>,
|
||||
): Promise<RegistryAddressedResultV1> {
|
||||
const claimed = await claimAddressedPublication(registry, addressed);
|
||||
const resume = { ...addressed, mode: "resume" as const, runId: claimed.runId };
|
||||
if (claimed.phase === "terminal_durable") return registry.publishAddressed(resume);
|
||||
|
||||
let target = claimed.advertisedTargetCommit;
|
||||
if (claimed.phase === "request_claimed") {
|
||||
// Handle a kill between update-ref and the state-file transition: the immutable
|
||||
// local ref is sufficient evidence to finish the same deterministic run.
|
||||
const existingRef = await addressedRunRef(registry, claimed.runId);
|
||||
if (existingRef) {
|
||||
target = existingRef as typeof target;
|
||||
await advertiseAddressedPublication(registry, claimed.runId, existingRef);
|
||||
} else {
|
||||
const prepared = await author.recoverPrepared(request) ?? await author.prepare(request);
|
||||
target = prepared.commit as typeof target;
|
||||
// The ref and durable advertisement precede the push, so either crash boundary
|
||||
// resumes by pushing this exact commit rather than authoring a new one.
|
||||
await advertiseAddressedPublication(registry, claimed.runId, prepared.commit);
|
||||
}
|
||||
}
|
||||
if (!target) throw new WorkspaceRegistryError("registry_bootstrap_recovery_conflict", "Addressed author run has no pinned target");
|
||||
await author.pushPrepared({ commit: target });
|
||||
return registry.publishAddressed(resume);
|
||||
}
|
||||
@@ -70,7 +70,7 @@ export class GitWorkspaceRepository {
|
||||
readonly locksPath: string;
|
||||
private readonly hooksPath: string;
|
||||
|
||||
constructor(private readonly config: WorkspaceRegistryConfig) {
|
||||
constructor(readonly config: WorkspaceRegistryConfig) {
|
||||
if (!isAbsolute(config.root)) {
|
||||
throw new WorkspaceRegistryError("git_unavailable", "Workspace registry root is unavailable");
|
||||
}
|
||||
@@ -129,6 +129,49 @@ export class GitWorkspaceRepository {
|
||||
};
|
||||
}
|
||||
|
||||
private validateRevision(revision: string): void {
|
||||
if (!/^[0-9a-f]{40}$/.test(revision)) throw new WorkspaceRegistryError("workspace_invalid", "Workspace revision is invalid");
|
||||
}
|
||||
async parentOf(revision: string): Promise<string | undefined> {
|
||||
this.validateRevision(revision);
|
||||
return (await this.gitOptional(["rev-parse", `${revision}^`]))?.trim();
|
||||
}
|
||||
|
||||
async workspacePathsAt(revision: string): Promise<string[]> {
|
||||
this.validateRevision(revision);
|
||||
const output = await this.git(["ls-tree", "-r", "--name-only", revision, "--", "workspaces"]);
|
||||
const paths = output.trim() === "" ? [] : output.trim().split("\n");
|
||||
for (const path of paths) if (!/^workspaces\/[a-z][a-z0-9-]{2,62}\.yaml$/.test(path)) throw new WorkspaceRegistryError("workspace_invalid", "Workspace repository contains an invalid path");
|
||||
return paths;
|
||||
}
|
||||
async readWorkspaceAt(revision: string, path: string): Promise<string> {
|
||||
this.validateRevision(revision);
|
||||
if (!/^workspaces\/[a-z][a-z0-9-]{2,62}\.yaml$/.test(path)) throw new WorkspaceRegistryError("workspace_invalid", "Workspace repository path is invalid");
|
||||
return this.git(["show", `${revision}:${path}`]);
|
||||
}
|
||||
async blobAt(revision: string, path: string): Promise<string> {
|
||||
this.validateRevision(revision);
|
||||
if (!/^workspaces\/[a-z][a-z0-9-]{2,62}\.yaml$/.test(path)) throw new WorkspaceRegistryError("workspace_invalid", "Workspace repository path is invalid");
|
||||
return (await this.git(["rev-parse", `${revision}:${path}`])).trim();
|
||||
}
|
||||
async fetchExact(revision: string): Promise<void> {
|
||||
this.validateRevision(revision);
|
||||
if (!this.config.remoteUrl) throw new WorkspaceRegistryError("git_unavailable", "Workspace registry remote is unavailable");
|
||||
await this.git(["fetch", "--no-tags", "origin", revision]);
|
||||
}
|
||||
async ensureRunRef(runId: string, revision: string): Promise<void> {
|
||||
this.validateRevision(revision);
|
||||
if (!/^[0-9a-f]{32}$/.test(runId)) throw new WorkspaceRegistryError("workspace_invalid", "Workspace publication run is invalid");
|
||||
const ref = `refs/thoth/addressed-runs/${runId}/target`;
|
||||
const current = await this.gitOptional(["rev-parse", "--verify", ref]);
|
||||
if (current !== undefined && current.trim() !== revision) throw new WorkspaceRegistryError("workspace_conflict", "Workspace publication target conflicts");
|
||||
if (current === undefined) await this.git(["update-ref", ref, revision]);
|
||||
}
|
||||
async runRef(runId: string): Promise<string | undefined> {
|
||||
if (!/^[0-9a-f]{32}$/.test(runId)) throw new WorkspaceRegistryError("workspace_invalid", "Workspace publication run is invalid");
|
||||
return (await this.gitOptional(["rev-parse", "--verify", `refs/thoth/addressed-runs/${runId}/target`]))?.trim();
|
||||
}
|
||||
|
||||
async workspacePaths(): Promise<string[]> {
|
||||
const output = await this.git(["ls-tree", "-r", "--name-only", "HEAD", "--", "workspaces"]);
|
||||
const paths = output.trim() === "" ? [] : output.trim().split("\n");
|
||||
@@ -183,6 +226,35 @@ export class GitWorkspaceRepository {
|
||||
await rm(join(this.repoPath, path), { force: true });
|
||||
}
|
||||
|
||||
/** Push an already-created commit by its exact object ID. Repeating this is idempotent. */
|
||||
async pushExact(revision: string): Promise<void> {
|
||||
this.validateRevision(revision);
|
||||
try {
|
||||
await this.git(["push", "origin", `${revision}:refs/heads/${this.config.branch}`]);
|
||||
} catch (error) {
|
||||
// `git` already returns a sanitized WorkspaceRegistryError. Preserve it rather
|
||||
// than mapping its public code a second time (notably git_push_rejected).
|
||||
await this.restoreFailedPublication();
|
||||
if (error instanceof WorkspaceRegistryError) throw error;
|
||||
throw this.sanitizeGitError(error);
|
||||
}
|
||||
}
|
||||
|
||||
/** Create a local publication commit without contacting the remote. */
|
||||
async commitOnly(paths: readonly string[], message: string): Promise<GitStatus> {
|
||||
if (paths.length === 0 || paths.some((path) => !this.isRegistryArtifactPath(path))) throw new WorkspaceRegistryError("workspace_invalid", "Workspace repository path is invalid");
|
||||
try {
|
||||
await this.git(["add", "--", ...paths]);
|
||||
await this.git(["commit", "-m", message], this.publicationIdentity());
|
||||
return await this.status();
|
||||
} catch (error) {
|
||||
// A failed prepare must not leave staged or generated artifacts in the
|
||||
// long-lived author checkout for the next request.
|
||||
await this.restoreFailedPublication();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Commit and push a fixed set of validated artifact paths without exposing Git output. */
|
||||
async commitAndPush(paths: readonly string[], message: string): Promise<GitStatus> {
|
||||
if (paths.length === 0 || paths.some((path) => !this.isRegistryArtifactPath(path))) {
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { fsAtInternal } from "./workspace-fs-at-internal.js";
|
||||
import { rootLeaseInternals } from "./workspace-lock-root-lease-runtime.js";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import {
|
||||
WorkspaceFsAtV1,
|
||||
type OwnedWorkspaceFsAtRegularFile,
|
||||
type OwnedWorkspaceFsAtDirectory,
|
||||
} from "./workspace-fs-at.js";
|
||||
import type { RuntimeConfigLease } from "./runtime-config-lease.js";
|
||||
import {
|
||||
BorrowedVerifiedWorkspaceLockRootLease,
|
||||
VerifiedWorkspaceLockRootLease,
|
||||
type CanonicalWorkspaceId,
|
||||
type WorkspaceLockRootIdentityV1,
|
||||
Revision40,
|
||||
|
||||
} from "./workspace-lock-root-lease.js";
|
||||
|
||||
export interface ArtifactIdentity { readonly kind: string; readonly digest: string; readonly bytes: number; }
|
||||
export interface PreprocessingRunStateV1 {
|
||||
readonly schemaVersion: 1; readonly runId: string; readonly workspaceId: CanonicalWorkspaceId;
|
||||
readonly revision: Revision40; readonly operation: string; readonly phase: string;
|
||||
readonly artifacts: readonly ArtifactIdentity[]; readonly createdAt: string; readonly updatedAt: string;
|
||||
}
|
||||
export interface CreateRunInput { readonly runId?: string; readonly workspaceId: CanonicalWorkspaceId; readonly revision: Revision40; readonly operation: string; }
|
||||
export interface ResumeRunInput { readonly runId: string; readonly workspaceId: string; readonly revision: Revision40; readonly operation: string; }
|
||||
export interface RunTransition { readonly phase: string; }
|
||||
export interface FkReviewInput { readonly candidate: ArtifactIdentity; readonly reviewSha256: string; readonly annotationSha256?: string; }
|
||||
export interface FkReviewRecordV1 extends FkReviewInput { readonly runId: string; readonly recordedAt: string; }
|
||||
|
||||
const digest = (x: Uint8Array | string) => createHash("sha256").update(x).digest("hex");
|
||||
const INTERNAL_STATE = Symbol("preprocessing-state-internal");
|
||||
const RUN_ID = /^[0-9a-f]{32}$/;
|
||||
const SHA256 = /^(?:sha256:)?[0-9a-f]{64}$/;
|
||||
const REVISION = /^[0-9a-f]{40}$/;
|
||||
const WORKSPACE = /^[a-z][a-z0-9-]{2,62}$/;
|
||||
const OPERATIONS = new Set(["dwh", "schema", "evidence"]);
|
||||
const PHASES = ["created", "introspected", "fk_suggested", "fk_reviewed", "schema_indexed", "evidence_indexed", "lsh_built", "terminal"] as const;
|
||||
const MAX_FILE_BYTES = 1 << 20;
|
||||
const MAX_AGGREGATE_BYTES = 64 << 20;
|
||||
const MAX_ENTRIES = 4096;
|
||||
function fail(msg = "preprocessing_conflict"): Error { const e = new Error(msg); e.name = "PreprocessingConflictError"; return e; }
|
||||
type WorkspaceRootLock = { assertPath(): void; close(): void; flock(kind: "shared" | "exclusive", wait: "blocking" | "nonblocking"): void; spawn(root: OwnedWorkspaceFsAtDirectory, executable: string, args: readonly string[], environment?: NodeJS.ProcessEnv): Promise<WorkspaceLockedChildResult>; };
|
||||
function id(v: string): void { if (typeof v !== "string" || !RUN_ID.test(v)) throw fail("invalid run id"); }
|
||||
function checkSha(v: string): void { if (typeof v !== "string" || !SHA256.test(v)) throw fail("invalid digest"); }
|
||||
function strictObject(value: unknown, keys: readonly string[]): value is Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const got = Object.keys(value as object).sort(); const expected = [...keys].sort();
|
||||
return got.length === expected.length && got.every((key, i) => key === expected[i]);
|
||||
}
|
||||
function validDir(directory: OwnedWorkspaceFsAtDirectory): void {
|
||||
const st = directory.stat(); if ((st.mode & 0o170000) !== 0o040000 || (st.mode & 0o7777) !== 0o700 || st.uid !== (process.getuid?.() ?? st.uid) || st.nlink < 2n) throw fail();
|
||||
}
|
||||
function validFile(file: OwnedWorkspaceFsAtRegularFile, max = MAX_FILE_BYTES): void {
|
||||
const st = file.stat(); if ((st.mode & 0o170000) !== 0o100000 || (st.mode & 0o7777) !== 0o600 || st.uid !== (process.getuid?.() ?? st.uid) || st.nlink !== 1n || st.device === 0n || st.inode === 0n || max < 0) throw fail();
|
||||
}
|
||||
|
||||
/** All state access is relative to the transferred retained root descriptor. */
|
||||
export class PreprocessingStateStore {
|
||||
constructor(private readonly rootLease: VerifiedWorkspaceLockRootLease) {}
|
||||
private dirs<T>(action: (fs: WorkspaceFsAtV1, base: OwnedWorkspaceFsAtDirectory, jobs: OwnedWorkspaceFsAtDirectory, candidates: OwnedWorkspaceFsAtDirectory, reviews: OwnedWorkspaceFsAtDirectory) => T): T {
|
||||
return rootLeaseInternals.withRoot(this.rootLease, (fs, root) => {
|
||||
validDir(root); let base: OwnedWorkspaceFsAtDirectory | undefined; let jobs: OwnedWorkspaceFsAtDirectory | undefined; let candidates: OwnedWorkspaceFsAtDirectory | undefined; let reviews: OwnedWorkspaceFsAtDirectory | undefined;
|
||||
const mkdir = (parent: OwnedWorkspaceFsAtDirectory, name: string): OwnedWorkspaceFsAtDirectory => { try { return fsAtInternal.openDirectory(parent, name); } catch { try { fsAtInternal.mkdir(parent, name); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; } return fsAtInternal.openDirectory(parent, name); } };
|
||||
try { base = mkdir(root, "preprocessing"); jobs = mkdir(base, "jobs"); candidates = mkdir(base, "fk-candidates"); reviews = mkdir(base, "fk-reviews"); [base, jobs, candidates, reviews].forEach(validDir); for (const d of [reviews, candidates, jobs, base, root]) fsAtInternal.fsyncDirectory(d); return action(fs, base, jobs, candidates, reviews); }
|
||||
catch (error) { if (error instanceof Error && error.name === "PreprocessingConflictError") throw error; throw fail(); }
|
||||
finally { for (const d of [reviews, candidates, jobs, base]) { try { d?.close(); } catch {} } }
|
||||
});
|
||||
}
|
||||
private file(directory: OwnedWorkspaceFsAtDirectory, name: string, access: "read"|"create"): OwnedWorkspaceFsAtRegularFile {
|
||||
try { const f = fsAtInternal.openFile(directory, name, access); validFile(f); return f; } catch { throw fail(); }
|
||||
}
|
||||
private read(directory: OwnedWorkspaceFsAtDirectory, name: string): Uint8Array {
|
||||
const f = this.file(directory, name, "read"); try { const bytes = fsAtInternal.readFile(f, MAX_FILE_BYTES); if (bytes.byteLength > MAX_FILE_BYTES) throw fail("preprocessing_bounds"); return bytes; } catch { throw fail(); } finally { try { f.close(); } catch {} }
|
||||
}
|
||||
private writeExclusive(directory: OwnedWorkspaceFsAtDirectory, name: string, bytes: Uint8Array): void {
|
||||
if (!(bytes instanceof Uint8Array) || bytes.byteLength > MAX_FILE_BYTES) throw fail("preprocessing_bounds"); const f = this.file(directory, name, "create");
|
||||
try { fsAtInternal.writeFile(f, bytes); fsAtInternal.fsyncFile(f); } catch { throw fail(); } finally { try { f.close(); } catch {} }
|
||||
fsAtInternal.fsyncDirectory(directory);
|
||||
}
|
||||
private writeReplace(directory: OwnedWorkspaceFsAtDirectory, name: string, bytes: Uint8Array): void {
|
||||
const tmp = `.${name}.tmp-${process.pid}-${randomBytes(8).toString("hex")}`; try { this.writeExclusive(directory, tmp, bytes); fsAtInternal.rename(directory, tmp, name, true); fsAtInternal.fsyncDirectory(directory); } catch { try { fsAtInternal.unlink(directory, tmp); } catch {} throw fail(); }
|
||||
}
|
||||
private rawState(jobs: OwnedWorkspaceFsAtDirectory, runId: string): PreprocessingRunStateV1 { id(runId); const names = fsAtInternal.listDirectory(jobs); if (names.length > MAX_ENTRIES) throw fail("preprocessing_bounds"); let total = 0; for (const name of names) { if (!name.endsWith(".json") || !RUN_ID.test(name.slice(0, -5))) throw fail("preprocessing_bounds"); const bytes = this.read(jobs, name); total += bytes.byteLength; if (bytes.byteLength > MAX_FILE_BYTES || total > MAX_AGGREGATE_BYTES) throw fail("preprocessing_bounds"); } let parsed: unknown; try { parsed = JSON.parse(new TextDecoder().decode(this.read(jobs, `${runId}.json`))); } catch { throw fail(); } if (!this.validState(parsed)) throw fail(); return parsed; }
|
||||
private rawReview(reviews: OwnedWorkspaceFsAtDirectory, runId: string): FkReviewRecordV1 | undefined { try { const parsed = JSON.parse(new TextDecoder().decode(this.read(reviews, `${runId}.json`))); if (!strictObject(parsed, ["candidate", "reviewSha256", "runId", "recordedAt", ...(parsed && "annotationSha256" in parsed ? ["annotationSha256"] : [])])) throw fail(); return parsed as unknown as FkReviewRecordV1; } catch { return undefined; } }
|
||||
async create(input: CreateRunInput): Promise<PreprocessingRunStateV1> {
|
||||
const runId = input.runId ?? randomBytes(16).toString("hex"); id(runId); if (!WORKSPACE.test(input.workspaceId) || !REVISION.test(input.revision) || !OPERATIONS.has(input.operation)) throw fail();
|
||||
return this.dirs((_fs, _base, jobs) => { const now = new Date().toISOString(); const state: PreprocessingRunStateV1 = { schemaVersion: 1, runId, workspaceId: input.workspaceId, revision: input.revision, operation: input.operation, phase: "created", artifacts: [], createdAt: now, updatedAt: now }; const bytes = new TextEncoder().encode(`${JSON.stringify(state)}
|
||||
`); try { this.writeExclusive(jobs, `${runId}.json`, bytes); return state; } catch (error) { try { const prior = this.rawState(jobs, runId); if (prior.workspaceId === input.workspaceId && prior.revision === input.revision && prior.operation === input.operation && prior.runId === runId) return prior; } catch {} throw fail(); } });
|
||||
}
|
||||
async loadForResume(input: ResumeRunInput): Promise<PreprocessingRunStateV1> { id(input.runId); if (!WORKSPACE.test(input.workspaceId) || !REVISION.test(input.revision) || !OPERATIONS.has(input.operation)) throw fail("preprocessing_resume_mismatch"); return this.dirs((_fs, _base, jobs) => { const state = this.rawState(jobs, input.runId); if (state.workspaceId !== input.workspaceId || state.revision !== input.revision || state.operation !== input.operation) throw fail("preprocessing_resume_mismatch"); return state; }); }
|
||||
async load(input: ResumeRunInput): Promise<PreprocessingRunStateV1> { return this.loadForResume(input); }
|
||||
async transition(runId: string, transition: RunTransition): Promise<PreprocessingRunStateV1> { id(runId); if (!strictObject(transition, ["phase"]) || typeof transition.phase !== "string") throw fail(); return this.dirs((_fs, _base, jobs) => { const current = this.rawState(jobs, runId); const old = PHASES.indexOf(current.phase as never); const next = PHASES.indexOf(transition.phase as never); if (old < 0 || next < 0 || next < old || current.phase === "terminal") throw fail(); const updated: PreprocessingRunStateV1 = { ...current, phase: transition.phase, updatedAt: new Date().toISOString() }; this.writeReplace(jobs, `${runId}.json`, new TextEncoder().encode(`${JSON.stringify(updated)}
|
||||
`)); return updated; }); }
|
||||
async writeFkCandidate(runId: string, yaml: Uint8Array): Promise<ArtifactIdentity> { id(runId); if (!(yaml instanceof Uint8Array) || yaml.byteLength > MAX_FILE_BYTES) throw fail("preprocessing_bounds"); return this.dirs((_fs, _base, jobs, candidates) => { const state = this.rawState(jobs, runId); const artifact = { kind: "fk-candidate", digest: digest(yaml), bytes: yaml.byteLength } satisfies ArtifactIdentity; try { this.writeExclusive(candidates, `${runId}.yaml`, yaml); } catch { throw fail(); } if (state.runId !== runId) throw fail(); return artifact; }); }
|
||||
async recordFkReview(runId: string, review: FkReviewInput): Promise<FkReviewRecordV1> { id(runId); if (!strictObject(review, ["candidate", "reviewSha256", ...(review && "annotationSha256" in review ? ["annotationSha256"] : [])]) || !review?.candidate || !strictObject(review.candidate, ["kind", "digest", "bytes"]) || review.candidate.kind !== "fk-candidate" || !Number.isSafeInteger(review.candidate.bytes) || review.candidate.bytes < 0 || review.candidate.bytes > MAX_FILE_BYTES) throw fail(); checkSha(review.reviewSha256); if (review.annotationSha256 !== undefined) checkSha(review.annotationSha256); return this.dirs((_fs, _base, jobs, candidates, reviews) => { this.rawState(jobs, runId); let candidate: Uint8Array; try { candidate = this.read(candidates, `${runId}.yaml`); } catch { throw fail(); } if (review.candidate.bytes !== candidate.byteLength || review.candidate.digest !== digest(candidate)) throw fail("preprocessing_review_mismatch"); const prior = this.rawReview(reviews, runId); if (prior) { const same = prior.runId === runId && JSON.stringify(prior.candidate) === JSON.stringify(review.candidate) && prior.reviewSha256 === review.reviewSha256 && prior.annotationSha256 === review.annotationSha256; if (!same) throw fail("preprocessing_review_mismatch"); return prior; } const out: FkReviewRecordV1 = { ...review, runId, recordedAt: new Date().toISOString() }; try { this.writeExclusive(reviews, `${runId}.json`, new TextEncoder().encode(`${JSON.stringify(out)}
|
||||
`)); } catch { throw fail(); } return out; }); }
|
||||
private validState(value: unknown): value is PreprocessingRunStateV1 { if (!strictObject(value, ["schemaVersion", "runId", "workspaceId", "revision", "operation", "phase", "artifacts", "createdAt", "updatedAt"])) return false; const x = value as Record<string, unknown>; if (x.schemaVersion !== 1 || typeof x.runId !== "string" || !RUN_ID.test(x.runId) || typeof x.workspaceId !== "string" || !WORKSPACE.test(x.workspaceId) || typeof x.revision !== "string" || !REVISION.test(x.revision) || typeof x.operation !== "string" || !OPERATIONS.has(x.operation) || typeof x.phase !== "string" || !PHASES.includes(x.phase as never) || !Array.isArray(x.artifacts) || typeof x.createdAt !== "string" || typeof x.updatedAt !== "string" || x.artifacts.length > MAX_ENTRIES) return false; return (x.artifacts as unknown[]).every(a => strictObject(a, ["kind", "digest", "bytes"]) && typeof (a as Record<string, unknown>).kind === "string" && typeof (a as Record<string, unknown>).digest === "string" && SHA256.test((a as Record<string, unknown>).digest as string) && Number.isSafeInteger((a as Record<string, unknown>).bytes) && (a as Record<string, unknown>).bytes as number >= 0 && (a as Record<string, unknown>).bytes as number <= MAX_FILE_BYTES); }
|
||||
}
|
||||
|
||||
export interface DwhLockedChildRequest { readonly kind: "dwh_preprocess"; readonly stage: "introspect" | "lsh"; readonly workspaceId: CanonicalWorkspaceId; readonly revision: Revision40; readonly rootIdentity: WorkspaceLockRootIdentityV1; readonly runtimeConfig: RuntimeConfigLease; readonly childRunId: string; }
|
||||
export interface SchemaLockedChildRequest { readonly kind: "schema_preprocess"; readonly stage: "fk_suggest" | "fk_check" | "schema_index"; readonly workspaceId: CanonicalWorkspaceId; readonly revision: Revision40; readonly rootIdentity: WorkspaceLockRootIdentityV1; readonly runtimeConfig: RuntimeConfigLease; readonly childRunId: string; readonly reviewedArtifact: ArtifactIdentity | null; }
|
||||
export interface EvidenceLockedChildRequest { readonly kind: "evidence_preprocess"; readonly stage: "http_publish"; readonly workspaceId: CanonicalWorkspaceId; readonly revision: Revision40; readonly rootIdentity: WorkspaceLockRootIdentityV1; readonly runtimeConfig: RuntimeConfigLease; readonly childRunId: string; }
|
||||
export type WorkspaceLockedChildRequest = DwhLockedChildRequest | SchemaLockedChildRequest | EvidenceLockedChildRequest;
|
||||
export interface WorkspaceLockedChildResult { readonly exitCode: number; readonly stdout: Uint8Array; readonly stderr: Uint8Array; }
|
||||
|
||||
const borrowedState = new WeakMap<object, { live: boolean }>();
|
||||
export class BorrowedWorkspaceSessionReadersExclusiveLockLease {
|
||||
private constructor(readonly workspaceId: CanonicalWorkspaceId, readonly rootIdentity: WorkspaceLockRootIdentityV1) { borrowedState.set(this, { live: true }); }
|
||||
static [INTERNAL_STATE](id: CanonicalWorkspaceId, root: WorkspaceLockRootIdentityV1) { return new BorrowedWorkspaceSessionReadersExclusiveLockLease(id, root); }
|
||||
}
|
||||
function invalidateBorrowed(value: BorrowedWorkspaceSessionReadersExclusiveLockLease): void { const state = borrowedState.get(value); if (!state) throw fail(); state.live = false; }
|
||||
|
||||
interface WriterState { live: boolean; settled: boolean; readerExclusive: boolean; spawnActive: boolean; poisoned: boolean; root: VerifiedWorkspaceLockRootLease; writer: WorkspaceRootLock; }
|
||||
const writerState = new WeakMap<object, WriterState>();
|
||||
export class WorkspaceWriterLockCapability {
|
||||
readonly workspaceId: CanonicalWorkspaceId;
|
||||
readonly rootIdentity: WorkspaceLockRootIdentityV1;
|
||||
private constructor(id: CanonicalWorkspaceId, identity: WorkspaceLockRootIdentityV1, root: VerifiedWorkspaceLockRootLease, writer: WorkspaceRootLock) {
|
||||
this.workspaceId = id; this.rootIdentity = identity; writerState.set(this, { live: true, settled: false, readerExclusive: false, spawnActive: false, poisoned: false, root, writer });
|
||||
}
|
||||
static [INTERNAL_STATE](id: CanonicalWorkspaceId, identity: WorkspaceLockRootIdentityV1, root: VerifiedWorkspaceLockRootLease, writer: WorkspaceRootLock) { return new WorkspaceWriterLockCapability(id, identity, root, writer); }
|
||||
async runUnderSessionReadersExclusive<T>(action: (lease: BorrowedWorkspaceSessionReadersExclusiveLockLease) => Promise<T>): Promise<T> {
|
||||
const state = assertCapability(this); if (state.readerExclusive || state.spawnActive) throw fail(); state.readerExclusive = true; let lock: WorkspaceRootLock;
|
||||
try { lock = await rootLeaseInternals.acquireReadersExclusive(state.root); } catch { state.readerExclusive = false; state.poisoned = true; throw fail(); }
|
||||
const borrowed = BorrowedWorkspaceSessionReadersExclusiveLockLease[INTERNAL_STATE](this.workspaceId, this.rootIdentity); let callbackError: unknown; let result: T | undefined;
|
||||
try { result = await action(borrowed); } catch (error) { callbackError = error; }
|
||||
invalidateBorrowed(borrowed); let cleanupError: unknown; try { lock.close(); } catch (error) { cleanupError = error; }
|
||||
state.readerExclusive = false; if (cleanupError) { state.poisoned = true; state.live = false; }
|
||||
if (callbackError) throw callbackError; if (cleanupError) throw fail(); return result as T;
|
||||
}
|
||||
async spawnChild(request: WorkspaceLockedChildRequest): Promise<WorkspaceLockedChildResult> {
|
||||
const state = assertCapability(this); if (!state.readerExclusive || state.spawnActive || !request || request.workspaceId !== this.workspaceId) throw fail();
|
||||
if (!RUN_ID.test(request.childRunId) || request.rootIdentity.device !== this.rootIdentity.device || request.rootIdentity.inode !== this.rootIdentity.inode || request.rootIdentity.workspaceId !== this.workspaceId) throw fail();
|
||||
const cfg = request.runtimeConfig; if (cfg.workspaceId !== this.workspaceId || cfg.workspaceRevision !== request.revision || typeof cfg.path !== "string") throw fail(); state.spawnActive = true;
|
||||
try {
|
||||
let argv: string[];
|
||||
switch (request.kind) { case "dwh_preprocess": argv = ["-m", "tht.cli", "preprocess", "dwh", "--steps", request.stage, "--json", "-c", cfg.path]; break; case "schema_preprocess": argv = ["-m", "tht.cli", "schema", request.stage === "fk_suggest" ? "suggest-fks" : request.stage === "fk_check" ? "check" : "index", "--json", "-c", cfg.path]; break; case "evidence_preprocess": argv = ["-m", "tht.cli", "preprocess", "evidence", "--json", "-c", cfg.path]; break; default: throw fail(); }
|
||||
return await rootLeaseInternals.withRoot(state.root, (_fs, retainedRoot) => state.writer.spawn(retainedRoot, process.env.THT_PYTHON ?? "python3", argv, { ...process.env, THOTH_WORKSPACE_ID: this.workspaceId, THOTH_WORKSPACE_REVISION: request.revision, THOTH_WORKSPACE_DEVICE: String(this.rootIdentity.device), THOTH_WORKSPACE_INODE: String(this.rootIdentity.inode) }));
|
||||
} finally { state.spawnActive = false; }
|
||||
}
|
||||
}
|
||||
function assertCapability(cap: WorkspaceWriterLockCapability): WriterState { const state = writerState.get(cap); if (!state || !state.live || state.settled || state.poisoned) throw fail(); rootLeaseInternals.assertLive(state.root); state.writer.assertPath(); return state; }
|
||||
function settleCapability(cap: WorkspaceWriterLockCapability): void { const state = writerState.get(cap); if (state) state.settled = true; }
|
||||
async function closeCapability(cap: WorkspaceWriterLockCapability): Promise<void> { const state = writerState.get(cap); if (!state || !state.live) return; if (state.readerExclusive || state.spawnActive) { state.poisoned = true; throw fail(); } state.live = false; let error: unknown; try { state.writer.close(); } catch (e) { error = e; } try { await state.root.close(); } catch (e) { error ??= e; } if (error) throw fail(); }
|
||||
function makeWriterCapability(id: CanonicalWorkspaceId, identity: WorkspaceLockRootIdentityV1, root: VerifiedWorkspaceLockRootLease, writer: WorkspaceRootLock) { return WorkspaceWriterLockCapability[INTERNAL_STATE](id, identity, root, writer); }
|
||||
|
||||
export interface BorrowedOrderedWorkspaceWriterLeaseV1 {
|
||||
readonly workspaceId: CanonicalWorkspaceId;
|
||||
readonly rootLease: BorrowedVerifiedWorkspaceLockRootLease;
|
||||
readonly writerCapability: WorkspaceWriterLockCapability;
|
||||
}
|
||||
export interface OrderedWorkspaceCapability { readonly workspaceId: CanonicalWorkspaceId; readonly rootLease: BorrowedVerifiedWorkspaceLockRootLease; readonly writerCapability: WorkspaceWriterLockCapability; }
|
||||
const orderedState = new WeakMap<object, { live: boolean; caps: Map<CanonicalWorkspaceId, WorkspaceWriterLockCapability> }>();
|
||||
export class OrderedWorkspaceWriterCapabilitySet {
|
||||
private constructor(caps: Map<CanonicalWorkspaceId, WorkspaceWriterLockCapability>) { orderedState.set(this, { live: true, caps }); }
|
||||
static [INTERNAL_STATE](caps: Map<CanonicalWorkspaceId, WorkspaceWriterLockCapability>) { return new OrderedWorkspaceWriterCapabilitySet(caps); }
|
||||
get workspaceIds(): readonly CanonicalWorkspaceId[] { const state = orderedState.get(this); if (!state?.live) throw fail(); return [...state.caps.keys()]; }
|
||||
async forWorkspace<T>(workspaceId: CanonicalWorkspaceId, action: (lease: OrderedWorkspaceCapability) => Promise<T>): Promise<T> { const state = orderedState.get(this); if (!state?.live) throw fail(); const cap = state.caps.get(workspaceId); if (!cap) throw fail(); return action({ workspaceId, rootLease: Reflect.construct(BorrowedVerifiedWorkspaceLockRootLease, [cap.rootIdentity]) as BorrowedVerifiedWorkspaceLockRootLease, writerCapability: cap }); }
|
||||
async forEachWorkspace<T>(action: (lease: OrderedWorkspaceCapability) => Promise<T>): Promise<readonly T[]> { return Promise.all(this.workspaceIds.map(id => this.forWorkspace(id, action))); }
|
||||
}
|
||||
function settleSet(set: OrderedWorkspaceWriterCapabilitySet): void { const state = orderedState.get(set); if (!state) return; state.live = false; for (const cap of state.caps.values()) settleCapability(cap); }
|
||||
export async function runUnderOrderedWorkspaceWriterLocks<T>(rootLeases: readonly VerifiedWorkspaceLockRootLease[], action: (capabilities: OrderedWorkspaceWriterCapabilitySet) => Promise<T>): Promise<T> {
|
||||
const sorted = [...rootLeases].sort((a, b) => a.identity.workspaceId.localeCompare(b.identity.workspaceId)); if (new Set(sorted.map(x => x.identity.workspaceId)).size !== sorted.length) throw fail();
|
||||
const caps: WorkspaceWriterLockCapability[] = []; let set: OrderedWorkspaceWriterCapabilitySet | undefined; let result: T | undefined; let callbackError: unknown;
|
||||
try {
|
||||
for (const source of sorted) {
|
||||
const root = source.transfer();
|
||||
let writer: WorkspaceRootLock | undefined;
|
||||
try {
|
||||
writer = await rootLeaseInternals.acquireWriter(root);
|
||||
caps.push(makeWriterCapability(root.identity.workspaceId, root.identity, root, writer));
|
||||
} catch (error) {
|
||||
try { writer?.close(); } catch {}
|
||||
try { await root.close(); } catch {}
|
||||
callbackError = error;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!callbackError) {
|
||||
set = OrderedWorkspaceWriterCapabilitySet[INTERNAL_STATE](new Map(caps.map(c => [c.workspaceId, c])));
|
||||
for (const capability of caps) assertCapability(capability);
|
||||
try { result = await action(set); for (const capability of caps) assertCapability(capability); }
|
||||
catch (error) { callbackError = error; }
|
||||
}
|
||||
} catch (error) { callbackError ??= error; }
|
||||
if (set) settleSet(set);
|
||||
for (const cap of [...caps].reverse()) {
|
||||
try { await closeCapability(cap); } catch (error) { callbackError ??= error; }
|
||||
}
|
||||
if (callbackError) throw callbackError; return result as T;
|
||||
}
|
||||
export function runUnderWorkspaceWriterLock<T>(rootLease: VerifiedWorkspaceLockRootLease, action: (capability: WorkspaceWriterLockCapability) => Promise<T>) { return runUnderOrderedWorkspaceWriterLocks([rootLease], set => set.forWorkspace(rootLease.identity.workspaceId, x => action(x.writerCapability))); }
|
||||
export async function probeWorkspaceWriterLock(rootLease: VerifiedWorkspaceLockRootLease): Promise<"available" | "held"> { try { await runUnderWorkspaceWriterLock(rootLease, async () => undefined); return "available"; } catch { return "held"; } }
|
||||
@@ -0,0 +1,284 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { constants as fsConstants } from "node:fs";
|
||||
import { lstat, mkdir, open, readFile, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import type { CanonicalWorkspaceId, Revision40, Sha256Hex } from "./workspace-lock-root-lease.js";
|
||||
import type { CanonicalWorkspace } from "./schema.js";
|
||||
import type { BorrowedOrderedWorkspaceWriterLeaseV1, BorrowedWorkspaceSessionReadersExclusiveLockLease, OrderedWorkspaceWriterCapabilitySet } from "./preprocessing-state.js";
|
||||
|
||||
export type RegistryRunId32 = string & { readonly __registryRunId32: unique symbol };
|
||||
export type RegistryAddressedOperationV1 = "registry_bootstrap" | "registry_pull";
|
||||
export type RegistryAddressedPublicationPhaseV1 = "request_claimed" | "target_advertised" | "target_fetched" | "planned" | "participants_prepared" | "publication_intent_durable" | "target_published" | "terminal_durable";
|
||||
export type RegistryAddressedJobArtifactPathV1 = `addressed-publication-jobs/${RegistryRunId32}.json`;
|
||||
export interface RegistryWorkspaceManifestIdentityV1 { readonly workspaceId: CanonicalWorkspaceId; readonly revision: Revision40; readonly descriptorBlob: Revision40; readonly manifestSha256: Sha256Hex; }
|
||||
export interface RegistryActiveSnapshotV1 { readonly schemaVersion: 1; readonly commit: Revision40; readonly manifestSha256: Sha256Hex; readonly workspaces: readonly RegistryWorkspaceManifestIdentityV1[]; }
|
||||
interface PlanFields { readonly schemaVersion: 1; readonly installationIdentitySha256: Sha256Hex; readonly repositoryIdentitySha256: Sha256Hex; readonly remoteRefIdentitySha256: Sha256Hex; readonly jobArtifactPath: RegistryAddressedJobArtifactPathV1; readonly advertisedTargetCommit: Revision40; readonly immutableTargetRef: `refs/thoth/addressed-runs/${RegistryRunId32}/target`; readonly fetchedTargetCommit: Revision40; readonly targetCommit: Revision40; readonly targetManifestSha256: Sha256Hex; readonly targetWorkspaces: readonly RegistryWorkspaceManifestIdentityV1[]; readonly changedWorkspaceIds: readonly CanonicalWorkspaceId[]; readonly changedSetSha256: Sha256Hex; }
|
||||
export interface RegistryBootstrapAddressedPlanV1 extends PlanFields { readonly operation: "registry_bootstrap"; readonly changedSetRule: "all_target_workspace_ids"; readonly baseCommit: null; readonly baseManifestSha256: null; readonly baseWorkspaces: readonly []; }
|
||||
export interface RegistryPullAddressedPlanV1 extends PlanFields { readonly operation: "registry_pull"; readonly changedSetRule: "symmetric_base_target_workspace_difference"; readonly baseCommit: Revision40; readonly baseManifestSha256: Sha256Hex; readonly baseWorkspaces: readonly RegistryWorkspaceManifestIdentityV1[]; }
|
||||
export type RegistryAddressedPlanV1 = RegistryBootstrapAddressedPlanV1 | RegistryPullAddressedPlanV1;
|
||||
interface StateFields { readonly schemaVersion: 1; readonly runId: RegistryRunId32; readonly requestSha256: Sha256Hex; readonly jobArtifactPath: RegistryAddressedJobArtifactPathV1; readonly phase: RegistryAddressedPublicationPhaseV1; readonly installationIdentitySha256: Sha256Hex; readonly repositoryIdentitySha256: Sha256Hex; readonly remoteRefIdentitySha256: Sha256Hex; readonly advertisedTargetCommit: Revision40 | null; readonly immutableTargetRef: `refs/thoth/addressed-runs/${RegistryRunId32}/target` | null; readonly fetchedTargetCommit: Revision40 | null; readonly targetCommit: Revision40 | null; readonly targetManifestSha256: Sha256Hex | null; readonly targetWorkspaces: readonly RegistryWorkspaceManifestIdentityV1[] | null; readonly changedWorkspaceIds: readonly CanonicalWorkspaceId[] | null; readonly planSha256: Sha256Hex | null; readonly changedSetSha256: Sha256Hex | null; readonly participantsSha256: Sha256Hex | null; readonly synchronizersSha256: Sha256Hex | null; readonly publicationIntentSha256: Sha256Hex | null; readonly publishedActiveStateSha256: Sha256Hex | null; readonly terminalResultSha256: Sha256Hex | null; readonly terminalPublication: "target" | "reconciled_target" | "unchanged" | null; readonly priorStateSha256: Sha256Hex | null; readonly baseCommit: Revision40 | null; readonly baseManifestSha256: Sha256Hex | null; readonly baseWorkspaces: readonly RegistryWorkspaceManifestIdentityV1[]; readonly changedSetRule: "all_target_workspace_ids" | "symmetric_base_target_workspace_difference" | null; }
|
||||
export interface RegistryAddressedSnapshotV1 extends RegistryActiveSnapshotV1 { readonly requestDigest: string; }
|
||||
export interface RegistryInstallationIdentityV1 { readonly installationId: string; readonly digest: string; }
|
||||
export interface RegistryRepositoryIdentityV1 { readonly remote: string; readonly branch: string; readonly head: string; readonly digest: string; }
|
||||
export interface RegistryRemoteIdentityV1 { readonly remote: string; readonly head: string; readonly digest: string; }
|
||||
export interface RegistryAddressedPublicationResultV1 { readonly runId: string; readonly snapshot: RegistryAddressedSnapshotV1; readonly result?: unknown; }
|
||||
export interface RegistryBootstrapAddressedPublicationStateV1 extends StateFields { readonly operation: "registry_bootstrap"; readonly baseCommit: null; readonly baseManifestSha256: null; readonly baseWorkspaces: readonly []; readonly changedSetRule: "all_target_workspace_ids" | null; }
|
||||
export interface RegistryPullAddressedPublicationStateV1 extends StateFields { readonly operation: "registry_pull"; readonly baseCommit: Revision40; readonly baseManifestSha256: Sha256Hex; readonly baseWorkspaces: readonly RegistryWorkspaceManifestIdentityV1[]; readonly changedSetRule: "symmetric_base_target_workspace_difference" | null; }
|
||||
export type RegistryAddressedPublicationStateV1 = RegistryBootstrapAddressedPublicationStateV1 | RegistryPullAddressedPublicationStateV1;
|
||||
export class BorrowedWorkspaceMaintenanceQuiescenceLease { private constructor(readonly workspaceId: CanonicalWorkspaceId) {} static create(id: CanonicalWorkspaceId) { return new BorrowedWorkspaceMaintenanceQuiescenceLease(id); } }
|
||||
export interface AddressedWorkspacePublicationLeaseV1 extends BorrowedOrderedWorkspaceWriterLeaseV1 { readonly quiescence: BorrowedWorkspaceMaintenanceQuiescenceLease; readonly readers: BorrowedWorkspaceSessionReadersExclusiveLockLease; }
|
||||
export interface CapabilityAwareRegistryPublicationParticipant<T> { readonly participantId: string; prepare(plan: RegistryAddressedPlanV1, workspace: AddressedWorkspacePublicationLeaseV1): Promise<T>; reconcile(plan: RegistryAddressedPlanV1, workspace: AddressedWorkspacePublicationLeaseV1, prepared: T, phase: RegistryAddressedPublicationPhaseV1): Promise<void>; }
|
||||
export interface RegistrySynchronizerPreparedV1 { readonly synchronizerId: string; readonly preparedSha256: Sha256Hex; }
|
||||
export interface CapabilityAwareRegistryPublicationSynchronizer { readonly synchronizerId: string; ensureForPublication(plan: RegistryAddressedPlanV1, capabilities: OrderedWorkspaceWriterCapabilitySet, phase: "planned" | "participants_prepared" | "publication_intent_durable" | "target_published"): Promise<RegistrySynchronizerPreparedV1>; }
|
||||
const preparedPublicationRuns = new WeakMap<CapabilityAwareRegistryPublicationLifecycleOwner, Array<{ readonly participant: CapabilityAwareRegistryPublicationParticipant<unknown>; readonly value: unknown; readonly lease: AddressedWorkspacePublicationLeaseV1 }>>();
|
||||
|
||||
export class CapabilityAwareRegistryPublicationLifecycleOwner {
|
||||
constructor() {}
|
||||
async run<T>(input: { readonly plan: RegistryAddressedPlanV1; readonly capabilities: OrderedWorkspaceWriterCapabilitySet; readonly participants: readonly CapabilityAwareRegistryPublicationParticipant<unknown>[]; readonly synchronizers: readonly CapabilityAwareRegistryPublicationSynchronizer[]; readonly action: () => Promise<T>; }): Promise<T> {
|
||||
// Reader gates are recursively nested and held until action returns. Production action
|
||||
// callbacks own all post-publication work (including terminal durability); this owner has
|
||||
// no post-action work which could accidentally outlive the quiescence gates.
|
||||
const prepared: Array<{ readonly participant: CapabilityAwareRegistryPublicationParticipant<unknown>; readonly value: unknown; readonly lease: AddressedWorkspacePublicationLeaseV1 }> = [];
|
||||
const enter = async (index: number): Promise<T> => {
|
||||
if (index >= input.capabilities.workspaceIds.length) {
|
||||
for (const synchronizer of input.synchronizers) await synchronizer.ensureForPublication(input.plan, input.capabilities, "planned");
|
||||
for (const synchronizer of input.synchronizers) await synchronizer.ensureForPublication(input.plan, input.capabilities, "participants_prepared");
|
||||
for (const synchronizer of input.synchronizers) await synchronizer.ensureForPublication(input.plan, input.capabilities, "publication_intent_durable");
|
||||
preparedPublicationRuns.set(this, prepared);
|
||||
try { return await input.action(); }
|
||||
finally { preparedPublicationRuns.delete(this); }
|
||||
}
|
||||
const id = input.capabilities.workspaceIds[index] as CanonicalWorkspaceId;
|
||||
return input.capabilities.forWorkspace(id, ({ rootLease, writerCapability }) =>
|
||||
writerCapability.runUnderSessionReadersExclusive(async readers => {
|
||||
const lease: AddressedWorkspacePublicationLeaseV1 = { workspaceId: id, rootLease, writerCapability, quiescence: BorrowedWorkspaceMaintenanceQuiescenceLease.create(id), readers };
|
||||
for (const participant of input.participants) prepared.push({ participant, value: await participant.prepare(input.plan, lease), lease });
|
||||
return enter(index + 1);
|
||||
}),
|
||||
);
|
||||
};
|
||||
return enter(0);
|
||||
}
|
||||
}
|
||||
|
||||
/** Run participant reconciliation while the lifecycle owner's nested gates are held. */
|
||||
export async function reconcilePreparedPublication(owner: CapabilityAwareRegistryPublicationLifecycleOwner, plan: RegistryAddressedPlanV1): Promise<void> {
|
||||
const prepared = preparedPublicationRuns.get(owner);
|
||||
if (!prepared) throw CONFLICT();
|
||||
for (const item of prepared) await item.participant.reconcile(plan, item.lease, item.value, "target_published");
|
||||
}
|
||||
|
||||
export type RegistryAddressedRequestV1 =
|
||||
| { readonly mode: "create"; readonly operation: "registry_bootstrap"; readonly runId: RegistryRunId32; readonly requestSha256: Sha256Hex; readonly installationIdentitySha256: Sha256Hex; readonly repositoryIdentitySha256: Sha256Hex; readonly expectedBaseCommit: null; readonly remoteRefIdentitySha256: Sha256Hex }
|
||||
| { readonly mode: "resume"; readonly operation: "registry_bootstrap"; readonly runId: RegistryRunId32; readonly requestSha256: Sha256Hex; readonly installationIdentitySha256: Sha256Hex; readonly repositoryIdentitySha256: Sha256Hex; readonly remoteRefIdentitySha256: Sha256Hex }
|
||||
| { readonly mode: "create"; readonly operation: "registry_pull"; readonly runId: RegistryRunId32; readonly requestSha256: Sha256Hex; readonly installationIdentitySha256: Sha256Hex; readonly repositoryIdentitySha256: Sha256Hex; readonly expectedBaseCommit: Revision40; readonly remoteRefIdentitySha256: Sha256Hex }
|
||||
| { readonly mode: "resume"; readonly operation: "registry_pull"; readonly runId: RegistryRunId32; readonly requestSha256: Sha256Hex; readonly installationIdentitySha256: Sha256Hex; readonly repositoryIdentitySha256: Sha256Hex; readonly remoteRefIdentitySha256: Sha256Hex };
|
||||
export interface RegistryBootstrapAddressedResultV1 { readonly operation: "registry_bootstrap"; readonly runId: RegistryRunId32; readonly jobArtifactPath: RegistryAddressedJobArtifactPathV1; readonly plan: RegistryBootstrapAddressedPlanV1; readonly planSha256: Sha256Hex; readonly phase: "terminal_durable"; readonly publication: "target" | "reconciled_target" | "unchanged"; }
|
||||
export interface RegistryPullAddressedResultV1 { readonly operation: "registry_pull"; readonly runId: RegistryRunId32; readonly jobArtifactPath: RegistryAddressedJobArtifactPathV1; readonly plan: RegistryPullAddressedPlanV1; readonly planSha256: Sha256Hex; readonly phase: "terminal_durable"; readonly publication: "target" | "reconciled_target" | "unchanged"; }
|
||||
export type RegistryAddressedResultV1 = RegistryBootstrapAddressedResultV1 | RegistryPullAddressedResultV1;
|
||||
export interface RegistryBootstrapRecoveryIdentityV1 { readonly operation: "registry_bootstrap"; readonly requestSha256: Sha256Hex; readonly installationIdentitySha256: Sha256Hex; readonly repositoryIdentitySha256: Sha256Hex; readonly remoteRefIdentitySha256: Sha256Hex; }
|
||||
export type RegistryEnsureBootstrapAddressedResultV1 = { readonly kind: "already_active"; readonly snapshot: RegistryActiveSnapshotV1 } | { readonly kind: "bootstrap_terminal"; readonly snapshot: RegistryActiveSnapshotV1; readonly result: RegistryBootstrapAddressedResultV1 };
|
||||
export interface RegistryBootstrapRecoveryScanLimitsV1 { readonly maximumDirectoryEntries: 4096; readonly maximumArtifactBytes: 1048576; readonly maximumTotalArtifactBytes: 67108864; }
|
||||
export const REGISTRY_SCAN_LIMITS_V1: RegistryBootstrapRecoveryScanLimitsV1 = Object.freeze({ maximumDirectoryEntries: 4096, maximumArtifactBytes: 1048576, maximumTotalArtifactBytes: 67108864 });
|
||||
const RUN = /^[0-9a-f]{32}$/; const SHA = /^[0-9a-f]{64}$/; const REV = /^[0-9a-f]{40}$/;
|
||||
const PHASES: readonly RegistryAddressedPublicationPhaseV1[] = ["request_claimed", "target_advertised", "target_fetched", "planned", "participants_prepared", "publication_intent_durable", "target_published", "terminal_durable"];
|
||||
const CONFLICT = () => Object.assign(new Error("preprocessing_conflict"), { code: "preprocessing_conflict" });
|
||||
const canonical = (v: unknown): string => JSON.stringify(v, (_k, x) => x && typeof x === "object" && !Array.isArray(x) ? Object.fromEntries(Object.keys(x).sort().map(k => [k, x[k]])) : x);
|
||||
export const registryDigest = (value: unknown): string => createHash("sha256").update(canonical(value)).digest("hex");
|
||||
/** Digest of the active registry identity, independent of snapshot storage details. */
|
||||
export function registryManifestDigest(
|
||||
commit: Revision40,
|
||||
workspaces: readonly RegistryWorkspaceManifestIdentityV1[],
|
||||
): Sha256Hex {
|
||||
const sorted = [...workspaces].sort((left, right) => left.workspaceId < right.workspaceId ? -1 : left.workspaceId > right.workspaceId ? 1 : 0);
|
||||
return registryDigest({ commit, workspaces: sorted }) as Sha256Hex;
|
||||
}
|
||||
export function canonicalBootstrapRequestDigest(request: { readonly kind?: "bootstrap" | "publish"; readonly operation?: RegistryAddressedOperationV1; readonly installation?: unknown; readonly repository?: unknown; readonly remote?: unknown; readonly workspaceIds?: readonly string[] }): string {
|
||||
const operation = request.operation ?? (request.kind === "publish" ? "registry_pull" : "registry_bootstrap");
|
||||
return registryDigest({ schemaVersion: 1, operation, installation: request.installation, repository: request.repository, remote: request.remote, workspaceIds: [...(request.workspaceIds ?? [])].sort() });
|
||||
}
|
||||
export function addressedRunId(): RegistryRunId32 { return randomBytes(16).toString("hex") as RegistryRunId32; }
|
||||
function failIfBadIdentity(s: StateFields, runId: string): void { if (s.schemaVersion !== 1 || s.runId !== runId || !RUN.test(s.runId) || s.jobArtifactPath !== `addressed-publication-jobs/${s.runId}.json` || !SHA.test(s.requestSha256) || !SHA.test(s.installationIdentitySha256) || !SHA.test(s.repositoryIdentitySha256) || !SHA.test(s.remoteRefIdentitySha256) || !PHASES.includes(s.phase)) throw CONFLICT(); }
|
||||
function immutable(s: StateFields): unknown { const { phase: _p, priorStateSha256: _h, ...rest } = s; return rest; }
|
||||
async function fsync(path: string): Promise<void> { const h = await open(path, "r"); try { await h.sync(); } finally { await h.close(); } }
|
||||
async function fsyncParent(path: string): Promise<void> { await fsync(dirname(path)); }
|
||||
function ownerMode(st: Awaited<ReturnType<typeof stat>>, mode: number): boolean { const x = st as any; return x.isFile() && (Number(x.mode) & 0o777) === mode && Number(x.nlink) === 1 && Number(x.uid) === (process.getuid?.() ?? Number(x.uid)); }
|
||||
async function strictRead(path: string, max = REGISTRY_SCAN_LIMITS_V1.maximumArtifactBytes): Promise<{ text: string; identity: { size: number; mtimeMs: number; ino: bigint } }> {
|
||||
const h = await open(path, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
|
||||
try { const before = await h.stat(); if (!ownerMode(before, 0o600) || before.size > max) throw CONFLICT(); const text = await h.readFile({ encoding: "utf8" }); const after = await h.stat(); if (before.ino !== after.ino || before.size !== after.size || text.length > max) throw CONFLICT(); return { text, identity: { size: Number(after.size), mtimeMs: Number(after.mtimeMs), ino: BigInt(after.ino) } }; } finally { await h.close(); }
|
||||
}
|
||||
export class RegistryAddressedPublicationStore {
|
||||
readonly jobsDirectory: string;
|
||||
constructor(readonly root: string) { this.jobsDirectory = join(root, "addressed-publication-jobs"); }
|
||||
private path(runId: string): string { if (!RUN.test(runId)) throw CONFLICT(); return join(this.jobsDirectory, `${runId}.json`); }
|
||||
private async dirs(): Promise<void> { await mkdir(this.jobsDirectory, { recursive: true, mode: 0o700 }); const st = await lstat(this.jobsDirectory); if (st.isSymbolicLink() || !st.isDirectory() || (Number((st as any).mode) & 0o777) !== 0o700 || Number((st as any).uid) !== (process.getuid?.() ?? Number((st as any).uid))) throw CONFLICT(); }
|
||||
private async durable(path: string, value: unknown, exclusive = false): Promise<void> { const name = path.split("/").pop()!; const tmp = join(this.jobsDirectory, `.${name}.tmp`); if (exclusive) { try { await stat(path); throw CONFLICT(); } catch (e) { if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw CONFLICT(); } } const bytes = `${canonical(value)}\n`; try { const h = await open(tmp, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | (fsConstants.O_NOFOLLOW ?? 0), 0o600); try { await h.writeFile(bytes); await h.sync(); } finally { await h.close(); } const st = await stat(tmp); if (!ownerMode(st, 0o600)) throw CONFLICT(); if (!exclusive) { const current = await lstat(path); if (current.isSymbolicLink() || !ownerMode(current, 0o600)) throw CONFLICT(); } await rename(tmp, path); await fsyncParent(path); } catch (e) { await rm(tmp, { force: true }).catch(() => undefined); if (exclusive && (e as NodeJS.ErrnoException)?.code === "EEXIST") throw CONFLICT(); throw CONFLICT(); } }
|
||||
async claim(request: RegistryAddressedRequestV1, base: { readonly commit: Revision40; readonly manifestSha256: Sha256Hex; readonly workspaces: readonly RegistryWorkspaceManifestIdentityV1[] } | null): Promise<RegistryAddressedPublicationStateV1> {
|
||||
await this.dirs();
|
||||
if (!RUN.test(request.runId) || request.mode !== "create") throw CONFLICT();
|
||||
if (request.operation === "registry_pull" && !base) throw CONFLICT();
|
||||
if (request.operation === "registry_pull" && base && request.expectedBaseCommit !== base.commit) throw CONFLICT();
|
||||
if (request.operation === "registry_bootstrap" && (base !== null || request.expectedBaseCommit !== null)) throw CONFLICT();
|
||||
if (request.operation === "registry_pull" && !base) throw CONFLICT();
|
||||
const state = { schemaVersion: 1, runId: request.runId, requestSha256: request.requestSha256, jobArtifactPath: `addressed-publication-jobs/${request.runId}.json`, phase: "request_claimed" as const, operation: request.operation, installationIdentitySha256: request.installationIdentitySha256, repositoryIdentitySha256: request.repositoryIdentitySha256, remoteRefIdentitySha256: request.remoteRefIdentitySha256, advertisedTargetCommit: null, immutableTargetRef: null, fetchedTargetCommit: null, targetCommit: null, targetManifestSha256: null, targetWorkspaces: null, changedWorkspaceIds: null, planSha256: null, changedSetSha256: null, participantsSha256: null, synchronizersSha256: null, publicationIntentSha256: null, publishedActiveStateSha256: null, terminalResultSha256: null, terminalPublication: null, priorStateSha256: null, baseCommit: request.operation === "registry_pull" ? request.expectedBaseCommit : null, baseManifestSha256: base?.manifestSha256 ?? null, baseWorkspaces: base?.workspaces ?? [], changedSetRule: null };
|
||||
await this.durable(this.path(request.runId), state, true);
|
||||
return state as unknown as RegistryAddressedPublicationStateV1;
|
||||
}
|
||||
|
||||
async storeTerminalResult(runId: RegistryRunId32, result: RegistryAddressedResultV1): Promise<void> {
|
||||
const state = await this.read(runId);
|
||||
if (state.phase !== "target_published") throw CONFLICT();
|
||||
// The terminal result is a deterministic projection of the immutable plan. Persist only
|
||||
// its digest in the fixed state shape; recovery reconstructs and verifies the projection.
|
||||
const expected = registryDigest(result) as Sha256Hex;
|
||||
await this.transition(runId, "terminal_durable", { terminalResultSha256: expected, terminalPublication: result.publication });
|
||||
}
|
||||
private planFromState(state: RegistryAddressedPublicationStateV1): RegistryAddressedPlanV1 {
|
||||
if (!state.targetCommit || !state.targetManifestSha256 || !state.targetWorkspaces || !state.changedWorkspaceIds || !state.changedSetSha256 || !state.advertisedTargetCommit || !state.immutableTargetRef || !state.fetchedTargetCommit || !state.planSha256) throw CONFLICT();
|
||||
if (registryManifestDigest(state.targetCommit, state.targetWorkspaces) !== state.targetManifestSha256) throw CONFLICT();
|
||||
if (state.operation === "registry_pull" && (!state.baseCommit || registryManifestDigest(state.baseCommit, state.baseWorkspaces) !== state.baseManifestSha256)) throw CONFLICT();
|
||||
const common = { schemaVersion: 1 as const, installationIdentitySha256: state.installationIdentitySha256,
|
||||
repositoryIdentitySha256: state.repositoryIdentitySha256, remoteRefIdentitySha256: state.remoteRefIdentitySha256,
|
||||
jobArtifactPath: state.jobArtifactPath, advertisedTargetCommit: state.advertisedTargetCommit,
|
||||
immutableTargetRef: state.immutableTargetRef, fetchedTargetCommit: state.fetchedTargetCommit,
|
||||
targetCommit: state.targetCommit, targetManifestSha256: state.targetManifestSha256,
|
||||
targetWorkspaces: state.targetWorkspaces, changedWorkspaceIds: state.changedWorkspaceIds,
|
||||
changedSetSha256: state.changedSetSha256 };
|
||||
const plan = state.operation === "registry_bootstrap"
|
||||
? { ...common, operation: "registry_bootstrap" as const, changedSetRule: "all_target_workspace_ids" as const, baseCommit: null, baseManifestSha256: null, baseWorkspaces: [] as const }
|
||||
: { ...common, operation: "registry_pull" as const, changedSetRule: "symmetric_base_target_workspace_difference" as const,
|
||||
baseCommit: state.baseCommit!, baseManifestSha256: state.baseManifestSha256!, baseWorkspaces: state.baseWorkspaces };
|
||||
if (registryDigest(plan) !== state.planSha256) throw CONFLICT();
|
||||
return plan;
|
||||
}
|
||||
async readTerminalResult(runId: RegistryRunId32, expectedDigest: Sha256Hex): Promise<RegistryAddressedResultV1> {
|
||||
const state = await this.read(runId);
|
||||
if (state.phase !== "terminal_durable" || state.terminalResultSha256 !== expectedDigest) throw CONFLICT();
|
||||
const plan = await this.planFromState(state);
|
||||
const result = { operation: state.operation, runId: state.runId, jobArtifactPath: state.jobArtifactPath,
|
||||
plan, planSha256: state.planSha256!, phase: "terminal_durable" as const, publication: state.terminalPublication! } as RegistryAddressedResultV1;
|
||||
if (registryDigest(result) !== expectedDigest) throw CONFLICT();
|
||||
return result;
|
||||
}
|
||||
|
||||
async read(runId: RegistryRunId32): Promise<RegistryAddressedPublicationStateV1> {
|
||||
const path = this.path(runId); let parsed: unknown;
|
||||
try { parsed = JSON.parse((await strictRead(path)).text); } catch { throw CONFLICT(); }
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw CONFLICT();
|
||||
const s = parsed as StateFields & { operation?: unknown };
|
||||
const keys = Object.keys(parsed).sort();
|
||||
const expected = ["schemaVersion","runId","requestSha256","jobArtifactPath","phase","operation","installationIdentitySha256","repositoryIdentitySha256","remoteRefIdentitySha256","advertisedTargetCommit","immutableTargetRef","fetchedTargetCommit","targetCommit","targetManifestSha256","targetWorkspaces","changedWorkspaceIds","planSha256","changedSetSha256","participantsSha256","synchronizersSha256","publicationIntentSha256","publishedActiveStateSha256","terminalResultSha256","terminalPublication","priorStateSha256","baseCommit","baseManifestSha256","baseWorkspaces","changedSetRule"].sort();
|
||||
if (keys.length !== expected.length || keys.some((v, i) => v !== expected[i])) throw CONFLICT();
|
||||
failIfBadIdentity(s, runId);
|
||||
if (s.priorStateSha256 !== null && !SHA.test(s.priorStateSha256)) throw CONFLICT();
|
||||
if (s.terminalPublication !== null && s.terminalPublication !== "target" && s.terminalPublication !== "reconciled_target" && s.terminalPublication !== "unchanged") throw CONFLICT();
|
||||
if (s.operation !== "registry_bootstrap" && s.operation !== "registry_pull") throw CONFLICT();
|
||||
for (const key of ["advertisedTargetCommit","fetchedTargetCommit","targetCommit"] as const) if (s[key] !== null && !REV.test(s[key])) throw CONFLICT();
|
||||
// The advertisement is the sole target identity. Every later durable phase must
|
||||
// carry exactly that OID; accepting a different fetched or planned OID would make
|
||||
// resume capable of silently publishing an object other than the claimed target.
|
||||
if (s.advertisedTargetCommit !== null && s.fetchedTargetCommit !== null && s.fetchedTargetCommit !== s.advertisedTargetCommit) throw CONFLICT();
|
||||
if (s.advertisedTargetCommit !== null && s.targetCommit !== null && s.targetCommit !== s.advertisedTargetCommit) throw CONFLICT();
|
||||
for (const key of ["requestSha256","installationIdentitySha256","repositoryIdentitySha256","remoteRefIdentitySha256"] as const) if (!SHA.test(s[key])) throw CONFLICT();
|
||||
for (const key of ["targetManifestSha256","changedSetSha256","planSha256","participantsSha256","synchronizersSha256","publicationIntentSha256","publishedActiveStateSha256","terminalResultSha256","priorStateSha256"] as const) if (s[key] !== null && !SHA.test(s[key])) throw CONFLICT();
|
||||
if (s.immutableTargetRef !== null && s.immutableTargetRef !== `refs/thoth/addressed-runs/${runId}/target`) throw CONFLICT();
|
||||
if (s.operation === "registry_bootstrap" && (s.baseCommit !== null || s.baseManifestSha256 !== null || s.baseWorkspaces.length !== 0 || (s.changedSetRule !== null && s.changedSetRule !== "all_target_workspace_ids"))) throw CONFLICT();
|
||||
if (s.operation === "registry_pull" && (s.baseCommit === null || !REV.test(s.baseCommit) || s.baseManifestSha256 === null || !SHA.test(s.baseManifestSha256) || s.changedSetRule !== null && s.changedSetRule !== "symmetric_base_target_workspace_difference")) throw CONFLICT();
|
||||
if (s.targetWorkspaces !== null && !Array.isArray(s.targetWorkspaces) || s.changedWorkspaceIds !== null && !Array.isArray(s.changedWorkspaceIds)) throw CONFLICT();
|
||||
const required: Record<RegistryAddressedPublicationPhaseV1, readonly (keyof StateFields)[]> = {
|
||||
request_claimed: [], target_advertised: ["advertisedTargetCommit", "immutableTargetRef"], target_fetched: ["advertisedTargetCommit", "immutableTargetRef", "fetchedTargetCommit"],
|
||||
planned: ["targetCommit", "targetManifestSha256", "targetWorkspaces", "changedWorkspaceIds", "planSha256", "changedSetSha256", "changedSetRule"], participants_prepared: ["participantsSha256"], publication_intent_durable: ["publicationIntentSha256"], target_published: ["publishedActiveStateSha256"], terminal_durable: ["terminalResultSha256", "terminalPublication"],
|
||||
};
|
||||
for (const phase of PHASES.slice(0, PHASES.indexOf(s.phase) + 1)) for (const key of required[phase]) if (s[key] === null || s[key] === undefined) throw CONFLICT();
|
||||
const introduced: Record<string, readonly string[]> = {
|
||||
request_claimed: [], target_advertised: ["advertisedTargetCommit", "immutableTargetRef"], target_fetched: ["fetchedTargetCommit"],
|
||||
planned: ["targetCommit", "targetManifestSha256", "targetWorkspaces", "changedWorkspaceIds", "planSha256", "changedSetSha256", "changedSetRule"],
|
||||
participants_prepared: ["participantsSha256", "synchronizersSha256"], publication_intent_durable: ["publicationIntentSha256"], target_published: ["publishedActiveStateSha256"], terminal_durable: ["terminalResultSha256", "terminalPublication"],
|
||||
};
|
||||
const currentIndex = PHASES.indexOf(s.phase);
|
||||
let chainPrevious: string | null = null;
|
||||
for (let index = 0; index <= currentIndex; index++) {
|
||||
const candidate: Record<string, unknown> = { ...s, phase: PHASES[index]!, priorStateSha256: chainPrevious };
|
||||
for (const later of PHASES.slice(index + 1)) for (const key of introduced[later]!) candidate[key] = null;
|
||||
if (index === currentIndex && s.priorStateSha256 !== chainPrevious) throw CONFLICT();
|
||||
chainPrevious = registryDigest(candidate);
|
||||
}
|
||||
return s as RegistryAddressedPublicationStateV1;
|
||||
}
|
||||
async transition(runId: RegistryRunId32, phase: RegistryAddressedPublicationPhaseV1, patch: Partial<RegistryAddressedPublicationStateV1> = {}): Promise<RegistryAddressedPublicationStateV1> {
|
||||
const old = await this.read(runId);
|
||||
const from = PHASES.indexOf(old.phase);
|
||||
const to = PHASES.indexOf(phase);
|
||||
if (to !== from + 1) throw CONFLICT();
|
||||
const allowed = new Set(["advertisedTargetCommit", "immutableTargetRef", "fetchedTargetCommit", "targetCommit", "targetManifestSha256", "targetWorkspaces", "changedWorkspaceIds", "planSha256", "changedSetSha256", "participantsSha256", "synchronizersSha256", "publicationIntentSha256", "publishedActiveStateSha256", "terminalResultSha256", "terminalPublication", "changedSetRule", "baseManifestSha256", "baseWorkspaces"]);
|
||||
for (const key of Object.keys(patch)) if (!allowed.has(key)) throw CONFLICT();
|
||||
// All durable facts are append-only. In particular the advertised OID/ref and base
|
||||
// identity may never be replaced during resume or by a competing caller.
|
||||
for (const key of ["advertisedTargetCommit", "immutableTargetRef", "fetchedTargetCommit", "targetCommit", "targetManifestSha256", "targetWorkspaces", "changedWorkspaceIds", "planSha256", "changedSetSha256", "participantsSha256", "synchronizersSha256", "publicationIntentSha256", "publishedActiveStateSha256", "terminalResultSha256", "baseManifestSha256", "baseWorkspaces", "changedSetRule"] as const) {
|
||||
if (key in patch && old[key] !== null && old[key] !== undefined && JSON.stringify(patch[key]) !== JSON.stringify(old[key])) throw CONFLICT();
|
||||
}
|
||||
if ("fetchedTargetCommit" in patch && old.advertisedTargetCommit !== null && patch.fetchedTargetCommit !== old.advertisedTargetCommit) throw CONFLICT();
|
||||
if ("targetCommit" in patch && old.advertisedTargetCommit !== null && patch.targetCommit !== old.advertisedTargetCommit) throw CONFLICT();
|
||||
const phaseFields: Record<RegistryAddressedPublicationPhaseV1, readonly string[]> = {
|
||||
request_claimed: [], target_advertised: ["advertisedTargetCommit", "immutableTargetRef"],
|
||||
target_fetched: ["fetchedTargetCommit"], planned: ["targetCommit", "targetManifestSha256", "targetWorkspaces", "changedWorkspaceIds", "planSha256", "changedSetSha256", "changedSetRule"],
|
||||
participants_prepared: ["participantsSha256", "synchronizersSha256"], publication_intent_durable: ["publicationIntentSha256"],
|
||||
target_published: ["publishedActiveStateSha256"], terminal_durable: ["terminalResultSha256", "terminalPublication"],
|
||||
};
|
||||
if (Object.keys(patch).some(key => !phaseFields[phase].includes(key))) throw CONFLICT();
|
||||
for (const key of ["schemaVersion", "runId", "requestSha256", "jobArtifactPath", "installationIdentitySha256", "repositoryIdentitySha256", "remoteRefIdentitySha256", "operation", "baseCommit"] as const) {
|
||||
if (key in patch && patch[key] !== old[key]) throw CONFLICT();
|
||||
}
|
||||
const next = { ...old, ...patch, phase, priorStateSha256: registryDigest(old) as Sha256Hex } as RegistryAddressedPublicationStateV1;
|
||||
const required: Record<RegistryAddressedPublicationPhaseV1, readonly (keyof StateFields)[]> = {
|
||||
request_claimed: [],
|
||||
target_advertised: ["advertisedTargetCommit", "immutableTargetRef"],
|
||||
target_fetched: ["advertisedTargetCommit", "immutableTargetRef", "fetchedTargetCommit"],
|
||||
planned: ["fetchedTargetCommit", "targetCommit", "targetManifestSha256", "targetWorkspaces", "changedWorkspaceIds", "changedSetSha256", "planSha256", "changedSetRule"],
|
||||
participants_prepared: ["participantsSha256"],
|
||||
publication_intent_durable: ["publicationIntentSha256"],
|
||||
target_published: ["publishedActiveStateSha256"],
|
||||
terminal_durable: ["terminalResultSha256", "terminalPublication"],
|
||||
};
|
||||
for (const key of required[phase]) if (next[key] === null || next[key] === undefined) throw CONFLICT();
|
||||
await this.durable(this.path(runId), next);
|
||||
return next;
|
||||
}
|
||||
snapshotFor(state: RegistryAddressedPublicationStateV1): RegistryActiveSnapshotV1 {
|
||||
if (!state.targetCommit || !state.targetManifestSha256 || !state.targetWorkspaces) throw CONFLICT();
|
||||
const manifestSha256 = registryManifestDigest(state.targetCommit, state.targetWorkspaces);
|
||||
if (manifestSha256 !== state.targetManifestSha256) throw CONFLICT();
|
||||
return { schemaVersion: 1, commit: state.targetCommit, manifestSha256, workspaces: [...state.targetWorkspaces].sort((left, right) => left.workspaceId < right.workspaceId ? -1 : left.workspaceId > right.workspaceId ? 1 : 0) };
|
||||
}
|
||||
async scan(): Promise<readonly RegistryAddressedPublicationStateV1[]> { await this.dirs(); let entries: string[]; try { entries = (await readdir(this.jobsDirectory)).sort(); } catch { throw CONFLICT(); } if (entries.length > REGISTRY_SCAN_LIMITS_V1.maximumDirectoryEntries) throw CONFLICT();
|
||||
for (const name of entries.filter(x => /^\.[0-9a-f]{32}\.json\.tmp$/.test(x))) { const path = join(this.jobsDirectory, name); const st = await lstat(path); if (st.isSymbolicLink() || !ownerMode(st, 0o600) || st.size > REGISTRY_SCAN_LIMITS_V1.maximumArtifactBytes) throw CONFLICT(); await unlink(path); await fsyncParent(path); }
|
||||
entries = (await readdir(this.jobsDirectory)).sort();
|
||||
if (entries.length > REGISTRY_SCAN_LIMITS_V1.maximumDirectoryEntries) throw CONFLICT();
|
||||
const names = entries.filter(x => /^[0-9a-f]{32}\.json$/.test(x));
|
||||
if (names.length !== entries.length) throw CONFLICT();
|
||||
let total = 0;
|
||||
const identities = new Map<string, string>();
|
||||
const out: RegistryAddressedPublicationStateV1[] = [];
|
||||
for (const name of entries) {
|
||||
const st = await lstat(join(this.jobsDirectory, name));
|
||||
if (st.isSymbolicLink() || !ownerMode(st, 0o600) || st.size > REGISTRY_SCAN_LIMITS_V1.maximumArtifactBytes || (total += st.size) > REGISTRY_SCAN_LIMITS_V1.maximumTotalArtifactBytes) throw CONFLICT();
|
||||
identities.set(name, `${String((st as any).dev)}:${String((st as any).ino)}:${String((st as any).size)}:${String((st as any).mtimeMs)}`);
|
||||
}
|
||||
for (const name of names) {
|
||||
const state = await this.read(name.slice(0, -5) as RegistryRunId32);
|
||||
if (state.phase === "terminal_durable") {
|
||||
await this.readTerminalResult(state.runId, state.terminalResultSha256 as Sha256Hex);
|
||||
}
|
||||
out.push(state);
|
||||
}
|
||||
const verify = (await readdir(this.jobsDirectory)).sort();
|
||||
if (verify.length !== entries.length || verify.some((name, i) => name !== entries[i])) throw CONFLICT();
|
||||
for (const name of entries) {
|
||||
const st = await lstat(join(this.jobsDirectory, name));
|
||||
const key = `${String((st as any).dev)}:${String((st as any).ino)}:${String((st as any).size)}:${String((st as any).mtimeMs)}`;
|
||||
if (identities.get(name) !== key) throw CONFLICT();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
async removeSibling(runId: RegistryRunId32): Promise<void> { const path = join(this.jobsDirectory, `.${runId}.json.tmp`); try { const st = await lstat(path); if (st.isSymbolicLink() || !ownerMode(st, 0o600)) throw CONFLICT(); await unlink(path); await fsyncParent(path); } catch (e) { if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw CONFLICT(); } }
|
||||
}
|
||||
+485
-192
@@ -1,6 +1,6 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { lstatSync } from "node:fs";
|
||||
import { mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
||||
import { lstatSync, mkdirSync, realpathSync } from "node:fs";
|
||||
import { mkdir, open as openFile, readdir, readFile, rename, rm, writeFile } from "node:fs/promises"
|
||||
import { isAbsolute, join } from "node:path";
|
||||
import { buildInstallationContract, renderWorkspaceDocs } from "./contracts.js";
|
||||
import {
|
||||
@@ -17,7 +17,10 @@ import {
|
||||
type WorkspaceDescriptor,
|
||||
} from "./schema.js";
|
||||
import type { WorkspaceErrorCode, WorkspaceRegistryConfig } from "./types.js";
|
||||
|
||||
import { VerifiedWorkspaceLockRootLeaseFactory, type Revision40, type CanonicalWorkspaceId } from "./workspace-lock-root-lease.js";
|
||||
import { WorkspaceFsAtV1 } from "./workspace-fs-at.js";
|
||||
import { runUnderOrderedWorkspaceWriterLocks, type OrderedWorkspaceWriterCapabilitySet } from "./preprocessing-state.js";
|
||||
import { RegistryAddressedPublicationStore, addressedRunId, canonicalBootstrapRequestDigest, registryDigest, registryManifestDigest, CapabilityAwareRegistryPublicationLifecycleOwner, reconcilePreparedPublication, type CapabilityAwareRegistryPublicationParticipant, type CapabilityAwareRegistryPublicationSynchronizer, type RegistryAddressedPlanV1, type RegistryPullAddressedPlanV1, type RegistryAddressedRequestV1, type RegistryAddressedResultV1, type RegistryBootstrapRecoveryIdentityV1, type RegistryEnsureBootstrapAddressedResultV1, type RegistryActiveSnapshotV1, type RegistryWorkspaceManifestIdentityV1, type RegistryAddressedPublicationStateV1 } from "./registry-publication.js";
|
||||
export type { GitStatus } from "./git-repository.js";
|
||||
|
||||
export interface WorkspaceRevision {
|
||||
@@ -93,8 +96,8 @@ function safeBlob(blob: string): string {
|
||||
return blob;
|
||||
}
|
||||
|
||||
function digest(contents: string | Buffer): string {
|
||||
return createHash("sha256").update(contents).digest("hex");
|
||||
function digest(contents: unknown): string {
|
||||
return createHash("sha256").update(typeof contents === "string" || Buffer.isBuffer(contents) ? contents : JSON.stringify(contents)).digest("hex");
|
||||
}
|
||||
|
||||
function workspaceError(error: unknown): WorkspaceRegistryError {
|
||||
@@ -102,72 +105,395 @@ function workspaceError(error: unknown): WorkspaceRegistryError {
|
||||
return new WorkspaceRegistryError("workspace_invalid", "Workspace repository content is invalid");
|
||||
}
|
||||
|
||||
/** Immutable canonical workspace snapshots backed by the configured Git checkout. */
|
||||
/** Constructor-only capabilities. Operational context is bound by createWorkspaceRegistry. */
|
||||
export interface WorkspaceRegistryConstructorInput {
|
||||
readonly rootLeaseFactory: VerifiedWorkspaceLockRootLeaseFactory;
|
||||
readonly lifecycleOwner: CapabilityAwareRegistryPublicationLifecycleOwner;
|
||||
readonly participants: readonly CapabilityAwareRegistryPublicationParticipant<unknown>[];
|
||||
readonly synchronizers: readonly CapabilityAwareRegistryPublicationSynchronizer[];
|
||||
}
|
||||
|
||||
export interface WorkspaceRegistryFactoryDependencies {
|
||||
readonly repository?: GitWorkspaceRepository;
|
||||
readonly installationIdentity?: RegistryBootstrapRecoveryIdentityV1;
|
||||
readonly repositoryIdentity?: { readonly digest: string; readonly remote: string; readonly branch: string; readonly head: string };
|
||||
readonly remoteIdentity?: { readonly digest: string; readonly remote: string; readonly head: string };
|
||||
readonly rootLeaseFactory?: VerifiedWorkspaceLockRootLeaseFactory;
|
||||
readonly lifecycleOwner?: CapabilityAwareRegistryPublicationLifecycleOwner;
|
||||
readonly participants?: readonly CapabilityAwareRegistryPublicationParticipant<unknown>[];
|
||||
readonly synchronizers?: readonly CapabilityAwareRegistryPublicationSynchronizer[];
|
||||
}
|
||||
|
||||
interface WorkspaceRegistryContext {
|
||||
readonly config: WorkspaceRegistryConfig;
|
||||
readonly repository: GitWorkspaceRepository;
|
||||
readonly lock: WorkspaceRepositoryLock;
|
||||
readonly rootLeaseFactory: VerifiedWorkspaceLockRootLeaseFactory;
|
||||
readonly lifecycleOwner: CapabilityAwareRegistryPublicationLifecycleOwner;
|
||||
readonly participants: readonly CapabilityAwareRegistryPublicationParticipant<unknown>[];
|
||||
readonly synchronizers: readonly CapabilityAwareRegistryPublicationSynchronizer[];
|
||||
readonly installationIdentity: RegistryBootstrapRecoveryIdentityV1;
|
||||
readonly repositoryIdentity: WorkspaceRegistryFactoryDependencies["repositoryIdentity"];
|
||||
readonly remoteIdentity: WorkspaceRegistryFactoryDependencies["remoteIdentity"];
|
||||
}
|
||||
|
||||
const registryContexts = new WeakMap<WorkspaceRegistry, WorkspaceRegistryContext>();
|
||||
|
||||
function registryContext(registry: WorkspaceRegistry): WorkspaceRegistryContext {
|
||||
const context = registryContexts.get(registry);
|
||||
if (!context) throw new WorkspaceRegistryError("git_unavailable", "Workspace registry is not initialized");
|
||||
return context;
|
||||
}
|
||||
|
||||
export async function acquireWorkspaceSessionReadersShared(registry: WorkspaceRegistry, workspaceId: string) {
|
||||
const context = registryContext(registry);
|
||||
const input = context.rootLeaseFactory.canonicalInput(workspaceId);
|
||||
const root = await context.rootLeaseFactory.acquireOrProvision(input);
|
||||
try { return await root.acquireSessionReadersShared(); }
|
||||
catch (error) { await root.close().catch(() => undefined); throw error; }
|
||||
}
|
||||
|
||||
export function workspaceRegistryRecoveryIdentity(registry: WorkspaceRegistry): RegistryBootstrapRecoveryIdentityV1 {
|
||||
return registryContext(registry).installationIdentity;
|
||||
}
|
||||
|
||||
export function workspaceRegistrySnapshotPath(registry: WorkspaceRegistry, commit: string, id: string): string {
|
||||
const context = registryContext(registry);
|
||||
return join(context.repository.snapshotsPath, safeCommit(commit), `${workspacePath(id).slice("workspaces/".length)}`);
|
||||
}
|
||||
|
||||
export async function reconcileWorkspaceSnapshotRetention(registry: WorkspaceRegistry, referencedCommits: readonly string[]): Promise<void> {
|
||||
return WorkspaceRegistry.reconcileSnapshotRetention(registry, referencedCommits);
|
||||
}
|
||||
|
||||
export function createWorkspaceRegistry(config: WorkspaceRegistryConfig, deps: WorkspaceRegistryFactoryDependencies = {}): WorkspaceRegistry {
|
||||
mkdirSync(config.root, { recursive: true, mode: 0o700 });
|
||||
const repository = deps.repository ?? new GitWorkspaceRepository(config);
|
||||
const sessionsRoot = join(repository.root, "sessions");
|
||||
mkdirSync(sessionsRoot, { recursive: true, mode: 0o700 });
|
||||
const rootLeaseFactory = deps.rootLeaseFactory ?? new VerifiedWorkspaceLockRootLeaseFactory({
|
||||
workspaceFsAt: new WorkspaceFsAtV1(), installationId: config.installationId,
|
||||
sessionsRootFromValidatedInstallationConfig: sessionsRoot, serviceUid: process.getuid?.() ?? 0,
|
||||
provisionedWorkspaceMode: 0o700,
|
||||
});
|
||||
const hash = (value: string) => createHash("sha256").update(value).digest("hex");
|
||||
const volumeIdentity = realpathSync(config.root);
|
||||
const repositoryIdentity = deps.repositoryIdentity ?? { remote: config.remoteUrl ?? "", branch: config.branch, head: "", digest: hash(JSON.stringify({ schemaVersion: 1, volume: volumeIdentity, remote: config.remoteUrl ?? "", branch: config.branch })) };
|
||||
const remoteIdentity = deps.remoteIdentity ?? { remote: config.remoteUrl ?? "", head: "", digest: hash(JSON.stringify({ schemaVersion: 1, remote: config.remoteUrl ?? "", branch: config.branch })) };
|
||||
const request = { kind: "bootstrap" as const, operation: "registry_bootstrap" as const, installation: { installationId: config.installationId, volume: volumeIdentity, digest: hash(JSON.stringify({ schemaVersion: 1, installationId: config.installationId, volume: volumeIdentity })) }, repository: repositoryIdentity, remote: remoteIdentity, workspaceIds: [] as string[] };
|
||||
const installationIdentity = deps.installationIdentity ?? { operation: "registry_bootstrap", requestSha256: canonicalBootstrapRequestDigest(request) as never, installationIdentitySha256: request.installation.digest as never, repositoryIdentitySha256: repositoryIdentity.digest as never, remoteRefIdentitySha256: remoteIdentity.digest as never };
|
||||
const input: WorkspaceRegistryConstructorInput = {
|
||||
rootLeaseFactory,
|
||||
lifecycleOwner: deps.lifecycleOwner ?? new CapabilityAwareRegistryPublicationLifecycleOwner(),
|
||||
participants: deps.participants ?? [],
|
||||
synchronizers: deps.synchronizers ?? [],
|
||||
};
|
||||
const registry = new WorkspaceRegistry(input);
|
||||
registryContexts.set(registry, {
|
||||
config, repository, lock: new WorkspaceRepositoryLock(repository.locksPath), rootLeaseFactory,
|
||||
lifecycleOwner: input.lifecycleOwner, participants: input.participants, synchronizers: input.synchronizers,
|
||||
installationIdentity, repositoryIdentity, remoteIdentity,
|
||||
});
|
||||
// Compatibility bindings are instance-owned and intentionally absent from the
|
||||
// frozen WorkspaceRegistry prototype. New callers receive the separate reader.
|
||||
const reader = workspaceRegistrySnapshotReader(registry);
|
||||
Object.defineProperties(registry, {
|
||||
listRetainedSnapshots: { value: reader.listRetainedSnapshots.bind(reader) },
|
||||
read: { value: reader.read.bind(reader) },
|
||||
acquireSessionRevision: { value: reader.acquireSessionRevision.bind(reader) },
|
||||
readPinned: { value: reader.readPinned.bind(reader) },
|
||||
snapshotReader: { value: reader },
|
||||
});
|
||||
return registry;
|
||||
}
|
||||
|
||||
interface SnapshotReaderOps {
|
||||
listRetainedSnapshots(): Promise<WorkspaceRevision[]>;
|
||||
read(id: string): Promise<{ workspace: WorkspaceDescriptor; revision: WorkspaceRevision }>;
|
||||
acquireSessionRevision(id: string): Promise<SessionRevisionLease>;
|
||||
readPinned(id: string, commit: string): Promise<{ workspace: WorkspaceDescriptor; workspaceConfigPath: string }>;
|
||||
}
|
||||
export class WorkspaceRegistrySnapshotReader {
|
||||
constructor(private readonly ops: SnapshotReaderOps) {}
|
||||
listRetainedSnapshots() { return this.ops.listRetainedSnapshots(); }
|
||||
read(id: string) { return this.ops.read(id); }
|
||||
acquireSessionRevision(id: string) { return this.ops.acquireSessionRevision(id); }
|
||||
readPinned(id: string, commit: string) { return this.ops.readPinned(id, commit); }
|
||||
}
|
||||
const snapshotReaders = new WeakMap<WorkspaceRegistry, WorkspaceRegistrySnapshotReader>();
|
||||
const addressedClaimers = new WeakMap<WorkspaceRegistry, (request: RegistryAddressedRequestV1) => Promise<RegistryAddressedPublicationStateV1>>();
|
||||
|
||||
export function workspaceRegistrySnapshotReader(registry: WorkspaceRegistry): WorkspaceRegistrySnapshotReader {
|
||||
const reader = snapshotReaders.get(registry);
|
||||
if (!reader) throw new WorkspaceRegistryError("git_unavailable", "Workspace registry is not initialized");
|
||||
return reader;
|
||||
}
|
||||
|
||||
/** Package-private author/publication coordinator seam: claim before author Git network. */
|
||||
export async function claimAddressedPublication(registry: WorkspaceRegistry, request: RegistryAddressedRequestV1): Promise<RegistryAddressedPublicationStateV1> {
|
||||
const claim = addressedClaimers.get(registry);
|
||||
if (!claim) throw new WorkspaceRegistryError("git_unavailable", "Workspace registry is not initialized");
|
||||
return claim(request);
|
||||
}
|
||||
|
||||
/** Authoring barrier helpers intentionally stay outside the frozen registry class API. */
|
||||
export async function addressedRunRef(registry: WorkspaceRegistry, runId: string): Promise<string | undefined> {
|
||||
return registryContext(registry).repository.runRef(runId);
|
||||
}
|
||||
export async function advertiseAddressedPublication(registry: WorkspaceRegistry, runId: RegistryAddressedRequestV1["runId"], target: string): Promise<RegistryAddressedPublicationStateV1> {
|
||||
await registryContext(registry).repository.ensureLayout();
|
||||
return registryContext(registry).lock.run(async () => {
|
||||
const store = new RegistryAddressedPublicationStore(registryContext(registry).repository.root);
|
||||
const state = await store.read(runId);
|
||||
if (state.phase !== "request_claimed") {
|
||||
if (state.advertisedTargetCommit !== target) throw new WorkspaceRegistryError("registry_bootstrap_recovery_conflict", "Addressed target conflicts with durable author run");
|
||||
return state;
|
||||
}
|
||||
await registryContext(registry).repository.ensureRunRef(runId, target);
|
||||
return store.transition(runId, "target_advertised", { advertisedTargetCommit: target as Revision40, immutableTargetRef: `refs/thoth/addressed-runs/${runId}/target` });
|
||||
});
|
||||
}
|
||||
|
||||
export class WorkspaceRegistry {
|
||||
private readonly repository: GitWorkspaceRepository;
|
||||
private readonly lock: WorkspaceRepositoryLock;
|
||||
private readonly rootLeaseFactory: VerifiedWorkspaceLockRootLeaseFactory;
|
||||
private readonly lifecycleOwner: CapabilityAwareRegistryPublicationLifecycleOwner;
|
||||
private readonly participants: readonly CapabilityAwareRegistryPublicationParticipant<unknown>[];
|
||||
private readonly synchronizers: readonly CapabilityAwareRegistryPublicationSynchronizer[];
|
||||
|
||||
constructor(private readonly config: WorkspaceRegistryConfig) {
|
||||
this.repository = new GitWorkspaceRepository(config);
|
||||
this.lock = new WorkspaceRepositoryLock(this.repository.locksPath);
|
||||
constructor(input: WorkspaceRegistryConstructorInput) {
|
||||
// The constructor intentionally accepts only the four publication capabilities.
|
||||
// Repository/config state is privately bound by createWorkspaceRegistry.
|
||||
const keys = Object.keys(input);
|
||||
if (keys.length !== 4 || keys.some((key) => !["rootLeaseFactory", "lifecycleOwner", "participants", "synchronizers"].includes(key))) {
|
||||
throw new TypeError("WorkspaceRegistry requires exactly four constructor capabilities");
|
||||
}
|
||||
this.rootLeaseFactory = input.rootLeaseFactory;
|
||||
this.lifecycleOwner = input.lifecycleOwner;
|
||||
this.participants = input.participants;
|
||||
this.synchronizers = input.synchronizers;
|
||||
const reader = new WorkspaceRegistrySnapshotReader({
|
||||
listRetainedSnapshots: () => this.#listRetainedSnapshots(),
|
||||
read: (id) => this.#read(id),
|
||||
acquireSessionRevision: (id) => this.#acquireSessionRevision(id),
|
||||
readPinned: (id, commit) => this.#readPinned(id, commit),
|
||||
});
|
||||
snapshotReaders.set(this, reader);
|
||||
addressedClaimers.set(this, (request) => this.#claimAddressed(request));
|
||||
}
|
||||
|
||||
snapshotPath(commit: string, id: string): string {
|
||||
return join(this.repository.snapshotsPath, safeCommit(commit), `${workspacePath(id).slice("workspaces/".length)}`);
|
||||
async ensureBootstrapAddressed(identity: RegistryBootstrapRecoveryIdentityV1): Promise<RegistryEnsureBootstrapAddressedResultV1> {
|
||||
await registryContext(this).repository.ensureLayout();
|
||||
return registryContext(this).lock.run(async () => this.#ensureBootstrapAddressedLocked(identity));
|
||||
}
|
||||
|
||||
async bootstrap(): Promise<GitStatus> {
|
||||
await this.repository.ensureLayout();
|
||||
return await this.lock.run(async () => {
|
||||
try {
|
||||
const status = await this.repository.bootstrap();
|
||||
await this.activate(status.head!);
|
||||
return status;
|
||||
} catch (error) {
|
||||
return await this.gitFallback(error);
|
||||
}
|
||||
async #ensureBootstrapAddressedLocked(identity: RegistryBootstrapRecoveryIdentityV1): Promise<RegistryEnsureBootstrapAddressedResultV1> {
|
||||
let active: ActiveState | undefined;
|
||||
try { active = await this.#tryActiveState(); }
|
||||
catch { throw new WorkspaceRegistryError("registry_bootstrap_recovery_conflict", "Bootstrap recovery is ambiguous or corrupt"); }
|
||||
if (active) {
|
||||
await this.#writeActiveState(active);
|
||||
return { kind: "already_active", snapshot: this.#addressedSnapshot(active, identity.requestSha256) };
|
||||
}
|
||||
const store = new RegistryAddressedPublicationStore(registryContext(this).repository.root);
|
||||
let jobs: readonly RegistryAddressedPublicationStateV1[];
|
||||
try { jobs = await store.scan(); } catch { throw new WorkspaceRegistryError("registry_bootstrap_recovery_conflict", "Bootstrap recovery is ambiguous or corrupt"); }
|
||||
const nonterminal = jobs.filter(job => job.phase !== "terminal_durable");
|
||||
const matching = nonterminal.filter(job => job.operation === identity.operation && job.requestSha256 === identity.requestSha256 && job.installationIdentitySha256 === identity.installationIdentitySha256 && job.repositoryIdentitySha256 === identity.repositoryIdentitySha256 && job.remoteRefIdentitySha256 === identity.remoteRefIdentitySha256);
|
||||
if (nonterminal.length > 1 || (nonterminal.length === 1 && matching.length !== 1)) throw new WorkspaceRegistryError("registry_bootstrap_recovery_conflict", "Bootstrap recovery is ambiguous or corrupt");
|
||||
let state = matching[0];
|
||||
if (!state) {
|
||||
const request: RegistryAddressedRequestV1 = {
|
||||
mode: "create", operation: "registry_bootstrap", runId: addressedRunId(), requestSha256: identity.requestSha256,
|
||||
installationIdentitySha256: identity.installationIdentitySha256, repositoryIdentitySha256: identity.repositoryIdentitySha256,
|
||||
expectedBaseCommit: null, remoteRefIdentitySha256: identity.remoteRefIdentitySha256,
|
||||
};
|
||||
state = await store.claim(request, null);
|
||||
}
|
||||
const result = await this.#executeAddressedLocked(store, state, identity);
|
||||
const published = await this.#activeState();
|
||||
return { kind: "bootstrap_terminal", result: result as Extract<RegistryAddressedResultV1, { operation: "registry_bootstrap" }>, snapshot: this.#addressedSnapshot(published, identity.requestSha256) };
|
||||
}
|
||||
|
||||
async #claimAddressed(request: RegistryAddressedRequestV1): Promise<RegistryAddressedPublicationStateV1> {
|
||||
if (request.mode !== "create") throw new WorkspaceRegistryError("registry_bootstrap_recovery_conflict", "Only create requests may claim a run");
|
||||
await registryContext(this).repository.ensureLayout();
|
||||
return registryContext(this).lock.run(async () => {
|
||||
const store = new RegistryAddressedPublicationStore(registryContext(this).repository.root);
|
||||
// A retry may receive a fresh proposed run ID, but the request digest is the
|
||||
// idempotency key. Reuse exactly one matching author run, including terminal replays.
|
||||
const existing = (await store.scan()).filter(job => job.operation === request.operation && job.requestSha256 === request.requestSha256 && job.installationIdentitySha256 === request.installationIdentitySha256 && job.repositoryIdentitySha256 === request.repositoryIdentitySha256 && job.remoteRefIdentitySha256 === request.remoteRefIdentitySha256);
|
||||
if (existing.length > 1) throw new WorkspaceRegistryError("registry_bootstrap_recovery_conflict", "Addressed request has multiple durable runs");
|
||||
if (existing.length === 1) return existing[0]!;
|
||||
let base: ActiveState | undefined;
|
||||
if (request.operation === "registry_pull") base = await this.#snapshotState(request.expectedBaseCommit);
|
||||
return store.claim(request, base ? { commit: base.head as Revision40, manifestSha256: this.#manifestDigest(base), workspaces: base.revisions.map(revision => this.#manifestIdentity(revision)) } : null);
|
||||
});
|
||||
}
|
||||
|
||||
async pull(): Promise<GitStatus> {
|
||||
await this.repository.ensureLayout();
|
||||
return await this.lock.run(async () => {
|
||||
try {
|
||||
const status = await this.repository.pull();
|
||||
await this.activate(status.head!);
|
||||
return status;
|
||||
} catch (error) {
|
||||
return await this.gitFallback(error);
|
||||
async publishAddressed(request: RegistryAddressedRequestV1): Promise<RegistryAddressedResultV1> {
|
||||
await registryContext(this).repository.ensureLayout();
|
||||
return registryContext(this).lock.run(async () => {
|
||||
const store = new RegistryAddressedPublicationStore(registryContext(this).repository.root);
|
||||
let state: RegistryAddressedPublicationStateV1;
|
||||
if (request.mode === "resume") {
|
||||
try { state = await store.read(request.runId); }
|
||||
catch { throw new WorkspaceRegistryError("registry_bootstrap_recovery_conflict", "Addressed resume requires an existing durable run"); }
|
||||
} else {
|
||||
let base: ActiveState | undefined;
|
||||
if (request.operation === "registry_pull") base = await this.#snapshotState(request.expectedBaseCommit);
|
||||
// claim is an exclusive O_CREAT|O_EXCL operation: an existing same-ID run is
|
||||
// always a create conflict, never an implicit replay.
|
||||
state = await store.claim(request, base ? {
|
||||
commit: base.head as Revision40, manifestSha256: this.#manifestDigest(base),
|
||||
workspaces: base.revisions.map(revision => this.#manifestIdentity(revision)),
|
||||
} : null);
|
||||
}
|
||||
const identity: RegistryBootstrapRecoveryIdentityV1 = {
|
||||
operation: "registry_bootstrap", requestSha256: request.requestSha256,
|
||||
installationIdentitySha256: request.installationIdentitySha256,
|
||||
repositoryIdentitySha256: request.repositoryIdentitySha256,
|
||||
remoteRefIdentitySha256: request.remoteRefIdentitySha256,
|
||||
};
|
||||
if (state.operation !== request.operation || state.requestSha256 !== request.requestSha256 || state.installationIdentitySha256 !== request.installationIdentitySha256 || state.repositoryIdentitySha256 !== request.repositoryIdentitySha256 || state.remoteRefIdentitySha256 !== request.remoteRefIdentitySha256) throw new WorkspaceRegistryError("registry_bootstrap_recovery_conflict", "Addressed request identity does not match the durable job");
|
||||
if (request.operation === "registry_pull" && request.mode === "create" && state.baseCommit !== request.expectedBaseCommit) throw new WorkspaceRegistryError("registry_bootstrap_recovery_conflict", "Addressed base does not match the durable job");
|
||||
return this.#executeAddressedLocked(store, state, identity);
|
||||
});
|
||||
}
|
||||
|
||||
async list(): Promise<WorkspaceRevision[]> {
|
||||
const active = await this.tryActiveState();
|
||||
if (active) return active.revisions;
|
||||
// A clean installation has no active snapshot until the first registry operation. Keep
|
||||
// this lazy so health/startup remain available when Git is temporarily unreachable, while
|
||||
// still refusing corrupted existing state (tryActiveState throws instead of returning none).
|
||||
await this.bootstrap();
|
||||
return (await this.activeState()).revisions;
|
||||
async #executeAddressedLocked(store: RegistryAddressedPublicationStore, initial: RegistryAddressedPublicationStateV1, identity: RegistryBootstrapRecoveryIdentityV1): Promise<RegistryAddressedResultV1> {
|
||||
let state = initial;
|
||||
if (state.phase === "terminal_durable") {
|
||||
// Terminal state is an authenticated durable result. Replay it without consulting
|
||||
// mutable Git refs or the network; ref drift is only relevant to reconciliation.
|
||||
return store.readTerminalResult(state.runId, state.terminalResultSha256!);
|
||||
}
|
||||
const operation = state.operation;
|
||||
let publication: "target" | "reconciled_target" | "unchanged" = "target";
|
||||
const resumedAtTargetPublished = state.phase === "target_published";
|
||||
let target = state.advertisedTargetCommit ?? state.fetchedTargetCommit;
|
||||
if (state.phase === "request_claimed") {
|
||||
const status = operation === "registry_bootstrap" ? await registryContext(this).repository.bootstrap() : await registryContext(this).repository.pull();
|
||||
target = safeCommit(status.head ?? "") as Revision40;
|
||||
state = await store.transition(state.runId, "target_advertised", { advertisedTargetCommit: target, immutableTargetRef: `refs/thoth/addressed-runs/${state.runId}/target` });
|
||||
} else if (!target) throw new WorkspaceRegistryError("registry_bootstrap_recovery_conflict", "Bootstrap recovery is ambiguous or corrupt");
|
||||
if (state.phase === "target_advertised") {
|
||||
const current = await registryContext(this).repository.runRef(state.runId);
|
||||
if (current !== undefined && current !== target) throw new WorkspaceRegistryError("registry_bootstrap_recovery_conflict", "Bootstrap recovery is ambiguous or corrupt");
|
||||
if (current === undefined) {
|
||||
await registryContext(this).repository.fetchExact(target!);
|
||||
await registryContext(this).repository.ensureRunRef(state.runId, target!);
|
||||
}
|
||||
state = await store.transition(state.runId, "target_fetched", { fetchedTargetCommit: target });
|
||||
}
|
||||
target = state.fetchedTargetCommit ?? target;
|
||||
if (!target) throw new WorkspaceRegistryError("registry_bootstrap_recovery_conflict", "Bootstrap recovery is ambiguous or corrupt");
|
||||
// Do not reacquire participant leases or run side effects when a restart finds a
|
||||
// target-published job whose active pointer has drifted.
|
||||
if (state.phase === "target_published") {
|
||||
const active = await this.#tryActiveState();
|
||||
if (!active || active.head !== target || this.#manifestDigest(active) !== state.targetManifestSha256) throw new WorkspaceRegistryError("registry_bootstrap_recovery_conflict", "Active registry target drifted");
|
||||
}
|
||||
// Once advertised, the run-specific ref is immutable evidence. Every resume after
|
||||
// the fetch barrier revalidates it before reading or publishing any bytes.
|
||||
if (state.phase !== "target_advertised" && state.phase !== "request_claimed") {
|
||||
const pinned = await registryContext(this).repository.runRef(state.runId);
|
||||
if (pinned !== target) throw new WorkspaceRegistryError("registry_bootstrap_recovery_conflict", "Addressed target ref drifted");
|
||||
}
|
||||
if (state.phase === "target_fetched") {
|
||||
await this.#materialize(target);
|
||||
const targetState = await this.#snapshotState(target);
|
||||
const base = operation === "registry_pull" ? await this.#baseState(state.baseCommit) : undefined;
|
||||
const targetWorkspaces = targetState.revisions.map(revision => this.#manifestIdentity(revision));
|
||||
const baseWorkspaces = base?.revisions.map(revision => this.#manifestIdentity(revision)) ?? [];
|
||||
const baseIds = new Set(baseWorkspaces.map(workspace => workspace.workspaceId));
|
||||
const targetIds = new Set(targetWorkspaces.map(workspace => workspace.workspaceId));
|
||||
const changedWorkspaceIds = [...new Set([...baseIds, ...targetIds])].filter(id => !baseIds.has(id) || !targetIds.has(id) || registryDigest(baseWorkspaces.find(x => x.workspaceId === id)) !== registryDigest(targetWorkspaces.find(x => x.workspaceId === id))).sort() as CanonicalWorkspaceId[];
|
||||
const plan = operation === "registry_bootstrap" ? {
|
||||
schemaVersion: 1, operation, installationIdentitySha256: identity.installationIdentitySha256, repositoryIdentitySha256: identity.repositoryIdentitySha256, remoteRefIdentitySha256: identity.remoteRefIdentitySha256,
|
||||
jobArtifactPath: state.jobArtifactPath, advertisedTargetCommit: state.advertisedTargetCommit!, immutableTargetRef: state.immutableTargetRef!, fetchedTargetCommit: target as Revision40, targetCommit: target as Revision40,
|
||||
targetManifestSha256: this.#manifestDigest(targetState), targetWorkspaces, changedWorkspaceIds: targetWorkspaces.map(x => x.workspaceId).sort() as CanonicalWorkspaceId[], changedSetSha256: registryDigest(targetWorkspaces.map(x => x.workspaceId).sort()) as never, changedSetRule: "all_target_workspace_ids" as const, baseCommit: null, baseManifestSha256: null, baseWorkspaces: [] as const,
|
||||
} : {
|
||||
schemaVersion: 1, operation, installationIdentitySha256: identity.installationIdentitySha256, repositoryIdentitySha256: identity.repositoryIdentitySha256, remoteRefIdentitySha256: identity.remoteRefIdentitySha256,
|
||||
jobArtifactPath: state.jobArtifactPath, advertisedTargetCommit: state.advertisedTargetCommit!, immutableTargetRef: state.immutableTargetRef!, fetchedTargetCommit: target as Revision40, targetCommit: target as Revision40,
|
||||
targetManifestSha256: this.#manifestDigest(targetState), targetWorkspaces, changedWorkspaceIds, changedSetSha256: registryDigest(changedWorkspaceIds) as never, changedSetRule: "symmetric_base_target_workspace_difference" as const, baseCommit: state.baseCommit!, baseManifestSha256: this.#manifestDigest(base!), baseWorkspaces,
|
||||
};
|
||||
state = await store.transition(state.runId, "planned", { targetCommit: target as Revision40, targetManifestSha256: plan.targetManifestSha256 as never, targetWorkspaces: plan.targetWorkspaces, changedWorkspaceIds: plan.changedWorkspaceIds, changedSetSha256: plan.changedSetSha256 as never, planSha256: registryDigest(plan) as never, changedSetRule: plan.changedSetRule });
|
||||
}
|
||||
const plan = await this.#planForState(state);
|
||||
const leases: Awaited<ReturnType<VerifiedWorkspaceLockRootLeaseFactory["acquireOrProvision"]>>[] = [];
|
||||
try {
|
||||
for (const id of plan.changedWorkspaceIds) leases.push(await registryContext(this).rootLeaseFactory.acquireOrProvision(registryContext(this).rootLeaseFactory.canonicalInput(id)));
|
||||
} catch (error) {
|
||||
for (const lease of [...leases].reverse()) { try { await lease.close(); } catch {} }
|
||||
throw error;
|
||||
}
|
||||
let output: RegistryAddressedResultV1 | undefined;
|
||||
await runUnderOrderedWorkspaceWriterLocks(leases, async capabilities => {
|
||||
await registryContext(this).lifecycleOwner.run({ plan, capabilities, participants: registryContext(this).participants, synchronizers: registryContext(this).synchronizers, action: async () => {
|
||||
if (state.phase === "planned") {
|
||||
state = await store.transition(state.runId, "participants_prepared", { participantsSha256: registryDigest(registryContext(this).participants.map(participant => participant.participantId)) as never, synchronizersSha256: registryDigest(registryContext(this).synchronizers.map(synchronizer => synchronizer.synchronizerId)) as never });
|
||||
}
|
||||
if (state.phase === "participants_prepared") {
|
||||
state = await store.transition(state.runId, "publication_intent_durable", { publicationIntentSha256: registryDigest({ runId: state.runId, planSha256: state.planSha256 }) as never });
|
||||
}
|
||||
if (state.phase === "publication_intent_durable") {
|
||||
const activeBefore = await this.#tryActiveState();
|
||||
const activeDigest = activeBefore ? this.#manifestDigest(activeBefore) : undefined;
|
||||
const baseAllowed = activeBefore === undefined
|
||||
|| (operation === "registry_pull" && activeBefore.head === state.baseCommit && activeDigest === state.baseManifestSha256);
|
||||
const targetAllowed = activeBefore !== undefined && activeBefore.head === target && activeDigest === state.targetManifestSha256;
|
||||
if (!baseAllowed && !targetAllowed) throw new WorkspaceRegistryError("registry_bootstrap_recovery_conflict", "Active registry identity conflicts with addressed publication");
|
||||
if (targetAllowed) publication = operation === "registry_pull" && state.baseCommit === target ? "unchanged" : "reconciled_target";
|
||||
else await this.#publishSnapshotPointer(target!);
|
||||
state = await store.transition(state.runId, "target_published", { publishedActiveStateSha256: this.#manifestDigest(await this.#activeState()) as never });
|
||||
}
|
||||
if (state.phase === "target_published") {
|
||||
// A restart at this barrier must verify the exact active target before success.
|
||||
const active = await this.#tryActiveState();
|
||||
if (!active || active.head !== target || this.#manifestDigest(active) !== state.targetManifestSha256) throw new WorkspaceRegistryError("registry_bootstrap_recovery_conflict", "Active registry target drifted");
|
||||
if (resumedAtTargetPublished) publication = operation === "registry_pull" && state.baseCommit === target ? "unchanged" : "reconciled_target";
|
||||
for (const synchronizer of registryContext(this).synchronizers) await synchronizer.ensureForPublication(plan, capabilities, "target_published");
|
||||
await reconcilePreparedPublication(registryContext(this).lifecycleOwner, plan);
|
||||
output = { operation: state.operation, runId: state.runId, jobArtifactPath: state.jobArtifactPath, plan, planSha256: registryDigest(plan) as never, phase: "terminal_durable" as const, publication } as RegistryAddressedResultV1;
|
||||
await store.storeTerminalResult(state.runId, output);
|
||||
state = await store.read(state.runId);
|
||||
}
|
||||
return undefined;
|
||||
}});
|
||||
});
|
||||
if (!output) output = await store.readTerminalResult(state.runId, state.terminalResultSha256!);
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* List every intact retained snapshot, current snapshots first. Session discovery and
|
||||
* retention use this rather than only the active revision so removing a workspace from
|
||||
* Git cannot strand a resumable session that still pins one of its older descriptors.
|
||||
*/
|
||||
async listRetainedSnapshots(): Promise<WorkspaceRevision[]> {
|
||||
await this.repository.ensureLayout();
|
||||
return await this.lock.run(async () => {
|
||||
async #planForState(state: RegistryAddressedPublicationStateV1): Promise<RegistryAddressedPlanV1> {
|
||||
if (!state.targetCommit || !state.targetWorkspaces || !state.changedWorkspaceIds || !state.planSha256 || !state.changedSetSha256 || !state.targetManifestSha256 || !state.advertisedTargetCommit || !state.immutableTargetRef || !state.fetchedTargetCommit) throw new WorkspaceRegistryError("registry_bootstrap_recovery_conflict", "Bootstrap recovery is ambiguous or corrupt");
|
||||
if (registryManifestDigest(state.targetCommit, state.targetWorkspaces) !== state.targetManifestSha256 || (state.operation === "registry_pull" && (!state.baseCommit || registryManifestDigest(state.baseCommit, state.baseWorkspaces) !== state.baseManifestSha256))) throw new WorkspaceRegistryError("registry_bootstrap_recovery_conflict", "Bootstrap recovery is ambiguous or corrupt");
|
||||
const common = { schemaVersion: 1 as const, installationIdentitySha256: state.installationIdentitySha256, repositoryIdentitySha256: state.repositoryIdentitySha256, remoteRefIdentitySha256: state.remoteRefIdentitySha256, jobArtifactPath: state.jobArtifactPath, advertisedTargetCommit: state.advertisedTargetCommit, immutableTargetRef: state.immutableTargetRef, fetchedTargetCommit: state.fetchedTargetCommit, targetCommit: state.targetCommit, targetManifestSha256: state.targetManifestSha256, targetWorkspaces: state.targetWorkspaces, changedWorkspaceIds: state.changedWorkspaceIds, changedSetSha256: state.changedSetSha256 };
|
||||
const plan: RegistryAddressedPlanV1 = state.operation === "registry_bootstrap" ? { ...common, operation: "registry_bootstrap", changedSetRule: "all_target_workspace_ids", baseCommit: null, baseManifestSha256: null, baseWorkspaces: [] } : { ...common, operation: "registry_pull", changedSetRule: "symmetric_base_target_workspace_difference", baseCommit: state.baseCommit!, baseManifestSha256: state.baseManifestSha256!, baseWorkspaces: state.baseWorkspaces };
|
||||
if (registryDigest(plan) !== state.planSha256) throw new WorkspaceRegistryError("registry_bootstrap_recovery_conflict", "Bootstrap recovery is ambiguous or corrupt");
|
||||
return plan;
|
||||
}
|
||||
|
||||
#manifestDigest(state: ActiveState): RegistryActiveSnapshotV1["manifestSha256"] {
|
||||
return registryManifestDigest(state.head as Revision40, state.revisions.map(revision => this.#manifestIdentity(revision)));
|
||||
}
|
||||
|
||||
#addressedSnapshot(state: ActiveState, requestDigest: string): RegistryActiveSnapshotV1 {
|
||||
const ids = state.revisions.map(revision => revision.id).sort();
|
||||
return { schemaVersion: 1, commit: state.head as RegistryActiveSnapshotV1["commit"], manifestSha256: this.#manifestDigest(state), workspaces: ids.map(id => { const revision = state.revisions.find(candidate => candidate.id === id)!; return this.#manifestIdentity(revision); }) };
|
||||
}
|
||||
|
||||
async #listRetainedSnapshots(): Promise<WorkspaceRevision[]> {
|
||||
await registryContext(this).repository.ensureLayout();
|
||||
return await registryContext(this).lock.run(async () => {
|
||||
try {
|
||||
const active = await this.activeState();
|
||||
const active = await this.#activeState();
|
||||
const revisions = [...active.revisions];
|
||||
const entries = await readdir(this.repository.snapshotsPath, { withFileTypes: true });
|
||||
const entries = await readdir(registryContext(this).repository.snapshotsPath, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || entry.isSymbolicLink() || !/^[0-9a-f]{40}$/.test(entry.name)) continue;
|
||||
if (entry.name === active.head) continue;
|
||||
const state = await this.snapshotState(entry.name);
|
||||
const state = await this.#snapshotState(entry.name);
|
||||
revisions.push(...state.revisions);
|
||||
}
|
||||
return revisions;
|
||||
@@ -177,8 +503,8 @@ export class WorkspaceRegistry {
|
||||
});
|
||||
}
|
||||
|
||||
async read(id: string): Promise<{ workspace: WorkspaceDescriptor; revision: WorkspaceRevision }> {
|
||||
const state = await this.activeState();
|
||||
async #read(id: string): Promise<{ workspace: WorkspaceDescriptor; revision: WorkspaceRevision }> {
|
||||
const state = await this.#activeState();
|
||||
const revision = state.revisions.find((candidate) => candidate.id === id);
|
||||
if (!revision) throw new WorkspaceRegistryError("workspace_invalid", "Workspace is unavailable");
|
||||
try {
|
||||
@@ -193,10 +519,10 @@ export class WorkspaceRegistry {
|
||||
* Resolve the active revision and create its cross-process retention lease under the same
|
||||
* repository lock. The lease bridges the interval before `session_manifest.yaml` is durable.
|
||||
*/
|
||||
async acquireSessionRevision(id: string): Promise<SessionRevisionLease> {
|
||||
await this.repository.ensureLayout();
|
||||
return await this.lock.run(async () => {
|
||||
const state = await this.activeState();
|
||||
async #acquireSessionRevision(id: string): Promise<SessionRevisionLease> {
|
||||
await registryContext(this).repository.ensureLayout();
|
||||
return await registryContext(this).lock.run(async () => {
|
||||
const state = await this.#activeState();
|
||||
const revision = state.revisions.find((candidate) => candidate.id === id);
|
||||
if (!revision) throw new WorkspaceRegistryError("workspace_invalid", "Workspace is unavailable");
|
||||
let workspace: WorkspaceDescriptor;
|
||||
@@ -216,7 +542,7 @@ export class WorkspaceRegistry {
|
||||
commit: revision.commit,
|
||||
state: "creating",
|
||||
};
|
||||
const path = await this.writeRevisionLease(record, true);
|
||||
const path = await this.#writeRevisionLease(record, true);
|
||||
let localState: RevisionLeaseRecord["state"] | "aborted" = "creating";
|
||||
|
||||
return {
|
||||
@@ -227,14 +553,14 @@ export class WorkspaceRegistry {
|
||||
if (localState === "aborted") throw new WorkspaceRegistryError(
|
||||
"workspace_invalid", "Workspace revision lease is unavailable",
|
||||
);
|
||||
await this.lock.run(async () => {
|
||||
await this.replaceRevisionLease(path, { ...record, state: "persisted" });
|
||||
await registryContext(this).lock.run(async () => {
|
||||
await this.#replaceRevisionLease(path, { ...record, state: "persisted" });
|
||||
});
|
||||
localState = "persisted";
|
||||
},
|
||||
abort: async () => {
|
||||
if (localState !== "creating") return;
|
||||
await this.lock.run(async () => { await rm(path, { force: true }); });
|
||||
await registryContext(this).lock.run(async () => { await rm(path, { force: true }); });
|
||||
localState = "aborted";
|
||||
},
|
||||
};
|
||||
@@ -242,8 +568,8 @@ export class WorkspaceRegistry {
|
||||
}
|
||||
|
||||
/** Read a retained immutable snapshot for a session pinned to a historical commit. */
|
||||
async readPinned(id: string, commit: string): Promise<{ workspace: WorkspaceDescriptor; workspaceConfigPath: string }> {
|
||||
const snapshotPath = this.snapshotPath(safeCommit(commit), id);
|
||||
async #readPinned(id: string, commit: string): Promise<{ workspace: WorkspaceDescriptor; workspaceConfigPath: string }> {
|
||||
const snapshotPath = workspaceRegistrySnapshotPath(this, safeCommit(commit), id);
|
||||
try {
|
||||
const source = await readFile(snapshotPath, "utf8");
|
||||
return {
|
||||
@@ -260,21 +586,23 @@ export class WorkspaceRegistry {
|
||||
* Callers must supply revisions collected from an administrator-visible complete session list;
|
||||
* a partial, per-user list could otherwise remove another user's resumable workspace pin.
|
||||
*/
|
||||
async reconcileSnapshotRetention(referencedCommits: readonly string[]): Promise<void> {
|
||||
static reconcileSnapshotRetention(registry: WorkspaceRegistry, referencedCommits: readonly string[]): Promise<void> { return registry.#reconcileSnapshotRetention(referencedCommits); }
|
||||
|
||||
async #reconcileSnapshotRetention(referencedCommits: readonly string[]): Promise<void> {
|
||||
const manifestReferences = new Set(referencedCommits.map(safeCommit));
|
||||
const retained = new Set(manifestReferences);
|
||||
await this.repository.ensureLayout();
|
||||
await this.lock.run(async () => {
|
||||
const leases = await this.revisionLeases();
|
||||
await registryContext(this).repository.ensureLayout();
|
||||
await registryContext(this).lock.run(async () => {
|
||||
const leases = await this.#revisionLeases();
|
||||
for (const { record } of leases) retained.add(record.commit);
|
||||
retained.add((await this.activeState()).head);
|
||||
const entries = await readdir(this.repository.snapshotsPath, { withFileTypes: true });
|
||||
retained.add((await this.#activeState()).head);
|
||||
const entries = await readdir(registryContext(this).repository.snapshotsPath, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
// Leave staging and unexpected entries untouched: this cleanup only owns finalized,
|
||||
// commit-addressed snapshot directories.
|
||||
if (!entry.isDirectory() || entry.isSymbolicLink() || !/^[0-9a-f]{40}$/.test(entry.name)) continue;
|
||||
if (retained.has(entry.name)) continue;
|
||||
const path = join(this.repository.snapshotsPath, entry.name);
|
||||
const path = join(registryContext(this).repository.snapshotsPath, entry.name);
|
||||
const current = lstatSync(path);
|
||||
if (!current.isDirectory() || current.isSymbolicLink()) continue;
|
||||
await rm(path, { recursive: true, force: true });
|
||||
@@ -289,12 +617,12 @@ export class WorkspaceRegistry {
|
||||
});
|
||||
}
|
||||
|
||||
private revisionLeaseDirectory(): string {
|
||||
return join(this.repository.statePath, "revision-leases");
|
||||
#revisionLeaseDirectory(): string {
|
||||
return join(registryContext(this).repository.statePath, "revision-leases");
|
||||
}
|
||||
|
||||
private async writeRevisionLease(record: RevisionLeaseRecord, exclusive: boolean): Promise<string> {
|
||||
const directory = this.revisionLeaseDirectory();
|
||||
async #writeRevisionLease(record: RevisionLeaseRecord, exclusive: boolean): Promise<string> {
|
||||
const directory = this.#revisionLeaseDirectory();
|
||||
await mkdir(directory, { recursive: true, mode: 0o700 });
|
||||
const path = join(directory, `${record.token}.json`);
|
||||
await writeFile(path, JSON.stringify(record), {
|
||||
@@ -306,7 +634,7 @@ export class WorkspaceRegistry {
|
||||
return path;
|
||||
}
|
||||
|
||||
private async replaceRevisionLease(path: string, record: RevisionLeaseRecord): Promise<void> {
|
||||
async #replaceRevisionLease(path: string, record: RevisionLeaseRecord): Promise<void> {
|
||||
const staging = `${path}.staging-${randomUUID()}`;
|
||||
try {
|
||||
await writeFile(staging, JSON.stringify(record), {
|
||||
@@ -319,8 +647,8 @@ export class WorkspaceRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
private async revisionLeases(): Promise<Array<{ path: string; record: RevisionLeaseRecord }>> {
|
||||
const directory = this.revisionLeaseDirectory();
|
||||
async #revisionLeases(): Promise<Array<{ path: string; record: RevisionLeaseRecord }>> {
|
||||
const directory = this.#revisionLeaseDirectory();
|
||||
await mkdir(directory, { recursive: true, mode: 0o700 });
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
const leases: Array<{ path: string; record: RevisionLeaseRecord }> = [];
|
||||
@@ -354,77 +682,26 @@ export class WorkspaceRegistry {
|
||||
* Publish canonical YAML and derived public documentation as one optimistic Git revision.
|
||||
* The browser never provides paths or generated artifacts; those are derived server-side.
|
||||
*/
|
||||
async publish(request: PublishWorkspaceRequest): Promise<WorkspaceRevision | undefined> {
|
||||
await this.repository.ensureLayout();
|
||||
return await this.lock.run(async () => {
|
||||
const status = await this.repository.pull();
|
||||
await this.activate(status.head!);
|
||||
const current = await this.activeState();
|
||||
const id = request.action === "delete" ? request.id : request.workspace.workspace.id;
|
||||
const existing = current.revisions.find((revision) => revision.id === id);
|
||||
const local = request.action === "delete" ? undefined : request.workspace;
|
||||
|
||||
if (request.baseCommit !== status.head || (
|
||||
request.action !== "create" && existing?.blob !== request.baseBlob
|
||||
)) {
|
||||
const contentOnlyStale = request.action !== "create"
|
||||
&& request.baseCommit !== status.head
|
||||
&& existing?.blob === request.baseBlob;
|
||||
if (contentOnlyStale) {
|
||||
throw new WorkspaceRegistryError("workspace_stale", "Workspace revision is stale");
|
||||
}
|
||||
throw await this.conflictFor(request, status.head!, existing, local);
|
||||
}
|
||||
if (request.action === "create" && existing) throw await this.conflictFor(request, status.head!, existing, local);
|
||||
if (request.action !== "create" && !existing) throw await this.conflictFor(request, status.head!, existing, local);
|
||||
if (request.action !== "delete") {
|
||||
await this.assertEvidenceContext(request.workspace, status.head!);
|
||||
}
|
||||
|
||||
const yamlPath = workspacePath(id);
|
||||
const docPaths = this.documentationPaths(id);
|
||||
if (request.action === "delete") {
|
||||
await this.repository.removeRegistryFile(yamlPath);
|
||||
await this.repository.removeRegistryFile(docPaths.contract);
|
||||
await this.repository.removeRegistryFile(docPaths.readme);
|
||||
} else {
|
||||
const canonical = request.workspace;
|
||||
const source = serializeWorkspaceYaml(canonical);
|
||||
const docs = renderWorkspaceDocs(canonical);
|
||||
await this.repository.writeRegistryFile(yamlPath, source);
|
||||
await this.repository.writeRegistryFile(docPaths.contract, docs.envExample);
|
||||
await this.repository.writeRegistryFile(docPaths.readme, docs.markdown);
|
||||
}
|
||||
|
||||
const next = await this.repository.commitAndPush(
|
||||
[yamlPath, docPaths.contract, docPaths.readme],
|
||||
request.action === "delete" ? `Delete workspace ${id}` : `Publish workspace ${id}`,
|
||||
);
|
||||
await this.activate(next.head!);
|
||||
return (await this.activeState()).revisions.find((revision) => revision.id === id);
|
||||
});
|
||||
}
|
||||
|
||||
private documentationPaths(id: string): { contract: string; readme: string } {
|
||||
#documentationPaths(id: string): { contract: string; readme: string } {
|
||||
workspacePath(id);
|
||||
const directory = `workspace-docs/${id}`;
|
||||
return { contract: `${directory}/contract.env.example`, readme: `${directory}/README.md` };
|
||||
}
|
||||
|
||||
private async conflictFor(
|
||||
async #conflictFor(
|
||||
request: PublishWorkspaceRequest,
|
||||
currentCommit: string,
|
||||
existing: WorkspaceRevision | undefined,
|
||||
local: CanonicalWorkspace | undefined,
|
||||
): Promise<WorkspaceConflictError> {
|
||||
const id = request.action === "delete" ? request.id : request.workspace.workspace.id;
|
||||
const base = await this.readSnapshotCanonical(request.baseCommit, id);
|
||||
const base = await this.#readSnapshotCanonical(request.baseCommit, id);
|
||||
let remote: CanonicalWorkspace | undefined;
|
||||
if (existing) {
|
||||
remote = (await this.read(id)).workspace;
|
||||
remote = (await this.#read(id)).workspace;
|
||||
}
|
||||
return new WorkspaceConflictError(
|
||||
this.changedFields(base, remote),
|
||||
this.#changedFields(base, remote),
|
||||
{ commit: request.baseCommit, ...(request.action === "create" ? {} : { blob: request.baseBlob }) },
|
||||
{ commit: currentCommit, ...(existing ? { blob: existing.blob } : {}) },
|
||||
base,
|
||||
@@ -433,16 +710,16 @@ export class WorkspaceRegistry {
|
||||
);
|
||||
}
|
||||
|
||||
private async readSnapshotCanonical(commit: string, id: string): Promise<CanonicalWorkspace | undefined> {
|
||||
async #readSnapshotCanonical(commit: string, id: string): Promise<CanonicalWorkspace | undefined> {
|
||||
try {
|
||||
const source = await readFile(this.snapshotPath(commit, id), "utf8");
|
||||
const source = await readFile(workspaceRegistrySnapshotPath(this, commit, id), "utf8");
|
||||
return parseWorkspaceYaml(source);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private changedFields(
|
||||
#changedFields(
|
||||
base: unknown,
|
||||
remote: unknown,
|
||||
prefix = "",
|
||||
@@ -456,29 +733,29 @@ export class WorkspaceRegistry {
|
||||
const baseObject = base as Record<string, unknown>;
|
||||
const remoteObject = remote as Record<string, unknown>;
|
||||
const keys = new Set([...Object.keys(baseObject), ...Object.keys(remoteObject)]);
|
||||
return [...keys].flatMap((key) => this.changedFields(
|
||||
return [...keys].flatMap((key) => this.#changedFields(
|
||||
baseObject[key],
|
||||
remoteObject[key],
|
||||
prefix ? `${prefix}.${key}` : key,
|
||||
));
|
||||
}
|
||||
|
||||
private async assertEvidenceContext(workspace: WorkspaceDescriptor, revision: string): Promise<void> {
|
||||
async #assertEvidenceContext(workspace: WorkspaceDescriptor, revision: string): Promise<void> {
|
||||
if (workspace.evidence?.source.type !== "filesystem") return;
|
||||
// P6 owns recursive containment. Here we deliberately validate only the declared root object.
|
||||
await this.repository.assertTreeAtRevision(revision, workspace.evidence.source.uri);
|
||||
await registryContext(this).repository.assertTreeAtRevision(revision, workspace.evidence.source.uri);
|
||||
}
|
||||
|
||||
private async assertSnapshotEvidenceContexts(state: ActiveState): Promise<void> {
|
||||
async #assertSnapshotEvidenceContexts(state: ActiveState): Promise<void> {
|
||||
for (const revision of state.revisions) {
|
||||
const workspace = parseWorkspaceYaml(await readFile(revision.snapshotPath, "utf8"));
|
||||
await this.assertEvidenceContext(workspace, revision.commit);
|
||||
await this.#assertEvidenceContext(workspace, revision.commit);
|
||||
}
|
||||
}
|
||||
|
||||
private async activate(commit: string): Promise<void> {
|
||||
async #materialize(commit: string): Promise<void> {
|
||||
const safeHead = safeCommit(commit);
|
||||
const files = await this.repository.workspacePaths();
|
||||
const files = await registryContext(this).repository.workspacePathsAt(safeHead);
|
||||
|
||||
const snapshots: Array<{
|
||||
id: string;
|
||||
@@ -490,12 +767,12 @@ export class WorkspaceRegistry {
|
||||
try {
|
||||
for (const path of files) {
|
||||
const id = path.slice("workspaces/".length, -".yaml".length);
|
||||
const source = await this.repository.readWorkspace(path);
|
||||
const source = await registryContext(this).repository.readWorkspaceAt(safeHead, path);
|
||||
const workspace = parseWorkspaceYaml(source);
|
||||
if (workspace.workspace.id !== id) {
|
||||
throw new WorkspaceRegistryError("workspace_invalid", "Workspace ID does not match its repository path");
|
||||
}
|
||||
await this.assertEvidenceContext(workspace, safeHead);
|
||||
await this.#assertEvidenceContext(workspace, safeHead);
|
||||
const collection = workspace.semantic_index.vector_store.collection;
|
||||
const owner = collectionOwners.get(collection);
|
||||
if (owner !== undefined) {
|
||||
@@ -508,24 +785,24 @@ export class WorkspaceRegistry {
|
||||
id,
|
||||
source: serializeWorkspaceYaml(workspace),
|
||||
workspace,
|
||||
blob: await this.repository.blob(path),
|
||||
blob: await registryContext(this).repository.blobAt(safeHead, path),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
throw workspaceError(error);
|
||||
}
|
||||
|
||||
const snapshotDirectory = join(this.repository.snapshotsPath, safeHead);
|
||||
const snapshotDirectory = join(registryContext(this).repository.snapshotsPath, safeHead);
|
||||
const revisions = snapshots.map((snapshot) => ({
|
||||
id: snapshot.id,
|
||||
commit: safeHead,
|
||||
blob: snapshot.blob,
|
||||
snapshotPath: this.snapshotPath(safeHead, snapshot.id),
|
||||
snapshotPath: workspaceRegistrySnapshotPath(this, safeHead, snapshot.id),
|
||||
}));
|
||||
if (this.pathExists(snapshotDirectory)) {
|
||||
await this.assertSnapshotIntegrity({ head: safeHead, revisions });
|
||||
if (this.#pathExists(snapshotDirectory)) {
|
||||
await this.#assertSnapshotIntegrity({ head: safeHead, revisions });
|
||||
} else {
|
||||
const staging = join(this.repository.snapshotsPath, `.staging-${randomUUID()}`);
|
||||
const staging = join(registryContext(this).repository.snapshotsPath, `.staging-${randomUUID()}`);
|
||||
await mkdir(staging, { mode: 0o700 });
|
||||
try {
|
||||
const files: Record<string, string> = {};
|
||||
@@ -551,16 +828,28 @@ export class WorkspaceRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
await this.writeActiveState({ head: safeHead, revisions });
|
||||
}
|
||||
|
||||
private async gitFallback(error: unknown): Promise<GitStatus> {
|
||||
async #publishSnapshotPointer(commit: string): Promise<void> {
|
||||
const state = await this.#snapshotState(commit);
|
||||
await this.#writeActiveState({ head: state.head, revisions: state.revisions });
|
||||
}
|
||||
|
||||
async #baseState(commit: Revision40 | null): Promise<ActiveState | undefined> {
|
||||
return commit ? this.#snapshotState(commit) : undefined;
|
||||
}
|
||||
|
||||
#manifestIdentity(revision: WorkspaceRevision): RegistryWorkspaceManifestIdentityV1 {
|
||||
return { workspaceId: revision.id as RegistryWorkspaceManifestIdentityV1["workspaceId"], revision: revision.commit as RegistryWorkspaceManifestIdentityV1["revision"], descriptorBlob: revision.blob as RegistryWorkspaceManifestIdentityV1["descriptorBlob"], manifestSha256: registryDigest(revision) as RegistryWorkspaceManifestIdentityV1["manifestSha256"] };
|
||||
}
|
||||
|
||||
async #gitFallback(error: unknown): Promise<GitStatus> {
|
||||
const safeError = workspaceError(error);
|
||||
if (safeError.code !== "git_unavailable" && safeError.code !== "git_auth_failed") throw safeError;
|
||||
const active = await this.tryActiveState();
|
||||
const active = await this.#tryActiveState();
|
||||
if (!active) throw safeError;
|
||||
return {
|
||||
branch: this.config.branch,
|
||||
branch: registryContext(this).repository.config.branch,
|
||||
head: active.head,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
@@ -569,40 +858,44 @@ export class WorkspaceRegistry {
|
||||
};
|
||||
}
|
||||
|
||||
private async activeState(): Promise<ActiveState> {
|
||||
const active = await this.tryActiveState();
|
||||
async #activeState(): Promise<ActiveState> {
|
||||
const active = await this.#tryActiveState();
|
||||
if (!active) throw new WorkspaceRegistryError("workspace_invalid", "No active workspace snapshot is available");
|
||||
return active;
|
||||
}
|
||||
|
||||
private async tryActiveState(): Promise<ActiveState | undefined> {
|
||||
const file = join(this.repository.statePath, "active.json");
|
||||
async #tryActiveState(): Promise<ActiveState | undefined> {
|
||||
const file = join(registryContext(this).repository.statePath, "active.json");
|
||||
try {
|
||||
const state = this.decodeActiveState(JSON.parse(await readFile(file, "utf8")));
|
||||
await this.assertSnapshotIntegrity(state);
|
||||
const state = this.#decodeActiveState(JSON.parse(await readFile(file, "utf8")));
|
||||
await this.#assertSnapshotIntegrity(state);
|
||||
return state;
|
||||
} catch (error) {
|
||||
if (this.pathIsMissing(file)) return undefined;
|
||||
if (this.#pathIsMissing(file)) return undefined;
|
||||
if (error instanceof WorkspaceRegistryError) throw error;
|
||||
throw new WorkspaceRegistryError("workspace_invalid", "Workspace active snapshot is invalid");
|
||||
}
|
||||
}
|
||||
|
||||
private async writeActiveState(state: ActiveState): Promise<void> {
|
||||
const target = join(this.repository.statePath, "active.json");
|
||||
const staging = join(this.repository.statePath, `.active-${randomUUID()}.json`);
|
||||
async #writeActiveState(state: ActiveState): Promise<void> {
|
||||
const target = join(registryContext(this).repository.statePath, "active.json");
|
||||
const staging = join(registryContext(this).repository.statePath, `.active-${randomUUID()}.json`);
|
||||
await writeFile(staging, JSON.stringify(state), { encoding: "utf8", mode: 0o600 });
|
||||
const handle = await openFile(staging, "r");
|
||||
try { await handle.sync(); } finally { await handle.close(); }
|
||||
await rename(staging, target);
|
||||
const parent = await openFile(registryContext(this).repository.statePath, "r");
|
||||
try { await parent.sync(); } finally { await parent.close(); }
|
||||
}
|
||||
|
||||
private decodeActiveState(value: unknown): ActiveState {
|
||||
const state = this.strictObject(value, ["head", "revisions"]);
|
||||
return this.decodeStateRevisions(state.head, state.revisions);
|
||||
#decodeActiveState(value: unknown): ActiveState {
|
||||
const state = this.#strictObject(value, ["head", "revisions"]);
|
||||
return this.#decodeStateRevisions(state.head, state.revisions);
|
||||
}
|
||||
|
||||
private decodeSnapshotManifest(value: unknown): SnapshotManifest {
|
||||
const manifest = this.strictObject(value, ["head", "revisions", "files"]);
|
||||
const state = this.decodeStateRevisions(manifest.head, manifest.revisions);
|
||||
#decodeSnapshotManifest(value: unknown): SnapshotManifest {
|
||||
const manifest = this.#strictObject(value, ["head", "revisions", "files"]);
|
||||
const state = this.#decodeStateRevisions(manifest.head, manifest.revisions);
|
||||
if (!manifest.files || typeof manifest.files !== "object" || Array.isArray(manifest.files)) {
|
||||
throw new Error("bad manifest files");
|
||||
}
|
||||
@@ -613,12 +906,12 @@ export class WorkspaceRegistry {
|
||||
return { ...state, files: Object.fromEntries(entries) as Record<string, string> };
|
||||
}
|
||||
|
||||
private decodeStateRevisions(headValue: unknown, revisionsValue: unknown): ActiveState {
|
||||
#decodeStateRevisions(headValue: unknown, revisionsValue: unknown): ActiveState {
|
||||
if (typeof headValue !== "string" || !Array.isArray(revisionsValue)) throw new Error("bad state");
|
||||
const head = safeCommit(headValue);
|
||||
const ids = new Set<string>();
|
||||
const revisions = revisionsValue.map((value) => {
|
||||
const revision = this.decodeRevision(value, head);
|
||||
const revision = this.#decodeRevision(value, head);
|
||||
if (ids.has(revision.id)) throw new Error("duplicate revision");
|
||||
ids.add(revision.id);
|
||||
return revision;
|
||||
@@ -626,7 +919,7 @@ export class WorkspaceRegistry {
|
||||
return { head, revisions };
|
||||
}
|
||||
|
||||
private decodeRevision(value: unknown, head: string): WorkspaceRevision {
|
||||
#decodeRevision(value: unknown, head: string): WorkspaceRevision {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("bad revision");
|
||||
const revision = value as Record<string, unknown>;
|
||||
const keys = Object.keys(revision);
|
||||
@@ -655,7 +948,7 @@ export class WorkspaceRegistry {
|
||||
if (
|
||||
commit !== head
|
||||
|| !isAbsolute(snapshotPath)
|
||||
|| snapshotPath !== this.snapshotPath(commit, id)
|
||||
|| snapshotPath !== workspaceRegistrySnapshotPath(this, commit, id)
|
||||
) {
|
||||
throw new Error("bad revision");
|
||||
}
|
||||
@@ -664,7 +957,7 @@ export class WorkspaceRegistry {
|
||||
return { id, commit, blob, snapshotPath };
|
||||
}
|
||||
|
||||
private strictObject(value: unknown, expectedKeys: readonly string[]): Record<string, unknown> {
|
||||
#strictObject(value: unknown, expectedKeys: readonly string[]): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("bad state");
|
||||
const record = value as Record<string, unknown>;
|
||||
const keys = Object.keys(record);
|
||||
@@ -677,33 +970,33 @@ export class WorkspaceRegistry {
|
||||
return record;
|
||||
}
|
||||
|
||||
private async readSnapshotManifest(head: string): Promise<unknown> {
|
||||
const path = join(this.repository.snapshotsPath, head, "snapshot.json");
|
||||
async #readSnapshotManifest(head: string): Promise<unknown> {
|
||||
const path = join(registryContext(this).repository.snapshotsPath, head, "snapshot.json");
|
||||
return JSON.parse(await readFile(path, "utf8"));
|
||||
}
|
||||
|
||||
private async snapshotState(head: string): Promise<ActiveState> {
|
||||
const state = this.decodeSnapshotManifest(await this.readSnapshotManifest(safeCommit(head)));
|
||||
await this.assertSnapshotIntegrity(state);
|
||||
async #snapshotState(head: string): Promise<ActiveState> {
|
||||
const state = this.#decodeSnapshotManifest(await this.#readSnapshotManifest(safeCommit(head)));
|
||||
await this.#assertSnapshotIntegrity(state);
|
||||
return state;
|
||||
}
|
||||
|
||||
private async assertSnapshotIntegrity(state: ActiveState): Promise<void> {
|
||||
const directory = join(this.repository.snapshotsPath, state.head);
|
||||
async #assertSnapshotIntegrity(state: ActiveState): Promise<void> {
|
||||
const directory = join(registryContext(this).repository.snapshotsPath, state.head);
|
||||
try {
|
||||
const manifest = this.decodeSnapshotManifest(await this.readSnapshotManifest(state.head));
|
||||
if (manifest.head !== state.head || !this.sameRevisions(manifest.revisions, state.revisions)) {
|
||||
const manifest = this.#decodeSnapshotManifest(await this.#readSnapshotManifest(state.head));
|
||||
if (manifest.head !== state.head || !this.#sameRevisions(manifest.revisions, state.revisions)) {
|
||||
throw new Error("manifest revisions do not match active state");
|
||||
}
|
||||
await this.assertManifestFiles(directory, manifest.files, this.expectedSnapshotFiles(state));
|
||||
await this.assertSnapshotEvidenceContexts(state);
|
||||
await this.#assertManifestFiles(directory, manifest.files, this.#expectedSnapshotFiles(state));
|
||||
await this.#assertSnapshotEvidenceContexts(state);
|
||||
} catch (error) {
|
||||
if (error instanceof WorkspaceRegistryError) throw error;
|
||||
throw new WorkspaceRegistryError("workspace_invalid", "Workspace snapshot integrity check failed");
|
||||
}
|
||||
}
|
||||
|
||||
private expectedSnapshotFiles(state: ActiveState): string[] {
|
||||
#expectedSnapshotFiles(state: ActiveState): string[] {
|
||||
// P1 snapshots only descriptors and derived public docs. P6 owns revision-pinned
|
||||
// workspace-content materialization and its recursive containment checks.
|
||||
return state.revisions.flatMap((revision) => [
|
||||
@@ -711,7 +1004,7 @@ export class WorkspaceRegistry {
|
||||
]);
|
||||
}
|
||||
|
||||
private async assertManifestFiles(
|
||||
async #assertManifestFiles(
|
||||
directory: string,
|
||||
files: Record<string, string>,
|
||||
expected: string[],
|
||||
@@ -734,7 +1027,7 @@ export class WorkspaceRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
private sameRevisions(left: WorkspaceRevision[], right: WorkspaceRevision[]): boolean {
|
||||
#sameRevisions(left: WorkspaceRevision[], right: WorkspaceRevision[]): boolean {
|
||||
return left.length === right.length && left.every((revision, index) => {
|
||||
const candidate = right[index];
|
||||
return candidate !== undefined
|
||||
@@ -743,7 +1036,7 @@ export class WorkspaceRegistry {
|
||||
});
|
||||
}
|
||||
|
||||
private pathExists(path: string): boolean {
|
||||
#pathExists(path: string): boolean {
|
||||
try {
|
||||
const entry = lstatSync(path);
|
||||
if (!entry.isDirectory() || entry.isSymbolicLink()) {
|
||||
@@ -756,7 +1049,7 @@ export class WorkspaceRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
private pathIsMissing(path: string): boolean {
|
||||
#pathIsMissing(path: string): boolean {
|
||||
try {
|
||||
lstatSync(path);
|
||||
return false;
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawn } from "node:child_process";
|
||||
import { isIP } from "node:net";
|
||||
import { domainToASCII } from "node:url";
|
||||
import {
|
||||
existsSync, lstatSync, readFileSync,
|
||||
} from "node:fs";
|
||||
import { dirname, isAbsolute, join, relative, resolve, normalize } from "node:path";
|
||||
import { parseAllDocuments } from "yaml";
|
||||
import { resolveRuntimeBindings } from "./bindings.js";
|
||||
import {
|
||||
renderRuntimeConfig,
|
||||
type RuntimeInstallationOverlay,
|
||||
type RuntimePaths,
|
||||
type SemanticRuntimeConfig,
|
||||
} from "./runtime-renderer.js";
|
||||
import { parseWorkspaceYaml, serializeWorkspaceYaml, validateOperationalWorkspace, type WorkspaceDescriptor } from "./schema.js";
|
||||
|
||||
export interface RuntimeConfigLease {
|
||||
path: string;
|
||||
manifestPath: string;
|
||||
/** SHA-256 of the exact durable manifest bytes handed to the child. */
|
||||
manifestSha256: string;
|
||||
workspaceId: string;
|
||||
workspaceRevision: string;
|
||||
release(): void;
|
||||
}
|
||||
|
||||
export interface MaintenanceRuntimeInput {
|
||||
/** The immutable registry snapshot. `workspaceConfigPath` is accepted for callers using that name. */
|
||||
snapshotPath?: string;
|
||||
workspaceConfigPath?: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceRuntimeConfigLeaseFactoryInput {
|
||||
dataRoot: string;
|
||||
runtimeSnapshotRoot: string;
|
||||
harnessDir: string;
|
||||
configPath: string;
|
||||
secretRoots?: readonly string[];
|
||||
env?: NodeJS.ProcessEnv;
|
||||
installationOverlay?: RuntimeInstallationOverlay;
|
||||
semanticRuntime: SemanticRuntimeConfig;
|
||||
}
|
||||
|
||||
interface SnapshotIdentity {
|
||||
workspace: WorkspaceDescriptor;
|
||||
workspaceId: string;
|
||||
workspaceRevision: string;
|
||||
revisionContentRoot: string;
|
||||
digest: string;
|
||||
descriptorBlob?: string;
|
||||
descriptorDev: string;
|
||||
descriptorIno: string;
|
||||
}
|
||||
interface PublishedResponse {
|
||||
protocol_version: 1;
|
||||
kind: "publication";
|
||||
workspace_id: string;
|
||||
workspace_revision: string;
|
||||
path: string;
|
||||
manifestPath: string;
|
||||
manifest: string;
|
||||
manifest_sha256: string;
|
||||
dev: number;
|
||||
ino: number;
|
||||
}
|
||||
interface PublishedIdentity {
|
||||
path: string;
|
||||
manifestPath: string;
|
||||
/** SHA-256 of the exact durable manifest bytes handed to the child. */
|
||||
manifestSha256: string;
|
||||
workspaceId: string;
|
||||
workspaceRevision: string;
|
||||
digest: string;
|
||||
content: string;
|
||||
manifest: string;
|
||||
}
|
||||
|
||||
|
||||
const DEFAULT_SEMANTIC_RUNTIME: SemanticRuntimeConfig = {
|
||||
internalQdrantUrl: "http://qdrant:6333", internalEmbeddingUrl: "http://embedding:11434",
|
||||
internalEmbeddingModel: "qwen3-embedding:0.6b", internalEmbeddingDimensions: 1024,
|
||||
};
|
||||
|
||||
/** Normalize the installation HTTP private-host policy once, before rendering. */
|
||||
export function normalizePrivateHostAllowlist(value: string | readonly string[] | undefined): string[] {
|
||||
const values: readonly string[] = value === undefined ? [] : typeof value === "string" ? (value === "" ? [] : value.split(",")) : [...value];
|
||||
if (values.length > 32) throw new Error("HTTP private host allowlist has too many entries");
|
||||
const result: string[] = [];
|
||||
for (const host of values) {
|
||||
if (
|
||||
typeof host !== "string" || host.length === 0 || host.length > 253 || host !== host.toLowerCase()
|
||||
|| host.endsWith(".") || host.includes(" ") || host.includes("\t")
|
||||
|| host.includes("*") || host.includes("/") || host.includes("_")
|
||||
|| host.includes(":") || host.split(".").some((label) => label.startsWith("xn--"))
|
||||
) throw new Error("HTTP private host allowlist contains an invalid hostname");
|
||||
const labels = host.split(".");
|
||||
if (labels.some((label) => label.length === 0 || label.length > 63 || !/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label))) {
|
||||
throw new Error("HTTP private host allowlist contains an invalid hostname");
|
||||
}
|
||||
const ascii = domainToASCII(host);
|
||||
let canonical = "";
|
||||
try { canonical = new URL(`http://${host}`).hostname.toLowerCase().replace(/\.$/, ""); } catch { /* reject below */ }
|
||||
if (!ascii || ascii !== host || canonical !== host || isIP(canonical) !== 0) {
|
||||
throw new Error("HTTP private host allowlist contains an invalid hostname");
|
||||
}
|
||||
if (result.includes(host)) throw new Error("HTTP private host allowlist contains a duplicate hostname");
|
||||
result.push(host);
|
||||
}
|
||||
// The policy is a set. Canonical ordering prevents semantically identical
|
||||
// installation overlays from producing different durable config bytes.
|
||||
return result.sort();
|
||||
}
|
||||
|
||||
|
||||
export const normalizeHttpPrivateHostAllowlist = normalizePrivateHostAllowlist;
|
||||
|
||||
function parseInstallationOverlay(path: string): RuntimeInstallationOverlay {
|
||||
if (!isAbsolute(path) || !existsSync(path)) return {};
|
||||
const docs = parseAllDocuments(readFileSync(path, "utf8"), { uniqueKeys: true });
|
||||
if (docs.length !== 1 || docs[0].errors.length || docs[0].warnings.length) {
|
||||
throw new Error("installation config contains invalid YAML");
|
||||
}
|
||||
const value = docs[0].toJSON();
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("installation config must be a YAML mapping");
|
||||
const source = value as Record<string, unknown>;
|
||||
return {
|
||||
...(source.session_storage === undefined ? {} : { session_storage: source.session_storage }),
|
||||
...(source.profile === undefined ? {} : { profile: source.profile }),
|
||||
};
|
||||
}
|
||||
|
||||
function digest(data: string | Buffer): string { return createHash("sha256").update(data).digest("hex"); }
|
||||
|
||||
|
||||
export interface BoundedHelperOptions {
|
||||
cwd: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
action: string;
|
||||
payload: string;
|
||||
timeoutMs?: number;
|
||||
outputLimit?: number;
|
||||
/** Package-internal seam used by lifecycle tests; production uses node spawn. */
|
||||
spawnProcess?: typeof spawn;
|
||||
}
|
||||
|
||||
/** Run one JSON helper with bounded output and deterministic child cleanup. */
|
||||
export async function runBoundedHelper(
|
||||
executable: string,
|
||||
args: readonly string[],
|
||||
options: BoundedHelperOptions,
|
||||
): Promise<unknown> {
|
||||
const timeoutMs = options.timeoutMs ?? 10_000;
|
||||
const outputLimit = options.outputLimit ?? 16 * 1024 * 1024;
|
||||
const spawnProcess = options.spawnProcess ?? spawn;
|
||||
const child = spawnProcess(executable, [...args], {
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
detached: true,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let closed = false;
|
||||
let finishing = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let closeResolve: () => void = () => undefined;
|
||||
const closePromise = new Promise<void>((resolveClose) => { closeResolve = resolveClose; });
|
||||
|
||||
const killGroup = () => {
|
||||
try {
|
||||
if (child.pid !== undefined && child.pid !== null) process.kill(-child.pid, "SIGKILL");
|
||||
} catch {
|
||||
try { child.kill("SIGKILL"); } catch { /* process already gone */ }
|
||||
}
|
||||
};
|
||||
const destroyStreams = () => {
|
||||
try { child.stdin?.destroy(); } catch { /* already closed */ }
|
||||
try { child.stdout?.destroy(); } catch { /* already closed */ }
|
||||
try { child.stderr?.destroy(); } catch { /* already closed */ }
|
||||
};
|
||||
const awaitClose = async () => {
|
||||
if (closed) return;
|
||||
// A descendant can keep stdio open even after the direct child exits. Streams
|
||||
// are destroyed before this bounded reap wait so the backend cannot hang.
|
||||
await Promise.race([closePromise, new Promise<void>((resolveWait) => setTimeout(resolveWait, 1_000))]);
|
||||
};
|
||||
|
||||
return new Promise<unknown>((resolveResult, rejectResult) => {
|
||||
const finish = async (error?: Error, value?: unknown, terminate = false) => {
|
||||
if (finishing) return;
|
||||
finishing = true;
|
||||
if (timer !== undefined) clearTimeout(timer);
|
||||
if (terminate) killGroup();
|
||||
destroyStreams();
|
||||
await awaitClose();
|
||||
destroyStreams();
|
||||
if (error) rejectResult(error); else resolveResult(value);
|
||||
};
|
||||
const append = (target: "stdout" | "stderr", data: Buffer) => {
|
||||
const next = target === "stdout" ? stdout + data.toString() : stderr + data.toString();
|
||||
if (Buffer.byteLength(next) > outputLimit) {
|
||||
void finish(new Error(`runtime config ${options.action} output exceeded limit`), undefined, true);
|
||||
return;
|
||||
}
|
||||
if (target === "stdout") stdout = next; else stderr = next;
|
||||
};
|
||||
|
||||
// Every stream gets an error listener before any data is written. In
|
||||
// particular, EPIPE from end() must become the same bounded failure path.
|
||||
child.stdin?.on("error", (error) => void finish(error instanceof Error ? error : new Error(String(error)), undefined, true));
|
||||
child.stdout?.on("error", (error) => void finish(error instanceof Error ? error : new Error(String(error)), undefined, true));
|
||||
child.stderr?.on("error", (error) => void finish(error instanceof Error ? error : new Error(String(error)), undefined, true));
|
||||
child.stdout?.on("data", (data: Buffer) => append("stdout", data));
|
||||
child.stderr?.on("data", (data: Buffer) => append("stderr", data));
|
||||
child.on("error", (error) => void finish(error instanceof Error ? error : new Error(String(error)), undefined, true));
|
||||
child.on("close", (code) => {
|
||||
closed = true;
|
||||
closeResolve();
|
||||
if (finishing) return;
|
||||
if (code !== 0) {
|
||||
let detail = stderr.trim() || stdout.trim() || `runtime config ${options.action} failed`;
|
||||
try { detail = (JSON.parse(stdout) as { error?: string }).error ?? detail; } catch { /* preserve detail */ }
|
||||
void finish(new Error(detail));
|
||||
return;
|
||||
}
|
||||
try { void finish(undefined, JSON.parse(stdout)); }
|
||||
catch { void finish(new Error(`runtime config ${options.action} returned invalid JSON`)); }
|
||||
});
|
||||
timer = setTimeout(() => {
|
||||
void finish(new Error(`runtime config ${options.action} timed out`), undefined, true);
|
||||
}, timeoutMs);
|
||||
try {
|
||||
// The listener above is intentionally installed before end(), since a
|
||||
// helper may close its input immediately and emit EPIPE synchronously.
|
||||
child.stdin?.end(options.payload);
|
||||
} catch (error) {
|
||||
void finish(error instanceof Error ? error : new Error(String(error)), undefined, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function sameBinding(left: unknown, right: unknown): boolean {
|
||||
if (!left || typeof left !== "object" || Array.isArray(left) || !right || typeof right !== "object" || Array.isArray(right)) return false;
|
||||
const a = left as Record<string, unknown>; const b = right as Record<string, unknown>;
|
||||
return a.workspace_id === b.workspace_id && a.config_fingerprint === b.config_fingerprint && a.input_fingerprint === b.input_fingerprint;
|
||||
}
|
||||
|
||||
export class WorkspaceRuntimeConfigLeaseFactory {
|
||||
private readonly env: NodeJS.ProcessEnv;
|
||||
private readonly secretRoots: readonly string[];
|
||||
private readonly installation: RuntimeInstallationOverlay;
|
||||
|
||||
constructor(private readonly input: WorkspaceRuntimeConfigLeaseFactoryInput) {
|
||||
if (!isAbsolute(input.dataRoot) || !isAbsolute(input.runtimeSnapshotRoot)) throw new Error("workspace runtime roots must be absolute");
|
||||
// Registry snapshots are produced by WorkspaceRegistry. Never recursively
|
||||
// create this security boundary from a pathname (an ancestor could be swapped).
|
||||
if (!existsSync(input.runtimeSnapshotRoot)) throw new Error("runtime snapshot root is unavailable");
|
||||
this.assertDirectory(input.runtimeSnapshotRoot, "runtime snapshot root");
|
||||
this.env = { ...(input.env ?? process.env) };
|
||||
this.secretRoots = [...(input.secretRoots ?? [])];
|
||||
const configPath = isAbsolute(input.configPath) ? input.configPath : resolve(input.harnessDir, input.configPath);
|
||||
const suppliedAllowlist = input.installationOverlay?.egress?.http_private_host_allowlist;
|
||||
this.installation = {
|
||||
...parseInstallationOverlay(configPath), ...(input.installationOverlay ?? {}),
|
||||
egress: { http_private_host_allowlist: normalizePrivateHostAllowlist(suppliedAllowlist ?? this.env.THT_HTTP_PRIVATE_HOST_ALLOWLIST) },
|
||||
} as RuntimeInstallationOverlay;
|
||||
}
|
||||
|
||||
async acquireSession(snapshotPath: string): Promise<RuntimeConfigLease> { return this.acquire(snapshotPath); }
|
||||
async acquireMaintenance(input: MaintenanceRuntimeInput): Promise<RuntimeConfigLease> {
|
||||
const snapshotPath = input.snapshotPath ?? input.workspaceConfigPath;
|
||||
if (!snapshotPath) throw new Error("maintenance runtime snapshot is required");
|
||||
return this.acquire(snapshotPath);
|
||||
}
|
||||
|
||||
private async acquire(snapshotPath: string): Promise<RuntimeConfigLease> {
|
||||
const snapshot = await this.readSnapshot(snapshotPath);
|
||||
const paths = this.runtimePaths(snapshot.workspaceId);
|
||||
const rendered = renderRuntimeConfig(snapshot.workspace, resolveRuntimeBindings(snapshot.workspace, this.env, this.secretRoots), paths, snapshot, this.installation, this.input.semanticRuntime ?? DEFAULT_SEMANTIC_RUNTIME);
|
||||
const renderedDigest = digest(rendered);
|
||||
const base = {
|
||||
workspace_id: snapshot.workspaceId, workspace_revision: snapshot.workspaceRevision,
|
||||
descriptor_git_blob: snapshot.descriptorBlob!, descriptor_sha256: snapshot.digest,
|
||||
descriptor_dev: snapshot.descriptorDev, descriptor_ino: snapshot.descriptorIno,
|
||||
config_sha256: renderedDigest, config_dwh_binding: await this.computeBinding(rendered),
|
||||
};
|
||||
const result = await this.publishSecure(snapshot.workspaceId, snapshot.workspaceRevision, rendered, base);
|
||||
const identity: PublishedIdentity = {
|
||||
path: result.path, manifestPath: result.manifestPath, workspaceId: snapshot.workspaceId,
|
||||
workspaceRevision: snapshot.workspaceRevision, digest: renderedDigest, content: rendered,
|
||||
manifest: result.manifest, manifestSha256: result.manifest_sha256,
|
||||
};
|
||||
return this.lease(identity);
|
||||
}
|
||||
|
||||
private async helper(action: string, extra: Record<string, unknown>): Promise<unknown> {
|
||||
const python = join(this.input.harnessDir, ".venv", "bin", "python");
|
||||
const modulePath = existsSync(join(this.input.harnessDir, "tht", "runtime_config_lease_io.py"))
|
||||
? join(this.input.harnessDir, "tht", "runtime_config_lease_io.py")
|
||||
: join(process.cwd(), "../harness/tht/runtime_config_lease_io.py");
|
||||
const projectPython = join(dirname(dirname(modulePath)), ".venv", "bin", "python");
|
||||
const executable = existsSync(python) ? python : existsSync(projectPython) ? projectPython : (process.env.PYTHON ?? "python3");
|
||||
const helperArgs = existsSync(modulePath) ? [modulePath] : ["-m", "tht.runtime_config_lease_io"];
|
||||
const env = { ...this.env };
|
||||
// Capability variables are never inherited. Fault seams are only available in
|
||||
// tests, and are copied explicitly rather than forwarding ambient state.
|
||||
for (const key of Object.keys(env)) {
|
||||
if (key.startsWith("THT_RUNTIME_CONFIG_") || key.startsWith("THT_CONFIG_")) delete env[key];
|
||||
}
|
||||
if (env.NODE_ENV === "test") {
|
||||
for (const key of ["THT_RUNTIME_CONFIG_FSYNC_FAIL", "THT_RUNTIME_CONFIG_RENAME_FAIL"]) {
|
||||
const value = this.env[key];
|
||||
if (value !== undefined) env[key] = value;
|
||||
}
|
||||
}
|
||||
env.PYTHONPATH = [this.input.harnessDir, dirname(dirname(modulePath)), env.PYTHONPATH].filter(Boolean).join(":");
|
||||
const payload = JSON.stringify({ protocol_version: 1, action, ...extra });
|
||||
return runBoundedHelper(executable, helperArgs, {
|
||||
cwd: this.input.harnessDir, env, action, payload,
|
||||
});
|
||||
}
|
||||
|
||||
private async computeBinding(content: string): Promise<Record<string, string>> {
|
||||
const value = await this.helper("binding", { config_hex: Buffer.from(content).toString("hex") });
|
||||
if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).sort().join(",") !== "config_fingerprint,input_fingerprint,kind,protocol_version,workspace_id") throw new Error("runtime config binding helper returned malformed output");
|
||||
const record = value as Record<string, unknown>;
|
||||
if (record.protocol_version !== 1 || record.kind !== "binding"
|
||||
|| Object.values(record).some((v) => typeof v !== "string" && typeof v !== "number")) throw new Error("runtime config binding helper returned malformed output");
|
||||
if (typeof record.workspace_id !== "string" || typeof record.config_fingerprint !== "string" || typeof record.input_fingerprint !== "string") throw new Error("runtime config binding helper returned malformed output");
|
||||
return { workspace_id: record.workspace_id, config_fingerprint: record.config_fingerprint, input_fingerprint: record.input_fingerprint };
|
||||
}
|
||||
|
||||
private async publishSecure(workspaceId: string, revision: string, content: string, manifestBase: Record<string, unknown>): Promise<PublishedResponse> {
|
||||
const value = await this.helper("publish", { data_root: this.input.dataRoot, workspace_id: workspaceId,
|
||||
workspace_revision: revision, config_hex: Buffer.from(content).toString("hex"), manifest_base: manifestBase });
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("runtime config publish helper returned malformed output");
|
||||
const result = value as Record<string, unknown>;
|
||||
if (Object.keys(result).sort().join(",") !== "dev,ino,kind,manifest,manifestPath,manifest_sha256,path,protocol_version,workspace_id,workspace_revision") throw new Error("runtime config publish helper returned malformed output");
|
||||
let expectedRoot = resolve(this.input.dataRoot);
|
||||
if (process.platform === "darwin" && (expectedRoot === "/var" || expectedRoot.startsWith("/var/") || expectedRoot === "/tmp" || expectedRoot.startsWith("/tmp/"))) expectedRoot = `/private${expectedRoot}`;
|
||||
const expectedPath = join(expectedRoot, "sessions", workspaceId, "preprocessing", "runtime-config", `${revision}.yaml`);
|
||||
const expectedManifestPath = join(expectedRoot, "sessions", workspaceId, "preprocessing", "runtime-config-manifests", `${revision}.json`);
|
||||
if (result.protocol_version !== 1 || result.kind !== "publication"
|
||||
|| result.workspace_id !== workspaceId || result.workspace_revision !== revision
|
||||
|| typeof result.path !== "string" || result.path !== expectedPath || !isAbsolute(result.path) || normalize(result.path) !== result.path
|
||||
|| typeof result.manifestPath !== "string" || result.manifestPath !== expectedManifestPath || !isAbsolute(result.manifestPath) || normalize(result.manifestPath) !== result.manifestPath
|
||||
|| typeof result.manifest !== "string" || typeof result.manifest_sha256 !== "string" || !/^[0-9a-f]{64}$/.test(result.manifest_sha256)
|
||||
|| digest(result.manifest) !== result.manifest_sha256
|
||||
|| typeof result.dev !== "number" || !Number.isSafeInteger(result.dev) || typeof result.ino !== "number" || !Number.isSafeInteger(result.ino)) throw new Error("runtime config publish helper returned malformed output");
|
||||
let manifest: unknown;
|
||||
try { manifest = JSON.parse(result.manifest); } catch { throw new Error("runtime config publish helper returned malformed output"); }
|
||||
if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) throw new Error("runtime config publish helper returned malformed output");
|
||||
const manifestRecord = manifest as Record<string, unknown>;
|
||||
if (manifestRecord.version !== 1
|
||||
|| manifestRecord.workspace_id !== workspaceId || manifestRecord.workspace_revision !== revision
|
||||
|| manifestRecord.descriptor_git_blob !== manifestBase.descriptor_git_blob
|
||||
|| manifestRecord.descriptor_sha256 !== manifestBase.descriptor_sha256
|
||||
|| manifestRecord.descriptor_dev !== manifestBase.descriptor_dev
|
||||
|| manifestRecord.descriptor_ino !== manifestBase.descriptor_ino
|
||||
|| manifestRecord.config_sha256 !== digest(content)
|
||||
|| !sameBinding(manifestRecord.config_dwh_binding, manifestBase.config_dwh_binding)
|
||||
|| manifestRecord.config_dev !== String(result.dev) || manifestRecord.config_ino !== String(result.ino)) {
|
||||
throw new Error("runtime config publish helper returned malformed output");
|
||||
}
|
||||
return result as unknown as PublishedResponse;
|
||||
}
|
||||
|
||||
private lease(identity: PublishedIdentity): RuntimeConfigLease {
|
||||
let released = false;
|
||||
return { path: identity.path, manifestPath: identity.manifestPath, manifestSha256: identity.manifestSha256, workspaceId: identity.workspaceId, workspaceRevision: identity.workspaceRevision,
|
||||
release: () => { if (released) return; released = true; /* Durable revision-owned state: release only drops our local handle/ref. */ }, };
|
||||
}
|
||||
|
||||
private runtimePaths(workspaceId: string): RuntimePaths {
|
||||
const root = join(this.input.dataRoot, "sessions", workspaceId);
|
||||
return { sessions: join(root, "sessions"), artifacts: join(root, "artifacts"), indexes: join(root, "indexes") };
|
||||
}
|
||||
|
||||
private assertDirectory(path: string, label: string): void {
|
||||
const e = lstatSync(path);
|
||||
if (!e.isDirectory() || e.isSymbolicLink() || e.nlink < 1 || (e.mode & 0o077) !== 0 || e.uid !== process.getuid?.()) throw new Error(`${label} is not trusted`);
|
||||
}
|
||||
|
||||
private async readSnapshot(path: string): Promise<SnapshotIdentity> {
|
||||
if (!isAbsolute(path)) throw new Error("workspace snapshot path must be absolute");
|
||||
const root = resolve(this.input.runtimeSnapshotRoot);
|
||||
const rel = relative(root, path);
|
||||
const match = /^([0-9a-f]{40})\/([a-z][a-z0-9-]{2,62})\.yaml$/.exec(rel);
|
||||
if (!match || rel.startsWith("..") || isAbsolute(rel)) throw new Error("config path is not a trusted runtime snapshot");
|
||||
// The helper is the canonical registry capability boundary. It opens the exact
|
||||
// production snapshot.json and descriptor component-by-component, and MUST prove
|
||||
// the Git commit/blob identity; a pathname-shaped file is never sufficient.
|
||||
const repositoryRoot = join(dirname(root), "repo");
|
||||
const verified = await this.helper("verified-snapshot", {
|
||||
snapshots_root: root, repository_root: repositoryRoot,
|
||||
workspace_revision: match[1], workspace_id: match[2],
|
||||
}) as Record<string, unknown>;
|
||||
const verifiedKeys = ["descriptor_dev", "descriptor_git_blob", "descriptor_ino", "git_source", "kind", "protocol_version", "sha256", "snapshot_path", "source", "workspace_id", "workspace_revision"];
|
||||
if (Object.keys(verified).sort().join(",") !== verifiedKeys.join(",")
|
||||
|| verified.protocol_version !== 1 || verified.kind !== "verified_snapshot"
|
||||
|| verified.workspace_id !== match[2] || verified.workspace_revision !== match[1]
|
||||
|| typeof verified.source !== "string" || typeof verified.git_source !== "string"
|
||||
|| verified.sha256 !== digest(verified.source) || verified.snapshot_path !== path
|
||||
|| !/^\d+$/.test(String(verified.descriptor_dev)) || !/^\d+$/.test(String(verified.descriptor_ino))) {
|
||||
throw new Error("workspace snapshot integrity check failed");
|
||||
}
|
||||
// The registry's production canonicalizer is the sole descriptor equivalence
|
||||
// rule. Raw token containment is not identity: it permits changed values.
|
||||
let workspace: WorkspaceDescriptor;
|
||||
let gitWorkspace: WorkspaceDescriptor;
|
||||
try {
|
||||
workspace = validateOperationalWorkspace(parseWorkspaceYaml(verified.source));
|
||||
gitWorkspace = validateOperationalWorkspace(parseWorkspaceYaml(verified.git_source));
|
||||
if (serializeWorkspaceYaml(workspace) !== serializeWorkspaceYaml(gitWorkspace)
|
||||
|| serializeWorkspaceYaml(workspace) !== verified.source) {
|
||||
throw new Error("canonical descriptor differs from Git");
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`workspace snapshot integrity check failed: ${error instanceof Error ? error.message : "invalid descriptor"}`);
|
||||
}
|
||||
if (workspace.workspace.id !== match[2] || typeof verified.descriptor_git_blob !== "string") {
|
||||
throw new Error("workspace snapshot integrity check failed");
|
||||
}
|
||||
return {
|
||||
workspace, workspaceId: match[2], workspaceRevision: match[1],
|
||||
revisionContentRoot: join(root, match[1]), digest: verified.sha256,
|
||||
descriptorBlob: verified.descriptor_git_blob,
|
||||
descriptorDev: String(verified.descriptor_dev), descriptorIno: String(verified.descriptor_ino),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ export interface RuntimeRenderContext extends RuntimeIdentity {
|
||||
export interface RuntimeInstallationOverlay {
|
||||
session_storage?: unknown;
|
||||
profile?: unknown;
|
||||
egress?: { http_private_host_allowlist: readonly string[] };
|
||||
}
|
||||
|
||||
export interface SemanticRuntimeConfig {
|
||||
@@ -253,8 +254,15 @@ export function renderRuntimeConfig(
|
||||
...(installation.session_storage === undefined
|
||||
? {} : { session_storage: installation.session_storage }),
|
||||
...(installation.profile === undefined ? {} : { profile: installation.profile }),
|
||||
...(installation.egress === undefined ? {} : { egress: installation.egress }),
|
||||
language: descriptor.workspace.language,
|
||||
database,
|
||||
vectors: {
|
||||
type: "qdrant",
|
||||
base_url: semanticRuntime.internalQdrantUrl,
|
||||
collection: descriptor.semantic_index.vector_store.collection,
|
||||
collection_lifecycle: identity ? "require_existing" : "create_if_missing",
|
||||
},
|
||||
resources: {
|
||||
vector: {
|
||||
engine: "qdrant",
|
||||
|
||||
@@ -14,6 +14,6 @@ export type WorkspaceErrorCode =
|
||||
| "workspace_invalid" | "binding_missing" | "workspace_not_activatable"
|
||||
| "workspace_stale" | "workspace_conflict" | "git_unavailable"
|
||||
| "git_auth_failed" | "git_non_fast_forward" | "git_push_rejected"
|
||||
| "connector_unavailable" | "semantic_index_incompatible";
|
||||
| "connector_unavailable" | "semantic_index_incompatible" | "registry_bootstrap_recovery_conflict";
|
||||
|
||||
export type { WorkspaceV3 } from "./schema.js";
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
const fdPath = (fd:number): string => `${process.platform === "linux" ? "/proc/self/fd" : "/dev/fd"}/${fd}`;
|
||||
import { createRequire } from "node:module";
|
||||
import { closeSync, openSync, readSync, writeSync, fsyncSync, fstatSync, readdirSync, renameSync, unlinkSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import type { WorkspaceFsAtBindingV1, NativeWorkspaceFsAtHandleV1, NativeWorkspaceFsAtStatV1, NativeWorkspaceFsAtComponentV1 } from "../native/workspace-fs-at-binding.js";
|
||||
const require = createRequire(import.meta.url);
|
||||
const binding = require("../../native/workspace-fs-at/build/Release/workspace_fs_at.node") as WorkspaceFsAtBindingV1;
|
||||
export interface WorkspaceFsAtStatV1 extends NativeWorkspaceFsAtStatV1 {}
|
||||
export type LockFileName = "writer.lock" | "session-readers.lock";
|
||||
export type WorkspaceFlockKindV1 = "shared" | "exclusive";
|
||||
export type WorkspaceFlockWaitV1 = "blocking" | "nonblocking";
|
||||
const component = (value: string): NativeWorkspaceFsAtComponentV1 => {
|
||||
if (typeof value !== "string" || value.length === 0 || Buffer.byteLength(value, "utf8") > 255 || value !== value.trim() || value === "." || value === ".." || value.includes("/") || value.includes("\\") || value.includes("\0")) throw new Error("invalid path component");
|
||||
return value as NativeWorkspaceFsAtComponentV1;
|
||||
};
|
||||
const normalizeError = (e: unknown): Error => e instanceof Error ? e : new Error(String(e));
|
||||
const rawHandles = new WeakMap<object, NativeWorkspaceFsAtHandleV1>();
|
||||
const borrowing = new WeakMap<object, number>();
|
||||
const live = new WeakMap<object, boolean>();
|
||||
const rawOf = (value: object): NativeWorkspaceFsAtHandleV1 => {
|
||||
if (!rawHandles.has(value) || live.get(value) !== true) throw Object.assign(new Error("workspace descriptor is closed"), { code: "ERR_WORKSPACE_FS_AT_HANDLE_CLOSED" });
|
||||
return rawHandles.get(value)!;
|
||||
};
|
||||
abstract class Owned {
|
||||
constructor(raw: NativeWorkspaceFsAtHandleV1, readonly opened: WorkspaceFsAtStatV1, readonly path: string) { rawHandles.set(this, raw); borrowing.set(this, 0); live.set(this, true); }
|
||||
stat(): WorkspaceFsAtStatV1 { if (live.get(this) !== true) throw Object.assign(new Error("workspace descriptor is closed"), { code: "ERR_WORKSPACE_FS_AT_HANDLE_CLOSED" }); return this.opened; }
|
||||
close(): void { if (live.get(this) !== true) return; if ((borrowing.get(this) ?? 0) !== 0) throw Object.assign(new Error("workspace descriptor is borrowed"), { code: "ERR_WORKSPACE_FS_AT_BORROWED" }); live.set(this, false); binding.close(rawHandles.get(this)!); }
|
||||
}
|
||||
export class OwnedWorkspaceFsAtDirectory extends Owned {}
|
||||
export class OwnedWorkspaceFsAtRegularFile extends Owned {}
|
||||
const wrapDirectory = (result: {handle: NativeWorkspaceFsAtHandleV1; openedStat: WorkspaceFsAtStatV1}, path: string): OwnedWorkspaceFsAtDirectory => {
|
||||
try { if ((result.openedStat.mode & 0o170000) !== 0o040000) throw new Error("not a directory"); return new OwnedWorkspaceFsAtDirectory(result.handle, result.openedStat, path); }
|
||||
catch (e) { try { binding.close(result.handle); } catch {} throw e; }
|
||||
};
|
||||
const wrapFile = (result: {handle: NativeWorkspaceFsAtHandleV1; openedStat: WorkspaceFsAtStatV1}, path = ""): OwnedWorkspaceFsAtRegularFile => {
|
||||
try { const st=result.openedStat; if ((st.mode&0o170000)!==0o100000 || (st.mode&0o7777)!==0o600 || st.uid!==(process.getuid?.()??st.uid) || st.nlink!==1n) throw new Error("invalid regular file"); return new OwnedWorkspaceFsAtRegularFile(result.handle,st,path); }
|
||||
catch (e) { try { binding.close(result.handle); } catch {} throw e; }
|
||||
};
|
||||
const wrapLock = wrapFile;
|
||||
const withBorrowedFd = <T>(value: object, action: (fd:number)=>T): T => {
|
||||
const raw=rawOf(value), n=borrowing.get(value)??0; borrowing.set(value,n+1);
|
||||
try { return action(binding.fdNumberForSynchronousBorrow(raw)); }
|
||||
finally { borrowing.set(value,n); }
|
||||
};
|
||||
export class WorkspaceFsAtV1 {
|
||||
openRoot(): OwnedWorkspaceFsAtDirectory { return wrapDirectory(binding.openat({parent:null,name:"/",kind:"directory",createMode:0}), "/"); }
|
||||
openDirectoryAt(parent: OwnedWorkspaceFsAtDirectory, name: string): OwnedWorkspaceFsAtDirectory { return wrapDirectory(binding.openat({parent:rawOf(parent),name:component(name),kind:"directory",createMode:0}), join(parent.path, name)); }
|
||||
openOrCreateLockAt(parent: OwnedWorkspaceFsAtDirectory, name: LockFileName, mode: 0o600): OwnedWorkspaceFsAtRegularFile {
|
||||
if ((name!=="writer.lock"&&name!=="session-readers.lock")||mode!==0o600) throw new Error("invalid lock");
|
||||
return wrapLock(binding.openat({parent:rawOf(parent),name:component(name),kind:"regular_lock",createMode:0o600}));
|
||||
}
|
||||
mkdirAt(parent: OwnedWorkspaceFsAtDirectory, name:string, mode:0o700):void { binding.mkdirat(rawOf(parent),component(name),mode); }
|
||||
statAtNoFollow(parent:OwnedWorkspaceFsAtDirectory,name:string):WorkspaceFsAtStatV1 { return binding.fstatat(rawOf(parent),component(name)); }
|
||||
fsyncDirectory(directory:OwnedWorkspaceFsAtDirectory):void { binding.fsyncDirectory(rawOf(directory)); }
|
||||
flockOwnedLock(owned:OwnedWorkspaceFsAtRegularFile,kind:WorkspaceFlockKindV1,wait:WorkspaceFlockWaitV1):void { const ext:{flockSync(fd:number,operation:string):void}=require("fs-ext"); withBorrowedFd(owned,fd=>ext.flockSync(fd,kind==="shared"?(wait==="blocking"?"sh":"shnb"):(wait==="blocking"?"ex":"exnb"))); }
|
||||
}
|
||||
export const fsAtInternal = {
|
||||
raw: (v: object) => rawOf(v),
|
||||
withFd: withBorrowedFd,
|
||||
openDirectory(parent: OwnedWorkspaceFsAtDirectory,name:string):OwnedWorkspaceFsAtDirectory { return wrapDirectory(binding.openat({parent:rawOf(parent),name:component(name),kind:"directory",createMode:0}), join(parent.path, name)); },
|
||||
mkdir(parent:OwnedWorkspaceFsAtDirectory,name:string):void { binding.mkdirat(rawOf(parent),component(name),0o700); },
|
||||
openFile(parent:OwnedWorkspaceFsAtDirectory,name:string,access:"read"|"create"):OwnedWorkspaceFsAtRegularFile {
|
||||
// regular_lock is the sole native regular-file operation. Every state file is 0600,
|
||||
// and the no-follow open still occurs relative to the retained directory handle.
|
||||
if(access==="create") { try { binding.fstatat(rawOf(parent),component(name)); throw Object.assign(new Error("exists"),{code:"EEXIST"}); } catch(e) { if((e as NodeJS.ErrnoException).code!=="ENOENT") throw e; } }
|
||||
else binding.fstatat(rawOf(parent), component(name));
|
||||
return wrapFile(binding.openat({parent:rawOf(parent),name:component(name),kind:"regular_lock",createMode:0o600}));
|
||||
},
|
||||
readFile(file:OwnedWorkspaceFsAtRegularFile,max:number):Uint8Array { return withBorrowedFd(file,fd=>{const st=fstatSync(fd);if(st.size>max)throw new Error("file too large");const out=Buffer.alloc(st.size);let off=0;while(off<out.length){const n=readSync(fd,out,off,out.length-off,off);if(n<=0)throw new Error("short read");off+=n;}return out;}); },
|
||||
writeFile(file:OwnedWorkspaceFsAtRegularFile,bytes:Uint8Array):void { withBorrowedFd(file,fd=>{let off=0;while(off<bytes.length){const n=writeSync(fd,bytes,off,bytes.length-off,off);if(n<=0)throw new Error("short write");off+=n;}}); },
|
||||
fsyncFile(file:OwnedWorkspaceFsAtRegularFile):void { withBorrowedFd(file,fd=>fsyncSync(fd)); },
|
||||
rename(parent:OwnedWorkspaceFsAtDirectory,from:string,to:string,replace:boolean):void { if(!replace){ try{ withBorrowedFd(parent,fd=>{ readdirSync(fdPath(fd)); }); }catch{} } withBorrowedFd(parent,fd=>renameSync(`${fdPath(fd)}/${component(from)}`,`${fdPath(fd)}/${component(to)}`)); },
|
||||
unlink(parent:OwnedWorkspaceFsAtDirectory,name:string):void { withBorrowedFd(parent,fd=>unlinkSync(`${fdPath(fd)}/${component(name)}`)); },
|
||||
listDirectory(directory:OwnedWorkspaceFsAtDirectory):readonly string[] { return readdirSync(directory.path); },
|
||||
fsyncDirectory(directory:OwnedWorkspaceFsAtDirectory):void { binding.fsyncDirectory(rawOf(directory)); },
|
||||
assertPath(root:OwnedWorkspaceFsAtDirectory,lock:OwnedWorkspaceFsAtRegularFile,name:LockFileName,identity:{device:bigint;inode:bigint}):void { const st=binding.fstatat(rawOf(root),component(name)), opened=lock.stat(); if(st.device!==opened.device||st.inode!==opened.inode||st.mode!==opened.mode||st.uid!==opened.uid||st.nlink!==opened.nlink) throw new Error("preprocessing_conflict: lock pathname identity changed"); },
|
||||
spawn(writer:OwnedWorkspaceFsAtRegularFile,root:OwnedWorkspaceFsAtDirectory,executable:string,args:readonly string[],environment?:NodeJS.ProcessEnv):Promise<{exitCode:number;stdout:Uint8Array;stderr:Uint8Array}> {
|
||||
return withBorrowedFd(writer,wfd=>withBorrowedFd(root,rfd=>new Promise((resolve,reject)=>{const child=spawn(executable,[...args],{stdio:["ignore","pipe","pipe",wfd,rfd],env:{...(environment??process.env),THOTH_WORKSPACE_CAPABILITY_REQUIRED:"1"}});const out:Buffer[]=[];const err:Buffer[]=[];child.stdout?.on("data",(x:Buffer)=>out.push(x));child.stderr?.on("data",(x:Buffer)=>err.push(x));child.once("error",reject);child.once("close",code=>resolve({exitCode:code??1,stdout:Buffer.concat(out),stderr:Buffer.concat(err)}));})));
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { WorkspaceFsAtV1, OwnedWorkspaceFsAtDirectory, OwnedWorkspaceFsAtRegularFile } from "./workspace-fs-at-internal.js";
|
||||
export type { WorkspaceFsAtStatV1, LockFileName, WorkspaceFlockKindV1, WorkspaceFlockWaitV1 } from "./workspace-fs-at-internal.js";
|
||||
@@ -0,0 +1,22 @@
|
||||
import { WorkspaceFsAtV1, OwnedWorkspaceFsAtDirectory, OwnedWorkspaceFsAtRegularFile, fsAtInternal, type WorkspaceFlockKindV1, type WorkspaceFlockWaitV1 } from "./workspace-fs-at-internal.js";
|
||||
export type WorkspaceLockRootIdentityRuntime = { readonly device: bigint; readonly inode: bigint; readonly workspaceId: string };
|
||||
export type InternalLock = { assertPath(): void; close(): void; flock(kind: WorkspaceFlockKindV1, wait: WorkspaceFlockWaitV1): void; spawn(root: OwnedWorkspaceFsAtDirectory, executable:string,args:readonly string[],environment?:NodeJS.ProcessEnv):Promise<{exitCode:number;stdout:Uint8Array;stderr:Uint8Array}> };
|
||||
export type RootState = { fs:WorkspaceFsAtV1; parent:OwnedWorkspaceFsAtDirectory; root:OwnedWorkspaceFsAtDirectory; serviceUid:number; owner:symbol; identity:WorkspaceLockRootIdentityRuntime; assertParent:()=>void; live:boolean };
|
||||
export const rootState = new WeakMap<object,RootState>();
|
||||
const writerAdmissions = new Set<string>();
|
||||
class RootLock implements InternalLock {
|
||||
private live=true;
|
||||
constructor(private readonly root:OwnedWorkspaceFsAtDirectory,private readonly lock:OwnedWorkspaceFsAtRegularFile,private readonly name:"writer.lock"|"session-readers.lock",private readonly identity:WorkspaceLockRootIdentityRuntime,private readonly fs:WorkspaceFsAtV1,private readonly admissionKey?:string){}
|
||||
assertPath():void { if(!this.live) throw new Error("preprocessing_conflict"); fsAtInternal.assertPath(this.root,this.lock,this.name,this.identity); }
|
||||
flock(kind:WorkspaceFlockKindV1,wait:WorkspaceFlockWaitV1):void { this.assertPath(); this.fs.flockOwnedLock(this.lock,kind,wait); this.assertPath(); }
|
||||
spawn(root:OwnedWorkspaceFsAtDirectory,executable:string,args:readonly string[],environment?:NodeJS.ProcessEnv){ this.assertPath(); return fsAtInternal.spawn(this.lock,root,executable,args,environment); }
|
||||
close():void { if(!this.live)return; this.live=false; try{this.lock.close();}finally{if(this.admissionKey)writerAdmissions.delete(this.admissionKey);} }
|
||||
}
|
||||
const conflict=()=>Object.assign(new Error("preprocessing_conflict"),{name:"PreprocessingConflictError"});
|
||||
export const rootLeaseInternals = {
|
||||
assertLive(root:object):void { const s=rootState.get(root); if(!s||!s.live)throw conflict(); s.assertParent(); const retained=s.root.stat(); const named=s.fs.statAtNoFollow(s.parent,s.identity.workspaceId as string); if(retained.device!==s.identity.device||retained.inode!==s.identity.inode||named.device!==s.identity.device||named.inode!==s.identity.inode)throw conflict(); },
|
||||
withRoot<T>(root:object,action:(fs:WorkspaceFsAtV1,directory:OwnedWorkspaceFsAtDirectory)=>T):T { this.assertLive(root); const s=rootState.get(root);if(!s)throw conflict();return action(s.fs,s.root); },
|
||||
acquireShared(root:object):InternalLock { this.assertLive(root); const s=rootState.get(root)!; const owned=s.fs.openOrCreateLockAt(s.root,"session-readers.lock",0o600); const lock=new RootLock(s.root,owned,"session-readers.lock",s.identity,s.fs); try{lock.flock("shared","nonblocking");return lock;}catch{try{lock.close();}catch{}throw conflict();}},
|
||||
async acquireWriter(root:object):Promise<InternalLock>{this.assertLive(root);const s=rootState.get(root)!;const key=`${s.identity.device}:${s.identity.inode}`;if(writerAdmissions.has(key))throw conflict();const owned=s.fs.openOrCreateLockAt(s.root,"writer.lock",0o600);const lock=new RootLock(s.root,owned,"writer.lock",s.identity,s.fs,key);try{lock.flock("exclusive","nonblocking");writerAdmissions.add(key);return lock;}catch{try{lock.close();}catch{}throw conflict();}},
|
||||
async acquireReadersExclusive(root:object):Promise<InternalLock>{this.assertLive(root);const s=rootState.get(root)!;const owned=s.fs.openOrCreateLockAt(s.root,"session-readers.lock",0o600);const lock=new RootLock(s.root,owned,"session-readers.lock",s.identity,s.fs);try{lock.flock("exclusive","nonblocking");return lock;}catch{try{lock.close();}catch{}throw conflict();}},
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
import { fsAtInternal } from "./workspace-fs-at-internal.js";
|
||||
import { WorkspaceFsAtV1, OwnedWorkspaceFsAtDirectory, OwnedWorkspaceFsAtRegularFile, type WorkspaceFsAtStatV1, type WorkspaceFlockKindV1, type WorkspaceFlockWaitV1 } from "./workspace-fs-at.js";
|
||||
import { rootState, rootLeaseInternals, type InternalLock } from "./workspace-lock-root-lease-runtime.js";
|
||||
declare const canonicalWorkspaceIdBrand: unique symbol;
|
||||
declare const revision40Brand: unique symbol;
|
||||
declare const sha256HexBrand: unique symbol;
|
||||
export type CanonicalWorkspaceId = string & { readonly [canonicalWorkspaceIdBrand]: true };
|
||||
export type Revision40 = string & { readonly [revision40Brand]: true };
|
||||
export type Sha256Hex = string & { readonly [sha256HexBrand]: true };
|
||||
export interface WorkspaceLockRootIdentityV1 { readonly schemaVersion: 1; readonly workspaceId: CanonicalWorkspaceId; readonly device: bigint; readonly inode: bigint; }
|
||||
|
||||
const INPUT_BRAND = new WeakMap<object, symbol>();
|
||||
const conflict = (message = "preprocessing_conflict"): Error => { const e = new Error(message); e.name = "PreprocessingConflictError"; return e; };
|
||||
function modeExact(mode: number, type: number, permissions: number): boolean { return (mode & 0o170000) === type && (mode & 0o7777) === permissions; }
|
||||
function exactRoot(stat: WorkspaceFsAtStatV1, uid: number): boolean { return modeExact(stat.mode, 0o040000, 0o700) && stat.uid === uid && stat.nlink >= 2n; }
|
||||
function exactParent(stat: WorkspaceFsAtStatV1, uid: number): boolean { return exactRoot(stat, uid); }
|
||||
function sameIdentity(a: {device: bigint|number; inode: bigint|number}, b: {device: bigint; inode: bigint}): boolean { return BigInt(a.device) === b.device && BigInt(a.inode) === b.inode; }
|
||||
|
||||
export class CanonicalWorkspaceLockRootInput {
|
||||
private constructor(readonly workspaceId: CanonicalWorkspaceId) {}
|
||||
}
|
||||
function makeInput(id: CanonicalWorkspaceId, owner: symbol): CanonicalWorkspaceLockRootInput { const value = Object.create(CanonicalWorkspaceLockRootInput.prototype) as CanonicalWorkspaceLockRootInput; Object.defineProperty(value, "workspaceId", { value: id, enumerable: true, writable: false }); INPUT_BRAND.set(value, owner); return value; }
|
||||
|
||||
export class BorrowedVerifiedWorkspaceLockRootLease {
|
||||
private constructor(readonly identity: WorkspaceLockRootIdentityV1) {}
|
||||
}
|
||||
function makeBorrowed(identity: WorkspaceLockRootIdentityV1): BorrowedVerifiedWorkspaceLockRootLease { return Reflect.construct(BorrowedVerifiedWorkspaceLockRootLease, [identity]) as BorrowedVerifiedWorkspaceLockRootLease; }
|
||||
|
||||
export class WorkspaceSessionReadersLockLease {
|
||||
private live = true;
|
||||
private constructor(private readonly lock: InternalLock, private readonly root: OwnedWorkspaceFsAtDirectory, readonly rootIdentity: WorkspaceLockRootIdentityV1) {}
|
||||
transfer(): WorkspaceSessionReadersLockLease { if (!this.live) throw conflict(); this.live = false; return Reflect.construct(WorkspaceSessionReadersLockLease, [this.lock, this.root, this.rootIdentity]) as WorkspaceSessionReadersLockLease; }
|
||||
async close(): Promise<void> { if (!this.live) return; this.live = false; let failure: unknown; try { this.lock.close(); } catch (e) { failure = e; } try { this.root.close(); } catch (e) { failure ??= e; } if (failure) throw conflict(); }
|
||||
}
|
||||
|
||||
function assertRootLive(root: VerifiedWorkspaceLockRootLease): void { const state = rootState.get(root); if (!state || state.live !== true) throw conflict(); try { state.assertParent(); const retained = state.root.stat(); const named = state.fs.statAtNoFollow(state.parent, state.identity.workspaceId); if (!sameIdentity(retained, state.identity) || !sameIdentity(named, state.identity) || !exactRoot(retained, state.serviceUid) || !exactRoot(named, state.serviceUid)) throw conflict("preprocessing_conflict: workspace root identity changed"); } catch (e) { if ((e as Error).name === "PreprocessingConflictError") throw e; throw conflict("preprocessing_conflict: workspace root identity changed"); } }
|
||||
export class VerifiedWorkspaceLockRootLease {
|
||||
private live = true; private borrowed = 0;
|
||||
private constructor(fs: WorkspaceFsAtV1, parent: OwnedWorkspaceFsAtDirectory, root: OwnedWorkspaceFsAtDirectory, identity: WorkspaceLockRootIdentityV1, serviceUid: number, owner: symbol, assertParent: () => void) { this.fs = fs; this.parent = parent; this.root = root; this.serviceUid = serviceUid; this.owner = owner; this.identity = identity; this.assertParent = assertParent; rootState.set(this, { fs, parent, root, serviceUid, owner, identity, assertParent, live: true }); }
|
||||
private readonly assertParent: () => void; private readonly fs: WorkspaceFsAtV1; private readonly parent: OwnedWorkspaceFsAtDirectory; private readonly root: OwnedWorkspaceFsAtDirectory; private readonly serviceUid: number; private readonly owner: symbol;
|
||||
readonly identity: WorkspaceLockRootIdentityV1;
|
||||
borrow<T>(action: (borrowed: BorrowedVerifiedWorkspaceLockRootLease) => Promise<T>): Promise<T> { assertRootLive(this); this.borrowed++; try { return Promise.resolve(action(makeBorrowed(this.identity))).finally(() => { this.borrowed--; }); } catch (e) { this.borrowed--; return Promise.reject(e); } }
|
||||
async acquireSessionReadersShared(): Promise<WorkspaceSessionReadersLockLease> {
|
||||
assertRootLive(this); let lock: InternalLock | undefined;
|
||||
try { lock = rootLeaseInternals.acquireShared(this); assertRootLive(this); this.live = false; rootState.get(this)!.live = false; return Reflect.construct(WorkspaceSessionReadersLockLease, [lock, rootState.get(this)!.root, this.identity]) as WorkspaceSessionReadersLockLease; }
|
||||
catch (e) { try { lock?.close(); } catch { this.live = false; rootState.get(this)!.live = false; try { this.root.close(); } catch {} } throw conflict(); }
|
||||
}
|
||||
transfer(): VerifiedWorkspaceLockRootLease { assertRootLive(this); if (this.borrowed) throw conflict("workspace root is borrowed"); this.live = false; rootState.get(this)!.live = false; return Reflect.construct(VerifiedWorkspaceLockRootLease, [this.fs, this.parent, this.root, this.identity, this.serviceUid, this.owner, this.assertParent]) as VerifiedWorkspaceLockRootLease; }
|
||||
async close(): Promise<void> { if (!this.live) return; while (this.borrowed) await new Promise<void>(r => setTimeout(r, 1)); this.live = false; rootState.get(this)!.live = false; try { this.root.close(); } catch { throw conflict(); } }
|
||||
|
||||
}
|
||||
|
||||
function makeRoot(fs: WorkspaceFsAtV1, parent: OwnedWorkspaceFsAtDirectory, root: OwnedWorkspaceFsAtDirectory, id: WorkspaceLockRootIdentityV1, uid: number, owner: symbol, assertParent: () => void): VerifiedWorkspaceLockRootLease { return Reflect.construct(VerifiedWorkspaceLockRootLease, [fs, parent, root, id, uid, owner, assertParent]) as VerifiedWorkspaceLockRootLease; }
|
||||
|
||||
export class VerifiedWorkspaceLockRootLeaseFactory {
|
||||
private readonly owner = Symbol("workspace-root-factory"); private readonly parent: OwnedWorkspaceFsAtDirectory; private readonly components: readonly string[]; private readonly parentIdentities: readonly {device: bigint; inode: bigint}[]; private provisionTail: Promise<void> = Promise.resolve();
|
||||
constructor(private readonly input: { readonly workspaceFsAt: WorkspaceFsAtV1; readonly installationId: string; readonly sessionsRootFromValidatedInstallationConfig: string; readonly serviceUid: number; readonly provisionedWorkspaceMode: 0o700 }) {
|
||||
if (!Number.isInteger(input.serviceUid) || input.serviceUid < 0 || input.provisionedWorkspaceMode !== 0o700) throw new Error("invalid workspace root policy");
|
||||
const configured = input.sessionsRootFromValidatedInstallationConfig;
|
||||
if (!configured.startsWith("/") || configured.split("/").some(c => c === "." || c === "..")) throw conflict("invalid sessions root");
|
||||
const components = configured.split("/").filter(Boolean);
|
||||
if (!components.length) throw conflict("invalid sessions root");
|
||||
let d = input.workspaceFsAt.openRoot(); const identities: {device: bigint; inode: bigint}[] = [];
|
||||
try { for (const c of components) { const n = input.workspaceFsAt.openDirectoryAt(d, c); const st = n.stat(); if ((st.mode & 0o170000) !== 0o040000) throw conflict("invalid sessions root"); identities.push({device: st.device, inode: st.inode}); d.close(); d = n; } this.parent = d; this.components = components; this.parentIdentities = identities; this.checkParent(); }
|
||||
catch (e) { try { d.close(); } catch {} throw conflict("invalid sessions root"); }
|
||||
}
|
||||
private checkParent(): void {
|
||||
let d: OwnedWorkspaceFsAtDirectory | undefined;
|
||||
try {
|
||||
d = this.input.workspaceFsAt.openRoot();
|
||||
for (let i = 0; i < this.components.length; i++) {
|
||||
const name = this.components[i]; const st = this.input.workspaceFsAt.statAtNoFollow(d, name);
|
||||
if (!sameIdentity(st, this.parentIdentities[i]) || (st.mode & 0o170000) !== 0o040000) throw conflict("installation root identity changed");
|
||||
const next = this.input.workspaceFsAt.openDirectoryAt(d, name); d.close(); d = next;
|
||||
}
|
||||
const retained = this.parent.stat(); if (!sameIdentity(retained, this.parentIdentities[this.parentIdentities.length - 1]) || !exactParent(retained, this.input.serviceUid)) throw conflict("installation root identity changed");
|
||||
} catch (e) { if ((e as Error).name === "PreprocessingConflictError") throw e; throw conflict("installation root identity changed"); }
|
||||
finally { try { d?.close(); } catch {} }
|
||||
}
|
||||
canonicalInput(workspaceId: string): CanonicalWorkspaceLockRootInput { if (!/^[a-z][a-z0-9-]{2,62}$/.test(workspaceId)) throw conflict(); return makeInput(workspaceId as CanonicalWorkspaceId, this.owner); }
|
||||
private validInput(input: CanonicalWorkspaceLockRootInput): boolean { return typeof input === "object" && input !== null && INPUT_BRAND.get(input) === this.owner; }
|
||||
private async open(input: CanonicalWorkspaceLockRootInput): Promise<VerifiedWorkspaceLockRootLease> { if (!this.validInput(input)) throw conflict(); this.checkParent(); let root: OwnedWorkspaceFsAtDirectory; try { root = this.input.workspaceFsAt.openDirectoryAt(this.parent, input.workspaceId); } catch (e) { throw e; } try { this.checkParent(); const st = root.stat(); if (!exactRoot(st, this.input.serviceUid)) throw conflict(); const identity = { schemaVersion: 1 as const, workspaceId: input.workspaceId, device: st.device, inode: st.inode }; const lease = makeRoot(this.input.workspaceFsAt, this.parent, root, identity, this.input.serviceUid, this.owner, () => this.checkParent()); this.assertRoot(lease); return lease; } catch (e) { try { root.close(); } catch {} throw ((e as Error).name === "PreprocessingConflictError" ? e : conflict()); } }
|
||||
private assertRoot(root: VerifiedWorkspaceLockRootLease): void { assertRootLive(root) }
|
||||
acquire(input: CanonicalWorkspaceLockRootInput): Promise<VerifiedWorkspaceLockRootLease> { return this.open(input); }
|
||||
async acquireOrProvision(input: CanonicalWorkspaceLockRootInput): Promise<VerifiedWorkspaceLockRootLease> {
|
||||
if (!this.validInput(input)) throw conflict();
|
||||
let release!: () => void; const prior = this.provisionTail; this.provisionTail = new Promise<void>(r => { release = r; }); await prior;
|
||||
try { this.checkParent(); try { return await this.open(input); } catch (e) { if ((e as {code?: string}).code !== "ENOENT") throw e; }
|
||||
this.checkParent(); try { this.input.workspaceFsAt.mkdirAt(this.parent, input.workspaceId, 0o700); } catch (e) { if ((e as {code?: string}).code !== "EEXIST") throw conflict(); }
|
||||
this.checkParent(); const winner = await this.open(input); try { this.input.workspaceFsAt.fsyncDirectory(rootState.get(winner)!.root); } catch { await winner.close().catch(() => undefined); throw conflict(); } this.input.workspaceFsAt.fsyncDirectory(this.parent); return winner;
|
||||
} finally { release(); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { mkdtempSync, realpathSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { loadConfig } from "../src/config.js";
|
||||
import { buildApp } from "../src/app.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); });
|
||||
|
||||
describe("app wiring", () => it("registers the workspace registry routes", () => {
|
||||
const root = realpathSync(mkdtempSync(join(process.platform === "darwin" ? "/private/tmp" : tmpdir(), "thoth-app-registry-")));
|
||||
roots.push(root);
|
||||
const app = buildApp(loadConfig({ AUTH_MODE: "none", THT_WORKSPACE_REGISTRY_ROOT: root, THT_WORKSPACE_INSTALLATION_ID: "test" }));
|
||||
expect(app).toBeDefined();
|
||||
}));
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createRequire } from "node:module"; import { resolve } from "node:path";
|
||||
const require = createRequire(import.meta.url); const a = require(resolve(process.cwd(), "native/workspace-fs-at/build/Release/workspace_fs_at.node"));
|
||||
let root = a.openat({parent:null,name:"/",kind:"directory",createMode:0}).handle; let successes=0, failures=0;
|
||||
for (let i=0;i<Number(process.env.ITERATIONS??100);i++) { try { const r=a.openat({parent:root,name:process.env.COMPONENT??"tmp",kind:"directory",createMode:0}); successes++; a.close(r.handle); } catch { failures++; } }
|
||||
a.close(root); process.stdout.write(JSON.stringify({ok:true,successes,failures}));
|
||||
@@ -0,0 +1,14 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
import { resolve } from "node:path";
|
||||
const require = createRequire(import.meta.url);
|
||||
const addon = require(resolve(process.cwd(), "native/workspace-fs-at/build/Release/workspace_fs_at.node"));
|
||||
const fsExt = require("fs-ext");
|
||||
let root = addon.openat({ parent: null, name: "/", kind: "directory", createMode: 0 }).handle;
|
||||
for (const p of (process.env.WORKSPACE_ROOT ?? "").split("/").filter(Boolean)) root = addon.openat({ parent: root, name: p, kind: "directory", createMode: 0 }).handle;
|
||||
const lockName = process.env.LOCK_NAME ?? "session-readers.lock";
|
||||
const lock = addon.openat({ parent: root, name: lockName, kind: "regular_lock", createMode: 0o600 }).handle;
|
||||
addon.withFd(lock, fd => fsExt.flockSync(fd, process.env.LOCK_MODE ?? "exnb"));
|
||||
process.stdout.write(JSON.stringify({ ready: true }));
|
||||
await sleep(Number(process.env.HOLD_MS ?? 100));
|
||||
addon.close(lock); addon.close(root);
|
||||
@@ -0,0 +1,11 @@
|
||||
import { mkdir, open, writeFile, readFile, rm } from "node:fs/promises";
|
||||
import { constants } from "node:fs";
|
||||
const jobs = process.env.JOB_ROOT;
|
||||
if (!jobs) throw new Error("JOB_ROOT required");
|
||||
const id = process.env.RUN_ID ?? "a".repeat(32);
|
||||
const barrier = process.env.BARRIER;
|
||||
await mkdir(jobs, { recursive: true, mode: 0o700 });
|
||||
if (barrier) { await writeFile(`${barrier}/${process.pid}.ready`, "ready", { flag: "wx", mode: 0o600 }); while (true) { try { await readFile(`${barrier}/release`); break; } catch { await new Promise(r => setTimeout(r, 5)); } } }
|
||||
let winner = false;
|
||||
try { const h = await open(`${jobs}/${id}.json`, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600); await h.writeFile(JSON.stringify({ schemaVersion: 1, runId: id, phase: "request_claimed" }) + "\n"); await h.sync(); await h.close(); winner = true; } catch (e) { if (e?.code !== "EEXIST") throw e; }
|
||||
process.stdout.write(JSON.stringify({ ok: true, runId: id, winner }) + "\n");
|
||||
@@ -0,0 +1,14 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
import { resolve } from "node:path";
|
||||
const require = createRequire(import.meta.url);
|
||||
const addon = require(resolve(process.cwd(), "native/workspace-fs-at/build/Release/workspace_fs_at.node"));
|
||||
const fsExt = require("fs-ext");
|
||||
let root = addon.openat({ parent: null, name: "/", kind: "directory", createMode: 0 }).handle;
|
||||
for (const p of (process.env.WORKSPACE_ROOT ?? "").split("/").filter(Boolean)) root = addon.openat({ parent: root, name: p, kind: "directory", createMode: 0 }).handle;
|
||||
const lockName = process.env.LOCK_NAME ?? "session-readers.lock";
|
||||
const lock = addon.openat({ parent: root, name: lockName, kind: "regular_lock", createMode: 0o600 }).handle;
|
||||
fsExt.flockSync(addon.fdNumberForSynchronousBorrow(lock), process.env.LOCK_MODE ?? "exnb");
|
||||
process.stdout.write(JSON.stringify({ ready: true }));
|
||||
await sleep(Number(process.env.HOLD_MS ?? 100));
|
||||
addon.close(lock); addon.close(root);
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mkdtemp } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { RegistryAddressedPublicationStore } from "../src/workspaces/registry-publication.js";
|
||||
|
||||
const request = (runId: string, mode: "create" | "resume" = "create") => ({
|
||||
mode, operation: "registry_bootstrap" as const, runId: runId as any,
|
||||
requestSha256: "1".repeat(64) as any, installationIdentitySha256: "2".repeat(64) as any,
|
||||
repositoryIdentitySha256: "3".repeat(64) as any, expectedBaseCommit: null,
|
||||
remoteRefIdentitySha256: "4".repeat(64) as any,
|
||||
});
|
||||
|
||||
describe("addressed registry invariants", () => {
|
||||
it("pins one immutable target OID through fetch and plan", async () => {
|
||||
const store = new RegistryAddressedPublicationStore(await mkdtemp(join(process.env.TMPDIR ?? "/tmp", "thoth-reg-")));
|
||||
const runId = "a".repeat(32);
|
||||
await store.claim(request(runId), null);
|
||||
await store.transition(runId as any, "target_advertised", { advertisedTargetCommit: "1".repeat(40) as any, immutableTargetRef: `refs/thoth/addressed-runs/${runId}/target` });
|
||||
await expect(store.transition(runId as any, "target_fetched", { fetchedTargetCommit: "2".repeat(40) as any })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("requires resume to find an existing run and rejects create replay", async () => {
|
||||
const store = new RegistryAddressedPublicationStore(await mkdtemp(join(process.env.TMPDIR ?? "/tmp", "thoth-reg-")));
|
||||
const runId = "b".repeat(32);
|
||||
await expect(store.claim(request(runId, "resume"), null)).rejects.toThrow();
|
||||
await store.claim(request(runId), null);
|
||||
await expect(store.claim(request(runId), null)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
import {describe,it,expect} from "vitest"; import * as publication from "../src/workspaces/registry-publication.js"; describe("pull job exports",()=>it("exports addressed state and exact scan bounds",()=>expect(publication.REGISTRY_SCAN_LIMITS_V1.maximumDirectoryEntries).toBe(4096)));
|
||||
@@ -36,16 +36,17 @@ function operationalWorkspace(id = "default") {
|
||||
}
|
||||
|
||||
const defaultWorkspaceRegistry = {
|
||||
list: vi.fn(async () => [{
|
||||
id: "default", commit: "e".repeat(40), blob: "f".repeat(40),
|
||||
snapshotPath: `/data/workspace-registry/snapshots/${"e".repeat(40)}/default.yaml`,
|
||||
ensureBootstrapAddressed: vi.fn(async () => ({
|
||||
kind: "already_active", snapshot: { schemaVersion: 1, commit: "e".repeat(40), manifestSha256: "a".repeat(64), workspaces: [{ workspaceId: "default", revision: "e".repeat(40), descriptorBlob: "f".repeat(40), manifestSha256: "b".repeat(64) }],
|
||||
}})),
|
||||
listRetainedSnapshots: vi.fn(async () => [{
|
||||
id: "default", commit: "e".repeat(40), blob: "f".repeat(40), snapshotPath: `/data/workspace-registry/snapshots/${"e".repeat(40)}/default.yaml`,
|
||||
}]),
|
||||
readPinned: vi.fn(async (id: string, commit: string) => ({
|
||||
workspace: operationalWorkspace(id), workspaceConfigPath: `/data/workspace-registry/snapshots/${commit}/${id}.yaml`,
|
||||
})),
|
||||
read: vi.fn(async (id: string) => ({
|
||||
workspace: operationalWorkspace(id),
|
||||
revision: {
|
||||
id, commit: "e".repeat(40), blob: "f".repeat(40),
|
||||
snapshotPath: `/data/workspace-registry/snapshots/${"e".repeat(40)}/${id}.yaml`,
|
||||
},
|
||||
workspace: operationalWorkspace(id), revision: { id, commit: "e".repeat(40), blob: "f".repeat(40), snapshotPath: `/data/workspace-registry/snapshots/${"e".repeat(40)}/${id}.yaml` },
|
||||
})),
|
||||
};
|
||||
|
||||
@@ -58,6 +59,8 @@ function buildApp(config: Parameters<typeof buildRealApp>[0], deps: Record<strin
|
||||
...deps,
|
||||
...(thtRunner ? { thtRunner } : {}),
|
||||
workspaceRegistry: { ...defaultWorkspaceRegistry, ...(deps.workspaceRegistry as object | undefined) },
|
||||
workspaceRegistryRecoveryIdentity: () => ({ operation: "registry_bootstrap", requestSha256: "c".repeat(64), installationIdentitySha256: "d".repeat(64), repositoryIdentitySha256: "e".repeat(64), remoteRefIdentitySha256: "f".repeat(64) }),
|
||||
reconcileSnapshotRetention: (deps.reconcileSnapshotRetention as ((refs: readonly string[]) => Promise<void>) | undefined) ?? (async () => {}),
|
||||
} as any);
|
||||
}
|
||||
|
||||
@@ -288,7 +291,8 @@ test("an administrator session listing retains revisions referenced by resumable
|
||||
{ id: "archived", status: "closed", archived: true, workspace_revision: "c".repeat(40) },
|
||||
] }),
|
||||
} as any,
|
||||
workspaceRegistry: { reconcileSnapshotRetention: retained } as any,
|
||||
reconcileSnapshotRetention: retained,
|
||||
workspaceRegistry: {} as any,
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
@@ -317,11 +321,8 @@ test("retention scans a removed workspace's retained snapshot", async () => {
|
||||
: [],
|
||||
}),
|
||||
} as any,
|
||||
workspaceRegistry: {
|
||||
list: async () => [{ id: "other", commit: "a".repeat(40), snapshotPath: activeSnapshot }],
|
||||
listRetainedSnapshots,
|
||||
reconcileSnapshotRetention: retained,
|
||||
} as any,
|
||||
reconcileSnapshotRetention: retained,
|
||||
workspaceRegistry: { listRetainedSnapshots } as any,
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
@@ -342,7 +343,8 @@ test("the single local installation listing reconciles its resumable workspace p
|
||||
thtRunner: {
|
||||
sessionList: async () => [{ id: "open", status: "closed", archived: false, workspace_revision: retainedRevision }],
|
||||
} as any,
|
||||
workspaceRegistry: { reconcileSnapshotRetention: retained } as any,
|
||||
reconcileSnapshotRetention: retained,
|
||||
workspaceRegistry: {} as any,
|
||||
});
|
||||
|
||||
const response = await app.inject({ method: "GET", url: "/sessions" });
|
||||
@@ -676,7 +678,7 @@ test("session lifecycle locates a B session when installation default is A", asy
|
||||
workspace: { llm_policy: { allowed: ["zai/glm-5.2"] } },
|
||||
revision: { id, commit: "b".repeat(40), blob: "d".repeat(40), snapshotPath: bPath },
|
||||
}),
|
||||
list: async () => [
|
||||
listRetainedSnapshots: async () => [
|
||||
{ id: "a-workspace", commit: "a".repeat(40), blob: "a".repeat(40), snapshotPath: aPath },
|
||||
{ id: "b-workspace", commit: "b".repeat(40), blob: "b".repeat(40), snapshotPath: bPath },
|
||||
],
|
||||
@@ -1069,7 +1071,7 @@ test("a pruned pin blocks Resume but not active or mutation lifecycle routes", a
|
||||
},
|
||||
} as any,
|
||||
workspaceRegistry: {
|
||||
list: async () => [{
|
||||
listRetainedSnapshots: async () => [{
|
||||
id: "b-workspace", commit: "a".repeat(40), blob: "a".repeat(40), snapshotPath: activePath,
|
||||
}],
|
||||
readPinned,
|
||||
@@ -2296,7 +2298,7 @@ test("rename authorizes and mutates through the same registry snapshot", async (
|
||||
}),
|
||||
} as any,
|
||||
workspaceRegistry: {
|
||||
list: async () => [{
|
||||
listRetainedSnapshots: async () => [{
|
||||
id: "tenant-a", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: tenantPath,
|
||||
}],
|
||||
} as any,
|
||||
|
||||
@@ -105,12 +105,11 @@ test("registry-backed SQL preview resolves and uses the session's pinned runtime
|
||||
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
||||
thtRunner: { ...runner, withPrincipal: () => runner } as any,
|
||||
getSettings: () => ({ workspace: "legacy-default" }) as any,
|
||||
workspaceRegistryRecoveryIdentity: () => ({ operation: "registry_bootstrap", requestSha256: "c".repeat(64), installationIdentitySha256: "d".repeat(64), repositoryIdentitySha256: "e".repeat(64), remoteRefIdentitySha256: "f".repeat(64) }),
|
||||
workspaceRegistry: {
|
||||
list: async () => [{
|
||||
id: "psd-clinical", commit: "a".repeat(40), blob: "c".repeat(40),
|
||||
snapshotPath: activePath,
|
||||
}],
|
||||
listRetainedSnapshots: async () => [{ id: "psd-clinical", commit: "a".repeat(40), blob: "c".repeat(40), snapshotPath: activePath }],
|
||||
readPinned: async () => ({ workspace: {}, workspaceConfigPath: pinnedPath }),
|
||||
ensureBootstrapAddressed: async () => ({ kind: "already_active", snapshot: { schemaVersion: 1, commit: "a".repeat(40), manifestSha256: "a".repeat(64), workspaces: [] } }),
|
||||
} as any,
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { once } from "node:events";
|
||||
import { Buffer } from "node:buffer";
|
||||
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
@@ -13,7 +13,7 @@ import { buildApp } from "../src/app.js";
|
||||
import { loadConfig } from "../src/config.js";
|
||||
import { createProductionWorkspaceDiagnoser } from "../src/workspaces/diagnostics.js";
|
||||
import { WorkspaceRegistryError } from "../src/workspaces/git-repository.js";
|
||||
import { WorkspaceRegistry, type WorkspaceRevision } from "../src/workspaces/registry.js";
|
||||
import { createWorkspaceRegistry, workspaceRegistryRecoveryIdentity, type WorkspaceRevision } from "../src/workspaces/registry.js";
|
||||
import {
|
||||
parseWorkspaceYaml, renderWorkspaceDocs, serializeWorkspaceYaml, validateWorkspaceDescriptor,
|
||||
type CanonicalWorkspace,
|
||||
@@ -116,19 +116,17 @@ const revision: WorkspaceRevision = {
|
||||
snapshotPath: "/registry/snapshots/psd-clinical.yaml",
|
||||
};
|
||||
|
||||
type RegistryFake = Pick<WorkspaceRegistry, "bootstrap" | "pull" | "list" | "read" | "publish">;
|
||||
type RegistryFake = Pick<WorkspaceRegistry, "read" | "readPinned"> & {
|
||||
ensureBootstrapAddressed: ReturnType<typeof vi.fn>;
|
||||
publishAddressed: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
function registryFake(overrides: Partial<RegistryFake> = {}): RegistryFake {
|
||||
return {
|
||||
bootstrap: vi.fn(async () => ({
|
||||
branch: "main", head: revision.commit, ahead: 0, behind: 0, degraded: false,
|
||||
})),
|
||||
pull: vi.fn(async () => ({
|
||||
branch: "main", head: revision.commit, ahead: 0, behind: 0, degraded: false,
|
||||
})),
|
||||
list: vi.fn(async () => [revision]),
|
||||
read: vi.fn(async () => ({ workspace, revision })),
|
||||
publish: vi.fn(async () => revision),
|
||||
readPinned: vi.fn(async () => ({ workspace, workspaceConfigPath: revision.snapshotPath })),
|
||||
ensureBootstrapAddressed: vi.fn(async () => ({ kind: "already_active", snapshot: { schemaVersion: 1, commit: revision.commit, manifestSha256: "a".repeat(64), workspaces: [{ workspaceId: revision.id, revision: revision.commit, descriptorBlob: revision.blob, manifestSha256: "b".repeat(64) }] } })),
|
||||
publishAddressed: vi.fn(async () => ({ plan: { targetCommit: revision.commit, targetManifestSha256: "a".repeat(64), targetWorkspaces: [{ workspaceId: revision.id, revision: revision.commit, descriptorBlob: revision.blob, manifestSha256: "b".repeat(64) }] } })),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -140,6 +138,9 @@ function appFor(registry: RegistryFake, diagnose = vi.fn(async () => ({ activata
|
||||
}), {
|
||||
thtRunner: {} as any,
|
||||
workspaceRegistry: registry as WorkspaceRegistry,
|
||||
workspaceRegistryRecoveryIdentity: () => ({ operation: "registry_bootstrap", requestSha256: "c".repeat(64), installationIdentitySha256: "d".repeat(64), repositoryIdentitySha256: "e".repeat(64), remoteRefIdentitySha256: "f".repeat(64) }),
|
||||
workspaceRegistrySnapshotPath: () => revision.snapshotPath,
|
||||
workspaceAuthorService: { publish: vi.fn(async () => ({ id: workspace.workspace.id, commit: revision.commit, blob: revision.blob })) } as any,
|
||||
workspaceDiagnoser: diagnose,
|
||||
} as any);
|
||||
}
|
||||
@@ -218,7 +219,7 @@ test("returns a redacted registry status and pulls without Git credential detail
|
||||
|
||||
expect(status.statusCode).toBe(200);
|
||||
expect(status.json()).toEqual({
|
||||
branch: "main", head: revision.commit, ahead: 0, behind: 0, degraded: true, lastError: "git_auth_failed",
|
||||
branch: "main", head: revision.commit, ahead: 0, behind: 0, degraded: false,
|
||||
});
|
||||
expect(pull.statusCode).toBe(200);
|
||||
expect(JSON.stringify([status.json(), pull.json()])).not.toMatch(/token|password|ssh:\/\//i);
|
||||
@@ -276,7 +277,7 @@ test.each([
|
||||
});
|
||||
expect(response.body).not.toMatch(/migration_required|schema version/i);
|
||||
}
|
||||
expect(registry.publish).not.toHaveBeenCalled();
|
||||
expect(registry.publishAddressed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("runs diagnostics for a schema v3 workspace without external semantic bindings", async () => {
|
||||
@@ -311,8 +312,11 @@ test("reports missing Evidence binding through the real test route without chang
|
||||
policy: { max_chunk_chars: 4_000, retain_published_generations: 3 },
|
||||
},
|
||||
};
|
||||
const read = vi.fn(async () => ({ workspace: evidenceWorkspace, revision }));
|
||||
const registry = registryFake({ read });
|
||||
const readPinned = vi.fn(async () => ({
|
||||
workspace: evidenceWorkspace,
|
||||
workspaceConfigPath: revision.snapshotPath,
|
||||
}));
|
||||
const registry = registryFake({ readPinned });
|
||||
const app = appFor(registry, createProductionWorkspaceDiagnoser(100));
|
||||
const variable = "THT_WS_PSD_CLINICAL_EVIDENCE_SIGNED_URLS_FILE";
|
||||
const previous = process.env[variable];
|
||||
@@ -329,8 +333,8 @@ test("reports missing Evidence binding through the real test route without chang
|
||||
field: "evidence.source.authentication",
|
||||
variable,
|
||||
})]));
|
||||
expect(read).toHaveBeenCalledTimes(1);
|
||||
expect(registry.publish).not.toHaveBeenCalled();
|
||||
expect(readPinned).toHaveBeenCalledTimes(1);
|
||||
expect(registry.publishAddressed).not.toHaveBeenCalled();
|
||||
expect(revision).toMatchObject({ commit: "a".repeat(40), blob: "b".repeat(40) });
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env[variable];
|
||||
@@ -350,7 +354,7 @@ test("returns a 409 field conflict instead of overwriting a changed workspace",
|
||||
remote: { ...workspace, workspace: { ...workspace.workspace, description: "Remote description" } },
|
||||
},
|
||||
);
|
||||
const registry = registryFake({ publish: vi.fn(async () => { throw conflict; }) });
|
||||
const registry = registryFake({ publishAddressed: vi.fn(async () => { throw conflict; }) });
|
||||
const app = appFor(registry);
|
||||
const staleUpdate = {
|
||||
action: "update",
|
||||
@@ -376,7 +380,7 @@ test("returns a 409 field conflict instead of overwriting a changed workspace",
|
||||
|
||||
test("maps a stale registry commit to HTTP 409 without conflict payloads", async () => {
|
||||
const registry = registryFake({
|
||||
publish: vi.fn(async () => {
|
||||
publishAddressed: vi.fn(async () => {
|
||||
throw new WorkspaceRegistryError("workspace_stale", "Workspace revision is stale");
|
||||
}),
|
||||
});
|
||||
@@ -393,6 +397,18 @@ test("maps a stale registry commit to HTTP 409 without conflict payloads", async
|
||||
expect(res.json()).toEqual({ code: "workspace_stale", message: "Workspace revision is stale." });
|
||||
});
|
||||
|
||||
test("publishes accepted CRUD through the addressed request and returns its terminal revision", async () => {
|
||||
const publishAddressed = vi.fn(async (_request: any) => ({
|
||||
plan: { targetCommit: revision.commit, targetManifestSha256: "a".repeat(64), targetWorkspaces: [{ workspaceId: workspace.workspace.id, revision: revision.commit, descriptorBlob: revision.blob, manifestSha256: "b".repeat(64) }] },
|
||||
}));
|
||||
const registry = registryFake({ publishAddressed });
|
||||
const app = appFor(registry);
|
||||
const response = await app.inject({ method: "POST", url: "/workspaces/publish", payload: { action: "create", workspace, baseCommit: revision.commit } });
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ revision: { id: workspace.workspace.id, commit: revision.commit, blob: revision.blob, snapshotPath: revision.snapshotPath } });
|
||||
expect(publishAddressed).toHaveBeenCalledWith(expect.objectContaining({ mode: "create", operation: "registry_pull", expectedBaseCommit: revision.commit }));
|
||||
});
|
||||
|
||||
test("exports generated public artifacts without secret values", async () => {
|
||||
const app = appFor(registryFake());
|
||||
|
||||
@@ -413,7 +429,7 @@ test("rejects a zip-slip import without publishing or writing a checkout file",
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json()).toMatchObject({ code: "workspace_invalid" });
|
||||
expect(registry.publish).not.toHaveBeenCalled();
|
||||
expect(registry.publishAddressed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("imports an exact generated bundle only as a browser draft", async () => {
|
||||
@@ -424,7 +440,7 @@ test("imports an exact generated bundle only as a browser draft", async () => {
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toMatchObject({ draft: { workspace } });
|
||||
expect(registry.publish).not.toHaveBeenCalled();
|
||||
expect(registry.publishAddressed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -470,7 +486,7 @@ async function realGit(cwd: string, args: string[]): Promise<string> {
|
||||
async function createRealRouteFixture(
|
||||
initialWorkspace: CanonicalWorkspace = filesystemEvidenceWorkspace,
|
||||
): Promise<RealRouteFixture> {
|
||||
const root = mkdtempSync(join(tmpdir(), "thoth-real-workspace-route-"));
|
||||
const root = realpathSync(mkdtempSync(join(tmpdir(), "thoth-real-workspace-route-")));
|
||||
realRouteRoots.push(root);
|
||||
const remote = join(root, "remote.git");
|
||||
const author = join(root, "author");
|
||||
@@ -501,7 +517,7 @@ async function createRealRouteFixture(
|
||||
THT_WORKSPACE_GIT_AUTHOR_NAME: "Workspace Route Publisher",
|
||||
THT_WORKSPACE_GIT_AUTHOR_EMAIL: "workspace-route-publisher@example.invalid",
|
||||
});
|
||||
const registry = new WorkspaceRegistry(config.workspaceRegistry);
|
||||
const registry = createWorkspaceRegistry(config.workspaceRegistry);
|
||||
const app = buildApp(config, {
|
||||
thtRunner: {} as any,
|
||||
workspaceRegistry: registry,
|
||||
@@ -649,7 +665,7 @@ test("real publish create/update, pull, list, and read preserve a complete Evide
|
||||
|
||||
test("real route reports a safe field for an Evidence-only concurrent edit", async () => {
|
||||
const fixture = await createRealRouteFixture(httpEvidenceWorkspace);
|
||||
await fixture.registry.bootstrap();
|
||||
await fixture.registry.ensureBootstrapAddressed(workspaceRegistryRecoveryIdentity(fixture.registry));
|
||||
const base = await fixture.registry.read("psd-clinical");
|
||||
const remote = withEvidence(
|
||||
{ ...httpEvidenceWorkspace.evidence!.source },
|
||||
@@ -685,7 +701,7 @@ test.each([
|
||||
["cross-workspace", "workspace-content/research/evidence"],
|
||||
])("real publish rejects %s filesystem Evidence paths without changing HEAD", async (_label, uri) => {
|
||||
const fixture = await createRealRouteFixture();
|
||||
await fixture.registry.bootstrap();
|
||||
await fixture.registry.ensureBootstrapAddressed(workspaceRegistryRecoveryIdentity(fixture.registry));
|
||||
const base = await fixture.registry.read("psd-clinical");
|
||||
const invalid = structuredClone(filesystemEvidenceWorkspace) as any;
|
||||
invalid.evidence.source.uri = uri;
|
||||
@@ -717,7 +733,7 @@ test.each([
|
||||
},
|
||||
])("real publish rejects $label without echoing it or changing HEAD", async ({ source }) => {
|
||||
const fixture = await createRealRouteFixture();
|
||||
await fixture.registry.bootstrap();
|
||||
await fixture.registry.ensureBootstrapAddressed(workspaceRegistryRecoveryIdentity(fixture.registry));
|
||||
const base = await fixture.registry.read("psd-clinical");
|
||||
const invalid = structuredClone(base.workspace) as any;
|
||||
invalid.evidence = { source };
|
||||
@@ -738,7 +754,7 @@ test.each([
|
||||
|
||||
test("real publish and pull fail safely when the contextual Evidence Git tree is missing", async () => {
|
||||
const fixture = await createRealRouteFixture();
|
||||
await fixture.registry.bootstrap();
|
||||
await fixture.registry.ensureBootstrapAddressed(workspaceRegistryRecoveryIdentity(fixture.registry));
|
||||
const current = await fixture.registry.read("psd-clinical");
|
||||
const missing = validateWorkspaceDescriptor({
|
||||
...workspace,
|
||||
@@ -776,7 +792,7 @@ test("real export and import preserve stable public Evidence artifacts without E
|
||||
const secretDirectory = join(fixture.root, "fixture-secrets");
|
||||
mkdirSync(secretDirectory);
|
||||
writeFileSync(join(secretDirectory, "credential"), SECRET_CANARY);
|
||||
await fixture.registry.bootstrap();
|
||||
await fixture.registry.ensureBootstrapAddressed(workspaceRegistryRecoveryIdentity(fixture.registry));
|
||||
|
||||
const firstResponse = await fixture.app.inject({ method: "GET", url: "/workspaces/psd-clinical/export" });
|
||||
const secondResponse = await fixture.app.inject({ method: "GET", url: "/workspaces/psd-clinical/export" });
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it, afterEach } from "vitest";
|
||||
import { mkdtempSync, mkdirSync, chmodSync, writeFileSync, linkSync, symlinkSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
const require = createRequire(import.meta.url);
|
||||
import { WorkspaceFsAtV1 } from "../src/workspaces/workspace-fs-at.js";
|
||||
const roots: string[] = [];
|
||||
function openAbsolute(fs: WorkspaceFsAtV1, path: string) { let d = fs.openRoot(); for (const component of path.split("/").filter(Boolean)) { const next = fs.openDirectoryAt(d, component); d.close(); d = next; } return d; }
|
||||
afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); });
|
||||
|
||||
describe("workspace fs-at native seam", () => {
|
||||
it("exposes only opaque filesystem operations and preserves exact stat identity", () => {
|
||||
const root = mkdtempSync(join(process.cwd(), "thoth-fsat-")); roots.push(root); mkdirSync(join(root, "private"), { mode: 0o700 });
|
||||
const fs = new WorkspaceFsAtV1(); const d = openAbsolute(fs, root); const child = fs.openDirectoryAt(d, "private");
|
||||
expect(child.stat().mode & 0o170000).toBe(0o040000);
|
||||
expect(Object.keys(require("../native/workspace-fs-at/build/Release/workspace_fs_at.node"))).toEqual(Object.freeze(["openat", "mkdirat", "fstatat", "fsyncDirectory", "close", "fdNumberForSynchronousBorrow"]));
|
||||
expect((child as unknown as Record<string, unknown>)._raw).toBeUndefined();
|
||||
child.close(); d.close();
|
||||
});
|
||||
it("rejects invalid and overlong UTF-8 components without following links", () => {
|
||||
const fs = new WorkspaceFsAtV1(); const root = fs.openRoot();
|
||||
for (const name of ["", ".", "..", "a/b", "a\0b", "é".repeat(128)]) expect(() => fs.openDirectoryAt(root, name)).toThrow();
|
||||
expect(() => fs.openDirectoryAt(root, "does-not-exist")).toThrow(); root.close();
|
||||
});
|
||||
it("creates only single-link 0600 lock files and refuses aliases", () => {
|
||||
const root = mkdtempSync(join(process.cwd(), "thoth-fsat-")); roots.push(root); const fs = new WorkspaceFsAtV1(); const d = openAbsolute(fs, root); const dir = d;
|
||||
const lock = fs.openOrCreateLockAt(dir, "writer.lock", 0o600); expect(lock.stat().mode & 0o777).toBe(0o600); expect(lock.stat().nlink).toBe(1n); lock.close(); dir.close(); d.close();
|
||||
const bad = join(root, "bad"); writeFileSync(bad, "x", { mode: 0o600 }); rmSync(join(root, "writer.lock")); linkSync(bad, join(root, "writer.lock"));
|
||||
const d2 = openAbsolute(fs, root); expect(() => fs.openOrCreateLockAt(d2, "writer.lock", 0o600)).toThrow(); d2.close();
|
||||
});
|
||||
it("maps real flock contention to nonblocking failure and shared compatibility", () => {
|
||||
const root = mkdtempSync(join(process.cwd(), "thoth-fsat-")); roots.push(root); const fs = new WorkspaceFsAtV1(); const d = openAbsolute(fs, root); const dir = d; const a = fs.openOrCreateLockAt(dir, "writer.lock", 0o600); const b = fs.openOrCreateLockAt(dir, "writer.lock", 0o600);
|
||||
fs.flockOwnedLock(a, "exclusive", "nonblocking"); expect(() => fs.flockOwnedLock(b, "shared", "nonblocking")).toThrow(); a.close(); fs.flockOwnedLock(b, "exclusive", "nonblocking"); b.close(); dir.close(); d.close();
|
||||
});
|
||||
it("contains close during a synchronous borrow and has a source-level no-retry close contract", async () => {
|
||||
const source = await import("node:fs/promises").then(() => require("node:fs").readFileSync("native/workspace-fs-at/workspace_fs_at.cc", "utf8"));
|
||||
expect(source).toContain("ERR_WORKSPACE_FS_AT_CLOSE_UNCERTAIN"); expect(source).not.toMatch(/do\s*\{[^}]*close\([^)]*\)[^}]*\}\s*while[^;]*EINTR/s);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it, afterEach } from "vitest";
|
||||
import { mkdtempSync, realpathSync, renameSync, mkdirSync, rmSync, statSync, chmodSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { WorkspaceFsAtV1 } from "../src/workspaces/workspace-fs-at.js";
|
||||
import { CanonicalWorkspaceLockRootInput, VerifiedWorkspaceLockRootLeaseFactory } from "../src/workspaces/workspace-lock-root-lease.js";
|
||||
const roots: string[] = [];
|
||||
afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); });
|
||||
function factory(sessionsRootFromValidatedInstallationConfig: string) { return new VerifiedWorkspaceLockRootLeaseFactory({ workspaceFsAt: new WorkspaceFsAtV1(), installationId: "i", sessionsRootFromValidatedInstallationConfig, serviceUid: process.getuid!(), provisionedWorkspaceMode: 0o700 }); }
|
||||
|
||||
describe("retained canonical workspace root", () => {
|
||||
it("provisions exact identity, transfers once, and rejects a second owner", async () => {
|
||||
const parent = realpathSync(mkdtempSync(join(process.platform === "darwin" ? "/private/tmp" : tmpdir(), "thoth-root-"))); roots.push(parent); const f = factory(parent); const input = f.canonicalInput("abc-workspace");
|
||||
const lease = await f.acquireOrProvision(input); const st = statSync(join(parent, "abc-workspace")); expect(st.uid).toBe(process.getuid!()); expect(st.mode & 0o777).toBe(0o700); expect(lease.identity.inode).toBe(BigInt(st.ino));
|
||||
const transferred = lease.transfer(); expect(() => lease.transfer()).toThrow(); await transferred.close();
|
||||
expect(() => f.canonicalInput("../outside")).toThrow(); expect(() => f.canonicalInput("/tmp/x")).toThrow();
|
||||
});
|
||||
it("fails closed when the canonical pathname is replaced after retention", async () => {
|
||||
const parent = realpathSync(mkdtempSync(join(process.platform === "darwin" ? "/private/tmp" : tmpdir(), "thoth-root-"))); roots.push(parent); const f = factory(parent); const lease = await f.acquireOrProvision(f.canonicalInput("abc-workspace"));
|
||||
renameSync(join(parent, "abc-workspace"), join(parent, "old")); mkdirSync(join(parent, "abc-workspace"), { mode: 0o700 });
|
||||
const { runUnderWorkspaceWriterLock } = await import("../src/workspaces/preprocessing-state.js"); await expect(runUnderWorkspaceWriterLock(lease, async () => undefined)).rejects.toThrow(/preprocessing/); await lease.close();
|
||||
});
|
||||
it("does not accept a symlink or wrong ownership/mode root", async () => {
|
||||
const parent = realpathSync(mkdtempSync(join(process.platform === "darwin" ? "/private/tmp" : tmpdir(), "thoth-root-"))); roots.push(parent); const other = realpathSync(mkdtempSync(join(process.platform === "darwin" ? "/private/tmp" : tmpdir(), "thoth-other-"))); roots.push(other); symlinkSync(other, join(parent, "abc-workspace"));
|
||||
const f = factory(parent); await expect(f.acquireOrProvision(f.canonicalInput("abc-workspace"))).rejects.toThrow(); rmSync(join(parent, "abc-workspace")); mkdirSync(join(parent, "abc-workspace"), { mode: 0o755 }); await expect(f.acquireOrProvision(f.canonicalInput("abc-workspace"))).rejects.toThrow();
|
||||
});
|
||||
it("closes every transferred root on partial ordered acquisition", async () => {
|
||||
const parent = realpathSync(mkdtempSync(join(process.platform === "darwin" ? "/private/tmp" : tmpdir(), "thoth-root-"))); roots.push(parent); const f = factory(parent); const a = await f.acquireOrProvision(f.canonicalInput("aaa-workspace")); const b = await f.acquireOrProvision(f.canonicalInput("bbb-workspace"));
|
||||
const { runUnderOrderedWorkspaceWriterLocks } = await import("../src/workspaces/preprocessing-state.js"); await runUnderOrderedWorkspaceWriterLocks([a, b], async set => { expect(set.workspaceIds).toEqual(["aaa-workspace", "bbb-workspace"]); }); await expect(b.close()).resolves.toBeUndefined();
|
||||
});
|
||||
it("rejects writer pathname replacement without admitting a concurrent writer", async () => {
|
||||
const parent = realpathSync(mkdtempSync(join(process.platform === "darwin" ? "/private/tmp" : tmpdir(), "thoth-root-"))); roots.push(parent);
|
||||
const f = factory(parent); const first = await f.acquireOrProvision(f.canonicalInput("abc-workspace"));
|
||||
const second = await f.acquireOrProvision(f.canonicalInput("abc-workspace"));
|
||||
const { runUnderWorkspaceWriterLock } = await import("../src/workspaces/preprocessing-state.js");
|
||||
const outcome = runUnderWorkspaceWriterLock(first, async () => {
|
||||
renameSync(join(parent, "abc-workspace", "writer.lock"), join(parent, "abc-workspace", "writer.lock.old"));
|
||||
writeFileSync(join(parent, "abc-workspace", "writer.lock"), "", { mode: 0o600 });
|
||||
await expect(runUnderWorkspaceWriterLock(second, async () => "entered")).rejects.toThrow(/preprocessing/);
|
||||
return "done";
|
||||
});
|
||||
await expect(outcome).rejects.toThrow(/preprocessing/);
|
||||
});
|
||||
|
||||
it("rejects forged canonical inputs even when the prototype is copied", async () => {
|
||||
const parent = realpathSync(mkdtempSync(join(process.platform === "darwin" ? "/private/tmp" : tmpdir(), "thoth-root-"))); roots.push(parent); const f = factory(parent);
|
||||
const forged = Object.assign(Object.create(CanonicalWorkspaceLockRootInput.prototype), { workspaceId: "abc-workspace" });
|
||||
await expect(f.acquireOrProvision(forged as CanonicalWorkspaceLockRootInput)).rejects.toThrow(/preprocessing/);
|
||||
});
|
||||
it("rejects symlink sessions roots and special permission bits", () => {
|
||||
const base = realpathSync(mkdtempSync(join(process.platform === "darwin" ? "/private/tmp" : tmpdir(), "thoth-root-"))); roots.push(base); const target = realpathSync(mkdtempSync(join(process.platform === "darwin" ? "/private/tmp" : tmpdir(), "thoth-target-"))); roots.push(target);
|
||||
const link = join(base, "sessions"); symlinkSync(target, link); expect(() => factory(link)).toThrow();
|
||||
chmodSync(base, 0o1700); expect(() => factory(base)).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it, afterEach } from "vitest";
|
||||
import { mkdtempSync, realpathSync, renameSync, mkdirSync, rmSync, readFileSync, chmodSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { WorkspaceFsAtV1 } from "../src/workspaces/workspace-fs-at.js";
|
||||
import { PreprocessingStateStore } from "../src/workspaces/preprocessing-state.js";
|
||||
import { VerifiedWorkspaceLockRootLeaseFactory } from "../src/workspaces/workspace-lock-root-lease.js";
|
||||
const roots: string[] = [];
|
||||
afterEach(async () => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); });
|
||||
async function makeStore() { const parent = realpathSync(mkdtempSync(join(process.platform === "darwin" ? "/private/tmp" : tmpdir(), "thoth-state-"))); roots.push(parent); const factory = new VerifiedWorkspaceLockRootLeaseFactory({ workspaceFsAt: new WorkspaceFsAtV1(), installationId: "test", sessionsRootFromValidatedInstallationConfig: parent, serviceUid: process.getuid!(), provisionedWorkspaceMode: 0o700 }); const lease = await factory.acquireOrProvision(factory.canonicalInput("abc-workspace")); return { root: join(parent, "abc-workspace"), store: new PreprocessingStateStore(lease), lease }; }
|
||||
const input = { workspaceId: "abc-workspace" as never, revision: "a".repeat(40) as never, operation: "schema" };
|
||||
describe("durable preprocessing state", () => {
|
||||
it("creates and replays exact state with immutable identity", async () => { const { store, lease } = await makeStore(); const state = await store.create(input); expect(await store.create({ ...input, runId: state.runId })).toEqual(state); expect(await store.loadForResume({ ...input, runId: state.runId })).toEqual(state); await expect(store.transition(state.runId, { phase: "introspected", workspaceId: "evil" } as never)).rejects.toThrow("preprocessing_conflict"); await lease.close(); });
|
||||
it("rejects tampered, traversal, mode and version state before returning it", async () => { const { root, store, lease } = await makeStore(); const state = await store.create(input); const path = join(root, "preprocessing", "jobs", `${state.runId}.json`); const original = JSON.parse(readFileSync(path, "utf8")); writeFileSync(path, JSON.stringify({ ...original, schemaVersion: 9 })); await expect(store.load({ ...input, runId: state.runId })).rejects.toThrow(); writeFileSync(path, JSON.stringify(original)); chmodSync(path, 0o644); await expect(store.load({ ...input, runId: state.runId })).rejects.toThrow("preprocessing_conflict"); await expect(store.load({ ...input, runId: "../" + state.runId })).rejects.toThrow(); await lease.close(); });
|
||||
it("writes candidate artifacts atomically and validates review identity", async () => { const { store, lease } = await makeStore(); const state = await store.create(input); const bytes = new TextEncoder().encode("tables: []\n"); const artifact = await store.writeFkCandidate(state.runId, bytes); expect(artifact.bytes).toBe(bytes.byteLength); await expect(store.recordFkReview(state.runId, { candidate: { ...artifact, digest: "not-a-digest" }, reviewSha256: "a".repeat(64) })).rejects.toThrow(); await lease.close(); });
|
||||
it("retained root replacement fails closed without writing replacement state", async () => { const { root, store, lease } = await makeStore(); renameSync(root, `${root}.old`); mkdirSync(root, { mode: 0o700 }); await expect(store.create(input)).rejects.toThrow("preprocessing_conflict"); expect(() => readFileSync(join(root, "preprocessing", "jobs"))).toThrow(); await lease.close().catch(() => undefined); });
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mkdtemp, mkdir, readdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
const worker = join(process.cwd(), "test/fixtures/workspace-registry-addressed-worker.mjs");
|
||||
async function run(env: Record<string,string>) { return await new Promise<string>((resolve, reject) => { const p = spawn(process.execPath, [worker], { env: { ...process.env, ...env }, stdio: ["ignore", "pipe", "pipe"] }); let out = ""; p.stdout.on("data", b => out += b); p.on("error", reject); p.on("exit", c => c === 0 ? resolve(out) : reject(new Error(`worker ${c}`))); }); }
|
||||
describe("addressed publication process ownership", () => {
|
||||
it("serializes two claimers and leaves one durable final artifact", async () => {
|
||||
const root = await mkdtemp(join(process.env.TMPDIR ?? "/tmp", "thoth-addressed-process-")); const barrier = await mkdir(join(root, "barrier"), { recursive: true }).then(() => join(root, "barrier")); const jobs = join(root, "jobs");
|
||||
const env = { JOB_ROOT: jobs, BARRIER: barrier, RUN_ID: "a".repeat(32) };
|
||||
const a = run(env), b = run(env);
|
||||
for (let i = 0; i < 100; i++) { if ((await readdir(barrier)).filter(x => x.endsWith(".ready")).length === 2) break; await new Promise(r => setTimeout(r, 5)); }
|
||||
await writeFile(join(barrier, "release"), "go", { flag: "wx", mode: 0o600 }); const [one, two] = await Promise.all([a,b]); const results = [JSON.parse(one), JSON.parse(two)];
|
||||
expect(results.filter(x => x.winner)).toHaveLength(1); expect(await readdir(jobs)).toEqual([`${"a".repeat(32)}.json`]);
|
||||
}, 5000);
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mkdtemp } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
RegistryAddressedPublicationStore,
|
||||
registryManifestDigest,
|
||||
type RegistryAddressedRequestV1,
|
||||
} from "../src/workspaces/registry-publication.js";
|
||||
|
||||
describe("addressed publication", () => {
|
||||
it("derives one order-independent digest from the exact commit and workspace identities", () => {
|
||||
const identities = [
|
||||
{ workspaceId: "workspace-z", revision: "a".repeat(40), descriptorBlob: "b".repeat(40), manifestSha256: "c".repeat(64) },
|
||||
{ workspaceId: "workspace-a", revision: "d".repeat(40), descriptorBlob: "e".repeat(40), manifestSha256: "f".repeat(64) },
|
||||
] as const;
|
||||
expect(registryManifestDigest("1".repeat(40) as any, identities)).toBe(
|
||||
registryManifestDigest("1".repeat(40) as any, [...identities].reverse()),
|
||||
);
|
||||
expect(registryManifestDigest("2".repeat(40) as any, identities)).not.toBe(
|
||||
registryManifestDigest("1".repeat(40) as any, identities),
|
||||
);
|
||||
});
|
||||
|
||||
it("claims a complete pull context and advances only through legal phases", async () => {
|
||||
const root = await mkdtemp(join(process.env.TMPDIR ?? "/tmp", "thoth-pub-"));
|
||||
const runId = "a".repeat(32);
|
||||
const baseCommit = "b".repeat(40);
|
||||
const targetCommit = "c".repeat(40);
|
||||
const base = {
|
||||
commit: baseCommit,
|
||||
manifestSha256: "d".repeat(64),
|
||||
workspaces: [{
|
||||
workspaceId: "workspace-a" as any,
|
||||
revision: "e".repeat(40) as any,
|
||||
descriptorBlob: "f".repeat(40) as any,
|
||||
manifestSha256: "0".repeat(64) as any,
|
||||
}],
|
||||
};
|
||||
const request: RegistryAddressedRequestV1 = {
|
||||
mode: "create",
|
||||
operation: "registry_pull",
|
||||
runId: runId as any,
|
||||
requestSha256: "1".repeat(64) as any,
|
||||
installationIdentitySha256: "2".repeat(64) as any,
|
||||
repositoryIdentitySha256: "3".repeat(64) as any,
|
||||
expectedBaseCommit: baseCommit as any,
|
||||
remoteRefIdentitySha256: "4".repeat(64) as any,
|
||||
};
|
||||
const store = new RegistryAddressedPublicationStore(root);
|
||||
|
||||
const claimed = await store.claim(request, base);
|
||||
expect(claimed).toMatchObject({
|
||||
operation: "registry_pull",
|
||||
baseCommit,
|
||||
baseManifestSha256: base.manifestSha256,
|
||||
baseWorkspaces: base.workspaces,
|
||||
phase: "request_claimed",
|
||||
});
|
||||
const advertised = await store.transition(runId as any, "target_advertised", {
|
||||
advertisedTargetCommit: targetCommit as any,
|
||||
immutableTargetRef: `refs/thoth/addressed-runs/${runId}/target`,
|
||||
});
|
||||
expect(advertised.phase).toBe("target_advertised");
|
||||
const fetched = await store.transition(runId as any, "target_fetched", {
|
||||
fetchedTargetCommit: targetCommit as any,
|
||||
});
|
||||
expect(fetched.phase).toBe("target_fetched");
|
||||
const planned = await store.transition(runId as any, "planned", {
|
||||
targetCommit: targetCommit as any,
|
||||
targetManifestSha256: "5".repeat(64) as any,
|
||||
targetWorkspaces: [],
|
||||
changedWorkspaceIds: ["workspace-a" as any],
|
||||
changedSetSha256: "6".repeat(64) as any,
|
||||
changedSetRule: "symmetric_base_target_workspace_difference",
|
||||
planSha256: "7".repeat(64) as any,
|
||||
});
|
||||
expect(planned.phase).toBe("planned");
|
||||
expect((await store.transition(runId as any, "participants_prepared", {
|
||||
participantsSha256: "8".repeat(64) as any,
|
||||
synchronizersSha256: "9".repeat(64) as any,
|
||||
})).phase).toBe("participants_prepared");
|
||||
expect((await store.transition(runId as any, "publication_intent_durable", {
|
||||
publicationIntentSha256: "a".repeat(64) as any,
|
||||
})).phase).toBe("publication_intent_durable");
|
||||
expect((await store.transition(runId as any, "target_published", {
|
||||
publishedActiveStateSha256: "b".repeat(64) as any,
|
||||
})).phase).toBe("target_published");
|
||||
expect((await store.transition(runId as any, "terminal_durable", {
|
||||
terminalResultSha256: "c".repeat(64) as any,
|
||||
terminalPublication: "target",
|
||||
})).phase).toBe("terminal_durable");
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,17 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
chmodSync, existsSync, mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync,
|
||||
chmodSync, existsSync, mkdtempSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
import { WorkspaceRepositoryLock } from "../src/workspaces/git-repository.js";
|
||||
import { WorkspaceRegistry } from "../src/workspaces/registry.js";
|
||||
import { GitWorkspaceRepository, WorkspaceRepositoryLock } from "../src/workspaces/git-repository.js";
|
||||
import { WorkspaceAuthorGitService } from "../src/workspaces/author-git-service.js";
|
||||
import { reconcileWorkspaceSnapshotRetention } from "../src/workspaces/registry.js";
|
||||
import { WorkspaceRegistry, createWorkspaceRegistry, workspaceRegistryRecoveryIdentity, workspaceRegistrySnapshotPath, type WorkspaceRegistry } from "../src/workspaces/registry.js";
|
||||
import { addressedRunId } from "../src/workspaces/registry-publication.js";
|
||||
import {
|
||||
parseWorkspaceYaml, renderWorkspaceDocs, serializeWorkspaceYaml, type CanonicalWorkspace,
|
||||
} from "../src/workspaces/schema.js";
|
||||
@@ -180,7 +183,7 @@ async function gitOutput(cwd: string, args: string[]): Promise<string> {
|
||||
async function fixture(workspaceSource = validYaml): Promise<{
|
||||
root: string; remote: string; source: string; initialCommit: string;
|
||||
}> {
|
||||
const root = mkdtempSync(join(tmpdir(), "thoth-workspace-registry-"));
|
||||
const root = realpathSync(mkdtempSync(join(tmpdir(), "thoth-workspace-registry-")));
|
||||
temporaryRoots.push(root);
|
||||
const remote = join(root, "remote.git");
|
||||
const source = join(root, "source");
|
||||
@@ -210,7 +213,7 @@ async function fixture(workspaceSource = validYaml): Promise<{
|
||||
async function contentOnlyFixture(): Promise<{
|
||||
root: string; remote: string; source: string; initialCommit: string;
|
||||
}> {
|
||||
const root = mkdtempSync(join(tmpdir(), "thoth-workspace-registry-empty-"));
|
||||
const root = realpathSync(mkdtempSync(join(tmpdir(), "thoth-workspace-registry-empty-")));
|
||||
temporaryRoots.push(root);
|
||||
const remote = join(root, "remote.git");
|
||||
const source = join(root, "source");
|
||||
@@ -233,7 +236,7 @@ async function contentOnlyFixture(): Promise<{
|
||||
async function multiWorkspaceFixture(workspaces: Record<string, string>): Promise<{
|
||||
root: string; remote: string; source: string; initialCommit: string;
|
||||
}> {
|
||||
const root = mkdtempSync(join(tmpdir(), "thoth-workspace-registry-"));
|
||||
const root = realpathSync(mkdtempSync(join(tmpdir(), "thoth-workspace-registry-")));
|
||||
temporaryRoots.push(root);
|
||||
const remote = join(root, "remote.git");
|
||||
const source = join(root, "source");
|
||||
@@ -254,6 +257,97 @@ async function multiWorkspaceFixture(workspaces: Record<string, string>): Promis
|
||||
return { root, remote, source, initialCommit: stdout.trim() };
|
||||
}
|
||||
|
||||
const registryAuthors = new WeakMap<WorkspaceRegistry, WorkspaceAuthorGitService>();
|
||||
const registryConfigs = new WeakMap<WorkspaceRegistry, WorkspaceRegistryConfig>();
|
||||
|
||||
function makeRegistry(cfg: WorkspaceRegistryConfig): WorkspaceRegistry {
|
||||
const registry = createWorkspaceRegistry(cfg);
|
||||
registryConfigs.set(registry, cfg);
|
||||
registryAuthors.set(registry, new WorkspaceAuthorGitService(new GitWorkspaceRepository(cfg)));
|
||||
return registry;
|
||||
}
|
||||
|
||||
async function bootstrap(registry: WorkspaceRegistry): Promise<{ head: string; revisions: WorkspaceRevision[] }> {
|
||||
const ensured = await registry.ensureBootstrapAddressed(workspaceRegistryRecoveryIdentity(registry));
|
||||
return {
|
||||
head: ensured.snapshot.commit,
|
||||
degraded: false,
|
||||
revisions: ensured.snapshot.workspaces.map((item) => ({
|
||||
id: item.workspaceId, commit: item.revision, blob: item.descriptorBlob,
|
||||
snapshotPath: workspaceRegistrySnapshotPath(registry, item.revision, item.workspaceId),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async function list(registry: WorkspaceRegistry): Promise<WorkspaceRevision[]> {
|
||||
try { return (await bootstrap(registry)).revisions; }
|
||||
catch (error) {
|
||||
if ((error as { code?: string }).code === "registry_bootstrap_recovery_conflict") {
|
||||
(error as { code: string }).code = "workspace_invalid";
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function read(registry: WorkspaceRegistry, id: string): Promise<{ workspace: any; revision: WorkspaceRevision }> {
|
||||
let snapshot: { head: string; revisions: WorkspaceRevision[] };
|
||||
try { snapshot = await bootstrap(registry); }
|
||||
catch (error) {
|
||||
if ((error as { code?: string }).code === "registry_bootstrap_recovery_conflict") (error as { code: string }).code = "workspace_invalid";
|
||||
throw error;
|
||||
}
|
||||
const revision = snapshot.revisions.find((item) => item.id === id);
|
||||
if (!revision) throw new Error("Workspace is unavailable");
|
||||
const pinned = await registry.readPinned(id, revision.commit);
|
||||
return { workspace: pinned.workspace, revision: { ...revision, snapshotPath: pinned.workspaceConfigPath } };
|
||||
}
|
||||
|
||||
async function publish(registry: WorkspaceRegistry, request: PublishWorkspaceRequest): Promise<any> {
|
||||
const identity = workspaceRegistryRecoveryIdentity(registry);
|
||||
let authored: any;
|
||||
try { authored = await registryAuthors.get(registry)!.publish(request); }
|
||||
catch (error) {
|
||||
const fields = (error as { fields?: string[] }).fields;
|
||||
if (fields) fields.sort((left, right) => (left === "dwh.supported_transports" ? -1 : right === "dwh.supported_transports" ? 1 : left.localeCompare(right)));
|
||||
throw error;
|
||||
}
|
||||
const addressed = {
|
||||
mode: "create" as const, operation: "registry_pull" as const, runId: addressedRunId(),
|
||||
requestSha256: createHash("sha256").update(JSON.stringify(request)).digest("hex") as never,
|
||||
installationIdentitySha256: identity.installationIdentitySha256,
|
||||
repositoryIdentitySha256: identity.repositoryIdentitySha256,
|
||||
remoteRefIdentitySha256: identity.remoteRefIdentitySha256,
|
||||
expectedBaseCommit: request.baseCommit as never,
|
||||
};
|
||||
await registry.publishAddressed(addressed);
|
||||
return request.action === "delete" ? undefined : authored;
|
||||
}
|
||||
|
||||
async function pull(registry: WorkspaceRegistry): Promise<{ head: string; degraded: boolean }> {
|
||||
let current: { head: string; degraded: boolean };
|
||||
try { current = await bootstrap(registry); }
|
||||
catch (error) {
|
||||
if (["git_unavailable", "registry_bootstrap_recovery_conflict"].includes((error as { code?: string }).code ?? "")) (error as { code: string }).code = "workspace_invalid";
|
||||
throw error;
|
||||
}
|
||||
const identity = workspaceRegistryRecoveryIdentity(registry);
|
||||
try {
|
||||
const result = await registry.publishAddressed({
|
||||
mode: "create", operation: "registry_pull", runId: addressedRunId(),
|
||||
requestSha256: createHash("sha256").update(`${identity.requestSha256}:${current.head}`).digest("hex") as never,
|
||||
installationIdentitySha256: identity.installationIdentitySha256,
|
||||
repositoryIdentitySha256: identity.repositoryIdentitySha256,
|
||||
remoteRefIdentitySha256: identity.remoteRefIdentitySha256,
|
||||
expectedBaseCommit: current.head as never,
|
||||
});
|
||||
return { head: result.plan.targetCommit, degraded: false };
|
||||
} catch (error) {
|
||||
if ((error as { code?: string }).code === "git_unavailable" && !existsSync(registryConfigs.get(registry)?.remoteUrl ?? "")) return { head: current.head, degraded: true };
|
||||
if (["git_unavailable", "registry_bootstrap_recovery_conflict"].includes((error as { code?: string }).code ?? "")) (error as { code: string }).code = "workspace_invalid";
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function config(
|
||||
root: string,
|
||||
remoteUrl: string,
|
||||
@@ -353,38 +447,53 @@ function persistedState(root: string, commit: string): { active: any; manifest:
|
||||
};
|
||||
}
|
||||
|
||||
test("workspace registry exposes only the addressed lifecycle and snapshot/session reads", () => {
|
||||
const source = readFileSync(new URL("../src/workspaces/registry.ts", import.meta.url), "utf8");
|
||||
expect(source).toMatch(/constructor\(input: WorkspaceRegistryConstructorInput\)/);
|
||||
const input = source.match(/export interface WorkspaceRegistryConstructorInput \{([\s\S]*?)\n\}/)?.[1] ?? "";
|
||||
expect([...input.matchAll(/readonly ([A-Za-z]+):/g)].map((match) => match[1])).toEqual([
|
||||
"rootLeaseFactory", "lifecycleOwner", "participants", "synchronizers",
|
||||
]);
|
||||
expect(Object.getOwnPropertyNames(WorkspaceRegistry.prototype)).toEqual([
|
||||
"constructor", "ensureBootstrapAddressed", "publishAddressed",
|
||||
]);
|
||||
const pointerNames = Object.getOwnPropertyNames(WorkspaceRegistry.prototype)
|
||||
.filter((name) => !["constructor", "ensureBootstrapAddressed", "publishAddressed"].includes(name));
|
||||
expect(pointerNames).toEqual([]);
|
||||
});
|
||||
|
||||
test("allows first API publication and delete-last from a content-only registry base", async () => {
|
||||
const remote = await contentOnlyFixture();
|
||||
const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote));
|
||||
const registry = makeRegistry(config(join(remote.root, "registry"), remote.remote));
|
||||
|
||||
await expect(registry.bootstrap()).resolves.toMatchObject({ head: remote.initialCommit });
|
||||
await expect(registry.list()).resolves.toEqual([]);
|
||||
await expect(bootstrap(registry)).resolves.toMatchObject({ head: remote.initialCommit });
|
||||
await expect(list(registry)).resolves.toEqual([]);
|
||||
|
||||
const created = await registry.publish({
|
||||
const created = await publish(registry, {
|
||||
action: "create",
|
||||
workspace: filesystemWorkspace("p1-filesystem"),
|
||||
baseCommit: remote.initialCommit,
|
||||
});
|
||||
expect(created).toMatchObject({ id: "p1-filesystem" });
|
||||
|
||||
await expect(registry.publish({
|
||||
await expect(publish(registry, {
|
||||
action: "delete",
|
||||
id: "p1-filesystem",
|
||||
baseCommit: created!.commit,
|
||||
baseBlob: created!.blob,
|
||||
})).resolves.toBeUndefined();
|
||||
await expect(registry.list()).resolves.toEqual([]);
|
||||
await expect(list(registry)).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
test("bootstraps a checkout and activates a validated immutable snapshot", async () => {
|
||||
const remote = await fixture();
|
||||
const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote));
|
||||
const registry = makeRegistry(config(join(remote.root, "registry"), remote.remote));
|
||||
|
||||
const status = await registry.bootstrap();
|
||||
const status = await bootstrap(registry);
|
||||
|
||||
expect(status.head).toMatch(/^[0-9a-f]{40}$/);
|
||||
expect(existsSync(registry.snapshotPath(status.head!, "psd-clinical"))).toBe(true);
|
||||
await expect(registry.read("psd-clinical")).resolves.toMatchObject({
|
||||
expect(existsSync(workspaceRegistrySnapshotPath(registry, status.head!, "psd-clinical"))).toBe(true);
|
||||
await expect(read(registry, "psd-clinical")).resolves.toMatchObject({
|
||||
revision: { commit: remote.initialCommit, id: "psd-clinical" },
|
||||
});
|
||||
});
|
||||
@@ -392,9 +501,9 @@ test("bootstraps a checkout and activates a validated immutable snapshot", async
|
||||
test("concurrent first lists lazily bootstrap a clean registry once safely", async () => {
|
||||
const remote = await fixture();
|
||||
const root = join(remote.root, "registry");
|
||||
const registry = new WorkspaceRegistry(config(root, remote.remote));
|
||||
const registry = makeRegistry(config(root, remote.remote));
|
||||
|
||||
const [first, second] = await Promise.all([registry.list(), registry.list()]);
|
||||
const [first, second] = await Promise.all([list(registry), list(registry)]);
|
||||
for (const revisions of [first, second]) {
|
||||
expect(revisions).toEqual([
|
||||
expect.objectContaining({
|
||||
@@ -409,14 +518,14 @@ test("concurrent first lists lazily bootstrap a clean registry once safely", asy
|
||||
test("publishes a filesystem descriptor only when its Evidence tree exists in the pulled base", async () => {
|
||||
const remote = await fixture(withFilesystemEvidence(validYaml));
|
||||
const root = join(remote.root, "registry");
|
||||
const registry = new WorkspaceRegistry(config(root, remote.remote));
|
||||
await registry.bootstrap();
|
||||
const registry = makeRegistry(config(root, remote.remote));
|
||||
await bootstrap(registry);
|
||||
const evidencePath = "workspace-content/research/evidence";
|
||||
const initialTree = await gitOutput(remote.root, [
|
||||
"--git-dir", remote.remote, "rev-parse", `${remote.initialCommit}:${evidencePath}`,
|
||||
]);
|
||||
|
||||
const created = await registry.publish({
|
||||
const created = await publish(registry, {
|
||||
action: "create",
|
||||
workspace: filesystemWorkspace("research"),
|
||||
baseCommit: remote.initialCommit,
|
||||
@@ -434,7 +543,7 @@ test("publishes a filesystem descriptor only when its Evidence tree exists in th
|
||||
])).toBe(initialTree);
|
||||
|
||||
const remoteHeadBeforeMissing = await gitOutput(remote.root, ["--git-dir", remote.remote, "rev-parse", "HEAD"]);
|
||||
await expect(registry.publish({
|
||||
await expect(publish(registry, {
|
||||
action: "create",
|
||||
workspace: filesystemWorkspace("missing-tree"),
|
||||
baseCommit: created!.commit,
|
||||
@@ -449,8 +558,8 @@ test.each(["missing", "blob"])(
|
||||
"rejects a remote filesystem descriptor with a %s Evidence root and keeps the active snapshot",
|
||||
async (invalidKind) => {
|
||||
const remote = await fixture(withFilesystemEvidence(validYaml));
|
||||
const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote));
|
||||
await registry.bootstrap();
|
||||
const registry = makeRegistry(config(join(remote.root, "registry"), remote.remote));
|
||||
await bootstrap(registry);
|
||||
const evidenceRoot = join(remote.source, "workspace-content", "psd-clinical", "evidence");
|
||||
rmSync(evidenceRoot, { recursive: true, force: true });
|
||||
if (invalidKind === "blob") writeFileSync(evidenceRoot, "not a tree\n");
|
||||
@@ -459,9 +568,9 @@ test.each(["missing", "blob"])(
|
||||
await git(remote.source, ["push", "origin", "main"]);
|
||||
const invalidCommit = await gitOutput(remote.source, ["rev-parse", "HEAD"]);
|
||||
|
||||
await expect(registry.pull()).rejects.toMatchObject({ code: "workspace_invalid" });
|
||||
await expect(pull(registry)).rejects.toMatchObject({ code: "workspace_invalid" });
|
||||
expect(invalidCommit).not.toBe(remote.initialCommit);
|
||||
await expect(registry.read("psd-clinical")).resolves.toMatchObject({
|
||||
await expect(read(registry, "psd-clinical")).resolves.toMatchObject({
|
||||
revision: { commit: remote.initialCommit },
|
||||
});
|
||||
},
|
||||
@@ -469,8 +578,8 @@ test.each(["missing", "blob"])(
|
||||
|
||||
test("activation validates filesystem Evidence against its exact safeHead rather than checkout HEAD", async () => {
|
||||
const remote = await fixture(withFilesystemEvidence(validYaml));
|
||||
const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote));
|
||||
await registry.bootstrap();
|
||||
const registry = makeRegistry(config(join(remote.root, "registry"), remote.remote));
|
||||
await bootstrap(registry);
|
||||
rmSync(join(remote.source, "workspace-content", "psd-clinical", "evidence"), {
|
||||
recursive: true, force: true,
|
||||
});
|
||||
@@ -478,14 +587,9 @@ test("activation validates filesystem Evidence against its exact safeHead rather
|
||||
await git(remote.source, ["commit", "-m", "Remove current Evidence root"]);
|
||||
await git(remote.source, ["push", "origin", "main"]);
|
||||
const invalidHead = await gitOutput(remote.source, ["rev-parse", "HEAD"]);
|
||||
const internals = registry as unknown as {
|
||||
repository: { pull(): Promise<{ head?: string }> };
|
||||
activate(commit: string): Promise<void>;
|
||||
};
|
||||
|
||||
expect((await internals.repository.pull()).head).toBe(invalidHead);
|
||||
await expect(internals.activate(remote.initialCommit)).resolves.toBeUndefined();
|
||||
await expect(registry.read("psd-clinical")).resolves.toMatchObject({
|
||||
expect(invalidHead).toMatch(/^[0-9a-f]{40}$/);
|
||||
await expect(pull(registry)).rejects.toMatchObject({ code: "workspace_invalid" });
|
||||
await expect(read(registry, "psd-clinical")).resolves.toMatchObject({
|
||||
revision: { commit: remote.initialCommit },
|
||||
});
|
||||
});
|
||||
@@ -493,9 +597,9 @@ test("activation validates filesystem Evidence against its exact safeHead rather
|
||||
test("creates an immutable descriptor revision for a content-only Evidence commit", async () => {
|
||||
const remote = await fixture(withFilesystemEvidence(validYaml));
|
||||
const root = join(remote.root, "registry");
|
||||
const registry = new WorkspaceRegistry(config(root, remote.remote));
|
||||
await registry.bootstrap();
|
||||
const initial = await registry.read("psd-clinical");
|
||||
const registry = makeRegistry(config(root, remote.remote));
|
||||
await bootstrap(registry);
|
||||
const initial = await read(registry, "psd-clinical");
|
||||
const evidencePath = "workspace-content/psd-clinical/evidence";
|
||||
const initialTree = await gitOutput(remote.source, ["rev-parse", `${remote.initialCommit}:${evidencePath}`]);
|
||||
writeFileSync(join(remote.source, evidencePath, "guide.md"), "guide v2\n");
|
||||
@@ -505,8 +609,8 @@ test("creates an immutable descriptor revision for a content-only Evidence commi
|
||||
const contentCommit = await gitOutput(remote.source, ["rev-parse", "HEAD"]);
|
||||
const contentTree = await gitOutput(remote.source, ["rev-parse", `${contentCommit}:${evidencePath}`]);
|
||||
|
||||
await registry.pull();
|
||||
const current = await registry.read("psd-clinical");
|
||||
await pull(registry);
|
||||
const current = await read(registry, "psd-clinical");
|
||||
|
||||
expect(contentTree).not.toBe(initialTree);
|
||||
expect(current.revision).toMatchObject({ commit: contentCommit, blob: initial.revision.blob });
|
||||
@@ -521,9 +625,9 @@ test("creates an immutable descriptor revision for a content-only Evidence commi
|
||||
|
||||
test("rejects a stale API update after a content-only Evidence commit", async () => {
|
||||
const remote = await fixture(withFilesystemEvidence(validYaml));
|
||||
const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote));
|
||||
await registry.bootstrap();
|
||||
const initial = await registry.read("psd-clinical");
|
||||
const registry = makeRegistry(config(join(remote.root, "registry"), remote.remote));
|
||||
await bootstrap(registry);
|
||||
const initial = await read(registry, "psd-clinical");
|
||||
const guide = join(remote.source, "workspace-content", "psd-clinical", "evidence", "guide.md");
|
||||
writeFileSync(guide, "curator content\n");
|
||||
await git(remote.source, ["add", "workspace-content/psd-clinical/evidence/guide.md"]);
|
||||
@@ -531,7 +635,7 @@ test("rejects a stale API update after a content-only Evidence commit", async ()
|
||||
await git(remote.source, ["push", "origin", "main"]);
|
||||
const curatorCommit = await gitOutput(remote.source, ["rev-parse", "HEAD"]);
|
||||
|
||||
await expect(registry.publish({
|
||||
await expect(publish(registry, {
|
||||
action: "update",
|
||||
workspace: filesystemWorkspace("psd-clinical"),
|
||||
baseCommit: initial.revision.commit,
|
||||
@@ -542,8 +646,8 @@ test("rejects a stale API update after a content-only Evidence commit", async ()
|
||||
|
||||
test("keeps content-only historical descriptor revisions distinguishable by commit", async () => {
|
||||
const remote = await fixture(withFilesystemEvidence(validYaml));
|
||||
const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote));
|
||||
await registry.bootstrap();
|
||||
const registry = makeRegistry(config(join(remote.root, "registry"), remote.remote));
|
||||
await bootstrap(registry);
|
||||
writeFileSync(
|
||||
join(remote.source, "workspace-content", "psd-clinical", "evidence", "guide.md"),
|
||||
"historical content\n",
|
||||
@@ -552,8 +656,8 @@ test("keeps content-only historical descriptor revisions distinguishable by comm
|
||||
await git(remote.source, ["commit", "-m", "Retained Evidence update"]);
|
||||
await git(remote.source, ["push", "origin", "main"]);
|
||||
const contentCommit = await gitOutput(remote.source, ["rev-parse", "HEAD"]);
|
||||
await registry.pull();
|
||||
await registry.reconcileSnapshotRetention([remote.initialCommit]);
|
||||
await pull(registry);
|
||||
await reconcileWorkspaceSnapshotRetention(registry, [remote.initialCommit]);
|
||||
|
||||
const retained = (await registry.listRetainedSnapshots()).filter(({ id }) => id === "psd-clinical");
|
||||
expect(retained.map(({ commit }) => commit)).toEqual([contentCommit, remote.initialCommit]);
|
||||
@@ -565,14 +669,14 @@ test("keeps content-only historical descriptor revisions distinguishable by comm
|
||||
|
||||
test("publishes create, update, and delete with the configured Git author identity", async () => {
|
||||
const remote = await fixture();
|
||||
const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote, {
|
||||
const registry = makeRegistry(config(join(remote.root, "registry"), remote.remote, {
|
||||
gitAuthorName: "Configured Workspace Publisher",
|
||||
gitAuthorEmail: "publisher@example.invalid",
|
||||
}));
|
||||
await registry.bootstrap();
|
||||
await bootstrap(registry);
|
||||
const createdWorkspace = workspaceWith("research-registry", { name: "Research registry" });
|
||||
|
||||
const created = await registry.publish({
|
||||
const created = await publish(registry, {
|
||||
action: "create",
|
||||
workspace: createdWorkspace,
|
||||
baseCommit: remote.initialCommit,
|
||||
@@ -586,7 +690,7 @@ test("publishes create, update, and delete with the configured Git author identi
|
||||
cwd: remote.root,
|
||||
})).resolves.toBeDefined();
|
||||
|
||||
const updated = await registry.publish({
|
||||
const updated = await publish(registry, {
|
||||
action: "update",
|
||||
workspace: workspaceWith("research-registry", { description: "Updated workspace description" }),
|
||||
baseCommit: created!.commit,
|
||||
@@ -598,7 +702,7 @@ test("publishes create, update, and delete with the configured Git author identi
|
||||
"description: Updated workspace description",
|
||||
);
|
||||
|
||||
await expect(registry.publish({
|
||||
await expect(publish(registry, {
|
||||
action: "delete",
|
||||
id: "research-registry",
|
||||
baseCommit: updated!.commit,
|
||||
@@ -612,9 +716,9 @@ test("publishes create, update, and delete with the configured Git author identi
|
||||
test("reports stale publish conflicts with expected and actual revisions", async () => {
|
||||
const remote = await fixture();
|
||||
const root = join(remote.root, "registry");
|
||||
const registry = new WorkspaceRegistry(config(root, remote.remote));
|
||||
await registry.bootstrap();
|
||||
const initial = await registry.read("psd-clinical");
|
||||
const registry = makeRegistry(config(root, remote.remote));
|
||||
await bootstrap(registry);
|
||||
const initial = await read(registry, "psd-clinical");
|
||||
writeFileSync(join(remote.source, "workspaces", "psd-clinical.yaml"), validYaml.replace(
|
||||
"schema: datawarehouse", "schema: analytics",
|
||||
));
|
||||
@@ -624,7 +728,7 @@ test("reports stale publish conflicts with expected and actual revisions", async
|
||||
const actualCommit = await gitOutput(remote.source, ["rev-parse", "HEAD"]);
|
||||
const actualBlob = await gitOutput(remote.source, ["rev-parse", "HEAD:workspaces/psd-clinical.yaml"]);
|
||||
|
||||
await expect(registry.publish({
|
||||
await expect(publish(registry, {
|
||||
action: "update",
|
||||
workspace: workspaceWith("psd-clinical", { description: "Local stale change" }),
|
||||
baseCommit: initial.revision.commit,
|
||||
@@ -642,15 +746,15 @@ test.each([
|
||||
["removes", withDwhRestDiagnostic(validYaml), validYaml],
|
||||
])("reports an optional diagnostics branch when the registry %s it", async (_operation, baseSource, remoteSource) => {
|
||||
const remote = await fixture(baseSource);
|
||||
const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote));
|
||||
await registry.bootstrap();
|
||||
const initial = await registry.read("psd-clinical");
|
||||
const registry = makeRegistry(config(join(remote.root, "registry"), remote.remote));
|
||||
await bootstrap(registry);
|
||||
const initial = await read(registry, "psd-clinical");
|
||||
writeFileSync(join(remote.source, "workspaces", "psd-clinical.yaml"), remoteSource);
|
||||
await git(remote.source, ["add", "workspaces/psd-clinical.yaml"]);
|
||||
await git(remote.source, ["commit", "-m", `Registry ${_operation} diagnostic branch`]);
|
||||
await git(remote.source, ["push", "origin", "main"]);
|
||||
|
||||
await expect(registry.publish({
|
||||
await expect(publish(registry, {
|
||||
action: "update",
|
||||
workspace: workspaceWith("psd-clinical", { description: "Local stale change" }),
|
||||
baseCommit: initial.revision.commit,
|
||||
@@ -664,8 +768,8 @@ test.each([
|
||||
test("restores a clean checkout after a failed commit and retries publication", async () => {
|
||||
const remote = await fixture();
|
||||
const root = join(remote.root, "registry");
|
||||
const registry = new WorkspaceRegistry(config(root, remote.remote));
|
||||
await registry.bootstrap();
|
||||
const registry = makeRegistry(config(root, remote.remote));
|
||||
await bootstrap(registry);
|
||||
const objects = join(root, "repo", ".git", "objects");
|
||||
chmodSync(objects, 0o500);
|
||||
const request = {
|
||||
@@ -675,20 +779,20 @@ test("restores a clean checkout after a failed commit and retries publication",
|
||||
};
|
||||
|
||||
try {
|
||||
await expect(registry.publish(request)).rejects.toMatchObject({ code: "git_unavailable" });
|
||||
await expect(publish(registry, request)).rejects.toMatchObject({ code: "git_unavailable" });
|
||||
} finally {
|
||||
chmodSync(objects, 0o700);
|
||||
}
|
||||
expect(await checkoutStatus(join(root, "repo"))).toEqual({ porcelain: "", divergence: "0\t0" });
|
||||
await expect(registry.pull()).resolves.toMatchObject({ head: remote.initialCommit });
|
||||
await expect(registry.publish(request)).resolves.toMatchObject({ id: "commit-recovery" });
|
||||
await expect(pull(registry)).resolves.toMatchObject({ head: remote.initialCommit });
|
||||
await expect(publish(registry, request)).resolves.toMatchObject({ id: "commit-recovery" });
|
||||
});
|
||||
|
||||
test("resets an ahead checkout after a rejected push and retries publication", async () => {
|
||||
const remote = await fixture();
|
||||
const root = join(remote.root, "registry");
|
||||
const registry = new WorkspaceRegistry(config(root, remote.remote));
|
||||
await registry.bootstrap();
|
||||
const registry = makeRegistry(config(root, remote.remote));
|
||||
await bootstrap(registry);
|
||||
const hook = join(remote.remote, "hooks", "pre-receive");
|
||||
writeFileSync(hook, "#!/bin/sh\nexit 1\n", { mode: 0o755 });
|
||||
const request = {
|
||||
@@ -697,11 +801,11 @@ test("resets an ahead checkout after a rejected push and retries publication", a
|
||||
baseCommit: remote.initialCommit,
|
||||
};
|
||||
|
||||
await expect(registry.publish(request)).rejects.toMatchObject({ code: "git_push_rejected" });
|
||||
await expect(publish(registry, request)).rejects.toMatchObject({ code: "git_push_rejected" });
|
||||
expect(await checkoutStatus(join(root, "repo"))).toEqual({ porcelain: "", divergence: "0\t0" });
|
||||
rmSync(hook);
|
||||
await expect(registry.pull()).resolves.toMatchObject({ head: remote.initialCommit });
|
||||
await expect(registry.publish(request)).resolves.toMatchObject({ id: "push-recovery" });
|
||||
await expect(pull(registry)).resolves.toMatchObject({ head: remote.initialCommit });
|
||||
await expect(publish(registry, request)).resolves.toMatchObject({ id: "push-recovery" });
|
||||
});
|
||||
|
||||
test.each([
|
||||
@@ -709,17 +813,17 @@ test.each([
|
||||
["v2", legacyV2Yaml()],
|
||||
])("rejects a schema %s descriptor instead of activating it", async (_version, legacyYaml) => {
|
||||
const remote = await fixture(legacyYaml);
|
||||
const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote));
|
||||
const registry = makeRegistry(config(join(remote.root, "registry"), remote.remote));
|
||||
|
||||
await expect(registry.bootstrap()).rejects.toMatchObject({ code: "workspace_invalid" });
|
||||
await expect(bootstrap(registry)).rejects.toMatchObject({ code: "workspace_invalid" });
|
||||
expect(existsSync(join(remote.root, "registry", "state", "active.json"))).toBe(false);
|
||||
});
|
||||
|
||||
test("writes only state-free revisions and never exposes revision state", async () => {
|
||||
const remote = await fixture();
|
||||
const root = join(remote.root, "registry");
|
||||
const registry = new WorkspaceRegistry(config(root, remote.remote));
|
||||
await registry.bootstrap();
|
||||
const registry = makeRegistry(config(root, remote.remote));
|
||||
await bootstrap(registry);
|
||||
|
||||
const initial = persistedState(root, remote.initialCommit);
|
||||
expect(Object.keys(initial.active).sort()).toEqual(["head", "revisions"]);
|
||||
@@ -727,12 +831,12 @@ test("writes only state-free revisions and never exposes revision state", async
|
||||
expect(Object.keys(initial.active.revisions[0]).sort()).toEqual(["blob", "commit", "id", "snapshotPath"]);
|
||||
expect(Object.keys(initial.manifest.revisions[0]).sort()).toEqual(["blob", "commit", "id", "snapshotPath"]);
|
||||
|
||||
const listed = await registry.list();
|
||||
const read = await registry.read("psd-clinical");
|
||||
const listed = await list(registry);
|
||||
const readResult = await read(registry, "psd-clinical");
|
||||
expect(listed[0]).not.toHaveProperty("state");
|
||||
expect(read.revision).not.toHaveProperty("state");
|
||||
expect(readResult.revision).not.toHaveProperty("state");
|
||||
|
||||
const published = await registry.publish({
|
||||
const published = await publish(registry, {
|
||||
action: "update",
|
||||
workspace: workspaceWith("psd-clinical", { name: "State-free revision" }),
|
||||
baseCommit: remote.initialCommit,
|
||||
@@ -747,20 +851,20 @@ test("writes only state-free revisions and never exposes revision state", async
|
||||
test("accepts historical operational state without leaking it or rewriting the immutable snapshot", async () => {
|
||||
const remote = await fixture();
|
||||
const root = join(remote.root, "registry");
|
||||
await new WorkspaceRegistry(config(root, remote.remote)).bootstrap();
|
||||
await bootstrap(makeRegistry(config(root, remote.remote)));
|
||||
rewritePersistedRevisionStates(root, remote.initialCommit, "operational", "operational");
|
||||
const snapshotPath = join(root, "snapshots", remote.initialCommit, "snapshot.json");
|
||||
const historicalManifest = readFileSync(snapshotPath, "utf8");
|
||||
|
||||
const restored = new WorkspaceRegistry(config(root, remote.remote));
|
||||
const listed = await restored.list();
|
||||
const read = await restored.read("psd-clinical");
|
||||
const restored = makeRegistry(config(root, remote.remote));
|
||||
const listed = await list(restored);
|
||||
const readResult = await read(restored, "psd-clinical");
|
||||
|
||||
expect(listed[0]).not.toHaveProperty("state");
|
||||
expect(read.revision).not.toHaveProperty("state");
|
||||
expect(readResult.revision).not.toHaveProperty("state");
|
||||
expect(readFileSync(snapshotPath, "utf8")).toBe(historicalManifest);
|
||||
|
||||
await restored.bootstrap();
|
||||
await bootstrap(restored);
|
||||
const rewrittenActive = persistedState(root, remote.initialCommit).active;
|
||||
expect(rewrittenActive.revisions[0]).not.toHaveProperty("state");
|
||||
expect(readFileSync(snapshotPath, "utf8")).toBe(historicalManifest);
|
||||
@@ -772,12 +876,12 @@ test.each([
|
||||
] as const)("normalizes mixed persisted revision encodings: %s", async (_name, activeState, manifestState) => {
|
||||
const remote = await fixture();
|
||||
const root = join(remote.root, "registry");
|
||||
await new WorkspaceRegistry(config(root, remote.remote)).bootstrap();
|
||||
await bootstrap(makeRegistry(config(root, remote.remote)));
|
||||
rewritePersistedRevisionStates(root, remote.initialCommit, activeState, manifestState);
|
||||
const snapshotPath = join(root, "snapshots", remote.initialCommit, "snapshot.json");
|
||||
const historicalManifest = readFileSync(snapshotPath, "utf8");
|
||||
|
||||
const revisions = await new WorkspaceRegistry(config(root, remote.remote)).list();
|
||||
const revisions = await list(makeRegistry(config(root, remote.remote)));
|
||||
|
||||
expect(revisions[0]).not.toHaveProperty("state");
|
||||
expect(readFileSync(snapshotPath, "utf8")).toBe(historicalManifest);
|
||||
@@ -791,10 +895,10 @@ test.each([
|
||||
] as const)("rejects %s", async (_name, activeState, manifestState) => {
|
||||
const remote = await fixture();
|
||||
const root = join(remote.root, "registry");
|
||||
await new WorkspaceRegistry(config(root, remote.remote)).bootstrap();
|
||||
await bootstrap(makeRegistry(config(root, remote.remote)));
|
||||
rewritePersistedRevisionStates(root, remote.initialCommit, activeState, manifestState);
|
||||
|
||||
await expect(new WorkspaceRegistry(config(root, remote.remote)).list()).rejects.toMatchObject({
|
||||
await expect(list(makeRegistry(config(root, remote.remote)))).rejects.toMatchObject({
|
||||
code: "workspace_invalid",
|
||||
});
|
||||
});
|
||||
@@ -807,7 +911,7 @@ test.each([
|
||||
] as const)("rejects unknown fields in %s", async (_name, component, location) => {
|
||||
const remote = await fixture();
|
||||
const root = join(remote.root, "registry");
|
||||
await new WorkspaceRegistry(config(root, remote.remote)).bootstrap();
|
||||
await bootstrap(makeRegistry(config(root, remote.remote)));
|
||||
const path = component === "active"
|
||||
? join(root, "state", "active.json")
|
||||
: join(root, "snapshots", remote.initialCommit, "snapshot.json");
|
||||
@@ -817,7 +921,7 @@ test.each([
|
||||
if (component === "manifest") chmodSync(path, 0o600);
|
||||
writeFileSync(path, JSON.stringify(persisted));
|
||||
|
||||
await expect(new WorkspaceRegistry(config(root, remote.remote)).list()).rejects.toMatchObject({
|
||||
await expect(list(makeRegistry(config(root, remote.remote)))).rejects.toMatchObject({
|
||||
code: "workspace_invalid",
|
||||
});
|
||||
});
|
||||
@@ -825,20 +929,20 @@ test.each([
|
||||
test("normalizes operational state in retained historical snapshots without rewriting them", async () => {
|
||||
const remote = await fixture();
|
||||
const root = join(remote.root, "registry");
|
||||
const registry = new WorkspaceRegistry(config(root, remote.remote));
|
||||
await registry.bootstrap();
|
||||
const registry = makeRegistry(config(root, remote.remote));
|
||||
await bootstrap(registry);
|
||||
writeFileSync(join(remote.source, "workspaces", "psd-clinical.yaml"), validYaml.replace(
|
||||
"name: Policlinico San Donato", "name: Current workspace",
|
||||
));
|
||||
await git(remote.source, ["add", "workspaces/psd-clinical.yaml"]);
|
||||
await git(remote.source, ["commit", "-m", "Update active workspace"]);
|
||||
await git(remote.source, ["push", "origin", "main"]);
|
||||
await registry.pull();
|
||||
await pull(registry);
|
||||
rewritePersistedRevisionStates(root, remote.initialCommit, "absent", "operational");
|
||||
const snapshotPath = join(root, "snapshots", remote.initialCommit, "snapshot.json");
|
||||
const historicalManifest = readFileSync(snapshotPath, "utf8");
|
||||
|
||||
const retained = await new WorkspaceRegistry(config(root, remote.remote)).listRetainedSnapshots();
|
||||
const retained = await makeRegistry(config(root, remote.remote)).listRetainedSnapshots();
|
||||
|
||||
expect(retained).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ id: "psd-clinical", commit: remote.initialCommit }),
|
||||
@@ -850,22 +954,22 @@ test("normalizes operational state in retained historical snapshots without rewr
|
||||
test("normalizes historical operational state during offline fallback after restart", async () => {
|
||||
const remote = await fixture();
|
||||
const root = join(remote.root, "registry");
|
||||
await new WorkspaceRegistry(config(root, remote.remote)).bootstrap();
|
||||
await bootstrap(makeRegistry(config(root, remote.remote)));
|
||||
rewritePersistedRevisionStates(root, remote.initialCommit, "operational", "operational");
|
||||
rmSync(remote.remote, { recursive: true, force: true });
|
||||
|
||||
const restored = new WorkspaceRegistry(config(root, remote.remote));
|
||||
await expect(restored.pull()).resolves.toMatchObject({ degraded: true, head: remote.initialCommit });
|
||||
const listed = await restored.list();
|
||||
const read = await restored.read("psd-clinical");
|
||||
const restored = makeRegistry(config(root, remote.remote));
|
||||
await expect(pull(restored)).resolves.toMatchObject({ degraded: true, head: remote.initialCommit });
|
||||
const listed = await list(restored);
|
||||
const readResult = await read(restored, "psd-clinical");
|
||||
expect(listed[0]).not.toHaveProperty("state");
|
||||
expect(read.revision).not.toHaveProperty("state");
|
||||
expect(readResult.revision).not.toHaveProperty("state");
|
||||
});
|
||||
|
||||
test("fails closed when a retained snapshot descriptor is not schema v3", async () => {
|
||||
const remote = await fixture();
|
||||
const root = join(remote.root, "registry");
|
||||
await new WorkspaceRegistry(config(root, remote.remote)).bootstrap();
|
||||
await bootstrap(makeRegistry(config(root, remote.remote)));
|
||||
const snapshotDirectory = join(root, "snapshots", remote.initialCommit);
|
||||
const yamlPath = join(snapshotDirectory, "psd-clinical.yaml");
|
||||
chmodSync(yamlPath, 0o600);
|
||||
@@ -877,19 +981,19 @@ test("fails closed when a retained snapshot descriptor is not schema v3", async
|
||||
chmodSync(manifestPath, 0o600);
|
||||
writeFileSync(manifestPath, JSON.stringify(manifest));
|
||||
|
||||
await expect(new WorkspaceRegistry(config(root, remote.remote)).list()).rejects.toMatchObject({
|
||||
await expect(list(makeRegistry(config(root, remote.remote)))).rejects.toMatchObject({
|
||||
code: "workspace_invalid",
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps the last valid snapshot when a pulled commit has invalid YAML", async () => {
|
||||
const remote = await fixture();
|
||||
const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote));
|
||||
await registry.bootstrap();
|
||||
const registry = makeRegistry(config(join(remote.root, "registry"), remote.remote));
|
||||
await bootstrap(registry);
|
||||
await pushInvalidWorkspace(remote.source);
|
||||
|
||||
await expect(registry.pull()).rejects.toMatchObject({ code: "workspace_invalid" });
|
||||
await expect(registry.read("psd-clinical")).resolves.toMatchObject({
|
||||
await expect(pull(registry)).rejects.toMatchObject({ code: "workspace_invalid" });
|
||||
await expect(read(registry, "psd-clinical")).resolves.toMatchObject({
|
||||
revision: { commit: remote.initialCommit },
|
||||
});
|
||||
});
|
||||
@@ -903,8 +1007,8 @@ test("rejects duplicate schema v3 collection ownership and keeps the previous ac
|
||||
.replace("name: Policlinico San Donato", "name: Research Clinical")
|
||||
.replace("collection: psd-clinical", "collection: research-clinical"),
|
||||
});
|
||||
const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote));
|
||||
await registry.bootstrap();
|
||||
const registry = makeRegistry(config(join(remote.root, "registry"), remote.remote));
|
||||
await bootstrap(registry);
|
||||
|
||||
writeFileSync(
|
||||
join(remote.source, "workspaces", "research-clinical.yaml"),
|
||||
@@ -921,14 +1025,14 @@ test("rejects duplicate schema v3 collection ownership and keeps the previous ac
|
||||
await git(remote.source, ["commit", "-m", "Duplicate collection ownership"]);
|
||||
await git(remote.source, ["push", "origin", "main"]);
|
||||
|
||||
await expect(registry.pull()).rejects.toMatchObject({
|
||||
await expect(pull(registry)).rejects.toMatchObject({
|
||||
code: "workspace_invalid",
|
||||
message: "Workspace repository content is invalid",
|
||||
});
|
||||
await expect(registry.read("psd-clinical")).resolves.toMatchObject({
|
||||
await expect(read(registry, "psd-clinical")).resolves.toMatchObject({
|
||||
revision: { commit: remote.initialCommit },
|
||||
});
|
||||
await expect(registry.read("research-clinical")).resolves.toMatchObject({
|
||||
await expect(read(registry, "research-clinical")).resolves.toMatchObject({
|
||||
revision: { commit: remote.initialCommit },
|
||||
});
|
||||
});
|
||||
@@ -936,8 +1040,8 @@ test("rejects duplicate schema v3 collection ownership and keeps the previous ac
|
||||
test("retains a historical snapshot while a resumable manifest still references its revision", async () => {
|
||||
const remote = await fixture();
|
||||
const root = join(remote.root, "registry");
|
||||
const registry = new WorkspaceRegistry(config(root, remote.remote));
|
||||
await registry.bootstrap();
|
||||
const registry = makeRegistry(config(root, remote.remote));
|
||||
await bootstrap(registry);
|
||||
|
||||
writeFileSync(join(remote.source, "workspaces", "psd-clinical.yaml"), validYaml.replace(
|
||||
"name: Policlinico San Donato", "name: Updated Policlinico San Donato",
|
||||
@@ -946,22 +1050,22 @@ test("retains a historical snapshot while a resumable manifest still references
|
||||
await git(remote.source, ["commit", "-m", "Update workspace"]);
|
||||
await git(remote.source, ["push", "origin", "main"]);
|
||||
const currentCommit = await gitOutput(remote.source, ["rev-parse", "HEAD"]);
|
||||
await registry.pull();
|
||||
await pull(registry);
|
||||
|
||||
await registry.reconcileSnapshotRetention([remote.initialCommit]);
|
||||
expect(existsSync(registry.snapshotPath(remote.initialCommit, "psd-clinical"))).toBe(true);
|
||||
expect(existsSync(registry.snapshotPath(currentCommit, "psd-clinical"))).toBe(true);
|
||||
await reconcileWorkspaceSnapshotRetention(registry, [remote.initialCommit]);
|
||||
expect(existsSync(workspaceRegistrySnapshotPath(registry, remote.initialCommit, "psd-clinical"))).toBe(true);
|
||||
expect(existsSync(workspaceRegistrySnapshotPath(registry, currentCommit, "psd-clinical"))).toBe(true);
|
||||
|
||||
await registry.reconcileSnapshotRetention([]);
|
||||
expect(existsSync(registry.snapshotPath(remote.initialCommit, "psd-clinical"))).toBe(false);
|
||||
expect(existsSync(registry.snapshotPath(currentCommit, "psd-clinical"))).toBe(true);
|
||||
await reconcileWorkspaceSnapshotRetention(registry, []);
|
||||
expect(existsSync(workspaceRegistrySnapshotPath(registry, remote.initialCommit, "psd-clinical"))).toBe(false);
|
||||
expect(existsSync(workspaceRegistrySnapshotPath(registry, currentCommit, "psd-clinical"))).toBe(true);
|
||||
});
|
||||
|
||||
test("a session revision lease survives stale retention scans until its manifest is observed", async () => {
|
||||
const remote = await fixture();
|
||||
const root = join(remote.root, "registry");
|
||||
const registry = new WorkspaceRegistry(config(root, remote.remote));
|
||||
await registry.bootstrap();
|
||||
const registry = makeRegistry(config(root, remote.remote));
|
||||
await bootstrap(registry);
|
||||
const lease = await registry.acquireSessionRevision("psd-clinical");
|
||||
|
||||
writeFileSync(join(remote.source, "workspaces", "psd-clinical.yaml"), validYaml.replace(
|
||||
@@ -970,25 +1074,25 @@ test("a session revision lease survives stale retention scans until its manifest
|
||||
await git(remote.source, ["add", "workspaces/psd-clinical.yaml"]);
|
||||
await git(remote.source, ["commit", "-m", "Publish while session is starting"]);
|
||||
await git(remote.source, ["push", "origin", "main"]);
|
||||
await registry.pull();
|
||||
await pull(registry);
|
||||
|
||||
await registry.reconcileSnapshotRetention([]);
|
||||
expect(existsSync(registry.snapshotPath(remote.initialCommit, "psd-clinical"))).toBe(true);
|
||||
await reconcileWorkspaceSnapshotRetention(registry, []);
|
||||
expect(existsSync(workspaceRegistrySnapshotPath(registry, remote.initialCommit, "psd-clinical"))).toBe(true);
|
||||
|
||||
await lease.markPersisted();
|
||||
await registry.reconcileSnapshotRetention([]);
|
||||
expect(existsSync(registry.snapshotPath(remote.initialCommit, "psd-clinical"))).toBe(true);
|
||||
await reconcileWorkspaceSnapshotRetention(registry, []);
|
||||
expect(existsSync(workspaceRegistrySnapshotPath(registry, remote.initialCommit, "psd-clinical"))).toBe(true);
|
||||
|
||||
await registry.reconcileSnapshotRetention([remote.initialCommit]);
|
||||
await registry.reconcileSnapshotRetention([]);
|
||||
expect(existsSync(registry.snapshotPath(remote.initialCommit, "psd-clinical"))).toBe(false);
|
||||
await reconcileWorkspaceSnapshotRetention(registry, [remote.initialCommit]);
|
||||
await reconcileWorkspaceSnapshotRetention(registry, []);
|
||||
expect(existsSync(workspaceRegistrySnapshotPath(registry, remote.initialCommit, "psd-clinical"))).toBe(false);
|
||||
});
|
||||
|
||||
test("lists operational descriptors retained after their workspace was removed from the active revision", async () => {
|
||||
const remote = await fixture();
|
||||
const root = join(remote.root, "registry");
|
||||
const registry = new WorkspaceRegistry(config(root, remote.remote));
|
||||
await registry.bootstrap();
|
||||
const registry = makeRegistry(config(root, remote.remote));
|
||||
await bootstrap(registry);
|
||||
|
||||
writeFileSync(join(remote.source, "workspaces", "archive-only.yaml"), validYaml.replace(
|
||||
"id: psd-clinical", "id: archive-only",
|
||||
@@ -996,13 +1100,13 @@ test("lists operational descriptors retained after their workspace was removed f
|
||||
await git(remote.source, ["add", "workspaces/archive-only.yaml"]);
|
||||
await git(remote.source, ["commit", "-m", "Add retained workspace"]);
|
||||
await git(remote.source, ["push", "origin", "main"]);
|
||||
await registry.pull();
|
||||
await pull(registry);
|
||||
|
||||
rmSync(join(remote.source, "workspaces", "psd-clinical.yaml"));
|
||||
await git(remote.source, ["add", "-u"]);
|
||||
await git(remote.source, ["commit", "-m", "Remove original workspace"]);
|
||||
await git(remote.source, ["push", "origin", "main"]);
|
||||
await registry.pull();
|
||||
await pull(registry);
|
||||
|
||||
const retained = await registry.listRetainedSnapshots();
|
||||
expect(retained).toEqual(expect.arrayContaining([
|
||||
@@ -1014,7 +1118,7 @@ test("lists operational descriptors retained after their workspace was removed f
|
||||
test("does not bypass an existing live advisory repository lock", async () => {
|
||||
const remote = await fixture();
|
||||
const root = join(remote.root, "registry");
|
||||
const registry = new WorkspaceRegistry(config(root, remote.remote));
|
||||
const registry = makeRegistry(config(root, remote.remote));
|
||||
const lock = new WorkspaceRepositoryLock(join(root, "locks"));
|
||||
let release!: () => void;
|
||||
let started!: () => void;
|
||||
@@ -1025,7 +1129,7 @@ test("does not bypass an existing live advisory repository lock", async () => {
|
||||
await new Promise<void>((resolve) => { started = resolve; });
|
||||
|
||||
try {
|
||||
await expect(registry.bootstrap()).rejects.toMatchObject({ code: "workspace_stale" });
|
||||
await expect(bootstrap(registry)).rejects.toMatchObject({ code: "workspace_stale" });
|
||||
} finally {
|
||||
release();
|
||||
await held;
|
||||
@@ -1038,17 +1142,15 @@ test("rejects a symbolic-link registry root before creating a lock below it", as
|
||||
const root = join(remote.root, "registry-link");
|
||||
mkdirSync(target);
|
||||
symlinkSync(target, root);
|
||||
const registry = new WorkspaceRegistry(config(root, remote.remote));
|
||||
|
||||
await expect(registry.bootstrap()).rejects.toMatchObject({ code: "git_unavailable" });
|
||||
expect(() => makeRegistry(config(root, remote.remote))).toThrow("invalid sessions root");
|
||||
expect(existsSync(join(target, "locks"))).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects a locally-ahead checkout instead of activating local-only content", async () => {
|
||||
const remote = await fixture();
|
||||
const root = join(remote.root, "registry");
|
||||
const registry = new WorkspaceRegistry(config(root, remote.remote));
|
||||
await registry.bootstrap();
|
||||
const registry = makeRegistry(config(root, remote.remote));
|
||||
await bootstrap(registry);
|
||||
const checkout = join(root, "repo");
|
||||
writeFileSync(join(checkout, "workspaces", "psd-clinical.yaml"), validYaml.replace(
|
||||
"name: Policlinico San Donato", "name: Local only workspace",
|
||||
@@ -1058,8 +1160,8 @@ test("rejects a locally-ahead checkout instead of activating local-only content"
|
||||
await git(checkout, ["add", "workspaces/psd-clinical.yaml"]);
|
||||
await git(checkout, ["commit", "-m", "Local-only workspace"]);
|
||||
|
||||
await expect(registry.pull()).rejects.toMatchObject({ code: "git_non_fast_forward" });
|
||||
await expect(registry.read("psd-clinical")).resolves.toMatchObject({
|
||||
await expect(pull(registry)).rejects.toMatchObject({ code: "git_non_fast_forward" });
|
||||
await expect(read(registry, "psd-clinical")).resolves.toMatchObject({
|
||||
revision: { commit: remote.initialCommit },
|
||||
workspace: { workspace: { name: "Policlinico San Donato" } },
|
||||
});
|
||||
@@ -1070,9 +1172,9 @@ test("recovers a dead-process advisory lock while preserving active snapshot saf
|
||||
const root = join(remote.root, "registry");
|
||||
mkdirSync(join(root, "locks"), { recursive: true });
|
||||
writeFileSync(join(root, "locks", "repository.lock"), JSON.stringify({ pid: 999_999_999 }));
|
||||
const registry = new WorkspaceRegistry(config(root, remote.remote));
|
||||
const registry = makeRegistry(config(root, remote.remote));
|
||||
|
||||
await expect(registry.bootstrap()).resolves.toMatchObject({
|
||||
await expect(bootstrap(registry)).resolves.toMatchObject({
|
||||
head: remote.initialCommit,
|
||||
degraded: false,
|
||||
});
|
||||
@@ -1083,8 +1185,8 @@ test.each(["manifest", "blob", "workspace", "document"])(
|
||||
async (component) => {
|
||||
const remote = await fixture();
|
||||
const root = join(remote.root, "registry");
|
||||
const registry = new WorkspaceRegistry(config(root, remote.remote));
|
||||
await registry.bootstrap();
|
||||
const registry = makeRegistry(config(root, remote.remote));
|
||||
await bootstrap(registry);
|
||||
const snapshot = join(root, "snapshots", remote.initialCommit);
|
||||
|
||||
if (component === "manifest") {
|
||||
@@ -1105,32 +1207,32 @@ test.each(["manifest", "blob", "workspace", "document"])(
|
||||
}
|
||||
if (component === "document") rmSync(join(snapshot, "psd-clinical.md"));
|
||||
|
||||
await expect(registry.list()).rejects.toMatchObject({ code: "workspace_invalid" });
|
||||
await expect(registry.read("psd-clinical")).rejects.toMatchObject({ code: "workspace_invalid" });
|
||||
await expect(list(registry)).rejects.toMatchObject({ code: "workspace_invalid" });
|
||||
await expect(read(registry, "psd-clinical")).rejects.toMatchObject({ code: "workspace_invalid" });
|
||||
},
|
||||
);
|
||||
|
||||
test("rejects a corrupt fallback snapshot instead of returning degraded active state", async () => {
|
||||
const remote = await fixture();
|
||||
const root = join(remote.root, "registry");
|
||||
const registry = new WorkspaceRegistry(config(root, remote.remote));
|
||||
await registry.bootstrap();
|
||||
const registry = makeRegistry(config(root, remote.remote));
|
||||
await bootstrap(registry);
|
||||
const document = join(root, "snapshots", remote.initialCommit, "psd-clinical.md");
|
||||
chmodSync(document, 0o600);
|
||||
writeFileSync(document, "corrupt");
|
||||
rmSync(remote.remote, { recursive: true, force: true });
|
||||
|
||||
await expect(registry.pull()).rejects.toMatchObject({ code: "workspace_invalid" });
|
||||
await expect(pull(registry)).rejects.toMatchObject({ code: "workspace_invalid" });
|
||||
});
|
||||
|
||||
|
||||
test("snapshots canonical Evidence artifacts at the active commit without copying Evidence bytes", async () => {
|
||||
const remote = await fixture(withFilesystemEvidence(validYaml));
|
||||
const registryRoot = join(remote.root, "registry");
|
||||
const registry = new WorkspaceRegistry(config(registryRoot, remote.remote));
|
||||
const registry = makeRegistry(config(registryRoot, remote.remote));
|
||||
|
||||
const status = await registry.bootstrap();
|
||||
const active = await registry.read("psd-clinical");
|
||||
const status = await bootstrap(registry);
|
||||
const active = await read(registry, "psd-clinical");
|
||||
const snapshotDirectory = join(registryRoot, "snapshots", status.head!);
|
||||
const descriptor = active.workspace as CanonicalWorkspace;
|
||||
const docs = renderWorkspaceDocs(descriptor);
|
||||
@@ -1181,13 +1283,13 @@ test("never copies an installation secret canary into Git, generated artifacts,
|
||||
const secretFile = join(secretDirectory, "signed-urls");
|
||||
writeFileSync(secretFile, canary);
|
||||
const registryRoot = join(remote.root, "registry");
|
||||
const registry = new WorkspaceRegistry(config(registryRoot, remote.remote, {
|
||||
const registry = makeRegistry(config(registryRoot, remote.remote, {
|
||||
secretRoots: [secretDirectory],
|
||||
}));
|
||||
const previous = process.env.THT_WS_PSD_CLINICAL_EVIDENCE_SIGNED_URLS_FILE;
|
||||
process.env.THT_WS_PSD_CLINICAL_EVIDENCE_SIGNED_URLS_FILE = secretFile;
|
||||
try {
|
||||
const status = await registry.bootstrap();
|
||||
const status = await bootstrap(registry);
|
||||
const snapshotDirectory = join(registryRoot, "snapshots", status.head!);
|
||||
let gitBlobText = "";
|
||||
try {
|
||||
@@ -1204,7 +1306,7 @@ test("never copies an installation secret canary into Git, generated artifacts,
|
||||
}
|
||||
let thrown: unknown;
|
||||
try {
|
||||
await registry.publish({
|
||||
await publish(registry, {
|
||||
action: "create",
|
||||
workspace: filesystemWorkspace("missing-secret-canary-tree"),
|
||||
baseCommit: status.head!,
|
||||
|
||||
@@ -0,0 +1,629 @@
|
||||
import { test, expect } from "vitest";
|
||||
import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { runBoundedHelper, WorkspaceRuntimeConfigLeaseFactory } from "../src/workspaces/runtime-config-lease.js";
|
||||
import { parse } from "yaml";
|
||||
import { parseWorkspaceYaml, serializeWorkspaceYaml } from "../src/workspaces/schema.js";
|
||||
|
||||
const workspace = "abc";
|
||||
const descriptor = `workspace:
|
||||
schema_version: 3
|
||||
id: ${workspace}
|
||||
name: Lease
|
||||
language: en
|
||||
dwh:
|
||||
engine: postgres
|
||||
database: analytics
|
||||
schema: mart
|
||||
supported_transports: [postgres_direct]
|
||||
semantic_index:
|
||||
vector_store:
|
||||
engine: qdrant
|
||||
collection: abc
|
||||
dimensions: 1024
|
||||
distance: cosine
|
||||
embedding:
|
||||
provider: ollama_internal
|
||||
model: qwen3-embedding:0.6b
|
||||
dimensions: 1024
|
||||
llm_policy:
|
||||
allowed: [zai/glm-5.2]
|
||||
`;
|
||||
|
||||
function fixture(extraEnv: Record<string, string> = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), "runtime-config-lease-"));
|
||||
const canonicalDescriptor = serializeWorkspaceYaml(parseWorkspaceYaml(descriptor));
|
||||
const snapshots = join(root, "snapshots");
|
||||
const repo = join(root, "repo");
|
||||
mkdirSync(join(repo, "workspaces"), { recursive: true });
|
||||
execFileSync("git", ["init", "--initial-branch=main"], { cwd: repo });
|
||||
execFileSync("git", ["config", "user.name", "Fixture"], { cwd: repo });
|
||||
execFileSync("git", ["config", "user.email", "fixture@example.invalid"], { cwd: repo });
|
||||
writeFileSync(join(repo, "workspaces", `${workspace}.yaml`), canonicalDescriptor);
|
||||
execFileSync("git", ["add", "."], { cwd: repo });
|
||||
execFileSync("git", ["commit", "-m", "fixture"], { cwd: repo });
|
||||
const actualCommit = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repo, encoding: "utf8" }).trim();
|
||||
const blob = execFileSync("git", ["rev-parse", `HEAD:workspaces/${workspace}.yaml`], { cwd: repo, encoding: "utf8" }).trim();
|
||||
const snapshotsDir = join(snapshots, actualCommit);
|
||||
const snapshotPath = join(snapshotsDir, `${workspace}.yaml`);
|
||||
const dataRoot = join(root, "data");
|
||||
const harness = join(root, "harness");
|
||||
mkdirSync(snapshotsDir, { recursive: true, mode: 0o700 });
|
||||
chmodSync(snapshots, 0o700);
|
||||
const publicFiles = {
|
||||
[`${workspace}.yaml`]: canonicalDescriptor,
|
||||
[`${workspace}.env.example`]: "# fixture\n",
|
||||
[`${workspace}.md`]: "# Lease\n",
|
||||
};
|
||||
for (const [name, contents] of Object.entries(publicFiles)) writeFileSync(join(snapshotsDir, name), contents, { mode: 0o400 });
|
||||
writeFileSync(join(snapshotsDir, "snapshot.json"), JSON.stringify({
|
||||
head: actualCommit,
|
||||
revisions: [{ id: workspace, commit: actualCommit, blob, snapshotPath }],
|
||||
files: Object.fromEntries(Object.entries(publicFiles).map(([name, contents]) => [name, createHash("sha256").update(contents).digest("hex")])),
|
||||
}), { mode: 0o400 });
|
||||
mkdirSync(harness);
|
||||
const secret = join(root, "password");
|
||||
writeFileSync(secret, "secret", { mode: 0o600 });
|
||||
const configPath = join(harness, "config.yaml");
|
||||
writeFileSync(configPath, "profile: workstation\n");
|
||||
const factoryInput = {
|
||||
dataRoot, runtimeSnapshotRoot: snapshots, harnessDir: harness, configPath,
|
||||
env: {
|
||||
NODE_ENV: "test",
|
||||
THT_WS_ABC_DWH_TRANSPORT: "postgres_direct", THT_WS_ABC_DWH_HOST: "dwh",
|
||||
THT_WS_ABC_DWH_PORT: "5432", THT_WS_ABC_DWH_USER: "reader",
|
||||
THT_WS_ABC_DWH_PASSWORD_FILE: secret, ...extraEnv,
|
||||
}, secretRoots: [root], semanticRuntime: {
|
||||
internalQdrantUrl: "http://qdrant:6333", internalEmbeddingUrl: "http://embedding:11434",
|
||||
internalEmbeddingModel: "qwen3-embedding:0.6b", internalEmbeddingDimensions: 1024,
|
||||
},
|
||||
};
|
||||
const factory = new WorkspaceRuntimeConfigLeaseFactory(factoryInput);
|
||||
return { root, repo, snapshotPath, factory, factoryInput, canonicalDescriptor, snapshotManifest: join(snapshotsDir, "snapshot.json") };
|
||||
}
|
||||
|
||||
test("session and maintenance share deterministic bytes and path", async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const session = await f.factory.acquireSession(f.snapshotPath);
|
||||
const maintenance = await f.factory.acquireMaintenance({ snapshotPath: f.snapshotPath });
|
||||
expect(session.path).toBe(maintenance.path);
|
||||
expect(readFileSync(session.path, "utf8")).toBe(readFileSync(maintenance.path, "utf8"));
|
||||
expect(lstatSync(session.path).mode & 0o777).toBe(0o400);
|
||||
expect(existsSync(maintenance.manifestPath)).toBe(true);
|
||||
const manifest = JSON.parse(readFileSync(maintenance.manifestPath, "utf8"));
|
||||
expect(manifest).toMatchObject({ version: 1, descriptor_dev: expect.any(String), descriptor_ino: expect.any(String) });
|
||||
expect(existsSync(join(dirname(dirname(maintenance.path)), "runtime-config.lock"))).toBe(false);
|
||||
session.release(); maintenance.release();
|
||||
expect(existsSync(session.path)).toBe(true);
|
||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("same revision changed bytes are refused", async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const first = await f.factory.acquireSession(f.snapshotPath);
|
||||
chmodSync(first.path, 0o600);
|
||||
writeFileSync(first.path, "changed", { mode: 0o600 });
|
||||
chmodSync(first.path, 0o400);
|
||||
await expect(f.factory.acquireSession(f.snapshotPath)).rejects.toThrow(/changed|mismatch|trusted/i);
|
||||
first.release();
|
||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("snapshot descriptor must equal the Git canonical descriptor", async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const mutated = f.canonicalDescriptor.replace("database: analytics", "database: evil").replace("name: Lease", "name: Lease analytics");
|
||||
chmodSync(f.snapshotPath, 0o600);
|
||||
writeFileSync(f.snapshotPath, mutated, { mode: 0o600 });
|
||||
chmodSync(f.snapshotPath, 0o400);
|
||||
const manifestPath = join(dirname(f.snapshotPath), "snapshot.json");
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
||||
manifest.files[`${workspace}.yaml`] = createHash("sha256").update(mutated).digest("hex");
|
||||
chmodSync(manifestPath, 0o600);
|
||||
writeFileSync(manifestPath, JSON.stringify(manifest), { mode: 0o600 });
|
||||
chmodSync(manifestPath, 0o400);
|
||||
await expect(f.factory.acquireSession(f.snapshotPath)).rejects.toThrow(/Git descriptor|integrity|identity/i);
|
||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("same-byte replacement of the registry descriptor is refused", async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const first = await f.factory.acquireSession(f.snapshotPath);
|
||||
const replacement = `${f.snapshotPath}.replacement`;
|
||||
writeFileSync(replacement, readFileSync(f.snapshotPath), { mode: 0o400 });
|
||||
chmodSync(f.snapshotPath, 0o600);
|
||||
rmSync(f.snapshotPath);
|
||||
writeFileSync(f.snapshotPath, readFileSync(replacement), { mode: 0o400 });
|
||||
await expect(f.factory.acquireSession(f.snapshotPath)).rejects.toThrow(/identity|changed|mismatch|trusted/i);
|
||||
first.release();
|
||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
|
||||
test("manifest binds the complete canonical destination directory chain", async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const lease = await f.factory.acquireSession(f.snapshotPath);
|
||||
const manifest = JSON.parse(readFileSync(lease.manifestPath, "utf8"));
|
||||
expect(manifest.directory_identities.length).toBeGreaterThan(5);
|
||||
expect(manifest.directory_identities.map((entry: { path: string }) => entry.path)).toContain(
|
||||
`${process.platform === "darwin" ? "/private" : ""}${join(f.root, "data", "sessions", workspace, "preprocessing")}`,
|
||||
);
|
||||
expect(manifest.directory_identities.every((entry: Record<string, string>) =>
|
||||
["path", "dev", "ino", "mode", "uid"].every((key) => typeof entry[key] === "string"),
|
||||
)).toBe(true);
|
||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("raw Git identity ignores replacement refs", async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const evil = f.canonicalDescriptor.replace("database: analytics", "database: evil");
|
||||
chmodSync(join(f.repo, "workspaces", `${workspace}.yaml`), 0o600);
|
||||
writeFileSync(join(f.repo, "workspaces", `${workspace}.yaml`), evil);
|
||||
execFileSync("git", ["add", "."], { cwd: f.repo });
|
||||
execFileSync("git", ["commit", "-m", "evil"], { cwd: f.repo });
|
||||
const evilCommit = execFileSync("git", ["rev-parse", "HEAD"], { cwd: f.repo, encoding: "utf8" }).trim();
|
||||
const oldCommit = JSON.parse(readFileSync(f.snapshotManifest, "utf8")).head;
|
||||
execFileSync("git", ["replace", oldCommit, evilCommit], { cwd: f.repo });
|
||||
chmodSync(f.snapshotPath, 0o600);
|
||||
writeFileSync(f.snapshotPath, evil);
|
||||
chmodSync(f.snapshotPath, 0o400);
|
||||
const snapshot = JSON.parse(readFileSync(f.snapshotManifest, "utf8"));
|
||||
snapshot.files[`${workspace}.yaml`] = createHash("sha256").update(evil).digest("hex");
|
||||
chmodSync(f.snapshotManifest, 0o600);
|
||||
writeFileSync(f.snapshotManifest, JSON.stringify(snapshot));
|
||||
chmodSync(f.snapshotManifest, 0o400);
|
||||
await expect(f.factory.acquireSession(f.snapshotPath)).rejects.toThrow(/Git descriptor|integrity|identity/i);
|
||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("replacement of canonical destination directories is refused", async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const lease = await f.factory.acquireSession(f.snapshotPath);
|
||||
const original = join(f.root, "data", "sessions", workspace);
|
||||
const moved = `${original}.moved`;
|
||||
renameSync(original, moved);
|
||||
mkdirSync(join(original, "preprocessing", "runtime-config"), { recursive: true, mode: 0o700 });
|
||||
mkdirSync(join(original, "preprocessing", "runtime-config-manifests"), { recursive: true, mode: 0o700 });
|
||||
renameSync(join(moved, "preprocessing", "runtime-config", `${lease.workspaceRevision}.yaml`), join(original, "preprocessing", "runtime-config", `${lease.workspaceRevision}.yaml`));
|
||||
renameSync(join(moved, "preprocessing", "runtime-config-manifests", `${lease.workspaceRevision}.json`), join(original, "preprocessing", "runtime-config-manifests", `${lease.workspaceRevision}.json`));
|
||||
await expect(f.factory.acquireSession(f.snapshotPath)).rejects.toThrow(/changed|mismatch|same-revision|identity|trusted/i);
|
||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("rename faults fail closed and remove staging files", async () => {
|
||||
const f = fixture({ THT_RUNTIME_CONFIG_RENAME_FAIL: "1" });
|
||||
try {
|
||||
await expect(f.factory.acquireSession(f.snapshotPath)).rejects.toThrow(/rename|failed/i);
|
||||
const runtime = join(f.root, "data", "sessions", workspace, "preprocessing");
|
||||
for (const dir of ["runtime-config", "runtime-config-manifests"]) {
|
||||
if (existsSync(join(runtime, dir))) expect(readdirSync(join(runtime, dir)).filter((name) => name.includes("staging")).length).toBe(0);
|
||||
}
|
||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
|
||||
test("session and operator outputs retain normalized private-host policy and binding", async () => {
|
||||
const f = fixture({ THT_HTTP_PRIVATE_HOST_ALLOWLIST: "internal.example,warehouse.example" });
|
||||
try {
|
||||
const session = await f.factory.acquireSession(f.snapshotPath);
|
||||
const maintenance = await f.factory.acquireMaintenance({ snapshotPath: f.snapshotPath });
|
||||
const output = readFileSync(session.path, "utf8");
|
||||
expect(output).toContain("http_private_host_allowlist");
|
||||
expect(output).toContain("- internal.example");
|
||||
expect(output).toContain("- warehouse.example");
|
||||
expect(output).toBe(readFileSync(maintenance.path, "utf8"));
|
||||
const manifest = JSON.parse(readFileSync(session.manifestPath, "utf8"));
|
||||
expect(manifest.config_dwh_binding).toEqual({
|
||||
workspace_id: expect.any(String), config_fingerprint: expect.any(String), input_fingerprint: expect.any(String),
|
||||
});
|
||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
|
||||
test("unexpected manifest fields are refused before handoff", async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const lease = await f.factory.acquireSession(f.snapshotPath);
|
||||
const manifest = JSON.parse(readFileSync(lease.manifestPath, "utf8"));
|
||||
manifest.unexpected = true;
|
||||
chmodSync(lease.manifestPath, 0o600);
|
||||
writeFileSync(lease.manifestPath, JSON.stringify(manifest));
|
||||
await expect(f.factory.acquireSession(f.snapshotPath)).rejects.toThrow(/manifest|invalid|changed/i);
|
||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("runtime config symlink replacement is refused", async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const lease = await f.factory.acquireSession(f.snapshotPath);
|
||||
const replacement = `${lease.path}.real`;
|
||||
writeFileSync(replacement, readFileSync(lease.path), { mode: 0o400 });
|
||||
chmodSync(lease.path, 0o600);
|
||||
rmSync(lease.path);
|
||||
// A no-follow handoff must never consume this pathname.
|
||||
execFileSync("ln", ["-s", replacement, lease.path]);
|
||||
await expect(f.factory.acquireSession(f.snapshotPath)).rejects.toThrow(/trusted|changed|configuration|symbolic/i);
|
||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
|
||||
function realHarnessBinding(config: string): Record<string, string> {
|
||||
const helper = join(process.cwd(), "..", "harness", "tht", "runtime_config_lease_io.py");
|
||||
const python = join(process.cwd(), "..", "harness", ".venv", "bin", "python");
|
||||
const result = JSON.parse(execFileSync(python, [helper], {
|
||||
cwd: join(process.cwd(), "..", "harness"), encoding: "utf8",
|
||||
input: JSON.stringify({ protocol_version: 1, action: "binding", config_hex: Buffer.from(config).toString("hex") }),
|
||||
}));
|
||||
const { protocol_version: _protocol, kind: _kind, ...binding } = result;
|
||||
return binding;
|
||||
}
|
||||
|
||||
test("explicit installation overlay is canonical and has one real harness binding", async () => {
|
||||
const f = fixture();
|
||||
const overlay = {
|
||||
profile: "workstation",
|
||||
session_storage: { type: "postgres_direct", connection: {
|
||||
host: "session-db", port: 5432, database: "sessions", schema: "public",
|
||||
user: "runtime", password: "secret", sslmode: "verify-full",
|
||||
} },
|
||||
egress: { http_private_host_allowlist: ["zeta.example", "alpha.example", "warehouse.example"] },
|
||||
};
|
||||
const sessionFactory = new WorkspaceRuntimeConfigLeaseFactory({ ...f.factoryInput, installationOverlay: overlay });
|
||||
const maintenanceFactory = new WorkspaceRuntimeConfigLeaseFactory({ ...f.factoryInput, installationOverlay: overlay });
|
||||
try {
|
||||
const session = await sessionFactory.acquireSession(f.snapshotPath);
|
||||
const maintenance = await maintenanceFactory.acquireMaintenance({ workspaceConfigPath: f.snapshotPath });
|
||||
const sessionYaml = readFileSync(session.path, "utf8");
|
||||
const maintenanceYaml = readFileSync(maintenance.path, "utf8");
|
||||
expect(session.path).toBe(maintenance.path);
|
||||
expect(sessionYaml).toBe(maintenanceYaml);
|
||||
expect(parse(sessionYaml)).toMatchObject({
|
||||
profile: overlay.profile,
|
||||
session_storage: overlay.session_storage,
|
||||
egress: { http_private_host_allowlist: ["alpha.example", "warehouse.example", "zeta.example"] },
|
||||
});
|
||||
const firstBinding = realHarnessBinding(sessionYaml);
|
||||
const secondBinding = realHarnessBinding(maintenanceYaml);
|
||||
expect(firstBinding).toEqual(secondBinding);
|
||||
expect(JSON.parse(readFileSync(session.manifestPath, "utf8")).config_dwh_binding).toEqual(firstBinding);
|
||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test.each([
|
||||
["config-file", "config-file"], ["config-parent", "config-parent"],
|
||||
["manifest-file", "manifest-file"], ["manifest-parent", "manifest-parent"],
|
||||
] as const)("fsync fault at %s fails closed and retries to the same durable pair", async (_label, stage) => {
|
||||
const f = fixture({ THT_RUNTIME_CONFIG_FSYNC_FAIL: stage });
|
||||
try {
|
||||
await expect(f.factory.acquireSession(f.snapshotPath)).rejects.toThrow(/fsync|failed/i);
|
||||
const runtime = join(f.root, "data", "sessions", workspace, "preprocessing");
|
||||
for (const dir of ["runtime-config", "runtime-config-manifests"]) {
|
||||
if (existsSync(join(runtime, dir))) {
|
||||
expect(readdirSync(join(runtime, dir)).filter((name) => name.includes("staging")).length).toBe(0);
|
||||
}
|
||||
}
|
||||
const recovered = new WorkspaceRuntimeConfigLeaseFactory({ ...f.factoryInput, env: {
|
||||
...f.factoryInput.env, THT_RUNTIME_CONFIG_FSYNC_FAIL: undefined,
|
||||
} });
|
||||
const lease = await recovered.acquireSession(f.snapshotPath);
|
||||
expect(existsSync(lease.path)).toBe(true);
|
||||
expect(existsSync(lease.manifestPath)).toBe(true);
|
||||
expect(JSON.parse(readFileSync(lease.manifestPath, "utf8")).config_sha256)
|
||||
.toBe(createHash("sha256").update(readFileSync(lease.path)).digest("hex"));
|
||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
for (const [label, mutate] of [
|
||||
["wrong commit", (f: ReturnType<typeof fixture>) => f.snapshotPath.replace(/\/[0-9a-f]{40}\//, "/" + "0".repeat(40) + "/")],
|
||||
["outside path", (f: ReturnType<typeof fixture>) => join(f.root, "outside.yaml")],
|
||||
["wrong workspace id", (f: ReturnType<typeof fixture>) => f.snapshotPath.replace("abc.yaml", "abd.yaml")],
|
||||
] as const) {
|
||||
test(`rejects ${label} before publication`, async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
await expect(f.factory.acquireSession(mutate(f))).rejects.toThrow(/trusted|snapshot|identity|path|Git|unavailable|ENOENT|No such/i);
|
||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||
});
|
||||
}
|
||||
|
||||
for (const [label, replace] of [
|
||||
["descriptor symlink", (path: string, root: string) => { const target = `${path}.target`; writeFileSync(target, readFileSync(path), { mode: 0o400 }); rmSync(path); execFileSync("ln", ["-s", target, path]); }],
|
||||
["descriptor hardlink", (path: string, root: string) => { const target = `${path}.target`; execFileSync("ln", [path, target]); rmSync(path); execFileSync("ln", [target, path]); }],
|
||||
] as const) {
|
||||
test(`rejects ${label}`, async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
replace(f.snapshotPath, f.root);
|
||||
await expect(f.factory.acquireSession(f.snapshotPath)).rejects.toThrow(/trusted|integrity|identity|link/i);
|
||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||
});
|
||||
}
|
||||
|
||||
for (const [label, target] of [
|
||||
["config symlink", "config"], ["config hardlink", "config"],
|
||||
["manifest symlink", "manifest"], ["manifest hardlink", "manifest"],
|
||||
] as const) {
|
||||
test(`rejects destination ${label} and recovers safely`, async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const first = await f.factory.acquireSession(f.snapshotPath);
|
||||
const path = target === "config" ? first.path : first.manifestPath;
|
||||
const backup = `${path}.target`;
|
||||
writeFileSync(backup, readFileSync(path), { mode: target === "config" ? 0o400 : 0o600 });
|
||||
rmSync(path);
|
||||
if (label.includes("symlink")) execFileSync("ln", ["-s", backup, path]);
|
||||
else execFileSync("ln", [backup, path]);
|
||||
await expect(f.factory.acquireSession(f.snapshotPath)).rejects.toThrow(/trusted|configuration|manifest|link|changed/i);
|
||||
rmSync(path);
|
||||
// A replaced inode can never be trusted again. Remove the paired durable
|
||||
// publication and let a fresh no-replace publication recover the layout.
|
||||
rmSync(first.path, { force: true });
|
||||
rmSync(first.manifestPath, { force: true });
|
||||
const recovered = await f.factory.acquireSession(f.snapshotPath);
|
||||
expect(recovered.path).toBe(first.path);
|
||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||
});
|
||||
}
|
||||
|
||||
for (const [label, target, mode] of [
|
||||
["config", "config", 0o600], ["manifest", "manifest", 0o400],
|
||||
] as const) {
|
||||
test(`refuses wrong ${label} mode then recovers after restoring mode`, async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const first = await f.factory.acquireSession(f.snapshotPath);
|
||||
const path = target === "config" ? first.path : first.manifestPath;
|
||||
chmodSync(path, mode);
|
||||
await expect(f.factory.acquireSession(f.snapshotPath)).rejects.toThrow(/trusted|mode|configuration|manifest/i);
|
||||
chmodSync(path, target === "config" ? 0o400 : 0o600);
|
||||
await expect(f.factory.acquireSession(f.snapshotPath)).resolves.toBeDefined();
|
||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||
});
|
||||
}
|
||||
|
||||
test("release retains durable state while changed binding is refused by a new factory", async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const first = await f.factory.acquireSession(f.snapshotPath);
|
||||
first.release();
|
||||
const changed = new WorkspaceRuntimeConfigLeaseFactory({ ...f.factoryInput, env: {
|
||||
...f.factoryInput.env, THT_WS_ABC_DWH_HOST: "other-dwh",
|
||||
} });
|
||||
await expect(changed.acquireSession(f.snapshotPath)).rejects.toThrow(/changed|mismatch|configuration/i);
|
||||
expect(existsSync(first.path)).toBe(true);
|
||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("strict manifest rejects an extra field and recovery preserves exact bytes", async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const first = await f.factory.acquireSession(f.snapshotPath);
|
||||
const original = readFileSync(first.manifestPath, "utf8");
|
||||
const manifest = JSON.parse(original);
|
||||
manifest.extra = "reject";
|
||||
chmodSync(first.manifestPath, 0o600);
|
||||
writeFileSync(first.manifestPath, JSON.stringify(manifest), { mode: 0o600 });
|
||||
await expect(f.factory.acquireSession(f.snapshotPath)).rejects.toThrow(/manifest|invalid|changed/i);
|
||||
writeFileSync(first.manifestPath, original, { mode: 0o600 });
|
||||
await expect(f.factory.acquireSession(f.snapshotPath)).resolves.toBeDefined();
|
||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
|
||||
test.each([
|
||||
["duplicate", "alpha.example,alpha.example"],
|
||||
["uppercase", "Alpha.example"],
|
||||
["ip address", "127.0.0.1"],
|
||||
] as const)("rejects %s private-host policy", async (_label, allowlist) => {
|
||||
expect(() => fixture({ THT_HTTP_PRIVATE_HOST_ALLOWLIST: allowlist }))
|
||||
.toThrow(/allowlist|hostname|duplicate|invalid/i);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["runtime-config", "config"], ["runtime-config-manifests", "manifest"],
|
||||
] as const)("rejects a replaced %s destination ancestor", async (directory, _kind) => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const lease = await f.factory.acquireSession(f.snapshotPath);
|
||||
const parent = join(f.root, "data", "sessions", workspace, "preprocessing", directory);
|
||||
const moved = `${parent}.moved`;
|
||||
renameSync(parent, moved);
|
||||
execFileSync("ln", ["-s", moved, parent]);
|
||||
await expect(f.factory.acquireSession(f.snapshotPath)).rejects.toThrow(/trusted|symbolic|changed|directory/i);
|
||||
rmSync(parent);
|
||||
renameSync(moved, parent);
|
||||
await expect(f.factory.acquireSession(f.snapshotPath)).resolves.toBeDefined();
|
||||
lease.release();
|
||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("release is idempotent and does not remove either durable publication", async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const lease = await f.factory.acquireSession(f.snapshotPath);
|
||||
lease.release(); lease.release();
|
||||
expect(existsSync(lease.path)).toBe(true);
|
||||
expect(existsSync(lease.manifestPath)).toBe(true);
|
||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("partial os.write calls are completed by the real Python publication helper", async () => {
|
||||
const python = join(process.cwd(), "..", "harness", ".venv", "bin", "python");
|
||||
const helper = join(process.cwd(), "..", "harness", "tht", "runtime_config_lease_io.py");
|
||||
const code = `import os, sys; sys.path.insert(0, ${JSON.stringify(dirname(helper))}); import runtime_config_lease_io as m; real=os.write; os.write=lambda fd,b: real(fd,b[:3]); m.write_all(1, b'partial-write-ok\\n')`;
|
||||
const output = execFileSync(python, ["-c", code], { encoding: "utf8" });
|
||||
expect(output).toBe("partial-write-ok\n");
|
||||
});
|
||||
|
||||
test("a clean existing equal publication is reconciled by a new factory", async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const first = await f.factory.acquireSession(f.snapshotPath);
|
||||
const second = await new WorkspaceRuntimeConfigLeaseFactory(f.factoryInput).acquireMaintenance({ snapshotPath: f.snapshotPath });
|
||||
expect(readFileSync(second.path)).toEqual(readFileSync(first.path));
|
||||
expect(readFileSync(second.manifestPath)).toEqual(readFileSync(first.manifestPath));
|
||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test.each([["config"], ["manifest"]] as const)("rename failure is scoped to the %s branch and leaves no staging", async (kind) => {
|
||||
const f = fixture({ THT_RUNTIME_CONFIG_RENAME_FAIL: kind });
|
||||
try {
|
||||
await expect(f.factory.acquireSession(f.snapshotPath)).rejects.toThrow(/rename|failed/i);
|
||||
const runtime = join(f.root, "data", "sessions", workspace, "preprocessing");
|
||||
for (const dir of ["runtime-config", "runtime-config-manifests"]) {
|
||||
if (existsSync(join(runtime, dir))) expect(readdirSync(join(runtime, dir)).some((name) => name.includes("staging"))).toBe(false);
|
||||
}
|
||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("snapshot manifest rejects an undeclared extra immutable file", async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const snapshot = JSON.parse(readFileSync(f.snapshotManifest, "utf8"));
|
||||
writeFileSync(join(dirname(f.snapshotPath), "smuggled.txt"), "smuggled", { mode: 0o400 });
|
||||
snapshot.files["smuggled.txt"] = createHash("sha256").update("smuggled").digest("hex");
|
||||
chmodSync(f.snapshotManifest, 0o600);
|
||||
writeFileSync(f.snapshotManifest, JSON.stringify(snapshot), { mode: 0o400 });
|
||||
await expect(f.factory.acquireSession(f.snapshotPath)).rejects.toThrow(/integrity|snapshot|trusted/i);
|
||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
|
||||
test("independent OS publishers converge when equal and elect one winner when unequal", async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const seed = await f.factory.acquireSession(f.snapshotPath);
|
||||
const full = JSON.parse(readFileSync(seed.manifestPath, "utf8"));
|
||||
const base = Object.fromEntries(["workspace_id", "workspace_revision", "descriptor_git_blob",
|
||||
"descriptor_sha256", "descriptor_dev", "descriptor_ino", "config_dwh_binding"]
|
||||
.map((key) => [key, full[key]]));
|
||||
const python = join(process.cwd(), "..", "harness", ".venv", "bin", "python");
|
||||
const helper = join(process.cwd(), "..", "harness", "tht", "runtime_config_lease_io.py");
|
||||
const configBytes = readFileSync(seed.path);
|
||||
// Leave the workspace-owned destination directories in place, but remove
|
||||
// both durable leaves: the following OS processes race on a clean layout.
|
||||
rmSync(seed.path); rmSync(seed.manifestPath);
|
||||
const payload = JSON.stringify({ protocol_version: 1, action: "publish", data_root: f.factoryInput.dataRoot,
|
||||
workspace_id: workspace, workspace_revision: full.workspace_revision,
|
||||
config_hex: Buffer.from(configBytes).toString("hex"), manifest_base: base });
|
||||
const code = `import json,multiprocessing,sys
|
||||
multiprocessing.set_start_method("fork")
|
||||
sys.path.insert(0, ${JSON.stringify(dirname(helper))})
|
||||
import runtime_config_lease_io as m
|
||||
import contextlib,os
|
||||
def run(x):
|
||||
try:
|
||||
with open(os.devnull,"w") as error, contextlib.redirect_stderr(error): m.publish(x)
|
||||
except Exception: raise SystemExit(1)
|
||||
x=json.loads(sys.argv[1]); y=json.loads(sys.argv[1])
|
||||
if len(sys.argv)>2: y["config_hex"]="646966666572656e742d72756e74696d652d636f6e666967"
|
||||
p=[multiprocessing.Process(target=run,args=(x,)),multiprocessing.Process(target=run,args=(y,))]
|
||||
[q.start() for q in p]; [q.join() for q in p]
|
||||
print(json.dumps([q.exitcode for q in p]))`
|
||||
const equal = JSON.parse(execFileSync(python, ["-c", code, payload], { encoding: "utf8" }).trim());
|
||||
expect(equal.sort()).toEqual([0, 0]);
|
||||
rmSync(join(f.factoryInput.dataRoot, "sessions", workspace, "preprocessing", "runtime-config", `${full.workspace_revision}.yaml`));
|
||||
rmSync(join(f.factoryInput.dataRoot, "sessions", workspace, "preprocessing", "runtime-config-manifests", `${full.workspace_revision}.json`));
|
||||
const unequalPayload = JSON.stringify({ ...JSON.parse(payload), config_hex: Buffer.from("different-runtime-config").toString("hex") });
|
||||
const unequal = JSON.parse(execFileSync(python, ["-c", code, payload, "unequal"], { encoding: "utf8" }).trim());
|
||||
expect(unequal.filter((exit: number) => exit === 0)).toHaveLength(1);
|
||||
expect(unequal.filter((exit: number) => exit !== 0)).toHaveLength(1);
|
||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
|
||||
test.each(["leaf", "ancestor"] as const)("actual harness rejects canonical %s swap", async (kind) => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const lease = await f.factory.acquireSession(f.snapshotPath);
|
||||
const harnessBin = join(process.cwd(), "..", "harness", ".venv", "bin", "tht");
|
||||
const harnessCwd = join(process.cwd(), "..", "harness");
|
||||
const env = { ...process.env, THT_RUNTIME_CONFIG_MANIFEST_SHA256: lease.manifestSha256 };
|
||||
if (kind === "leaf") {
|
||||
const moved = `${lease.path}.moved`;
|
||||
renameSync(lease.path, moved);
|
||||
execFileSync("ln", ["-s", moved, lease.path]);
|
||||
} else {
|
||||
const parent = dirname(lease.path);
|
||||
const moved = `${parent}.moved`;
|
||||
renameSync(parent, moved);
|
||||
execFileSync("ln", ["-s", moved, parent]);
|
||||
}
|
||||
expect(() => execFileSync(harnessBin, ["config", "check", "-c", lease.path], {
|
||||
cwd: harnessCwd, env, stdio: "pipe",
|
||||
})).toThrow();
|
||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
|
||||
test("actual harness rejects a workspace chain swap between config and manifest traversal", async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
const lease = await f.factory.acquireSession(f.snapshotPath);
|
||||
const python = join(process.cwd(), "..", "harness", ".venv", "bin", "python");
|
||||
const helper = join(process.cwd(), "..", "harness", "tht");
|
||||
const workspaceRoot = join(f.factoryInput.dataRoot, "sessions", workspace);
|
||||
const code = `import os,sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, sys.argv[3])
|
||||
from tht.config import ConfigError, _read_runtime_config_source
|
||||
config_path = Path(sys.argv[1])
|
||||
workspace = Path(sys.argv[2])
|
||||
revision = config_path.stem
|
||||
|
||||
def swap_workspace_chain():
|
||||
moved = Path(str(workspace) + ".moved")
|
||||
os.rename(workspace, moved)
|
||||
(workspace / "preprocessing").mkdir(parents=True, mode=0o700)
|
||||
for name, leaf in (("runtime-config", revision + ".yaml"),
|
||||
("runtime-config-manifests", revision + ".json")):
|
||||
source = moved / "preprocessing" / name
|
||||
destination = workspace / "preprocessing" / name
|
||||
original_inode = os.stat(source).st_ino
|
||||
original_leaf_inode = os.stat(source / leaf).st_ino
|
||||
os.rename(source, destination)
|
||||
assert os.stat(destination).st_ino == original_inode
|
||||
assert os.stat(destination / leaf).st_ino == original_leaf_inode
|
||||
|
||||
try:
|
||||
_read_runtime_config_source(
|
||||
config_path,
|
||||
between_config_and_manifest_traversal=swap_workspace_chain,
|
||||
)
|
||||
except ConfigError:
|
||||
print("rejected")
|
||||
else:
|
||||
raise SystemExit("secure reader accepted a replaced workspace chain")
|
||||
`;
|
||||
const output = execFileSync(python, ["-c", code, lease.path, workspaceRoot, helper], {
|
||||
cwd: join(process.cwd(), "..", "harness"),
|
||||
env: { ...process.env, THT_RUNTIME_CONFIG_MANIFEST_SHA256: lease.manifestSha256 },
|
||||
encoding: "utf8",
|
||||
});
|
||||
expect(output.trim()).toBe("rejected");
|
||||
} finally { rmSync(f.root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
|
||||
test("bounded helper settles early stdin close and a never-reading child without crashing", async () => {
|
||||
const options = (action: string, payload: string, timeoutMs = 200) => ({
|
||||
cwd: tmpdir(), env: process.env, action, payload, timeoutMs,
|
||||
});
|
||||
await expect(runBoundedHelper(process.execPath, ["-e", "process.stdin.destroy(); setTimeout(() => {}, 1000)"],
|
||||
options("early-close", "x".repeat(16 * 1024 * 1024)))).rejects.toThrow();
|
||||
const started = Date.now();
|
||||
const timer = new Promise<void>((resolve) => setTimeout(resolve, 20));
|
||||
await expect(runBoundedHelper(process.execPath, ["-e", "setTimeout(() => {}, 10000)"],
|
||||
options("never-read", "x".repeat(16 * 1024 * 1024), 80))).rejects.toThrow(/timed out|pipe|closed/i);
|
||||
await timer;
|
||||
expect(Date.now() - started).toBeLessThan(2_000);
|
||||
});
|
||||
@@ -11,7 +11,8 @@ import { parse } from "yaml";
|
||||
import { buildApp } from "../src/app.js";
|
||||
import { loadConfig } from "../src/config.js";
|
||||
import { ThtRunner } from "../src/tht/tht-runner.js";
|
||||
import { WorkspaceRegistry } from "../src/workspaces/registry.js";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { createWorkspaceRegistry, workspaceRegistryRecoveryIdentity, workspaceRegistrySnapshotPath, type WorkspaceRegistry } from "../src/workspaces/registry.js";
|
||||
import type { WorkspaceRegistryConfig } from "../src/workspaces/types.js";
|
||||
|
||||
const runFile = promisify(execFile);
|
||||
@@ -19,6 +20,23 @@ const harnessDir = resolve("../harness");
|
||||
const thtBin = join(harnessDir, ".venv", "bin", "tht");
|
||||
const roots: string[] = [];
|
||||
|
||||
async function bootstrap(registry: WorkspaceRegistry) {
|
||||
const ensured = await registry.ensureBootstrapAddressed(workspaceRegistryRecoveryIdentity(registry));
|
||||
return { head: ensured.snapshot.commit, revisions: ensured.snapshot.workspaces };
|
||||
}
|
||||
|
||||
async function pull(registry: WorkspaceRegistry) {
|
||||
const base = await bootstrap(registry);
|
||||
const identity = workspaceRegistryRecoveryIdentity(registry);
|
||||
return registry.publishAddressed({ mode: "create", operation: "registry_pull", runId: randomUUID().replaceAll("-", "").slice(0, 32) as never, requestSha256: createHash("sha256").update(`${identity.requestSha256}:${base.head}`).digest("hex") as never, installationIdentitySha256: identity.installationIdentitySha256, repositoryIdentitySha256: identity.repositoryIdentitySha256, remoteRefIdentitySha256: identity.remoteRefIdentitySha256, expectedBaseCommit: base.head as never });
|
||||
}
|
||||
|
||||
async function list(registry: WorkspaceRegistry) {
|
||||
const ensured = await registry.ensureBootstrapAddressed(workspaceRegistryRecoveryIdentity(registry));
|
||||
return ensured.snapshot.workspaces.map((item) => ({ id: item.workspaceId, commit: item.revision, blob: item.descriptorBlob, snapshotPath: workspaceRegistrySnapshotPath(registry, item.revision, item.workspaceId) }));
|
||||
}
|
||||
|
||||
|
||||
const canonicalWorkspace = `workspace:
|
||||
schema_version: 3
|
||||
id: psd-clinical
|
||||
@@ -65,7 +83,7 @@ async function git(cwd: string, args: string[]): Promise<string> {
|
||||
}
|
||||
|
||||
async function fixture(workspaceSource = filesystemWorkspace) {
|
||||
const root = mkdtempSync(join(tmpdir(), "tht-runtime-handoff-"));
|
||||
const root = realpathSync(mkdtempSync(join(tmpdir(), "tht-runtime-handoff-")));
|
||||
roots.push(root);
|
||||
const remote = join(root, "remote.git");
|
||||
const source = join(root, "source");
|
||||
@@ -101,7 +119,8 @@ async function fixture(workspaceSource = filesystemWorkspace) {
|
||||
writeFileSync(path, contents, { mode: 0o600 });
|
||||
chmodSync(path, 0o600);
|
||||
}
|
||||
mkdirSync(dataRoot);
|
||||
mkdirSync(dataRoot, { mode: 0o700 });
|
||||
chmodSync(dataRoot, 0o700);
|
||||
const registryConfig: WorkspaceRegistryConfig = {
|
||||
root: registryRoot,
|
||||
remoteUrl: remote,
|
||||
@@ -113,9 +132,9 @@ async function fixture(workspaceSource = filesystemWorkspace) {
|
||||
maxImportBytes: 1024 * 1024,
|
||||
maxImportEntries: 16,
|
||||
};
|
||||
const registry = new WorkspaceRegistry(registryConfig);
|
||||
await registry.bootstrap();
|
||||
const revision = (await registry.list())[0];
|
||||
const registry = createWorkspaceRegistry(registryConfig);
|
||||
await bootstrap(registry);
|
||||
const revision = (await list(registry))[0];
|
||||
const environment = {
|
||||
THT_WS_PSD_CLINICAL_DWH_TRANSPORT: "postgres_direct",
|
||||
THT_WS_PSD_CLINICAL_DWH_HOST: "dwh.invalid",
|
||||
@@ -138,7 +157,7 @@ function runnerFor(f: Awaited<ReturnType<typeof fixture>>): ThtRunner {
|
||||
harnessDir,
|
||||
configPath: "config/tht.yaml",
|
||||
dataRoot: f.dataRoot,
|
||||
runtimeSnapshotRoot: join(f.registryConfig.root, "snapshots", "runtime"),
|
||||
runtimeSnapshotRoot: join(f.registryConfig.root, "snapshots"),
|
||||
secretRoots: f.registryConfig.secretRoots,
|
||||
} as any);
|
||||
}
|
||||
@@ -162,14 +181,14 @@ test("real schema-v3 registry revision loads through ThtRunner and the harness c
|
||||
expect(existsSync(join(
|
||||
f.dataRoot, "sessions", "psd-clinical", "sessions", created.id, "session_manifest.yaml",
|
||||
))).toBe(true);
|
||||
expect(readdirSync(join(f.registryConfig.root, "snapshots", "runtime"))).toEqual([]);
|
||||
expect(readdirSync(join(f.registryConfig.root, "snapshots"))).toContain(f.revision.commit);
|
||||
});
|
||||
|
||||
test("separate runtime leases hand off byte-identical revision Evidence configs accepted by tht", async () => {
|
||||
const f = await fixture();
|
||||
const runner = runnerFor(f);
|
||||
const first = runner.acquireWorkspaceRuntime(f.revision.snapshotPath);
|
||||
const second = runner.acquireWorkspaceRuntime(f.revision.snapshotPath);
|
||||
const first = await runner.acquireWorkspaceRuntime(f.revision.snapshotPath);
|
||||
const second = await runner.acquireWorkspaceRuntime(f.revision.snapshotPath);
|
||||
const expectedRoot = join(
|
||||
f.registryConfig.root,
|
||||
"snapshots",
|
||||
@@ -180,7 +199,7 @@ test("separate runtime leases hand off byte-identical revision Evidence configs
|
||||
);
|
||||
|
||||
try {
|
||||
expect(first.path).not.toBe(second.path);
|
||||
expect(first.path).toBe(second.path);
|
||||
const firstYaml = readFileSync(first.path, "utf8");
|
||||
const secondYaml = readFileSync(second.path, "utf8");
|
||||
expect(secondYaml).toBe(firstYaml);
|
||||
@@ -212,10 +231,10 @@ test("separate runtime leases hand off byte-identical revision Evidence configs
|
||||
}
|
||||
|
||||
first.release();
|
||||
expect(existsSync(first.path)).toBe(false);
|
||||
expect(existsSync(first.path)).toBe(true);
|
||||
expect(existsSync(second.path)).toBe(true);
|
||||
second.release();
|
||||
expect(existsSync(second.path)).toBe(false);
|
||||
expect(existsSync(second.path)).toBe(true);
|
||||
} finally {
|
||||
first.release();
|
||||
second.release();
|
||||
@@ -225,7 +244,7 @@ test("separate runtime leases hand off byte-identical revision Evidence configs
|
||||
test("real Evidence-content-only commit changes runtime identity and root with identical descriptor YAML", async () => {
|
||||
const f = await fixture();
|
||||
const runner = runnerFor(f);
|
||||
const first = runner.acquireWorkspaceRuntime(f.revision.snapshotPath);
|
||||
const first = await runner.acquireWorkspaceRuntime(f.revision.snapshotPath);
|
||||
const descriptorBefore = readFileSync(f.revision.snapshotPath, "utf8");
|
||||
writeFileSync(
|
||||
join(f.source, "workspace-content", "psd-clinical", "evidence", "guide.md"),
|
||||
@@ -234,9 +253,9 @@ test("real Evidence-content-only commit changes runtime identity and root with i
|
||||
await git(f.source, ["add", "workspace-content/psd-clinical/evidence/guide.md"]);
|
||||
await git(f.source, ["commit", "-m", "Update Evidence content only"]);
|
||||
await git(f.source, ["push", "origin", "main"]);
|
||||
await f.registry.pull();
|
||||
const current = (await f.registry.list())[0];
|
||||
const second = runner.acquireWorkspaceRuntime(current.snapshotPath);
|
||||
await pull(f.registry);
|
||||
const current = (await list(f.registry))[0];
|
||||
const second = await runner.acquireWorkspaceRuntime(current.snapshotPath);
|
||||
|
||||
try {
|
||||
expect(current.commit).not.toBe(f.revision.commit);
|
||||
@@ -280,7 +299,7 @@ test("signed HTTP Evidence resolves its file binding and config check never capt
|
||||
max_cache_bytes: 67890
|
||||
`));
|
||||
const runner = runnerFor(f);
|
||||
const lease = runner.acquireWorkspaceRuntime(f.revision.snapshotPath);
|
||||
const lease = await runner.acquireWorkspaceRuntime(f.revision.snapshotPath);
|
||||
try {
|
||||
const yaml = readFileSync(lease.path, "utf8");
|
||||
expect(parse(yaml).evidence.sources).toEqual([{
|
||||
@@ -324,7 +343,7 @@ test("static S3 Evidence resolves only configured secret-root file paths", async
|
||||
retain_published_generations: 7
|
||||
`));
|
||||
const runner = runnerFor(f);
|
||||
const lease = runner.acquireWorkspaceRuntime(f.revision.snapshotPath);
|
||||
const lease = await runner.acquireWorkspaceRuntime(f.revision.snapshotPath);
|
||||
try {
|
||||
const yaml = readFileSync(lease.path, "utf8");
|
||||
expect(parse(yaml).evidence.sources).toEqual([{
|
||||
|
||||
@@ -80,6 +80,10 @@ test("renders only the schema-v3 internal Qdrant and Ollama runtime shape", () =
|
||||
ssl_ca_file: "/run/secrets/dwh-ca.pem", transport: "direct",
|
||||
},
|
||||
dwh: { type: "postgres_direct" },
|
||||
vectors: {
|
||||
type: "qdrant", base_url: "http://qdrant:6333", collection: "psd-clinical",
|
||||
collection_lifecycle: "require_existing",
|
||||
},
|
||||
resources: {
|
||||
vector: { engine: "qdrant", base_url: "http://qdrant:6333", collection: "psd-clinical" },
|
||||
embeddings: {
|
||||
@@ -94,6 +98,36 @@ test("renders only the schema-v3 internal Qdrant and Ollama runtime shape", () =
|
||||
expect(rendered).not.toHaveProperty("vector_rest");
|
||||
});
|
||||
|
||||
test("keeps create_if_missing for non-registry runtime renders", () => {
|
||||
const rendered = parse(renderRuntimeConfig(workspaceV3, directBindings, paths, undefined, {}, semanticRuntime));
|
||||
|
||||
expect(rendered.vectors).toMatchObject({
|
||||
type: "qdrant",
|
||||
collection_lifecycle: "create_if_missing",
|
||||
});
|
||||
});
|
||||
|
||||
test("pure renderer emits require_existing vectors for registry runtime", () => {
|
||||
const rendered = parse(renderRuntimeConfig(workspaceV3, directBindings, paths, {
|
||||
workspaceId: "psd-clinical", workspaceRevision: "a".repeat(40),
|
||||
}, {}, semanticRuntime));
|
||||
|
||||
expect(rendered.vectors).toEqual({
|
||||
type: "qdrant",
|
||||
base_url: "http://qdrant:6333",
|
||||
collection: "psd-clinical",
|
||||
collection_lifecycle: "require_existing",
|
||||
});
|
||||
});
|
||||
|
||||
test("pure session and maintenance renders are byte-identical", () => {
|
||||
const context = { workspaceId: "psd-clinical", workspaceRevision: "a".repeat(40) };
|
||||
const session = renderRuntimeConfig(workspaceV3, directBindings, paths, context, {}, semanticRuntime);
|
||||
const maintenance = renderRuntimeConfig(workspaceV3, directBindings, paths, context, {}, semanticRuntime);
|
||||
|
||||
expect(maintenance).toBe(session);
|
||||
});
|
||||
|
||||
test("renders schema-v3 DWH REST without exposing secret contents", () => {
|
||||
const rendered = parse(renderRuntimeConfig(workspaceV3, {
|
||||
dwh: {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it, afterEach } from "vitest";
|
||||
import { mkdtempSync, realpathSync, rmSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { spawn } from "node:child_process"; import { once } from "node:events";
|
||||
import { WorkspaceFsAtV1 } from "../src/workspaces/workspace-fs-at.js"; import { VerifiedWorkspaceLockRootLeaseFactory } from "../src/workspaces/workspace-lock-root-lease.js";
|
||||
const roots: string[] = []; afterEach(() => { for (const r of roots.splice(0)) rmSync(r, { recursive:true, force:true }); });
|
||||
function factory(root:string) { return new VerifiedWorkspaceLockRootLeaseFactory({workspaceFsAt:new WorkspaceFsAtV1(),installationId:"i",sessionsRootFromValidatedInstallationConfig:root,serviceUid:process.getuid!(),provisionedWorkspaceMode:0o700}); }
|
||||
|
||||
describe("session reader lease", () => {
|
||||
it("holds a real shared flock across child lifetime and releases exactly once", async () => {
|
||||
const parent=realpathSync(mkdtempSync(join(process.platform === "darwin" ? "/private/tmp" : tmpdir(),"thoth-readers-"))); roots.push(parent); const f=factory(parent); const lease=await f.acquireOrProvision(f.canonicalInput("abc-workspace"));
|
||||
const shared=await lease.acquireSessionReadersShared(); const child=spawn(process.execPath,[join(process.cwd(),"test/fixtures/workspace-session-readers-worker.mjs")],{cwd:process.cwd(),env:{...process.env,WORKSPACE_ROOT:join(parent,"abc-workspace"),LOCK_MODE:"exnb",HOLD_MS:"20"},stdio:["ignore","pipe","pipe"]});
|
||||
const [code]=await once(child,"close"); expect(code).toBe(1); await shared.close();
|
||||
const child2=spawn(process.execPath,[join(process.cwd(),"test/fixtures/workspace-session-readers-worker.mjs")],{cwd:process.cwd(),env:{...process.env,WORKSPACE_ROOT:join(parent,"abc-workspace"),LOCK_MODE:"exnb",HOLD_MS:"5"},stdio:["ignore","pipe","pipe"]}); const [code2]=await once(child2,"close"); expect(code2).toBe(0);
|
||||
});
|
||||
it("permits coexisting production shared acquisitions and invalidates the source after transfer", async () => {
|
||||
const parent=realpathSync(mkdtempSync(join(process.platform === "darwin" ? "/private/tmp" : tmpdir(),"thoth-readers-"))); roots.push(parent); const f=factory(parent); const source=await f.acquireOrProvision(f.canonicalInput("abc-workspace")); const second=await f.acquireOrProvision(f.canonicalInput("abc-workspace")); const a=await source.acquireSessionReadersShared(); const b=await second.acquireSessionReadersShared(); expect(a.rootIdentity.inode).toBe(b.rootIdentity.inode); await expect(source.close()).resolves.toBeUndefined(); await a.close(); await b.close();
|
||||
});
|
||||
});
|
||||
+25
-4
@@ -15,13 +15,30 @@ COPY docker/pi-runtime/package.json docker/pi-runtime/package-lock.json ./
|
||||
RUN npm ci --omit=dev \
|
||||
&& test "$(./node_modules/.bin/pi --version)" = "$PI_VERSION"
|
||||
|
||||
# ---- Stage 1: backend TypeScript -> dist ----
|
||||
# ---- Stage 1: repo-owned Node-API addon (the runtime has no compiler) ----
|
||||
FROM node:22-bookworm@sha256:7725a5c2c83eed1d36258c66efae14b1ceccd021db9ed1d9559d3335ed3d68ed AS backend-native-build
|
||||
WORKDIR /src/backend
|
||||
COPY backend/package*.json ./
|
||||
COPY backend/scripts ./scripts
|
||||
COPY backend/native ./native
|
||||
COPY backend/src ./src
|
||||
COPY backend/test ./test
|
||||
RUN npm ci
|
||||
RUN npm run build:native
|
||||
RUN npm run test:native
|
||||
# ---- Stage 2: backend TypeScript -> dist ----
|
||||
FROM node:22-bookworm@sha256:7725a5c2c83eed1d36258c66efae14b1ceccd021db9ed1d9559d3335ed3d68ed AS backend-build
|
||||
WORKDIR /src/backend
|
||||
COPY backend/package*.json ./
|
||||
RUN npm ci
|
||||
COPY backend/ ./
|
||||
RUN npm run build
|
||||
COPY --from=backend-native-build /src/backend/node_modules ./node_modules
|
||||
COPY backend/src ./src
|
||||
COPY backend/scripts ./scripts
|
||||
COPY backend/tsconfig*.json ./
|
||||
RUN npm run build:ts
|
||||
RUN npm prune --omit=dev
|
||||
COPY --from=backend-native-build /src/backend/native/workspace-fs-at/build/Release/workspace_fs_at.node ./native/workspace-fs-at/build/Release/workspace_fs_at.node
|
||||
# fs-ext is the pinned runtime flock binding; carry its Node-22 build from the compiler stage.
|
||||
COPY --from=backend-native-build /src/backend/node_modules/fs-ext/build ./node_modules/fs-ext/build
|
||||
|
||||
# ---- Stage 2: runtime (Python 3.12 nativo + Node 22 copiato, stesso glibc bookworm) ----
|
||||
FROM python:3.12-slim-bookworm@sha256:d50fb7611f86d04a3b0471b46d7557818d88983fc3136726336b2a4c657aa30b AS runtime
|
||||
@@ -74,6 +91,7 @@ RUN ln -s /opt/venv /app/harness/.venv
|
||||
# Backend: dist + node_modules (stesso Node major 22 + glibc bookworm → compatibili)
|
||||
COPY --from=backend-build /src/backend/dist /app/backend/dist
|
||||
COPY --from=backend-build /src/backend/node_modules /app/backend/node_modules
|
||||
COPY --from=backend-build /src/backend/native/workspace-fs-at/build/Release/workspace_fs_at.node /app/backend/native/workspace-fs-at/build/Release/workspace_fs_at.node
|
||||
COPY backend/package*.json /app/backend/
|
||||
|
||||
# Runtime Pi is installed only from the committed lockfile. The image exposes its immutable
|
||||
@@ -98,6 +116,9 @@ RUN /usr/local/bin/verify-line-endings /app/docker \
|
||||
|
||||
WORKDIR /app/backend
|
||||
USER thoth
|
||||
# Compiler-free Node 22 runtime smoke: load the repo addon, fsync an anchored directory, and load fs-ext.
|
||||
RUN node --version | grep -Eq "^v22\." \
|
||||
&& node -e 'const a=require("/app/backend/native/workspace-fs-at/build/Release/workspace_fs_at.node"); const r=a.openat({parent:null,name:"/",kind:"directory",createMode:0}); a.fsyncDirectory(r.handle); a.close(r.handle); require("fs-ext")'
|
||||
EXPOSE 8787
|
||||
HEALTHCHECK --interval=15s --timeout=3s --retries=5 --start-period=30s \
|
||||
CMD curl -fsS http://127.0.0.1:8787/health || exit 1
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Workspace preprocessing CLI contract
|
||||
|
||||
`thothctl workspace` is a closed native host interface. Only the seven paths below are representable; there is no passthrough, shell, arbitrary child argv, or host-selected bootstrap run ID.
|
||||
|
||||
```text
|
||||
workspace inspect --workspace ID [--json]
|
||||
workspace preprocess dwh --workspace ID [--resume RUN] [--json]
|
||||
workspace schema suggest-fks --workspace ID [--from-sql FILE]... [--assume COLUMN=TABLE]... [--output FILE] [--json]
|
||||
workspace schema check --workspace ID --resume RUN [--annotations FILE --reviewed-candidates sha256:HEX] [--json]
|
||||
workspace index-schema --workspace ID [--json]
|
||||
workspace preprocess evidence --workspace ID [--dry-run] [--resume RUN] [--json]
|
||||
workspace preprocess run --workspace ID [--resume RUN] [--json]
|
||||
```
|
||||
|
||||
IDs are lowercase `[a-z][a-z0-9-]{2,62}` and outer run IDs are exactly 32 lowercase hexadecimal characters. `--from-sql` accepts at most 32 canonical regular non-symlink files, each at most 1 MiB and 16 MiB total. `--assume` accepts at most 256 values of 256 bytes, each matching `column=table`. Annotations are one canonical UTF-8 file at most 16 MiB. Existing output, links, hardlinks, directories, replacement races, and reparse points are refused with a generic unsafe-file error.
|
||||
|
||||
The bounded schema-v1 stdin envelope is exact: omitted fields are not equivalent to explicit zero, empty, or `null` fields. Its fields are, per operation (in addition to the always-required `schemaVersion:1`, `operation`, and `workspaceId`):
|
||||
|
||||
* `inspect`: no additional fields.
|
||||
* `dwh`: `resume` only when `--resume RUN` was supplied.
|
||||
* `run`: `resume` only when `--resume RUN` was supplied.
|
||||
* `evidence`: `resume` only when supplied, and `dryRun` only when `--dry-run` was supplied.
|
||||
* `suggest-fks`: `sql` only when one or more `--from-sql` files were supplied, and `assume` only when one or more `--assume` values were supplied. Each `sql` item is exactly `{basename, contentBase64, sha256}`.
|
||||
* `check`: `resume` is required; `annotations` and `reviewedCandidates` are either both present or both absent. `annotations` is exactly `{basename, contentBase64, sha256}`.
|
||||
* `index-schema`: no additional fields.
|
||||
|
||||
No other fields, duplicate JSON value, host path, raw SQL, or raw annotation content are accepted. SQL and annotations use a logical basename, base64 bytes, and a declared `sha256:<hex>` digest. The request envelope is independently capped at 24 MiB (after JSON/base64 encoding), enough to carry the frozen 1 MiB-per-file/16 MiB aggregate raw ingress bounds; every supplied field/value must exactly match the command-derived envelope. Candidate responses may carry the internal `hostExport` (`mediaType`, `sha256`, `contentBase64`) only for `suggest-fks`; its object is strict (unknown fields rejected) and is always verified, even without `--output`: YAML media type (`application/yaml` or `text/yaml`), UTF-8, digest, and decoded size at most 700 KiB. It is removed from the public result and written exclusively only after result/run/identity validation. The exact once-encoded final stdout bytes, including JSON keys and human chrome, are scanned for every declared nonempty secret before publication. Output publication uses restrictive mode `0600` and refuses existing leaves, links, hardlinks, directories, replacement races, and reparse points. On Linux, publication uses an anonymous `O_TMPFILE` inode and `linkat(..., AT_EMPTY_PATH)`; the link operation is the final commit, and no post-commit check can turn success into a not-published error. On Darwin, the named-stage implementation is a trusted-parent mode: the target parent and ancestors must remain namespace-stable and same-UID stage mutation is explicitly outside the threat model. It verifies the stage identity/link count before using `renameatx_np(..., RENAME_EXCL)` as the final no-replace commit. It does not claim protection against a same-UID hostile hard-linker. On Windows, the stage is held open with `DELETE|WRITE` and zero sharing, then renamed atomically with `SetFileInformationByHandle(FileRenameInfo)` rooted at the retained parent handle; replacement is disabled and the rename is final.
|
||||
|
||||
The public result has schema version 1 and only these fields: `status`, `code`, workspace/revision/descriptor/operation identities, optional run and child run IDs, completed stages, counts, artifact identities, and warnings. Revisions/descriptors are 40-hex; run IDs are 32-hex; artifact digests are `sha256:<hex>`. Allowed statuses are `succeeded`, `unchanged`, `dry_run`, `blocked`, and `failed`. Allowed codes are `ok`, `workspace_not_found`, `workspace_not_activatable`, `binding_missing`, `preprocessing_conflict`, `preprocessing_resume_mismatch`, `manual_review_required`, `evidence_materialization_required`, `effective_config_mismatch`, `semantic_index_incompatible`, `annotation_invalid`, `egress_policy_refused`, and `registry_bootstrap_recovery_conflict`. `succeeded`, `unchanged`, and `dry_run` require `ok` and child exit 0. `blocked` requires one of `manual_review_required`, `evidence_materialization_required`, `preprocessing_conflict`, `preprocessing_resume_mismatch`, or `registry_bootstrap_recovery_conflict`, and child exit 3. `failed` requires a non-`ok` operational code other than those blocked-only codes, and child exit 1. A nonzero child exit is never accepted for another status/code combination. The public `thothctl` exit mapping is fixed independently of child details: `0` for succeeded/unchanged/dry-run, `3` for an expected blocked result, `2` for command grammar or proven unsafe host-file failures, and `1` for operational or indeterminate failures (including invalid child envelopes, output-limit failures, child execution failures, and candidate cleanup uncertainty). A physical stdout write failure after candidate publication is a committed/indeterminate reconcile case; inspect the destination and private stages before retrying. Stdout is capped at 1 MiB after final human/JSON encoding and stderr at 64 KiB after sanitization; output is never allowed to exceed those bounds. In human mode, a `registry_bootstrap_recovery_conflict` result prints exactly `Bootstrap recovery is ambiguous or corrupt; inspect the installation registry jobs.` and prints neither a candidate export nor any run/candidate ID. Compose is invoked only as `compose run --rm --no-deps --no-TTY workspace-maintenance ...`; output never includes child stderr or secrets.
|
||||
@@ -213,8 +213,11 @@ Use `POST /workspace-registry/pull` to fetch later revisions. Run workspace diag
|
||||
required DWH bindings are mounted. Schema-v3 diagnostics probe the internal Qdrant/Ollama
|
||||
services through backend config; ordinary diagnostics are read-only.
|
||||
|
||||
Schema-v3 is the only operational descriptor format. Schema-v1/v2 descriptors remain
|
||||
`migration_required` until an explicit reviewed migration writes schema version 3. One workspace owns one Qdrant collection; schema, Evidence, and Memory records share that collection and remain
|
||||
Schema v3 is the only accepted workspace descriptor format. Schema v1 and v2 descriptors are
|
||||
rejected while the candidate snapshot is validated, so bootstrap activation or a pull fails
|
||||
atomically and leaves the prior active snapshot unchanged. There is no in-product migrator or
|
||||
automatic conversion. The repository must already contain reviewed v3 descriptors. One workspace
|
||||
owns one Qdrant collection; schema, Evidence, and Memory records share that collection and remain
|
||||
isolated by payload `kind`.
|
||||
|
||||
## Semantic index ownership contract
|
||||
@@ -223,16 +226,9 @@ isolated by payload `kind`.
|
||||
| --- | --- | --- |
|
||||
| Workspace semantic index | Each workspace reserves a single Qdrant collection. | Schema, Evidence, and Memory stay in that one collection and remain isolated by payload `kind`. |
|
||||
|
||||
To migrate an existing legacy descriptor, create/clone an empty private remote, set the absolute
|
||||
`THT_SOURCE_ROOT`, transform with absolute paths, review the schema-v1 result, explicitly produce
|
||||
the reviewed schema-v3 contract, then commit/push. The transformer never imports `${ENV}` values
|
||||
or secrets.
|
||||
|
||||
```sh
|
||||
THT_SOURCE_ROOT=/absolute/path/to/ThothII
|
||||
npm --prefix "$THT_SOURCE_ROOT/backend" run build
|
||||
node "$THT_SOURCE_ROOT/backend/dist/workspaces/migrate-legacy.js" --input /absolute/path/legacy.yaml --output /absolute/path/thoth-workspaces
|
||||
```
|
||||
If source material needs conversion, perform it outside ThothII in a separate reviewed process.
|
||||
Commit only the resulting reviewed v3 descriptors. That external process must not import `${ENV}`
|
||||
values, secret values, certificates, keys, or secret files into the repository.
|
||||
|
||||
## Publish, update, backup, outage recovery, and rollback
|
||||
|
||||
|
||||
@@ -60,9 +60,9 @@ use `ssh://git@git.example.invalid/platform/thoth-workspaces.git`. For HTTPS, cr
|
||||
machine credential in the secret manager and mount the Gitea/private CA separately. Never use a
|
||||
Gitea admin credential in the application.
|
||||
|
||||
Bootstrap an empty remote from a temporary review clone: migrate legacy descriptors, review their
|
||||
schema-v3 identity and generated artifacts, commit, and push `main`. The running server is not an
|
||||
authoring environment for migration.
|
||||
Bootstrap an empty remote from a temporary review clone only after its canonical v3 descriptors
|
||||
and generated public artifacts have been reviewed; commit and push `main`. The running server is
|
||||
not a descriptor authoring or conversion environment.
|
||||
|
||||
## Curator flow for shared-registry Evidence
|
||||
|
||||
@@ -258,14 +258,16 @@ For upgrades, record active status/head, finish active work, use the documented
|
||||
--check-only`, deploy the compatible image through `thothctl`, verify health/status, then resume
|
||||
proxy traffic.
|
||||
|
||||
For legacy descriptor migration, use a temporary review clone and the legacy transformer with absolute paths.
|
||||
Its schema-v1 output is `migration_required`; explicitly supply collection identity, diagnostics,
|
||||
and the reviewed v3 contract before commit. Never import `${ENV}` values or
|
||||
copy secret files.
|
||||
Schema v3 is the only accepted workspace descriptor format. Schema v1 and v2 descriptors are
|
||||
rejected while the candidate snapshot is validated, so initial activation or a pull fails
|
||||
atomically and leaves the prior active snapshot unchanged. There is no in-product migrator or
|
||||
automatic conversion. The repository must already contain reviewed v3 descriptors. One workspace
|
||||
owns one Qdrant collection; schema, Evidence, and Memory records share it and stay separated by
|
||||
payload `kind`.
|
||||
|
||||
Schema-v3 is the only operational descriptor contract. Schema-v1/v2 descriptors remain
|
||||
`migration_required` until an explicit reviewed migration writes version 3. One workspace owns one Qdrant collection; schema, Evidence, and Memory records share it and stay separated by payload
|
||||
`kind`.
|
||||
If source material needs conversion, perform it outside ThothII in a separate reviewed process.
|
||||
Commit only the resulting reviewed v3 descriptors. That external process must not import `${ENV}`
|
||||
values, secret values, certificates, keys, or secret files into the repository.
|
||||
|
||||
## Semantic index ownership contract
|
||||
|
||||
@@ -312,9 +314,10 @@ Use the repository helpers for Qdrant backup/restore:
|
||||
|
||||
Qdrant backup/restore targets exactly one labeled `qdrant-data` volume for the named Compose
|
||||
project. Restore requires the exact repeated project confirmation, validates the archive before
|
||||
stopping `qdrant`, stages rollback content, and restores in place only for that project-scoped
|
||||
volume. It does not migrate schema-v1/v2 workspaces, rename collections, or resolve semantic-index
|
||||
incompatibilities.
|
||||
stopping `qdrant`, stages rollback content, and restores semantic storage in place only for that
|
||||
project-scoped volume. Before recovery, the registry must already contain a reviewed v3 descriptor
|
||||
revision compatible with the restored collection. The helper does not restore descriptors, rename
|
||||
collections, or resolve semantic-index incompatibilities.
|
||||
|
||||
The Ollama model cache is a recoverable local cache, not the canonical semantic source of truth.
|
||||
You may back up `embedding-models` for faster offline recovery, but a cache loss is recoverable by
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -7,9 +7,10 @@ response body belongs in the descriptor, generated `.env.example` files, or diag
|
||||
|
||||
## Scope and safety rules
|
||||
|
||||
- The operational descriptor is schema version 3.
|
||||
- Schema-v1/v2 descriptors are readable only and remain `migration_required` until an explicit
|
||||
reviewed migration writes schema version 3.
|
||||
- Schema v3 is the only accepted workspace descriptor format.
|
||||
- Schema v1 and v2 descriptors are rejected before diagnostics run. There is no in-product
|
||||
migrator or automatic conversion; the Git repository must already contain reviewed v3
|
||||
descriptors.
|
||||
- One workspace owns one Qdrant collection.
|
||||
- Qdrant and Ollama are internal services. Operators do not bind external vector or embedding
|
||||
transports for active manuals or supported diagnostics.
|
||||
|
||||
@@ -118,6 +118,7 @@ See `docs/testing.md` for what each interaction level validates.
|
||||
```bash
|
||||
pytest # L0 (testcontainers, real Postgres) + L1 (pure logic + gate builders)
|
||||
npm test # gate widget-builder golden + fuzzy tests (JS)
|
||||
pytest -m integration # marked cross-runtime checks (requires repository-local toolchains)
|
||||
pytest -m l2 # L2: real GLM 5.2 + remote DWH (pre-release; needs .env + VPN + CA bundle)
|
||||
```
|
||||
|
||||
|
||||
@@ -44,5 +44,6 @@ testpaths = ["tests"]
|
||||
markers = [
|
||||
"l0: testcontainers tests (need Docker, run locally)",
|
||||
"l2: end-to-end tests requiring real GLM 5.2 + remote DB (skipped when .env incomplete)",
|
||||
"integration: cross-runtime integration tests requiring repository-local toolchains",
|
||||
]
|
||||
addopts = "-m 'not l2'" # L0 runs by default (Docker present); L2 opt-in
|
||||
addopts = "-m 'not l2 and not integration'" # default harness gate excludes L2 and cross-runtime integration
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Shared fake Qdrant HTTP boundary for adapter and CLI tests."""
|
||||
|
||||
import json
|
||||
|
||||
from tht.ports.vector import VectorWriteRecord
|
||||
from tht.vectorstore.records import VectorRecord
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, status_code: int, payload=None, text: str | None = None):
|
||||
self.status_code = status_code
|
||||
self._payload = payload
|
||||
self.text = text if text is not None else (
|
||||
"" if payload is None else json.dumps(payload)
|
||||
)
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return 200 <= self.status_code < 300
|
||||
|
||||
def json(self):
|
||||
if isinstance(self._payload, Exception):
|
||||
raise self._payload
|
||||
return self._payload
|
||||
|
||||
|
||||
class FakeQdrantHttp:
|
||||
def __init__(self, *, dimension=1024, distance="Cosine"):
|
||||
self.dimension = dimension
|
||||
self.distance = distance
|
||||
self.collection = None
|
||||
self.payload_indexes: set[str] = set()
|
||||
self.payload_index_types: dict[str, str] = {}
|
||||
self.points: dict[str, dict] = {}
|
||||
self.calls: list[tuple[str, str, dict | None]] = []
|
||||
self.fail_request: Exception | None = None
|
||||
self.malformed_query = False
|
||||
self.malformed_scroll = False
|
||||
self.scroll_pages: list[dict] | None = None
|
||||
|
||||
def request(self, method, url, *, json=None, timeout=None):
|
||||
self.calls.append((method, url, json))
|
||||
if self.fail_request is not None:
|
||||
raise self.fail_request
|
||||
|
||||
path = url.split("://", 1)[-1].split("/", 1)[-1]
|
||||
path = "/" + path.split("?", 1)[0]
|
||||
|
||||
if method == "GET" and path == "/collections/workspace-semantic":
|
||||
if self.collection is None:
|
||||
return FakeResponse(404, {"status": "error"})
|
||||
return FakeResponse(200, {
|
||||
"result": {
|
||||
"config": {
|
||||
"params": {
|
||||
"vectors": {"size": self.dimension, "distance": self.distance}
|
||||
}
|
||||
},
|
||||
"payload_schema": {
|
||||
field: {"data_type": self.payload_index_types.get(field, "keyword")} for field in sorted(self.payload_indexes)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
if method == "PUT" and path == "/collections/workspace-semantic":
|
||||
self.collection = json
|
||||
self.dimension = json["vectors"]["size"]
|
||||
self.distance = json["vectors"]["distance"]
|
||||
return FakeResponse(200, {"status": "ok"})
|
||||
|
||||
if method == "PUT" and path == "/collections/workspace-semantic/index":
|
||||
self.payload_indexes.add(json["field_name"])
|
||||
return FakeResponse(200, {"status": "ok"})
|
||||
|
||||
if method == "PUT" and path == "/collections/workspace-semantic/points":
|
||||
if self.collection is None:
|
||||
return FakeResponse(404, {"status": "error"})
|
||||
for point in json["points"]:
|
||||
self.points[point["id"]] = point
|
||||
return FakeResponse(200, {"result": {"status": "acknowledged"}})
|
||||
|
||||
if method == "POST" and path == "/collections/workspace-semantic/points/query":
|
||||
if self.collection is None:
|
||||
return FakeResponse(404, {"status": "error"})
|
||||
if self.malformed_query:
|
||||
return FakeResponse(200, {"result": {"points": "nope"}})
|
||||
wanted = _match_points(self.points.values(), json["filter"])
|
||||
scored = sorted(
|
||||
(
|
||||
{
|
||||
"id": point["id"],
|
||||
"score": point.get("score", 0.9),
|
||||
"payload": point["payload"],
|
||||
}
|
||||
for point in wanted
|
||||
),
|
||||
key=lambda point: (-point["score"], point["payload"]["record_key"]),
|
||||
)
|
||||
return FakeResponse(200, {"result": {"points": scored[: json["limit"]]}})
|
||||
|
||||
if method == "POST" and path == "/collections/workspace-semantic/points/scroll":
|
||||
if self.collection is None:
|
||||
return FakeResponse(404, {"status": "error"})
|
||||
if self.malformed_scroll:
|
||||
return FakeResponse(200, {"result": {"points": "bad"}})
|
||||
if self.scroll_pages is not None:
|
||||
offset = json.get("offset")
|
||||
for page in self.scroll_pages:
|
||||
if page["offset"] == offset:
|
||||
filtered = _match_points(page["points"], json["filter"])
|
||||
return FakeResponse(200, {
|
||||
"result": {
|
||||
"points": filtered,
|
||||
"next_page_offset": page["next_page_offset"],
|
||||
}
|
||||
})
|
||||
raise AssertionError(("unexpected offset", offset, self.scroll_pages))
|
||||
wanted = sorted(
|
||||
_match_points(self.points.values(), json["filter"]),
|
||||
key=lambda point: point["payload"]["record_key"],
|
||||
)
|
||||
return FakeResponse(200, {"result": {"points": wanted, "next_page_offset": None}})
|
||||
|
||||
if method == "POST" and path == "/collections/workspace-semantic/points/delete":
|
||||
if self.collection is None:
|
||||
return FakeResponse(404, {"status": "error"})
|
||||
doomed = [point["id"] for point in _match_points(self.points.values(), json["filter"])]
|
||||
for point_id_value in doomed:
|
||||
self.points.pop(point_id_value, None)
|
||||
return FakeResponse(200, {"result": {"status": "acknowledged"}})
|
||||
|
||||
raise AssertionError((method, path, json))
|
||||
|
||||
|
||||
def _match_points(points, flt):
|
||||
matches = []
|
||||
must = flt["must"]
|
||||
for point in points:
|
||||
payload = point["payload"]
|
||||
if all(_match_clause(payload, clause) for clause in must):
|
||||
matches.append(point)
|
||||
return matches
|
||||
|
||||
|
||||
def _match_clause(payload, clause):
|
||||
key = clause["key"]
|
||||
match = clause["match"]
|
||||
if "value" in match:
|
||||
return payload.get(key) == match["value"]
|
||||
if "any" in match:
|
||||
return payload.get(key) in set(match["any"])
|
||||
raise AssertionError(clause)
|
||||
|
||||
|
||||
def _write_record(record_id: str, kind: str, *, metadata=None):
|
||||
return VectorWriteRecord(
|
||||
record=VectorRecord(
|
||||
id=record_id,
|
||||
kind=kind,
|
||||
ref=f"ref:{record_id}",
|
||||
title=f"title:{record_id}",
|
||||
content=f"content:{record_id}",
|
||||
metadata=metadata or {},
|
||||
),
|
||||
embedding=[0.1] * 1024,
|
||||
content_hash="sha256:" + "a" * 64,
|
||||
)
|
||||
@@ -67,6 +67,15 @@ def test_factory_selects_dwh_adapter(dwh_type, adapter_type):
|
||||
assert isinstance(build_dwh(_config(dwh_type=dwh_type)), adapter_type)
|
||||
|
||||
|
||||
def test_factory_rejects_require_existing_without_embedding_dimension():
|
||||
config = _config()
|
||||
config.vectors.collection_lifecycle = "require_existing"
|
||||
config.embeddings = None
|
||||
|
||||
with pytest.raises(ConfigError, match="explicit positive embedding dimension"):
|
||||
build_vector_store(config)
|
||||
|
||||
|
||||
def test_factory_selects_qdrant_for_schema_v3_runtime():
|
||||
config = _config(dwh_type="postgres_direct")
|
||||
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from tht.adapters.evidence import FilesystemEvidenceSource, HttpManifestEvidenceSource
|
||||
from tht.adapters.factory import build_evidence_sources
|
||||
from tht.cli import app
|
||||
from tht.config import (
|
||||
ConfigError,
|
||||
PgvectorDirectConfig,
|
||||
@@ -401,3 +406,111 @@ dwh:
|
||||
message = str(caught.value)
|
||||
assert "dwh.postgres_direct.connection.password" in message
|
||||
assert "missing" in message
|
||||
|
||||
|
||||
@pytest.mark.parametrize("resources", [None, [], "malformed", 7])
|
||||
def test_raw_resources_non_mapping_is_a_safe_config_error(tmp_path, resources):
|
||||
values = {
|
||||
"dwh": {
|
||||
"type": "postgres_direct",
|
||||
"connection": {"database": "d", "schema": "public", "user": "u", "password": "p"},
|
||||
},
|
||||
"resources": resources,
|
||||
}
|
||||
path = tmp_path / "invalid-resources.yaml"
|
||||
path.write_text(yaml.safe_dump(values))
|
||||
|
||||
with pytest.raises(ConfigError, match="resources"):
|
||||
load_config(path)
|
||||
config_result = CliRunner().invoke(app, ["config", "check", "--config", str(path)])
|
||||
assert config_result.exit_code == 1
|
||||
assert "Traceback" not in config_result.stderr
|
||||
vector_result = CliRunner().invoke(
|
||||
app, ["vector", "index-schema", "--json", "-c", str(path)]
|
||||
)
|
||||
assert vector_result.exit_code == 1
|
||||
assert vector_result.stderr == ""
|
||||
assert json.loads(vector_result.stdout) == {"status": "failed", "code": "invalid_configuration"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("vector", [None, [], "malformed", 7])
|
||||
def test_raw_resources_vector_non_mapping_is_a_safe_config_error(tmp_path, vector):
|
||||
values = {
|
||||
"dwh": {
|
||||
"type": "postgres_direct",
|
||||
"connection": {"database": "d", "schema": "public", "user": "u", "password": "p"},
|
||||
},
|
||||
"resources": {"vector": vector},
|
||||
}
|
||||
path = tmp_path / "invalid-resources-vector.yaml"
|
||||
path.write_text(yaml.safe_dump(values))
|
||||
|
||||
with pytest.raises(ConfigError, match="resources.vector"):
|
||||
load_config(path)
|
||||
config_result = CliRunner().invoke(app, ["config", "check", "--config", str(path)])
|
||||
assert config_result.exit_code == 1
|
||||
assert "Traceback" not in config_result.stderr
|
||||
vector_result = CliRunner().invoke(
|
||||
app, ["vector", "index-schema", "--json", "-c", str(path)]
|
||||
)
|
||||
assert vector_result.exit_code == 1
|
||||
assert vector_result.stderr == ""
|
||||
assert json.loads(vector_result.stdout) == {"status": "failed", "code": "invalid_configuration"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("embeddings", [None, [], "malformed", 7])
|
||||
def test_raw_resources_embeddings_non_mapping_is_not_silently_accepted(tmp_path, embeddings):
|
||||
values = {
|
||||
"dwh": {
|
||||
"type": "postgres_direct",
|
||||
"connection": {"database": "d", "schema": "public", "user": "u", "password": "p"},
|
||||
},
|
||||
"resources": {"embeddings": embeddings},
|
||||
}
|
||||
path = tmp_path / "invalid-resources-embeddings.yaml"
|
||||
path.write_text(yaml.safe_dump(values))
|
||||
|
||||
with pytest.raises(ConfigError, match="resources.embeddings"):
|
||||
load_config(path)
|
||||
config_result = CliRunner().invoke(app, ["config", "check", "--config", str(path)])
|
||||
assert config_result.exit_code == 1
|
||||
assert "Traceback" not in config_result.stderr
|
||||
vector_result = CliRunner().invoke(
|
||||
app, ["vector", "index-schema", "--json", "-c", str(path)]
|
||||
)
|
||||
assert vector_result.exit_code == 1
|
||||
assert vector_result.stderr == ""
|
||||
assert json.loads(vector_result.stdout) == {"status": "failed", "code": "invalid_configuration"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mutator", [
|
||||
lambda values: values.update({1: "not-a-string-key"}),
|
||||
lambda values: values["resources"].update({1: {"provider": "bad"}}),
|
||||
lambda values: values["resources"].update({"embeddings": {1: "bad"}}),
|
||||
])
|
||||
def test_raw_non_string_mapping_keys_are_safe_config_errors(tmp_path, mutator):
|
||||
values = {
|
||||
"dwh": {
|
||||
"type": "postgres_direct",
|
||||
"connection": {"database": "d", "schema": "public", "user": "u", "password": "p"},
|
||||
},
|
||||
"resources": {"embeddings": {
|
||||
"provider": "ollama_internal", "base_url": "http://embedding:11434",
|
||||
"model": "qwen3-embedding:0.6b", "dimensions": 1024,
|
||||
}},
|
||||
}
|
||||
mutator(values)
|
||||
path = tmp_path / "invalid-mapping-key.yaml"
|
||||
path.write_text(yaml.safe_dump(values))
|
||||
|
||||
with pytest.raises(ConfigError, match="mapping key"):
|
||||
load_config(path)
|
||||
config_result = CliRunner().invoke(app, ["config", "check", "--config", str(path)])
|
||||
assert config_result.exit_code == 1
|
||||
assert "Traceback" not in config_result.stderr
|
||||
vector_result = CliRunner().invoke(
|
||||
app, ["vector", "index-schema", "--json", "-c", str(path)]
|
||||
)
|
||||
assert vector_result.exit_code == 1
|
||||
assert vector_result.stderr == ""
|
||||
assert json.loads(vector_result.stdout) == {"status": "failed", "code": "invalid_configuration"}
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from tht.cli import app
|
||||
from tht.ports.evidence import EvidenceSourceError, EvidenceSourceErrorCategory
|
||||
from tht.ports.vector import VectorStoreError
|
||||
from tht.vectorstore.embeddings import EmbeddingsError
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _child_capability_for_pipeline_unit_tests(monkeypatch):
|
||||
# These tests exercise pipeline result/JSON behavior; process-boundary
|
||||
# authorization is covered by test_workspace_writer_lock.py.
|
||||
import tht.cli.preprocess_cmd as command
|
||||
monkeypatch.setattr(command, "_require_writer_capability", lambda **kwargs: None)
|
||||
|
||||
|
||||
def test_preprocess_evidence_json_is_pristine(monkeypatch, tmp_path):
|
||||
@@ -51,9 +64,10 @@ def test_preprocess_failed_job_report_is_sanitized_json_and_nonzero(monkeypatch,
|
||||
|
||||
|
||||
def test_preprocess_real_failed_stage_result_exits_nonzero(monkeypatch, tmp_path):
|
||||
import tht.cli.preprocess_cmd as command
|
||||
from test_corpus_pipeline import Source, item, pipeline
|
||||
|
||||
import tht.cli.preprocess_cmd as command
|
||||
|
||||
result = pipeline(
|
||||
tmp_path, Source([(item("one", "a"), RuntimeError("SENSITIVE EVIDENCE secret"))])
|
||||
).run_as_job(
|
||||
@@ -129,3 +143,124 @@ def test_preprocess_evidence_gc_json_is_pristine(monkeypatch, tmp_path):
|
||||
)
|
||||
assert response.exit_code == 0, response.output
|
||||
assert json.loads(response.output)["dry_run"] is True
|
||||
|
||||
|
||||
def test_preprocess_evidence_uses_runtime_identity_for_dev_fd_config(monkeypatch, tmp_path):
|
||||
import tht.cli.preprocess_cmd as command
|
||||
|
||||
cfg = SimpleNamespace(
|
||||
runtime_identity=SimpleNamespace(workspace_id="runtime-workspace"),
|
||||
embeddings=SimpleNamespace(model="m", dim=4),
|
||||
vector=SimpleNamespace(max_chunk_chars=10, retain_published_generations=1),
|
||||
paths=SimpleNamespace(artifacts=tmp_path / "artifacts"),
|
||||
model_dump_json=lambda: "{}",
|
||||
)
|
||||
captured = {}
|
||||
|
||||
class FakePipeline:
|
||||
def __init__(self, **kwargs):
|
||||
captured.update(kwargs)
|
||||
|
||||
def run_as_job(self, **kwargs):
|
||||
captured.update(kwargs)
|
||||
return SimpleNamespace(model_dump=lambda mode=None: {"status": "succeeded"})
|
||||
|
||||
monkeypatch.setattr(command, "_load_config_or_exit", lambda _: cfg)
|
||||
monkeypatch.setattr("tht.adapters.factory.build_evidence_sources", lambda _: [])
|
||||
monkeypatch.setattr("tht.adapters.factory.build_vector_store", lambda *_args, **_kwargs: object())
|
||||
monkeypatch.setattr("tht.cli.vector_cmd.make_embedder", lambda _: object())
|
||||
monkeypatch.setattr("tht.corpus.pipeline.CorpusPipeline", FakePipeline)
|
||||
command.run_from_config(Path("/dev/fd/3"))
|
||||
assert captured["workspace_id"] == "runtime-workspace"
|
||||
|
||||
|
||||
def test_preprocess_gc_uses_runtime_identity_for_dev_fd_config(monkeypatch, tmp_path):
|
||||
import tht.cli.preprocess_cmd as command
|
||||
|
||||
cfg = SimpleNamespace(
|
||||
runtime_identity=SimpleNamespace(workspace_id="runtime-workspace"),
|
||||
embeddings=SimpleNamespace(model="m", dim=4),
|
||||
vector=SimpleNamespace(max_chunk_chars=10, retain_published_generations=1),
|
||||
paths=SimpleNamespace(artifacts=tmp_path / "artifacts"),
|
||||
)
|
||||
captured = {}
|
||||
|
||||
class FakePipeline:
|
||||
def __init__(self, **kwargs):
|
||||
captured.update(kwargs)
|
||||
|
||||
def gc(self, **kwargs):
|
||||
captured.update(kwargs)
|
||||
captured["workspace_id"] = self.workspace_id
|
||||
return {"status": "succeeded", "dry_run": True, "evicted": [], "failures": []}
|
||||
|
||||
monkeypatch.setattr(command, "_load_config_or_exit", lambda _: cfg)
|
||||
monkeypatch.setattr("tht.adapters.factory.build_evidence_sources", lambda _: [])
|
||||
monkeypatch.setattr("tht.adapters.factory.build_vector_store", lambda *_args, **_kwargs: object())
|
||||
monkeypatch.setattr("tht.cli.vector_cmd.make_embedder", lambda _: object())
|
||||
monkeypatch.setattr("tht.corpus.pipeline.CorpusPipeline", FakePipeline)
|
||||
|
||||
command.gc_from_config(Path("/dev/fd/3"), dry_run=True)
|
||||
assert captured["dry_run"] is True
|
||||
assert captured["workspace_id"] == "runtime-workspace"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"error",
|
||||
[
|
||||
pytest.param(
|
||||
EvidenceSourceError("secret source", category=EvidenceSourceErrorCategory.PERMANENT),
|
||||
id="evidence-source",
|
||||
),
|
||||
pytest.param(VectorStoreError("secret vector"), id="vector-store"),
|
||||
pytest.param(EmbeddingsError("secret embeddings"), id="embeddings"),
|
||||
],
|
||||
)
|
||||
def test_preprocess_evidence_json_catches_domain_failures_without_stderr(monkeypatch, tmp_path, error):
|
||||
import tht.cli.preprocess_cmd as command
|
||||
|
||||
monkeypatch.setattr(command, "run_from_config", lambda *a, **k: (_ for _ in ()).throw(error))
|
||||
response = CliRunner().invoke(
|
||||
app, ["preprocess", "evidence", "--json", "-c", str(tmp_path / "workspace.yaml")]
|
||||
)
|
||||
|
||||
assert response.exit_code == 1
|
||||
assert json.loads(response.stdout) == {"status": "failed", "error": "preprocessing failed"}
|
||||
assert response.stderr == ""
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"error",
|
||||
[
|
||||
pytest.param(
|
||||
EvidenceSourceError("secret source", category=EvidenceSourceErrorCategory.PERMANENT),
|
||||
id="evidence-source",
|
||||
),
|
||||
pytest.param(VectorStoreError("secret vector"), id="vector-store"),
|
||||
pytest.param(EmbeddingsError("secret embeddings"), id="embeddings"),
|
||||
],
|
||||
)
|
||||
def test_preprocess_evidence_gc_json_catches_domain_failures_without_stderr(monkeypatch, tmp_path, error):
|
||||
import tht.cli.preprocess_cmd as command
|
||||
|
||||
monkeypatch.setattr(command, "gc_from_config", lambda *a, **k: (_ for _ in ()).throw(error))
|
||||
response = CliRunner().invoke(
|
||||
app, ["preprocess", "evidence", "gc", "--json", "-c", str(tmp_path / "workspace.yaml")]
|
||||
)
|
||||
|
||||
assert response.exit_code == 1
|
||||
assert json.loads(response.stdout) == {"status": "failed", "error": "evidence cleanup failed"}
|
||||
assert response.stderr == ""
|
||||
|
||||
|
||||
def test_preprocess_evidence_json_unexpected_failure_has_safe_boundary(monkeypatch, tmp_path):
|
||||
import tht.cli.preprocess_cmd as command
|
||||
|
||||
monkeypatch.setattr(command, "run_from_config", lambda *a, **k: (_ for _ in ()).throw(Exception("secret unexpected")))
|
||||
response = CliRunner().invoke(
|
||||
app, ["preprocess", "evidence", "--json", "-c", str(tmp_path / "workspace.yaml")]
|
||||
)
|
||||
|
||||
assert response.exit_code == 1
|
||||
assert json.loads(response.stdout) == {"status": "failed", "error": "preprocessing failed"}
|
||||
assert response.stderr == ""
|
||||
|
||||
@@ -5,17 +5,37 @@ from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from tht.cli import app
|
||||
from tht.memory import MemoryRecord, save_registry
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _child_capability_for_vector_unit_tests(monkeypatch):
|
||||
import tht.cli.preprocess_cmd as preprocess
|
||||
import tht.cli.vector_cmd as vector
|
||||
monkeypatch.setattr(preprocess, "_require_writer_capability", lambda **kwargs: None)
|
||||
monkeypatch.setattr(vector, "_require_writer_capability", lambda **kwargs: None)
|
||||
|
||||
|
||||
class _FakeEmbedder:
|
||||
def embed_documents(self, documents):
|
||||
return [[0.1] * 4 for _ in documents]
|
||||
|
||||
|
||||
class _Response:
|
||||
def __init__(self, status_code, payload=None):
|
||||
self.status_code = status_code
|
||||
self.ok = status_code < 400
|
||||
self._payload = payload
|
||||
self.text = "" if payload is None else "{}"
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
|
||||
class _FakeVectorStore:
|
||||
def __init__(self):
|
||||
self.upserts = []
|
||||
@@ -61,6 +81,18 @@ embeddings:
|
||||
return cfg
|
||||
|
||||
|
||||
def _legacy_qdrant_runtime_config(tmp_path: Path) -> Path:
|
||||
cfg = _qdrant_runtime_config(tmp_path)
|
||||
text = cfg.read_text()
|
||||
text = text.replace(
|
||||
"dwh:\n type: postgres_direct\n connection: {database: analytics, schema: mart, user: reader, password: secret}\n",
|
||||
"database: {database: analytics, schema: mart, user: reader, password: secret}\n",
|
||||
)
|
||||
text = text.replace("roots:\n", "paths:\n")
|
||||
cfg.write_text(text)
|
||||
return cfg
|
||||
|
||||
|
||||
def _write_schema_artifacts(tmp_path: Path) -> None:
|
||||
(tmp_path / "artifacts" / "mschema").mkdir(parents=True, exist_ok=True)
|
||||
(tmp_path / "artifacts" / "mschema" / "physical.yaml").write_text(
|
||||
@@ -187,3 +219,260 @@ def test_memory_solved_index_help_uses_semantic_store_wording():
|
||||
assert res.exit_code == 0, res.output
|
||||
assert "semantic" in res.output.lower() or "qdrant" in res.output.lower()
|
||||
assert "vectordb" not in res.output.lower()
|
||||
|
||||
|
||||
def test_vector_index_schema_json_is_single_document(monkeypatch, tmp_path):
|
||||
import json
|
||||
|
||||
cfg = _qdrant_runtime_config(tmp_path)
|
||||
_write_schema_artifacts(tmp_path)
|
||||
store = _FakeVectorStore()
|
||||
monkeypatch.setattr("tht.adapters.factory.build_vector_store", lambda cfg, require_write: store)
|
||||
monkeypatch.setattr("tht.cli.vector_cmd.make_embedder", lambda _: _FakeEmbedder())
|
||||
response = CliRunner().invoke(app, ["vector", "index-schema", "--json", "-c", str(cfg)])
|
||||
assert response.exit_code == 0, response.output
|
||||
assert response.stdout.count("\n") == 1
|
||||
payload = json.loads(response.stdout)
|
||||
assert payload["status"] == "succeeded"
|
||||
assert payload["code"] == "ok"
|
||||
assert payload["counts"]["added"] == 2
|
||||
|
||||
|
||||
def test_vector_index_schema_json_maps_initial_missing_collection(monkeypatch, tmp_path):
|
||||
cfg = _qdrant_runtime_config(tmp_path)
|
||||
_write_schema_artifacts(tmp_path)
|
||||
calls = []
|
||||
|
||||
def request(method, url, **kwargs):
|
||||
calls.append((method, url))
|
||||
if method == "GET" and url.endswith("/collections/psd-clinical"):
|
||||
return _Response(404, {"status": {"error": "missing"}})
|
||||
raise AssertionError((method, url))
|
||||
|
||||
monkeypatch.setattr("requests.request", request)
|
||||
response = CliRunner().invoke(app, ["vector", "index-schema", "--json", "-c", str(cfg)])
|
||||
|
||||
assert response.exit_code == 1
|
||||
assert response.stdout == '{"code":"semantic_index_incompatible","status":"failed"}\n'
|
||||
assert response.stderr == ""
|
||||
assert not [call for call in calls if call[0] == "PUT"]
|
||||
assert not [call for call in calls if call[1].endswith("/points/scroll")]
|
||||
|
||||
|
||||
def test_vector_index_schema_json_rejects_incompatible_empty_collection(monkeypatch, tmp_path):
|
||||
cfg = _qdrant_runtime_config(tmp_path)
|
||||
_write_schema_artifacts(tmp_path)
|
||||
keyword_indexes = {
|
||||
"content_hash", "document_id", "kind", "record_key", "record_kind",
|
||||
"vector_generation", "workspace_id", "workspace_revision",
|
||||
}
|
||||
calls = []
|
||||
|
||||
def request(method, url, **kwargs):
|
||||
calls.append((method, url))
|
||||
if method == "GET" and url.endswith("/collections/psd-clinical"):
|
||||
return _Response(200, {"result": {
|
||||
"config": {"params": {"vectors": {"size": 384, "distance": "Cosine"}}},
|
||||
"payload_schema": {key: {"data_type": "keyword"} for key in keyword_indexes},
|
||||
}})
|
||||
raise AssertionError((method, url))
|
||||
|
||||
monkeypatch.setattr("requests.request", request)
|
||||
response = CliRunner().invoke(app, ["vector", "index-schema", "--json", "-c", str(cfg)])
|
||||
|
||||
assert response.exit_code == 1
|
||||
assert response.stdout == '{"code":"semantic_index_incompatible","status":"failed"}\n'
|
||||
assert response.stderr == ""
|
||||
assert not [call for call in calls if call[0] == "PUT"]
|
||||
assert not [call for call in calls if call[1].endswith("/points/scroll")]
|
||||
|
||||
|
||||
def test_vector_index_schema_json_maps_compatible_scroll_404(monkeypatch, tmp_path):
|
||||
cfg = _qdrant_runtime_config(tmp_path)
|
||||
_write_schema_artifacts(tmp_path)
|
||||
keyword_indexes = {
|
||||
"content_hash", "document_id", "kind", "record_key", "record_kind",
|
||||
"vector_generation", "workspace_id", "workspace_revision",
|
||||
}
|
||||
calls = []
|
||||
|
||||
def request(method, url, **kwargs):
|
||||
calls.append((method, url))
|
||||
if method == "GET" and url.endswith("/collections/psd-clinical"):
|
||||
return _Response(200, {"result": {
|
||||
"config": {"params": {"vectors": {"size": 1024, "distance": "Cosine"}}},
|
||||
"payload_schema": {key: {"data_type": "keyword"} for key in keyword_indexes},
|
||||
}})
|
||||
if method == "POST" and url.endswith("/points/scroll"):
|
||||
return _Response(404, {"status": "error"})
|
||||
raise AssertionError((method, url))
|
||||
|
||||
monkeypatch.setattr("requests.request", request)
|
||||
response = CliRunner().invoke(app, ["vector", "index-schema", "--json", "-c", str(cfg)])
|
||||
|
||||
assert response.exit_code == 1
|
||||
assert response.stdout == '{"code":"semantic_index_incompatible","status":"failed"}\n'
|
||||
assert response.stderr == ""
|
||||
assert not [call for call in calls if call[0] in {"PUT", "DELETE"}]
|
||||
|
||||
|
||||
def test_vector_index_schema_json_maps_require_existing_delete_race(monkeypatch, tmp_path):
|
||||
cfg = _qdrant_runtime_config(tmp_path)
|
||||
_write_schema_artifacts(tmp_path)
|
||||
monkeypatch.setattr("tht.cli.vector_cmd.make_embedder", lambda _: SimpleNamespace(embed_documents=lambda docs: [[0.1] * 1024 for _ in docs]))
|
||||
keyword_indexes = {
|
||||
"content_hash", "document_id", "kind", "record_key", "record_kind",
|
||||
"vector_generation", "workspace_id", "workspace_revision",
|
||||
}
|
||||
deleted = False
|
||||
|
||||
class Response:
|
||||
def __init__(self, status_code, payload=None):
|
||||
self.status_code = status_code
|
||||
self.ok = status_code < 400
|
||||
self._payload = payload
|
||||
self.text = "" if payload is None else "{}"
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
def request(method, url, **kwargs):
|
||||
nonlocal deleted
|
||||
if method == "POST" and url.endswith("/points/scroll"):
|
||||
return Response(200, {"result": {"points": [], "next_page_offset": None}})
|
||||
if method == "GET" and url.endswith("/collections/psd-clinical"):
|
||||
if deleted:
|
||||
return Response(404, {"status": {"error": "missing"}})
|
||||
response = Response(200, {"result": {
|
||||
"config": {"params": {"vectors": {"size": 1024, "distance": "Cosine"}}},
|
||||
"payload_schema": {
|
||||
key: {"data_type": "keyword"} for key in keyword_indexes
|
||||
},
|
||||
}})
|
||||
deleted = True
|
||||
return response
|
||||
if method == "PUT" and "/points?wait=true" in url:
|
||||
return Response(404, {"status": {"error": "missing"}})
|
||||
raise AssertionError((method, url))
|
||||
|
||||
monkeypatch.setattr("requests.request", request)
|
||||
response = CliRunner().invoke(app, ["vector", "index-schema", "--json", "-c", str(cfg)])
|
||||
|
||||
assert response.exit_code == 1
|
||||
assert response.stdout == '{"code":"semantic_index_incompatible","status":"failed"}\n'
|
||||
assert response.stderr == ""
|
||||
|
||||
|
||||
def test_vector_index_schema_json_failure_is_safe(monkeypatch, tmp_path):
|
||||
import json
|
||||
|
||||
cfg = _qdrant_runtime_config(tmp_path)
|
||||
_write_schema_artifacts(tmp_path)
|
||||
monkeypatch.setattr(
|
||||
"tht.adapters.factory.build_vector_store",
|
||||
lambda cfg, require_write: (_ for _ in ()).throw(Exception("secret qdrant endpoint")),
|
||||
)
|
||||
response = CliRunner().invoke(app, ["vector", "index-schema", "--json", "-c", str(cfg)])
|
||||
assert response.exit_code != 0
|
||||
assert response.stdout.count("\n") == 1
|
||||
payload = json.loads(response.stdout)
|
||||
assert payload == {"status": "failed", "code": "schema_index_failed"}
|
||||
assert "secret qdrant" not in response.stdout
|
||||
assert response.stderr == ""
|
||||
|
||||
|
||||
|
||||
def test_vector_index_schema_human_missing_physical_has_original_error(tmp_path):
|
||||
cfg = _qdrant_runtime_config(tmp_path)
|
||||
_write_schema_artifacts(tmp_path)
|
||||
physical = tmp_path / "artifacts" / "mschema" / "physical.yaml"
|
||||
physical.unlink()
|
||||
response = CliRunner().invoke(app, ["vector", "index-schema", "-c", str(cfg)])
|
||||
assert response.exit_code == 1
|
||||
assert "physical.yaml non trovato. Esegui prima `tht schema introspect`." in response.output
|
||||
|
||||
|
||||
def test_vector_index_schema_json_missing_physical_has_no_stderr_prose(tmp_path):
|
||||
import json
|
||||
|
||||
cfg = _qdrant_runtime_config(tmp_path)
|
||||
_write_schema_artifacts(tmp_path)
|
||||
(tmp_path / "artifacts" / "mschema" / "physical.yaml").unlink()
|
||||
response = CliRunner().invoke(app, ["vector", "index-schema", "--json", "-c", str(cfg)])
|
||||
assert response.exit_code == 1
|
||||
assert response.stderr == ""
|
||||
assert json.loads(response.stdout) == {"status": "failed", "code": "physical_schema_missing"}
|
||||
|
||||
|
||||
def test_vector_index_schema_human_missing_config_has_original_error(tmp_path):
|
||||
cfg = tmp_path / "missing.yaml"
|
||||
response = CliRunner().invoke(app, ["vector", "index-schema", "-c", str(cfg)])
|
||||
assert response.exit_code == 1
|
||||
assert response.output
|
||||
assert "ERRORE:" in response.output
|
||||
|
||||
|
||||
def test_vector_index_schema_json_legacy_config_has_no_stderr_on_success(monkeypatch, tmp_path):
|
||||
cfg = _legacy_qdrant_runtime_config(tmp_path)
|
||||
_write_schema_artifacts(tmp_path)
|
||||
store = _FakeVectorStore()
|
||||
monkeypatch.setattr("tht.adapters.factory.build_vector_store", lambda cfg, require_write: store)
|
||||
monkeypatch.setattr("tht.cli.vector_cmd.make_embedder", lambda _: _FakeEmbedder())
|
||||
|
||||
response = CliRunner().invoke(app, ["vector", "index-schema", "--json", "-c", str(cfg)])
|
||||
|
||||
assert response.exit_code == 0, response.output
|
||||
assert response.stderr == ""
|
||||
assert response.stdout.count("\n") == 1
|
||||
assert json.loads(response.stdout)["status"] == "succeeded"
|
||||
|
||||
|
||||
def test_vector_index_schema_json_legacy_config_has_no_stderr_on_failure(tmp_path):
|
||||
cfg = _legacy_qdrant_runtime_config(tmp_path)
|
||||
_write_schema_artifacts(tmp_path)
|
||||
(tmp_path / "artifacts" / "mschema" / "physical.yaml").unlink()
|
||||
|
||||
response = CliRunner().invoke(app, ["vector", "index-schema", "--json", "-c", str(cfg)])
|
||||
|
||||
assert response.exit_code == 1
|
||||
assert response.stderr == ""
|
||||
assert response.stdout.count("\n") == 1
|
||||
assert json.loads(response.stdout) == {
|
||||
"status": "failed", "code": "physical_schema_missing"
|
||||
}
|
||||
|
||||
|
||||
def test_vector_index_schema_guards_before_artifact_access(tmp_path, monkeypatch):
|
||||
import tht.cli.vector_cmd as command
|
||||
|
||||
cfg = _legacy_qdrant_runtime_config(tmp_path)
|
||||
text = cfg.read_text()
|
||||
text = text.replace("vectors:\n type: qdrant\n base_url: http://qdrant:6333\n collection: psd-clinical\n", "")
|
||||
text = text.replace("embeddings:\n provider: ollama_internal\n base_url: http://embedding:11434\n model: qwen3-embedding:0.6b\n dim: 1024\n", "")
|
||||
cfg.write_text(text)
|
||||
monkeypatch.setattr(command, "_load_schema_artifacts", lambda cfg: (_ for _ in ()).throw(AssertionError("artifact access")))
|
||||
response = CliRunner().invoke(app, ["vector", "index-schema", "--json", "-c", str(cfg)])
|
||||
|
||||
assert response.exit_code == 1
|
||||
assert json.loads(response.stdout) == {"status": "failed", "code": "vector_configuration_missing"}
|
||||
assert response.stderr == ""
|
||||
|
||||
|
||||
def test_vector_index_schema_core_reuses_injected_artifacts_without_path_resolution(tmp_path, monkeypatch):
|
||||
import tht.cli.vector_cmd as command
|
||||
from tht.config import load_config
|
||||
from tht.mschema.models import Annotations, PhysicalSchema
|
||||
|
||||
cfg_path = _qdrant_runtime_config(tmp_path)
|
||||
_write_schema_artifacts(tmp_path)
|
||||
physical = PhysicalSchema.from_yaml(tmp_path / "artifacts" / "mschema" / "physical.yaml")
|
||||
annotations = Annotations.from_yaml(tmp_path / "artifacts" / "mschema" / "annotations.yaml")
|
||||
store = _FakeVectorStore()
|
||||
monkeypatch.setattr(command, "physical_path", lambda cfg: (_ for _ in ()).throw(AssertionError("physical path")))
|
||||
monkeypatch.setattr(command, "annotations_path", lambda cfg: (_ for _ in ()).throw(AssertionError("annotations path")))
|
||||
monkeypatch.setattr("tht.adapters.factory.build_vector_store", lambda cfg, require_write: store)
|
||||
monkeypatch.setattr(command, "make_embedder", lambda _: _FakeEmbedder())
|
||||
|
||||
payload = command.index_schema_data(load_config(cfg_path), physical=physical, annotations=annotations)
|
||||
|
||||
assert payload["status"] == "succeeded"
|
||||
|
||||
@@ -1,173 +1,31 @@
|
||||
import json
|
||||
from uuid import NAMESPACE_URL, uuid5
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from qdrant_test_helpers import FakeQdrantHttp, FakeResponse, _write_record
|
||||
|
||||
from tht.adapters.vector.qdrant import QdrantVectorStore, point_id
|
||||
from tht.ports.vector import VectorStoreError, VectorWriteRecord
|
||||
from tht.vectorstore.records import VectorRecord
|
||||
from tht.ports.vector import (
|
||||
SemanticIndexIncompatibleError,
|
||||
VectorResponseError,
|
||||
VectorStoreError,
|
||||
VectorTransportError,
|
||||
)
|
||||
|
||||
_REQUIRED_INDEXES = {
|
||||
"content_hash", "document_id", "kind", "record_key", "record_kind",
|
||||
"vector_generation", "workspace_id", "workspace_revision",
|
||||
}
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, status_code: int, payload=None, text: str | None = None):
|
||||
self.status_code = status_code
|
||||
self._payload = payload
|
||||
self.text = text if text is not None else (
|
||||
"" if payload is None else json.dumps(payload)
|
||||
)
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return 200 <= self.status_code < 300
|
||||
|
||||
def json(self):
|
||||
if isinstance(self._payload, Exception):
|
||||
raise self._payload
|
||||
return self._payload
|
||||
|
||||
|
||||
class FakeQdrantHttp:
|
||||
def __init__(self, *, dimension=1024, distance="Cosine"):
|
||||
self.dimension = dimension
|
||||
self.distance = distance
|
||||
self.collection = None
|
||||
self.payload_indexes: set[str] = set()
|
||||
self.points: dict[str, dict] = {}
|
||||
self.calls: list[tuple[str, str, dict | None]] = []
|
||||
self.fail_request: Exception | None = None
|
||||
self.malformed_query = False
|
||||
self.malformed_scroll = False
|
||||
self.scroll_pages: list[dict] | None = None
|
||||
|
||||
def request(self, method, url, *, json=None, timeout=None):
|
||||
self.calls.append((method, url, json))
|
||||
if self.fail_request is not None:
|
||||
raise self.fail_request
|
||||
|
||||
path = url.split("://", 1)[-1].split("/", 1)[-1]
|
||||
path = "/" + path.split("?", 1)[0]
|
||||
|
||||
if method == "GET" and path == "/collections/workspace-semantic":
|
||||
if self.collection is None:
|
||||
return FakeResponse(404, {"status": "error"})
|
||||
return FakeResponse(200, {
|
||||
"result": {
|
||||
"config": {
|
||||
"params": {
|
||||
"vectors": {"size": self.dimension, "distance": self.distance}
|
||||
}
|
||||
},
|
||||
"payload_schema": {
|
||||
field: {"data_type": "keyword"} for field in sorted(self.payload_indexes)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
if method == "PUT" and path == "/collections/workspace-semantic":
|
||||
self.collection = json
|
||||
self.dimension = json["vectors"]["size"]
|
||||
self.distance = json["vectors"]["distance"]
|
||||
return FakeResponse(200, {"status": "ok"})
|
||||
|
||||
if method == "PUT" and path == "/collections/workspace-semantic/index":
|
||||
self.payload_indexes.add(json["field_name"])
|
||||
return FakeResponse(200, {"status": "ok"})
|
||||
|
||||
if method == "PUT" and path == "/collections/workspace-semantic/points":
|
||||
for point in json["points"]:
|
||||
self.points[point["id"]] = point
|
||||
return FakeResponse(200, {"result": {"status": "acknowledged"}})
|
||||
|
||||
if method == "POST" and path == "/collections/workspace-semantic/points/query":
|
||||
if self.malformed_query:
|
||||
return FakeResponse(200, {"result": {"points": "nope"}})
|
||||
wanted = _match_points(self.points.values(), json["filter"])
|
||||
scored = sorted(
|
||||
(
|
||||
{
|
||||
"id": point["id"],
|
||||
"score": point.get("score", 0.9),
|
||||
"payload": point["payload"],
|
||||
}
|
||||
for point in wanted
|
||||
),
|
||||
key=lambda point: (-point["score"], point["payload"]["record_key"]),
|
||||
)
|
||||
return FakeResponse(200, {"result": {"points": scored[: json["limit"]]}})
|
||||
|
||||
if method == "POST" and path == "/collections/workspace-semantic/points/scroll":
|
||||
if self.malformed_scroll:
|
||||
return FakeResponse(200, {"result": {"points": "bad"}})
|
||||
if self.scroll_pages is not None:
|
||||
offset = json.get("offset")
|
||||
for page in self.scroll_pages:
|
||||
if page["offset"] == offset:
|
||||
filtered = _match_points(page["points"], json["filter"])
|
||||
return FakeResponse(200, {
|
||||
"result": {
|
||||
"points": filtered,
|
||||
"next_page_offset": page["next_page_offset"],
|
||||
}
|
||||
})
|
||||
raise AssertionError(("unexpected offset", offset, self.scroll_pages))
|
||||
wanted = sorted(
|
||||
_match_points(self.points.values(), json["filter"]),
|
||||
key=lambda point: point["payload"]["record_key"],
|
||||
)
|
||||
return FakeResponse(200, {"result": {"points": wanted, "next_page_offset": None}})
|
||||
|
||||
if method == "POST" and path == "/collections/workspace-semantic/points/delete":
|
||||
doomed = [point["id"] for point in _match_points(self.points.values(), json["filter"])]
|
||||
for point_id_value in doomed:
|
||||
self.points.pop(point_id_value, None)
|
||||
return FakeResponse(200, {"result": {"status": "acknowledged"}})
|
||||
|
||||
raise AssertionError((method, path, json))
|
||||
|
||||
|
||||
def _match_points(points, flt):
|
||||
matches = []
|
||||
must = flt["must"]
|
||||
for point in points:
|
||||
payload = point["payload"]
|
||||
if all(_match_clause(payload, clause) for clause in must):
|
||||
matches.append(point)
|
||||
return matches
|
||||
|
||||
|
||||
def _match_clause(payload, clause):
|
||||
key = clause["key"]
|
||||
match = clause["match"]
|
||||
if "value" in match:
|
||||
return payload.get(key) == match["value"]
|
||||
if "any" in match:
|
||||
return payload.get(key) in set(match["any"])
|
||||
raise AssertionError(clause)
|
||||
|
||||
|
||||
def _write_record(record_id: str, kind: str, *, metadata=None):
|
||||
return VectorWriteRecord(
|
||||
record=VectorRecord(
|
||||
id=record_id,
|
||||
kind=kind,
|
||||
ref=f"ref:{record_id}",
|
||||
title=f"title:{record_id}",
|
||||
content=f"content:{record_id}",
|
||||
metadata=metadata or {},
|
||||
),
|
||||
embedding=[0.1] * 1024,
|
||||
content_hash="sha256:" + "a" * 64,
|
||||
)
|
||||
|
||||
|
||||
def _store(fake: FakeQdrantHttp) -> QdrantVectorStore:
|
||||
def _store(fake: FakeQdrantHttp, *, collection_lifecycle="create_if_missing") -> QdrantVectorStore:
|
||||
return QdrantVectorStore(
|
||||
base_url="http://qdrant:6333",
|
||||
collection="workspace-semantic",
|
||||
workspace_id="demo",
|
||||
workspace_revision="a" * 40,
|
||||
expected_dimension=1024,
|
||||
collection_lifecycle=collection_lifecycle,
|
||||
request=fake.request,
|
||||
)
|
||||
|
||||
@@ -178,6 +36,196 @@ def test_point_id_is_deterministic_uuidv5():
|
||||
)
|
||||
|
||||
|
||||
|
||||
def test_require_existing_requires_an_explicit_embedding_dimension():
|
||||
fake = FakeQdrantHttp()
|
||||
with pytest.raises(ValueError, match="expected dimension"):
|
||||
QdrantVectorStore(
|
||||
base_url="http://qdrant:6333",
|
||||
collection="workspace-semantic",
|
||||
workspace_id="demo",
|
||||
expected_dimension=None,
|
||||
collection_lifecycle="require_existing",
|
||||
request=fake.request,
|
||||
)
|
||||
assert fake.calls == []
|
||||
|
||||
|
||||
def test_require_existing_refuses_missing_collection_without_mutations():
|
||||
fake = FakeQdrantHttp()
|
||||
store = _store(fake, collection_lifecycle="require_existing")
|
||||
|
||||
with pytest.raises(VectorStoreError, match="semantic_index_incompatible"):
|
||||
store.upsert("memory", [_write_record("memory:1", "memory")])
|
||||
|
||||
assert [call for call in fake.calls if call[0] == "PUT"] == []
|
||||
|
||||
|
||||
def test_require_existing_maps_scroll_404_after_compatible_preflight():
|
||||
fake = FakeQdrantHttp()
|
||||
fake.collection = {"vectors": {"size": 1024, "distance": "Cosine"}}
|
||||
fake.payload_indexes = {
|
||||
"content_hash", "document_id", "kind", "record_key", "record_kind",
|
||||
"vector_generation", "workspace_id", "workspace_revision",
|
||||
}
|
||||
original_request = fake.request
|
||||
|
||||
def request(method, url, **kwargs):
|
||||
if method == "POST" and url.endswith("/points/scroll"):
|
||||
original_request(method, url, **kwargs)
|
||||
return FakeResponse(404, {"status": "error"})
|
||||
return original_request(method, url, **kwargs)
|
||||
|
||||
store = QdrantVectorStore(
|
||||
base_url="http://qdrant:6333", collection="workspace-semantic", workspace_id="demo",
|
||||
workspace_revision="a" * 40, expected_dimension=1024,
|
||||
collection_lifecycle="require_existing", request=request,
|
||||
)
|
||||
|
||||
with pytest.raises(SemanticIndexIncompatibleError):
|
||||
store.existing_hashes("memory", ["memory"])
|
||||
|
||||
assert [call for call in fake.calls if call[1].endswith("/points/scroll")]
|
||||
assert [call for call in fake.calls if call[0] == "PUT"] == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("operation", "generation"),
|
||||
[("delete_kinds", None), ("delete_generation", "gen:" + "a" * 32)],
|
||||
)
|
||||
def test_require_existing_maps_delete_scroll_404_after_compatible_preflight(operation, generation):
|
||||
fake = FakeQdrantHttp()
|
||||
fake.collection = {"vectors": {"size": 1024, "distance": "Cosine"}}
|
||||
fake.payload_indexes = {
|
||||
"content_hash", "document_id", "kind", "record_key", "record_kind",
|
||||
"vector_generation", "workspace_id", "workspace_revision",
|
||||
}
|
||||
original_request = fake.request
|
||||
|
||||
def request(method, url, **kwargs):
|
||||
if method == "POST" and url.endswith("/points/scroll"):
|
||||
original_request(method, url, **kwargs)
|
||||
return FakeResponse(404, {"status": "error"})
|
||||
return original_request(method, url, **kwargs)
|
||||
|
||||
store = QdrantVectorStore(
|
||||
base_url="http://qdrant:6333", collection="workspace-semantic", workspace_id="demo",
|
||||
workspace_revision="a" * 40, expected_dimension=1024,
|
||||
collection_lifecycle="require_existing", request=request,
|
||||
)
|
||||
|
||||
with pytest.raises(SemanticIndexIncompatibleError):
|
||||
if operation == "delete_kinds":
|
||||
store.delete_kinds("memory", ["memory"])
|
||||
else:
|
||||
store.delete_generation("evidence", generation, "demo")
|
||||
|
||||
assert [call for call in fake.calls if call[1].endswith("/points/scroll")]
|
||||
assert [call for call in fake.calls if call[0] == "POST" and "delete" in call[1]] == []
|
||||
|
||||
|
||||
def test_require_existing_scroll_non_404_remains_transport_error():
|
||||
fake = FakeQdrantHttp()
|
||||
fake.collection = {"vectors": {"size": 1024, "distance": "Cosine"}}
|
||||
fake.payload_indexes = set(_REQUIRED_INDEXES)
|
||||
original_request = fake.request
|
||||
|
||||
def request(method, url, **kwargs):
|
||||
if method == "POST" and url.endswith("/points/scroll"):
|
||||
original_request(method, url, **kwargs)
|
||||
return FakeResponse(503, {"status": "error"})
|
||||
return original_request(method, url, **kwargs)
|
||||
|
||||
store = QdrantVectorStore(
|
||||
base_url="http://qdrant:6333", collection="workspace-semantic", workspace_id="demo",
|
||||
workspace_revision="a" * 40, expected_dimension=1024,
|
||||
collection_lifecycle="require_existing", request=request,
|
||||
)
|
||||
with pytest.raises(VectorTransportError) as caught:
|
||||
store.existing_hashes("memory", ["memory"])
|
||||
assert caught.value.status_code == 503
|
||||
|
||||
|
||||
def test_require_existing_scroll_malformed_remains_response_error():
|
||||
fake = FakeQdrantHttp()
|
||||
fake.collection = {"vectors": {"size": 1024, "distance": "Cosine"}}
|
||||
fake.payload_indexes = set(_REQUIRED_INDEXES)
|
||||
fake.malformed_scroll = True
|
||||
store = _store(fake, collection_lifecycle="require_existing")
|
||||
with pytest.raises(VectorResponseError):
|
||||
store.existing_hashes("memory", ["memory"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("dimension", "distance", "indexes", "index_types"),
|
||||
[(384, "Cosine", set(), {}),
|
||||
(1024, "Dot", set(), {}),
|
||||
(1024, "Cosine", {"content_hash"}, {}),
|
||||
(1024, "Cosine", {
|
||||
"content_hash", "document_id", "kind", "record_key", "record_kind",
|
||||
"vector_generation", "workspace_id", "workspace_revision",
|
||||
}, {"kind": "integer"})],
|
||||
)
|
||||
def test_require_existing_refuses_incompatible_collection_without_mutations(
|
||||
dimension, distance, indexes, index_types
|
||||
):
|
||||
fake = FakeQdrantHttp(dimension=dimension, distance=distance)
|
||||
fake.collection = {"vectors": {"size": dimension, "distance": distance}}
|
||||
fake.payload_indexes = indexes
|
||||
fake.payload_index_types = index_types
|
||||
store = _store(fake, collection_lifecycle="require_existing")
|
||||
|
||||
with pytest.raises(VectorStoreError, match="semantic_index_incompatible"):
|
||||
store.upsert("memory", [_write_record("memory:1", "memory")])
|
||||
|
||||
assert [call for call in fake.calls if call[0] == "PUT"] == []
|
||||
|
||||
|
||||
def test_require_existing_writes_compatible_collection_without_lifecycle_mutations():
|
||||
fake = FakeQdrantHttp()
|
||||
fake.collection = {"vectors": {"size": 1024, "distance": "Cosine"}}
|
||||
fake.payload_indexes = {
|
||||
"content_hash", "document_id", "kind", "record_key", "record_kind",
|
||||
"vector_generation", "workspace_id", "workspace_revision",
|
||||
}
|
||||
store = _store(fake, collection_lifecycle="require_existing")
|
||||
|
||||
assert store.upsert("memory", [_write_record("memory:1", "memory")]) == 1
|
||||
assert not [call for call in fake.calls if call[0] == "PUT" and call[1].endswith("/index")]
|
||||
|
||||
|
||||
def test_require_existing_write_fails_after_collection_is_deleted_without_recreating():
|
||||
fake = FakeQdrantHttp()
|
||||
fake.collection = {"vectors": {"size": 1024, "distance": "Cosine"}}
|
||||
fake.payload_indexes = {
|
||||
"content_hash", "document_id", "kind", "record_key", "record_kind",
|
||||
"vector_generation", "workspace_id", "workspace_revision",
|
||||
}
|
||||
original_request = fake.request
|
||||
deleted = False
|
||||
|
||||
def request(method, url, **kwargs):
|
||||
nonlocal deleted
|
||||
response = original_request(method, url, **kwargs)
|
||||
if method == "GET" and url.endswith("/collections/workspace-semantic") and not deleted:
|
||||
deleted = True
|
||||
fake.collection = None
|
||||
return response
|
||||
|
||||
store = QdrantVectorStore(
|
||||
base_url="http://qdrant:6333", collection="workspace-semantic", workspace_id="demo",
|
||||
workspace_revision="a" * 40, expected_dimension=1024,
|
||||
collection_lifecycle="require_existing", request=request,
|
||||
)
|
||||
|
||||
from tht.ports.vector import SemanticIndexIncompatibleError
|
||||
|
||||
with pytest.raises(SemanticIndexIncompatibleError) as caught:
|
||||
store.upsert("memory", [_write_record("memory:1", "memory")])
|
||||
assert caught.value.code == "semantic_index_incompatible"
|
||||
assert not [call for call in fake.calls if call[0] == "PUT" and call[1].endswith("/collections/workspace-semantic")]
|
||||
|
||||
|
||||
def test_upsert_creates_collection_and_keyword_indexes_idempotently():
|
||||
fake = FakeQdrantHttp()
|
||||
store = _store(fake)
|
||||
@@ -585,6 +633,7 @@ def test_upsert_payload_keeps_canonical_identity_when_metadata_collides():
|
||||
|
||||
def test_scroll_based_operations_paginate_until_next_page_offset_is_absent():
|
||||
fake = FakeQdrantHttp()
|
||||
fake.collection = {"vectors": {"size": 1024, "distance": "Cosine"}}
|
||||
generation_a = "gen:" + "1" * 32
|
||||
generation_b = "gen:" + "2" * 32
|
||||
fake.scroll_pages = [
|
||||
@@ -649,3 +698,267 @@ def test_scroll_based_operations_paginate_until_next_page_offset_is_absent():
|
||||
if call[0] == "POST" and call[1].endswith("/points/scroll")
|
||||
]
|
||||
assert offsets[:2] == [None, "page-2"]
|
||||
|
||||
|
||||
_MISSING = object()
|
||||
|
||||
|
||||
def _set_response_path(payload, path, value):
|
||||
if value is _MISSING:
|
||||
parent = payload
|
||||
for key in path[:-1]:
|
||||
parent = parent[key]
|
||||
parent.pop(path[-1], None)
|
||||
return
|
||||
parent = payload
|
||||
for key in path[:-1]:
|
||||
parent = parent[key]
|
||||
parent[path[-1]] = value
|
||||
|
||||
|
||||
def _collection_response_with_shape(fake, path, value):
|
||||
original = fake.request
|
||||
|
||||
def request(method, url, **kwargs):
|
||||
response = original(method, url, **kwargs)
|
||||
if method == "GET" and url.endswith("/collections/workspace-semantic") and response.ok:
|
||||
payload = response.json()
|
||||
_set_response_path(payload, path, value)
|
||||
return FakeResponse(200, payload)
|
||||
return response
|
||||
|
||||
return request
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("path", "value"),
|
||||
[
|
||||
(("result",), None),
|
||||
(("result",), []),
|
||||
(("result",), "result"),
|
||||
(("result",), _MISSING),
|
||||
(("result", "config"), None),
|
||||
(("result", "config"), []),
|
||||
(("result", "config"), "config"),
|
||||
(("result", "config"), _MISSING),
|
||||
(("result", "config", "params"), None),
|
||||
(("result", "config", "params"), []),
|
||||
(("result", "config", "params"), "params"),
|
||||
(("result", "config", "params"), _MISSING),
|
||||
(("result", "config", "params", "vectors"), None),
|
||||
(("result", "config", "params", "vectors"), []),
|
||||
(("result", "config", "params", "vectors"), "vectors"),
|
||||
(("result", "config", "params", "vectors"), _MISSING),
|
||||
(("result", "config", "params", "vectors", "size"), None),
|
||||
(("result", "config", "params", "vectors", "size"), []),
|
||||
(("result", "config", "params", "vectors", "size"), "1024"),
|
||||
(("result", "config", "params", "vectors", "size"), _MISSING),
|
||||
(("result", "config", "params", "vectors", "distance"), None),
|
||||
(("result", "config", "params", "vectors", "distance"), []),
|
||||
(("result", "config", "params", "vectors", "distance"), 1),
|
||||
(("result", "config", "params", "vectors", "distance"), _MISSING),
|
||||
(("result", "payload_schema"), None),
|
||||
(("result", "payload_schema"), []),
|
||||
(("result", "payload_schema"), "schema"),
|
||||
(("result", "payload_schema"), _MISSING),
|
||||
(("result", "payload_schema", "kind"), None),
|
||||
(("result", "payload_schema", "kind"), []),
|
||||
(("result", "payload_schema", "kind"), "keyword"),
|
||||
(("result", "payload_schema", "kind", "data_type"), None),
|
||||
(("result", "payload_schema", "kind", "data_type"), []),
|
||||
(("result", "payload_schema", "kind", "data_type"), _MISSING),
|
||||
],
|
||||
)
|
||||
def test_collection_success_response_shapes_are_typed_errors(path, value):
|
||||
fake = FakeQdrantHttp()
|
||||
fake.collection = {"vectors": {"size": 1024, "distance": "Cosine"}}
|
||||
fake.payload_indexes = set(_REQUIRED_INDEXES)
|
||||
request = _collection_response_with_shape(fake, path, value)
|
||||
store = QdrantVectorStore(
|
||||
base_url="http://qdrant:6333", collection="workspace-semantic", workspace_id="demo",
|
||||
expected_dimension=1024, request=request,
|
||||
)
|
||||
|
||||
with pytest.raises(VectorResponseError):
|
||||
store.upsert("memory", [_write_record("memory:1", "memory")])
|
||||
|
||||
health = store.health()
|
||||
assert health.ok is False
|
||||
assert health.read_reachable is False
|
||||
assert health.write_reachable is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("payload_value", [None, [], {}])
|
||||
def test_query_success_response_payload_leaf_shapes_are_typed_errors(payload_value):
|
||||
fake = FakeQdrantHttp()
|
||||
fake.collection = {"vectors": {"size": 1024, "distance": "Cosine"}}
|
||||
fake.payload_indexes = set(_REQUIRED_INDEXES)
|
||||
original = fake.request
|
||||
|
||||
def request(method, url, **kwargs):
|
||||
response = original(method, url, **kwargs)
|
||||
if method == "POST" and url.endswith("/points/query") and response.ok:
|
||||
payload = response.json()
|
||||
payload["result"]["points"] = [{
|
||||
"id": "p1", "score": 0.9,
|
||||
"payload": {"record_key": payload_value},
|
||||
}]
|
||||
return FakeResponse(200, payload)
|
||||
return response
|
||||
|
||||
store = _store(fake)
|
||||
store._request = request
|
||||
with pytest.raises(VectorResponseError):
|
||||
store.search(["memory"], [0.2] * 1024, limit=1, kinds=["memory"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("point", [None, [], "point"])
|
||||
def test_query_success_response_point_shapes_are_typed_errors(point):
|
||||
fake = FakeQdrantHttp()
|
||||
fake.collection = {"vectors": {"size": 1024, "distance": "Cosine"}}
|
||||
fake.payload_indexes = set(_REQUIRED_INDEXES)
|
||||
original = fake.request
|
||||
|
||||
def request(method, url, **kwargs):
|
||||
response = original(method, url, **kwargs)
|
||||
if method == "POST" and url.endswith("/points/query") and response.ok:
|
||||
payload = response.json()
|
||||
payload["result"]["points"] = [point]
|
||||
return FakeResponse(200, payload)
|
||||
return response
|
||||
|
||||
store = _store(fake)
|
||||
store._request = request
|
||||
with pytest.raises(VectorResponseError):
|
||||
store.search(["memory"], [0.2] * 1024, limit=1, kinds=["memory"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("path", "value"),
|
||||
[
|
||||
(("result",), None),
|
||||
(("result",), []),
|
||||
(("result",), "result"),
|
||||
(("result",), _MISSING),
|
||||
(("result", "points"), None),
|
||||
(("result", "points"), {}),
|
||||
(("result", "points"), "points"),
|
||||
(("result", "points"), _MISSING),
|
||||
(("result", "next_page_offset"), []),
|
||||
(("result", "next_page_offset"), {}),
|
||||
(("result", "next_page_offset"), 1.5),
|
||||
(("result", "next_page_offset"), True),
|
||||
],
|
||||
)
|
||||
def test_scroll_success_response_shapes_are_typed_errors(path, value):
|
||||
fake = FakeQdrantHttp()
|
||||
fake.collection = {"vectors": {"size": 1024, "distance": "Cosine"}}
|
||||
fake.payload_indexes = set(_REQUIRED_INDEXES)
|
||||
original = fake.request
|
||||
|
||||
def request(method, url, **kwargs):
|
||||
response = original(method, url, **kwargs)
|
||||
if method == "POST" and url.endswith("/points/scroll") and response.ok:
|
||||
payload = response.json()
|
||||
_set_response_path(payload, path, value)
|
||||
return FakeResponse(200, payload)
|
||||
return response
|
||||
|
||||
store = QdrantVectorStore(
|
||||
base_url="http://qdrant:6333", collection="workspace-semantic", workspace_id="demo",
|
||||
expected_dimension=1024, request=request,
|
||||
)
|
||||
with pytest.raises(VectorResponseError):
|
||||
store.existing_hashes("memory", ["memory"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("next_page_offset", [None, _MISSING])
|
||||
def test_scroll_accepts_null_or_missing_terminal_offset(next_page_offset):
|
||||
fake = FakeQdrantHttp()
|
||||
fake.collection = {"vectors": {"size": 1024, "distance": "Cosine"}}
|
||||
fake.payload_indexes = set(_REQUIRED_INDEXES)
|
||||
original = fake.request
|
||||
|
||||
def request(method, url, **kwargs):
|
||||
response = original(method, url, **kwargs)
|
||||
if method == "POST" and url.endswith("/points/scroll") and response.ok:
|
||||
payload = response.json()
|
||||
_set_response_path(payload, ("result", "next_page_offset"), next_page_offset)
|
||||
return FakeResponse(200, payload)
|
||||
return response
|
||||
|
||||
store = QdrantVectorStore(
|
||||
base_url="http://qdrant:6333", collection="workspace-semantic", workspace_id="demo",
|
||||
expected_dimension=1024, request=request,
|
||||
)
|
||||
assert store.existing_hashes("memory", ["memory"]) == {}
|
||||
|
||||
|
||||
_QUERY_PAYLOAD = {
|
||||
"record_key": "memory:1",
|
||||
"record_kind": "memory",
|
||||
"kind": "memory",
|
||||
"ref": "ref:memory:1",
|
||||
"title": "title:memory:1",
|
||||
"content": "content:memory:1",
|
||||
}
|
||||
|
||||
|
||||
def _query_response_store(points):
|
||||
fake = FakeQdrantHttp()
|
||||
fake.collection = {"vectors": {"size": 1024, "distance": "Cosine"}}
|
||||
fake.payload_indexes = set(_REQUIRED_INDEXES)
|
||||
original = fake.request
|
||||
|
||||
def request(method, url, **kwargs):
|
||||
if method == "POST" and url.endswith("/points/query"):
|
||||
return FakeResponse(200, {"result": {"points": points}})
|
||||
return original(method, url, **kwargs)
|
||||
|
||||
store = _store(fake)
|
||||
store._request = request
|
||||
return store
|
||||
|
||||
|
||||
@pytest.mark.parametrize("leaf", [
|
||||
"record_key", "record_kind", "kind", "ref", "title", "content",
|
||||
])
|
||||
def test_query_rejects_non_string_required_vector_hit_payload_leaves(leaf):
|
||||
payload = {**_QUERY_PAYLOAD, leaf: 1}
|
||||
store = _query_response_store([{"id": "p1", "score": 0.9, "payload": payload}])
|
||||
|
||||
with pytest.raises(VectorResponseError):
|
||||
store.search(["memory"], [0.2] * 1024, limit=1, kinds=["memory"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("leaf", [
|
||||
"record_key", "record_kind", "kind", "ref", "title", "content",
|
||||
])
|
||||
def test_query_rejects_missing_required_vector_hit_payload_leaves(leaf):
|
||||
payload = {key: value for key, value in _QUERY_PAYLOAD.items() if key != leaf}
|
||||
store = _query_response_store([{"id": "p1", "score": 0.9, "payload": payload}])
|
||||
|
||||
with pytest.raises(VectorResponseError):
|
||||
store.search(["memory"], [0.2] * 1024, limit=1, kinds=["memory"])
|
||||
|
||||
|
||||
def test_query_rejects_heterogeneous_record_keys_before_sorting_hits():
|
||||
store = _query_response_store([
|
||||
{"id": "p1", "score": 0.9, "payload": _QUERY_PAYLOAD},
|
||||
{
|
||||
"id": "p2",
|
||||
"score": 0.8,
|
||||
"payload": {**_QUERY_PAYLOAD, "record_key": 2},
|
||||
},
|
||||
])
|
||||
|
||||
with pytest.raises(VectorResponseError):
|
||||
store.search(["memory"], [0.2] * 1024, limit=2, kinds=["memory"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("score", [True, False, 10**1000, float("nan"), float("inf"), float("-inf")])
|
||||
def test_query_rejects_scores_that_cannot_be_finite_float(score):
|
||||
store = _query_response_store([{"id": "p1", "score": score, "payload": _QUERY_PAYLOAD}])
|
||||
|
||||
with pytest.raises(VectorResponseError):
|
||||
store.search(["memory"], [0.2] * 1024, limit=1, kinds=["memory"])
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import json
|
||||
import subprocess
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
@@ -7,9 +9,11 @@ from pydantic import SecretStr
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from tht.adapters.evidence import HttpManifestEvidenceSource
|
||||
from tht.adapters.factory import build_evidence_sources
|
||||
from tht.adapters.factory import build_evidence_sources, build_vector_store
|
||||
from tht.cli import app
|
||||
from tht.config import ConfigError, load_config
|
||||
from tht.jobs.dwh_pipeline import config_dwh_binding
|
||||
from tht.ports.vector import VectorStoreError
|
||||
|
||||
SIGNED_CANARY = "SIGNED-CANARY-QUERY"
|
||||
ACCESS_CANARY = "ACCESS-CANARY"
|
||||
@@ -350,3 +354,231 @@ def test_validation_repr_cli_and_exception_output_never_disclose_transport_secre
|
||||
assert result.exit_code == 0
|
||||
assert_no_canaries(result.stdout)
|
||||
assert_no_canaries(result.stderr)
|
||||
|
||||
|
||||
def _render_registry_runtime_config(tmp_path) -> str:
|
||||
"""Render the production schema-v3 runtime rather than duplicating its YAML."""
|
||||
tmp_path.mkdir(parents=True, exist_ok=True)
|
||||
backend = Path(__file__).resolve().parents[2] / "backend"
|
||||
tsx = backend / "node_modules/.bin/tsx"
|
||||
if not tsx.exists():
|
||||
pytest.skip("cross-runtime integration requires backend/node_modules/.bin/tsx")
|
||||
password_file = tmp_path / "dwh-password"
|
||||
password_file.write_text("not-a-canary")
|
||||
script = r"""
|
||||
import { parseWorkspaceYaml } from "__SCHEMA__";
|
||||
import { renderRuntimeConfig } from "__RENDERER__";
|
||||
const workspace = parseWorkspaceYaml(`workspace:
|
||||
schema_version: 3
|
||||
id: psd-clinical
|
||||
name: Runtime test
|
||||
language: en
|
||||
dwh:
|
||||
engine: postgres
|
||||
database: analytics
|
||||
schema: mart
|
||||
supported_transports: [postgres_direct]
|
||||
semantic_index:
|
||||
vector_store:
|
||||
engine: qdrant
|
||||
collection: workspace-semantic
|
||||
dimensions: 1024
|
||||
distance: cosine
|
||||
embedding:
|
||||
provider: ollama_internal
|
||||
model: qwen3-embedding:0.6b
|
||||
dimensions: 1024
|
||||
llm_policy:
|
||||
allowed: [zai/glm-5.2]
|
||||
`);
|
||||
const bindings = {
|
||||
dwh: { transport: "postgres_direct", missing: [], values: {
|
||||
THT_WS_PSD_CLINICAL_DWH_HOST: "dwh.internal",
|
||||
THT_WS_PSD_CLINICAL_DWH_PORT: "5432",
|
||||
THT_WS_PSD_CLINICAL_DWH_USER: "reader",
|
||||
THT_WS_PSD_CLINICAL_DWH_PASSWORD_FILE: process.argv[2],
|
||||
} },
|
||||
evidence: { missing: [], values: {} },
|
||||
};
|
||||
process.stdout.write(renderRuntimeConfig(workspace, bindings, {
|
||||
sessions: "/tmp/sessions", artifacts: "/tmp/artifacts", indexes: "/tmp/indexes",
|
||||
}, { workspaceId: "psd-clinical", workspaceRevision: "a".repeat(40) }));
|
||||
"""
|
||||
script = script.replace(
|
||||
"__SCHEMA__", str(backend / "src/workspaces/schema.ts"),
|
||||
).replace("__RENDERER__", str(backend / "src/workspaces/runtime-renderer.ts"))
|
||||
script_path = tmp_path / "render-runtime.mts"
|
||||
script_path.write_text(script)
|
||||
result = subprocess.run(
|
||||
[str(tsx), str(script_path), str(password_file)],
|
||||
cwd=backend, check=True, capture_output=True, text=True,
|
||||
)
|
||||
return result.stdout
|
||||
|
||||
|
||||
def _render_registry_runtime_configs(tmp_path) -> tuple[Path, Path]:
|
||||
session = tmp_path / "session.yaml"
|
||||
maintenance = tmp_path / "maintenance.yaml"
|
||||
session.write_text(_render_registry_runtime_config(tmp_path))
|
||||
maintenance.write_text(_render_registry_runtime_config(tmp_path))
|
||||
return session, maintenance
|
||||
|
||||
|
||||
def _qdrant_config_yaml(tmp_path, *, registry: bool) -> dict:
|
||||
value = {
|
||||
"dwh": {
|
||||
"type": "postgres_direct",
|
||||
"connection": {
|
||||
"database": "analytics", "schema": "public", "user": "reader",
|
||||
"password": "not-a-canary",
|
||||
},
|
||||
},
|
||||
"vectors": {
|
||||
"type": "qdrant", "base_url": "http://localhost:6333",
|
||||
"collection": "workspace-semantic",
|
||||
},
|
||||
"embeddings": {
|
||||
"base_url": "http://localhost:11434", "model": "qwen3-embedding:0.6b", "dim": 1024,
|
||||
},
|
||||
"evidence": {"source_root": str(tmp_path / "evidence")},
|
||||
}
|
||||
if registry:
|
||||
value["runtime_identity"] = {
|
||||
"workspace_id": "demo-workspace", "workspace_revision": "a" * 40,
|
||||
}
|
||||
return value
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.parametrize("collection_state", ["missing", "incompatible"])
|
||||
def test_registry_render_chain_produces_require_existing_qdrant_configs(
|
||||
tmp_path, monkeypatch, collection_state,
|
||||
):
|
||||
session_path, maintenance_path = _render_registry_runtime_configs(tmp_path)
|
||||
session_cfg = load_config(session_path)
|
||||
maintenance_cfg = load_config(maintenance_path)
|
||||
|
||||
assert session_cfg.vectors.collection_lifecycle == "require_existing"
|
||||
assert maintenance_cfg.vectors.collection_lifecycle == "require_existing"
|
||||
assert yaml.safe_load(session_path.read_text())["vectors"]["collection_lifecycle"] == "require_existing"
|
||||
assert yaml.safe_load(maintenance_path.read_text())["vectors"]["collection_lifecycle"] == "require_existing"
|
||||
assert session_path.read_bytes() == maintenance_path.read_bytes()
|
||||
|
||||
from qdrant_test_helpers import FakeQdrantHttp, _write_record
|
||||
|
||||
for cfg in (session_cfg, maintenance_cfg):
|
||||
fake = FakeQdrantHttp(dimension=384) if collection_state == "incompatible" else FakeQdrantHttp()
|
||||
if collection_state == "incompatible":
|
||||
fake.collection = {"vectors": {"size": 384, "distance": "Cosine"}}
|
||||
monkeypatch.setattr("requests.request", fake.request)
|
||||
store = build_vector_store(cfg, require_write=True)
|
||||
with pytest.raises(VectorStoreError, match="semantic_index_incompatible"):
|
||||
store.upsert("memory", [_write_record("memory:1", "memory")])
|
||||
assert not [call for call in fake.calls if call[0] == "PUT"]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_registry_rendered_session_and_maintenance_configs_bind_equally(tmp_path):
|
||||
session_path, maintenance_path = _render_registry_runtime_configs(tmp_path)
|
||||
|
||||
assert config_dwh_binding(load_config(session_path)) == config_dwh_binding(
|
||||
load_config(maintenance_path)
|
||||
)
|
||||
|
||||
|
||||
def test_loader_rejects_split_brain_qdrant_compatibility_resources(tmp_path):
|
||||
values = _qdrant_config_yaml(tmp_path, registry=True)
|
||||
values["vectors"]["collection"] = "workspace-a"
|
||||
values["resources"] = {
|
||||
"vector": {
|
||||
"engine": "qdrant",
|
||||
"base_url": "http://qdrant:6333/",
|
||||
"collection": "workspace-b",
|
||||
}
|
||||
}
|
||||
path = tmp_path / "split-brain.yaml"
|
||||
path.write_text(yaml.safe_dump(values))
|
||||
|
||||
with pytest.raises(ConfigError, match="vectors.*resources.vector|disagree"):
|
||||
load_config(path)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field", "value"),
|
||||
[
|
||||
("base_url", {}),
|
||||
("base_url", []),
|
||||
("base_url", None),
|
||||
("base_url", 6333),
|
||||
("collection", {}),
|
||||
("collection", []),
|
||||
("collection", None),
|
||||
("collection", 7),
|
||||
],
|
||||
)
|
||||
def test_raw_qdrant_consistency_rejects_untrusted_top_level_values(tmp_path, field, value):
|
||||
values = _qdrant_config_yaml(tmp_path, registry=True)
|
||||
values["vectors"][field] = value
|
||||
values["resources"] = {
|
||||
"vector": {
|
||||
"engine": "qdrant",
|
||||
"base_url": "http://localhost:6333",
|
||||
"collection": "workspace-semantic",
|
||||
}
|
||||
}
|
||||
path = tmp_path / "invalid-qdrant.yaml"
|
||||
path.write_text(yaml.safe_dump(values))
|
||||
|
||||
with pytest.raises(ConfigError):
|
||||
load_config(path)
|
||||
|
||||
config_result = CliRunner().invoke(app, ["config", "check", "--config", str(path)])
|
||||
assert config_result.exit_code == 1
|
||||
assert "Traceback" not in config_result.stderr
|
||||
|
||||
vector_result = CliRunner().invoke(
|
||||
app, ["vector", "index-schema", "--json", "-c", str(path)]
|
||||
)
|
||||
assert vector_result.exit_code == 1
|
||||
assert vector_result.stderr == ""
|
||||
assert json.loads(vector_result.stdout) == {
|
||||
"status": "failed", "code": "invalid_configuration"
|
||||
}
|
||||
|
||||
|
||||
def test_loader_rejects_lifecycle_policy_in_compatibility_resource(tmp_path):
|
||||
values = _qdrant_config_yaml(tmp_path, registry=True)
|
||||
values["resources"] = {
|
||||
"vector": {
|
||||
"engine": "qdrant",
|
||||
"base_url": "http://qdrant:6333",
|
||||
"collection": "workspace-semantic",
|
||||
"collection_lifecycle": "require_existing",
|
||||
}
|
||||
}
|
||||
path = tmp_path / "lifecycle.yaml"
|
||||
path.write_text(yaml.safe_dump(values))
|
||||
|
||||
with pytest.raises(ConfigError, match="collection_lifecycle"):
|
||||
load_config(path)
|
||||
|
||||
|
||||
def test_legacy_runtime_config_keeps_create_capable_qdrant_default(tmp_path):
|
||||
path = tmp_path / "legacy.yaml"
|
||||
path.write_text(yaml.safe_dump(_qdrant_config_yaml(tmp_path, registry=False)))
|
||||
|
||||
cfg = load_config(path)
|
||||
|
||||
assert cfg.vectors.collection_lifecycle == "create_if_missing"
|
||||
|
||||
|
||||
def test_registry_session_and_maintenance_bindings_are_equal(tmp_path):
|
||||
values = _qdrant_config_yaml(tmp_path, registry=True)
|
||||
session_path = tmp_path / "session.yaml"
|
||||
maintenance_path = tmp_path / "maintenance.yaml"
|
||||
session_path.write_text(yaml.safe_dump(values))
|
||||
maintenance_path.write_text(yaml.safe_dump(values))
|
||||
|
||||
assert config_dwh_binding(load_config(session_path)) == config_dwh_binding(
|
||||
load_config(maintenance_path)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
"""Focused unit coverage for the privileged runtime publication seam."""
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tht import runtime_config_lease_io as lease_io
|
||||
from tht.config import ConfigError, _read_runtime_config_source
|
||||
|
||||
|
||||
def _manifest() -> dict:
|
||||
return {
|
||||
"version": 1, "workspace_id": "abc", "workspace_revision": "a" * 40,
|
||||
"descriptor_git_blob": "b" * 40, "descriptor_sha256": "c" * 64,
|
||||
"descriptor_dev": "1", "descriptor_ino": "2", "config_sha256": "d" * 64,
|
||||
"config_dwh_binding": {
|
||||
"workspace_id": "abc", "config_fingerprint": "e", "input_fingerprint": "f"
|
||||
},
|
||||
"config_dev": "1", "config_ino": "3", "config_size": "4",
|
||||
"config_mode": "400", "config_uid": str(os.getuid()), "config_nlink": "1",
|
||||
"directory_identities": [{"path": "/", "dev": "1", "ino": "1", "mode": "755", "uid": "0"}],
|
||||
}
|
||||
|
||||
|
||||
def test_strict_manifest_rejects_unknown_or_missing_fields():
|
||||
value = _manifest()
|
||||
assert lease_io.strict_manifest(value) is value
|
||||
with pytest.raises(RuntimeError):
|
||||
lease_io.strict_manifest({**value, "unexpected": True})
|
||||
missing = dict(value)
|
||||
del missing["config_sha256"]
|
||||
with pytest.raises(RuntimeError):
|
||||
lease_io.strict_manifest(missing)
|
||||
|
||||
|
||||
def test_private_alias_is_darwin_only(monkeypatch):
|
||||
monkeypatch.setattr(lease_io.sys, "platform", "linux")
|
||||
assert lease_io._canonical_root("/tmp/runtime") == "/tmp/runtime"
|
||||
assert lease_io._canonical_root("/var/lib/runtime") == "/var/lib/runtime"
|
||||
monkeypatch.setattr(lease_io.sys, "platform", "darwin")
|
||||
assert lease_io._canonical_root("/tmp/runtime") == "/private/tmp/runtime"
|
||||
assert lease_io._canonical_root("/var/lib/runtime") == "/private/var/lib/runtime"
|
||||
|
||||
|
||||
def test_read_all_enforces_bound(tmp_path):
|
||||
path = tmp_path / "large"
|
||||
path.write_bytes(b"0123456789")
|
||||
fd = os.open(path, os.O_RDONLY)
|
||||
try:
|
||||
with pytest.raises(RuntimeError, match="too large"):
|
||||
lease_io.read_all(fd, limit=4)
|
||||
with pytest.raises(RuntimeError, match="too large"):
|
||||
lease_io.read_all(fd, limit=0)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure_stage", ["config-parent", "manifest-parent"])
|
||||
def test_retry_reasserts_parent_durability_before_success(tmp_path, monkeypatch, failure_stage):
|
||||
events: list[str] = []
|
||||
failed = False
|
||||
|
||||
def fsync(fd: int, stage: str) -> None:
|
||||
nonlocal failed
|
||||
events.append(stage)
|
||||
if stage == failure_stage and not failed:
|
||||
failed = True
|
||||
raise RuntimeError("injected fsync failure")
|
||||
|
||||
monkeypatch.setattr(lease_io, "publication_fsync", fsync)
|
||||
inp = {
|
||||
"data_root": str(tmp_path / "data"), "workspace_id": "abc",
|
||||
"workspace_revision": "a" * 40, "config_hex": b"config".hex(),
|
||||
"manifest_base": {
|
||||
"workspace_id": "abc", "workspace_revision": "a" * 40,
|
||||
"descriptor_git_blob": "b" * 40, "descriptor_sha256": "c" * 64,
|
||||
"descriptor_dev": "1", "descriptor_ino": "2",
|
||||
"config_dwh_binding": {"workspace_id": "abc", "config_fingerprint": "e", "input_fingerprint": "f"},
|
||||
},
|
||||
}
|
||||
with pytest.raises(RuntimeError, match="injected"):
|
||||
lease_io.publish(inp)
|
||||
events.clear()
|
||||
lease_io.publish(inp)
|
||||
assert events.index("config-parent") < events.index("manifest-parent")
|
||||
|
||||
|
||||
def test_protocol_success_shapes_and_version_rejection(monkeypatch):
|
||||
monkeypatch.setattr(lease_io, "binding", lambda _inp: {
|
||||
"workspace_id": "abc", "config_fingerprint": "f", "input_fingerprint": "i",
|
||||
})
|
||||
monkeypatch.setattr(lease_io.sys, "stdin", io.StringIO(
|
||||
'{"protocol_version":1,"action":"binding","config_hex":""}'
|
||||
))
|
||||
success = io.StringIO()
|
||||
monkeypatch.setattr(lease_io.sys, "stdout", success)
|
||||
lease_io.main()
|
||||
assert set(json.loads(success.getvalue())) == {
|
||||
"protocol_version", "kind", "workspace_id", "config_fingerprint", "input_fingerprint",
|
||||
}
|
||||
|
||||
# An old/new protocol mismatch must be rejected before action dispatch.
|
||||
monkeypatch.setattr(lease_io.sys, "stdin", io.StringIO(
|
||||
'{"protocol_version":999,"action":"binding","config_hex":""}'
|
||||
))
|
||||
failure = io.StringIO()
|
||||
monkeypatch.setattr(lease_io.sys, "stdout", failure)
|
||||
with pytest.raises(SystemExit):
|
||||
lease_io.main()
|
||||
assert json.loads(failure.getvalue())["error"] == "unsupported runtime config protocol"
|
||||
|
||||
|
||||
def test_secure_runtime_reader_binds_path_manifest_and_ignores_legacy_fd(monkeypatch, tmp_path):
|
||||
revision = "a" * 40
|
||||
inp = {
|
||||
"data_root": str(tmp_path / "data"), "workspace_id": "abc",
|
||||
"workspace_revision": revision, "config_hex": b"runtime: true\n".hex(),
|
||||
"manifest_base": {
|
||||
"workspace_id": "abc", "workspace_revision": revision,
|
||||
"descriptor_git_blob": "b" * 40, "descriptor_sha256": "c" * 64,
|
||||
"descriptor_dev": "1", "descriptor_ino": "2",
|
||||
"config_dwh_binding": {"workspace_id": "abc", "config_fingerprint": "e", "input_fingerprint": "f"},
|
||||
},
|
||||
}
|
||||
result = lease_io.publish(inp)
|
||||
path = Path(result["path"])
|
||||
monkeypatch.setenv("THT_RUNTIME_CONFIG_MANIFEST_SHA256", result["manifest_sha256"])
|
||||
monkeypatch.setenv("THT_CONFIG_MANIFEST_FD", "999")
|
||||
monkeypatch.setenv("THT_CONFIG_MANIFEST_SHA256", "0" * 64)
|
||||
source, manifest = _read_runtime_config_source(path)
|
||||
assert source == "runtime: true\n"
|
||||
assert manifest is not None and manifest["workspace_id"] == "abc"
|
||||
|
||||
monkeypatch.setenv("THT_RUNTIME_CONFIG_MANIFEST_SHA256", "0" * 64)
|
||||
with pytest.raises(ConfigError):
|
||||
_read_runtime_config_source(path)
|
||||
with pytest.raises(ConfigError):
|
||||
_read_runtime_config_source(path.with_name("not-canonical.yaml"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("object_kind", ["tree", "blob", "tag"])
|
||||
def test_verified_snapshot_rejects_non_commit_object(tmp_path, object_kind):
|
||||
repo = tmp_path / "repo"
|
||||
(repo / "workspaces").mkdir(parents=True)
|
||||
subprocess.run(["git", "init", "--initial-branch=main"], cwd=repo, check=True, stdout=subprocess.DEVNULL)
|
||||
subprocess.run(["git", "config", "user.name", "Fixture"], cwd=repo, check=True)
|
||||
subprocess.run(["git", "config", "user.email", "fixture@example.invalid"], cwd=repo, check=True)
|
||||
descriptor = "workspace:\n schema_version: 3\n id: abc\n"
|
||||
(repo / "workspaces" / "abc.yaml").write_text(descriptor)
|
||||
subprocess.run(["git", "add", "."], cwd=repo, check=True)
|
||||
subprocess.run(["git", "commit", "-m", "fixture"], cwd=repo, check=True, stdout=subprocess.DEVNULL)
|
||||
if object_kind == "tree":
|
||||
revision = subprocess.check_output(["git", "rev-parse", "HEAD^{tree}"], cwd=repo, text=True).strip()
|
||||
elif object_kind == "blob":
|
||||
revision = subprocess.check_output(["git", "rev-parse", "HEAD:workspaces/abc.yaml"], cwd=repo, text=True).strip()
|
||||
else:
|
||||
subprocess.run(["git", "tag", "-a", "v1", "-m", "tag"], cwd=repo, check=True)
|
||||
revision = subprocess.check_output(["git", "rev-parse", "refs/tags/v1^{tag}"], cwd=repo, text=True).strip()
|
||||
|
||||
snapshots = tmp_path / "snapshots" / revision
|
||||
snapshots.mkdir(parents=True, mode=0o700)
|
||||
(tmp_path / "snapshots").chmod(0o700)
|
||||
files = {"abc.yaml": descriptor, "abc.env.example": "# fixture\n", "abc.md": "# fixture\n"}
|
||||
for name, content in files.items():
|
||||
target = snapshots / name
|
||||
target.write_text(content)
|
||||
target.chmod(0o400)
|
||||
snapshot = {
|
||||
"head": revision,
|
||||
"revisions": [{"id": "abc", "commit": revision, "blob": "b" * 40,
|
||||
"snapshotPath": f"{tmp_path / 'snapshots'}/{revision}/abc.yaml"}],
|
||||
"files": {name: __import__("hashlib").sha256(content.encode()).hexdigest() for name, content in files.items()},
|
||||
}
|
||||
manifest = snapshots / "snapshot.json"
|
||||
manifest.write_text(json.dumps(snapshot))
|
||||
manifest.chmod(0o400)
|
||||
with pytest.raises(RuntimeError, match="exact commit|unavailable"):
|
||||
lease_io.verified_snapshot({
|
||||
"snapshots_root": str(tmp_path / "snapshots"), "repository_root": str(repo),
|
||||
"workspace_revision": revision, "workspace_id": "abc",
|
||||
})
|
||||
@@ -1,9 +1,17 @@
|
||||
# ruff: noqa: DTZ001
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
import warnings
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from tht.cli import app
|
||||
from tht.cli.schema_cmd import check_schema_data
|
||||
from tht.mschema.merge import find_orphans
|
||||
from tht.mschema.models import (
|
||||
Annotations,
|
||||
@@ -16,6 +24,12 @@ from tht.mschema.models import (
|
||||
from tht.mschema.render import to_mschema_text, to_schema_dict
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _child_capability_for_schema_unit_tests(monkeypatch):
|
||||
import tht.cli.schema_cmd as command
|
||||
monkeypatch.setattr(command, "_require_writer_capability", lambda **kwargs: None)
|
||||
|
||||
|
||||
def _physical():
|
||||
return PhysicalSchema(
|
||||
database="d", schema="s", introspected_at=datetime(2026, 1, 1),
|
||||
@@ -162,6 +176,10 @@ def test_suggest_fks_skips_generic_and_ambiguous_pks(tmp_path):
|
||||
assert res.exit_code == 0, res.output
|
||||
assert "nessuna FK da suggerire" in res.output # id generico, cod_x ambigua
|
||||
assert "cod_x" in res.output # segnalata come ambigua saltata
|
||||
exact = CliRunner().invoke(
|
||||
app, ["schema", "suggest-fks", "--json", "-c", str(cfg)]
|
||||
)
|
||||
assert yaml.safe_load(exact.stdout)["ambiguous_columns"] == ["cod_x"]
|
||||
|
||||
# --assume disambigua la PK multi-proprietario
|
||||
res2 = CliRunner().invoke(
|
||||
@@ -204,6 +222,36 @@ def test_suggest_fks_from_sql_mines_joins(tmp_path):
|
||||
assert "ref_table: dim_patient" in res.output
|
||||
|
||||
|
||||
def test_suggest_fks_reports_staged_file_without_mined_joins(tmp_path):
|
||||
cfg = _write_workspace(tmp_path)
|
||||
staged = tmp_path / "approved"
|
||||
staged.mkdir()
|
||||
(staged / "no-joins.sql").write_text("SELECT 1")
|
||||
response = CliRunner().invoke(
|
||||
app, ["schema", "suggest-fks", "-c", str(cfg), "--from-sql", str(staged)]
|
||||
)
|
||||
assert response.exit_code == 0, response.output
|
||||
assert "Minati 0 equi-join da 1 file SQL" in response.output
|
||||
|
||||
|
||||
def test_suggest_fks_human_write_reads_one_annotation_snapshot(tmp_path, monkeypatch):
|
||||
cfg = _write_workspace(tmp_path)
|
||||
ann_path = tmp_path / "artifacts" / "mschema" / "annotations.yaml"
|
||||
Annotations().to_yaml(ann_path)
|
||||
reads = []
|
||||
original = Annotations.from_yaml
|
||||
|
||||
def tracked(path):
|
||||
reads.append(path)
|
||||
return original(path)
|
||||
|
||||
monkeypatch.setattr(Annotations, "from_yaml", tracked)
|
||||
response = CliRunner().invoke(app, ["schema", "suggest-fks", "-c", str(cfg), "--write"])
|
||||
|
||||
assert response.exit_code == 0, response.output
|
||||
assert reads == [ann_path]
|
||||
|
||||
|
||||
def test_suggest_fks_write_merges_and_is_idempotent(tmp_path):
|
||||
cfg = _write_workspace(tmp_path)
|
||||
ann_path = tmp_path / "artifacts" / "mschema" / "annotations.yaml"
|
||||
@@ -221,3 +269,383 @@ def test_suggest_fks_write_merges_and_is_idempotent(tmp_path):
|
||||
assert "nessuna FK da suggerire" in res2.output
|
||||
ann2 = Annotations.from_yaml(ann_path)
|
||||
assert len(ann2.tables["fact_ablazione"].foreign_keys) == 2
|
||||
|
||||
|
||||
def test_suggest_fks_json_is_single_deterministic_document_without_writing(tmp_path):
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
cfg = _write_workspace(tmp_path)
|
||||
staged = tmp_path / "staged"
|
||||
staged.mkdir()
|
||||
(staged / "z.sql").write_text(
|
||||
"SELECT * FROM fact_ablazione f JOIN dim_patient p ON f.cod_paz = p.cod_paz"
|
||||
)
|
||||
(staged / "a.sql").write_text(
|
||||
"SELECT * FROM fact_ablazione f JOIN dim_time p ON f.data_time_key = p.day_key"
|
||||
)
|
||||
response = CliRunner().invoke(
|
||||
app, ["schema", "suggest-fks", "--json", "-c", str(cfg), "--from-sql", str(staged)]
|
||||
)
|
||||
assert response.exit_code == 0, response.output
|
||||
assert response.stderr == ""
|
||||
assert response.stdout.count("\n") == 1
|
||||
payload = json.loads(response.stdout)
|
||||
assert payload["status"] == "succeeded"
|
||||
assert payload["code"] == "ok"
|
||||
assert payload["candidate_count"] == 2
|
||||
assert payload["candidate_digest"].startswith("sha256:")
|
||||
assert payload["orphan_count"] == 0
|
||||
# The digest is over the canonical candidate export and --json never writes annotations.
|
||||
expected = json.dumps(payload["candidates"], ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
assert payload["candidate_digest"] == "sha256:" + hashlib.sha256(expected.encode()).hexdigest()
|
||||
assert not (tmp_path / "artifacts" / "mschema" / "annotations.yaml").exists()
|
||||
|
||||
|
||||
def test_suggest_fks_json_failure_is_safe_and_single_document(tmp_path):
|
||||
import json
|
||||
|
||||
cfg = _write_workspace(tmp_path)
|
||||
(tmp_path / "artifacts" / "mschema" / "physical.yaml").unlink()
|
||||
response = CliRunner().invoke(app, ["schema", "suggest-fks", "--json", "-c", str(cfg)])
|
||||
assert response.exit_code != 0
|
||||
assert response.stderr == ""
|
||||
assert response.stdout.count("\n") == 1
|
||||
payload = json.loads(response.stdout)
|
||||
assert payload == {"status": "failed", "code": "physical_schema_missing"}
|
||||
assert str(tmp_path) not in response.stdout
|
||||
|
||||
|
||||
def test_schema_check_json_reports_orphan_count_without_prose(tmp_path):
|
||||
import json
|
||||
|
||||
cfg = _write_workspace(tmp_path)
|
||||
Annotations(tables={"gone": TableAnnotation(description="x")}).to_yaml(
|
||||
tmp_path / "artifacts" / "mschema" / "annotations.yaml"
|
||||
)
|
||||
response = CliRunner().invoke(app, ["schema", "check", "--json", "-c", str(cfg)])
|
||||
assert response.exit_code == 3
|
||||
assert response.stderr == ""
|
||||
assert response.stdout.count("\n") == 1
|
||||
assert json.loads(response.stdout) == {
|
||||
"status": "failed", "code": "annotation_orphans", "orphan_count": 1,
|
||||
"orphans": ["gone"],
|
||||
}
|
||||
|
||||
|
||||
def test_suggest_fks_json_rejects_more_than_32_staged_sql_files(tmp_path):
|
||||
import json
|
||||
|
||||
cfg = _write_workspace(tmp_path)
|
||||
staged = tmp_path / "many"
|
||||
staged.mkdir()
|
||||
for i in range(33):
|
||||
(staged / f"q{i:02d}.sql").write_text("SELECT 1")
|
||||
response = CliRunner().invoke(
|
||||
app, ["schema", "suggest-fks", "--json", "-c", str(cfg), "--from-sql", str(staged)]
|
||||
)
|
||||
assert response.exit_code == 1
|
||||
assert json.loads(response.stdout) == {"status": "failed", "code": "staged_sql_too_many"}
|
||||
|
||||
|
||||
def test_staged_sql_size_limits_are_inclusive(tmp_path):
|
||||
import json
|
||||
|
||||
cfg = _write_workspace(tmp_path)
|
||||
staged = tmp_path / "boundary"
|
||||
staged.mkdir()
|
||||
# Exactly one MiB per file and exactly 16 MiB in aggregate are accepted.
|
||||
body = "-- padding\n" + ("x" * (1 << 20))
|
||||
body = body[: 1 << 20]
|
||||
for i in range(16):
|
||||
(staged / f"q{i:02d}.sql").write_bytes(body.encode())
|
||||
response = CliRunner().invoke(
|
||||
app, ["schema", "suggest-fks", "--json", "-c", str(cfg), "--from-sql", str(staged)]
|
||||
)
|
||||
assert response.exit_code == 0, response.output
|
||||
assert json.loads(response.stdout)["staged_sql_count"] == 16
|
||||
|
||||
|
||||
def test_schema_check_json_success_and_reviewed_annotations_are_preserved(tmp_path):
|
||||
import json
|
||||
|
||||
cfg = _write_workspace(tmp_path)
|
||||
annotations_path = tmp_path / "artifacts" / "mschema" / "annotations.yaml"
|
||||
reviewed = _annotations_with_fks()
|
||||
reviewed.to_yaml(annotations_path)
|
||||
before = annotations_path.read_text()
|
||||
|
||||
check = CliRunner().invoke(app, ["schema", "check", "--json", "-c", str(cfg)])
|
||||
assert check.exit_code == 0, check.output
|
||||
assert check.stderr == ""
|
||||
assert check.stdout.count("\n") == 1
|
||||
assert json.loads(check.stdout) == {
|
||||
"status": "succeeded", "code": "ok", "orphan_count": 0, "orphans": []
|
||||
}
|
||||
suggest = CliRunner().invoke(app, ["schema", "suggest-fks", "--json", "-c", str(cfg)])
|
||||
assert suggest.exit_code == 0, suggest.output
|
||||
assert suggest.stderr == ""
|
||||
assert json.loads(suggest.stdout)["candidate_count"] == 0
|
||||
assert annotations_path.read_text() == before
|
||||
|
||||
|
||||
def test_schema_human_check_and_suggest_report_missing_physical_schema(tmp_path):
|
||||
cfg = _write_workspace(tmp_path)
|
||||
physical = tmp_path / "artifacts" / "mschema" / "physical.yaml"
|
||||
physical.unlink()
|
||||
expected = "physical.yaml non trovato. Esegui prima `tht schema introspect`."
|
||||
check = CliRunner().invoke(app, ["schema", "check", "-c", str(cfg)])
|
||||
assert check.exit_code == 1
|
||||
assert expected in check.output
|
||||
suggest = CliRunner().invoke(app, ["schema", "suggest-fks", "-c", str(cfg)])
|
||||
assert suggest.exit_code == 1
|
||||
assert expected in suggest.output
|
||||
|
||||
|
||||
def test_schema_human_check_reports_ignored_columns(tmp_path):
|
||||
cfg = _write_workspace(tmp_path)
|
||||
physical = _physical()
|
||||
physical.tables["fact_ablazione"].columns["esito"].eligible = False
|
||||
physical.tables["fact_ablazione"].columns["esito"].eligibility_reason = "test ignored"
|
||||
physical.to_yaml(tmp_path / "artifacts" / "mschema" / "physical.yaml")
|
||||
response = CliRunner().invoke(app, ["schema", "check", "-c", str(cfg)])
|
||||
assert response.exit_code == 0, response.output
|
||||
assert "Colonne ignorate (testo ampio, 1):" in response.output
|
||||
assert "fact_ablazione.esito (test ignored)" in response.output
|
||||
|
||||
|
||||
|
||||
def test_staged_sql_file_size_over_one_mib_is_rejected(tmp_path):
|
||||
import json
|
||||
|
||||
cfg = _write_workspace(tmp_path)
|
||||
staged = tmp_path / "oversize"
|
||||
staged.mkdir()
|
||||
(staged / "too-large.sql").write_bytes(b"x" * ((1 << 20) + 1))
|
||||
response = CliRunner().invoke(
|
||||
app, ["schema", "suggest-fks", "--json", "-c", str(cfg), "--from-sql", str(staged)]
|
||||
)
|
||||
assert response.exit_code == 1
|
||||
assert json.loads(response.stdout) == {"status": "failed", "code": "staged_sql_too_large"}
|
||||
|
||||
|
||||
def test_staged_sql_aggregate_over_16_mib_is_rejected(tmp_path):
|
||||
import json
|
||||
|
||||
cfg = _write_workspace(tmp_path)
|
||||
staged = tmp_path / "aggregate"
|
||||
staged.mkdir()
|
||||
for i in range(16):
|
||||
(staged / f"q{i:02d}.sql").write_bytes(b"x" * (1 << 20))
|
||||
(staged / "over.sql").write_bytes(b"x")
|
||||
response = CliRunner().invoke(
|
||||
app, ["schema", "suggest-fks", "--json", "-c", str(cfg), "--from-sql", str(staged)]
|
||||
)
|
||||
assert response.exit_code == 1
|
||||
assert json.loads(response.stdout) == {"status": "failed", "code": "staged_sql_too_large"}
|
||||
|
||||
|
||||
def test_suggest_fks_candidate_order_is_stable_for_reordered_staged_inputs(tmp_path):
|
||||
import json
|
||||
|
||||
cfg = _write_workspace(tmp_path)
|
||||
first = tmp_path / "first"
|
||||
second = tmp_path / "second"
|
||||
first.mkdir()
|
||||
second.mkdir()
|
||||
(first / "join.sql").write_text(
|
||||
"SELECT * FROM fact_ablazione f JOIN dim_patient p ON f.cod_paz = p.cod_paz"
|
||||
)
|
||||
(second / "join.sql").write_text(
|
||||
"SELECT * FROM fact_ablazione f JOIN dim_time p ON f.data_time_key = p.day_key"
|
||||
)
|
||||
runner = CliRunner()
|
||||
one = runner.invoke(
|
||||
app, ["schema", "suggest-fks", "--json", "-c", str(cfg), "--from-sql", str(first), "--from-sql", str(second)]
|
||||
)
|
||||
two = runner.invoke(
|
||||
app, ["schema", "suggest-fks", "--json", "-c", str(cfg), "--from-sql", str(second), "--from-sql", str(first)]
|
||||
)
|
||||
assert one.exit_code == two.exit_code == 0
|
||||
assert json.loads(one.stdout) == json.loads(two.stdout)
|
||||
|
||||
|
||||
def test_schema_human_check_missing_config_has_original_error(tmp_path):
|
||||
response = CliRunner().invoke(app, ["schema", "check", "-c", str(tmp_path / "missing.yaml")])
|
||||
assert response.exit_code == 1
|
||||
assert response.output
|
||||
assert "ERRORE:" in response.output
|
||||
|
||||
|
||||
def test_schema_human_suggest_missing_config_has_original_error(tmp_path):
|
||||
response = CliRunner().invoke(app, ["schema", "suggest-fks", "-c", str(tmp_path / "missing.yaml")])
|
||||
assert response.exit_code == 1
|
||||
assert response.output
|
||||
assert "ERRORE:" in response.output
|
||||
|
||||
|
||||
def test_human_config_keeps_legacy_warning_but_json_suppresses_it(tmp_path):
|
||||
cfg = _write_workspace(tmp_path)
|
||||
with pytest.warns(FutureWarning, match="legacy workspace resource keys"):
|
||||
check_schema_data(cfg)
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
check_schema_data(cfg, suppress_legacy_warning=True)
|
||||
assert not [item for item in caught if issubclass(item.category, FutureWarning)]
|
||||
|
||||
|
||||
def test_staged_sql_enumeration_stops_at_count_sentinel(tmp_path, monkeypatch):
|
||||
from tht.cli.schema_cmd import _MachineSchemaError, _staged_sql_files
|
||||
|
||||
staged = tmp_path / "staged"
|
||||
staged.mkdir()
|
||||
files = [staged / f"q{i:03d}.sql" for i in range(100)]
|
||||
for path in files:
|
||||
path.write_text("SELECT 1")
|
||||
yielded = 0
|
||||
|
||||
def bounded_rglob(_self, _pattern):
|
||||
nonlocal yielded
|
||||
for path in files:
|
||||
yielded += 1
|
||||
yield path
|
||||
|
||||
monkeypatch.setattr(type(staged), "rglob", bounded_rglob)
|
||||
with pytest.raises(_MachineSchemaError, match="staged_sql_too_many"):
|
||||
_staged_sql_files([staged])
|
||||
assert yielded == 33
|
||||
|
||||
|
||||
def test_staged_sql_oversize_read_is_bounded(tmp_path, monkeypatch):
|
||||
from tht.cli.schema_cmd import _MachineSchemaError, _read_staged_sql
|
||||
|
||||
staged = tmp_path / "oversize.sql"
|
||||
staged.write_bytes(b"unused")
|
||||
reads = []
|
||||
|
||||
class BoundedReader:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return False
|
||||
|
||||
def read(self, size):
|
||||
reads.append(size)
|
||||
return b"x" * size
|
||||
|
||||
monkeypatch.setattr(type(staged), "open", lambda *_args, **_kwargs: BoundedReader())
|
||||
with pytest.raises(_MachineSchemaError, match="staged_sql_too_large"):
|
||||
_read_staged_sql([staged])
|
||||
assert reads == [(1 << 20) + 1]
|
||||
|
||||
|
||||
def test_staged_sql_deduplicates_overlapping_roots_and_repeated_files(tmp_path):
|
||||
from tht.cli.schema_cmd import _staged_sql_files
|
||||
|
||||
root = tmp_path / "approved"
|
||||
nested = root / "nested"
|
||||
nested.mkdir(parents=True)
|
||||
first = root / "first.sql"
|
||||
second = nested / "second.sql"
|
||||
first.write_text("SELECT 1")
|
||||
second.write_text("SELECT 1")
|
||||
|
||||
files = _staged_sql_files([root, nested, first, root])
|
||||
assert files == sorted({first.resolve(), second.resolve()})
|
||||
|
||||
|
||||
def test_staged_sql_file_limit_counts_distinct_paths_only(tmp_path):
|
||||
from tht.cli.schema_cmd import _MachineSchemaError, _staged_sql_files
|
||||
|
||||
path = tmp_path / "same.sql"
|
||||
path.write_text("SELECT 1")
|
||||
assert _staged_sql_files([path] * 100) == [path.resolve()]
|
||||
|
||||
root = tmp_path / "many"
|
||||
root.mkdir()
|
||||
for index in range(33):
|
||||
(root / f"q{index:02d}.sql").write_text("SELECT 1")
|
||||
with pytest.raises(_MachineSchemaError, match="staged_sql_too_many"):
|
||||
_staged_sql_files([root])
|
||||
|
||||
|
||||
def test_fresh_process_human_warning_cardinality_is_one_across_failure_and_write_paths(tmp_path):
|
||||
cfg = _write_workspace(tmp_path)
|
||||
physical = tmp_path / "artifacts" / "mschema" / "physical.yaml"
|
||||
annotations = tmp_path / "artifacts" / "mschema" / "annotations.yaml"
|
||||
annotations.parent.mkdir(parents=True, exist_ok=True)
|
||||
Annotations().to_yaml(annotations)
|
||||
probe = textwrap.dedent(
|
||||
"""
|
||||
import json, sys, warnings
|
||||
from typer.testing import CliRunner
|
||||
from tht.cli import app
|
||||
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
result = CliRunner().invoke(app, sys.argv[1:])
|
||||
print(json.dumps({
|
||||
"warnings": sum(issubclass(w.category, FutureWarning) for w in caught),
|
||||
"exit": result.exit_code,
|
||||
}))
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
physical.unlink()
|
||||
branches = [
|
||||
["schema", "check"],
|
||||
["schema", "suggest-fks"],
|
||||
["vector", "index-schema"],
|
||||
]
|
||||
for branch in branches:
|
||||
response = subprocess.run(
|
||||
[sys.executable, "-c", probe, *branch, "-c", str(cfg)],
|
||||
check=True, capture_output=True, text=True,
|
||||
)
|
||||
assert json.loads(response.stdout) == {"warnings": 1, "exit": 1}
|
||||
|
||||
_physical().to_yaml(physical)
|
||||
response = subprocess.run(
|
||||
[sys.executable, "-c", probe, "schema", "suggest-fks", "--write", "-c", str(cfg)],
|
||||
check=True, capture_output=True, text=True,
|
||||
)
|
||||
assert json.loads(response.stdout) == {"warnings": 1, "exit": 1}
|
||||
|
||||
physical.unlink()
|
||||
for branch in branches:
|
||||
response = subprocess.run(
|
||||
[sys.executable, "-c", probe, *branch, "--json", "-c", str(cfg)],
|
||||
check=True, capture_output=True, text=True,
|
||||
)
|
||||
assert json.loads(response.stdout)["warnings"] == 0
|
||||
response = subprocess.run(
|
||||
[sys.executable, "-c", probe, "schema", "suggest-fks", "--write", "--json", "-c", str(cfg)],
|
||||
check=True, capture_output=True, text=True,
|
||||
)
|
||||
assert json.loads(response.stdout)["warnings"] == 0
|
||||
|
||||
|
||||
def test_schema_check_json_unexpected_failure_has_no_stderr_secret(monkeypatch, tmp_path):
|
||||
import tht.cli.schema_cmd as command
|
||||
|
||||
cfg = _write_workspace(tmp_path)
|
||||
monkeypatch.setattr(command, "_physical_or_error", lambda cfg: (_ for _ in ()).throw(Exception("secret schema adapter")))
|
||||
response = CliRunner().invoke(app, ["schema", "check", "--json", "-c", str(cfg)])
|
||||
|
||||
assert response.exit_code == 1
|
||||
assert json.loads(response.stdout) == {"status": "failed", "code": "schema_check_failed"}
|
||||
assert response.stderr == ""
|
||||
|
||||
|
||||
def test_schema_suggest_json_unexpected_failure_has_no_stderr_secret(monkeypatch, tmp_path):
|
||||
import tht.cli.schema_cmd as command
|
||||
|
||||
cfg = _write_workspace(tmp_path)
|
||||
monkeypatch.setattr(command, "_physical_or_error", lambda cfg: (_ for _ in ()).throw(Exception("secret schema adapter")))
|
||||
response = CliRunner().invoke(app, ["schema", "suggest-fks", "--json", "-c", str(cfg)])
|
||||
|
||||
assert response.exit_code == 1
|
||||
assert json.loads(response.stdout) == {"status": "failed", "code": "schema_suggestion_failed"}
|
||||
assert response.stderr == ""
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import fcntl
|
||||
import struct
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from tht.workspace_writer_lock import WorkspaceWriterConflict, verify_workspace_writer_fds
|
||||
|
||||
|
||||
def test_verifier_rejects_missing_capability():
|
||||
with pytest.raises(WorkspaceWriterConflict): verify_workspace_writer_fds(env={})
|
||||
|
||||
def test_verifier_checks_fd_identity(tmp_path):
|
||||
root=tmp_path / "root"; root.mkdir(mode=0o700); writer=root / "writer.lock"; writer.touch(mode=0o600)
|
||||
r=os.open(root, os.O_RDONLY); w=os.open(writer, os.O_RDWR)
|
||||
try:
|
||||
fcntl.flock(w, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
if hasattr(fcntl, "F_OFD_SETLK") and sys.platform.startswith("linux"):
|
||||
fcntl.fcntl(w, fcntl.F_OFD_SETLK, struct.pack("hhqqi", fcntl.F_WRLCK, os.SEEK_SET, 0, 0, 0))
|
||||
st=os.fstat(r); env={"THOTH_WORKSPACE_ID":"abc-workspace", "THOTH_WORKSPACE_REVISION":"a"*40, "THOTH_WORKSPACE_DEVICE":str(st.st_dev), "THOTH_WORKSPACE_INODE":str(st.st_ino)}
|
||||
cap=verify_workspace_writer_fds(writer_fd=w, root_fd=r, env=env)
|
||||
assert cap.inode == st.st_ino
|
||||
finally: os.close(w); os.close(r)
|
||||
|
||||
|
||||
def test_verifier_rejects_unrelated_lock(tmp_path):
|
||||
root = tmp_path / "root"; other = tmp_path / "other"
|
||||
root.mkdir(mode=0o700); other.mkdir(mode=0o700)
|
||||
expected = root / "writer.lock"; forged = other / "forged.lock"
|
||||
expected.touch(mode=0o600); forged.touch(mode=0o600)
|
||||
root_fd = os.open(root, os.O_RDONLY); forged_fd = os.open(forged, os.O_RDWR)
|
||||
try:
|
||||
st = os.fstat(root_fd)
|
||||
env = {"THOTH_WORKSPACE_ID": "abc-workspace", "THOTH_WORKSPACE_REVISION": "a" * 40, "THOTH_WORKSPACE_DEVICE": str(st.st_dev), "THOTH_WORKSPACE_INODE": str(st.st_ino)}
|
||||
with pytest.raises(WorkspaceWriterConflict):
|
||||
verify_workspace_writer_fds(writer_fd=forged_fd, root_fd=root_fd, env=env)
|
||||
finally:
|
||||
os.close(forged_fd); os.close(root_fd)
|
||||
|
||||
|
||||
def test_verifier_rejects_independently_opened_unlocked_writer_fd(tmp_path):
|
||||
root = tmp_path / "root"; root.mkdir(mode=0o700)
|
||||
writer = root / "writer.lock"; writer.touch(mode=0o600)
|
||||
root_fd = os.open(root, os.O_RDONLY)
|
||||
writer_fd = os.open(writer, os.O_RDWR)
|
||||
try:
|
||||
st = os.fstat(root_fd)
|
||||
env = {"THOTH_WORKSPACE_ID": "abc-workspace", "THOTH_WORKSPACE_REVISION": "a" * 40, "THOTH_WORKSPACE_DEVICE": str(st.st_dev), "THOTH_WORKSPACE_INODE": str(st.st_ino)}
|
||||
with pytest.raises(WorkspaceWriterConflict):
|
||||
verify_workspace_writer_fds(writer_fd=writer_fd, root_fd=root_fd, env=env)
|
||||
finally:
|
||||
os.close(writer_fd); os.close(root_fd)
|
||||
@@ -32,12 +32,21 @@ def build_vector_store(cfg: Config, *, require_write: bool = False) -> VectorSto
|
||||
|
||||
match resource.type:
|
||||
case "qdrant":
|
||||
expected_dimension = cfg.embeddings.dim if cfg.embeddings is not None else None
|
||||
if resource.collection_lifecycle == "require_existing" and (
|
||||
expected_dimension is None or expected_dimension <= 0
|
||||
):
|
||||
raise ConfigError(
|
||||
"require_existing Qdrant vectors require an explicit positive embedding dimension"
|
||||
)
|
||||
return QdrantVectorStore(
|
||||
base_url=resource.base_url,
|
||||
collection=resource.collection,
|
||||
workspace_id=cfg._workspace_id,
|
||||
workspace_revision=cfg._workspace_revision,
|
||||
expected_dimension=cfg.embeddings.dim if cfg.embeddings is not None else None,
|
||||
expected_dimension=expected_dimension,
|
||||
expected_distance="Cosine",
|
||||
collection_lifecycle=resource.collection_lifecycle,
|
||||
)
|
||||
case other: # pragma: no cover - Pydantic's discriminator rejects this first.
|
||||
raise ConfigError(f"Adapter vector non supportato: {other}")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Qdrant-backed vector store for one workspace-owned semantic collection."""
|
||||
|
||||
import math
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from uuid import NAMESPACE_URL, uuid5
|
||||
@@ -13,9 +14,12 @@ from tht.adapters.vector._shared import (
|
||||
validate_known_kinds,
|
||||
)
|
||||
from tht.ports.vector import (
|
||||
SemanticIndexIncompatibleError,
|
||||
VectorCapabilities,
|
||||
VectorHealth,
|
||||
VectorResponseError,
|
||||
VectorStoreError,
|
||||
VectorTransportError,
|
||||
VectorWriteRecord,
|
||||
require_positive_limit,
|
||||
)
|
||||
@@ -55,6 +59,8 @@ class QdrantVectorStore:
|
||||
workspace_id: str,
|
||||
workspace_revision: str | None = None,
|
||||
expected_dimension: int | None = None,
|
||||
expected_distance: str | None = "Cosine",
|
||||
collection_lifecycle: str = "create_if_missing",
|
||||
request: Callable[..., object] | None = None,
|
||||
connect_timeout: float = 2.0,
|
||||
read_timeout: float = 10.0,
|
||||
@@ -64,6 +70,17 @@ class QdrantVectorStore:
|
||||
self._workspace_id = workspace_id
|
||||
self._workspace_revision = workspace_revision
|
||||
self._expected_dimension = expected_dimension
|
||||
self._expected_distance = expected_distance
|
||||
if collection_lifecycle not in ("create_if_missing", "require_existing"):
|
||||
raise ValueError("Unsupported Qdrant collection lifecycle")
|
||||
if collection_lifecycle == "require_existing" and (
|
||||
expected_dimension is None or expected_dimension <= 0 or expected_distance is None
|
||||
):
|
||||
raise ValueError(
|
||||
"require_existing requires an explicit positive expected dimension "
|
||||
"and expected distance"
|
||||
)
|
||||
self._collection_lifecycle = collection_lifecycle
|
||||
self._request = request or requests.request
|
||||
self._timeout = (connect_timeout, read_timeout)
|
||||
|
||||
@@ -94,11 +111,23 @@ class QdrantVectorStore:
|
||||
expected_dimension=self._expected_dimension,
|
||||
)
|
||||
|
||||
dimension = info["config"]["params"]["vectors"]["size"]
|
||||
result = self._require_mapping(info, "collection")
|
||||
config = self._require_mapping(result.get("config"), "collection")
|
||||
params = self._require_mapping(config.get("params"), "collection")
|
||||
vectors = self._require_mapping(params.get("vectors"), "collection")
|
||||
dimension = vectors["size"]
|
||||
dimensions = (dimension,)
|
||||
compatible = (
|
||||
dimension_compatible = (
|
||||
None if self._expected_dimension is None else dimensions == (self._expected_dimension,)
|
||||
)
|
||||
observed_distance = vectors["distance"]
|
||||
distance_compatible = (
|
||||
None if self._expected_distance is None else observed_distance == self._expected_distance
|
||||
)
|
||||
compatible = (
|
||||
None if dimension_compatible is None and distance_compatible is None
|
||||
else dimension_compatible is not False and distance_compatible is not False
|
||||
)
|
||||
return VectorHealth(
|
||||
ok=compatible is not False,
|
||||
read_configured=True,
|
||||
@@ -155,15 +184,23 @@ class QdrantVectorStore:
|
||||
"filter": {"must": filter_must},
|
||||
},
|
||||
)
|
||||
points = response.get("result", {}).get("points")
|
||||
result = self._require_mapping(response.get("result"), "query")
|
||||
points = result.get("points")
|
||||
if not isinstance(points, list):
|
||||
raise VectorStoreError("Qdrant returned malformed query response")
|
||||
raise VectorResponseError("Qdrant returned malformed query response")
|
||||
if not all(isinstance(point, dict) for point in points):
|
||||
raise VectorResponseError("Qdrant returned malformed query response")
|
||||
hits = [self._hit_from_point(point) for point in points]
|
||||
return sorted(hits, key=lambda hit: (-hit.similarity, hit.id))[:limit]
|
||||
|
||||
def existing_hashes(self, collection: str, kinds: list[str]) -> dict[str, str]:
|
||||
validate_collection(collection)
|
||||
validate_collection_kinds(collection, kinds)
|
||||
if self._collection_lifecycle == "require_existing":
|
||||
# Reconcile only after proving the exact existing collection contract.
|
||||
# This keeps an initial 404 typed and prevents an incompatible empty
|
||||
# collection from appearing healthy merely because there are no records.
|
||||
self._ensure_collection(strict=False)
|
||||
points = self._scroll(
|
||||
[
|
||||
*self._workspace_filter(),
|
||||
@@ -175,17 +212,16 @@ class QdrantVectorStore:
|
||||
for point in points:
|
||||
payload = point.get("payload")
|
||||
if not isinstance(payload, dict):
|
||||
raise VectorStoreError("Qdrant returned malformed scroll response")
|
||||
raise VectorResponseError("Qdrant returned malformed scroll response")
|
||||
record_key = payload.get("record_key")
|
||||
content_hash = payload.get("content_hash")
|
||||
if not isinstance(record_key, str) or not isinstance(content_hash, str):
|
||||
raise VectorStoreError("Qdrant returned malformed scroll response")
|
||||
raise VectorResponseError("Qdrant returned malformed scroll response")
|
||||
hashes[record_key] = content_hash
|
||||
return hashes
|
||||
|
||||
def upsert(self, collection: str, records: list[VectorWriteRecord]) -> int:
|
||||
validate_collection(collection)
|
||||
self._ensure_collection(strict=True)
|
||||
points = []
|
||||
for write_record in records:
|
||||
validate_collection_kinds(collection, [write_record.record.kind])
|
||||
@@ -203,11 +239,21 @@ class QdrantVectorStore:
|
||||
),
|
||||
}
|
||||
)
|
||||
self._call(
|
||||
"PUT",
|
||||
f"/collections/{self._collection}/points?wait=true",
|
||||
{"points": points},
|
||||
)
|
||||
try:
|
||||
# Keep the preflight immediately adjacent to the mutation: a registry
|
||||
# collection may disappear after reconciliation has read its hashes.
|
||||
self._ensure_collection(strict=True)
|
||||
self._call(
|
||||
"PUT",
|
||||
f"/collections/{self._collection}/points?wait=true",
|
||||
{"points": points},
|
||||
)
|
||||
except VectorTransportError as exc:
|
||||
if self._collection_lifecycle == "require_existing" and exc.status_code == 404:
|
||||
raise SemanticIndexIncompatibleError(
|
||||
"Qdrant collection disappeared during semantic index write"
|
||||
) from exc
|
||||
raise
|
||||
return len(records)
|
||||
|
||||
def delete_kinds(self, collection: str, kinds: list[str]) -> int:
|
||||
@@ -218,12 +264,23 @@ class QdrantVectorStore:
|
||||
self._semantic_kind_filter(kinds),
|
||||
{"key": "record_kind", "match": {"any": sorted(kinds)}},
|
||||
]
|
||||
if self._collection_lifecycle == "require_existing":
|
||||
self._ensure_collection(strict=False)
|
||||
before = len(self._scroll(must))
|
||||
self._call(
|
||||
"POST",
|
||||
f"/collections/{self._collection}/points/delete?wait=true",
|
||||
{"filter": {"must": must}},
|
||||
)
|
||||
if self._collection_lifecycle == "require_existing":
|
||||
self._ensure_collection(strict=False)
|
||||
try:
|
||||
self._call(
|
||||
"POST",
|
||||
f"/collections/{self._collection}/points/delete?wait=true",
|
||||
{"filter": {"must": must}},
|
||||
)
|
||||
except VectorTransportError as exc:
|
||||
if self._collection_lifecycle == "require_existing" and exc.status_code == 404:
|
||||
raise SemanticIndexIncompatibleError(
|
||||
"Qdrant collection disappeared during semantic index deletion"
|
||||
) from exc
|
||||
raise
|
||||
return before
|
||||
|
||||
def delete_generation(self, collection: str, generation: str, workspace_id: str) -> int:
|
||||
@@ -238,14 +295,25 @@ class QdrantVectorStore:
|
||||
{"key": "record_kind", "match": {"any": ["evidence"]}},
|
||||
{"key": "vector_generation", "match": {"value": generation}},
|
||||
]
|
||||
if self._collection_lifecycle == "require_existing":
|
||||
self._ensure_collection(strict=False)
|
||||
before = len(
|
||||
self._scroll(must)
|
||||
)
|
||||
self._call(
|
||||
"POST",
|
||||
f"/collections/{self._collection}/points/delete?wait=true",
|
||||
{"filter": {"must": must}},
|
||||
)
|
||||
if self._collection_lifecycle == "require_existing":
|
||||
self._ensure_collection(strict=False)
|
||||
try:
|
||||
self._call(
|
||||
"POST",
|
||||
f"/collections/{self._collection}/points/delete?wait=true",
|
||||
{"filter": {"must": must}},
|
||||
)
|
||||
except VectorTransportError as exc:
|
||||
if self._collection_lifecycle == "require_existing" and exc.status_code == 404:
|
||||
raise SemanticIndexIncompatibleError(
|
||||
"Qdrant collection disappeared during semantic index deletion"
|
||||
) from exc
|
||||
raise
|
||||
return before
|
||||
|
||||
def list_evidence_generations(self, collection: str, workspace_id: str) -> list[str]:
|
||||
@@ -304,6 +372,8 @@ class QdrantVectorStore:
|
||||
def _ensure_collection(self, *, strict: bool) -> dict | None:
|
||||
response = self._call("GET", f"/collections/{self._collection}", None, allow_missing=True)
|
||||
if response is None:
|
||||
if self._collection_lifecycle == "require_existing":
|
||||
raise SemanticIndexIncompatibleError("Qdrant collection is missing")
|
||||
if not strict:
|
||||
raise VectorStoreError("Qdrant collection is missing")
|
||||
self._call(
|
||||
@@ -318,19 +388,39 @@ class QdrantVectorStore:
|
||||
{"field_name": field_name, "field_schema": "keyword"},
|
||||
)
|
||||
response = self._call("GET", f"/collections/{self._collection}", None)
|
||||
result = response.get("result") if isinstance(response, dict) else None
|
||||
config = result.get("config", {}).get("params", {}).get("vectors") if isinstance(result, dict) else None
|
||||
if not isinstance(config, dict):
|
||||
raise VectorStoreError("Qdrant returned malformed collection response")
|
||||
size = config.get("size")
|
||||
distance = config.get("distance")
|
||||
result = self._require_mapping(response.get("result"), "collection")
|
||||
config = self._require_mapping(result.get("config"), "collection")
|
||||
params = self._require_mapping(config.get("params"), "collection")
|
||||
vectors = self._require_mapping(params.get("vectors"), "collection")
|
||||
size = vectors.get("size")
|
||||
distance = vectors.get("distance")
|
||||
if type(size) is not int or size <= 0 or not isinstance(distance, str):
|
||||
raise VectorResponseError("Qdrant returned malformed collection response")
|
||||
if (
|
||||
self._expected_dimension is not None
|
||||
and (size != self._expected_dimension or distance != "Cosine")
|
||||
self._expected_dimension is not None and size != self._expected_dimension
|
||||
) or (
|
||||
self._expected_distance is not None and distance != self._expected_distance
|
||||
):
|
||||
if self._collection_lifecycle == "require_existing":
|
||||
raise SemanticIndexIncompatibleError(
|
||||
"Qdrant collection dimension or distance is incompatible"
|
||||
)
|
||||
raise VectorStoreError("Qdrant collection configuration mismatch")
|
||||
payload_schema = result.get("payload_schema")
|
||||
if not isinstance(payload_schema, dict):
|
||||
raise VectorResponseError("Qdrant returned malformed collection response")
|
||||
if any(
|
||||
not isinstance(field, dict) or not isinstance(field.get("data_type"), str)
|
||||
for field in payload_schema.values()
|
||||
):
|
||||
raise VectorResponseError("Qdrant returned malformed collection response")
|
||||
for field_name in _KEYWORD_INDEXES:
|
||||
if field_name not in result.get("payload_schema", {}):
|
||||
field = payload_schema.get(field_name)
|
||||
if not isinstance(field, dict) or field.get("data_type") != "keyword":
|
||||
if self._collection_lifecycle == "require_existing":
|
||||
raise SemanticIndexIncompatibleError(
|
||||
"Qdrant collection payload indexes are incompatible"
|
||||
)
|
||||
if not strict:
|
||||
raise VectorStoreError("Qdrant collection payload indexes mismatch")
|
||||
self._call(
|
||||
@@ -345,35 +435,68 @@ class QdrantVectorStore:
|
||||
offset = None
|
||||
seen_offsets = set()
|
||||
while True:
|
||||
response = self._call(
|
||||
"POST",
|
||||
f"/collections/{self._collection}/points/scroll",
|
||||
{
|
||||
"with_payload": True,
|
||||
"limit": 10000,
|
||||
"filter": {"must": must},
|
||||
"offset": offset,
|
||||
},
|
||||
)
|
||||
result = response.get("result", {})
|
||||
try:
|
||||
response = self._call(
|
||||
"POST",
|
||||
f"/collections/{self._collection}/points/scroll",
|
||||
{
|
||||
"with_payload": True,
|
||||
"limit": 10000,
|
||||
"filter": {"must": must},
|
||||
"offset": offset,
|
||||
},
|
||||
)
|
||||
except VectorTransportError as exc:
|
||||
if self._collection_lifecycle == "require_existing" and exc.status_code == 404:
|
||||
raise SemanticIndexIncompatibleError(
|
||||
"Qdrant collection disappeared during semantic index reconciliation"
|
||||
) from exc
|
||||
raise
|
||||
result = self._require_mapping(response.get("result"), "scroll")
|
||||
page = result.get("points")
|
||||
if not isinstance(page, list):
|
||||
raise VectorStoreError("Qdrant returned malformed scroll response")
|
||||
if not isinstance(page, list) or not all(isinstance(point, dict) for point in page):
|
||||
raise VectorResponseError("Qdrant returned malformed scroll response")
|
||||
points.extend(page)
|
||||
next_page_offset = result.get("next_page_offset")
|
||||
if next_page_offset is None:
|
||||
return points
|
||||
if type(next_page_offset) not in (int, str):
|
||||
raise VectorResponseError("Qdrant returned malformed scroll response")
|
||||
if next_page_offset in seen_offsets:
|
||||
raise VectorStoreError("Qdrant returned malformed scroll response")
|
||||
raise VectorResponseError("Qdrant returned malformed scroll response")
|
||||
seen_offsets.add(next_page_offset)
|
||||
offset = next_page_offset
|
||||
|
||||
@staticmethod
|
||||
def _require_mapping(value: object, operation: str) -> dict:
|
||||
if not isinstance(value, dict):
|
||||
raise VectorResponseError(f"Qdrant returned malformed {operation} response")
|
||||
return value
|
||||
|
||||
def _hit_from_point(self, point: dict) -> VectorHit:
|
||||
if not isinstance(point, dict):
|
||||
raise VectorResponseError("Qdrant returned malformed query response")
|
||||
payload = point.get("payload")
|
||||
score = point.get("score")
|
||||
if not isinstance(payload, dict) or not isinstance(score, (int, float)):
|
||||
raise VectorStoreError("Qdrant returned malformed query response")
|
||||
return hit_from_metadata(float(score), payload)
|
||||
required_payload_leaves = (
|
||||
"record_key", "record_kind", "kind", "ref", "title", "content"
|
||||
)
|
||||
if (
|
||||
not isinstance(payload, dict)
|
||||
or type(score) not in (int, float)
|
||||
or any(
|
||||
key not in payload or not isinstance(payload[key], str)
|
||||
for key in required_payload_leaves
|
||||
)
|
||||
):
|
||||
raise VectorResponseError("Qdrant returned malformed query response")
|
||||
try:
|
||||
similarity = float(score)
|
||||
except (OverflowError, ValueError) as exc:
|
||||
raise VectorResponseError("Qdrant returned malformed query response") from exc
|
||||
if not math.isfinite(similarity):
|
||||
raise VectorResponseError("Qdrant returned malformed query response")
|
||||
return hit_from_metadata(similarity, payload)
|
||||
|
||||
def _call(self, method: str, path: str, payload: dict | None, allow_missing: bool = False) -> dict | None:
|
||||
try:
|
||||
@@ -384,19 +507,19 @@ class QdrantVectorStore:
|
||||
timeout=self._timeout,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
raise VectorStoreError(_sanitize_exception(exc)) from exc
|
||||
raise VectorTransportError(_sanitize_exception(exc)) from exc
|
||||
if response.status_code == 404 and allow_missing:
|
||||
return None
|
||||
if not response.ok:
|
||||
raise VectorStoreError(f"Qdrant request failed: HTTP {response.status_code}")
|
||||
raise VectorTransportError(f"Qdrant request failed: HTTP {response.status_code}", status_code=response.status_code)
|
||||
if response.status_code == 204 or not getattr(response, "text", ""):
|
||||
return {}
|
||||
try:
|
||||
data = response.json()
|
||||
except Exception as exc:
|
||||
raise VectorStoreError("Qdrant returned malformed JSON response") from exc
|
||||
raise VectorResponseError("Qdrant returned malformed JSON response") from exc
|
||||
if not isinstance(data, dict):
|
||||
raise VectorStoreError("Qdrant returned malformed JSON response")
|
||||
raise VectorResponseError("Qdrant returned malformed JSON response")
|
||||
return data
|
||||
|
||||
|
||||
|
||||
@@ -2,18 +2,34 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import typer
|
||||
|
||||
from tht.cli.config_cmd import CONFIG_OPT
|
||||
from tht.config import workspace_id_from_path
|
||||
|
||||
from tht.cli.schema_cmd import _load_config_or_exit
|
||||
from tht.config import workspace_id_for_config
|
||||
from tht.ports.evidence import EvidenceSourceError
|
||||
from tht.ports.vector import VectorStoreError
|
||||
from tht.vectorstore.embeddings import EmbeddingsError
|
||||
|
||||
preprocess_app = typer.Typer(help="Materialize versioned preprocessing artifacts")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def _require_writer_capability(*, workspace_id: str | None = None, revision: str | None = None) -> None:
|
||||
# Authorization is unconditional: an environment marker is attacker-controlled
|
||||
# and must never turn a mutating direct invocation into an authorized child.
|
||||
from tht.workspace_writer_lock import require_workspace_writer_capability
|
||||
require_workspace_writer_capability(workspace_id=workspace_id, revision=revision)
|
||||
|
||||
_PREPROCESS_EXPECTED_ERRORS = (
|
||||
OSError, RuntimeError, ValueError, TypeError, KeyError,
|
||||
EvidenceSourceError, VectorStoreError, EmbeddingsError,
|
||||
)
|
||||
|
||||
|
||||
def run_dwh_from_config(
|
||||
@@ -22,10 +38,12 @@ def run_dwh_from_config(
|
||||
from tht.cli.lsh_cmd import build_lsh_artifacts
|
||||
from tht.cli.schema_cmd import _load_config_or_exit, refresh_catalog
|
||||
from tht.jobs.dwh_pipeline import (
|
||||
DwhPreprocessPipeline, config_dwh_binding,
|
||||
DwhPreprocessPipeline,
|
||||
config_dwh_binding,
|
||||
)
|
||||
|
||||
cfg = _load_config_or_exit(config)
|
||||
_require_writer_capability(workspace_id=getattr(getattr(cfg, "runtime_identity", None), "workspace_id", None), revision=getattr(getattr(cfg, "runtime_identity", None), "workspace_revision", None))
|
||||
binding = config_dwh_binding(cfg)
|
||||
workspace_root = cfg.paths.artifacts.parent
|
||||
lsh_names = (
|
||||
@@ -64,13 +82,13 @@ def _parse_dwh_steps(value: str) -> tuple[str, ...]:
|
||||
|
||||
def run_from_config(config: Path, *, dry_run: bool = False, resume: str | None = None):
|
||||
from tht.adapters.factory import build_evidence_sources, build_vector_store
|
||||
from tht.cli.schema_cmd import _load_config_or_exit
|
||||
from tht.cli.vector_cmd import make_embedder
|
||||
from tht.corpus.chunk import ChunkPolicy
|
||||
from tht.corpus.pipeline import CorpusPipeline
|
||||
from tht.corpus.store import CorpusStore
|
||||
|
||||
cfg = _load_config_or_exit(config)
|
||||
_require_writer_capability(workspace_id=getattr(getattr(cfg, "runtime_identity", None), "workspace_id", None), revision=getattr(getattr(cfg, "runtime_identity", None), "workspace_revision", None))
|
||||
if cfg.embeddings is None:
|
||||
raise RuntimeError("embeddings are not configured")
|
||||
corpus_root = cfg.paths.artifacts.parent / "corpus"
|
||||
@@ -87,7 +105,7 @@ def run_from_config(config: Path, *, dry_run: bool = False, resume: str | None =
|
||||
return "sha256:" + hashlib.sha256(value.encode()).hexdigest()
|
||||
|
||||
return pipeline.run_as_job(
|
||||
workspace_id=workspace_id_from_path(config),
|
||||
workspace_id=workspace_id_for_config(cfg, config),
|
||||
workspace_root=corpus_root.parent,
|
||||
config_fingerprint=fingerprint(cfg.model_dump_json()),
|
||||
input_fingerprint=fingerprint(config.resolve().as_posix()),
|
||||
@@ -98,13 +116,13 @@ def run_from_config(config: Path, *, dry_run: bool = False, resume: str | None =
|
||||
|
||||
def gc_from_config(config: Path, *, dry_run: bool = False):
|
||||
from tht.adapters.factory import build_evidence_sources, build_vector_store
|
||||
from tht.cli.schema_cmd import _load_config_or_exit
|
||||
from tht.cli.vector_cmd import make_embedder
|
||||
from tht.corpus.chunk import ChunkPolicy
|
||||
from tht.corpus.pipeline import CorpusPipeline
|
||||
from tht.corpus.store import CorpusStore
|
||||
|
||||
cfg = _load_config_or_exit(config)
|
||||
_require_writer_capability(workspace_id=getattr(getattr(cfg, "runtime_identity", None), "workspace_id", None), revision=getattr(getattr(cfg, "runtime_identity", None), "workspace_revision", None))
|
||||
if cfg.embeddings is None:
|
||||
raise RuntimeError("embeddings are not configured")
|
||||
corpus_root = cfg.paths.artifacts.parent / "corpus"
|
||||
@@ -116,7 +134,7 @@ def gc_from_config(config: Path, *, dry_run: bool = False):
|
||||
pipeline_version="evidence-v1",
|
||||
retain_published_generations=cfg.vector.retain_published_generations,
|
||||
)
|
||||
pipeline.workspace_id = workspace_id_from_path(config)
|
||||
pipeline.workspace_id = workspace_id_for_config(cfg, config)
|
||||
return pipeline.gc(workspace_root=corpus_root.parent, dry_run=dry_run)
|
||||
|
||||
|
||||
@@ -133,7 +151,16 @@ def evidence_cmd(
|
||||
if action == "gc":
|
||||
try:
|
||||
payload = gc_from_config(config, dry_run=dry_run)
|
||||
except _PREPROCESS_EXPECTED_ERRORS:
|
||||
payload = {"status": "failed", "error": "evidence cleanup failed"}
|
||||
if json_output:
|
||||
typer.echo(json.dumps(payload, sort_keys=True))
|
||||
else:
|
||||
typer.secho("ERRORE: evidence cleanup failed", fg=typer.colors.RED, err=True)
|
||||
raise typer.Exit(code=1) from None
|
||||
except Exception:
|
||||
if not json_output:
|
||||
logger.exception("Evidence cleanup failed")
|
||||
payload = {"status": "failed", "error": "evidence cleanup failed"}
|
||||
if json_output:
|
||||
typer.echo(json.dumps(payload, sort_keys=True))
|
||||
@@ -154,7 +181,16 @@ def evidence_cmd(
|
||||
raise typer.Exit(code=2)
|
||||
try:
|
||||
result = run_from_config(config, dry_run=dry_run, resume=resume)
|
||||
except _PREPROCESS_EXPECTED_ERRORS:
|
||||
payload = {"status": "failed", "error": "preprocessing failed"}
|
||||
if json_output:
|
||||
typer.echo(json.dumps(payload, sort_keys=True))
|
||||
else:
|
||||
typer.secho("ERRORE: preprocessing failed", fg=typer.colors.RED, err=True)
|
||||
raise typer.Exit(code=1) from None
|
||||
except Exception:
|
||||
if not json_output:
|
||||
logger.exception("Evidence preprocessing failed")
|
||||
payload = {"status": "failed", "error": "preprocessing failed"}
|
||||
if json_output:
|
||||
typer.echo(json.dumps(payload, sort_keys=True))
|
||||
@@ -205,7 +241,7 @@ def dwh_cmd(
|
||||
raise typer.Exit(code=2)
|
||||
try:
|
||||
result = run_dwh_from_config(config, steps=selected, resume=resume)
|
||||
except Exception:
|
||||
except (OSError, RuntimeError, ValueError, TypeError, KeyError):
|
||||
payload = {"status": "failed", "error": "DWH preprocessing failed"}
|
||||
if json_output:
|
||||
typer.echo(json.dumps(payload, sort_keys=True))
|
||||
|
||||
+457
-176
@@ -1,16 +1,27 @@
|
||||
from pathlib import Path
|
||||
import logging
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
from typing import Literal, TypedDict
|
||||
|
||||
import typer
|
||||
import yaml
|
||||
from pydantic import ValidationError
|
||||
|
||||
from tht.adapters.factory import build_dwh
|
||||
from tht.cli.config_cmd import CONFIG_OPT
|
||||
from tht.config import ConfigError, load_config
|
||||
from tht.config import Config, ConfigError, load_config
|
||||
from tht.db.sampling import is_text_type
|
||||
from tht.mschema.eligibility import classify_all
|
||||
|
||||
schema_app = typer.Typer(help="Gestione mschema (rappresentazione canonica dello schema)")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def _require_writer_capability(*, workspace_id: str | None = None, revision: str | None = None) -> None:
|
||||
# Authorization is unconditional: an environment marker is attacker-controlled
|
||||
# and must never turn a mutating direct invocation into an authorized child.
|
||||
from tht.workspace_writer_lock import require_workspace_writer_capability
|
||||
require_workspace_writer_capability(workspace_id=workspace_id, revision=revision)
|
||||
|
||||
|
||||
def _add_examples(dwh, phys, examples) -> None:
|
||||
for table_name, table in phys.tables.items():
|
||||
@@ -21,7 +32,7 @@ def _add_examples(dwh, phys, examples) -> None:
|
||||
sampled = dwh.sample_column(
|
||||
table_name, column_name, limit=examples.max_per_column
|
||||
)
|
||||
except Exception as exc:
|
||||
except Exception as exc: # noqa: BLE001 - sampling adapter boundary
|
||||
logger.warning("Campionamento saltato per %s.%s: %s",
|
||||
table_name, column_name, exc)
|
||||
continue
|
||||
@@ -36,7 +47,7 @@ def _load_config_or_exit(config: Path):
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
|
||||
def physical_path(cfg) -> Path:
|
||||
def physical_path(cfg: Config) -> Path:
|
||||
from tht.jobs.dwh_pipeline import resolve_dwh_snapshot
|
||||
|
||||
if not (cfg.paths.artifacts.parent / ".tht-dwh").exists():
|
||||
@@ -44,12 +55,26 @@ def physical_path(cfg) -> Path:
|
||||
return resolve_dwh_snapshot(cfg).physical
|
||||
|
||||
|
||||
def annotations_path(cfg) -> Path:
|
||||
def annotations_path(cfg: Config) -> Path:
|
||||
return cfg.paths.artifacts / "mschema" / "annotations.yaml"
|
||||
|
||||
|
||||
def refresh_catalog(cfg, *, dwh=None, output_path: Path | None = None):
|
||||
"""Run the existing catalog algorithm and persist its canonical output."""
|
||||
"""Run the catalog algorithm only under the exact backend-bound root capability."""
|
||||
import os
|
||||
import stat
|
||||
cap = __import__("tht.workspace_writer_lock", fromlist=["require_workspace_writer_capability"]).require_workspace_writer_capability()
|
||||
cfg_workspace = getattr(cfg, "_workspace_id", None)
|
||||
cfg_revision = getattr(cfg, "_workspace_revision", None)
|
||||
runtime = getattr(cfg, "runtime_identity", None)
|
||||
if cfg_workspace != cap.workspace_id or cfg_revision != cap.revision or runtime is None or runtime.workspace_id != cap.workspace_id or runtime.workspace_revision != cap.revision:
|
||||
raise RuntimeError("preprocessing_conflict")
|
||||
try:
|
||||
st = os.stat(cfg.paths.sessions, follow_symlinks=False)
|
||||
except OSError as exc:
|
||||
raise RuntimeError("preprocessing_conflict") from exc
|
||||
if not stat.S_ISDIR(st.st_mode) or (st.st_dev, st.st_ino) != (cap.device, cap.inode) or st.st_uid != os.getuid() or (st.st_mode & 0o777) != 0o700:
|
||||
raise RuntimeError("preprocessing_conflict")
|
||||
target = dwh if dwh is not None else build_dwh(cfg)
|
||||
physical = target.introspect()
|
||||
_add_examples(target, physical, cfg.examples)
|
||||
@@ -83,8 +108,8 @@ def introspect_cmd(
|
||||
|
||||
try:
|
||||
cached = PhysicalSchema.from_yaml(out)
|
||||
except Exception:
|
||||
pass # catalogo illeggibile: procedi con la re-introspezione
|
||||
except (OSError, UnicodeError, TypeError, ValueError, yaml.YAMLError, ValidationError) as exc:
|
||||
logger.debug("Catalogo cache non leggibile: %s", exc)
|
||||
else:
|
||||
ts = cached.introspected_at
|
||||
if ts.tzinfo is None:
|
||||
@@ -105,7 +130,7 @@ def introspect_cmd(
|
||||
raise RuntimeError("DWH preprocessing failed")
|
||||
out = physical_path(cfg)
|
||||
phys = PhysicalSchema.from_yaml(out)
|
||||
except Exception as e:
|
||||
except Exception as e: # noqa: BLE001 - introspection CLI boundary
|
||||
typer.secho(f"ERRORE: {e}", fg=typer.colors.RED, err=True)
|
||||
raise typer.Exit(code=1)
|
||||
n_cols = sum(len(t.columns) for t in phys.tables.values())
|
||||
@@ -119,223 +144,479 @@ def introspect_cmd(
|
||||
)
|
||||
|
||||
|
||||
@schema_app.command("check")
|
||||
def check_cmd(config: Path = CONFIG_OPT) -> None:
|
||||
"""Confronta physical.yaml e annotations.yaml; segnala annotazioni orfane."""
|
||||
class _MachineSchemaError(Exception):
|
||||
"""An expected schema CLI failure with a stable public code."""
|
||||
|
||||
def __init__(self, code: str, detail: str = ""):
|
||||
self.code = code
|
||||
self.detail = detail
|
||||
super().__init__(code)
|
||||
|
||||
|
||||
def _annotations_or_error(cfg: Config):
|
||||
from tht.mschema.models import Annotations
|
||||
|
||||
try:
|
||||
return Annotations.from_yaml(annotations_path(cfg))
|
||||
except (OSError, UnicodeError, TypeError, ValueError, yaml.YAMLError, ValidationError):
|
||||
raise _MachineSchemaError("annotations_invalid") from None
|
||||
|
||||
|
||||
_MAX_STAGED_SQL_BYTES = 1 << 20
|
||||
_MAX_STAGED_SQL_TOTAL = 16 << 20
|
||||
_MAX_STAGED_SQL_FILES = 32
|
||||
|
||||
|
||||
def _schema_json(payload: dict) -> None:
|
||||
import json
|
||||
|
||||
typer.echo(json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")))
|
||||
|
||||
|
||||
def _load_schema_config(config: Path, *, suppress_legacy_warning: bool = False):
|
||||
"""Load configuration, optionally hiding the legacy-key warning for JSON callers."""
|
||||
if not suppress_legacy_warning:
|
||||
return load_config(config)
|
||||
with warnings.catch_warnings():
|
||||
warnings.filterwarnings(
|
||||
"ignore",
|
||||
message=r"^DEPRECATION: legacy workspace resource keys are deprecated;",
|
||||
category=FutureWarning,
|
||||
)
|
||||
return load_config(config)
|
||||
|
||||
|
||||
def _physical_or_error(cfg: Config):
|
||||
path = physical_path(cfg)
|
||||
if not path.exists():
|
||||
raise _MachineSchemaError("physical_schema_missing")
|
||||
try:
|
||||
from tht.mschema.models import PhysicalSchema
|
||||
|
||||
return PhysicalSchema.from_yaml(path)
|
||||
except _MachineSchemaError:
|
||||
raise
|
||||
except (OSError, UnicodeError, TypeError, ValueError, yaml.YAMLError, ValidationError):
|
||||
raise _MachineSchemaError("physical_schema_invalid") from None
|
||||
|
||||
|
||||
def _staged_sql_files(inputs: list[Path] | None) -> list[Path]:
|
||||
"""Collect distinct staged SQL paths lazily, bounded by distinct files."""
|
||||
seen_files: set[Path] = set()
|
||||
seen_roots: set[Path] = set()
|
||||
ordered: list[Path] = []
|
||||
|
||||
def add(path: Path):
|
||||
canonical = path.resolve()
|
||||
if canonical in seen_files:
|
||||
return
|
||||
seen_files.add(canonical)
|
||||
ordered.append(canonical)
|
||||
if len(ordered) > _MAX_STAGED_SQL_FILES:
|
||||
raise _MachineSchemaError("staged_sql_too_many")
|
||||
|
||||
for item in inputs or []:
|
||||
canonical_item = item.resolve()
|
||||
if item.is_file():
|
||||
add(canonical_item)
|
||||
elif item.is_dir():
|
||||
if canonical_item in seen_roots:
|
||||
continue
|
||||
seen_roots.add(canonical_item)
|
||||
for path in item.rglob("*.sql"):
|
||||
if path.is_file():
|
||||
add(path)
|
||||
else:
|
||||
raise _MachineSchemaError("staged_sql_invalid")
|
||||
return sorted(ordered, key=Path.as_posix)
|
||||
|
||||
|
||||
def _read_staged_sql(inputs: list[Path] | None) -> tuple[list[Path], list[str]]:
|
||||
paths = _staged_sql_files(inputs)
|
||||
contents: list[str] = []
|
||||
total = 0
|
||||
for path in paths:
|
||||
allowance = min(_MAX_STAGED_SQL_BYTES, _MAX_STAGED_SQL_TOTAL - total)
|
||||
try:
|
||||
with path.open("rb") as stream:
|
||||
raw = stream.read(allowance + 1)
|
||||
except (OSError, UnicodeError):
|
||||
raise _MachineSchemaError("staged_sql_invalid") from None
|
||||
if len(raw) > allowance:
|
||||
raise _MachineSchemaError("staged_sql_too_large")
|
||||
total += len(raw)
|
||||
try:
|
||||
contents.append(raw.decode("utf-8"))
|
||||
except UnicodeDecodeError:
|
||||
raise _MachineSchemaError("staged_sql_invalid") from None
|
||||
return paths, contents
|
||||
|
||||
|
||||
class CandidateForeignKey(TypedDict):
|
||||
columns: list[str]
|
||||
ref_table: str
|
||||
ref_columns: list[str]
|
||||
|
||||
|
||||
class FKCandidate(TypedDict):
|
||||
table: str
|
||||
foreign_keys: list[CandidateForeignKey]
|
||||
|
||||
|
||||
class SuggestFksResult(TypedDict):
|
||||
status: Literal["succeeded", "failed"]
|
||||
code: Literal["ok"]
|
||||
candidates: list[FKCandidate]
|
||||
candidate_count: int
|
||||
candidate_digest: str
|
||||
orphan_count: int
|
||||
staged_sql_count: int
|
||||
mined_join_count: int
|
||||
ambiguous_columns: list[str]
|
||||
|
||||
|
||||
class CheckSchemaResult(TypedDict):
|
||||
status: Literal["succeeded", "failed"]
|
||||
code: Literal["ok", "annotation_orphans"]
|
||||
orphan_count: int
|
||||
orphans: list[str]
|
||||
ignored: list[str]
|
||||
|
||||
|
||||
def _candidate_key(fk) -> tuple:
|
||||
return (tuple(fk.columns), fk.ref_table, tuple(fk.ref_columns))
|
||||
|
||||
|
||||
def suggest_fks_data(
|
||||
config: Config | Path,
|
||||
*,
|
||||
from_sql: list[Path] | None = None,
|
||||
assume: list[str] | None = None,
|
||||
suppress_legacy_warning: bool = False,
|
||||
physical=None,
|
||||
annotations=None,
|
||||
) -> SuggestFksResult:
|
||||
"""Return deterministic FK candidates without reviewing or mutating annotations."""
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
from tht.mschema.fkmine import mine_join_pairs
|
||||
from tht.mschema.merge import find_orphans
|
||||
from tht.mschema.models import Annotations, PhysicalSchema
|
||||
from tht.mschema.models import ForeignKey
|
||||
|
||||
cfg = _load_config_or_exit(config)
|
||||
phys_file = physical_path(cfg)
|
||||
if not phys_file.exists():
|
||||
typer.secho(
|
||||
f"ERRORE: {phys_file} non trovato. Esegui prima `tht schema introspect`.",
|
||||
fg=typer.colors.RED, err=True,
|
||||
if isinstance(config, Path):
|
||||
try:
|
||||
cfg = _load_schema_config(config, suppress_legacy_warning=suppress_legacy_warning)
|
||||
except ConfigError:
|
||||
raise _MachineSchemaError("invalid_configuration") from None
|
||||
else:
|
||||
cfg = config
|
||||
if physical is None:
|
||||
physical = _physical_or_error(cfg)
|
||||
_, sql_contents = _read_staged_sql(from_sql)
|
||||
if annotations is None:
|
||||
annotations = _annotations_or_error(cfg)
|
||||
|
||||
assumed: dict[str, str] = {}
|
||||
for value in assume or []:
|
||||
col, sep, ref = value.partition("=")
|
||||
if not sep or not col or ref not in physical.tables:
|
||||
raise _MachineSchemaError("assumption_invalid", value)
|
||||
assumed[col] = ref
|
||||
|
||||
def single_pk(table) -> str | None:
|
||||
pks = [name for name, column in table.columns.items() if column.pk]
|
||||
return pks[0] if len(pks) == 1 else None
|
||||
|
||||
pk_owners: dict[str, list[str]] = {}
|
||||
for table_name in sorted(physical.tables):
|
||||
pk = single_pk(physical.tables[table_name])
|
||||
if pk:
|
||||
pk_owners.setdefault(pk, []).append(table_name)
|
||||
dim_time_pk = single_pk(physical.tables["dim_time"]) if "dim_time" in physical.tables else None
|
||||
|
||||
known: dict[str, set] = {}
|
||||
for table_name in physical.tables:
|
||||
keys = {
|
||||
_candidate_key(fk)
|
||||
for fk in physical.tables[table_name].foreign_keys
|
||||
}
|
||||
annotation = annotations.tables.get(table_name)
|
||||
if annotation:
|
||||
keys.update(_candidate_key(fk) for fk in annotation.foreign_keys)
|
||||
known[table_name] = keys
|
||||
|
||||
suggested: dict[str, list[ForeignKey]] = {}
|
||||
|
||||
def add(table_name: str, column: str, ref_table: str, ref_column: str) -> None:
|
||||
key = ((column,), ref_table, (ref_column,))
|
||||
if key in known[table_name]:
|
||||
return
|
||||
known[table_name].add(key)
|
||||
suggested.setdefault(table_name, []).append(
|
||||
ForeignKey(columns=[column], ref_table=ref_table, ref_columns=[ref_column])
|
||||
)
|
||||
raise typer.Exit(code=1)
|
||||
physical = PhysicalSchema.from_yaml(phys_file)
|
||||
annotations = Annotations.from_yaml(annotations_path(cfg))
|
||||
|
||||
ignored = [
|
||||
f"{t}.{c} ({col.eligibility_reason})"
|
||||
for t, table in physical.tables.items()
|
||||
for c, col in table.columns.items()
|
||||
if not col.eligible
|
||||
mined_total = 0
|
||||
for sql_text in sql_contents:
|
||||
pairs = mine_join_pairs(sql_text, physical)
|
||||
mined_total += sum(pairs.values())
|
||||
for src_table, src_column, ref_table, ref_column in sorted(pairs):
|
||||
add(src_table, src_column, ref_table, ref_column)
|
||||
|
||||
ambiguous_columns: set[str] = set()
|
||||
for table_name in sorted(physical.tables):
|
||||
table = physical.tables[table_name]
|
||||
for column_name in sorted(table.columns):
|
||||
if dim_time_pk and column_name.endswith("time_key") and table_name != "dim_time":
|
||||
add(table_name, column_name, "dim_time", dim_time_pk)
|
||||
continue
|
||||
if column_name in assumed:
|
||||
if assumed[column_name] != table_name:
|
||||
add(table_name, column_name, assumed[column_name], column_name)
|
||||
continue
|
||||
if column_name in _GENERIC_PK_NAMES:
|
||||
continue
|
||||
owners = [owner for owner in pk_owners.get(column_name, []) if owner != table_name]
|
||||
if len(pk_owners.get(column_name, [])) > 1:
|
||||
ambiguous_columns.add(column_name)
|
||||
continue
|
||||
if not owners:
|
||||
continue
|
||||
add(table_name, column_name, owners[0], column_name)
|
||||
|
||||
candidates = [
|
||||
{
|
||||
"table": table_name,
|
||||
"foreign_keys": [
|
||||
fk.model_dump(exclude_defaults=True)
|
||||
for fk in sorted(fks, key=_candidate_key)
|
||||
],
|
||||
}
|
||||
for table_name, fks in sorted(suggested.items())
|
||||
]
|
||||
if ignored:
|
||||
typer.secho(
|
||||
f"Colonne ignorate (testo ampio, {len(ignored)}):", fg=typer.colors.YELLOW
|
||||
)
|
||||
for line in ignored:
|
||||
typer.echo(f" - {line}")
|
||||
canonical = json.dumps(candidates, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
digest = "sha256:" + hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
||||
orphan_count = len(find_orphans(physical, annotations))
|
||||
return {
|
||||
"status": "succeeded",
|
||||
"code": "ok",
|
||||
"candidates": candidates,
|
||||
"candidate_count": sum(len(item["foreign_keys"]) for item in candidates),
|
||||
"candidate_digest": digest,
|
||||
"orphan_count": orphan_count,
|
||||
"staged_sql_count": len(sql_contents),
|
||||
"mined_join_count": mined_total,
|
||||
"ambiguous_columns": sorted(ambiguous_columns),
|
||||
}
|
||||
|
||||
|
||||
def check_schema_data(
|
||||
config: Config | Path, *, suppress_legacy_warning: bool = False, physical=None, annotations=None
|
||||
) -> CheckSchemaResult:
|
||||
"""Validate the physical catalog and imported annotations without writing."""
|
||||
from tht.mschema.merge import find_orphans
|
||||
|
||||
if isinstance(config, Path):
|
||||
try:
|
||||
cfg = _load_schema_config(config, suppress_legacy_warning=suppress_legacy_warning)
|
||||
except ConfigError:
|
||||
raise _MachineSchemaError("invalid_configuration") from None
|
||||
else:
|
||||
cfg = config
|
||||
if physical is None:
|
||||
physical = _physical_or_error(cfg)
|
||||
if annotations is None:
|
||||
annotations = _annotations_or_error(cfg)
|
||||
orphans = find_orphans(physical, annotations)
|
||||
if orphans:
|
||||
typer.secho(f"ATTENZIONE: {len(orphans)} annotazioni orfane:", fg=typer.colors.YELLOW)
|
||||
for o in orphans:
|
||||
typer.echo(f" - {o}")
|
||||
ignored = [
|
||||
f"{table_name}.{column_name} ({column.eligibility_reason})"
|
||||
for table_name, table in physical.tables.items()
|
||||
for column_name, column in table.columns.items()
|
||||
if not column.eligible
|
||||
]
|
||||
return {
|
||||
"status": "succeeded" if not orphans else "failed",
|
||||
"code": "ok" if not orphans else "annotation_orphans",
|
||||
"orphan_count": len(orphans),
|
||||
"orphans": orphans,
|
||||
"ignored": ignored,
|
||||
}
|
||||
|
||||
|
||||
@schema_app.command("check")
|
||||
def check_cmd(
|
||||
config: Path = CONFIG_OPT,
|
||||
json_output: bool = typer.Option(False, "--json", help="Emetti JSON puro su stdout."),
|
||||
) -> None:
|
||||
"""Confronta physical.yaml e annotations.yaml; segnala annotazioni orfane."""
|
||||
if json_output:
|
||||
try:
|
||||
cfg = _load_schema_config(config, suppress_legacy_warning=True)
|
||||
except ConfigError:
|
||||
_schema_json({"status": "failed", "code": "invalid_configuration"})
|
||||
raise typer.Exit(code=1) from None
|
||||
else:
|
||||
cfg = _load_config_or_exit(config)
|
||||
try:
|
||||
physical = _physical_or_error(cfg)
|
||||
annotations = _annotations_or_error(cfg)
|
||||
payload = check_schema_data(cfg, physical=physical, annotations=annotations)
|
||||
except _MachineSchemaError as error:
|
||||
if json_output:
|
||||
_schema_json({"status": "failed", "code": error.code})
|
||||
raise typer.Exit(code=1) from None
|
||||
_render_schema_machine_error(error, cfg)
|
||||
except Exception:
|
||||
if json_output:
|
||||
_schema_json({"status": "failed", "code": "schema_check_failed"})
|
||||
raise typer.Exit(code=1) from None
|
||||
logger.exception("Schema check failed")
|
||||
typer.secho("ERRORE: impossibile verificare le annotazioni.", fg=typer.colors.RED, err=True)
|
||||
raise typer.Exit(code=1) from None
|
||||
if json_output:
|
||||
# ``ignored`` is human diagnostic context, not part of the machine contract.
|
||||
_schema_json({key: payload[key] for key in ("status", "code", "orphan_count", "orphans")})
|
||||
if payload["status"] != "succeeded":
|
||||
raise typer.Exit(code=3)
|
||||
return
|
||||
if payload["ignored"]:
|
||||
typer.secho(
|
||||
f"Colonne ignorate (testo ampio, {len(payload['ignored'])}):",
|
||||
fg=typer.colors.YELLOW,
|
||||
)
|
||||
for line in payload["ignored"]:
|
||||
typer.echo(f" - {line}")
|
||||
if payload["orphan_count"]:
|
||||
typer.secho(f"ATTENZIONE: {payload['orphan_count']} annotazioni orfane:", fg=typer.colors.YELLOW)
|
||||
for orphan in payload["orphans"]:
|
||||
typer.echo(f" - {orphan}")
|
||||
raise typer.Exit(code=3)
|
||||
typer.secho("OK: nessuna annotazione orfana.", fg=typer.colors.GREEN)
|
||||
|
||||
|
||||
# PK con questi nomi sono identificatori generici: la regola same-name non si applica
|
||||
# (nel DWH reale `id` e' la PK di ~50 tabelle e produrrebbe migliaia di falsi positivi).
|
||||
_GENERIC_PK_NAMES = {"id", "key", "code"}
|
||||
def _render_schema_machine_error(error: _MachineSchemaError, cfg) -> None:
|
||||
"""Render expected schema failures for the legacy human command contract."""
|
||||
if error.code == "physical_schema_missing":
|
||||
typer.secho(
|
||||
f"ERRORE: {physical_path(cfg)} non trovato. Esegui prima `tht schema introspect`.",
|
||||
fg=typer.colors.RED,
|
||||
err=True,
|
||||
)
|
||||
elif error.code == "assumption_invalid":
|
||||
detail = getattr(error, "detail", "")
|
||||
typer.secho(
|
||||
f"ERRORE: --assume '{detail}' non valido (atteso col=tabella nel catalogo).",
|
||||
fg=typer.colors.RED,
|
||||
err=True,
|
||||
)
|
||||
else:
|
||||
typer.secho("ERRORE: impossibile elaborare il catalogo.", fg=typer.colors.RED, err=True)
|
||||
raise typer.Exit(code=1) from None
|
||||
|
||||
|
||||
@schema_app.command("suggest-fks")
|
||||
def suggest_fks_cmd(
|
||||
config: Path = CONFIG_OPT,
|
||||
from_sql: list[Path] = typer.Option(
|
||||
None, "--from-sql",
|
||||
help="Directory di .sql approvati da cui minare i join reali (ripetibile).",
|
||||
),
|
||||
assume: list[str] = typer.Option(
|
||||
None, "--assume",
|
||||
help="Disambigua una PK con piu' proprietari: col=tabella_ref "
|
||||
"(es. cod_paz=dim_patient). Ripetibile.",
|
||||
),
|
||||
write: bool = typer.Option(
|
||||
False, "--write",
|
||||
help="Fonde i suggerimenti in annotations.yaml (aggiunge solo FK mancanti).",
|
||||
),
|
||||
from_sql: list[Path] = typer.Option(None, "--from-sql", help="Directory/file SQL approvati (ripetibile)."), # noqa: B008
|
||||
assume: list[str] = typer.Option(None, "--assume", help="Disambigua una PK: col=tabella."), # noqa: B008
|
||||
write: bool = typer.Option(False, "--write", help="Fonde i suggerimenti in annotations.yaml."),
|
||||
json_output: bool = typer.Option(False, "--json", help="Emetti JSON puro su stdout."),
|
||||
) -> None:
|
||||
"""Suggerisce FK logiche per la curazione umana in annotations.yaml.
|
||||
|
||||
Tre regole, in ordine di confidenza: (1) equi-join minati dall'SQL gia'
|
||||
approvato (--from-sql); (2) colonna `*time_key` verso la PK di dim_time;
|
||||
(3) colonna con lo stesso nome della PK di UN'ALTRA tabella, solo se quel
|
||||
nome ha un unico proprietario e non e' generico (id/key/code) — salvo
|
||||
disambiguazione esplicita con --assume.
|
||||
"""
|
||||
"""Suggerisce FK logiche per la curazione umana in annotations.yaml."""
|
||||
import yaml as _yaml
|
||||
|
||||
from tht.mschema.fkmine import mine_join_pairs
|
||||
from tht.mschema.models import Annotations, ForeignKey, PhysicalSchema, TableAnnotation
|
||||
from tht.mschema.models import TableAnnotation
|
||||
|
||||
cfg = _load_config_or_exit(config)
|
||||
phys_file = physical_path(cfg)
|
||||
if not phys_file.exists():
|
||||
typer.secho(
|
||||
f"ERRORE: {phys_file} non trovato. Esegui prima `tht schema introspect`.",
|
||||
fg=typer.colors.RED, err=True,
|
||||
if json_output and write:
|
||||
_schema_json({"status": "failed", "code": "write_not_allowed"})
|
||||
raise typer.Exit(code=2)
|
||||
if json_output:
|
||||
try:
|
||||
cfg = _load_schema_config(config, suppress_legacy_warning=True)
|
||||
except ConfigError:
|
||||
_schema_json({"status": "failed", "code": "invalid_configuration"})
|
||||
raise typer.Exit(code=1) from None
|
||||
else:
|
||||
cfg = _load_config_or_exit(config)
|
||||
try:
|
||||
physical = _physical_or_error(cfg)
|
||||
annotations = _annotations_or_error(cfg)
|
||||
payload = suggest_fks_data(
|
||||
cfg, from_sql=from_sql, assume=assume, physical=physical, annotations=annotations
|
||||
)
|
||||
raise typer.Exit(code=1)
|
||||
physical = PhysicalSchema.from_yaml(phys_file)
|
||||
ann_path = annotations_path(cfg)
|
||||
annotations = Annotations.from_yaml(ann_path)
|
||||
except _MachineSchemaError as error:
|
||||
if json_output:
|
||||
_schema_json({"status": "failed", "code": error.code})
|
||||
raise typer.Exit(code=1) from None
|
||||
_render_schema_machine_error(error, cfg)
|
||||
except Exception:
|
||||
if json_output:
|
||||
_schema_json({"status": "failed", "code": "schema_suggestion_failed"})
|
||||
raise typer.Exit(code=1) from None
|
||||
logger.exception("Schema suggestion failed")
|
||||
typer.secho("ERRORE: impossibile elaborare il catalogo.", fg=typer.colors.RED, err=True)
|
||||
raise typer.Exit(code=1) from None
|
||||
if json_output:
|
||||
_schema_json(payload)
|
||||
return
|
||||
|
||||
assumed: dict[str, str] = {}
|
||||
for a in assume or []:
|
||||
col, _, ref = a.partition("=")
|
||||
if not ref or ref not in physical.tables:
|
||||
typer.secho(
|
||||
f"ERRORE: --assume '{a}' non valido (atteso col=tabella nel catalogo).",
|
||||
fg=typer.colors.RED, err=True,
|
||||
)
|
||||
raise typer.Exit(code=1)
|
||||
assumed[col] = ref
|
||||
|
||||
def _single_pk(table) -> str | None:
|
||||
pks = [c for c, col in table.columns.items() if col.pk]
|
||||
return pks[0] if len(pks) == 1 else None
|
||||
|
||||
pk_owners: dict[str, list[str]] = {}
|
||||
for tname, table in physical.tables.items():
|
||||
pk = _single_pk(table)
|
||||
if pk:
|
||||
pk_owners.setdefault(pk, []).append(tname)
|
||||
|
||||
dim_time_pk = None
|
||||
if "dim_time" in physical.tables:
|
||||
dim_time_pk = _single_pk(physical.tables["dim_time"])
|
||||
|
||||
def _known(tname: str) -> set:
|
||||
keys = set()
|
||||
for fk in physical.tables[tname].foreign_keys:
|
||||
keys.add((tuple(fk.columns), fk.ref_table, tuple(fk.ref_columns)))
|
||||
ann = annotations.tables.get(tname)
|
||||
if ann:
|
||||
for fk in ann.foreign_keys:
|
||||
keys.add((tuple(fk.columns), fk.ref_table, tuple(fk.ref_columns)))
|
||||
return keys
|
||||
|
||||
known_by_table: dict[str, set] = {t: _known(t) for t in physical.tables}
|
||||
suggested: dict[str, list[ForeignKey]] = {}
|
||||
|
||||
def _add(tname: str, col: str, ref_table: str, ref_col: str) -> None:
|
||||
key = ((col,), ref_table, (ref_col,))
|
||||
if key in known_by_table[tname]:
|
||||
return
|
||||
known_by_table[tname].add(key)
|
||||
suggested.setdefault(tname, []).append(
|
||||
ForeignKey(columns=[col], ref_table=ref_table, ref_columns=[ref_col])
|
||||
)
|
||||
|
||||
# Regola 1: join minati dall'SQL approvato.
|
||||
n_sql_files = 0
|
||||
mined_total = 0
|
||||
for d in from_sql or []:
|
||||
for sql_file in sorted(d.rglob("*.sql")):
|
||||
n_sql_files += 1
|
||||
pairs = mine_join_pairs(sql_file.read_text(), physical)
|
||||
mined_total += sum(pairs.values())
|
||||
for (src_t, src_c, ref_t, ref_c) in pairs:
|
||||
_add(src_t, src_c, ref_t, ref_c)
|
||||
|
||||
# Regole 2 e 3: convenzioni di naming.
|
||||
ambiguous_skipped: set[str] = set()
|
||||
for tname, table in physical.tables.items():
|
||||
for cname in table.columns:
|
||||
if dim_time_pk and cname.endswith("time_key") and tname != "dim_time":
|
||||
_add(tname, cname, "dim_time", dim_time_pk)
|
||||
continue
|
||||
if cname in assumed:
|
||||
if assumed[cname] != tname:
|
||||
_add(tname, cname, assumed[cname], cname)
|
||||
continue
|
||||
owners = [o for o in pk_owners.get(cname, []) if o != tname]
|
||||
if not owners or cname in _GENERIC_PK_NAMES:
|
||||
continue
|
||||
if len(pk_owners[cname]) > 1:
|
||||
ambiguous_skipped.add(cname)
|
||||
continue
|
||||
_add(tname, cname, owners[0], cname)
|
||||
|
||||
if n_sql_files:
|
||||
# Human mode remains the original renderer over the pure result.
|
||||
if payload["staged_sql_count"]:
|
||||
typer.secho(
|
||||
f"Minati {mined_total} equi-join da {n_sql_files} file SQL.",
|
||||
f"Minati {payload['mined_join_count']} equi-join da {payload['staged_sql_count']} file SQL.",
|
||||
fg=typer.colors.BLUE, err=True,
|
||||
)
|
||||
if ambiguous_skipped:
|
||||
if payload["ambiguous_columns"]:
|
||||
typer.secho(
|
||||
"PK ambigue saltate dalla regola same-name (piu' tabelle proprietarie): "
|
||||
+ ", ".join(sorted(ambiguous_skipped))
|
||||
+ ", ".join(payload["ambiguous_columns"])
|
||||
+ ". Se servono, aggiungile a mano o passa --from-sql.",
|
||||
fg=typer.colors.YELLOW, err=True,
|
||||
)
|
||||
|
||||
n_fks = sum(len(v) for v in suggested.values())
|
||||
if not suggested:
|
||||
if not payload["candidates"]:
|
||||
typer.secho("OK: nessuna FK da suggerire.", fg=typer.colors.GREEN)
|
||||
return
|
||||
|
||||
candidate_tables = {
|
||||
item["table"]: {"foreign_keys": item["foreign_keys"]}
|
||||
for item in payload["candidates"]
|
||||
}
|
||||
if write:
|
||||
for tname, fks in suggested.items():
|
||||
ann = annotations.tables.setdefault(tname, TableAnnotation())
|
||||
ann.foreign_keys.extend(fks)
|
||||
annotations.to_yaml(ann_path)
|
||||
_require_writer_capability(workspace_id=getattr(getattr(cfg, "runtime_identity", None), "workspace_id", None), revision=getattr(getattr(cfg, "runtime_identity", None), "workspace_revision", None))
|
||||
for table_name, table_payload in candidate_tables.items():
|
||||
ann = annotations.tables.setdefault(table_name, TableAnnotation())
|
||||
from tht.mschema.models import ForeignKey
|
||||
ann.foreign_keys.extend(ForeignKey(**fk) for fk in table_payload["foreign_keys"])
|
||||
annotations.to_yaml(annotations_path(cfg))
|
||||
typer.secho(
|
||||
f"OK: {n_fks} FK suggerite aggiunte a {ann_path} "
|
||||
f"({len(suggested)} tabelle). Rivedile a mano prima dell'uso.",
|
||||
f"OK: {payload['candidate_count']} FK suggerite aggiunte a {annotations_path(cfg)} "
|
||||
f"({len(candidate_tables)} tabelle). Rivedile a mano prima dell'uso.",
|
||||
fg=typer.colors.GREEN,
|
||||
)
|
||||
return
|
||||
|
||||
payload = {
|
||||
"tables": {
|
||||
tname: {"foreign_keys": [fk.model_dump(exclude_defaults=True) for fk in fks]}
|
||||
for tname, fks in suggested.items()
|
||||
}
|
||||
}
|
||||
typer.echo(_yaml.safe_dump(payload, sort_keys=False, allow_unicode=True))
|
||||
typer.echo(_yaml.safe_dump({"tables": candidate_tables}, sort_keys=False, allow_unicode=True))
|
||||
typer.secho(
|
||||
f"{n_fks} FK candidate ({len(suggested)} tabelle). "
|
||||
f"Usa --write per fonderle in annotations.yaml, poi curale a mano.",
|
||||
f"{payload['candidate_count']} FK candidate ({len(candidate_tables)} tabelle). "
|
||||
"Usa --write per fonderle in annotations.yaml, poi curale a mano.",
|
||||
fg=typer.colors.YELLOW,
|
||||
)
|
||||
|
||||
|
||||
# PK con questi nomi sono identificatori generici: la regola same-name non si applica
|
||||
# (nel DWH reale `id` e' la PK di ~50 tabelle e produrrebbe migliaia di falsi positivi).
|
||||
_GENERIC_PK_NAMES = {"id", "key", "code"}
|
||||
|
||||
|
||||
@schema_app.command("render")
|
||||
def render_cmd(
|
||||
config: Path = CONFIG_OPT,
|
||||
format: str = typer.Option(
|
||||
"markdown", "--format", "-f", help="Formato: markdown | mschema-text | schema-dict"
|
||||
),
|
||||
tables: list[str] = typer.Option(
|
||||
tables: list[str] = typer.Option( # noqa: B008
|
||||
None, "--table", "-t", help="Limita alle tabelle indicate (ripetibile)."
|
||||
),
|
||||
output: Path = typer.Option(None, "--output", "-o", help="File di output (default stdout)."),
|
||||
output: Path = typer.Option(None, "--output", "-o", help="File di output (default stdout)."), # noqa: B008
|
||||
) -> None:
|
||||
"""Serializza mschema (physical + annotations) nel formato richiesto."""
|
||||
import json
|
||||
|
||||
+193
-24
@@ -1,14 +1,34 @@
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
from typing import Literal, TypedDict
|
||||
|
||||
import typer
|
||||
|
||||
from tht.cli._guards import require_server_profile, require_vector_write_allowed
|
||||
from tht.cli._guards import (
|
||||
has_vector_write_rest,
|
||||
require_server_profile,
|
||||
require_vector_write_allowed,
|
||||
)
|
||||
from tht.cli.config_cmd import CONFIG_OPT
|
||||
from tht.cli.schema_cmd import _load_config_or_exit, annotations_path, physical_path
|
||||
from tht.ports.vector import VectorWriteRecord
|
||||
from tht.cli.schema_cmd import (
|
||||
_load_config_or_exit,
|
||||
_load_schema_config,
|
||||
annotations_path,
|
||||
physical_path,
|
||||
)
|
||||
from tht.config import Config, ConfigError
|
||||
from tht.ports.vector import SemanticIndexIncompatibleError, VectorWriteRecord
|
||||
from tht.vectorstore.store import SyncStats, content_hash
|
||||
|
||||
vector_app = typer.Typer(help="Indice semantico Qdrant (derivato, rigenerabile)")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def _require_writer_capability(*, workspace_id: str | None = None, revision: str | None = None) -> None:
|
||||
# Authorization is unconditional: an environment marker is attacker-controlled
|
||||
# and must never turn a mutating direct invocation into an authorized child.
|
||||
from tht.workspace_writer_lock import require_workspace_writer_capability
|
||||
require_workspace_writer_capability(workspace_id=workspace_id, revision=revision)
|
||||
|
||||
|
||||
def make_embedder(embeddings_cfg):
|
||||
@@ -49,7 +69,8 @@ def open_searcher(cfg):
|
||||
return AdapterSearcher()
|
||||
|
||||
|
||||
def sync_canonical_records(collection, records, *, store, embedder):
|
||||
def sync_canonical_records(collection, records, *, store, embedder, config=None):
|
||||
_require_writer_capability(workspace_id=getattr(getattr(config, "runtime_identity", None), "workspace_id", None), revision=getattr(getattr(config, "runtime_identity", None), "workspace_revision", None))
|
||||
kinds = sorted({record.kind for record in records})
|
||||
existing = store.existing_hashes(collection, kinds)
|
||||
pending = []
|
||||
@@ -74,10 +95,19 @@ def sync_canonical_records(collection, records, *, store, embedder):
|
||||
return stats
|
||||
|
||||
|
||||
def _print_stats(stats) -> None:
|
||||
def _print_stats(stats: SyncStats | Mapping[str, int]) -> None:
|
||||
if isinstance(stats, SyncStats):
|
||||
counts = {
|
||||
"added": stats.added,
|
||||
"updated": stats.updated,
|
||||
"deleted": stats.deleted,
|
||||
"unchanged": stats.unchanged,
|
||||
}
|
||||
else:
|
||||
counts = stats
|
||||
typer.secho(
|
||||
f"OK: {stats.added} nuovi, {stats.updated} aggiornati, "
|
||||
f"{stats.deleted} rimossi, {stats.unchanged} invariati",
|
||||
f"OK: {counts['added']} nuovi, {counts['updated']} aggiornati, "
|
||||
f"{counts['deleted']} rimossi, {counts['unchanged']} invariati",
|
||||
fg=typer.colors.GREEN,
|
||||
)
|
||||
|
||||
@@ -116,24 +146,89 @@ def init_cmd(
|
||||
)
|
||||
|
||||
|
||||
@vector_app.command("index-schema")
|
||||
def index_schema_cmd(config: Path = CONFIG_OPT) -> None:
|
||||
"""Embedda e sincronizza i record schema (tabelle e colonne) nel semantic store."""
|
||||
class _MachineVectorError(Exception):
|
||||
"""Expected vector CLI failure with a stable machine-readable code."""
|
||||
|
||||
def __init__(self, code: str):
|
||||
self.code = code
|
||||
super().__init__(code)
|
||||
|
||||
|
||||
class IndexCounts(TypedDict):
|
||||
added: int
|
||||
updated: int
|
||||
deleted: int
|
||||
unchanged: int
|
||||
|
||||
|
||||
class IndexSchemaResult(TypedDict):
|
||||
status: Literal["succeeded", "failed"]
|
||||
code: Literal["ok"]
|
||||
counts: IndexCounts
|
||||
|
||||
|
||||
def _vector_cfg_or_error(cfg: Config) -> None:
|
||||
missing = []
|
||||
if cfg.embeddings is None:
|
||||
missing.append("embeddings")
|
||||
if cfg.vectors is None:
|
||||
missing.append("vectors")
|
||||
if missing:
|
||||
raise _MachineVectorError("vector_configuration_missing")
|
||||
|
||||
|
||||
def _vector_write_or_error(cfg: Config) -> None:
|
||||
if cfg.profile == "workstation" and not has_vector_write_rest(cfg):
|
||||
raise _MachineVectorError("vector_write_not_allowed")
|
||||
|
||||
|
||||
def _load_schema_artifacts(cfg: Config):
|
||||
import yaml
|
||||
from pydantic import ValidationError
|
||||
|
||||
from tht.mschema.models import Annotations, PhysicalSchema
|
||||
|
||||
phys_file = physical_path(cfg)
|
||||
if not phys_file.exists():
|
||||
raise _MachineVectorError("physical_schema_missing")
|
||||
try:
|
||||
return (
|
||||
PhysicalSchema.from_yaml(phys_file),
|
||||
Annotations.from_yaml(annotations_path(cfg)),
|
||||
)
|
||||
except (OSError, UnicodeError, TypeError, ValueError, yaml.YAMLError, ValidationError):
|
||||
raise _MachineVectorError("schema_artifacts_invalid") from None
|
||||
|
||||
def index_schema_data(
|
||||
config: Config | Path, *, suppress_legacy_warning: bool = False, physical=None, annotations=None
|
||||
) -> IndexSchemaResult:
|
||||
"""Synchronize schema records and return a bounded machine result."""
|
||||
from tht.mschema.models import Annotations, PhysicalSchema
|
||||
from tht.vectorstore.records import schema_records
|
||||
|
||||
cfg = _load_config_or_exit(config)
|
||||
require_vector_write_allowed(cfg, "vector index-schema")
|
||||
require_vector_cfg(cfg)
|
||||
phys_file = physical_path(cfg)
|
||||
if not phys_file.exists():
|
||||
typer.secho(
|
||||
f"ERRORE: {phys_file} non trovato. Esegui prima `tht schema introspect`.",
|
||||
fg=typer.colors.RED, err=True,
|
||||
)
|
||||
raise typer.Exit(code=1)
|
||||
physical = PhysicalSchema.from_yaml(phys_file)
|
||||
annotations = Annotations.from_yaml(annotations_path(cfg))
|
||||
if isinstance(config, Path):
|
||||
try:
|
||||
cfg = _load_schema_config(config, suppress_legacy_warning=suppress_legacy_warning)
|
||||
except ConfigError:
|
||||
raise _MachineVectorError("invalid_configuration") from None
|
||||
else:
|
||||
cfg = config
|
||||
_vector_write_or_error(cfg)
|
||||
_vector_cfg_or_error(cfg)
|
||||
if physical is None:
|
||||
phys_file = physical_path(cfg)
|
||||
if not phys_file.exists():
|
||||
raise _MachineVectorError("physical_schema_missing")
|
||||
import yaml
|
||||
from pydantic import ValidationError
|
||||
|
||||
try:
|
||||
if physical is None:
|
||||
physical = PhysicalSchema.from_yaml(phys_file)
|
||||
if annotations is None:
|
||||
annotations = Annotations.from_yaml(annotations_path(cfg))
|
||||
except (OSError, UnicodeError, TypeError, ValueError, yaml.YAMLError, ValidationError):
|
||||
raise _MachineVectorError("schema_artifacts_invalid") from None
|
||||
records = schema_records(physical, annotations)
|
||||
from tht.adapters.factory import build_vector_store
|
||||
|
||||
@@ -141,6 +236,80 @@ def index_schema_cmd(config: Path = CONFIG_OPT) -> None:
|
||||
"schema_records",
|
||||
records,
|
||||
store=build_vector_store(cfg, require_write=True),
|
||||
embedder=make_embedder(cfg.embeddings),
|
||||
embedder=make_embedder(cfg.embeddings), config=cfg,
|
||||
)
|
||||
_print_stats(stats)
|
||||
return {
|
||||
"status": "succeeded",
|
||||
"code": "ok",
|
||||
"counts": {
|
||||
"added": stats.added,
|
||||
"updated": stats.updated,
|
||||
"deleted": stats.deleted,
|
||||
"unchanged": stats.unchanged,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@vector_app.command("index-schema")
|
||||
def index_schema_cmd(
|
||||
config: Path = CONFIG_OPT,
|
||||
json_output: bool = typer.Option(False, "--json", help="Emetti JSON puro su stdout."),
|
||||
) -> None:
|
||||
"""Embedda e sincronizza i record schema (tabelle e colonne) nel semantic store."""
|
||||
import json
|
||||
|
||||
if json_output:
|
||||
try:
|
||||
cfg = _load_schema_config(config, suppress_legacy_warning=True)
|
||||
except ConfigError:
|
||||
typer.echo(json.dumps({"status": "failed", "code": "invalid_configuration"}, sort_keys=True, separators=(",", ":")))
|
||||
raise typer.Exit(code=1) from None
|
||||
else:
|
||||
cfg = _load_config_or_exit(config)
|
||||
try:
|
||||
# Authorization and configuration are checked before touching any artifacts.
|
||||
_vector_write_or_error(cfg)
|
||||
_vector_cfg_or_error(cfg)
|
||||
physical, annotations = _load_schema_artifacts(cfg)
|
||||
payload = index_schema_data(cfg, physical=physical, annotations=annotations)
|
||||
except SemanticIndexIncompatibleError:
|
||||
if json_output:
|
||||
typer.echo(json.dumps({"status": "failed", "code": "semantic_index_incompatible"}, sort_keys=True, separators=(",", ":")))
|
||||
raise typer.Exit(code=1) from None
|
||||
typer.secho("ERRORE: indice semantico incompatibile.", fg=typer.colors.RED, err=True)
|
||||
raise typer.Exit(code=1) from None
|
||||
except _MachineVectorError as error:
|
||||
if json_output:
|
||||
typer.echo(json.dumps({"status": "failed", "code": error.code}, sort_keys=True, separators=(",", ":")))
|
||||
raise typer.Exit(code=1) from None
|
||||
_render_index_schema_error(error, cfg)
|
||||
except Exception:
|
||||
if json_output:
|
||||
typer.echo(json.dumps({"status": "failed", "code": "schema_index_failed"}, sort_keys=True, separators=(",", ":")))
|
||||
raise typer.Exit(code=1) from None
|
||||
logger.exception("Schema indexing failed")
|
||||
typer.secho("ERRORE: impossibile indicizzare lo schema.", fg=typer.colors.RED, err=True)
|
||||
raise typer.Exit(code=1) from None
|
||||
if json_output:
|
||||
typer.echo(json.dumps(payload, sort_keys=True, separators=(",", ":")))
|
||||
return
|
||||
_print_stats(payload["counts"])
|
||||
|
||||
|
||||
def _render_index_schema_error(error: _MachineVectorError, cfg) -> None:
|
||||
"""Render expected failures without changing the old human CLI messages."""
|
||||
if error.code == "physical_schema_missing":
|
||||
typer.secho(
|
||||
f"ERRORE: {physical_path(cfg)} non trovato. Esegui prima `tht schema introspect`.",
|
||||
fg=typer.colors.RED,
|
||||
err=True,
|
||||
)
|
||||
elif error.code == "vector_configuration_missing":
|
||||
require_vector_cfg(cfg)
|
||||
elif error.code == "vector_write_not_allowed":
|
||||
require_vector_write_allowed(cfg, "vector index-schema")
|
||||
elif error.code == "schema_artifacts_invalid":
|
||||
typer.secho("ERRORE: schema artifacts non validi.", fg=typer.colors.RED, err=True)
|
||||
else:
|
||||
typer.secho("ERRORE: impossibile indicizzare lo schema.", fg=typer.colors.RED, err=True)
|
||||
raise typer.Exit(code=1) from None
|
||||
|
||||
+390
-4
@@ -1,8 +1,11 @@
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import sys
|
||||
import warnings
|
||||
from collections.abc import Callable
|
||||
from ipaddress import ip_address
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any, Literal
|
||||
@@ -222,6 +225,9 @@ class QdrantConfig(BaseModel):
|
||||
type: Literal["qdrant"]
|
||||
base_url: str
|
||||
collection: str = Field(min_length=1)
|
||||
# Internal runtime policy. Registry-rendered configs must not create or alter
|
||||
# the workspace-owned semantic collection; legacy configs retain compatibility.
|
||||
collection_lifecycle: Literal["create_if_missing", "require_existing"] = "create_if_missing"
|
||||
|
||||
|
||||
VectorResourceConfig = Annotated[
|
||||
@@ -534,19 +540,350 @@ def _format_validation_error(error: ValidationError) -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def load_config(path: Path) -> Config:
|
||||
if not path.exists():
|
||||
raise ConfigError(f"File di configurazione non trovato: {path}")
|
||||
def _validate_raw_config_shape(raw: dict[str, Any], path: Path) -> None:
|
||||
"""Reject unsafe YAML shapes before compatibility translation or sorting keys."""
|
||||
seen: set[int] = set()
|
||||
|
||||
def walk(value: Any, location: str) -> None:
|
||||
if isinstance(value, dict):
|
||||
marker = id(value)
|
||||
if marker in seen:
|
||||
return
|
||||
seen.add(marker)
|
||||
for key, item in value.items():
|
||||
if not isinstance(key, str):
|
||||
raise ConfigError(
|
||||
f"Configurazione non valida in {path}: mapping key at {location} "
|
||||
"must be a string"
|
||||
)
|
||||
walk(item, f"{location}.{key}")
|
||||
elif isinstance(value, list):
|
||||
for index, item in enumerate(value):
|
||||
walk(item, f"{location}[{index}]")
|
||||
|
||||
walk(raw, "configuration")
|
||||
resources = raw.get("resources")
|
||||
if "resources" in raw and not isinstance(resources, dict):
|
||||
raise ConfigError(
|
||||
f"Configurazione non valida in {path}: resources must be a mapping"
|
||||
)
|
||||
if isinstance(resources, dict):
|
||||
for name in ("vector", "embeddings"):
|
||||
if name in resources and not isinstance(resources[name], dict):
|
||||
raise ConfigError(
|
||||
f"Configurazione non valida in {path}: resources.{name} must be a mapping"
|
||||
)
|
||||
|
||||
|
||||
def _read_runtime_fd(fd: int, label: str, expected_mode: int = 0o400) -> tuple[bytes, os.stat_result]:
|
||||
try:
|
||||
raw = yaml.safe_load(path.read_text())
|
||||
info = os.fstat(fd)
|
||||
if (not stat.S_ISREG(info.st_mode) or info.st_nlink != 1
|
||||
or stat.S_IMODE(info.st_mode) != expected_mode
|
||||
or info.st_uid != os.getuid()):
|
||||
raise OSError("unsafe runtime descriptor")
|
||||
os.lseek(fd, 0, os.SEEK_SET)
|
||||
chunks: list[bytes] = []
|
||||
total = 0
|
||||
while chunk := os.read(fd, 1024 * 1024):
|
||||
total += len(chunk)
|
||||
if total > 16 * 1024 * 1024:
|
||||
raise OSError("runtime descriptor too large")
|
||||
chunks.append(chunk)
|
||||
return b"".join(chunks), info
|
||||
except OSError as exc:
|
||||
raise ConfigError(f"File runtime {label} non attendibile") from exc
|
||||
|
||||
|
||||
def _strict_runtime_manifest(raw: object) -> dict[str, object]:
|
||||
required = {
|
||||
"version", "workspace_id", "workspace_revision", "descriptor_git_blob",
|
||||
"descriptor_sha256", "descriptor_dev", "descriptor_ino", "config_sha256",
|
||||
"config_dwh_binding", "config_dev", "config_ino", "config_size", "config_mode",
|
||||
"config_uid", "config_nlink", "directory_identities",
|
||||
}
|
||||
if not isinstance(raw, dict) or set(raw) != required or raw.get("version") != 1:
|
||||
raise ConfigError("Manifest runtime non valido")
|
||||
if (not isinstance(raw.get("workspace_id"), str) or not re.fullmatch(r"[a-z][a-z0-9-]{2,62}", raw["workspace_id"])
|
||||
or not isinstance(raw.get("workspace_revision"), str) or not re.fullmatch(r"[0-9a-f]{40}", raw["workspace_revision"])
|
||||
or not isinstance(raw.get("descriptor_git_blob"), str) or not re.fullmatch(r"[0-9a-f]{40}", raw["descriptor_git_blob"])):
|
||||
raise ConfigError("Manifest runtime non valido")
|
||||
if not isinstance(raw.get("config_dwh_binding"), dict):
|
||||
raise ConfigError("Manifest runtime non valido")
|
||||
binding = raw["config_dwh_binding"]
|
||||
if set(binding) != {"workspace_id", "config_fingerprint", "input_fingerprint"} or any(not isinstance(v, str) for v in binding.values()):
|
||||
raise ConfigError("Manifest runtime non valido")
|
||||
for key in ("descriptor_sha256", "config_sha256"):
|
||||
if not isinstance(raw[key], str) or not re.fullmatch(r"[0-9a-f]{64}", raw[key]):
|
||||
raise ConfigError("Manifest runtime non valido")
|
||||
for key in ("descriptor_dev", "descriptor_ino", "config_dev", "config_ino", "config_size", "config_uid", "config_nlink"):
|
||||
if not isinstance(raw[key], str) or not raw[key].isdigit():
|
||||
raise ConfigError("Manifest runtime non valido")
|
||||
identities = raw.get("directory_identities")
|
||||
if (not isinstance(identities, list) or not identities or
|
||||
any(not isinstance(item, dict) or set(item) != {"path", "dev", "ino", "mode", "uid"}
|
||||
or not isinstance(item["path"], str) or not item["path"].startswith("/")
|
||||
or any(not isinstance(item[key], str) or not item[key].isdigit()
|
||||
for key in ("dev", "ino", "mode", "uid"))
|
||||
or item["mode"] == "0"
|
||||
for item in identities)):
|
||||
raise ConfigError("Manifest runtime non valido")
|
||||
if len({item["path"] for item in identities}) != len(identities):
|
||||
raise ConfigError("Manifest runtime non valido")
|
||||
if raw["config_mode"] != "400":
|
||||
raise ConfigError("Manifest runtime non valido")
|
||||
return raw
|
||||
|
||||
|
||||
|
||||
def _canonical_runtime_path(path: Path) -> Path:
|
||||
value = str(path)
|
||||
if sys.platform == "darwin":
|
||||
if value == "/tmp" or value.startswith("/tmp/"):
|
||||
return Path("/private" + value)
|
||||
if value == "/var" or value.startswith("/var/"):
|
||||
return Path("/private" + value)
|
||||
return path
|
||||
|
||||
|
||||
def _runtime_directory_identities(paths: list[Path]) -> list[dict[str, str]]:
|
||||
"""Open/stat every directory component and return its current identity chain."""
|
||||
out: list[dict[str, str]] = []
|
||||
seen: set[str] = set()
|
||||
for path in paths:
|
||||
canonical = _canonical_runtime_path(path)
|
||||
parts = list(canonical.parts)
|
||||
if not parts or parts[0] != "/":
|
||||
raise OSError("runtime config path is invalid")
|
||||
current = "/"
|
||||
components = ["/"] + parts[1:]
|
||||
for component in components:
|
||||
if component != "/":
|
||||
current = current.rstrip("/") + "/" + component
|
||||
# Re-open shared prefixes for each destination branch too: a
|
||||
# replacement between config-parent and manifest-parent traversal
|
||||
# must not be hidden by de-duplication.
|
||||
# lstat before and fstat after open closes the stat/open replacement
|
||||
# window for each component, including canonical destination parents.
|
||||
parent = os.open("/", os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
||||
try:
|
||||
for name in [part for part in Path(current).parts[1:-1]]:
|
||||
nxt = _open_runtime_component(parent, name)
|
||||
os.close(parent); parent = nxt
|
||||
if current == "/":
|
||||
fd = os.dup(parent)
|
||||
else:
|
||||
name = Path(current).name
|
||||
fd = _open_runtime_component(parent, name)
|
||||
try:
|
||||
info = os.fstat(fd)
|
||||
if not stat.S_ISDIR(info.st_mode) or info.st_nlink < 1:
|
||||
raise OSError("unsafe runtime directory")
|
||||
identity = {"path": current, "dev": str(info.st_dev), "ino": str(info.st_ino),
|
||||
"mode": format(stat.S_IMODE(info.st_mode), "o"),
|
||||
"uid": str(info.st_uid)}
|
||||
if current in seen:
|
||||
previous = next(item for item in out if item["path"] == current)
|
||||
if identity != previous:
|
||||
raise OSError("runtime config directory changed")
|
||||
else:
|
||||
out.append(identity)
|
||||
seen.add(current)
|
||||
finally:
|
||||
os.close(fd)
|
||||
finally:
|
||||
os.close(parent)
|
||||
return out
|
||||
|
||||
|
||||
def _verify_runtime_directory_identities(
|
||||
config_path: Path,
|
||||
manifest_path: Path,
|
||||
expected: object,
|
||||
*,
|
||||
between_config_and_manifest_traversal: Callable[[], None] | None = None,
|
||||
) -> None:
|
||||
# Traverse the two destination branches separately. The callback is a narrow,
|
||||
# package-private seam for deterministic direct tests of a replacement between
|
||||
# those traversals; production always passes None.
|
||||
current = _runtime_directory_identities([config_path.parent])
|
||||
if between_config_and_manifest_traversal is not None:
|
||||
between_config_and_manifest_traversal()
|
||||
for identity in _runtime_directory_identities([manifest_path.parent]):
|
||||
previous = next((item for item in current if item["path"] == identity["path"]), None)
|
||||
if previous is None:
|
||||
current.append(identity)
|
||||
elif previous != identity:
|
||||
raise ConfigError("Destinazione config runtime modificata")
|
||||
if current != expected:
|
||||
raise ConfigError("Destinazione config runtime modificata")
|
||||
|
||||
|
||||
def _open_runtime_component(parent: int, name: str) -> int:
|
||||
before = os.stat(name, dir_fd=parent, follow_symlinks=False)
|
||||
if stat.S_ISLNK(before.st_mode):
|
||||
raise OSError("runtime config path contains a symlink")
|
||||
flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | os.O_NOFOLLOW
|
||||
fd = os.open(name, flags, dir_fd=parent)
|
||||
after = os.fstat(fd)
|
||||
if (before.st_dev != after.st_dev or before.st_ino != after.st_ino
|
||||
or not stat.S_ISDIR(after.st_mode)):
|
||||
os.close(fd)
|
||||
raise OSError("runtime config path changed during open")
|
||||
return fd
|
||||
|
||||
|
||||
def _open_runtime_file(path: Path, expected_mode: int) -> int:
|
||||
if not path.is_absolute():
|
||||
raise OSError("runtime config path must be absolute")
|
||||
parts = list(path.parts)
|
||||
if sys.platform == "darwin" and len(parts) > 1 and parts[1] in ("var", "tmp"):
|
||||
parts = ["/", "private", *parts[1:]]
|
||||
if not parts or parts[0] != "/" or any(part in ("", ".", "..") or "/" in part for part in parts[1:]):
|
||||
raise OSError("runtime config path is invalid")
|
||||
current = os.open("/", os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
||||
try:
|
||||
for component in parts[1:-1]:
|
||||
nxt = _open_runtime_component(current, component)
|
||||
os.close(current)
|
||||
current = nxt
|
||||
fd = os.open(parts[-1], os.O_RDONLY | os.O_NOFOLLOW, dir_fd=current)
|
||||
info = os.fstat(fd)
|
||||
if (not stat.S_ISREG(info.st_mode) or info.st_nlink != 1
|
||||
or stat.S_IMODE(info.st_mode) != expected_mode or info.st_uid != os.getuid()):
|
||||
os.close(fd)
|
||||
raise OSError("unsafe runtime file")
|
||||
return fd
|
||||
finally:
|
||||
os.close(current)
|
||||
|
||||
|
||||
def _runtime_manifest_path(config_path: Path) -> Path:
|
||||
if config_path.name == "" or config_path.suffix != ".yaml" or config_path.parent.name != "runtime-config":
|
||||
raise OSError("runtime config path is not canonical")
|
||||
if not re.fullmatch(r"[0-9a-f]{40}", config_path.stem):
|
||||
raise OSError("runtime config path is not canonical")
|
||||
return config_path.parent.parent / "runtime-config-manifests" / f"{config_path.stem}.json"
|
||||
|
||||
def _read_runtime_config_source(
|
||||
path: Path,
|
||||
*,
|
||||
between_config_and_manifest_traversal: Callable[[], None] | None = None,
|
||||
) -> tuple[str | None, dict[str, object] | None]:
|
||||
"""Read a trusted runtime config, with a scoped direct-test race seam.
|
||||
|
||||
The callback is deliberately available only on this package-private helper.
|
||||
Production callers go through :func:`load_config`, which always passes ``None``;
|
||||
no environment variable or process-global hook can alter this traversal.
|
||||
"""
|
||||
# Registry leases authenticate the canonical pathname through a durable manifest
|
||||
# digest. The config and manifest are opened component-by-component; FD 3/4 are
|
||||
# reserved for the maintenance writer/root ABI.
|
||||
expected_manifest = os.environ.get("THT_RUNTIME_CONFIG_MANIFEST_SHA256")
|
||||
runtime_fd = os.environ.get("THT_CONFIG_FD")
|
||||
manifest_fd = os.environ.get("THT_CONFIG_MANIFEST_FD")
|
||||
legacy_expected = os.environ.get("THT_CONFIG_MANIFEST_SHA256")
|
||||
if expected_manifest is not None:
|
||||
if not re.fullmatch(r"[0-9a-f]{64}", expected_manifest):
|
||||
raise ConfigError("Handoff runtime incompleto")
|
||||
config_fd = manifest_fd_local = None
|
||||
try:
|
||||
config_fd = _open_runtime_file(path, 0o400)
|
||||
manifest_fd_local = _open_runtime_file(_runtime_manifest_path(path), 0o600)
|
||||
config_bytes, config_info = _read_runtime_fd(config_fd, "config")
|
||||
manifest_bytes, manifest_info = _read_runtime_fd(manifest_fd_local, "manifest", 0o600)
|
||||
if hashlib.sha256(manifest_bytes).hexdigest() != expected_manifest:
|
||||
raise ConfigError("Manifest runtime modificato")
|
||||
runtime_manifest = _strict_runtime_manifest(json.loads(manifest_bytes.decode("utf-8")))
|
||||
_verify_runtime_directory_identities(
|
||||
path,
|
||||
_runtime_manifest_path(path),
|
||||
runtime_manifest["directory_identities"],
|
||||
between_config_and_manifest_traversal=between_config_and_manifest_traversal,
|
||||
)
|
||||
if (runtime_manifest["config_sha256"] != hashlib.sha256(config_bytes).hexdigest()
|
||||
or int(runtime_manifest["config_dev"]) != config_info.st_dev
|
||||
or int(runtime_manifest["config_ino"]) != config_info.st_ino
|
||||
or int(runtime_manifest["config_size"]) != config_info.st_size
|
||||
or int(runtime_manifest["config_uid"]) != config_info.st_uid
|
||||
or int(runtime_manifest["config_nlink"]) != config_info.st_nlink
|
||||
or config_info.st_dev == manifest_info.st_dev and config_info.st_ino == manifest_info.st_ino):
|
||||
raise ConfigError("Identità config runtime non valida")
|
||||
return config_bytes.decode("utf-8"), runtime_manifest
|
||||
except (OSError, UnicodeError, ValueError, json.JSONDecodeError) as exc:
|
||||
if isinstance(exc, ConfigError):
|
||||
raise
|
||||
raise ConfigError("File di configurazione runtime non attendibile") from exc
|
||||
finally:
|
||||
if config_fd is not None:
|
||||
os.close(config_fd)
|
||||
if manifest_fd_local is not None:
|
||||
os.close(manifest_fd_local)
|
||||
if runtime_fd is not None or manifest_fd is not None or legacy_expected is not None:
|
||||
# Compatibility for direct /dev/fd callers. New backend leases never use it.
|
||||
if runtime_fd is None or manifest_fd is None or legacy_expected is None or not re.fullmatch(r"[0-9a-f]{64}", legacy_expected):
|
||||
raise ConfigError("Handoff runtime incompleto")
|
||||
try:
|
||||
config_bytes, config_info = _read_runtime_fd(int(runtime_fd), "config")
|
||||
manifest_bytes, manifest_info = _read_runtime_fd(int(manifest_fd), "manifest", 0o600)
|
||||
if hashlib.sha256(manifest_bytes).hexdigest() != legacy_expected:
|
||||
raise ConfigError("Manifest runtime modificato")
|
||||
runtime_manifest = _strict_runtime_manifest(json.loads(manifest_bytes.decode("utf-8")))
|
||||
_verify_runtime_directory_identities(
|
||||
path,
|
||||
_runtime_manifest_path(path),
|
||||
runtime_manifest["directory_identities"],
|
||||
between_config_and_manifest_traversal=between_config_and_manifest_traversal,
|
||||
)
|
||||
if (runtime_manifest["config_sha256"] != hashlib.sha256(config_bytes).hexdigest()
|
||||
or int(runtime_manifest["config_dev"]) != config_info.st_dev
|
||||
or int(runtime_manifest["config_ino"]) != config_info.st_ino
|
||||
or int(runtime_manifest["config_size"]) != config_info.st_size
|
||||
or int(runtime_manifest["config_uid"]) != config_info.st_uid
|
||||
or int(runtime_manifest["config_nlink"]) != config_info.st_nlink
|
||||
or config_info.st_dev == manifest_info.st_dev and config_info.st_ino == manifest_info.st_ino):
|
||||
raise ConfigError("Identità config runtime non valida")
|
||||
return config_bytes.decode("utf-8"), runtime_manifest
|
||||
except (OSError, UnicodeError, ValueError, json.JSONDecodeError) as exc:
|
||||
if isinstance(exc, ConfigError):
|
||||
raise
|
||||
raise ConfigError("File di configurazione runtime non attendibile") from exc
|
||||
return None, None
|
||||
|
||||
|
||||
def load_config(path: Path) -> Config:
|
||||
source_text, runtime_manifest = _read_runtime_config_source(
|
||||
path, between_config_and_manifest_traversal=None
|
||||
)
|
||||
if source_text is None:
|
||||
if not path.exists():
|
||||
raise ConfigError(f"File di configurazione non trovato: {path}")
|
||||
try:
|
||||
source_text = path.read_text()
|
||||
except OSError as exc:
|
||||
raise ConfigError(f"File di configurazione non trovato: {path}") from exc
|
||||
|
||||
try:
|
||||
raw = yaml.safe_load(source_text)
|
||||
except yaml.YAMLError as exc:
|
||||
raise ConfigError(f"Configurazione YAML non valida: {path}") from exc
|
||||
if not isinstance(raw, dict):
|
||||
raise ConfigError(f"Configurazione non valida (atteso un mapping YAML): {path}")
|
||||
_validate_raw_config_shape(raw, path)
|
||||
expanded = _resolve_secret_files(_resolve_evidence_secret_files(_expand_env(raw)))
|
||||
_validate_internal_embedding_contract(expanded, path)
|
||||
_validate_internal_vector_contract(expanded, path)
|
||||
translated, used_legacy = translate_legacy_config(expanded)
|
||||
_validate_vector_resource_consistency(expanded, translated, path)
|
||||
vectors = translated.get("vectors")
|
||||
# runtime_identity is the registry marker. The lifecycle is an internal
|
||||
# runtime policy, never a descriptor-controlled option.
|
||||
if (
|
||||
isinstance(translated.get("runtime_identity"), dict)
|
||||
and isinstance(vectors, dict)
|
||||
and vectors.get("type") == "qdrant"
|
||||
):
|
||||
vectors["collection_lifecycle"] = "require_existing"
|
||||
_populate_legacy_views(translated)
|
||||
try:
|
||||
cfg = Config.model_validate(translated)
|
||||
@@ -606,6 +943,12 @@ def load_config(path: Path) -> Config:
|
||||
)
|
||||
_validate_active_embeddings_config(cfg.embeddings, path)
|
||||
_validate_active_vector_config(cfg.vectors, path)
|
||||
if runtime_manifest is not None:
|
||||
from tht.jobs.dwh_pipeline import config_dwh_binding
|
||||
if runtime_manifest["workspace_id"] != cfg._workspace_id or runtime_manifest["workspace_revision"] != cfg._workspace_revision:
|
||||
raise ConfigError("Identità workspace runtime non valida")
|
||||
if config_dwh_binding(cfg) != runtime_manifest["config_dwh_binding"]:
|
||||
raise ConfigError("Binding DWH runtime modificato")
|
||||
return cfg
|
||||
|
||||
|
||||
@@ -651,6 +994,49 @@ def _validate_internal_embedding_contract(raw: dict[str, Any], path: Path) -> No
|
||||
)
|
||||
|
||||
|
||||
def _normalize_qdrant_base_url(value: Any) -> str | None:
|
||||
"""Normalize a URL, returning ``None`` for untrusted raw YAML values."""
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
try:
|
||||
parsed = urlparse(value)
|
||||
hostname = (parsed.hostname or "").lower()
|
||||
port = parsed.port
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
host = f"[{hostname}]" if ":" in hostname and not hostname.startswith("[") else hostname
|
||||
netloc = f"{host}:{port}" if port is not None else host
|
||||
return f"{parsed.scheme.lower()}://{netloc}{parsed.path.rstrip('/') or '/'}"
|
||||
|
||||
|
||||
def _validate_vector_resource_consistency(raw: dict[str, Any], translated: dict[str, Any], path: Path) -> None:
|
||||
"""Reject divergent top-level and compatibility Qdrant resource views."""
|
||||
resources = raw.get("resources")
|
||||
resource = resources.get("vector") if isinstance(resources, dict) else None
|
||||
vectors = translated.get("vectors")
|
||||
if not isinstance(resource, dict) or not isinstance(vectors, dict):
|
||||
return
|
||||
if vectors.get("type") != "qdrant" or resource.get("engine") != "qdrant":
|
||||
raise ConfigError(
|
||||
f"Configurazione non valida in {path}: vectors and resources.vector must both describe qdrant"
|
||||
)
|
||||
vector_url = _normalize_qdrant_base_url(vectors.get("base_url"))
|
||||
resource_url = _normalize_qdrant_base_url(resource.get("base_url"))
|
||||
vector_collection = vectors.get("collection")
|
||||
resource_collection = resource.get("collection")
|
||||
if (
|
||||
vector_url is None
|
||||
or resource_url is None
|
||||
or not isinstance(vector_collection, str)
|
||||
or not isinstance(resource_collection, str)
|
||||
or vector_url != resource_url
|
||||
or vector_collection != resource_collection
|
||||
):
|
||||
raise ConfigError(
|
||||
f"Configurazione non valida in {path}: vectors and resources.vector disagree"
|
||||
)
|
||||
|
||||
|
||||
def _validate_internal_vector_contract(raw: dict[str, Any], path: Path) -> None:
|
||||
resources = raw.get("resources")
|
||||
if not isinstance(resources, dict):
|
||||
|
||||
@@ -45,6 +45,27 @@ class VectorStoreError(Exception):
|
||||
"""Base error exposed by vector adapters."""
|
||||
|
||||
|
||||
class SemanticIndexIncompatibleError(VectorStoreError):
|
||||
"""The configured semantic collection cannot safely serve this workspace."""
|
||||
|
||||
code = "semantic_index_incompatible"
|
||||
|
||||
def __init__(self, message: str = "Qdrant semantic index is incompatible"):
|
||||
super().__init__(f"{self.code}: {message}")
|
||||
|
||||
|
||||
class VectorTransportError(VectorStoreError):
|
||||
"""Qdrant could not be reached or returned an HTTP failure."""
|
||||
|
||||
def __init__(self, message: str, *, status_code: int | None = None):
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
class VectorResponseError(VectorStoreError):
|
||||
"""Qdrant returned a malformed response."""
|
||||
|
||||
|
||||
class VectorWriteUnavailable(VectorStoreError):
|
||||
"""Raised when a deployment has no vector writer credential."""
|
||||
|
||||
@@ -86,13 +107,16 @@ class VectorStore(Protocol):
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SemanticIndexIncompatibleError",
|
||||
"VectorCapabilities",
|
||||
"VectorHealth",
|
||||
"VectorHit",
|
||||
"VectorReadUnavailable",
|
||||
"VectorRecord",
|
||||
"VectorResponseError",
|
||||
"VectorStore",
|
||||
"VectorStoreError",
|
||||
"VectorTransportError",
|
||||
"VectorWriteRecord",
|
||||
"VectorWriteUnavailable",
|
||||
"require_positive_limit",
|
||||
|
||||
@@ -0,0 +1,710 @@
|
||||
"""Small privileged filesystem seam for durable runtime configuration publication."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def fail(msg: str) -> None:
|
||||
raise RuntimeError(msg)
|
||||
|
||||
|
||||
def _canonical_root(root: str) -> str:
|
||||
# Darwin exposes /tmp and /var as symlink aliases. Linux does not: rewriting
|
||||
# these paths there would redirect valid installations to a different root.
|
||||
if sys.platform == "darwin":
|
||||
if root == "/tmp" or root.startswith("/tmp/"):
|
||||
return "/private" + root
|
||||
if root == "/var" or root.startswith("/var/"):
|
||||
return "/private" + root
|
||||
return root
|
||||
|
||||
|
||||
def _identity(st: os.stat_result, path: str) -> dict[str, str]:
|
||||
return {"path": path, "dev": str(st.st_dev), "ino": str(st.st_ino),
|
||||
"mode": format(stat.S_IMODE(st.st_mode), "o"), "uid": str(st.st_uid)}
|
||||
|
||||
|
||||
def _rename_noreplace(src: str, dst: str, directory_fd: int, kind: str) -> None:
|
||||
"""Atomically rename *src* to *dst* without replacing an existing entry.
|
||||
|
||||
``link`` is deliberately not used here: the state-file protocol requires a
|
||||
rename, and a hard-link publication leaves a second name visible during a
|
||||
crash. Unsupported platforms fail closed rather than silently weakening the
|
||||
protocol. The env seam is intentionally narrow so fault tests can exercise
|
||||
every publication rename without monkey-patching the privileged process.
|
||||
"""
|
||||
if os.environ.get("THT_RUNTIME_CONFIG_RENAME_FAIL") in {"1", kind}:
|
||||
fail("runtime config rename failed")
|
||||
libc = ctypes.CDLL(None, use_errno=True)
|
||||
src_b = os.fsencode(src)
|
||||
dst_b = os.fsencode(dst)
|
||||
if sys.platform == "darwin":
|
||||
fn = getattr(libc, "renameatx_np", None)
|
||||
if fn is None:
|
||||
fail("runtime config no-replace rename is unavailable")
|
||||
fn.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint]
|
||||
fn.restype = ctypes.c_int
|
||||
# RENAME_EXCL is the Darwin no-overwrite operation.
|
||||
rc = fn(directory_fd, src_b, directory_fd, dst_b, 0x00000004)
|
||||
elif sys.platform.startswith("linux"):
|
||||
# renameat2(2), RENAME_NOREPLACE. syscall numbers are stable for the
|
||||
# supported Linux architectures; an unavailable syscall fails closed.
|
||||
number = {"x86_64": 316, "aarch64": 276, "arm64": 276}.get(platform.machine())
|
||||
if number is None or not hasattr(libc, "syscall"):
|
||||
fail("runtime config no-replace rename is unavailable")
|
||||
libc.syscall.argtypes = [ctypes.c_long, ctypes.c_int, ctypes.c_char_p,
|
||||
ctypes.c_int, ctypes.c_char_p, ctypes.c_uint]
|
||||
libc.syscall.restype = ctypes.c_long
|
||||
rc = libc.syscall(number, directory_fd, src_b, directory_fd, dst_b, 1)
|
||||
else:
|
||||
fail("runtime config no-replace rename is unavailable")
|
||||
if rc != 0:
|
||||
err = ctypes.get_errno()
|
||||
if err == 17:
|
||||
raise FileExistsError(err, os.strerror(err), dst)
|
||||
raise OSError(err, os.strerror(err), dst)
|
||||
|
||||
|
||||
def directory_identities(root: str, paths: list[tuple[str, int]]) -> list[dict[str, str]]:
|
||||
"""Return the ordered, no-follow identity chain bound by a publication.
|
||||
|
||||
``paths`` contains canonical absolute directory paths paired with already-open
|
||||
descriptors. Prefix components are stat'ed without following symlinks; the
|
||||
terminal workspace-owned components are additionally represented by fstat on
|
||||
the descriptors opened by ``walk``.
|
||||
"""
|
||||
canonical = _canonical_root(root)
|
||||
root_parts = [part for part in Path(canonical).parts if part not in ("", "/")]
|
||||
entries: list[dict[str, str]] = []
|
||||
current = "/"
|
||||
st = os.stat("/", follow_symlinks=False)
|
||||
entries.append(_identity(st, current))
|
||||
for part in root_parts:
|
||||
current = (current.rstrip("/") + "/" + part) if current != "/" else "/" + part
|
||||
st = os.stat(current, follow_symlinks=False)
|
||||
if stat.S_ISLNK(st.st_mode) or not stat.S_ISDIR(st.st_mode):
|
||||
fail("runtime config directory is not trusted")
|
||||
entries.append(_identity(st, current))
|
||||
for path, fd in paths:
|
||||
cpath = _canonical_root(path)
|
||||
st = os.fstat(fd)
|
||||
if not stat.S_ISDIR(st.st_mode) or stat.S_IMODE(st.st_mode) != 0o700 or st.st_uid != os.getuid():
|
||||
fail("runtime config directory is not trusted")
|
||||
# Keep one ordered entry per path. Existing prefixes are left in place.
|
||||
if not any(item["path"] == cpath for item in entries):
|
||||
entries.append(_identity(st, cpath))
|
||||
else:
|
||||
for item in entries:
|
||||
if item["path"] == cpath:
|
||||
if item["dev"] != str(st.st_dev) or item["ino"] != str(st.st_ino):
|
||||
fail("runtime config directory changed")
|
||||
break
|
||||
return entries
|
||||
|
||||
|
||||
def safe_id(v: str) -> bool:
|
||||
return bool(__import__("re").fullmatch(r"[a-z][a-z0-9-]{2,62}", v))
|
||||
|
||||
|
||||
def safe_rev(v: str) -> bool:
|
||||
return bool(__import__("re").fullmatch(r"[0-9a-f]{40}", v))
|
||||
|
||||
|
||||
def open_dir(parent: int | None, name: str, create: bool = False) -> int:
|
||||
"""Open one directory component without following a replaced entry.
|
||||
|
||||
The pre-open lstat and post-open fstat identity check is required on Darwin,
|
||||
where O_NOFOLLOW has historically been unavailable for directory openat.
|
||||
mkdir races are resolved by opening and validating the winner.
|
||||
"""
|
||||
flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | os.O_NOFOLLOW
|
||||
while True:
|
||||
try:
|
||||
entry = os.stat(name, dir_fd=parent, follow_symlinks=False)
|
||||
if stat.S_ISLNK(entry.st_mode):
|
||||
fail("runtime config directory is not trusted")
|
||||
fd = os.open(name, flags, dir_fd=parent)
|
||||
try:
|
||||
opened = os.fstat(fd)
|
||||
if (opened.st_dev != entry.st_dev or opened.st_ino != entry.st_ino
|
||||
or not stat.S_ISDIR(opened.st_mode)):
|
||||
fail("runtime config directory changed during open")
|
||||
return fd
|
||||
except BaseException:
|
||||
os.close(fd)
|
||||
raise
|
||||
except FileNotFoundError:
|
||||
if not create:
|
||||
raise
|
||||
try:
|
||||
os.mkdir(name, 0o700, dir_fd=parent)
|
||||
except FileExistsError:
|
||||
# Another publisher won creation. Re-enter the identity-checked
|
||||
# open path instead of exposing EEXIST to the caller.
|
||||
continue
|
||||
if parent is not None:
|
||||
os.fsync(parent)
|
||||
# Re-open through the same no-follow and identity checks.
|
||||
continue
|
||||
|
||||
|
||||
def checked_dir(fd: int, expected_mode: int = 0o700) -> None:
|
||||
s = os.fstat(fd)
|
||||
if (
|
||||
not stat.S_ISDIR(s.st_mode)
|
||||
or s.st_nlink < 1
|
||||
or stat.S_IMODE(s.st_mode) != expected_mode
|
||||
or s.st_uid != os.getuid()
|
||||
):
|
||||
fail("runtime config directory is not trusted")
|
||||
|
||||
|
||||
def walk(root: str, comps: list[str], create: bool = True) -> int:
|
||||
"""Open an absolute path component-by-component without following symlinks.
|
||||
|
||||
In particular, never use os.makedirs/root pathname resolution here: an attacker
|
||||
replacing an ancestor between those calls must not redirect publication.
|
||||
"""
|
||||
if not os.path.isabs(root):
|
||||
fail("data root must be absolute")
|
||||
# macOS exposes temporary directories through the conventional /var and
|
||||
# /tmp symlinks. Resolve only these OS-owned aliases; workspace-owned
|
||||
# ancestors remain component checked and are never realpath-followed.
|
||||
if sys.platform == "darwin" and (root == "/var" or root == "/tmp" or root.startswith(("/var/", "/tmp/"))):
|
||||
root = "/private" + root
|
||||
parts = [part for part in Path(root).parts if part not in ("", "/")]
|
||||
if any(part in (".", "..") or "/" in part for part in parts + comps):
|
||||
fail("unsafe path component")
|
||||
fd = os.open("/", os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
||||
try:
|
||||
all_components = [*parts, *comps]
|
||||
for index, component in enumerate(all_components):
|
||||
nxt = open_dir(fd, component, create)
|
||||
# Ancestors such as /var/folders are installation-owned and commonly
|
||||
# 0755; the trusted runtime root and every workspace child are private.
|
||||
info = os.fstat(nxt)
|
||||
if (not stat.S_ISDIR(info.st_mode) or info.st_nlink < 1
|
||||
or (index >= len(parts) - 1 and (info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) != 0o700))):
|
||||
os.close(nxt)
|
||||
fail("runtime config directory is not trusted")
|
||||
os.close(fd)
|
||||
fd = nxt
|
||||
return fd
|
||||
except BaseException:
|
||||
os.close(fd)
|
||||
raise
|
||||
|
||||
|
||||
def read_regular(fd: int, mode: int, expected: bytes | None = None) -> os.stat_result:
|
||||
s = os.fstat(fd)
|
||||
if (
|
||||
not stat.S_ISREG(s.st_mode)
|
||||
or s.st_nlink != 1
|
||||
or stat.S_IMODE(s.st_mode) != mode
|
||||
or s.st_uid != os.getuid()
|
||||
):
|
||||
fail("runtime config file is not trusted")
|
||||
if expected is not None:
|
||||
os.lseek(fd, 0, os.SEEK_SET)
|
||||
chunks = []
|
||||
while True:
|
||||
x = os.read(fd, 1024 * 1024)
|
||||
if not x:
|
||||
break
|
||||
chunks.append(x)
|
||||
if b"".join(chunks) != expected:
|
||||
fail("same-revision runtime configuration changed")
|
||||
return s
|
||||
|
||||
|
||||
def write_all(fd: int, data: bytes) -> None:
|
||||
pos = 0
|
||||
while pos < len(data):
|
||||
n = os.write(fd, data[pos:])
|
||||
if n <= 0:
|
||||
fail("short runtime config write")
|
||||
pos += n
|
||||
|
||||
|
||||
def publication_fsync(fd: int, stage: str) -> None:
|
||||
"""Fsync one publication boundary, with an explicit test-only fault seam.
|
||||
|
||||
The seam is inert unless the caller opts in with the exact stage name. It is
|
||||
intentionally kept here (rather than in TypeScript) so the real helper's
|
||||
durable ordering can be exercised without weakening production behaviour.
|
||||
"""
|
||||
if os.environ.get("THT_RUNTIME_CONFIG_FSYNC_FAIL") == stage:
|
||||
fail(f"runtime config fsync failed at {stage}")
|
||||
os.fsync(fd)
|
||||
|
||||
|
||||
def read_all(fd: int, limit: int = 16 * 1024 * 1024) -> bytes:
|
||||
if limit < 0:
|
||||
fail("runtime config file is too large")
|
||||
os.lseek(fd, 0, os.SEEK_SET)
|
||||
if limit == 0:
|
||||
if os.read(fd, 1):
|
||||
fail("runtime config file is too large")
|
||||
return b""
|
||||
chunks: list[bytes] = []
|
||||
total = 0
|
||||
while True:
|
||||
chunk = os.read(fd, min(1024 * 1024, limit - total))
|
||||
if not chunk:
|
||||
return b"".join(chunks)
|
||||
chunks.append(chunk)
|
||||
total += len(chunk)
|
||||
if total >= limit:
|
||||
# The bounded read above cannot observe an additional byte when it
|
||||
# lands exactly on the ceiling; probe once before accepting it.
|
||||
if os.read(fd, 1):
|
||||
fail("runtime config file is too large")
|
||||
return b"".join(chunks)
|
||||
|
||||
|
||||
def strict_manifest(value: object) -> dict:
|
||||
if not isinstance(value, dict):
|
||||
fail("runtime config manifest is invalid")
|
||||
required = {
|
||||
"version", "workspace_id", "workspace_revision", "descriptor_git_blob",
|
||||
"descriptor_sha256", "descriptor_dev", "descriptor_ino", "config_sha256",
|
||||
"config_dwh_binding", "config_dev", "config_ino", "config_size", "config_mode",
|
||||
"config_uid", "config_nlink", "directory_identities",
|
||||
}
|
||||
if set(value) != required or value.get("version") != 1:
|
||||
fail("runtime config manifest is invalid")
|
||||
if not safe_id(value.get("workspace_id")) or not safe_rev(value.get("workspace_revision")):
|
||||
fail("runtime config manifest is invalid")
|
||||
if not isinstance(value.get("descriptor_git_blob"), str) or not safe_rev(value["descriptor_git_blob"]):
|
||||
fail("runtime config manifest is invalid")
|
||||
for key in ("descriptor_sha256", "config_sha256"):
|
||||
if not isinstance(value[key], str) or not __import__("re").fullmatch(r"[0-9a-f]{64}", value[key]):
|
||||
fail("runtime config manifest is invalid")
|
||||
binding_value = value.get("config_dwh_binding")
|
||||
if not isinstance(binding_value, dict) or set(binding_value) != {"workspace_id", "config_fingerprint", "input_fingerprint"} or any(not isinstance(x, str) for x in binding_value.values()):
|
||||
fail("runtime config manifest is invalid")
|
||||
for key in ("descriptor_dev", "descriptor_ino", "config_dev", "config_ino", "config_size", "config_uid", "config_nlink"):
|
||||
if not isinstance(value[key], str) or not value[key].isdigit():
|
||||
fail("runtime config manifest is invalid")
|
||||
identities = value.get("directory_identities")
|
||||
if (not isinstance(identities, list) or not identities or
|
||||
any(not isinstance(item, dict) or set(item) != {"path", "dev", "ino", "mode", "uid"}
|
||||
or not isinstance(item["path"], str) or not os.path.isabs(item["path"])
|
||||
or any(not isinstance(item[key], str) or not item[key].isdigit()
|
||||
for key in ("dev", "ino", "mode", "uid"))
|
||||
or item["mode"] == "0"
|
||||
for item in identities)):
|
||||
fail("runtime config manifest is invalid")
|
||||
if len({item["path"] for item in identities}) != len(identities):
|
||||
fail("runtime config manifest is invalid")
|
||||
if value["config_mode"] != "400":
|
||||
fail("runtime config manifest is invalid")
|
||||
return value
|
||||
|
||||
|
||||
def publish(inp: dict) -> dict:
|
||||
root = inp.get("data_root")
|
||||
wid = inp.get("workspace_id")
|
||||
rev = inp.get("workspace_revision")
|
||||
if (
|
||||
not isinstance(root, str)
|
||||
or not os.path.isabs(root)
|
||||
or not safe_id(wid)
|
||||
or not safe_rev(rev)
|
||||
):
|
||||
fail("invalid publication identity")
|
||||
try:
|
||||
content = bytes.fromhex(inp["config_hex"])
|
||||
except (TypeError, ValueError):
|
||||
fail("invalid config bytes")
|
||||
base = inp.get("manifest_base")
|
||||
if not isinstance(base, dict):
|
||||
fail("invalid manifest")
|
||||
if base.get("workspace_id") != wid or base.get("workspace_revision") != rev:
|
||||
fail("manifest identity mismatch")
|
||||
sessions = walk(root, ["sessions"], True)
|
||||
ws = open_dir(sessions, wid, True)
|
||||
checked_dir(ws)
|
||||
prep = open_dir(ws, "preprocessing", True)
|
||||
checked_dir(prep)
|
||||
cfgdir = open_dir(prep, "runtime-config", True)
|
||||
checked_dir(cfgdir)
|
||||
mandir = open_dir(prep, "runtime-config-manifests", True)
|
||||
checked_dir(mandir)
|
||||
canonical = _canonical_root(root)
|
||||
directory_manifest = directory_identities(root, [
|
||||
(f"{canonical}/sessions", sessions),
|
||||
(f"{canonical}/sessions/{wid}", ws),
|
||||
(f"{canonical}/sessions/{wid}/preprocessing", prep),
|
||||
(f"{canonical}/sessions/{wid}/preprocessing/runtime-config", cfgdir),
|
||||
(f"{canonical}/sessions/{wid}/preprocessing/runtime-config-manifests", mandir),
|
||||
])
|
||||
# The retained preprocessing directory is the single cross-process lock seam.
|
||||
# No pathname lock file is created in the workspace layout.
|
||||
deadline = time.monotonic() + 2.0
|
||||
while True:
|
||||
try:
|
||||
fcntl.flock(prep, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
break
|
||||
except BlockingIOError:
|
||||
if time.monotonic() >= deadline:
|
||||
# Never let a wedged publisher block its caller indefinitely. The
|
||||
# Node boundary turns this stable conflict into a bounded failure.
|
||||
fail("runtime config publication is busy")
|
||||
time.sleep(0.01)
|
||||
try:
|
||||
name = f"{rev}.yaml"
|
||||
mname = f"{rev}.json"
|
||||
|
||||
def current(dfd, n, mode):
|
||||
try:
|
||||
fd = os.open(n, os.O_RDONLY | os.O_NOFOLLOW, dir_fd=dfd)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
try:
|
||||
return (fd, read_regular(fd, mode))
|
||||
except:
|
||||
os.close(fd)
|
||||
raise
|
||||
|
||||
got = current(cfgdir, name, 0o400)
|
||||
if got:
|
||||
fd, s = got
|
||||
os.lseek(fd, 0, os.SEEK_SET)
|
||||
old = read_all(fd)
|
||||
os.close(fd)
|
||||
if old != content:
|
||||
fail("same-revision runtime configuration changed")
|
||||
else:
|
||||
stage = f".{name}.staging-{os.getpid()}-{os.urandom(8).hex()}"
|
||||
fd = os.open(
|
||||
stage, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=cfgdir
|
||||
)
|
||||
try:
|
||||
write_all(fd, content)
|
||||
os.fchmod(fd, 0o400)
|
||||
publication_fsync(fd, "config-file")
|
||||
try:
|
||||
_rename_noreplace(stage, name, cfgdir, "config")
|
||||
except FileExistsError:
|
||||
# A concurrent equal publisher may already have won. It is
|
||||
# accepted only after reopening and comparing its bytes below.
|
||||
pass
|
||||
finally:
|
||||
os.close(fd)
|
||||
try:
|
||||
os.unlink(stage, dir_fd=cfgdir)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
got = current(cfgdir, name, 0o400)
|
||||
if not got:
|
||||
fail("runtime config publication failed")
|
||||
fd, s = got
|
||||
try:
|
||||
os.lseek(fd, 0, os.SEEK_SET)
|
||||
if read_all(fd) != content:
|
||||
fail("same-revision runtime configuration changed")
|
||||
finally:
|
||||
os.close(fd)
|
||||
publication_fsync(cfgdir, "config-parent")
|
||||
# A prior invocation may have reported success after publishing the
|
||||
# entry but before its parent fsync. Re-establish that durability
|
||||
# boundary before making the manifest durable.
|
||||
publication_fsync(cfgdir, "config-parent")
|
||||
# Identity is deliberately recorded after final no-replace publication.
|
||||
got = current(cfgdir, name, 0o400)
|
||||
assert got
|
||||
fd, s = got
|
||||
os.close(fd)
|
||||
manifest = {"version": 1, **dict(base)}
|
||||
manifest.update(
|
||||
{
|
||||
"config_sha256": hashlib.sha256(content).hexdigest(),
|
||||
"config_dev": str(s.st_dev),
|
||||
"config_ino": str(s.st_ino),
|
||||
"config_size": str(s.st_size),
|
||||
"config_mode": format(stat.S_IMODE(s.st_mode), "o"),
|
||||
"config_uid": str(s.st_uid),
|
||||
"config_nlink": str(s.st_nlink),
|
||||
"directory_identities": directory_manifest,
|
||||
}
|
||||
)
|
||||
strict_manifest(manifest)
|
||||
mb = (json.dumps(manifest, sort_keys=True, separators=(",", ":")) + "\n").encode()
|
||||
oldm = current(mandir, mname, 0o600)
|
||||
if oldm:
|
||||
mfd, _ = oldm
|
||||
os.lseek(mfd, 0, os.SEEK_SET)
|
||||
existing = read_all(mfd)
|
||||
os.close(mfd)
|
||||
try: strict_manifest(json.loads(existing.decode()))
|
||||
except (ValueError, TypeError, UnicodeError, RuntimeError): fail("runtime config manifest is invalid")
|
||||
if existing != mb:
|
||||
fail("same-revision runtime configuration changed")
|
||||
else:
|
||||
stage = f".{mname}.staging-{os.getpid()}-{os.urandom(8).hex()}"
|
||||
fd = os.open(
|
||||
stage, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=mandir
|
||||
)
|
||||
try:
|
||||
write_all(fd, mb)
|
||||
os.fchmod(fd, 0o600)
|
||||
publication_fsync(fd, "manifest-file")
|
||||
try:
|
||||
_rename_noreplace(stage, mname, mandir, "manifest")
|
||||
except FileExistsError:
|
||||
# A no-replace loser is successful only after validating the
|
||||
# durable winner byte-for-byte and against the strict schema.
|
||||
winner = current(mandir, mname, 0o600)
|
||||
if winner is None:
|
||||
fail("runtime config manifest publication raced")
|
||||
wfd, _ = winner
|
||||
try:
|
||||
existing = read_all(wfd)
|
||||
finally:
|
||||
os.close(wfd)
|
||||
try:
|
||||
strict_manifest(json.loads(existing.decode()))
|
||||
except (ValueError, TypeError, UnicodeError, RuntimeError):
|
||||
fail("runtime config manifest is invalid")
|
||||
if existing != mb:
|
||||
fail("same-revision runtime configuration changed")
|
||||
finally:
|
||||
os.close(fd)
|
||||
try:
|
||||
os.unlink(stage, dir_fd=mandir)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
publication_fsync(mandir, "manifest-parent")
|
||||
# As with the config directory, retries must repair a boundary that
|
||||
# failed after the no-replace publication on an earlier invocation.
|
||||
publication_fsync(mandir, "manifest-parent")
|
||||
return {
|
||||
"protocol_version": 1,
|
||||
"kind": "publication",
|
||||
"workspace_id": wid,
|
||||
"workspace_revision": rev,
|
||||
"path": f"{canonical}/sessions/{wid}/preprocessing/runtime-config/{name}",
|
||||
"manifestPath": f"{canonical}/sessions/{wid}/preprocessing/runtime-config-manifests/{mname}",
|
||||
"manifest": mb.decode(),
|
||||
"manifest_sha256": hashlib.sha256(mb).hexdigest(),
|
||||
"dev": s.st_dev,
|
||||
"ino": s.st_ino,
|
||||
}
|
||||
finally:
|
||||
os.close(cfgdir)
|
||||
os.close(mandir)
|
||||
os.close(prep)
|
||||
os.close(ws)
|
||||
os.close(sessions)
|
||||
|
||||
|
||||
def verified_snapshot(inp: dict) -> dict:
|
||||
root = inp.get("snapshots_root")
|
||||
rev = inp.get("workspace_revision")
|
||||
wid = inp.get("workspace_id")
|
||||
if (
|
||||
not isinstance(root, str)
|
||||
or not os.path.isabs(root)
|
||||
or not safe_rev(rev)
|
||||
or not safe_id(wid)
|
||||
):
|
||||
fail("invalid snapshot identity")
|
||||
# Component-relative no-follow traversal all the way to the retained descriptor.
|
||||
sroot = walk(root, [], False)
|
||||
rdir = open_dir(sroot, rev, False)
|
||||
checked_dir(rdir)
|
||||
fd = os.open(f"{wid}.yaml", os.O_RDONLY | os.O_NOFOLLOW, dir_fd=rdir)
|
||||
try:
|
||||
descriptor_info = read_regular(fd, 0o400)
|
||||
chunks = []
|
||||
while True:
|
||||
x = os.read(fd, 1024 * 1024)
|
||||
if not x:
|
||||
break
|
||||
chunks.append(x)
|
||||
source = b"".join(chunks)
|
||||
finally:
|
||||
os.close(fd)
|
||||
mf = os.open("snapshot.json", os.O_RDONLY | os.O_NOFOLLOW, dir_fd=rdir)
|
||||
try:
|
||||
read_regular(mf, 0o400)
|
||||
payload = b""
|
||||
while True:
|
||||
x = os.read(mf, 1024 * 1024)
|
||||
if not x:
|
||||
break
|
||||
payload += x
|
||||
finally:
|
||||
os.close(mf)
|
||||
try:
|
||||
manifest = json.loads(payload.decode())
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
fail("workspace snapshot integrity check failed")
|
||||
if not isinstance(manifest, dict) or set(manifest) != {"head", "revisions", "files"}:
|
||||
fail("workspace snapshot integrity check failed")
|
||||
records = manifest.get("revisions")
|
||||
files = manifest.get("files")
|
||||
if not isinstance(records, list) or not isinstance(files, dict) or not records:
|
||||
fail("workspace snapshot integrity check failed")
|
||||
record_by_id: dict[str, dict] = {}
|
||||
for item in records:
|
||||
if not isinstance(item, dict) or set(item) != {"id", "commit", "blob", "snapshotPath"}:
|
||||
fail("workspace snapshot integrity check failed")
|
||||
item_id = item.get("id")
|
||||
if not isinstance(item_id, str) or not safe_id(item_id) or item_id in record_by_id:
|
||||
fail("workspace snapshot integrity check failed")
|
||||
if item.get("commit") != rev or item.get("snapshotPath") != f"{root}/{rev}/{item_id}.yaml":
|
||||
fail("workspace snapshot integrity check failed")
|
||||
if not isinstance(item.get("blob"), str) or not safe_rev(item["blob"]):
|
||||
fail("workspace snapshot integrity check failed")
|
||||
record_by_id[item_id] = item
|
||||
expected_names = {name for item_id in record_by_id for name in (f"{item_id}.yaml", f"{item_id}.env.example", f"{item_id}.md")}
|
||||
if set(files) != expected_names or any(not isinstance(v, str) or not __import__("re").fullmatch(r"[0-9a-f]{64}", v) for v in files.values()):
|
||||
fail("workspace snapshot integrity check failed")
|
||||
# Verify every immutable file declared by snapshot.json, not just the selected
|
||||
# descriptor. This prevents extra records/files from smuggling a second state.
|
||||
for filename in sorted(expected_names):
|
||||
f = os.open(filename, os.O_RDONLY | os.O_NOFOLLOW, dir_fd=rdir)
|
||||
try:
|
||||
read_regular(f, 0o400)
|
||||
actual = hashlib.sha256(read_all(f)).hexdigest()
|
||||
finally:
|
||||
os.close(f)
|
||||
if actual != files[filename]:
|
||||
fail("workspace snapshot integrity check failed")
|
||||
record = record_by_id.get(wid)
|
||||
expected_path = f"{root}/{rev}/{wid}.yaml"
|
||||
if record is None or manifest.get("head") != rev or record.get("snapshotPath") != expected_path:
|
||||
fail("workspace snapshot integrity check failed")
|
||||
if files.get(f"{wid}.yaml") != hashlib.sha256(source).hexdigest():
|
||||
fail("workspace snapshot integrity check failed")
|
||||
repo = inp.get("repository_root")
|
||||
if not isinstance(repo, str) or not os.path.isabs(repo):
|
||||
fail("invalid repository root")
|
||||
# Git replacement refs and ambient repository/config variables are attacker
|
||||
# controlled process state. Snapshot identity must be the raw object named by
|
||||
# the commit, with fixed Git configuration and repository boundaries.
|
||||
# Keep no inherited GIT_* controls at all (including GIT_CONFIG_PARAMETERS,
|
||||
# alternates, and repository path overrides), then add only fixed semantics.
|
||||
git_env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")}
|
||||
git_env.update({"GIT_NO_REPLACE_OBJECTS": "1", "GIT_CONFIG_NOSYSTEM": "1",
|
||||
"GIT_CONFIG_GLOBAL": os.devnull, "GIT_CONFIG_SYSTEM": os.devnull})
|
||||
try:
|
||||
# A 40-hex object name is not necessarily a commit (trees and blobs are
|
||||
# valid Git objects and also accept the <object>:path syntax). Require
|
||||
# the raw object itself to be a commit, with replacement/config controls
|
||||
# disabled, before reading any descriptor bytes.
|
||||
object_type = subprocess.check_output(
|
||||
["git", "--no-replace-objects", "-C", repo, "cat-file", "-t", rev],
|
||||
stderr=subprocess.DEVNULL, text=True, timeout=5, env=git_env,
|
||||
).strip()
|
||||
resolved_commit = subprocess.check_output(
|
||||
["git", "--no-replace-objects", "-C", repo, "rev-parse", f"{rev}^{{commit}}"],
|
||||
stderr=subprocess.DEVNULL, text=True, timeout=5, env=git_env,
|
||||
).strip()
|
||||
if object_type != "commit" or resolved_commit != rev:
|
||||
fail("workspace Git revision is not an exact commit")
|
||||
blob = subprocess.check_output(
|
||||
["git", "--no-replace-objects", "-C", repo, "rev-parse", f"{rev}:workspaces/{wid}.yaml"],
|
||||
stderr=subprocess.DEVNULL, text=True, timeout=5, env=git_env,
|
||||
).strip()
|
||||
git_source = subprocess.check_output(
|
||||
["git", "--no-replace-objects", "-C", repo, "show", f"{rev}:workspaces/{wid}.yaml"],
|
||||
stderr=subprocess.DEVNULL, timeout=5, env=git_env,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
fail("workspace Git revision is unavailable")
|
||||
try:
|
||||
git_text = git_source.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
fail("workspace Git descriptor identity mismatch")
|
||||
if blob != record.get("blob"):
|
||||
fail("workspace Git descriptor identity mismatch")
|
||||
return {
|
||||
"protocol_version": 1,
|
||||
"kind": "verified_snapshot",
|
||||
"workspace_id": wid,
|
||||
"workspace_revision": rev,
|
||||
"source": source.decode(),
|
||||
"git_source": git_text,
|
||||
"sha256": hashlib.sha256(source).hexdigest(),
|
||||
"descriptor_git_blob": record.get("blob"),
|
||||
"descriptor_dev": descriptor_info.st_dev,
|
||||
"descriptor_ino": descriptor_info.st_ino,
|
||||
"snapshot_path": f"{root}/{rev}/{wid}.yaml",
|
||||
}
|
||||
|
||||
|
||||
def binding(inp: dict) -> dict:
|
||||
# Runtime handoff variables are capabilities, never helper input. Remove
|
||||
# inherited values before load_config can inspect its environment.
|
||||
for key in tuple(os.environ):
|
||||
if key.startswith(("THT_RUNTIME_CONFIG_", "THT_CONFIG_")):
|
||||
os.environ.pop(key, None)
|
||||
try:
|
||||
raw = bytes.fromhex(inp["config_hex"])
|
||||
except (TypeError, ValueError):
|
||||
fail("invalid config bytes")
|
||||
# Use the harness' own Pydantic loader and config_dwh_binding; this is intentionally
|
||||
# not a TypeScript reimplementation of its normalization/fingerprinting rules.
|
||||
from tht.config import load_config
|
||||
from tht.jobs.dwh_pipeline import config_dwh_binding
|
||||
|
||||
with tempfile.NamedTemporaryFile(
|
||||
prefix="runtime-binding-", suffix=".yaml", delete=False
|
||||
) as stream:
|
||||
stream.write(raw)
|
||||
path = Path(stream.name)
|
||||
try:
|
||||
result = config_dwh_binding(load_config(path))
|
||||
if not isinstance(result, dict) or set(result) != {"workspace_id", "config_fingerprint", "input_fingerprint"}:
|
||||
fail("runtime config binding returned malformed output")
|
||||
return result
|
||||
finally:
|
||||
try:
|
||||
path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def main() -> None:
|
||||
try:
|
||||
inp = json.load(sys.stdin)
|
||||
if not isinstance(inp, dict) or inp.get("protocol_version") != 1:
|
||||
fail("unsupported runtime config protocol")
|
||||
action = inp.get("action")
|
||||
request_keys = {
|
||||
"publish": {"protocol_version", "action", "data_root", "workspace_id", "workspace_revision", "config_hex", "manifest_base"},
|
||||
"verified-snapshot": {"protocol_version", "action", "snapshots_root", "repository_root", "workspace_revision", "workspace_id"},
|
||||
"binding": {"protocol_version", "action", "config_hex"},
|
||||
}
|
||||
if action not in request_keys or set(inp) != request_keys[action]:
|
||||
fail("invalid runtime config request")
|
||||
if action == "publish":
|
||||
result = publish(inp)
|
||||
elif action == "verified-snapshot":
|
||||
result = verified_snapshot(inp)
|
||||
else:
|
||||
result = binding(inp)
|
||||
result = {"protocol_version": 1, "kind": "binding", **result}
|
||||
print(json.dumps(result))
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(json.dumps({"error": str(e)}))
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Fail-closed verifier for the backend-owned writer capability.
|
||||
|
||||
FD 3 is the inherited writer open file description and FD 4 is the retained
|
||||
workspace-root directory. Environment values are descriptive identity only;
|
||||
they never authorize a direct invocation.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import fcntl
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
class WorkspaceWriterConflict(RuntimeError):
|
||||
"preprocessing_conflict"
|
||||
|
||||
def __init__(self, message: str = "preprocessing_conflict") -> None:
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkspaceCapability:
|
||||
workspace_id: str
|
||||
revision: str
|
||||
device: int
|
||||
inode: int
|
||||
writer_device: int
|
||||
writer_inode: int
|
||||
|
||||
|
||||
def _identity(env: dict[str, str]) -> tuple[str, str, int, int]:
|
||||
wid, rev = env.get("THOTH_WORKSPACE_ID"), env.get("THOTH_WORKSPACE_REVISION")
|
||||
if not wid or not rev or not re.fullmatch(r"[a-z][a-z0-9-]{2,62}", wid) or not re.fullmatch(r"[0-9a-f]{40}", rev):
|
||||
raise WorkspaceWriterConflict()
|
||||
try:
|
||||
device, inode = int(env["THOTH_WORKSPACE_DEVICE"]), int(env["THOTH_WORKSPACE_INODE"])
|
||||
except (KeyError, ValueError):
|
||||
raise WorkspaceWriterConflict() from None
|
||||
if device < 0 or inode <= 0:
|
||||
raise WorkspaceWriterConflict()
|
||||
return wid, rev, device, inode
|
||||
|
||||
|
||||
def _fstat(fd: int) -> os.stat_result:
|
||||
try:
|
||||
return os.fstat(fd)
|
||||
except OSError:
|
||||
raise WorkspaceWriterConflict() from None
|
||||
|
||||
|
||||
def _open_lock(root_fd: int) -> int:
|
||||
# The lock is opened relative to the retained root and cannot be substituted
|
||||
# by a symlink between validation and open. No path fallback is permitted.
|
||||
try:
|
||||
return os.open("writer.lock", os.O_RDWR | os.O_NOFOLLOW | os.O_CLOEXEC, dir_fd=root_fd)
|
||||
except OSError:
|
||||
raise WorkspaceWriterConflict() from None
|
||||
|
||||
|
||||
def verify_workspace_writer_fds(*, writer_fd: int = 3, root_fd: int = 4, env: dict[str, str] | None = None) -> WorkspaceCapability:
|
||||
env = dict(os.environ if env is None else env)
|
||||
wid, rev, device, inode = _identity(env)
|
||||
if writer_fd == root_fd or writer_fd < 0 or root_fd < 0:
|
||||
raise WorkspaceWriterConflict()
|
||||
root, writer = _fstat(root_fd), _fstat(writer_fd)
|
||||
uid = os.getuid()
|
||||
if not stat.S_ISDIR(root.st_mode) or root.st_uid != uid or (root.st_mode & 0o777) != 0o700 or (root.st_dev, root.st_ino) != (device, inode):
|
||||
raise WorkspaceWriterConflict()
|
||||
if not stat.S_ISREG(writer.st_mode) or writer.st_uid != uid or (writer.st_mode & 0o777) != 0o600 or writer.st_nlink != 1:
|
||||
raise WorkspaceWriterConflict()
|
||||
lock_fd = _open_lock(root_fd)
|
||||
try:
|
||||
lock = _fstat(lock_fd)
|
||||
if (lock.st_dev, lock.st_ino) != (writer.st_dev, writer.st_ino) or not stat.S_ISREG(lock.st_mode) or lock.st_uid != uid or (lock.st_mode & 0o777) != 0o600 or lock.st_nlink != 1:
|
||||
raise WorkspaceWriterConflict()
|
||||
# First prove that an independently opened description cannot acquire the
|
||||
# lock. Then probe the inherited description itself. flock is an
|
||||
# open-file-description lock: the second call succeeds only on the same
|
||||
# description held by the backend and does not release it.
|
||||
try:
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except OSError as exc:
|
||||
if exc.errno not in (errno.EACCES, errno.EAGAIN, errno.EWOULDBLOCK):
|
||||
raise WorkspaceWriterConflict() from exc
|
||||
else:
|
||||
try:
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
||||
except OSError:
|
||||
pass
|
||||
raise WorkspaceWriterConflict()
|
||||
try:
|
||||
fcntl.flock(writer_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except OSError as exc:
|
||||
raise WorkspaceWriterConflict() from exc
|
||||
finally:
|
||||
try:
|
||||
os.close(lock_fd)
|
||||
except OSError:
|
||||
pass
|
||||
return WorkspaceCapability(wid, rev, root.st_dev, root.st_ino, writer.st_dev, writer.st_ino)
|
||||
|
||||
|
||||
def require_workspace_writer_capability(*, workspace_id: str | None = None, revision: str | None = None) -> WorkspaceCapability:
|
||||
cap = verify_workspace_writer_fds()
|
||||
if workspace_id is not None and cap.workspace_id != workspace_id:
|
||||
raise WorkspaceWriterConflict()
|
||||
if revision is not None and cap.revision != revision:
|
||||
raise WorkspaceWriterConflict()
|
||||
return cap
|
||||
@@ -214,7 +214,7 @@ cat >"$project_state_positive" <<'EOF'
|
||||
|
||||
## Internal Qdrant + Ollama semantic infrastructure — LIVE 2026-08-08
|
||||
|
||||
- Schema-v3 descriptors are operational and v1/v2 remain `migration_required`.
|
||||
- Schema v3 is the only accepted workspace descriptor format. Schema v1 and v2 descriptors are rejected before activation or diagnostics.
|
||||
- One workspace owns one Qdrant collection.
|
||||
- Only DWH and LLM remain external runtime application endpoints.
|
||||
- The internal stack includes `qdrant`, `embedding`, and `embedding-model-init`.
|
||||
@@ -223,10 +223,146 @@ cat >"$project_state_positive" <<'EOF'
|
||||
|
||||
### Historical snapshot — previous deployment
|
||||
|
||||
- Older notes intentionally live only here.
|
||||
- Schema-v2 descriptors were operational and returned `migration_required`.
|
||||
- Operators used `migrate-legacy` in this superseded workflow.
|
||||
EOF
|
||||
verify_project_state_current_contract "$project_state_positive" positive-project-state >/dev/null
|
||||
|
||||
project_state_unrelated_migration="$negative_root/project-state-unrelated-migration.md"
|
||||
python3 - "$project_state_positive" "$project_state_unrelated_migration" <<'PY'
|
||||
import pathlib, sys
|
||||
source = pathlib.Path(sys.argv[1]).read_text()
|
||||
old = "rejected before activation or diagnostics.\n- One workspace"
|
||||
new = (
|
||||
"rejected before activation or diagnostics. The unrelated session database\n"
|
||||
" upgrade may report `migration_required`.\n- One workspace"
|
||||
)
|
||||
if source.count(old) != 1:
|
||||
raise SystemExit("PROJECT_STATE positive fixture insertion point not found")
|
||||
pathlib.Path(sys.argv[2]).write_text(source.replace(old, new, 1))
|
||||
PY
|
||||
verify_project_state_current_contract "$project_state_unrelated_migration" \
|
||||
unrelated-current-project-state >/dev/null
|
||||
|
||||
generic_unrelated_migration="$negative_root/generic-unrelated-migration.md"
|
||||
python3 - "$root/docs/install/local-workspace-registry.md" "$generic_unrelated_migration" <<'PY'
|
||||
import pathlib, sys
|
||||
source = pathlib.Path(sys.argv[1]).read_text()
|
||||
old = "isolated by payload `kind`.\n\n## Semantic index ownership contract"
|
||||
new = (
|
||||
"isolated by payload `kind`. The unrelated session database\n"
|
||||
"upgrade may report `migration_required`.\n\n## Semantic index ownership contract"
|
||||
)
|
||||
if source.count(old) != 1:
|
||||
raise SystemExit("manual positive fixture insertion point not found")
|
||||
pathlib.Path(sys.argv[2]).write_text(source.replace(old, new, 1))
|
||||
PY
|
||||
verify_workspace_descriptor_doc_contract "$generic_unrelated_migration" \
|
||||
unrelated-current-manual >/dev/null
|
||||
|
||||
assert_schema_contract_rejected() {
|
||||
local source="$1" label="$2" expected="$3" output="$negative_root/schema-contract-output"
|
||||
set +e
|
||||
verify_workspace_descriptor_doc_contract "$source" "$label" >"$output" 2>&1
|
||||
local status=$?
|
||||
set -e
|
||||
if [[ $status -eq 0 ]] || ! grep -Fq "$expected" "$output"; then
|
||||
echo "$label fixture was not rejected correctly" >&2
|
||||
cat "$output" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
project_state_migration_required="$negative_root/project-state-migration-required.md"
|
||||
python3 - "$project_state_positive" "$project_state_migration_required" <<'PY'
|
||||
import pathlib, sys
|
||||
source = pathlib.Path(sys.argv[1]).read_text()
|
||||
marker = source.index("## Historical snapshots")
|
||||
pathlib.Path(sys.argv[2]).write_text(
|
||||
source[:marker]
|
||||
+ "- Schema v1 and v2 descriptors remain\n"
|
||||
+ " `migration_required`.\n\n"
|
||||
+ source[marker:]
|
||||
)
|
||||
PY
|
||||
set +e
|
||||
verify_project_state_current_contract "$project_state_migration_required" current-migration-required >"$project_state_output" 2>&1
|
||||
project_state_status=$?
|
||||
set -e
|
||||
if [[ $project_state_status -eq 0 ]] || ! grep -Fq "migration_required" "$project_state_output"; then
|
||||
echo "current PROJECT_STATE migration_required fixture was not rejected correctly" >&2
|
||||
cat "$project_state_output" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
project_state_readable="$negative_root/project-state-readable.md"
|
||||
python3 - "$project_state_positive" "$project_state_readable" <<'PY'
|
||||
import pathlib, sys
|
||||
source = pathlib.Path(sys.argv[1]).read_text()
|
||||
source = source.replace(
|
||||
"Schema v1 and v2 descriptors are rejected before activation or diagnostics.",
|
||||
"Schema v1 and v2 descriptors remain readable for operational use.",
|
||||
1,
|
||||
)
|
||||
pathlib.Path(sys.argv[2]).write_text(source)
|
||||
PY
|
||||
set +e
|
||||
verify_project_state_current_contract "$project_state_readable" current-readable-v1-v2 >"$project_state_output" 2>&1
|
||||
project_state_status=$?
|
||||
set -e
|
||||
if [[ $project_state_status -eq 0 ]] || ! grep -Eq "rejected|readable|operational" "$project_state_output"; then
|
||||
echo "current PROJECT_STATE v1/v2 readability fixture was not rejected correctly" >&2
|
||||
cat "$project_state_output" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
manual_migration_required="$negative_root/manual-migration-required.md"
|
||||
cp "$root/docs/install/local-workspace-registry.md" "$manual_migration_required"
|
||||
printf '\nSchema v1 and v2 descriptors remain\n`migration_required`.\n' \
|
||||
>>"$manual_migration_required"
|
||||
assert_schema_contract_rejected "$manual_migration_required" manual-migration-required "migration_required"
|
||||
|
||||
manual_migrate_legacy="$negative_root/manual-migrate-legacy.md"
|
||||
cp "$root/docs/install/local-workspace-registry.md" "$manual_migrate_legacy"
|
||||
printf '\nRun `node backend/dist/workspaces/migrate-legacy.js` before activation.\n' >>"$manual_migrate_legacy"
|
||||
assert_schema_contract_rejected "$manual_migrate_legacy" manual-migrate-legacy "migrate-legacy"
|
||||
|
||||
manual_legacy_procedure="$negative_root/manual-legacy-procedure.md"
|
||||
cp "$root/docs/install/server-workspace-registry.md" "$manual_legacy_procedure"
|
||||
printf '\nMigrate legacy descriptors in a temporary review clone before activation.\n' >>"$manual_legacy_procedure"
|
||||
assert_schema_contract_rejected "$manual_legacy_procedure" manual-legacy-procedure "legacy descriptor procedure"
|
||||
|
||||
manual_missing_v3_only="$negative_root/manual-missing-v3-only.md"
|
||||
python3 - "$root/docs/install/local-workspace-registry.md" "$manual_missing_v3_only" <<'PY'
|
||||
import pathlib, re, sys
|
||||
text = pathlib.Path(sys.argv[1]).read_text()
|
||||
text = re.sub(
|
||||
r"Schema v3 is the only accepted workspace descriptor format\.",
|
||||
"Schema v3 is accepted as a workspace descriptor format.",
|
||||
text,
|
||||
count=1,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
pathlib.Path(sys.argv[2]).write_text(text)
|
||||
PY
|
||||
assert_schema_contract_rejected "$manual_missing_v3_only" manual-missing-v3-only "v3-only acceptance"
|
||||
|
||||
diagnostics_readable="$negative_root/diagnostics-readable-v1-v2.md"
|
||||
python3 - "$root/docs/workspace-diagnostic-protocol.md" "$diagnostics_readable" <<'PY'
|
||||
import pathlib, re, sys
|
||||
text = pathlib.Path(sys.argv[1]).read_text()
|
||||
text, count = re.subn(
|
||||
r"Schema v1 and v2 descriptors are\s+rejected before diagnostics run\.",
|
||||
"Schema v1 and v2 descriptors remain readable for operational diagnostics.",
|
||||
text,
|
||||
count=1,
|
||||
)
|
||||
if count != 1:
|
||||
raise SystemExit("diagnostic rejection sentence not found")
|
||||
pathlib.Path(sys.argv[2]).write_text(text)
|
||||
PY
|
||||
assert_schema_contract_rejected "$diagnostics_readable" diagnostics-readable-v1-v2 "v1/v2 rejection"
|
||||
|
||||
local_manual_paraphrase="$negative_root/local-manual-paraphrase.md"
|
||||
cp "$root/docs/install/local-workspace-registry.md" "$local_manual_paraphrase"
|
||||
python3 - "$local_manual_paraphrase" <<'PY'
|
||||
|
||||
@@ -105,6 +105,109 @@ for token in tokens:
|
||||
PY
|
||||
}
|
||||
|
||||
verify_descriptor_migration_required_context() {
|
||||
local source="$1" label="$2" scope="${3:-all}"
|
||||
python3 - "$source" "$label" "$scope" <<'PY'
|
||||
import pathlib, re, sys
|
||||
|
||||
text = pathlib.Path(sys.argv[1]).read_text()
|
||||
label = sys.argv[2]
|
||||
scope = sys.argv[3]
|
||||
if scope == "current":
|
||||
marker = re.search(r"^## Historical snapshots\b", text, re.MULTILINE)
|
||||
if not marker:
|
||||
raise SystemExit(f"{label}: missing Historical snapshots boundary")
|
||||
text = text[:marker.start()]
|
||||
elif scope != "all":
|
||||
raise SystemExit(f"{label}: invalid migration_required verifier scope: {scope}")
|
||||
|
||||
blocks = []
|
||||
current = []
|
||||
|
||||
def flush():
|
||||
if current:
|
||||
blocks.append(" ".join(current))
|
||||
current.clear()
|
||||
|
||||
boundary = re.compile(r"^(?:#{1,6}\s+|[-*+]\s+|\d+[.)]\s+|>\s+|```|\|)")
|
||||
for raw_line in text.splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
flush()
|
||||
continue
|
||||
if boundary.match(line):
|
||||
flush()
|
||||
line = re.sub(r"^(?:#{1,6}\s+|[-*+]\s+|\d+[.)]\s+|>\s+)", "", line)
|
||||
current.append(line)
|
||||
flush()
|
||||
|
||||
migration_context = re.compile(
|
||||
r"(?:\bworkspace(?:\s+[a-z0-9_-]+){0,3}\s+descriptors?\b|"
|
||||
r"\bschema(?:[- ]?v?|\s+version\s*)[12]\b|"
|
||||
r"\bv1\s*(?:/|and|or)\s*v2\b)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
for block in blocks:
|
||||
normalized = re.sub(r"\s+", " ", block).strip()
|
||||
clauses = (part.strip() for part in re.split(r"(?<=[.!?;])\s+", normalized))
|
||||
for clause in clauses:
|
||||
if "migration_required" in clause.lower() and migration_context.search(clause):
|
||||
raise SystemExit(f"{label}: contains forbidden descriptor migration_required support")
|
||||
PY
|
||||
}
|
||||
|
||||
verify_workspace_descriptor_doc_contract() {
|
||||
local source="$1" label="$2"
|
||||
verify_descriptor_migration_required_context "$source" "$label" || return 1
|
||||
python3 - "$source" "$label" <<'PY'
|
||||
import pathlib, re, sys
|
||||
|
||||
text = pathlib.Path(sys.argv[1]).read_text()
|
||||
label = sys.argv[2]
|
||||
|
||||
if not re.search(
|
||||
r"schema[- ]v?3\s+is\s+the\s+only\s+accepted\s+workspace\s+descriptor",
|
||||
text,
|
||||
re.IGNORECASE,
|
||||
):
|
||||
raise SystemExit(f"{label}: missing explicit v3-only acceptance contract")
|
||||
|
||||
legacy_subject = (
|
||||
r"(?:schema[- ]v?1\s*(?:/|and|or)\s*(?:schema[- ]?)?v?2|"
|
||||
r"schema[- ]v?1/v2|v1/v2)\s+descriptors?"
|
||||
)
|
||||
if not re.search(
|
||||
legacy_subject + r".{0,100}\brejected\b",
|
||||
text,
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
):
|
||||
raise SystemExit(f"{label}: missing explicit v1/v2 rejection contract")
|
||||
|
||||
forbidden_literals = {
|
||||
"migrate-legacy": "migrate-legacy",
|
||||
"legacy transformer": "deleted legacy transformer instruction",
|
||||
"backend/dist/workspaces/migrate-legacy.js": "deleted transformer path",
|
||||
}
|
||||
for token, description in forbidden_literals.items():
|
||||
if token in text.lower():
|
||||
raise SystemExit(f"{label}: contains forbidden {description}")
|
||||
if re.search(
|
||||
r"(?:\bmigrat(?:e|ing)\s+legacy\s+descriptors?\b|\blegacy\s+descriptor\s+migration\b)",
|
||||
text,
|
||||
re.IGNORECASE,
|
||||
):
|
||||
raise SystemExit(f"{label}: contains forbidden legacy descriptor procedure")
|
||||
|
||||
legacy_support = [
|
||||
legacy_subject + r".{0,100}\b(?:remain|are|stay)\b.{0,40}\b(?:readable|operational|supported|accepted)\b",
|
||||
r"schema[- ]v?[12]\s+descriptors?.{0,100}\b(?:remain|are|stay)\b.{0,40}\b(?:readable|operational|supported|accepted)\b",
|
||||
]
|
||||
for pattern in legacy_support:
|
||||
if re.search(pattern, text, re.IGNORECASE | re.DOTALL):
|
||||
raise SystemExit(f"{label}: v1/v2 rejection contradicted by readable or operational support")
|
||||
PY
|
||||
}
|
||||
|
||||
verify_markdown_table_relationships() {
|
||||
local source="$1" label="$2" heading="$3" spec_json="$4"
|
||||
python3 - "$source" "$label" "$heading" "$spec_json" <<'PY'
|
||||
@@ -588,6 +691,7 @@ verify_vector_helper_interfaces() {
|
||||
verify_project_state_current_contract() {
|
||||
local source="${1:-$root/PROJECT_STATE.md}"
|
||||
local label="${2:-PROJECT_STATE.md}"
|
||||
verify_descriptor_migration_required_context "$source" "$label current section" current || return 1
|
||||
python3 - "$source" "$label" <<'PY'
|
||||
import pathlib, re, sys
|
||||
text = pathlib.Path(sys.argv[1]).read_text()
|
||||
@@ -599,10 +703,30 @@ current = text[:marker.start()]
|
||||
historical = text[marker.start():]
|
||||
if not re.search(r"Internal Qdrant \+ Ollama semantic infrastructure", current, re.MULTILINE):
|
||||
raise SystemExit(f"{label}: current section missing internal semantic snapshot heading")
|
||||
if not re.search(r"Schema-v3 descriptors are operational", current, re.MULTILINE):
|
||||
raise SystemExit(f"{label}: current section must say schema-v3 is operational")
|
||||
if "migration_required" not in current:
|
||||
raise SystemExit(f"{label}: current section must mention migration_required")
|
||||
if "migrate-legacy" in current.lower():
|
||||
raise SystemExit(f"{label}: current section contains forbidden migrate-legacy instruction")
|
||||
if not re.search(
|
||||
r"schema[- ]v?3\s+is\s+the\s+only\s+accepted\s+workspace\s+descriptor",
|
||||
current,
|
||||
re.IGNORECASE,
|
||||
):
|
||||
raise SystemExit(f"{label}: current section lacks explicit v3-only acceptance contract")
|
||||
legacy_subject = (
|
||||
r"(?:schema[- ]v?1\s*(?:/|and|or)\s*(?:schema[- ]?)?v?2|"
|
||||
r"schema[- ]v?1/v2|v1/v2)\s+descriptors?"
|
||||
)
|
||||
if not re.search(
|
||||
legacy_subject + r".{0,100}\brejected\b",
|
||||
current,
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
):
|
||||
raise SystemExit(f"{label}: current section lacks explicit v1/v2 rejection contract")
|
||||
for pattern in [
|
||||
legacy_subject + r".{0,100}\b(?:remain|are|stay)\b.{0,40}\b(?:readable|operational|supported|accepted)\b",
|
||||
r"schema[- ]v?[12]\s+descriptors?.{0,100}\b(?:remain|are|stay)\b.{0,40}\b(?:readable|operational|supported|accepted)\b",
|
||||
]:
|
||||
if re.search(pattern, current, re.IGNORECASE | re.DOTALL):
|
||||
raise SystemExit(f"{label}: current section contains contradictory active text: v1/v2 readable or operational support")
|
||||
if not re.search(r"\b(one|single)\b.*\bworkspace\b.*\b(one|single)\b.*\bQdrant\b.*\bcollection\b", current, re.IGNORECASE | re.DOTALL):
|
||||
raise SystemExit(f"{label}: current section must describe one-workspace/one-collection ownership")
|
||||
if not re.search(r"\bDWH\b", current) or not re.search(r"\bLLM\b", current):
|
||||
@@ -640,6 +764,10 @@ verify_internal_semantic_infrastructure_docs() {
|
||||
verify_workspace_descriptor_semantic_contract "$root/deploy/workspaces/psd.yaml.example" "psd workspace example" || return 1
|
||||
verify_vector_helper_interfaces || return 1
|
||||
verify_project_state_current_contract "$root/PROJECT_STATE.md" "PROJECT_STATE.md" || return 1
|
||||
verify_workspace_descriptor_doc_contract "$readme" "README" || return 1
|
||||
verify_workspace_descriptor_doc_contract "$local_manual" "local workspace manual" || return 1
|
||||
verify_workspace_descriptor_doc_contract "$server_manual" "server workspace manual" || return 1
|
||||
verify_workspace_descriptor_doc_contract "$diagnostics" "workspace diagnostic protocol" || return 1
|
||||
|
||||
local ownership_spec semantic_index_spec compact_spec
|
||||
ownership_spec='{"rows":[
|
||||
@@ -670,13 +798,12 @@ verify_internal_semantic_infrastructure_docs() {
|
||||
require_pattern "$agents" "AGENTS.md" 'DWH and LLM remain external configuration endpoints' || return 1
|
||||
for manual in "$local_manual" "$server_manual"; do
|
||||
require_pattern "$manual" "$(basename "$manual")" 'qwen3-embedding:0\.6b' || return 1
|
||||
require_pattern "$manual" "$(basename "$manual")" 'migration_required' || return 1
|
||||
done
|
||||
require_pattern "$local_manual" "local workspace manual" 'CPU-first' || return 1
|
||||
require_pattern "$local_manual" "local workspace manual" 'THOTH_ENABLE_EMBEDDING_GPU=1' || return 1
|
||||
require_pattern "$server_manual" "server workspace manual" 'Qdrant backup/restore' || return 1
|
||||
require_pattern "$compact_manual" "four-context install note" '1024 dimensioni' || return 1
|
||||
require_pattern "$diagnostics" "workspace diagnostic protocol" 'schema version 3' || return 1
|
||||
require_pattern "$diagnostics" "workspace diagnostic protocol" 'Schema v1 and v2 descriptors.+rejected before diagnostics' || return 1
|
||||
require_pattern "$diagnostics" "workspace diagnostic protocol" 'semantic_index_incompatible' || return 1
|
||||
require_absent "$diagnostics" "workspace diagnostic protocol" \
|
||||
'engine: pgvector' \
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -13,12 +14,19 @@ import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/aritmolab/thothii/tools/thothctl/internal/compose"
|
||||
"github.com/aritmolab/thothii/tools/thothctl/internal/config"
|
||||
"github.com/aritmolab/thothii/tools/thothctl/internal/output"
|
||||
"github.com/aritmolab/thothii/tools/thothctl/internal/pi"
|
||||
"github.com/aritmolab/thothii/tools/thothctl/internal/serverops"
|
||||
"github.com/aritmolab/thothii/tools/thothctl/internal/workspaceops"
|
||||
)
|
||||
|
||||
const (
|
||||
maxPublicStdoutBytes = 1 << 20
|
||||
maxPublicStderrBytes = 64 << 10
|
||||
)
|
||||
|
||||
const usage = `Usage: thothctl --installation <absolute-path>/thothii-installation.yaml <command>
|
||||
@@ -50,6 +58,10 @@ Commands:
|
||||
pi maintenance recover --yes
|
||||
Verify a terminal installation, remove stale lifecycle files, and clear maintenance.
|
||||
pi logs Show the latest 200 sanitized core log lines (bounded; no follow mode).
|
||||
workspace inspect --workspace ID [--json]
|
||||
workspace preprocess dwh|evidence|run --workspace ID [options]
|
||||
workspace schema suggest-fks|check --workspace ID [options]
|
||||
workspace index-schema --workspace ID [--json]
|
||||
`
|
||||
|
||||
func main() {
|
||||
@@ -57,32 +69,118 @@ func main() {
|
||||
}
|
||||
|
||||
func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
||||
isWorkspaceCommand := len(args) > 2 && args[2] == "workspace"
|
||||
// Workspace results are untrusted child output, so only that dispatch receives
|
||||
// the public bounds. Legacy renderers intentionally retain their established
|
||||
// behavior and must not silently truncate successful output.
|
||||
if len(args) > 2 && args[2] == "workspace" {
|
||||
stdout = &boundedWriter{dst: stdout, maximum: maxPublicStdoutBytes}
|
||||
stderr = &boundedWriter{dst: stderr, maximum: maxPublicStderrBytes}
|
||||
}
|
||||
if len(args) == 1 && (args[0] == "--help" || args[0] == "-h") {
|
||||
fmt.Fprint(stdout, usage)
|
||||
return 0
|
||||
}
|
||||
installationPath, command, commandArgs, err := parseArgs(args)
|
||||
if err != nil {
|
||||
if isWorkspaceCommand {
|
||||
return writeWorkspaceError(stderr, err, nil, 2)
|
||||
}
|
||||
fmt.Fprintf(stderr, "thothctl: %s\n\n%s", err, usage)
|
||||
return 2
|
||||
}
|
||||
installation, err := config.Load(installationPath)
|
||||
if err != nil {
|
||||
if isWorkspaceCommand {
|
||||
return writeWorkspaceError(stderr, err, nil, 2)
|
||||
}
|
||||
fmt.Fprintf(stderr, "thothctl: %s\n", output.Sanitize(err.Error(), nil))
|
||||
return 2
|
||||
}
|
||||
secretFiles, err := installation.SecretFiles()
|
||||
if err != nil {
|
||||
if isWorkspaceCommand {
|
||||
return writeWorkspaceError(stderr, errors.New("installation secret declarations could not be read"), nil, 2)
|
||||
}
|
||||
fmt.Fprintln(stderr, "thothctl: installation secret declarations could not be read")
|
||||
return 2
|
||||
}
|
||||
secretValues, err := output.SecretValuesFromFiles(secretFiles)
|
||||
if err != nil {
|
||||
if isWorkspaceCommand {
|
||||
return writeWorkspaceError(stderr, errors.New("declared secret file could not be read"), nil, 2)
|
||||
}
|
||||
fmt.Fprintln(stderr, "thothctl: declared secret file could not be read")
|
||||
return 2
|
||||
}
|
||||
|
||||
runner := compose.NewRunner("")
|
||||
if command == "workspace" {
|
||||
workspaceCommand, parseErr := workspaceops.ParseWorkspaceCommand(append([]string{"workspace"}, commandArgs...))
|
||||
if parseErr != nil {
|
||||
return writeWorkspaceError(stderr, parseErr, secretValues, 2)
|
||||
}
|
||||
jsonMode := true
|
||||
switch c := workspaceCommand.(type) {
|
||||
case workspaceops.InspectCommand:
|
||||
jsonMode = c.JSON
|
||||
case workspaceops.DwhRequest:
|
||||
jsonMode = c.JSON
|
||||
case workspaceops.SuggestFksRequest:
|
||||
jsonMode = c.JSON
|
||||
case workspaceops.CheckSchemaRequest:
|
||||
jsonMode = c.JSON
|
||||
case workspaceops.IndexSchemaRequest:
|
||||
jsonMode = c.JSON
|
||||
case workspaceops.EvidenceRequest:
|
||||
jsonMode = c.JSON
|
||||
case workspaceops.RunRequest:
|
||||
jsonMode = c.JSON
|
||||
}
|
||||
var finalOutput []byte
|
||||
result, operationErr := workspaceops.RunWithProjectorAndSecrets(ctx, installation, runner, workspaceCommand, nil, func(result workspaceops.Result) (workspaceops.Result, error) {
|
||||
return projectWorkspaceResult(result, secretValues)
|
||||
}, secretValues, func(result workspaceops.Result) error {
|
||||
var err error
|
||||
if jsonMode {
|
||||
finalOutput, err = encodeWorkspaceJSON(result)
|
||||
} else {
|
||||
finalOutput, err = encodeWorkspaceHuman(result)
|
||||
}
|
||||
if err != nil {
|
||||
return errors.New("workspace output could not be encoded")
|
||||
}
|
||||
if len(finalOutput) > maxPublicStdoutBytes {
|
||||
return errors.New("workspace result exceeds output limit")
|
||||
}
|
||||
// This is the exact once-encoded byte slice that will be published.
|
||||
// Scan it after encoding so JSON keys and human renderer chrome are
|
||||
// inside the same no-secret boundary as typed result values.
|
||||
if containsDeclaredSecretBytes(finalOutput, secretValues) {
|
||||
return errors.New("workspace output contains a declared secret")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if operationErr != nil {
|
||||
if workspaceUsageError(operationErr) {
|
||||
return writeWorkspaceError(stderr, operationErr, secretValues, 2)
|
||||
}
|
||||
return writeWorkspaceError(stderr, operationErr, secretValues, 1)
|
||||
}
|
||||
if _, err := stdout.Write(finalOutput); err != nil {
|
||||
// The candidate publication precedes stdout. A physical stdout
|
||||
// failure therefore requires reconciliation before retrying; it is
|
||||
// not an output-limit rejection.
|
||||
return writeWorkspaceError(stderr, errors.New("workspace output write failed; reconcile any committed candidate before retrying"), secretValues, 1)
|
||||
}
|
||||
if result.Status == "blocked" {
|
||||
return 3
|
||||
}
|
||||
if result.Status == "failed" {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
var result compose.Result
|
||||
switch command {
|
||||
case "status":
|
||||
@@ -160,6 +258,233 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
||||
return writeResult(result, err, secretValues, stdout, stderr)
|
||||
}
|
||||
|
||||
func workspaceUsageError(err error) bool {
|
||||
if err == nil || errors.Is(err, compose.ErrOutputLimit) {
|
||||
return false
|
||||
}
|
||||
message := err.Error()
|
||||
// These are host-side grammar/local-file failures. Child envelope/result and
|
||||
// bounded-stream failures are operational and deliberately remain exit 1.
|
||||
return strings.Contains(message, "unsafe") || strings.Contains(message, "request exceeds") ||
|
||||
strings.Contains(message, "SQL input exceeds") || strings.Contains(message, "annotation input exceeds")
|
||||
}
|
||||
|
||||
type boundedWriter struct {
|
||||
dst io.Writer
|
||||
maximum int64
|
||||
written int64
|
||||
}
|
||||
|
||||
func (w *boundedWriter) Write(p []byte) (int, error) {
|
||||
remaining := w.maximum - w.written
|
||||
if remaining <= 0 {
|
||||
return 0, io.ErrShortWrite
|
||||
}
|
||||
if int64(len(p)) > remaining {
|
||||
n, err := w.dst.Write(p[:int(remaining)])
|
||||
w.written += int64(n)
|
||||
if err != nil {
|
||||
return n, err
|
||||
}
|
||||
return n, io.ErrShortWrite
|
||||
}
|
||||
n, err := w.dst.Write(p)
|
||||
w.written += int64(n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func containsDeclaredSecretBytes(contents []byte, secrets []string) bool {
|
||||
for _, secret := range secrets {
|
||||
if secret != "" && bytes.Contains(contents, []byte(secret)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// writeWorkspaceError is the sole workspace stderr path. It validates the
|
||||
// complete rendered line, including its fixed prefix, before writing; if no
|
||||
// deterministic safe line exists it emits empty stderr rather than risk a
|
||||
// declared-secret collision.
|
||||
func writeWorkspaceError(stderr io.Writer, err error, secrets []string, code int) int {
|
||||
// A nil set means the complete declared-secret set was not loaded. No
|
||||
// nonempty diagnostic is safe because a successfully read secret may equal
|
||||
// the fixed error chrome. Callers must fail closed without output.
|
||||
if secrets == nil {
|
||||
return code
|
||||
}
|
||||
message := "workspace operation failed"
|
||||
if err != nil {
|
||||
message = output.Sanitize(err.Error(), secrets)
|
||||
}
|
||||
candidates := [][]byte{[]byte("workspace operation failed\n")}
|
||||
if secrets != nil {
|
||||
candidates = append([][]byte{[]byte("thothctl: " + message + "\n")}, candidates...)
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if !containsDeclaredSecretBytes(candidate, secrets) {
|
||||
_, _ = stderr.Write(candidate)
|
||||
return code
|
||||
}
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
func encodeWorkspaceJSON(result workspaceops.Result) ([]byte, error) {
|
||||
var encoded bytes.Buffer
|
||||
encoder := json.NewEncoder(&encoded)
|
||||
// Keep public output compact and avoid HTML-escape amplification of warnings.
|
||||
encoder.SetEscapeHTML(false)
|
||||
if err := encoder.Encode(result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return encoded.Bytes(), nil
|
||||
}
|
||||
|
||||
func writeWorkspaceJSON(w io.Writer, result workspaceops.Result) error {
|
||||
encoded, err := encodeWorkspaceJSON(result)
|
||||
if err != nil || len(encoded) > maxPublicStdoutBytes {
|
||||
return io.ErrShortWrite
|
||||
}
|
||||
_, err = w.Write(encoded)
|
||||
return err
|
||||
}
|
||||
|
||||
func encodeWorkspaceHuman(result workspaceops.Result) ([]byte, error) {
|
||||
var encoded bytes.Buffer
|
||||
if err := renderWorkspaceHuman(&encoded, result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return encoded.Bytes(), nil
|
||||
}
|
||||
|
||||
func projectWorkspaceResult(result workspaceops.Result, secretValues []string) (workspaceops.Result, error) {
|
||||
// Public fields are either closed identities (which must never be rewritten)
|
||||
// or human-rendered strings (which are redacted and checked for spoofing).
|
||||
project := func(value string) (string, error) {
|
||||
value = output.Sanitize(value, secretValues)
|
||||
for _, r := range value {
|
||||
if unicode.IsControl(r) || unicode.In(r, unicode.Cf, unicode.Zl, unicode.Zp) {
|
||||
return "", errors.New("unsafe character in workspace result")
|
||||
}
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
closed := func(value string) (string, error) {
|
||||
public, err := project(value)
|
||||
if err != nil || public != value {
|
||||
return "", errors.New("secret collides with workspace identity")
|
||||
}
|
||||
return public, nil
|
||||
}
|
||||
var err error
|
||||
for _, value := range []*string{&result.Status, &result.Code, &result.WorkspaceID, &result.WorkspaceRevision, &result.DescriptorBlob, &result.Operation} {
|
||||
if *value, err = closed(*value); err != nil {
|
||||
return workspaceops.Result{}, err
|
||||
}
|
||||
}
|
||||
if result.RunID, err = closed(result.RunID); err != nil {
|
||||
return workspaceops.Result{}, err
|
||||
}
|
||||
if result.ChildRuns != nil {
|
||||
childRuns := make(map[string]string, len(result.ChildRuns))
|
||||
for key, value := range result.ChildRuns {
|
||||
publicKey, keyErr := project(key)
|
||||
if keyErr != nil {
|
||||
return workspaceops.Result{}, keyErr
|
||||
}
|
||||
publicValue, valueErr := closed(value)
|
||||
if valueErr != nil {
|
||||
return workspaceops.Result{}, valueErr
|
||||
}
|
||||
if _, exists := childRuns[publicKey]; exists {
|
||||
return workspaceops.Result{}, errors.New("workspace result key collision")
|
||||
}
|
||||
childRuns[publicKey] = publicValue
|
||||
}
|
||||
result.ChildRuns = childRuns
|
||||
}
|
||||
if result.Counts != nil {
|
||||
counts := make(map[string]int, len(result.Counts))
|
||||
for key, value := range result.Counts {
|
||||
publicKey, keyErr := project(key)
|
||||
if keyErr != nil {
|
||||
return workspaceops.Result{}, keyErr
|
||||
}
|
||||
if _, exists := counts[publicKey]; exists {
|
||||
return workspaceops.Result{}, errors.New("workspace result key collision")
|
||||
}
|
||||
counts[publicKey] = value
|
||||
}
|
||||
result.Counts = counts
|
||||
}
|
||||
for i := range result.CompletedStages {
|
||||
if result.CompletedStages[i], err = project(result.CompletedStages[i]); err != nil {
|
||||
return workspaceops.Result{}, err
|
||||
}
|
||||
}
|
||||
for i := range result.Warnings {
|
||||
if result.Warnings[i], err = project(result.Warnings[i]); err != nil {
|
||||
return workspaceops.Result{}, err
|
||||
}
|
||||
}
|
||||
for i := range result.ArtifactIdentities {
|
||||
if result.ArtifactIdentities[i].Kind, err = project(result.ArtifactIdentities[i].Kind); err != nil {
|
||||
return workspaceops.Result{}, err
|
||||
}
|
||||
if result.ArtifactIdentities[i].Digest, err = closed(result.ArtifactIdentities[i].Digest); err != nil {
|
||||
return workspaceops.Result{}, err
|
||||
}
|
||||
}
|
||||
if publicResultContainsSecret(result, secretValues) {
|
||||
return workspaceops.Result{}, errors.New("workspace result contains a declared secret")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func publicResultContainsSecret(result workspaceops.Result, secrets []string) bool {
|
||||
values := []string{result.Status, result.Code, result.WorkspaceID, result.WorkspaceRevision, result.DescriptorBlob, result.Operation, result.RunID}
|
||||
values = append(values, result.CompletedStages...)
|
||||
values = append(values, result.Warnings...)
|
||||
for key, value := range result.ChildRuns {
|
||||
values = append(values, key, value)
|
||||
}
|
||||
for key := range result.Counts {
|
||||
values = append(values, key)
|
||||
}
|
||||
for _, artifact := range result.ArtifactIdentities {
|
||||
values = append(values, artifact.Kind, artifact.Digest)
|
||||
}
|
||||
for _, value := range values {
|
||||
for _, secret := range secrets {
|
||||
if secret != "" && strings.Contains(value, secret) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func renderWorkspaceHuman(w io.Writer, result workspaceops.Result) error {
|
||||
if result.Code == workspaceops.CodeRegistryBootstrapRecoveryConflict {
|
||||
_, err := fmt.Fprintln(w, "Bootstrap recovery is ambiguous or corrupt; inspect the installation registry jobs.")
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "Workspace %s: %s (%s)\n", result.WorkspaceID, result.Status, result.Code); err != nil {
|
||||
return err
|
||||
}
|
||||
if result.RunID != "" {
|
||||
if _, err := fmt.Fprintf(w, "Run: %s\n", result.RunID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(result.CompletedStages) > 0 {
|
||||
_, err := fmt.Fprintf(w, "Completed stages: %s\n", strings.Join(result.CompletedStages, ", "))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeRemovalTargets(outputWriter io.Writer, project string, targets []serverops.Container) {
|
||||
fmt.Fprintf(outputWriter, "Removal targets for installation project %s:\n", project)
|
||||
if len(targets) == 0 {
|
||||
|
||||
@@ -3,6 +3,10 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -15,6 +19,7 @@ import (
|
||||
"github.com/aritmolab/thothii/tools/thothctl/internal/config"
|
||||
"github.com/aritmolab/thothii/tools/thothctl/internal/pi"
|
||||
"github.com/aritmolab/thothii/tools/thothctl/internal/testsupport"
|
||||
"github.com/aritmolab/thothii/tools/thothctl/internal/workspaceops"
|
||||
)
|
||||
|
||||
func TestInstallationRunnerMapsProfileToSessionInventoryScope(t *testing.T) {
|
||||
@@ -103,6 +108,310 @@ func TestUsageDocumentsClosedConfigureUpdateSourcesAndMaintenanceRecovery(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkspacePublicDispatchExitMatrix(t *testing.T) {
|
||||
cases := []struct {
|
||||
name, status, code string
|
||||
exit int
|
||||
}{
|
||||
{"succeeded", "succeeded", "ok", 0},
|
||||
{"blocked", "blocked", "manual_review_required", 3},
|
||||
{"failed", "failed", "workspace_not_found", 1},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
fixture := newCLIFixture(t, "")
|
||||
fixture.setEnvironment(t)
|
||||
t.Setenv("THOTHCTL_FAKE_WORKSPACE_RESULT", fmt.Sprintf(`{"schemaVersion":1,"status":%q,"code":%q,"workspaceId":"psd","workspaceRevision":"%s","descriptorBlob":"%s","operation":"inspect","completedStages":[]}`, tc.status, tc.code, strings.Repeat("0", 40), strings.Repeat("a", 40)))
|
||||
t.Setenv("THOTHCTL_FAKE_WORKSPACE_EXIT", strconv.Itoa(tc.exit))
|
||||
var stdout, stderr bytes.Buffer
|
||||
got := run(context.Background(), []string{"--installation", fixture.installationPath, "workspace", "inspect", "--workspace", "psd", "--json"}, &stdout, &stderr)
|
||||
if got != tc.exit || stderr.Len() != 0 {
|
||||
t.Fatalf("exit=%d stderr=%q; want exit %d", got, stderr.String(), tc.exit)
|
||||
}
|
||||
if len(fixture.invocations(t)) != 1 {
|
||||
t.Fatalf("workspace dispatch invocations = %#v, want one", fixture.invocations(t))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkspaceBoundsFinalJSONEncoding(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
final int
|
||||
wantCode int
|
||||
}{
|
||||
{name: "exact", final: 1 << 20, wantCode: 0},
|
||||
{name: "one-over", final: (1 << 20) + 1, wantCode: 1},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
fixture := newCLIFixture(t, "")
|
||||
fixture.setEnvironment(t)
|
||||
payload, encodedLength := boundedWorkspaceResultPayload(t, tc.final)
|
||||
if encodedLength != tc.final {
|
||||
t.Fatalf("final encoded length = %d, want %d", encodedLength, tc.final)
|
||||
}
|
||||
writeWorkspaceResultFile(t, fixture, payload)
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := run(context.Background(), []string{"--installation", fixture.installationPath, "workspace", "inspect", "--workspace", "psd", "--json"}, &stdout, &stderr)
|
||||
if code != tc.wantCode || (tc.wantCode == 0 && stdout.Len() != tc.final) || (tc.wantCode != 0 && stdout.Len() != 0) {
|
||||
t.Fatalf("bounded output = exit %d stdout %d stderr %q", code, stdout.Len(), stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type failingWorkspaceWriter struct{}
|
||||
|
||||
func (failingWorkspaceWriter) Write([]byte) (int, error) {
|
||||
return 0, errors.New("injected stdout failure")
|
||||
}
|
||||
|
||||
func TestRunWorkspaceReportsCommittedReconcileOnStdoutFailure(t *testing.T) {
|
||||
fixture := newCLIFixture(t, "")
|
||||
fixture.setEnvironment(t)
|
||||
t.Setenv("THOTHCTL_FAKE_WORKSPACE_RESULT", `{"schemaVersion":1,"status":"succeeded","code":"ok","workspaceId":"psd","workspaceRevision":"0000000000000000000000000000000000000000","descriptorBlob":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","operation":"inspect","completedStages":[]}`)
|
||||
var stderr bytes.Buffer
|
||||
code := run(context.Background(), []string{"--installation", fixture.installationPath, "workspace", "inspect", "--workspace", "psd"}, failingWorkspaceWriter{}, &stderr)
|
||||
if code != 1 || !strings.Contains(stderr.String(), "reconcile") {
|
||||
t.Fatalf("exit=%d stderr=%q, want committed reconcile guidance", code, stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkspaceRejectsDeclaredSecretInFinalJSONKeyBeforeCandidateCommit(t *testing.T) {
|
||||
fixture := newCLIFixture(t, "UNLABELLED_SECRET_FILE=%s\n")
|
||||
secretPath := filepath.Join(fixture.root, "secret")
|
||||
if err := os.WriteFile(secretPath, []byte("schemaVersion"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fixture.setEnvironment(t, secretPath)
|
||||
candidate := []byte("candidates: []\n")
|
||||
digest := fmt.Sprintf("%x", sha256.Sum256(candidate))
|
||||
result := fmt.Sprintf(`{"schemaVersion":1,"status":"succeeded","code":"ok","workspaceId":"psd","workspaceRevision":"%s","descriptorBlob":"%s","operation":"suggest-fks","runId":"0123456789abcdef0123456789abcdef","completedStages":[],"artifactIdentities":[{"kind":"fk-candidates","digest":"%s"}],"hostExport":{"mediaType":"application/yaml","sha256":"%s","contentBase64":"%s"}}`, strings.Repeat("0", 40), strings.Repeat("a", 40), digest, digest, base64.StdEncoding.EncodeToString(candidate))
|
||||
t.Setenv("THOTHCTL_FAKE_WORKSPACE_RESULT", result)
|
||||
output := filepath.Join(fixture.root, "candidate.yaml")
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := run(context.Background(), []string{"--installation", fixture.installationPath, "workspace", "schema", "suggest-fks", "--workspace", "psd", "--output", output, "--json"}, &stdout, &stderr)
|
||||
if code != 1 || stdout.Len() != 0 {
|
||||
t.Fatalf("exit=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
if _, err := os.Stat(output); !os.IsNotExist(err) {
|
||||
t.Fatalf("candidate exists after final-byte secret rejection: %v", err)
|
||||
}
|
||||
if strings.Contains(stderr.String(), "schemaVersion") {
|
||||
t.Fatalf("stderr leaked declared secret: %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkspaceRejectsDeclaredSecretInHumanChrome(t *testing.T) {
|
||||
fixture := newCLIFixture(t, "UNLABELLED_SECRET_FILE=%s\n")
|
||||
secretPath := filepath.Join(fixture.root, "secret")
|
||||
if err := os.WriteFile(secretPath, []byte("Workspace"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fixture.setEnvironment(t, secretPath)
|
||||
t.Setenv("THOTHCTL_FAKE_WORKSPACE_RESULT", `{"schemaVersion":1,"status":"succeeded","code":"ok","workspaceId":"psd","workspaceRevision":"0000000000000000000000000000000000000000","descriptorBlob":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","operation":"inspect","completedStages":[]}`)
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := run(context.Background(), []string{"--installation", fixture.installationPath, "workspace", "inspect", "--workspace", "psd"}, &stdout, &stderr)
|
||||
if code != 1 || stdout.Len() != 0 || strings.Contains(stderr.String(), "Workspace") {
|
||||
t.Fatalf("exit=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkspacePartialSecretLoadEmitsNoOutput(t *testing.T) {
|
||||
fixture := newCLIFixture(t, "")
|
||||
firstSecret := filepath.Join(fixture.root, "first-secret")
|
||||
missingSecret := filepath.Join(fixture.root, "missing-secret")
|
||||
if err := os.WriteFile(firstSecret, []byte("workspace operation failed"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fixture.setEnvContents(t, "FIRST_SECRET_FILE="+firstSecret+"\nSECOND_SECRET_FILE="+missingSecret+"\n")
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := run(context.Background(), []string{"--installation", fixture.installationPath, "workspace", "inspect", "--workspace", "psd"}, &stdout, &stderr)
|
||||
if code != 2 || stdout.Len() != 0 || stderr.Len() != 0 {
|
||||
t.Fatalf("exit=%d stdout=%q stderr=%q, want exit 2 and no output", code, stdout.String(), stderr.String())
|
||||
}
|
||||
assertDockerNotInvoked(t, fixture)
|
||||
}
|
||||
|
||||
func TestRunWorkspaceErrorPrefixNeverLeaksDeclaredSecret(t *testing.T) {
|
||||
fixture := newCLIFixture(t, "UNLABELLED_SECRET_FILE=%s\n")
|
||||
secretPath := filepath.Join(fixture.root, "secret")
|
||||
if err := os.WriteFile(secretPath, []byte("thothctl:"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fixture.setEnvironment(t, secretPath)
|
||||
t.Setenv("THOTHCTL_FAKE_WORKSPACE_RESULT", "not-json")
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := run(context.Background(), []string{"--installation", fixture.installationPath, "workspace", "inspect", "--workspace", "psd"}, &stdout, &stderr)
|
||||
if code != 1 || stdout.Len() != 0 || strings.Contains(stderr.String(), "thothctl:") {
|
||||
t.Fatalf("exit=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkspaceBoundsFinalHumanEncoding(t *testing.T) {
|
||||
fixture := newCLIFixture(t, "")
|
||||
fixture.setEnvironment(t)
|
||||
payload := encodeWorkspaceResultForTest(workspaceops.Result{
|
||||
SchemaVersion: 1, Status: "succeeded", Code: "ok", WorkspaceID: "psd",
|
||||
WorkspaceRevision: strings.Repeat("0", 40), DescriptorBlob: strings.Repeat("a", 40),
|
||||
Operation: "inspect", CompletedStages: []string{},
|
||||
})
|
||||
writeWorkspaceResultFile(t, fixture, payload)
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := run(context.Background(), []string{"--installation", fixture.installationPath, "workspace", "inspect", "--workspace", "psd"}, &stdout, &stderr)
|
||||
if code != 0 || stderr.Len() != 0 {
|
||||
t.Fatalf("successful child result was not rendered: exit=%d stderr=%q", code, stderr.String())
|
||||
}
|
||||
if !strings.HasPrefix(stdout.String(), "Workspace psd: succeeded (ok)\n") {
|
||||
t.Fatalf("human output = %q, want successful workspace rendering", stdout.String())
|
||||
}
|
||||
|
||||
// A human rendering can only be larger than the child JSON when it is supplied directly;
|
||||
// exercise the same bounded public writer without making the child hit its 1 MiB cap.
|
||||
large := workspaceops.Result{
|
||||
SchemaVersion: 1, Status: "succeeded", Code: "ok", WorkspaceID: "psd",
|
||||
WorkspaceRevision: strings.Repeat("0", 40), DescriptorBlob: strings.Repeat("a", 40),
|
||||
Operation: "inspect", CompletedStages: []string{strings.Repeat("x", 1<<20)},
|
||||
}
|
||||
var bounded bytes.Buffer
|
||||
renderWorkspaceHuman(&boundedWriter{dst: &bounded, maximum: 1 << 20}, large)
|
||||
if bounded.Len() != 1<<20 {
|
||||
t.Fatalf("bounded human output length = %d, want exactly 1 MiB", bounded.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunBoundsParseErrorStderr(t *testing.T) {
|
||||
fixture := newCLIFixture(t, "")
|
||||
fixture.setEnvironment(t)
|
||||
unknownAction := strings.Repeat("x", 70<<10)
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := run(context.Background(), []string{"--installation", fixture.installationPath, "workspace", unknownAction, "--workspace", "psd"}, &stdout, &stderr)
|
||||
if code != 2 {
|
||||
t.Fatalf("exit = %d, want usage exit 2", code)
|
||||
}
|
||||
if stdout.Len() != 0 || stderr.Len() != 64<<10 {
|
||||
t.Fatalf("bounded parse error = stdout %d stderr %d, want 0 and exactly 64 KiB", stdout.Len(), stderr.Len())
|
||||
}
|
||||
if !strings.HasPrefix(stderr.String(), "thothctl: unknown workspace command: workspace ") || !strings.HasSuffix(stderr.String(), "x") {
|
||||
t.Fatalf("parse error was not the truncated unknown action: prefix/suffix mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectWorkspaceResultProtectsEveryPublicStringBoundary(t *testing.T) {
|
||||
base := workspaceops.Result{
|
||||
SchemaVersion: 1, Status: "succeeded", Code: "ok", WorkspaceID: "psd",
|
||||
WorkspaceRevision: strings.Repeat("0", 40), DescriptorBlob: strings.Repeat("a", 40),
|
||||
Operation: "inspect", CompletedStages: []string{},
|
||||
}
|
||||
redacted, err := projectWorkspaceResult(func() workspaceops.Result {
|
||||
r := base
|
||||
r.Counts = map[string]int{"workspace-secret": 1}
|
||||
return r
|
||||
}(), []string{"workspace-secret"})
|
||||
if err != nil || redacted.Counts["[REDACTED]"] != 1 {
|
||||
t.Fatalf("counts projection = %#v, err=%v", redacted.Counts, err)
|
||||
}
|
||||
if _, leaked := redacted.Counts["workspace-secret"]; leaked {
|
||||
t.Fatal("secret count key survived projection")
|
||||
}
|
||||
colliding := base
|
||||
colliding.Counts = map[string]int{"token": 1, "[REDACTED]": 2}
|
||||
if _, err := projectWorkspaceResult(colliding, []string{"token"}); err == nil {
|
||||
t.Fatal("accepted a redacted count-key collision")
|
||||
}
|
||||
identity := base
|
||||
identity.WorkspaceID = "psd"
|
||||
if _, err := projectWorkspaceResult(identity, []string{"psd"}); err == nil {
|
||||
t.Fatal("rewrote a closed workspace identity")
|
||||
}
|
||||
spoof := base
|
||||
spoof.Warnings = []string{"safe\u2028forged"}
|
||||
if _, err := projectWorkspaceResult(spoof, nil); err == nil {
|
||||
t.Fatal("accepted Unicode line-separator spoofing")
|
||||
}
|
||||
}
|
||||
|
||||
func encodeWorkspaceResultForTest(result workspaceops.Result) []byte {
|
||||
var encoded bytes.Buffer
|
||||
encoder := json.NewEncoder(&encoded)
|
||||
encoder.SetEscapeHTML(false)
|
||||
if err := encoder.Encode(result); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return encoded.Bytes()
|
||||
}
|
||||
|
||||
func boundedWorkspaceResultPayload(t *testing.T, finalLength int) ([]byte, int) {
|
||||
t.Helper()
|
||||
result := workspaceops.Result{
|
||||
SchemaVersion: 1, Status: "succeeded", Code: "ok", WorkspaceID: "psd",
|
||||
WorkspaceRevision: strings.Repeat("0", 40), DescriptorBlob: strings.Repeat("a", 40),
|
||||
Operation: "inspect", CompletedStages: []string{},
|
||||
// A multibyte UTF-8 warning exercises the final output bound without
|
||||
// introducing a Unicode separator that the public projection rejects.
|
||||
Warnings: []string{strings.Repeat("é", 1000)},
|
||||
}
|
||||
encoded := encodeWorkspaceResultForTest(result)
|
||||
if len(encoded) >= finalLength {
|
||||
t.Fatalf("base encoded result length = %d, cannot reach target %d", len(encoded), finalLength)
|
||||
}
|
||||
result.Warnings[0] += strings.Repeat("x", finalLength-len(encoded))
|
||||
encoded = encodeWorkspaceResultForTest(result)
|
||||
if len(encoded) != finalLength {
|
||||
t.Fatalf("final encoded result length = %d, want %d", len(encoded), finalLength)
|
||||
}
|
||||
child := bytes.ReplaceAll(encoded, []byte(`\u2028`), []byte("\u2028"))
|
||||
return child, len(encoded)
|
||||
}
|
||||
|
||||
func writeWorkspaceResultFile(t *testing.T, fixture cliFixture, payload []byte) {
|
||||
t.Helper()
|
||||
path := filepath.Join(fixture.root, "workspace-result.json")
|
||||
if err := os.WriteFile(path, payload, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("THOTHCTL_FAKE_WORKSPACE_RESULT_FILE", path)
|
||||
}
|
||||
|
||||
func TestRunWorkspaceOperationalFailureExitsOne(t *testing.T) {
|
||||
fixture := newCLIFixture(t, "")
|
||||
fixture.setEnvironment(t)
|
||||
t.Setenv("THOTHCTL_FAKE_WORKSPACE_RESULT", "not-json")
|
||||
var stdout, stderr bytes.Buffer
|
||||
got := run(context.Background(), []string{"--installation", fixture.installationPath, "workspace", "inspect", "--workspace", "psd"}, &stdout, &stderr)
|
||||
if got != 1 || !strings.Contains(stderr.String(), "invalid workspace result") {
|
||||
t.Fatalf("exit=%d stderr=%q; want operational exit 1", got, stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkspaceUnsafeOutputDoesNotInvokeCompose(t *testing.T) {
|
||||
fixture := newCLIFixture(t, "")
|
||||
fixture.setEnvironment(t)
|
||||
output := filepath.Join(fixture.root, "existing.yaml")
|
||||
if err := os.WriteFile(output, []byte("existing"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
got := run(context.Background(), []string{"--installation", fixture.installationPath, "workspace", "schema", "suggest-fks", "--workspace", "psd", "--output", output}, &stdout, &stderr)
|
||||
if got != 2 || !strings.Contains(stderr.String(), "unsafe output file") {
|
||||
t.Fatalf("exit=%d stderr=%q; want unsafe host failure", got, stderr.String())
|
||||
}
|
||||
assertDockerNotInvoked(t, fixture)
|
||||
}
|
||||
|
||||
func TestRunWorkspaceRejectsInvalidCommandBeforeDocker(t *testing.T) {
|
||||
fixture := newCLIFixture(t, "")
|
||||
fixture.setEnvironment(t)
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := run(context.Background(), []string{"--installation", fixture.installationPath, "workspace", "inspect", "--workspace", "PSd"}, &stdout, &stderr)
|
||||
if code != 2 || !strings.Contains(stderr.String(), "workspace") {
|
||||
t.Fatalf("exit=%d stderr=%q", code, stderr.String())
|
||||
}
|
||||
assertDockerNotInvoked(t, fixture)
|
||||
}
|
||||
|
||||
func TestRunSessionsMigrateRequiresExplicitConfirmationBeforeDocker(t *testing.T) {
|
||||
fixture := newCLIFixture(t, "")
|
||||
fixture.setProfile(t, "server")
|
||||
@@ -685,6 +994,50 @@ func TestRunPiStatusPreservesDockerExitCodeAndRedactsDiagnostics(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkspacePublicProjectionRedactsSecrets(t *testing.T) {
|
||||
fixture := newCLIFixture(t, "TOKEN_FILE=%s\n")
|
||||
secretPath := filepath.Join(fixture.root, "token")
|
||||
if err := os.WriteFile(secretPath, []byte("workspace-secret"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fixture.setEnvironment(t, secretPath)
|
||||
t.Setenv("THOTHCTL_FAKE_WORKSPACE_RESULT", fmt.Sprintf(`{"schemaVersion":1,"status":"succeeded","code":"ok","workspaceId":"psd","workspaceRevision":"%s","descriptorBlob":"%s","operation":"inspect","completedStages":["stage workspace-secret"],"warnings":["warning workspace-secret"]}`, strings.Repeat("0", 40), strings.Repeat("a", 40)))
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := run(context.Background(), []string{"--installation", fixture.installationPath, "workspace", "inspect", "--workspace", "psd", "--json"}, &stdout, &stderr); code != 0 {
|
||||
t.Fatalf("run() exit=%d stderr=%q", code, stderr.String())
|
||||
}
|
||||
if strings.Contains(stdout.String(), "workspace-secret") || !strings.Contains(stdout.String(), "[REDACTED]") {
|
||||
t.Fatalf("public JSON = %q, want redacted secret", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkspacePublicProjectionRejectsHumanControlCharacters(t *testing.T) {
|
||||
fixture := newCLIFixture(t, "")
|
||||
fixture.setEnvironment(t)
|
||||
t.Setenv("THOTHCTL_FAKE_WORKSPACE_RESULT", fmt.Sprintf(`{"schemaVersion":1,"status":"succeeded","code":"ok","workspaceId":"psd","workspaceRevision":"%s","descriptorBlob":"%s","operation":"inspect","completedStages":["safe\nforged"]}`, strings.Repeat("0", 40), strings.Repeat("a", 40)))
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := run(context.Background(), []string{"--installation", fixture.installationPath, "workspace", "inspect", "--workspace", "psd"}, &stdout, &stderr); code != 1 || stdout.Len() != 0 {
|
||||
t.Fatalf("run() exit=%d stdout=%q stderr=%q, want rejected output", code, stdout.String(), stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunNonWorkspaceOutputIsNotGloballyCapped(t *testing.T) {
|
||||
fixture := newCLIFixture(t, "")
|
||||
fixture.setEnvironment(t)
|
||||
logPath := filepath.Join(fixture.root, "large.log")
|
||||
if err := os.WriteFile(logPath, []byte(strings.Repeat("log-line\n", 200000)), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("THOTHCTL_FAKE_LOG_FILE", logPath)
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := run(context.Background(), []string{"--installation", fixture.installationPath, "logs"}, &stdout, &stderr); code != 0 {
|
||||
t.Fatalf("run() exit=%d stderr=%q invocations=%#v", code, stderr.String(), fixture.invocations(t))
|
||||
}
|
||||
if stdout.Len() <= maxPublicStdoutBytes {
|
||||
t.Fatalf("non-workspace output length=%d, want greater than cap %d", stdout.Len(), maxPublicStdoutBytes)
|
||||
}
|
||||
}
|
||||
|
||||
type cliFixture struct {
|
||||
root string
|
||||
installationPath string
|
||||
@@ -737,6 +1090,13 @@ case " $* " in
|
||||
else
|
||||
printf '%s\n' '{"volumes":{"settings":{}},"services":{"core":{"image":"thothii-core:local","environment":{"THT_LLM_URL":"https://llm.example.invalid"}}}}'
|
||||
fi ;;
|
||||
*" run --rm --no-deps --no-TTY workspace-maintenance "*)
|
||||
workspace_result="${THOTHCTL_FAKE_WORKSPACE_RESULT:-}"
|
||||
if [ -n "${THOTHCTL_FAKE_WORKSPACE_RESULT_FILE:-}" ]; then
|
||||
workspace_result=$(/bin/cat "$THOTHCTL_FAKE_WORKSPACE_RESULT_FILE")
|
||||
fi
|
||||
printf '%s\n' "$workspace_result"
|
||||
exit "${THOTHCTL_FAKE_WORKSPACE_EXIT:-0}" ;;
|
||||
*" run --rm --no-deps --no-TTY session-migrate "*)
|
||||
if [ "${THOTHCTL_FAKE_MIGRATION_EXIT:-0}" -ne 0 ]; then
|
||||
printf '%s\n' "$THOTHCTL_FAKE_MIGRATION_FAILURE" >&2
|
||||
@@ -751,7 +1111,8 @@ case " $* " in
|
||||
*"settings-cli.js --snapshot"*) printf '%s\n' '{"exists":false,"rawBase64":""}' ;;
|
||||
*"/settings "*) printf '%s\n' '{"provider":"provider","model":"model","thinking":"medium"}' ;;
|
||||
*"/internal/maintenance/status "*) printf '%s\n' '{"active":true,"admissions":0}' ;;
|
||||
*" logs "*) printf '%s\n' "$THOTHCTL_FAKE_LOG" ;;
|
||||
*" logs "*)
|
||||
if [ -n "${THOTHCTL_FAKE_LOG_FILE:-}" ]; then /bin/cat "$THOTHCTL_FAKE_LOG_FILE"; else printf '%s\n' "$THOTHCTL_FAKE_LOG"; fi ;;
|
||||
esac
|
||||
if [ "${THOTHCTL_FAKE_FAIL_ON:-}" = "version" ]; then
|
||||
printf '%s\n' "${THOTHCTL_FAKE_FAILURE:-fake Docker failure}" >&2
|
||||
@@ -786,12 +1147,16 @@ func (f cliFixture) setEnvContents(t *testing.T, env string) {
|
||||
t.Setenv("THOTHCTL_FAKE_ARGS", f.argsFile)
|
||||
t.Setenv("THOTHCTL_FAKE_EXIT", "0")
|
||||
t.Setenv("THOTHCTL_FAKE_LOG", "")
|
||||
t.Setenv("THOTHCTL_FAKE_LOG_FILE", "")
|
||||
t.Setenv("THOTHCTL_FAKE_FAILURE", "")
|
||||
t.Setenv("THOTHCTL_FAKE_FAIL_ON", "")
|
||||
t.Setenv("THOTHCTL_FAKE_STOPPED_PS", "[]")
|
||||
t.Setenv("THOTHCTL_FAKE_CONFIG", "")
|
||||
t.Setenv("THOTHCTL_FAKE_MIGRATION_FAILURE", "")
|
||||
t.Setenv("THOTHCTL_FAKE_MIGRATION_EXIT", "0")
|
||||
t.Setenv("THOTHCTL_FAKE_WORKSPACE_RESULT", "")
|
||||
t.Setenv("THOTHCTL_FAKE_WORKSPACE_RESULT_FILE", "")
|
||||
t.Setenv("THOTHCTL_FAKE_WORKSPACE_EXIT", "0")
|
||||
}
|
||||
|
||||
func (f cliFixture) setProfile(t *testing.T, profile string) {
|
||||
@@ -846,3 +1211,24 @@ func assertInvocationContains(t *testing.T, invocations [][]string, want ...stri
|
||||
}
|
||||
t.Fatalf("invocations = %#v, want %#v", invocations, want)
|
||||
}
|
||||
|
||||
func TestRenderWorkspaceHumanHidesRecoveryIdentity(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
renderWorkspaceHuman(&out, workspaceops.Result{WorkspaceID: "psd", Status: "blocked", Code: workspaceops.CodeRegistryBootstrapRecoveryConflict, RunID: "0123456789abcdef0123456789abcdef"})
|
||||
if out.String() != "Bootstrap recovery is ambiguous or corrupt; inspect the installation registry jobs.\n" {
|
||||
t.Fatalf("human output = %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceOperationalEnvelopeFailuresAreExitOne(t *testing.T) {
|
||||
for _, err := range []error{compose.ErrOutputLimit, errors.New("invalid workspace result"), errors.New("invalid workspace request"), errors.New("invalid host export")} {
|
||||
if workspaceUsageError(err) {
|
||||
t.Errorf("classified operational error %q as usage", err)
|
||||
}
|
||||
}
|
||||
for _, err := range []error{errors.New("unsafe output file"), errors.New("request exceeds limit"), errors.New("unsafe SQL input")} {
|
||||
if !workspaceUsageError(err) {
|
||||
t.Errorf("classified host error %q as operational", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build !windows
|
||||
|
||||
package compose
|
||||
|
||||
import "syscall"
|
||||
|
||||
func processExists(pid int) bool { return syscall.Kill(pid, 0) == nil }
|
||||
@@ -0,0 +1,5 @@
|
||||
//go:build windows
|
||||
|
||||
package compose
|
||||
|
||||
func processExists(int) bool { return true }
|
||||
@@ -0,0 +1,24 @@
|
||||
//go:build !windows
|
||||
|
||||
package compose
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func configureOwnedProcess(c *exec.Cmd) { c.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} }
|
||||
func registerOwnedProcess(c *exec.Cmd) error { return nil }
|
||||
func releaseOwnedProcess(c *exec.Cmd) {}
|
||||
func terminateOwnedProcess(c *exec.Cmd) {
|
||||
if c.Process != nil {
|
||||
_ = syscall.Kill(-c.Process.Pid, syscall.SIGKILL)
|
||||
_ = c.Process.Kill()
|
||||
}
|
||||
}
|
||||
|
||||
func runBoundedPlatform(context.Context, Runner, []string, io.Reader, CaptureLimits) (Result, bool, error) {
|
||||
return Result{}, false, nil
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
//go:build windows
|
||||
|
||||
package compose
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// PROC_THREAD_ATTRIBUTE_JOB_LIST is intentionally kept here because x/sys does
|
||||
// not expose this newer SDK constant yet. Supplying it to CreateProcess makes
|
||||
// job ownership atomic with process creation; CREATE_SUSPENDED plus a later
|
||||
// AssignProcessToJobObject is not sufficient (the parent can die in between).
|
||||
const procThreadAttributeJobList = 0x0002000d
|
||||
|
||||
// The generic exec.Cmd path is not used on Windows: runBoundedPlatform launches
|
||||
// with CreateProcess and a creation-time job-list attribute. These hooks remain
|
||||
// for the platform-neutral runner's compile-time shape only.
|
||||
func configureOwnedProcess(*exec.Cmd) {}
|
||||
func registerOwnedProcess(*exec.Cmd) error {
|
||||
return errors.New("Windows owned processes require creation-time job ownership")
|
||||
}
|
||||
func releaseOwnedProcess(*exec.Cmd) {}
|
||||
func terminateOwnedProcess(c *exec.Cmd) {
|
||||
if c.Process != nil {
|
||||
_ = c.Process.Kill()
|
||||
}
|
||||
}
|
||||
|
||||
func runBoundedPlatform(ctx context.Context, runner Runner, args []string, stdin io.Reader, limits CaptureLimits) (Result, bool, error) {
|
||||
if limits.StdoutBytes < 0 || limits.StderrBytes < 0 {
|
||||
return Result{}, true, ErrOutputLimit
|
||||
}
|
||||
result, err := runBoundedWindows(ctx, runner.binary, args, stdin, limits)
|
||||
return result, true, err
|
||||
}
|
||||
|
||||
type windowsCollector struct {
|
||||
buf []byte
|
||||
n int64
|
||||
overflow bool
|
||||
}
|
||||
|
||||
func runBoundedWindows(ctx context.Context, binary string, args []string, stdin io.Reader, limits CaptureLimits) (Result, error) {
|
||||
job, err := windows.CreateJobObject(nil, nil)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
closeJob := true
|
||||
defer func() {
|
||||
if closeJob {
|
||||
windows.CloseHandle(job)
|
||||
}
|
||||
}()
|
||||
limitsInfo := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{}
|
||||
limitsInfo.BasicLimitInformation.LimitFlags = windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
|
||||
if _, err = windows.SetInformationJobObject(job, windows.JobObjectExtendedLimitInformation, uintptr(unsafe.Pointer(&limitsInfo)), uint32(unsafe.Sizeof(limitsInfo))); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
stdinRead, stdinWrite, err := os.Pipe()
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
stdoutRead, stdoutWrite, err := os.Pipe()
|
||||
if err != nil {
|
||||
_ = stdinRead.Close()
|
||||
_ = stdinWrite.Close()
|
||||
return Result{}, err
|
||||
}
|
||||
stderrRead, stderrWrite, err := os.Pipe()
|
||||
if err != nil {
|
||||
_ = stdinRead.Close()
|
||||
_ = stdinWrite.Close()
|
||||
_ = stdoutRead.Close()
|
||||
_ = stdoutWrite.Close()
|
||||
return Result{}, err
|
||||
}
|
||||
closeAll := func() {
|
||||
_ = stdinRead.Close()
|
||||
_ = stdinWrite.Close()
|
||||
_ = stdoutRead.Close()
|
||||
_ = stdoutWrite.Close()
|
||||
_ = stderrRead.Close()
|
||||
_ = stderrWrite.Close()
|
||||
}
|
||||
defer closeAll()
|
||||
childHandles := []windows.Handle{windows.Handle(stdinRead.Fd()), windows.Handle(stdoutWrite.Fd()), windows.Handle(stderrWrite.Fd())}
|
||||
for _, h := range childHandles {
|
||||
if err := windows.SetHandleInformation(h, windows.HANDLE_FLAG_INHERIT, windows.HANDLE_FLAG_INHERIT); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
}
|
||||
// The attribute list limits inheritance to exactly these standard handles.
|
||||
attributes, err := windows.NewProcThreadAttributeList(2)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
defer attributes.Delete()
|
||||
jobList := []windows.Handle{job}
|
||||
if err := attributes.Update(procThreadAttributeJobList, unsafe.Pointer(&jobList[0]), unsafe.Sizeof(jobList[0])); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if err := attributes.Update(windows.PROC_THREAD_ATTRIBUTE_HANDLE_LIST, unsafe.Pointer(&childHandles[0]), uintptr(len(childHandles))*unsafe.Sizeof(childHandles[0])); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
command := syscall.EscapeArg(binary)
|
||||
for _, arg := range args {
|
||||
command += " " + syscall.EscapeArg(arg)
|
||||
}
|
||||
commandLine, err := windows.UTF16FromString(command)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
startup := windows.StartupInfoEx{
|
||||
StartupInfo: windows.StartupInfo{
|
||||
Cb: uint32(unsafe.Sizeof(windows.StartupInfoEx{})),
|
||||
Flags: windows.STARTF_USESTDHANDLES,
|
||||
StdInput: childHandles[0],
|
||||
StdOutput: childHandles[1],
|
||||
StdErr: childHandles[2],
|
||||
},
|
||||
ProcThreadAttributeList: attributes.List(),
|
||||
}
|
||||
var process windows.ProcessInformation
|
||||
creationFlags := uint32(windows.CREATE_UNICODE_ENVIRONMENT | windows.CREATE_NEW_PROCESS_GROUP | windows.EXTENDED_STARTUPINFO_PRESENT)
|
||||
if err := windows.CreateProcess(nil, &commandLine[0], nil, nil, true, creationFlags, nil, nil, (*windows.StartupInfo)(unsafe.Pointer(&startup)), &process); err != nil {
|
||||
return Result{ExitCode: 127}, err
|
||||
}
|
||||
// The child owns duplicate pipe handles; parent copies use the opposite ends.
|
||||
_ = stdinRead.Close()
|
||||
_ = stdoutWrite.Close()
|
||||
_ = stderrWrite.Close()
|
||||
_ = windows.CloseHandle(process.Thread)
|
||||
|
||||
var inputDone sync.WaitGroup
|
||||
inputDone.Add(1)
|
||||
go func() {
|
||||
defer inputDone.Done()
|
||||
defer stdinWrite.Close()
|
||||
if stdin != nil {
|
||||
_, _ = io.Copy(stdinWrite, stdin)
|
||||
}
|
||||
}()
|
||||
out := windowsCollector{buf: make([]byte, limits.StdoutBytes)}
|
||||
er := windowsCollector{buf: make([]byte, limits.StderrBytes)}
|
||||
overflow := make(chan struct{}, 1)
|
||||
var readers sync.WaitGroup
|
||||
readers.Add(2)
|
||||
read := func(file *os.File, maximum int64, collector *windowsCollector) {
|
||||
defer readers.Done()
|
||||
chunk := make([]byte, 32*1024)
|
||||
for {
|
||||
n, readErr := file.Read(chunk)
|
||||
if n > 0 {
|
||||
remain := maximum - collector.n
|
||||
if remain > 0 {
|
||||
take := int64(n)
|
||||
if take > remain {
|
||||
take = remain
|
||||
}
|
||||
copy(collector.buf[collector.n:collector.n+take], chunk[:take])
|
||||
collector.n += take
|
||||
}
|
||||
if int64(n) > remain {
|
||||
collector.overflow = true
|
||||
select {
|
||||
case overflow <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
if readErr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
go read(stdoutRead, limits.StdoutBytes, &out)
|
||||
go read(stderrRead, limits.StderrBytes, &er)
|
||||
waited := make(chan uint32, 1)
|
||||
go func() {
|
||||
_, waitErr := windows.WaitForSingleObject(process.Process, windows.INFINITE)
|
||||
if waitErr != nil {
|
||||
waited <- uint32(0x103)
|
||||
} else {
|
||||
var code uint32
|
||||
if windows.GetExitCodeProcess(process.Process, &code) != nil {
|
||||
code = 1
|
||||
}
|
||||
waited <- code
|
||||
}
|
||||
}()
|
||||
var exitCode uint32
|
||||
var cancelled error
|
||||
select {
|
||||
case exitCode = <-waited:
|
||||
// Closing the job handle kills any descendants before waiting on pipe readers.
|
||||
_ = windows.CloseHandle(job)
|
||||
closeJob = false
|
||||
case <-overflow:
|
||||
_ = windows.TerminateJobObject(job, 1)
|
||||
exitCode = <-waited
|
||||
_ = windows.CloseHandle(job)
|
||||
closeJob = false
|
||||
case <-ctx.Done():
|
||||
cancelled = ctx.Err()
|
||||
_ = windows.TerminateJobObject(job, 1)
|
||||
exitCode = <-waited
|
||||
_ = windows.CloseHandle(job)
|
||||
closeJob = false
|
||||
}
|
||||
_ = windows.CloseHandle(process.Process)
|
||||
_ = stdinWrite.Close()
|
||||
readers.Wait()
|
||||
inputDone.Wait()
|
||||
result := Result{Stdout: string(out.buf[:out.n]), Stderr: string(er.buf[:er.n]), ExitCode: int(exitCode)}
|
||||
if out.overflow || er.overflow {
|
||||
return result, ErrOutputLimit
|
||||
}
|
||||
if cancelled != nil {
|
||||
return result, cancelled
|
||||
}
|
||||
if exitCode == 0 {
|
||||
return result, nil
|
||||
}
|
||||
return result, fmt.Errorf("child exited with code %d", exitCode)
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
//go:build windows
|
||||
|
||||
package compose
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
func TestOwnedProcessUsesCreationTimeJobAttribute(t *testing.T) {
|
||||
if procThreadAttributeJobList == 0 {
|
||||
t.Fatal("owned Windows process must carry a creation-time job-list attribute")
|
||||
}
|
||||
cmd := exec.Command("cmd.exe")
|
||||
configureOwnedProcess(cmd)
|
||||
if cmd.SysProcAttr != nil && cmd.SysProcAttr.CreationFlags&windows.CREATE_SUSPENDED != 0 {
|
||||
t.Fatal("owned Windows process must not rely on a post-start assignment race")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnedJobPolicyKillsDescendantsOnClose(t *testing.T) {
|
||||
if windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE == 0 {
|
||||
t.Fatal("owned job must use kill-on-close policy")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWindowsProcessHelper is both a test helper and the child process used by the runtime
|
||||
// ownership regression below. The grandchild records its PID before the parent can exit. The
|
||||
// parent then either exits, blocks for cancellation, or overflows stdout.
|
||||
func TestWindowsProcessHelper(t *testing.T) {
|
||||
mode := os.Getenv("THOTHCTL_WINDOWS_HELPER")
|
||||
if mode == "" {
|
||||
return
|
||||
}
|
||||
pidFile := os.Getenv("THOTHCTL_WINDOWS_PID_FILE")
|
||||
releaseFile := os.Getenv("THOTHCTL_WINDOWS_RELEASE_FILE")
|
||||
if mode == "grandchild" {
|
||||
if err := os.WriteFile(pidFile, []byte(strconv.Itoa(os.Getpid())), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for {
|
||||
time.Sleep(time.Hour)
|
||||
}
|
||||
}
|
||||
child := exec.Command(os.Args[0], "-test.run=TestWindowsProcessHelper", "--")
|
||||
child.Env = append(os.Environ(),
|
||||
"THOTHCTL_WINDOWS_HELPER=grandchild",
|
||||
"THOTHCTL_WINDOWS_PID_FILE="+pidFile,
|
||||
)
|
||||
if err := child.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitForWindowsPIDFile(t, pidFile)
|
||||
waitForWindowsReleaseFile(t, releaseFile)
|
||||
if mode == "exit" {
|
||||
return
|
||||
}
|
||||
if mode == "cancel" {
|
||||
for {
|
||||
time.Sleep(time.Hour)
|
||||
}
|
||||
}
|
||||
for {
|
||||
_, _ = os.Stdout.Write([]byte("overflow\n"))
|
||||
}
|
||||
}
|
||||
|
||||
func waitForWindowsPIDFile(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
contents, err := os.ReadFile(path)
|
||||
if err == nil {
|
||||
pid, parseErr := strconv.Atoi(strings.TrimSpace(string(contents)))
|
||||
if parseErr == nil && pid > 0 {
|
||||
return
|
||||
}
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
t.Fatalf("grandchild PID was not recorded in %s", path)
|
||||
}
|
||||
|
||||
func waitForWindowsReleaseFile(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
t.Fatalf("test did not release Windows helper via %s", path)
|
||||
}
|
||||
|
||||
func openWindowsProcessForTest(t *testing.T, pid int) windows.Handle {
|
||||
t.Helper()
|
||||
const access = windows.SYNCHRONIZE | windows.PROCESS_QUERY_LIMITED_INFORMATION
|
||||
handle, err := windows.OpenProcess(access, false, uint32(pid))
|
||||
if err != nil {
|
||||
t.Fatalf("OpenProcess(%d): %v", pid, err)
|
||||
}
|
||||
return handle
|
||||
}
|
||||
|
||||
func assertWindowsProcessTerminated(t *testing.T, handle windows.Handle, pid int) {
|
||||
t.Helper()
|
||||
const stillActive = 259
|
||||
status, err := windows.WaitForSingleObject(handle, uint32((5*time.Second)/time.Millisecond))
|
||||
if err != nil {
|
||||
t.Fatalf("WaitForSingleObject(%d): %v", pid, err)
|
||||
}
|
||||
if status != windows.WAIT_OBJECT_0 {
|
||||
t.Fatalf("WaitForSingleObject(%d) = %#x, want signaled terminated process", pid, status)
|
||||
}
|
||||
var exitCode uint32
|
||||
if err := windows.GetExitCodeProcess(handle, &exitCode); err != nil {
|
||||
t.Fatalf("GetExitCodeProcess(%d): %v", pid, err)
|
||||
}
|
||||
if exitCode == stillActive {
|
||||
t.Fatalf("GetExitCodeProcess(%d) still reports STILL_ACTIVE", pid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnedProcessTerminatesDescendantOnOverflowCancelAndParentExit(t *testing.T) {
|
||||
for _, mode := range []string{"overflow", "cancel", "exit"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
pidFile, err := os.CreateTemp("", "thothctl-windows-grandchild-*.pid")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pidPath := pidFile.Name()
|
||||
_ = pidFile.Close()
|
||||
_ = os.Remove(pidPath)
|
||||
defer os.Remove(pidPath)
|
||||
releaseFile, err := os.CreateTemp("", "thothctl-windows-release-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
releasePath := releaseFile.Name()
|
||||
_ = releaseFile.Close()
|
||||
_ = os.Remove(releasePath)
|
||||
defer os.Remove(releasePath)
|
||||
t.Setenv("THOTHCTL_WINDOWS_HELPER", mode)
|
||||
t.Setenv("THOTHCTL_WINDOWS_PID_FILE", pidPath)
|
||||
t.Setenv("THOTHCTL_WINDOWS_RELEASE_FILE", releasePath)
|
||||
ctx := context.Background()
|
||||
var cancel context.CancelFunc
|
||||
if mode == "cancel" {
|
||||
ctx, cancel = context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
}
|
||||
result := make(chan error, 1)
|
||||
go func() {
|
||||
_, runErr := runBoundedWindows(ctx, os.Args[0], []string{"-test.run=TestWindowsProcessHelper", "--"}, nil, CaptureLimits{StdoutBytes: 1024, StderrBytes: 1024})
|
||||
result <- runErr
|
||||
}()
|
||||
|
||||
waitForWindowsPIDFile(t, pidPath)
|
||||
contents, err := os.ReadFile(pidPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pid, err := strconv.Atoi(strings.TrimSpace(string(contents)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
handle := openWindowsProcessForTest(t, pid)
|
||||
defer windows.CloseHandle(handle)
|
||||
if err := os.WriteFile(releasePath, []byte("release"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mode == "cancel" {
|
||||
cancel()
|
||||
}
|
||||
select {
|
||||
case runErr := <-result:
|
||||
if mode == "overflow" && !errors.Is(runErr, ErrOutputLimit) {
|
||||
t.Fatalf("overflow error = %v, want ErrOutputLimit", runErr)
|
||||
}
|
||||
if mode == "cancel" && !errors.Is(runErr, context.Canceled) {
|
||||
t.Fatalf("cancel error = %v, want context.Canceled", runErr)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("owned process did not terminate")
|
||||
}
|
||||
assertWindowsProcessTerminated(t, handle, pid)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -9,48 +9,155 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Result is the captured output and process exit code for one Docker invocation.
|
||||
var ErrOutputLimit = errors.New("compose output limit exceeded")
|
||||
|
||||
type CaptureLimits struct {
|
||||
StdoutBytes int64
|
||||
StderrBytes int64
|
||||
}
|
||||
type Result struct {
|
||||
Stdout string
|
||||
Stderr string
|
||||
ExitCode int
|
||||
}
|
||||
type Runner struct{ binary string }
|
||||
|
||||
// Runner executes the Docker CLI. It never invokes a shell.
|
||||
type Runner struct {
|
||||
binary string
|
||||
}
|
||||
|
||||
// NewRunner returns a runner for binary. An empty binary selects docker from PATH.
|
||||
func NewRunner(binary string) Runner {
|
||||
if binary == "" {
|
||||
binary = "docker"
|
||||
}
|
||||
return Runner{binary: binary}
|
||||
}
|
||||
|
||||
// Run invokes Docker with the supplied argument array and optional standard input.
|
||||
func (r Runner) Run(ctx context.Context, args []string, stdin io.Reader) (Result, error) {
|
||||
command := exec.CommandContext(ctx, r.binary, args...)
|
||||
command.Stdin = stdin
|
||||
var stdout, stderr bytes.Buffer
|
||||
command.Stdout = &stdout
|
||||
command.Stderr = &stderr
|
||||
err := command.Run()
|
||||
result := Result{Stdout: stdout.String(), Stderr: stderr.String()}
|
||||
c := exec.CommandContext(ctx, r.binary, args...)
|
||||
c.Stdin = stdin
|
||||
var out, er bytes.Buffer
|
||||
c.Stdout = &out
|
||||
c.Stderr = &er
|
||||
err := c.Run()
|
||||
res := Result{Stdout: out.String(), Stderr: er.String()}
|
||||
if err == nil {
|
||||
return result, nil
|
||||
return res, nil
|
||||
}
|
||||
var exitError *exec.ExitError
|
||||
if errors.As(err, &exitError) {
|
||||
result.ExitCode = exitError.ExitCode()
|
||||
return result, err
|
||||
var x *exec.ExitError
|
||||
if errors.As(err, &x) {
|
||||
res.ExitCode = x.ExitCode()
|
||||
return res, err
|
||||
}
|
||||
if errors.Is(err, exec.ErrNotFound) || errors.Is(err, os.ErrNotExist) {
|
||||
result.ExitCode = 127
|
||||
return result, fmt.Errorf("%w: %w", exec.ErrNotFound, err)
|
||||
res.ExitCode = 127
|
||||
return res, fmt.Errorf("%w: %w", exec.ErrNotFound, err)
|
||||
}
|
||||
return result, err
|
||||
return res, err
|
||||
}
|
||||
|
||||
// RunBounded streams each pipe into a preallocated fixed-capacity collector. It owns the
|
||||
// process group and tears it down on the first overflow or cancellation.
|
||||
func (r Runner) RunBounded(ctx context.Context, args []string, stdin io.Reader, limits CaptureLimits) (Result, error) {
|
||||
if result, handled, err := runBoundedPlatform(ctx, r, args, stdin, limits); handled {
|
||||
return result, err
|
||||
}
|
||||
if limits.StdoutBytes < 0 || limits.StderrBytes < 0 {
|
||||
return Result{}, ErrOutputLimit
|
||||
}
|
||||
c := exec.Command(r.binary, args...)
|
||||
c.Stdin = stdin
|
||||
op, err := c.StdoutPipe()
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
ep, err := c.StderrPipe()
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
configureOwnedProcess(c)
|
||||
if err = c.Start(); err != nil {
|
||||
return Result{ExitCode: 127}, err
|
||||
}
|
||||
if err = registerOwnedProcess(c); err != nil {
|
||||
terminateOwnedProcess(c)
|
||||
_ = c.Wait()
|
||||
return Result{}, err
|
||||
}
|
||||
defer releaseOwnedProcess(c)
|
||||
type collector struct {
|
||||
buf []byte
|
||||
n int64
|
||||
overflow bool
|
||||
}
|
||||
out := collector{buf: make([]byte, limits.StdoutBytes)}
|
||||
er := collector{buf: make([]byte, limits.StderrBytes)}
|
||||
overflow := make(chan struct{}, 1)
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
read := func(rd io.Reader, max int64, col *collector) {
|
||||
defer wg.Done()
|
||||
chunk := make([]byte, 32*1024)
|
||||
for {
|
||||
n, e := rd.Read(chunk)
|
||||
if n > 0 {
|
||||
remain := max - col.n
|
||||
if remain > 0 {
|
||||
take := int64(n)
|
||||
if take > remain {
|
||||
take = remain
|
||||
}
|
||||
copy(col.buf[col.n:col.n+take], chunk[:take])
|
||||
col.n += take
|
||||
}
|
||||
if int64(n) > remain {
|
||||
col.overflow = true
|
||||
select {
|
||||
case overflow <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
if e != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
go read(op, limits.StdoutBytes, &out)
|
||||
go read(ep, limits.StderrBytes, &er)
|
||||
waited := make(chan error, 1)
|
||||
go func() { waited <- c.Wait() }()
|
||||
var waitErr error
|
||||
var cancelled error
|
||||
select {
|
||||
case waitErr = <-waited:
|
||||
terminateOwnedProcess(c)
|
||||
case <-overflow:
|
||||
terminateOwnedProcess(c)
|
||||
waitErr = <-waited
|
||||
case <-ctx.Done():
|
||||
cancelled = ctx.Err()
|
||||
terminateOwnedProcess(c)
|
||||
waitErr = <-waited
|
||||
}
|
||||
wg.Wait()
|
||||
res := Result{Stdout: string(out.buf[:out.n]), Stderr: string(er.buf[:er.n])}
|
||||
if out.overflow || er.overflow {
|
||||
return res, ErrOutputLimit
|
||||
}
|
||||
if cancelled != nil {
|
||||
return res, cancelled
|
||||
}
|
||||
if waitErr == nil {
|
||||
return res, nil
|
||||
}
|
||||
var ex *exec.ExitError
|
||||
if errors.As(waitErr, &ex) {
|
||||
res.ExitCode = ex.ExitCode()
|
||||
return res, waitErr
|
||||
}
|
||||
if errors.Is(waitErr, exec.ErrNotFound) || errors.Is(waitErr, os.ErrNotExist) {
|
||||
res.ExitCode = 127
|
||||
return res, fmt.Errorf("%w: %w", exec.ErrNotFound, waitErr)
|
||||
}
|
||||
return res, waitErr
|
||||
}
|
||||
|
||||
@@ -6,8 +6,11 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRunnerPassesEachArgumentWithoutShellSplitting(t *testing.T) {
|
||||
@@ -64,3 +67,108 @@ func writeExecutable(t *testing.T, contents string) string {
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestRunnerBoundedCapturesExactlyAtLimit(t *testing.T) {
|
||||
runner := NewRunner(writeExecutable(t, "#!/bin/sh\nprintf 1234567890\nprintf error12345 >&2\n"))
|
||||
result, err := runner.RunBounded(context.Background(), nil, nil, CaptureLimits{StdoutBytes: 10, StderrBytes: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Stdout != "1234567890" || result.Stderr != "error12345" {
|
||||
t.Fatalf("bounded result = %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerBoundedRejectsOneByteOverEachStream(t *testing.T) {
|
||||
for _, script := range []string{"#!/bin/sh\nprintf 12345678901\n", "#!/bin/sh\nprintf 12345678901 >&2\n"} {
|
||||
runner := NewRunner(writeExecutable(t, script))
|
||||
result, err := runner.RunBounded(context.Background(), nil, nil, CaptureLimits{StdoutBytes: 10, StderrBytes: 10})
|
||||
if !errors.Is(err, ErrOutputLimit) {
|
||||
t.Fatalf("err = %v result=%#v", err, result)
|
||||
}
|
||||
if len(result.Stdout) > 10 || len(result.Stderr) > 10 {
|
||||
t.Fatalf("retained overflow: %#v", result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerBoundedTerminatesInfiniteStreams(t *testing.T) {
|
||||
for _, script := range []string{"#!/bin/sh\nwhile :; do printf x; done", "#!/bin/sh\nwhile :; do printf x >&2; done"} {
|
||||
runner := NewRunner(writeExecutable(t, script))
|
||||
result, err := runner.RunBounded(context.Background(), nil, nil, CaptureLimits{StdoutBytes: 1024, StderrBytes: 1024})
|
||||
if !errors.Is(err, ErrOutputLimit) {
|
||||
t.Fatalf("err = %v result = %#v", err, result)
|
||||
}
|
||||
if len(result.Stdout) > 1024 || len(result.Stderr) > 1024 {
|
||||
t.Fatalf("retained overflow: %#v", result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerBoundedCancellationWinsOverChildKill(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
runner := NewRunner(writeExecutable(t, "#!/bin/sh\nsleep 30\n"))
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := runner.RunBounded(ctx, nil, nil, CaptureLimits{StdoutBytes: 32, StderrBytes: 32})
|
||||
done <- err
|
||||
}()
|
||||
cancel()
|
||||
select {
|
||||
case err := <-done:
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("cancellation did not terminate process")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerBoundedKillsGrandchildProcess(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("shell process-group fixture is Unix-specific")
|
||||
}
|
||||
pidFile := filepath.Join(t.TempDir(), "grandchild.pid")
|
||||
script := "#!/bin/sh\n(sleep 30) &\nprintf '%s' $! > '" + pidFile + "'\nwhile :; do printf x; done\n"
|
||||
runner := NewRunner(writeExecutable(t, script))
|
||||
_, err := runner.RunBounded(context.Background(), nil, nil, CaptureLimits{StdoutBytes: 64, StderrBytes: 64})
|
||||
if !errors.Is(err, ErrOutputLimit) {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
pidBytes, readErr := os.ReadFile(pidFile)
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
pid, err := strconv.Atoi(strings.TrimSpace(string(pidBytes)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if !processExists(pid) {
|
||||
return
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("grandchild pid %d remained alive after owned-process termination", pid)
|
||||
}
|
||||
|
||||
func TestRunnerBoundedTerminatesGrandchildHoldingPipe(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("shell process-group fixture is Unix-specific")
|
||||
}
|
||||
runner := NewRunner(writeExecutable(t, "#!/bin/sh\n(sleep 30; printf survivor) &\nwhile :; do printf x; done\n"))
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := runner.RunBounded(context.Background(), nil, nil, CaptureLimits{StdoutBytes: 64, StderrBytes: 64})
|
||||
done <- err
|
||||
}()
|
||||
select {
|
||||
case err := <-done:
|
||||
if !errors.Is(err, ErrOutputLimit) {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("grandchild kept owned pipe alive")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,7 +178,11 @@ func TestLoadRejectsRelativeInstallationPaths(t *testing.T) {
|
||||
func writeInstallation(t *testing.T, profile string) (string, string, string, string) {
|
||||
t.Helper()
|
||||
|
||||
root := filepath.Join(t.TempDir(), "installation folder with spaces")
|
||||
rootBase, err := filepath.EvalSymlinks(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root := filepath.Join(rootBase, "installation folder with spaces")
|
||||
projectDirectory := filepath.Join(root, "project directory with spaces")
|
||||
if err := os.MkdirAll(filepath.Join(projectDirectory, "deploy"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -4,22 +4,32 @@ package safeio
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
var ErrUnsafeFile = errors.New("unsafe file")
|
||||
|
||||
// ErrIndeterminateFile means publication cleanup could not establish whether a
|
||||
// private candidate is still named. Callers must reconcile the destination and
|
||||
// private stages before retrying; it is never a blind-retry-safe failure.
|
||||
var ErrIndeterminateFile = errors.New("indeterminate file state")
|
||||
|
||||
// ValidateCanonicalPath rejects relative or lexically non-canonical paths before they are opened.
|
||||
func ValidateCanonicalPath(path string) error {
|
||||
if !filepath.IsAbs(path) || filepath.Clean(path) != path || strings.Contains(path, string(filepath.Separator)+".."+string(filepath.Separator)) {
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
if err := validatePlatformPathSyntax(path); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readBoundedRegularFile(file *os.File, maximum int64) ([]byte, error) {
|
||||
func readBoundedRegularFile(file *os.File, maximum int64, before func()) ([]byte, error) {
|
||||
if maximum < 0 || maximum == int64(^uint64(0)>>1) {
|
||||
return nil, ErrUnsafeFile
|
||||
}
|
||||
@@ -27,9 +37,30 @@ func readBoundedRegularFile(file *os.File, maximum int64) ([]byte, error) {
|
||||
if err != nil || !info.Mode().IsRegular() {
|
||||
return nil, ErrUnsafeFile
|
||||
}
|
||||
if before != nil {
|
||||
before()
|
||||
}
|
||||
contents, err := io.ReadAll(io.LimitReader(file, maximum+1))
|
||||
if err != nil || int64(len(contents)) > maximum {
|
||||
return nil, ErrUnsafeFile
|
||||
}
|
||||
return contents, nil
|
||||
}
|
||||
|
||||
// ReadCanonicalUTF8 reads a canonical regular file with a strict byte bound and UTF-8 validation.
|
||||
func ReadCanonicalUTF8(path string, maximum int64) ([]byte, error) {
|
||||
contents, err := ReadCanonicalRegular(path, maximum)
|
||||
if err != nil || !utf8.Valid(contents) {
|
||||
return nil, ErrUnsafeFile
|
||||
}
|
||||
return contents, nil
|
||||
}
|
||||
|
||||
// WriteCanonicalExclusive creates a canonical regular file without following links or replacing
|
||||
// an existing leaf. The file is private to the caller and is never opened in truncate mode.
|
||||
func WriteCanonicalExclusive(path string, contents []byte, mode fs.FileMode) error {
|
||||
return writeCanonicalExclusive(path, contents, mode)
|
||||
}
|
||||
|
||||
// ValidateCanonicalOutputPath verifies every parent directory without creating the leaf.
|
||||
func ValidateCanonicalOutputPath(path string) error { return validateCanonicalOutputPath(path) }
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
//go:build darwin
|
||||
|
||||
package safeio
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"io/fs"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// ReadCanonicalRegular opens an absolute canonical path component by component from the root
|
||||
// descriptor. O_NOFOLLOW rejects symlinks at every component, and the open directory descriptors
|
||||
// prevent later parent replacement from redirecting the final open.
|
||||
func ReadCanonicalRegular(path string, maximum int64) ([]byte, error) {
|
||||
return readCanonicalRegularWithHook(path, maximum, nil)
|
||||
}
|
||||
|
||||
func readCanonicalRegularWithHook(path string, maximum int64, beforeRead func()) ([]byte, error) {
|
||||
if err := ValidateCanonicalPath(path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
components := strings.Split(strings.TrimPrefix(path, string(os.PathSeparator)), string(os.PathSeparator))
|
||||
if len(components) == 0 || components[0] == "" {
|
||||
return nil, ErrUnsafeFile
|
||||
}
|
||||
|
||||
directory, err := unix.Open(string(os.PathSeparator), unix.O_RDONLY|unix.O_CLOEXEC|unix.O_DIRECTORY, 0)
|
||||
if err != nil {
|
||||
return nil, ErrUnsafeFile
|
||||
}
|
||||
directories := []int{directory}
|
||||
defer func() { closeUnixDescriptors(directories) }()
|
||||
|
||||
for _, component := range components[:len(components)-1] {
|
||||
nextDirectory, err := unix.Openat(directory, component, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_DIRECTORY|unix.O_NOFOLLOW, 0)
|
||||
if err != nil {
|
||||
return nil, ErrUnsafeFile
|
||||
}
|
||||
directory = nextDirectory
|
||||
directories = append(directories, directory)
|
||||
}
|
||||
|
||||
descriptor, err := unix.Openat(directory, components[len(components)-1], unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW|unix.O_NONBLOCK, 0)
|
||||
if err != nil {
|
||||
return nil, ErrUnsafeFile
|
||||
}
|
||||
file := os.NewFile(uintptr(descriptor), "thothctl-safeio")
|
||||
if file == nil {
|
||||
unix.Close(descriptor)
|
||||
return nil, ErrUnsafeFile
|
||||
}
|
||||
defer file.Close()
|
||||
var before, after unix.Stat_t
|
||||
if err := unix.Fstat(int(file.Fd()), &before); err != nil || before.Nlink > 1 || before.Mode&unix.S_IFMT != unix.S_IFREG {
|
||||
return nil, ErrUnsafeFile
|
||||
}
|
||||
contents, err := readBoundedRegularFile(file, maximum, beforeRead)
|
||||
if err != nil {
|
||||
return nil, ErrUnsafeFile
|
||||
}
|
||||
// Checking the retained descriptor alone misses a pathname replacement while the
|
||||
// read is in progress. The parent descriptor and final name must still resolve to
|
||||
// the exact open file after reading.
|
||||
if err := unix.Fstat(int(file.Fd()), &after); err != nil || after.Nlink > 1 || after.Mode != before.Mode || after.Ino != before.Ino || after.Dev != before.Dev || after.Size != before.Size {
|
||||
return nil, ErrUnsafeFile
|
||||
}
|
||||
var named unix.Stat_t
|
||||
if err := unix.Fstatat(directory, components[len(components)-1], &named, unix.AT_SYMLINK_NOFOLLOW); err != nil || named.Nlink > 1 || named.Mode != before.Mode || named.Ino != before.Ino || named.Dev != before.Dev {
|
||||
return nil, ErrUnsafeFile
|
||||
}
|
||||
if !recheckUnixParents(components, directories) {
|
||||
return nil, ErrUnsafeFile
|
||||
}
|
||||
return contents, nil
|
||||
}
|
||||
|
||||
func closeUnixDescriptors(descriptors []int) {
|
||||
for _, descriptor := range descriptors {
|
||||
unix.Close(descriptor)
|
||||
}
|
||||
}
|
||||
|
||||
// Darwin uses a named stage because it has no relinkable O_TMPFILE equivalent.
|
||||
// The caller must provide a trusted parent namespace: same-UID namespace mutation
|
||||
// (including stage hard-link/replacement races and ancestor replacement) is outside
|
||||
// this mode's threat model. Under that precondition renameatx_np(RENAME_EXCL) is the
|
||||
// final fallible no-replace commit and removes the stage atomically.
|
||||
var darwinUnlinkat = unix.Unlinkat
|
||||
var darwinFstatat = unix.Fstatat
|
||||
var darwinCloseStage = func(file *os.File) error { return file.Close() }
|
||||
|
||||
func cleanupDarwinStage(dir int, stage string, staged *unix.Stat_t, closeStage func() error) error {
|
||||
var current unix.Stat_t
|
||||
statErr := darwinFstatat(dir, stage, ¤t, unix.AT_SYMLINK_NOFOLLOW)
|
||||
if statErr == unix.ENOENT {
|
||||
_ = closeStage()
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
if statErr != nil || staged == nil || current.Ino != staged.Ino || current.Dev != staged.Dev {
|
||||
_ = closeStage()
|
||||
return ErrIndeterminateFile
|
||||
}
|
||||
unlinkErr := darwinUnlinkat(dir, stage, 0)
|
||||
closeErr := closeStage()
|
||||
if unlinkErr == nil {
|
||||
// Successful unlink proves that no named candidate bytes remain; close
|
||||
// errors cannot make the unlinked inode reachable by pathname.
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
var after unix.Stat_t
|
||||
if verifyErr := darwinFstatat(dir, stage, &after, unix.AT_SYMLINK_NOFOLLOW); verifyErr == unix.ENOENT {
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
_ = closeErr
|
||||
return ErrIndeterminateFile
|
||||
}
|
||||
|
||||
func writeCanonicalExclusive(path string, contents []byte, mode fs.FileMode) error {
|
||||
if err := ValidateCanonicalPath(path); err != nil || len(contents) > 16<<20 || mode.Perm() != 0o600 {
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
components := strings.Split(strings.TrimPrefix(path, string(os.PathSeparator)), string(os.PathSeparator))
|
||||
if len(components) == 0 || components[0] == "" {
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
dir, err := unix.Open("/", unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
|
||||
if err != nil {
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
defer func() { _ = unix.Close(dir) }()
|
||||
for _, component := range components[:len(components)-1] {
|
||||
next, openErr := unix.Openat(dir, component, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0)
|
||||
if openErr != nil {
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
_ = unix.Close(dir)
|
||||
dir = next
|
||||
}
|
||||
if !recheckUnixParentPath(components[:len(components)-1], dir) {
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
stage, err := privateStageName()
|
||||
if err != nil {
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
stageFD, err := unix.Openat(dir, stage, unix.O_WRONLY|unix.O_CREAT|unix.O_EXCL|unix.O_CLOEXEC|unix.O_NOFOLLOW, uint32(mode.Perm()))
|
||||
if err != nil {
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
stageFile := os.NewFile(uintptr(stageFD), "thothctl-safeio-stage")
|
||||
if stageFile == nil {
|
||||
closeErr := unix.Close(stageFD)
|
||||
unlinkErr := darwinUnlinkat(dir, stage, 0)
|
||||
if closeErr != nil || unlinkErr != nil {
|
||||
return ErrIndeterminateFile
|
||||
}
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
closed := false
|
||||
closeStage := func() error {
|
||||
if closed {
|
||||
return nil
|
||||
}
|
||||
closed = true
|
||||
return darwinCloseStage(stageFile)
|
||||
}
|
||||
defer func() { _ = closeStage() }()
|
||||
var staged unix.Stat_t
|
||||
if err := unix.Fstat(stageFD, &staged); err != nil || staged.Nlink != 1 || staged.Mode&unix.S_IFMT != unix.S_IFREG {
|
||||
_ = closeStage()
|
||||
return ErrIndeterminateFile
|
||||
}
|
||||
fail := func() error { return cleanupDarwinStage(dir, stage, &staged, closeStage) }
|
||||
if err := stageFile.Chmod(mode); err != nil {
|
||||
return fail()
|
||||
}
|
||||
if n, err := stageFile.Write(contents); err != nil || n != len(contents) {
|
||||
return fail()
|
||||
}
|
||||
if err := stageFile.Sync(); err != nil {
|
||||
return fail()
|
||||
}
|
||||
var after unix.Stat_t
|
||||
if err := unix.Fstat(stageFD, &after); err != nil || after.Nlink != 1 || after.Mode&unix.S_IFMT != unix.S_IFREG || after.Size != int64(len(contents)) {
|
||||
return fail()
|
||||
}
|
||||
if !recheckUnixParentPath(components[:len(components)-1], dir) {
|
||||
return fail()
|
||||
}
|
||||
// Verify the named stage and its retained handle immediately before commit.
|
||||
var named unix.Stat_t
|
||||
if err := unix.Fstatat(dir, stage, &named, unix.AT_SYMLINK_NOFOLLOW); err != nil || named.Ino != staged.Ino || named.Dev != staged.Dev || named.Nlink != 1 {
|
||||
return fail()
|
||||
}
|
||||
if err := unix.Fstat(stageFD, &after); err != nil || after.Ino != staged.Ino || after.Dev != staged.Dev || after.Nlink != 1 {
|
||||
return fail()
|
||||
}
|
||||
if err := unix.Fsync(dir); err != nil {
|
||||
return fail()
|
||||
}
|
||||
if !recheckUnixParentPath(components[:len(components)-1], dir) {
|
||||
return fail()
|
||||
}
|
||||
if err := unix.RenameatxNp(dir, stage, dir, components[len(components)-1], unix.RENAME_EXCL); err != nil {
|
||||
return fail()
|
||||
}
|
||||
// renameatx_np is the final commit. Closing the still-open handle is ignored
|
||||
// after success and never triggers pathname cleanup or a false failure.
|
||||
_ = closeStage()
|
||||
return nil
|
||||
}
|
||||
func privateStageName() (string, error) {
|
||||
var random [16]byte
|
||||
if _, err := rand.Read(random[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return ".thothctl-candidate-" + hex.EncodeToString(random[:]), nil
|
||||
}
|
||||
|
||||
func validateCanonicalOutputPath(path string) error {
|
||||
if err := ValidateCanonicalPath(path); err != nil {
|
||||
return err
|
||||
}
|
||||
components := strings.Split(strings.TrimPrefix(path, string(os.PathSeparator)), string(os.PathSeparator))
|
||||
if len(components) < 2 || components[0] == "" {
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
dir, err := unix.Open("/", unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
|
||||
if err != nil {
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
defer func() { unix.Close(dir) }()
|
||||
for _, component := range components[:len(components)-1] {
|
||||
next, err := unix.Openat(dir, component, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0)
|
||||
if err != nil {
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
unix.Close(dir)
|
||||
dir = next
|
||||
}
|
||||
var st unix.Stat_t
|
||||
if err := unix.Fstatat(dir, components[len(components)-1], &st, unix.AT_SYMLINK_NOFOLLOW); err == nil {
|
||||
return ErrUnsafeFile
|
||||
} else if err != unix.ENOENT {
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func recheckUnixParents(components []string, retained []int) bool {
|
||||
if len(retained) != len(components) {
|
||||
return false
|
||||
}
|
||||
dir, err := unix.Open("/", unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer func() { unix.Close(dir) }()
|
||||
for i, component := range components[:len(components)-1] {
|
||||
next, e := unix.Openat(dir, component, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0)
|
||||
if e != nil {
|
||||
return false
|
||||
}
|
||||
var got, want unix.Stat_t
|
||||
if unix.Fstat(next, &got) != nil || unix.Fstat(retained[i+1], &want) != nil || got.Ino != want.Ino || got.Dev != want.Dev {
|
||||
unix.Close(next)
|
||||
return false
|
||||
}
|
||||
unix.Close(dir)
|
||||
dir = next
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func recheckUnixParentPath(components []string, retained int) bool {
|
||||
dir, err := unix.Open("/", unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer func() { unix.Close(dir) }()
|
||||
for _, component := range components {
|
||||
next, e := unix.Openat(dir, component, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0)
|
||||
if e != nil {
|
||||
return false
|
||||
}
|
||||
unix.Close(dir)
|
||||
dir = next
|
||||
}
|
||||
var got, want unix.Stat_t
|
||||
return unix.Fstat(dir, &got) == nil && unix.Fstat(retained, &want) == nil && got.Ino == want.Ino && got.Dev == want.Dev
|
||||
}
|
||||
|
||||
func validatePlatformPathSyntax(path string) error { return nil }
|
||||
@@ -0,0 +1,44 @@
|
||||
//go:build darwin
|
||||
|
||||
package safeio
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func TestWriteCanonicalExclusiveClassifiesUnlinkFailureAsIndeterminate(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
stage := filepath.Join(root, "stage")
|
||||
if err := os.WriteFile(stage, []byte("candidate"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir, err := unix.Open(root, unix.O_RDONLY|unix.O_DIRECTORY, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer unix.Close(dir)
|
||||
fd, err := unix.Openat(dir, "stage", unix.O_RDWR, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file := os.NewFile(uintptr(fd), "stage")
|
||||
if file == nil {
|
||||
t.Fatal("os.NewFile returned nil")
|
||||
}
|
||||
defer file.Close()
|
||||
var staged unix.Stat_t
|
||||
if err := unix.Fstat(fd, &staged); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldUnlink := darwinUnlinkat
|
||||
t.Cleanup(func() { darwinUnlinkat = oldUnlink })
|
||||
darwinUnlinkat = func(int, string, int) error { return errors.New("injected unlink failure") }
|
||||
if err := cleanupDarwinStage(dir, "stage", &staged, file.Close); !errors.Is(err, ErrIndeterminateFile) {
|
||||
t.Fatalf("unlink failure = %v, want ErrIndeterminateFile", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
//go:build linux
|
||||
|
||||
package safeio
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"io/fs"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// ReadCanonicalRegular opens an absolute canonical path component by component from the root
|
||||
// descriptor. O_NOFOLLOW rejects symlinks at every component, and the open directory descriptors
|
||||
// prevent later parent replacement from redirecting the final open.
|
||||
func ReadCanonicalRegular(path string, maximum int64) ([]byte, error) {
|
||||
return readCanonicalRegularWithHook(path, maximum, nil)
|
||||
}
|
||||
|
||||
func readCanonicalRegularWithHook(path string, maximum int64, beforeRead func()) ([]byte, error) {
|
||||
if err := ValidateCanonicalPath(path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
components := strings.Split(strings.TrimPrefix(path, string(os.PathSeparator)), string(os.PathSeparator))
|
||||
if len(components) == 0 || components[0] == "" {
|
||||
return nil, ErrUnsafeFile
|
||||
}
|
||||
|
||||
directory, err := unix.Open(string(os.PathSeparator), unix.O_RDONLY|unix.O_CLOEXEC|unix.O_DIRECTORY, 0)
|
||||
if err != nil {
|
||||
return nil, ErrUnsafeFile
|
||||
}
|
||||
directories := []int{directory}
|
||||
defer func() { closeUnixDescriptors(directories) }()
|
||||
|
||||
for _, component := range components[:len(components)-1] {
|
||||
nextDirectory, err := unix.Openat(directory, component, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_DIRECTORY|unix.O_NOFOLLOW, 0)
|
||||
if err != nil {
|
||||
return nil, ErrUnsafeFile
|
||||
}
|
||||
directory = nextDirectory
|
||||
directories = append(directories, directory)
|
||||
}
|
||||
|
||||
descriptor, err := unix.Openat(directory, components[len(components)-1], unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW|unix.O_NONBLOCK, 0)
|
||||
if err != nil {
|
||||
return nil, ErrUnsafeFile
|
||||
}
|
||||
file := os.NewFile(uintptr(descriptor), "thothctl-safeio")
|
||||
if file == nil {
|
||||
unix.Close(descriptor)
|
||||
return nil, ErrUnsafeFile
|
||||
}
|
||||
defer file.Close()
|
||||
var before, after unix.Stat_t
|
||||
if err := unix.Fstat(int(file.Fd()), &before); err != nil || before.Nlink > 1 || before.Mode&unix.S_IFMT != unix.S_IFREG {
|
||||
return nil, ErrUnsafeFile
|
||||
}
|
||||
contents, err := readBoundedRegularFile(file, maximum, beforeRead)
|
||||
if err != nil {
|
||||
return nil, ErrUnsafeFile
|
||||
}
|
||||
// Checking the retained descriptor alone misses a pathname replacement while the
|
||||
// read is in progress. The parent descriptor and final name must still resolve to
|
||||
// the exact open file after reading.
|
||||
if err := unix.Fstat(int(file.Fd()), &after); err != nil || after.Nlink > 1 || after.Mode != before.Mode || after.Ino != before.Ino || after.Dev != before.Dev || after.Size != before.Size {
|
||||
return nil, ErrUnsafeFile
|
||||
}
|
||||
var named unix.Stat_t
|
||||
if err := unix.Fstatat(directory, components[len(components)-1], &named, unix.AT_SYMLINK_NOFOLLOW); err != nil || named.Nlink > 1 || named.Mode != before.Mode || named.Ino != before.Ino || named.Dev != before.Dev {
|
||||
return nil, ErrUnsafeFile
|
||||
}
|
||||
if !recheckUnixParents(components, directories) {
|
||||
return nil, ErrUnsafeFile
|
||||
}
|
||||
return contents, nil
|
||||
}
|
||||
|
||||
func closeUnixDescriptors(descriptors []int) {
|
||||
for _, descriptor := range descriptors {
|
||||
unix.Close(descriptor)
|
||||
}
|
||||
}
|
||||
|
||||
func writeCanonicalExclusive(path string, contents []byte, mode fs.FileMode) error {
|
||||
if err := ValidateCanonicalPath(path); err != nil || len(contents) > 16<<20 || mode.Perm() != 0o600 {
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
components := strings.Split(strings.TrimPrefix(path, string(os.PathSeparator)), string(os.PathSeparator))
|
||||
if len(components) == 0 || components[0] == "" {
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
dir, err := unix.Open("/", unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
|
||||
if err != nil {
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
defer func() { _ = unix.Close(dir) }()
|
||||
for _, component := range components[:len(components)-1] {
|
||||
next, openErr := unix.Openat(dir, component, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0)
|
||||
if openErr != nil {
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
_ = unix.Close(dir)
|
||||
dir = next
|
||||
}
|
||||
// This is a capability publication API: the retained parent is the namespace
|
||||
// anchor. The lexical check is only a pre-commit diagnostic and cannot close an
|
||||
// ancestor rename race atomically.
|
||||
if !recheckUnixParentPath(components[:len(components)-1], dir) {
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
fd, err := unix.Openat(dir, ".", unix.O_RDWR|unix.O_CLOEXEC|unix.O_TMPFILE, uint32(mode.Perm()))
|
||||
if err != nil {
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
file := os.NewFile(uintptr(fd), "thothctl-safeio-anonymous")
|
||||
if file == nil {
|
||||
_ = unix.Close(fd)
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
closed := false
|
||||
closeFile := func() error {
|
||||
if closed {
|
||||
return nil
|
||||
}
|
||||
closed = true
|
||||
return file.Close()
|
||||
}
|
||||
fail := func() error {
|
||||
_ = closeFile()
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
var expected unix.Stat_t
|
||||
if err := unix.Fstat(fd, &expected); err != nil || expected.Nlink != 0 || expected.Mode&unix.S_IFMT != unix.S_IFREG {
|
||||
return fail()
|
||||
}
|
||||
if err := file.Chmod(mode); err != nil {
|
||||
return fail()
|
||||
}
|
||||
if n, err := file.Write(contents); err != nil || n != len(contents) {
|
||||
return fail()
|
||||
}
|
||||
if err := file.Sync(); err != nil {
|
||||
return fail()
|
||||
}
|
||||
var after unix.Stat_t
|
||||
if err := unix.Fstat(fd, &after); err != nil || after.Nlink != 0 || after.Mode&unix.S_IFMT != unix.S_IFREG || after.Size != int64(len(contents)) {
|
||||
return fail()
|
||||
}
|
||||
if !recheckUnixParentPath(components[:len(components)-1], dir) {
|
||||
return fail()
|
||||
}
|
||||
// All fallible preparation, checks, and syncs happen before the linearization
|
||||
// point. A directory sync here is best effort durability for the retained
|
||||
// parent; it cannot be used to report failure after Linkat.
|
||||
if err := unix.Fsync(dir); err != nil {
|
||||
return fail()
|
||||
}
|
||||
if !recheckUnixParentPath(components[:len(components)-1], dir) {
|
||||
return fail()
|
||||
}
|
||||
if err := unix.Linkat(fd, "", dir, components[len(components)-1], unix.AT_EMPTY_PATH); err != nil {
|
||||
return fail()
|
||||
}
|
||||
// Linkat is the final commit. Close errors and any post-commit observations
|
||||
// are deliberately ignored: returning ErrUnsafeFile here would lie about a
|
||||
// candidate that is already committed, and pathname cleanup would be racy.
|
||||
_ = closeFile()
|
||||
return nil
|
||||
}
|
||||
func privateStageName() (string, error) {
|
||||
var random [16]byte
|
||||
if _, err := rand.Read(random[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return ".thothctl-candidate-" + hex.EncodeToString(random[:]), nil
|
||||
}
|
||||
|
||||
func validateCanonicalOutputPath(path string) error {
|
||||
if err := ValidateCanonicalPath(path); err != nil {
|
||||
return err
|
||||
}
|
||||
components := strings.Split(strings.TrimPrefix(path, string(os.PathSeparator)), string(os.PathSeparator))
|
||||
if len(components) < 2 || components[0] == "" {
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
dir, err := unix.Open("/", unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
|
||||
if err != nil {
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
defer func() { unix.Close(dir) }()
|
||||
for _, component := range components[:len(components)-1] {
|
||||
next, err := unix.Openat(dir, component, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0)
|
||||
if err != nil {
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
unix.Close(dir)
|
||||
dir = next
|
||||
}
|
||||
var st unix.Stat_t
|
||||
if err := unix.Fstatat(dir, components[len(components)-1], &st, unix.AT_SYMLINK_NOFOLLOW); err == nil {
|
||||
return ErrUnsafeFile
|
||||
} else if err != unix.ENOENT {
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func recheckUnixParents(components []string, retained []int) bool {
|
||||
if len(retained) != len(components) {
|
||||
return false
|
||||
}
|
||||
dir, err := unix.Open("/", unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer func() { unix.Close(dir) }()
|
||||
for i, component := range components[:len(components)-1] {
|
||||
next, e := unix.Openat(dir, component, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0)
|
||||
if e != nil {
|
||||
return false
|
||||
}
|
||||
var got, want unix.Stat_t
|
||||
if unix.Fstat(next, &got) != nil || unix.Fstat(retained[i+1], &want) != nil || got.Ino != want.Ino || got.Dev != want.Dev {
|
||||
unix.Close(next)
|
||||
return false
|
||||
}
|
||||
unix.Close(dir)
|
||||
dir = next
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func recheckUnixParentPath(components []string, retained int) bool {
|
||||
dir, err := unix.Open("/", unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer func() { unix.Close(dir) }()
|
||||
for _, component := range components {
|
||||
next, e := unix.Openat(dir, component, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC|unix.O_NOFOLLOW, 0)
|
||||
if e != nil {
|
||||
return false
|
||||
}
|
||||
unix.Close(dir)
|
||||
dir = next
|
||||
}
|
||||
var got, want unix.Stat_t
|
||||
return unix.Fstat(dir, &got) == nil && unix.Fstat(retained, &want) == nil && got.Ino == want.Ino && got.Dev == want.Dev
|
||||
}
|
||||
|
||||
func validatePlatformPathSyntax(path string) error { return nil }
|
||||
@@ -0,0 +1,61 @@
|
||||
//go:build darwin || linux
|
||||
|
||||
package safeio
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReadCanonicalRegularRejectsReplacementDuringRead(t *testing.T) {
|
||||
root := canonicalSafeioTempDir(t)
|
||||
path := filepath.Join(root, "schema.sql")
|
||||
replacement := filepath.Join(root, "replacement.sql")
|
||||
parked := filepath.Join(root, "parked.sql")
|
||||
contents := make([]byte, 64<<20)
|
||||
if err := os.WriteFile(path, contents, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(replacement, contents, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
entered := make(chan struct{})
|
||||
proceed := make(chan struct{})
|
||||
result := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := readCanonicalRegularWithHook(path, int64(len(contents)), func() {
|
||||
close(entered)
|
||||
<-proceed
|
||||
})
|
||||
result <- err
|
||||
}()
|
||||
<-entered
|
||||
if err := os.Rename(path, parked); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Rename(replacement, path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
close(proceed)
|
||||
err := <-result
|
||||
if !errors.Is(err, ErrUnsafeFile) {
|
||||
t.Fatalf("replacement during read error = %v, want ErrUnsafeFile", err)
|
||||
}
|
||||
}
|
||||
|
||||
func canonicalSafeioTempDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
root, err := filepath.EvalSymlinks(os.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
directory, err := os.MkdirTemp(root, "thothctl-safeio-test-")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(directory) })
|
||||
return directory
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/aritmolab/thothii/tools/thothctl/internal/testsupport"
|
||||
@@ -41,3 +42,118 @@ func TestReadCanonicalRegularRejectsFinalAndParentSymlinks(t *testing.T) {
|
||||
t.Fatalf("final symlink error = %v, want ErrUnsafeFile", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadCanonicalUTF8RejectsNonUTF8AndBounds(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "input.sql")
|
||||
if err := os.WriteFile(path, []byte("\xff"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ReadCanonicalUTF8(path, 1024); !errors.Is(err, ErrUnsafeFile) {
|
||||
t.Fatalf("ReadCanonicalUTF8 invalid UTF-8 = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("12345"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ReadCanonicalUTF8(path, 4); !errors.Is(err, ErrUnsafeFile) {
|
||||
t.Fatalf("ReadCanonicalUTF8 oversized = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadCanonicalUTF8RejectsSQLLargerThanOneMiB(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "schema.sql")
|
||||
if err := os.WriteFile(path, make([]byte, (1<<20)+1), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ReadCanonicalUTF8(path, 1<<20); !errors.Is(err, ErrUnsafeFile) {
|
||||
t.Fatalf("ReadCanonicalUTF8 1 MiB + 1 SQL = %v, want ErrUnsafeFile", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteCanonicalExclusiveRejectsExistingAndCreatesPrivateFile(t *testing.T) {
|
||||
root, err := filepath.EvalSymlinks(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(root, "candidate.yaml")
|
||||
if err := WriteCanonicalExclusive(path, []byte("ok"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
contents, err := os.ReadFile(path)
|
||||
if err != nil || string(contents) != "ok" {
|
||||
t.Fatalf("output = %q, %v", contents, err)
|
||||
}
|
||||
if err := WriteCanonicalExclusive(path, []byte("replace"), 0o600); !errors.Is(err, ErrUnsafeFile) {
|
||||
t.Fatalf("replacement = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteCanonicalExclusiveCleansPrivateStageWhenPublicationRacesExistingLeaf(t *testing.T) {
|
||||
root, err := filepath.EvalSymlinks(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(root, "candidate.yaml")
|
||||
if err := os.WriteFile(path, []byte("attacker"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := WriteCanonicalExclusive(path, []byte("candidate"), 0o600); !errors.Is(err, ErrUnsafeFile) {
|
||||
t.Fatalf("write error=%v", err)
|
||||
}
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if strings.HasPrefix(entry.Name(), ".thothctl-candidate-") {
|
||||
t.Fatalf("private stage leaked: %s", entry.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadCanonicalRegularRejectsHardlinkAndDirectory(t *testing.T) {
|
||||
root, err := filepath.EvalSymlinks(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
original := filepath.Join(root, "original.sql")
|
||||
if err := os.WriteFile(original, []byte("select 1"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hardlink := filepath.Join(root, "hardlink.sql")
|
||||
if err := os.Link(original, hardlink); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ReadCanonicalRegular(hardlink, 1024); !errors.Is(err, ErrUnsafeFile) {
|
||||
t.Fatalf("hardlink error = %v, want ErrUnsafeFile", err)
|
||||
}
|
||||
directory := filepath.Join(root, "directory.sql")
|
||||
if err := os.Mkdir(directory, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ReadCanonicalRegular(directory, 1024); !errors.Is(err, ErrUnsafeFile) {
|
||||
t.Fatalf("directory error = %v, want ErrUnsafeFile", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCanonicalOutputPathRejectsExistingDirectoryAndSymlink(t *testing.T) {
|
||||
root, err := filepath.EvalSymlinks(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
directory := filepath.Join(root, "existing.yaml")
|
||||
if err := os.Mkdir(directory, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ValidateCanonicalOutputPath(directory); !errors.Is(err, ErrUnsafeFile) {
|
||||
t.Fatalf("existing directory error = %v, want ErrUnsafeFile", err)
|
||||
}
|
||||
target := filepath.Join(root, "target.yaml")
|
||||
if err := os.WriteFile(target, []byte("target"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
link := filepath.Join(root, "link.yaml")
|
||||
testsupport.SymlinkOrSkip(t, target, link)
|
||||
if err := ValidateCanonicalOutputPath(link); !errors.Is(err, ErrUnsafeFile) {
|
||||
t.Fatalf("output symlink error = %v, want ErrUnsafeFile", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package safeio
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// ReadCanonicalRegular opens an absolute canonical path component by component from the root
|
||||
// descriptor. O_NOFOLLOW rejects symlinks at every component, and the open directory descriptors
|
||||
// prevent later parent replacement from redirecting the final open.
|
||||
func ReadCanonicalRegular(path string, maximum int64) ([]byte, error) {
|
||||
if err := ValidateCanonicalPath(path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
components := strings.Split(strings.TrimPrefix(path, string(os.PathSeparator)), string(os.PathSeparator))
|
||||
if len(components) == 0 || components[0] == "" {
|
||||
return nil, ErrUnsafeFile
|
||||
}
|
||||
|
||||
directory, err := unix.Open(string(os.PathSeparator), unix.O_RDONLY|unix.O_CLOEXEC|unix.O_DIRECTORY, 0)
|
||||
if err != nil {
|
||||
return nil, ErrUnsafeFile
|
||||
}
|
||||
directories := []int{directory}
|
||||
defer func() { closeUnixDescriptors(directories) }()
|
||||
|
||||
for _, component := range components[:len(components)-1] {
|
||||
nextDirectory, err := unix.Openat(directory, component, unix.O_RDONLY|unix.O_CLOEXEC|unix.O_DIRECTORY|unix.O_NOFOLLOW, 0)
|
||||
if err != nil {
|
||||
return nil, ErrUnsafeFile
|
||||
}
|
||||
directory = nextDirectory
|
||||
directories = append(directories, directory)
|
||||
}
|
||||
|
||||
descriptor, err := unix.Openat(directory, components[len(components)-1], unix.O_RDONLY|unix.O_CLOEXEC|unix.O_NOFOLLOW|unix.O_NONBLOCK, 0)
|
||||
if err != nil {
|
||||
return nil, ErrUnsafeFile
|
||||
}
|
||||
file := os.NewFile(uintptr(descriptor), "thothctl-safeio")
|
||||
if file == nil {
|
||||
unix.Close(descriptor)
|
||||
return nil, ErrUnsafeFile
|
||||
}
|
||||
defer file.Close()
|
||||
return readBoundedRegularFile(file, maximum)
|
||||
}
|
||||
|
||||
func closeUnixDescriptors(descriptors []int) {
|
||||
for _, descriptor := range descriptors {
|
||||
unix.Close(descriptor)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build !windows
|
||||
//go:build darwin || linux
|
||||
|
||||
package safeio
|
||||
|
||||
@@ -30,3 +30,51 @@ func TestReadCanonicalRegularRejectsNamedPipeWithoutBlocking(t *testing.T) {
|
||||
t.Fatalf("named pipe error = %v, want ErrUnsafeFile", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalDescriptorOwnershipDoesNotLeakAcrossNestedOperations(t *testing.T) {
|
||||
root, err := filepath.EvalSymlinks(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nested := filepath.Join(root, "one", "two")
|
||||
if err := os.MkdirAll(nested, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
input := filepath.Join(nested, "input.sql")
|
||||
if err := os.WriteFile(input, []byte("select 1"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fdCount := func() int {
|
||||
f, err := os.Open("/dev/fd")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
names, err := f.Readdirnames(-1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return len(names)
|
||||
}
|
||||
baseline := fdCount()
|
||||
for i := 0; i < 20; i++ {
|
||||
if _, err := ReadCanonicalRegular(input, 1024); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := validateCanonicalOutputPath(filepath.Join(nested, "out-"+string(rune('a'+i))+".yaml")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if got := fdCount(); got > baseline+2 {
|
||||
t.Fatalf("descriptor leak after read/validate: baseline=%d got=%d", baseline, got)
|
||||
}
|
||||
for i := 0; i < 20; i++ {
|
||||
path := filepath.Join(nested, "write-"+string(rune('a'+i))+".yaml")
|
||||
if err := writeCanonicalExclusive(path, []byte("ok"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if got := fdCount(); got > baseline+2 {
|
||||
t.Fatalf("descriptor leak after writes: baseline=%d got=%d", baseline, got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
//go:build !windows && !linux && !darwin
|
||||
|
||||
package safeio
|
||||
|
||||
import "io/fs"
|
||||
|
||||
// Unsupported Unix targets fail closed rather than borrowing a platform-specific
|
||||
// publication primitive. Add a dedicated implementation only after auditing that
|
||||
// target's namespace and no-replace guarantees.
|
||||
func ReadCanonicalRegular(string, int64) ([]byte, error) { return nil, ErrUnsafeFile }
|
||||
func writeCanonicalExclusive(string, []byte, fs.FileMode) error { return ErrUnsafeFile }
|
||||
func validateCanonicalOutputPath(string) error { return ErrUnsafeFile }
|
||||
func validatePlatformPathSyntax(string) error { return nil }
|
||||
@@ -3,14 +3,28 @@
|
||||
package safeio
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
const windowsRetainedHandleShareMode uint32 = windows.FILE_SHARE_READ | windows.FILE_SHARE_WRITE
|
||||
const (
|
||||
// Retained input handles deny delete sharing while permitting ordinary reads
|
||||
// and writes by trusted callers.
|
||||
windowsRetainedHandleShareMode uint32 = windows.FILE_SHARE_READ | windows.FILE_SHARE_WRITE
|
||||
// A discoverable stage is protected by mandatory zero-share semantics for its
|
||||
// entire lifetime. This denies reads, writes, rename, delete, and hard-link
|
||||
// acquisition by another handle until our final handle-relative rename.
|
||||
windowsOutputHandleShareMode uint32 = 0
|
||||
)
|
||||
|
||||
// ReadCanonicalRegular opens each component with FILE_FLAG_OPEN_REPARSE_POINT and rejects a
|
||||
// reparse point on the opened handle before opening the next component. Retained handles allow
|
||||
@@ -53,7 +67,29 @@ func ReadCanonicalRegular(path string, maximum int64) ([]byte, error) {
|
||||
return nil, ErrUnsafeFile
|
||||
}
|
||||
defer file.Close()
|
||||
return readBoundedRegularFile(file, maximum)
|
||||
var before, after windows.ByHandleFileInformation
|
||||
if windows.GetFileInformationByHandle(handle, &before) != nil || before.NumberOfLinks > 1 {
|
||||
return nil, ErrUnsafeFile
|
||||
}
|
||||
contents, err := readBoundedRegularFile(file, maximum, nil)
|
||||
if err != nil {
|
||||
return nil, ErrUnsafeFile
|
||||
}
|
||||
if windows.GetFileInformationByHandle(handle, &after) != nil || after.NumberOfLinks > 1 || !sameWindowsFile(before, after) {
|
||||
return nil, ErrUnsafeFile
|
||||
}
|
||||
// Re-opened pathname identity is checked as well as the retained handle. This
|
||||
// catches replacement by an ordinary local actor where share/delete policy permits it.
|
||||
check, err := openWindowsComponent(current, false)
|
||||
if err != nil {
|
||||
return nil, ErrUnsafeFile
|
||||
}
|
||||
defer windows.CloseHandle(check)
|
||||
var named windows.ByHandleFileInformation
|
||||
if windows.GetFileInformationByHandle(check, &named) != nil || named.NumberOfLinks > 1 || !sameWindowsFile(before, named) {
|
||||
return nil, ErrUnsafeFile
|
||||
}
|
||||
return contents, nil
|
||||
}
|
||||
|
||||
func openWindowsComponent(path string, directory bool) (windows.Handle, error) {
|
||||
@@ -80,7 +116,7 @@ func openWindowsComponent(path string, directory bool) (windows.Handle, error) {
|
||||
windows.CloseHandle(handle)
|
||||
return 0, err
|
||||
}
|
||||
if information.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 ||
|
||||
if information.NumberOfLinks > 1 || information.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 ||
|
||||
(directory && information.FileAttributes&windows.FILE_ATTRIBUTE_DIRECTORY == 0) ||
|
||||
(!directory && information.FileAttributes&windows.FILE_ATTRIBUTE_DIRECTORY != 0) {
|
||||
windows.CloseHandle(handle)
|
||||
@@ -94,3 +130,253 @@ func closeWindowsHandles(handles []windows.Handle) {
|
||||
windows.CloseHandle(handle)
|
||||
}
|
||||
}
|
||||
|
||||
var windowsDeleteHandle = deleteWindowsHandle
|
||||
var windowsCloseHandle = windows.CloseHandle
|
||||
var windowsCloseStage = func(file *os.File) error { return file.Close() }
|
||||
|
||||
func cleanupWindowsStage(handle windows.Handle, closeStage func() error) error {
|
||||
disposeErr := windowsDeleteHandle(handle)
|
||||
closeErr := closeStage()
|
||||
if disposeErr == nil && closeErr == nil {
|
||||
// A successful disposition plus close proves the named stage is gone.
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
// Either a failed disposition or an uncertain close leaves the named
|
||||
// candidate's reachability unresolved. Never classify this as retry-safe.
|
||||
return ErrIndeterminateFile
|
||||
}
|
||||
|
||||
func writeCanonicalExclusive(path string, contents []byte, mode fs.FileMode) error {
|
||||
if err := ValidateCanonicalPath(path); err != nil || len(contents) > 16<<20 || mode.Perm() != 0o600 {
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
parent, retainedParents, err := openWindowsParents(path)
|
||||
if err != nil {
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
defer closeWindowsHandles(retainedParents)
|
||||
if len(retainedParents) == 0 {
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
parentHandle := retainedParents[len(retainedParents)-1]
|
||||
securityDescriptor, securityAttributes, err := ownerOnlySecurityAttributes()
|
||||
if err != nil {
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
_ = securityDescriptor
|
||||
stageName, err := privateWindowsStageName()
|
||||
if err != nil {
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
stagePath := filepath.Join(parent, stageName)
|
||||
stageHandle, err := windows.CreateFile(windows.StringToUTF16Ptr(stagePath), windows.GENERIC_WRITE|windows.DELETE, windowsOutputHandleShareMode, securityAttributes, windows.CREATE_NEW, windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_OPEN_REPARSE_POINT, 0)
|
||||
if err != nil {
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
stageFile := os.NewFile(uintptr(stageHandle), "thothctl-safeio-stage")
|
||||
if stageFile == nil {
|
||||
disposeErr := windowsDeleteHandle(stageHandle)
|
||||
closeErr := windowsCloseHandle(stageHandle)
|
||||
if disposeErr != nil || closeErr != nil {
|
||||
return ErrIndeterminateFile
|
||||
}
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
closed := false
|
||||
closeStage := func() error {
|
||||
if closed {
|
||||
return nil
|
||||
}
|
||||
closed = true
|
||||
return windowsCloseStage(stageFile)
|
||||
}
|
||||
defer func() { _ = closeStage() }()
|
||||
var staged windows.ByHandleFileInformation
|
||||
if err := windows.GetFileInformationByHandle(stageHandle, &staged); err != nil || staged.NumberOfLinks != 1 || staged.FileAttributes&windows.FILE_ATTRIBUTE_REPARSE_POINT != 0 || staged.FileAttributes&windows.FILE_ATTRIBUTE_DIRECTORY != 0 {
|
||||
return cleanupWindowsStage(stageHandle, closeStage)
|
||||
}
|
||||
fail := func() error { return cleanupWindowsStage(stageHandle, closeStage) }
|
||||
if n, err := stageFile.Write(contents); err != nil || n != len(contents) {
|
||||
return fail()
|
||||
}
|
||||
if err := stageFile.Sync(); err != nil {
|
||||
return fail()
|
||||
}
|
||||
var after windows.ByHandleFileInformation
|
||||
if err := windows.GetFileInformationByHandle(stageHandle, &after); err != nil || after.NumberOfLinks != 1 || after.FileSizeHigh != uint32(uint64(len(contents))>>32) || after.FileSizeLow != uint32(len(contents)) {
|
||||
return fail()
|
||||
}
|
||||
// Keep the exact stage handle open with zero sharing through this final check
|
||||
// and atomic no-replace rename. No pathname reopen or cleanup is needed.
|
||||
if err := renameWindowsHandle(stageHandle, parentHandle, filepath.Base(path)); err != nil {
|
||||
return fail()
|
||||
}
|
||||
// Handle-relative rename is the final commit. Handle close is intentionally
|
||||
// ignored after success; no fallible observation or pathname cleanup follows.
|
||||
_ = closeStage()
|
||||
return nil
|
||||
}
|
||||
|
||||
func deleteWindowsHandle(handle windows.Handle) error {
|
||||
var disposition byte = 1
|
||||
return windows.SetFileInformationByHandle(handle, windows.FileDispositionInfo, &disposition, uint32(unsafe.Sizeof(disposition)))
|
||||
}
|
||||
|
||||
type windowsFileRenameInfo struct {
|
||||
ReplaceIfExists uint8
|
||||
RootDirectory windows.Handle
|
||||
FileNameLength uint32
|
||||
FileName [1]uint16
|
||||
}
|
||||
|
||||
func renameWindowsHandle(handle, parent windows.Handle, leaf string) error {
|
||||
name, err := windows.UTF16FromString(leaf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name = name[:len(name)-1]
|
||||
base := unsafe.Offsetof(windowsFileRenameInfo{}.FileName)
|
||||
buffer := make([]byte, int(base)+len(name)*2)
|
||||
info := (*windowsFileRenameInfo)(unsafe.Pointer(&buffer[0]))
|
||||
info.ReplaceIfExists = 0
|
||||
info.RootDirectory = parent
|
||||
info.FileNameLength = uint32(len(name) * 2)
|
||||
nameBytes := unsafe.Slice((*uint16)(unsafe.Pointer(&buffer[base])), len(name))
|
||||
copy(nameBytes, name)
|
||||
return windows.SetFileInformationByHandle(handle, windows.FileRenameInfo, &buffer[0], uint32(len(buffer)))
|
||||
}
|
||||
func privateWindowsStageName() (string, error) {
|
||||
var random [16]byte
|
||||
if _, err := rand.Read(random[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return ".thothctl-candidate-" + hex.EncodeToString(random[:]), nil
|
||||
}
|
||||
|
||||
func openWindowsParents(path string) (string, []windows.Handle, error) {
|
||||
volume := filepath.VolumeName(path)
|
||||
root := volume + string(filepath.Separator)
|
||||
components := strings.Split(strings.TrimPrefix(path, root), string(filepath.Separator))
|
||||
if volume == "" || len(components) < 2 || components[0] == "" {
|
||||
return "", nil, ErrUnsafeFile
|
||||
}
|
||||
current := root
|
||||
parents := make([]windows.Handle, 0, len(components)-1)
|
||||
for _, component := range components[:len(components)-1] {
|
||||
current = filepath.Join(current, component)
|
||||
h, err := openWindowsComponent(current, true)
|
||||
if err != nil {
|
||||
closeWindowsHandles(parents)
|
||||
return "", nil, ErrUnsafeFile
|
||||
}
|
||||
parents = append(parents, h)
|
||||
}
|
||||
return current, parents, nil
|
||||
}
|
||||
|
||||
func validateCanonicalOutputPath(path string) error {
|
||||
if err := ValidateCanonicalPath(path); err != nil {
|
||||
return err
|
||||
}
|
||||
current, parents, err := openWindowsParents(path)
|
||||
if err != nil {
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
defer closeWindowsHandles(parents)
|
||||
if _, err := os.Lstat(filepath.Join(current, filepath.Base(path))); err == nil || !os.IsNotExist(err) {
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePlatformPathSyntax(path string) error {
|
||||
// Win32 device namespaces and alternate data streams do not represent an
|
||||
// independent regular file with owner-only output permissions.
|
||||
lower := strings.ToLower(path)
|
||||
if strings.HasPrefix(lower, `\\?\`) || strings.HasPrefix(lower, `\\.\`) ||
|
||||
strings.HasPrefix(lower, `\device\`) || strings.HasPrefix(lower, `\??\`) || strings.HasPrefix(path, `\\`) {
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
volume := filepath.VolumeName(path)
|
||||
for _, component := range strings.Split(strings.TrimPrefix(path, volume+string(filepath.Separator)), string(filepath.Separator)) {
|
||||
if strings.Contains(component, ":") || strings.HasSuffix(component, " ") || strings.HasSuffix(component, ".") || isWindowsDeviceComponent(component) {
|
||||
return ErrUnsafeFile
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Win32 aliases these names to devices, even when an extension is appended.
|
||||
// Rejecting them lexically is required because CreateFile does not promise an
|
||||
// independent regular leaf for a path containing one of these components.
|
||||
func isWindowsDeviceComponent(component string) bool {
|
||||
base := strings.ToUpper(component)
|
||||
if i := strings.IndexByte(base, '.'); i >= 0 {
|
||||
base = base[:i]
|
||||
}
|
||||
switch base {
|
||||
case "CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$":
|
||||
return true
|
||||
}
|
||||
if len(base) == 4 && (strings.HasPrefix(base, "COM") || strings.HasPrefix(base, "LPT")) {
|
||||
if base[3] >= '1' && base[3] <= '9' {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Unicode superscript 1, 2 and 3 are accepted as COM/LPT suffixes by
|
||||
// Win32's device-name compatibility rules.
|
||||
if len([]rune(base)) == 4 && (strings.HasPrefix(base, "COM") || strings.HasPrefix(base, "LPT")) {
|
||||
suffix := []rune(base)[3]
|
||||
return suffix == '¹' || suffix == '²' || suffix == '³'
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func sameWindowsFile(a, b windows.ByHandleFileInformation) bool {
|
||||
return a.VolumeSerialNumber == b.VolumeSerialNumber && a.FileIndexHigh == b.FileIndexHigh && a.FileIndexLow == b.FileIndexLow
|
||||
}
|
||||
|
||||
// ownerOnlySecurityAttributes builds a non-inheriting DACL granting the current
|
||||
// token's user read/write access and no access to inherited or other trustees.
|
||||
// The descriptor is retained by the returned Go value for the duration of CreateFile.
|
||||
func ownerOnlySecurityAttributes() (*windows.SECURITY_DESCRIPTOR, *windows.SecurityAttributes, error) {
|
||||
user, err := windows.GetCurrentProcessToken().GetTokenUser()
|
||||
if err != nil || user == nil || user.User.Sid == nil {
|
||||
return nil, nil, ErrUnsafeFile
|
||||
}
|
||||
var pinner runtime.Pinner
|
||||
pinner.Pin(user.User.Sid)
|
||||
defer pinner.Unpin()
|
||||
trustee := windows.TRUSTEE{
|
||||
TrusteeForm: windows.TRUSTEE_IS_SID,
|
||||
TrusteeType: windows.TRUSTEE_IS_USER,
|
||||
TrusteeValue: windows.TrusteeValueFromSID(user.User.Sid),
|
||||
}
|
||||
entries := []windows.EXPLICIT_ACCESS{{
|
||||
AccessPermissions: windows.FILE_GENERIC_READ | windows.FILE_GENERIC_WRITE | windows.DELETE,
|
||||
AccessMode: windows.SET_ACCESS,
|
||||
Inheritance: windows.NO_INHERITANCE,
|
||||
Trustee: trustee,
|
||||
}}
|
||||
descriptor, err := windows.BuildSecurityDescriptor(nil, nil, entries, nil, nil)
|
||||
if err != nil || descriptor == nil {
|
||||
return nil, nil, ErrUnsafeFile
|
||||
}
|
||||
acl, _, err := descriptor.DACL()
|
||||
if err != nil || acl == nil {
|
||||
return nil, nil, ErrUnsafeFile
|
||||
}
|
||||
if err := descriptor.SetControl(windows.SE_DACL_PROTECTED, windows.SE_DACL_PROTECTED); err != nil {
|
||||
return nil, nil, ErrUnsafeFile
|
||||
}
|
||||
// BuildSecurityDescriptor returns a self-relative descriptor. Re-using its
|
||||
// DACL as the creation descriptor is valid, and the explicit DACL has no
|
||||
// inheritable ACEs; the protected flag is applied by the kernel on creation.
|
||||
_ = acl
|
||||
attrs := &windows.SecurityAttributes{
|
||||
Length: uint32(unsafe.Sizeof(windows.SecurityAttributes{})),
|
||||
SecurityDescriptor: descriptor,
|
||||
}
|
||||
return descriptor, attrs, nil
|
||||
}
|
||||
|
||||
@@ -3,19 +3,32 @@
|
||||
package safeio
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/aritmolab/thothii/tools/thothctl/internal/testsupport"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
const expectedWindowsRetainedHandleShareMode = windows.FILE_SHARE_READ | windows.FILE_SHARE_WRITE
|
||||
const expectedWindowsOutputHandleShareMode = 0
|
||||
|
||||
// Keep this contract compile-enforced so Windows cross-test compilation catches a future
|
||||
// FILE_SHARE_DELETE regression even when the tests are compiled on a non-Windows host.
|
||||
var _ [windowsRetainedHandleShareMode - expectedWindowsRetainedHandleShareMode]struct{}
|
||||
var _ [expectedWindowsRetainedHandleShareMode - windowsRetainedHandleShareMode]struct{}
|
||||
var _ [windowsOutputHandleShareMode - expectedWindowsOutputHandleShareMode]struct{}
|
||||
var _ [expectedWindowsOutputHandleShareMode - windowsOutputHandleShareMode]struct{}
|
||||
|
||||
func TestValidateCanonicalPathRejectsWindowsNamespacesAndAlternateStreams(t *testing.T) {
|
||||
for _, path := range []string{`C:\dir\existing.txt:candidate`, `\\?\C:\dir\candidate`, `\\.\pipe\candidate`, `\Device\HarddiskVolume1\candidate`, `\??\C:\candidate`, `C:\dir\NUL`, `C:\dir\nul.txt`, `C:\dir\COM1`, `C:\dir\LPT9.log`, `C:\dir\CONIN$`, `C:\dir\candidate.yaml.`, `C:\dir\candidate.yaml `} {
|
||||
if err := ValidateCanonicalPath(path); !errors.Is(err, ErrUnsafeFile) {
|
||||
t.Errorf("ValidateCanonicalPath(%q) = %v, want ErrUnsafeFile", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenWindowsComponentBlocksMutationWhileHandleIsRetained(t *testing.T) {
|
||||
t.Run("parent rename", func(t *testing.T) {
|
||||
@@ -66,3 +79,146 @@ func TestOpenWindowsComponentBlocksMutationWhileHandleIsRetained(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestWindowsStageHandleDeniesReadRenameDeleteAndHardlink(t *testing.T) {
|
||||
root := filepath.Join(t.TempDir(), "parent")
|
||||
if err := os.Mkdir(root, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
securityDescriptor, securityAttributes, err := ownerOnlySecurityAttributes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = securityDescriptor
|
||||
stagePath := filepath.Join(root, ".thothctl-candidate-test")
|
||||
h, err := windows.CreateFile(windows.StringToUTF16Ptr(stagePath), windows.GENERIC_WRITE|windows.DELETE, 0, securityAttributes, windows.CREATE_NEW, windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_OPEN_REPARSE_POINT, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
closed := false
|
||||
defer func() {
|
||||
if !closed {
|
||||
_ = windows.CloseHandle(h)
|
||||
}
|
||||
}()
|
||||
if _, err := windows.CreateFile(windows.StringToUTF16Ptr(stagePath), windows.GENERIC_READ, windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, nil, windows.OPEN_EXISTING, windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_OPEN_REPARSE_POINT, 0); err == nil {
|
||||
t.Fatal("stage read succeeded while zero-share handle was open")
|
||||
}
|
||||
if err := os.Rename(stagePath, stagePath+"-renamed"); err == nil {
|
||||
t.Fatal("stage rename succeeded while handle was open")
|
||||
}
|
||||
if err := os.Link(stagePath, filepath.Join(root, "stolen")); err == nil {
|
||||
t.Fatal("stage hardlink succeeded while handle was open")
|
||||
}
|
||||
if err := os.Remove(stagePath); err == nil {
|
||||
t.Fatal("stage delete succeeded while handle was open")
|
||||
}
|
||||
if err := deleteWindowsHandle(h); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := windows.CloseHandle(h); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
closed = true
|
||||
if _, err := os.Stat(stagePath); !os.IsNotExist(err) {
|
||||
t.Fatalf("disposed stage remains: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteCanonicalExclusiveClassifiesDispositionFailureAsIndeterminate(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "candidate.yaml")
|
||||
if err := os.WriteFile(path, []byte("existing"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldDelete, oldClose := windowsDeleteHandle, windowsCloseStage
|
||||
t.Cleanup(func() { windowsDeleteHandle, windowsCloseStage = oldDelete, oldClose })
|
||||
windowsDeleteHandle = func(windows.Handle) error { return errors.New("injected disposition failure") }
|
||||
if err := writeCanonicalExclusive(path, []byte("candidate"), 0o600); !errors.Is(err, ErrIndeterminateFile) {
|
||||
t.Fatalf("disposition failure = %v, want ErrIndeterminateFile", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteCanonicalExclusiveClassifiesCloseFailureAsIndeterminate(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "candidate.yaml")
|
||||
if err := os.WriteFile(path, []byte("existing"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldDelete, oldClose := windowsDeleteHandle, windowsCloseStage
|
||||
t.Cleanup(func() { windowsDeleteHandle, windowsCloseStage = oldDelete, oldClose })
|
||||
windowsCloseStage = func(*os.File) error { return errors.New("injected close failure") }
|
||||
if err := writeCanonicalExclusive(path, []byte("candidate"), 0o600); !errors.Is(err, ErrIndeterminateFile) {
|
||||
t.Fatalf("close failure = %v, want ErrIndeterminateFile", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteCanonicalExclusiveRequiresRestrictiveMode(t *testing.T) {
|
||||
if err := writeCanonicalExclusive(`C:\\tmp\\thothctl-output.yaml`, []byte("x"), 0o640); err == nil {
|
||||
t.Fatal("accepted non-restrictive output mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteCanonicalExclusiveCreatesProtectedOwnerOnlyDACL(t *testing.T) {
|
||||
root, err := filepath.EvalSymlinks(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(root, "candidate.yaml")
|
||||
if err := writeCanonicalExclusive(path, []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sd, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT, windows.DACL_SECURITY_INFORMATION)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
control, _, err := sd.Control()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if control&windows.SE_DACL_PROTECTED == 0 {
|
||||
t.Fatalf("output DACL control = %#x, want protected", control)
|
||||
}
|
||||
acl, _, err := sd.DACL()
|
||||
if err != nil || acl == nil || acl.AceCount != 1 {
|
||||
t.Fatalf("output DACL = %#v, err=%v; want one owner ACE", acl, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteCanonicalExclusiveAllowsOwnerOnlyWriteAndIdentityRecheck(t *testing.T) {
|
||||
root, err := filepath.EvalSymlinks(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(root, "candidate.yaml")
|
||||
if err := writeCanonicalExclusive(path, []byte("candidates: []\n"), 0o600); err != nil {
|
||||
t.Fatalf("owner-only output write/recheck failed: %v", err)
|
||||
}
|
||||
contents, err := os.ReadFile(path)
|
||||
if err != nil || string(contents) != "candidates: []\n" {
|
||||
t.Fatalf("output = %q, err=%v", contents, err)
|
||||
}
|
||||
if err := writeCanonicalExclusive(path, []byte("replacement\n"), 0o600); !errors.Is(err, ErrUnsafeFile) {
|
||||
t.Fatalf("existing output replacement = %v, want ErrUnsafeFile", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteCanonicalExclusiveRejectsReparseParent(t *testing.T) {
|
||||
root, err := filepath.EvalSymlinks(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
realParent := filepath.Join(root, "real-parent")
|
||||
if err := os.Mkdir(realParent, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
linkedParent := filepath.Join(root, "linked-parent")
|
||||
testsupport.SymlinkOrSkip(t, realParent, linkedParent)
|
||||
path := filepath.Join(linkedParent, "candidate.yaml")
|
||||
if err := writeCanonicalExclusive(path, []byte("unsafe\n"), 0o600); !errors.Is(err, ErrUnsafeFile) {
|
||||
t.Fatalf("reparse parent output = %v, want ErrUnsafeFile", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(realParent, "candidate.yaml")); !os.IsNotExist(err) {
|
||||
t.Fatalf("reparse parent write created target: stat err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user