Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec0421e9fd | ||
|
|
55f3569e55 | ||
|
|
b9c3369e7b | ||
|
|
64e6b9664a | ||
|
|
67ee52624c | ||
|
|
0d2e573e0d | ||
|
|
bd416f7327 | ||
|
|
23e52c80de | ||
|
|
84084bba37 | ||
|
|
efd7d788d9 | ||
|
|
b1c510a097 | ||
|
|
5f3680a0fb | ||
|
|
4ff91e8d6e | ||
|
|
5f3a7f5975 | ||
|
|
043ffdfad6 | ||
|
|
6a4634dcf1 | ||
|
|
84804be9f8 | ||
|
|
c3caba94dd | ||
|
|
b1723c34c4 | ||
|
|
d6cdffea62 | ||
|
|
49333a2d35 | ||
|
|
b006b94479 | ||
|
|
bdcd8fcd28 | ||
|
|
cf90c1bd51 | ||
|
|
571a4bcaa2 | ||
|
|
9051463654 | ||
|
|
26c5605ff7 | ||
|
|
3535fda958 | ||
|
|
2953f6b608 | ||
|
|
45db3a239b | ||
|
|
7d826e46c0 | ||
|
|
648434a32e | ||
|
|
023b822f83 | ||
|
|
d8a29bfbdd | ||
|
|
a59624a68f | ||
|
|
5af4408194 | ||
|
|
e088abd60a | ||
|
|
803e9e9201 | ||
|
|
8c81996896 | ||
|
|
eed398e569 | ||
|
|
c8d276ddc6 | ||
|
|
2d1b714ebe | ||
|
|
c7e5f295e6 | ||
|
|
f52bf22e05 | ||
|
|
840344706f | ||
|
|
41b9fed4d5 | ||
|
|
36bf659ea9 | ||
|
|
4a67d60233 | ||
|
|
debb63d87b | ||
|
|
3943022a97 | ||
|
|
f5ec2d9313 | ||
|
|
82e2c91f42 | ||
|
|
8fe526dd6e | ||
|
|
e68e80a33d | ||
|
|
818563c408 | ||
|
|
50c546e42d | ||
|
|
651a5c7902 | ||
|
|
28db30bd78 | ||
|
|
cffa60772e | ||
|
|
8707ae1d46 | ||
|
|
ad744f0212 | ||
|
|
eba6148511 | ||
|
|
7b1d69a65b | ||
|
|
b891246664 | ||
|
|
8e778b9edb | ||
|
|
f114d0065a | ||
|
|
7b87e95427 | ||
|
|
a50475d687 | ||
|
|
a6a5bf2036 | ||
|
|
ce4c31a6fb | ||
|
|
538dc8ef56 | ||
|
|
7b7927bfe5 | ||
|
|
ae053961a3 | ||
|
|
4531746038 | ||
|
|
076c9742c5 |
@@ -17,6 +17,7 @@ frontend/vite.database-management-prototype.config.ts
|
||||
!deploy/env/*.env.example
|
||||
deploy/thothii.env
|
||||
deploy/secrets/
|
||||
deploy/psd/
|
||||
harness/workspaces/*.yaml
|
||||
!harness/workspaces/local.yaml
|
||||
!harness/workspaces/tht.example.yaml
|
||||
@@ -29,3 +30,7 @@ coverage/
|
||||
data/
|
||||
sessions/
|
||||
workspace-registry/
|
||||
|
||||
.tht/
|
||||
|
||||
deploy/local/
|
||||
|
||||
@@ -7,6 +7,11 @@ on:
|
||||
paths:
|
||||
- "docs/**"
|
||||
- "mkdocs.yml"
|
||||
- "scripts/build-docs.sh"
|
||||
- "scripts/verify-public-docs.py"
|
||||
- "scripts/test-verify-public-docs.py"
|
||||
- "scripts/verify-auth-docs.py"
|
||||
- "scripts/test-verify-auth-docs.py"
|
||||
- "docs/requirements.txt"
|
||||
- ".gitea/workflows/publish-docs.yml"
|
||||
workflow_dispatch:
|
||||
@@ -34,14 +39,25 @@ jobs:
|
||||
with:
|
||||
python-version: "3.x"
|
||||
cache: pip
|
||||
cache-dependency-path: docs/requirements.txt
|
||||
cache-dependency-path: docs/requirements.lock
|
||||
|
||||
- name: Install MkDocs dependencies
|
||||
run: python -m pip install -r docs/requirements.txt
|
||||
run: python -m pip install -r docs/requirements.lock
|
||||
|
||||
- name: Test public documentation boundary
|
||||
run: python scripts/test-verify-public-docs.py
|
||||
|
||||
- name: Test current authentication documentation
|
||||
run: |
|
||||
python scripts/verify-auth-docs.py auth
|
||||
python scripts/verify-auth-docs.py dwh
|
||||
python scripts/test-verify-auth-docs.py auth
|
||||
python scripts/test-verify-auth-docs.py dwh
|
||||
|
||||
- name: Build documentation
|
||||
# Some documented source files intentionally live outside docs/.
|
||||
run: mkdocs build
|
||||
run: |
|
||||
mkdocs build --strict
|
||||
python scripts/verify-public-docs.py
|
||||
|
||||
- name: Publish generated site to the pages branch
|
||||
working-directory: site
|
||||
|
||||
@@ -35,6 +35,7 @@ config/ca-chain.pem
|
||||
|
||||
# ThothII deployment configuration and secret values (keep only the README tracked)
|
||||
deploy/.env
|
||||
deploy/env/local.env
|
||||
deploy/compose.connector-secrets.local.yaml
|
||||
deploy/compose.psd-local.yaml
|
||||
deploy/workspaces/psd.yaml
|
||||
@@ -45,6 +46,8 @@ deploy/secrets/*
|
||||
# Per-installation configuration generated by `tht setup` (examples stay tracked).
|
||||
deploy/*/thothii-installation.yaml
|
||||
deploy/*/operator.env
|
||||
deploy/*/auth/
|
||||
deploy/*/generated/
|
||||
deploy/*/secrets/*
|
||||
!deploy/*/secrets/.gitkeep
|
||||
!deploy/*/secrets/*.example
|
||||
|
||||
@@ -83,7 +83,8 @@ frontend (React/SSE) → backend (Fastify) → pi --mode rpc → tht/harness →
|
||||
`SseHub` fans them out over SSE to the browser. The separate PostgreSQL catalog stores database
|
||||
metadata and sequential AI description-generation runs. Description generation samples the DWH
|
||||
through read-only connectors and calls a short-lived Python LiteLLM helper; it does not use Pi or
|
||||
expose a public CLI command. App settings still live in `backend/data/settings.json`.
|
||||
expose a public CLI command. Sessions, metadata generation, and embedding resolve models from the
|
||||
generated Installation Model Catalog; `thothii-installation.yaml` is its only authored source.
|
||||
|
||||
- **Human-in-the-loop gate contract.** The model proposes; a human reviewer decides at gates
|
||||
via widgets (`reviewer_select` = single pick — a chosen option carrying a `decision` payload
|
||||
@@ -97,13 +98,24 @@ frontend (React/SSE) → backend (Fastify) → pi --mode rpc → tht/harness →
|
||||
- **`tht`'s `-c`/`--config` is a PER-COMMAND option** — it must follow the subcommand, never
|
||||
precede it (`ThtRunner.buildArgv` enforces this; prepending caused live 500s).
|
||||
- **`--json` output must be pristine** (only valid JSON on stdout) — used as a machine contract.
|
||||
- **UI strings are English; document *content* stays the workspace language** (Italian for
|
||||
`psd`) because it's the real data. Only chrome/labels are English.
|
||||
- **Workspaces** (`harness/workspaces/*.yaml`) set the DB target and **absolute**
|
||||
`paths.sessions/artifacts/indexes` — for `psd` these point at a *separate, uncommitted* repo
|
||||
(`tht-workspace-psd/`). Secrets live ONLY in `harness/.env` (gitignored).
|
||||
- **Settings are global** (`backend/data/settings.json`: workspace/provider/model/thinking);
|
||||
the New-session form is question-only.
|
||||
- **Localization:** deterministic UI uses the EN/IT catalogs with English fallback;
|
||||
model interaction uses the session manifest's immutable `interaction_language`.
|
||||
Workspace content, SQL, and identifiers remain unchanged. For shell modes, portal
|
||||
integration, or translations, read `docs/operations/shell-and-localization.md`.
|
||||
- **Server deployment:** for the coordinated ThothII/Omics upgrade, follow
|
||||
`docs/operations/server-codex-handoff.md`; it supersedes earlier Omics delivery
|
||||
instructions. Omics source integration uses GitHub with no repository relay prerequisite.
|
||||
- **Server identity:** for portal login/logout, proxy headers, or Omics deploy, read
|
||||
`docs/install/authentication-upstream.md` before changing authentication. Omics
|
||||
uses embedded/upstream, not a second ThothII OIDC login. Full/embedded rendering
|
||||
is documented in `docs/architecture/application-shell.md`; release acceptance
|
||||
is in `docs/testing/authentication-manual-acceptance.md`.
|
||||
- **Workspace schema v4** defines workspace identity and optional Evidence only. PostgreSQL Metadata
|
||||
Catalog owns database identity, binding, schema, descriptions, sensitivity, and relationships;
|
||||
embedding/model facts come from the installation catalog. The legacy `harness/workspaces/*.yaml` runtime snapshots still use
|
||||
absolute session/artifact/index paths; secrets stay in `harness/.env` (gitignored).
|
||||
- **Settings are global** (`backend/data/settings.json`: workspace/thinking). Provider/model choices
|
||||
are ephemeral canonical catalog selections pinned into the session manifest.
|
||||
- **Resume**: a resumable session re-enters at its last incomplete phase. The backend refuses
|
||||
resume with 409 when `finalized` or `archived`, and `PiProcessManager.spawnFor` must send
|
||||
`/riprendi-sessione <id>` (resume mode) vs `/nuova-domanda` (new) — sending the wrong prompt
|
||||
|
||||
+308
-45
@@ -91,21 +91,79 @@ correzione successiva crea una nuova sessione derivata, collegata a quella prece
|
||||
dopo la finalizzazione. Non può modificare il ledger, gli artifact canonici o lo stato
|
||||
terminale della sessione.
|
||||
|
||||
## Memory
|
||||
|
||||
**Memory Module** — Il modulo che possiede le conoscenze ed esperienze curate per
|
||||
migliorare schema linking e generazione SQL di domande future. Le Memory appartengono
|
||||
a un workspace e rimangono distinte dalle Evidence.
|
||||
|
||||
**Memory Card** — L'unità di contenuto gestibile del Memory Module, con identità,
|
||||
ambito di applicazione e provenienza. Il formato è allineato per analogia alle
|
||||
Evidence, senza implicare la stessa origine o lo stesso percorso di pubblicazione.
|
||||
|
||||
**Reusable Memory** — Una Memory Card che esprime un chiarimento di dominio, una
|
||||
regola di costruzione SQL o un errore da evitare con motivo compreso e approvato.
|
||||
La sua validità è circoscritta a un ambito esplicito e non deriva dalla sola
|
||||
approvazione di una scelta occasionale in una domanda.
|
||||
|
||||
**Solved Question** — Una Memory Card che conserva una domanda risolta con la
|
||||
relativa soluzione SQL e il contesto necessario a interpretarla. È un exemplar
|
||||
consultativo: i parametri e le scelte del caso non diventano regole generali.
|
||||
|
||||
**Memory Graph** — L'insieme dei collegamenti espliciti fra card che contribuisce
|
||||
al recupero di conoscenze pertinenti oltre alla somiglianza del contenuto. Il
|
||||
ritrovamento di una card tramite un collegamento non ne implica l'approvazione.
|
||||
|
||||
**Memory Link** — Un collegamento curato fra card, con destinazione e significato
|
||||
espliciti, che contribuisce alla consultazione di contenuti pertinenti. La sua
|
||||
rimozione non comporta la cancellazione delle card collegate.
|
||||
|
||||
## Evidence
|
||||
|
||||
**Context specialist** — La persona competente sul dominio che redige e cura il
|
||||
contenuto delle Evidence. Può essere distinta da chi amministra l'installazione;
|
||||
il suo lavoro di redazione non richiede accesso al database applicativo.
|
||||
|
||||
**Evidence draft** — Il documento iniziale scritto dallo specialista di contesto,
|
||||
che il sistema acquisisce e raffina in Evidence Unit. Può essere redatto e
|
||||
consegnato indipendentemente dall'installazione che userà le Evidence risultanti.
|
||||
|
||||
**Evidence Module** — Il modulo autonomo che possiede la preparazione delle Evidence e
|
||||
la loro consultazione durante il workflow. La preparazione avviene fuori dalle singole
|
||||
sessioni; il workflow usa soltanto contenuti già pubblicati. A runtime contribuisce agli
|
||||
stage semantici esistenti, senza diventare uno stage visibile e senza modificare ledger,
|
||||
artifact o stato del workflow.
|
||||
|
||||
**Source Evidence** — Un documento originale del workspace, conservato senza modifiche
|
||||
come riferimento umano e origine della successiva ristrutturazione.
|
||||
**Source Evidence** — Il documento o la dichiarazione che sostiene il contenuto
|
||||
corrente di una Evidence Unit. Un documento acquisito viene conservato come
|
||||
riferimento umano; una dichiarazione manuale attribuisce il contenuto alla persona
|
||||
che lo ha scritto e approvato.
|
||||
|
||||
**Manual Evidence declaration** — Una dichiarazione esplicita dell'amministratore
|
||||
che sostiene una Evidence creata direttamente o una correzione del suo significato.
|
||||
Non implica una verifica indipendente da parte di una fonte documentale esterna.
|
||||
|
||||
**Evidence origin** — Il documento da cui una Evidence Unit è stata inizialmente
|
||||
derivata. Può restare collegato per provenienza e confronto con gli aggiornamenti
|
||||
anche quando una dichiarazione manuale sostiene il testo corrente. La sola origine
|
||||
non dimostra il supporto semantico di una successiva correzione.
|
||||
|
||||
**Local Evidence archive** — L'insieme delle Evidence curate custodite
|
||||
dall'installazione, distinto dalle draft originali e dai contenuti derivati per
|
||||
la ricerca. Comprende le correzioni manuali e i ritiri deliberati.
|
||||
|
||||
**Consolidated Evidence** — Una versione delle Evidence locali controllata come
|
||||
insieme coerente e pronta per l'attivazione. I file ancora in modifica non ne
|
||||
cambiano il contenuto.
|
||||
|
||||
**Active Evidence** — La versione consolidata disponibile alla consultazione del
|
||||
core. Un tentativo di aggiornamento fallito conserva la versione attiva precedente.
|
||||
|
||||
**Evidence Unit** — La più piccola unità semantica coerente, revisionabile e ricercabile
|
||||
derivata da una sola Source Evidence. Possiede un identificatore stabile indipendente
|
||||
dal kind, assegnato una volta nella forma `evidence:<slug>`; fonti diverse non vengono
|
||||
fuse automaticamente.
|
||||
fondata su una Source Evidence corrente, anche manuale, e con eventuale origine
|
||||
documentale distinta. Possiede un identificatore stabile indipendente dal kind,
|
||||
assegnato una volta nella forma `evidence:<slug>`; fonti diverse non vengono fuse
|
||||
automaticamente.
|
||||
|
||||
**Evidence kind** — La categoria semantica di una Evidence Unit, che ne determina i
|
||||
campi specifici e ne orienta l'uso. Ogni unità ha un solo kind primario; i tipi iniziali
|
||||
@@ -151,10 +209,9 @@ avanzare fino a un retry riuscito.
|
||||
nella sessione: stage semantico, purpose, generazione interrogata e identificatori delle
|
||||
Evidence restituite. Non duplica il contenuto delle Evidence.
|
||||
|
||||
**Curated Evidence** — Una o più Evidence Unit ristrutturate a partire da una Source
|
||||
Evidence e conservate nel repository del workspace come proposte per la revisione
|
||||
umana. Git conserva la versione precedente e rende visibile ogni modifica; una Curated
|
||||
Evidence non è ancora contenuto autorevole del runtime.
|
||||
**Curated Evidence** — Una o più Evidence Unit preparate da documenti o curate
|
||||
manualmente. La presenza nell'archivio curato non implica da sola che il contenuto
|
||||
sia già attivo per il workflow.
|
||||
|
||||
**Published Evidence** — Le Curated Evidence valide appartenenti alla revisione attiva
|
||||
del workspace e alla generazione Evidence pubblicata. L'approvazione umana precede
|
||||
@@ -176,9 +233,17 @@ una Evidence Unit. Il sistema ne verifica deterministicamente la presenza dopo l
|
||||
normalizzazione meccanica; il curatore resta responsabile di verificarne la sufficienza
|
||||
semantica.
|
||||
|
||||
**Evidence resolution** — L'operazione esplicita con cui un curatore ritira una
|
||||
Evidence Unit oppure la ricollega a un Source Evidence esistente. Aggiorna documento e
|
||||
manifest insieme, lascia un diff Git revisionabile e non pubblica né crea commit.
|
||||
**Evidence resolution** — La decisione esplicita con cui un curatore risolve un
|
||||
problema di una Evidence Unit, correggendola, ritirandola oppure ricollegandola a
|
||||
una fonte adeguata.
|
||||
|
||||
**Source update conflict** — Un contrasto fra una fonte aggiornata e una correzione
|
||||
manuale già approvata. La correzione resta in uso fino alla risoluzione esplicita
|
||||
del confronto da parte dell'amministratore.
|
||||
|
||||
**Evidence source refresh** — La riacquisizione delle fonti esterne richiesta
|
||||
dall'amministratore per rilevarne le modifiche. Fra due aggiornamenti il contenuto
|
||||
già acquisito resta il riferimento per preparazione e consultazione.
|
||||
|
||||
**Review item** — Un blocco di revisione descritto da codice stabile, messaggio umano e
|
||||
campo opzionale. Finché viene mantenuto nell'Evidence Unit, ne impedisce la
|
||||
@@ -230,10 +295,18 @@ conversione degli a-capo e rimozione degli spazi esterni. Gli elementi contestua
|
||||
poi deduplicati e ordinati senza conversione delle maiuscole, mentre punteggiatura e
|
||||
spazi interni della domanda non vengono riscritti.
|
||||
|
||||
**Additive BM25 upgrade** — L'estensione non distruttiva della collezione semantica di
|
||||
un workspace che conserva il vettore dense predefinito e aggiunge il solo vettore
|
||||
sparse `bm25`. Soltanto gli Evidence Fragment ricevono valori BM25; Schema e Memory
|
||||
mantengono invariati dati e ricerca dense.
|
||||
**Reference Vector Collection** — La collezione Qdrant ricostruibile di un workspace che
|
||||
contiene Schema, relazioni ed Evidence. Possiede il vettore dense predefinito e il vettore
|
||||
sparse `bm25`; soltanto gli Evidence Fragment ricevono valori BM25. Il preprocessing può
|
||||
sostituirla o eliminarla integralmente.
|
||||
|
||||
**Memory Vector Collection** — La collezione Qdrant persistente di un workspace che contiene
|
||||
`memory` e `solved_question`. Non è un output del preprocessing e non viene eliminata dal
|
||||
Preprocessing Clear.
|
||||
|
||||
**Preprocessing Clear** — L'operazione amministrativa che elimina Reference Vector Collection,
|
||||
LSH, corpus e checkpoint derivati e rende il workspace non pronto. Conserva Memory Vector
|
||||
Collection, sessioni, Catalog Metadata e database sorgente; non offre history o rollback.
|
||||
|
||||
**Formula proposal** — Una formula individuata durante una sessione e conservata come
|
||||
artefatto della sessione. Non diventa Published Evidence finché non viene importata,
|
||||
@@ -244,6 +317,45 @@ incompatibile o non aggiornato produce nessuna Evidence e un avviso esplicito. I
|
||||
workflow può continuare, ma non usa mai silenziosamente contenuti di una revisione
|
||||
precedente o di un altro workspace.
|
||||
|
||||
## Configurazione dei modelli
|
||||
|
||||
**Workspace Descriptor** — La dichiarazione versionata dell'identità del workspace e dello
|
||||
scope delle sue Evidence. Non contiene identità o configurazione del Workspace Database,
|
||||
Database Binding, fatti strutturali o metadati semantici: il Metadata Catalog associa il
|
||||
workspace al relativo database.
|
||||
|
||||
**Installation Model Catalog** — L'insieme dichiarativo, proprio di un'installazione, dei
|
||||
modelli disponibili, dei loro Model Usage e dei relativi default. È l'unica autorità per i
|
||||
modelli di sessione, generazione dei metadati ed embedding e non appartiene a un workspace.
|
||||
_Avoid_: Model Catalog, Metadata Generation Model Configuration
|
||||
|
||||
**Model Usage** — Lo scopo per cui un modello dell'Installation Model Catalog può essere
|
||||
usato: `session`, `metadata_generation` oppure `embedding`. L'ammissibilità e il default
|
||||
dipendono dall'uso, non dal workspace.
|
||||
|
||||
**Model Selection** — La scelta runtime, a livello di installazione, di un modello del
|
||||
catalogo per uno specifico Model Usage. Riferisce l'identità canonica del modello senza
|
||||
ridefinirne provider, endpoint o capacità.
|
||||
|
||||
**Model Runtime Projection** — La rappresentazione derivata e non autoritativa
|
||||
dell'Installation Model Catalog richiesta da uno specifico runtime. Può essere rigenerata
|
||||
integralmente dalla configurazione dell'installazione.
|
||||
|
||||
## Distribuzione del prodotto
|
||||
|
||||
**Customer-Hosted Installation** — Un'installazione eseguita interamente nel trust boundary
|
||||
controllato dall'organizzazione cliente, inclusi eventuali tenant cloud privati. Credenziali,
|
||||
domande, prompt, metadati e risultati non attraversano quel boundary.
|
||||
_Avoid_: on-premise deployment, self-managed deployment
|
||||
|
||||
**Community Edition** — La distribuzione open source utilizzabile gratuitamente anche in
|
||||
produzione e capace di eseguire il workflow fondamentale completo.
|
||||
_Avoid_: free tier, trial edition
|
||||
|
||||
**Enterprise Edition** — La distribuzione con licenza commerciale che aggiunge governance
|
||||
organizzativa, esercizio production-grade e industrializzazione alla Community Edition.
|
||||
_Avoid_: paid tier, pro edition
|
||||
|
||||
## Catalogo dei metadati
|
||||
|
||||
**Workspace Database** — Il database che appartiene a un solo workspace e non può essere
|
||||
@@ -264,8 +376,9 @@ client configurabile per API REST arbitrarie.
|
||||
nel catalogo autorevole. Rimane conservato per il recupero amministrativo, ma non può essere
|
||||
usato dal workflow finché non viene riassegnato a un workspace esistente.
|
||||
|
||||
**Metadata Catalog** — Il contesto amministrativo che raccoglie e cura i metadati di un
|
||||
Workspace Database. Non definisce quali elementi partecipano al workflow SQL.
|
||||
**Metadata Catalog** — L'autorità per l'associazione fra workspace e Workspace Database, la
|
||||
relativa Database Binding, i fatti strutturali osservati e i metadati semantici curati. Ogni
|
||||
uso downstream dei metadati del database deriva da questo catalogo.
|
||||
|
||||
**Database Profile** — L'insieme curato di scope, descrizioni e metadati semantici
|
||||
associato a un Workspace Database.
|
||||
@@ -296,14 +409,50 @@ Metadata Catalog. Non è creata o modificata manualmente, ma può essere rimossa
|
||||
Metadata Cleanup.
|
||||
_Avoid_: denormalized FK, relationship string
|
||||
|
||||
**Logical Relationship** — Una relazione semantica curata o inferita che non corrisponde
|
||||
necessariamente a un vincolo fisico. Ha ownership e lifecycle distinti da Catalog Relationship.
|
||||
**Logical Relationship** — Una relazione modificabile fra due Catalog Column che non corrisponde
|
||||
necessariamente a un vincolo fisico. Può essere Generated o Manual e rimane distinta dalla Catalog
|
||||
Relationship osservata nel database.
|
||||
|
||||
**Generated Relationship** — Una Logical Relationship ricavata dai nomi delle colonne, dalle
|
||||
primary key e dalla compatibilità dei tipi mediante regole deterministiche, senza LLM, embedding o
|
||||
campionamento dei dati. Una ricostruzione non riattiva una Generated Relationship cancellata
|
||||
logicamente, ma può ricrearne una cancellata fisicamente.
|
||||
|
||||
**Manual Relationship** — Una Logical Relationship aggiunta dall'utente. La ricostruzione delle
|
||||
Generated Relationship non la modifica.
|
||||
|
||||
**Logical Relationship Deletion** — L'esclusione persistente di una Logical Relationship che ne
|
||||
conserva l'identità per impedirne la ricreazione automatica finché esistono entrambe le Catalog
|
||||
Column alle quali è collegata.
|
||||
|
||||
**Permanent Relationship Deletion** — La rimozione completa di una Logical Relationship. Una
|
||||
ricostruzione successiva può ricrearla quando soddisfa nuovamente le regole di inferenza. Anche il
|
||||
cleanup distruttivo di una tabella o colonna endpoint rimuove permanentemente le relative esclusioni.
|
||||
|
||||
**Relationship Reconstruction** — L'operazione amministrativa esplicita che scopre e aggiunge le
|
||||
Generated Relationship mancanti. Conserva le Manual Relationship e le relationship già presenti e
|
||||
non riattiva quelle cancellate logicamente.
|
||||
|
||||
**Relationship Restore** — La riattivazione esplicita di una Logical Relationship cancellata
|
||||
logicamente.
|
||||
|
||||
**Effective Relationship Map** — La vista unificata delle Catalog Relationship fisiche e delle
|
||||
Logical Relationship, con origine e stato espliciti. È l'interfaccia usata dall'amministrazione e
|
||||
dalla comprensione dello schema, non un ulteriore modello persistito.
|
||||
|
||||
**Catalog Metadata Snapshot** — La proiezione immutabile e versionata della struttura catalogata,
|
||||
delle descrizioni pubblicabili e delle relazioni effettive attive di un Workspace Database che il
|
||||
core consuma. È derivata esclusivamente dal Metadata Catalog e non è un archivio autoritativo.
|
||||
|
||||
**Schema Index** — La proiezione vettoriale ricostruibile dei metadati del Workspace Database nel
|
||||
Metadata Catalog. Il preprocessing la sostituisce integralmente e non è una fonte di verità.
|
||||
|
||||
**Description** — Il testo curato e consolidato che descrive una Catalog Table o Catalog Column
|
||||
per gli usi downstream.
|
||||
per gli usi downstream. Quando presente, prevale sulla relativa Generated Description.
|
||||
|
||||
**Generated Description** — Una proposta modificabile sottoposta a revisione umana prima di
|
||||
essere consolidata come Description. Rimane distinta dal commento osservato nel database.
|
||||
**Generated Description** — Il testo modificabile prodotto dall'AI per una Catalog Table o Catalog
|
||||
Column. È pubblicabile per gli usi downstream quando manca una Description, anche senza essere
|
||||
prima consolidato, e rimane distinto dal commento osservato nel database.
|
||||
_Avoid_: generated comment, source comment
|
||||
|
||||
**Description Consolidation** — L'azione amministrativa esplicita che copia la Generated
|
||||
@@ -349,14 +498,17 @@ Schema Synchronization.
|
||||
della Database Binding. Uno scope rimane consultabile ma è stale finché non viene sincronizzato
|
||||
con la binding corrente.
|
||||
|
||||
**Metadata Content Revision** — La revisione monotona di tutto lo stato del Metadata Catalog che
|
||||
può modificare il comportamento del core. Ogni mutazione rilevante produce una nuova revisione
|
||||
nella stessa transazione che la rende durevole.
|
||||
|
||||
**Preprocessing State** — Lo stato corrente `running`, `succeeded` o `failed` del preprocessing di
|
||||
un workspace, insieme all'identità dei suoi input. Il core può usare il workspace soltanto quando
|
||||
lo stato è `succeeded` e gli input coincidono ancora.
|
||||
|
||||
**Catalog Metadata** — I campi mutabili che descrivono database, tabelle, colonne e relazioni,
|
||||
distinti dai fatti strutturali governati dalla sincronizzazione. Possono essere popolati dall'AI,
|
||||
da un'importazione o da una modifica amministrativa senza cambiare il database esterno.
|
||||
|
||||
**Metadata Generation Model Configuration** — La configurazione a livello di setup applicativo
|
||||
che elenca i modelli selezionabili, il default e i riferimenti agli eventuali segreti per la sola
|
||||
generazione dei metadati. Un modello keyless è ammesso solo con un endpoint esplicito che non
|
||||
richiede autenticazione. Non appartiene al workspace ed è indipendente dalla configurazione Pi.
|
||||
o da una modifica amministrativa senza cambiare il database esterno.
|
||||
|
||||
**Model Completion Helper** — Il processo Python interno ed effimero che esegue una singola
|
||||
richiesta LiteLLM per conto del backend. Non è un servizio HTTP, non possiede il lifecycle della
|
||||
@@ -364,28 +516,139 @@ Description Generation Run e non è una CLI esposta agli utenti.
|
||||
|
||||
**Catalog Sample** — Un input transitorio composto da un massimo di cinque righe e da valori di
|
||||
esempio bounded di una Catalog Table per la generazione delle descrizioni. Può contenere valori
|
||||
reali oppure sintetici in base al Sensitive Data Flag della Catalog Column; non viene persistito
|
||||
e non diventa Catalog Metadata.
|
||||
reali oppure sintetici in base alla Source Value Disclosure Decision; non viene persistito e non
|
||||
diventa Catalog Metadata.
|
||||
|
||||
**Sensitive Data Flag** — La scelta binaria umana applicata a una Catalog Column: `true` protegge
|
||||
i valori sorgente e `false` ne consente l'invio al modello. Il valore predefinito è `false`, anche
|
||||
per le nuove colonne.
|
||||
**Sensitive Data Flag** — La classificazione binaria umana applicata a una Catalog Column. Può
|
||||
essere impostata liberamente dall'amministratore anche in contrasto con una valutazione automatica.
|
||||
|
||||
**Sensitive Data Policy** — La regola che applica il Sensitive Data Flag ai Catalog Sample:
|
||||
valori sintetici per una colonna protetta, valori reali per una colonna non protetta. L'AI può
|
||||
suggerire il flag dai soli metadati tecnici di un database, delle tabelle o delle colonne
|
||||
esplicitamente selezionate; le richieste ampie vengono divise in batch bounded, ma soltanto
|
||||
l'utente imposta i flag dopo aver rivisto la proposta completa.
|
||||
**Sensitivity Reason** — La motivazione sanificata persistita insieme al Sensitive Data Flag
|
||||
quando l'amministratore salva una Sensitivity Review Draft. È Catalog Metadata della colonna, non
|
||||
history della run; viene rimossa quando il flag torna non-sensitive e può essere assente per una
|
||||
classificazione manuale priva di valutazione locale.
|
||||
_Avoid_: AI reasoning, source evidence
|
||||
|
||||
**Local Sensitivity Assessment** — La valutazione locale, non autoritativa e priva di LLM di una
|
||||
Catalog Column, basata su metadati e contenuto sorgente, con esito `sensitive`, `non_sensitive`
|
||||
oppure `unknown`.
|
||||
_Avoid_: AI suggestion, automatic flag
|
||||
|
||||
**Local NER Detector** — Il componente NLP opzionale e CPU-only che esamina soltanto testo ancora
|
||||
ambiguo e restituisce evidenze al Local Sensitivity Assessment. Non decide lo stato della colonna,
|
||||
non usa un LLM generativo e non persiste valori sorgente.
|
||||
_Avoid_: AI classifier, local LLM fallback
|
||||
|
||||
**Model Data Boundary** — La qualificazione amministrativa di un modello come `internal` oppure
|
||||
`external` rispetto al confine entro cui i valori sorgente possono essere comunicati.
|
||||
_Avoid_: local model, remote model
|
||||
|
||||
**Source Value Disclosure Decision** — L'unica decisione effettiva che stabilisce se un modello
|
||||
riceve valori sorgente reali oppure sostituti sintetici, combinando Model Data Boundary e Sensitive
|
||||
Data Flag.
|
||||
_Avoid_: sample filter, export flag
|
||||
|
||||
**Sensitive Data Policy** — L'insieme versionato di regole locali generali e specifiche che produce
|
||||
una Local Sensitivity Assessment. Un singolo riscontro blocca l'intera colonna e qualsiasi valore
|
||||
testuale più lungo di 500 caratteri rende sensibile la colonna.
|
||||
_Avoid_: PII filter, sample filter
|
||||
|
||||
**Sensitive Data Suggestion Run** — Il tentativo amministrativo tracciato con cui il modello
|
||||
propone Sensitive Data Flag dai soli metadati strutturali. Conserva stato e conteggi aggregati,
|
||||
ma non i suggerimenti per colonna, che restano una proposta transitoria fino al salvataggio umano.
|
||||
**Sensitivity Analysis Run** — Il tentativo amministrativo esplicito e tracciato che valuta una
|
||||
selezione di colonne mediante la Sensitive Data Policy. Conserva stato, copertura e conteggi
|
||||
aggregati, ma non valori sorgente né esiti per colonna.
|
||||
_Avoid_: Sensitive Data Suggestion Run, AI analysis
|
||||
|
||||
**Sensitive Data Suggestion Event** — Una riga testuale ordinata e sanitizzata che registra
|
||||
l'avvio, l'esito o l'errore di una Sensitive Data Suggestion Run senza conservare prompt,
|
||||
risposte grezze del provider o proposte per colonna.
|
||||
**Sensitivity Review Draft** — La proposta transitoria che associa alle colonne selezionate una
|
||||
Local Sensitivity Assessment e le relative evidenze sanificate. Non modifica il Sensitive Data Flag
|
||||
né la Sensitivity Reason finché l'amministratore non salva le proprie decisioni e viene scartata al
|
||||
reload.
|
||||
_Avoid_: automatic flag
|
||||
|
||||
**Sensitivity Analysis Event** — Una riga testuale ordinata e sanificata che registra l'avvio,
|
||||
l'avanzamento per fase e batch, l'esito o l'errore di una Sensitivity Analysis Run senza conservare
|
||||
contenuti sorgente, output grezzi del detector o proposte per colonna.
|
||||
_Avoid_: Sensitive Data Suggestion Event
|
||||
|
||||
**Introspection Capability** — Una categoria di struttura fisica che una Database Binding
|
||||
può osservare, come tabelle, colonne, relazioni, indici o enum. Una capability non disponibile
|
||||
è distinta da una capability osservata che non ha restituito elementi.
|
||||
|
||||
## Amministrazione e integrazione
|
||||
|
||||
**Workspace Readiness** — La preparazione di uno specifico Workspace per l'uso nel
|
||||
workflow, comprensiva della disponibilità degli artefatti derivati dai suoi metadati
|
||||
Database e dalle sue Evidence. Il preprocessing appartiene a questa preparazione;
|
||||
la configurazione e la sincronizzazione del catalogo restano responsabilità Database.
|
||||
|
||||
**Administration Surface** — Una superficie amministrativa autonoma per configurare o curare una
|
||||
parte dell'installazione. Workspace, Evidence, Memory, Database e Pi sono superfici peer e non
|
||||
dipendono dall'esistenza di una sessione attiva.
|
||||
|
||||
**Administration Page** — La rappresentazione a pagina intera di una Administration Surface, con
|
||||
una gerarchia condivisa per identità, stato, azioni e contenuto. Un form amministrativo appartiene
|
||||
alla pagina e non a una popup come contenitore principale.
|
||||
_Avoid_: management popup, settings modal
|
||||
|
||||
**Administration Route** — L'identità navigabile di una Administration Surface nel browser. Deve
|
||||
essere ripristinabile con refresh e cronologia e non contiene valori transitori o segreti dei form.
|
||||
|
||||
**Embedded Thoth Shell** — L'esperienza Thoth ospitata dentro il documento e il contesto visuale di
|
||||
un portale host. Conserva la propria gerarchia funzionale, ma deve rispettare la geometria,
|
||||
l'autenticazione e le regole responsive del portale host.
|
||||
|
||||
**Full Thoth Shell** — L'esperienza Thoth autonoma che possiede il proprio header e il proprio
|
||||
layout di pagina. Non replica la navigazione amministrativa del portale host e non dipende dal suo
|
||||
template visuale.
|
||||
|
||||
**Shell mode** — La scelta di installazione fra `embedded` e `full`. Determina chi possiede il
|
||||
chrome globale, i comandi di identità e le integrazioni visuali, ma non cambia il workflow o la
|
||||
persistenza delle sessioni.
|
||||
|
||||
**Fullscreen state** — Lo stato temporaneo in cui il documento applicativo occupa il fullscreen
|
||||
del browser. È distinto da `Shell mode`: una Full Thoth Shell può essere aperta senza fullscreen;
|
||||
il passaggio è attivato da un comando esplicito e può essere annullato con la stessa azione o con
|
||||
il comando nativo del browser.
|
||||
|
||||
**Portal Shell Adapter** — Il confine sostituibile che traduce lo stato e i comandi del chrome di
|
||||
un portale host nel modello semantico usato da Thoth. L'adapter non possiede autorizzazione,
|
||||
sessioni di workflow o contenuti del modello.
|
||||
|
||||
**Host Shell State** — Il minimo stato visuale fornito dal portale host: locale UI, tema e stato
|
||||
fullscreen. In una Embedded Thoth Shell è la fonte autorevole per queste preferenze;
|
||||
non include identità, token o stato di autenticazione, che restano responsabilità dell'accesso.
|
||||
|
||||
**UI locale** — La lingua delle label, dei messaggi, dei tooltip, degli stati e delle istruzioni
|
||||
non generate dal modello nell'interfaccia Thoth. È distinta dalla lingua dei contenuti di un
|
||||
workspace.
|
||||
|
||||
**Interaction language** — La lingua in cui il modello presenta domande, spiegazioni e proposte
|
||||
al revisore durante una sessione. Viene fissata alla creazione della sessione e rimane invariata
|
||||
durante una ripresa, anche se la UI locale corrente cambia.
|
||||
|
||||
**Administrative Page Family** — L'insieme delle cinque Administration Page che condividono shell,
|
||||
navigazione, tipografia e regole responsive, pur mantenendo contenuti e operazioni specifici:
|
||||
Workspace, Evidence, Memory, Database e Pi.
|
||||
|
||||
## Installazione
|
||||
|
||||
**Manual standalone installation** — Una copia di ThothII predisposta per l'uso autonomo da una
|
||||
persona che possiede il computer, con una Full Thoth Shell e servizi applicativi locali. La
|
||||
procedura non implica che DWH o provider LLM siano locali o disponibili offline.
|
||||
|
||||
**Installation bootstrap** — L'insieme delle attività iniziali che rende disponibile una
|
||||
installazione manuale: verifica dell'host, generazione della configurazione, predisposizione
|
||||
delle credenziali protette e avvio dei servizi. Non è un installer dell'applicazione.
|
||||
|
||||
**Installation preparation** — La predisposizione dei documenti che descrivono i workspace
|
||||
e i parametri dell'installazione, prima di applicarli. Permette all'operatore di raccogliere
|
||||
e correggere le informazioni senza avviare l'applicazione.
|
||||
|
||||
**Installation validation** — La verifica ripetibile della completezza e coerenza dei
|
||||
documenti e delle precondizioni di un'installazione. Distingue ciò che è stato verificato
|
||||
da ciò che richiede un'applicazione già avviata.
|
||||
|
||||
**Installation execution** — L'applicazione dei documenti verificati per predisporre e
|
||||
avviare ThothII. Non raccoglie nuovi parametri dall'operatore durante l'esecuzione.
|
||||
|
||||
**Platform acceptance** — La verifica che una Manual standalone installation possa essere
|
||||
predisposta e avviata su una specifica combinazione di sistema operativo, architettura e runtime,
|
||||
distinta dalla verifica funzionale del collegamento a DWH e provider LLM.
|
||||
|
||||
@@ -11,46 +11,50 @@ colors:
|
||||
warm-graphite: "oklch(26.78% 0.0097 355.6)"
|
||||
muted-graphite: "oklch(51.33% 0.0088 345.6)"
|
||||
quiet-border: "oklch(90.93% 0.0035 354.7)"
|
||||
success-mint: "oklch(75.77% 0.1581 165)"
|
||||
warning-amber: "oklch(85.23% 0.1386 78.9)"
|
||||
information-blue: "oklch(70.35% 0.1128 221.3)"
|
||||
success-mint: "oklch(46% 0.095 160)"
|
||||
navigation-active: "oklch(92.5% 0.052 23.2)"
|
||||
navigation-active-hover: "oklch(89.5% 0.071 23.2)"
|
||||
navigation-active-foreground: "oklch(36.5% 0.11 23.2)"
|
||||
navigation-active-border: "oklch(60% 0.135 23.2)"
|
||||
warning-amber: "oklch(48% 0.09 70)"
|
||||
information-neutral: "oklch(51.33% 0.0088 345.6)"
|
||||
typography:
|
||||
display:
|
||||
fontFamily: "Fraunces, Source Serif Pro, Georgia, Times New Roman, serif"
|
||||
fontSize: "3rem"
|
||||
fontFamily: "Manrope Variable, Manrope, system-ui, sans-serif"
|
||||
fontSize: "1.5rem"
|
||||
fontWeight: 600
|
||||
lineHeight: 1.03
|
||||
letterSpacing: "-0.025em"
|
||||
headline:
|
||||
fontFamily: "Fraunces, Source Serif Pro, Georgia, Times New Roman, serif"
|
||||
fontSize: "1.875rem"
|
||||
fontFamily: "Manrope Variable, Manrope, system-ui, sans-serif"
|
||||
fontSize: "1.5rem"
|
||||
fontWeight: 600
|
||||
lineHeight: 1.15
|
||||
letterSpacing: "-0.015em"
|
||||
title:
|
||||
fontFamily: "Fraunces, Source Serif Pro, Georgia, Times New Roman, serif"
|
||||
fontSize: "1.2rem"
|
||||
fontFamily: "Manrope Variable, Manrope, system-ui, sans-serif"
|
||||
fontSize: "1.25rem"
|
||||
fontWeight: 600
|
||||
lineHeight: 1.25
|
||||
letterSpacing: "-0.01em"
|
||||
body:
|
||||
fontFamily: "Manrope, -apple-system, BlinkMacSystemFont, Segoe UI, system-ui, Arial, sans-serif"
|
||||
fontSize: "0.9375rem"
|
||||
fontFamily: "Manrope Variable, Manrope, -apple-system, BlinkMacSystemFont, Segoe UI, system-ui, Arial, sans-serif"
|
||||
fontSize: "1rem"
|
||||
fontWeight: 400
|
||||
lineHeight: 1.65
|
||||
letterSpacing: "normal"
|
||||
control:
|
||||
fontFamily: "Manrope, -apple-system, BlinkMacSystemFont, Segoe UI, system-ui, Arial, sans-serif"
|
||||
fontFamily: "Manrope Variable, Manrope, -apple-system, BlinkMacSystemFont, Segoe UI, system-ui, Arial, sans-serif"
|
||||
fontSize: "0.875rem"
|
||||
fontWeight: 600
|
||||
lineHeight: 1.25
|
||||
letterSpacing: "0.005em"
|
||||
label:
|
||||
fontFamily: "ui-monospace, SF Mono, Cascadia Code, Menlo, Consolas, monospace"
|
||||
fontSize: "0.6875rem"
|
||||
fontFamily: "Manrope Variable, Manrope, system-ui, sans-serif"
|
||||
fontSize: "0.75rem"
|
||||
fontWeight: 600
|
||||
lineHeight: 1.25
|
||||
letterSpacing: "0.06em"
|
||||
letterSpacing: "normal"
|
||||
rounded:
|
||||
xs: "4px"
|
||||
sm: "6px"
|
||||
@@ -109,6 +113,16 @@ components:
|
||||
|
||||
# Design System: ThothII
|
||||
|
||||
## Visual review branch, September 2026
|
||||
|
||||
The revision on `codex/ui-visual-review` is approved for implementation and Docker visual review,
|
||||
not yet for adoption on `main`. The previous look remains recoverable from the base commit and
|
||||
the preserved Docker image. Historical prototypes must remain untouched.
|
||||
|
||||
This revision follows Impeccable's product register: one locally bundled Manrope family for the
|
||||
whole UI, five fixed size roles, red as the sole brand accent and additional color only for meaningful
|
||||
state. The primary scene remains an analyst reading data and SQL in a well-lit office.
|
||||
|
||||
## Overview
|
||||
|
||||
**Creative North Star: "The Clinical Workbench"**
|
||||
@@ -130,7 +144,7 @@ disciplined and tactile, never playful, sluggish, or visually unstable.
|
||||
**Key Characteristics:**
|
||||
|
||||
- Warm, restrained surfaces with one scarce red accent.
|
||||
- Editorial headings paired with highly legible operational body text.
|
||||
- One sans-serif family, with hierarchy expressed through size, weight and spacing.
|
||||
- Dense information organized through hierarchy, rhythm, and progressive disclosure.
|
||||
- Persisted artifacts and reviewer decisions presented as the visual source of truth.
|
||||
- Fast state feedback with reduced-motion parity.
|
||||
@@ -146,14 +160,23 @@ users can scan structure without adding nested containers.
|
||||
|
||||
## Colors
|
||||
|
||||
The full-mode application header matches Omics Portal's `--gsd-red-primary`
|
||||
(`#CB333B`) in both themes. Its complete wordmark, including `II`, and controls
|
||||
use a near-white foreground. This header is absent in embedded mode. The sidebar
|
||||
and welcome wordmarks retain their red suffix. Context editing places workspace,
|
||||
model and Done in one desktop row, stacking on narrow containers. Session-scope
|
||||
tabs retain their selected fill and accessible keyboard state with a uniform one-pixel
|
||||
border on every side, gray when inactive and red when active. Their padding is 11px
|
||||
horizontal and 3px vertical, with a 38px minimum height and wrapping labels.
|
||||
|
||||
The palette combines warm porcelain surfaces, warm graphite text, and an instrument red used only
|
||||
for action, focus, and important state. OKLCH values in the frontmatter are normative because the
|
||||
frontend uses OKLCH tokens directly.
|
||||
|
||||
### Primary
|
||||
|
||||
- **Instrument Red** (`instrument-red`): primary actions, current selection, focus identity, and
|
||||
destructive meaning where the context already makes the action explicit.
|
||||
- **Instrument Red** (`instrument-red`): primary actions, focus identity, and destructive meaning
|
||||
where the context already makes the action explicit.
|
||||
- **Instrument Red Pressed** (`instrument-red-hover`): hover and active emphasis for the primary
|
||||
action family.
|
||||
|
||||
@@ -170,8 +193,12 @@ frontend uses OKLCH tokens directly.
|
||||
### Semantic
|
||||
|
||||
- **Success Mint** (`success-mint`): completed and ready states.
|
||||
- **Navigation Active** (`navigation-active`): the one application surface currently in the
|
||||
foreground. It shares Instrument Red's hue but uses a lighter, lower-chroma fill, so location is
|
||||
visible without carrying the full weight of a primary action.
|
||||
- **Warning Amber** (`warning-amber`): waiting, attention, and in-progress states.
|
||||
- **Information Blue** (`information-blue`): informational state when red would imply action.
|
||||
- **Information**: neutral text and indicators for dates, protocols and ordinary status. The legacy
|
||||
`--info` token resolves to muted foreground, not an additional blue accent.
|
||||
|
||||
The dark theme keeps the same semantic mapping with neutral near-black surfaces and a slightly
|
||||
lighter red accent. Do not introduce a second visual identity for dark mode.
|
||||
@@ -184,30 +211,33 @@ for their named states. Color is never the only state indicator.
|
||||
|
||||
## Typography
|
||||
|
||||
**Display Font:** Fraunces, with Source Serif Pro, Georgia, and Times New Roman fallbacks
|
||||
**Body Font:** Manrope, with native system sans-serif fallbacks
|
||||
**Label/Mono Font:** SF Mono or Cascadia Code, with Menlo and Consolas fallbacks
|
||||
**UI Font:** locally bundled Manrope Variable, with Manrope and native sans-serif fallbacks.
|
||||
**Technical Font:** SF Mono or Cascadia Code, with Menlo and Consolas fallbacks.
|
||||
|
||||
**Character:** Fraunces gives persisted artifacts and key headings editorial authority. Manrope
|
||||
keeps dense controls and prose calm and readable. The mono register separates machine identity,
|
||||
metadata, SQL, identifiers, and micro-labels from natural-language content.
|
||||
Manrope covers headings, labels, controls, navigation and document reading. Monospace is reserved
|
||||
for SQL, code, paths and machine identifiers, never for ordinary UI labels or status headings.
|
||||
|
||||
### Hierarchy
|
||||
|
||||
- **Display** (600, `3rem`, `1.03`): authentication and exceptional page-level statements only.
|
||||
- **Headline** (600, `1.875rem`, `1.15`): major page or artifact titles.
|
||||
- **Title** (600, `1.2rem`, `1.25`): panel and document section hierarchy.
|
||||
- **Body** (400, `0.9375rem`, `1.65`): operational prose, with a target line length of 65 to 75
|
||||
- **Headline** (600, `1.5rem`, `1.3`): page or artifact titles, `--text-page`.
|
||||
- **Title** (600, `1.25rem`, `1.4`): section hierarchy, `--text-section`.
|
||||
- **Body** (400, `1rem`, `1.6`): operational prose, `--text-body`, with a target line length of 65 to 75
|
||||
characters where the surface controls width.
|
||||
- **Control** (600, `0.875rem`, `1.25`): buttons, inputs, tabs, and compact actions.
|
||||
- **Label** (600, `0.6875rem`, `0.06em` tracking): uppercase micro-labels, state metadata, and panel
|
||||
headers. Labels use the mono family.
|
||||
- **Control** (400–600, `0.875rem`, `1.5`): buttons, inputs, tables, tabs and compact subheadings,
|
||||
`--text-control`.
|
||||
- **Metadata** (400–600, `0.75rem`, `1.5`): secondary status, counts and timestamps, `--text-meta`.
|
||||
Labels use sentence case and normal tracking. Ordinary operational text never falls below 12px.
|
||||
|
||||
Typography uses fixed sizes. Responsive changes happen at structural breakpoints, not through fluid
|
||||
type scaling. Numeric data and identifiers use tabular numerals where comparison matters.
|
||||
|
||||
**The Three Registers Rule.** Serif means authority, sans means interaction and reading, mono means
|
||||
machine identity. Do not exchange these roles for novelty.
|
||||
**Application wordmark:** ThothII is a brand mark, not a page title: use Manrope semibold at
|
||||
48px (`3rem`) in the Core welcome area and 32px (`2rem`) in the session sidebar, with the
|
||||
`II` suffix in brand red. Preserve these sizes across responsive layouts.
|
||||
|
||||
**The One Family Rule.** The UI and document readers use sans-serif throughout. The legacy
|
||||
`--font-heading` alias resolves to `--font-sans`. Preserve technical monospace without turning it
|
||||
into a second decorative hierarchy. Do not shrink text to solve layout constraints.
|
||||
|
||||
**The Read Once Rule.** A heading, label, and body must be distinguishable on first glance through
|
||||
size and weight. Do not repeat headings in explanatory copy.
|
||||
@@ -272,20 +302,125 @@ default, hover, focus, active, disabled, loading, and error behavior where those
|
||||
- **Focus:** three-pixel Instrument Red ring with a clear border shift.
|
||||
- **Error / Disabled:** errors combine destructive color with explanatory text; disabled controls
|
||||
retain readable contrast and use 50 percent opacity.
|
||||
- **Global context:** the collapsible top shelf is the sole workspace/model selector for Core and
|
||||
Admin. Preserve independent remembered choices, installation defaults, operation locks and unsaved
|
||||
edit guards. Never introduce a separate metadata-generation default or selector.
|
||||
|
||||
### Navigation
|
||||
|
||||
- **Workspace readiness:** the Workspace navigation button carries an 8px dot to
|
||||
the right of its label. Green means a selected workspace with confirmed ready
|
||||
preprocessing and no query error; all other states are red. The button's
|
||||
tooltip and accessible description retain the translated exact state. Do not
|
||||
add a separate readiness text row or change the backend readiness gate.
|
||||
- **Session groups:** one accessible single-open accordion contains Active sessions
|
||||
and Archive, both initially closed. Below the scope tabs, show only their
|
||||
adjacent section headers, without a redundant Sessions heading. Selection and
|
||||
bulk-delete controls belong inside each panel and only appear for nonempty
|
||||
lists. Select all affects that list only, preserves the other list's selection,
|
||||
and exposes a mixed state for partial selection. Preserve the existing archived
|
||||
flag as the grouping rule, independent of whether a Pi process is running.
|
||||
Opening a section closes the other; either can be collapsed, including both.
|
||||
Empty lists show only the translated "No sessions yet." message.
|
||||
The open section uses the rail's remaining height; its list scrolls internally
|
||||
with a cap of `min(18rem, 35dvh)`, while its trigger remains outside that scroll
|
||||
area. The mobile navigation dialog supplies a bounded viewport-height container.
|
||||
Keyboard users can focus and scroll each labelled panel.
|
||||
- **Session entry:** one Session button returns to the current unfinished session,
|
||||
including provisional creation, without resetting or reconnecting it. Otherwise
|
||||
it prepares a new question using the normal readiness and unsaved-work guards.
|
||||
- **Style:** compact session rows use `8px` corners and restrained vertical padding.
|
||||
- **Default / Hover / Active:** transparent at rest, Sunken Surface on hover, and the same surface
|
||||
with stronger text weight when active.
|
||||
- **Default / Hover / Active:** porcelain at rest, Sunken Surface on hover, and a muted Navigation
|
||||
Active red with a defined border when current. Exactly one top-level navigation control is current.
|
||||
- **Administrative controls:** the admin-only Administration accordion groups Database,
|
||||
Memory, Evidence, a structural divider, Workspace, and Pi configuration in that order. Its trigger exposes
|
||||
expanded state and starts collapsed by default, while non-admin users do not receive the accordion
|
||||
or its navigation actions.
|
||||
- **Responsive:** collapse navigation structurally at the application breakpoint. Do not shrink
|
||||
labels into illegibility.
|
||||
labels into illegibility. Below 768px, Memory and Evidence management use the full content
|
||||
width; a Navigation button opens the shared accessible dialog. Selecting another archive
|
||||
page or pressing Escape closes it. Desktop retains the right session sidebar and its My sessions /
|
||||
All sessions tabs. Core retains question/answer, eight phases, reviewer gates and the left log.
|
||||
In embedded mode the portal owns the red header and left sidebar; ThothII must not duplicate them. Size to the
|
||||
actual application container. Narrow session document panels may use the available width.
|
||||
|
||||
### Session review and confirmations
|
||||
|
||||
Session dialogs use the visible application area, including the portal's header
|
||||
and side rail. Artifact and schema-column review can grow to 80rem wide and the
|
||||
available height; short confirmations use up to 40rem and at least 18rem when
|
||||
space permits. Keep a 24px outer margin on desktop and 8px on small or short
|
||||
screens. Long review content scrolls internally; on very short screens the
|
||||
whole dialog can also scroll so every action remains reachable.
|
||||
|
||||
Session forms and review gates repeat their existing primary confirmation above
|
||||
and below the content, sharing selection, validation, pending state and response
|
||||
handlers. Alternate-response inputs follow the same rule. Reserved navigation
|
||||
controls remain below the review. Stop/delete initially focus Cancel; rename
|
||||
initially focuses the name field. Administration dialogs and forms retain their
|
||||
existing layout and actions.
|
||||
|
||||
### Tabs
|
||||
|
||||
- **Shape:** compact label tabs sit on a shared baseline with rounded top corners and a two-pixel
|
||||
lower edge, except session-scope tabs which use a uniform one-pixel border, rounded
|
||||
corners and a 4px gap without a shared border or negative bottom margin.
|
||||
Inactive labels retain a Quiet Border and Porcelain Card surface, so every
|
||||
label reads as a tab before interaction; hover feedback reinforces clickability.
|
||||
- **Current:** the selected tab uses the muted Navigation Active red for its fill, text, and defined border.
|
||||
It must expose `aria-selected`, participate in a labelled `tablist`/`tabpanel`, and be the only
|
||||
tab in the roving keyboard tab order.
|
||||
- **Keyboard:** Left/Right move between adjacent tabs with wrapping; Home/End select the first or
|
||||
last tab.
|
||||
|
||||
### Tooltips
|
||||
|
||||
- **Row actions:** icon-action tooltips open three pixels below the trigger and align to its trailing
|
||||
edge, so they never cover the icon row. They use a dark slate surface, porcelain text, and a
|
||||
defined border rather than the light popover treatment.
|
||||
- **Interaction:** tooltip layers never receive pointer events. They appear on hover and keyboard
|
||||
focus with a short ease-out transition, while the icon button keeps its complete accessible name.
|
||||
- **Scope:** this treatment is shared by database, table, column, and relationship row actions.
|
||||
Toolbar and navigation hints may use separate collision-aware placement.
|
||||
|
||||
### Curated Evidence Documents
|
||||
|
||||
Memory and Evidence share the `thot-knowledge-reader` reading contract. Use locally
|
||||
bundled Manrope with normal tracking for prose and labels, and these fixed roles:
|
||||
|
||||
- Card title: 24px, weight 600, line-height 1.3 (`thot-knowledge-title`).
|
||||
- Field/section heading, including Scope and Provenance: 20px, weight 600,
|
||||
line-height 1.4, 8px clearance below (`thot-knowledge-heading`).
|
||||
- All narrative text, including scope, lists and provenance: 16px, weight 400,
|
||||
line-height 1.65. Do not apply compact UI text sizes to these fields.
|
||||
- Authored Markdown subheadings inside a field: 16px, weight 600, line-height 1.5,
|
||||
24px above/8px below. They remain subordinate to the enclosing field heading;
|
||||
their semantic heading levels and original content are preserved.
|
||||
- Technical metadata labels/values: 14px/1.5, with weight 600 for labels.
|
||||
Only code, paths and machine identifiers use the technical monospace family at
|
||||
14px/1.65, identical for inline and fenced code (never compound `em` shrinkage).
|
||||
|
||||
Separate reading sections by 24px; keep the first Markdown block flush with its
|
||||
field heading's 8px bottom gap. The same typography applies in light/dark and at
|
||||
all responsive widths. Controls and archive indexes retain their compact UI roles.
|
||||
|
||||
Memory and Evidence detail readers use the entire available content width, without
|
||||
the ordinary 72–75ch prose cap. This is the owner's explicit reading-layout choice.
|
||||
Long unstructured paragraphs are split for display at existing sentence/semicolon
|
||||
boundaries outside inline code and links; authored Markdown structure and stored
|
||||
content are unchanged. Paragraph spacing is 1.25em. Scope and provenance share the
|
||||
available width; provenance excerpts render Markdown rather than literal markers.
|
||||
Copy actions use the two-overlapping-sheets icon, an accessible name/tooltip and
|
||||
live success/failure feedback instead of a visible Copy label.
|
||||
|
||||
Memory has four explicitly FAKE formatting examples, one per family, in a separate
|
||||
expandable section. They reuse the real detail reader but never enter persistence,
|
||||
indexing, link search or model recall, and expose no edit/delete/save actions.
|
||||
|
||||
Curated evidence follows a fixed reading order: title, compact type and purpose summary, scope,
|
||||
typed content, supporting excerpts, review items, then collapsed technical provenance. Machine
|
||||
metadata stays in invisible comments so GitHub Preview shows only the reviewable document.
|
||||
typed content, supporting excerpts, review items, then technical provenance. Curated v4 files
|
||||
use short, visible YAML frontmatter for identity and classification. The Markdown title and
|
||||
body are authoritative; hidden payload comments are a legacy format converted on consolidation.
|
||||
|
||||
`applies_to` is rendered as “Ambito di applicazione” with separate bullet lists for concepts,
|
||||
tables, and columns. Enum values also use lists. Tables are forbidden for metadata, scope, or any
|
||||
@@ -293,7 +428,9 @@ one-dimensional collection; reserve tables for genuinely two-dimensional dataset
|
||||
identifiers use inline code. SQL uses fenced code. Supporting excerpts use blockquotes.
|
||||
|
||||
**The Review Surface Rule.** The visible Markdown must be readable without understanding the
|
||||
machine contract. Technical metadata belongs in progressive disclosure, not above the title.
|
||||
machine contract. In Administration, explain current and original provenance separately and
|
||||
keep file-editing templates and Git instructions in progressive disclosure. Show actual host
|
||||
paths with copy controls, never browser file links to container-only locations.
|
||||
|
||||
## Do's and Don'ts
|
||||
|
||||
@@ -305,7 +442,8 @@ machine contract. Technical metadata belongs in progressive disclosure, not abov
|
||||
- **Do** preserve information density with headings, rhythm, and progressive disclosure.
|
||||
- **Do** keep keyboard focus explicit and pair color with text, shape, icon, or position.
|
||||
- **Do** respect `prefers-reduced-motion` while preserving immediate non-kinetic feedback.
|
||||
- **Do** use English for interface chrome and the workspace language for persisted document content.
|
||||
- **Do** use the selected interface language (English by default) for chrome and preserve the
|
||||
workspace language for persisted domain content. Session interaction language remains pinned.
|
||||
- **Do** render curated metadata and scope as Markdown prose or lists, never as a frontmatter table.
|
||||
- **Do** break long curated rules into paragraphs, labelled subsections, and lists at existing
|
||||
punctuation boundaries while preserving the exact canonical text for machines.
|
||||
|
||||
+91
-219
@@ -1,237 +1,109 @@
|
||||
# ThothII — Project State
|
||||
# Project state
|
||||
|
||||
Last updated: 2026-08-27.
|
||||
Updated: 2026-09-28. This is a current snapshot, not a release diary. Stable commands
|
||||
and invariants are in [AGENTS.md](AGENTS.md); prior snapshots remain in Git.
|
||||
|
||||
This file is the short operational snapshot. Stable commands and the architecture mental model
|
||||
live in `AGENTS.md`; current design and runtime contracts live under `docs/architecture/`,
|
||||
`docs/contracts/`, `docs/adr/`, and `docs/evidence.md`. Superseded plans and reports are
|
||||
available from Git history rather than duplicated in the working tree.
|
||||
## Current contracts
|
||||
|
||||
## Current product shape
|
||||
- Document-first installation ticket #43 provides offline `tht workspace prepare`
|
||||
and `tht workspace validate` through a native two-executable bundle. Build and
|
||||
test instructions are in [the host CLI guide](tools/tht/README.md). Local validation
|
||||
reuses runtime workspace/catalog parsers and explicitly defers runtime Evidence,
|
||||
database binding and readiness checks. Ticket #44 adds `tht installation prepare`,
|
||||
explicit `installation credentials`, and `installation validate --workspaces PATH`
|
||||
for protected application documents, canonical model settings and schema-v1
|
||||
database bootstrap inputs. These commands do not start services or import Catalog
|
||||
bindings. Ticket #45 adds host `installation preflight` and `installation plan`
|
||||
with release/image checks, canonical external diagnostics and private input seals;
|
||||
see [the preflight reference](docs/install/installation-preflight.md).
|
||||
The remainder of the installation tickets,
|
||||
Docker Hub publication and example databases remain pending.
|
||||
|
||||
ThothII is a human-in-the-loop datamart builder with three independently built layers:
|
||||
- React supports full/embedded rendering independently of local/OIDC/upstream auth,
|
||||
with EN/IT UI and immutable session interaction language. See
|
||||
[application shell](docs/architecture/application-shell.md) and
|
||||
[localization](docs/operations/shell-and-localization.md).
|
||||
- PostgreSQL Metadata Catalog owns database identity, binding, schema, descriptions,
|
||||
sensitivity and relationships for all core consumers. Workspace schema v4 contains
|
||||
identity and optional Evidence only. Installation schema v2 is the authored model
|
||||
catalog source. See [overview](docs/architecture/overview.md) and
|
||||
[model configuration](docs/general/pi-configuration.md).
|
||||
- The harness owns workflow persistence; chat is not the durable session record.
|
||||
Memory uses PostgreSQL authority and derived Qdrant dense/BM25 search. Editable
|
||||
Evidence has local archive authority and manual consolidation. File save, search
|
||||
activation and Git publication have distinct outcomes. See
|
||||
[Memory](docs/gestione-memory.md), [Evidence](docs/contracts/curated-evidence-v4.md)
|
||||
and [consolidated release evidence](docs/reports/knowledge-archives-release.md).
|
||||
- Reference preprocessing must not clear Memory. Use the installation-scoped
|
||||
`tht --installation /absolute/path/thothii-installation.yaml workspace preprocess run`
|
||||
and its [contract](docs/contracts/workspace-preprocessing-cli.md).
|
||||
- DWH sessions are read-only. SSH tunnels support database-management diagnostics
|
||||
and metadata synchronization, not NL→SQL session creation; use direct or REST
|
||||
transport for sessions.
|
||||
|
||||
```text
|
||||
frontend (React/SSE) → backend (Fastify) → pi --mode rpc → tht/harness → DWH (read-only)
|
||||
```
|
||||
## Installation and workspace boundaries
|
||||
|
||||
The harness owns the deterministic eight-phase NL→SQL workflow and all session persistence.
|
||||
The backend remains a process/RPC/SSE bridge for sessions and now also owns an isolated PostgreSQL
|
||||
metadata catalog for administrative database configuration. The frontend renders the review gates
|
||||
and keeps the live transcript in memory. See
|
||||
`docs/architecture/components.md` for the detailed component and data-flow map.
|
||||
Fresh standalone installations follow the manual terminal procedures in
|
||||
[Italian](docs/install/standalone-manual-it.md) or
|
||||
[English](docs/install/standalone-manual-en.md), without an installer or launcher.
|
||||
The [Compose reference](docs/operations/compose-reference.md) is for maintainers,
|
||||
not another quick start. Catalog and Memory migrations are explicit.
|
||||
|
||||
## Evidence restructuring — accepted
|
||||
Descriptors, authentication, provider credentials, certificates and runtime bindings
|
||||
stay in protected installation-local paths. Do not copy secrets into examples or
|
||||
workspace Git. Legacy runtime snapshots may use absolute paths and protected
|
||||
`harness/.env`; do not silently relocate them.
|
||||
|
||||
The evidence restructuring and PSD migration completed real acceptance on 2026-08-25.
|
||||
PSD authoring is separate at `/Users/mp/projects/tht-workspace-psd`. Its GitHub
|
||||
repository was copied to private Gitea
|
||||
[workspace_psd](https://git.tylconsulting.it/mptyl/workspace_psd), preserving both
|
||||
branches. It is a copy, not automatic synchronization. Running installations were
|
||||
not repointed to a different workspace remote.
|
||||
|
||||
- The curated PSD revision contains 35 approved Evidence units and 60 review items.
|
||||
- The PSD authoring repository publishes all 35 units using Curated unit schema v3. Its table-free
|
||||
presentation uses hidden canonical metadata, wrapping Markdown scope lists, list-based enum
|
||||
values, and collapsed technical provenance. Long domain rules now have a deterministic
|
||||
human-readable presentation while retaining their exact canonical text for vector ingestion.
|
||||
`tht evidence migrate <workspace-root>` performs the deterministic v1/v2 upgrade and older-v3
|
||||
presentation rewrite without model calls. The structured-rule PSD rewrite is currently local and
|
||||
pending commit/publication.
|
||||
- The accepted snapshot is
|
||||
`psd-clinical-675990d90eae51da6f2bd51b1ae2609f245772ef-snapshot`.
|
||||
- The active generation is `gen:f968b3bd7a553dbfef3cf47093698f2bc7f95f11`.
|
||||
- Retrieval acceptance reached 20/20 Hit@10.
|
||||
- A real session, `20301df7-cad7-403d-a4c1-9f35c9d07b66`, completed F1–F8 with five
|
||||
receipts, three CTEs, and a final result of 78 patients.
|
||||
- The durable acceptance record is
|
||||
`docs/testing/evidence/evidence-restructuring-psd-acceptance-2026-08-25.md`.
|
||||
## Recorded deployments and server authority
|
||||
|
||||
The canonical authoring, validation, publication, materialization, and preprocessing flow is
|
||||
documented in `docs/evidence.md`. The governing contracts are
|
||||
`docs/contracts/workspace-evidence-v3.md` and
|
||||
`docs/contracts/workspace-preprocessing-cli.md`.
|
||||
Use the ordered [server handoff](docs/operations/server-codex-handoff.md) for
|
||||
coordinated ThothII/Omics upgrades. Omics integration uses GitHub
|
||||
`Dallavilla-Tiziano/omics_portal`, with no Gitea relay prerequisite. Omics uses
|
||||
embedded/upstream identity, not another ThothII OIDC login. Read
|
||||
[upstream authentication](docs/install/authentication-upstream.md) before changes.
|
||||
|
||||
## Workspace preprocessing and configuration
|
||||
Last recorded application deliveries (not a fresh runtime attestation):
|
||||
|
||||
The native host CLI `tht` is the operator surface. Workspace preprocessing runs through:
|
||||
- [Session dialogs](docs/reports/2026-09-14-session-dialogs-release.md):
|
||||
`b1723c34-session-dialogs-20260914`, frontend-only.
|
||||
- [Session layout/Memory fix](docs/reports/2026-09-14-session-layout-memory-fix.md):
|
||||
`49333a2d-session-memory-fix`, core/frontend.
|
||||
|
||||
```sh
|
||||
tht --installation /absolute/path/thothii-installation.yaml workspace preprocess evidence
|
||||
tht --installation /absolute/path/thothii-installation.yaml workspace preprocess dwh
|
||||
```
|
||||
Keep those reports and rollback instructions while operator gates remain open.
|
||||
A later deployment does not prove every earlier acceptance item passed.
|
||||
|
||||
These commands use the profile-gated `workspace-maintenance` service. The former standalone
|
||||
preprocessing Compose fixtures are retired.
|
||||
## Remaining acceptance and design gates
|
||||
|
||||
Workspace descriptors use schema v3. For PSD, workspace content and runtime roots point to the
|
||||
separate uncommitted repository `/Users/mp/projects/tht-workspace-psd`. Secrets remain outside
|
||||
Git and are supplied only through installation-local protected files.
|
||||
- Fresh-machine Mac, Windows/WSL2 and Linux installation acceptance, including real
|
||||
DWH/model endpoints, remains a separate operator exercise.
|
||||
- Real IdP/portal login, logout, embedded interaction and PSD semantic acceptance
|
||||
follow the [manual matrix](docs/testing/authentication-manual-acceptance.md) and
|
||||
delivery reports; synthetic tests do not close them.
|
||||
- [Security hardening](docs/plans/2026-09-08-security-hardening-prd.md) is a draft.
|
||||
Revalidate SEC01–12 and obtain design approval before implementation or real
|
||||
server/IdP/DWH mutation.
|
||||
- Optional NER remains opt-in; labeled Italian quality, benchmark and licensing
|
||||
acceptance are not implied by document cleanup.
|
||||
- Semantic aliases, value descriptions, synonyms/concepts, dialect and multi-schema
|
||||
extensions remain explicit design work. Current sensitivity delivery follows the
|
||||
Catalog contract; additional policies require their own acceptance.
|
||||
- Legacy database UI fallback (`?db-ui=legacy`, dev/staging) and prototype removal
|
||||
remain subject to owner acceptance.
|
||||
|
||||
## Database management
|
||||
## Documentation maintenance
|
||||
|
||||
The database, table, and authoritative physical-schema catalog slices are implemented. Database
|
||||
Management now opens the Fleet Ledger presentation by default inside `AppShell`, lists every YAML
|
||||
workspace, creates at most one PostgreSQL database configuration per workspace, edits direct
|
||||
PostgreSQL, REST API, or SSH-tunnel installation bindings, replaces write-only encrypted secrets,
|
||||
and tests supported connector bindings. The surface keeps one responsive AG Grid visible at a time:
|
||||
databases lead to tables, tables lead to columns, and relationships are a sibling database view.
|
||||
Parent navigation remains explicit through the breadcrumb and emphasized back control.
|
||||
MkDocs publishes only 20 product/operator pages and five approved assets.
|
||||
Architecture, contracts, ADRs, plans, research, tests and release evidence are
|
||||
excluded from HTML and search. The repository itself is public: editorial exclusion
|
||||
is not confidentiality.
|
||||
|
||||
Selection-scoped operations use one action selector plus an explicit **Run** control; ineligible
|
||||
actions remain visible with their disabled reason, while row-scoped actions stay in the pinned final
|
||||
column. The KPI strip reads installation-wide or selected-database aggregates from
|
||||
`GET /catalog/metrics`. Database configuration, metadata editors, synchronization history,
|
||||
description history, and sensitive-field review/history use the production APIs in right-side
|
||||
drawers rather than prototype fixtures; closing a history drawer does not stop its background run.
|
||||
|
||||
Physical membership, source
|
||||
comments, column types/default/nullability/PK positions, and constraint-level ordered FK pairs are
|
||||
projections of the external schema. They cannot be created, renamed, or structurally edited by
|
||||
hand, but administrators can explicitly clear catalog tables, columns, or relationships without
|
||||
touching the source database, binding, configuration, or secrets. Table deletion cascades through
|
||||
columns and relationships; table-scoped relationship cleanup includes incoming and outgoing
|
||||
relationships. Curated and generated descriptions are editable; generated descriptions start null
|
||||
and Database Management can generate or consolidate them for selected tables, selected columns,
|
||||
all targets, or only targets whose Generated Description is missing.
|
||||
|
||||
The previous Database Management renderer remains a temporary comparison fallback for development
|
||||
and staging only: `?db-ui=legacy` is honored in Vite development or when
|
||||
`VITE_DB_MANAGEMENT_LEGACY=true`; it is not a production presentation. The standalone Fleet Ledger
|
||||
prototype on port `5173` also remains temporary until owner acceptance of the integrated surface,
|
||||
after which both migration aids can be removed.
|
||||
|
||||
Schema refresh is one durable asynchronous engine with database-table, database-column,
|
||||
selected-table-column, relationship, and full-database actions. Database-level menus expose the
|
||||
table, all-column, relationship, and full scopes separately; selecting tables exposes column
|
||||
synchronization plus manual column and relationship cleanup for that subset. Database selections
|
||||
also expose manual table and relationship cleanup. Cleanup selections are atomic and share the
|
||||
one-active-operation-per-database exclusion with synchronization. Runs have leases and
|
||||
restart recovery, atomic apply, destructive-diff confirmation with re-scan, cancellation before
|
||||
apply, retained history, and a live SSE log with polling fallback. Null metadata renders blank
|
||||
rather than as a placeholder.
|
||||
|
||||
Direct PostgreSQL and strict known-host-verified OpenSSH use `pg_catalog`. REST bindings use the
|
||||
typed full-snapshot `POST /rpc/schema_snapshot` contract when available. Servers such as the
|
||||
current PSD endpoint that exposes only `POST /rpc/run_query` use one catalog-owned read-only query
|
||||
to return the exact same strict v1 snapshot in a single round trip. Both paths remain fail-closed:
|
||||
an absent capability, query error, partial result, or invalid snapshot applies no catalog changes.
|
||||
SSH is not yet enabled for NL→SQL session runtime.
|
||||
|
||||
The catalog runs in the internal `catalog-db` PostgreSQL service. Kysely migrations are an explicit
|
||||
one-shot `catalog-migrate` operation; `scripts/run-stack.sh` runs it before local startup. Runtime
|
||||
sessions still consume the existing workspace configuration in this slice: database-management
|
||||
records do not yet change the NL→SQL handoff. The accepted design is recorded in
|
||||
`docs/plans/2026-08-26-metadata-catalog-from-thothai.md`, the snapshot contract under
|
||||
`docs/contracts/`, and ADRs 0001–0011.
|
||||
|
||||
Semantic aliases, value descriptions, synonyms, concepts, and logical relationships remain
|
||||
deferred to their dedicated slices.
|
||||
|
||||
AI Description Generation uses the catalog's human-owned Sensitive Data Flag. The flag defaults to
|
||||
`false`, including for newly synchronized columns. An administrator may request an AI proposal based
|
||||
only on structural metadata for one selected database, selected tables, or selected columns. The
|
||||
backend divides large scopes into deterministic model requests of at most ten columns, also bounded
|
||||
by helper message size, and combines their results, but the proposal remains an unsaved draft until
|
||||
the human reviews and saves it.
|
||||
Each started suggestion attempt records a separate Sensitive Data Suggestion Run with aggregate
|
||||
counters and safe ordered events. This operational history never stores per-column proposals,
|
||||
prompts, raw model output, or provider diagnostics; reloading still discards an unsaved review
|
||||
draft.
|
||||
For unprotected columns, up to five source rows and five representative non-null values may be sent
|
||||
transiently to the configured model provider. Protected columns are omitted from source reads and
|
||||
replaced in the prompt by deterministic plausible values derived only from their metadata. Existing
|
||||
descriptions are not regenerated when a flag changes.
|
||||
|
||||
The accepted AI-description design is recorded in
|
||||
`docs/plans/2026-08-28-ai-catalog-description-generation.md`, with the formal specification in the
|
||||
adjacent `-spec.md` document and Gitea issue #4. Gitea issues #5–#11 deliver the implementation.
|
||||
The runtime deliberately keeps ThothAI's simple operating model: one installation-wide sequential
|
||||
run owned by the backend, one short-lived Python/LiteLLM completion helper per request, and
|
||||
persistence limited to the run, its safe ordered text events, and each Generated Description as
|
||||
soon as it succeeds. The helper performs at most one provider retry and never falls back to another
|
||||
model. Stop terminates the current helper and retains prior results; three consecutive exhausted
|
||||
technical batches fail the run. Startup marks stale queued/running work interrupted, and Unlock is
|
||||
available only when no local start, worker, or helper is live. Runs remain inspectable through a
|
||||
live SSE log with ordered polling fallback; there is no automatic resume or user-facing generation
|
||||
CLI. ADRs 0009–0010 record the runtime and source-sampling decisions.
|
||||
|
||||
Metadata-generation setup accepts the protected `DEEPSEEK_API_KEY` and `ZAI_API_KEY` references.
|
||||
It also accepts a model with no secret reference only when its OpenAI-compatible endpoint is
|
||||
explicit; this covers the VPN-only AritmoLab Qwen 3.6 server without creating a fake operator
|
||||
credential. The Python client supplies only its fixed non-secret compatibility placeholder.
|
||||
The AritmoLab entry also sets `disableThinking: true`, mapped to the endpoint's chat-template flag,
|
||||
because its default reasoning prose would violate the worker's exact JSON response contract.
|
||||
|
||||
Integration of the completed metadata catalog with core schema-linking is explicitly deferred
|
||||
until the database, table, column, relationship, and synchronization slices are complete. At that
|
||||
point the next required design gate is to compare the catalog snapshot with the current DWH
|
||||
preprocessing/schema-linking contracts and plan the cutover; this follow-up must not be treated as
|
||||
optional cleanup or silently omitted.
|
||||
|
||||
**Deferred follow-up — Sensitive Data Policy in schema-linking.** The policy is first delivered
|
||||
and tested in catalog description generation. Its enforcement for core schema-linking remains
|
||||
out of scope until the current tickets are closed and the owner has completed the acceptance test.
|
||||
At that gate, resume the design: `tht` must receive a read-only projection of the current Sensitive
|
||||
Data Flags and exclude values from columns marked sensitive from every LSH result before it is
|
||||
given to Pi. Do not start this integration before the owner gives final approval after that test.
|
||||
|
||||
## Active deployment work and manual gates
|
||||
|
||||
### PSD server deployment program
|
||||
|
||||
The approved design and executable entry point are:
|
||||
|
||||
- `docs/plans/2026-08-20-psd-server-deployment-program-design.md`
|
||||
- `docs/plans/2026-08-20-psd-server-deployment-program.md`
|
||||
- `docs/plans/2026-08-20-psd-server-survey.md`
|
||||
- `docs/plans/2026-08-20-psd-server-project-a-standalone.md`
|
||||
- `docs/plans/2026-08-20-psd-server-project-b-authentik.md`
|
||||
|
||||
Last recorded state:
|
||||
|
||||
- survey: `SURVEY_NO_GO`;
|
||||
- Project A: `BLOCKED_BY_SURVEY_AND_MUTATION_GATE`;
|
||||
- Project B: `BLOCKED_BY_PROJECT_A_AND_PRE_B_GATE`.
|
||||
|
||||
The deployment is a clean replacement: legacy sessions, indexes, and application configuration
|
||||
are not migration inputs. The existing stack remains intact until its documented mutation and
|
||||
rollback gates are explicitly approved. Shared Omics/LocalLLM networks, ETL Evidence, DWH,
|
||||
`dwh-auth`, Supabase, Authentik, Superset, and Aritmolab are outside cleanup scope.
|
||||
|
||||
Human acceptance guides and sanitized report templates live under `docs/testing/` and
|
||||
`docs/testing/evidence/`. The remediation checklist is
|
||||
`docs/operations/psd-server-survey-remediation-checklist.md`.
|
||||
|
||||
### Authentication
|
||||
|
||||
The local/OIDC authentication remediation passed its automated review on 2026-08-18. Release and
|
||||
PSD mutation gates remain governed by:
|
||||
|
||||
- `docs/architecture/authentication.md`;
|
||||
- `docs/plans/2026-08-18-thothii-authentication-acceptance-and-psd-deployment.md`;
|
||||
- `docs/operations/psd-dwh-auth-rollout.md`;
|
||||
- `docs/testing/authentication-manual-acceptance.md`.
|
||||
|
||||
Do not infer authorization for server, Nginx, Authentik, database, credential, or cutover changes
|
||||
from an automated PASS.
|
||||
|
||||
## Verification status
|
||||
|
||||
- The P1.1 workspace-directory registry and P2–P6 preprocessing workstreams are implemented and
|
||||
have automated coverage.
|
||||
- Evidence restructuring has a real PSD acceptance PASS as recorded above.
|
||||
- AI Description Generation has automated coverage across installation setup, model selection,
|
||||
generation/consolidation scopes, bounded sampling, cancellation/recovery, history, SSE/polling,
|
||||
and the LiteLLM helper boundary.
|
||||
- L2 tests requiring real providers or remote databases remain opt-in.
|
||||
- Server deployment, release, and owner-operated acceptance steps remain pending wherever the
|
||||
referenced runbooks require explicit approval.
|
||||
|
||||
Run the layer-specific checks documented in `AGENTS.md`. For release-sensitive changes, also run
|
||||
the repository contract scripts in `scripts/` and build the MkDocs site.
|
||||
|
||||
## Operational invariants
|
||||
|
||||
- `tht`'s `-c`/`--config` option follows the subcommand; it is not a global option.
|
||||
- `--json` commands write pristine JSON to stdout.
|
||||
- Persisted phase documents and the decision ledger are the source of session truth; chat is not.
|
||||
- UI chrome is English; workspace document content retains the workspace language.
|
||||
- The backend refuses resume for finalized or archived sessions.
|
||||
- A resume must send `/riprendi-sessione <id>`; a new session must send `/nuova-domanda`.
|
||||
- DWH access is read-only.
|
||||
The [cleanup record](docs/maintenance/2026-09-15-documentation-cleanup.md) records
|
||||
retired sources and retained gates. Main contains source; Actions generates the
|
||||
`pages` branch. The live site requires the separate explicit deployment described
|
||||
in [public manual publication](docs/operations/public-docs-publication.md).
|
||||
|
||||
@@ -4,72 +4,52 @@ ThothII is a human-reviewed NL-to-SQL workflow with a React frontend and a Fasti
|
||||
core. The portable deployment runs two application services plus the installation-local metadata
|
||||
catalog; DWH and LLM services remain external. Semantic services are bundled in Compose.
|
||||
|
||||
Authentication is configured through the single host CLI tht: see the [local authentication guide](docs/install/authentication-local.md),
|
||||
[generic OIDC guide](docs/install/authentication-oidc.md), and [manual acceptance matrix](docs/testing/authentication-manual-acceptance.md).
|
||||
The same frontend supports **full** (its own header) and **embedded** (inside a
|
||||
portal). This choice is independent of authentication: the Mac uses full/local,
|
||||
Omics uses embedded/upstream with its existing login, and a standalone server
|
||||
can use full/OIDC. See [rendering architecture](docs/architecture/application-shell.md)
|
||||
and [configuration, Omics delivery and deploy](docs/operations/shell-and-localization.md).
|
||||
|
||||
## Docker Compose: local startup
|
||||
For the current server upgrade with Omics Portal, follow the ordered
|
||||
[Codex server handoff](docs/operations/server-codex-handoff.md), including source
|
||||
integration, embedded/upstream configuration, coordinated rollout and rollback.
|
||||
|
||||
Requirements: Docker Engine with Compose v2. The mandatory stack is `frontend`, `core`,
|
||||
`catalog-db`, `qdrant`, `embedding`, and the one-shot `embedding-model-init`. DWH and LLM remain external,
|
||||
configurable endpoints—even when they are co-located with ThothII.
|
||||
Local/OIDC authentication is configured through the host CLI `tht`; portal
|
||||
authentication is established by the trusted server proxy. See the
|
||||
[local guide](docs/install/authentication-local.md),
|
||||
[OIDC guide](docs/install/authentication-oidc.md),
|
||||
[upstream integration](docs/install/authentication-upstream.md), and
|
||||
[manual acceptance matrix](docs/testing/authentication-manual-acceptance.md).
|
||||
|
||||
From a fresh clone, run these commands from the repository root:
|
||||
For the clone-based manual standalone installation test on macOS, Windows, and Linux, use the
|
||||
[Italian procedure](docs/install/standalone-manual-it.md) or the
|
||||
[English procedure](docs/install/standalone-manual-en.md).
|
||||
|
||||
```sh
|
||||
cp deploy/env/local.env.example deploy/env/local.env
|
||||
# Copy docs/install/examples/thothii-installation.local.yaml to a protected operator path,
|
||||
# replace its placeholders, chmod it 600, and set that exact THT_INSTALLATION_CONFIG_SOURCE.
|
||||
# Edit deploy/env/local.env, including PI_AUTH_FILE, THT_SECRETS_FILE, and external endpoints.
|
||||
./scripts/run-stack.sh
|
||||
```
|
||||
The [public manual](https://git.tylconsulting.it/thothii-docs/) covers the product,
|
||||
installation, use and administration. Developer architecture, contracts, ADRs, tests,
|
||||
plans and release records remain in this repository but are excluded from MkDocs
|
||||
pages and search. This is an editorial boundary, not an access restriction on the
|
||||
public repository. See the [documentation cleanup review](docs/maintenance/2026-09-15-documentation-cleanup.md)
|
||||
for the executed consolidation and the inventory of historical sources retained in Git.
|
||||
|
||||
The launcher builds the core, starts `catalog-db`, runs the explicit one-shot Kysely migrations,
|
||||
then runs the base+local stack in the foreground. Migrations never run implicitly in backend
|
||||
startup. The core image contains its Pi runtime; no host `pi` executable is used. For a server
|
||||
installation, build the image, start the catalog, and run the same migration service before the
|
||||
application rollout:
|
||||
## Docker Compose and installation
|
||||
|
||||
```sh
|
||||
cp deploy/env/server.env.example deploy/env/server.env
|
||||
# Prepare a mode-600 thothii-installation.yaml from the server example and set its exact
|
||||
# path as THT_INSTALLATION_CONFIG_SOURCE. Edit all remaining storage/secret/endpoint paths.
|
||||
sudo scripts/prepare-server-pi-state.sh /srv/thothii/pi-state 10001 10001
|
||||
docker compose --env-file deploy/env/server.env \
|
||||
-f compose.yaml -f deploy/compose.server.yaml \
|
||||
-f deploy/compose.session-server.yaml.example build core
|
||||
docker compose --env-file deploy/env/server.env \
|
||||
-f compose.yaml -f deploy/compose.server.yaml \
|
||||
-f deploy/compose.session-server.yaml.example up -d catalog-db
|
||||
docker compose --env-file deploy/env/server.env \
|
||||
-f compose.yaml -f deploy/compose.server.yaml \
|
||||
-f deploy/compose.session-server.yaml.example run --rm catalog-migrate
|
||||
docker compose --env-file deploy/env/server.env \
|
||||
-f compose.yaml -f deploy/compose.server.yaml \
|
||||
-f deploy/compose.session-server.yaml.example up --build -d
|
||||
```
|
||||
For a fresh installation, follow the guided terminal procedure in
|
||||
[Italian](docs/install/standalone-manual-it.md) or
|
||||
[English](docs/install/standalone-manual-en.md). The single
|
||||
`tht setup --complete` command validates protected files, builds the images, runs
|
||||
Catalog migration, starts the stack and imports the configured workspace repository.
|
||||
There is no graphical installer or native launcher.
|
||||
|
||||
The initializer is required for an empty or restored server Pi-state bind. It atomically creates
|
||||
the three regular targets hidden below the writable parent bind; protected Pi auth and tracked
|
||||
model/settings sources remain separate read-only mounts. See the server manual before substituting
|
||||
a root other than `/srv/thothii/pi-state`.
|
||||
The mandatory stack includes frontend, core, PostgreSQL catalog, Qdrant, Ollama
|
||||
and the embedding initializer. DWH and LLM endpoints remain external dependencies.
|
||||
Pi is included in the core image. Credentials and certificates belong in protected
|
||||
installation-local files, never in the workspace repository.
|
||||
|
||||
Workspace descriptors come from the Git remote configured by `THT_WORKSPACE_GIT_REMOTE`; their
|
||||
runtime endpoint and secret bindings remain installation-local. Open
|
||||
<http://127.0.0.1:8080> (set `THOTH_HTTP_PORT` in `deploy/env/local.env` to choose another
|
||||
loopback port).
|
||||
|
||||
Credentials and certificates are local protected files. Do not put them in environment examples,
|
||||
workspace YAML, URLs, or Compose interpolation values.
|
||||
|
||||
Application state is split across the named `settings`, `pi-state`, `workspace-registry`,
|
||||
`sessions`, `qdrant-data`, and `embedding-models` volumes. `docker compose down` keeps them.
|
||||
`qdrant-data` is a derived but persistent index store; `embedding-models` is an Ollama model
|
||||
cache for `qwen3-embedding:0.6b` with fixed `1024`-dimension embeddings. Only an explicit destructive command such as `docker compose
|
||||
down --volumes` removes them.
|
||||
|
||||
The frontend depends on the core health check and proxies `/health` and `/api/*` to it. The
|
||||
application health endpoint intentionally checks process readiness only; external dependency
|
||||
diagnostics are exposed by `tht doctor` and do not prevent the UI from starting.
|
||||
For developer topology, overlays and lifecycle details, see the internal
|
||||
[Compose reference](docs/operations/compose-reference.md). Ordinary stop/down keeps
|
||||
persistent data; removing volumes is destructive and is not an upgrade step.
|
||||
Process health is distinct from external dependency checks performed by doctor.
|
||||
|
||||
## Git-backed workspace repository
|
||||
|
||||
@@ -106,15 +86,20 @@ a remote user's partial list. The isolated deployment exercise is
|
||||
`./scripts/verify-workspace-install-docs.sh --profile local` or `--profile server`.
|
||||
|
||||
<!-- workspace-descriptor-contract:start -->
|
||||
Schema v3 is the only accepted workspace descriptor. Schema v1 and v2 workspace descriptors are
|
||||
rejected before activation. Candidate snapshot validation therefore makes activation or a pull fail
|
||||
atomically while 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`.
|
||||
Schema v4 is the only accepted workspace descriptor. It contains workspace identity and optional
|
||||
Evidence configuration only; PostgreSQL Metadata Catalog owns every database fact and binding.
|
||||
Schema v1, v2, and v3 descriptors are rejected before activation. Candidate snapshot validation
|
||||
therefore makes activation or a pull fail atomically while the prior valid snapshot remains active.
|
||||
Each workspace owns separate Qdrant `reference` and `memory` collections: Schema, relationships, and
|
||||
Evidence are replaceable reference data; Memory and solved questions have a persistent lifecycle.
|
||||
<!-- workspace-descriptor-contract:end -->
|
||||
|
||||
<!-- non-workspace-migration:start -->
|
||||
Create a clean v4 descriptor containing only `workspace` and optional `evidence`. Do not copy the
|
||||
legacy database, diagnostics, `llm_policy`, or `semantic_index` blocks; configure the database in
|
||||
Database Management.
|
||||
<!-- non-workspace-migration:end -->
|
||||
|
||||
For NL→SQL runtime sessions, connector `ssh_tunnel` bindings remain diagnostic-only: their bounded
|
||||
probe cleans up the loopback forward and returns `workspace_not_activatable`; session creation is
|
||||
rejected before persistence. Database management is a separate boundary and supports a strict
|
||||
@@ -176,10 +161,10 @@ secret files, upstream-auth checks, and a fail-closed `503` assertion for its de
|
||||
unavailable disposable session endpoint. No real provider, database credential, or repository
|
||||
secret is required.
|
||||
|
||||
For a clean server bind, `scripts/prepare-server-pi-state.sh` creates the hidden regular
|
||||
`agent/auth.json`, `agent/models.json`, and `agent/settings.json` mount targets atomically before
|
||||
Compose. The server smoke starts from an empty Pi-state root and applies this same preflight; the
|
||||
real protected/tracked sources remain separate read-only mounts. Deterministic fixture tests render
|
||||
For a clean server bind, `scripts/prepare-server-pi-state.sh` creates the hidden regular Pi agent
|
||||
mount targets atomically before Compose. The auth target receives the protected credential bind;
|
||||
the model and settings targets receive generated read-only projections. The server smoke starts
|
||||
from an empty Pi-state root and applies this same preflight. Deterministic fixture tests render
|
||||
both profiles, verify that bindings stay on `core`, check mount readability, and run the production
|
||||
workspace resolver. Wrong-service, wrong-value, and broken-secret-mount mutations must fail.
|
||||
|
||||
@@ -189,7 +174,7 @@ an independent 32-minute outer timeout and does not retry a failed command.
|
||||
Current release status (2026-08-05): clean-root render/setup and the production runtime-binding
|
||||
resolver contracts are green. The server fixture supplies all four private trusted claims,
|
||||
including exact non-admin value `0`, and a focused test proves nginx normalization produces the
|
||||
accepted non-admin backend principal. Canonical schema-v3 registry descriptors now pass through
|
||||
accepted non-admin backend principal. Canonical schema-v4 registry descriptors now pass through
|
||||
one backend-owned, secret-safe runtime handoff for inventory and session execution; canonical
|
||||
identity and durable session/artifact/index roots are retained. The fresh update-only smoke passed
|
||||
bad-candidate mutation, automatic `rolled_back` compensation, exact prior-image restoration,
|
||||
@@ -220,15 +205,23 @@ job remains deterministic and does not claim Docker startup.
|
||||
|
||||
## Workspace preprocessing and S3 Evidence
|
||||
|
||||
Run preprocessing through the native host CLI and the installation descriptor:
|
||||
For an interactive run, select the workspace, expand **Administration** in the right sidebar, and
|
||||
use its **Preprocessing** control. The control explains any unmet prerequisite and exposes only the
|
||||
latest safe failure diagnostic. For unattended operation, use the native host CLI and installation
|
||||
descriptor:
|
||||
|
||||
```sh
|
||||
tht --installation /absolute/path/thothii-installation.yaml workspace preprocess evidence
|
||||
tht --installation /absolute/path/thothii-installation.yaml workspace preprocess dwh
|
||||
tht --installation /absolute/path/thothii-installation.yaml \
|
||||
workspace preprocess run --workspace <workspace-id>
|
||||
|
||||
tht --installation /absolute/path/thothii-installation.yaml \
|
||||
workspace preprocess clear --workspace <workspace-id>
|
||||
```
|
||||
|
||||
The CLI starts the profile-gated `workspace-maintenance` service and enforces the workspace,
|
||||
secret, Qdrant, and embedding contracts. See [Evidence](docs/evidence.md) and the
|
||||
The one-shot command starts the profile-gated `workspace-maintenance` service, reads database
|
||||
metadata from PostgreSQL, and rebuilds LSH plus schema/Evidence vectors. The clear command removes
|
||||
those derived artifacts while preserving the separate Memory collection. The core remains unavailable
|
||||
until preprocessing completes. See [Evidence](docs/evidence.md) and the
|
||||
[workspace preprocessing CLI contract](docs/contracts/workspace-preprocessing-cli.md).
|
||||
|
||||
S3 Evidence uses the optional `tht[s3]` dependency and canonical `s3://bucket/key` provenance.
|
||||
@@ -269,7 +262,7 @@ 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. It restores semantic storage only. Before reopening write
|
||||
traffic, the workspace registry must already be at a reviewed v3 descriptor revision compatible
|
||||
traffic, the workspace registry must already be at a reviewed v4 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.
|
||||
@@ -295,14 +288,12 @@ Copy `deploy/secrets/thothii.secrets.example` to a protected host file, include
|
||||
keys, and set its absolute path as `THT_SECRETS_FILE` in the operator env. Keep Pi's native
|
||||
provider auth in the separate protected file named by `PI_AUTH_FILE`.
|
||||
|
||||
Description Generation is configured independently in the protected installation descriptor under
|
||||
`metadataGeneration`. Set `THT_INSTALLATION_CONFIG_SOURCE` to that exact host file; Compose mounts
|
||||
it read-only into `core` and supplies the fixed runtime `THT_INSTALLATION_CONFIG_FILE` path. Each
|
||||
keyed model stores only an audited `apiKeyEnv` reference. The referenced value stays in the secret
|
||||
bundle; a model may omit `apiKeyEnv` only when it declares an explicit endpoint that accepts
|
||||
unauthenticated requests. The browser receives only model IDs, labels, and the configured default.
|
||||
Configuration changes take effect after restart and do not use Pi settings or workspace
|
||||
`llm_policy`.
|
||||
Interactive sessions, Description Generation, and embedding share the protected installation
|
||||
descriptor's `modelCatalog`. Set `THT_INSTALLATION_CONFIG_SOURCE` to that exact host file; `tht`
|
||||
validates it and generates the runtime catalog, Pi adapters, and Compose override before startup.
|
||||
Each authenticated provider stores only an audited `apiKeyEnv` reference; the referenced value stays
|
||||
in the secret bundle. A provider may use `authentication.mode: none` only with an explicit keyless
|
||||
endpoint. The browser receives only eligible model IDs, labels, and the catalog default.
|
||||
|
||||
Before enabling Description Generation, approve the selected model provider for bounded source-data
|
||||
disclosure. Every catalog column has a **Sensitive** flag that defaults to `false`. Administrators can
|
||||
@@ -333,22 +324,11 @@ the host/secret-manager materialization and add a reviewed Compose override that
|
||||
does not create that mount. The frontend remains on loopback; the authenticated host proxy is the
|
||||
only public listener.
|
||||
|
||||
Set the selected model provider in application settings (or `PI_PROVIDER`). For each Pi spawn the
|
||||
backend validates and reads `THT_MODEL_API_KEY` from the bundle, then exposes its value only as the provider's
|
||||
recognized child variable (for example `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, or
|
||||
`ZAI_API_KEY`). Neither the generic file path nor deprecated `PI_PROVIDER_API_KEY` is inherited by
|
||||
Pi. Local providers such as Ollama require no model key.
|
||||
|
||||
`THT_MODEL_API_KEY` supports Pi providers whose authentication is exactly one key:
|
||||
`ant-ling`, `anthropic`, `cerebras`, `deepseek`, `fireworks`, `github-copilot`, `google`
|
||||
(including the `gemini` alias), `google-vertex` when using its API-key mode, `groq`,
|
||||
`huggingface`, `kimi-coding`, `minimax`, `minimax-cn`, `mistral`, `moonshotai`,
|
||||
`moonshotai-cn`, `nvidia`, `openai`, `opencode`, `opencode-go`, `openrouter`, `together`,
|
||||
`vercel-ai-gateway`, `xai`, the four `xiaomi*` providers, `zai`, and `zai-coding-cn`.
|
||||
Compound providers are deliberately unsupported: `amazon-bedrock`, `azure-openai-responses`,
|
||||
`cloudflare-workers-ai`, and `cloudflare-ai-gateway` require multiple credential/configuration
|
||||
values. Selecting one fails before Pi starts; ambient AWS, Azure, and Cloudflare credentials are
|
||||
still scrubbed. Supporting them requires a future dedicated provider-specific configuration.
|
||||
For each Pi spawn, the backend resolves the selected canonical provider/model in the runtime catalog,
|
||||
reads exactly that provider's declared `apiKeyEnv` value from the bundle, and exposes only that key
|
||||
to the child. Ambient provider credentials and secret-bundle paths are scrubbed. Providers needing a
|
||||
compound credential bundle remain unsupported until the catalog gains an explicit generic contract
|
||||
for them.
|
||||
|
||||
## User-owned session server cutover
|
||||
|
||||
@@ -357,6 +337,8 @@ The server profile stores sessions and per-user preferences directly in PostgreS
|
||||
dual write. Use [`deploy/compose.session-server.yaml.example`](deploy/compose.session-server.yaml.example)
|
||||
with the canonical base+server files and set `THT_SERVER_WORKSPACE_CONFIG` to an absolute,
|
||||
protected copy of [`deploy/workspaces/server-sessions.yaml.example`](deploy/workspaces/server-sessions.yaml.example).
|
||||
That file is an installation runtime template, not an authored workspace descriptor; database
|
||||
bindings are injected from the PostgreSQL Metadata Catalog for each runtime lease.
|
||||
|
||||
The runtime login needs membership in the no-login database role `thoth_sessions_runtime` only.
|
||||
The distinct, one-shot migrator login needs migration authority and uses
|
||||
|
||||
Generated
+693
-19
@@ -6,25 +6,339 @@
|
||||
"": {
|
||||
"name": "thothii-backend",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.1141.0",
|
||||
"@fastify/cookie": "11.1.2",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/rate-limit": "11.2.0",
|
||||
"@types/pg": "^8.20.3",
|
||||
"fastify": "^5.0.0",
|
||||
"ipaddr.js": "2.4.0",
|
||||
"kysely": "^0.29.5",
|
||||
"libphonenumber-js": "1.13.12",
|
||||
"openid-client": "6.8.5",
|
||||
"pg": "^8.22.0",
|
||||
"validator": "13.15.35",
|
||||
"yaml": "^2.9.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testcontainers/postgresql": "^12.1.0",
|
||||
"@types/node": "24.13.3",
|
||||
"@types/validator": "13.15.10",
|
||||
"bun": "1.4.2",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vitest": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/checksums": {
|
||||
"version": "3.1001.1",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1001.1.tgz",
|
||||
"integrity": "sha512-x12Q17KYlJAd3nKf8LV5LV0vt8sh8/6YfQLGPtrGnQf/tW4jqxPGq5GPpuVitpQYM3eUR4XB7CbxZf751NMbLw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.978.1",
|
||||
"@aws-sdk/types": "^3.974.6",
|
||||
"@smithy/core": "^3.35.0",
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/client-s3": {
|
||||
"version": "3.1141.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1141.0.tgz",
|
||||
"integrity": "sha512-uOVH37xGLenAdJkCPCin/JJG2PgWrFcSsDnQ9+C9Zq8N9Oalo5ol4xmn5fG28iWAlA/b/9boQZgHbMh+UsIhcg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/checksums": "^3.1001.1",
|
||||
"@aws-sdk/core": "^3.978.1",
|
||||
"@aws-sdk/credential-provider-node": "^3.972.84",
|
||||
"@aws-sdk/middleware-sdk-s3": "^3.972.77",
|
||||
"@aws-sdk/signature-v4-multi-region": "^3.996.47",
|
||||
"@aws-sdk/types": "^3.974.6",
|
||||
"@smithy/core": "^3.35.0",
|
||||
"@smithy/fetch-http-handler": "^5.8.0",
|
||||
"@smithy/node-http-handler": "^4.12.1",
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/core": {
|
||||
"version": "3.978.1",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.978.1.tgz",
|
||||
"integrity": "sha512-LbY9aGsEiznDWmUc30Nwv3aIX/+dbwTx8KfS0yOC3NPYMO+O91e6jkT1azf34FwjOndq8/Q+RcVVZz5xnerwdg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/types": "^3.974.6",
|
||||
"@aws-sdk/xml-builder": "^3.972.41",
|
||||
"@aws/lambda-invoke-store": "^0.3.0",
|
||||
"@smithy/core": "^3.35.0",
|
||||
"@smithy/signature-v4": "^5.7.3",
|
||||
"@smithy/types": "^4.19.0",
|
||||
"bowser": "^2.11.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-env": {
|
||||
"version": "3.972.72",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.72.tgz",
|
||||
"integrity": "sha512-xTKO/FWJPozTIXbozVnVGoNBhaGba8TBcx+KyUjRVeOlXE+dUc7GTR1cLvu0uTdIdmemzaFbqqCshXeZA1fZew==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.978.1",
|
||||
"@aws-sdk/types": "^3.974.6",
|
||||
"@smithy/core": "^3.35.0",
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-http": {
|
||||
"version": "3.972.74",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.74.tgz",
|
||||
"integrity": "sha512-u91E/hT8f4d1xy0Jl7VG4nVKJ3lxbrZkoBTeSVoJdWBiSEUMwMS/9+e0H/aJVQV//Lt5wuzP+E69v4aRSsNTmw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.978.1",
|
||||
"@aws-sdk/types": "^3.974.6",
|
||||
"@smithy/core": "^3.35.0",
|
||||
"@smithy/fetch-http-handler": "^5.8.0",
|
||||
"@smithy/node-http-handler": "^4.12.1",
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-ini": {
|
||||
"version": "3.973.17",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.17.tgz",
|
||||
"integrity": "sha512-ged4KXdBkvIC81bLvNHHuQKdKak/VXhQTR1NWYTTqW0474nlmsxy9O/vlgTIohDDWH3xpBdtVMZRyjb+DnocDA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.978.1",
|
||||
"@aws-sdk/credential-provider-env": "^3.972.72",
|
||||
"@aws-sdk/credential-provider-http": "^3.972.74",
|
||||
"@aws-sdk/credential-provider-login": "^3.972.79",
|
||||
"@aws-sdk/credential-provider-process": "^3.972.72",
|
||||
"@aws-sdk/credential-provider-sso": "^3.973.16",
|
||||
"@aws-sdk/credential-provider-web-identity": "^3.972.78",
|
||||
"@aws-sdk/nested-clients": "^3.997.46",
|
||||
"@aws-sdk/types": "^3.974.6",
|
||||
"@smithy/core": "^3.35.0",
|
||||
"@smithy/credential-provider-imds": "^4.5.2",
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-login": {
|
||||
"version": "3.972.79",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.79.tgz",
|
||||
"integrity": "sha512-L+Z85anONJd8MaiuraO4wRxATCdEejBZ3K3eymzWI5JPXa9sOS9CkIm72PBKqXKX+Z9p9NGMX5AIMXm0LEflgw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.978.1",
|
||||
"@aws-sdk/nested-clients": "^3.997.46",
|
||||
"@aws-sdk/types": "^3.974.6",
|
||||
"@smithy/core": "^3.35.0",
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-node": {
|
||||
"version": "3.972.84",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.84.tgz",
|
||||
"integrity": "sha512-oHt854odINVwzwsh+c5x69j0ajm4DbqqqVJ+O1ECsCIZeMDAbzFpXItaqP7UZstJj/ATdTk/KFSH0LaNAgV+kA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/credential-provider-env": "^3.972.72",
|
||||
"@aws-sdk/credential-provider-http": "^3.972.74",
|
||||
"@aws-sdk/credential-provider-ini": "^3.973.17",
|
||||
"@aws-sdk/credential-provider-process": "^3.972.72",
|
||||
"@aws-sdk/credential-provider-sso": "^3.973.16",
|
||||
"@aws-sdk/credential-provider-web-identity": "^3.972.78",
|
||||
"@aws-sdk/types": "^3.974.6",
|
||||
"@smithy/core": "^3.35.0",
|
||||
"@smithy/credential-provider-imds": "^4.5.2",
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-process": {
|
||||
"version": "3.972.72",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.72.tgz",
|
||||
"integrity": "sha512-rLIp2xbMjX/k9/od7APpqq1ZgXXnV0pOL1Th3ZsL8Wu0TRtBsDTVS8iPqcfRFcHakFxPvR04OSTv2ka2qOb/2A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.978.1",
|
||||
"@aws-sdk/types": "^3.974.6",
|
||||
"@smithy/core": "^3.35.0",
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-sso": {
|
||||
"version": "3.973.16",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.16.tgz",
|
||||
"integrity": "sha512-IGihaJfFZYacJJr/odqILCoK7W/mvrZ7cuK7ECn3sAu4vLC6u0V8bS7mCGbdugJ8Aum2tnvqmx0F2MRFp2rn9g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.978.1",
|
||||
"@aws-sdk/nested-clients": "^3.997.46",
|
||||
"@aws-sdk/token-providers": "3.1138.0",
|
||||
"@aws-sdk/types": "^3.974.6",
|
||||
"@smithy/core": "^3.35.0",
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-web-identity": {
|
||||
"version": "3.972.78",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.78.tgz",
|
||||
"integrity": "sha512-/y9WvNtlcPBGLR0qc1a+9J/xtYZfVczvLUOuXaVWylzttH7ewsxwHtjmiJSolNrVSDorIxHGHMU61CbonRkmwA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.978.1",
|
||||
"@aws-sdk/nested-clients": "^3.997.46",
|
||||
"@aws-sdk/types": "^3.974.6",
|
||||
"@smithy/core": "^3.35.0",
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/middleware-sdk-s3": {
|
||||
"version": "3.972.77",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.77.tgz",
|
||||
"integrity": "sha512-E7W2UOeUoc+lg3uIfR/dM7ZwusHwhBQrKMnlkRv4EXRR+C0YtV1pg25xC7GdZIhXH+NAMgZPCbE7o5to2cjFiw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.978.1",
|
||||
"@aws-sdk/signature-v4-multi-region": "^3.996.47",
|
||||
"@aws-sdk/types": "^3.974.6",
|
||||
"@smithy/core": "^3.35.0",
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/nested-clients": {
|
||||
"version": "3.997.46",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.46.tgz",
|
||||
"integrity": "sha512-oRxtBcka/JGHGs9l9p9IVajGoTP8vTPmoAzdHGy4Qcy9P5vPnDf6nhIeM/COQNY9k/OahImTRaLkHftoXvfcmQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.978.1",
|
||||
"@aws-sdk/signature-v4-multi-region": "^3.996.47",
|
||||
"@aws-sdk/types": "^3.974.6",
|
||||
"@smithy/core": "^3.35.0",
|
||||
"@smithy/fetch-http-handler": "^5.8.0",
|
||||
"@smithy/node-http-handler": "^4.12.1",
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/signature-v4-multi-region": {
|
||||
"version": "3.996.47",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.47.tgz",
|
||||
"integrity": "sha512-Zk08macMvQTHzQJCLJVkOlviVoqwYMrpXv4lmLN7b7sAbiMoOK7Go0NYdR5UeF+MW8LIbRmwrNy9u/5VvX1U5g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/types": "^3.974.6",
|
||||
"@smithy/signature-v4": "^5.7.3",
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/token-providers": {
|
||||
"version": "3.1138.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1138.0.tgz",
|
||||
"integrity": "sha512-GpyAr0DD63YOEmYFM6Df+gJuIgC92MMTiBK4FTKfxii5MJ9ge20epR7LyroulscYlG89J+ZB2ivFDPjvfQhzdw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.978.1",
|
||||
"@aws-sdk/nested-clients": "^3.997.46",
|
||||
"@aws-sdk/types": "^3.974.6",
|
||||
"@smithy/core": "^3.35.0",
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/types": {
|
||||
"version": "3.974.6",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.6.tgz",
|
||||
"integrity": "sha512-v/clNZzZnDxGyvpHMOGpJKVXFAExJzUNAAjaWGdcx8QAcXLGwTaOkw33p5SHAi0YAioK32xB3hWwOekRVfmfKg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/xml-builder": {
|
||||
"version": "3.972.41",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.41.tgz",
|
||||
"integrity": "sha512-ctjVSyCMegrWfXlx6VqzSBFI6UqmQ5ZlnfMhdLIiWmhoH8UAQxSCP5N3OpG7X3k4LnS7ou74C4mt20+bfTW2aQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws/lambda-invoke-store": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz",
|
||||
"integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@balena/dockerignore": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@balena/dockerignore/-/dockerignore-1.0.2.tgz",
|
||||
@@ -799,6 +1113,174 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@oven/bun-darwin-aarch64": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@oven/bun-darwin-aarch64/-/bun-darwin-aarch64-1.4.2.tgz",
|
||||
"integrity": "sha512-MXdZkP1featqxZ+/VTXWG1BVjM4OGBehVY2Q88EeUj/7L0UMeCGItmyPYTN+wxvlGJ6F66JEtzsw+GvQWewnag==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@oven/bun-darwin-x64": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64/-/bun-darwin-x64-1.4.2.tgz",
|
||||
"integrity": "sha512-gZTxZuLjkUhAWjTETu3tw0WhsEdNkJ64daj60ybhPf835a2yollV3yTkK9JozvzKPx4TRFzLSl8C+U525pxVbw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
]
|
||||
},
|
||||
"node_modules/@oven/bun-freebsd-aarch64": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@oven/bun-freebsd-aarch64/-/bun-freebsd-aarch64-1.4.2.tgz",
|
||||
"integrity": "sha512-SMNItMw1Z8QeeQVKnw8jA7xQNkeXdP+OPgin4Wi/QTx/B8RHHLnuZfqmFy7NtVeT2NF0kKYppW4WWd2CCYZjhQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
]
|
||||
},
|
||||
"node_modules/@oven/bun-freebsd-x64": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@oven/bun-freebsd-x64/-/bun-freebsd-x64-1.4.2.tgz",
|
||||
"integrity": "sha512-THbPKXhO54N0DpFRKZNDZpQ7dpbX0bWASuARckAUS9wRtFIHsiY+uULXJvxJGo2YD1YewvXQ4G8Fj7XT5oBCiw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
]
|
||||
},
|
||||
"node_modules/@oven/bun-linux-aarch64": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64/-/bun-linux-aarch64-1.4.2.tgz",
|
||||
"integrity": "sha512-3BBP9ovJ2RGHFH6Ae1CAtxNtG1+YY6GD6rmYbsUosoAk9+OEl6zeDQ/k4fBkc6dYOJCtWnx8hUxzNzQATSmvYQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@oven/bun-linux-aarch64-android": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64-android/-/bun-linux-aarch64-android-1.4.2.tgz",
|
||||
"integrity": "sha512-3mZKO2rhsNgbAUtAHC1UKUlF2zTxFraDZT/Elv8wzyH0fJL9h+Iv3TgB9lO63w89PRn3eFe+NRA1bhVgikKNPQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
]
|
||||
},
|
||||
"node_modules/@oven/bun-linux-aarch64-musl": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64-musl/-/bun-linux-aarch64-musl-1.4.2.tgz",
|
||||
"integrity": "sha512-+Sm6y+lSiSFBOtXmnekp5Q6n1tUKlyv71FCPWBc61Cgb14T5eBs8SN/nh4MUCOKzONkI3O+as3MGUgikS4aCBQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@oven/bun-linux-x64": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@oven/bun-linux-x64/-/bun-linux-x64-1.4.2.tgz",
|
||||
"integrity": "sha512-9/E/UXOTpSo3YsV5g+FhtTd/qTpiWoKuxS12cqtuYA1ssu9fRAoPQnipFgGyck3tWO63iUdxBiygq+kELFawng==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@oven/bun-linux-x64-android": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-android/-/bun-linux-x64-android-1.4.2.tgz",
|
||||
"integrity": "sha512-6HC5tzcC79113n2IHCTJMWv+HsQImv4ZFEK2XpYLxY6HbT8tM4cUM2Zv1bHZBQsS3jv/zYBamDJ1UX7If0d5tw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
]
|
||||
},
|
||||
"node_modules/@oven/bun-linux-x64-musl": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl/-/bun-linux-x64-musl-1.4.2.tgz",
|
||||
"integrity": "sha512-vVTKUg1bnPhRP/Hp73jIVoFh2vPFNYEqYX0ERKfZBOQEEHitNAeukZzzuUDZS0SoDCIpuWUGSpd/CDMbjdR+Uw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@oven/bun-windows-aarch64": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@oven/bun-windows-aarch64/-/bun-windows-aarch64-1.4.2.tgz",
|
||||
"integrity": "sha512-8EJ1ST7339WJE3poPW5nBgVW/lWf9HBz4W27ZUNhburKmcBLOByPyE6DP9fHD8FQGm5c+ilUN2hX1mrW0jxq9Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@oven/bun-windows-x64": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@oven/bun-windows-x64/-/bun-windows-x64-1.4.2.tgz",
|
||||
"integrity": "sha512-+bN6OuVld/9diT/RLSXSW7JE6CvNE3gL9XsAEjULi1nUsXd6DNO6GuA9jNdNb3r8PdJFnYHr5aypNV1Oj3Rd9g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@pinojs/redact": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
||||
@@ -1232,6 +1714,87 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@smithy/core": {
|
||||
"version": "3.35.0",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.35.0.tgz",
|
||||
"integrity": "sha512-zRMhfkByhT2snNdr1si24vJitU6Cr9ix2MikUfWmkAgp4jrNP0GcKSP5YvwQ+TlI8AZXER5QOGJn3JsVtSD9/A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/credential-provider-imds": {
|
||||
"version": "4.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz",
|
||||
"integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/core": "^3.33.2",
|
||||
"@smithy/types": "^4.17.2",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/fetch-http-handler": {
|
||||
"version": "5.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.8.0.tgz",
|
||||
"integrity": "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/core": "^3.33.3",
|
||||
"@smithy/types": "^4.18.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/node-http-handler": {
|
||||
"version": "4.12.1",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.12.1.tgz",
|
||||
"integrity": "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/core": "^3.33.3",
|
||||
"@smithy/types": "^4.18.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/signature-v4": {
|
||||
"version": "5.7.4",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.4.tgz",
|
||||
"integrity": "sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/core": "^3.35.0",
|
||||
"@smithy/types": "^4.19.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/types": {
|
||||
"version": "4.19.0",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.19.0.tgz",
|
||||
"integrity": "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@testcontainers/postgresql": {
|
||||
"version": "12.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@testcontainers/postgresql/-/postgresql-12.1.0.tgz",
|
||||
@@ -1329,6 +1892,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/validator": {
|
||||
"version": "13.15.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz",
|
||||
"integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz",
|
||||
@@ -1811,6 +2381,12 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/bowser": {
|
||||
"version": "2.14.1",
|
||||
"resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz",
|
||||
"integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
|
||||
@@ -1866,6 +2442,43 @@
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bun": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/bun/-/bun-1.4.2.tgz",
|
||||
"integrity": "sha512-TrSXo6HJfIEaczpb3kjX82I2pL47vK1QUNmHRCUdz9IzaOwa9lzOXSWwu2l18YHE3sNfGRapVLd4nNm+22vVVA==",
|
||||
"cpu": [
|
||||
"arm64",
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"os": [
|
||||
"darwin",
|
||||
"linux",
|
||||
"android",
|
||||
"freebsd",
|
||||
"win32"
|
||||
],
|
||||
"bin": {
|
||||
"bun": "bin/bun.exe",
|
||||
"bunx": "bin/bunx.exe"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@oven/bun-darwin-aarch64": "1.4.2",
|
||||
"@oven/bun-darwin-x64": "1.4.2",
|
||||
"@oven/bun-freebsd-aarch64": "1.4.2",
|
||||
"@oven/bun-freebsd-x64": "1.4.2",
|
||||
"@oven/bun-linux-aarch64": "1.4.2",
|
||||
"@oven/bun-linux-aarch64-android": "1.4.2",
|
||||
"@oven/bun-linux-aarch64-musl": "1.4.2",
|
||||
"@oven/bun-linux-x64": "1.4.2",
|
||||
"@oven/bun-linux-x64-android": "1.4.2",
|
||||
"@oven/bun-linux-x64-musl": "1.4.2",
|
||||
"@oven/bun-windows-aarch64": "1.4.2",
|
||||
"@oven/bun-windows-x64": "1.4.2"
|
||||
}
|
||||
},
|
||||
"node_modules/byline": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/byline/-/byline-5.0.0.tgz",
|
||||
@@ -2458,9 +3071,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
|
||||
"integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
|
||||
"version": "3.1.7",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
|
||||
"integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -2474,9 +3087,9 @@
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/fastify": {
|
||||
"version": "5.8.5",
|
||||
"resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.5.tgz",
|
||||
"integrity": "sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==",
|
||||
"version": "5.12.3",
|
||||
"resolved": "https://registry.npmjs.org/fastify/-/fastify-5.12.3.tgz",
|
||||
"integrity": "sha512-reZ8wce5VNCcufIt9AVtzZa3L4u1j8esikn7OEgHWLVpRpL5R7Y2+Xzj70OUkv5zDfzUAxXZT6cu4Rt0zr3EKA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -2495,11 +3108,11 @@
|
||||
"@fastify/proxy-addr": "^5.0.0",
|
||||
"abstract-logging": "^2.0.1",
|
||||
"avvio": "^9.0.0",
|
||||
"fast-json-stringify": "^6.0.0",
|
||||
"find-my-way": "^9.0.0",
|
||||
"fast-json-stringify": "^7.0.0",
|
||||
"find-my-way": "^9.6.0",
|
||||
"light-my-request": "^6.0.0",
|
||||
"pino": "^9.14.0 || ^10.1.0",
|
||||
"process-warning": "^5.0.0",
|
||||
"process-warning": "^5.1.0",
|
||||
"rfdc": "^1.3.1",
|
||||
"secure-json-parse": "^4.0.0",
|
||||
"semver": "^7.6.0",
|
||||
@@ -2522,6 +3135,46 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fastify/node_modules/fast-json-stringify": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-7.0.1.tgz",
|
||||
"integrity": "sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fastify/merge-json-schemas": "^0.2.0",
|
||||
"ajv": "^8.12.0",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"fast-uri": "^4.0.0",
|
||||
"json-schema-ref-resolver": "^3.0.0",
|
||||
"rfdc": "^1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fastify/node_modules/fast-uri": {
|
||||
"version": "4.1.4",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.4.tgz",
|
||||
"integrity": "sha512-dODXrIxlS9JSdgAnhIUKOosKV1oMtU2VtVw87QRaHzyl5jxO290Ii5tEZfCfzfWNHi3jKWwBSdQj0qIyshdZdQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/fastq": {
|
||||
"version": "1.20.1",
|
||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
|
||||
@@ -2824,6 +3477,12 @@
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/libphonenumber-js": {
|
||||
"version": "1.13.12",
|
||||
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.12.tgz",
|
||||
"integrity": "sha512-uLVeV1c9OTk6qkdqnj+mpMD+ZdnZ0szVyWu58HwMmpwkHA1gCEkyjd3veZQXDnuw9KEwSRjcc9B1pS9XKIN1fA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/light-my-request": {
|
||||
"version": "6.6.0",
|
||||
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz",
|
||||
@@ -2971,9 +3630,9 @@
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.15",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
|
||||
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
|
||||
"version": "3.3.18",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -3225,9 +3884,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
||||
"version": "8.5.28",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
|
||||
"integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -3245,7 +3904,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.12",
|
||||
"nanoid": "^3.3.18",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
@@ -3310,9 +3969,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/process-warning": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
|
||||
"integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.0.tgz",
|
||||
"integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -4058,6 +4717,12 @@
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.22.4",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz",
|
||||
@@ -4121,6 +4786,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/validator": {
|
||||
"version": "13.15.35",
|
||||
"resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz",
|
||||
"integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "5.4.21",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||
|
||||
+10
-1
@@ -3,29 +3,38 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build:workspace-tools": "node scripts/build-workspace-tools.mjs",
|
||||
"dev": "tsx watch src/server.ts",
|
||||
"prebuild": "node scripts/clean-dist.mjs",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"catalog:migrate": "node dist/catalog/migrate.js",
|
||||
"sensitivity:shadow": "node dist/catalog/sensitivity-shadow.js",
|
||||
"test": "vitest run",
|
||||
"start": "node dist/server.js",
|
||||
"test:schema-v3-verifier": "python3 -I -B scripts/test_revision_state_policy.py && node --test scripts/verify-workspace-descriptor-files.test.mjs scripts/revision-state-policy.test.mjs"
|
||||
"test:schema-v4-verifier": "python3 -I -B scripts/test_revision_state_policy.py && node --test scripts/verify-workspace-descriptor-files.test.mjs scripts/revision-state-policy.test.mjs",
|
||||
"test:schema-v3-verifier": "npm run test:schema-v4-verifier"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.1141.0",
|
||||
"@fastify/cookie": "11.1.2",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/rate-limit": "11.2.0",
|
||||
"@types/pg": "^8.20.3",
|
||||
"fastify": "^5.0.0",
|
||||
"ipaddr.js": "2.4.0",
|
||||
"kysely": "^0.29.5",
|
||||
"libphonenumber-js": "1.13.12",
|
||||
"openid-client": "6.8.5",
|
||||
"pg": "^8.22.0",
|
||||
"validator": "13.15.35",
|
||||
"yaml": "^2.9.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testcontainers/postgresql": "^12.1.0",
|
||||
"@types/node": "24.13.3",
|
||||
"@types/validator": "13.15.10",
|
||||
"bun": "1.4.2",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vitest": "^2.1.0"
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
34448b82c17d60fec9b65b1f093c115ddbaadc04beb1b0140b6bfed2e012a930 ./.gitattributes
|
||||
4d9344c58a2a2ea4bb4ff4f7c611a853cf413205fc10d0cace564eba06f73828 ./README.md
|
||||
180f0a10d1d5ed5ce3318db0bcb0b1b7780d79a52f0a8fc3acbd27f74536d0e4 ./THOTHII_MODEL_REVISION
|
||||
164f17362bcf9d114067d3465e7374bfdd79ce6b605acb745de5a49dabb9595c ./config.json
|
||||
f27dd63cc43a248d2566f0b6ad7a115db353676ce0561dcbca45bac766464c1a ./encoder_config/config.json
|
||||
0280f6f39f6012da50b6640bad438d9b7e763a1b0102094115d1b710c4dd79b6 ./model.safetensors
|
||||
f6df10ec83bea993035b2dd7c39345a3d4fcf23421c2adb6cb4ffc1e6d1bc4b5 ./tokenizer.json
|
||||
233beed1f1095cccfc7907cde31a8d90a0c6aa4fdfaf6493f8e55fd162e81ae6 ./tokenizer_config.json
|
||||
@@ -0,0 +1,34 @@
|
||||
# Optional offline CPU pack. Fully version-locked in its own venv; not part of the base image.
|
||||
--extra-index-url https://download.pytorch.org/whl/cpu
|
||||
accelerate==1.14.0
|
||||
annotated-types==0.8.0
|
||||
certifi==2026.7.22
|
||||
charset-normalizer==3.5.1
|
||||
filelock==3.32.5
|
||||
fsspec==2026.7.0
|
||||
gliner2[local]==2.0.0
|
||||
hf-xet==1.6.0
|
||||
huggingface-hub==0.36.2
|
||||
idna==3.19
|
||||
Jinja2==3.1.6
|
||||
MarkupSafe==3.0.3
|
||||
mpmath==1.3.0
|
||||
networkx==3.6.1
|
||||
numpy==2.5.2
|
||||
packaging==26.3
|
||||
peft==0.20.0
|
||||
psutil==7.2.2
|
||||
pydantic==2.13.5
|
||||
pydantic-core==2.46.5
|
||||
PyYAML==6.0.3
|
||||
regex==2026.9.3
|
||||
requests==2.34.2
|
||||
safetensors==0.8.0
|
||||
sympy==1.14.0
|
||||
tokenizers==0.22.2
|
||||
torch==2.14.0+cpu
|
||||
tqdm==4.70.0
|
||||
transformers==4.57.6
|
||||
typing-extensions==4.16.0
|
||||
typing-inspection==0.4.4
|
||||
urllib3==2.7.0
|
||||
@@ -0,0 +1,301 @@
|
||||
"""Offline, CPU-only JSONL worker for optional sensitivity NER evidence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import contextlib
|
||||
import ctypes
|
||||
import errno
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
PII_LABELS = [
|
||||
"person",
|
||||
"full_name",
|
||||
"first_name",
|
||||
"middle_name",
|
||||
"last_name",
|
||||
"date_of_birth",
|
||||
"email",
|
||||
"phone_number",
|
||||
"address",
|
||||
"street_address",
|
||||
"city",
|
||||
"state_or_region",
|
||||
"postal_code",
|
||||
"country",
|
||||
"government_id",
|
||||
"national_id_number",
|
||||
"passport_number",
|
||||
"drivers_license_number",
|
||||
"license_number",
|
||||
"tax_id",
|
||||
"tax_number",
|
||||
"bank_account",
|
||||
"account_number",
|
||||
"routing_number",
|
||||
"iban",
|
||||
"payment_card",
|
||||
"card_number",
|
||||
"card_expiry",
|
||||
"card_cvv",
|
||||
"username",
|
||||
"ip_address",
|
||||
"account_id",
|
||||
"sensitive_account_id",
|
||||
"password",
|
||||
"secret",
|
||||
"api_key",
|
||||
"access_token",
|
||||
"recovery_code",
|
||||
"sensitive_date",
|
||||
"document_date",
|
||||
"expiration_date",
|
||||
"transaction_date",
|
||||
]
|
||||
|
||||
_MODEL_COMPAT_DIRECTORY: tempfile.TemporaryDirectory[str] | None = None
|
||||
_EXPECTED_MODEL_REVISION = "c153999da5f4c509df4322b0c6a1baf3d2c284d7"
|
||||
|
||||
|
||||
def _arguments() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(add_help=False)
|
||||
parser.add_argument("--model", required=True)
|
||||
parser.add_argument("--threads", type=int, default=2)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def _disable_network() -> None:
|
||||
libc = ctypes.CDLL(None, use_errno=True)
|
||||
libc.prctl.argtypes = [
|
||||
ctypes.c_int,
|
||||
ctypes.c_ulong,
|
||||
ctypes.c_ulong,
|
||||
ctypes.c_ulong,
|
||||
ctypes.c_ulong,
|
||||
]
|
||||
libc.prctl.restype = ctypes.c_int
|
||||
if libc.prctl(38, 1, 0, 0, 0) != 0: # PR_SET_NO_NEW_PRIVS
|
||||
raise RuntimeError("cannot enable no-new-privileges for network isolation")
|
||||
|
||||
try:
|
||||
seccomp = ctypes.CDLL("libseccomp.so.2", use_errno=True)
|
||||
except OSError as error:
|
||||
raise RuntimeError("libseccomp is required for network isolation") from error
|
||||
seccomp.seccomp_init.argtypes = [ctypes.c_uint32]
|
||||
seccomp.seccomp_init.restype = ctypes.c_void_p
|
||||
seccomp.seccomp_syscall_resolve_name.argtypes = [ctypes.c_char_p]
|
||||
seccomp.seccomp_syscall_resolve_name.restype = ctypes.c_int
|
||||
seccomp.seccomp_rule_add.argtypes = [
|
||||
ctypes.c_void_p,
|
||||
ctypes.c_uint32,
|
||||
ctypes.c_int,
|
||||
ctypes.c_uint,
|
||||
]
|
||||
seccomp.seccomp_rule_add.restype = ctypes.c_int
|
||||
seccomp.seccomp_load.argtypes = [ctypes.c_void_p]
|
||||
seccomp.seccomp_load.restype = ctypes.c_int
|
||||
seccomp.seccomp_release.argtypes = [ctypes.c_void_p]
|
||||
seccomp.seccomp_release.restype = None
|
||||
|
||||
allow = 0x7FFF0000 # SCMP_ACT_ALLOW
|
||||
deny = 0x00050000 | errno.EPERM # SCMP_ACT_ERRNO(EPERM)
|
||||
filter_context = seccomp.seccomp_init(allow)
|
||||
if not filter_context:
|
||||
raise RuntimeError("cannot initialize network syscall filter")
|
||||
try:
|
||||
for syscall in (
|
||||
"socket",
|
||||
"connect",
|
||||
"sendto",
|
||||
"sendmsg",
|
||||
"sendmmsg",
|
||||
"bind",
|
||||
"listen",
|
||||
"accept",
|
||||
"accept4",
|
||||
):
|
||||
syscall_number = seccomp.seccomp_syscall_resolve_name(syscall.encode("ascii"))
|
||||
if syscall_number < 0:
|
||||
raise RuntimeError(f"cannot resolve network syscall: {syscall}")
|
||||
if seccomp.seccomp_rule_add(filter_context, deny, syscall_number, 0) != 0:
|
||||
raise RuntimeError(f"cannot block network syscall: {syscall}")
|
||||
if seccomp.seccomp_load(filter_context) != 0:
|
||||
raise RuntimeError("cannot activate network syscall filter")
|
||||
finally:
|
||||
seccomp.seccomp_release(filter_context)
|
||||
|
||||
def blocked(*_args: Any, **_kwargs: Any) -> Any:
|
||||
raise PermissionError(errno.EPERM, "network disabled")
|
||||
|
||||
socket.socket = blocked # type: ignore[assignment]
|
||||
socket.create_connection = blocked # type: ignore[assignment]
|
||||
|
||||
|
||||
def _verify_model(path: Path) -> None:
|
||||
revision_path = path / "THOTHII_MODEL_REVISION"
|
||||
try:
|
||||
revision = revision_path.read_text(encoding="utf-8").strip()
|
||||
except OSError as error:
|
||||
raise RuntimeError("model revision marker is unavailable") from error
|
||||
if revision != _EXPECTED_MODEL_REVISION:
|
||||
raise RuntimeError("model revision is not approved")
|
||||
|
||||
manifest_path = Path(__file__).with_name("sensitivity-ner-model-sha256.txt")
|
||||
try:
|
||||
manifest = manifest_path.read_text(encoding="utf-8").splitlines()
|
||||
except OSError as error:
|
||||
raise RuntimeError("model checksum manifest is unavailable") from error
|
||||
for line in manifest:
|
||||
checksum, separator, relative_name = line.partition(" ")
|
||||
if not separator or len(checksum) != 64 or not relative_name.startswith("./"):
|
||||
raise RuntimeError("model checksum manifest is invalid")
|
||||
relative_path = Path(relative_name[2:])
|
||||
if relative_path.is_absolute() or ".." in relative_path.parts:
|
||||
raise RuntimeError("model checksum path is invalid")
|
||||
model_file = path / relative_path
|
||||
if not model_file.is_file() or model_file.is_symlink():
|
||||
raise RuntimeError("approved model file is unavailable")
|
||||
digest = hashlib.sha256()
|
||||
with model_file.open("rb") as stream:
|
||||
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
if digest.hexdigest() != checksum:
|
||||
raise RuntimeError("approved model checksum does not match")
|
||||
|
||||
|
||||
def _transformers4_model_path(path: Path) -> Path:
|
||||
"""Adapt tokenizer metadata emitted by Transformers 5 without changing pinned weights.
|
||||
|
||||
GLiNER2 2.0.0 officially requires Transformers <5, while current Fastino checkpoints were
|
||||
saved by Transformers 5.8.0. Transformers 4 calls the same list
|
||||
``additional_special_tokens``; Transformers 5 renamed it to ``extra_special_tokens`` and
|
||||
changed its type. Keep the downloaded model immutable and create a temporary symlink view
|
||||
containing only the compatibility metadata needed by the supported GLiNER2 dependency set.
|
||||
"""
|
||||
|
||||
tokenizer_path = path / "tokenizer_config.json"
|
||||
try:
|
||||
tokenizer = json.loads(tokenizer_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as error:
|
||||
raise RuntimeError("invalid tokenizer configuration") from error
|
||||
extra_tokens = tokenizer.get("extra_special_tokens")
|
||||
if extra_tokens is None:
|
||||
return path
|
||||
if not isinstance(extra_tokens, list) or not all(isinstance(token, str) for token in extra_tokens):
|
||||
raise RuntimeError("unsupported extra_special_tokens configuration")
|
||||
if "additional_special_tokens" in tokenizer:
|
||||
raise RuntimeError("ambiguous special-token configuration")
|
||||
|
||||
global _MODEL_COMPAT_DIRECTORY
|
||||
_MODEL_COMPAT_DIRECTORY = tempfile.TemporaryDirectory(prefix="thothii-ner-model-")
|
||||
compatible_path = Path(_MODEL_COMPAT_DIRECTORY.name)
|
||||
for child in path.iterdir():
|
||||
if child.name == tokenizer_path.name:
|
||||
continue
|
||||
(compatible_path / child.name).symlink_to(child, target_is_directory=child.is_dir())
|
||||
tokenizer["additional_special_tokens"] = tokenizer.pop("extra_special_tokens")
|
||||
(compatible_path / tokenizer_path.name).write_text(
|
||||
json.dumps(tokenizer, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return compatible_path
|
||||
|
||||
|
||||
def _load_model(model_path: str, threads: int) -> Any:
|
||||
path = Path(model_path).resolve(strict=True)
|
||||
if not path.is_dir():
|
||||
raise RuntimeError("model path must be a local directory")
|
||||
_verify_model(path)
|
||||
os.environ["CUDA_VISIBLE_DEVICES"] = ""
|
||||
os.environ["HIP_VISIBLE_DEVICES"] = ""
|
||||
os.environ["HF_HUB_OFFLINE"] = "1"
|
||||
os.environ["TRANSFORMERS_OFFLINE"] = "1"
|
||||
import torch
|
||||
from gliner2 import AutoExtractor
|
||||
|
||||
torch.set_num_threads(max(1, min(threads, 8)))
|
||||
torch.set_num_interop_threads(1)
|
||||
compatible_path = _transformers4_model_path(path)
|
||||
with contextlib.redirect_stdout(sys.stderr):
|
||||
model = AutoExtractor.from_pretrained(str(compatible_path), map_location="cpu")
|
||||
_disable_network()
|
||||
return model
|
||||
|
||||
|
||||
def _request(value: Any) -> tuple[str, list[dict[str, str]]]:
|
||||
if not isinstance(value, dict) or not isinstance(value.get("id"), str):
|
||||
raise ValueError("invalid request")
|
||||
candidates = value.get("candidates")
|
||||
if not isinstance(candidates, list) or not 1 <= len(candidates) <= 128:
|
||||
raise ValueError("invalid candidates")
|
||||
parsed: list[dict[str, str]] = []
|
||||
for candidate in candidates:
|
||||
if not isinstance(candidate, dict):
|
||||
raise ValueError("invalid candidate")
|
||||
column_id = candidate.get("columnId")
|
||||
text = candidate.get("text")
|
||||
if not isinstance(column_id, str) or not isinstance(text, str) or not 1 <= len(text) <= 500:
|
||||
raise ValueError("invalid candidate")
|
||||
parsed.append({"columnId": column_id, "text": text})
|
||||
return value["id"], parsed
|
||||
|
||||
|
||||
def _detect(model: Any, candidates: list[dict[str, str]]) -> list[dict[str, Any]]:
|
||||
evidence: list[dict[str, Any]] = []
|
||||
for candidate in candidates:
|
||||
result = model.extract_entities(
|
||||
candidate["text"],
|
||||
PII_LABELS,
|
||||
threshold=0.5,
|
||||
include_confidence=True,
|
||||
)
|
||||
entities = result.get("entities", {}) if isinstance(result, dict) else {}
|
||||
best: tuple[str, float] | None = None
|
||||
if isinstance(entities, dict):
|
||||
for label, matches in entities.items():
|
||||
if label not in PII_LABELS or not isinstance(matches, list):
|
||||
continue
|
||||
for match in matches:
|
||||
if not isinstance(match, dict):
|
||||
continue
|
||||
confidence = match.get("confidence")
|
||||
if not isinstance(confidence, (int, float)) or not 0 <= confidence <= 1:
|
||||
continue
|
||||
if best is None or confidence > best[1]:
|
||||
best = (label, float(confidence))
|
||||
if best is not None:
|
||||
evidence.append(
|
||||
{
|
||||
"columnId": candidate["columnId"],
|
||||
"label": best[0],
|
||||
"confidence": best[1],
|
||||
}
|
||||
)
|
||||
return evidence
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = _arguments()
|
||||
model = _load_model(args.model, args.threads)
|
||||
print(json.dumps({"ready": True}, separators=(",", ":")), flush=True)
|
||||
for line in sys.stdin:
|
||||
request_id = "invalid"
|
||||
try:
|
||||
request_id, candidates = _request(json.loads(line))
|
||||
response = {"id": request_id, "ok": True, "evidence": _detect(model, candidates)}
|
||||
except Exception:
|
||||
response = {"id": request_id, "ok": False, "error": "detection_failed"}
|
||||
print(json.dumps(response, separators=(",", ":")), flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,49 @@
|
||||
// Maintainer-only build. The resulting two-binary bundle needs no extra host runtime.
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repository = process.env.THT_BUILD_SOURCE_ROOT ? resolve(process.env.THT_BUILD_SOURCE_ROOT) : resolve(dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const backend = join(repository, "backend");
|
||||
const native = `${process.platform === "win32" ? "windows" : process.platform}-${process.arch === "x64" ? "amd64" : process.arch}`;
|
||||
const targets = {
|
||||
"windows-amd64": ["windows", "amd64", "bun-windows-x64"],
|
||||
"darwin-amd64": ["darwin", "amd64", "bun-darwin-x64"],
|
||||
"darwin-arm64": ["darwin", "arm64", "bun-darwin-arm64"],
|
||||
"linux-amd64": ["linux", "amd64", "bun-linux-x64"],
|
||||
"linux-arm64": ["linux", "arm64", "bun-linux-arm64"],
|
||||
};
|
||||
const requested = process.argv.slice(2);
|
||||
const selected = requested.length === 1 && requested[0] === "--all" ? Object.keys(targets) : requested.length ? requested : [native];
|
||||
if (selected.some((target) => !targets[target])) {
|
||||
console.error(`Usage: npm run build:workspace-tools -- [${Object.keys(targets).join("|")}|--all]`);
|
||||
process.exit(2);
|
||||
}
|
||||
function run(command, args, cwd = backend, env = process.env) {
|
||||
const result = spawnSync(command, args, { cwd, env, stdio: "inherit" });
|
||||
if (result.error || result.status !== 0) throw new Error(`Build failed: ${command}`);
|
||||
}
|
||||
const revision = spawnSync("git", ["rev-parse", "HEAD"], { cwd: repository, encoding: "utf8" });
|
||||
if (revision.status !== 0) throw new Error("Cannot read build revision");
|
||||
const commit = revision.stdout.trim();
|
||||
const buildTime = process.env.THT_BUILD_TIME ?? new Date().toISOString();
|
||||
const releaseVersion = process.env.THT_BUILD_VERSION ?? "0.0.0-dev";
|
||||
if (!/^[0-9]+\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9.-]+)?$/.test(releaseVersion) || !Number.isFinite(Date.parse(buildTime))) throw new Error("Invalid build identity");
|
||||
const module = "github.com/aritmolab/thothii/tools/tht/internal/version";
|
||||
const bunPackage = JSON.parse(readFileSync(join(backend, "node_modules", "bun", "package.json"), "utf8"));
|
||||
const bun = join(backend, "node_modules", "bun", bunPackage.bin.bun);
|
||||
for (const target of selected) {
|
||||
const [os, arch, bunTarget] = targets[target];
|
||||
const output = join(repository, "dist", "workspace-tools", target);
|
||||
mkdirSync(output, { recursive: true });
|
||||
const extension = os === "windows" ? ".exe" : "";
|
||||
const names = [`tht${extension}`, `tht-workspace-documents${extension}`];
|
||||
run(bun, ["build", "src/workspace-documents-cli.ts", "--compile", `--target=${bunTarget}`, "--outfile", join(output, names[1])]);
|
||||
run("go", ["build", "-trimpath", "-ldflags", `-s -w -X ${module}.semanticVersion=${releaseVersion} -X ${module}.commit=${commit} -X ${module}.buildTime=${buildTime}`, "-o", join(output, names[0]), "./cmd/tht"], join(repository, "tools", "tht"), { ...process.env, CGO_ENABLED: "0", GOOS: os, GOARCH: arch });
|
||||
const hashes = names.map((name) => `${createHash("sha256").update(readFileSync(join(output, name))).digest("hex")} ${name}\n`).join("");
|
||||
writeFileSync(join(output, "SHA256SUMS"), hashes);
|
||||
writeFileSync(join(output, "build.json"), JSON.stringify({ version: releaseVersion, commit, buildTime, target, bun: JSON.parse(readFileSync(join(backend, "package.json"), "utf8")).devDependencies.bun }, null, 2) + "\n");
|
||||
console.log(output);
|
||||
}
|
||||
@@ -1195,13 +1195,8 @@ export async function executeChecks({ checks, failAt, recorder } = {}) {
|
||||
|
||||
function baseWorkspace(id, evidenceSource) {
|
||||
return {
|
||||
workspace: { schema_version: 3, id, name: `P1 ${id}`, language: "en" },
|
||||
workspace: { schema_version: 4, id, name: `P1 ${id}`, language: "en" },
|
||||
dwh: { engine: "postgres", database: "postgres", schema: "public", supported_transports: ["postgres_direct"] },
|
||||
semantic_index: {
|
||||
vector_store: { engine: "qdrant", collection: id, dimensions: 1024, distance: "cosine" },
|
||||
embedding: { provider: "ollama_internal", model: "qwen3-embedding:0.6b", dimensions: 1024 },
|
||||
},
|
||||
llm_policy: { allowed: ["zai/glm-5.2"] },
|
||||
evidence: { source: evidenceSource, policy: { max_chunk_chars: 4000, retain_published_generations: 3 } },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ async function validateDistFiles(repo,files){const dist=join(repo,"backend","dis
|
||||
|
||||
export async function readManualOwnership({repositoryRoot=defaultRepositoryRoot}={}){const repo=realpathSync(repositoryRoot),root=fixedManualRoot(repo);noSymlinkExisting(repo,root);let rootEntry,ownershipEntry;try{rootEntry=await lstat(root);ownershipEntry=await lstat(join(root,"ownership.json"));}catch{throw new Error("manual ownership is missing");}if(!rootEntry.isDirectory()||rootEntry.isSymbolicLink()||await realpath(root)!==root||!ownershipEntry.isFile()||ownershipEntry.isSymbolicLink())throw new Error("manual ownership is unsafe");let value;try{value=JSON.parse(await readFile(join(root,"ownership.json"),"utf8"));}catch{throw new Error("manual ownership is malformed");}const baseValid=value.schemaVersion===1&&value.kind==="p1-manual-acceptance"&&HEX64.test(value.nonce??"")&&value.repositoryRoot===repo&&value.root===root&&value.status==="PENDING"&&["PREPARING","READY"].includes(value.stage)&&value.listener?.host===HOST&&value.listener?.port===PORT&&value.listener?.state==="stopped"&&typeof value.createdAt==="string"&&validEntrypoint(value.entrypoint,repo)&&validDistManifest(value.distManifest,root)&&JSON.stringify(value.resources)===JSON.stringify([root,{kind:"fastify",host:HOST,port:PORT}]);const readyLog=value.backendLog?.path===join(root,"logs/backend.log")&&Number.isSafeInteger(value.backendLog?.dev)&&Number.isSafeInteger(value.backendLog?.ino);if(!baseValid||(value.stage==="READY"?!readyLog:value.backendLog!==null))throw new Error("manual ownership identity mismatch");return value;}
|
||||
async function run(executable,argv,options={}){return await exec(executable,argv,{...options,maxBuffer:2*1024*1024,encoding:"utf8"});}
|
||||
function descriptor(id,source){return{workspace:{schema_version:3,id,name:`P1 ${id}`,language:"en"},dwh:{engine:"postgres",database:"postgres",schema:"public",supported_transports:["postgres_direct"]},semantic_index:{vector_store:{engine:"qdrant",collection:id,dimensions:1024,distance:"cosine"},embedding:{provider:"ollama_internal",model:"qwen3-embedding:0.6b",dimensions:1024}},llm_policy:{allowed:["zai/glm-5.2"]},evidence:{source,policy:{max_chunk_chars:4000,retain_published_generations:3}}};}
|
||||
function descriptor(id,source){return{workspace:{schema_version:4,id,name:`P1 ${id}`,language:"en"},dwh:{engine:"postgres",database:"postgres",schema:"public",supported_transports:["postgres_direct"]},evidence:{source,policy:{max_chunk_chars:4000,retain_published_generations:3}}};}
|
||||
function descriptors(){return[descriptor("p1-filesystem",{type:"filesystem",uri:"workspace-content/p1-filesystem/evidence",patterns:["**/*.md"],max_bytes:10485760}),descriptor("p1-http",{type:"http",uris:["https://evidence.example.test/guide.md"],authentication:"signed_urls_file",connect_timeout_ms:1250,read_timeout_ms:30001,max_bytes:12345,max_redirects:2,allow_private_hosts:false,max_cache_bytes:67890}),descriptor("p1-s3",{type:"s3",uri:"s3://p1-evidence/published/",endpoint_url:"https://s3.example.test/",region:"eu-west-1",credentials:"static_files",trusted_endpoint:true,allow_private_endpoint:false,allow_insecure_endpoint:false,max_bytes:12345,max_objects:33,max_pages:4,page_size:5})];}
|
||||
function quote(value){return `'${String(value).replaceAll("'",`'"'"'`)}'`;}
|
||||
async function checkPrerequisites(repo){for(const path of ["scripts/p1-acceptance.sh","scripts/test-p1-acceptance.sh","backend/scripts/p1-acceptance.mjs","backend/dist/server.js"]){try{await access(join(repo,path));}catch{throw new Error(`Task 8 prerequisite is missing: ${path}`);}}for(const command of ["node","npm","git","curl","unzip","zipinfo","lsof","python3"]){try{await run(command,[command==="unzip"||command==="lsof"?"-v":command==="zipinfo"?"-h":"--version"]);}catch{throw new Error(`missing prerequisite: ${command}`);}}const tht=join(repo,"harness",".venv","bin","tht");try{await access(tht,constants.X_OK);}catch{throw new Error("missing prerequisite: harness/.venv/bin/tht");}}
|
||||
|
||||
@@ -403,7 +403,7 @@ test("generated render command validates saved responses and owned snapshot befo
|
||||
});
|
||||
|
||||
const renderSnapshotYaml=`workspace:
|
||||
schema_version: 3
|
||||
schema_version: 4
|
||||
id: p1-filesystem
|
||||
name: P1 filesystem
|
||||
language: en
|
||||
@@ -412,11 +412,6 @@ dwh:
|
||||
database: postgres
|
||||
schema: public
|
||||
supported_transports: [postgres_direct]
|
||||
semantic_index:
|
||||
vector_store: {engine: qdrant, collection: p1-filesystem, dimensions: 1024, distance: cosine}
|
||||
embedding: {provider: ollama_internal, model: qwen3-embedding:0.6b, dimensions: 1024}
|
||||
llm_policy:
|
||||
allowed: [zai/glm-5.2]
|
||||
evidence:
|
||||
source: {type: filesystem, uri: workspace-content/p1-filesystem/evidence, patterns: ["**/*.md"], max_bytes: 10485760}
|
||||
policy: {max_chunk_chars: 4000, retain_published_generations: 3}
|
||||
|
||||
@@ -16,7 +16,7 @@ async function fixture() {
|
||||
await writeFile(join(root,"installation/base.yaml"),"{}\n");
|
||||
const secret=join(root,"fixture-secrets/dwh-password"); await writeFile(secret,"not-inspected",{mode:0o600});
|
||||
await writeFile(snapshot,`workspace:
|
||||
schema_version: 3
|
||||
schema_version: 4
|
||||
id: p1-filesystem
|
||||
name: P1 filesystem
|
||||
language: en
|
||||
@@ -25,11 +25,6 @@ dwh:
|
||||
database: postgres
|
||||
schema: public
|
||||
supported_transports: [postgres_direct]
|
||||
semantic_index:
|
||||
vector_store: {engine: qdrant, collection: p1-filesystem, dimensions: 1024, distance: cosine}
|
||||
embedding: {provider: ollama_internal, model: qwen3-embedding:0.6b, dimensions: 1024}
|
||||
llm_policy:
|
||||
allowed: [zai/glm-5.2]
|
||||
evidence:
|
||||
source: {type: filesystem, uri: workspace-content/p1-filesystem/evidence, patterns: ["**/*.md"], max_bytes: 10485760}
|
||||
policy: {max_chunk_chars: 4000, retain_published_generations: 3}
|
||||
@@ -61,7 +56,7 @@ test("renderer refuses snapshot manifest head, digest, and expected-digest tampe
|
||||
|
||||
test("renderer refuses a missing or malformed snapshot manifest",async()=>{ const f=await fixture(); const output=join(f.root,"rendered/nomanifest.yaml"); await rm(f.manifestPath); await assert.rejects(call(f,{outputPath:output}),/snapshot manifest.*(missing|unbounded|unsafe)/); await writeFile(f.manifestPath,"{not json"); await assert.rejects(call(f,{outputPath:output}),/snapshot manifest.*malformed/); await assert.rejects(lstat(output)); assert.deepEqual(await runtimeLeases(f),[]); });
|
||||
|
||||
test("renderer rejects a regular snapshot replacement against its manifest",async()=>{ const f=await fixture(); const output=join(f.root,"rendered/replaced.yaml"); await assert.rejects(call(f,{outputPath:output,beforePublish:async()=>{await writeFile(f.snapshot,"workspace:\n schema_version: 3\n id: p1-filesystem\n name: replaced\n")}}),/snapshot content changed/); await assert.rejects(lstat(output)); });
|
||||
test("renderer rejects a regular snapshot replacement against its manifest",async()=>{ const f=await fixture(); const output=join(f.root,"rendered/replaced.yaml"); await assert.rejects(call(f,{outputPath:output,beforePublish:async()=>{await writeFile(f.snapshot,"workspace:\n schema_version: 4\n id: p1-filesystem\n name: replaced\n")}}),/snapshot content changed/); await assert.rejects(lstat(output)); });
|
||||
|
||||
test("renderer anchors publication when rendered parent is concurrently swapped", async()=>{
|
||||
const f=await fixture(),output=join(f.root,"rendered/raced.yaml"),moved=join(f.root,"rendered-moved"),outside=join(f.repo,"outside-rendered"); await mkdir(outside);
|
||||
|
||||
@@ -328,13 +328,8 @@ async function tht(ctx, argv, options = {}) {
|
||||
function namespace(id) { return id.toUpperCase().replaceAll("-", "_"); }
|
||||
function baseWorkspace(id, evidenceSource) {
|
||||
return {
|
||||
workspace: { schema_version: 3, id, name: `P1.1 ${id}`, description: `Catalog entry for ${id}`, language: "en" },
|
||||
workspace: { schema_version: 4, id, name: `P1.1 ${id}`, description: `Catalog entry for ${id}`, language: "en" },
|
||||
dwh: { engine: "postgres", database: "postgres", schema: "public", supported_transports: ["postgres_direct"] },
|
||||
semantic_index: {
|
||||
vector_store: { engine: "qdrant", collection: id, dimensions: 1024, distance: "cosine" },
|
||||
embedding: { provider: "ollama_internal", model: "qwen3-embedding:0.6b", dimensions: 1024 },
|
||||
},
|
||||
llm_policy: { allowed: ["zai/glm-5.2"] },
|
||||
evidence: { source: evidenceSource, policy: { max_chunk_chars: 4000, retain_published_generations: 3 } },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -81,13 +81,8 @@ async function git(executable, argv, options = {}) {
|
||||
function namespace(id) { return id.toUpperCase().replaceAll("-", "_"); }
|
||||
function baseWorkspace(id, evidenceSource) {
|
||||
return {
|
||||
workspace: { schema_version: 3, id, name: `P1.1 ${id}`, description: `Catalog entry for ${id}`, language: "en" },
|
||||
workspace: { schema_version: 4, id, name: `P1.1 ${id}`, description: `Catalog entry for ${id}`, language: "en" },
|
||||
dwh: { engine: "postgres", database: "postgres", schema: "public", supported_transports: ["postgres_direct"] },
|
||||
semantic_index: {
|
||||
vector_store: { engine: "qdrant", collection: id, dimensions: 1024, distance: "cosine" },
|
||||
embedding: { provider: "ollama_internal", model: "qwen3-embedding:0.6b", dimensions: 1024 },
|
||||
},
|
||||
llm_policy: { allowed: ["zai/glm-5.2"] },
|
||||
evidence: { source: evidenceSource, policy: { max_chunk_chars: 4000, retain_published_generations: 3 } },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -375,16 +375,11 @@ function installationProjectName(installationPath) {
|
||||
|
||||
function baseWorkspace(id, { dwhBaseUrl, evidenceSource }) {
|
||||
return {
|
||||
workspace: { schema_version: 3, id, name: `P2 ${id}`, language: "en" },
|
||||
workspace: { schema_version: 4, id, name: `P2 ${id}`, language: "en" },
|
||||
dwh: { engine: "postgres", database: "warehouse", schema: "dw", supported_transports: ["rest_api"] },
|
||||
semantic_index: {
|
||||
vector_store: { engine: "qdrant", collection: id, dimensions: 1024, distance: "cosine" },
|
||||
embedding: { provider: "ollama_internal", model: "qwen3-embedding:0.6b", dimensions: 1024 },
|
||||
},
|
||||
diagnostics: {
|
||||
dwh_rest: { method: "POST", path: "/rpc/ping", auth: "x-api-key", response: { database: "database", schema: "schema" } },
|
||||
},
|
||||
llm_policy: { allowed: ["zai/glm-5.2"] },
|
||||
...(evidenceSource ? { evidence: { source: evidenceSource, policy: { max_chunk_chars: 4000, retain_published_generations: 3 } } } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -376,16 +376,11 @@ function installationProjectName(installationPath) {
|
||||
|
||||
function baseWorkspace(id, { dwhBaseUrl, evidenceSource }) {
|
||||
return {
|
||||
workspace: { schema_version: 3, id, name: `P2 ${id}`, language: "en" },
|
||||
workspace: { schema_version: 4, id, name: `P2 ${id}`, language: "en" },
|
||||
dwh: { engine: "postgres", database: "warehouse", schema: "dw", supported_transports: ["rest_api"] },
|
||||
semantic_index: {
|
||||
vector_store: { engine: "qdrant", collection: id, dimensions: 1024, distance: "cosine" },
|
||||
embedding: { provider: "ollama_internal", model: "qwen3-embedding:0.6b", dimensions: 1024 },
|
||||
},
|
||||
diagnostics: {
|
||||
dwh_rest: { method: "POST", path: "/rpc/ping", auth: "x-api-key", response: { database: "database", schema: "schema" } },
|
||||
},
|
||||
llm_policy: { allowed: ["zai/glm-5.2"] },
|
||||
...(evidenceSource ? { evidence: { source: evidenceSource, policy: { max_chunk_chars: 4000, retain_published_generations: 3 } } } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -379,16 +379,11 @@ function installationProjectName(installationPath) {
|
||||
|
||||
function baseWorkspace(id, { dwhBaseUrl, evidenceSource }) {
|
||||
return {
|
||||
workspace: { schema_version: 3, id, name: `P2 ${id}`, language: "en" },
|
||||
workspace: { schema_version: 4, id, name: `P2 ${id}`, language: "en" },
|
||||
dwh: { engine: "postgres", database: "warehouse", schema: "dw", supported_transports: ["rest_api"] },
|
||||
semantic_index: {
|
||||
vector_store: { engine: "qdrant", collection: id, dimensions: 1024, distance: "cosine" },
|
||||
embedding: { provider: "ollama_internal", model: "qwen3-embedding:0.6b", dimensions: 1024 },
|
||||
},
|
||||
diagnostics: {
|
||||
dwh_rest: { method: "POST", path: "/rpc/ping", auth: "x-api-key", response: { database: "database", schema: "schema" } },
|
||||
},
|
||||
llm_policy: { allowed: ["zai/glm-5.2"] },
|
||||
...(evidenceSource ? { evidence: { source: evidenceSource, policy: { max_chunk_chars: 4000, retain_published_generations: 3 } } } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -374,16 +374,11 @@ function installationProjectName(installationPath) {
|
||||
|
||||
function baseWorkspace(id, { dwhBaseUrl, evidenceSource }) {
|
||||
return {
|
||||
workspace: { schema_version: 3, id, name: `P2 ${id}`, language: "en" },
|
||||
workspace: { schema_version: 4, id, name: `P2 ${id}`, language: "en" },
|
||||
dwh: { engine: "postgres", database: "warehouse", schema: "dw", supported_transports: ["rest_api"] },
|
||||
semantic_index: {
|
||||
vector_store: { engine: "qdrant", collection: id, dimensions: 1024, distance: "cosine" },
|
||||
embedding: { provider: "ollama_internal", model: "qwen3-embedding:0.6b", dimensions: 1024 },
|
||||
},
|
||||
diagnostics: {
|
||||
dwh_rest: { method: "POST", path: "/rpc/ping", auth: "x-api-key", response: { database: "database", schema: "schema" } },
|
||||
},
|
||||
llm_policy: { allowed: ["zai/glm-5.2"] },
|
||||
...(evidenceSource ? { evidence: { source: evidenceSource, policy: { max_chunk_chars: 4000, retain_published_generations: 3 } } } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -374,16 +374,11 @@ function installationProjectName(installationPath) {
|
||||
|
||||
function baseWorkspace(id, { dwhBaseUrl, evidenceSource }) {
|
||||
return {
|
||||
workspace: { schema_version: 3, id, name: `P2 ${id}`, language: "en" },
|
||||
workspace: { schema_version: 4, id, name: `P2 ${id}`, language: "en" },
|
||||
dwh: { engine: "postgres", database: "warehouse", schema: "dw", supported_transports: ["rest_api"] },
|
||||
semantic_index: {
|
||||
vector_store: { engine: "qdrant", collection: id, dimensions: 1024, distance: "cosine" },
|
||||
embedding: { provider: "ollama_internal", model: "qwen3-embedding:0.6b", dimensions: 1024 },
|
||||
},
|
||||
diagnostics: {
|
||||
dwh_rest: { method: "POST", path: "/rpc/ping", auth: "x-api-key", response: { database: "database", schema: "schema" } },
|
||||
},
|
||||
llm_policy: { allowed: ["zai/glm-5.2"] },
|
||||
...(evidenceSource ? { evidence: { source: evidenceSource, policy: { max_chunk_chars: 4000, retain_published_generations: 3 } } } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env node
|
||||
// Maintainer-only producer. Consumers download the resulting native bundle.
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync, existsSync, realpathSync, renameSync, rmSync, statSync, copyFileSync, chmodSync, openSync, closeSync, readdirSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { basename, dirname, join, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { parse } from "yaml";
|
||||
import { prepareBundle, sha256 } from "./release-bundle.mjs";
|
||||
import { dockerCredentials, dockerHub } from "./release-registry.mjs";
|
||||
import { giteaHosting } from "./release-hosting.mjs";
|
||||
import { publishVerifiedRelease } from "./release-publication.mjs";
|
||||
|
||||
const repositoryRoot = resolve(import.meta.dirname, "../..");
|
||||
export function optionsFromArgs(args) {
|
||||
const values = {};
|
||||
for (let i = 0; i < args.length; i += 2) {
|
||||
if (!['--revision', '--version', '--namespace', '--platforms', '--output', '--repository'].includes(args[i]) || !args[i + 1] || values[args[i]]) throw new Error("Usage: --revision REF --version VERSION --namespace DOCKER_HUB_NAMESPACE --platforms linux/amd64 --output NEW_OR_MATCHING_DIRECTORY [--repository HTTPS_GITEA_REPO]");
|
||||
values[args[i]] = args[i + 1];
|
||||
}
|
||||
if (!values['--revision'] || values['--revision'].startsWith('-') || !/^[0-9]+\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9.-]+)?$/.test(values['--version'] ?? '') || !/^[a-z0-9][a-z0-9_-]{1,38}$/.test(values['--namespace'] ?? '') || !values['--output']) throw new Error("Supply an explicit source revision, semantic release version, Docker Hub namespace and output directory.");
|
||||
const platforms = (values['--platforms'] ?? '').split(',');
|
||||
if (!platforms.length || new Set(platforms).size !== platforms.length || platforms.some((p) => !['linux/amd64', 'linux/arm64'].includes(p))) throw new Error("Select explicit Linux image platforms; begin with linux/amd64 for Windows/WSL2 and Omarchy.");
|
||||
const repository = values['--repository'] ?? 'https://git.tylconsulting.it/mptyl/ThothII';
|
||||
const url = new URL(repository);
|
||||
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash || !/^\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(url.pathname)) throw new Error("Use a credential-free HTTPS Gitea owner/repository URL.");
|
||||
return { revision: values['--revision'], version: values['--version'], namespace: values['--namespace'], platforms: platforms.sort(), output: resolve(values['--output']), repository };
|
||||
}
|
||||
export function commandRunner(logs) {
|
||||
let sequence = 0;
|
||||
return async (command, args, { cwd = repositoryRoot, input = '', env = process.env, quiet = false, timeout = 120_000 } = {}) => {
|
||||
const log = join(logs, `${++sequence}-${basename(command)}.log`);
|
||||
return new Promise((accept, reject) => {
|
||||
if (process.platform === 'win32') { reject(new Error('Run the release producer on Linux, WSL2 or macOS.')); return; }
|
||||
const child = spawn(command, args, { cwd, env, detached: true, stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
const stdout = [], stderr = []; let size = 0, overflow = false, settled = false, reapTimer;
|
||||
const terminate = () => {
|
||||
if (overflow) return;
|
||||
overflow = true;
|
||||
try { process.kill(-child.pid, 'SIGKILL'); } catch { child.kill('SIGKILL'); }
|
||||
// An escaped descendant must never retain our pipes indefinitely.
|
||||
reapTimer = setTimeout(() => { child.stdout.destroy(); child.stderr.destroy(); finish(-1); }, 500);
|
||||
};
|
||||
const timer = setTimeout(terminate, timeout);
|
||||
const collect = (chunks) => (data) => { size += data.length; if (size > 64 * 2 ** 20) terminate(); else chunks.push(data); };
|
||||
child.stdout.on('data', collect(stdout)); child.stderr.on('data', collect(stderr));
|
||||
child.stdin.on('error', () => {}); child.stdin.end(input);
|
||||
child.on('error', () => { settled = true; clearTimeout(timer); clearTimeout(reapTimer); reject(new Error(`Required maintainer command ${basename(command)} is unavailable.`)); });
|
||||
function finish(code) {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer); clearTimeout(reapTimer);
|
||||
if (!quiet) writeFileSync(log, Buffer.concat([...stdout, ...stderr]), { mode: 0o600 });
|
||||
if (code !== 0 || overflow) reject(new Error(`${basename(command)} failed${quiet ? '.' : `; inspect private log ${log}`}`));
|
||||
else accept(Buffer.concat(stdout).toString());
|
||||
}
|
||||
child.on('close', finish);
|
||||
});
|
||||
};
|
||||
}
|
||||
function acquireOutput(output) {
|
||||
if (!existsSync(output)) mkdirSync(output, { mode: 0o700 });
|
||||
if (realpathSync(output) !== output || !statSync(output).isDirectory() || (statSync(output).mode & 0o077)) throw new Error("Use a canonical owner-only output directory.");
|
||||
if (!existsSync(join(output, 'publication-state.json')) && readdirSync(output).length) throw new Error("Choose an empty output directory or the matching previous publication directory.");
|
||||
const lock = join(output, '.publisher.lock');
|
||||
if (existsSync(lock)) {
|
||||
const pid = Number(readFileSync(lock, 'utf8'));
|
||||
if (!Number.isInteger(pid) || pid <= 0) throw new Error("Inspect the incomplete publisher lock before retrying.");
|
||||
let alive = true;
|
||||
try { process.kill(pid, 0); } catch (error) { if (error.code === 'ESRCH') alive = false; }
|
||||
if (alive) throw new Error("Another publisher owns this output directory.");
|
||||
rmSync(lock);
|
||||
}
|
||||
const fd = openSync(lock, 'wx', 0o600); writeFileSync(fd, String(process.pid)); closeSync(fd);
|
||||
return () => rmSync(lock, { force: true });
|
||||
}
|
||||
export async function publishInstallation(options) {
|
||||
const unlock = acquireOutput(options.output);
|
||||
const logs = join(options.output, 'logs'); mkdirSync(logs, { recursive: true, mode: 0o700 });
|
||||
const run = commandRunner(logs);
|
||||
let worktree, temporary;
|
||||
try {
|
||||
const revision = (await run('git', ['rev-parse', '--verify', `${options.revision}^{commit}`], { quiet: true })).trim();
|
||||
if (!/^[a-f0-9]{40}$/.test(revision)) throw new Error("Source revision is not a commit.");
|
||||
const identity = { revision, version: options.version, namespace: options.namespace, platforms: options.platforms, repository: options.repository };
|
||||
const statePath = join(options.output, 'publication-state.json');
|
||||
let state = { identity };
|
||||
if (existsSync(statePath)) {
|
||||
state = JSON.parse(readFileSync(statePath, 'utf8'));
|
||||
if (JSON.stringify(state.identity) !== JSON.stringify(identity)) throw new Error("Output directory belongs to a different release; choose a new directory.");
|
||||
}
|
||||
const save = () => { const temp = statePath + '.tmp'; writeFileSync(temp, JSON.stringify(state, null, 2) + '\n', { mode: 0o600 }); renameSync(temp, statePath); };
|
||||
save();
|
||||
console.log(`Release ${identity.version}: ${identity.namespace}, ${identity.platforms.join(',')}, source ${revision}`);
|
||||
await run('docker', ['info', '--format', '{{.OSType}}']);
|
||||
await run('docker', ['buildx', 'version']);
|
||||
const registry = dockerHub(await dockerCredentials(run));
|
||||
const hosting = await giteaHosting({ run, repository: options.repository, identity, body: `Installer prerelease for ${identity.platforms.join(', ')}.\n\nImages: docker.io/${identity.namespace}/thothii-core:${identity.version} and docker.io/${identity.namespace}/thothii-frontend:${identity.version}.\n\nDownload the native operator bundle and SHA256SUMS.txt below. This release supplies images, document validation and preflight; complete non-interactive setup and Windows/Omarchy/macOS acceptance are subsequent tickets. No example databases, credentials or user workspace data are included.` });
|
||||
async function prepare() {
|
||||
if (state.assets) {
|
||||
const names = [...identity.platforms.map((platform) => `thothii-${identity.version}-${platform.replace('/', '-')}.tar.gz`), 'SHA256SUMS.txt'];
|
||||
if (state.assets.length !== names.length) throw new Error("Cached artifact set is incomplete.");
|
||||
for (const asset of state.assets) if (!names.includes(asset.name) || asset.path !== join(options.output, asset.name) || !existsSync(asset.path) || sha256(readFileSync(asset.path)) !== asset.sha256) throw new Error("Previously built release artifact changed; do not overwrite an immutable version.");
|
||||
for (const [platform, images] of Object.entries(state.images)) for (const reference of Object.values(images)) {
|
||||
const [repository, digest] = reference.replace(/^docker.io\//, '').split('@');
|
||||
await registry.inspect(repository, digest, platform, { anonymous: true });
|
||||
}
|
||||
return state.assets;
|
||||
}
|
||||
temporary = realpathSync(mkdtempSync(join(tmpdir(), 'thothii-release-')));
|
||||
worktree = join(temporary, 'source');
|
||||
await run('git', ['worktree', 'add', '--detach', worktree, revision]);
|
||||
const sourceCompose = parse(readFileSync(join(worktree, 'compose.yaml'), 'utf8'));
|
||||
for (const role of ['core', 'frontend']) {
|
||||
console.log(`Preparing public repository ${identity.namespace}/thothii-${role}`);
|
||||
await registry.ensurePublic(identity.namespace, `thothii-${role}`);
|
||||
let existing = null;
|
||||
for (const platform of identity.platforms) {
|
||||
const image = await registry.inspect(`${identity.namespace}/thothii-${role}`, identity.version, platform, { allowMissing: true });
|
||||
if (image && (image.labels['org.opencontainers.image.revision'] !== revision || image.labels['org.opencontainers.image.version'] !== identity.version)) throw new Error("Image tag already belongs to another immutable build; select a new release version.");
|
||||
existing = existing || image;
|
||||
}
|
||||
if (!existing) {
|
||||
console.log(`Building and publishing ${role} (${identity.platforms.join(', ')})`);
|
||||
await run('docker', ['buildx', 'build', '--platform', identity.platforms.join(','), '--file', `docker/${role}.Dockerfile`, '--tag', `docker.io/${identity.namespace}/thothii-${role}:${identity.version}`, '--build-arg', `IMAGE_VERSION=${identity.version}`, '--label', `org.opencontainers.image.revision=${revision}`, '--label', `org.opencontainers.image.source=${identity.repository}`, '--provenance=false', '--sbom=false', '--push', '.'], { cwd: worktree, timeout: 45 * 60_000 });
|
||||
}
|
||||
}
|
||||
state.images = {};
|
||||
for (const platform of identity.platforms) {
|
||||
const images = {};
|
||||
for (const role of ['core', 'frontend']) images[role] = (await registry.inspect(`${identity.namespace}/thothii-${role}`, identity.version, platform, { anonymous: true })).reference;
|
||||
for (const [role, service] of [['catalog', 'catalog-db'], ['qdrant', 'qdrant'], ['embedding', 'embedding']]) {
|
||||
const [named, digest] = sourceCompose.services[service].image.split('@');
|
||||
let repository = named.replace(/:[^/:]+$/, '').replace(/^docker.io\//, '');
|
||||
if (!repository.includes('/')) repository = 'library/' + repository;
|
||||
images[role] = (await registry.inspect(repository, digest, platform, { anonymous: true })).reference;
|
||||
}
|
||||
state.images[platform] = images;
|
||||
}
|
||||
save();
|
||||
console.log('Building native operator bundles from the selected source');
|
||||
await run('npm', ['ci'], { cwd: join(worktree, 'backend'), timeout: 10 * 60_000 });
|
||||
const sourceTime = (await run('git', ['show', '-s', '--format=%cI', revision], { quiet: true })).trim();
|
||||
const targets = identity.platforms.map((platform) => platform.replace('/', '-'));
|
||||
await run('node', [join(repositoryRoot, 'backend/scripts/build-workspace-tools.mjs'), ...targets], { cwd: join(worktree, 'backend'), env: { ...process.env, THT_BUILD_SOURCE_ROOT: worktree, THT_BUILD_VERSION: identity.version, THT_BUILD_TIME: sourceTime }, timeout: 10 * 60_000 });
|
||||
const assets = [];
|
||||
for (const platform of identity.platforms) {
|
||||
const target = platform.replace('/', '-');
|
||||
const name = `thothii-${identity.version}-${target}`;
|
||||
const bundle = join(options.output, name);
|
||||
if (existsSync(bundle)) rmSync(bundle, { recursive: true }); // owned staging, never an installed runtime
|
||||
mkdirSync(bundle);
|
||||
prepareBundle({ source: worktree, destination: bundle, platform, version: identity.version, revision, images: state.images[platform] });
|
||||
mkdirSync(join(bundle, 'bin'));
|
||||
for (const executable of ['tht', 'tht-workspace-documents']) {
|
||||
copyFileSync(join(worktree, 'dist/workspace-tools', target, executable), join(bundle, 'bin', executable));
|
||||
chmodSync(join(bundle, 'bin', executable), 0o755);
|
||||
}
|
||||
// Resolve source-independent resource/config shape without any operator credentials.
|
||||
await run('docker', ['compose', '-f', join(bundle, 'compose.yaml'), '-f', join(bundle, 'deploy/compose.local.yaml'), 'config', '--no-interpolate', '--no-env-resolution', '--format', 'json']);
|
||||
const archive = join(options.output, name + '.tar.gz');
|
||||
await run('tar', ['-czf', archive, '-C', options.output, name]);
|
||||
assets.push({ name: basename(archive), path: archive, bytes: statSync(archive).size, sha256: sha256(readFileSync(archive)) });
|
||||
}
|
||||
const sums = join(options.output, 'SHA256SUMS.txt');
|
||||
writeFileSync(sums, assets.map((asset) => `${asset.sha256} ${asset.name}\n`).join(''));
|
||||
assets.push({ name: 'SHA256SUMS.txt', path: sums, bytes: statSync(sums).size, sha256: sha256(readFileSync(sums)) });
|
||||
// An empty Docker configuration proves the consumer can pull without publisher credentials.
|
||||
const publicConfig = join(temporary, 'public-docker'); mkdirSync(publicConfig);
|
||||
for (const [platform, images] of Object.entries(state.images)) {
|
||||
for (const reference of Object.values(images)) {
|
||||
console.log(`Verifying anonymous pull ${reference.split('@')[0]} (${platform})`);
|
||||
await run('docker', ['--config', publicConfig, 'pull', '--platform', platform, reference], { timeout: 20 * 60_000 });
|
||||
}
|
||||
console.log(`Smoke checking published images (${platform})`);
|
||||
await run('docker', ['run', '--rm', '--platform', platform, '--network', 'none', '--entrypoint', '/bin/sh', images.core, '-ec', 'test "$(pi --version)" = "$PI_VERSION"; tht --help >/dev/null; test -f /app/backend/dist/catalog/migrate.js; test -x /app/docker/workspace-maintenance-entrypoint.sh; test -f /app/docker/catalog-migrate.sh; test ! -e /run/secrets/thothii.secrets'], { timeout: 5 * 60_000 });
|
||||
await run('docker', ['run', '--rm', '--platform', platform, '--network', 'none', '--entrypoint', '/usr/local/bin/frontend-config-smoke', images.frontend], { timeout: 60_000 });
|
||||
}
|
||||
state.assets = assets; save();
|
||||
return assets;
|
||||
}
|
||||
const published = await publishVerifiedRelease({ hosting, prepare });
|
||||
state.releaseURL = published.html_url; state.complete = true; save();
|
||||
console.log(`Published and verified: ${published.html_url}`);
|
||||
return published;
|
||||
} finally {
|
||||
if (worktree && existsSync(worktree)) await run('git', ['worktree', 'remove', '--force', worktree]).catch(() => {});
|
||||
if (temporary && !existsSync(worktree ?? '')) rmSync(temporary, { recursive: true, force: true });
|
||||
unlock();
|
||||
}
|
||||
}
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
||||
try { await publishInstallation(optionsFromArgs(process.argv.slice(2))); }
|
||||
catch (error) { console.error(error instanceof SyntaxError ? 'Invalid release metadata; no secret values are printed.' : error.message); process.exitCode = 1; }
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdirSync, readFileSync, writeFileSync, lstatSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { parse, stringify } from "yaml";
|
||||
|
||||
export const serviceRoles = Object.freeze({ core: "core", frontend: "frontend", "catalog-db": "catalog", "catalog-migrate": "core", "workspace-maintenance": "core", qdrant: "qdrant", embedding: "embedding", "embedding-model-init": "embedding" });
|
||||
export const sha256 = (data) => createHash("sha256").update(data).digest("hex");
|
||||
|
||||
/** Only distribution assets enter the bundle: never a checkout, environment file or workspace. */
|
||||
export function prepareBundle({ source, destination, platform, version, revision, images }) {
|
||||
const compose = parse(readFileSync(join(source, "compose.yaml"), "utf8"));
|
||||
if (Object.keys(compose.services).sort().join() !== Object.keys(serviceRoles).sort().join()) throw new Error("Release service contract changed; review packaging before publishing.");
|
||||
for (const [name, role] of Object.entries(serviceRoles)) {
|
||||
if (!/^docker\.io\/[a-z0-9][a-z0-9._/-]*@sha256:[a-f0-9]{64}$/.test(images[role] ?? "")) throw new Error("Release requires immutable Docker Hub image references.");
|
||||
delete compose.services[name].build;
|
||||
delete compose.services[name].pull_policy;
|
||||
compose.services[name].image = images[role];
|
||||
compose.services[name].platform = platform;
|
||||
}
|
||||
const files = {};
|
||||
const write = (name, data) => {
|
||||
mkdirSync(dirname(join(destination, name)), { recursive: true });
|
||||
writeFileSync(join(destination, name), data);
|
||||
files[name] = sha256(data);
|
||||
};
|
||||
write("compose.yaml", stringify(compose));
|
||||
for (const name of ["deploy/compose.local.yaml", "deploy/compose.git-https.yaml", "deploy/compose.git-ssh.yaml", "docker/catalog-db-init.sql", "docker/embedding-model-init.sh"]) {
|
||||
const path = join(source, name);
|
||||
if (!lstatSync(path).isFile()) throw new Error("Release asset must be a regular tracked file.");
|
||||
write(name, readFileSync(path));
|
||||
}
|
||||
// Any future source bind mount must be deliberately added to the asset allowlist.
|
||||
for (const service of Object.values(compose.services)) {
|
||||
for (const volume of service.volumes ?? []) {
|
||||
const sourcePath = typeof volume === "string" ? volume.split(":")[0] : volume.type === "bind" ? volume.source : undefined;
|
||||
if (sourcePath?.startsWith(".") && !files[sourcePath.replace(/^\.\//, "")]) throw new Error("Source bind mount is missing from the release bundle.");
|
||||
}
|
||||
}
|
||||
const manifest = { schema_version: 1, version, revision, validator_protocol: 1,
|
||||
requirements: { cpus: 2, memory_bytes: 4 * 2 ** 30, disk_bytes: 10 * 2 ** 30 },
|
||||
components: ["pi", "catalog-migrations", "workspace-maintenance"],
|
||||
images: Object.fromEntries(Object.entries(images).map(([role, reference]) => [role, { [platform]: reference }])),
|
||||
files, compose: ["compose.yaml", "deploy/compose.local.yaml"] };
|
||||
writeFileSync(join(destination, "release-manifest.json"), JSON.stringify(manifest, null, 2) + "\n");
|
||||
writeFileSync(join(destination, "README.md"), `# ThothII ${version} — ${platform}\n\nSource / Sorgente: ${revision}\n\nThis prerelease provides images and document/preflight tools. Non-interactive execution and real-host acceptance are separate follow-up tickets; this is not a certified complete installation.\nQuesta prerelease fornisce immagini e strumenti di preparazione/preflight. Esecuzione non interattiva e collaudi reali sono incrementi successivi: non è ancora un'installazione completa certificata.\n\nUse bin/tht and its sibling bin/tht-workspace-documents together; no Node, Python, Bun or application checkout is required on the consumer host.\nWindows: use the Linux amd64 bundle inside Ubuntu WSL2, not a native Windows shell.\n\n1. bin/tht workspace prepare --directory NEW_WORKSPACE --id practice --name Practice\n2. bin/tht workspace validate --directory WORKSPACE\n3. bin/tht installation prepare --directory NEW_PRIVATE_INSTALLATION\n4. bin/tht installation preflight --directory INSTALLATION --release ABSOLUTE_RELEASE_DIR/release-manifest.json\n5. Complete the commented documents, generate technical credentials explicitly, then run installation validate and installation plan with --installation ABSOLUTE_INSTALLATION_FILE.\n\nImages are pinned by digest; runtime credentials and user workspaces are never bundled.\nConsult the accompanying IT/EN guides for prepared documents and mandatory runtime checks.\n`);
|
||||
for (const [name, target] of [["standalone-manual-it.md", "GUIDE-IT.md"], ["standalone-manual-en.md", "GUIDE-EN.md"], ["installation-preflight.md", "PREFLIGHT.md"]]) writeFileSync(join(destination, target), readFileSync(join(source, "docs/install", name)));
|
||||
return manifest;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, readFileSync, rmSync, existsSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { resolve, join } from "node:path";
|
||||
import { parse } from "yaml";
|
||||
import { prepareBundle } from "./release-bundle.mjs";
|
||||
|
||||
test("consumer bundle pins every service and carries all source bind resources", () => {
|
||||
const output = mkdtempSync(join(tmpdir(), "thoth-release-test-"));
|
||||
const images = Object.fromEntries(["core", "frontend", "catalog", "qdrant", "embedding"].map((role) => [role, `docker.io/tylconsulting/${role}@sha256:${"a".repeat(64)}`]));
|
||||
try {
|
||||
prepareBundle({ source: resolve(import.meta.dirname, "../.."), destination: output, platform: "linux/amd64", version: "0.1.0-install-preview.1", revision: "b".repeat(40), images });
|
||||
const manifest = JSON.parse(readFileSync(join(output, "release-manifest.json")));
|
||||
const compose = parse(readFileSync(join(output, "compose.yaml"), "utf8"));
|
||||
assert.equal(compose.services.core.image, images.core);
|
||||
assert.equal(compose.services["catalog-migrate"].image, images.core);
|
||||
assert.equal(compose.services["workspace-maintenance"].image, images.core);
|
||||
assert.equal(compose.services["embedding-model-init"].image, images.embedding);
|
||||
for (const service of Object.values(compose.services)) {
|
||||
assert.equal(service.build, undefined);
|
||||
assert.equal(service.pull_policy, undefined);
|
||||
assert.equal(service.platform, "linux/amd64");
|
||||
for (const volume of service.volumes ?? []) {
|
||||
if (typeof volume === "string" && volume.startsWith("./")) assert.ok(existsSync(join(output, volume.split(":")[0])));
|
||||
}
|
||||
}
|
||||
assert.equal(manifest.validator_protocol, 1);
|
||||
assert.deepEqual(manifest.compose, ["compose.yaml", "deploy/compose.local.yaml"]);
|
||||
assert.ok(manifest.files["docker/catalog-db-init.sql"]);
|
||||
assert.ok(manifest.files["docker/embedding-model-init.sh"]);
|
||||
assert.ok(!existsSync(join(output, "backend")));
|
||||
assert.ok(!existsSync(join(output, "harness")));
|
||||
} finally { rmSync(output, { recursive: true, force: true }); }
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { boundedFetch, readLimited } from "./release-registry.mjs";
|
||||
import { sha256 } from "./release-bundle.mjs";
|
||||
|
||||
export async function giteaHosting({ run, repository, identity, body }) {
|
||||
const remote = new URL(repository);
|
||||
const credential = await run("git", ["credential", "fill"], { input: `protocol=https\nhost=${remote.host}\n\n`, quiet: true, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } });
|
||||
const fields = Object.fromEntries(credential.trim().split("\n").map((line) => { const at = line.indexOf("="); return [line.slice(0, at), line.slice(at + 1)]; }));
|
||||
if (!fields.username || !fields.password) throw new Error("Gitea publishing credentials are unavailable in the Git credential store.");
|
||||
const authorization = `Basic ${Buffer.from(`${fields.username}:${fields.password}`).toString("base64")}`;
|
||||
const api = `${remote.origin}/api/v1/repos${remote.pathname.replace(/\.git$/, "")}`;
|
||||
const marker = `<!-- thothii-release:${JSON.stringify(identity)} -->`;
|
||||
const tag = `installation-v${identity.version}`;
|
||||
async function request(path, options = {}, allowMissing = false) {
|
||||
const response = await boundedFetch(api + path, { ...options, headers: { Authorization: authorization, ...options.headers } }, 120_000);
|
||||
if (allowMissing && response.status === 404) return null;
|
||||
if (!response.ok) throw new Error(`Gitea release operation failed (HTTP ${response.status}).`);
|
||||
const bytes = await readLimited(response, 4 * 2 ** 20);
|
||||
try { return JSON.parse(bytes.toString()); } catch { throw new Error("Gitea returned invalid release metadata."); }
|
||||
}
|
||||
const json = (method, value) => ({ method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(value) });
|
||||
async function verifyTag(required = false) {
|
||||
const existing = await request(`/tags/${encodeURIComponent(tag)}`, {}, true);
|
||||
if ((!existing && required) || (existing && existing.commit?.sha !== identity.revision)) throw new Error("Release Git tag does not match the requested source revision.");
|
||||
}
|
||||
async function assets(release) { return request(`/releases/${release.id}/assets`); }
|
||||
async function verifyAsset(asset, expected, publicRead) {
|
||||
const url = new URL(asset.browser_download_url);
|
||||
if (url.origin !== remote.origin) throw new Error("Unexpected release asset origin.");
|
||||
const response = await boundedFetch(url, { headers: publicRead ? {} : { Authorization: authorization } }, 120_000);
|
||||
if (!response.ok || sha256(await readLimited(response, expected.bytes + 1)) !== expected.sha256) throw new Error("Published release asset does not match its verified checksum.");
|
||||
}
|
||||
return {
|
||||
async open() {
|
||||
const info = await request("");
|
||||
if (info.private || !info.permissions?.push) throw new Error("Release hosting must be a public Gitea repository with publication rights.");
|
||||
await verifyTag();
|
||||
const existing = await request(`/releases/tags/${encodeURIComponent(tag)}`, {}, true);
|
||||
if (existing) {
|
||||
if (!existing.body?.includes(marker)) throw new Error("Release version already belongs to another source or publication identity; it will not be overwritten.");
|
||||
return existing;
|
||||
}
|
||||
return request("/releases", json("POST", { tag_name: tag, target_commitish: identity.revision, name: `ThothII ${identity.version}`, body: `${body}\n\n${marker}`, draft: true, prerelease: true }));
|
||||
},
|
||||
async upload(release, asset) {
|
||||
const matching = (await assets(release)).filter((item) => item.name === asset.name);
|
||||
if (matching.length > 1) throw new Error("Ambiguous release assets; no published files were replaced.");
|
||||
if (matching.length === 1) { await verifyAsset(matching[0], asset, false); return; }
|
||||
const data = readFileSync(asset.path);
|
||||
if (sha256(data) !== asset.sha256) throw new Error("Local release asset changed before upload.");
|
||||
const form = new FormData(); form.append("attachment", new Blob([data]), asset.name);
|
||||
await request(`/releases/${release.id}/assets?name=${encodeURIComponent(asset.name)}`, { method: "POST", body: form });
|
||||
},
|
||||
async verify(release, expected, publicRead) {
|
||||
await verifyTag(publicRead);
|
||||
const uploaded = await assets(release);
|
||||
if (uploaded.length !== expected.length) throw new Error("Release asset set is incomplete or contains unexpected files.");
|
||||
for (const asset of expected) {
|
||||
const matching = uploaded.filter((item) => item.name === asset.name);
|
||||
if (matching.length !== 1) throw new Error("Release asset missing or ambiguous.");
|
||||
await verifyAsset(matching[0], asset, publicRead);
|
||||
}
|
||||
},
|
||||
async publish(release) {
|
||||
await verifyTag();
|
||||
return request(`/releases/${release.id}`, json("PATCH", { draft: false }));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { giteaHosting } from './release-hosting.mjs';
|
||||
|
||||
test('release hosting refuses a pre-existing Git tag on another commit', async () => {
|
||||
const fetch = globalThis.fetch;
|
||||
const revision = 'a'.repeat(40);
|
||||
let writes = 0;
|
||||
globalThis.fetch = async (url, options) => {
|
||||
if (options.method) { writes++; return Response.json({ id: 1, draft: true }); }
|
||||
if (String(url).includes('/releases/tags/')) return new Response('', { status: 404 });
|
||||
if (String(url).includes('/tags/installation-v')) return Response.json({ commit: { sha: 'b'.repeat(40) } });
|
||||
return Response.json({ private: false, permissions: { push: true } });
|
||||
};
|
||||
try {
|
||||
const hosting = await giteaHosting({ run: async () => 'username=test\npassword=test\n', repository: 'https://example.test/owner/repo', identity: { revision, version: '1.0.0' }, body: '' });
|
||||
await assert.rejects(hosting.open(), /tag.*revision/i);
|
||||
assert.equal(writes, 0);
|
||||
} finally { globalThis.fetch = fetch; }
|
||||
});
|
||||
|
||||
test('matching Git tag is accepted and verified again before publishing', async () => {
|
||||
const fetch = globalThis.fetch;
|
||||
const identity = { revision: 'a'.repeat(40), version: '1.0.0' };
|
||||
let sha = identity.revision;
|
||||
let writes = 0;
|
||||
globalThis.fetch = async (url, options) => {
|
||||
if (options.method) { writes++; return Response.json({ id: 1, draft: false }); }
|
||||
if (String(url).includes('/releases/tags/')) return Response.json({ id: 1, draft: true, body: `<!-- thothii-release:${JSON.stringify(identity)} -->` });
|
||||
if (String(url).includes('/tags/')) return Response.json({ commit: { sha } });
|
||||
if (String(url).endsWith('/assets')) return Response.json([]);
|
||||
return Response.json({ private: false, permissions: { push: true } });
|
||||
};
|
||||
try {
|
||||
const hosting = await giteaHosting({ run: async () => 'username=test\npassword=test\n', repository: 'https://example.test/owner/repo', identity, body: '' });
|
||||
const release = await hosting.open();
|
||||
await hosting.verify(release, [], false);
|
||||
sha = 'b'.repeat(40);
|
||||
await assert.rejects(hosting.verify(release, [], false), /tag.*revision/i);
|
||||
await assert.rejects(hosting.publish(release), /tag.*revision/i);
|
||||
assert.equal(writes, 0);
|
||||
} finally { globalThis.fetch = fetch; }
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { commandRunner } from './publish-installation.mjs';
|
||||
|
||||
test('publisher timeout terminates descendants that retain output pipes', async () => {
|
||||
const logs = mkdtempSync(join(tmpdir(), 'release-process-test-'));
|
||||
try {
|
||||
const started = Date.now();
|
||||
await assert.rejects(commandRunner(logs)(process.execPath, ['-e', `
|
||||
require('child_process').spawn(process.execPath, ['-e', 'setTimeout(() => {}, 2500)'], {stdio: 'inherit'});
|
||||
setTimeout(() => {}, 2500);
|
||||
`], { timeout: 250, quiet: true }));
|
||||
assert.ok(Date.now() - started < 1800, 'timeout must not wait for the descendant to exit naturally');
|
||||
} finally { rmSync(logs, { recursive: true, force: true }); }
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
/** Drafts are the publication boundary. A partial build/upload is never a consumer release. */
|
||||
export async function publishVerifiedRelease({ hosting, prepare }) {
|
||||
const release = await hosting.open();
|
||||
const assets = await prepare();
|
||||
if (!release.draft) {
|
||||
await hosting.verify(release, assets, true);
|
||||
return release;
|
||||
}
|
||||
for (const asset of assets) await hosting.upload(release, asset);
|
||||
await hosting.verify(release, assets, false);
|
||||
const published = await hosting.publish(release);
|
||||
await hosting.verify(published, assets, true);
|
||||
return published;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { publishVerifiedRelease } from "./release-publication.mjs";
|
||||
|
||||
test("an interrupted preparation stays draft and retry publishes only after every artifact verifies", async () => {
|
||||
const events = [];
|
||||
let failing = true;
|
||||
const hosting = {
|
||||
open: async () => ({ id: 7, draft: true }),
|
||||
upload: async (_draft, asset) => events.push(`upload:${asset.name}`),
|
||||
verify: async (_draft, assets, publicRead) => events.push(`verify:${publicRead}:${assets.length}`),
|
||||
publish: async () => { events.push("publish"); return { html_url: "https://example.test/release" }; },
|
||||
};
|
||||
const prepare = async () => {
|
||||
if (failing) throw new Error("frontend build unavailable");
|
||||
return [{ name: "bundle.tar.gz" }, { name: "SHA256SUMS" }];
|
||||
};
|
||||
await assert.rejects(publishVerifiedRelease({ hosting, prepare }), /frontend/);
|
||||
assert.deepEqual(events, []);
|
||||
failing = false;
|
||||
await publishVerifiedRelease({ hosting, prepare });
|
||||
assert.deepEqual(events, ["upload:bundle.tar.gz", "upload:SHA256SUMS", "verify:false:2", "publish", "verify:true:2"]);
|
||||
});
|
||||
|
||||
test("a published version is verified without replacing any asset", async () => {
|
||||
const events = [];
|
||||
await publishVerifiedRelease({ prepare: async () => [{ name: "bundle.tar.gz" }], hosting: {
|
||||
open: async () => ({ id: 7, draft: false }),
|
||||
upload: async () => { throw new Error("overwrote a published version"); },
|
||||
publish: async () => { throw new Error("republished a version"); },
|
||||
verify: async (_release, _assets, publicRead) => events.push(publicRead),
|
||||
} });
|
||||
assert.deepEqual(events, [true]);
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { sha256 } from "./release-bundle.mjs";
|
||||
|
||||
const accept = "application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json";
|
||||
export async function boundedFetch(url, options = {}, timeout = 30_000) {
|
||||
try { return await fetch(url, { ...options, redirect: options.redirect ?? "error", signal: AbortSignal.timeout(timeout) }); }
|
||||
catch { throw new Error("Release network request failed; retry with the same output directory."); }
|
||||
}
|
||||
export async function readLimited(response, maximum) {
|
||||
const chunks = []; let size = 0;
|
||||
for await (const chunk of response.body) { size += chunk.length; if (size > maximum) throw new Error("Release response exceeded its bound."); chunks.push(chunk); }
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
async function jsonResponse(response, description) {
|
||||
if (!response.ok) throw new Error(`${description} refused (HTTP ${response.status}).`);
|
||||
const bytes = await readLimited(response, 4 * 2 ** 20);
|
||||
try { return { bytes, value: JSON.parse(bytes.toString()) }; }
|
||||
catch { throw new Error("Release service returned invalid metadata."); }
|
||||
}
|
||||
export async function dockerCredentials(run) {
|
||||
const config = JSON.parse(readFileSync(join(process.env.DOCKER_CONFIG || join(homedir(), ".docker"), "config.json"), "utf8"));
|
||||
const server = "https://index.docker.io/v1/";
|
||||
const helper = config.credHelpers?.[server] || config.credsStore;
|
||||
if (!helper || !/^[A-Za-z0-9._-]+$/.test(helper)) throw new Error("Use docker login with an OS credential store before publishing.");
|
||||
const auth = JSON.parse(await run(`docker-credential-${helper}`, ["get"], { input: server + "\n", quiet: true }));
|
||||
if (!auth.Username || !auth.Secret) throw new Error("Docker Hub login is unavailable.");
|
||||
return auth;
|
||||
}
|
||||
export function dockerHub(auth) {
|
||||
async function token(repository, anonymous) {
|
||||
const url = new URL("https://auth.docker.io/token");
|
||||
url.searchParams.set("service", "registry.docker.io");
|
||||
url.searchParams.set("scope", `repository:${repository}:pull`);
|
||||
const response = await boundedFetch(url, { headers: anonymous ? {} : { Authorization: `Basic ${Buffer.from(`${auth.Username}:${auth.Secret}`).toString("base64")}` } });
|
||||
return (await jsonResponse(response, "Registry authentication")).value.token;
|
||||
}
|
||||
async function registryJSON(repository, route, anonymous, allowMissing = false) {
|
||||
const bearer = await token(repository, anonymous);
|
||||
let response = await boundedFetch(`https://registry-1.docker.io/v2/${repository}/${route}`, { redirect: "manual", headers: { Accept: accept, Authorization: `Bearer ${bearer}` } });
|
||||
if ([302, 307].includes(response.status) && route.startsWith("blobs/")) {
|
||||
const target = new URL(response.headers.get("location"));
|
||||
if (target.protocol !== "https:" || target.username || target.password) throw new Error("Invalid registry blob redirect.");
|
||||
// Signed blob URLs are fetched without forwarding registry credentials.
|
||||
response = await boundedFetch(target);
|
||||
}
|
||||
if (allowMissing && response.status === 404) return null;
|
||||
const { bytes, value } = await jsonResponse(response, "Registry read");
|
||||
const digest = `sha256:${sha256(bytes)}`;
|
||||
const advertised = response.headers.get("docker-content-digest");
|
||||
if (advertised && advertised !== digest) throw new Error("Registry content digest mismatch.");
|
||||
return { value, digest };
|
||||
}
|
||||
return {
|
||||
async ensurePublic(namespace, name) {
|
||||
const response = await boundedFetch("https://hub.docker.com/v2/auth/token", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ identifier: auth.Username, secret: auth.Secret }) });
|
||||
const bearer = (await jsonResponse(response, "Docker Hub authentication")).value.access_token;
|
||||
const headers = { Authorization: `Bearer ${bearer}`, "Content-Type": "application/json" };
|
||||
const path = `https://hub.docker.com/v2/namespaces/${namespace}/repositories`;
|
||||
let existing = await boundedFetch(`${path}/${name}`, { headers });
|
||||
if (existing.status === 404) {
|
||||
existing = await boundedFetch(path, { method: "POST", headers, body: JSON.stringify({ namespace, name, registry: "docker.io", is_private: false, description: `ThothII ${name.endsWith("core") ? "core with embedded Pi" : "standalone frontend"}` }) });
|
||||
}
|
||||
const data = (await jsonResponse(existing, "Public repository preparation")).value;
|
||||
if (data.is_private !== false) throw new Error("Selected Docker Hub repository is private; make this release repository public before retrying.");
|
||||
},
|
||||
async inspect(repository, reference, platform, { anonymous = false, allowMissing = false } = {}) {
|
||||
let image = await registryJSON(repository, `manifests/${reference}`, anonymous, allowMissing);
|
||||
if (!image) return null;
|
||||
if (reference.startsWith("sha256:") && image.digest !== reference) throw new Error("Requested image digest does not match registry content.");
|
||||
if (image.value.manifests) {
|
||||
const match = image.value.manifests.find((entry) => `${entry.platform?.os}/${entry.platform?.architecture}` === platform);
|
||||
if (!match || !/^sha256:[a-f0-9]{64}$/.test(match.digest)) throw new Error("Image does not contain the requested platform.");
|
||||
image = await registryJSON(repository, `manifests/${match.digest}`, anonymous);
|
||||
if (image.digest !== match.digest) throw new Error("Image index digest mismatch.");
|
||||
}
|
||||
if (reference.startsWith("sha256:") && !image.value.config) throw new Error("Image metadata is incomplete.");
|
||||
const configDigest = image.value.config?.digest;
|
||||
if (!/^sha256:[a-f0-9]{64}$/.test(configDigest ?? "")) throw new Error("Image configuration digest is invalid.");
|
||||
const config = await registryJSON(repository, `blobs/${configDigest}`, anonymous);
|
||||
if (config.digest !== configDigest || `${config.value.os}/${config.value.architecture}` !== platform) throw new Error("Image configuration or platform mismatch.");
|
||||
return { reference: `docker.io/${repository}@${image.digest}`, labels: config.value.config?.Labels ?? {} };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { dockerHub } from "./release-registry.mjs";
|
||||
import { sha256 } from "./release-bundle.mjs";
|
||||
|
||||
test("registry verifies pinned config/platform and does not forward auth to blob storage", async () => {
|
||||
const original = globalThis.fetch;
|
||||
const config = JSON.stringify({ os: "linux", architecture: "amd64", config: { Labels: { "org.opencontainers.image.revision": "b".repeat(40) } } });
|
||||
const configDigest = "sha256:" + sha256(config);
|
||||
const manifest = JSON.stringify({ schemaVersion: 2, config: { digest: configDigest } });
|
||||
const imageDigest = "sha256:" + sha256(manifest);
|
||||
const auth = [];
|
||||
globalThis.fetch = async (target, options) => {
|
||||
const url = new URL(target);
|
||||
if (url.hostname === "auth.docker.io") return new Response(JSON.stringify({ token: "registry-token" }));
|
||||
if (url.hostname === "blob.example.test") { auth.push(options.headers?.Authorization); return new Response(config); }
|
||||
if (url.pathname.includes("/blobs/")) return new Response(null, { status: 307, headers: { location: "https://blob.example.test/config" } });
|
||||
return new Response(manifest, { headers: { "docker-content-digest": imageDigest } });
|
||||
};
|
||||
try {
|
||||
const registry = dockerHub({ Username: "publisher", Secret: "PRIVATE_TOKEN" });
|
||||
const image = await registry.inspect("example/core", imageDigest, "linux/amd64", { anonymous: true });
|
||||
assert.equal(image.reference, `docker.io/example/core@${imageDigest}`);
|
||||
assert.deepEqual(auth, [undefined]);
|
||||
await assert.rejects(registry.inspect("example/core", imageDigest, "linux/arm64"), /platform mismatch/);
|
||||
await assert.rejects(registry.inspect("example/core", "sha256:" + "0".repeat(64), "linux/amd64"), /Requested image digest/);
|
||||
} finally { globalThis.fetch = original; }
|
||||
});
|
||||
@@ -22,6 +22,7 @@ const reviewedExpandableBlocks = new Map([
|
||||
{ sha256: "37f18ce7ce93cb8b84f3b3708462cc16d50fdc7bab22836c382dbacf8382f05f", rationale: "Generates the reviewed synthetic tht installer artifact." },
|
||||
]],
|
||||
["scripts/test-server-pi-state-topology.sh", [
|
||||
{ sha256: "435c769b8cbd7b834f56fdabddb86ba04fb404dd0d8a6b7c21719a8b0f7cf011", rationale: "Generates the reviewed model-catalog projection override for the isolated server topology test." },
|
||||
{ sha256: "6ae9567db53d6cd45a2c19c98acaf45f382450b157ea7d6f6d35125f68c50947", rationale: "Generates the isolated server topology test environment, including its installation descriptor and authentication configuration root." },
|
||||
]],
|
||||
["scripts/test-vector-backup-restore-safety.sh", [
|
||||
@@ -36,11 +37,10 @@ const reviewedExpandableBlocks = new Map([
|
||||
{ sha256: "b903e5dae953ae1372f1a5276f12a92ed3dd632b897f3afe5e00c646d90a1b42", rationale: "Same reviewed block in the repository-required CRLF checkout representation." },
|
||||
]],
|
||||
["scripts/unified-deployment-smoke.sh", [
|
||||
{ sha256: "1d60bf140165a8fabfa0c3729e776136904717e67becf3e0ab68c70d8e37847e", rationale: "Generates reviewed Task 13 runtime configuration." },
|
||||
{ sha256: "36d3d8a2362dbdc4fad90948d6c227586d749f56b9a4bc5b6b5a91bcbec6407b", rationale: "Generates the reviewed local Task 13 Compose override." },
|
||||
{ sha256: "c556f7d910d0788e219b042957e6b307cb9925b43920c680535d0d3a6dcbdb25", rationale: "Generates the reviewed local Task 13 installation descriptor." },
|
||||
{ sha256: "b6c0826151b2c8b955399d1abf5b691cc8fe6b6454b17da000dde7ba3bc55d2d", rationale: "Generates the reviewed local Task 13 Compose override with normalized catalog mounts." },
|
||||
{ sha256: "24f69d12b8554aa2bebba455be99fde3e60743eef5a40fa2ef5b29397a477c03", rationale: "Generates the reviewed local Task 13 installation descriptor with its model catalog." },
|
||||
{ sha256: "526006fa6d48a8080b3834723630c64de5005a67243e944ebf1da15212b4d654", rationale: "Generates the reviewed server Task 13 Compose override." },
|
||||
{ sha256: "c57ae2205c21ead0c2015a353aaabb948fa4ddd9b78a2cdcdb71f48cf2db742d", rationale: "Generates the reviewed projected-auth server Task 13 installation descriptor." },
|
||||
{ sha256: "406ccead1967f642225c946fc4a23fe5b019c9764cc5153e1125876ade16ec90", rationale: "Generates the reviewed projected-auth server Task 13 installation descriptor with its model catalog." },
|
||||
]],
|
||||
["scripts/vector-backup.sh", [
|
||||
{ sha256: "571899db49dfdcec8107fbe1e0a86a61e7581979d3c4c248c20546843e275bcf", rationale: "Generates the reviewed backup manifest inside the helper command." },
|
||||
@@ -103,6 +103,7 @@ function isPolicyImplementationException(label, category) {
|
||||
]);
|
||||
if (implementations.has(label)) return true;
|
||||
if (category === "migration-marker" && new Set([
|
||||
"backend/src/workspaces/schema.ts",
|
||||
"scripts/workspace_descriptor_doc_contract.py",
|
||||
"scripts/test_workspace_descriptor_doc_contract.py",
|
||||
"backend/scripts/clean-dist.test.mjs",
|
||||
@@ -173,7 +174,7 @@ function validateWorkspaceSource(source, label, { requireWorkspace, expandable =
|
||||
try {
|
||||
parseWorkspaceYaml(source);
|
||||
} catch (error) {
|
||||
throw new Error(`${label}: workspace descriptor is not valid schema v3: ${error instanceof Error ? error.message : String(error)}`);
|
||||
throw new Error(`${label}: workspace descriptor is not valid schema v4: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -33,20 +33,20 @@ function bashN(root, path) {
|
||||
function replaceWorkspaceKeys(source, workspaceKey, schemaLine) {
|
||||
return source
|
||||
.replace(/^workspace:$/m, workspaceKey)
|
||||
.replace(/^ schema_version: 3$/m, schemaLine);
|
||||
.replace(/^ schema_version: 4$/m, schemaLine);
|
||||
}
|
||||
|
||||
test("production parser accepts semantic v3 with quoted Unicode/tagged keys and spacing", async (t) => {
|
||||
test("production parser accepts semantic v4 with quoted Unicode/tagged keys and spacing", async (t) => {
|
||||
const root = await fixture(t);
|
||||
const unicode = replaceWorkspaceKeys(
|
||||
canonicalDescriptor,
|
||||
'"\\u0077orkspace" :',
|
||||
' "\\u0073chema_version" : 3',
|
||||
' "\\u0073chema_version" : 4',
|
||||
);
|
||||
const tagged = replaceWorkspaceKeys(
|
||||
canonicalDescriptor,
|
||||
"!!str workspace :",
|
||||
" !!str schema_version : 3",
|
||||
" !!str schema_version : 4",
|
||||
);
|
||||
await put(root, "deploy/workspaces/unicode.yaml", unicode);
|
||||
await put(root, "deploy/workspaces/tagged.yaml", tagged);
|
||||
@@ -59,14 +59,15 @@ test("production parser accepts semantic v3 with quoted Unicode/tagged keys and
|
||||
});
|
||||
});
|
||||
|
||||
test("production parser rejects fancy keys with every non-v3 or ambiguous value", async (t) => {
|
||||
test("production parser rejects fancy keys with every non-v4 or ambiguous value", async (t) => {
|
||||
const invalid = [
|
||||
["unicode-v2", '"\\u0077orkspace" :', ' "\\u0073chema_version" : 2'],
|
||||
["tagged-leading-zero", "!!str workspace :", " !!str schema_version : 02"],
|
||||
["hexadecimal", "workspace :", " schema_version : 0x2"],
|
||||
["multiline", "workspace :", " schema_version : >\n 3"],
|
||||
["duplicate", "workspace :", " schema_version : 3\n schema_version: 3"],
|
||||
["inline", "workspace: { schema_version: 3 }", " schema_version: 3"],
|
||||
["unicode-v3", '"\\u0077orkspace" :', ' "\\u0073chema_version" : 3'],
|
||||
["tagged-leading-zero", "!!str workspace :", " !!str schema_version : 03"],
|
||||
["hexadecimal", "workspace :", " schema_version : 0x3"],
|
||||
["multiline", "workspace :", " schema_version : >\n 4"],
|
||||
["duplicate", "workspace :", " schema_version : 4\n schema_version: 4"],
|
||||
["inline", "workspace: { schema_version: 4 }", " schema_version: 4"],
|
||||
];
|
||||
for (const [name, workspaceKey, schemaLine] of invalid) {
|
||||
await t.test(name, async () => {
|
||||
@@ -120,7 +121,7 @@ test("PowerShell embedded workspace mappings are rejected while bundle-only stri
|
||||
const root = await fixture(t);
|
||||
const source = [
|
||||
"$workspace = @'",
|
||||
canonicalDescriptor.replace(" schema_version: 3", " schema_version: 0x2").trimEnd(),
|
||||
canonicalDescriptor.replace(" schema_version: 4", " schema_version: 0x2").trimEnd(),
|
||||
"'@",
|
||||
'$bundle = @"',
|
||||
"bundle:",
|
||||
@@ -137,7 +138,7 @@ test("PowerShell embedded workspace mappings are rejected while bundle-only stri
|
||||
|
||||
test("workspace descriptor family entries require a top-level workspace", async (t) => {
|
||||
const root = await fixture(t);
|
||||
await put(root, "scripts/fixtures/workspace-registry-future.yaml", "bundle:\n schema_version: 3\n");
|
||||
await put(root, "scripts/fixtures/workspace-registry-future.yaml", "bundle:\n schema_version: 4\n");
|
||||
await assert.rejects(
|
||||
verifyEntries({
|
||||
root,
|
||||
@@ -179,7 +180,7 @@ test("script scalar workspace remains a bundle even with descriptor-like sibling
|
||||
test("standalone descriptor files require workspace to be a mapping", async (t) => {
|
||||
const root = await fixture(t);
|
||||
const path = "scripts/fixtures/workspace-registry-scalar.yaml";
|
||||
await put(root, path, "workspace: analytics\nschema_version: 3\n");
|
||||
await put(root, path, "workspace: analytics\nschema_version: 4\n");
|
||||
await assert.rejects(
|
||||
verifyEntries({ root, entries: [entry("workspace_descriptor", path)] }),
|
||||
/workspace.*mapping/i,
|
||||
@@ -193,11 +194,11 @@ test("Bash extractor supports hyphen, digit, escaped delimiters, and tab strippi
|
||||
name: "hyphen-v2",
|
||||
opener: "cat <<'WORKSPACE-YAML'",
|
||||
delimiter: "WORKSPACE-YAML",
|
||||
descriptor: canonicalDescriptor.replace(" schema_version: 3", " schema_version: 2"),
|
||||
descriptor: canonicalDescriptor.replace(" schema_version: 4", " schema_version: 2"),
|
||||
rejected: true,
|
||||
},
|
||||
{
|
||||
name: "digit-v3",
|
||||
name: "digit-v4",
|
||||
opener: "cat <<2YAML",
|
||||
delimiter: "2YAML",
|
||||
descriptor: canonicalDescriptor,
|
||||
@@ -207,11 +208,11 @@ test("Bash extractor supports hyphen, digit, escaped delimiters, and tab strippi
|
||||
name: "escaped-v2",
|
||||
opener: "cat <<WORKSPACE\\-YAML",
|
||||
delimiter: "WORKSPACE-YAML",
|
||||
descriptor: canonicalDescriptor.replace(" schema_version: 3", " schema_version: 2"),
|
||||
descriptor: canonicalDescriptor.replace(" schema_version: 4", " schema_version: 2"),
|
||||
rejected: true,
|
||||
},
|
||||
{
|
||||
name: "tab-strip-v3",
|
||||
name: "tab-strip-v4",
|
||||
opener: "cat <<-'TAB-YAML'",
|
||||
delimiter: "\tTAB-YAML",
|
||||
descriptor: canonicalDescriptor.split("\n").map((line) => `\t${line}`).join("\n"),
|
||||
@@ -272,7 +273,7 @@ test("non-stripping heredoc close requires an exact physical delimiter line", as
|
||||
"#!/usr/bin/env bash",
|
||||
"cat <<'---'",
|
||||
"--- ",
|
||||
canonicalDescriptor.replace(" schema_version: 3", " schema_version: 2").trimEnd(),
|
||||
canonicalDescriptor.replace(" schema_version: 4", " schema_version: 2").trimEnd(),
|
||||
"---",
|
||||
"",
|
||||
].join("\n");
|
||||
@@ -313,7 +314,7 @@ test("double-quoted non-special backslash is preserved in the delimiter", async
|
||||
"#!/usr/bin/env bash",
|
||||
'cat <<"\\---"',
|
||||
"---",
|
||||
canonicalDescriptor.replace(" schema_version: 3", " schema_version: 2").trimEnd(),
|
||||
canonicalDescriptor.replace(" schema_version: 4", " schema_version: 2").trimEnd(),
|
||||
"\\---",
|
||||
"",
|
||||
].join("\n");
|
||||
@@ -355,7 +356,7 @@ test("split heredoc operator continuation cannot bypass v2 validation", async (t
|
||||
"#!/usr/bin/env bash",
|
||||
"cat <\\",
|
||||
"<'YAML'",
|
||||
canonicalDescriptor.replace(" schema_version: 3", " schema_version: 2").trimEnd(),
|
||||
canonicalDescriptor.replace(" schema_version: 4", " schema_version: 2").trimEnd(),
|
||||
"YAML",
|
||||
"",
|
||||
].join("\n");
|
||||
@@ -424,7 +425,7 @@ test("PowerShell comment backslash cannot hide a following v2 here-string", asyn
|
||||
const source = [
|
||||
"# harmless PowerShell comment \\",
|
||||
"$workspace = @'",
|
||||
canonicalDescriptor.replace(" schema_version: 3", " schema_version: 2").trimEnd(),
|
||||
canonicalDescriptor.replace(" schema_version: 4", " schema_version: 2").trimEnd(),
|
||||
"'@",
|
||||
"",
|
||||
].join("\n");
|
||||
@@ -435,7 +436,7 @@ test("PowerShell comment backslash cannot hide a following v2 here-string", asyn
|
||||
);
|
||||
});
|
||||
|
||||
test("PowerShell dialect accepts normal v3 and non-workspace bundle here-strings", async (t) => {
|
||||
test("PowerShell dialect accepts normal v4 and non-workspace bundle here-strings", async (t) => {
|
||||
const root = await fixture(t);
|
||||
const path = "scripts/powershell-valid-smoke.ps1";
|
||||
const source = [
|
||||
@@ -494,10 +495,10 @@ test("PowerShell cast and concatenation openers cannot hide embedded descriptors
|
||||
test("expandable YAML interpolation that can hide a workspace descriptor fails closed", async (t) => {
|
||||
const root = await fixture(t);
|
||||
const cases = [
|
||||
["braced-key", "${key}:\n schema_version: 3"],
|
||||
["plain-key", "$key:\n schema_version: 3"],
|
||||
["quoted-key", '"$key" :\n schema_version: 3'],
|
||||
["subexpression-key", "$($key):\n schema_version: 3"],
|
||||
["braced-key", "${key}:\n schema_version: 4"],
|
||||
["plain-key", "$key:\n schema_version: 4"],
|
||||
["quoted-key", '"$key" :\n schema_version: 4'],
|
||||
["subexpression-key", "$($key):\n schema_version: 4"],
|
||||
["version", "workspace:\n schema_version: $version"],
|
||||
];
|
||||
for (const [name, body] of cases) {
|
||||
@@ -564,7 +565,7 @@ test("unmarked expandable Bash YAML cannot generate descriptor keys or values at
|
||||
"key=workspace",
|
||||
"cat <<YAML",
|
||||
generatedKey,
|
||||
" schema_version: 3",
|
||||
" schema_version: 4",
|
||||
"YAML",
|
||||
"",
|
||||
].join("\n");
|
||||
@@ -600,14 +601,14 @@ test("an in-band marker cannot authorize expandable content", async (t) => {
|
||||
for (const [path, source] of [
|
||||
["scripts/fake-marker.sh", [
|
||||
"#!/usr/bin/env bash",
|
||||
"# schema-v3-only: expandable-nonworkspace",
|
||||
"# schema-v4-only: expandable-nonworkspace",
|
||||
"cat <<YAML",
|
||||
"${DESCRIPTOR}",
|
||||
"YAML",
|
||||
"",
|
||||
].join("\n")],
|
||||
["scripts/fake-marker.ps1", [
|
||||
"# schema-v3-only: expandable-nonworkspace",
|
||||
"# schema-v4-only: expandable-nonworkspace",
|
||||
'$yaml = @"',
|
||||
"$descriptor",
|
||||
'"@',
|
||||
|
||||
+122
-20
@@ -3,9 +3,11 @@ import cors from "@fastify/cors";
|
||||
import cookie from "@fastify/cookie";
|
||||
import rateLimit from "@fastify/rate-limit";
|
||||
import { dirname, isAbsolute, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { tmpdir } from "node:os";
|
||||
import type { AppConfig } from "./config.js";
|
||||
import { ThtRunner } from "./tht/tht-runner.js";
|
||||
import { createMemoryCleanup } from "./catalog/memory-cleanup.js";
|
||||
import { PiProcessManager } from "./pi/pi-process-manager.js";
|
||||
import { SseHub } from "./sse/sse-hub.js";
|
||||
import { authenticateSession, captureAuthConfigSnapshot, configuredOrigin } from "./auth/auth.js";
|
||||
@@ -22,7 +24,8 @@ import { isUsableAuthenticationSecret } from "./auth/secret-policy.js";
|
||||
import { secretValue } from "./config/secret-bundle.js";
|
||||
import { sessionRoutes } from "./routes/sessions.js";
|
||||
import { sqlRoutes } from "./routes/sql.js";
|
||||
import { metaRoutes, type ListModelsFn } from "./routes/meta.js";
|
||||
import { metaRoutes } from "./routes/meta.js";
|
||||
import type { ListModelsFn } from "./pi/list-models.js";
|
||||
import { settingsRoutes, effectiveSettings } from "./routes/settings.js";
|
||||
import { createPiModelLister } from "./pi/list-models.js";
|
||||
import { createPiManagement, type PiManagementService } from "./pi/management.js";
|
||||
@@ -31,7 +34,11 @@ import { ReadinessManager } from "./runtime/readiness-manager.js";
|
||||
import { MaintenanceBarrier } from "./runtime/maintenance-gate.js";
|
||||
import { WorkspaceRegistry } from "./workspaces/registry.js";
|
||||
import { createProductionWorkspaceDiagnoser } from "./workspaces/diagnostics.js";
|
||||
import { workspaceRoutes, type WorkspaceDiagnoser } from "./routes/workspaces.js";
|
||||
import {
|
||||
workspaceRoutes,
|
||||
type WorkspaceDatabaseTester,
|
||||
type WorkspaceDiagnoser,
|
||||
} from "./routes/workspaces.js";
|
||||
import { piManagementRoutes } from "./routes/pi-management.js";
|
||||
import { supportsSessionRuntime } from "./workspaces/bindings.js";
|
||||
import { resolveRuntimeBindingsWithWorkspaceSecrets } from "./workspaces/secret-requirements.js";
|
||||
@@ -56,13 +63,26 @@ import { metadataGenerationModelRoutes } from "./routes/metadata-generation-mode
|
||||
import { catalogDescriptionConsolidationRoutes } from "./routes/catalog-description-consolidation.js";
|
||||
import { PythonModelCompleter, type ModelCompleter } from "./catalog/model-completer.js";
|
||||
import { DescriptionGenerationWorker } from "./catalog/description-generation-worker.js";
|
||||
import { SensitiveDataSuggester } from "./catalog/sensitive-data-suggester.js";
|
||||
import { SensitiveDataSuggestionRunner } from "./catalog/sensitive-data-suggestion-runner.js";
|
||||
import { SensitivityAnalysisService } from "./catalog/sensitivity-analysis-service.js";
|
||||
import { SensitivityAnalysisRunner } from "./catalog/sensitivity-analysis-runner.js";
|
||||
import { SensitivityClassifier, type LocalNerDetector, type SensitivityValueSource } from "./catalog/sensitivity-classifier.js";
|
||||
import { ConcreteSensitivityValueSource } from "./catalog/sensitivity-value-source.js";
|
||||
import { PythonLocalNerDetector } from "./catalog/local-ner-detector.js";
|
||||
import {
|
||||
PostgresDescriptionSourceSampler,
|
||||
ConcreteDescriptionSourceSampler,
|
||||
type DescriptionSourceSampler,
|
||||
} from "./catalog/description-source-sampler.js";
|
||||
import { catalogDescriptionGenerationRoutes } from "./routes/catalog-description-generation.js";
|
||||
import { CatalogLogicalRelationshipService } from "./catalog/logical-relationship-service.js";
|
||||
import { catalogLogicalRelationshipRoutes } from "./routes/catalog-logical-relationships.js";
|
||||
import { EffectiveRelationshipSnapshotProvider } from "./catalog/effective-relationship-snapshot.js";
|
||||
import { loadRuntimeModelCatalog, type RuntimeModelCatalog } from "./models/runtime-model-catalog.js";
|
||||
import { createProductionWorkspacePreprocessingService } from "./workspace-maintenance.js";
|
||||
import type { WorkspacePreprocessingService } from "./workspaces/preprocessing-service.js";
|
||||
import { PreprocessingStateStore } from "./workspaces/preprocessing-state.js";
|
||||
import { workspacePreprocessingRoutes } from "./routes/workspace-preprocessing.js";
|
||||
import { memoryRoutes } from "./routes/memory.js";
|
||||
import { evidenceRoutes } from "./routes/evidence.js";
|
||||
|
||||
export interface BuildAppDeps {
|
||||
thtRunner?: ThtRunner;
|
||||
@@ -74,17 +94,24 @@ export interface BuildAppDeps {
|
||||
hub?: SseHub;
|
||||
workspaceRegistry?: WorkspaceRegistry;
|
||||
workspaceDiagnoser?: WorkspaceDiagnoser;
|
||||
workspaceDatabaseTester?: WorkspaceDatabaseTester;
|
||||
workspaceSecretStore?: WorkspaceSecretStore;
|
||||
workspacePreprocessingService?: Pick<WorkspacePreprocessingService, "run" | "clear"> & Partial<Pick<WorkspacePreprocessingService, "consolidateEvidence" | "evidenceSources">>;
|
||||
catalogRepository?: CatalogRepository;
|
||||
catalogService?: CatalogService;
|
||||
catalogPostgresAccess?: CatalogPostgresAccess;
|
||||
catalogTableService?: CatalogTableService;
|
||||
catalogLogicalRelationshipService?: CatalogLogicalRelationshipService;
|
||||
effectiveRelationshipSnapshotProvider?: EffectiveRelationshipSnapshotProvider;
|
||||
catalogSchemaIntrospector?: CatalogSchemaIntrospector;
|
||||
catalogSyncWorker?: CatalogSyncWorker;
|
||||
catalogOperationCoordinator?: CatalogOperationCoordinator;
|
||||
metadataGenerationModels?: MetadataGenerationModels;
|
||||
runtimeModelCatalog?: RuntimeModelCatalog;
|
||||
modelCompleter?: ModelCompleter;
|
||||
descriptionSourceSampler?: DescriptionSourceSampler;
|
||||
sensitivityValueSource?: SensitivityValueSource;
|
||||
localNerDetector?: LocalNerDetector;
|
||||
workspaceRuntimeSupport?: (workspace: WorkspaceDescriptor) => boolean;
|
||||
maintenanceBarrier?: MaintenanceBarrier;
|
||||
piManagement?: PiManagementService;
|
||||
@@ -137,6 +164,8 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
|
||||
app.register(cookie);
|
||||
app.register(rateLimit, { global: false });
|
||||
|
||||
const workspaceRegistry = deps?.workspaceRegistry ?? new WorkspaceRegistry(config.workspaceRegistry);
|
||||
const catalogRepository = deps?.catalogRepository ?? createCatalogRepository(config.catalogDatabase);
|
||||
const tht = deps?.thtRunner ?? new ThtRunner({
|
||||
thtBin: config.thtBin,
|
||||
harnessDir: config.harnessDir,
|
||||
@@ -147,20 +176,32 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
|
||||
secretsFile: config.secretsFile,
|
||||
secretFiles: config.secretFiles,
|
||||
workspaceSecretStore,
|
||||
catalogRepository: deps?.catalogRepository ?? (config.catalogDatabase ? catalogRepository : undefined),
|
||||
semanticRuntime: {
|
||||
internalQdrantUrl: config.internalQdrantUrl,
|
||||
internalEmbeddingUrl: config.internalEmbeddingUrl,
|
||||
internalEmbeddingId: config.internalEmbeddingId,
|
||||
internalEmbeddingModel: config.internalEmbeddingModel,
|
||||
internalEmbeddingDimensions: config.internalEmbeddingDimensions,
|
||||
},
|
||||
});
|
||||
const mgr = deps?.mgr ?? new PiProcessManager(config, deps?.spawnFn ? { spawnFn: deps.spawnFn } : undefined);
|
||||
const workspacePreprocessingService = deps?.workspacePreprocessingService
|
||||
?? createProductionWorkspacePreprocessingService({
|
||||
config,
|
||||
catalogRepository,
|
||||
registry: workspaceRegistry,
|
||||
workspaceSecretStore,
|
||||
runner: tht as ThtRunner,
|
||||
});
|
||||
const hub = deps?.hub ?? new SseHub();
|
||||
const workspaceRegistry = deps?.workspaceRegistry ?? new WorkspaceRegistry(config.workspaceRegistry);
|
||||
const catalogRepository = deps?.catalogRepository ?? createCatalogRepository(config.catalogDatabase);
|
||||
const catalogOperationCoordinator = deps?.catalogOperationCoordinator ?? new CatalogOperationCoordinator();
|
||||
const runtimeModelCatalog = deps?.runtimeModelCatalog ?? loadRuntimeModelCatalog(config.modelCatalogFile);
|
||||
const mgr = deps?.mgr ?? new PiProcessManager(config, {
|
||||
...(deps?.spawnFn ? { spawnFn: deps.spawnFn } : {}),
|
||||
modelCatalog: runtimeModelCatalog,
|
||||
});
|
||||
const metadataGenerationModels = deps?.metadataGenerationModels ?? loadMetadataGenerationModels({
|
||||
installationFile: config.installationConfigFile,
|
||||
catalogFile: config.modelCatalogFile,
|
||||
secretsFile: config.secretsFile,
|
||||
});
|
||||
const modelCompleter = deps?.modelCompleter ?? new PythonModelCompleter({
|
||||
@@ -172,7 +213,7 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
|
||||
{ connectTimeoutMs: config.workspaceDiagnosticTimeoutMs },
|
||||
);
|
||||
const descriptionSourceSampler = deps?.descriptionSourceSampler
|
||||
?? new PostgresDescriptionSourceSampler(catalogPostgresAccess);
|
||||
?? new ConcreteDescriptionSourceSampler(catalogPostgresAccess, workspaceSecretStore);
|
||||
const descriptionGenerationWorker = new DescriptionGenerationWorker(
|
||||
catalogRepository,
|
||||
workspaceRegistry,
|
||||
@@ -181,12 +222,24 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
|
||||
catalogOperationCoordinator,
|
||||
descriptionSourceSampler,
|
||||
);
|
||||
const sensitiveDataSuggester = new SensitiveDataSuggester(
|
||||
const sensitivityValueSource = deps?.sensitivityValueSource
|
||||
?? new ConcreteSensitivityValueSource(catalogPostgresAccess, workspaceSecretStore);
|
||||
const configuredNerWorker = config.sensitivityNer?.workerScript
|
||||
?? fileURLToPath(new URL("../python/sensitivity_ner_worker.py", import.meta.url));
|
||||
const localNerDetector = deps?.localNerDetector ?? (config.sensitivityNer
|
||||
? new PythonLocalNerDetector({
|
||||
pythonExecutable: config.sensitivityNer.pythonExecutable,
|
||||
workerScript: configuredNerWorker,
|
||||
modelPath: config.sensitivityNer.modelPath,
|
||||
cwd: dirname(configuredNerWorker),
|
||||
threads: config.sensitivityNer.threads,
|
||||
})
|
||||
: undefined);
|
||||
const sensitiveDataSuggester = new SensitivityAnalysisService(
|
||||
catalogRepository,
|
||||
metadataGenerationModels,
|
||||
modelCompleter,
|
||||
new SensitivityClassifier(sensitivityValueSource, localNerDetector),
|
||||
);
|
||||
const sensitiveDataSuggestionRunner = new SensitiveDataSuggestionRunner(
|
||||
const sensitivityAnalysisRunner = new SensitivityAnalysisRunner(
|
||||
catalogRepository,
|
||||
sensitiveDataSuggester,
|
||||
);
|
||||
@@ -199,7 +252,13 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
|
||||
catalogPostgresAccess,
|
||||
catalogOperationCoordinator,
|
||||
);
|
||||
const workspaceDatabaseTester = deps?.workspaceDatabaseTester ?? (async (workspaceId: string) => {
|
||||
const database = await catalogRepository.getByWorkspace(workspaceId);
|
||||
return database ? catalogService.test(database) : undefined;
|
||||
});
|
||||
const catalogTableService = deps?.catalogTableService ?? new CatalogTableService(catalogRepository);
|
||||
const catalogLogicalRelationshipService = deps?.catalogLogicalRelationshipService
|
||||
?? new CatalogLogicalRelationshipService(catalogRepository);
|
||||
const catalogSchemaIntrospector = deps?.catalogSchemaIntrospector ?? new ConcreteCatalogSchemaIntrospector(
|
||||
catalogPostgresAccess,
|
||||
workspaceSecretStore,
|
||||
@@ -209,19 +268,33 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
|
||||
catalogSchemaIntrospector,
|
||||
catalogOperationCoordinator,
|
||||
config.catalogSyncTimeoutMs,
|
||||
createMemoryCleanup(tht as ThtRunner, {
|
||||
internalQdrantUrl: config.internalQdrantUrl, internalEmbeddingUrl: config.internalEmbeddingUrl,
|
||||
internalEmbeddingId: config.internalEmbeddingId, internalEmbeddingModel: config.internalEmbeddingModel,
|
||||
internalEmbeddingDimensions: config.internalEmbeddingDimensions,
|
||||
}),
|
||||
);
|
||||
app.addHook("onReady", async () => { await catalogSyncWorker.initialize(); });
|
||||
app.addHook("onReady", async () => { await descriptionGenerationWorker.initialize(); });
|
||||
app.addHook("onReady", async () => { await sensitiveDataSuggestionRunner.initialize(); });
|
||||
app.addHook("onReady", async () => { await sensitivityAnalysisRunner.initialize(); });
|
||||
if (localNerDetector?.warmup) {
|
||||
app.addHook("onReady", async () => {
|
||||
void localNerDetector.warmup?.().catch(() => undefined);
|
||||
});
|
||||
}
|
||||
if (!deps?.catalogRepository && catalogRepository.close) {
|
||||
app.addHook("onClose", async () => { await catalogRepository.close?.(); });
|
||||
}
|
||||
app.addHook("onClose", async () => { await catalogSyncWorker.stop(); });
|
||||
app.addHook("onClose", async () => { await descriptionGenerationWorker.stop(); });
|
||||
if (localNerDetector?.close) {
|
||||
app.addHook("onClose", async () => { await localNerDetector.close?.(); });
|
||||
}
|
||||
const workspaceDiagnoser = deps?.workspaceDiagnoser
|
||||
?? createProductionWorkspaceDiagnoser(config.workspaceDiagnosticTimeoutMs, undefined, {
|
||||
internalQdrantUrl: config.internalQdrantUrl,
|
||||
internalEmbeddingUrl: config.internalEmbeddingUrl,
|
||||
internalEmbeddingId: config.internalEmbeddingId,
|
||||
internalEmbeddingModel: config.internalEmbeddingModel,
|
||||
internalEmbeddingDimensions: config.internalEmbeddingDimensions,
|
||||
});
|
||||
@@ -244,6 +317,7 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
|
||||
);
|
||||
|
||||
const listModels = deps?.listModels ?? createPiModelLister(config, {
|
||||
modelCatalog: runtimeModelCatalog,
|
||||
warn: (detail) => app.log.warn(
|
||||
{ component: "pi-model-list", detail },
|
||||
"Pi enabled-model configuration warning",
|
||||
@@ -256,7 +330,7 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
|
||||
const getSettings = async (principal: PrincipalContext): Promise<Settings> => {
|
||||
if (deps?.getSettings) return await deps.getSettings(principal);
|
||||
const stored = loadSettings(config);
|
||||
const effective = effectiveSettings(config, stored);
|
||||
const effective = effectiveSettings(config, stored, runtimeModelCatalog);
|
||||
// In the registry system the legacy `harness/workspaces/*.yaml` default is obsolete: when no
|
||||
// installation workspace is pinned, default to the first active registry workspace.
|
||||
if (!stored.workspace) {
|
||||
@@ -269,7 +343,9 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
|
||||
}
|
||||
return effective;
|
||||
};
|
||||
const piManagement = deps?.piManagement ?? createPiManagement(config, { listModels });
|
||||
const piManagement = deps?.piManagement ?? createPiManagement(config, {
|
||||
modelCatalog: runtimeModelCatalog,
|
||||
});
|
||||
|
||||
const maintenanceBarrier = deps?.maintenanceBarrier ?? new MaintenanceBarrier(config.maintenanceFile);
|
||||
const localRegistryResolver = deps?.localUserRegistry === undefined
|
||||
@@ -393,7 +469,9 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
|
||||
dwhPrecheck: config.dwhPrecheck,
|
||||
legacyWorkspaceMode: config.legacyWorkspaceMode,
|
||||
workspaceRuntimeSupport,
|
||||
modelCatalog: runtimeModelCatalog,
|
||||
maintenanceBarrier,
|
||||
catalogRepository: deps?.catalogRepository ?? (config.catalogDatabase ? catalogRepository : undefined),
|
||||
});
|
||||
app.post("/internal/maintenance/activate", async (req, reply) => {
|
||||
try {
|
||||
@@ -423,13 +501,33 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
|
||||
return maintenanceBarrier.status();
|
||||
});
|
||||
sqlRoutes(app, { tht: tht as ThtRunner, getSettings, workspaceRegistry });
|
||||
metaRoutes(app, { harnessDir: config.harnessDir, listModels });
|
||||
memoryRoutes(app, { runner: tht as ThtRunner, registry: workspaceRegistry, runtime: {
|
||||
internalQdrantUrl: config.internalQdrantUrl,
|
||||
internalEmbeddingUrl: config.internalEmbeddingUrl,
|
||||
internalEmbeddingModel: config.internalEmbeddingModel,
|
||||
internalEmbeddingDimensions: config.internalEmbeddingDimensions,
|
||||
} });
|
||||
metaRoutes(app, { harnessDir: config.harnessDir, modelCatalog: runtimeModelCatalog });
|
||||
evidenceRoutes(app, { runner: tht as ThtRunner, registry: workspaceRegistry,
|
||||
registryRoot: config.workspaceRegistry.root, hostRegistryRoot: config.evidenceHostRegistryRoot,
|
||||
service: workspacePreprocessingService });
|
||||
workspaceRoutes(app, {
|
||||
registry: workspaceRegistry,
|
||||
config: config.workspaceRegistry,
|
||||
diagnose: workspaceDiagnoser,
|
||||
authDiagnoser,
|
||||
secretStore: workspaceSecretStore,
|
||||
testDatabaseConnection: workspaceDatabaseTester,
|
||||
});
|
||||
workspacePreprocessingRoutes(app, {
|
||||
repository: catalogRepository,
|
||||
registry: workspaceRegistry,
|
||||
service: workspacePreprocessingService,
|
||||
inputFingerprint: tht as ThtRunner,
|
||||
readLatestJob: (workspaceId) => new PreprocessingStateStore({
|
||||
dataRoot: config.dataRoot ?? "/data",
|
||||
workspaceId,
|
||||
}).readLatestJob(),
|
||||
});
|
||||
catalogDatabaseRoutes(app, { repository: catalogRepository, service: catalogService, operations: catalogOperationCoordinator });
|
||||
catalogTableRoutes(app, {
|
||||
@@ -442,6 +540,10 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
|
||||
worker: catalogSyncWorker,
|
||||
operations: catalogOperationCoordinator,
|
||||
});
|
||||
catalogLogicalRelationshipRoutes(app, {
|
||||
service: catalogLogicalRelationshipService,
|
||||
operations: catalogOperationCoordinator,
|
||||
});
|
||||
catalogDescriptionConsolidationRoutes(app, {
|
||||
repository: catalogRepository,
|
||||
operations: catalogOperationCoordinator,
|
||||
@@ -450,9 +552,9 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
|
||||
catalogDescriptionGenerationRoutes(app, {
|
||||
repository: catalogRepository,
|
||||
worker: descriptionGenerationWorker,
|
||||
sensitiveDataSuggestionRunner,
|
||||
sensitivityAnalysisRunner,
|
||||
});
|
||||
settingsRoutes(app, { cfg: config, listModels, getSettings });
|
||||
settingsRoutes(app, { cfg: config, getSettings });
|
||||
piManagementRoutes(app, { service: piManagement });
|
||||
|
||||
return app;
|
||||
|
||||
@@ -119,7 +119,9 @@ export function authenticateSession(deps: AuthDependencies): preHandlerHookHandl
|
||||
subject: session.subject,
|
||||
...(session.displayName === undefined ? {} : { displayName: session.displayName }),
|
||||
roles: session.roles,
|
||||
permissions: session.permissions,
|
||||
// Sessions can outlive a deployment that changes the role permission catalog.
|
||||
// resolve() has already checked validity, including current local user roles.
|
||||
permissions: rolesToPermissions(session.roles),
|
||||
isAdmin: session.roles.includes("admin"),
|
||||
};
|
||||
if (STATE_CHANGING_METHODS.has(request.method)) {
|
||||
|
||||
@@ -38,7 +38,7 @@ const MAX_MAPPED_GROUPS = 128;
|
||||
const ROLES = ["user", "admin"] as const;
|
||||
export const PERMISSION_CATALOG: readonly Permission[] = [
|
||||
"session.use", "session.read_all", "session.manage_all", "settings.manage",
|
||||
"workspace.manage", "workspace.secrets.manage", "database.manage", "pi.manage", "auth.diagnostics.read",
|
||||
"workspace.manage", "workspace.secrets.manage", "database.manage", "memory.manage", "evidence.manage", "pi.manage", "auth.diagnostics.read",
|
||||
];
|
||||
|
||||
const invalid = (): Error => new Error("authentication configuration is invalid");
|
||||
|
||||
@@ -33,7 +33,7 @@ const EMPTY_HKDF_SALT = Buffer.alloc(0);
|
||||
const ROLES = ["user", "admin"] as const;
|
||||
const PERMISSIONS = [
|
||||
"session.use", "session.read_all", "session.manage_all", "settings.manage",
|
||||
"workspace.manage", "workspace.secrets.manage", "database.manage", "pi.manage", "auth.diagnostics.read",
|
||||
"workspace.manage", "workspace.secrets.manage", "database.manage", "memory.manage", "evidence.manage", "pi.manage", "auth.diagnostics.read",
|
||||
] as const satisfies readonly Permission[];
|
||||
|
||||
const invalid = (): Error => new Error("auth_session_store_invalid");
|
||||
|
||||
@@ -5,7 +5,7 @@ export type Role = "user" | "admin";
|
||||
export type Permission =
|
||||
| "session.use" | "session.read_all" | "session.manage_all"
|
||||
| "settings.manage" | "workspace.manage" | "workspace.secrets.manage"
|
||||
| "database.manage" | "pi.manage" | "auth.diagnostics.read";
|
||||
| "database.manage" | "memory.manage" | "evidence.manage" | "pi.manage" | "auth.diagnostics.read";
|
||||
|
||||
export interface AuthenticationSessionConfig {
|
||||
regularTtlSeconds: number;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { readFileSync, lstatSync } from "node:fs";
|
||||
import { parseAllDocuments } from "yaml";
|
||||
import { decode, DocumentError, runWorkspaceDocuments } from "../workspaces/documents.js";
|
||||
import { validateDatabaseBootstrap } from "./bootstrap-documents.js";
|
||||
|
||||
/** Internal sibling protocol: only references cross back to Go, never secret contents. */
|
||||
export function runBootstrapValidation(args: string[]): { status: number; output: string } {
|
||||
try {
|
||||
if (args.length !== 5 || args[0] !== "--directory" || args[2] !== "--bootstrap" || args[4] !== "--json") throw new Error("usage");
|
||||
const checked = runWorkspaceDocuments(["validate", "--directory", args[1], "--json"]);
|
||||
if (checked.status !== 0) return checked;
|
||||
const info = lstatSync(args[3]);
|
||||
if (!info.isFile() || info.isSymbolicLink() || info.size > 1024 * 1024) throw new Error("file");
|
||||
const validated = decode(readFileSync(args[3], "utf8"), "database-bootstrap.yaml", (source) =>
|
||||
validateDatabaseBootstrap(parseAllDocuments(source)[0].toJSON(), args[1]), "database bootstrap schema v1 and the Catalog binding contract");
|
||||
return { status: 0, output: JSON.stringify({ schema_version: 1, ok: true, secret_files: validated.secretFiles, warnings: validated.warnings, issues: [] }) };
|
||||
} catch (error) {
|
||||
const issue = error instanceof DocumentError ? error.issue : { document: "database-bootstrap.yaml", field: "$", code: "bootstrap_invalid", correction: "Supply one complete Catalog database configuration per workspace in a readable local bootstrap document." };
|
||||
return { status: 1, output: JSON.stringify({ schema_version: 1, ok: false, issues: [issue] }) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { z } from "zod";
|
||||
import { databaseConfigurationSchema } from "./configuration-schema.js";
|
||||
import { parseWorkspaceCatalogYaml } from "../workspaces/catalog.js";
|
||||
import { parseWorkspaceYaml } from "../workspaces/schema.js";
|
||||
import { discoverWorkspaceSecretRequirements } from "../workspaces/secret-requirements.js";
|
||||
|
||||
const reference = z.string().min(1).max(4096);
|
||||
const database = databaseConfigurationSchema.extend({
|
||||
secretFiles: z.object({ password: reference.optional(), apiKey: reference.optional(), sshPrivateKey: reference.optional(), sshPrivateKeyPassphrase: reference.optional(), sshKnownHosts: reference.optional(), tlsCa: reference.optional() }).strict(),
|
||||
evidenceSecretFiles: z.object({ "evidence.signed_urls": reference.optional(), "evidence.access_key": reference.optional(), "evidence.secret_key": reference.optional(), "evidence.session_token": reference.optional() }).strict().optional(),
|
||||
});
|
||||
const bootstrap = z.object({ schemaVersion: z.literal(1), databases: z.array(database).min(1).max(1000) }).strict();
|
||||
|
||||
export const parseDatabaseBootstrap = (value: unknown) => bootstrap.parse(value);
|
||||
|
||||
export interface BootstrapReference { field: string; path: string }
|
||||
|
||||
/** Offline bootstrap boundary: runtime Catalog owns the resulting bindings after import. */
|
||||
export function validateDatabaseBootstrap(value: unknown, workspaceRoot: string): { secretFiles: BootstrapReference[]; warnings: string[] } {
|
||||
const document = bootstrap.parse(value);
|
||||
const catalog = parseWorkspaceCatalogYaml(readFileSync(join(workspaceRoot, "thoth-workspaces.yaml"), "utf8"));
|
||||
const expected = new Set(catalog.workspaces.map((entry) => entry.id));
|
||||
const seen = new Set<string>();
|
||||
const secretFiles: BootstrapReference[] = [];
|
||||
const warnings: string[] = [];
|
||||
const issue = (path: (string | number)[], message: string): never => { throw new z.ZodError([{ code: "custom", path, message }]); };
|
||||
document.databases.forEach((entry, index) => {
|
||||
if (!expected.has(entry.workspaceId) || seen.has(entry.workspaceId)) issue(["databases", index, "workspaceId"], "Declare each catalog workspace exactly once.");
|
||||
seen.add(entry.workspaceId);
|
||||
const required = entry.binding.transport === "rest_api"
|
||||
? entry.binding.restAuth === "none" ? [] : ["apiKey"] as const
|
||||
: entry.binding.transport === "ssh_tunnel" ? ["password", "sshPrivateKey", "sshKnownHosts"] as const : ["password"] as const;
|
||||
for (const name of required) {
|
||||
if (!entry.secretFiles[name]) issue(["databases", index, "secretFiles", name], "Supply a protected file reference for this transport.");
|
||||
}
|
||||
if (entry.binding.transport === "ssh_tunnel") warnings.push(`databases.${index}:ssh_tunnel supports Catalog diagnostics, not NL-to-SQL sessions; choose direct or REST for practice.`);
|
||||
for (const [name, path] of Object.entries(entry.secretFiles)) secretFiles.push({ field: `databases.${index}.secretFiles.${name}`, path });
|
||||
const workspace = parseWorkspaceYaml(readFileSync(join(workspaceRoot, entry.workspaceId, "workspace.yaml"), "utf8"));
|
||||
const requirements = discoverWorkspaceSecretRequirements(workspace, {});
|
||||
for (const requirement of requirements.filter((item) => item.connector === "evidence" && item.required)) {
|
||||
if (!(entry.evidenceSecretFiles as Record<string, string> | undefined)?.[requirement.id]) issue(["databases", index, "evidenceSecretFiles"], "Supply the configured Evidence authentication file references.");
|
||||
}
|
||||
for (const [name, path] of Object.entries(entry.evidenceSecretFiles ?? {})) secretFiles.push({ field: `databases.${index}.evidenceSecretFiles.${name}`, path });
|
||||
});
|
||||
if (seen.size !== expected.size) issue(["databases"], "Add a database binding for every catalog workspace.");
|
||||
return { secretFiles, warnings };
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { S3Client, ListObjectsV2Command } from "@aws-sdk/client-s3";
|
||||
import { parseWorkspaceYaml } from "../workspaces/schema.js";
|
||||
import { evidencePolicy } from "../workspaces/evidence/preprocessing.js";
|
||||
import type { parseDatabaseBootstrap } from "./bootstrap-documents.js";
|
||||
import { probePublicEvidenceUrl, publicEvidenceAgent } from "./evidence-probe-http.js";
|
||||
|
||||
type Entry = ReturnType<typeof parseDatabaseBootstrap>["databases"][number];
|
||||
|
||||
/** Read-only availability probes; domain correctness and materialization remain runtime gates. */
|
||||
export async function probeEvidence(entry: Entry, root: string): Promise<void> {
|
||||
const evidence = parseWorkspaceYaml(readFileSync(join(root, entry.workspaceId, "workspace.yaml"), "utf8")).evidence;
|
||||
if (!evidence || evidence.source.type === "filesystem") return;
|
||||
if (evidencePolicy(evidence)) throw new Error("Evidence egress policy refused");
|
||||
const secret = (name: keyof NonNullable<Entry["evidenceSecretFiles"]>) => {
|
||||
const path = entry.evidenceSecretFiles?.[name];
|
||||
if (!path) throw new Error("Evidence credential missing");
|
||||
return readFileSync(path, "utf8").trim();
|
||||
};
|
||||
const source = evidence.source;
|
||||
if (source.type === "http") {
|
||||
const urls: unknown = source.authentication === "signed_urls_file"
|
||||
? JSON.parse(secret("evidence.signed_urls")) : source.uris;
|
||||
if (!Array.isArray(urls) || urls.length !== source.uris.length || urls.length > 1000) throw new Error("Invalid signed URLs");
|
||||
for (const [index, value] of urls.entries()) {
|
||||
if (typeof value !== "string") throw new Error("Invalid signed URL");
|
||||
const url = new URL(value);
|
||||
const provenance = new URL(source.uris[index]);
|
||||
// Signed queries may authorize the same identity, never a different host/path.
|
||||
if (url.origin !== provenance.origin || url.pathname !== provenance.pathname || url.username || url.password || url.hash) throw new Error("Invalid signed URL identity");
|
||||
await probePublicEvidenceUrl(url);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// The shared runtime policy currently permits trusted AWS endpoints with explicit file credentials.
|
||||
const location = new URL(source.uri);
|
||||
const client = new S3Client({
|
||||
region: source.region ?? "us-east-1", maxAttempts: 1,
|
||||
requestHandler: { httpsAgent: publicEvidenceAgent(), connectionTimeout: 5_000, requestTimeout: 5_000 },
|
||||
credentials: { accessKeyId: secret("evidence.access_key"), secretAccessKey: secret("evidence.secret_key"),
|
||||
...(entry.evidenceSecretFiles?.["evidence.session_token"] ? { sessionToken: secret("evidence.session_token") } : {}) },
|
||||
});
|
||||
try {
|
||||
await client.send(new ListObjectsV2Command({ Bucket: location.hostname, Prefix: decodeURIComponent(location.pathname.slice(1)), MaxKeys: 1 }), { abortSignal: AbortSignal.timeout(5_000) });
|
||||
} finally { client.destroy(); }
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { parse } from "yaml";
|
||||
import { parseDatabaseBootstrap } from "./bootstrap-documents.js";
|
||||
import { runBootstrapValidation } from "./bootstrap-cli.js";
|
||||
import { createConcreteDiagnosticAdapters, type DiagnosticAdapters } from "../workspaces/diagnostics.js";
|
||||
import { probeEvidence } from "./bootstrap-evidence-probes.js";
|
||||
|
||||
interface ProbeCheck { id: string; outcome: "passed" | "error"; field: string; action: string }
|
||||
|
||||
/** Uses the same read-only, authenticated connector diagnostics as the Catalog. */
|
||||
export async function probeBootstrapDependencies(value: unknown, adapters: DiagnosticAdapters = createConcreteDiagnosticAdapters(), workspaceRoot?: string) {
|
||||
const document = parseDatabaseBootstrap(value);
|
||||
const checks: ProbeCheck[] = [];
|
||||
for (const [index, entry] of document.databases.entries()) {
|
||||
let outcome: ProbeCheck["outcome"] = "passed";
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 5_000);
|
||||
try {
|
||||
if (entry.binding.transport === "ssh_tunnel") throw new Error("session transport unavailable");
|
||||
await adapters.probeConnector({
|
||||
role: "dwh", transport: entry.binding.transport,
|
||||
host: entry.binding.host, port: entry.binding.port, user: entry.binding.username,
|
||||
baseUrl: entry.binding.baseUrl,
|
||||
credentialFile: entry.binding.transport === "rest_api" ? entry.secretFiles.apiKey : entry.secretFiles.password,
|
||||
tlsCaFile: entry.secretFiles.tlsCa, tlsServername: entry.binding.tlsServername,
|
||||
resource: { database: entry.databaseName, schema: entry.schema },
|
||||
timeoutMs: 5_000, signal: controller.signal,
|
||||
diagnostic: { method: "GET", path: entry.binding.restPath ?? "/health", auth: entry.binding.restAuth ?? "bearer" },
|
||||
});
|
||||
} catch { outcome = "error"; } finally { clearTimeout(timer); }
|
||||
checks.push({ id: `database-${index}`, outcome, field: `database-bootstrap.databases.${index}`,
|
||||
action: entry.binding.transport === "ssh_tunnel"
|
||||
? "Choose postgres_direct or rest_api for NL-to-SQL practice; SSH diagnostics alone cannot establish session readiness."
|
||||
: "Require an authenticated read-only connection and access to the configured database/schema; correct endpoint, permissions or protected credentials." });
|
||||
if (workspaceRoot) {
|
||||
let evidenceOutcome: ProbeCheck["outcome"] = "passed";
|
||||
try { await probeEvidence(entry, workspaceRoot); } catch { evidenceOutcome = "error"; }
|
||||
checks.push({ id: `evidence-${index}`, outcome: evidenceOutcome, field: `workspaces.${index}.evidence`, action: "Require readable local Evidence or authenticated bounded HTTP/S3 access under the canonical egress policy; domain meaning is verified during practice." });
|
||||
}
|
||||
}
|
||||
return { schema_version: 1, ok: checks.every((check) => check.outcome === "passed"), checks };
|
||||
}
|
||||
|
||||
export async function runBootstrapProbes(args: string[]) {
|
||||
const validation = runBootstrapValidation(args);
|
||||
if (validation.status !== 0) return validation;
|
||||
try {
|
||||
const report = await probeBootstrapDependencies(parse(readFileSync(args[3], "utf8")), undefined, args[1]);
|
||||
return { status: report.ok ? 0 : 1, output: JSON.stringify(report) };
|
||||
} catch {
|
||||
return { status: 1, output: JSON.stringify({ schema_version: 1, ok: false, checks: [{ id: "database-probes", outcome: "error", field: "database-bootstrap", action: "Revalidate prepared documents and protected credential references." }] }) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { z } from "zod";
|
||||
import { parseCredentialFreeHttpUrl } from "../auth/url-policy.js";
|
||||
import { DATABASE_TRANSPORTS } from "./types.js";
|
||||
|
||||
const workspaceIdSchema = z.string().regex(/^[a-z][a-z0-9-]{2,62}$/);
|
||||
const identifier = z.string().trim().min(1).max(128).regex(/^[A-Za-z_][A-Za-z0-9_$-]*$/);
|
||||
const nonEmpty = z.string().trim().min(1).max(512);
|
||||
const port = z.number().int().min(1).max(65_535);
|
||||
const optionalText = nonEmpty.optional();
|
||||
const sshHost = z.string().trim().min(1).max(255).regex(/^[A-Za-z0-9_.:\[\]-]+$/).optional();
|
||||
const sshUsername = z.string().trim().min(1).max(128).regex(/^[A-Za-z0-9._-]+$/).optional();
|
||||
const bindingSchema = z.object({
|
||||
transport: z.enum(DATABASE_TRANSPORTS),
|
||||
host: optionalText,
|
||||
port: port.optional(),
|
||||
username: optionalText,
|
||||
baseUrl: z.string().max(2048)
|
||||
.refine((value) => parseCredentialFreeHttpUrl(value) !== undefined)
|
||||
.optional(),
|
||||
restPath: z.string().regex(/^\/(?!\/)[^?#\\\u0000-\u001f]*$/).max(512).optional(),
|
||||
restAuth: z.enum(["none", "bearer", "x-api-key"]).optional(),
|
||||
tlsServername: optionalText,
|
||||
sshHost,
|
||||
sshPort: port.optional(),
|
||||
sshUsername,
|
||||
sshTargetHost: sshHost,
|
||||
sshTargetPort: port.optional(),
|
||||
}).strict().superRefine((binding, context) => {
|
||||
const required = binding.transport === "postgres_direct"
|
||||
? ["host", "port", "username"] as const
|
||||
: binding.transport === "rest_api"
|
||||
? ["baseUrl", "restPath", "restAuth"] as const
|
||||
: ["username", "sshHost", "sshPort", "sshUsername", "sshTargetHost", "sshTargetPort"] as const;
|
||||
for (const field of required) {
|
||||
if (binding[field] === undefined) context.addIssue({ code: "custom", path: [field], message: "Required" });
|
||||
}
|
||||
});
|
||||
export const databaseConfigurationSchema = z.object({
|
||||
workspaceId: workspaceIdSchema,
|
||||
engine: z.literal("postgres"),
|
||||
databaseName: identifier,
|
||||
schema: identifier,
|
||||
binding: bindingSchema,
|
||||
}).strict();
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import type { WorkspaceRegistry } from "../workspaces/registry.js";
|
||||
import type { MetadataGenerationModels, ResolvedMetadataGenerationModel } from "./metadata-generation-models.js";
|
||||
import type { ModelCompleter, ModelCompletionMessage } from "./model-completer.js";
|
||||
import type { ModelCompleter, ModelCompletionMessage, ModelCompletionResult } from "./model-completer.js";
|
||||
import {
|
||||
ModelCompletionCancelledError,
|
||||
ModelCompletionProviderError,
|
||||
@@ -38,6 +38,7 @@ const MAX_SAMPLE_FIELDS_PER_ROW = 4;
|
||||
const MAX_SAMPLE_COLUMNS = 4;
|
||||
const MAX_REPRESENTATIVE_VALUES_PER_REQUEST = 5;
|
||||
const MAX_TARGET_SAMPLE_JSON_BYTES = 8 * 1024;
|
||||
const MAX_COMPLETION_ATTEMPTS_PER_BATCH = 2;
|
||||
const generatedOutcomeSchema = z.object({
|
||||
targetId: z.uuid(),
|
||||
outcome: z.literal("generated"),
|
||||
@@ -55,11 +56,19 @@ const completionResponseSchema = z.object({
|
||||
results: z.array(outcomeSchema).min(1).max(MAX_TARGETS_PER_BATCH),
|
||||
}).strict();
|
||||
|
||||
class InvalidModelOutcomeError extends Error {}
|
||||
class InvalidModelJsonError extends Error {}
|
||||
class InvalidModelSchemaError extends Error {}
|
||||
class MissingModelTargetsError extends Error {}
|
||||
|
||||
interface DescriptionGenerationFailureTarget {
|
||||
id: string;
|
||||
reference: string;
|
||||
}
|
||||
|
||||
class DescriptionGenerationBatchError extends Error {
|
||||
constructor(
|
||||
readonly failure: unknown,
|
||||
readonly failedTargets: readonly { id: string; label: "Catalog Column" | "Catalog Table" }[],
|
||||
readonly failedTargets: readonly DescriptionGenerationFailureTarget[],
|
||||
) {
|
||||
super("description generation batch failed");
|
||||
}
|
||||
@@ -135,7 +144,7 @@ type ParsedOutcome = z.infer<typeof outcomeSchema>;
|
||||
|
||||
interface DescriptionGenerationPlan {
|
||||
columnTargets: SelectedColumnTarget[];
|
||||
tableIds: string[];
|
||||
tableTargets: Array<{ id: string; name: string }>;
|
||||
}
|
||||
|
||||
interface DescriptionGenerationCounters {
|
||||
@@ -143,6 +152,9 @@ interface DescriptionGenerationCounters {
|
||||
generated: number;
|
||||
nonGeneratable: number;
|
||||
failed: number;
|
||||
inputTokens: number;
|
||||
cacheReadTokens: number;
|
||||
outputTokens: number;
|
||||
consecutiveTechnicalFailures: number;
|
||||
}
|
||||
|
||||
@@ -152,13 +164,23 @@ function persistedCounters(counters: DescriptionGenerationCounters) {
|
||||
generated: counters.generated,
|
||||
nonGeneratable: counters.nonGeneratable,
|
||||
failed: counters.failed,
|
||||
inputTokens: counters.inputTokens,
|
||||
cacheReadTokens: counters.cacheReadTokens,
|
||||
outputTokens: counters.outputTokens,
|
||||
};
|
||||
}
|
||||
|
||||
function failureTarget(target: SelectedTarget) {
|
||||
function targetReference(target: SelectedTarget): string {
|
||||
return target.kind === "column"
|
||||
? { id: target.column.id, label: "Catalog Column" as const }
|
||||
: { id: target.table.id, label: "Catalog Table" as const };
|
||||
? `Column ${JSON.stringify(`${target.table.name}.${target.column.name}`)}`
|
||||
: `Table ${JSON.stringify(target.table.name)}`;
|
||||
}
|
||||
|
||||
function failureTarget(target: SelectedTarget): DescriptionGenerationFailureTarget {
|
||||
return {
|
||||
id: target.kind === "column" ? target.column.id : target.table.id,
|
||||
reference: targetReference(target),
|
||||
};
|
||||
}
|
||||
|
||||
const NON_GENERATABLE_DESCRIPTION: Record<DescriptionGenerationRun["language"], string> = {
|
||||
@@ -625,43 +647,54 @@ function messagesFor(
|
||||
}
|
||||
|
||||
function parseOutcomes(content: string, expectedTargetIds: readonly string[]): Map<string, ParsedOutcome> {
|
||||
const trimmed = content.trim();
|
||||
const fenced = /^```(?:json)?[ \t]*\r?\n([\s\S]*?)\r?\n```$/iu.exec(trimmed);
|
||||
let parsed: unknown;
|
||||
try {
|
||||
const trimmed = content.trim();
|
||||
const fenced = /^```(?:json)?[ \t]*\r?\n([\s\S]*?)\r?\n```$/iu.exec(trimmed);
|
||||
const outcomes = completionResponseSchema.parse(JSON.parse(fenced?.[1] ?? trimmed)).results;
|
||||
const expected = new Set(expectedTargetIds);
|
||||
if (outcomes.length !== expectedTargetIds.length || expected.size !== expectedTargetIds.length) {
|
||||
throw new InvalidModelOutcomeError();
|
||||
}
|
||||
const mapped = new Map<string, ParsedOutcome>();
|
||||
for (const outcome of outcomes) {
|
||||
if (!expected.has(outcome.targetId) || mapped.has(outcome.targetId)) {
|
||||
throw new InvalidModelOutcomeError();
|
||||
}
|
||||
mapped.set(outcome.targetId, outcome.outcome === "generated"
|
||||
? { ...outcome, description: outcome.description.trim() }
|
||||
: outcome);
|
||||
}
|
||||
if (mapped.size !== expected.size) throw new InvalidModelOutcomeError();
|
||||
return mapped;
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidModelOutcomeError) throw error;
|
||||
throw new InvalidModelOutcomeError();
|
||||
parsed = JSON.parse(fenced?.[1] ?? trimmed);
|
||||
} catch {
|
||||
throw new InvalidModelJsonError();
|
||||
}
|
||||
|
||||
const response = completionResponseSchema.safeParse(parsed);
|
||||
if (!response.success) throw new InvalidModelSchemaError();
|
||||
|
||||
const outcomes = response.data.results;
|
||||
const expected = new Set(expectedTargetIds);
|
||||
if (outcomes.length !== expectedTargetIds.length || expected.size !== expectedTargetIds.length) {
|
||||
throw new MissingModelTargetsError();
|
||||
}
|
||||
const mapped = new Map<string, ParsedOutcome>();
|
||||
for (const outcome of outcomes) {
|
||||
if (!expected.has(outcome.targetId) || mapped.has(outcome.targetId)) {
|
||||
throw new MissingModelTargetsError();
|
||||
}
|
||||
mapped.set(outcome.targetId, outcome.outcome === "generated"
|
||||
? { ...outcome, description: outcome.description.trim() }
|
||||
: outcome);
|
||||
}
|
||||
if (mapped.size !== expected.size) throw new MissingModelTargetsError();
|
||||
return mapped;
|
||||
}
|
||||
|
||||
function safeFailure(error: unknown): string {
|
||||
if (error instanceof DescriptionGenerationFailureStreakError) return error.message;
|
||||
const failure = error instanceof DescriptionGenerationBatchError ? error.failure : error;
|
||||
if (failure instanceof ModelCompletionProviderError) return "The model provider request failed.";
|
||||
if (failure instanceof InvalidModelOutcomeError) return "The model response was invalid.";
|
||||
if (failure instanceof InvalidModelJsonError) return "The model response was not valid JSON.";
|
||||
if (failure instanceof InvalidModelSchemaError) {
|
||||
return "The model response did not match the required schema.";
|
||||
}
|
||||
if (failure instanceof MissingModelTargetsError) {
|
||||
return "The model response was missing one or more requested targets.";
|
||||
}
|
||||
return "Description generation failed.";
|
||||
}
|
||||
|
||||
function batchFailureEvent(error: DescriptionGenerationBatchError): string {
|
||||
const summary = safeFailure(error);
|
||||
return error.failedTargets.length > 0
|
||||
? `${summary} Affected ${error.failedTargets[0]!.label} target${error.failedTargets.length === 1 ? "" : "s"}: ${error.failedTargets.map((target) => target.id).join(", ")}.`
|
||||
? `${summary} Affected target${error.failedTargets.length === 1 ? "" : "s"}: ${error.failedTargets.map((target) => target.reference).join(", ")}.`
|
||||
: summary;
|
||||
}
|
||||
|
||||
@@ -774,7 +807,7 @@ export class DescriptionGenerationWorker {
|
||||
scope === "selected_columns" ? "column" : "table",
|
||||
);
|
||||
}
|
||||
const total = plan.columnTargets.length + plan.tableIds.length;
|
||||
const total = plan.columnTargets.length + plan.tableTargets.length;
|
||||
if (total === 0 && (scope === "all" || scope === "missing")) {
|
||||
throw new DescriptionGenerationNoEligibleTargetsError(scope);
|
||||
}
|
||||
@@ -907,16 +940,25 @@ export class DescriptionGenerationWorker {
|
||||
generated: 0,
|
||||
nonGeneratable: 0,
|
||||
failed: 0,
|
||||
inputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
outputTokens: 0,
|
||||
consecutiveTechnicalFailures: 0,
|
||||
};
|
||||
await this.processTargets(run, database, plan.columnTargets, model, counters, signal);
|
||||
throwIfCancelled(signal);
|
||||
if (plan.tableIds.length > 0) {
|
||||
const tableTargets = await this.resolveTableTargets(run.databaseId, plan.tableIds);
|
||||
if (plan.tableTargets.length > 0) {
|
||||
const tableTargets = await this.resolveTableTargets(
|
||||
run.databaseId,
|
||||
plan.tableTargets.map((target) => target.id),
|
||||
);
|
||||
if (!tableTargets) {
|
||||
throw new DescriptionGenerationBatchError(
|
||||
new Error("selected tables changed during generation"),
|
||||
plan.tableIds.map((id) => ({ id, label: "Catalog Table" })),
|
||||
plan.tableTargets.map((target) => ({
|
||||
id: target.id,
|
||||
reference: `Table ${JSON.stringify(target.name)}`,
|
||||
})),
|
||||
);
|
||||
}
|
||||
await this.processTargets(run, database, tableTargets, model, counters, signal);
|
||||
@@ -973,20 +1015,41 @@ export class DescriptionGenerationWorker {
|
||||
);
|
||||
}
|
||||
throwIfCancelled(signal);
|
||||
let outcomes: Map<string, ParsedOutcome>;
|
||||
try {
|
||||
const content = await this.completer.complete({
|
||||
model,
|
||||
messages: messagesFor(database, batch, run.language, sourceSamples),
|
||||
signal,
|
||||
});
|
||||
throwIfCancelled(signal);
|
||||
outcomes = parseOutcomes(content, batch.map((target) => (
|
||||
target.kind === "column" ? target.column.id : target.table.id
|
||||
)));
|
||||
} catch (error) {
|
||||
if (error instanceof ModelCompletionCancelledError) throw error;
|
||||
const batchError = new DescriptionGenerationBatchError(error, batch.map(failureTarget));
|
||||
const expectedTargetIds = batch.map((target) => (
|
||||
target.kind === "column" ? target.column.id : target.table.id
|
||||
));
|
||||
const messages = messagesFor(database, batch, run.language, sourceSamples);
|
||||
let outcomes: Map<string, ParsedOutcome> | undefined;
|
||||
let terminalFailure: unknown;
|
||||
for (let attempt = 1; attempt <= MAX_COMPLETION_ATTEMPTS_PER_BATCH; attempt += 1) {
|
||||
try {
|
||||
const completion = await this.completer.complete({ model, messages, signal });
|
||||
const result: ModelCompletionResult = typeof completion === "string"
|
||||
? { content: completion, usage: { input: 0, cacheRead: 0, output: 0 } }
|
||||
: completion;
|
||||
counters.inputTokens += result.usage.input;
|
||||
counters.cacheReadTokens += result.usage.cacheRead;
|
||||
counters.outputTokens += result.usage.output;
|
||||
throwIfCancelled(signal);
|
||||
outcomes = parseOutcomes(result.content, expectedTargetIds);
|
||||
break;
|
||||
} catch (error) {
|
||||
if (error instanceof ModelCompletionCancelledError) throw error;
|
||||
terminalFailure = error;
|
||||
if (attempt < MAX_COMPLETION_ATTEMPTS_PER_BATCH) {
|
||||
await this.appendEvent(
|
||||
run.id,
|
||||
"warning",
|
||||
`${safeFailure(error)} Retrying batch (attempt ${attempt + 1} of ${MAX_COMPLETION_ATTEMPTS_PER_BATCH}).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!outcomes) {
|
||||
const batchError = new DescriptionGenerationBatchError(
|
||||
terminalFailure,
|
||||
batch.map(failureTarget),
|
||||
);
|
||||
counters.processed += batch.length;
|
||||
counters.failed += batch.length;
|
||||
counters.consecutiveTechnicalFailures += 1;
|
||||
@@ -1009,7 +1072,10 @@ export class DescriptionGenerationWorker {
|
||||
const targetId = target.kind === "column" ? target.column.id : target.table.id;
|
||||
const outcome = outcomes.get(targetId);
|
||||
if (!outcome) {
|
||||
throw new DescriptionGenerationBatchError(new InvalidModelOutcomeError(), [failureTarget(target)]);
|
||||
throw new DescriptionGenerationBatchError(
|
||||
new MissingModelTargetsError(),
|
||||
[failureTarget(target)],
|
||||
);
|
||||
}
|
||||
const generatedDescription = outcome.outcome === "generated"
|
||||
? outcome.description
|
||||
@@ -1046,8 +1112,8 @@ export class DescriptionGenerationWorker {
|
||||
run.id,
|
||||
"info",
|
||||
outcome.outcome === "generated"
|
||||
? `Generated description for ${target.kind === "column" ? "Catalog Column" : "Catalog Table"} ${targetId}.`
|
||||
: `Stored non-generatable result for ${target.kind === "column" ? "Catalog Column" : "Catalog Table"} ${targetId}.`,
|
||||
? `Generated description for ${targetReference(target)}.`
|
||||
: `Stored non-generatable result for ${targetReference(target)}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1141,16 +1207,22 @@ export class DescriptionGenerationWorker {
|
||||
}
|
||||
return {
|
||||
columnTargets,
|
||||
tableIds: tables
|
||||
tableTargets: tables
|
||||
.filter((table) => scope === "all" || !table.generatedDescription?.trim())
|
||||
.map((table) => table.id),
|
||||
.map((table) => ({ id: table.id, name: table.name })),
|
||||
};
|
||||
}
|
||||
if (scope === "selected_tables") {
|
||||
const tableById = new Map(tables.map((table) => [table.id, table]));
|
||||
const selected = targetIds.map((tableId) => tableById.get(tableId));
|
||||
if (selected.some((table) => table === undefined)) return undefined;
|
||||
return { columnTargets: [], tableIds: [...targetIds] };
|
||||
return {
|
||||
columnTargets: [],
|
||||
tableTargets: (selected as CatalogTable[]).map((table) => ({
|
||||
id: table.id,
|
||||
name: table.name,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const byId = new Map<string, SelectedColumnTarget>();
|
||||
@@ -1162,7 +1234,7 @@ export class DescriptionGenerationWorker {
|
||||
const targets = targetIds.map((columnId) => byId.get(columnId));
|
||||
return targets.some((target) => target === undefined)
|
||||
? undefined
|
||||
: { columnTargets: targets as SelectedColumnTarget[], tableIds: [] };
|
||||
: { columnTargets: targets as SelectedColumnTarget[], tableTargets: [] };
|
||||
}
|
||||
|
||||
private async resolveTableTargets(
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import type { WorkspaceSecretStore } from "../workspaces/secret-store.js";
|
||||
import type { CatalogPostgresAccess } from "./postgres-access.js";
|
||||
import type { WorkspaceDatabase } from "./types.js";
|
||||
import { CATALOG_SECRET_IDS } from "./secrets.js";
|
||||
import { CatalogConnectorError, type WorkspaceDatabase } from "./types.js";
|
||||
|
||||
const MAX_SOURCE_ROWS = 5;
|
||||
const MAX_REPRESENTATIVE_VALUES = 5;
|
||||
@@ -79,15 +82,82 @@ function distinctKey(value: Exclude<DescriptionSourceSampleValue, null>): string
|
||||
return `${typeof value}:${String(value)}`;
|
||||
}
|
||||
|
||||
/** PostgreSQL-wire sampler. REST bindings remain unsupported by CatalogPostgresAccess. */
|
||||
export class PostgresDescriptionSourceSampler implements DescriptionSourceSampler {
|
||||
constructor(private readonly access: CatalogPostgresAccess) {}
|
||||
function columnsFor(target: DescriptionSourceSamplingTarget): string[] {
|
||||
return [...new Set(target.columnNames)].slice(0, MAX_SOURCE_COLUMNS_PER_TARGET);
|
||||
}
|
||||
|
||||
function normalizedSample(
|
||||
target: DescriptionSourceSamplingTarget,
|
||||
columnNames: readonly string[],
|
||||
sourceRows: readonly Record<string, unknown>[],
|
||||
): DescriptionTargetSourceSample {
|
||||
const rows = sourceRows.slice(0, MAX_SOURCE_ROWS).map((row) => ({
|
||||
fields: columnNames.flatMap((name) => {
|
||||
const value = normalizeValue(row[name]);
|
||||
return value === undefined ? [] : [{ name, value }];
|
||||
}),
|
||||
}));
|
||||
const valuesByColumn = new Map<string, Exclude<DescriptionSourceSampleValue, null>[]>();
|
||||
const seenByColumn = new Map<string, Set<string>>();
|
||||
let representativeValueCount = 0;
|
||||
for (const row of rows) {
|
||||
for (const field of row.fields) {
|
||||
if (representativeValueCount === MAX_REPRESENTATIVE_VALUES) break;
|
||||
if (field.value === null) continue;
|
||||
const seen = seenByColumn.get(field.name) ?? new Set<string>();
|
||||
const key = distinctKey(field.value);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
seenByColumn.set(field.name, seen);
|
||||
const values = valuesByColumn.get(field.name) ?? [];
|
||||
values.push(field.value);
|
||||
valuesByColumn.set(field.name, values);
|
||||
representativeValueCount += 1;
|
||||
}
|
||||
if (representativeValueCount === MAX_REPRESENTATIVE_VALUES) break;
|
||||
}
|
||||
return {
|
||||
targetId: target.targetId,
|
||||
tableName: target.tableName,
|
||||
rows,
|
||||
representativeValues: columnNames.flatMap((column) => {
|
||||
const values = valuesByColumn.get(column);
|
||||
return values && values.length > 0 ? [{ column, values }] : [];
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function samplingSql(
|
||||
database: WorkspaceDatabase,
|
||||
target: DescriptionSourceSamplingTarget,
|
||||
columns: readonly string[],
|
||||
): string {
|
||||
const projections = columns.map((columnName) => {
|
||||
const identifier = quoteIdentifier(columnName);
|
||||
return `LEFT((${identifier})::text, ${MAX_SOURCE_VALUE_BYTES}) AS ${identifier}`;
|
||||
});
|
||||
return [
|
||||
`SELECT ${projections.join(", ")}`,
|
||||
`FROM ${quoteIdentifier(database.schema)}.${quoteIdentifier(target.tableName)}`,
|
||||
`LIMIT ${MAX_SOURCE_ROWS}`,
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
/** Bounded source sampler that follows the database's PostgreSQL-wire or REST binding. */
|
||||
export class ConcreteDescriptionSourceSampler implements DescriptionSourceSampler {
|
||||
constructor(
|
||||
private readonly access: CatalogPostgresAccess,
|
||||
private readonly secretStore?: Pick<WorkspaceSecretStore, "materialize">,
|
||||
) {}
|
||||
|
||||
async sample(
|
||||
database: WorkspaceDatabase,
|
||||
targets: readonly DescriptionSourceSamplingTarget[],
|
||||
signal: AbortSignal,
|
||||
): Promise<readonly DescriptionTargetSourceSample[]> {
|
||||
if (database.binding.transport === "rest_api") {
|
||||
return await this.sampleRest(database, targets, signal);
|
||||
}
|
||||
const client = await this.access.connect(database, signal);
|
||||
let transactionOpen = false;
|
||||
try {
|
||||
@@ -95,7 +165,7 @@ export class PostgresDescriptionSourceSampler implements DescriptionSourceSample
|
||||
transactionOpen = true;
|
||||
const samples: DescriptionTargetSourceSample[] = [];
|
||||
for (const target of targets) {
|
||||
const columnNames = [...new Set(target.columnNames)].slice(0, MAX_SOURCE_COLUMNS_PER_TARGET);
|
||||
const columnNames = columnsFor(target);
|
||||
if (columnNames.length === 0) {
|
||||
samples.push({
|
||||
targetId: target.targetId,
|
||||
@@ -115,44 +185,7 @@ export class PostgresDescriptionSourceSampler implements DescriptionSourceSample
|
||||
"LIMIT $2",
|
||||
].join(" ");
|
||||
const result = await client.query(sql, [MAX_SOURCE_VALUE_BYTES, MAX_SOURCE_ROWS]);
|
||||
const rows = result.rows.slice(0, MAX_SOURCE_ROWS).map((row) => ({
|
||||
fields: columnNames.flatMap((name) => {
|
||||
const value = normalizeValue(row[name]);
|
||||
return value === undefined ? [] : [{ name, value }];
|
||||
}),
|
||||
}));
|
||||
const valuesByColumn = new Map<
|
||||
string,
|
||||
Exclude<DescriptionSourceSampleValue, null>[]
|
||||
>();
|
||||
const seenByColumn = new Map<string, Set<string>>();
|
||||
let representativeValueCount = 0;
|
||||
for (const row of rows) {
|
||||
for (const field of row.fields) {
|
||||
if (representativeValueCount === MAX_REPRESENTATIVE_VALUES) break;
|
||||
if (field.value === null) continue;
|
||||
const seen = seenByColumn.get(field.name) ?? new Set<string>();
|
||||
const key = distinctKey(field.value);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
seenByColumn.set(field.name, seen);
|
||||
const values = valuesByColumn.get(field.name) ?? [];
|
||||
values.push(field.value);
|
||||
valuesByColumn.set(field.name, values);
|
||||
representativeValueCount += 1;
|
||||
}
|
||||
if (representativeValueCount === MAX_REPRESENTATIVE_VALUES) break;
|
||||
}
|
||||
const representativeValues = columnNames.flatMap((column) => {
|
||||
const values = valuesByColumn.get(column);
|
||||
return values && values.length > 0 ? [{ column, values }] : [];
|
||||
});
|
||||
samples.push({
|
||||
targetId: target.targetId,
|
||||
tableName: target.tableName,
|
||||
rows,
|
||||
representativeValues,
|
||||
});
|
||||
samples.push(normalizedSample(target, columnNames, result.rows));
|
||||
}
|
||||
return samples;
|
||||
} finally {
|
||||
@@ -160,4 +193,60 @@ export class PostgresDescriptionSourceSampler implements DescriptionSourceSample
|
||||
await client.end().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
private async sampleRest(
|
||||
database: WorkspaceDatabase,
|
||||
targets: readonly DescriptionSourceSamplingTarget[],
|
||||
signal: AbortSignal,
|
||||
): Promise<readonly DescriptionTargetSourceSample[]> {
|
||||
if (!this.secretStore) throw new CatalogConnectorError("REST source sampling is not configured");
|
||||
const auth = database.binding.restAuth ?? "bearer";
|
||||
const materialized = this.secretStore.materialize(
|
||||
database.workspaceId,
|
||||
auth === "none" ? [] : [CATALOG_SECRET_IDS.apiKey],
|
||||
);
|
||||
try {
|
||||
const headers: Record<string, string> = { "content-type": "application/json" };
|
||||
if (auth !== "none") {
|
||||
const credentialFile = materialized.files.get(CATALOG_SECRET_IDS.apiKey);
|
||||
if (!credentialFile) throw new CatalogConnectorError("REST API key is not configured");
|
||||
const credential = (await readFile(credentialFile, "utf8")).trim();
|
||||
if (auth === "bearer") headers.authorization = `Bearer ${credential}`;
|
||||
else headers["x-api-key"] = credential;
|
||||
}
|
||||
const baseUrl = database.binding.baseUrl?.replace(/\/+$/, "");
|
||||
if (!baseUrl) throw new CatalogConnectorError("Database binding is incomplete");
|
||||
const samples: DescriptionTargetSourceSample[] = [];
|
||||
for (const target of targets) {
|
||||
const columnNames = columnsFor(target);
|
||||
if (columnNames.length === 0) {
|
||||
samples.push(normalizedSample(target, columnNames, []));
|
||||
continue;
|
||||
}
|
||||
const response = await fetch(`${baseUrl}/rpc/run_query`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ query_text: samplingSql(database, target, columnNames) }),
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) throw new CatalogConnectorError("REST source sampling failed");
|
||||
const body: unknown = await response.json();
|
||||
if (!Array.isArray(body)
|
||||
|| body.some((row) => !row || typeof row !== "object" || Array.isArray(row))) {
|
||||
throw new CatalogConnectorError("REST source sampling response is invalid");
|
||||
}
|
||||
samples.push(normalizedSample(
|
||||
target,
|
||||
columnNames,
|
||||
body as Array<Record<string, unknown>>,
|
||||
));
|
||||
}
|
||||
return samples;
|
||||
} catch (error) {
|
||||
if (error instanceof CatalogConnectorError) throw error;
|
||||
throw new CatalogConnectorError("REST source sampling failed");
|
||||
} finally {
|
||||
materialized.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { CatalogRelationship, CatalogRepository } from "./types.js";
|
||||
|
||||
export interface EffectiveRelationshipSnapshotReader {
|
||||
list(databaseId: string): Promise<CatalogRelationship[]>;
|
||||
}
|
||||
|
||||
export interface EffectiveRelationshipSnapshotCoordinator {
|
||||
run<T>(databaseId: string, operation: () => Promise<T>): Promise<T>;
|
||||
}
|
||||
|
||||
export class EffectiveRelationshipSnapshotStaleError extends Error {}
|
||||
|
||||
interface EffectiveRelationship {
|
||||
sourceTable: string;
|
||||
sourceColumns: string[];
|
||||
targetTable: string;
|
||||
targetColumns: string[];
|
||||
origin: CatalogRelationship["origin"];
|
||||
}
|
||||
|
||||
interface EffectiveRelationshipSnapshot {
|
||||
schemaVersion: 1;
|
||||
workspaceId: string;
|
||||
relationships: EffectiveRelationship[];
|
||||
}
|
||||
|
||||
const originRank: Record<CatalogRelationship["origin"], number> = {
|
||||
physical: 0,
|
||||
manual: 1,
|
||||
generated: 2,
|
||||
};
|
||||
|
||||
function endpointKey(relationship: EffectiveRelationship): string {
|
||||
return [
|
||||
relationship.sourceTable,
|
||||
relationship.sourceColumns.join("\u0000"),
|
||||
relationship.targetTable,
|
||||
relationship.targetColumns.join("\u0000"),
|
||||
].join("\u0001");
|
||||
}
|
||||
|
||||
function compareRelationships(left: EffectiveRelationship, right: EffectiveRelationship): number {
|
||||
return endpointKey(left).localeCompare(endpointKey(right))
|
||||
|| originRank[left.origin] - originRank[right.origin];
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapter from the mutable Catalog model to the immutable relationship contract consumed by the
|
||||
* harness. The returned JSON is a deterministic projection, never an authored second store.
|
||||
*/
|
||||
export class EffectiveRelationshipSnapshotProvider {
|
||||
constructor(
|
||||
private readonly repository: Pick<CatalogRepository, "get" | "getByWorkspace">,
|
||||
private readonly relationships: EffectiveRelationshipSnapshotReader,
|
||||
private readonly operations: EffectiveRelationshipSnapshotCoordinator,
|
||||
) {}
|
||||
|
||||
async render(workspaceId: string): Promise<string | undefined> {
|
||||
const database = await this.repository.getByWorkspace(workspaceId);
|
||||
if (!database) return undefined;
|
||||
return await this.operations.run(database.id, async () => {
|
||||
const current = await this.repository.get(database.id);
|
||||
if (!current || current.schemaSyncedVersion !== current.version) {
|
||||
throw new EffectiveRelationshipSnapshotStaleError(
|
||||
"effective relationship snapshot requires a current full schema synchronization",
|
||||
);
|
||||
}
|
||||
const projected = (await this.relationships.list(database.id))
|
||||
.filter((relationship) => relationship.status === "active")
|
||||
.map((relationship): EffectiveRelationship => ({
|
||||
sourceTable: relationship.sourceTableName,
|
||||
sourceColumns: relationship.columns.map((column) => column.sourceColumnName),
|
||||
targetTable: relationship.targetTableName,
|
||||
targetColumns: relationship.columns.map((column) => column.targetColumnName),
|
||||
origin: relationship.origin,
|
||||
}))
|
||||
.sort(compareRelationships);
|
||||
|
||||
const seen = new Set<string>();
|
||||
const snapshot: EffectiveRelationshipSnapshot = {
|
||||
schemaVersion: 1,
|
||||
workspaceId,
|
||||
relationships: projected.filter((relationship) => {
|
||||
const key = endpointKey(relationship);
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
}),
|
||||
};
|
||||
return `${JSON.stringify(snapshot, null, 2)}\n`;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { lookup } from "node:dns/promises";
|
||||
import { request as httpRequest } from "node:http";
|
||||
import { request as httpsRequest, Agent } from "node:https";
|
||||
import type { LookupFunction } from "node:net";
|
||||
import ipaddr from "ipaddr.js";
|
||||
|
||||
const refused = () => new Error("Evidence network policy refused");
|
||||
function normalizedPublicAddress(value: string): string {
|
||||
const address = ipaddr.process(value);
|
||||
if (address.range() !== "unicast") throw refused();
|
||||
return address.toString();
|
||||
}
|
||||
|
||||
/** Reject the entire DNS answer set, then pin the connection to that verified set. */
|
||||
export async function resolvePublicEvidenceHost(hostname: string) {
|
||||
const values = await lookup(hostname.replace(/^\[|\]$/g, ""), { all: true });
|
||||
if (!values.length) throw refused();
|
||||
values.forEach((value) => normalizedPublicAddress(value.address));
|
||||
return values;
|
||||
}
|
||||
const publicLookup: LookupFunction = (hostname, options, callback) => {
|
||||
void resolvePublicEvidenceHost(hostname).then((values) => {
|
||||
if (options.all) callback(null, values);
|
||||
else callback(null, values[0].address, values[0].family);
|
||||
}, () => callback(refused(), "", 0));
|
||||
};
|
||||
|
||||
// Node's direct agent does not inherit HTTP proxy environment or ambient credentials.
|
||||
export const publicEvidenceAgent = () => new Agent({ lookup: publicLookup });
|
||||
|
||||
export async function probePublicEvidenceUrl(url: URL): Promise<void> {
|
||||
if (!['http:', 'https:'].includes(url.protocol)) throw refused();
|
||||
const signal = AbortSignal.timeout(5_000);
|
||||
const values = await Promise.race([
|
||||
resolvePublicEvidenceHost(url.hostname),
|
||||
new Promise<never>((_, reject) => signal.addEventListener("abort", () => reject(refused()), { once: true })),
|
||||
]);
|
||||
signal.throwIfAborted();
|
||||
const allowed = new Set(values.map((value) => normalizedPublicAddress(value.address)));
|
||||
const pinned: LookupFunction = (_hostname, options, callback) => {
|
||||
if (options.all) callback(null, values);
|
||||
else callback(null, values[0].address, values[0].family);
|
||||
};
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const request = (url.protocol === "https:" ? httpsRequest : httpRequest)(url, {
|
||||
method: "GET", lookup: pinned, signal, agent: false,
|
||||
}, (response) => {
|
||||
try {
|
||||
const peer = response.socket.remoteAddress;
|
||||
if (!peer || !allowed.has(normalizedPublicAddress(peer)) || !response.statusCode || response.statusCode < 200 || response.statusCode >= 300) throw refused();
|
||||
resolve();
|
||||
} catch { reject(refused()); } finally { response.destroy(); }
|
||||
});
|
||||
request.on("error", () => reject(refused()));
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { tmpdir } from "node:os";
|
||||
import { z } from "zod";
|
||||
import type {
|
||||
LocalNerCandidate,
|
||||
LocalNerDetector,
|
||||
LocalNerEvidence,
|
||||
} from "./sensitivity-classifier.js";
|
||||
|
||||
const MAX_LINE_BYTES = 64 * 1024;
|
||||
const candidateSchema = z.object({
|
||||
columnId: z.uuid(),
|
||||
text: z.string().min(1).max(500),
|
||||
}).strict();
|
||||
const workerMessageSchema = z.union([
|
||||
z.object({ ready: z.literal(true) }).strict(),
|
||||
z.object({
|
||||
id: z.uuid(),
|
||||
ok: z.literal(true),
|
||||
evidence: z.array(z.object({
|
||||
columnId: z.uuid(),
|
||||
label: z.string().min(1).max(80),
|
||||
confidence: z.number().min(0).max(1),
|
||||
}).strict()).max(1_000),
|
||||
}).strict(),
|
||||
z.object({ id: z.uuid(), ok: z.literal(false), error: z.string().min(1).max(80) }).strict(),
|
||||
]);
|
||||
|
||||
export class LocalNerUnavailableError extends Error {
|
||||
constructor() {
|
||||
super("local NER is unavailable");
|
||||
this.name = "LocalNerUnavailableError";
|
||||
}
|
||||
}
|
||||
|
||||
interface PendingRequest {
|
||||
resolve: (value: readonly LocalNerEvidence[]) => void;
|
||||
reject: (error: Error) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
signal: AbortSignal;
|
||||
cancel: () => void;
|
||||
}
|
||||
|
||||
/** Persistent JSONL adapter for the optional, CPU-only Python NER worker. */
|
||||
export class PythonLocalNerDetector implements LocalNerDetector {
|
||||
private child?: ChildProcessWithoutNullStreams;
|
||||
private ready?: Promise<void>;
|
||||
private readyResolve?: () => void;
|
||||
private readyReject?: (error: Error) => void;
|
||||
private workerReady = false;
|
||||
private stdout = "";
|
||||
private readonly pending = new Map<string, PendingRequest>();
|
||||
|
||||
constructor(private readonly options: {
|
||||
pythonExecutable: string;
|
||||
workerScript: string;
|
||||
modelPath: string;
|
||||
cwd: string;
|
||||
threads?: number;
|
||||
startupTimeoutMs?: number;
|
||||
}) {}
|
||||
|
||||
async warmup(): Promise<void> {
|
||||
await this.ensureStarted();
|
||||
}
|
||||
|
||||
isReady(): boolean {
|
||||
return this.workerReady
|
||||
&& this.child !== undefined
|
||||
&& this.child.exitCode === null
|
||||
&& this.child.signalCode === null;
|
||||
}
|
||||
|
||||
async detect(
|
||||
candidates: readonly LocalNerCandidate[],
|
||||
signal: AbortSignal,
|
||||
deadline: number,
|
||||
): Promise<readonly LocalNerEvidence[]> {
|
||||
const parsed = z.array(candidateSchema).min(1).max(128).parse(candidates);
|
||||
if (signal.aborted || deadline <= Date.now()) throw new LocalNerUnavailableError();
|
||||
await this.ensureStartedWithin(signal, deadline);
|
||||
if (!this.child || this.child.exitCode !== null || this.child.signalCode !== null) {
|
||||
throw new LocalNerUnavailableError();
|
||||
}
|
||||
const id = randomUUID();
|
||||
return await new Promise<readonly LocalNerEvidence[]>((resolve, reject) => {
|
||||
const fail = () => {
|
||||
this.finishPending(id);
|
||||
reject(new LocalNerUnavailableError());
|
||||
this.stopWorker();
|
||||
};
|
||||
const timer = setTimeout(fail, Math.max(1, Math.floor(deadline - Date.now())));
|
||||
const cancel = fail;
|
||||
const pending: PendingRequest = { resolve, reject, timer, signal, cancel };
|
||||
this.pending.set(id, pending);
|
||||
signal.addEventListener("abort", cancel, { once: true });
|
||||
this.child!.stdin.write(`${JSON.stringify({ id, candidates: parsed })}\n`, (error) => {
|
||||
if (error) fail();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
const child = this.child;
|
||||
if (!child || child.exitCode !== null || child.signalCode !== null) return;
|
||||
await new Promise<void>((resolve) => {
|
||||
child.once("close", () => resolve());
|
||||
child.kill("SIGTERM");
|
||||
setTimeout(() => {
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
||||
}, 250).unref();
|
||||
});
|
||||
}
|
||||
|
||||
private async ensureStarted(): Promise<void> {
|
||||
if (this.ready) return await this.ready;
|
||||
this.ready = new Promise<void>((resolve, reject) => {
|
||||
this.readyResolve = resolve;
|
||||
this.readyReject = reject;
|
||||
});
|
||||
const threads = String(this.options.threads ?? 2);
|
||||
const inheritedRuntimeEnvironment = Object.fromEntries([
|
||||
"PATH", "SystemRoot", "WINDIR", "PATHEXT", "TMPDIR", "TEMP", "TMP", "LANG", "LC_ALL",
|
||||
].flatMap((name) => process.env[name] === undefined ? [] : [[name, process.env[name]!]]));
|
||||
const child = spawn(this.options.pythonExecutable, [
|
||||
"-I",
|
||||
"-B",
|
||||
this.options.workerScript,
|
||||
"--model",
|
||||
this.options.modelPath,
|
||||
"--threads",
|
||||
threads,
|
||||
], {
|
||||
cwd: this.options.cwd,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
env: {
|
||||
...inheritedRuntimeEnvironment,
|
||||
HOME: process.env.HOME ?? tmpdir(),
|
||||
CUDA_VISIBLE_DEVICES: "",
|
||||
HIP_VISIBLE_DEVICES: "",
|
||||
HF_HUB_OFFLINE: "1",
|
||||
HF_HUB_DISABLE_TELEMETRY: "1",
|
||||
TRANSFORMERS_OFFLINE: "1",
|
||||
TOKENIZERS_PARALLELISM: "false",
|
||||
PYTHONNOUSERSITE: "1",
|
||||
OMP_NUM_THREADS: threads,
|
||||
MKL_NUM_THREADS: threads,
|
||||
OPENBLAS_NUM_THREADS: threads,
|
||||
HTTP_PROXY: "",
|
||||
HTTPS_PROXY: "",
|
||||
ALL_PROXY: "",
|
||||
NO_PROXY: "*",
|
||||
},
|
||||
});
|
||||
this.child = child;
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk: string) => this.receive(chunk));
|
||||
child.stderr.resume();
|
||||
child.once("error", () => this.failWorker());
|
||||
child.once("close", () => this.failWorker());
|
||||
const startupTimer = setTimeout(() => this.failWorker(), this.options.startupTimeoutMs ?? 120_000);
|
||||
startupTimer.unref();
|
||||
try {
|
||||
await this.ready;
|
||||
} finally {
|
||||
clearTimeout(startupTimer);
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureStartedWithin(signal: AbortSignal, deadline: number): Promise<void> {
|
||||
const started = this.ensureStarted();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const finish = (error?: Error, stopWorker = false) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
signal.removeEventListener("abort", cancel);
|
||||
if (stopWorker) this.failWorker();
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
};
|
||||
const cancel = () => finish(new LocalNerUnavailableError(), true);
|
||||
const timer = setTimeout(cancel, Math.max(1, Math.floor(deadline - Date.now())));
|
||||
signal.addEventListener("abort", cancel, { once: true });
|
||||
void started.then(
|
||||
() => finish(),
|
||||
() => finish(new LocalNerUnavailableError()),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private receive(chunk: string): void {
|
||||
this.stdout += chunk;
|
||||
if (Buffer.byteLength(this.stdout, "utf8") > MAX_LINE_BYTES) {
|
||||
this.failWorker();
|
||||
return;
|
||||
}
|
||||
let newline: number;
|
||||
while ((newline = this.stdout.indexOf("\n")) >= 0) {
|
||||
const line = this.stdout.slice(0, newline);
|
||||
this.stdout = this.stdout.slice(newline + 1);
|
||||
if (!line) continue;
|
||||
try {
|
||||
const message = workerMessageSchema.parse(JSON.parse(line));
|
||||
if ("ready" in message) {
|
||||
this.workerReady = true;
|
||||
this.readyResolve?.();
|
||||
this.readyResolve = undefined;
|
||||
this.readyReject = undefined;
|
||||
continue;
|
||||
}
|
||||
const pending = this.pending.get(message.id);
|
||||
if (!pending) continue;
|
||||
this.finishPending(message.id);
|
||||
if (message.ok) pending.resolve(message.evidence);
|
||||
else pending.reject(new LocalNerUnavailableError());
|
||||
} catch {
|
||||
this.failWorker();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private finishPending(id: string): void {
|
||||
const pending = this.pending.get(id);
|
||||
if (!pending) return;
|
||||
clearTimeout(pending.timer);
|
||||
pending.signal.removeEventListener("abort", pending.cancel);
|
||||
this.pending.delete(id);
|
||||
}
|
||||
|
||||
private stopWorker(): void {
|
||||
const child = this.child;
|
||||
if (child && child.exitCode === null && child.signalCode === null) child.kill("SIGTERM");
|
||||
}
|
||||
|
||||
private failWorker(): void {
|
||||
const error = new LocalNerUnavailableError();
|
||||
this.readyReject?.(error);
|
||||
this.readyResolve = undefined;
|
||||
this.readyReject = undefined;
|
||||
for (const [id, pending] of this.pending) {
|
||||
this.finishPending(id);
|
||||
pending.reject(error);
|
||||
}
|
||||
this.stopWorker();
|
||||
this.child = undefined;
|
||||
this.ready = undefined;
|
||||
this.workerReady = false;
|
||||
this.stdout = "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import type {
|
||||
CatalogLogicalRelationship,
|
||||
CatalogLogicalRelationshipCandidate,
|
||||
CatalogLogicalRelationshipContext,
|
||||
CatalogLogicalRelationshipEndpoint,
|
||||
CatalogRelationship,
|
||||
CatalogRepository,
|
||||
} from "./types.js";
|
||||
|
||||
export class LogicalRelationshipDatabaseNotFoundError extends Error {}
|
||||
export class LogicalRelationshipDuplicateError extends Error {}
|
||||
export class LogicalRelationshipNotFoundError extends Error {}
|
||||
export class LogicalRelationshipReadOnlyError extends Error {}
|
||||
export class LogicalRelationshipSchemaStaleError extends Error {}
|
||||
export class LogicalRelationshipTargetNotUniqueError extends Error {}
|
||||
export class LogicalRelationshipTypeIncompatibleError extends Error {}
|
||||
export class LogicalRelationshipColumnNotFoundError extends Error {
|
||||
constructor(readonly field: "sourceColumnId" | "targetColumnId") {
|
||||
super(`Catalog column '${field}' was not found`);
|
||||
}
|
||||
}
|
||||
|
||||
export interface RebuildGeneratedRelationshipsResult {
|
||||
added: number;
|
||||
alreadyPresent: number;
|
||||
excluded: number;
|
||||
ambiguous: number;
|
||||
}
|
||||
|
||||
function identifierTokens(value: string): string[] {
|
||||
return value
|
||||
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
||||
.toLowerCase()
|
||||
.split(/[^a-z0-9]+/)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function singularWord(value: string): string {
|
||||
if (value.length > 4 && value.endsWith("ies")) return `${value.slice(0, -3)}y`;
|
||||
if (value.length > 4 && /(ches|shes|xes|zes|ses)$/.test(value)) return value.slice(0, -2);
|
||||
if (value.length > 3 && value.endsWith("s") && !/(ss|us)$/.test(value)) return value.slice(0, -1);
|
||||
return value;
|
||||
}
|
||||
|
||||
function tableAliases(tableName: string): string[] {
|
||||
const tokens = identifierTokens(tableName);
|
||||
if (tokens.length === 0) return [];
|
||||
const normalized = tokens.join("_");
|
||||
const singular = [...tokens];
|
||||
singular[singular.length - 1] = singularWord(singular[singular.length - 1]);
|
||||
return [...new Set([normalized, singular.join("_")])];
|
||||
}
|
||||
|
||||
const GENERIC_PRIMARY_KEY_NAMES = new Set(["id", "key", "code", "pk"]);
|
||||
|
||||
function nameMatches(
|
||||
source: CatalogLogicalRelationshipEndpoint,
|
||||
target: CatalogLogicalRelationshipEndpoint,
|
||||
): boolean {
|
||||
const sourceName = identifierTokens(source.columnName).join("_");
|
||||
const targetName = identifierTokens(target.columnName).join("_");
|
||||
if (!sourceName || !targetName) return false;
|
||||
const expected = new Set<string>();
|
||||
if (!GENERIC_PRIMARY_KEY_NAMES.has(targetName)) expected.add(targetName);
|
||||
for (const alias of tableAliases(target.tableName)) {
|
||||
expected.add(`${alias}_${targetName}`);
|
||||
expected.add(`${alias.replaceAll("_", "")}${targetName.replaceAll("_", "")}`);
|
||||
if (targetName === "id" || targetName === "pk") expected.add(alias);
|
||||
}
|
||||
return expected.has(sourceName);
|
||||
}
|
||||
|
||||
function canonicalDataType(value: string): string {
|
||||
const normalized = value.trim().toLowerCase().replace(/\s+/g, " ");
|
||||
const arraySuffix = normalized.endsWith("[]") ? "[]" : "";
|
||||
const base = arraySuffix ? normalized.slice(0, -2) : normalized;
|
||||
const withoutModifier = base.replace(/\([^)]*\)/g, "").trim();
|
||||
const aliases: Record<string, string> = {
|
||||
int2: "smallint",
|
||||
smallserial: "smallint",
|
||||
int4: "integer",
|
||||
int: "integer",
|
||||
serial: "integer",
|
||||
int8: "bigint",
|
||||
bigserial: "bigint",
|
||||
decimal: "numeric",
|
||||
varchar: "text",
|
||||
"character varying": "text",
|
||||
bool: "boolean",
|
||||
"timestamp without time zone": "timestamp",
|
||||
"timestamp with time zone": "timestamptz",
|
||||
"time without time zone": "time",
|
||||
"time with time zone": "timetz",
|
||||
};
|
||||
return `${aliases[withoutModifier] ?? withoutModifier}${arraySuffix}`;
|
||||
}
|
||||
|
||||
function typesCompatible(left: string, right: string): boolean {
|
||||
return canonicalDataType(left) === canonicalDataType(right);
|
||||
}
|
||||
|
||||
function pairKey(sourceColumnId: string, targetColumnId: string): string {
|
||||
return `${sourceColumnId}\u0000${targetColumnId}`;
|
||||
}
|
||||
|
||||
function relationshipPair(relationship: CatalogLogicalRelationship): CatalogLogicalRelationshipCandidate {
|
||||
return {
|
||||
sourceColumnId: relationship.columns[0].sourceColumnId,
|
||||
targetColumnId: relationship.columns[0].targetColumnId,
|
||||
};
|
||||
}
|
||||
|
||||
function relationshipSortKey(relationship: CatalogRelationship): string {
|
||||
const sourceColumns = relationship.columns.map((column) => column.sourceColumnName).join(",");
|
||||
const targetColumns = relationship.columns.map((column) => column.targetColumnName).join(",");
|
||||
return [
|
||||
relationship.sourceTableName,
|
||||
sourceColumns,
|
||||
relationship.targetTableName,
|
||||
targetColumns,
|
||||
relationship.origin,
|
||||
].join("\u0000");
|
||||
}
|
||||
|
||||
export class CatalogLogicalRelationshipService {
|
||||
constructor(private readonly repository: CatalogRepository) {}
|
||||
|
||||
async list(databaseId: string): Promise<CatalogRelationship[]> {
|
||||
if (!(await this.repository.get(databaseId))) throw new LogicalRelationshipDatabaseNotFoundError();
|
||||
const relationships: CatalogRelationship[] = [
|
||||
...await this.repository.listRelationships(databaseId),
|
||||
...await this.repository.listLogicalRelationships(databaseId),
|
||||
];
|
||||
return relationships.sort((left, right) => relationshipSortKey(left).localeCompare(relationshipSortKey(right)));
|
||||
}
|
||||
|
||||
async addManual(
|
||||
databaseId: string,
|
||||
sourceColumnId: string,
|
||||
targetColumnId: string,
|
||||
): Promise<CatalogLogicalRelationship> {
|
||||
const context = await this.requiredContext(databaseId);
|
||||
const source = context.endpoints.find((endpoint) => endpoint.columnId === sourceColumnId);
|
||||
if (!source) throw new LogicalRelationshipColumnNotFoundError("sourceColumnId");
|
||||
const target = context.endpoints.find((endpoint) => endpoint.columnId === targetColumnId);
|
||||
if (!target) throw new LogicalRelationshipColumnNotFoundError("targetColumnId");
|
||||
if (sourceColumnId === targetColumnId
|
||||
|| target.primaryKeyPosition === null
|
||||
|| target.tablePrimaryKeyColumnCount !== 1) {
|
||||
throw new LogicalRelationshipTargetNotUniqueError();
|
||||
}
|
||||
if (!typesCompatible(source.dataType, target.dataType)) {
|
||||
throw new LogicalRelationshipTypeIncompatibleError();
|
||||
}
|
||||
const key = pairKey(sourceColumnId, targetColumnId);
|
||||
if (context.physicalPairs.some((pair) => pairKey(pair.sourceColumnId, pair.targetColumnId) === key)
|
||||
|| context.logicalRelationships.some((relationship) => {
|
||||
const pair = relationshipPair(relationship);
|
||||
return pairKey(pair.sourceColumnId, pair.targetColumnId) === key;
|
||||
})) throw new LogicalRelationshipDuplicateError();
|
||||
const created = await this.repository.insertLogicalRelationship(
|
||||
databaseId,
|
||||
sourceColumnId,
|
||||
targetColumnId,
|
||||
false,
|
||||
);
|
||||
if (!created) throw new LogicalRelationshipDuplicateError();
|
||||
return created;
|
||||
}
|
||||
|
||||
async rebuildGenerated(databaseId: string): Promise<RebuildGeneratedRelationshipsResult> {
|
||||
const context = await this.requiredContext(databaseId);
|
||||
const targets = context.endpoints.filter((endpoint) => (
|
||||
endpoint.primaryKeyPosition !== null && endpoint.tablePrimaryKeyColumnCount === 1
|
||||
));
|
||||
const physical = new Set(context.physicalPairs.map((pair) => pairKey(pair.sourceColumnId, pair.targetColumnId)));
|
||||
const active = new Set<string>();
|
||||
const excluded = new Set<string>();
|
||||
for (const relationship of context.logicalRelationships) {
|
||||
const pair = relationshipPair(relationship);
|
||||
(relationship.status === "excluded" ? excluded : active)
|
||||
.add(pairKey(pair.sourceColumnId, pair.targetColumnId));
|
||||
}
|
||||
|
||||
const pending: CatalogLogicalRelationshipCandidate[] = [];
|
||||
let alreadyPresent = 0;
|
||||
let excludedCount = 0;
|
||||
let ambiguous = 0;
|
||||
const dimTimeTargets = targets.filter((target) => (
|
||||
identifierTokens(target.tableName).join("_") === "dim_time"
|
||||
));
|
||||
const sources = context.endpoints.filter((endpoint) => (
|
||||
endpoint.primaryKeyPosition === null || endpoint.tablePrimaryKeyColumnCount > 1
|
||||
));
|
||||
for (const source of sources) {
|
||||
const sourceName = identifierTokens(source.columnName).join("_");
|
||||
const isTimeKey = sourceName.endsWith("time_key")
|
||||
&& identifierTokens(source.tableName).join("_") !== "dim_time";
|
||||
const candidates = isTimeKey ? dimTimeTargets : targets;
|
||||
const matches = candidates.filter((target) => (
|
||||
target.columnId !== source.columnId
|
||||
&& typesCompatible(source.dataType, target.dataType)
|
||||
&& (isTimeKey || nameMatches(source, target))
|
||||
));
|
||||
if (matches.length > 1) {
|
||||
ambiguous += 1;
|
||||
continue;
|
||||
}
|
||||
if (matches.length === 0) continue;
|
||||
const candidate = { sourceColumnId: source.columnId, targetColumnId: matches[0].columnId };
|
||||
const key = pairKey(candidate.sourceColumnId, candidate.targetColumnId);
|
||||
if (physical.has(key) || active.has(key)) {
|
||||
alreadyPresent += 1;
|
||||
} else if (excluded.has(key)) {
|
||||
excludedCount += 1;
|
||||
} else {
|
||||
pending.push(candidate);
|
||||
}
|
||||
}
|
||||
const added = await this.repository.insertGeneratedLogicalRelationships(databaseId, pending);
|
||||
alreadyPresent += pending.length - added;
|
||||
return { added, alreadyPresent, excluded: excludedCount, ambiguous };
|
||||
}
|
||||
|
||||
async setStatus(
|
||||
databaseId: string,
|
||||
relationshipId: string,
|
||||
status: CatalogLogicalRelationship["status"],
|
||||
): Promise<CatalogLogicalRelationship> {
|
||||
if (!(await this.repository.get(databaseId))) throw new LogicalRelationshipDatabaseNotFoundError();
|
||||
const updated = await this.repository.setLogicalRelationshipStatus(databaseId, relationshipId, status);
|
||||
if (updated) return updated;
|
||||
await this.assertNotPhysical(databaseId, relationshipId);
|
||||
throw new LogicalRelationshipNotFoundError();
|
||||
}
|
||||
|
||||
async deletePermanently(databaseId: string, relationshipId: string): Promise<void> {
|
||||
if (!(await this.repository.get(databaseId))) throw new LogicalRelationshipDatabaseNotFoundError();
|
||||
if (await this.repository.deleteLogicalRelationship(databaseId, relationshipId)) return;
|
||||
await this.assertNotPhysical(databaseId, relationshipId);
|
||||
throw new LogicalRelationshipNotFoundError();
|
||||
}
|
||||
|
||||
private async requiredContext(databaseId: string): Promise<CatalogLogicalRelationshipContext> {
|
||||
const database = await this.repository.get(databaseId);
|
||||
if (!database) throw new LogicalRelationshipDatabaseNotFoundError();
|
||||
if (database.schemaSyncedVersion !== database.version) {
|
||||
throw new LogicalRelationshipSchemaStaleError();
|
||||
}
|
||||
const context = await this.repository.getLogicalRelationshipContext(databaseId);
|
||||
if (!context) throw new LogicalRelationshipDatabaseNotFoundError();
|
||||
return context;
|
||||
}
|
||||
|
||||
private async assertNotPhysical(databaseId: string, relationshipId: string): Promise<void> {
|
||||
if ((await this.repository.listRelationships(databaseId)).some((relationship) => relationship.id === relationshipId)) {
|
||||
throw new LogicalRelationshipReadOnlyError();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { ThtRunner } from "../tht/tht-runner.js";
|
||||
import type { SemanticRuntimeConfig } from "../workspaces/runtime-renderer.js";
|
||||
import type { CatalogSyncRun, WorkspaceDatabase } from "./types.js";
|
||||
|
||||
/** Internal continuation of an applied physical sync, using the harness Memory boundary. */
|
||||
export function createMemoryCleanup(runner: Pick<ThtRunner, "withPrincipal">, runtime: SemanticRuntimeConfig) {
|
||||
return async (database: WorkspaceDatabase, run: CatalogSyncRun): Promise<number> => {
|
||||
if (run.phase !== "memory_cleanup" || !run.plannedDiff) throw new Error("Physical cleanup is not committed");
|
||||
const result = await runner.withPrincipal({ issuer: "installation", subject: "catalog-sync",
|
||||
roles: ["admin"], permissions: ["memory.manage"], isAdmin: true,
|
||||
}).runWithRuntimeSnapshot(["memory", "admin", "--workspace", database.workspaceId], JSON.stringify({
|
||||
action: "cleanup", runtime, request: { sync_id: run.id, database: database.databaseName,
|
||||
schema_name: database.schema, removed_tables: run.plannedDiff.deletedTables,
|
||||
removed_columns: run.plannedDiff.deletedColumns.map(column => ({ table: column.tableName, column: column.columnName })),
|
||||
},
|
||||
}));
|
||||
const payload = JSON.parse(result.stdout);
|
||||
if (result.code !== 0 || payload.indexed !== true || !Number.isInteger(payload.deleted) || payload.deleted < 0) {
|
||||
throw new Error("Memory cleanup is incomplete");
|
||||
}
|
||||
return payload.deleted;
|
||||
};
|
||||
}
|
||||
@@ -14,7 +14,12 @@ import {
|
||||
type CatalogDatabaseMetadataDeleteTarget,
|
||||
type CatalogMetadataDeleteCounts,
|
||||
type CatalogMetrics,
|
||||
type CatalogRelationship,
|
||||
type CatalogLogicalRelationship,
|
||||
type CatalogLogicalRelationshipCandidate,
|
||||
type CatalogLogicalRelationshipContext,
|
||||
type CatalogPhysicalRelationship,
|
||||
type CatalogPreprocessingStartResult,
|
||||
type CatalogPreprocessingClearResult,
|
||||
type CatalogSchemaDiff,
|
||||
type CatalogSyncCounts,
|
||||
type CatalogSyncEvent,
|
||||
@@ -32,10 +37,10 @@ import {
|
||||
type DescriptionGenerationRun,
|
||||
type DescriptionGenerationRunUpdate,
|
||||
type DescriptionGenerationScope,
|
||||
type SensitiveDataSuggestionEvent,
|
||||
type SensitiveDataSuggestionRun,
|
||||
type SensitiveDataSuggestionRunUpdate,
|
||||
type SensitiveDataSuggestionScope,
|
||||
type SensitivityAnalysisEvent,
|
||||
type SensitivityAnalysisRun,
|
||||
type SensitivityAnalysisRunUpdate,
|
||||
type SensitivityAnalysisScope,
|
||||
type TableSyncRepositoryResult,
|
||||
type WorkspaceDatabase,
|
||||
} from "./types.js";
|
||||
@@ -49,11 +54,12 @@ export class MemoryCatalogRepository implements CatalogRepository {
|
||||
private readonly records = new Map<string, WorkspaceDatabase>();
|
||||
private readonly tables = new Map<string, CatalogTable>();
|
||||
private readonly columns = new Map<string, CatalogColumn>();
|
||||
private readonly relationships = new Map<string, CatalogRelationship>();
|
||||
private readonly relationships = new Map<string, CatalogPhysicalRelationship>();
|
||||
private readonly logicalRelationships = new Map<string, CatalogLogicalRelationship>();
|
||||
private readonly descriptionGenerationRuns = new Map<string, DescriptionGenerationRun>();
|
||||
private readonly descriptionGenerationEvents = new Map<string, DescriptionGenerationEvent[]>();
|
||||
private readonly sensitiveDataSuggestionRuns = new Map<string, SensitiveDataSuggestionRun>();
|
||||
private readonly sensitiveDataSuggestionEvents = new Map<string, SensitiveDataSuggestionEvent[]>();
|
||||
private readonly sensitivityAnalysisRuns = new Map<string, SensitivityAnalysisRun>();
|
||||
private readonly sensitivityAnalysisEvents = new Map<string, SensitivityAnalysisEvent[]>();
|
||||
private readonly syncRuns = new Map<string, CatalogSyncRun>();
|
||||
private readonly syncEvents = new Map<string, CatalogSyncEvent[]>();
|
||||
|
||||
@@ -68,6 +74,77 @@ export class MemoryCatalogRepository implements CatalogRepository {
|
||||
const value = [...this.records.values()].find((record) => record.workspaceId === workspaceId);
|
||||
return value ? clone(value) : undefined;
|
||||
}
|
||||
async beginPreprocessing(
|
||||
workspaceId: string,
|
||||
inputFingerprint: string,
|
||||
): Promise<CatalogPreprocessingStartResult> {
|
||||
const database = [...this.records.values()].find((record) => record.workspaceId === workspaceId);
|
||||
if (!database) return { kind: "not_found" };
|
||||
if (database.preprocessingStatus === "running") return { kind: "already_running" };
|
||||
if (database.schemaSyncedVersion !== database.version) return { kind: "schema_stale" };
|
||||
const catalogBusy = [...this.syncRuns.values()].some((run) =>
|
||||
run.databaseId === database.id
|
||||
&& ["queued", "running", "awaiting_confirmation", "applying"].includes(run.state))
|
||||
|| [...this.descriptionGenerationRuns.values()].some((run) =>
|
||||
run.databaseId === database.id && ["queued", "running"].includes(run.status))
|
||||
|| [...this.sensitivityAnalysisRuns.values()].some((run) =>
|
||||
run.databaseId === database.id && ["queued", "running"].includes(run.status));
|
||||
if (catalogBusy) return { kind: "catalog_busy" };
|
||||
const now = new Date().toISOString();
|
||||
const updated: WorkspaceDatabase = {
|
||||
...database,
|
||||
preprocessingStatus: "running",
|
||||
preprocessingInputFingerprint: inputFingerprint,
|
||||
preprocessedMetadataRevision: undefined,
|
||||
preprocessingStartedAt: now,
|
||||
preprocessingFinishedAt: undefined,
|
||||
preprocessingErrorCode: undefined,
|
||||
updatedAt: now,
|
||||
};
|
||||
this.records.set(database.id, updated);
|
||||
return { kind: "started", database: clone(updated) };
|
||||
}
|
||||
async finishPreprocessing(
|
||||
workspaceId: string,
|
||||
metadataContentRevision: number,
|
||||
inputFingerprint: string,
|
||||
outcome: { status: "succeeded" } | { status: "failed"; errorCode: string },
|
||||
): Promise<WorkspaceDatabase | undefined> {
|
||||
const database = [...this.records.values()].find((record) => record.workspaceId === workspaceId);
|
||||
if (!database
|
||||
|| database.preprocessingStatus !== "running"
|
||||
|| database.metadataContentRevision !== metadataContentRevision
|
||||
|| database.preprocessingInputFingerprint !== inputFingerprint) return undefined;
|
||||
const updated: WorkspaceDatabase = {
|
||||
...database,
|
||||
preprocessingStatus: outcome.status,
|
||||
preprocessedMetadataRevision: outcome.status === "succeeded"
|
||||
? metadataContentRevision
|
||||
: undefined,
|
||||
preprocessingFinishedAt: new Date().toISOString(),
|
||||
preprocessingErrorCode: outcome.status === "failed" ? outcome.errorCode : undefined,
|
||||
};
|
||||
this.records.set(database.id, updated);
|
||||
return clone(updated);
|
||||
}
|
||||
async clearPreprocessing(workspaceId: string): Promise<CatalogPreprocessingClearResult> {
|
||||
const database = [...this.records.values()].find((record) => record.workspaceId === workspaceId);
|
||||
if (!database) return { kind: "not_found" };
|
||||
if (database.preprocessingStatus === "running") return { kind: "already_running" };
|
||||
const now = new Date().toISOString();
|
||||
const updated: WorkspaceDatabase = {
|
||||
...database,
|
||||
preprocessingStatus: "failed",
|
||||
preprocessingInputFingerprint: undefined,
|
||||
preprocessedMetadataRevision: undefined,
|
||||
preprocessingStartedAt: undefined,
|
||||
preprocessingFinishedAt: now,
|
||||
preprocessingErrorCode: "derived_data_cleared",
|
||||
updatedAt: now,
|
||||
};
|
||||
this.records.set(database.id, updated);
|
||||
return { kind: "cleared", database: clone(updated) };
|
||||
}
|
||||
async getCatalogMetrics(databaseId?: string): Promise<CatalogMetrics | undefined> {
|
||||
if (databaseId !== undefined && !this.records.has(databaseId)) return undefined;
|
||||
|
||||
@@ -79,8 +156,10 @@ export class MemoryCatalogRepository implements CatalogRepository {
|
||||
const tableIds = new Set(tables.map((table) => table.id));
|
||||
const columns = [...this.columns.values()]
|
||||
.filter((column) => tableIds.has(column.tableId));
|
||||
const relationships = [...this.relationships.values()]
|
||||
.filter((relationship) => selectedDatabaseIds.has(relationship.databaseId));
|
||||
const relationships = [
|
||||
...this.relationships.values(),
|
||||
...this.logicalRelationships.values(),
|
||||
].filter((relationship) => selectedDatabaseIds.has(relationship.databaseId));
|
||||
|
||||
return createCatalogMetrics(databaseId, {
|
||||
tables: tables.length,
|
||||
@@ -108,6 +187,8 @@ export class MemoryCatalogRepository implements CatalogRepository {
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
connectionStatus: "untested",
|
||||
metadataContentRevision: 0,
|
||||
preprocessingStatus: "failed",
|
||||
};
|
||||
this.records.set(record.id, record);
|
||||
return clone(record);
|
||||
@@ -171,15 +252,18 @@ export class MemoryCatalogRepository implements CatalogRepository {
|
||||
for (const [relationshipId, relationship] of this.relationships) {
|
||||
if (relationship.databaseId === id) this.relationships.delete(relationshipId);
|
||||
}
|
||||
for (const [relationshipId, relationship] of this.logicalRelationships) {
|
||||
if (relationship.databaseId === id) this.logicalRelationships.delete(relationshipId);
|
||||
}
|
||||
for (const [runId, run] of this.descriptionGenerationRuns) {
|
||||
if (run.databaseId !== id) continue;
|
||||
this.descriptionGenerationRuns.delete(runId);
|
||||
this.descriptionGenerationEvents.delete(runId);
|
||||
}
|
||||
for (const [runId, run] of this.sensitiveDataSuggestionRuns) {
|
||||
for (const [runId, run] of this.sensitivityAnalysisRuns) {
|
||||
if (run.databaseId !== id) continue;
|
||||
this.sensitiveDataSuggestionRuns.delete(runId);
|
||||
this.sensitiveDataSuggestionEvents.delete(runId);
|
||||
this.sensitivityAnalysisRuns.delete(runId);
|
||||
this.sensitivityAnalysisEvents.delete(runId);
|
||||
}
|
||||
return this.records.delete(id);
|
||||
}
|
||||
@@ -248,6 +332,7 @@ export class MemoryCatalogRepository implements CatalogRepository {
|
||||
description: string | null,
|
||||
generatedDescription: string | null,
|
||||
sensitive?: boolean,
|
||||
sensitivityReason?: string | null,
|
||||
): Promise<CatalogColumn | undefined> {
|
||||
const current = await this.getColumn(databaseId, tableId, columnId);
|
||||
if (!current || current.version !== expectedVersion) return undefined;
|
||||
@@ -256,6 +341,9 @@ export class MemoryCatalogRepository implements CatalogRepository {
|
||||
description,
|
||||
generatedDescription,
|
||||
sensitive: sensitive ?? current.sensitive,
|
||||
sensitivityReason: sensitive === false
|
||||
? null
|
||||
: sensitivityReason === undefined ? current.sensitivityReason : sensitivityReason,
|
||||
version: current.version + 1,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
@@ -269,10 +357,39 @@ export class MemoryCatalogRepository implements CatalogRepository {
|
||||
targetIds: readonly string[],
|
||||
): Promise<CatalogDescriptionConsolidationCounts | undefined> {
|
||||
const selectedTargetIds = [...new Set(targetIds)];
|
||||
if (!this.records.has(databaseId) || selectedTargetIds.length === 0) {
|
||||
if (!this.records.has(databaseId)) {
|
||||
return undefined;
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
if (target === "database" || target === "database_columns") {
|
||||
const tableTargets = target === "database"
|
||||
? [...this.tables.values()].filter((table) => table.databaseId === databaseId)
|
||||
: [];
|
||||
const columnTargets = [...this.columns.values()].filter((column) => (
|
||||
this.tables.get(column.tableId)?.databaseId === databaseId
|
||||
));
|
||||
const copiedTables = tableTargets.filter((table) => Boolean(table.generatedDescription?.trim()));
|
||||
const copiedColumns = columnTargets.filter((column) => Boolean(column.generatedDescription?.trim()));
|
||||
for (const table of copiedTables) {
|
||||
this.tables.set(table.id, {
|
||||
...table,
|
||||
description: table.generatedDescription,
|
||||
version: table.version + 1,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
for (const column of copiedColumns) {
|
||||
this.columns.set(column.id, {
|
||||
...column,
|
||||
description: column.generatedDescription,
|
||||
version: column.version + 1,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
const copied = copiedTables.length + copiedColumns.length;
|
||||
return { copied, skipped: tableTargets.length + columnTargets.length - copied };
|
||||
}
|
||||
if (selectedTargetIds.length === 0) return undefined;
|
||||
if (target === "tables") {
|
||||
const targets = selectedTargetIds.map((id) => this.tables.get(id));
|
||||
if (targets.some((table) => !table || table.databaseId !== databaseId)) return undefined;
|
||||
@@ -328,7 +445,10 @@ export class MemoryCatalogRepository implements CatalogRepository {
|
||||
processed: 0,
|
||||
generated: 0,
|
||||
nonGeneratable: 0,
|
||||
failed: 0,
|
||||
failed: 0,
|
||||
inputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
outputTokens: 0,
|
||||
createdAt: now,
|
||||
startedAt: null,
|
||||
updatedAt: now,
|
||||
@@ -423,90 +543,96 @@ export class MemoryCatalogRepository implements CatalogRepository {
|
||||
.map((event) => structuredClone(event));
|
||||
}
|
||||
|
||||
async createSensitiveDataSuggestionRun(
|
||||
async createSensitivityAnalysisRun(
|
||||
databaseId: string,
|
||||
scope: SensitiveDataSuggestionScope,
|
||||
modelId: string,
|
||||
): Promise<SensitiveDataSuggestionRun> {
|
||||
scope: SensitivityAnalysisScope,
|
||||
origin: { engine: "llm"; modelId: string } | { engine: "local"; policyVersion: string },
|
||||
): Promise<SensitivityAnalysisRun> {
|
||||
const now = new Date().toISOString();
|
||||
const run: SensitiveDataSuggestionRun = {
|
||||
const run: SensitivityAnalysisRun = {
|
||||
id: randomUUID(),
|
||||
databaseId,
|
||||
scope,
|
||||
modelId,
|
||||
engine: origin.engine,
|
||||
modelId: origin.engine === "llm" ? origin.modelId : null,
|
||||
policyVersion: origin.engine === "local" ? origin.policyVersion : null,
|
||||
status: "running",
|
||||
total: 0,
|
||||
suggestedSensitive: 0,
|
||||
suggestedNonSensitive: 0,
|
||||
unknown: 0,
|
||||
inputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
outputTokens: 0,
|
||||
createdAt: now,
|
||||
startedAt: now,
|
||||
updatedAt: now,
|
||||
finishedAt: null,
|
||||
errorSummary: null,
|
||||
};
|
||||
this.sensitiveDataSuggestionRuns.set(run.id, run);
|
||||
this.sensitivityAnalysisRuns.set(run.id, run);
|
||||
return structuredClone(run);
|
||||
}
|
||||
|
||||
async getSensitiveDataSuggestionRun(
|
||||
async getSensitivityAnalysisRun(
|
||||
runId: string,
|
||||
): Promise<SensitiveDataSuggestionRun | undefined> {
|
||||
const run = this.sensitiveDataSuggestionRuns.get(runId);
|
||||
): Promise<SensitivityAnalysisRun | undefined> {
|
||||
const run = this.sensitivityAnalysisRuns.get(runId);
|
||||
return run ? structuredClone(run) : undefined;
|
||||
}
|
||||
|
||||
async listSensitiveDataSuggestionRuns(limit = 50): Promise<SensitiveDataSuggestionRun[]> {
|
||||
return [...this.sensitiveDataSuggestionRuns.values()]
|
||||
async listSensitivityAnalysisRuns(limit = 50): Promise<SensitivityAnalysisRun[]> {
|
||||
return [...this.sensitivityAnalysisRuns.values()]
|
||||
.sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id))
|
||||
.slice(0, limit)
|
||||
.map((run) => structuredClone(run));
|
||||
}
|
||||
|
||||
async interruptActiveSensitiveDataSuggestionRuns(
|
||||
async interruptActiveSensitivityAnalysisRuns(
|
||||
errorSummary: string,
|
||||
): Promise<SensitiveDataSuggestionRun[]> {
|
||||
const interrupted: SensitiveDataSuggestionRun[] = [];
|
||||
for (const run of this.sensitiveDataSuggestionRuns.values()) {
|
||||
): Promise<SensitivityAnalysisRun[]> {
|
||||
const interrupted: SensitivityAnalysisRun[] = [];
|
||||
for (const run of this.sensitivityAnalysisRuns.values()) {
|
||||
if (run.status !== "running") continue;
|
||||
const now = new Date().toISOString();
|
||||
const updated: SensitiveDataSuggestionRun = {
|
||||
const updated: SensitivityAnalysisRun = {
|
||||
...run,
|
||||
status: "interrupted",
|
||||
updatedAt: now,
|
||||
finishedAt: now,
|
||||
errorSummary,
|
||||
};
|
||||
this.sensitiveDataSuggestionRuns.set(run.id, updated);
|
||||
this.sensitivityAnalysisRuns.set(run.id, updated);
|
||||
interrupted.push(structuredClone(updated));
|
||||
}
|
||||
return interrupted;
|
||||
}
|
||||
|
||||
async updateSensitiveDataSuggestionRun(
|
||||
async updateSensitivityAnalysisRun(
|
||||
runId: string,
|
||||
update: SensitiveDataSuggestionRunUpdate,
|
||||
): Promise<SensitiveDataSuggestionRun | undefined> {
|
||||
const current = this.sensitiveDataSuggestionRuns.get(runId);
|
||||
update: SensitivityAnalysisRunUpdate,
|
||||
): Promise<SensitivityAnalysisRun | undefined> {
|
||||
const current = this.sensitivityAnalysisRuns.get(runId);
|
||||
if (!current) return undefined;
|
||||
const updated = {
|
||||
...current,
|
||||
...structuredClone(update),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
this.sensitiveDataSuggestionRuns.set(runId, updated);
|
||||
this.sensitivityAnalysisRuns.set(runId, updated);
|
||||
return structuredClone(updated);
|
||||
}
|
||||
|
||||
async appendSensitiveDataSuggestionEvent(
|
||||
async appendSensitivityAnalysisEvent(
|
||||
runId: string,
|
||||
level: SensitiveDataSuggestionEvent["level"],
|
||||
level: SensitivityAnalysisEvent["level"],
|
||||
message: string,
|
||||
): Promise<SensitiveDataSuggestionEvent> {
|
||||
if (!this.sensitiveDataSuggestionRuns.has(runId)) {
|
||||
throw new CatalogConflictError("Sensitive Data Suggestion Run does not exist");
|
||||
): Promise<SensitivityAnalysisEvent> {
|
||||
if (!this.sensitivityAnalysisRuns.has(runId)) {
|
||||
throw new CatalogConflictError("Sensitivity Analysis Run does not exist");
|
||||
}
|
||||
const events = this.sensitiveDataSuggestionEvents.get(runId) ?? [];
|
||||
const event: SensitiveDataSuggestionEvent = {
|
||||
const events = this.sensitivityAnalysisEvents.get(runId) ?? [];
|
||||
const event: SensitivityAnalysisEvent = {
|
||||
runId,
|
||||
sequence: events.length + 1,
|
||||
level,
|
||||
@@ -514,25 +640,151 @@ export class MemoryCatalogRepository implements CatalogRepository {
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
events.push(event);
|
||||
this.sensitiveDataSuggestionEvents.set(runId, events);
|
||||
this.sensitivityAnalysisEvents.set(runId, events);
|
||||
return structuredClone(event);
|
||||
}
|
||||
|
||||
async listSensitiveDataSuggestionEvents(
|
||||
async listSensitivityAnalysisEvents(
|
||||
runId: string,
|
||||
afterSequence = 0,
|
||||
): Promise<SensitiveDataSuggestionEvent[]> {
|
||||
return (this.sensitiveDataSuggestionEvents.get(runId) ?? [])
|
||||
): Promise<SensitivityAnalysisEvent[]> {
|
||||
return (this.sensitivityAnalysisEvents.get(runId) ?? [])
|
||||
.filter((event) => event.sequence > afterSequence)
|
||||
.map((event) => structuredClone(event));
|
||||
}
|
||||
|
||||
async listRelationships(databaseId: string): Promise<CatalogRelationship[]> {
|
||||
async listRelationships(databaseId: string): Promise<CatalogPhysicalRelationship[]> {
|
||||
return [...this.relationships.values()].filter((relationship) => relationship.databaseId === databaseId)
|
||||
.sort((a, b) => `${a.sourceTableName}.${a.constraintName}`.localeCompare(`${b.sourceTableName}.${b.constraintName}`))
|
||||
.map((relationship) => structuredClone(relationship));
|
||||
}
|
||||
|
||||
async listLogicalRelationships(databaseId: string): Promise<CatalogLogicalRelationship[]> {
|
||||
return [...this.logicalRelationships.values()]
|
||||
.filter((relationship) => relationship.databaseId === databaseId)
|
||||
.sort((a, b) => {
|
||||
const left = `${a.sourceTableName}.${a.columns[0].sourceColumnName}.${a.targetTableName}.${a.columns[0].targetColumnName}`;
|
||||
const right = `${b.sourceTableName}.${b.columns[0].sourceColumnName}.${b.targetTableName}.${b.columns[0].targetColumnName}`;
|
||||
return left.localeCompare(right);
|
||||
})
|
||||
.map((relationship) => structuredClone(relationship));
|
||||
}
|
||||
|
||||
async getLogicalRelationshipContext(
|
||||
databaseId: string,
|
||||
): Promise<CatalogLogicalRelationshipContext | undefined> {
|
||||
if (!this.records.has(databaseId)) return undefined;
|
||||
const tables = [...this.tables.values()].filter((table) => table.databaseId === databaseId);
|
||||
const tableById = new Map(tables.map((table) => [table.id, table]));
|
||||
const columns = [...this.columns.values()].filter((column) => tableById.has(column.tableId));
|
||||
const primaryKeyCounts = new Map<string, number>();
|
||||
for (const column of columns) {
|
||||
if (column.primaryKeyPosition !== null) {
|
||||
primaryKeyCounts.set(column.tableId, (primaryKeyCounts.get(column.tableId) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
return {
|
||||
endpoints: columns.map((column) => ({
|
||||
columnId: column.id,
|
||||
columnName: column.name,
|
||||
tableId: column.tableId,
|
||||
tableName: tableById.get(column.tableId)!.name,
|
||||
dataType: column.dataType,
|
||||
primaryKeyPosition: column.primaryKeyPosition,
|
||||
tablePrimaryKeyColumnCount: primaryKeyCounts.get(column.tableId) ?? 0,
|
||||
})),
|
||||
physicalPairs: [...this.relationships.values()]
|
||||
.filter((relationship) => relationship.databaseId === databaseId)
|
||||
.flatMap((relationship) => relationship.columns.map((column) => ({
|
||||
sourceColumnId: column.sourceColumnId,
|
||||
targetColumnId: column.targetColumnId,
|
||||
}))),
|
||||
logicalRelationships: await this.listLogicalRelationships(databaseId),
|
||||
};
|
||||
}
|
||||
|
||||
async insertLogicalRelationship(
|
||||
databaseId: string,
|
||||
sourceColumnId: string,
|
||||
targetColumnId: string,
|
||||
generated: boolean,
|
||||
): Promise<CatalogLogicalRelationship | undefined> {
|
||||
if ([...this.logicalRelationships.values()].some((relationship) => (
|
||||
relationship.databaseId === databaseId
|
||||
&& relationship.columns[0].sourceColumnId === sourceColumnId
|
||||
&& relationship.columns[0].targetColumnId === targetColumnId
|
||||
))) return undefined;
|
||||
const sourceColumn = this.columns.get(sourceColumnId);
|
||||
const targetColumn = this.columns.get(targetColumnId);
|
||||
const sourceTable = sourceColumn ? this.tables.get(sourceColumn.tableId) : undefined;
|
||||
const targetTable = targetColumn ? this.tables.get(targetColumn.tableId) : undefined;
|
||||
if (!sourceColumn || !targetColumn || !sourceTable || !targetTable
|
||||
|| sourceTable.databaseId !== databaseId || targetTable.databaseId !== databaseId
|
||||
|| sourceColumnId === targetColumnId) return undefined;
|
||||
const now = new Date().toISOString();
|
||||
const relationship: CatalogLogicalRelationship = {
|
||||
id: randomUUID(),
|
||||
databaseId,
|
||||
constraintName: null,
|
||||
sourceTableId: sourceTable.id,
|
||||
sourceTableName: sourceTable.name,
|
||||
targetTableId: targetTable.id,
|
||||
targetTableName: targetTable.name,
|
||||
updateRule: null,
|
||||
deleteRule: null,
|
||||
deferrable: false,
|
||||
initiallyDeferred: false,
|
||||
columns: [{
|
||||
position: 1,
|
||||
sourceColumnId,
|
||||
sourceColumnName: sourceColumn.name,
|
||||
targetColumnId,
|
||||
targetColumnName: targetColumn.name,
|
||||
}],
|
||||
lastSyncedDatabaseVersion: null,
|
||||
lastSyncedAt: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
origin: generated ? "generated" : "manual",
|
||||
status: "active",
|
||||
};
|
||||
this.logicalRelationships.set(relationship.id, relationship);
|
||||
return structuredClone(relationship);
|
||||
}
|
||||
|
||||
async insertGeneratedLogicalRelationships(
|
||||
databaseId: string,
|
||||
candidates: readonly CatalogLogicalRelationshipCandidate[],
|
||||
): Promise<number> {
|
||||
let added = 0;
|
||||
for (const candidate of candidates) {
|
||||
if (await this.insertLogicalRelationship(
|
||||
databaseId,
|
||||
candidate.sourceColumnId,
|
||||
candidate.targetColumnId,
|
||||
true,
|
||||
)) added += 1;
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
async setLogicalRelationshipStatus(
|
||||
databaseId: string,
|
||||
relationshipId: string,
|
||||
status: CatalogLogicalRelationship["status"],
|
||||
): Promise<CatalogLogicalRelationship | undefined> {
|
||||
const current = this.logicalRelationships.get(relationshipId);
|
||||
if (!current || current.databaseId !== databaseId) return undefined;
|
||||
const updated = { ...current, status, updatedAt: new Date().toISOString() };
|
||||
this.logicalRelationships.set(relationshipId, updated);
|
||||
return structuredClone(updated);
|
||||
}
|
||||
|
||||
async deleteLogicalRelationship(databaseId: string, relationshipId: string): Promise<boolean> {
|
||||
const current = this.logicalRelationships.get(relationshipId);
|
||||
return Boolean(current?.databaseId === databaseId && this.logicalRelationships.delete(relationshipId));
|
||||
}
|
||||
|
||||
async deleteDatabaseMetadata(
|
||||
databaseIds: readonly string[],
|
||||
target: CatalogDatabaseMetadataDeleteTarget,
|
||||
@@ -545,18 +797,22 @@ export class MemoryCatalogRepository implements CatalogRepository {
|
||||
const tables = [...this.tables.values()].filter((table) => selected.has(table.databaseId));
|
||||
const tableIds = new Set(tables.map((table) => table.id));
|
||||
const columns = [...this.columns.values()].filter((column) => tableIds.has(column.tableId));
|
||||
const relationships = [...this.relationships.values()]
|
||||
const physicalRelationships = [...this.relationships.values()]
|
||||
.filter((relationship) => selected.has(relationship.databaseId));
|
||||
const logicalRelationships = [...this.logicalRelationships.values()]
|
||||
.filter((relationship) => selected.has(relationship.databaseId));
|
||||
const relationshipCount = physicalRelationships.length + logicalRelationships.length;
|
||||
|
||||
if (target === "tables") {
|
||||
for (const table of tables) this.deleteTable(table.id);
|
||||
this.markCatalogIncomplete(selectedDatabaseIds);
|
||||
return { tables: tables.length, columns: columns.length, relationships: relationships.length };
|
||||
return { tables: tables.length, columns: columns.length, relationships: relationshipCount };
|
||||
}
|
||||
for (const relationship of relationships) this.relationships.delete(relationship.id);
|
||||
for (const relationship of physicalRelationships) this.relationships.delete(relationship.id);
|
||||
for (const relationship of logicalRelationships) this.logicalRelationships.delete(relationship.id);
|
||||
for (const databaseId of selectedDatabaseIds) this.refreshForeignKeyFlags(databaseId);
|
||||
this.markCatalogIncomplete(selectedDatabaseIds);
|
||||
return { tables: 0, columns: 0, relationships: relationships.length };
|
||||
return { tables: 0, columns: 0, relationships: relationshipCount };
|
||||
}
|
||||
|
||||
async deleteTableMetadata(
|
||||
@@ -574,6 +830,12 @@ export class MemoryCatalogRepository implements CatalogRepository {
|
||||
const columns = [...this.columns.values()].filter((column) => selected.has(column.tableId));
|
||||
const deletedColumnIds = new Set(columns.map((column) => column.id));
|
||||
for (const column of columns) this.columns.delete(column.id);
|
||||
for (const relationship of [...this.logicalRelationships.values()]) {
|
||||
const pair = relationship.columns[0];
|
||||
if (deletedColumnIds.has(pair.sourceColumnId) || deletedColumnIds.has(pair.targetColumnId)) {
|
||||
this.logicalRelationships.delete(relationship.id);
|
||||
}
|
||||
}
|
||||
for (const [relationshipId, relationship] of this.relationships) {
|
||||
if (relationship.databaseId !== databaseId) continue;
|
||||
this.relationships.set(relationshipId, {
|
||||
@@ -588,14 +850,23 @@ export class MemoryCatalogRepository implements CatalogRepository {
|
||||
return { tables: 0, columns: columns.length, relationships: 0 };
|
||||
}
|
||||
|
||||
const relationships = [...this.relationships.values()].filter((relationship) => (
|
||||
const physicalRelationships = [...this.relationships.values()].filter((relationship) => (
|
||||
relationship.databaseId === databaseId
|
||||
&& (selected.has(relationship.sourceTableId) || selected.has(relationship.targetTableId))
|
||||
));
|
||||
for (const relationship of relationships) this.relationships.delete(relationship.id);
|
||||
const logicalRelationships = [...this.logicalRelationships.values()].filter((relationship) => (
|
||||
relationship.databaseId === databaseId
|
||||
&& (selected.has(relationship.sourceTableId) || selected.has(relationship.targetTableId))
|
||||
));
|
||||
for (const relationship of physicalRelationships) this.relationships.delete(relationship.id);
|
||||
for (const relationship of logicalRelationships) this.logicalRelationships.delete(relationship.id);
|
||||
this.refreshForeignKeyFlags(databaseId);
|
||||
this.markCatalogIncomplete([databaseId]);
|
||||
return { tables: 0, columns: 0, relationships: relationships.length };
|
||||
return {
|
||||
tables: 0,
|
||||
columns: 0,
|
||||
relationships: physicalRelationships.length + logicalRelationships.length,
|
||||
};
|
||||
}
|
||||
|
||||
async planSchemaSync(
|
||||
@@ -655,6 +926,7 @@ export class MemoryCatalogRepository implements CatalogRepository {
|
||||
scope: CatalogSyncScope,
|
||||
tableIds: readonly string[],
|
||||
snapshot: ObservedSchemaSnapshot,
|
||||
syncRunId?: string,
|
||||
): Promise<CatalogSyncCounts | undefined> {
|
||||
const database = this.records.get(databaseId);
|
||||
if (!database || database.version !== expectedDatabaseVersion) return undefined;
|
||||
@@ -779,6 +1051,7 @@ export class MemoryCatalogRepository implements CatalogRepository {
|
||||
description: null,
|
||||
generatedDescription: null,
|
||||
sensitive: false,
|
||||
sensitivityReason: null,
|
||||
lastSyncedDatabaseVersion: expectedDatabaseVersion,
|
||||
lastSyncedAt: now,
|
||||
version: 1,
|
||||
@@ -857,6 +1130,8 @@ export class MemoryCatalogRepository implements CatalogRepository {
|
||||
lastSyncedAt: now,
|
||||
createdAt: current?.createdAt ?? now,
|
||||
updatedAt: comparable === nextComparable ? (current?.updatedAt ?? now) : now,
|
||||
origin: "physical",
|
||||
status: "active",
|
||||
});
|
||||
if (!current) created += 1;
|
||||
else if (comparable !== nextComparable) updated += 1;
|
||||
@@ -867,6 +1142,7 @@ export class MemoryCatalogRepository implements CatalogRepository {
|
||||
if (scope === "all") {
|
||||
this.records.set(databaseId, { ...database, schemaSyncedVersion: expectedDatabaseVersion, schemaSyncedAt: now });
|
||||
}
|
||||
if (syncRunId) await this.updateSyncRun(syncRunId, { phase: "memory_cleanup" });
|
||||
return {
|
||||
tables: (await this.listTables(databaseId)).length,
|
||||
columns: [...this.columns.values()].filter((column) => this.tables.get(column.tableId)?.databaseId === databaseId).length,
|
||||
@@ -980,7 +1256,7 @@ export class MemoryCatalogRepository implements CatalogRepository {
|
||||
for (const run of this.syncRuns.values()) {
|
||||
if (["queued", "running", "awaiting_confirmation", "applying"].includes(run.state)) {
|
||||
await this.updateSyncRun(run.id, {
|
||||
state: "interrupted", phase: "completed", finishedAt: new Date().toISOString(),
|
||||
state: "interrupted", phase: run.phase === "memory_cleanup" ? "memory_cleanup" : "completed", finishedAt: new Date().toISOString(),
|
||||
errorCode: "SYNC_INTERRUPTED", errorMessage: "Synchronization was interrupted by a service restart",
|
||||
});
|
||||
}
|
||||
@@ -1073,6 +1349,12 @@ export class MemoryCatalogRepository implements CatalogRepository {
|
||||
this.relationships.delete(relationship.id);
|
||||
}
|
||||
}
|
||||
for (const relationship of [...this.logicalRelationships.values()]) {
|
||||
const pair = relationship.columns[0];
|
||||
if (pair.sourceColumnId === columnId || pair.targetColumnId === columnId) {
|
||||
this.logicalRelationships.delete(relationship.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private markCatalogIncomplete(databaseIds: readonly string[]): void {
|
||||
|
||||
@@ -1,63 +1,5 @@
|
||||
import {
|
||||
closeSync, constants, fstatSync, lstatSync, openSync, readFileSync,
|
||||
type Stats,
|
||||
} from "node:fs";
|
||||
import { parseAllDocuments } from "yaml";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
loadSecretBundle,
|
||||
METADATA_GENERATION_SECRET_KEYS,
|
||||
} from "../config/secret-bundle.js";
|
||||
|
||||
const MAX_INSTALLATION_BYTES = 1024 * 1024;
|
||||
const RUNTIME_INSTALLATION_FILE = "/run/thothii-installation/thothii-installation.yaml";
|
||||
const modelId = z.string().regex(/^[a-z][a-z0-9._-]{0,63}$/);
|
||||
const apiKeyEnvironment = z.enum(METADATA_GENERATION_SECRET_KEYS);
|
||||
const endpointSchema = z.object({
|
||||
baseUrl: z.string().min(1).max(2048).refine((value) => {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return (url.protocol === "http:" || url.protocol === "https:")
|
||||
&& url.username === "" && url.password === "" && url.search === "" && url.hash === "";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}),
|
||||
apiVersion: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/).optional(),
|
||||
}).strict();
|
||||
const configuredModelSchema = z.object({
|
||||
id: modelId,
|
||||
label: z.string().min(1).max(128).refine((value) => value.trim() === value && !/\p{Cc}/u.test(value)),
|
||||
litellm: z.object({
|
||||
provider: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/),
|
||||
model: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/),
|
||||
disableThinking: z.literal(true).optional(),
|
||||
endpoint: endpointSchema.optional(),
|
||||
}).strict(),
|
||||
apiKeyEnv: apiKeyEnvironment.optional(),
|
||||
}).strict().superRefine((value, context) => {
|
||||
if (value.apiKeyEnv === undefined && value.litellm.endpoint === undefined) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["apiKeyEnv"],
|
||||
message: "keyless models require an explicit endpoint",
|
||||
});
|
||||
}
|
||||
if (value.litellm.disableThinking === true && value.litellm.endpoint === undefined) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["litellm", "disableThinking"],
|
||||
message: "thinking may be disabled only for an explicit endpoint",
|
||||
});
|
||||
}
|
||||
});
|
||||
const metadataGenerationSchema = z.object({
|
||||
default: modelId.optional(),
|
||||
models: z.array(configuredModelSchema).max(64).default([]),
|
||||
}).strict();
|
||||
const installationSchema = z.object({
|
||||
metadataGeneration: metadataGenerationSchema.optional(),
|
||||
}).passthrough();
|
||||
import { loadSecretBundle } from "../config/secret-bundle.js";
|
||||
import { loadRuntimeModelCatalog } from "../models/runtime-model-catalog.js";
|
||||
|
||||
export interface MetadataGenerationModelChoice {
|
||||
id: string;
|
||||
@@ -86,7 +28,6 @@ export class MetadataGenerationModelUnavailableError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** The complete interface callers need: safe discovery plus fail-closed runtime resolution. */
|
||||
export interface MetadataGenerationModels {
|
||||
catalog(): MetadataGenerationModelCatalog;
|
||||
resolve(selection: string): ResolvedMetadataGenerationModel;
|
||||
@@ -96,140 +37,78 @@ class RestartLoadedMetadataGenerationModels implements MetadataGenerationModels
|
||||
readonly #models: ReadonlyMap<string, ResolvedMetadataGenerationModel>;
|
||||
readonly #catalog: MetadataGenerationModelCatalog;
|
||||
|
||||
constructor(
|
||||
models: ReadonlyMap<string, ResolvedMetadataGenerationModel> = new Map(),
|
||||
defaultModel: string | null = null,
|
||||
choices: MetadataGenerationModelChoice[] = [],
|
||||
) {
|
||||
constructor(models: ReadonlyMap<string, ResolvedMetadataGenerationModel>, defaultModel: string | null) {
|
||||
this.#models = models;
|
||||
this.#catalog = {
|
||||
models: choices.map((choice) => ({ ...choice })),
|
||||
models: [...models.values()].map(({ id }) => ({ id, label: id })),
|
||||
default: defaultModel,
|
||||
};
|
||||
}
|
||||
|
||||
catalog(): MetadataGenerationModelCatalog {
|
||||
return {
|
||||
models: this.#catalog.models.map((choice) => ({ ...choice })),
|
||||
default: this.#catalog.default,
|
||||
};
|
||||
return { models: this.#catalog.models.map((choice) => ({ ...choice })), default: this.#catalog.default };
|
||||
}
|
||||
|
||||
resolve(selection: string): ResolvedMetadataGenerationModel {
|
||||
const model = typeof selection === "string" ? this.#models.get(selection) : undefined;
|
||||
const model = this.#models.get(selection);
|
||||
if (!model) throw new MetadataGenerationModelUnavailableError();
|
||||
return model;
|
||||
}
|
||||
}
|
||||
|
||||
function invalid(message = "metadata-generation configuration is invalid"): Error {
|
||||
function invalid(message = "metadata-generation runtime catalog is invalid"): Error {
|
||||
return new Error(message);
|
||||
}
|
||||
|
||||
function protectedInstallationStat(file: string, info: Stats): boolean {
|
||||
const mode = info.mode & 0o777;
|
||||
if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1
|
||||
|| info.size < 1 || info.size > MAX_INSTALLATION_BYTES) return false;
|
||||
if (file === RUNTIME_INSTALLATION_FILE && info.uid === 0 && mode === 0o444) return true;
|
||||
return info.uid === (process.getuid?.() ?? info.uid) && (mode === 0o400 || mode === 0o600);
|
||||
}
|
||||
|
||||
function readProtectedInstallation(file: string): string {
|
||||
let descriptor: number | undefined;
|
||||
try {
|
||||
const before = lstatSync(file);
|
||||
if (!protectedInstallationStat(file, before)) throw new Error("unavailable");
|
||||
descriptor = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||
const opened = fstatSync(descriptor);
|
||||
if (!protectedInstallationStat(file, opened)
|
||||
|| before.dev !== opened.dev || before.ino !== opened.ino) throw new Error("unavailable");
|
||||
const source = readFileSync(descriptor, "utf8");
|
||||
const after = fstatSync(descriptor);
|
||||
const current = lstatSync(file);
|
||||
if (!protectedInstallationStat(file, after) || !protectedInstallationStat(file, current)
|
||||
|| opened.dev !== after.dev || opened.ino !== after.ino
|
||||
|| opened.dev !== current.dev || opened.ino !== current.ino) throw new Error("unavailable");
|
||||
return source;
|
||||
} finally {
|
||||
if (descriptor !== undefined) try { closeSync(descriptor); } catch { /* sanitized below */ }
|
||||
}
|
||||
}
|
||||
|
||||
function readInstallation(file: string): unknown {
|
||||
try {
|
||||
const documents = parseAllDocuments(readProtectedInstallation(file), { uniqueKeys: true });
|
||||
if (documents.length !== 1) throw invalid("metadata-generation installation must contain one YAML document");
|
||||
const document = documents[0];
|
||||
if (document.errors.length > 0 || document.warnings.length > 0) {
|
||||
throw invalid("metadata-generation installation contains invalid YAML");
|
||||
}
|
||||
return document.toJSON();
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.startsWith("metadata-generation")) throw error;
|
||||
throw invalid("metadata-generation installation is unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
export function loadMetadataGenerationModels(options: {
|
||||
installationFile?: string;
|
||||
catalogFile?: string;
|
||||
secretsFile?: string;
|
||||
}): MetadataGenerationModels {
|
||||
if (!options.installationFile) return new RestartLoadedMetadataGenerationModels();
|
||||
const installation = installationSchema.safeParse(readInstallation(options.installationFile));
|
||||
if (!installation.success) throw invalid();
|
||||
const configured = installation.data.metadataGeneration;
|
||||
if (!configured || configured.models.length === 0) {
|
||||
if (configured?.default !== undefined) throw invalid("metadata-generation default does not identify a configured model");
|
||||
return new RestartLoadedMetadataGenerationModels();
|
||||
}
|
||||
if (!configured.default) throw invalid("metadata-generation default is required when models are configured");
|
||||
const catalog = loadRuntimeModelCatalog(options.catalogFile);
|
||||
const configured = catalog.metadataModels();
|
||||
if (configured.length === 0) return new RestartLoadedMetadataGenerationModels(new Map(), null);
|
||||
|
||||
const seen = new Set<string>();
|
||||
for (const model of configured.models) {
|
||||
if (seen.has(model.id)) throw invalid(`metadata-generation model id "${model.id}" is duplicated`);
|
||||
seen.add(model.id);
|
||||
}
|
||||
if (!seen.has(configured.default)) {
|
||||
throw invalid(`metadata-generation default "${configured.default}" is not configured`);
|
||||
}
|
||||
const requiresSecrets = configured.models.some((model) => model.apiKeyEnv !== undefined);
|
||||
const requiresSecrets = configured.some((model) => model.authentication.mode === "secret_env");
|
||||
let secrets: ReadonlyMap<string, string> = new Map();
|
||||
if (requiresSecrets) {
|
||||
if (!options.secretsFile) throw invalid("metadata-generation keyed models require THT_SECRETS_FILE");
|
||||
try {
|
||||
secrets = loadSecretBundle(options.secretsFile);
|
||||
} catch {
|
||||
throw invalid("metadata-generation secrets are unavailable");
|
||||
}
|
||||
try { secrets = loadSecretBundle(options.secretsFile); }
|
||||
catch { throw invalid("metadata-generation secrets are unavailable"); }
|
||||
}
|
||||
|
||||
const models = new Map<string, ResolvedMetadataGenerationModel>();
|
||||
for (const configuredModel of configured.models) {
|
||||
const labels = new Map<string, string>();
|
||||
for (const configuredModel of configured) {
|
||||
const adapter = configuredModel.metadataAdapter;
|
||||
if (!adapter || configuredModel.authentication.mode === "pi_auth") throw invalid();
|
||||
const apiKeyEnv = configuredModel.authentication.apiKeyEnv;
|
||||
let apiKey: string | undefined;
|
||||
if (configuredModel.apiKeyEnv !== undefined) {
|
||||
apiKey = secrets.get(configuredModel.apiKeyEnv);
|
||||
if (!apiKey) {
|
||||
throw invalid(`metadata-generation model "${configuredModel.id}" secret "${configuredModel.apiKeyEnv}" is missing`);
|
||||
}
|
||||
if (configuredModel.authentication.mode === "secret_env") {
|
||||
if (!apiKeyEnv) throw invalid();
|
||||
apiKey = secrets.get(apiKeyEnv);
|
||||
if (!apiKey) throw invalid(`metadata-generation model "${configuredModel.id}" secret "${apiKeyEnv}" is missing`);
|
||||
if (apiKey.length > 16 * 1024 || /\s/u.test(apiKey)) {
|
||||
throw invalid(`metadata-generation model "${configuredModel.id}" secret "${configuredModel.apiKeyEnv}" is unusable`);
|
||||
throw invalid(`metadata-generation model "${configuredModel.id}" secret "${apiKeyEnv}" is unusable`);
|
||||
}
|
||||
}
|
||||
labels.set(configuredModel.id, configuredModel.label);
|
||||
models.set(configuredModel.id, Object.freeze({
|
||||
id: configuredModel.id,
|
||||
provider: configuredModel.litellm.provider,
|
||||
model: configuredModel.litellm.model,
|
||||
...(configuredModel.litellm.disableThinking === true ? { disableThinking: true as const } : {}),
|
||||
...(configuredModel.litellm.endpoint === undefined
|
||||
? {}
|
||||
: { endpoint: Object.freeze({ ...configuredModel.litellm.endpoint }) }),
|
||||
...(configuredModel.apiKeyEnv === undefined
|
||||
? {}
|
||||
: { apiKeyEnv: configuredModel.apiKeyEnv, apiKey }),
|
||||
provider: adapter.litellmProvider,
|
||||
model: configuredModel.upstreamModel,
|
||||
...(configuredModel.metadataGeneration?.disableThinking === true
|
||||
? { disableThinking: true as const } : {}),
|
||||
...(configuredModel.endpoint ? { endpoint: Object.freeze({ ...configuredModel.endpoint }) } : {}),
|
||||
...(apiKeyEnv ? { apiKeyEnv, apiKey } : {}),
|
||||
}));
|
||||
}
|
||||
return new RestartLoadedMetadataGenerationModels(
|
||||
models,
|
||||
configured.default,
|
||||
configured.models.map(({ id, label }) => ({ id, label })),
|
||||
);
|
||||
const result = new RestartLoadedMetadataGenerationModels(models, models.size ? catalog.defaultInteraction : null);
|
||||
const safe = result.catalog();
|
||||
return {
|
||||
catalog: () => ({
|
||||
default: safe.default,
|
||||
models: safe.models.map((choice) => ({ ...choice, label: labels.get(choice.id) ?? choice.id })),
|
||||
}),
|
||||
resolve: (selection) => result.resolve(selection),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import type {
|
||||
CatalogColumn,
|
||||
CatalogLogicalRelationship,
|
||||
CatalogPhysicalRelationship,
|
||||
CatalogRepository,
|
||||
CatalogTable,
|
||||
} from "./types.js";
|
||||
|
||||
export type CatalogDescriptionSource = "curated" | "generated" | "source_comment";
|
||||
|
||||
export interface CatalogMetadataSnapshotColumn {
|
||||
id: string;
|
||||
name: string;
|
||||
ordinalPosition: number;
|
||||
dataType: string;
|
||||
isNullable: boolean;
|
||||
defaultExpression: string | null;
|
||||
primaryKeyPosition: number | null;
|
||||
sensitive: boolean;
|
||||
description: string | null;
|
||||
descriptionSource: CatalogDescriptionSource | null;
|
||||
}
|
||||
|
||||
export interface CatalogMetadataSnapshotTable {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
descriptionSource: CatalogDescriptionSource | null;
|
||||
columns: CatalogMetadataSnapshotColumn[];
|
||||
}
|
||||
|
||||
export interface CatalogMetadataSnapshotRelationship {
|
||||
id: string;
|
||||
origin: "physical" | "generated" | "manual";
|
||||
sourceTable: string;
|
||||
sourceColumns: string[];
|
||||
targetTable: string;
|
||||
targetColumns: string[];
|
||||
}
|
||||
|
||||
export interface CatalogMetadataSnapshot {
|
||||
schemaVersion: 1;
|
||||
workspaceId: string;
|
||||
databaseId: string;
|
||||
databaseName: string;
|
||||
schemaName: string;
|
||||
metadataContentRevision: number;
|
||||
tables: CatalogMetadataSnapshotTable[];
|
||||
relationships: CatalogMetadataSnapshotRelationship[];
|
||||
}
|
||||
|
||||
function effectiveDescription(value: {
|
||||
description: string | null;
|
||||
generatedDescription: string | null;
|
||||
sourceComment: string | null;
|
||||
}): { description: string | null; descriptionSource: CatalogDescriptionSource | null } {
|
||||
if (value.description?.trim()) {
|
||||
return { description: value.description.trim(), descriptionSource: "curated" };
|
||||
}
|
||||
if (value.generatedDescription?.trim()) {
|
||||
return { description: value.generatedDescription.trim(), descriptionSource: "generated" };
|
||||
}
|
||||
if (value.sourceComment?.trim()) {
|
||||
return { description: value.sourceComment.trim(), descriptionSource: "source_comment" };
|
||||
}
|
||||
return { description: null, descriptionSource: null };
|
||||
}
|
||||
|
||||
function snapshotColumn(column: CatalogColumn): CatalogMetadataSnapshotColumn {
|
||||
return {
|
||||
id: column.id,
|
||||
name: column.name,
|
||||
ordinalPosition: column.ordinalPosition,
|
||||
dataType: column.dataType,
|
||||
isNullable: column.isNullable,
|
||||
defaultExpression: column.defaultExpression,
|
||||
primaryKeyPosition: column.primaryKeyPosition,
|
||||
sensitive: column.sensitive,
|
||||
...effectiveDescription(column),
|
||||
};
|
||||
}
|
||||
|
||||
function snapshotRelationship(
|
||||
relationship: CatalogPhysicalRelationship | CatalogLogicalRelationship,
|
||||
): CatalogMetadataSnapshotRelationship {
|
||||
const columns = [...relationship.columns].sort((left, right) => left.position - right.position);
|
||||
return {
|
||||
id: relationship.id,
|
||||
origin: relationship.origin,
|
||||
sourceTable: relationship.sourceTableName,
|
||||
sourceColumns: columns.map((column) => column.sourceColumnName),
|
||||
targetTable: relationship.targetTableName,
|
||||
targetColumns: columns.map((column) => column.targetColumnName),
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildCatalogMetadataSnapshot(
|
||||
repository: CatalogRepository,
|
||||
workspaceId: string,
|
||||
expectedMetadataContentRevision: number,
|
||||
): Promise<CatalogMetadataSnapshot> {
|
||||
const database = await repository.getByWorkspace(workspaceId);
|
||||
if (!database || database.preprocessingStatus !== "running") {
|
||||
throw new Error("catalog preprocessing lease is not active");
|
||||
}
|
||||
if (database.metadataContentRevision !== expectedMetadataContentRevision) {
|
||||
throw new Error("catalog metadata revision changed");
|
||||
}
|
||||
|
||||
const catalogTables = await repository.listTables(database.id);
|
||||
const tables: CatalogMetadataSnapshotTable[] = [];
|
||||
for (const table of [...catalogTables].sort((left, right) => left.name.localeCompare(right.name))) {
|
||||
const columns = await repository.listColumns(database.id, table.id);
|
||||
tables.push({
|
||||
id: table.id,
|
||||
name: table.name,
|
||||
...effectiveDescription(table),
|
||||
columns: columns
|
||||
.sort((left, right) => left.ordinalPosition - right.ordinalPosition || left.name.localeCompare(right.name))
|
||||
.map(snapshotColumn),
|
||||
});
|
||||
}
|
||||
|
||||
const physical = await repository.listRelationships(database.id);
|
||||
const logical = (await repository.listLogicalRelationships(database.id))
|
||||
.filter((relationship) => relationship.status === "active");
|
||||
const relationships = [...physical, ...logical]
|
||||
.map(snapshotRelationship)
|
||||
.sort((left, right) =>
|
||||
left.sourceTable.localeCompare(right.sourceTable)
|
||||
|| left.targetTable.localeCompare(right.targetTable)
|
||||
|| left.id.localeCompare(right.id));
|
||||
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
workspaceId,
|
||||
databaseId: database.id,
|
||||
databaseName: database.databaseName,
|
||||
schemaName: database.schema,
|
||||
metadataContentRevision: expectedMetadataContentRevision,
|
||||
tables,
|
||||
relationships,
|
||||
};
|
||||
}
|
||||
@@ -9,7 +9,13 @@ import * as catalogSchemaSyncMigration from "./migrations/003_catalog_schema_syn
|
||||
import * as catalogRuntimeSequencePrivilegesMigration from "./migrations/004_catalog_runtime_sequence_privileges.js";
|
||||
import * as descriptionGenerationRunsMigration from "./migrations/005_description_generation_runs.js";
|
||||
import * as sensitiveDataFlagMigration from "./migrations/006_sensitive_data_flag.js";
|
||||
import * as sensitiveDataSuggestionRunsMigration from "./migrations/007_sensitive_data_suggestion_runs.js";
|
||||
import * as sensitivityAnalysisRunsMigration from "./migrations/007_sensitive_data_suggestion_runs.js";
|
||||
import * as catalogLogicalRelationshipsMigration from "./migrations/008_catalog_logical_relationships.js";
|
||||
import * as aiTokenUsageMigration from "./migrations/009_ai_token_usage.js";
|
||||
import * as canonicalModelIdsMigration from "./migrations/010_canonical_model_ids.js";
|
||||
import * as localSensitivityAnalysisMigration from "./migrations/011_local_sensitivity_analysis.js";
|
||||
import * as sensitivityReasonMigration from "./migrations/012_sensitivity_reason.js";
|
||||
import * as catalogPreprocessingStateMigration from "./migrations/013_catalog_preprocessing_state.js";
|
||||
|
||||
const connectionString = process.env.THT_CATALOG_MIGRATOR_DATABASE_URL;
|
||||
const host = process.env.THT_CATALOG_DB_HOST;
|
||||
@@ -41,7 +47,13 @@ const provider: MigrationProvider = {
|
||||
"004_catalog_runtime_sequence_privileges": catalogRuntimeSequencePrivilegesMigration,
|
||||
"005_description_generation_runs": descriptionGenerationRunsMigration,
|
||||
"006_sensitive_data_flag": sensitiveDataFlagMigration,
|
||||
"007_sensitive_data_suggestion_runs": sensitiveDataSuggestionRunsMigration,
|
||||
"007_sensitive_data_suggestion_runs": sensitivityAnalysisRunsMigration,
|
||||
"008_catalog_logical_relationships": catalogLogicalRelationshipsMigration,
|
||||
"009_ai_token_usage": aiTokenUsageMigration,
|
||||
"010_canonical_model_ids": canonicalModelIdsMigration,
|
||||
"011_local_sensitivity_analysis": localSensitivityAnalysisMigration,
|
||||
"012_sensitivity_reason": sensitivityReasonMigration,
|
||||
"013_catalog_preprocessing_state": catalogPreprocessingStateMigration,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { type Kysely, sql } from "kysely";
|
||||
import type { CatalogDatabase } from "../repository.js";
|
||||
|
||||
export async function up(db: Kysely<CatalogDatabase>): Promise<void> {
|
||||
await db.schema.createTable("catalog_logical_relationships")
|
||||
.addColumn("id", "uuid", (column) => column.primaryKey())
|
||||
.addColumn("database_id", "uuid", (column) => column.notNull()
|
||||
.references("workspace_databases.id").onDelete("cascade"))
|
||||
.addColumn("source_column_id", "uuid", (column) => column.notNull()
|
||||
.references("catalog_columns.id").onDelete("cascade"))
|
||||
.addColumn("target_column_id", "uuid", (column) => column.notNull()
|
||||
.references("catalog_columns.id").onDelete("cascade"))
|
||||
.addColumn("generated", "boolean", (column) => column.notNull().defaultTo(false))
|
||||
.addColumn("deleted_at", "timestamptz")
|
||||
.addColumn("created_at", "timestamptz", (column) => column.notNull().defaultTo(sql`now()`))
|
||||
.addColumn("updated_at", "timestamptz", (column) => column.notNull().defaultTo(sql`now()`))
|
||||
.addUniqueConstraint(
|
||||
"catalog_logical_relationships_endpoint_key",
|
||||
["database_id", "source_column_id", "target_column_id"],
|
||||
)
|
||||
.addCheckConstraint(
|
||||
"catalog_logical_relationships_distinct_columns_check",
|
||||
sql`source_column_id <> target_column_id`,
|
||||
)
|
||||
.execute();
|
||||
|
||||
await db.schema.createIndex("catalog_logical_relationships_database_deleted_idx")
|
||||
.on("catalog_logical_relationships")
|
||||
.columns(["database_id", "deleted_at"])
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<CatalogDatabase>): Promise<void> {
|
||||
await db.schema.dropTable("catalog_logical_relationships").execute();
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Kysely } from "kysely";
|
||||
import type { CatalogDatabase } from "../repository.js";
|
||||
|
||||
export async function up(db: Kysely<CatalogDatabase>): Promise<void> {
|
||||
for (const table of ["description_generation_runs", "sensitive_data_suggestion_runs"] as const) {
|
||||
await db.schema.alterTable(table)
|
||||
.addColumn("input_tokens", "integer", (col) => col.notNull().defaultTo(0))
|
||||
.addColumn("cache_read_tokens", "integer", (col) => col.notNull().defaultTo(0))
|
||||
.addColumn("output_tokens", "integer", (col) => col.notNull().defaultTo(0))
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<CatalogDatabase>): Promise<void> {
|
||||
for (const table of ["sensitive_data_suggestion_runs", "description_generation_runs"] as const) {
|
||||
await db.schema.alterTable(table)
|
||||
.dropColumn("input_tokens").dropColumn("cache_read_tokens").dropColumn("output_tokens")
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { sql, type Kysely } from "kysely";
|
||||
import type { CatalogDatabase } from "../repository.js";
|
||||
|
||||
const canonicalModelPattern = "^[a-z][a-z0-9._-]{0,63}/[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$";
|
||||
const legacyModelPattern = "^[a-z][a-z0-9._-]{0,63}$";
|
||||
const historicalOrCanonicalModelPattern = `(${legacyModelPattern})|(${canonicalModelPattern})`;
|
||||
|
||||
export async function up(db: Kysely<CatalogDatabase>): Promise<void> {
|
||||
await sql.raw(`alter table description_generation_runs
|
||||
drop constraint description_generation_runs_model_id_check,
|
||||
add constraint description_generation_runs_model_id_check
|
||||
check (model_id ~ '${historicalOrCanonicalModelPattern}')`).execute(db);
|
||||
await sql.raw(`alter table sensitive_data_suggestion_runs
|
||||
drop constraint sensitive_data_suggestion_runs_model_id_check,
|
||||
add constraint sensitive_data_suggestion_runs_model_id_check
|
||||
check (model_id ~ '${historicalOrCanonicalModelPattern}')`).execute(db);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<CatalogDatabase>): Promise<void> {
|
||||
await sql.raw(`alter table sensitive_data_suggestion_runs
|
||||
drop constraint sensitive_data_suggestion_runs_model_id_check,
|
||||
add constraint sensitive_data_suggestion_runs_model_id_check
|
||||
check (model_id ~ '${legacyModelPattern}')`).execute(db);
|
||||
await sql.raw(`alter table description_generation_runs
|
||||
drop constraint description_generation_runs_model_id_check,
|
||||
add constraint description_generation_runs_model_id_check
|
||||
check (model_id ~ '${legacyModelPattern}')`).execute(db);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { sql, type Kysely } from "kysely";
|
||||
import type { CatalogDatabase } from "../repository.js";
|
||||
|
||||
export async function up(db: Kysely<CatalogDatabase>): Promise<void> {
|
||||
await sql.raw(`alter table sensitive_data_suggestion_runs
|
||||
alter column model_id drop not null,
|
||||
add column engine text not null default 'llm',
|
||||
add column policy_version text,
|
||||
add column unknown integer not null default 0,
|
||||
drop constraint sensitive_data_suggestion_runs_counters_check,
|
||||
add constraint sensitive_data_suggestion_runs_counters_check
|
||||
check (total >= 0
|
||||
and suggested_sensitive >= 0
|
||||
and suggested_non_sensitive >= 0
|
||||
and unknown >= 0
|
||||
and suggested_sensitive + suggested_non_sensitive + unknown <= total),
|
||||
add constraint sensitive_data_suggestion_runs_engine_check
|
||||
check (engine in ('llm', 'local')),
|
||||
add constraint sensitive_data_suggestion_runs_origin_check
|
||||
check ((engine = 'llm' and model_id is not null and policy_version is null)
|
||||
or (engine = 'local' and model_id is null
|
||||
and policy_version ~ '^[a-z][a-z0-9._-]{0,63}$'))`).execute(db);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<CatalogDatabase>): Promise<void> {
|
||||
await sql.raw(`alter table sensitive_data_suggestion_runs
|
||||
drop constraint sensitive_data_suggestion_runs_origin_check,
|
||||
drop constraint sensitive_data_suggestion_runs_engine_check,
|
||||
drop constraint sensitive_data_suggestion_runs_counters_check`).execute(db);
|
||||
await sql.raw(`update sensitive_data_suggestion_runs
|
||||
set model_id = coalesce(model_id, 'local/sensitivity-v1')`).execute(db);
|
||||
await sql.raw(`alter table sensitive_data_suggestion_runs
|
||||
drop column unknown,
|
||||
drop column policy_version,
|
||||
drop column engine,
|
||||
alter column model_id set not null,
|
||||
add constraint sensitive_data_suggestion_runs_counters_check
|
||||
check (total >= 0
|
||||
and suggested_sensitive >= 0
|
||||
and suggested_non_sensitive >= 0
|
||||
and suggested_sensitive + suggested_non_sensitive <= total)`).execute(db);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { Kysely } from "kysely";
|
||||
import type { CatalogDatabase } from "../repository.js";
|
||||
|
||||
export async function up(db: Kysely<CatalogDatabase>): Promise<void> {
|
||||
await db.schema.alterTable("catalog_columns")
|
||||
.addColumn("sensitivity_reason", "text")
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<CatalogDatabase>): Promise<void> {
|
||||
await db.schema.alterTable("catalog_columns").dropColumn("sensitivity_reason").execute();
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import { type Kysely, sql } from "kysely";
|
||||
import type { CatalogDatabase } from "../repository.js";
|
||||
|
||||
export async function up(db: Kysely<CatalogDatabase>): Promise<void> {
|
||||
await db.schema.alterTable("workspace_databases")
|
||||
.addColumn("metadata_content_revision", "bigint", (column) => column.notNull().defaultTo(0))
|
||||
.addColumn("preprocessing_status", "text", (column) => column.notNull().defaultTo("failed"))
|
||||
.addColumn("preprocessing_input_fingerprint", "text")
|
||||
.addColumn("preprocessed_metadata_revision", "bigint")
|
||||
.addColumn("preprocessing_started_at", "timestamptz")
|
||||
.addColumn("preprocessing_finished_at", "timestamptz")
|
||||
.addColumn("preprocessing_error_code", "text")
|
||||
.execute();
|
||||
await sql`
|
||||
alter table workspace_databases
|
||||
add constraint workspace_databases_preprocessing_status_check
|
||||
check (preprocessing_status in ('running', 'succeeded', 'failed'))
|
||||
`.execute(db);
|
||||
|
||||
await sql`
|
||||
create function catalog_metadata_write_guard()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
as $$
|
||||
declare
|
||||
resolved_database_id uuid;
|
||||
current_status text;
|
||||
relation_id uuid;
|
||||
table_id uuid;
|
||||
begin
|
||||
if tg_table_name = 'catalog_tables' then
|
||||
resolved_database_id := coalesce(new.database_id, old.database_id);
|
||||
elsif tg_table_name = 'catalog_columns' then
|
||||
table_id := coalesce(new.table_id, old.table_id);
|
||||
select database_id into resolved_database_id from catalog_tables where id = table_id;
|
||||
elsif tg_table_name = 'catalog_relationships' then
|
||||
resolved_database_id := coalesce(new.database_id, old.database_id);
|
||||
elsif tg_table_name = 'catalog_relationship_columns' then
|
||||
relation_id := coalesce(new.relationship_id, old.relationship_id);
|
||||
select database_id into resolved_database_id from catalog_relationships where id = relation_id;
|
||||
elsif tg_table_name = 'catalog_logical_relationships' then
|
||||
resolved_database_id := coalesce(new.database_id, old.database_id);
|
||||
elsif tg_table_name = 'database_bindings' then
|
||||
if tg_op = 'UPDATE' and not (
|
||||
new.transport is distinct from old.transport
|
||||
or new.host is distinct from old.host
|
||||
or new.port is distinct from old.port
|
||||
or new.username is distinct from old.username
|
||||
or new.base_url is distinct from old.base_url
|
||||
or new.rest_path is distinct from old.rest_path
|
||||
or new.rest_auth is distinct from old.rest_auth
|
||||
or new.tls_servername is distinct from old.tls_servername
|
||||
or new.ssh_host is distinct from old.ssh_host
|
||||
or new.ssh_port is distinct from old.ssh_port
|
||||
or new.ssh_username is distinct from old.ssh_username
|
||||
or new.ssh_target_host is distinct from old.ssh_target_host
|
||||
or new.ssh_target_port is distinct from old.ssh_target_port
|
||||
) then
|
||||
return new;
|
||||
end if;
|
||||
resolved_database_id := coalesce(new.database_id, old.database_id);
|
||||
end if;
|
||||
if resolved_database_id is null then
|
||||
if tg_op = 'DELETE' then return old; else return new; end if;
|
||||
end if;
|
||||
|
||||
select preprocessing_status into current_status
|
||||
from workspace_databases
|
||||
where id = resolved_database_id
|
||||
for update;
|
||||
|
||||
if current_status = 'running' then
|
||||
raise exception 'catalog preprocessing is running'
|
||||
using errcode = '55000';
|
||||
end if;
|
||||
|
||||
update workspace_databases
|
||||
set metadata_content_revision = metadata_content_revision + 1,
|
||||
preprocessing_status = 'failed',
|
||||
preprocessing_finished_at = now(),
|
||||
preprocessing_error_code = 'catalog_changed',
|
||||
updated_at = now()
|
||||
where id = resolved_database_id;
|
||||
if tg_op = 'DELETE' then return old; else return new; end if;
|
||||
end;
|
||||
$$
|
||||
`.execute(db);
|
||||
|
||||
for (const table of [
|
||||
"catalog_tables",
|
||||
"catalog_columns",
|
||||
"catalog_relationships",
|
||||
"catalog_relationship_columns",
|
||||
"catalog_logical_relationships",
|
||||
"database_bindings",
|
||||
]) {
|
||||
await sql.raw(`
|
||||
create trigger ${table}_metadata_write_guard
|
||||
before insert or update or delete on ${table}
|
||||
for each row execute function catalog_metadata_write_guard()
|
||||
`).execute(db);
|
||||
}
|
||||
|
||||
await sql`
|
||||
create function catalog_database_configuration_guard()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
as $$
|
||||
begin
|
||||
if new.workspace_id is distinct from old.workspace_id
|
||||
or new.engine is distinct from old.engine
|
||||
or new.database_name is distinct from old.database_name
|
||||
or new.schema_name is distinct from old.schema_name then
|
||||
if old.preprocessing_status = 'running' then
|
||||
raise exception 'catalog preprocessing is running'
|
||||
using errcode = '55000';
|
||||
end if;
|
||||
new.metadata_content_revision := old.metadata_content_revision + 1;
|
||||
new.preprocessing_status := 'failed';
|
||||
new.preprocessing_finished_at := now();
|
||||
new.preprocessing_error_code := 'catalog_changed';
|
||||
end if;
|
||||
return new;
|
||||
end;
|
||||
$$
|
||||
`.execute(db);
|
||||
await sql`
|
||||
create trigger workspace_databases_configuration_guard
|
||||
before update of workspace_id, engine, database_name, schema_name on workspace_databases
|
||||
for each row execute function catalog_database_configuration_guard()
|
||||
`.execute(db);
|
||||
|
||||
await sql`
|
||||
create function catalog_operation_start_guard()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
as $$
|
||||
declare
|
||||
current_status text;
|
||||
starting boolean;
|
||||
begin
|
||||
if tg_table_name = 'catalog_sync_runs' then
|
||||
starting := new.state in ('queued', 'running', 'awaiting_confirmation', 'applying');
|
||||
else
|
||||
starting := new.status in ('queued', 'running');
|
||||
end if;
|
||||
if not starting then
|
||||
return new;
|
||||
end if;
|
||||
|
||||
select preprocessing_status into current_status
|
||||
from workspace_databases
|
||||
where id = new.database_id
|
||||
for update;
|
||||
if current_status = 'running' then
|
||||
raise exception 'catalog preprocessing is running'
|
||||
using errcode = '55000';
|
||||
end if;
|
||||
return new;
|
||||
end;
|
||||
$$
|
||||
`.execute(db);
|
||||
|
||||
for (const table of [
|
||||
"catalog_sync_runs",
|
||||
"description_generation_runs",
|
||||
"sensitive_data_suggestion_runs",
|
||||
]) {
|
||||
await sql.raw(`
|
||||
create trigger ${table}_operation_start_guard
|
||||
before insert or update on ${table}
|
||||
for each row execute function catalog_operation_start_guard()
|
||||
`).execute(db);
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<CatalogDatabase>): Promise<void> {
|
||||
for (const table of [
|
||||
"catalog_sync_runs",
|
||||
"description_generation_runs",
|
||||
"sensitive_data_suggestion_runs",
|
||||
]) {
|
||||
await sql.raw(`drop trigger if exists ${table}_operation_start_guard on ${table}`).execute(db);
|
||||
}
|
||||
await sql`drop function if exists catalog_operation_start_guard()`.execute(db);
|
||||
await sql`drop trigger if exists workspace_databases_configuration_guard on workspace_databases`.execute(db);
|
||||
await sql`drop function if exists catalog_database_configuration_guard()`.execute(db);
|
||||
for (const table of [
|
||||
"catalog_tables",
|
||||
"catalog_columns",
|
||||
"catalog_relationships",
|
||||
"catalog_relationship_columns",
|
||||
"catalog_logical_relationships",
|
||||
"database_bindings",
|
||||
]) {
|
||||
await sql.raw(`drop trigger if exists ${table}_metadata_write_guard on ${table}`).execute(db);
|
||||
}
|
||||
await sql`drop function if exists catalog_metadata_write_guard()`.execute(db);
|
||||
await db.schema.alterTable("workspace_databases")
|
||||
.dropConstraint("workspace_databases_preprocessing_status_check").execute();
|
||||
for (const column of [
|
||||
"preprocessing_error_code",
|
||||
"preprocessing_finished_at",
|
||||
"preprocessing_started_at",
|
||||
"preprocessed_metadata_revision",
|
||||
"preprocessing_input_fingerprint",
|
||||
"preprocessing_status",
|
||||
"metadata_content_revision",
|
||||
]) {
|
||||
await sql.raw(`alter table workspace_databases drop column ${column}`).execute(db);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import type { ResolvedMetadataGenerationModel } from "./metadata-generation-mode
|
||||
|
||||
const MAX_HELPER_OUTPUT_BYTES = 64 * 1024;
|
||||
const helperOutputSchema = z.discriminatedUnion("ok", [
|
||||
z.object({ ok: z.literal(true), content: z.string() }).strict(),
|
||||
z.object({ ok: z.literal(true), content: z.string(), usage: z.object({ input: z.number().int().nonnegative(), cacheRead: z.number().int().nonnegative(), output: z.number().int().nonnegative() }).strict().optional() }).strict(),
|
||||
z.object({ ok: z.literal(false), error: z.literal("provider_failure") }).strict(),
|
||||
]);
|
||||
|
||||
@@ -18,10 +18,12 @@ export interface ModelCompletionRequest {
|
||||
messages: readonly ModelCompletionMessage[];
|
||||
signal: AbortSignal;
|
||||
}
|
||||
export interface ModelCompletionUsage { input: number; cacheRead: number; output: number; }
|
||||
export interface ModelCompletionResult { content: string; usage: ModelCompletionUsage; }
|
||||
|
||||
/** The provider boundary used by Description Generation. */
|
||||
export interface ModelCompleter {
|
||||
complete(request: ModelCompletionRequest): Promise<string>;
|
||||
complete(request: ModelCompletionRequest): Promise<string | ModelCompletionResult>;
|
||||
}
|
||||
|
||||
export class ModelCompletionProviderError extends Error {
|
||||
@@ -47,7 +49,7 @@ export class PythonModelCompleter implements ModelCompleter {
|
||||
terminationGraceMs?: number;
|
||||
}) {}
|
||||
|
||||
async complete(request: ModelCompletionRequest): Promise<string> {
|
||||
async complete(request: ModelCompletionRequest): Promise<ModelCompletionResult> {
|
||||
if (request.signal.aborted) throw new ModelCompletionCancelledError();
|
||||
const payload = {
|
||||
model: `${request.model.provider}/${request.model.model}`,
|
||||
@@ -62,7 +64,7 @@ export class PythonModelCompleter implements ModelCompleter {
|
||||
...(request.model.disableThinking === true ? { disable_thinking: true } : {}),
|
||||
};
|
||||
|
||||
return await new Promise<string>((resolve, reject) => {
|
||||
return await new Promise<ModelCompletionResult>((resolve, reject) => {
|
||||
const child = spawn(
|
||||
this.options.pythonExecutable,
|
||||
["-m", this.options.helperModule ?? "tht.internal.litellm_completion"],
|
||||
@@ -135,7 +137,7 @@ export class PythonModelCompleter implements ModelCompleter {
|
||||
if (!output.ok) return fail();
|
||||
settled = true;
|
||||
cleanup();
|
||||
resolve(output.content);
|
||||
resolve({ content: output.content, usage: output.usage ?? { input: 0, cacheRead: 0, output: 0 } });
|
||||
} catch {
|
||||
fail();
|
||||
}
|
||||
|
||||
@@ -23,7 +23,12 @@ import {
|
||||
type CatalogDatabaseMetadataDeleteTarget,
|
||||
type CatalogMetadataDeleteCounts,
|
||||
type CatalogMetrics,
|
||||
type CatalogRelationship,
|
||||
type CatalogLogicalRelationship,
|
||||
type CatalogLogicalRelationshipCandidate,
|
||||
type CatalogLogicalRelationshipContext,
|
||||
type CatalogPhysicalRelationship,
|
||||
type CatalogPreprocessingStartResult,
|
||||
type CatalogPreprocessingClearResult,
|
||||
type CatalogSchemaDiff,
|
||||
type CatalogSyncCounts,
|
||||
type CatalogSyncEvent,
|
||||
@@ -42,15 +47,20 @@ import {
|
||||
type DescriptionGenerationScope,
|
||||
type ObservedCatalogTable,
|
||||
type ObservedSchemaSnapshot,
|
||||
type SensitiveDataSuggestionEvent,
|
||||
type SensitiveDataSuggestionRun,
|
||||
type SensitiveDataSuggestionRunUpdate,
|
||||
type SensitiveDataSuggestionScope,
|
||||
type SensitivityAnalysisEvent,
|
||||
type SensitivityAnalysisRun,
|
||||
type SensitivityAnalysisRunUpdate,
|
||||
type SensitivityAnalysisScope,
|
||||
type TableSyncRepositoryResult,
|
||||
type WorkspaceDatabase,
|
||||
} from "./types.js";
|
||||
|
||||
type Timestamp = ColumnType<Date, Date | string | undefined, Date | string>;
|
||||
type NullableTimestamp = ColumnType<
|
||||
Date | null,
|
||||
Date | string | null | undefined,
|
||||
Date | string | null
|
||||
>;
|
||||
|
||||
interface WorkspaceDatabaseTable {
|
||||
id: string;
|
||||
@@ -63,6 +73,13 @@ interface WorkspaceDatabaseTable {
|
||||
updatedAt: Timestamp;
|
||||
schemaSyncedVersion: number | null;
|
||||
schemaSyncedAt: Timestamp | null;
|
||||
metadataContentRevision: Generated<number>;
|
||||
preprocessingStatus: Generated<WorkspaceDatabase["preprocessingStatus"]>;
|
||||
preprocessingInputFingerprint: Generated<string | null>;
|
||||
preprocessedMetadataRevision: Generated<number | null>;
|
||||
preprocessingStartedAt: NullableTimestamp;
|
||||
preprocessingFinishedAt: NullableTimestamp;
|
||||
preprocessingErrorCode: Generated<string | null>;
|
||||
}
|
||||
|
||||
interface DatabaseBindingTable {
|
||||
@@ -115,6 +132,7 @@ interface CatalogColumnTable {
|
||||
description: string | null;
|
||||
generatedDescription: string | null;
|
||||
sensitive: Generated<boolean>;
|
||||
sensitivityReason: Generated<string | null>;
|
||||
lastSyncedDatabaseVersion: number | null;
|
||||
lastSyncedAt: Timestamp | null;
|
||||
version: Generated<number>;
|
||||
@@ -145,6 +163,17 @@ interface CatalogRelationshipColumnTable {
|
||||
targetColumnId: string;
|
||||
}
|
||||
|
||||
interface CatalogLogicalRelationshipTable {
|
||||
id: string;
|
||||
databaseId: string;
|
||||
sourceColumnId: string;
|
||||
targetColumnId: string;
|
||||
generated: Generated<boolean>;
|
||||
deletedAt: Timestamp | null;
|
||||
createdAt: Timestamp;
|
||||
updatedAt: Timestamp;
|
||||
}
|
||||
|
||||
interface DescriptionGenerationRunTable {
|
||||
id: string;
|
||||
databaseId: string;
|
||||
@@ -157,6 +186,9 @@ interface DescriptionGenerationRunTable {
|
||||
generated: number;
|
||||
nonGeneratable: number;
|
||||
failed: number;
|
||||
inputTokens: number;
|
||||
cacheReadTokens: number;
|
||||
outputTokens: number;
|
||||
createdAt: Timestamp;
|
||||
startedAt: Timestamp | null;
|
||||
updatedAt: Timestamp;
|
||||
@@ -172,15 +204,21 @@ interface DescriptionGenerationEventTable {
|
||||
createdAt: Timestamp;
|
||||
}
|
||||
|
||||
interface SensitiveDataSuggestionRunTable {
|
||||
interface SensitivityAnalysisRunTable {
|
||||
id: string;
|
||||
databaseId: string;
|
||||
scope: SensitiveDataSuggestionScope;
|
||||
modelId: string;
|
||||
status: SensitiveDataSuggestionRun["status"];
|
||||
scope: SensitivityAnalysisScope;
|
||||
engine: SensitivityAnalysisRun["engine"];
|
||||
modelId: string | null;
|
||||
policyVersion: string | null;
|
||||
status: SensitivityAnalysisRun["status"];
|
||||
total: number;
|
||||
suggestedSensitive: number;
|
||||
suggestedNonSensitive: number;
|
||||
unknown: number;
|
||||
inputTokens: number;
|
||||
cacheReadTokens: number;
|
||||
outputTokens: number;
|
||||
createdAt: Timestamp;
|
||||
startedAt: Timestamp;
|
||||
updatedAt: Timestamp;
|
||||
@@ -188,10 +226,10 @@ interface SensitiveDataSuggestionRunTable {
|
||||
errorSummary: string | null;
|
||||
}
|
||||
|
||||
interface SensitiveDataSuggestionEventTable {
|
||||
interface SensitivityAnalysisEventTable {
|
||||
runId: string;
|
||||
sequence: number;
|
||||
level: SensitiveDataSuggestionEvent["level"];
|
||||
level: SensitivityAnalysisEvent["level"];
|
||||
message: string;
|
||||
createdAt: Timestamp;
|
||||
}
|
||||
@@ -241,10 +279,12 @@ export interface CatalogDatabase {
|
||||
catalogColumns: CatalogColumnTable;
|
||||
catalogRelationships: CatalogRelationshipTable;
|
||||
catalogRelationshipColumns: CatalogRelationshipColumnTable;
|
||||
catalogLogicalRelationships: CatalogLogicalRelationshipTable;
|
||||
descriptionGenerationRuns: DescriptionGenerationRunTable;
|
||||
descriptionGenerationEvents: DescriptionGenerationEventTable;
|
||||
sensitiveDataSuggestionRuns: SensitiveDataSuggestionRunTable;
|
||||
sensitiveDataSuggestionEvents: SensitiveDataSuggestionEventTable;
|
||||
// Legacy physical table names retained for migration and storage compatibility.
|
||||
sensitiveDataSuggestionRuns: SensitivityAnalysisRunTable;
|
||||
sensitiveDataSuggestionEvents: SensitivityAnalysisEventTable;
|
||||
catalogSyncRuns: CatalogSyncRunTable;
|
||||
catalogSyncEvents: CatalogSyncEventTable;
|
||||
}
|
||||
@@ -299,6 +339,19 @@ function serialize(row: JoinedRow): WorkspaceDatabase {
|
||||
lastErrorMessage: present(row.lastErrorMessage),
|
||||
schemaSyncedVersion: present(row.schemaSyncedVersion),
|
||||
schemaSyncedAt: row.schemaSyncedAt == null ? undefined : new Date(row.schemaSyncedAt).toISOString(),
|
||||
metadataContentRevision: Number(row.metadataContentRevision),
|
||||
preprocessingStatus: row.preprocessingStatus,
|
||||
preprocessingInputFingerprint: present(row.preprocessingInputFingerprint),
|
||||
preprocessedMetadataRevision: row.preprocessedMetadataRevision == null
|
||||
? undefined
|
||||
: Number(row.preprocessedMetadataRevision),
|
||||
preprocessingStartedAt: row.preprocessingStartedAt == null
|
||||
? undefined
|
||||
: new Date(row.preprocessingStartedAt).toISOString(),
|
||||
preprocessingFinishedAt: row.preprocessingFinishedAt == null
|
||||
? undefined
|
||||
: new Date(row.preprocessingFinishedAt).toISOString(),
|
||||
preprocessingErrorCode: present(row.preprocessingErrorCode),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -335,6 +388,7 @@ function serializeColumn(row: Selectable<CatalogColumnTable>, foreignKeyCount =
|
||||
description: row.description,
|
||||
generatedDescription: row.generatedDescription,
|
||||
sensitive: row.sensitive,
|
||||
sensitivityReason: row.sensitivityReason,
|
||||
lastSyncedDatabaseVersion: row.lastSyncedDatabaseVersion,
|
||||
lastSyncedAt: row.lastSyncedAt === null ? null : new Date(row.lastSyncedAt).toISOString(),
|
||||
version: row.version,
|
||||
@@ -381,9 +435,9 @@ function serializeDescriptionGenerationEvent(
|
||||
return { ...row, createdAt: new Date(row.createdAt).toISOString() };
|
||||
}
|
||||
|
||||
function serializeSensitiveDataSuggestionRun(
|
||||
row: Selectable<SensitiveDataSuggestionRunTable>,
|
||||
): SensitiveDataSuggestionRun {
|
||||
function serializeSensitivityAnalysisRun(
|
||||
row: Selectable<SensitivityAnalysisRunTable>,
|
||||
): SensitivityAnalysisRun {
|
||||
const stamp = (value: Date | string | null) => value === null ? null : new Date(value).toISOString();
|
||||
return {
|
||||
...row,
|
||||
@@ -394,9 +448,9 @@ function serializeSensitiveDataSuggestionRun(
|
||||
};
|
||||
}
|
||||
|
||||
function serializeSensitiveDataSuggestionEvent(
|
||||
row: Selectable<SensitiveDataSuggestionEventTable>,
|
||||
): SensitiveDataSuggestionEvent {
|
||||
function serializeSensitivityAnalysisEvent(
|
||||
row: Selectable<SensitivityAnalysisEventTable>,
|
||||
): SensitivityAnalysisEvent {
|
||||
return { ...row, createdAt: new Date(row.createdAt).toISOString() };
|
||||
}
|
||||
|
||||
@@ -450,6 +504,98 @@ export class KyselyCatalogRepository implements CatalogRepository {
|
||||
return id ? await this.get(id.id) : undefined;
|
||||
}
|
||||
|
||||
async beginPreprocessing(
|
||||
workspaceId: string,
|
||||
inputFingerprint: string,
|
||||
): Promise<CatalogPreprocessingStartResult> {
|
||||
return await this.db.transaction().execute(async (trx) => {
|
||||
const database = await trx.selectFrom("workspaceDatabases")
|
||||
.selectAll()
|
||||
.where("workspaceId", "=", workspaceId)
|
||||
.forUpdate()
|
||||
.executeTakeFirst();
|
||||
if (!database) return { kind: "not_found" };
|
||||
if (database.preprocessingStatus === "running") return { kind: "already_running" };
|
||||
if (database.schemaSyncedVersion !== database.version) return { kind: "schema_stale" };
|
||||
|
||||
const activeSync = await trx.selectFrom("catalogSyncRuns")
|
||||
.select("id")
|
||||
.where("databaseId", "=", database.id)
|
||||
.where("state", "in", ["queued", "running", "awaiting_confirmation", "applying"])
|
||||
.executeTakeFirst();
|
||||
const activeDescriptions = await trx.selectFrom("descriptionGenerationRuns")
|
||||
.select("id")
|
||||
.where("databaseId", "=", database.id)
|
||||
.where("status", "in", ["queued", "running"])
|
||||
.executeTakeFirst();
|
||||
const activeSensitivity = await trx.selectFrom("sensitiveDataSuggestionRuns")
|
||||
.select("id")
|
||||
.where("databaseId", "=", database.id)
|
||||
.where("status", "=", "running")
|
||||
.executeTakeFirst();
|
||||
if (activeSync || activeDescriptions || activeSensitivity) return { kind: "catalog_busy" };
|
||||
|
||||
await trx.updateTable("workspaceDatabases")
|
||||
.set({
|
||||
preprocessingStatus: "running",
|
||||
preprocessingInputFingerprint: inputFingerprint,
|
||||
preprocessedMetadataRevision: null,
|
||||
preprocessingStartedAt: sql`now()`,
|
||||
preprocessingFinishedAt: null,
|
||||
preprocessingErrorCode: null,
|
||||
updatedAt: sql`now()`,
|
||||
})
|
||||
.where("id", "=", database.id)
|
||||
.execute();
|
||||
return { kind: "started", database: (await selectOne(trx, database.id))! };
|
||||
});
|
||||
}
|
||||
|
||||
async finishPreprocessing(
|
||||
workspaceId: string,
|
||||
metadataContentRevision: number,
|
||||
inputFingerprint: string,
|
||||
outcome: { status: "succeeded" } | { status: "failed"; errorCode: string },
|
||||
): Promise<WorkspaceDatabase | undefined> {
|
||||
const result = await this.db.updateTable("workspaceDatabases")
|
||||
.set({
|
||||
preprocessingStatus: outcome.status,
|
||||
preprocessedMetadataRevision: outcome.status === "succeeded" ? metadataContentRevision : null,
|
||||
preprocessingFinishedAt: sql`now()`,
|
||||
preprocessingErrorCode: outcome.status === "failed" ? outcome.errorCode : null,
|
||||
updatedAt: sql`now()`,
|
||||
})
|
||||
.where("workspaceId", "=", workspaceId)
|
||||
.where("preprocessingStatus", "=", "running")
|
||||
.where("metadataContentRevision", "=", metadataContentRevision)
|
||||
.where("preprocessingInputFingerprint", "=", inputFingerprint)
|
||||
.returning("id")
|
||||
.executeTakeFirst();
|
||||
return result ? await this.get(result.id) : undefined;
|
||||
}
|
||||
|
||||
async clearPreprocessing(workspaceId: string): Promise<CatalogPreprocessingClearResult> {
|
||||
return await this.db.transaction().execute(async (trx) => {
|
||||
const database = await trx.selectFrom("workspaceDatabases")
|
||||
.select(["id", "preprocessingStatus"])
|
||||
.where("workspaceId", "=", workspaceId)
|
||||
.forUpdate()
|
||||
.executeTakeFirst();
|
||||
if (!database) return { kind: "not_found" };
|
||||
if (database.preprocessingStatus === "running") return { kind: "already_running" };
|
||||
await trx.updateTable("workspaceDatabases").set({
|
||||
preprocessingStatus: "failed",
|
||||
preprocessingInputFingerprint: null,
|
||||
preprocessedMetadataRevision: null,
|
||||
preprocessingStartedAt: null,
|
||||
preprocessingFinishedAt: sql`now()`,
|
||||
preprocessingErrorCode: "derived_data_cleared",
|
||||
updatedAt: sql`now()`,
|
||||
}).where("id", "=", database.id).execute();
|
||||
return { kind: "cleared", database: (await selectOne(trx, database.id))! };
|
||||
});
|
||||
}
|
||||
|
||||
async getCatalogMetrics(databaseId?: string): Promise<CatalogMetrics | undefined> {
|
||||
const result = await sql<CatalogMetricsRow>`
|
||||
WITH requested_database AS (
|
||||
@@ -491,10 +637,18 @@ export class KyselyCatalogRepository implements CatalogRepository {
|
||||
relationship_metrics AS (
|
||||
SELECT
|
||||
count(*)::int AS relationships,
|
||||
max(catalog_relationships.updated_at) AS updated_at
|
||||
FROM catalog_relationships
|
||||
INNER JOIN selected_databases
|
||||
ON selected_databases.id = catalog_relationships.database_id
|
||||
max(relationship.updated_at) AS updated_at
|
||||
FROM (
|
||||
SELECT catalog_relationships.updated_at
|
||||
FROM catalog_relationships
|
||||
INNER JOIN selected_databases
|
||||
ON selected_databases.id = catalog_relationships.database_id
|
||||
UNION ALL
|
||||
SELECT catalog_logical_relationships.updated_at
|
||||
FROM catalog_logical_relationships
|
||||
INNER JOIN selected_databases
|
||||
ON selected_databases.id = catalog_logical_relationships.database_id
|
||||
) AS relationship
|
||||
)
|
||||
SELECT
|
||||
(SELECT count(*)::int FROM selected_databases) AS "databaseCount",
|
||||
@@ -703,6 +857,7 @@ export class KyselyCatalogRepository implements CatalogRepository {
|
||||
description: string | null,
|
||||
generatedDescription: string | null,
|
||||
sensitive?: boolean,
|
||||
sensitivityReason?: string | null,
|
||||
): Promise<CatalogColumn | undefined> {
|
||||
const belongs = await this.db.selectFrom("catalogTables").select("id")
|
||||
.where("id", "=", tableId).where("databaseId", "=", databaseId).executeTakeFirst();
|
||||
@@ -711,6 +866,9 @@ export class KyselyCatalogRepository implements CatalogRepository {
|
||||
description,
|
||||
generatedDescription,
|
||||
...(sensitive === undefined ? {} : { sensitive }),
|
||||
...(sensitive === false
|
||||
? { sensitivityReason: null }
|
||||
: sensitivityReason === undefined ? {} : { sensitivityReason }),
|
||||
version: sql`version + 1`,
|
||||
updatedAt: sql`now()`,
|
||||
}).where("id", "=", columnId).where("tableId", "=", tableId)
|
||||
@@ -724,7 +882,9 @@ export class KyselyCatalogRepository implements CatalogRepository {
|
||||
targetIds: readonly string[],
|
||||
): Promise<CatalogDescriptionConsolidationCounts | undefined> {
|
||||
const selectedTargetIds = [...new Set(targetIds)];
|
||||
if (selectedTargetIds.length === 0) return undefined;
|
||||
if (target !== "database" && target !== "database_columns" && selectedTargetIds.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return await this.db.transaction().execute(async (trx) => {
|
||||
const database = await trx.selectFrom("workspaceDatabases").select("id")
|
||||
.where("id", "=", databaseId).forUpdate().executeTakeFirst();
|
||||
@@ -754,29 +914,51 @@ export class KyselyCatalogRepository implements CatalogRepository {
|
||||
};
|
||||
}
|
||||
|
||||
const tableRows = await trx.selectFrom("catalogTables").select("id")
|
||||
.where("databaseId", "=", databaseId).execute();
|
||||
const rows = tableRows.length === 0 ? [] : await trx.selectFrom("catalogColumns")
|
||||
.select(["id", "generatedDescription"])
|
||||
.where("tableId", "in", tableRows.map((table) => table.id))
|
||||
.where("id", "in", selectedTargetIds)
|
||||
const tableRows = await trx.selectFrom("catalogTables").select(["id", "generatedDescription"])
|
||||
.where("databaseId", "=", databaseId)
|
||||
.orderBy("id")
|
||||
.forUpdate()
|
||||
.execute();
|
||||
if (rows.length !== selectedTargetIds.length) return undefined;
|
||||
const copiedTableIds = target === "database"
|
||||
? tableRows
|
||||
.filter((row) => Boolean(row.generatedDescription?.trim()))
|
||||
.map((row) => row.id)
|
||||
: [];
|
||||
if (copiedTableIds.length > 0) {
|
||||
await trx.updateTable("catalogTables").set({
|
||||
description: sql`generated_description`,
|
||||
version: sql`version + 1`,
|
||||
updatedAt: sql`now()`,
|
||||
}).where("id", "in", copiedTableIds).execute();
|
||||
}
|
||||
const rows = tableRows.length === 0 ? [] : await trx.selectFrom("catalogColumns")
|
||||
.select(["id", "generatedDescription"])
|
||||
.where("tableId", "in", tableRows.map((table) => table.id))
|
||||
.$if(target === "columns", (query) => query.where("id", "in", selectedTargetIds))
|
||||
.orderBy("id")
|
||||
.forUpdate()
|
||||
.execute();
|
||||
if (target === "columns" && rows.length !== selectedTargetIds.length) return undefined;
|
||||
const copiedIds = rows
|
||||
.filter((row) => Boolean(row.generatedDescription?.trim()))
|
||||
.map((row) => row.id);
|
||||
if (copiedIds.length > 0) {
|
||||
await trx.updateTable("catalogColumns").set({
|
||||
let update = trx.updateTable("catalogColumns").set({
|
||||
description: sql`generated_description`,
|
||||
version: sql`version + 1`,
|
||||
updatedAt: sql`now()`,
|
||||
}).where("id", "in", copiedIds).execute();
|
||||
});
|
||||
update = target === "database" || target === "database_columns"
|
||||
? update
|
||||
.where("tableId", "in", tableRows.map((table) => table.id))
|
||||
.where(sql<boolean>`nullif(btrim(generated_description), '') is not null`)
|
||||
: update.where("id", "in", copiedIds);
|
||||
await update.execute();
|
||||
}
|
||||
return {
|
||||
copied: copiedIds.length,
|
||||
skipped: selectedTargetIds.length - copiedIds.length,
|
||||
copied: copiedTableIds.length + copiedIds.length,
|
||||
skipped: (target === "database" ? tableRows.length : 0) - copiedTableIds.length
|
||||
+ rows.length - copiedIds.length,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -801,6 +983,9 @@ export class KyselyCatalogRepository implements CatalogRepository {
|
||||
generated: 0,
|
||||
nonGeneratable: 0,
|
||||
failed: 0,
|
||||
inputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
outputTokens: 0,
|
||||
startedAt: null,
|
||||
finishedAt: null,
|
||||
errorSummary: null,
|
||||
@@ -914,49 +1099,55 @@ export class KyselyCatalogRepository implements CatalogRepository {
|
||||
return rows.map(serializeDescriptionGenerationEvent);
|
||||
}
|
||||
|
||||
async createSensitiveDataSuggestionRun(
|
||||
async createSensitivityAnalysisRun(
|
||||
databaseId: string,
|
||||
scope: SensitiveDataSuggestionScope,
|
||||
modelId: string,
|
||||
): Promise<SensitiveDataSuggestionRun> {
|
||||
scope: SensitivityAnalysisScope,
|
||||
origin: { engine: "llm"; modelId: string } | { engine: "local"; policyVersion: string },
|
||||
): Promise<SensitivityAnalysisRun> {
|
||||
const row = await this.db.insertInto("sensitiveDataSuggestionRuns").values({
|
||||
id: randomUUID(),
|
||||
databaseId,
|
||||
scope,
|
||||
modelId,
|
||||
engine: origin.engine,
|
||||
modelId: origin.engine === "llm" ? origin.modelId : null,
|
||||
policyVersion: origin.engine === "local" ? origin.policyVersion : null,
|
||||
status: "running",
|
||||
total: 0,
|
||||
suggestedSensitive: 0,
|
||||
suggestedNonSensitive: 0,
|
||||
unknown: 0,
|
||||
inputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
outputTokens: 0,
|
||||
finishedAt: null,
|
||||
errorSummary: null,
|
||||
}).returningAll().executeTakeFirstOrThrow();
|
||||
return serializeSensitiveDataSuggestionRun(row);
|
||||
return serializeSensitivityAnalysisRun(row);
|
||||
}
|
||||
|
||||
async getSensitiveDataSuggestionRun(
|
||||
async getSensitivityAnalysisRun(
|
||||
runId: string,
|
||||
): Promise<SensitiveDataSuggestionRun | undefined> {
|
||||
): Promise<SensitivityAnalysisRun | undefined> {
|
||||
const row = await this.db.selectFrom("sensitiveDataSuggestionRuns")
|
||||
.selectAll()
|
||||
.where("id", "=", runId)
|
||||
.executeTakeFirst();
|
||||
return row ? serializeSensitiveDataSuggestionRun(row) : undefined;
|
||||
return row ? serializeSensitivityAnalysisRun(row) : undefined;
|
||||
}
|
||||
|
||||
async listSensitiveDataSuggestionRuns(limit = 50): Promise<SensitiveDataSuggestionRun[]> {
|
||||
async listSensitivityAnalysisRuns(limit = 50): Promise<SensitivityAnalysisRun[]> {
|
||||
const rows = await this.db.selectFrom("sensitiveDataSuggestionRuns")
|
||||
.selectAll()
|
||||
.orderBy("createdAt", "desc")
|
||||
.orderBy("id", "desc")
|
||||
.limit(limit)
|
||||
.execute();
|
||||
return rows.map(serializeSensitiveDataSuggestionRun);
|
||||
return rows.map(serializeSensitivityAnalysisRun);
|
||||
}
|
||||
|
||||
async interruptActiveSensitiveDataSuggestionRuns(
|
||||
async interruptActiveSensitivityAnalysisRuns(
|
||||
errorSummary: string,
|
||||
): Promise<SensitiveDataSuggestionRun[]> {
|
||||
): Promise<SensitivityAnalysisRun[]> {
|
||||
const rows = await this.db.updateTable("sensitiveDataSuggestionRuns")
|
||||
.set({
|
||||
status: "interrupted",
|
||||
@@ -967,34 +1158,34 @@ export class KyselyCatalogRepository implements CatalogRepository {
|
||||
.where("status", "=", "running")
|
||||
.returningAll()
|
||||
.execute();
|
||||
return rows.map(serializeSensitiveDataSuggestionRun);
|
||||
return rows.map(serializeSensitivityAnalysisRun);
|
||||
}
|
||||
|
||||
async updateSensitiveDataSuggestionRun(
|
||||
async updateSensitivityAnalysisRun(
|
||||
runId: string,
|
||||
update: SensitiveDataSuggestionRunUpdate,
|
||||
): Promise<SensitiveDataSuggestionRun | undefined> {
|
||||
update: SensitivityAnalysisRunUpdate,
|
||||
): Promise<SensitivityAnalysisRun | undefined> {
|
||||
const values: any = { ...update, updatedAt: sql`now()` };
|
||||
const row = await this.db.updateTable("sensitiveDataSuggestionRuns")
|
||||
.set(values)
|
||||
.where("id", "=", runId)
|
||||
.returningAll()
|
||||
.executeTakeFirst();
|
||||
return row ? serializeSensitiveDataSuggestionRun(row) : undefined;
|
||||
return row ? serializeSensitivityAnalysisRun(row) : undefined;
|
||||
}
|
||||
|
||||
async appendSensitiveDataSuggestionEvent(
|
||||
async appendSensitivityAnalysisEvent(
|
||||
runId: string,
|
||||
level: SensitiveDataSuggestionEvent["level"],
|
||||
level: SensitivityAnalysisEvent["level"],
|
||||
message: string,
|
||||
): Promise<SensitiveDataSuggestionEvent> {
|
||||
): Promise<SensitivityAnalysisEvent> {
|
||||
return await this.db.transaction().execute(async (trx) => {
|
||||
const run = await trx.selectFrom("sensitiveDataSuggestionRuns")
|
||||
.select("id")
|
||||
.where("id", "=", runId)
|
||||
.forUpdate()
|
||||
.executeTakeFirst();
|
||||
if (!run) throw new CatalogConflictError("Sensitive Data Suggestion Run does not exist");
|
||||
if (!run) throw new CatalogConflictError("Sensitivity Analysis Run does not exist");
|
||||
const current = await trx.selectFrom("sensitiveDataSuggestionEvents")
|
||||
.select(sql<number>`coalesce(max(sequence), 0)::int`.as("sequence"))
|
||||
.where("runId", "=", runId)
|
||||
@@ -1005,24 +1196,24 @@ export class KyselyCatalogRepository implements CatalogRepository {
|
||||
level,
|
||||
message,
|
||||
}).returningAll().executeTakeFirstOrThrow();
|
||||
return serializeSensitiveDataSuggestionEvent(row);
|
||||
return serializeSensitivityAnalysisEvent(row);
|
||||
});
|
||||
}
|
||||
|
||||
async listSensitiveDataSuggestionEvents(
|
||||
async listSensitivityAnalysisEvents(
|
||||
runId: string,
|
||||
afterSequence = 0,
|
||||
): Promise<SensitiveDataSuggestionEvent[]> {
|
||||
): Promise<SensitivityAnalysisEvent[]> {
|
||||
const rows = await this.db.selectFrom("sensitiveDataSuggestionEvents")
|
||||
.selectAll()
|
||||
.where("runId", "=", runId)
|
||||
.where("sequence", ">", afterSequence)
|
||||
.orderBy("sequence")
|
||||
.execute();
|
||||
return rows.map(serializeSensitiveDataSuggestionEvent);
|
||||
return rows.map(serializeSensitivityAnalysisEvent);
|
||||
}
|
||||
|
||||
async listRelationships(databaseId: string): Promise<CatalogRelationship[]> {
|
||||
async listRelationships(databaseId: string): Promise<CatalogPhysicalRelationship[]> {
|
||||
const rows = await this.db.selectFrom("catalogRelationships as relationship")
|
||||
.innerJoin("catalogTables as sourceTable", "sourceTable.id", "relationship.sourceTableId")
|
||||
.innerJoin("catalogTables as targetTable", "targetTable.id", "relationship.targetTableId")
|
||||
@@ -1049,7 +1240,7 @@ export class KyselyCatalogRepository implements CatalogRepository {
|
||||
.where("pair.relationshipId", "in", rows.map((row) => row.id))
|
||||
.orderBy("pair.relationshipId").orderBy("pair.position")
|
||||
.execute();
|
||||
const byRelationship = new Map<string, CatalogRelationship["columns"]>();
|
||||
const byRelationship = new Map<string, CatalogPhysicalRelationship["columns"]>();
|
||||
for (const pair of pairs) {
|
||||
const items = byRelationship.get(pair.relationshipId) ?? [];
|
||||
items.push(pair);
|
||||
@@ -1061,9 +1252,160 @@ export class KyselyCatalogRepository implements CatalogRepository {
|
||||
lastSyncedAt: row.lastSyncedAt === null ? null : new Date(row.lastSyncedAt).toISOString(),
|
||||
createdAt: new Date(row.createdAt).toISOString(),
|
||||
updatedAt: new Date(row.updatedAt).toISOString(),
|
||||
origin: "physical" as const,
|
||||
status: "active" as const,
|
||||
}));
|
||||
}
|
||||
|
||||
async listLogicalRelationships(databaseId: string): Promise<CatalogLogicalRelationship[]> {
|
||||
const rows = await this.db.selectFrom("catalogLogicalRelationships as relationship")
|
||||
.innerJoin("catalogColumns as sourceColumn", "sourceColumn.id", "relationship.sourceColumnId")
|
||||
.innerJoin("catalogTables as sourceTable", "sourceTable.id", "sourceColumn.tableId")
|
||||
.innerJoin("catalogColumns as targetColumn", "targetColumn.id", "relationship.targetColumnId")
|
||||
.innerJoin("catalogTables as targetTable", "targetTable.id", "targetColumn.tableId")
|
||||
.select([
|
||||
"relationship.id", "relationship.databaseId", "relationship.generated",
|
||||
"relationship.deletedAt", "relationship.createdAt", "relationship.updatedAt",
|
||||
"sourceTable.id as sourceTableId", "sourceTable.name as sourceTableName",
|
||||
"sourceColumn.id as sourceColumnId", "sourceColumn.name as sourceColumnName",
|
||||
"targetTable.id as targetTableId", "targetTable.name as targetTableName",
|
||||
"targetColumn.id as targetColumnId", "targetColumn.name as targetColumnName",
|
||||
])
|
||||
.where("relationship.databaseId", "=", databaseId)
|
||||
.orderBy("sourceTable.name")
|
||||
.orderBy("sourceColumn.name")
|
||||
.orderBy("targetTable.name")
|
||||
.orderBy("targetColumn.name")
|
||||
.execute();
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
databaseId: row.databaseId,
|
||||
constraintName: null,
|
||||
sourceTableId: row.sourceTableId,
|
||||
sourceTableName: row.sourceTableName,
|
||||
targetTableId: row.targetTableId,
|
||||
targetTableName: row.targetTableName,
|
||||
updateRule: null,
|
||||
deleteRule: null,
|
||||
deferrable: false,
|
||||
initiallyDeferred: false,
|
||||
columns: [{
|
||||
position: 1,
|
||||
sourceColumnId: row.sourceColumnId,
|
||||
sourceColumnName: row.sourceColumnName,
|
||||
targetColumnId: row.targetColumnId,
|
||||
targetColumnName: row.targetColumnName,
|
||||
}],
|
||||
lastSyncedDatabaseVersion: null,
|
||||
lastSyncedAt: null,
|
||||
createdAt: new Date(row.createdAt).toISOString(),
|
||||
updatedAt: new Date(row.updatedAt).toISOString(),
|
||||
origin: row.generated ? "generated" : "manual",
|
||||
status: row.deletedAt === null ? "active" : "excluded",
|
||||
}));
|
||||
}
|
||||
|
||||
async getLogicalRelationshipContext(
|
||||
databaseId: string,
|
||||
): Promise<CatalogLogicalRelationshipContext | undefined> {
|
||||
if (!(await selectOne(this.db, databaseId))) return undefined;
|
||||
const columns = await this.db.selectFrom("catalogColumns as column")
|
||||
.innerJoin("catalogTables as table", "table.id", "column.tableId")
|
||||
.select([
|
||||
"column.id as columnId", "column.name as columnName", "column.dataType",
|
||||
"column.primaryKeyPosition", "table.id as tableId", "table.name as tableName",
|
||||
])
|
||||
.where("table.databaseId", "=", databaseId)
|
||||
.orderBy("table.name")
|
||||
.orderBy("column.ordinalPosition")
|
||||
.execute();
|
||||
const primaryKeyCounts = new Map<string, number>();
|
||||
for (const column of columns) {
|
||||
if (column.primaryKeyPosition !== null) {
|
||||
primaryKeyCounts.set(column.tableId, (primaryKeyCounts.get(column.tableId) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
const physicalPairs = await this.db.selectFrom("catalogRelationshipColumns as pair")
|
||||
.innerJoin("catalogRelationships as relationship", "relationship.id", "pair.relationshipId")
|
||||
.select(["pair.sourceColumnId", "pair.targetColumnId"])
|
||||
.where("relationship.databaseId", "=", databaseId)
|
||||
.execute();
|
||||
return {
|
||||
endpoints: columns.map((column) => ({
|
||||
...column,
|
||||
tablePrimaryKeyColumnCount: primaryKeyCounts.get(column.tableId) ?? 0,
|
||||
})),
|
||||
physicalPairs,
|
||||
logicalRelationships: await this.listLogicalRelationships(databaseId),
|
||||
};
|
||||
}
|
||||
|
||||
async insertLogicalRelationship(
|
||||
databaseId: string,
|
||||
sourceColumnId: string,
|
||||
targetColumnId: string,
|
||||
generated: boolean,
|
||||
): Promise<CatalogLogicalRelationship | undefined> {
|
||||
const id = randomUUID();
|
||||
const inserted = await this.db.insertInto("catalogLogicalRelationships").values({
|
||||
id, databaseId, sourceColumnId, targetColumnId, generated,
|
||||
}).onConflict((conflict) => conflict
|
||||
.columns(["databaseId", "sourceColumnId", "targetColumnId"])
|
||||
.doNothing())
|
||||
.returning("id")
|
||||
.executeTakeFirst();
|
||||
if (!inserted) return undefined;
|
||||
return (await this.listLogicalRelationships(databaseId)).find((item) => item.id === id);
|
||||
}
|
||||
|
||||
async insertGeneratedLogicalRelationships(
|
||||
databaseId: string,
|
||||
candidates: readonly CatalogLogicalRelationshipCandidate[],
|
||||
): Promise<number> {
|
||||
if (candidates.length === 0) return 0;
|
||||
return await this.db.transaction().execute(async (trx) => {
|
||||
let added = 0;
|
||||
for (const candidate of candidates) {
|
||||
const inserted = await trx.insertInto("catalogLogicalRelationships").values({
|
||||
id: randomUUID(),
|
||||
databaseId,
|
||||
sourceColumnId: candidate.sourceColumnId,
|
||||
targetColumnId: candidate.targetColumnId,
|
||||
generated: true,
|
||||
}).onConflict((conflict) => conflict
|
||||
.columns(["databaseId", "sourceColumnId", "targetColumnId"])
|
||||
.doNothing())
|
||||
.returning("id")
|
||||
.executeTakeFirst();
|
||||
if (inserted) added += 1;
|
||||
}
|
||||
return added;
|
||||
});
|
||||
}
|
||||
|
||||
async setLogicalRelationshipStatus(
|
||||
databaseId: string,
|
||||
relationshipId: string,
|
||||
status: CatalogLogicalRelationship["status"],
|
||||
): Promise<CatalogLogicalRelationship | undefined> {
|
||||
const updated = await this.db.updateTable("catalogLogicalRelationships")
|
||||
.set({ deletedAt: status === "excluded" ? sql`now()` : null, updatedAt: sql`now()` })
|
||||
.where("databaseId", "=", databaseId)
|
||||
.where("id", "=", relationshipId)
|
||||
.returning("id")
|
||||
.executeTakeFirst();
|
||||
if (!updated) return undefined;
|
||||
return (await this.listLogicalRelationships(databaseId)).find((item) => item.id === relationshipId);
|
||||
}
|
||||
|
||||
async deleteLogicalRelationship(databaseId: string, relationshipId: string): Promise<boolean> {
|
||||
const result = await this.db.deleteFrom("catalogLogicalRelationships")
|
||||
.where("databaseId", "=", databaseId)
|
||||
.where("id", "=", relationshipId)
|
||||
.executeTakeFirst();
|
||||
return result.numDeletedRows > 0n;
|
||||
}
|
||||
|
||||
async deleteDatabaseMetadata(
|
||||
databaseIds: readonly string[],
|
||||
target: CatalogDatabaseMetadataDeleteTarget,
|
||||
@@ -1076,16 +1418,25 @@ export class KyselyCatalogRepository implements CatalogRepository {
|
||||
if (databases.length !== selectedDatabaseIds.length) return undefined;
|
||||
|
||||
if (target === "relationships") {
|
||||
const count = await trx.selectFrom("catalogRelationships")
|
||||
const physicalCount = await trx.selectFrom("catalogRelationships")
|
||||
.select(sql<number>`count(*)::int`.as("count"))
|
||||
.where("databaseId", "in", selectedDatabaseIds).executeTakeFirst();
|
||||
const logicalCount = await trx.selectFrom("catalogLogicalRelationships")
|
||||
.select(sql<number>`count(*)::int`.as("count"))
|
||||
.where("databaseId", "in", selectedDatabaseIds).executeTakeFirst();
|
||||
await trx.deleteFrom("catalogLogicalRelationships")
|
||||
.where("databaseId", "in", selectedDatabaseIds).execute();
|
||||
await trx.deleteFrom("catalogRelationships")
|
||||
.where("databaseId", "in", selectedDatabaseIds).execute();
|
||||
await trx.updateTable("workspaceDatabases").set({
|
||||
schemaSyncedVersion: null,
|
||||
schemaSyncedAt: null,
|
||||
}).where("id", "in", selectedDatabaseIds).execute();
|
||||
return { tables: 0, columns: 0, relationships: Number(count?.count ?? 0) };
|
||||
return {
|
||||
tables: 0,
|
||||
columns: 0,
|
||||
relationships: Number(physicalCount?.count ?? 0) + Number(logicalCount?.count ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
const tableCount = await trx.selectFrom("catalogTables")
|
||||
@@ -1095,7 +1446,10 @@ export class KyselyCatalogRepository implements CatalogRepository {
|
||||
.innerJoin("catalogTables", "catalogTables.id", "catalogColumns.tableId")
|
||||
.select(sql<number>`count(*)::int`.as("count"))
|
||||
.where("catalogTables.databaseId", "in", selectedDatabaseIds).executeTakeFirst();
|
||||
const relationshipCount = await trx.selectFrom("catalogRelationships")
|
||||
const physicalRelationshipCount = await trx.selectFrom("catalogRelationships")
|
||||
.select(sql<number>`count(*)::int`.as("count"))
|
||||
.where("databaseId", "in", selectedDatabaseIds).executeTakeFirst();
|
||||
const logicalRelationshipCount = await trx.selectFrom("catalogLogicalRelationships")
|
||||
.select(sql<number>`count(*)::int`.as("count"))
|
||||
.where("databaseId", "in", selectedDatabaseIds).executeTakeFirst();
|
||||
await trx.deleteFrom("catalogTables")
|
||||
@@ -1107,7 +1461,8 @@ export class KyselyCatalogRepository implements CatalogRepository {
|
||||
return {
|
||||
tables: Number(tableCount?.count ?? 0),
|
||||
columns: Number(columnCount?.count ?? 0),
|
||||
relationships: Number(relationshipCount?.count ?? 0),
|
||||
relationships: Number(physicalRelationshipCount?.count ?? 0)
|
||||
+ Number(logicalRelationshipCount?.count ?? 0),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1141,13 +1496,34 @@ export class KyselyCatalogRepository implements CatalogRepository {
|
||||
return { tables: 0, columns: Number(count?.count ?? 0), relationships: 0 };
|
||||
}
|
||||
|
||||
const count = await trx.selectFrom("catalogRelationships")
|
||||
const physicalCount = await trx.selectFrom("catalogRelationships")
|
||||
.select(sql<number>`count(*)::int`.as("count"))
|
||||
.where("databaseId", "=", databaseId)
|
||||
.where((eb) => eb.or([
|
||||
eb("sourceTableId", "in", selectedTableIds),
|
||||
eb("targetTableId", "in", selectedTableIds),
|
||||
])).executeTakeFirst();
|
||||
const selectedColumnIds = (await trx.selectFrom("catalogColumns")
|
||||
.select("id")
|
||||
.where("tableId", "in", selectedTableIds)
|
||||
.execute()).map((column) => column.id);
|
||||
const logicalCount = selectedColumnIds.length === 0
|
||||
? undefined
|
||||
: await trx.selectFrom("catalogLogicalRelationships")
|
||||
.select(sql<number>`count(*)::int`.as("count"))
|
||||
.where("databaseId", "=", databaseId)
|
||||
.where((eb) => eb.or([
|
||||
eb("sourceColumnId", "in", selectedColumnIds),
|
||||
eb("targetColumnId", "in", selectedColumnIds),
|
||||
])).executeTakeFirst();
|
||||
if (selectedColumnIds.length > 0) {
|
||||
await trx.deleteFrom("catalogLogicalRelationships")
|
||||
.where("databaseId", "=", databaseId)
|
||||
.where((eb) => eb.or([
|
||||
eb("sourceColumnId", "in", selectedColumnIds),
|
||||
eb("targetColumnId", "in", selectedColumnIds),
|
||||
])).execute();
|
||||
}
|
||||
await trx.deleteFrom("catalogRelationships")
|
||||
.where("databaseId", "=", databaseId)
|
||||
.where((eb) => eb.or([
|
||||
@@ -1158,7 +1534,11 @@ export class KyselyCatalogRepository implements CatalogRepository {
|
||||
schemaSyncedVersion: null,
|
||||
schemaSyncedAt: null,
|
||||
}).where("id", "=", databaseId).execute();
|
||||
return { tables: 0, columns: 0, relationships: Number(count?.count ?? 0) };
|
||||
return {
|
||||
tables: 0,
|
||||
columns: 0,
|
||||
relationships: Number(physicalCount?.count ?? 0) + Number(logicalCount?.count ?? 0),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1224,6 +1604,7 @@ export class KyselyCatalogRepository implements CatalogRepository {
|
||||
scope: CatalogSyncScope,
|
||||
tableIds: readonly string[],
|
||||
snapshot: ObservedSchemaSnapshot,
|
||||
syncRunId?: string,
|
||||
): Promise<CatalogSyncCounts | undefined> {
|
||||
return await this.db.transaction().execute(async (trx) => {
|
||||
const database = await trx.selectFrom("workspaceDatabases").select("version")
|
||||
@@ -1399,6 +1780,10 @@ export class KyselyCatalogRepository implements CatalogRepository {
|
||||
schemaSyncedAt: now,
|
||||
}).where("id", "=", databaseId).execute();
|
||||
}
|
||||
if (syncRunId) {
|
||||
await trx.updateTable("catalogSyncRuns").set({ phase: "memory_cleanup" })
|
||||
.where("id", "=", syncRunId).where("databaseId", "=", databaseId).execute();
|
||||
}
|
||||
return {
|
||||
tables: snapshot.tables.length,
|
||||
columns: scope === "tables" ? undefined : snapshot.columns.length,
|
||||
@@ -1502,7 +1887,7 @@ export class KyselyCatalogRepository implements CatalogRepository {
|
||||
|
||||
async interruptActiveSyncRuns(): Promise<void> {
|
||||
await this.db.updateTable("catalogSyncRuns").set({
|
||||
state: "interrupted", phase: "completed", errorCode: "worker_restarted",
|
||||
state: "interrupted", phase: sql`case when phase='memory_cleanup' then phase else 'completed' end`, errorCode: "worker_restarted",
|
||||
errorMessage: "Synchronization was interrupted by a backend restart.",
|
||||
finishedAt: sql`now()`, updatedAt: sql`now()`,
|
||||
leaseOwner: null, leaseExpiresAt: null,
|
||||
@@ -1592,6 +1977,9 @@ export class UnavailableCatalogRepository implements CatalogRepository {
|
||||
async list(): Promise<WorkspaceDatabase[]> { return this.fail(); }
|
||||
async get(): Promise<WorkspaceDatabase | undefined> { return this.fail(); }
|
||||
async getByWorkspace(): Promise<WorkspaceDatabase | undefined> { return this.fail(); }
|
||||
async beginPreprocessing(): Promise<CatalogPreprocessingStartResult> { return this.fail(); }
|
||||
async finishPreprocessing(): Promise<WorkspaceDatabase | undefined> { return this.fail(); }
|
||||
async clearPreprocessing(): Promise<CatalogPreprocessingClearResult> { return this.fail(); }
|
||||
async getCatalogMetrics(): Promise<CatalogMetrics | undefined> { return this.fail(); }
|
||||
async create(): Promise<WorkspaceDatabase> { return this.fail(); }
|
||||
async update(): Promise<WorkspaceDatabase | undefined> { return this.fail(); }
|
||||
@@ -1614,14 +2002,20 @@ export class UnavailableCatalogRepository implements CatalogRepository {
|
||||
async updateDescriptionGenerationRun(): Promise<DescriptionGenerationRun | undefined> { return this.fail(); }
|
||||
async appendDescriptionGenerationEvent(): Promise<DescriptionGenerationEvent> { return this.fail(); }
|
||||
async listDescriptionGenerationEvents(): Promise<DescriptionGenerationEvent[]> { return this.fail(); }
|
||||
async createSensitiveDataSuggestionRun(): Promise<SensitiveDataSuggestionRun> { return this.fail(); }
|
||||
async getSensitiveDataSuggestionRun(): Promise<SensitiveDataSuggestionRun | undefined> { return this.fail(); }
|
||||
async listSensitiveDataSuggestionRuns(): Promise<SensitiveDataSuggestionRun[]> { return this.fail(); }
|
||||
async interruptActiveSensitiveDataSuggestionRuns(): Promise<SensitiveDataSuggestionRun[]> { return this.fail(); }
|
||||
async updateSensitiveDataSuggestionRun(): Promise<SensitiveDataSuggestionRun | undefined> { return this.fail(); }
|
||||
async appendSensitiveDataSuggestionEvent(): Promise<SensitiveDataSuggestionEvent> { return this.fail(); }
|
||||
async listSensitiveDataSuggestionEvents(): Promise<SensitiveDataSuggestionEvent[]> { return this.fail(); }
|
||||
async listRelationships(): Promise<CatalogRelationship[]> { return this.fail(); }
|
||||
async createSensitivityAnalysisRun(): Promise<SensitivityAnalysisRun> { return this.fail(); }
|
||||
async getSensitivityAnalysisRun(): Promise<SensitivityAnalysisRun | undefined> { return this.fail(); }
|
||||
async listSensitivityAnalysisRuns(): Promise<SensitivityAnalysisRun[]> { return this.fail(); }
|
||||
async interruptActiveSensitivityAnalysisRuns(): Promise<SensitivityAnalysisRun[]> { return this.fail(); }
|
||||
async updateSensitivityAnalysisRun(): Promise<SensitivityAnalysisRun | undefined> { return this.fail(); }
|
||||
async appendSensitivityAnalysisEvent(): Promise<SensitivityAnalysisEvent> { return this.fail(); }
|
||||
async listSensitivityAnalysisEvents(): Promise<SensitivityAnalysisEvent[]> { return this.fail(); }
|
||||
async listRelationships(): Promise<CatalogPhysicalRelationship[]> { return this.fail(); }
|
||||
async listLogicalRelationships(): Promise<CatalogLogicalRelationship[]> { return this.fail(); }
|
||||
async getLogicalRelationshipContext(): Promise<CatalogLogicalRelationshipContext | undefined> { return this.fail(); }
|
||||
async insertLogicalRelationship(): Promise<CatalogLogicalRelationship | undefined> { return this.fail(); }
|
||||
async insertGeneratedLogicalRelationships(): Promise<number> { return this.fail(); }
|
||||
async setLogicalRelationshipStatus(): Promise<CatalogLogicalRelationship | undefined> { return this.fail(); }
|
||||
async deleteLogicalRelationship(): Promise<boolean> { return this.fail(); }
|
||||
async deleteDatabaseMetadata(): Promise<CatalogMetadataDeleteCounts | undefined> { return this.fail(); }
|
||||
async deleteTableMetadata(): Promise<CatalogMetadataDeleteCounts | undefined> { return this.fail(); }
|
||||
async planSchemaSync(): Promise<CatalogSchemaDiff> { return this.fail(); }
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { dirname } from "node:path";
|
||||
|
||||
import { resolveRuntimeBindings, type RuntimeBindings } from "../workspaces/bindings.js";
|
||||
import { buildInstallationContract, type InstallationSuffix } from "../workspaces/contracts.js";
|
||||
import {
|
||||
discoverWorkspaceSecretRequirements,
|
||||
} from "../workspaces/secret-requirements.js";
|
||||
import type {
|
||||
WorkspaceSecretMaterialization,
|
||||
WorkspaceSecretStore,
|
||||
} from "../workspaces/secret-store.js";
|
||||
import {
|
||||
validateWorkspaceDescriptor,
|
||||
type WorkspaceDescriptor,
|
||||
} from "../workspaces/schema.js";
|
||||
import { CATALOG_SECRET_IDS } from "./secrets.js";
|
||||
import type { WorkspaceDatabase } from "./types.js";
|
||||
|
||||
export interface CatalogRuntimeBindingLease {
|
||||
workspace: WorkspaceDescriptor;
|
||||
bindings: RuntimeBindings;
|
||||
release(): void;
|
||||
}
|
||||
|
||||
const CATALOG_SECRET_BY_SUFFIX: Readonly<Partial<Record<InstallationSuffix, string>>> = {
|
||||
PASSWORD_FILE: CATALOG_SECRET_IDS.password,
|
||||
API_KEY_FILE: CATALOG_SECRET_IDS.apiKey,
|
||||
TLS_CA_FILE: CATALOG_SECRET_IDS.tlsCa,
|
||||
SSH_PRIVATE_KEY_FILE: CATALOG_SECRET_IDS.sshPrivateKey,
|
||||
SSH_KNOWN_HOSTS_FILE: CATALOG_SECRET_IDS.sshKnownHosts,
|
||||
};
|
||||
|
||||
function runtimeWorkspace(
|
||||
workspace: WorkspaceDescriptor,
|
||||
database: WorkspaceDatabase,
|
||||
): WorkspaceDescriptor {
|
||||
if (workspace.workspace.id !== database.workspaceId) {
|
||||
throw new Error("Catalog database binding does not belong to the workspace");
|
||||
}
|
||||
const { dwh: _legacyDwh, diagnostics: _legacyDiagnostics, ...descriptor } = workspace;
|
||||
const binding = database.binding;
|
||||
return validateWorkspaceDescriptor({
|
||||
...descriptor,
|
||||
dwh: {
|
||||
engine: "postgres",
|
||||
database: database.databaseName,
|
||||
schema: database.schema,
|
||||
...(binding.port === undefined ? {} : { port: binding.port }),
|
||||
supported_transports: [binding.transport],
|
||||
},
|
||||
...(binding.transport === "rest_api" ? {
|
||||
diagnostics: {
|
||||
dwh_rest: {
|
||||
method: "GET",
|
||||
path: binding.restPath ?? "/health",
|
||||
auth: binding.restAuth ?? "bearer",
|
||||
response: { database: "database", schema: "schema" },
|
||||
},
|
||||
},
|
||||
} : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function setIfDefined(
|
||||
environment: NodeJS.ProcessEnv,
|
||||
name: string | undefined,
|
||||
value: string | number | undefined,
|
||||
): void {
|
||||
if (name !== undefined && value !== undefined && value !== "") environment[name] = String(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Project one PostgreSQL Catalog row into the legacy-shaped configuration consumed by the
|
||||
* Python runtime. The authored workspace remains database-free; this object exists only for
|
||||
* the lifetime of a backend-owned runtime lease.
|
||||
*/
|
||||
export function resolveCatalogRuntimeBinding(options: {
|
||||
workspace: WorkspaceDescriptor;
|
||||
database: WorkspaceDatabase;
|
||||
environment: NodeJS.ProcessEnv;
|
||||
secretRoots: readonly string[];
|
||||
secretStore: WorkspaceSecretStore;
|
||||
}): CatalogRuntimeBindingLease {
|
||||
const workspace = runtimeWorkspace(options.workspace, options.database);
|
||||
const evidenceRequirements = discoverWorkspaceSecretRequirements(
|
||||
options.workspace,
|
||||
options.environment,
|
||||
).filter(({ connector }) => connector === "evidence");
|
||||
const catalogSecretIds = Object.values(CATALOG_SECRET_IDS);
|
||||
let materialization: WorkspaceSecretMaterialization | undefined;
|
||||
try {
|
||||
materialization = options.secretStore.materialize(
|
||||
options.database.workspaceId,
|
||||
[...catalogSecretIds, ...evidenceRequirements.map(({ id }) => id)],
|
||||
);
|
||||
const environment: NodeJS.ProcessEnv = { ...options.environment };
|
||||
const roots = new Set(options.secretRoots);
|
||||
for (const path of materialization.files.values()) roots.add(dirname(path));
|
||||
|
||||
const variables = buildInstallationContract(workspace).variables;
|
||||
const variable = (role: "DWH" | "EVIDENCE", suffix: InstallationSuffix) => (
|
||||
variables.find((candidate) => candidate.role === role && candidate.suffix === suffix)?.name
|
||||
);
|
||||
const binding = options.database.binding;
|
||||
setIfDefined(environment, variable("DWH", "TRANSPORT"), binding.transport);
|
||||
setIfDefined(environment, variable("DWH", "HOST"), binding.host);
|
||||
setIfDefined(environment, variable("DWH", "PORT"), binding.port);
|
||||
setIfDefined(environment, variable("DWH", "BASE_URL"), binding.baseUrl);
|
||||
setIfDefined(environment, variable("DWH", "USER"), binding.username);
|
||||
setIfDefined(environment, variable("DWH", "SSH_HOST"), binding.sshHost);
|
||||
setIfDefined(environment, variable("DWH", "SSH_PORT"), binding.sshPort);
|
||||
setIfDefined(environment, variable("DWH", "SSH_USER"), binding.sshUsername);
|
||||
setIfDefined(environment, variable("DWH", "SSH_TARGET_HOST"), binding.sshTargetHost);
|
||||
setIfDefined(environment, variable("DWH", "SSH_TARGET_PORT"), binding.sshTargetPort);
|
||||
for (const [suffix, secretId] of Object.entries(CATALOG_SECRET_BY_SUFFIX) as Array<[
|
||||
InstallationSuffix,
|
||||
string,
|
||||
]>) {
|
||||
setIfDefined(environment, variable("DWH", suffix), materialization.files.get(secretId));
|
||||
}
|
||||
for (const requirement of evidenceRequirements) {
|
||||
setIfDefined(environment, requirement.variable, materialization.files.get(requirement.id));
|
||||
}
|
||||
|
||||
const bindings = resolveRuntimeBindings(workspace, environment, [...roots]);
|
||||
let released = false;
|
||||
return {
|
||||
workspace,
|
||||
bindings,
|
||||
release: () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
materialization?.release();
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
materialization?.release();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import type { MetadataGenerationModels } from "./metadata-generation-models.js";
|
||||
import type { ModelCompleter, ModelCompletionMessage } from "./model-completer.js";
|
||||
import type {
|
||||
CatalogColumn,
|
||||
CatalogRepository,
|
||||
CatalogTable,
|
||||
SensitiveDataSuggestionScope,
|
||||
} from "./types.js";
|
||||
|
||||
export type { SensitiveDataSuggestionScope } from "./types.js";
|
||||
|
||||
// The helper accepts at most 64 KiB per message. Keep the same safety margin used by
|
||||
// Description Generation so UTF-8 structural metadata never reaches that hard limit.
|
||||
const MAX_USER_MESSAGE_BYTES = 60 * 1024;
|
||||
// Preserve ThothAI's proven completion granularity: small batches keep generation time and
|
||||
// structured-output accuracy predictable even when the helper byte limit would allow much more.
|
||||
const MAX_COLUMNS_PER_BATCH = 10;
|
||||
const responseSchema = z.object({
|
||||
suggestions: z.array(z.object({
|
||||
columnId: z.uuid(),
|
||||
sensitive: z.boolean(),
|
||||
}).strict()),
|
||||
}).strict();
|
||||
|
||||
interface StructuralColumn {
|
||||
columnId: string;
|
||||
tableId: string;
|
||||
table: string;
|
||||
column: string;
|
||||
dataType: string;
|
||||
nullable: boolean;
|
||||
primaryKey: boolean;
|
||||
foreignKey: boolean;
|
||||
version: number;
|
||||
currentSensitive: boolean;
|
||||
}
|
||||
|
||||
export interface SensitiveDataSuggestion {
|
||||
columnId: string;
|
||||
tableId: string;
|
||||
tableName: string;
|
||||
columnName: string;
|
||||
version: number;
|
||||
currentSensitive: boolean;
|
||||
sensitive: boolean;
|
||||
}
|
||||
|
||||
export class SensitiveDataSuggestionTargetNotFoundError extends Error {
|
||||
constructor(readonly target: "database" | "table" | "column") {
|
||||
super(`${target} not found`);
|
||||
this.name = "SensitiveDataSuggestionTargetNotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
export class SensitiveDataSuggestionDuplicateTargetIdsError extends Error {
|
||||
constructor() {
|
||||
super("sensitive-data suggestion target IDs must be unique");
|
||||
this.name = "SensitiveDataSuggestionDuplicateTargetIdsError";
|
||||
}
|
||||
}
|
||||
|
||||
export class SensitiveDataSuggestionNoEligibleColumnsError extends Error {
|
||||
constructor(readonly scope: SensitiveDataSuggestionScope) {
|
||||
super("selected scope has no catalog columns");
|
||||
this.name = "SensitiveDataSuggestionNoEligibleColumnsError";
|
||||
}
|
||||
}
|
||||
|
||||
export class SensitiveDataSuggestionPayloadTooLargeError extends Error {
|
||||
constructor() {
|
||||
super("sensitive-data suggestion structural metadata is too large");
|
||||
this.name = "SensitiveDataSuggestionPayloadTooLargeError";
|
||||
}
|
||||
}
|
||||
|
||||
export class SensitiveDataSuggestionInvalidResponseError extends Error {
|
||||
constructor() {
|
||||
super("sensitive-data suggestion response is invalid");
|
||||
this.name = "SensitiveDataSuggestionInvalidResponseError";
|
||||
}
|
||||
}
|
||||
|
||||
function userContent(
|
||||
database: { databaseName: string; schema: string },
|
||||
columns: readonly StructuralColumn[],
|
||||
): string {
|
||||
return JSON.stringify({
|
||||
database: database.databaseName,
|
||||
schema: database.schema,
|
||||
columns: columns.map((column) => ({
|
||||
columnId: column.columnId,
|
||||
table: column.table,
|
||||
column: column.column,
|
||||
dataType: column.dataType,
|
||||
nullable: column.nullable,
|
||||
primaryKey: column.primaryKey,
|
||||
foreignKey: column.foreignKey,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
function batchesFor(
|
||||
database: { databaseName: string; schema: string },
|
||||
columns: readonly StructuralColumn[],
|
||||
): StructuralColumn[][] {
|
||||
const batches: StructuralColumn[][] = [];
|
||||
let current: StructuralColumn[] = [];
|
||||
for (const column of columns) {
|
||||
if (current.length === MAX_COLUMNS_PER_BATCH) {
|
||||
batches.push(current);
|
||||
current = [];
|
||||
}
|
||||
const candidate = [...current, column];
|
||||
if (Buffer.byteLength(userContent(database, candidate), "utf8") <= MAX_USER_MESSAGE_BYTES) {
|
||||
current = candidate;
|
||||
continue;
|
||||
}
|
||||
if (current.length === 0) throw new SensitiveDataSuggestionPayloadTooLargeError();
|
||||
batches.push(current);
|
||||
current = [column];
|
||||
if (Buffer.byteLength(userContent(database, current), "utf8") > MAX_USER_MESSAGE_BYTES) {
|
||||
throw new SensitiveDataSuggestionPayloadTooLargeError();
|
||||
}
|
||||
}
|
||||
if (current.length > 0) batches.push(current);
|
||||
return batches;
|
||||
}
|
||||
|
||||
function structuralColumn(table: CatalogTable, column: CatalogColumn): StructuralColumn {
|
||||
return {
|
||||
columnId: column.id,
|
||||
tableId: table.id,
|
||||
table: table.name,
|
||||
column: column.name,
|
||||
dataType: column.dataType,
|
||||
nullable: column.isNullable,
|
||||
primaryKey: column.isPrimaryKey,
|
||||
foreignKey: column.isForeignKey,
|
||||
version: column.version,
|
||||
currentSensitive: column.sensitive,
|
||||
};
|
||||
}
|
||||
|
||||
const systemMessage: ModelCompletionMessage = {
|
||||
role: "system",
|
||||
content: [
|
||||
"Classify whether each database column is likely to contain sensitive source values.",
|
||||
"Use only the supplied structural metadata. Return strict JSON with this exact shape:",
|
||||
'{"suggestions":[{"columnId":"uuid","sensitive":true}]}',
|
||||
"Return every supplied column exactly once. Do not add explanations or markdown.",
|
||||
].join("\n"),
|
||||
};
|
||||
|
||||
export class SensitiveDataSuggester {
|
||||
constructor(
|
||||
private readonly repository: CatalogRepository,
|
||||
private readonly models: MetadataGenerationModels,
|
||||
private readonly completer: ModelCompleter,
|
||||
) {}
|
||||
|
||||
private async selectColumns(
|
||||
databaseId: string,
|
||||
scope: SensitiveDataSuggestionScope,
|
||||
targetIds: readonly string[],
|
||||
): Promise<StructuralColumn[]> {
|
||||
if (new Set(targetIds).size !== targetIds.length) {
|
||||
throw new SensitiveDataSuggestionDuplicateTargetIdsError();
|
||||
}
|
||||
const tables = await this.repository.listTables(databaseId);
|
||||
const tableIds = new Set(targetIds);
|
||||
const selectedTables = scope === "selected_tables"
|
||||
? tables.filter((table) => tableIds.has(table.id))
|
||||
: tables;
|
||||
if (scope === "selected_tables" && selectedTables.length !== targetIds.length) {
|
||||
throw new SensitiveDataSuggestionTargetNotFoundError("table");
|
||||
}
|
||||
|
||||
const columns = (await Promise.all(selectedTables.map(async (table) => (
|
||||
(await this.repository.listColumns(databaseId, table.id)).map((column) => (
|
||||
structuralColumn(table, column)
|
||||
))
|
||||
)))).flat();
|
||||
const columnIds = new Set(targetIds);
|
||||
const selectedColumns = scope === "selected_columns"
|
||||
? columns.filter((column) => columnIds.has(column.columnId))
|
||||
: columns;
|
||||
if (scope === "selected_columns" && selectedColumns.length !== targetIds.length) {
|
||||
throw new SensitiveDataSuggestionTargetNotFoundError("column");
|
||||
}
|
||||
if (selectedColumns.length === 0) {
|
||||
throw new SensitiveDataSuggestionNoEligibleColumnsError(scope);
|
||||
}
|
||||
return selectedColumns;
|
||||
}
|
||||
|
||||
async suggest(
|
||||
databaseId: string,
|
||||
modelId: string,
|
||||
scope: SensitiveDataSuggestionScope,
|
||||
targetIds: readonly string[],
|
||||
signal: AbortSignal,
|
||||
onPrepared?: (total: number) => void | Promise<void>,
|
||||
): Promise<readonly SensitiveDataSuggestion[]> {
|
||||
const database = await this.repository.get(databaseId);
|
||||
if (!database) throw new SensitiveDataSuggestionTargetNotFoundError("database");
|
||||
const columns = await this.selectColumns(databaseId, scope, targetIds);
|
||||
await onPrepared?.(columns.length);
|
||||
const model = this.models.resolve(modelId);
|
||||
const suggestions: SensitiveDataSuggestion[] = [];
|
||||
|
||||
for (const batch of batchesFor(database, columns)) {
|
||||
let received: Map<string, { columnId: string; sensitive: boolean }> | undefined;
|
||||
for (let attempt = 0; attempt < 2 && !received; attempt += 1) {
|
||||
const content = await this.completer.complete({
|
||||
model,
|
||||
signal,
|
||||
messages: [systemMessage, { role: "user", content: userContent(database, batch) }],
|
||||
});
|
||||
try {
|
||||
const parsed = responseSchema.parse(JSON.parse(content));
|
||||
const expected = new Set(batch.map((column) => column.columnId));
|
||||
const candidate = new Map(parsed.suggestions.map((suggestion) => [suggestion.columnId, suggestion]));
|
||||
if (candidate.size !== parsed.suggestions.length
|
||||
|| candidate.size !== expected.size
|
||||
|| [...candidate.keys()].some((columnId) => !expected.has(columnId))) {
|
||||
throw new SensitiveDataSuggestionInvalidResponseError();
|
||||
}
|
||||
received = candidate;
|
||||
} catch {
|
||||
if (attempt === 1) throw new SensitiveDataSuggestionInvalidResponseError();
|
||||
}
|
||||
}
|
||||
suggestions.push(...batch.map((column) => ({
|
||||
columnId: column.columnId,
|
||||
tableId: column.tableId,
|
||||
tableName: column.table,
|
||||
columnName: column.column,
|
||||
version: column.version,
|
||||
currentSensitive: column.currentSensitive,
|
||||
sensitive: received!.get(column.columnId)!.sensitive,
|
||||
})));
|
||||
}
|
||||
return suggestions;
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import type {
|
||||
SensitiveDataSuggestion,
|
||||
} from "./sensitive-data-suggester.js";
|
||||
import {
|
||||
SensitiveDataSuggester,
|
||||
SensitiveDataSuggestionTargetNotFoundError,
|
||||
} from "./sensitive-data-suggester.js";
|
||||
import type {
|
||||
CatalogRepository,
|
||||
SensitiveDataSuggestionRun,
|
||||
SensitiveDataSuggestionScope,
|
||||
} from "./types.js";
|
||||
|
||||
const interruptedMessage = "Sensitive-field suggestion generation was interrupted by backend restart.";
|
||||
const failedMessage = "Sensitive-field suggestion generation failed.";
|
||||
|
||||
export interface SensitiveDataSuggestionRunResult {
|
||||
suggestions: readonly SensitiveDataSuggestion[];
|
||||
run: SensitiveDataSuggestionRun;
|
||||
}
|
||||
|
||||
export class SensitiveDataSuggestionRunner {
|
||||
constructor(
|
||||
private readonly repository: CatalogRepository,
|
||||
private readonly suggester: SensitiveDataSuggester,
|
||||
) {}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (!(await this.repository.available())) return;
|
||||
const interrupted = await this.repository.interruptActiveSensitiveDataSuggestionRuns(
|
||||
interruptedMessage,
|
||||
);
|
||||
for (const run of interrupted) {
|
||||
await this.repository.appendSensitiveDataSuggestionEvent(
|
||||
run.id,
|
||||
"warning",
|
||||
interruptedMessage,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async run(
|
||||
databaseId: string,
|
||||
modelId: string,
|
||||
scope: SensitiveDataSuggestionScope,
|
||||
targetIds: readonly string[],
|
||||
signal: AbortSignal,
|
||||
): Promise<SensitiveDataSuggestionRunResult> {
|
||||
if (!(await this.repository.get(databaseId))) {
|
||||
throw new SensitiveDataSuggestionTargetNotFoundError("database");
|
||||
}
|
||||
const started = await this.repository.createSensitiveDataSuggestionRun(
|
||||
databaseId,
|
||||
scope,
|
||||
modelId,
|
||||
);
|
||||
|
||||
try {
|
||||
await this.repository.appendSensitiveDataSuggestionEvent(
|
||||
started.id,
|
||||
"info",
|
||||
"Sensitive-field suggestion generation started.",
|
||||
);
|
||||
const suggestions = await this.suggester.suggest(
|
||||
databaseId,
|
||||
modelId,
|
||||
scope,
|
||||
targetIds,
|
||||
signal,
|
||||
async (total) => {
|
||||
const prepared = await this.repository.updateSensitiveDataSuggestionRun(started.id, {
|
||||
total,
|
||||
});
|
||||
if (!prepared) throw new Error("Sensitive Data Suggestion Run disappeared");
|
||||
},
|
||||
);
|
||||
const suggestedSensitive = suggestions.filter((suggestion) => suggestion.sensitive).length;
|
||||
const suggestedNonSensitive = suggestions.length - suggestedSensitive;
|
||||
await this.repository.appendSensitiveDataSuggestionEvent(
|
||||
started.id,
|
||||
"info",
|
||||
`Sensitive-field suggestion generation completed for ${suggestions.length} column${
|
||||
suggestions.length === 1 ? "" : "s"
|
||||
}.`,
|
||||
);
|
||||
const completed = await this.repository.updateSensitiveDataSuggestionRun(started.id, {
|
||||
status: "completed",
|
||||
total: suggestions.length,
|
||||
suggestedSensitive,
|
||||
suggestedNonSensitive,
|
||||
finishedAt: new Date().toISOString(),
|
||||
errorSummary: null,
|
||||
});
|
||||
if (!completed) throw new Error("Sensitive Data Suggestion Run disappeared");
|
||||
return { suggestions, run: completed };
|
||||
} catch (error) {
|
||||
await this.repository.updateSensitiveDataSuggestionRun(started.id, {
|
||||
status: "failed",
|
||||
finishedAt: new Date().toISOString(),
|
||||
errorSummary: failedMessage,
|
||||
}).catch(() => undefined);
|
||||
await this.repository.appendSensitiveDataSuggestionEvent(
|
||||
started.id,
|
||||
"error",
|
||||
failedMessage,
|
||||
).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import type {
|
||||
SensitivityReviewItem,
|
||||
} from "./sensitivity-analysis-service.js";
|
||||
import {
|
||||
SENSITIVITY_POLICY_VERSION,
|
||||
SensitivityAnalysisInterruptedError,
|
||||
SensitivityAnalysisService,
|
||||
SensitivityAnalysisTargetNotFoundError,
|
||||
} from "./sensitivity-analysis-service.js";
|
||||
import type {
|
||||
CatalogRepository,
|
||||
SensitivityAnalysisRun,
|
||||
SensitivityAnalysisScope,
|
||||
} from "./types.js";
|
||||
|
||||
const interruptedMessage = "Local sensitivity analysis was interrupted by backend restart.";
|
||||
const interruptedDuringRunMessage = "Local sensitivity analysis was interrupted before completion.";
|
||||
const failedMessage = "Local sensitivity analysis failed.";
|
||||
|
||||
function ensureActive(signal: AbortSignal): void {
|
||||
if (signal.aborted) throw new SensitivityAnalysisInterruptedError();
|
||||
}
|
||||
|
||||
export interface SensitivityAnalysisRunResult {
|
||||
suggestions: readonly SensitivityReviewItem[];
|
||||
run: SensitivityAnalysisRun;
|
||||
}
|
||||
|
||||
export class SensitivityAnalysisRunner {
|
||||
constructor(
|
||||
private readonly repository: CatalogRepository,
|
||||
private readonly analysis: SensitivityAnalysisService,
|
||||
) {}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
if (!(await this.repository.available())) return;
|
||||
const interrupted = await this.repository.interruptActiveSensitivityAnalysisRuns(
|
||||
interruptedMessage,
|
||||
);
|
||||
for (const run of interrupted) {
|
||||
await this.repository.appendSensitivityAnalysisEvent(
|
||||
run.id,
|
||||
"warning",
|
||||
interruptedMessage,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async run(
|
||||
databaseId: string,
|
||||
scope: SensitivityAnalysisScope,
|
||||
targetIds: readonly string[],
|
||||
signal: AbortSignal,
|
||||
): Promise<SensitivityAnalysisRunResult> {
|
||||
ensureActive(signal);
|
||||
const database = await this.repository.get(databaseId);
|
||||
ensureActive(signal);
|
||||
if (!database) {
|
||||
throw new SensitivityAnalysisTargetNotFoundError("database");
|
||||
}
|
||||
ensureActive(signal);
|
||||
const started = await this.repository.createSensitivityAnalysisRun(
|
||||
databaseId,
|
||||
scope,
|
||||
{ engine: "local", policyVersion: SENSITIVITY_POLICY_VERSION },
|
||||
);
|
||||
let preparedTotal = 0;
|
||||
let processedSensitive = 0;
|
||||
let processedNonSensitive = 0;
|
||||
|
||||
try {
|
||||
ensureActive(signal);
|
||||
await this.repository.appendSensitivityAnalysisEvent(
|
||||
started.id,
|
||||
"info",
|
||||
"Local sensitivity analysis started.",
|
||||
);
|
||||
ensureActive(signal);
|
||||
const suggestions = await this.analysis.analyze(
|
||||
databaseId,
|
||||
scope,
|
||||
targetIds,
|
||||
signal,
|
||||
async (total) => {
|
||||
ensureActive(signal);
|
||||
preparedTotal = total;
|
||||
const prepared = await this.repository.updateSensitivityAnalysisRun(started.id, {
|
||||
total,
|
||||
});
|
||||
ensureActive(signal);
|
||||
if (!prepared) throw new Error("Sensitivity Analysis Run disappeared");
|
||||
},
|
||||
async (processed, batch) => {
|
||||
ensureActive(signal);
|
||||
const suggestedSensitive = batch.filter(
|
||||
(suggestion) => suggestion.assessment === "sensitive",
|
||||
).length;
|
||||
const suggestedNonSensitive = batch.filter(
|
||||
(suggestion) => suggestion.assessment === "non_sensitive",
|
||||
).length;
|
||||
const current = await this.repository.getSensitivityAnalysisRun(started.id);
|
||||
ensureActive(signal);
|
||||
if (!current) throw new Error("Sensitivity Analysis Run disappeared");
|
||||
const progress = await this.repository.updateSensitivityAnalysisRun(started.id, {
|
||||
suggestedSensitive: current.suggestedSensitive + suggestedSensitive,
|
||||
suggestedNonSensitive: current.suggestedNonSensitive + suggestedNonSensitive,
|
||||
});
|
||||
if (!progress) throw new Error("Sensitivity Analysis Run disappeared");
|
||||
processedSensitive += suggestedSensitive;
|
||||
processedNonSensitive += suggestedNonSensitive;
|
||||
ensureActive(signal);
|
||||
await this.repository.appendSensitivityAnalysisEvent(
|
||||
started.id,
|
||||
"info",
|
||||
`Assessed ${processed} of ${progress.total} columns locally.`,
|
||||
);
|
||||
ensureActive(signal);
|
||||
},
|
||||
async (message) => {
|
||||
ensureActive(signal);
|
||||
await this.repository.appendSensitivityAnalysisEvent(
|
||||
started.id,
|
||||
"info",
|
||||
message,
|
||||
);
|
||||
ensureActive(signal);
|
||||
},
|
||||
);
|
||||
ensureActive(signal);
|
||||
const suggestedSensitive = suggestions.filter(
|
||||
(suggestion) => suggestion.assessment === "sensitive",
|
||||
).length;
|
||||
const suggestedNonSensitive = suggestions.filter(
|
||||
(suggestion) => suggestion.assessment === "non_sensitive",
|
||||
).length;
|
||||
await this.repository.appendSensitivityAnalysisEvent(
|
||||
started.id,
|
||||
"info",
|
||||
`Local sensitivity analysis completed for ${suggestions.length} column${
|
||||
suggestions.length === 1 ? "" : "s"
|
||||
}.`,
|
||||
);
|
||||
ensureActive(signal);
|
||||
const completed = await this.repository.updateSensitivityAnalysisRun(started.id, {
|
||||
status: "completed",
|
||||
total: suggestions.length,
|
||||
suggestedSensitive,
|
||||
suggestedNonSensitive,
|
||||
unknown: 0,
|
||||
finishedAt: new Date().toISOString(),
|
||||
errorSummary: null,
|
||||
});
|
||||
ensureActive(signal);
|
||||
if (!completed) throw new Error("Sensitivity Analysis Run disappeared");
|
||||
return { suggestions, run: completed };
|
||||
} catch (error) {
|
||||
const interrupted = signal.aborted || error instanceof SensitivityAnalysisInterruptedError;
|
||||
const message = interrupted ? interruptedDuringRunMessage : failedMessage;
|
||||
await this.repository.updateSensitivityAnalysisRun(started.id, {
|
||||
status: interrupted ? "interrupted" : "failed",
|
||||
...(interrupted ? {
|
||||
total: preparedTotal,
|
||||
suggestedSensitive: processedSensitive,
|
||||
suggestedNonSensitive: processedNonSensitive,
|
||||
unknown: Math.max(0, preparedTotal - processedSensitive - processedNonSensitive),
|
||||
} : {}),
|
||||
finishedAt: new Date().toISOString(),
|
||||
errorSummary: message,
|
||||
}).catch(() => undefined);
|
||||
await this.repository.appendSensitivityAnalysisEvent(
|
||||
started.id,
|
||||
interrupted ? "warning" : "error",
|
||||
message,
|
||||
).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import type {
|
||||
SensitivityClassifier,
|
||||
SensitivityColumnAssessment,
|
||||
SensitivityEvidence,
|
||||
SensitivityNerBudget,
|
||||
} from "./sensitivity-classifier.js";
|
||||
import type {
|
||||
CatalogColumn,
|
||||
CatalogRepository,
|
||||
CatalogTable,
|
||||
SensitivityAnalysisScope,
|
||||
} from "./types.js";
|
||||
|
||||
export type { SensitivityAnalysisScope } from "./types.js";
|
||||
export const SENSITIVITY_POLICY_VERSION = "sensitivity-v4";
|
||||
|
||||
interface SelectedColumn {
|
||||
table: CatalogTable;
|
||||
column: CatalogColumn;
|
||||
}
|
||||
|
||||
export interface SensitivityReviewItem {
|
||||
columnId: string;
|
||||
tableId: string;
|
||||
tableName: string;
|
||||
columnName: string;
|
||||
version: number;
|
||||
currentSensitive: boolean;
|
||||
sensitive: boolean;
|
||||
assessment: SensitivityColumnAssessment["assessment"];
|
||||
evidence: readonly SensitivityEvidence[];
|
||||
observedValues: number;
|
||||
coverage: SensitivityColumnAssessment["coverage"];
|
||||
}
|
||||
|
||||
export class SensitivityAnalysisTargetNotFoundError extends Error {
|
||||
constructor(readonly target: "database" | "table" | "column") {
|
||||
super(`${target} not found`);
|
||||
this.name = "SensitivityAnalysisTargetNotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
export class SensitivityAnalysisDuplicateTargetIdsError extends Error {
|
||||
constructor() {
|
||||
super("sensitivity analysis target IDs must be unique");
|
||||
this.name = "SensitivityAnalysisDuplicateTargetIdsError";
|
||||
}
|
||||
}
|
||||
|
||||
export class SensitivityAnalysisInterruptedError extends Error {
|
||||
constructor() {
|
||||
super("sensitivity analysis interrupted");
|
||||
this.name = "SensitivityAnalysisInterruptedError";
|
||||
}
|
||||
}
|
||||
|
||||
function ensureActive(signal: AbortSignal): void {
|
||||
if (signal.aborted) throw new SensitivityAnalysisInterruptedError();
|
||||
}
|
||||
|
||||
export class SensitivityAnalysisNoEligibleColumnsError extends Error {
|
||||
constructor(readonly scope: SensitivityAnalysisScope) {
|
||||
super("selected scope has no catalog columns");
|
||||
this.name = "SensitivityAnalysisNoEligibleColumnsError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Selection and table orchestration around the single SensitivityClassifier decision module. */
|
||||
export class SensitivityAnalysisService {
|
||||
constructor(
|
||||
private readonly repository: CatalogRepository,
|
||||
private readonly classifier: SensitivityClassifier,
|
||||
private readonly options: { nerBudgetMs?: number } = {},
|
||||
) {}
|
||||
|
||||
private async selectColumns(
|
||||
databaseId: string,
|
||||
scope: SensitivityAnalysisScope,
|
||||
targetIds: readonly string[],
|
||||
signal: AbortSignal,
|
||||
): Promise<readonly SelectedColumn[]> {
|
||||
ensureActive(signal);
|
||||
if (new Set(targetIds).size !== targetIds.length) {
|
||||
throw new SensitivityAnalysisDuplicateTargetIdsError();
|
||||
}
|
||||
const tables = await this.repository.listTables(databaseId);
|
||||
ensureActive(signal);
|
||||
const tableIds = new Set(targetIds);
|
||||
const selectedTables = scope === "selected_tables"
|
||||
? tables.filter((table) => tableIds.has(table.id))
|
||||
: tables;
|
||||
if (scope === "selected_tables" && selectedTables.length !== targetIds.length) {
|
||||
throw new SensitivityAnalysisTargetNotFoundError("table");
|
||||
}
|
||||
const columns = (await Promise.all(selectedTables.map(async (table) => (
|
||||
(await this.repository.listColumns(databaseId, table.id)).map((column) => ({ table, column }))
|
||||
)))).flat();
|
||||
ensureActive(signal);
|
||||
const columnIds = new Set(targetIds);
|
||||
const selectedColumns = scope === "selected_columns"
|
||||
? columns.filter(({ column }) => columnIds.has(column.id))
|
||||
: columns;
|
||||
if (scope === "selected_columns" && selectedColumns.length !== targetIds.length) {
|
||||
throw new SensitivityAnalysisTargetNotFoundError("column");
|
||||
}
|
||||
if (selectedColumns.length === 0) {
|
||||
throw new SensitivityAnalysisNoEligibleColumnsError(scope);
|
||||
}
|
||||
return selectedColumns;
|
||||
}
|
||||
|
||||
async analyze(
|
||||
databaseId: string,
|
||||
scope: SensitivityAnalysisScope,
|
||||
targetIds: readonly string[],
|
||||
signal: AbortSignal,
|
||||
onPrepared?: (total: number) => void | Promise<void>,
|
||||
onProgress?: (processed: number, suggestions: readonly SensitivityReviewItem[]) => void | Promise<void>,
|
||||
onActivity?: (message: string) => void | Promise<void>,
|
||||
): Promise<readonly SensitivityReviewItem[]> {
|
||||
const configuredNerBudget = this.options.nerBudgetMs ?? 10_000;
|
||||
const nerBudget: SensitivityNerBudget = {
|
||||
remainingMs: Number.isFinite(configuredNerBudget) && configuredNerBudget >= 0
|
||||
? configuredNerBudget
|
||||
: 10_000,
|
||||
};
|
||||
ensureActive(signal);
|
||||
const database = await this.repository.get(databaseId);
|
||||
ensureActive(signal);
|
||||
if (!database) throw new SensitivityAnalysisTargetNotFoundError("database");
|
||||
const selected = await this.selectColumns(databaseId, scope, targetIds, signal);
|
||||
await onPrepared?.(selected.length);
|
||||
ensureActive(signal);
|
||||
const byTable = new Map<string, SelectedColumn[]>();
|
||||
for (const item of selected) {
|
||||
const items = byTable.get(item.table.id) ?? [];
|
||||
items.push(item);
|
||||
byTable.set(item.table.id, items);
|
||||
}
|
||||
const tableTargets = [...byTable.values()].map((items) => {
|
||||
const first = items[0]!;
|
||||
return {
|
||||
database,
|
||||
table: first.table,
|
||||
columns: items.map(({ column }) => column),
|
||||
};
|
||||
});
|
||||
const assessments = await this.classifier.assess(
|
||||
tableTargets,
|
||||
signal,
|
||||
nerBudget,
|
||||
onActivity,
|
||||
);
|
||||
ensureActive(signal);
|
||||
const assessmentById = new Map(assessments.map((assessment) => [
|
||||
assessment.columnId,
|
||||
assessment,
|
||||
]));
|
||||
const suggestions: SensitivityReviewItem[] = [];
|
||||
for (const items of byTable.values()) {
|
||||
ensureActive(signal);
|
||||
const batch = items.map(({ table, column }) => {
|
||||
const assessment = assessmentById.get(column.id)!;
|
||||
return {
|
||||
columnId: column.id,
|
||||
tableId: table.id,
|
||||
tableName: table.name,
|
||||
columnName: column.name,
|
||||
version: column.version,
|
||||
currentSensitive: column.sensitive,
|
||||
sensitive: assessment.proposedSensitive,
|
||||
assessment: assessment.assessment,
|
||||
evidence: assessment.evidence,
|
||||
observedValues: assessment.observedValues,
|
||||
coverage: assessment.coverage,
|
||||
};
|
||||
});
|
||||
suggestions.push(...batch);
|
||||
await onProgress?.(suggestions.length, batch);
|
||||
ensureActive(signal);
|
||||
}
|
||||
return suggestions;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,535 @@
|
||||
import type { CatalogColumn, CatalogTable, WorkspaceDatabase } from "./types.js";
|
||||
import { findPhoneNumbersInText } from "libphonenumber-js/max";
|
||||
import validator from "validator";
|
||||
|
||||
export type SensitivityAssessment = "sensitive" | "non_sensitive";
|
||||
|
||||
export interface SensitivityEvidence {
|
||||
kind: "metadata" | "content" | "length" | "ner" | "coverage" | "type";
|
||||
ruleId: string;
|
||||
label?: string;
|
||||
confidence?: number;
|
||||
}
|
||||
|
||||
export interface SensitivityValueObservation {
|
||||
columnId: string;
|
||||
value: string | null;
|
||||
characterLength: number | null;
|
||||
}
|
||||
|
||||
export interface SensitivityScanCoverage {
|
||||
kind: "complete" | "sampled";
|
||||
observedValues: number;
|
||||
}
|
||||
|
||||
export interface SensitivityTableScan {
|
||||
batches: readonly (readonly SensitivityValueObservation[])[];
|
||||
coverage: SensitivityScanCoverage;
|
||||
}
|
||||
|
||||
export interface SensitivityScanRequest {
|
||||
database: WorkspaceDatabase;
|
||||
table: CatalogTable;
|
||||
columns: readonly CatalogColumn[];
|
||||
valuesPerColumn: number;
|
||||
sampleOffset: number;
|
||||
sampleSeed: number;
|
||||
queryTimeoutMs: number;
|
||||
fullScanThreshold?: number;
|
||||
}
|
||||
|
||||
export interface SensitivityValueSource {
|
||||
scanTable(
|
||||
request: SensitivityScanRequest,
|
||||
consume: (batch: readonly SensitivityValueObservation[]) => void | Promise<void>,
|
||||
signal: AbortSignal,
|
||||
): Promise<SensitivityScanCoverage>;
|
||||
}
|
||||
|
||||
export interface LocalNerCandidate {
|
||||
columnId: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface LocalNerEvidence {
|
||||
columnId: string;
|
||||
label: string;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
export interface SensitivityNerBudget {
|
||||
remainingMs: number;
|
||||
}
|
||||
|
||||
/** Optional local detector. It returns evidence only; it never decides a column assessment. */
|
||||
export interface LocalNerDetector {
|
||||
warmup?(): Promise<void>;
|
||||
isReady?(): boolean;
|
||||
detect(
|
||||
candidates: readonly LocalNerCandidate[],
|
||||
signal: AbortSignal,
|
||||
deadline: number,
|
||||
): Promise<readonly LocalNerEvidence[]>;
|
||||
close?(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface SensitivityColumnAssessment {
|
||||
columnId: string;
|
||||
assessment: SensitivityAssessment;
|
||||
proposedSensitive: boolean;
|
||||
evidence: readonly SensitivityEvidence[];
|
||||
observedValues: number;
|
||||
coverage: "metadata" | "complete" | "sampled" | "no_values";
|
||||
}
|
||||
|
||||
export interface SensitivityTableTarget {
|
||||
database: WorkspaceDatabase;
|
||||
table: CatalogTable;
|
||||
columns: readonly CatalogColumn[];
|
||||
}
|
||||
|
||||
const EMAIL = /(?<![\p{L}\p{N}._%+-])[\p{L}\p{N}._%+-]+@[\p{L}\p{N}.-]+\.[\p{L}]{2,63}(?![\p{L}\p{N}._%+-])/giu;
|
||||
const DIRECT_IDENTIFIER_NAMES = new Set([
|
||||
"address", "birth_date", "codice_fiscale", "date_of_birth", "dob", "email", "e_mail",
|
||||
"bic", "first_name", "fiscal_code", "full_name", "iban", "indirizzo", "last_name", "mobile",
|
||||
"nome", "passport", "phone", "surname", "swift", "swift_code", "tax_id", "telefono",
|
||||
]);
|
||||
const CREDENTIAL_NAME = /(?:^|_)(?:api_key|credential|password|passwd|private_key|pwd|secret|token)(?:_|$)/u;
|
||||
const HEALTH_NAME = /(?:^|_)(?:anamnesi|clinical|diagnos(?:i|is)|health|medical|patient|patologia|therapy|terapia)(?:_|$)/u;
|
||||
const CLINICAL_TERM = /(?:^|[^\p{L}])(?:allergi[ae]|anamnesi|carcinoma|chemioterapia|diabete|diagnos[ei]|epatite|farmac[io]|gravidanza|hiv|metastasi|neoplasia|patologia|radioterapia|referto|terapia|tumore)(?:$|[^\p{L}])/iu;
|
||||
const UNSUPPORTED_BINARY_TYPE = /(?:^|\s)(?:binary|blob|bytea|image|varbinary)(?:\s|$|\()/iu;
|
||||
const DEEP_TEXT_TYPE = /(?:^|\s)(?:char|character|citext|clob|json|jsonb|nchar|nvarchar|string|text|varchar|xml)(?:\s|$|\()/iu;
|
||||
const MAX_NER_CANDIDATES_PER_REQUEST = 128;
|
||||
const MAX_CONCURRENT_TABLE_SCANS = 2;
|
||||
|
||||
export const SENSITIVITY_SAMPLE_PHASES = [
|
||||
{ targetValuesPerColumn: 300, additionalValuesPerColumn: 300, sampleSeed: 37, deepTextOnly: false },
|
||||
{ targetValuesPerColumn: 1_000, additionalValuesPerColumn: 700, sampleSeed: 73, deepTextOnly: false },
|
||||
{ targetValuesPerColumn: 3_000, additionalValuesPerColumn: 2_000, sampleSeed: 109, deepTextOnly: true },
|
||||
] as const;
|
||||
|
||||
function normalizedName(value: string): string {
|
||||
return value.normalize("NFKD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
||||
.toLocaleLowerCase("en-US")
|
||||
.replace(/[^a-z0-9]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "");
|
||||
}
|
||||
|
||||
function boundedCount(value: number | undefined, fallback: number, maximum: number): number {
|
||||
return value === undefined || !Number.isSafeInteger(value)
|
||||
? fallback
|
||||
: Math.max(1, Math.min(value, maximum));
|
||||
}
|
||||
|
||||
function metadataEvidence(column: CatalogColumn): SensitivityEvidence | undefined {
|
||||
const ruleId = sensitiveNameRule(column.name);
|
||||
return ruleId ? { kind: "metadata", ruleId } : undefined;
|
||||
}
|
||||
|
||||
function nonSensitiveStructuralEvidence(column: CatalogColumn): SensitivityEvidence | undefined {
|
||||
if (column.dataType.trim().toLowerCase() !== "bigint") return undefined;
|
||||
if (column.isPrimaryKey || column.primaryKeyPosition !== null) {
|
||||
return {
|
||||
kind: "type",
|
||||
ruleId: "type.bigint_primary_key_non_informative",
|
||||
label: "non-informative bigint primary key",
|
||||
};
|
||||
}
|
||||
if (normalizedName(column.name) === "pk") {
|
||||
return {
|
||||
kind: "metadata",
|
||||
ruleId: "metadata.bigint_pk_identifier_non_informative",
|
||||
label: "non-informative conventional bigint primary-key identifier",
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function sensitiveNameRule(value: string): string | undefined {
|
||||
const name = normalizedName(value);
|
||||
if (DIRECT_IDENTIFIER_NAMES.has(name)) {
|
||||
return "metadata.direct_identifier";
|
||||
}
|
||||
if (CREDENTIAL_NAME.test(name)) {
|
||||
return "metadata.credential";
|
||||
}
|
||||
if (HEALTH_NAME.test(name)) {
|
||||
return "metadata.health";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const ITALIAN_FISCAL_CODE = /(?<![A-Z0-9])[A-Z]{6}[0-9LMNPQRSTUV]{2}[ABCDEHLMPRST][0-9LMNPQRSTUV]{2}[A-Z][0-9LMNPQRSTUV]{3}[A-Z](?![A-Z0-9])/giu;
|
||||
const FISCAL_ODD: Record<string, number> = {
|
||||
"0": 1, "1": 0, "2": 5, "3": 7, "4": 9, "5": 13, "6": 15, "7": 17, "8": 19, "9": 21,
|
||||
A: 1, B: 0, C: 5, D: 7, E: 9, F: 13, G: 15, H: 17, I: 19, J: 21,
|
||||
K: 2, L: 4, M: 18, N: 20, O: 11, P: 3, Q: 6, R: 8, S: 12, T: 14,
|
||||
U: 16, V: 10, W: 22, X: 25, Y: 24, Z: 23,
|
||||
};
|
||||
|
||||
function validItalianFiscalCode(candidate: string): boolean {
|
||||
const value = candidate.toUpperCase();
|
||||
if (value.length !== 16) return false;
|
||||
let sum = 0;
|
||||
for (let index = 0; index < 15; index += 1) {
|
||||
const character = value[index]!;
|
||||
if (index % 2 === 0) sum += FISCAL_ODD[character] ?? -1000;
|
||||
else sum += /\d/u.test(character) ? Number(character) : character.charCodeAt(0) - 65;
|
||||
}
|
||||
return String.fromCharCode(65 + (sum % 26)) === value[15];
|
||||
}
|
||||
|
||||
function validIban(candidate: string): boolean {
|
||||
const value = candidate.replace(/\s+/gu, "").toUpperCase();
|
||||
if (!/^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$/u.test(value)) return false;
|
||||
const rearranged = value.slice(4) + value.slice(0, 4);
|
||||
let remainder = 0;
|
||||
for (const character of rearranged) {
|
||||
const digits = /\d/u.test(character) ? character : String(character.charCodeAt(0) - 55);
|
||||
for (const digit of digits) remainder = (remainder * 10 + Number(digit)) % 97;
|
||||
}
|
||||
return remainder === 1;
|
||||
}
|
||||
|
||||
function validPaymentCard(candidate: string): boolean {
|
||||
const digits = candidate.replace(/[ -]/gu, "");
|
||||
if (!/^\d{13,19}$/u.test(digits) || /^(\d)\1+$/u.test(digits)) return false;
|
||||
let sum = 0;
|
||||
let double = false;
|
||||
for (let index = digits.length - 1; index >= 0; index -= 1) {
|
||||
let digit = Number(digits[index]);
|
||||
if (double) {
|
||||
digit *= 2;
|
||||
if (digit > 9) digit -= 9;
|
||||
}
|
||||
sum += digit;
|
||||
double = !double;
|
||||
}
|
||||
return sum % 10 === 0;
|
||||
}
|
||||
|
||||
function jsonHasSensitiveKey(value: string): boolean {
|
||||
const trimmed = value.trim();
|
||||
if (!(trimmed.startsWith("{") || trimmed.startsWith("["))) return false;
|
||||
try {
|
||||
const pending: Array<{ value: unknown; depth: number }> = [{ value: JSON.parse(trimmed), depth: 0 }];
|
||||
let visited = 0;
|
||||
while (pending.length > 0 && visited < 1_000) {
|
||||
const item = pending.pop()!;
|
||||
visited += 1;
|
||||
if (item.depth > 8 || item.value === null || typeof item.value !== "object") continue;
|
||||
if (Array.isArray(item.value)) {
|
||||
for (const child of item.value) pending.push({ value: child, depth: item.depth + 1 });
|
||||
continue;
|
||||
}
|
||||
for (const [key, child] of Object.entries(item.value)) {
|
||||
if (sensitiveNameRule(key)) return true;
|
||||
pending.push({ value: child, depth: item.depth + 1 });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function contentEvidence(value: string): SensitivityEvidence | undefined {
|
||||
if (/-----BEGIN (?:[A-Z0-9]+ )?PRIVATE KEY-----/u.test(value)) {
|
||||
return { kind: "content", ruleId: "credential.private_key" };
|
||||
}
|
||||
if (/(?:^|[^A-Z0-9])AKIA[A-Z0-9]{16}(?![A-Z0-9])/u.test(value)
|
||||
|| /(?:^|[^A-Za-z0-9_])gh[pousr]_[A-Za-z0-9_]{30,}(?![A-Za-z0-9_])/u.test(value)
|
||||
|| /(?:^|[^A-Za-z0-9_-])eyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}(?![A-Za-z0-9_-])/u.test(value)) {
|
||||
return { kind: "content", ruleId: "credential.access_key" };
|
||||
}
|
||||
if (/(?:^|[^\p{L}\p{N}_])(?:api[_ -]?key|access[_ -]?token|password|passwd|pwd|secret)\s*[:=]\s*[^\s,;]{4,}/iu.test(value)) {
|
||||
return { kind: "content", ruleId: "credential.key_value" };
|
||||
}
|
||||
if (CLINICAL_TERM.test(value)) return { kind: "content", ruleId: "health.clinical_term" };
|
||||
for (const match of value.matchAll(EMAIL)) {
|
||||
if (validator.isEmail(match[0])) return { kind: "content", ruleId: "pii.email" };
|
||||
}
|
||||
for (const match of value.matchAll(ITALIAN_FISCAL_CODE)) {
|
||||
if (validItalianFiscalCode(match[0])) {
|
||||
return { kind: "content", ruleId: "pii.italian_fiscal_code" };
|
||||
}
|
||||
}
|
||||
for (const match of value.matchAll(/\b(?:passaporto|passport)(?:\s+(?:numero|number|n\.?))?\s*[:#-]?\s*([A-Z0-9]{9})\b/giu)) {
|
||||
if (validator.isPassportNumber(match[1]!, "IT")) {
|
||||
return { kind: "content", ruleId: "pii.passport_number" };
|
||||
}
|
||||
}
|
||||
for (const match of value.matchAll(/\bC[A-Z]\d{5}[A-Z]{2}\b/giu)) {
|
||||
if (validator.isIdentityCard(match[0], "IT")) {
|
||||
return { kind: "content", ruleId: "pii.identity_card" };
|
||||
}
|
||||
}
|
||||
if (/\b(?:patente(?:\s+di\s+guida)?|driving\s+licen[cs]e)(?:\s+(?:numero|number|n\.?))?\s*[:#-]?\s*[A-Z0-9]{8,12}\b/iu.test(value)) {
|
||||
return { kind: "content", ruleId: "pii.drivers_license_number" };
|
||||
}
|
||||
for (const match of value.matchAll(/(?<![A-Z0-9])[A-Z]{2}\d{2}(?:\s?[A-Z0-9]){11,30}(?![A-Z0-9])/giu)) {
|
||||
if (validIban(match[0])) return { kind: "content", ruleId: "financial.iban" };
|
||||
}
|
||||
for (const match of value.matchAll(/(?<!\d)(?:\d[ -]?){13,19}(?!\d)/gu)) {
|
||||
if (validPaymentCard(match[0])) {
|
||||
return { kind: "content", ruleId: "financial.payment_card" };
|
||||
}
|
||||
}
|
||||
for (const match of value.matchAll(/(?<![A-Z0-9])[A-Z]{6}[A-Z0-9]{2}(?:[A-Z0-9]{3})?(?![A-Z0-9])/giu)) {
|
||||
const before = value.slice(Math.max(0, (match.index ?? 0) - 24), match.index ?? 0);
|
||||
if (/\b(?:bic|swift)\s*[:=-]?\s*$/iu.test(before) && validator.isBIC(match[0])) {
|
||||
return { kind: "content", ruleId: "financial.bic" };
|
||||
}
|
||||
}
|
||||
for (const match of value.matchAll(/(?<!\d)(?:IT[ .-]?)?\d{11}(?!\d)/giu)) {
|
||||
const candidate = match[0].replace(/[ .-]/gu, "");
|
||||
if (validator.isVAT(candidate.replace(/^IT/iu, ""), "IT")) {
|
||||
return { kind: "content", ruleId: "pii.italian_vat" };
|
||||
}
|
||||
}
|
||||
for (const match of value.matchAll(/(?<![A-F0-9])(?:[A-F0-9]{2}[:-]){5}[A-F0-9]{2}(?![A-F0-9])/giu)) {
|
||||
if (validator.isMACAddress(match[0])) {
|
||||
return { kind: "content", ruleId: "network.mac_address" };
|
||||
}
|
||||
}
|
||||
for (const match of value.matchAll(/(?<![A-F0-9:.])[A-F0-9:.]{3,45}(?![A-F0-9:.])/giu)) {
|
||||
if (validator.isIP(match[0])) return { kind: "content", ruleId: "network.ip_address" };
|
||||
}
|
||||
for (const match of value.matchAll(/(?<![A-F0-9-])[0-9A-F]{8}-[0-9A-F]{4}-[1-8][0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}(?![A-F0-9-])/giu)) {
|
||||
if (validator.isUUID(match[0])) return { kind: "content", ruleId: "pii.uuid" };
|
||||
}
|
||||
for (const match of value.matchAll(/\b(?:https?|ftp):\/\/[^\s<>"']+/giu)) {
|
||||
const candidate = match[0].replace(/[.,;:!?\])}]+$/u, "");
|
||||
if (validator.isURL(candidate, { require_protocol: true })) {
|
||||
return { kind: "content", ruleId: "network.url" };
|
||||
}
|
||||
}
|
||||
if (findPhoneNumbersInText(value, "IT").some((match) => match.number.isValid())) {
|
||||
return { kind: "content", ruleId: "pii.phone_number" };
|
||||
}
|
||||
if (jsonHasSensitiveKey(value)) {
|
||||
return { kind: "content", ruleId: "pii.json_sensitive_key" };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
interface ColumnState {
|
||||
column: CatalogColumn;
|
||||
evidence: SensitivityEvidence[];
|
||||
nonSensitiveEvidence?: SensitivityEvidence;
|
||||
observedValues: number;
|
||||
nerCandidates: string[];
|
||||
coverage: "metadata" | "complete" | "sampled" | "no_values";
|
||||
sampledTarget: number;
|
||||
}
|
||||
|
||||
/** Sole decision module for local column-level sensitivity assessments. */
|
||||
export class SensitivityClassifier {
|
||||
constructor(
|
||||
private readonly values: SensitivityValueSource,
|
||||
private readonly detector?: LocalNerDetector,
|
||||
private readonly options: {
|
||||
queryTimeoutMs?: number;
|
||||
nerConfidenceThreshold?: number;
|
||||
maxNerValuesPerColumn?: number;
|
||||
maxNerCandidatesPerTable?: number;
|
||||
now?: () => number;
|
||||
} = {},
|
||||
) {}
|
||||
|
||||
async assess(
|
||||
targets: readonly SensitivityTableTarget[],
|
||||
signal: AbortSignal,
|
||||
sharedNerBudget?: SensitivityNerBudget,
|
||||
onActivity?: (message: string) => void | Promise<void>,
|
||||
): Promise<readonly SensitivityColumnAssessment[]> {
|
||||
const now = this.options.now ?? Date.now;
|
||||
const maxNerValuesPerColumn = boundedCount(this.options.maxNerValuesPerColumn, 8, 8);
|
||||
const states = new Map<string, ColumnState>();
|
||||
for (const target of targets) {
|
||||
for (const column of target.columns) {
|
||||
const nonSensitiveEvidence = nonSensitiveStructuralEvidence(column);
|
||||
const metadataMatch = nonSensitiveEvidence ? undefined : metadataEvidence(column);
|
||||
const binary = !nonSensitiveEvidence && UNSUPPORTED_BINARY_TYPE.test(column.dataType);
|
||||
states.set(column.id, {
|
||||
column,
|
||||
evidence: metadataMatch
|
||||
? [metadataMatch]
|
||||
: binary
|
||||
? [{ kind: "type", ruleId: "type.binary_uninspectable" }]
|
||||
: [],
|
||||
...(nonSensitiveEvidence ? { nonSensitiveEvidence } : {}),
|
||||
observedValues: 0,
|
||||
nerCandidates: [],
|
||||
coverage: nonSensitiveEvidence || metadataMatch || binary ? "metadata" : "no_values",
|
||||
sampledTarget: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const completeTables = new Set<string>();
|
||||
for (const [phaseIndex, phase] of SENSITIVITY_SAMPLE_PHASES.entries()) {
|
||||
for (let offset = 0; offset < targets.length; offset += MAX_CONCURRENT_TABLE_SCANS) {
|
||||
signal.throwIfAborted();
|
||||
const batchNumber = Math.floor(offset / MAX_CONCURRENT_TABLE_SCANS) + 1;
|
||||
const batchCount = Math.ceil(targets.length / MAX_CONCURRENT_TABLE_SCANS);
|
||||
await onActivity?.(
|
||||
`Scanning source data: pass ${phaseIndex + 1} of ${SENSITIVITY_SAMPLE_PHASES.length}, table batch ${batchNumber} of ${batchCount}.`,
|
||||
);
|
||||
signal.throwIfAborted();
|
||||
const peerController = new AbortController();
|
||||
const scanSignal = AbortSignal.any([signal, peerController.signal]);
|
||||
try {
|
||||
await Promise.all(targets.slice(offset, offset + MAX_CONCURRENT_TABLE_SCANS).map(async (target) => {
|
||||
if (completeTables.has(target.table.id)) return;
|
||||
const columns = target.columns.filter((column) => {
|
||||
const state = states.get(column.id)!;
|
||||
return state.evidence.length === 0 && !state.nonSensitiveEvidence
|
||||
&& (!phase.deepTextOnly || DEEP_TEXT_TYPE.test(column.dataType));
|
||||
});
|
||||
if (columns.length === 0) return;
|
||||
const coverage = await this.values.scanTable({
|
||||
...target,
|
||||
columns,
|
||||
valuesPerColumn: phase.additionalValuesPerColumn,
|
||||
sampleOffset: phase.targetValuesPerColumn - phase.additionalValuesPerColumn,
|
||||
sampleSeed: phase.sampleSeed,
|
||||
queryTimeoutMs: this.options.queryTimeoutMs ?? 5_000,
|
||||
...(phaseIndex === 0 ? { fullScanThreshold: 1_000 } : {}),
|
||||
}, (batch) => {
|
||||
for (const item of batch) {
|
||||
if (item.value === null) continue;
|
||||
const state = states.get(item.columnId);
|
||||
if (!state || state.evidence.length > 0) continue;
|
||||
state.observedValues += 1;
|
||||
if ((item.characterLength ?? item.value.length) > 500) {
|
||||
state.evidence.push({ kind: "length", ruleId: "text.over_500_characters" });
|
||||
continue;
|
||||
}
|
||||
const match = contentEvidence(item.value);
|
||||
if (match) {
|
||||
state.evidence.push(match);
|
||||
continue;
|
||||
}
|
||||
if (state.nerCandidates.length < maxNerValuesPerColumn
|
||||
&& !state.nerCandidates.includes(item.value)) {
|
||||
state.nerCandidates.push(item.value);
|
||||
}
|
||||
}
|
||||
}, scanSignal);
|
||||
for (const column of columns) {
|
||||
const state = states.get(column.id)!;
|
||||
state.sampledTarget = Math.max(state.sampledTarget, phase.targetValuesPerColumn);
|
||||
state.coverage = coverage.kind === "complete"
|
||||
? "complete"
|
||||
: state.observedValues === 0 ? "no_values" : "sampled";
|
||||
}
|
||||
if (coverage.kind === "complete") completeTables.add(target.table.id);
|
||||
}));
|
||||
} catch (error) {
|
||||
peerController.abort(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const nerBudget = sharedNerBudget ?? { remainingMs: 10_000 };
|
||||
if (this.detector && (this.detector.isReady?.() ?? true) && !signal.aborted
|
||||
&& nerBudget.remainingMs > 0) {
|
||||
const maxCandidates = boundedCount(this.options.maxNerCandidatesPerTable, 2, 1_024);
|
||||
const threshold = this.options.nerConfidenceThreshold ?? 0.8;
|
||||
for (const [targetIndex, target] of targets.entries()) {
|
||||
signal.throwIfAborted();
|
||||
if (nerBudget.remainingMs <= 0) break;
|
||||
const candidates: LocalNerCandidate[] = [];
|
||||
candidateSelection: for (let valueIndex = 0; valueIndex < maxNerValuesPerColumn; valueIndex += 1) {
|
||||
for (const column of target.columns) {
|
||||
const state = states.get(column.id)!;
|
||||
if (state.evidence.length > 0 || state.nonSensitiveEvidence) continue;
|
||||
const text = state.nerCandidates[valueIndex];
|
||||
if (text === undefined) continue;
|
||||
candidates.push({ columnId: column.id, text });
|
||||
if (candidates.length >= maxCandidates) break candidateSelection;
|
||||
}
|
||||
}
|
||||
if (candidates.length === 0) continue;
|
||||
await onActivity?.(
|
||||
`Running local entity detection: table ${targetIndex + 1} of ${targets.length}.`,
|
||||
);
|
||||
signal.throwIfAborted();
|
||||
const startedAt = now();
|
||||
const deadline = startedAt + nerBudget.remainingMs;
|
||||
try {
|
||||
for (let offset = 0; offset < candidates.length; offset += MAX_NER_CANDIDATES_PER_REQUEST) {
|
||||
if (signal.aborted || now() >= deadline) break;
|
||||
try {
|
||||
const detected = await this.detector.detect(
|
||||
candidates.slice(offset, offset + MAX_NER_CANDIDATES_PER_REQUEST),
|
||||
signal,
|
||||
deadline,
|
||||
);
|
||||
for (const item of detected) {
|
||||
const state = states.get(item.columnId);
|
||||
if (!state || state.evidence.length > 0 || !Number.isFinite(item.confidence)
|
||||
|| item.confidence < threshold || item.confidence > 1) continue;
|
||||
const label = normalizedName(item.label).slice(0, 80);
|
||||
if (!label) continue;
|
||||
state.evidence.push({
|
||||
kind: "ner",
|
||||
ruleId: "ner.entity",
|
||||
label,
|
||||
confidence: item.confidence,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// NER is optional: deterministic findings and scan coverage remain authoritative.
|
||||
break;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
nerBudget.remainingMs = Math.max(0, nerBudget.remainingMs - Math.max(1, now() - startedAt));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return targets.flatMap((target) => target.columns.map((column) => {
|
||||
const state = states.get(column.id)!;
|
||||
const sensitive = state.evidence.length > 0;
|
||||
const coverage = state.nonSensitiveEvidence
|
||||
? "metadata"
|
||||
: state.observedValues === 0 && !sensitive ? "no_values" : state.coverage;
|
||||
const coverageEvidence: SensitivityEvidence[] = sensitive
|
||||
? state.evidence
|
||||
: state.nonSensitiveEvidence
|
||||
? [state.nonSensitiveEvidence]
|
||||
: [{
|
||||
kind: "coverage",
|
||||
ruleId: coverage === "complete"
|
||||
? "coverage.complete"
|
||||
: coverage === "no_values"
|
||||
? "coverage.no_values"
|
||||
: `coverage.sampled_${state.sampledTarget}`,
|
||||
}];
|
||||
return {
|
||||
columnId: column.id,
|
||||
assessment: sensitive ? "sensitive" : "non_sensitive",
|
||||
proposedSensitive: sensitive,
|
||||
evidence: coverageEvidence,
|
||||
observedValues: state.observedValues,
|
||||
coverage,
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
/** Convenience for focused callers and rule-level tests. Production orchestration uses assess(). */
|
||||
async assessTable(
|
||||
target: SensitivityTableTarget,
|
||||
signal: AbortSignal,
|
||||
_retiredRunDeadline?: number,
|
||||
nerBudget?: SensitivityNerBudget,
|
||||
): Promise<readonly SensitivityColumnAssessment[]> {
|
||||
return await this.assess([target], signal, nerBudget);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { loadConfig } from "../config.js";
|
||||
import { WorkspaceSecretStore } from "../workspaces/secret-store.js";
|
||||
import { PythonLocalNerDetector } from "./local-ner-detector.js";
|
||||
import { ConcreteCatalogPostgresAccess } from "./postgres-access.js";
|
||||
import { createCatalogRepository } from "./repository.js";
|
||||
import {
|
||||
SENSITIVITY_POLICY_VERSION,
|
||||
SensitivityAnalysisService,
|
||||
} from "./sensitivity-analysis-service.js";
|
||||
import { SensitivityClassifier } from "./sensitivity-classifier.js";
|
||||
import { ConcreteSensitivityValueSource } from "./sensitivity-value-source.js";
|
||||
|
||||
const WORKSPACE_ID = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u;
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const workspaceId = process.argv[2];
|
||||
if (!workspaceId || !WORKSPACE_ID.test(workspaceId)) {
|
||||
process.stderr.write("Usage: sensitivity-shadow <workspace-id>\n");
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
|
||||
let detector: PythonLocalNerDetector | undefined;
|
||||
let stage = "configuration";
|
||||
try {
|
||||
const config = loadConfig(process.env);
|
||||
stage = "catalog";
|
||||
const repository = createCatalogRepository(config.catalogDatabase);
|
||||
if (!(await repository.available())) throw new Error("catalog unavailable");
|
||||
const database = await repository.getByWorkspace(workspaceId);
|
||||
if (!database) throw new Error("database unavailable");
|
||||
stage = "source";
|
||||
const secretStore = new WorkspaceSecretStore({
|
||||
root: config.workspaceSecretStoreRoot,
|
||||
runtimeRoot: config.workspaceSecretRuntimeRoot,
|
||||
installationId: config.workspaceRegistry.installationId,
|
||||
});
|
||||
const access = new ConcreteCatalogPostgresAccess(secretStore, {
|
||||
connectTimeoutMs: config.workspaceDiagnosticTimeoutMs,
|
||||
});
|
||||
const source = new ConcreteSensitivityValueSource(access, secretStore);
|
||||
if (config.sensitivityNer) {
|
||||
const workerScript = config.sensitivityNer.workerScript
|
||||
?? fileURLToPath(new URL("../../python/sensitivity_ner_worker.py", import.meta.url));
|
||||
detector = new PythonLocalNerDetector({
|
||||
pythonExecutable: config.sensitivityNer.pythonExecutable,
|
||||
workerScript,
|
||||
modelPath: config.sensitivityNer.modelPath,
|
||||
cwd: dirname(workerScript),
|
||||
threads: config.sensitivityNer.threads,
|
||||
});
|
||||
try {
|
||||
await detector.warmup();
|
||||
} catch {
|
||||
await detector.close();
|
||||
detector = undefined;
|
||||
}
|
||||
}
|
||||
const startedAt = Date.now();
|
||||
stage = "analysis";
|
||||
const suggestions = await new SensitivityAnalysisService(
|
||||
repository,
|
||||
new SensitivityClassifier(source, detector),
|
||||
).analyze(database.id, "all", [], new AbortController().signal);
|
||||
const assessments = { sensitive: 0, nonSensitive: 0 };
|
||||
const coverage = { metadata: 0, complete: 0, sampled: 0, noValues: 0 };
|
||||
const rules = new Map<string, number>();
|
||||
for (const suggestion of suggestions) {
|
||||
if (suggestion.assessment === "sensitive") assessments.sensitive += 1;
|
||||
else assessments.nonSensitive += 1;
|
||||
if (suggestion.coverage === "no_values") coverage.noValues += 1;
|
||||
else coverage[suggestion.coverage] += 1;
|
||||
for (const evidence of suggestion.evidence) {
|
||||
rules.set(evidence.ruleId, (rules.get(evidence.ruleId) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
process.stdout.write(`${JSON.stringify({
|
||||
ok: true,
|
||||
policyVersion: SENSITIVITY_POLICY_VERSION,
|
||||
nerEnabled: detector !== undefined,
|
||||
total: suggestions.length,
|
||||
assessments,
|
||||
coverage,
|
||||
rules: Object.fromEntries([...rules].sort(([left], [right]) => left.localeCompare(right))),
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
})}\n`);
|
||||
} catch {
|
||||
process.stdout.write(`${JSON.stringify({
|
||||
ok: false,
|
||||
code: `sensitivity_shadow_${stage}_failed`,
|
||||
})}\n`);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await detector?.close();
|
||||
}
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -0,0 +1,291 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import type { WorkspaceSecretStore } from "../workspaces/secret-store.js";
|
||||
import { CATALOG_SECRET_IDS } from "./secrets.js";
|
||||
import type { CatalogPostgresAccess } from "./postgres-access.js";
|
||||
import type {
|
||||
SensitivityScanCoverage,
|
||||
SensitivityScanRequest,
|
||||
SensitivityValueObservation,
|
||||
SensitivityValueSource,
|
||||
} from "./sensitivity-classifier.js";
|
||||
import { CatalogConnectorError, type CatalogColumn } from "./types.js";
|
||||
|
||||
const MAX_VALUE_CHARACTERS = 501;
|
||||
const MAX_COLUMNS_PER_QUERY = 25;
|
||||
const SAMPLE_OVERSCAN_FACTOR = 10;
|
||||
|
||||
function quoteIdentifier(identifier: string): string {
|
||||
return `"${identifier.replaceAll('"', '""')}"`;
|
||||
}
|
||||
|
||||
function chunks<T>(items: readonly T[], size: number): T[][] {
|
||||
const result: T[][] = [];
|
||||
for (let offset = 0; offset < items.length; offset += size) {
|
||||
result.push(items.slice(offset, offset + size));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function tableReference(request: SensitivityScanRequest): string {
|
||||
return `${quoteIdentifier(request.database.schema)}.${quoteIdentifier(request.table.name)}`;
|
||||
}
|
||||
|
||||
function samplePercentage(valuesPerColumn: number): number {
|
||||
if (valuesPerColumn <= 300) return 30;
|
||||
if (valuesPerColumn <= 700) return 70;
|
||||
return 100;
|
||||
}
|
||||
|
||||
function flatValueQuery(
|
||||
request: SensitivityScanRequest,
|
||||
columns: readonly CatalogColumn[],
|
||||
options: { complete: boolean; randomized: boolean },
|
||||
): string {
|
||||
const projections = columns.map((column) => quoteIdentifier(column.name)).join(", ");
|
||||
const perColumnLimit = options.complete
|
||||
? request.fullScanThreshold ?? request.valuesPerColumn
|
||||
: request.valuesPerColumn;
|
||||
const rowLimit = Math.max(perColumnLimit, perColumnLimit * SAMPLE_OVERSCAN_FACTOR);
|
||||
const sample = options.complete
|
||||
? `SELECT ${projections} FROM ${tableReference(request)}`
|
||||
: [
|
||||
`SELECT ${projections} FROM ${tableReference(request)}`,
|
||||
...(options.randomized
|
||||
? [`TABLESAMPLE SYSTEM (${samplePercentage(request.valuesPerColumn)}) REPEATABLE (${request.sampleSeed})`]
|
||||
: []),
|
||||
`LIMIT ${rowLimit} OFFSET ${request.sampleOffset}`,
|
||||
].join(" ");
|
||||
const values = columns.map((column, index) => {
|
||||
const identifier = quoteIdentifier(column.name);
|
||||
return [
|
||||
`(${index}, LEFT((sampled.${identifier})::text, ${MAX_VALUE_CHARACTERS}),`,
|
||||
`CASE WHEN sampled.${identifier} IS NULL THEN NULL`,
|
||||
`ELSE char_length((sampled.${identifier})::text) END)`,
|
||||
].join(" ");
|
||||
}).join(", ");
|
||||
return [
|
||||
`WITH sampled AS MATERIALIZED (${sample}),`,
|
||||
"ranked AS (",
|
||||
"SELECT value.__column_index, value.__value, value.__length,",
|
||||
"row_number() OVER (PARTITION BY value.__column_index) AS __rank",
|
||||
"FROM sampled",
|
||||
`CROSS JOIN LATERAL (VALUES ${values}) AS value(__column_index, __value, __length)`,
|
||||
"WHERE value.__value IS NOT NULL",
|
||||
")",
|
||||
"SELECT __column_index, __value, __length FROM ranked",
|
||||
`WHERE __rank <= ${perColumnLimit}`,
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
function observations(
|
||||
columns: readonly CatalogColumn[],
|
||||
rows: readonly Record<string, unknown>[],
|
||||
): SensitivityValueObservation[] {
|
||||
return rows.flatMap((row) => {
|
||||
const index = Number(row.__column_index);
|
||||
const column = Number.isSafeInteger(index) && index >= 0 ? columns[index] : undefined;
|
||||
if (!column || row.__value === null || row.__value === undefined) return [];
|
||||
const value = String(row.__value);
|
||||
const parsedLength = row.__length === null || row.__length === undefined
|
||||
? null
|
||||
: Number(row.__length);
|
||||
return [{
|
||||
columnId: column.id,
|
||||
value,
|
||||
characterLength: parsedLength !== null && Number.isSafeInteger(parsedLength) && parsedLength >= 0
|
||||
? parsedLength
|
||||
: value.length,
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
function cancelled(error: unknown): boolean {
|
||||
return Boolean(error && typeof error === "object" && "code" in error && error.code === "57014");
|
||||
}
|
||||
|
||||
/**
|
||||
* Database-specific sampling adapter. Policy stays in SensitivityClassifier; this module only
|
||||
* produces bounded, normalized non-null observations without persisting or logging values.
|
||||
*/
|
||||
export class ConcreteSensitivityValueSource implements SensitivityValueSource {
|
||||
constructor(
|
||||
private readonly access: CatalogPostgresAccess,
|
||||
private readonly secretStore?: Pick<WorkspaceSecretStore, "materialize">,
|
||||
) {}
|
||||
|
||||
async scanTable(
|
||||
request: SensitivityScanRequest,
|
||||
consume: (batch: readonly SensitivityValueObservation[]) => void | Promise<void>,
|
||||
signal: AbortSignal,
|
||||
): Promise<SensitivityScanCoverage> {
|
||||
if (request.columns.length === 0) return { kind: "complete", observedValues: 0 };
|
||||
if (request.database.binding.transport === "rest_api") {
|
||||
return await this.scanRest(request, consume, signal);
|
||||
}
|
||||
return await this.scanPostgres(request, consume, signal);
|
||||
}
|
||||
|
||||
private async scanPostgres(
|
||||
request: SensitivityScanRequest,
|
||||
consume: (batch: readonly SensitivityValueObservation[]) => void | Promise<void>,
|
||||
signal: AbortSignal,
|
||||
): Promise<SensitivityScanCoverage> {
|
||||
const client = await this.access.connect(request.database, signal);
|
||||
let transactionOpen = false;
|
||||
let savepointSequence = 0;
|
||||
let observedValues = 0;
|
||||
try {
|
||||
signal.throwIfAborted();
|
||||
await client.query("BEGIN TRANSACTION READ ONLY", []);
|
||||
transactionOpen = true;
|
||||
await client.query("SELECT set_config('statement_timeout', $1, true)", [
|
||||
`${Math.max(1, Math.floor(request.queryTimeoutMs))}ms`,
|
||||
]);
|
||||
const boundedQuery = async (sql: string): Promise<Array<Record<string, unknown>> | undefined> => {
|
||||
signal.throwIfAborted();
|
||||
savepointSequence += 1;
|
||||
const savepoint = `sensitivity_scan_${savepointSequence}`;
|
||||
await client.query(`SAVEPOINT ${savepoint}`, []);
|
||||
try {
|
||||
return (await client.query(sql, [])).rows;
|
||||
} catch (error) {
|
||||
if (!cancelled(error)) throw error;
|
||||
await client.query(`ROLLBACK TO SAVEPOINT ${savepoint}`, []);
|
||||
return undefined;
|
||||
} finally {
|
||||
await client.query(`RELEASE SAVEPOINT ${savepoint}`, []).catch(() => undefined);
|
||||
}
|
||||
};
|
||||
|
||||
let complete = false;
|
||||
if (request.fullScanThreshold !== undefined) {
|
||||
const probe = await boundedQuery(
|
||||
`SELECT 1 AS __present FROM ${tableReference(request)} LIMIT ${request.fullScanThreshold + 1}`,
|
||||
);
|
||||
complete = probe !== undefined && probe.length <= request.fullScanThreshold;
|
||||
}
|
||||
for (const columnChunk of chunks(request.columns, MAX_COLUMNS_PER_QUERY)) {
|
||||
signal.throwIfAborted();
|
||||
let rows = await boundedQuery(flatValueQuery(request, columnChunk, {
|
||||
complete,
|
||||
randomized: !complete,
|
||||
}));
|
||||
if (rows === undefined && complete) {
|
||||
complete = false;
|
||||
rows = await boundedQuery(flatValueQuery(request, columnChunk, {
|
||||
complete: false,
|
||||
randomized: true,
|
||||
}));
|
||||
}
|
||||
if (!complete && (rows === undefined || rows.length === 0)) {
|
||||
rows = await boundedQuery(flatValueQuery(request, columnChunk, {
|
||||
complete: false,
|
||||
randomized: false,
|
||||
}));
|
||||
}
|
||||
if (rows === undefined) throw new CatalogConnectorError("Sensitivity sample query timed out");
|
||||
const batch = observations(columnChunk, rows);
|
||||
observedValues += batch.length;
|
||||
if (batch.length > 0) await consume(batch);
|
||||
}
|
||||
return { kind: complete ? "complete" : "sampled", observedValues };
|
||||
} catch (error) {
|
||||
if (error instanceof CatalogConnectorError) throw error;
|
||||
throw new CatalogConnectorError("Sensitivity source scan failed");
|
||||
} finally {
|
||||
if (transactionOpen) await client.query("ROLLBACK", []).catch(() => undefined);
|
||||
await client.end().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
private async scanRest(
|
||||
request: SensitivityScanRequest,
|
||||
consume: (batch: readonly SensitivityValueObservation[]) => void | Promise<void>,
|
||||
signal: AbortSignal,
|
||||
): Promise<SensitivityScanCoverage> {
|
||||
if (!this.secretStore) throw new CatalogConnectorError("REST sensitivity scanning is not configured");
|
||||
const auth = request.database.binding.restAuth ?? "bearer";
|
||||
const materialized = this.secretStore.materialize(
|
||||
request.database.workspaceId,
|
||||
auth === "none" ? [] : [CATALOG_SECRET_IDS.apiKey],
|
||||
);
|
||||
let observedValues = 0;
|
||||
try {
|
||||
const headers: Record<string, string> = { "content-type": "application/json" };
|
||||
if (auth !== "none") {
|
||||
const credentialFile = materialized.files.get(CATALOG_SECRET_IDS.apiKey);
|
||||
if (!credentialFile) throw new CatalogConnectorError("REST API key is not configured");
|
||||
const credential = (await readFile(credentialFile, "utf8")).trim();
|
||||
if (auth === "bearer") headers.authorization = `Bearer ${credential}`;
|
||||
else headers["x-api-key"] = credential;
|
||||
}
|
||||
const baseUrl = request.database.binding.baseUrl?.replace(/\/+$/u, "");
|
||||
if (!baseUrl) throw new CatalogConnectorError("Database binding is incomplete");
|
||||
const runQuery = async (sql: string): Promise<Array<Record<string, unknown>> | undefined> => {
|
||||
const timeout = AbortSignal.timeout(Math.max(1, Math.floor(request.queryTimeoutMs)));
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/rpc/run_query`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ query_text: sql }),
|
||||
signal: AbortSignal.any([signal, timeout]),
|
||||
});
|
||||
if (!response.ok) throw new CatalogConnectorError("REST sensitivity source scan failed");
|
||||
const body: unknown = await response.json();
|
||||
if (!Array.isArray(body)
|
||||
|| body.some((row) => !row || typeof row !== "object" || Array.isArray(row))) {
|
||||
throw new CatalogConnectorError("REST sensitivity source response is invalid");
|
||||
}
|
||||
return body as Array<Record<string, unknown>>;
|
||||
} catch (error) {
|
||||
if (signal.aborted) throw error;
|
||||
if (timeout.aborted) return undefined;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
let complete = false;
|
||||
if (request.fullScanThreshold !== undefined) {
|
||||
const probe = await runQuery(
|
||||
`SELECT 1 AS __present FROM ${tableReference(request)} LIMIT ${request.fullScanThreshold + 1}`,
|
||||
);
|
||||
complete = probe !== undefined && probe.length <= request.fullScanThreshold;
|
||||
}
|
||||
let requestCount = request.fullScanThreshold === undefined ? 0 : 1;
|
||||
for (const columnChunk of chunks(request.columns, MAX_COLUMNS_PER_QUERY)) {
|
||||
signal.throwIfAborted();
|
||||
let rows = await runQuery(flatValueQuery(request, columnChunk, {
|
||||
complete,
|
||||
randomized: !complete,
|
||||
}));
|
||||
requestCount += 1;
|
||||
if (rows === undefined && complete) {
|
||||
complete = false;
|
||||
rows = await runQuery(flatValueQuery(request, columnChunk, {
|
||||
complete: false,
|
||||
randomized: true,
|
||||
}));
|
||||
requestCount += 1;
|
||||
}
|
||||
if (!complete && (rows === undefined || rows.length === 0)) {
|
||||
rows = await runQuery(flatValueQuery(request, columnChunk, {
|
||||
complete: false,
|
||||
randomized: false,
|
||||
}));
|
||||
requestCount += 1;
|
||||
}
|
||||
if (rows === undefined) throw new CatalogConnectorError("REST sensitivity sample query timed out");
|
||||
const batch = observations(columnChunk, rows);
|
||||
observedValues += batch.length;
|
||||
if (batch.length > 0) await consume(batch);
|
||||
}
|
||||
// Multiple HTTP requests cannot share a source snapshot, so only one-request reads are complete.
|
||||
return { kind: complete && requestCount === 1 ? "complete" : "sampled", observedValues };
|
||||
} catch (error) {
|
||||
if (error instanceof CatalogConnectorError) throw error;
|
||||
throw new CatalogConnectorError("REST sensitivity source scan failed");
|
||||
} finally {
|
||||
materialized.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { buildInstallationContract } from "../workspaces/contracts.js";
|
||||
import { resolveBinding } from "../workspaces/bindings.js";
|
||||
import type { WorkspaceRegistry } from "../workspaces/registry.js";
|
||||
import type { WorkspaceDescriptor } from "../workspaces/schema.js";
|
||||
import { discoverWorkspaceSecretRequirements } from "../workspaces/secret-requirements.js";
|
||||
import type { WorkspaceSecretStore } from "../workspaces/secret-store.js";
|
||||
import { createConcreteDiagnosticAdapters } from "../workspaces/diagnostics.js";
|
||||
import { CatalogOperationCoordinator } from "./operation-coordinator.js";
|
||||
@@ -25,49 +24,74 @@ export interface CatalogListItem extends Omit<WorkspaceDatabase, "id"> {
|
||||
workspaceName: string;
|
||||
workspaceDescription?: string;
|
||||
workspaceAvailable: boolean;
|
||||
workspaceRevision: { commit: string; blob: string } | null;
|
||||
workspaceEvidence: {
|
||||
sourceType: "filesystem" | "http" | "s3" | null;
|
||||
state:
|
||||
| "not_declared"
|
||||
| "materialized_current_revision"
|
||||
| "configuration_required"
|
||||
| "configured_unverified"
|
||||
| "workspace_unavailable";
|
||||
};
|
||||
runtimeBinding: {
|
||||
transport: DatabaseBinding["transport"];
|
||||
configurationState: "ready" | "configuration_required";
|
||||
sessionTransportSupported: boolean;
|
||||
} | null;
|
||||
configured: boolean;
|
||||
secrets: Record<CatalogSecretName, boolean>;
|
||||
}
|
||||
|
||||
function bindingValue(workspace: WorkspaceDescriptor, values: Record<string, string>, suffix: string) {
|
||||
const variable = buildInstallationContract(workspace).variables.find((entry) => (
|
||||
entry.role === "DWH" && entry.suffix === suffix
|
||||
));
|
||||
return variable ? values[variable.name] : undefined;
|
||||
}
|
||||
|
||||
function numeric(value: string | undefined): number | undefined {
|
||||
if (!value) return undefined;
|
||||
const parsed = Number(value);
|
||||
return Number.isInteger(parsed) && parsed >= 1 && parsed <= 65_535 ? parsed : undefined;
|
||||
}
|
||||
|
||||
function yamlBinding(workspace: WorkspaceDescriptor, secretRoots: readonly string[]): DatabaseBinding {
|
||||
const effective = resolveBinding(workspace, "DWH", process.env, secretRoots);
|
||||
const value = (suffix: string) => bindingValue(workspace, effective.values, suffix);
|
||||
return {
|
||||
transport: effective.transport,
|
||||
host: value("HOST"),
|
||||
port: numeric(value("PORT")) ?? workspace.dwh.port,
|
||||
username: value("USER"),
|
||||
baseUrl: value("BASE_URL"),
|
||||
restPath: workspace.diagnostics?.dwh_rest?.path ?? "/health",
|
||||
restAuth: workspace.diagnostics?.dwh_rest?.auth ?? "bearer",
|
||||
tlsServername: value("TLS_SERVERNAME"),
|
||||
sshHost: value("SSH_HOST"),
|
||||
sshPort: numeric(value("SSH_PORT")),
|
||||
sshUsername: value("SSH_USER"),
|
||||
sshTargetHost: value("SSH_TARGET_HOST"),
|
||||
sshTargetPort: numeric(value("SSH_TARGET_PORT")),
|
||||
};
|
||||
}
|
||||
|
||||
function secretState(store: WorkspaceSecretStore, workspaceId: string): Record<CatalogSecretName, boolean> {
|
||||
return Object.fromEntries(Object.entries(CATALOG_SECRET_IDS).map(([name, id]) => (
|
||||
[name, store.has(workspaceId, id)]
|
||||
))) as Record<CatalogSecretName, boolean>;
|
||||
}
|
||||
|
||||
function databaseRuntimeState(
|
||||
store: WorkspaceSecretStore,
|
||||
database: WorkspaceDatabase,
|
||||
): NonNullable<CatalogListItem["runtimeBinding"]> {
|
||||
const { binding, workspaceId } = database;
|
||||
const configured = (id: string) => store.has(workspaceId, id);
|
||||
const connectionComplete = binding.transport === "postgres_direct"
|
||||
? Boolean(binding.host && binding.port && binding.username && configured(CATALOG_SECRET_IDS.password))
|
||||
: binding.transport === "rest_api"
|
||||
? Boolean(binding.baseUrl && (binding.restAuth === "none" || configured(CATALOG_SECRET_IDS.apiKey)))
|
||||
: Boolean(
|
||||
binding.username && binding.sshHost && binding.sshPort && binding.sshUsername
|
||||
&& binding.sshTargetHost && binding.sshTargetPort
|
||||
&& configured(CATALOG_SECRET_IDS.password)
|
||||
&& configured(CATALOG_SECRET_IDS.sshPrivateKey)
|
||||
&& configured(CATALOG_SECRET_IDS.sshKnownHosts),
|
||||
);
|
||||
return {
|
||||
transport: binding.transport,
|
||||
configurationState: connectionComplete ? "ready" : "configuration_required",
|
||||
sessionTransportSupported: binding.transport !== "ssh_tunnel",
|
||||
};
|
||||
}
|
||||
|
||||
function workspaceEvidenceState(
|
||||
store: WorkspaceSecretStore,
|
||||
workspace: WorkspaceDescriptor,
|
||||
): CatalogListItem["workspaceEvidence"] {
|
||||
const source = workspace.evidence?.source;
|
||||
if (!source) return { sourceType: null, state: "not_declared" };
|
||||
if (source.type === "filesystem") {
|
||||
return { sourceType: source.type, state: "materialized_current_revision" };
|
||||
}
|
||||
const configurationRequired = discoverWorkspaceSecretRequirements(workspace, process.env)
|
||||
.some(({ connector, id, required }) => (
|
||||
connector === "evidence" && required && !store.has(workspace.workspace.id, id)
|
||||
));
|
||||
return {
|
||||
sourceType: source.type,
|
||||
state: configurationRequired ? "configuration_required" : "configured_unverified",
|
||||
};
|
||||
}
|
||||
|
||||
export class CatalogService {
|
||||
private readonly adapters = createConcreteDiagnosticAdapters();
|
||||
|
||||
@@ -91,23 +115,31 @@ export class CatalogService {
|
||||
const byWorkspace = new Map(configured.map((database) => [database.workspaceId, database]));
|
||||
const active = await Promise.all(workspaces.map(async (entry) => {
|
||||
const database = byWorkspace.get(entry.id);
|
||||
const { workspace } = await this.registry.read(entry.id);
|
||||
const { workspace } = await this.registry.readPinned(entry.id, entry.revision.commit);
|
||||
const base = database ?? {
|
||||
workspaceId: entry.id,
|
||||
engine: "postgres" as const,
|
||||
databaseName: workspace.dwh.database,
|
||||
schema: workspace.dwh.schema,
|
||||
databaseName: "",
|
||||
schema: "",
|
||||
version: 0,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
binding: yamlBinding(workspace, this.secretRoots),
|
||||
binding: { transport: "postgres_direct" as const },
|
||||
connectionStatus: "untested" as const,
|
||||
metadataContentRevision: 0,
|
||||
preprocessingStatus: "failed" as const,
|
||||
};
|
||||
return {
|
||||
...base,
|
||||
workspaceName: entry.name,
|
||||
workspaceDescription: entry.description,
|
||||
workspaceAvailable: true,
|
||||
workspaceRevision: {
|
||||
commit: entry.revision.commit,
|
||||
blob: entry.revision.blob,
|
||||
},
|
||||
workspaceEvidence: workspaceEvidenceState(this.secretStore, workspace),
|
||||
runtimeBinding: database ? databaseRuntimeState(this.secretStore, database) : null,
|
||||
configured: database !== undefined,
|
||||
secrets: secretState(this.secretStore, entry.id),
|
||||
};
|
||||
@@ -120,6 +152,9 @@ export class CatalogService {
|
||||
workspaceName: database.workspaceId,
|
||||
workspaceDescription: "Workspace is no longer present in the repository catalog.",
|
||||
workspaceAvailable: false,
|
||||
workspaceRevision: null,
|
||||
workspaceEvidence: { sourceType: null, state: "workspace_unavailable" },
|
||||
runtimeBinding: null,
|
||||
configured: true,
|
||||
secrets: secretState(this.secretStore, database.workspaceId),
|
||||
}));
|
||||
@@ -132,13 +167,13 @@ export class CatalogService {
|
||||
}
|
||||
|
||||
async normalizeInput(input: DatabaseConfigurationInput): Promise<DatabaseConfigurationInput> {
|
||||
const workspace = await this.ensureWorkspace(input.workspaceId);
|
||||
await this.ensureWorkspace(input.workspaceId);
|
||||
if (input.binding.transport !== "rest_api") return input;
|
||||
return {
|
||||
...input,
|
||||
binding: {
|
||||
...input.binding,
|
||||
restPath: workspace.diagnostics?.dwh_rest?.path ?? "/health",
|
||||
restPath: input.binding.restPath ?? "/health",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -39,7 +39,10 @@ function safeFailure(error: unknown): { code: string; message: string } {
|
||||
};
|
||||
}
|
||||
if (error instanceof CatalogConnectorError) {
|
||||
return { code: "schema_introspection_failed", message: "The database schema could not be read safely." };
|
||||
return {
|
||||
code: "schema_introspection_failed",
|
||||
message: "The database schema could not be read. Check the connection and credentials, then try again.",
|
||||
};
|
||||
}
|
||||
return { code: "schema_sync_failed", message: "Schema synchronization failed." };
|
||||
}
|
||||
@@ -55,6 +58,8 @@ export class CatalogSyncWorker {
|
||||
private readonly introspector: CatalogSchemaIntrospector,
|
||||
private readonly operations: CatalogOperationCoordinator,
|
||||
private readonly timeoutMs: number,
|
||||
private readonly cleanupMemory: (database: WorkspaceDatabase, run: CatalogSyncRun) => Promise<number>
|
||||
= async () => 0,
|
||||
) {}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
@@ -64,7 +69,9 @@ export class CatalogSyncWorker {
|
||||
}
|
||||
|
||||
async start(database: WorkspaceDatabase, scope: CatalogSyncScope, tableIds: readonly string[]): Promise<CatalogSyncRun> {
|
||||
this.assertReady(database);
|
||||
if ((await this.repository.listSyncRuns(database.id, 100)).some(run => run.phase === "memory_cleanup")) {
|
||||
throw new CatalogConflictError("Retry the pending Memory cleanup before starting another synchronization");
|
||||
}
|
||||
const uniqueTableIds = [...new Set(tableIds)];
|
||||
if (scope === "columns") {
|
||||
const tables = await Promise.all(uniqueTableIds.map((tableId) => this.repository.getTable(database.id, tableId)));
|
||||
@@ -107,7 +114,7 @@ export class CatalogSyncWorker {
|
||||
async cancel(runId: string): Promise<CatalogSyncRun | undefined> {
|
||||
const run = await this.repository.getSyncRun(runId);
|
||||
if (!run) return undefined;
|
||||
if (run.state === "applying" || TERMINAL_STATES.has(run.state)) return run;
|
||||
if (run.phase === "memory_cleanup" || run.state === "applying" || TERMINAL_STATES.has(run.state)) return run;
|
||||
await this.repository.requestSyncRunCancellation(runId);
|
||||
this.controllers.get(runId)?.abort();
|
||||
if (run.state === "queued" || run.state === "awaiting_confirmation") {
|
||||
@@ -136,6 +143,16 @@ export class CatalogSyncWorker {
|
||||
}
|
||||
const database = await this.repository.get(previous.databaseId);
|
||||
if (!database) return undefined;
|
||||
if (previous.phase === "memory_cleanup") {
|
||||
const release = this.operations.reserve(database.id);
|
||||
try {
|
||||
const queued = await this.repository.updateSyncRun(runId, { state: "queued", cancelRequested: false,
|
||||
errorCode: null, errorMessage: null, finishedAt: null, leaseOwner: null, leaseExpiresAt: null });
|
||||
this.reservations.set(runId, release);
|
||||
this.launch(runId);
|
||||
return queued;
|
||||
} catch (error) { release(); throw error; }
|
||||
}
|
||||
return await this.start(database, previous.scope, previous.tableIds);
|
||||
}
|
||||
|
||||
@@ -177,7 +194,10 @@ export class CatalogSyncWorker {
|
||||
if (!database || database.version !== claimed.requestedDatabaseVersion) {
|
||||
throw new CatalogConflictError("Database binding changed before synchronization started");
|
||||
}
|
||||
this.assertReady(database);
|
||||
if (claimed.phase === "memory_cleanup") {
|
||||
await this.finishMemoryCleanup(database, claimed);
|
||||
return;
|
||||
}
|
||||
const progress: CatalogSchemaScanProgress = async (phase, counts) => {
|
||||
await this.checkCancelled(runId);
|
||||
await this.repository.updateSyncRun(runId, {
|
||||
@@ -229,36 +249,30 @@ export class CatalogSyncWorker {
|
||||
claimed.scope,
|
||||
claimed.tableIds,
|
||||
snapshot,
|
||||
runId,
|
||||
);
|
||||
if (!applied) throw new CatalogConflictError("Database binding changed before schema changes were applied");
|
||||
await this.repository.updateSyncRun(runId, {
|
||||
state: "succeeded",
|
||||
phase: "completed",
|
||||
counts: applied,
|
||||
finishedAt: new Date().toISOString(),
|
||||
observedSnapshot: null,
|
||||
plannedDiff: null,
|
||||
confirmationToken: null,
|
||||
heartbeatAt: new Date().toISOString(),
|
||||
leaseOwner: null,
|
||||
leaseExpiresAt: null,
|
||||
});
|
||||
await this.repository.appendSyncEvent(runId, "info", "succeeded", "Synchronization completed.", { ...applied });
|
||||
this.release(runId);
|
||||
const cleanupRun = await this.repository.updateSyncRun(runId, { counts: applied });
|
||||
if (!cleanupRun) throw new Error("Synchronization run disappeared");
|
||||
await this.finishMemoryCleanup(database, cleanupRun);
|
||||
/* Completion is recorded only after the durable Memory cleanup succeeds. */
|
||||
return;
|
||||
} catch (error) {
|
||||
const current = await this.repository.getSyncRun(runId);
|
||||
const cancelled = !timedOut && (error instanceof SyncCancelledError || controller.signal.aborted || current?.cancelRequested);
|
||||
const failure = timedOut
|
||||
const failure = current?.phase === "memory_cleanup"
|
||||
? { code: "memory_cleanup_pending", message: "Catalog synchronized. Memory cleanup is pending; retry this synchronization to complete it." }
|
||||
: timedOut
|
||||
? { code: "schema_sync_timed_out", message: "Schema synchronization timed out." }
|
||||
: safeFailure(error);
|
||||
await this.repository.updateSyncRun(runId, {
|
||||
state: cancelled ? "cancelled" : "failed",
|
||||
phase: "completed",
|
||||
phase: current?.phase === "memory_cleanup" ? "memory_cleanup" : "completed",
|
||||
errorCode: cancelled ? null : failure.code,
|
||||
errorMessage: cancelled ? null : failure.message,
|
||||
finishedAt: new Date().toISOString(),
|
||||
observedSnapshot: null,
|
||||
plannedDiff: null,
|
||||
observedSnapshot: current?.phase === "memory_cleanup" ? current.observedSnapshot : null,
|
||||
plannedDiff: current?.phase === "memory_cleanup" ? current.plannedDiff : null,
|
||||
confirmationToken: null,
|
||||
leaseOwner: null,
|
||||
leaseExpiresAt: null,
|
||||
@@ -277,10 +291,18 @@ export class CatalogSyncWorker {
|
||||
}
|
||||
}
|
||||
|
||||
private assertReady(database: WorkspaceDatabase): void {
|
||||
if (database.connectionStatus !== "reachable" || database.testedVersion !== database.version) {
|
||||
throw new CatalogConflictError("Test the current database binding before synchronizing its schema");
|
||||
}
|
||||
private async finishMemoryCleanup(database: WorkspaceDatabase, run: CatalogSyncRun): Promise<void> {
|
||||
if (!run.plannedDiff || run.phase !== "memory_cleanup") throw new Error("Missing committed cleanup context");
|
||||
await this.repository.appendSyncEvent(run.id, "info", "memory_cleanup", "Removing Memory cards with deleted physical dependencies.");
|
||||
const memoryDeleted = await this.cleanupMemory(database, run);
|
||||
const counts = { ...run.counts, memoryDeleted };
|
||||
await this.repository.updateSyncRun(run.id, {
|
||||
state: "succeeded", phase: "completed", counts, finishedAt: new Date().toISOString(),
|
||||
observedSnapshot: null, plannedDiff: null, confirmationToken: null,
|
||||
heartbeatAt: new Date().toISOString(), leaseOwner: null, leaseExpiresAt: null,
|
||||
});
|
||||
await this.repository.appendSyncEvent(run.id, "info", "succeeded", "Synchronization completed.", counts);
|
||||
this.release(run.id);
|
||||
}
|
||||
|
||||
private assertCapability(scope: CatalogSyncScope, snapshot: ObservedSchemaSnapshot): void {
|
||||
|
||||
+140
-29
@@ -2,6 +2,7 @@ export const DATABASE_TRANSPORTS = ["postgres_direct", "rest_api", "ssh_tunnel"]
|
||||
export type DatabaseTransport = (typeof DATABASE_TRANSPORTS)[number];
|
||||
|
||||
export type ConnectionStatus = "untested" | "reachable" | "failed";
|
||||
export type CatalogPreprocessingStatus = "running" | "succeeded" | "failed";
|
||||
|
||||
export interface DatabaseBinding {
|
||||
transport: DatabaseTransport;
|
||||
@@ -36,8 +37,23 @@ export interface WorkspaceDatabase {
|
||||
lastErrorMessage?: string;
|
||||
schemaSyncedVersion?: number;
|
||||
schemaSyncedAt?: string;
|
||||
metadataContentRevision: number;
|
||||
preprocessingStatus: CatalogPreprocessingStatus;
|
||||
preprocessingInputFingerprint?: string;
|
||||
preprocessedMetadataRevision?: number;
|
||||
preprocessingStartedAt?: string;
|
||||
preprocessingFinishedAt?: string;
|
||||
preprocessingErrorCode?: string;
|
||||
}
|
||||
|
||||
export type CatalogPreprocessingStartResult =
|
||||
| { kind: "started"; database: WorkspaceDatabase }
|
||||
| { kind: "not_found" | "schema_stale" | "catalog_busy" | "already_running" };
|
||||
|
||||
export type CatalogPreprocessingClearResult =
|
||||
| { kind: "cleared"; database: WorkspaceDatabase }
|
||||
| { kind: "not_found" | "already_running" };
|
||||
|
||||
export interface CatalogMetrics {
|
||||
scope: "global" | "database";
|
||||
databaseId: string | null;
|
||||
@@ -102,6 +118,7 @@ export interface CatalogColumn {
|
||||
description: string | null;
|
||||
generatedDescription: string | null;
|
||||
sensitive: boolean;
|
||||
sensitivityReason: string | null;
|
||||
lastSyncedDatabaseVersion: number | null;
|
||||
lastSyncedAt: string | null;
|
||||
version: number;
|
||||
@@ -128,7 +145,7 @@ export interface CatalogRelationshipColumn {
|
||||
targetColumnName: string;
|
||||
}
|
||||
|
||||
export interface CatalogRelationship {
|
||||
export interface CatalogPhysicalRelationship {
|
||||
id: string;
|
||||
databaseId: string;
|
||||
constraintName: string;
|
||||
@@ -145,11 +162,57 @@ export interface CatalogRelationship {
|
||||
lastSyncedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
origin: "physical";
|
||||
status: "active";
|
||||
}
|
||||
|
||||
export interface CatalogLogicalRelationship {
|
||||
id: string;
|
||||
databaseId: string;
|
||||
constraintName: null;
|
||||
sourceTableId: string;
|
||||
sourceTableName: string;
|
||||
targetTableId: string;
|
||||
targetTableName: string;
|
||||
updateRule: null;
|
||||
deleteRule: null;
|
||||
deferrable: false;
|
||||
initiallyDeferred: false;
|
||||
columns: [CatalogRelationshipColumn];
|
||||
lastSyncedDatabaseVersion: null;
|
||||
lastSyncedAt: null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
origin: "generated" | "manual";
|
||||
status: "active" | "excluded";
|
||||
}
|
||||
|
||||
export type CatalogRelationship = CatalogPhysicalRelationship | CatalogLogicalRelationship;
|
||||
|
||||
export interface CatalogLogicalRelationshipEndpoint {
|
||||
columnId: string;
|
||||
columnName: string;
|
||||
tableId: string;
|
||||
tableName: string;
|
||||
dataType: string;
|
||||
primaryKeyPosition: number | null;
|
||||
tablePrimaryKeyColumnCount: number;
|
||||
}
|
||||
|
||||
export interface CatalogLogicalRelationshipContext {
|
||||
endpoints: CatalogLogicalRelationshipEndpoint[];
|
||||
physicalPairs: Array<{ sourceColumnId: string; targetColumnId: string }>;
|
||||
logicalRelationships: CatalogLogicalRelationship[];
|
||||
}
|
||||
|
||||
export interface CatalogLogicalRelationshipCandidate {
|
||||
sourceColumnId: string;
|
||||
targetColumnId: string;
|
||||
}
|
||||
|
||||
export type CatalogDatabaseMetadataDeleteTarget = "tables" | "relationships";
|
||||
export type CatalogTableMetadataDeleteTarget = "columns" | "relationships";
|
||||
export type CatalogDescriptionTarget = "tables" | "columns";
|
||||
export type CatalogDescriptionTarget = "tables" | "columns" | "database" | "database_columns";
|
||||
|
||||
export interface CatalogMetadataDeleteCounts {
|
||||
tables: number;
|
||||
@@ -188,6 +251,9 @@ export interface DescriptionGenerationRun {
|
||||
generated: number;
|
||||
nonGeneratable: number;
|
||||
failed: number;
|
||||
inputTokens: number;
|
||||
cacheReadTokens: number;
|
||||
outputTokens: number;
|
||||
createdAt: string;
|
||||
startedAt: string | null;
|
||||
updatedAt: string;
|
||||
@@ -204,6 +270,9 @@ export interface DescriptionGenerationRunUpdate {
|
||||
startedAt?: string | null;
|
||||
finishedAt?: string | null;
|
||||
errorSummary?: string | null;
|
||||
inputTokens?: number;
|
||||
cacheReadTokens?: number;
|
||||
outputTokens?: number;
|
||||
}
|
||||
|
||||
export interface DescriptionGenerationEvent {
|
||||
@@ -214,18 +283,24 @@ export interface DescriptionGenerationEvent {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export type SensitiveDataSuggestionScope = "all" | "selected_tables" | "selected_columns";
|
||||
export type SensitiveDataSuggestionStatus = "running" | "completed" | "failed" | "interrupted";
|
||||
export type SensitivityAnalysisScope = "all" | "selected_tables" | "selected_columns";
|
||||
export type SensitivityAnalysisStatus = "running" | "completed" | "failed" | "interrupted";
|
||||
|
||||
export interface SensitiveDataSuggestionRun {
|
||||
export interface SensitivityAnalysisRun {
|
||||
id: string;
|
||||
databaseId: string;
|
||||
scope: SensitiveDataSuggestionScope;
|
||||
modelId: string;
|
||||
status: SensitiveDataSuggestionStatus;
|
||||
scope: SensitivityAnalysisScope;
|
||||
engine: "llm" | "local";
|
||||
modelId: string | null;
|
||||
policyVersion: string | null;
|
||||
status: SensitivityAnalysisStatus;
|
||||
total: number;
|
||||
suggestedSensitive: number;
|
||||
suggestedNonSensitive: number;
|
||||
unknown: number;
|
||||
inputTokens: number;
|
||||
cacheReadTokens: number;
|
||||
outputTokens: number;
|
||||
createdAt: string;
|
||||
startedAt: string;
|
||||
updatedAt: string;
|
||||
@@ -233,16 +308,20 @@ export interface SensitiveDataSuggestionRun {
|
||||
errorSummary: string | null;
|
||||
}
|
||||
|
||||
export interface SensitiveDataSuggestionRunUpdate {
|
||||
status?: SensitiveDataSuggestionStatus;
|
||||
export interface SensitivityAnalysisRunUpdate {
|
||||
status?: SensitivityAnalysisStatus;
|
||||
total?: number;
|
||||
suggestedSensitive?: number;
|
||||
suggestedNonSensitive?: number;
|
||||
unknown?: number;
|
||||
finishedAt?: string | null;
|
||||
errorSummary?: string | null;
|
||||
inputTokens?: number;
|
||||
cacheReadTokens?: number;
|
||||
outputTokens?: number;
|
||||
}
|
||||
|
||||
export interface SensitiveDataSuggestionEvent {
|
||||
export interface SensitivityAnalysisEvent {
|
||||
runId: string;
|
||||
sequence: number;
|
||||
level: "info" | "warning" | "error";
|
||||
@@ -287,7 +366,7 @@ export type CatalogSyncState =
|
||||
export type CatalogSyncPhase =
|
||||
| "queued" | "connecting" | "scanning_tables" | "scanning_columns"
|
||||
| "scanning_relationships" | "planning" | "awaiting_confirmation"
|
||||
| "applying" | "completed";
|
||||
| "applying" | "memory_cleanup" | "completed";
|
||||
|
||||
export interface CatalogSchemaDiff {
|
||||
deletedTables: string[];
|
||||
@@ -296,6 +375,7 @@ export interface CatalogSchemaDiff {
|
||||
}
|
||||
|
||||
export interface CatalogSyncCounts {
|
||||
memoryDeleted?: number;
|
||||
tables?: number;
|
||||
columns?: number;
|
||||
relationships?: number;
|
||||
@@ -370,6 +450,17 @@ export interface CatalogRepository {
|
||||
list(): Promise<WorkspaceDatabase[]>;
|
||||
get(id: string): Promise<WorkspaceDatabase | undefined>;
|
||||
getByWorkspace(workspaceId: string): Promise<WorkspaceDatabase | undefined>;
|
||||
beginPreprocessing(
|
||||
workspaceId: string,
|
||||
inputFingerprint: string,
|
||||
): Promise<CatalogPreprocessingStartResult>;
|
||||
finishPreprocessing(
|
||||
workspaceId: string,
|
||||
metadataContentRevision: number,
|
||||
inputFingerprint: string,
|
||||
outcome: { status: "succeeded" } | { status: "failed"; errorCode: string },
|
||||
): Promise<WorkspaceDatabase | undefined>;
|
||||
clearPreprocessing(workspaceId: string): Promise<CatalogPreprocessingClearResult>;
|
||||
getCatalogMetrics(databaseId?: string): Promise<CatalogMetrics | undefined>;
|
||||
create(input: DatabaseConfigurationInput): Promise<WorkspaceDatabase>;
|
||||
update(id: string, expectedVersion: number, input: DatabaseConfigurationInput): Promise<WorkspaceDatabase | undefined>;
|
||||
@@ -401,6 +492,7 @@ export interface CatalogRepository {
|
||||
description: string | null,
|
||||
generatedDescription: string | null,
|
||||
sensitive?: boolean,
|
||||
sensitivityReason?: string | null,
|
||||
): Promise<CatalogColumn | undefined>;
|
||||
consolidateGeneratedDescriptions(
|
||||
databaseId: string,
|
||||
@@ -433,30 +525,48 @@ export interface CatalogRepository {
|
||||
runId: string,
|
||||
afterSequence?: number,
|
||||
): Promise<DescriptionGenerationEvent[]>;
|
||||
createSensitiveDataSuggestionRun(
|
||||
createSensitivityAnalysisRun(
|
||||
databaseId: string,
|
||||
scope: SensitiveDataSuggestionScope,
|
||||
modelId: string,
|
||||
): Promise<SensitiveDataSuggestionRun>;
|
||||
getSensitiveDataSuggestionRun(runId: string): Promise<SensitiveDataSuggestionRun | undefined>;
|
||||
listSensitiveDataSuggestionRuns(limit?: number): Promise<SensitiveDataSuggestionRun[]>;
|
||||
interruptActiveSensitiveDataSuggestionRuns(
|
||||
scope: SensitivityAnalysisScope,
|
||||
origin: { engine: "llm"; modelId: string } | { engine: "local"; policyVersion: string },
|
||||
): Promise<SensitivityAnalysisRun>;
|
||||
getSensitivityAnalysisRun(runId: string): Promise<SensitivityAnalysisRun | undefined>;
|
||||
listSensitivityAnalysisRuns(limit?: number): Promise<SensitivityAnalysisRun[]>;
|
||||
interruptActiveSensitivityAnalysisRuns(
|
||||
errorSummary: string,
|
||||
): Promise<SensitiveDataSuggestionRun[]>;
|
||||
updateSensitiveDataSuggestionRun(
|
||||
): Promise<SensitivityAnalysisRun[]>;
|
||||
updateSensitivityAnalysisRun(
|
||||
runId: string,
|
||||
update: SensitiveDataSuggestionRunUpdate,
|
||||
): Promise<SensitiveDataSuggestionRun | undefined>;
|
||||
appendSensitiveDataSuggestionEvent(
|
||||
update: SensitivityAnalysisRunUpdate,
|
||||
): Promise<SensitivityAnalysisRun | undefined>;
|
||||
appendSensitivityAnalysisEvent(
|
||||
runId: string,
|
||||
level: SensitiveDataSuggestionEvent["level"],
|
||||
level: SensitivityAnalysisEvent["level"],
|
||||
message: string,
|
||||
): Promise<SensitiveDataSuggestionEvent>;
|
||||
listSensitiveDataSuggestionEvents(
|
||||
): Promise<SensitivityAnalysisEvent>;
|
||||
listSensitivityAnalysisEvents(
|
||||
runId: string,
|
||||
afterSequence?: number,
|
||||
): Promise<SensitiveDataSuggestionEvent[]>;
|
||||
listRelationships(databaseId: string): Promise<CatalogRelationship[]>;
|
||||
): Promise<SensitivityAnalysisEvent[]>;
|
||||
listRelationships(databaseId: string): Promise<CatalogPhysicalRelationship[]>;
|
||||
listLogicalRelationships(databaseId: string): Promise<CatalogLogicalRelationship[]>;
|
||||
getLogicalRelationshipContext(databaseId: string): Promise<CatalogLogicalRelationshipContext | undefined>;
|
||||
insertLogicalRelationship(
|
||||
databaseId: string,
|
||||
sourceColumnId: string,
|
||||
targetColumnId: string,
|
||||
generated: boolean,
|
||||
): Promise<CatalogLogicalRelationship | undefined>;
|
||||
insertGeneratedLogicalRelationships(
|
||||
databaseId: string,
|
||||
candidates: readonly CatalogLogicalRelationshipCandidate[],
|
||||
): Promise<number>;
|
||||
setLogicalRelationshipStatus(
|
||||
databaseId: string,
|
||||
relationshipId: string,
|
||||
status: CatalogLogicalRelationship["status"],
|
||||
): Promise<CatalogLogicalRelationship | undefined>;
|
||||
deleteLogicalRelationship(databaseId: string, relationshipId: string): Promise<boolean>;
|
||||
deleteDatabaseMetadata(
|
||||
databaseIds: readonly string[],
|
||||
target: CatalogDatabaseMetadataDeleteTarget,
|
||||
@@ -478,6 +588,7 @@ export interface CatalogRepository {
|
||||
scope: CatalogSyncScope,
|
||||
tableIds: readonly string[],
|
||||
snapshot: ObservedSchemaSnapshot,
|
||||
syncRunId?: string,
|
||||
): Promise<CatalogSyncCounts | undefined>;
|
||||
createSyncRun(
|
||||
databaseId: string,
|
||||
|
||||
+73
-2
@@ -30,8 +30,18 @@ export interface AppConfig {
|
||||
dataRoot?: string;
|
||||
ollamaEnsureTimeoutMs: number;
|
||||
piManagementTimeoutMs: number;
|
||||
/** Host CLI platform projected into Docker; not the browser or container OS. */
|
||||
hostPlatform?: string;
|
||||
secretsFile?: string;
|
||||
installationConfigFile?: string;
|
||||
evidenceHostRegistryRoot?: string;
|
||||
modelCatalogFile?: string;
|
||||
sensitivityNer?: {
|
||||
pythonExecutable: string;
|
||||
modelPath: string;
|
||||
workerScript?: string;
|
||||
threads: number;
|
||||
};
|
||||
piAuthFile?: string;
|
||||
secretFiles: Readonly<Record<string, string | undefined>>;
|
||||
modelApiKeyFile?: string;
|
||||
@@ -50,6 +60,7 @@ export interface AppConfig {
|
||||
workspaceSecretRuntimeRoot: string;
|
||||
internalQdrantUrl: string;
|
||||
internalEmbeddingUrl: string;
|
||||
internalEmbeddingId: string;
|
||||
internalEmbeddingModel: string;
|
||||
internalEmbeddingDimensions: number;
|
||||
}
|
||||
@@ -189,6 +200,21 @@ function positiveDimension(value: string | undefined, fallback: number): number
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function internalEmbeddingIdentity(
|
||||
identityValue: string | undefined,
|
||||
modelValue: string | undefined,
|
||||
): { id: string; model: string } {
|
||||
const id = identityValue ?? "ollama/qwen3-embedding:0.6b";
|
||||
if (!/^ollama\/[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/.test(id)) {
|
||||
throw new Error("internal embedding identity configuration is invalid");
|
||||
}
|
||||
const model = id.slice(id.indexOf("/") + 1);
|
||||
if (modelValue !== undefined && modelValue !== model) {
|
||||
throw new Error("internal embedding model does not match its canonical identity");
|
||||
}
|
||||
return { id, model };
|
||||
}
|
||||
|
||||
function catalogDatabase(env: Record<string, string | undefined>): CatalogConnectionConfig | undefined {
|
||||
const value = env.THT_CATALOG_DATABASE_URL;
|
||||
if (value !== undefined) {
|
||||
@@ -330,6 +356,42 @@ export function loadConfig(
|
||||
|| installationConfigFile.includes("\0")
|
||||
|| !path.isAbsolute(installationConfigFile)
|
||||
)) throw new Error("installation configuration is invalid");
|
||||
const modelCatalogFile = env.THT_MODEL_CATALOG_FILE;
|
||||
if (modelCatalogFile !== undefined && (
|
||||
modelCatalogFile.trim() !== modelCatalogFile
|
||||
|| modelCatalogFile.length === 0
|
||||
|| modelCatalogFile.includes("\0")
|
||||
|| !path.isAbsolute(modelCatalogFile)
|
||||
)) throw new Error("runtime model catalog configuration is invalid");
|
||||
const sensitivityNerModelPath = env.THT_SENSITIVITY_NER_MODEL_PATH;
|
||||
const sensitivityNerPython = env.THT_SENSITIVITY_NER_PYTHON;
|
||||
const sensitivityNerWorker = env.THT_SENSITIVITY_NER_WORKER;
|
||||
for (const [value, label] of [
|
||||
[sensitivityNerModelPath, "model path"],
|
||||
[sensitivityNerPython, "Python executable"],
|
||||
[sensitivityNerWorker, "worker path"],
|
||||
] as const) {
|
||||
if (value !== undefined && (
|
||||
value.length === 0 || value.trim() !== value || value.includes("\0") || !path.isAbsolute(value)
|
||||
)) throw new Error(`sensitivity NER ${label} configuration is invalid`);
|
||||
}
|
||||
if (sensitivityNerModelPath === undefined && (
|
||||
sensitivityNerPython !== undefined
|
||||
|| sensitivityNerWorker !== undefined
|
||||
|| env.THT_SENSITIVITY_NER_THREADS !== undefined
|
||||
)) throw new Error("sensitivity NER settings require a model path");
|
||||
const sensitivityNerThreads = Number(env.THT_SENSITIVITY_NER_THREADS ?? 2);
|
||||
if (!Number.isSafeInteger(sensitivityNerThreads) || sensitivityNerThreads < 1 || sensitivityNerThreads > 8) {
|
||||
throw new Error("sensitivity NER thread configuration is invalid");
|
||||
}
|
||||
const sensitivityNer = sensitivityNerModelPath === undefined
|
||||
? undefined
|
||||
: {
|
||||
modelPath: sensitivityNerModelPath,
|
||||
pythonExecutable: sensitivityNerPython ?? "/opt/sensitivity-ner/bin/python",
|
||||
...(sensitivityNerWorker ? { workerScript: sensitivityNerWorker } : {}),
|
||||
threads: sensitivityNerThreads,
|
||||
};
|
||||
const piAuthFile = env.THT_PI_AUTH_FILE;
|
||||
if (piAuthFile !== undefined && (
|
||||
piAuthFile.trim() !== piAuthFile || piAuthFile.length === 0 || piAuthFile.includes("\0")
|
||||
@@ -391,6 +453,10 @@ export function loadConfig(
|
||||
"internal embedding URL",
|
||||
["embedding", "localhost"],
|
||||
);
|
||||
const internalEmbedding = internalEmbeddingIdentity(
|
||||
env.THT_INTERNAL_EMBEDDING_ID,
|
||||
env.THT_INTERNAL_EMBEDDING_MODEL,
|
||||
);
|
||||
return {
|
||||
host: env.HOST ?? "127.0.0.1",
|
||||
port: Number(env.PORT ?? 8787),
|
||||
@@ -404,15 +470,19 @@ export function loadConfig(
|
||||
publicExposure,
|
||||
sessionStorage,
|
||||
catalogDatabase: catalogDatabase(env),
|
||||
defaults: { provider: env.PI_PROVIDER, model: env.PI_MODEL, thinking: env.PI_THINKING },
|
||||
defaults: { thinking: env.PI_THINKING },
|
||||
maxPiProcesses: Number(env.MAX_PI_PROCESSES ?? 4),
|
||||
settingsFile,
|
||||
maintenanceFile: env.THT_MAINTENANCE_FILE ?? path.join(path.dirname(settingsFile), "maintenance.json"),
|
||||
dataRoot: env.THT_DATA_ROOT,
|
||||
ollamaEnsureTimeoutMs: Number(env.OLLAMA_ENSURE_TIMEOUT_MS ?? 60000),
|
||||
piManagementTimeoutMs: piManagementTimeout(env.PI_MANAGEMENT_TIMEOUT_MS),
|
||||
hostPlatform: env.THT_HOST_PLATFORM,
|
||||
secretsFile,
|
||||
installationConfigFile,
|
||||
evidenceHostRegistryRoot: env.THT_EVIDENCE_HOST_REGISTRY_ROOT || undefined,
|
||||
modelCatalogFile,
|
||||
sensitivityNer,
|
||||
piAuthFile,
|
||||
secretFiles,
|
||||
modelApiKeyFile,
|
||||
@@ -425,7 +495,8 @@ export function loadConfig(
|
||||
workspaceSecretRuntimeRoot,
|
||||
internalQdrantUrl,
|
||||
internalEmbeddingUrl,
|
||||
internalEmbeddingModel: env.THT_INTERNAL_EMBEDDING_MODEL ?? "qwen3-embedding:0.6b",
|
||||
internalEmbeddingId: internalEmbedding.id,
|
||||
internalEmbeddingModel: internalEmbedding.model,
|
||||
internalEmbeddingDimensions: positiveDimension(env.THT_INTERNAL_EMBEDDING_DIMENSIONS, 1024),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
isUsableAuthenticationSecret,
|
||||
} from "../auth/secret-policy.js";
|
||||
|
||||
/** Credential names that metadata-generation model entries may reference. */
|
||||
/** Credential names that Installation Model Catalog providers may reference. */
|
||||
export const METADATA_GENERATION_SECRET_KEYS = Object.freeze([
|
||||
"THT_METADATA_API_KEY", "ANTHROPIC_API_KEY", "AZURE_API_KEY", "GEMINI_API_KEY",
|
||||
"DEEPSEEK_API_KEY", "OPENAI_API_KEY", "OPENROUTER_API_KEY", "ZAI_API_KEY",
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import {
|
||||
closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, type Stats,
|
||||
} from "node:fs";
|
||||
import { z } from "zod";
|
||||
|
||||
const MAX_CATALOG_BYTES = 1024 * 1024;
|
||||
const RUNTIME_CATALOG_FILE = "/run/thothii-model-catalog/catalog.json";
|
||||
const canonicalId = z.string().regex(/^[a-z][a-z0-9._-]{0,63}\/[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/);
|
||||
const secretBundleKey = /^[A-Z][A-Z0-9_]{0,63}$/;
|
||||
|
||||
const endpointSchema = z.object({
|
||||
baseUrl: z.string().url(),
|
||||
apiVersion: z.string().optional(),
|
||||
}).strict();
|
||||
|
||||
const authenticationSchema = z.object({
|
||||
mode: z.enum(["secret_env", "pi_auth", "none"]),
|
||||
apiKeyEnv: z.string().optional(),
|
||||
}).strict();
|
||||
|
||||
const runtimeModelSchema = z.object({
|
||||
id: canonicalId,
|
||||
provider: z.string().min(1),
|
||||
model: z.string().min(1),
|
||||
label: z.string().min(1),
|
||||
upstreamModel: z.string().min(1),
|
||||
endpoint: endpointSchema.optional(),
|
||||
authentication: authenticationSchema,
|
||||
sessionAdapter: z.object({ mode: z.enum(["pi_builtin", "openai_compatible"]) }).strict().optional(),
|
||||
metadataAdapter: z.object({ litellmProvider: z.string().min(1) }).strict().optional(),
|
||||
session: z.object({
|
||||
reasoning: z.boolean(),
|
||||
input: z.array(z.string()).optional(),
|
||||
cost: z.object({
|
||||
input: z.number(), output: z.number(), cacheRead: z.number(), cacheWrite: z.number(),
|
||||
}).strict().optional(),
|
||||
contextWindow: z.number().int().positive().optional(),
|
||||
maxTokens: z.number().int().positive().optional(),
|
||||
compatibility: z.object({
|
||||
supportsDeveloperRole: z.boolean(),
|
||||
supportsReasoningEffort: z.boolean(),
|
||||
supportsStore: z.boolean(),
|
||||
maxTokensField: z.string().optional(),
|
||||
thinkingFormat: z.enum(["qwen", "qwen-chat-template"]).optional(),
|
||||
}).strict().optional(),
|
||||
}).strict().refine((session) => !session.compatibility?.thinkingFormat || session.reasoning, {
|
||||
message: "thinkingFormat requires reasoning: true",
|
||||
}).optional(),
|
||||
metadataGeneration: z.object({ disableThinking: z.boolean() }).strict().optional(),
|
||||
}).strict();
|
||||
|
||||
const catalogSchema = z.object({
|
||||
schemaVersion: z.literal(2),
|
||||
defaultInteraction: canonicalId,
|
||||
embedding: z.object({ id: canonicalId, dimensions: z.number().int().positive() }).strict(),
|
||||
models: z.array(runtimeModelSchema).max(64),
|
||||
}).strict();
|
||||
|
||||
export type RuntimeModel = z.infer<typeof runtimeModelSchema>;
|
||||
|
||||
export interface RuntimeModelCatalog {
|
||||
readonly defaultInteraction: string | null;
|
||||
readonly embedding: Readonly<{ id: string; dimensions: number }> | null;
|
||||
sessionModels(): readonly RuntimeModel[];
|
||||
metadataModels(): readonly RuntimeModel[];
|
||||
hasSession(id: string): boolean;
|
||||
}
|
||||
|
||||
class RestartLoadedRuntimeModelCatalog implements RuntimeModelCatalog {
|
||||
readonly defaultInteraction: string | null;
|
||||
readonly embedding: Readonly<{ id: string; dimensions: number }> | null;
|
||||
readonly #sessions: readonly RuntimeModel[];
|
||||
readonly #metadata: readonly RuntimeModel[];
|
||||
readonly #sessionIds: ReadonlySet<string>;
|
||||
|
||||
constructor(catalog?: z.infer<typeof catalogSchema>) {
|
||||
this.defaultInteraction = catalog?.defaultInteraction ?? null;
|
||||
this.embedding = catalog ? Object.freeze({ ...catalog.embedding }) : null;
|
||||
// One operational list. Session-only installations can still run Core, but once Admin
|
||||
// LLM models are configured every selectable model must support both adapters.
|
||||
const hasMetadata = catalog?.models.some((model) => model.metadataGeneration !== undefined);
|
||||
this.#sessions = Object.freeze((catalog?.models ?? []).filter((model) =>
|
||||
model.session !== undefined && (!hasMetadata || model.metadataGeneration !== undefined)));
|
||||
this.#metadata = Object.freeze(this.#sessions.filter((model) => model.metadataGeneration !== undefined));
|
||||
this.#sessionIds = new Set(this.#sessions.map((model) => model.id));
|
||||
}
|
||||
|
||||
sessionModels(): readonly RuntimeModel[] { return this.#sessions.map((model) => ({ ...model })); }
|
||||
metadataModels(): readonly RuntimeModel[] { return this.#metadata.map((model) => ({ ...model })); }
|
||||
hasSession(id: string): boolean { return this.#sessionIds.has(id); }
|
||||
}
|
||||
|
||||
function protectedCatalogStat(file: string, info: Stats): boolean {
|
||||
const mode = info.mode & 0o777;
|
||||
if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1
|
||||
|| info.size < 1 || info.size > MAX_CATALOG_BYTES) return false;
|
||||
if (file === RUNTIME_CATALOG_FILE && info.uid === 0 && (mode === 0o444 || mode === 0o644)) return true;
|
||||
return info.uid === (process.getuid?.() ?? info.uid) && (mode === 0o400 || mode === 0o600 || mode === 0o644);
|
||||
}
|
||||
|
||||
function readProtectedCatalog(file: string): unknown {
|
||||
let descriptor: number | undefined;
|
||||
try {
|
||||
const before = lstatSync(file);
|
||||
if (!protectedCatalogStat(file, before)) throw new Error("runtime model catalog is unavailable");
|
||||
descriptor = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||
const opened = fstatSync(descriptor);
|
||||
if (!protectedCatalogStat(file, opened)
|
||||
|| before.dev !== opened.dev || before.ino !== opened.ino) throw new Error("runtime model catalog is unavailable");
|
||||
const source = readFileSync(descriptor, "utf8");
|
||||
const after = fstatSync(descriptor);
|
||||
const current = lstatSync(file);
|
||||
if (!protectedCatalogStat(file, after) || !protectedCatalogStat(file, current)
|
||||
|| opened.dev !== after.dev || opened.ino !== after.ino
|
||||
|| opened.dev !== current.dev || opened.ino !== current.ino) throw new Error("runtime model catalog is unavailable");
|
||||
return JSON.parse(source);
|
||||
} catch {
|
||||
throw new Error("runtime model catalog is unavailable");
|
||||
} finally {
|
||||
if (descriptor !== undefined) try { closeSync(descriptor); } catch { /* sanitized above */ }
|
||||
}
|
||||
}
|
||||
|
||||
export function loadRuntimeModelCatalog(file?: string): RuntimeModelCatalog {
|
||||
if (!file) return new RestartLoadedRuntimeModelCatalog();
|
||||
const parsed = catalogSchema.safeParse(readProtectedCatalog(file));
|
||||
if (!parsed.success) throw new Error("runtime model catalog is invalid");
|
||||
if (parsed.data.models.some((model) => !validRuntimeModel(model))) {
|
||||
throw new Error("runtime model catalog is invalid");
|
||||
}
|
||||
const ids = new Set(parsed.data.models.map((model) => model.id));
|
||||
if (ids.size !== parsed.data.models.length) throw new Error("runtime model catalog contains duplicate models");
|
||||
const sessions = parsed.data.models.filter((model) => model.session !== undefined).map((model) => model.id);
|
||||
const metadata = parsed.data.models.filter((model) => model.metadataGeneration !== undefined).map((model) => model.id);
|
||||
if (!sessions.includes(parsed.data.defaultInteraction)
|
||||
|| (metadata.length > 0 && !metadata.includes(parsed.data.defaultInteraction))) {
|
||||
throw new Error("runtime model catalog interaction default is invalid");
|
||||
}
|
||||
return new RestartLoadedRuntimeModelCatalog(parsed.data);
|
||||
}
|
||||
|
||||
function validRuntimeModel(model: RuntimeModel): boolean {
|
||||
if ((model.session !== undefined) !== (model.sessionAdapter !== undefined)) return false;
|
||||
if ((model.metadataGeneration !== undefined) !== (model.metadataAdapter !== undefined)) return false;
|
||||
switch (model.authentication.mode) {
|
||||
case "secret_env":
|
||||
return model.authentication.apiKeyEnv !== undefined
|
||||
&& secretBundleKey.test(model.authentication.apiKeyEnv);
|
||||
case "pi_auth":
|
||||
return model.authentication.apiKeyEnv === undefined
|
||||
&& model.metadataGeneration === undefined
|
||||
&& model.sessionAdapter?.mode === "pi_builtin";
|
||||
case "none":
|
||||
return model.authentication.apiKeyEnv === undefined && model.endpoint !== undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function splitCanonicalModelId(id: string): { provider: string; model: string } {
|
||||
const slash = id.indexOf("/");
|
||||
if (slash <= 0 || slash === id.length - 1) throw new Error("model identity is invalid");
|
||||
return { provider: id.slice(0, slash), model: id.slice(slash + 1) };
|
||||
}
|
||||
+171
-27
@@ -8,18 +8,24 @@ import { join } from "node:path";
|
||||
import { loadConfig, type AppConfig } from "./config.js";
|
||||
import { rolesToPermissions } from "./auth/config.js";
|
||||
import type { PrincipalContext } from "./auth/principal.js";
|
||||
import { createPiModelLister } from "./pi/list-models.js";
|
||||
import { createPiManagement } from "./pi/management.js";
|
||||
import { loadRuntimeModelCatalog } from "./models/runtime-model-catalog.js";
|
||||
import { effectiveSettings } from "./routes/settings.js";
|
||||
import { MaintenanceBarrier } from "./runtime/maintenance-gate.js";
|
||||
import { loadSettings } from "./settings/settings-store.js";
|
||||
import { ThtRunner, type SessionRow } from "./tht/tht-runner.js";
|
||||
import { WorkspaceRegistry } from "./workspaces/registry.js";
|
||||
import { WorkspaceSecretStore } from "./workspaces/secret-store.js";
|
||||
import { createProductionWorkspaceDiagnoser } from "./workspaces/diagnostics.js";
|
||||
import { resolveCatalogRuntimeBinding } from "./catalog/runtime-binding.js";
|
||||
import { CatalogService } from "./catalog/service.js";
|
||||
import { validateOperationalWorkspace } from "./workspaces/schema.js";
|
||||
import { createCatalogRepository } from "./catalog/repository.js";
|
||||
import type { CatalogRepository } from "./catalog/types.js";
|
||||
|
||||
type OperatorAction = "maintenance-activate" | "maintenance-deactivate" | "maintenance-status"
|
||||
| "session-inventory" | "workflow-doctor" | "workspace-integrity"
|
||||
| "pi-options" | "pi-test" | "effective-settings";
|
||||
| "workspace-pull" | "workspace-test" | "pi-test" | "effective-settings";
|
||||
|
||||
const lifecyclePrincipal: PrincipalContext = {
|
||||
issuer: "tht-operator-command",
|
||||
@@ -30,7 +36,7 @@ const lifecyclePrincipal: PrincipalContext = {
|
||||
isAdmin: true,
|
||||
};
|
||||
|
||||
function operatorRunner(config: AppConfig): ThtRunner {
|
||||
function operatorRunner(config: AppConfig, catalogRepository: CatalogRepository): ThtRunner {
|
||||
const workspaceSecretStore = new WorkspaceSecretStore({
|
||||
root: config.workspaceSecretStoreRoot,
|
||||
runtimeRoot: config.workspaceSecretRuntimeRoot,
|
||||
@@ -46,6 +52,7 @@ function operatorRunner(config: AppConfig): ThtRunner {
|
||||
secretsFile: config.secretsFile,
|
||||
secretFiles: config.secretFiles,
|
||||
workspaceSecretStore,
|
||||
catalogRepository,
|
||||
semanticRuntime: {
|
||||
internalQdrantUrl: config.internalQdrantUrl,
|
||||
internalEmbeddingUrl: config.internalEmbeddingUrl,
|
||||
@@ -55,36 +62,55 @@ function operatorRunner(config: AppConfig): ThtRunner {
|
||||
}).withPrincipal(lifecyclePrincipal);
|
||||
}
|
||||
|
||||
async function withOperatorRunner<T>(
|
||||
config: AppConfig,
|
||||
operation: (runner: ThtRunner) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const catalogRepository = createCatalogRepository(config.catalogDatabase);
|
||||
try {
|
||||
return await operation(operatorRunner(config, catalogRepository));
|
||||
} finally {
|
||||
await catalogRepository.close?.();
|
||||
}
|
||||
}
|
||||
|
||||
async function sessionInventory(config: AppConfig): Promise<Array<Pick<SessionRow, "status" | "archived">>> {
|
||||
const registry = new WorkspaceRegistry(config.workspaceRegistry);
|
||||
const revisions = await registry.listRetainedSnapshots();
|
||||
const runner = operatorRunner(config);
|
||||
const sessions = new Map<string, SessionRow>();
|
||||
for (const revision of revisions) {
|
||||
for (const session of await runner.sessionList(revision.snapshotPath)) sessions.set(session.id, session);
|
||||
}
|
||||
return [...sessions.values()].map(({ status, archived }) => ({ status, archived: archived === true }));
|
||||
return await withOperatorRunner(config, async (runner) => {
|
||||
const sessions = new Map<string, SessionRow>();
|
||||
for (const revision of revisions) {
|
||||
for (const session of await runner.sessionList(revision.snapshotPath)) sessions.set(session.id, session);
|
||||
}
|
||||
return [...sessions.values()].map(({ status, archived }) => ({ status, archived: archived === true }));
|
||||
});
|
||||
}
|
||||
|
||||
async function workflowDiagnostics(config: AppConfig): Promise<{ ready: true; workspaces: number }> {
|
||||
const registry = new WorkspaceRegistry(config.workspaceRegistry);
|
||||
const revisions = await registry.listRetainedSnapshots();
|
||||
if (revisions.length === 0) throw new Error("workflow diagnostics unavailable");
|
||||
const runner = operatorRunner(config);
|
||||
for (const revision of revisions) {
|
||||
const result = await runner.run(["doctor", "--json"], revision.snapshotPath);
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = JSON.parse(result.stdout);
|
||||
} catch {
|
||||
throw new Error("workflow diagnostics failed");
|
||||
return await withOperatorRunner(config, async (runner) => {
|
||||
for (const revision of revisions) {
|
||||
const runtime = await runner.acquireWorkspaceRuntime(revision.snapshotPath);
|
||||
try {
|
||||
const result = await runner.run(["doctor", "--json"], runtime.path);
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = JSON.parse(result.stdout);
|
||||
} catch {
|
||||
throw new Error("workflow diagnostics failed");
|
||||
}
|
||||
if (
|
||||
result.code !== 0 || !payload || typeof payload !== "object"
|
||||
|| (payload as { ok?: unknown }).ok !== true
|
||||
) throw new Error("workflow diagnostics failed");
|
||||
} finally {
|
||||
runtime.release();
|
||||
}
|
||||
}
|
||||
if (
|
||||
result.code !== 0 || !payload || typeof payload !== "object"
|
||||
|| (payload as { ok?: unknown }).ok !== true
|
||||
) throw new Error("workflow diagnostics failed");
|
||||
}
|
||||
return { ready: true, workspaces: revisions.length };
|
||||
return { ready: true, workspaces: revisions.length };
|
||||
});
|
||||
}
|
||||
|
||||
async function workspaceIntegrity(config: AppConfig): Promise<{
|
||||
@@ -97,6 +123,119 @@ async function workspaceIntegrity(config: AppConfig): Promise<{
|
||||
return { ready: true, ...integrity };
|
||||
}
|
||||
|
||||
async function workspacePull(config: AppConfig): Promise<{
|
||||
ready: boolean;
|
||||
status: "succeeded" | "degraded";
|
||||
branch: string;
|
||||
head?: string;
|
||||
degraded: boolean;
|
||||
}> {
|
||||
const status = await new WorkspaceRegistry(config.workspaceRegistry).pull();
|
||||
return {
|
||||
ready: !status.degraded,
|
||||
status: status.degraded ? "degraded" : "succeeded",
|
||||
branch: status.branch,
|
||||
...(status.head ? { head: status.head } : {}),
|
||||
degraded: status.degraded,
|
||||
};
|
||||
}
|
||||
|
||||
interface WorkspaceTestReport {
|
||||
id: string;
|
||||
status: "ready" | "failed";
|
||||
database: "reachable" | "not_configured" | "failed";
|
||||
diagnostics: string[];
|
||||
}
|
||||
|
||||
async function workspaceTest(config: AppConfig): Promise<{
|
||||
ready: boolean;
|
||||
workspaces: WorkspaceTestReport[];
|
||||
}> {
|
||||
if (!config.catalogDatabase) throw new Error("Catalog database is not configured");
|
||||
const registry = new WorkspaceRegistry(config.workspaceRegistry);
|
||||
const revisions = await registry.list();
|
||||
const repository = createCatalogRepository(config.catalogDatabase);
|
||||
try {
|
||||
const secretStore = new WorkspaceSecretStore({
|
||||
root: config.workspaceSecretStoreRoot,
|
||||
runtimeRoot: config.workspaceSecretRuntimeRoot,
|
||||
installationId: config.workspaceRegistry.installationId,
|
||||
});
|
||||
const catalogService = new CatalogService(
|
||||
repository,
|
||||
registry,
|
||||
secretStore,
|
||||
config.workspaceRegistry.secretRoots,
|
||||
config.workspaceDiagnosticTimeoutMs,
|
||||
);
|
||||
const diagnose = createProductionWorkspaceDiagnoser(config.workspaceDiagnosticTimeoutMs, undefined, {
|
||||
internalQdrantUrl: config.internalQdrantUrl,
|
||||
internalEmbeddingUrl: config.internalEmbeddingUrl,
|
||||
internalEmbeddingId: config.internalEmbeddingId,
|
||||
internalEmbeddingModel: config.internalEmbeddingModel,
|
||||
internalEmbeddingDimensions: config.internalEmbeddingDimensions,
|
||||
});
|
||||
const databases = await repository.list();
|
||||
const reports: WorkspaceTestReport[] = [];
|
||||
for (const revision of revisions) {
|
||||
const diagnostics: string[] = [];
|
||||
let workspace: ReturnType<typeof validateOperationalWorkspace>;
|
||||
try {
|
||||
workspace = validateOperationalWorkspace((await registry.read(revision.id)).workspace);
|
||||
} catch {
|
||||
reports.push({ id: revision.id, status: "failed", database: "not_configured", diagnostics: ["workspace_invalid"] });
|
||||
continue;
|
||||
}
|
||||
const database = databases.find((candidate) => candidate.workspaceId === revision.id);
|
||||
if (!database) {
|
||||
reports.push({ id: revision.id, status: "failed", database: "not_configured", diagnostics: ["database_binding_missing"] });
|
||||
continue;
|
||||
}
|
||||
let tested;
|
||||
try {
|
||||
tested = await catalogService.test(database);
|
||||
} catch {
|
||||
reports.push({ id: revision.id, status: "failed", database: "failed", diagnostics: ["database_unreachable"] });
|
||||
continue;
|
||||
}
|
||||
if (!tested || tested.connectionStatus !== "reachable") {
|
||||
reports.push({ id: revision.id, status: "failed", database: "failed", diagnostics: ["database_unreachable"] });
|
||||
continue;
|
||||
}
|
||||
let lease: ReturnType<typeof resolveCatalogRuntimeBinding>;
|
||||
try {
|
||||
lease = resolveCatalogRuntimeBinding({
|
||||
workspace,
|
||||
database: tested,
|
||||
environment: process.env,
|
||||
secretRoots: config.workspaceRegistry.secretRoots,
|
||||
secretStore,
|
||||
});
|
||||
} catch {
|
||||
reports.push({ id: revision.id, status: "failed", database: "reachable", diagnostics: ["binding_missing"] });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
// CatalogService.test() above is the authoritative database probe and records its
|
||||
// outcome. The remaining diagnoser pass checks Evidence and internal semantic services;
|
||||
// skipping its legacy DWH probe avoids requiring a second response-shape contract for a
|
||||
// REST health endpoint.
|
||||
const result = await diagnose(lease.workspace, lease.bindings, { writeProbe: false, skipDwh: true });
|
||||
diagnostics.push(...result.diagnostics.map((diagnostic) => diagnostic.code));
|
||||
const ready = result.activatable;
|
||||
reports.push({ id: revision.id, status: ready ? "ready" : "failed", database: "reachable", diagnostics });
|
||||
} catch {
|
||||
reports.push({ id: revision.id, status: "failed", database: "reachable", diagnostics: ["connector_unavailable"] });
|
||||
} finally {
|
||||
lease.release();
|
||||
}
|
||||
}
|
||||
return { ready: reports.length > 0 && reports.every((report) => report.status === "ready"), workspaces: reports };
|
||||
} finally {
|
||||
await repository.close?.();
|
||||
}
|
||||
}
|
||||
|
||||
export async function runOperatorAction(
|
||||
action: OperatorAction,
|
||||
config: AppConfig,
|
||||
@@ -110,9 +249,13 @@ export async function runOperatorAction(
|
||||
if (action === "session-inventory") return await sessionInventory(config);
|
||||
if (action === "workflow-doctor") return await workflowDiagnostics(config);
|
||||
if (action === "workspace-integrity") return await workspaceIntegrity(config);
|
||||
if (action === "effective-settings") return effectiveSettings(config, loadSettings(config));
|
||||
const service = createPiManagement(config, { listModels: createPiModelLister(config) });
|
||||
if (action === "pi-options") return await service.options();
|
||||
if (action === "workspace-pull") return await workspacePull(config);
|
||||
if (action === "workspace-test") return await workspaceTest(config);
|
||||
const modelCatalog = loadRuntimeModelCatalog(config.modelCatalogFile);
|
||||
if (action === "effective-settings") {
|
||||
return effectiveSettings(config, loadSettings(config), modelCatalog);
|
||||
}
|
||||
const service = createPiManagement(config, { modelCatalog });
|
||||
if (action === "pi-test") return await service.test();
|
||||
throw new Error("unsupported operator action");
|
||||
}
|
||||
@@ -121,7 +264,8 @@ async function main(): Promise<void> {
|
||||
const action = process.argv[2] as OperatorAction | undefined;
|
||||
if (!action || ![
|
||||
"maintenance-activate", "maintenance-deactivate", "maintenance-status", "session-inventory",
|
||||
"workflow-doctor", "workspace-integrity", "pi-options", "pi-test", "effective-settings",
|
||||
"workflow-doctor", "workspace-integrity", "pi-test", "effective-settings",
|
||||
"workspace-pull", "workspace-test",
|
||||
].includes(action)) throw new Error("invalid operator action");
|
||||
const result = await runOperatorAction(action, loadConfig(process.env));
|
||||
process.stdout.write(`${JSON.stringify(result)}\n`);
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
readConfiguredPiAgentFile,
|
||||
validateDeclarativePiConfig,
|
||||
} from "./managed-config.js";
|
||||
import type { RuntimeModelCatalog } from "../models/runtime-model-catalog.js";
|
||||
import { secretValue } from "../config/secret-bundle.js";
|
||||
|
||||
export interface PiModel {
|
||||
provider: string;
|
||||
@@ -18,6 +20,8 @@ export interface PiModel {
|
||||
reasoning: boolean;
|
||||
}
|
||||
|
||||
export type ListModelsFn = () => Promise<PiModel[]>;
|
||||
|
||||
interface Opts {
|
||||
spawnFn?: (
|
||||
command: string,
|
||||
@@ -28,6 +32,7 @@ interface Opts {
|
||||
nowMs?: () => number;
|
||||
loadEnabledModels?: () => PiEnabledModelsResult;
|
||||
readModelsStore?: () => string | undefined;
|
||||
modelCatalog?: RuntimeModelCatalog;
|
||||
warn?: (message: string) => void;
|
||||
}
|
||||
|
||||
@@ -36,7 +41,7 @@ interface Opts {
|
||||
* configured) via an ephemeral `pi --mode rpc` process. Result is cached for
|
||||
* `ttlMs`. The returned function rejects on timeout/error; callers degrade.
|
||||
*/
|
||||
export function createPiModelLister(cfg: AppConfig, opts: Opts = {}): () => Promise<PiModel[]> {
|
||||
export function createPiModelLister(cfg: AppConfig, opts: Opts = {}): ListModelsFn {
|
||||
const ttlMs = opts.ttlMs ?? 60_000;
|
||||
const now = opts.nowMs ?? (() => Date.now());
|
||||
const spawnFn = opts.spawnFn ?? nodeSpawn;
|
||||
@@ -60,6 +65,14 @@ export function createPiModelLister(cfg: AppConfig, opts: Opts = {}): () => Prom
|
||||
}
|
||||
|
||||
const env = buildPiChildEnv({});
|
||||
// Pi's availability enumeration also needs the catalog-owned credentials for built-in
|
||||
// providers. It must keep working after their obsolete Pi auth entries are removed.
|
||||
for (const model of opts.modelCatalog?.sessionModels() ?? []) {
|
||||
const name = model.authentication.mode === "secret_env" ? model.authentication.apiKeyEnv : undefined;
|
||||
if (!name) continue;
|
||||
const value = secretValue(cfg, name);
|
||||
if (value) env[name] = value;
|
||||
}
|
||||
delete env.THT_DATA_ROOT;
|
||||
if (cfg.dataRoot !== undefined) env.THT_DATA_ROOT = cfg.dataRoot;
|
||||
const child = spawnFn(cfg.piBin, ["--mode", "rpc"], { cwd: cfg.harnessDir, env });
|
||||
@@ -80,9 +93,23 @@ export function createPiModelLister(cfg: AppConfig, opts: Opts = {}): () => Prom
|
||||
const byCompositeId = new Map(
|
||||
available.map((model) => [`${model.provider}/${model.id}`, model]),
|
||||
);
|
||||
const catalogByPiId = new Map(
|
||||
(opts.modelCatalog?.sessionModels() ?? []).map((model) => [
|
||||
`${model.provider}/${model.upstreamModel}`,
|
||||
model,
|
||||
]),
|
||||
);
|
||||
const models = enabled.ids.flatMap((id) => {
|
||||
const model = byCompositeId.get(id);
|
||||
return model ? [model] : [];
|
||||
if (!model) return [];
|
||||
const catalogModel = catalogByPiId.get(id);
|
||||
return [catalogModel ? {
|
||||
...model,
|
||||
provider: catalogModel.provider,
|
||||
id: catalogModel.model,
|
||||
name: catalogModel.label,
|
||||
reasoning: catalogModel.session?.reasoning ?? model.reasoning,
|
||||
} : model];
|
||||
});
|
||||
if (models.length === 0) opts.warn?.("No Pi-enabled models are currently available");
|
||||
cache = { at: now(), models };
|
||||
|
||||
@@ -138,7 +138,7 @@ export interface PiRuntimeAgentSnapshot {
|
||||
* Bind a session Pi process to the exact managed auth/model bytes validated at spawn time.
|
||||
* Other agent resources remain live through symlinks, while session storage stays persistent.
|
||||
*/
|
||||
export function createPiRuntimeAgentSnapshot(): PiRuntimeAgentSnapshot {
|
||||
export function createPiRuntimeAgentSnapshot(options: { excludeAuthProvider?: string } = {}): PiRuntimeAgentSnapshot {
|
||||
const sourceAgentDir = configuredPiAgentDir();
|
||||
const auth = readPiAgentFile(sourceAgentDir, "auth.json", true);
|
||||
const models = readPiAgentFile(sourceAgentDir, "models.json", true);
|
||||
@@ -165,7 +165,18 @@ export function createPiRuntimeAgentSnapshot(): PiRuntimeAgentSnapshot {
|
||||
);
|
||||
}
|
||||
if (auth !== undefined) {
|
||||
writeFileSync(join(snapshotDir, "auth.json"), auth, { flag: "wx", mode: 0o600 });
|
||||
let effectiveAuth = auth;
|
||||
if (options.excludeAuthProvider) {
|
||||
const parsed = parsePiConfigJson(auth);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new PiManagedConfigError();
|
||||
const provider = options.excludeAuthProvider.trim().toLowerCase();
|
||||
effectiveAuth = JSON.stringify(Object.fromEntries(
|
||||
Object.entries(parsed).filter(([key]) => key.trim().toLowerCase() !== provider),
|
||||
));
|
||||
}
|
||||
// secret_env is authoritative for this provider. Keep the operator's auth file intact,
|
||||
// but do not let an old Pi credential override the shared bundle inside this child.
|
||||
writeFileSync(join(snapshotDir, "auth.json"), effectiveAuth, { flag: "wx", mode: 0o600 });
|
||||
}
|
||||
if (models !== undefined) {
|
||||
writeFileSync(join(snapshotDir, "models.json"), models, { flag: "wx", mode: 0o600 });
|
||||
|
||||
@@ -2,12 +2,11 @@ import { execFile as nodeExecFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import type { AppConfig } from "../config.js";
|
||||
import { secretValue } from "../config/secret-bundle.js";
|
||||
import { loadSettings, type Settings } from "../settings/settings-store.js";
|
||||
import {
|
||||
loadSettings,
|
||||
saveSettings,
|
||||
type Settings,
|
||||
} from "../settings/settings-store.js";
|
||||
import type { PiModel } from "./list-models.js";
|
||||
splitCanonicalModelId,
|
||||
type RuntimeModelCatalog,
|
||||
} from "../models/runtime-model-catalog.js";
|
||||
import {
|
||||
configuredPiProviderApiKey,
|
||||
PI_MANAGED_CONFIG_ERROR_MESSAGE,
|
||||
@@ -37,6 +36,7 @@ export interface PiInstallationConfig {
|
||||
}
|
||||
|
||||
export interface PiStatus {
|
||||
hostPlatform: "linux" | "macos" | "windows";
|
||||
version?: string;
|
||||
ready: boolean;
|
||||
credentials: PiCredentialStatus;
|
||||
@@ -45,13 +45,6 @@ export interface PiStatus {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface PiOptions {
|
||||
providers: string[];
|
||||
models: Array<{ provider: string; id: string }>;
|
||||
reasoning: PiReasoning[];
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
export interface PiTestResult {
|
||||
ready: boolean;
|
||||
checkedAt: string;
|
||||
@@ -76,15 +69,13 @@ export type PiExecFile = (
|
||||
|
||||
export interface PiManagementService {
|
||||
status(): Promise<PiStatus>;
|
||||
options(): Promise<PiOptions>;
|
||||
configure(value: PiInstallationConfig): Promise<PiInstallationConfig & { updatedAt: string }>;
|
||||
test(): Promise<PiTestResult>;
|
||||
logs(): Promise<PiLogs>;
|
||||
}
|
||||
|
||||
export class PiManagementError extends Error {
|
||||
constructor(
|
||||
public readonly code: "pi_management_invalid_config" | "pi_management_unavailable" | "pi_management_write_failed",
|
||||
public readonly code: "pi_management_unavailable",
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
@@ -93,16 +84,18 @@ export class PiManagementError extends Error {
|
||||
|
||||
interface PiManagementDeps {
|
||||
execute?: PiExecFile;
|
||||
listModels: () => Promise<PiModel[]>;
|
||||
modelCatalog: RuntimeModelCatalog;
|
||||
smokeProvider?: PiProviderSmoke;
|
||||
readSettings?: () => Settings;
|
||||
saveSettings?: (settings: Settings) => Settings;
|
||||
readLogs?: () => string | Promise<string>;
|
||||
credentialStatus?: (provider: string | undefined) => PiCredentialStatus;
|
||||
now?: () => Date;
|
||||
}
|
||||
|
||||
export function createPiManagement(config: AppConfig, deps: PiManagementDeps): PiManagementService {
|
||||
const platform = config.hostPlatform ?? process.platform;
|
||||
const hostPlatform = platform === "darwin" ? "macos"
|
||||
: platform === "windows" || platform === "win32" ? "windows" : "linux";
|
||||
const now = deps.now ?? (() => new Date());
|
||||
const diagnostics: string[] = [];
|
||||
const addDiagnostic = (message: string): void => {
|
||||
@@ -111,54 +104,36 @@ export function createPiManagement(config: AppConfig, deps: PiManagementDeps): P
|
||||
};
|
||||
const execute = deps.execute ?? defaultExecFile;
|
||||
const readSettings = deps.readSettings ?? (() => loadSettings(config));
|
||||
const persistSettings = deps.saveSettings ?? ((settings) => saveSettings(config, settings));
|
||||
const readLogs = deps.readLogs ?? (() => diagnostics.join("\n"));
|
||||
const smokeProvider = deps.smokeProvider ?? createPiProviderSmoke(config);
|
||||
const smokeProvider = deps.smokeProvider ?? createPiProviderSmoke(config, {
|
||||
modelCatalog: deps.modelCatalog,
|
||||
});
|
||||
const credentialStatus = deps.credentialStatus ?? ((provider: string | undefined) => {
|
||||
try {
|
||||
const model = deps.modelCatalog.defaultInteraction
|
||||
? deps.modelCatalog.sessionModels().find((entry) => entry.id === deps.modelCatalog.defaultInteraction)
|
||||
: undefined;
|
||||
const credentialName = model?.authentication.mode === "secret_env"
|
||||
? model.authentication.apiKeyEnv
|
||||
: undefined;
|
||||
const configuredApiKey = credentialName ? `$${credentialName}` : configuredPiProviderApiKey(
|
||||
readConfiguredPiAgentFile("models.json", true),
|
||||
provider,
|
||||
);
|
||||
return piProviderCredentialStatus({
|
||||
provider,
|
||||
authProviders: loadPiAuthProviders(),
|
||||
resolveCredentialValue: () => secretValue(config, "THT_MODEL_API_KEY"),
|
||||
credentialFile: config.modelApiKeyFile,
|
||||
configuredApiKey: configuredPiProviderApiKey(
|
||||
readConfiguredPiAgentFile("models.json", true),
|
||||
provider,
|
||||
),
|
||||
authProviders: credentialName ? new Set() : loadPiAuthProviders(),
|
||||
resolveCredentialValue: () => credentialName
|
||||
? secretValue(config, credentialName)
|
||||
: config.modelCatalogFile ? undefined : secretValue(config, "THT_MODEL_API_KEY"),
|
||||
credentialFile: credentialName ? undefined : config.modelApiKeyFile,
|
||||
configuredApiKey,
|
||||
});
|
||||
} catch {
|
||||
return "missing";
|
||||
}
|
||||
});
|
||||
|
||||
const closedOptions = async (): Promise<Omit<PiOptions, "checkedAt">> => {
|
||||
let listed: PiModel[];
|
||||
try {
|
||||
listed = await deps.listModels();
|
||||
} catch (error) {
|
||||
if (isPiManagedConfigError(error)) {
|
||||
throw new PiManagementError("pi_management_unavailable", PI_MANAGED_CONFIG_ERROR_MESSAGE);
|
||||
}
|
||||
throw new PiManagementError("pi_management_unavailable", "Pi model choices are unavailable");
|
||||
}
|
||||
const models: Array<{ provider: string; id: string }> = [];
|
||||
const providers: string[] = [];
|
||||
const seenModels = new Set<string>();
|
||||
const seenProviders = new Set<string>();
|
||||
for (const model of listed) {
|
||||
if (!isChoice(model?.provider) || !isChoice(model?.id)) continue;
|
||||
const key = `${model.provider}\u0000${model.id}`;
|
||||
if (seenModels.has(key)) continue;
|
||||
seenModels.add(key);
|
||||
models.push({ provider: model.provider, id: model.id });
|
||||
if (!seenProviders.has(model.provider)) {
|
||||
seenProviders.add(model.provider);
|
||||
providers.push(model.provider);
|
||||
}
|
||||
}
|
||||
return { providers, models, reasoning: [...REASONING_CHOICES] };
|
||||
};
|
||||
|
||||
const version = async (timeoutMs = config.piManagementTimeoutMs): Promise<string> => {
|
||||
let output: { stdout: string; stderr: string };
|
||||
try {
|
||||
@@ -180,12 +155,12 @@ export function createPiManagement(config: AppConfig, deps: PiManagementDeps): P
|
||||
|
||||
const installationConfig = (): PiInstallationConfig => {
|
||||
const settings = readSettings();
|
||||
const provider = config.defaults.provider ?? settings.provider;
|
||||
const model = config.defaults.model ?? settings.model;
|
||||
const reasoning = config.defaults.thinking ?? settings.thinking;
|
||||
const selected = deps.modelCatalog.defaultInteraction
|
||||
? splitCanonicalModelId(deps.modelCatalog.defaultInteraction)
|
||||
: undefined;
|
||||
return {
|
||||
...(isChoice(provider) ? { provider } : {}),
|
||||
...(isChoice(model) ? { model } : {}),
|
||||
...(selected ? selected : {}),
|
||||
...(isReasoning(reasoning) ? { reasoning } : {}),
|
||||
};
|
||||
};
|
||||
@@ -198,36 +173,14 @@ export function createPiManagement(config: AppConfig, deps: PiManagementDeps): P
|
||||
try {
|
||||
const currentVersion = await version();
|
||||
addDiagnostic("Pi version probe succeeded");
|
||||
return { version: currentVersion, ready: true, credentials, config: current, checkedAt };
|
||||
return { hostPlatform, version: currentVersion, ready: true, credentials, config: current, checkedAt };
|
||||
} catch (error) {
|
||||
const message = stableMessage(error, "Pi runtime is unavailable");
|
||||
addDiagnostic(message);
|
||||
return { ready: false, credentials, config: current, checkedAt, message };
|
||||
return { hostPlatform, ready: false, credentials, config: current, checkedAt, message };
|
||||
}
|
||||
},
|
||||
|
||||
async options(): Promise<PiOptions> {
|
||||
const choices = await closedOptions();
|
||||
return { ...choices, checkedAt: now().toISOString() };
|
||||
},
|
||||
|
||||
async configure(value: PiInstallationConfig): Promise<PiInstallationConfig & { updatedAt: string }> {
|
||||
if (!isInstallationConfig(value)) {
|
||||
throw new PiManagementError("pi_management_invalid_config", "Pi installation configuration is invalid");
|
||||
}
|
||||
const choices = await closedOptions();
|
||||
if (!choices.models.some((model) => model.provider === value.provider && model.id === value.model)) {
|
||||
throw new PiManagementError("pi_management_invalid_config", "Pi provider and model must be selected from available choices");
|
||||
}
|
||||
try {
|
||||
persistSettings({ ...readSettings(), provider: value.provider, model: value.model, thinking: value.reasoning });
|
||||
} catch {
|
||||
throw new PiManagementError("pi_management_write_failed", "Pi installation configuration could not be saved");
|
||||
}
|
||||
addDiagnostic("Pi installation defaults updated");
|
||||
return { ...value, updatedAt: now().toISOString() };
|
||||
},
|
||||
|
||||
async test(): Promise<PiTestResult> {
|
||||
const checkedAt = now().toISOString();
|
||||
const deadline = Date.now() + config.piManagementTimeoutMs;
|
||||
@@ -293,24 +246,10 @@ async function defaultExecFile(command: string, args: string[], options: PiExecF
|
||||
return { stdout: String(result.stdout), stderr: String(result.stderr) };
|
||||
}
|
||||
|
||||
function isChoice(value: unknown): value is string {
|
||||
return typeof value === "string" && value.length > 0 && value.length <= 128 && value.trim() === value
|
||||
&& /^[A-Za-z0-9][A-Za-z0-9._/-]*$/u.test(value);
|
||||
}
|
||||
|
||||
function isReasoning(value: unknown): value is PiReasoning {
|
||||
return typeof value === "string" && (REASONING_CHOICES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
function isInstallationConfig(value: unknown): value is Required<PiInstallationConfig> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const candidate = value as Record<string, unknown>;
|
||||
if (Object.keys(candidate).length !== 3 || Object.keys(candidate).some((key) => !["provider", "model", "reasoning"].includes(key))) {
|
||||
return false;
|
||||
}
|
||||
return isChoice(candidate.provider) && isChoice(candidate.model) && isReasoning(candidate.reasoning);
|
||||
}
|
||||
|
||||
function isTimeout(error: unknown): boolean {
|
||||
return Boolean(
|
||||
error && typeof error === "object" && (
|
||||
|
||||
@@ -11,6 +11,10 @@ import {
|
||||
configuredPiProviderApiKey,
|
||||
createPiRuntimeAgentSnapshot,
|
||||
} from "./managed-config.js";
|
||||
import {
|
||||
loadRuntimeModelCatalog,
|
||||
type RuntimeModelCatalog,
|
||||
} from "../models/runtime-model-catalog.js";
|
||||
|
||||
export interface SessionRuntime {
|
||||
rpc: RpcClient;
|
||||
@@ -21,6 +25,7 @@ export interface SessionRuntime {
|
||||
}
|
||||
|
||||
export interface RuntimeOptions {
|
||||
interactionLanguage?: string | null;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
thinking?: string;
|
||||
@@ -42,23 +47,33 @@ export class PiProcessManager {
|
||||
private runtimes = new Map<string, SessionRuntime>();
|
||||
private agentSnapshotCleanups = new WeakMap<ChildProcessWithoutNullStreams, () => void>();
|
||||
private spawnFn: (
|
||||
sessionId: string, author: string, provider: string | undefined, principal?: PrincipalContext,
|
||||
runtimeConfigPath?: string,
|
||||
sessionId: string, author: string, provider: string | undefined, model: string | undefined,
|
||||
principal?: PrincipalContext, runtimeConfigPath?: string,
|
||||
interactionLanguage?: string | null,
|
||||
) => ChildProcessWithoutNullStreams;
|
||||
private loadAuthProviders: (agentDir: string) => ReadonlySet<string>;
|
||||
private modelCatalog: RuntimeModelCatalog;
|
||||
private modelCatalogConfigured: boolean;
|
||||
|
||||
constructor(
|
||||
private cfg: AppConfig,
|
||||
opts?: { spawnFn?: SpawnFn; authProviders?: (agentDir: string) => ReadonlySet<string> },
|
||||
opts?: {
|
||||
spawnFn?: SpawnFn;
|
||||
authProviders?: (agentDir: string) => ReadonlySet<string>;
|
||||
modelCatalog?: RuntimeModelCatalog;
|
||||
},
|
||||
) {
|
||||
this.modelCatalog = opts?.modelCatalog ?? loadRuntimeModelCatalog(cfg.modelCatalogFile);
|
||||
this.modelCatalogConfigured = cfg.modelCatalogFile !== undefined
|
||||
|| this.modelCatalog.defaultInteraction !== null;
|
||||
this.loadAuthProviders = opts?.authProviders
|
||||
?? ((agentDir) => loadPiAuthProviders({ agentDir }));
|
||||
if (opts?.spawnFn) {
|
||||
this.spawnFn = (sessionId, author, provider, principal, runtimeConfigPath) =>
|
||||
this.spawnPi(opts.spawnFn!, sessionId, author, provider, principal, runtimeConfigPath);
|
||||
this.spawnFn = (sessionId, author, provider, model, principal, runtimeConfigPath, language) =>
|
||||
this.spawnPi(opts.spawnFn!, sessionId, author, provider, model, principal, runtimeConfigPath, language);
|
||||
} else {
|
||||
this.spawnFn = (sessionId, author, provider, principal, runtimeConfigPath) =>
|
||||
this.spawnPi(nodeSpawn, sessionId, author, provider, principal, runtimeConfigPath);
|
||||
this.spawnFn = (sessionId, author, provider, model, principal, runtimeConfigPath, language) =>
|
||||
this.spawnPi(nodeSpawn, sessionId, author, provider, model, principal, runtimeConfigPath, language);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,23 +86,36 @@ export class PiProcessManager {
|
||||
|
||||
private spawnPi(
|
||||
spawnFn: SpawnFn, sessionId: string, author: string, provider: string | undefined,
|
||||
principal?: PrincipalContext, runtimeConfigPath?: string,
|
||||
model: string | undefined, principal?: PrincipalContext, runtimeConfigPath?: string,
|
||||
interactionLanguage?: string | null,
|
||||
): ChildProcessWithoutNullStreams {
|
||||
// This is the final shared boundary for createFor(), spawnFor(), and resume(). Validate
|
||||
// before auth-provider inspection, then make Pi consume the exact copied bytes rather than
|
||||
// reopening mutable mounted auth/models files after this check.
|
||||
const agent = createPiRuntimeAgentSnapshot();
|
||||
const catalogModel = provider && model
|
||||
? this.modelCatalog.sessionModels().find((entry) => entry.provider === provider && entry.model === model)
|
||||
: undefined;
|
||||
const credentialName = catalogModel?.authentication.mode === "secret_env"
|
||||
? catalogModel.authentication.apiKeyEnv : undefined;
|
||||
const agent = createPiRuntimeAgentSnapshot({ excludeAuthProvider: credentialName ? provider : undefined });
|
||||
let child: ChildProcessWithoutNullStreams | undefined;
|
||||
try {
|
||||
const projectedApiKey = credentialName ? `$${credentialName}`
|
||||
: configuredPiProviderApiKey(agent.models, provider);
|
||||
const env = buildPiChildEnv({
|
||||
provider,
|
||||
authProviders: this.loadAuthProviders(agent.agentDir),
|
||||
credentialValue: secretValue(this.cfg, "THT_MODEL_API_KEY"),
|
||||
credentialFile: this.cfg.modelApiKeyFile,
|
||||
configuredApiKey: configuredPiProviderApiKey(agent.models, provider),
|
||||
authProviders: credentialName ? new Set() : this.loadAuthProviders(agent.agentDir),
|
||||
credentialValue: credentialName
|
||||
? secretValue(this.cfg, credentialName)
|
||||
: this.modelCatalogConfigured ? undefined : secretValue(this.cfg, "THT_MODEL_API_KEY"),
|
||||
credentialFile: credentialName ? undefined : this.cfg.modelApiKeyFile,
|
||||
configuredApiKey: projectedApiKey,
|
||||
additions: { THT_SESSION: sessionId, THT_AUTHOR: author },
|
||||
});
|
||||
env.PI_CODING_AGENT_DIR = agent.agentDir;
|
||||
// A launch hint only: the gate reads the authoritative manifest before each turn.
|
||||
delete env.THT_INTERACTION_LANGUAGE;
|
||||
if (interactionLanguage) env.THT_INTERACTION_LANGUAGE = interactionLanguage;
|
||||
env.PI_CODING_AGENT_SESSION_DIR = agent.sessionDir;
|
||||
clearPrincipalEnvironment(env);
|
||||
if (principal) Object.assign(env, principalEnvironment(principal));
|
||||
@@ -162,9 +190,12 @@ export class PiProcessManager {
|
||||
}
|
||||
const author = o.author ?? "dev@local";
|
||||
const provider = canonicalPiProvider(o.provider ?? this.cfg.defaults.provider);
|
||||
const model = o.model ?? this.cfg.defaults.model;
|
||||
let child: ChildProcessWithoutNullStreams;
|
||||
try {
|
||||
child = this.spawnFn(sessionId, author, provider, o.principal, o.runtimeConfig?.path);
|
||||
child = this.spawnFn(
|
||||
sessionId, author, provider, model, o.principal, o.runtimeConfig?.path, o.interactionLanguage,
|
||||
);
|
||||
} catch (error) {
|
||||
o.runtimeConfig?.release();
|
||||
throw error;
|
||||
@@ -235,8 +266,11 @@ export class PiProcessManager {
|
||||
const thinking = o.thinking ?? this.cfg.defaults.thinking;
|
||||
|
||||
if (provider && model) {
|
||||
const upstreamModel = this.modelCatalog.sessionModels()
|
||||
.find((entry) => entry.provider === provider && entry.model === model)
|
||||
?.upstreamModel ?? model;
|
||||
const response = await rt.rpc.request(
|
||||
{ type: "set_model", provider, modelId: model } as object & { type: string },
|
||||
{ type: "set_model", provider, modelId: upstreamModel } as object & { type: string },
|
||||
);
|
||||
rt.bridge.setContextWindow(response?.data?.contextWindow);
|
||||
}
|
||||
@@ -270,8 +304,13 @@ export class PiProcessManager {
|
||||
}
|
||||
|
||||
async resume(sessionId: string, tht: ThtRunner): Promise<SessionRuntime> {
|
||||
const manifest = await tht.sessionShow(sessionId) as { provider?: string; model?: string; thinking?: string } | null;
|
||||
const manifest = await tht.sessionShow(sessionId) as {
|
||||
provider?: string; model?: string; thinking?: string; interaction_language?: string | null;
|
||||
} | null;
|
||||
const language = manifest?.interaction_language
|
||||
?? (await tht.ensureInteractionLanguage(sessionId)).interaction_language;
|
||||
return this.spawnFor(sessionId, {
|
||||
interactionLanguage: language,
|
||||
provider: manifest?.provider,
|
||||
model: manifest?.model,
|
||||
thinking: manifest?.thinking,
|
||||
|
||||
@@ -17,6 +17,10 @@ import {
|
||||
readConfiguredPiAgentFile,
|
||||
validateDeclarativePiConfig,
|
||||
} from "./managed-config.js";
|
||||
import {
|
||||
loadRuntimeModelCatalog,
|
||||
type RuntimeModelCatalog,
|
||||
} from "../models/runtime-model-catalog.js";
|
||||
|
||||
const SMOKE_PROMPT = "Provider health check. Reply with exactly OK.";
|
||||
const SMOKE_ARGS = [
|
||||
@@ -49,6 +53,7 @@ interface ProviderSmokeOptions {
|
||||
authProviders?: () => ReadonlySet<string>;
|
||||
readAuthStore?: () => string;
|
||||
readModelsStore?: () => string | undefined;
|
||||
modelCatalog?: RuntimeModelCatalog;
|
||||
}
|
||||
|
||||
export function createPiProviderSmoke(
|
||||
@@ -65,16 +70,30 @@ export function createPiProviderSmoke(
|
||||
try {
|
||||
const canonicalProvider = canonicalPiProvider(provider);
|
||||
if (!canonicalProvider || timeoutMs <= 0) throw providerFailure();
|
||||
const configuredAuthProviders = authProviders();
|
||||
const configuredAuthProviders = new Set(authProviders());
|
||||
const configuredModels = options.readModelsStore
|
||||
? options.readModelsStore()
|
||||
: readConfiguredPiAgentFile("models.json", true);
|
||||
const catalog = options.modelCatalog ?? loadRuntimeModelCatalog(config.modelCatalogFile);
|
||||
const catalogConfigured = config.modelCatalogFile !== undefined
|
||||
|| catalog.defaultInteraction !== null;
|
||||
const catalogModel = catalog.sessionModels()
|
||||
.find((entry) => entry.provider === canonicalProvider && entry.model === model);
|
||||
const upstreamModel = catalogModel?.upstreamModel ?? model;
|
||||
const credentialName = catalogModel?.authentication.mode === "secret_env"
|
||||
? catalogModel.authentication.apiKeyEnv
|
||||
: undefined;
|
||||
if (credentialName) configuredAuthProviders.delete(canonicalProvider);
|
||||
const projectedApiKey = credentialName ? `$${credentialName}`
|
||||
: configuredPiProviderApiKey(configuredModels, canonicalProvider);
|
||||
const env = buildPiChildEnv({
|
||||
provider: canonicalProvider,
|
||||
authProviders: configuredAuthProviders,
|
||||
credentialValue: secretValue(config, "THT_MODEL_API_KEY"),
|
||||
credentialFile: config.modelApiKeyFile,
|
||||
configuredApiKey: configuredPiProviderApiKey(configuredModels, canonicalProvider),
|
||||
credentialValue: credentialName
|
||||
? secretValue(config, credentialName)
|
||||
: catalogConfigured ? undefined : secretValue(config, "THT_MODEL_API_KEY"),
|
||||
credentialFile: credentialName ? undefined : config.modelApiKeyFile,
|
||||
configuredApiKey: projectedApiKey,
|
||||
});
|
||||
clearPrincipalEnvironment(env);
|
||||
delete env.THT_DATA_ROOT;
|
||||
@@ -113,7 +132,7 @@ export function createPiProviderSmoke(
|
||||
const capabilityGuard = failOnUnexpectedCapabilities(rpc);
|
||||
const turn = async (): Promise<void> => {
|
||||
requireSuccessfulResponse(await rpc.request({
|
||||
type: "set_model", provider: canonicalProvider, modelId: model,
|
||||
type: "set_model", provider: canonicalProvider, modelId: upstreamModel,
|
||||
} as object & { type: string }));
|
||||
requireSuccessfulResponse(await rpc.request({
|
||||
type: "set_thinking_level", level: reasoning,
|
||||
|
||||
@@ -1,60 +1,19 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { isPrincipalContext, requirePermission } from "../auth/authorization.js";
|
||||
import { parseCredentialFreeHttpUrl } from "../auth/url-policy.js";
|
||||
import { databaseConfigurationSchema as configSchema } from "../catalog/configuration-schema.js";
|
||||
import { CatalogService, type CatalogSecretName } from "../catalog/service.js";
|
||||
import { WorkspaceRegistryError } from "../workspaces/git-repository.js";
|
||||
import {
|
||||
CatalogConflictError,
|
||||
CatalogOperationInProgressError,
|
||||
CatalogUnavailableError,
|
||||
DATABASE_TRANSPORTS,
|
||||
type CatalogRepository,
|
||||
type DatabaseConfigurationInput,
|
||||
} from "../catalog/types.js";
|
||||
import type { CatalogOperationCoordinator } from "../catalog/operation-coordinator.js";
|
||||
|
||||
const idSchema = z.uuid();
|
||||
const workspaceIdSchema = z.string().regex(/^[a-z][a-z0-9-]{2,62}$/);
|
||||
const identifier = z.string().trim().min(1).max(128).regex(/^[A-Za-z_][A-Za-z0-9_$-]*$/);
|
||||
const nonEmpty = z.string().trim().min(1).max(512);
|
||||
const port = z.number().int().min(1).max(65_535);
|
||||
const optionalText = nonEmpty.optional();
|
||||
const sshHost = z.string().trim().min(1).max(255).regex(/^[A-Za-z0-9_.:\[\]-]+$/).optional();
|
||||
const sshUsername = z.string().trim().min(1).max(128).regex(/^[A-Za-z0-9._-]+$/).optional();
|
||||
const bindingSchema = z.object({
|
||||
transport: z.enum(DATABASE_TRANSPORTS),
|
||||
host: optionalText,
|
||||
port: port.optional(),
|
||||
username: optionalText,
|
||||
baseUrl: z.string().max(2048)
|
||||
.refine((value) => parseCredentialFreeHttpUrl(value) !== undefined)
|
||||
.optional(),
|
||||
restPath: z.string().regex(/^\/(?!\/)[^?#\\\u0000-\u001f]*$/).max(512).optional(),
|
||||
restAuth: z.enum(["none", "bearer", "x-api-key"]).optional(),
|
||||
tlsServername: optionalText,
|
||||
sshHost,
|
||||
sshPort: port.optional(),
|
||||
sshUsername,
|
||||
sshTargetHost: sshHost,
|
||||
sshTargetPort: port.optional(),
|
||||
}).strict().superRefine((binding, context) => {
|
||||
const required = binding.transport === "postgres_direct"
|
||||
? ["host", "port", "username"] as const
|
||||
: binding.transport === "rest_api"
|
||||
? ["baseUrl", "restPath", "restAuth"] as const
|
||||
: ["username", "sshHost", "sshPort", "sshUsername", "sshTargetHost", "sshTargetPort"] as const;
|
||||
for (const field of required) {
|
||||
if (binding[field] === undefined) context.addIssue({ code: "custom", path: [field], message: "Required" });
|
||||
}
|
||||
});
|
||||
const configSchema = z.object({
|
||||
workspaceId: workspaceIdSchema,
|
||||
engine: z.literal("postgres"),
|
||||
databaseName: identifier,
|
||||
schema: identifier,
|
||||
binding: bindingSchema,
|
||||
}).strict();
|
||||
const updateSchema = configSchema.extend({ version: z.number().int().positive() });
|
||||
const secretNames = [
|
||||
"password",
|
||||
|
||||
@@ -9,10 +9,13 @@ import {
|
||||
} from "../catalog/types.js";
|
||||
|
||||
const idSchema = z.uuid();
|
||||
const consolidationSchema = z.object({
|
||||
target: z.enum(["tables", "columns"]),
|
||||
targetIds: z.array(idSchema).min(1).max(10_000),
|
||||
}).strict();
|
||||
const consolidationSchema = z.discriminatedUnion("target", [
|
||||
z.object({
|
||||
target: z.enum(["tables", "columns"]),
|
||||
targetIds: z.array(idSchema).min(1).max(10_000),
|
||||
}).strict(),
|
||||
z.object({ target: z.enum(["database", "database_columns"]) }).strict(),
|
||||
]);
|
||||
|
||||
function manage(request: FastifyRequest, reply: FastifyReply) {
|
||||
return isPrincipalContext(requirePermission(request, reply, "database.manage"));
|
||||
@@ -49,7 +52,7 @@ export function catalogDescriptionConsolidationRoutes(
|
||||
try {
|
||||
const databaseId = idSchema.parse((request.params as { databaseId?: unknown }).databaseId);
|
||||
const input = consolidationSchema.parse(request.body);
|
||||
const targetIds = [...new Set(input.targetIds)];
|
||||
const targetIds = "targetIds" in input ? [...new Set(input.targetIds)] : [];
|
||||
const result = await deps.operations.run(
|
||||
databaseId,
|
||||
async () => await deps.repository.consolidateGeneratedDescriptions(
|
||||
|
||||
@@ -11,38 +11,35 @@ import {
|
||||
type DescriptionGenerationWorker,
|
||||
} from "../catalog/description-generation-worker.js";
|
||||
import { MetadataGenerationModelUnavailableError } from "../catalog/metadata-generation-models.js";
|
||||
import { ModelCompletionProviderError } from "../catalog/model-completer.js";
|
||||
import {
|
||||
SensitiveDataSuggestionDuplicateTargetIdsError,
|
||||
SensitiveDataSuggestionInvalidResponseError,
|
||||
SensitiveDataSuggestionNoEligibleColumnsError,
|
||||
SensitiveDataSuggestionPayloadTooLargeError,
|
||||
SensitiveDataSuggestionTargetNotFoundError,
|
||||
} from "../catalog/sensitive-data-suggester.js";
|
||||
import type { SensitiveDataSuggestionRunner } from "../catalog/sensitive-data-suggestion-runner.js";
|
||||
SensitivityAnalysisDuplicateTargetIdsError,
|
||||
SensitivityAnalysisInterruptedError,
|
||||
SensitivityAnalysisNoEligibleColumnsError,
|
||||
SensitivityAnalysisTargetNotFoundError,
|
||||
} from "../catalog/sensitivity-analysis-service.js";
|
||||
import type { SensitivityAnalysisRunner } from "../catalog/sensitivity-analysis-runner.js";
|
||||
import {
|
||||
CatalogOperationInProgressError,
|
||||
CatalogConnectorError,
|
||||
CatalogUnavailableError,
|
||||
DescriptionGenerationRunActiveError,
|
||||
type CatalogRepository,
|
||||
type DescriptionGenerationEvent,
|
||||
type DescriptionGenerationRun,
|
||||
type SensitiveDataSuggestionEvent,
|
||||
type SensitiveDataSuggestionRun,
|
||||
type SensitivityAnalysisEvent,
|
||||
type SensitivityAnalysisRun,
|
||||
} from "../catalog/types.js";
|
||||
|
||||
const idSchema = z.uuid();
|
||||
const modelIdSchema = z.string().regex(/^[a-z][a-z0-9._-]{0,63}$/);
|
||||
const modelIdSchema = z.string().regex(/^[a-z][a-z0-9._-]{0,63}\/[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/);
|
||||
const selectedTargetIdsSchema = z.array(idSchema).min(1);
|
||||
const suggestionSchema = z.discriminatedUnion("scope", [
|
||||
z.object({ modelId: modelIdSchema, scope: z.literal("all") }).strict(),
|
||||
z.object({ scope: z.literal("all") }).strict(),
|
||||
z.object({
|
||||
modelId: modelIdSchema,
|
||||
scope: z.literal("selected_tables"),
|
||||
targetIds: selectedTargetIdsSchema,
|
||||
}).strict(),
|
||||
z.object({
|
||||
modelId: modelIdSchema,
|
||||
scope: z.literal("selected_columns"),
|
||||
targetIds: selectedTargetIdsSchema,
|
||||
}).strict(),
|
||||
@@ -101,6 +98,9 @@ function publicRun(run: DescriptionGenerationRun) {
|
||||
generated: run.generated,
|
||||
nonGeneratable: run.nonGeneratable,
|
||||
failed: run.failed,
|
||||
inputTokens: run.inputTokens,
|
||||
cacheReadTokens: run.cacheReadTokens,
|
||||
outputTokens: run.outputTokens,
|
||||
createdAt: run.createdAt,
|
||||
startedAt: run.startedAt,
|
||||
updatedAt: run.updatedAt,
|
||||
@@ -109,7 +109,7 @@ function publicRun(run: DescriptionGenerationRun) {
|
||||
};
|
||||
}
|
||||
|
||||
function publicSensitiveDataSuggestionEvent(event: SensitiveDataSuggestionEvent) {
|
||||
function publicSensitivityAnalysisEvent(event: SensitivityAnalysisEvent) {
|
||||
return {
|
||||
runId: event.runId,
|
||||
sequence: event.sequence,
|
||||
@@ -119,16 +119,22 @@ function publicSensitiveDataSuggestionEvent(event: SensitiveDataSuggestionEvent)
|
||||
};
|
||||
}
|
||||
|
||||
function publicSensitiveDataSuggestionRun(run: SensitiveDataSuggestionRun) {
|
||||
function publicSensitivityAnalysisRun(run: SensitivityAnalysisRun) {
|
||||
return {
|
||||
id: run.id,
|
||||
databaseId: run.databaseId,
|
||||
scope: run.scope,
|
||||
engine: run.engine,
|
||||
modelId: run.modelId,
|
||||
policyVersion: run.policyVersion,
|
||||
status: run.status,
|
||||
total: run.total,
|
||||
suggestedSensitive: run.suggestedSensitive,
|
||||
suggestedNonSensitive: run.suggestedNonSensitive,
|
||||
unknown: run.unknown,
|
||||
inputTokens: run.inputTokens,
|
||||
cacheReadTokens: run.cacheReadTokens,
|
||||
outputTokens: run.outputTokens,
|
||||
createdAt: run.createdAt,
|
||||
startedAt: run.startedAt,
|
||||
updatedAt: run.updatedAt,
|
||||
@@ -226,16 +232,10 @@ function safeSuggestionError(reply: FastifyReply, error: unknown) {
|
||||
if (error instanceof CatalogUnavailableError) {
|
||||
return reply.code(503).send({
|
||||
code: "catalog_unavailable",
|
||||
message: "The database catalog is unavailable, so no sensitive-field suggestions were prepared.",
|
||||
message: "The database catalog is unavailable, so no sensitivity assessments were prepared.",
|
||||
});
|
||||
}
|
||||
if (error instanceof MetadataGenerationModelUnavailableError) {
|
||||
return reply.code(409).send({
|
||||
code: "metadata_generation_model_unavailable",
|
||||
message: "The selected metadata-generation model is unavailable.",
|
||||
});
|
||||
}
|
||||
if (error instanceof SensitiveDataSuggestionTargetNotFoundError) {
|
||||
if (error instanceof SensitivityAnalysisTargetNotFoundError) {
|
||||
const code = error.target === "database"
|
||||
? "database_not_found"
|
||||
: error.target === "table"
|
||||
@@ -248,45 +248,39 @@ function safeSuggestionError(reply: FastifyReply, error: unknown) {
|
||||
: "One or more selected Catalog Columns were not found in this database.";
|
||||
return reply.code(404).send({ code, message });
|
||||
}
|
||||
if (error instanceof SensitiveDataSuggestionDuplicateTargetIdsError) {
|
||||
if (error instanceof SensitivityAnalysisDuplicateTargetIdsError) {
|
||||
return reply.code(400).send({
|
||||
code: "sensitive_data_suggestion_target_ids_duplicate",
|
||||
message: "Each selected table or column must appear only once.",
|
||||
});
|
||||
}
|
||||
if (error instanceof SensitiveDataSuggestionNoEligibleColumnsError) {
|
||||
if (error instanceof SensitivityAnalysisNoEligibleColumnsError) {
|
||||
return reply.code(409).send({
|
||||
code: "sensitive_data_suggestion_no_columns",
|
||||
message: "The selected scope contains no Catalog Columns to classify.",
|
||||
message: "The selected scope contains no Catalog Columns to assess.",
|
||||
});
|
||||
}
|
||||
if (error instanceof SensitiveDataSuggestionPayloadTooLargeError) {
|
||||
return reply.code(413).send({
|
||||
code: "sensitive_data_suggestion_payload_too_large",
|
||||
message: "The selected structural metadata cannot be divided into safe LLM requests.",
|
||||
if (error instanceof SensitivityAnalysisInterruptedError) {
|
||||
return reply.code(499).send({
|
||||
code: "sensitivity_analysis_interrupted",
|
||||
message: "Sensitivity analysis was interrupted before completion. No assessments were applied.",
|
||||
});
|
||||
}
|
||||
if (error instanceof SensitiveDataSuggestionInvalidResponseError) {
|
||||
if (error instanceof CatalogConnectorError) {
|
||||
return reply.code(502).send({
|
||||
code: "sensitive_data_suggestion_invalid_response",
|
||||
message: "The LLM returned an incomplete or invalid classification. No suggestions were applied.",
|
||||
});
|
||||
}
|
||||
if (error instanceof ModelCompletionProviderError) {
|
||||
return reply.code(502).send({
|
||||
code: "sensitive_data_suggestion_provider_unavailable",
|
||||
message: "The selected LLM service could not complete the request. No suggestions were applied.",
|
||||
code: "sensitivity_source_unavailable",
|
||||
message: "The source values could not be inspected safely. No assessments were applied.",
|
||||
});
|
||||
}
|
||||
if (error instanceof z.ZodError) {
|
||||
return reply.code(400).send({
|
||||
code: "sensitive_data_suggestion_request_invalid",
|
||||
message: "Choose a database, one or more tables, or one or more columns to classify.",
|
||||
message: "Choose a database, one or more tables, or one or more columns to assess.",
|
||||
});
|
||||
}
|
||||
return reply.code(500).send({
|
||||
code: "sensitive_data_suggestion_failed",
|
||||
message: "Sensitive-field suggestions failed before review. No changes were applied.",
|
||||
message: "Local sensitivity analysis failed before review. No changes were applied.",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -294,18 +288,18 @@ function safeSuggestionHistoryError(reply: FastifyReply, error: unknown) {
|
||||
if (error instanceof CatalogUnavailableError) {
|
||||
return reply.code(503).send({
|
||||
code: "catalog_unavailable",
|
||||
message: "Sensitive Data Suggestion history is unavailable because the database catalog is unavailable.",
|
||||
message: "Sensitivity Analysis history is unavailable because the database catalog is unavailable.",
|
||||
});
|
||||
}
|
||||
if (error instanceof z.ZodError) {
|
||||
return reply.code(400).send({
|
||||
code: "sensitive_data_suggestion_history_request_invalid",
|
||||
message: "Sensitive Data Suggestion history parameters are invalid.",
|
||||
message: "Sensitivity Analysis history parameters are invalid.",
|
||||
});
|
||||
}
|
||||
return reply.code(500).send({
|
||||
code: "sensitive_data_suggestion_history_failed",
|
||||
message: "Sensitive Data Suggestion history could not be loaded.",
|
||||
message: "Sensitivity Analysis history could not be loaded.",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -314,7 +308,7 @@ export function catalogDescriptionGenerationRoutes(
|
||||
deps: {
|
||||
repository: CatalogRepository;
|
||||
worker: DescriptionGenerationWorker;
|
||||
sensitiveDataSuggestionRunner: SensitiveDataSuggestionRunner;
|
||||
sensitivityAnalysisRunner: SensitivityAnalysisRunner;
|
||||
},
|
||||
): void {
|
||||
app.post("/catalog/databases/:databaseId/sensitive-data-suggestions", async (request, reply) => {
|
||||
@@ -322,16 +316,25 @@ export function catalogDescriptionGenerationRoutes(
|
||||
try {
|
||||
const databaseId = idSchema.parse((request.params as { databaseId?: unknown }).databaseId);
|
||||
const input = suggestionSchema.parse(request.body);
|
||||
const result = await deps.sensitiveDataSuggestionRunner.run(
|
||||
databaseId,
|
||||
input.modelId,
|
||||
input.scope,
|
||||
"targetIds" in input ? input.targetIds : [],
|
||||
new AbortController().signal,
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const abort = () => controller.abort();
|
||||
request.raw.once("aborted", abort);
|
||||
reply.raw.once("close", abort);
|
||||
let result;
|
||||
try {
|
||||
result = await deps.sensitivityAnalysisRunner.run(
|
||||
databaseId,
|
||||
input.scope,
|
||||
"targetIds" in input ? input.targetIds : [],
|
||||
controller.signal,
|
||||
);
|
||||
} finally {
|
||||
request.raw.off("aborted", abort);
|
||||
reply.raw.off("close", abort);
|
||||
}
|
||||
return {
|
||||
suggestions: result.suggestions,
|
||||
run: publicSensitiveDataSuggestionRun(result.run),
|
||||
run: publicSensitivityAnalysisRun(result.run),
|
||||
};
|
||||
} catch (error) {
|
||||
return safeSuggestionError(reply, error);
|
||||
@@ -342,8 +345,8 @@ export function catalogDescriptionGenerationRoutes(
|
||||
if (!manage(request, reply)) return reply;
|
||||
try {
|
||||
const { limit } = historyQuerySchema.parse(request.query);
|
||||
return (await deps.repository.listSensitiveDataSuggestionRuns(limit))
|
||||
.map(publicSensitiveDataSuggestionRun);
|
||||
return (await deps.repository.listSensitivityAnalysisRuns(limit))
|
||||
.map(publicSensitivityAnalysisRun);
|
||||
} catch (error) {
|
||||
return safeSuggestionHistoryError(reply, error);
|
||||
}
|
||||
@@ -353,12 +356,12 @@ export function catalogDescriptionGenerationRoutes(
|
||||
if (!manage(request, reply)) return reply;
|
||||
try {
|
||||
const runId = idSchema.parse((request.params as { runId?: unknown }).runId);
|
||||
const run = await deps.repository.getSensitiveDataSuggestionRun(runId);
|
||||
const run = await deps.repository.getSensitivityAnalysisRun(runId);
|
||||
if (!run) return reply.code(404).send({
|
||||
code: "sensitive_data_suggestion_run_not_found",
|
||||
message: "Sensitive Data Suggestion Run was not found.",
|
||||
message: "Sensitivity Analysis Run was not found.",
|
||||
});
|
||||
return publicSensitiveDataSuggestionRun(run);
|
||||
return publicSensitivityAnalysisRun(run);
|
||||
} catch (error) {
|
||||
return safeSuggestionHistoryError(reply, error);
|
||||
}
|
||||
@@ -369,14 +372,14 @@ export function catalogDescriptionGenerationRoutes(
|
||||
try {
|
||||
const runId = idSchema.parse((request.params as { runId?: unknown }).runId);
|
||||
const { after } = eventQuerySchema.parse(request.query);
|
||||
if (!(await deps.repository.getSensitiveDataSuggestionRun(runId))) {
|
||||
if (!(await deps.repository.getSensitivityAnalysisRun(runId))) {
|
||||
return reply.code(404).send({
|
||||
code: "sensitive_data_suggestion_run_not_found",
|
||||
message: "Sensitive Data Suggestion Run was not found.",
|
||||
message: "Sensitivity Analysis Run was not found.",
|
||||
});
|
||||
}
|
||||
return (await deps.repository.listSensitiveDataSuggestionEvents(runId, after))
|
||||
.map(publicSensitiveDataSuggestionEvent);
|
||||
return (await deps.repository.listSensitivityAnalysisEvents(runId, after))
|
||||
.map(publicSensitivityAnalysisEvent);
|
||||
} catch (error) {
|
||||
return safeSuggestionHistoryError(reply, error);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { isPrincipalContext, requirePermission } from "../auth/authorization.js";
|
||||
import {
|
||||
CatalogLogicalRelationshipService,
|
||||
LogicalRelationshipColumnNotFoundError,
|
||||
LogicalRelationshipDatabaseNotFoundError,
|
||||
LogicalRelationshipDuplicateError,
|
||||
LogicalRelationshipNotFoundError,
|
||||
LogicalRelationshipReadOnlyError,
|
||||
LogicalRelationshipSchemaStaleError,
|
||||
LogicalRelationshipTargetNotUniqueError,
|
||||
LogicalRelationshipTypeIncompatibleError,
|
||||
} from "../catalog/logical-relationship-service.js";
|
||||
import type { CatalogOperationCoordinator } from "../catalog/operation-coordinator.js";
|
||||
import { CatalogOperationInProgressError, CatalogUnavailableError } from "../catalog/types.js";
|
||||
|
||||
const idSchema = z.uuid();
|
||||
const createSchema = z.object({
|
||||
sourceColumnId: idSchema,
|
||||
targetColumnId: idSchema,
|
||||
}).strict();
|
||||
const statusSchema = z.object({ status: z.enum(["active", "excluded"]) }).strict();
|
||||
const emptySchema = z.object({}).strict();
|
||||
|
||||
function manage(request: FastifyRequest, reply: FastifyReply) {
|
||||
return isPrincipalContext(requirePermission(request, reply, "database.manage"));
|
||||
}
|
||||
|
||||
function safeError(reply: FastifyReply, error: unknown) {
|
||||
if (error instanceof CatalogUnavailableError) {
|
||||
return reply.code(503).send({ code: "catalog_unavailable", message: "Database catalog is unavailable." });
|
||||
}
|
||||
if (error instanceof CatalogOperationInProgressError) {
|
||||
return reply.code(409).send({
|
||||
code: "database_operation_in_progress",
|
||||
message: "A database operation is already in progress.",
|
||||
});
|
||||
}
|
||||
if (error instanceof LogicalRelationshipDatabaseNotFoundError) {
|
||||
return reply.code(404).send({ code: "database_not_found", message: "Database configuration was not found." });
|
||||
}
|
||||
if (error instanceof LogicalRelationshipColumnNotFoundError) {
|
||||
return reply.code(404).send({
|
||||
code: "column_not_found",
|
||||
message: "Catalog column was not found.",
|
||||
field: error.field,
|
||||
});
|
||||
}
|
||||
if (error instanceof LogicalRelationshipNotFoundError) {
|
||||
return reply.code(404).send({ code: "relationship_not_found", message: "Relationship was not found." });
|
||||
}
|
||||
if (error instanceof LogicalRelationshipDuplicateError) {
|
||||
return reply.code(409).send({ code: "relationship_duplicate", message: "Relationship already exists." });
|
||||
}
|
||||
if (error instanceof LogicalRelationshipReadOnlyError) {
|
||||
return reply.code(409).send({ code: "relationship_read_only", message: "Physical relationships are read-only." });
|
||||
}
|
||||
if (error instanceof LogicalRelationshipSchemaStaleError) {
|
||||
return reply.code(409).send({
|
||||
code: "relationship_schema_stale",
|
||||
message: "Synchronize the current database schema before managing logical relationships.",
|
||||
});
|
||||
}
|
||||
if (error instanceof LogicalRelationshipTargetNotUniqueError) {
|
||||
return reply.code(422).send({
|
||||
code: "relationship_target_not_unique",
|
||||
message: "Target column must be the only primary-key column of its table.",
|
||||
field: "targetColumnId",
|
||||
});
|
||||
}
|
||||
if (error instanceof LogicalRelationshipTypeIncompatibleError) {
|
||||
return reply.code(422).send({
|
||||
code: "relationship_type_incompatible",
|
||||
message: "Source and target column types are incompatible.",
|
||||
field: "targetColumnId",
|
||||
});
|
||||
}
|
||||
if (error instanceof z.ZodError) {
|
||||
return reply.code(400).send({
|
||||
code: "relationship_request_invalid",
|
||||
message: "Relationship request is invalid.",
|
||||
});
|
||||
}
|
||||
return reply.code(500).send({
|
||||
code: "relationship_operation_failed",
|
||||
message: "Relationship operation failed.",
|
||||
});
|
||||
}
|
||||
|
||||
export function catalogLogicalRelationshipRoutes(
|
||||
app: FastifyInstance,
|
||||
deps: {
|
||||
service: CatalogLogicalRelationshipService;
|
||||
operations: CatalogOperationCoordinator;
|
||||
},
|
||||
): void {
|
||||
app.get("/catalog/databases/:databaseId/relationships", async (request, reply) => {
|
||||
if (!manage(request, reply)) return reply;
|
||||
try {
|
||||
const databaseId = idSchema.parse((request.params as { databaseId?: unknown }).databaseId);
|
||||
return await deps.service.list(databaseId);
|
||||
} catch (error) { return safeError(reply, error); }
|
||||
});
|
||||
|
||||
app.post("/catalog/databases/:databaseId/relationships", async (request, reply) => {
|
||||
if (!manage(request, reply)) return reply;
|
||||
try {
|
||||
const databaseId = idSchema.parse((request.params as { databaseId?: unknown }).databaseId);
|
||||
const input = createSchema.parse(request.body);
|
||||
const relationship = await deps.operations.run(databaseId, async () => (
|
||||
await deps.service.addManual(databaseId, input.sourceColumnId, input.targetColumnId)
|
||||
));
|
||||
return reply.code(201).send(relationship);
|
||||
} catch (error) { return safeError(reply, error); }
|
||||
});
|
||||
|
||||
app.post("/catalog/databases/:databaseId/relationships/rebuild-generated", async (request, reply) => {
|
||||
if (!manage(request, reply)) return reply;
|
||||
try {
|
||||
const databaseId = idSchema.parse((request.params as { databaseId?: unknown }).databaseId);
|
||||
emptySchema.parse(request.body ?? {});
|
||||
return await deps.operations.run(databaseId, async () => (
|
||||
await deps.service.rebuildGenerated(databaseId)
|
||||
));
|
||||
} catch (error) { return safeError(reply, error); }
|
||||
});
|
||||
|
||||
app.patch("/catalog/databases/:databaseId/relationships/:relationshipId", async (request, reply) => {
|
||||
if (!manage(request, reply)) return reply;
|
||||
try {
|
||||
const params = request.params as { databaseId?: unknown; relationshipId?: unknown };
|
||||
const databaseId = idSchema.parse(params.databaseId);
|
||||
const relationshipId = idSchema.parse(params.relationshipId);
|
||||
const input = statusSchema.parse(request.body);
|
||||
return await deps.operations.run(databaseId, async () => (
|
||||
await deps.service.setStatus(databaseId, relationshipId, input.status)
|
||||
));
|
||||
} catch (error) { return safeError(reply, error); }
|
||||
});
|
||||
|
||||
app.delete("/catalog/databases/:databaseId/relationships/:relationshipId", async (request, reply) => {
|
||||
if (!manage(request, reply)) return reply;
|
||||
try {
|
||||
const params = request.params as { databaseId?: unknown; relationshipId?: unknown };
|
||||
const databaseId = idSchema.parse(params.databaseId);
|
||||
const relationshipId = idSchema.parse(params.relationshipId);
|
||||
await deps.operations.run(databaseId, async () => {
|
||||
await deps.service.deletePermanently(databaseId, relationshipId);
|
||||
});
|
||||
return reply.code(204).send();
|
||||
} catch (error) { return safeError(reply, error); }
|
||||
});
|
||||
}
|
||||
@@ -19,8 +19,14 @@ const metadataSchema = z.object({
|
||||
description: z.string().max(20_000).nullable().optional(),
|
||||
generatedDescription: z.string().max(20_000).nullable().optional(),
|
||||
sensitive: z.boolean().optional(),
|
||||
sensitivityReason: z.string().max(2_000).nullable().optional(),
|
||||
}).strict().refine((value) => (
|
||||
"description" in value || "generatedDescription" in value || "sensitive" in value
|
||||
"description" in value
|
||||
|| "generatedDescription" in value
|
||||
|| "sensitive" in value
|
||||
|| "sensitivityReason" in value
|
||||
)).refine((value) => (
|
||||
value.sensitivityReason == null || value.sensitive === true
|
||||
));
|
||||
const createRunSchema = z.object({
|
||||
version: z.number().int().positive(),
|
||||
@@ -56,7 +62,10 @@ function safeError(reply: FastifyReply, error: unknown) {
|
||||
return reply.code(409).send({ code: "schema_sync_conflict", message: error.message });
|
||||
}
|
||||
if (error instanceof CatalogConnectorError) {
|
||||
return reply.code(502).send({ code: "schema_introspection_failed", message: "The database schema could not be read safely." });
|
||||
return reply.code(502).send({
|
||||
code: "schema_introspection_failed",
|
||||
message: "The database schema could not be read. Check the connection and credentials, then try again.",
|
||||
});
|
||||
}
|
||||
if (error instanceof z.ZodError) {
|
||||
return reply.code(400).send({ code: "schema_request_invalid", message: "Schema request is invalid." });
|
||||
@@ -113,6 +122,12 @@ export function catalogSchemaRoutes(
|
||||
if (current.version !== input.version) {
|
||||
return reply.code(409).send({ code: "column_stale", message: "Column metadata changed. Reload and try again." });
|
||||
}
|
||||
const nextSensitive = input.sensitive ?? current.sensitive;
|
||||
const nextSensitivityReason = nextSensitive
|
||||
? ("sensitivityReason" in input
|
||||
? normalized(input.sensitivityReason ?? null)
|
||||
: current.sensitivityReason)
|
||||
: null;
|
||||
const updated = await deps.repository.updateColumnMetadata(
|
||||
databaseId,
|
||||
tableId,
|
||||
@@ -123,6 +138,7 @@ export function catalogSchemaRoutes(
|
||||
? normalized(input.generatedDescription ?? null)
|
||||
: current.generatedDescription,
|
||||
input.sensitive,
|
||||
nextSensitivityReason,
|
||||
);
|
||||
if (!updated) return reply.code(409).send({ code: "column_stale", message: "Column metadata changed. Reload and try again." });
|
||||
return updated;
|
||||
@@ -131,17 +147,6 @@ export function catalogSchemaRoutes(
|
||||
} catch (error) { return safeError(reply, error); }
|
||||
});
|
||||
|
||||
app.get("/catalog/databases/:databaseId/relationships", async (request, reply) => {
|
||||
if (!manage(request, reply)) return reply;
|
||||
try {
|
||||
const databaseId = idSchema.parse((request.params as { databaseId?: unknown }).databaseId);
|
||||
if (!(await deps.repository.get(databaseId))) {
|
||||
return reply.code(404).send({ code: "database_not_found", message: "Database configuration was not found." });
|
||||
}
|
||||
return await deps.repository.listRelationships(databaseId);
|
||||
} catch (error) { return safeError(reply, error); }
|
||||
});
|
||||
|
||||
app.post("/catalog/databases/metadata-cleanup", async (request, reply) => {
|
||||
if (!manage(request, reply)) return reply;
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import path from "node:path";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { requirePermission, isPrincipalContext } from "../auth/authorization.js";
|
||||
import type { ThtRunner } from "../tht/tht-runner.js";
|
||||
import type { WorkspaceRegistry } from "../workspaces/registry.js";
|
||||
import type { WorkspacePreprocessingService } from "../workspaces/preprocessing-service.js";
|
||||
|
||||
const params = z.object({ workspaceId: z.string().regex(/^[a-z][a-z0-9-]{2,62}$/),
|
||||
evidenceId: z.string().regex(/^evidence:[a-z0-9]+(?:-[a-z0-9]+)*$/).optional() });
|
||||
const query = z.object({
|
||||
q: z.string().max(1000).optional(), kind: z.enum(["domain", "glossary", "enum", "example", "mapping", "normalization", "formula", "reference"]).optional(),
|
||||
purpose: z.enum(["disambiguation", "rewriting", "schema_linking", "sql_generation"]).optional(),
|
||||
status: z.enum(["new", "modified", "active", "removed", "review_required", "legacy", "invalid"]).optional(),
|
||||
concept: z.string().max(300).optional(), table: z.string().max(300).optional(), column: z.string().max(300).optional(),
|
||||
source: z.string().max(300).optional(), language: z.string().max(30).optional(),
|
||||
sort: z.enum(["title", "id", "kind", "status"]).optional(), direction: z.enum(["asc", "desc"]).optional(),
|
||||
page: z.coerce.number().int().positive().optional(), page_size: z.coerce.number().int().min(1).max(100).optional(),
|
||||
}).strict();
|
||||
const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
|
||||
|
||||
export function evidenceRoutes(app: FastifyInstance, deps: {
|
||||
runner: Pick<ThtRunner, "withPrincipal">; registry: Pick<WorkspaceRegistry, "list">;
|
||||
registryRoot: string; hostRegistryRoot?: string;
|
||||
service: Partial<Pick<WorkspacePreprocessingService, "consolidateEvidence" | "evidenceSources">>;
|
||||
}) {
|
||||
app.post("/workspaces/:workspaceId/evidence/sources", async (request, reply) => {
|
||||
const principal = requirePermission(request, reply, "evidence.manage");
|
||||
if (!isPrincipalContext(principal)) return reply;
|
||||
try {
|
||||
const { workspaceId } = params.parse(request.params);
|
||||
const body = z.discriminatedUnion("action", [
|
||||
z.object({ action: z.literal("refresh") }).strict(),
|
||||
z.object({ action: z.literal("decide"), sourceId: z.string().regex(/^[a-f0-9]{64}$/),
|
||||
revision: z.string().regex(/^[a-f0-9]{64}$/), decision: z.enum(["keep", "replace"]) }).strict(),
|
||||
]).parse(request.body);
|
||||
if (!(await deps.registry.list()).some(w => w.id === workspaceId)) return reply.code(404).send({ code: "workspace_invalid" });
|
||||
if (!deps.service.evidenceSources) throw new Error("Evidence service unavailable");
|
||||
return await deps.service.evidenceSources({ workspaceId, ...body, actor: principal.subject });
|
||||
} catch (error) {
|
||||
return reply.code(error instanceof z.ZodError ? 400 : 503).send({ code: "evidence_unavailable",
|
||||
message: error instanceof z.ZodError ? "Invalid source request." : "Evidence source service is unavailable." });
|
||||
}
|
||||
});
|
||||
app.get<{ Params: { workspaceId: string; evidenceId?: string } }>("/workspaces/:workspaceId/evidence", read);
|
||||
app.get<{ Params: { workspaceId: string; evidenceId?: string } }>("/workspaces/:workspaceId/evidence/:evidenceId", read);
|
||||
async function read(request: import("fastify").FastifyRequest, reply: import("fastify").FastifyReply) {
|
||||
const principal = requirePermission(request, reply, "evidence.manage");
|
||||
if (!isPrincipalContext(principal)) return reply;
|
||||
try {
|
||||
const { workspaceId, evidenceId } = params.parse(request.params);
|
||||
const filters = query.parse(request.query);
|
||||
if (!(await deps.registry.list()).some(w => w.id === workspaceId)) return reply.code(404).send({ code: "workspace_invalid" });
|
||||
const root = path.join(deps.registryRoot, "repo", workspaceId);
|
||||
const result = await deps.runner.withPrincipal(principal).runWithRuntimeSnapshot(
|
||||
["evidence", "admin", "--workspace", workspaceId],
|
||||
JSON.stringify({ root, query: { ...filters, ...(evidenceId ? { id: evidenceId } : {}) } }),
|
||||
);
|
||||
const payload = JSON.parse(result.stdout);
|
||||
if (result.code !== 0) return reply.code(503).send(payload);
|
||||
if (evidenceId && !payload.item) return reply.code(404).send({ code: "evidence_not_found", message: "Evidence was not found." });
|
||||
const host = deps.hostRegistryRoot;
|
||||
const hostPath = host && (path.isAbsolute(host) || path.win32.isAbsolute(host))
|
||||
? (path.win32.isAbsolute(host) && !path.isAbsolute(host) ? path.win32 : path).join(host, "repo") : null;
|
||||
const hostJoin = hostPath && path.win32.isAbsolute(hostPath) && !path.isAbsolute(hostPath) ? path.win32.join : path.join;
|
||||
return { ...payload, location: { repository: hostPath, workspace: hostPath ? hostJoin(hostPath, workspaceId) : null,
|
||||
runtime_workspace: root, host: "Installation host", command: `tht workspace evidence consolidate --workspace ${workspaceId}`,
|
||||
git_commands: hostPath ? [
|
||||
`git -C ${quote(hostPath)} status --short -- ${quote(workspaceId + "/evidence")}`,
|
||||
`git -C ${quote(hostPath)} diff -- ${quote(workspaceId + "/evidence")}`,
|
||||
`git -C ${quote(hostPath)} add -A -- ${quote(workspaceId + "/evidence")}`,
|
||||
`git -C ${quote(hostPath)} commit --only -m 'Curate Evidence' -- ${quote(workspaceId + "/evidence")}`,
|
||||
`git -C ${quote(hostPath)} push`,
|
||||
] : [] } };
|
||||
} catch (error) {
|
||||
return reply.code(error instanceof z.ZodError ? 400 : 503).send({ code: "evidence_unavailable",
|
||||
message: error instanceof z.ZodError ? "Evidence filters are invalid." : "Evidence archive is unavailable." });
|
||||
}
|
||||
}
|
||||
app.post("/workspaces/:workspaceId/evidence/consolidate", async (request, reply) => {
|
||||
const principal = requirePermission(request, reply, "evidence.manage");
|
||||
if (!isPrincipalContext(principal)) return reply;
|
||||
try {
|
||||
const { workspaceId } = params.parse(request.params);
|
||||
if (!(await deps.registry.list()).some(w => w.id === workspaceId)) return reply.code(404).send({ code: "workspace_invalid" });
|
||||
if (!deps.service.consolidateEvidence) throw new Error("Evidence service unavailable");
|
||||
return await deps.service.consolidateEvidence({ workspaceId });
|
||||
} catch { return reply.code(503).send({ code: "evidence_unavailable", message: "Evidence consolidation is unavailable." }); }
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { isPrincipalContext, requirePermission } from "../auth/authorization.js";
|
||||
import type { ThtRunner } from "../tht/tht-runner.js";
|
||||
import type { WorkspaceRegistry } from "../workspaces/registry.js";
|
||||
import type { SemanticRuntimeConfig } from "../workspaces/runtime-renderer.js";
|
||||
|
||||
const paramsSchema = z.object({
|
||||
workspaceId: z.string().regex(/^[a-z][a-z0-9_-]{0,63}$/),
|
||||
cardId: z.string().regex(/^mem-[0-9a-f-]{36}$/).optional(),
|
||||
});
|
||||
const family = z.enum(["domain_clarification", "sql_rule", "solved_question", "explained_error"]);
|
||||
const cardSchema = z.object({
|
||||
family, subject: z.string().trim().min(1).max(1000),
|
||||
detail: z.string().max(50000).default(""), scope: z.string().trim().min(1).max(10000),
|
||||
rationale: z.string().max(10000).default(""), question: z.string().max(10000).default(""),
|
||||
sql: z.string().max(100000).default(""),
|
||||
concepts: z.array(z.string().trim().min(1).max(200)).max(100).default([]),
|
||||
dependencies: z.array(z.object({
|
||||
database: z.string().trim().min(1).max(200), schema_name: z.string().max(200).default(""),
|
||||
table: z.string().max(200).default(""), column: z.string().max(200).default(""),
|
||||
}).strict()).max(200).default([]),
|
||||
links: z.array(z.object({
|
||||
target_id: z.string().min(1).max(100), meaning: z.string().trim().min(1).max(1000),
|
||||
}).strict()).max(200).default([]),
|
||||
}).strict();
|
||||
const querySchema = z.object({
|
||||
q: z.string().max(1000).optional(), family: family.optional(),
|
||||
concept: z.string().max(200).optional(), database: z.string().max(200).optional(),
|
||||
table: z.string().max(200).optional(), column: z.string().max(200).optional(),
|
||||
origin: z.enum(["manual", "workflow"]).optional(),
|
||||
updated_after: z.iso.datetime({ offset: true }).optional(),
|
||||
updated_before: z.iso.datetime({ offset: true }).optional(),
|
||||
page: z.coerce.number().int().positive().optional(),
|
||||
page_size: z.coerce.number().int().min(1).max(100).optional(),
|
||||
sort: z.enum(["updated_at", "created_at", "subject", "family"]).optional(),
|
||||
direction: z.enum(["asc", "desc"]).optional(),
|
||||
}).strict();
|
||||
|
||||
export function memoryRoutes(app: FastifyInstance, deps: {
|
||||
runner: Pick<ThtRunner, "withPrincipal">;
|
||||
registry: Pick<WorkspaceRegistry, "list" | "read">;
|
||||
runtime: SemanticRuntimeConfig;
|
||||
}) {
|
||||
const invoke = (action: string) => async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const principal = requirePermission(request, reply, "memory.manage");
|
||||
if (!isPrincipalContext(principal)) return reply;
|
||||
try {
|
||||
const { workspaceId, cardId } = paramsSchema.parse(request.params);
|
||||
const input = action === "list" ? querySchema.parse(request.query)
|
||||
: action === "create" || action === "update"
|
||||
? { card: cardSchema.parse(request.body), ...(cardId ? { id: cardId } : {}) }
|
||||
: cardId ? { id: cardId } : {};
|
||||
// Registry identity is enough: administrative browsing must not require a DWH binding.
|
||||
const workspaces = await deps.registry.list();
|
||||
if (!workspaces.some(workspace => workspace.id === workspaceId)) {
|
||||
return reply.code(404).send({ code: "workspace_invalid", message: "Workspace was not found." });
|
||||
}
|
||||
const { workspace } = await deps.registry.read(workspaceId);
|
||||
const result = await deps.runner.withPrincipal(principal).runWithRuntimeSnapshot(
|
||||
["memory", "admin", "--workspace", workspaceId],
|
||||
JSON.stringify({ action, request: input, runtime: {
|
||||
...deps.runtime, memoryLanguage: workspace.workspace.language,
|
||||
} }),
|
||||
);
|
||||
let payload;
|
||||
try { payload = JSON.parse(result.stdout); } catch {
|
||||
return reply.code(503).send({ code: "memory_unavailable", message: "Memory archive is unavailable." });
|
||||
}
|
||||
if (result.code !== 0) {
|
||||
const codes: Record<string, number> = {
|
||||
memory_invalid: 400, memory_forbidden: 403, memory_not_found: 404,
|
||||
memory_conflict: 409, memory_unavailable: 503,
|
||||
};
|
||||
const code = typeof payload?.code === "string" && payload.code in codes
|
||||
? payload.code : "memory_unavailable";
|
||||
return reply.code(codes[code]).send({ code, message: "Memory operation could not be completed." });
|
||||
}
|
||||
return reply.code(action === "create" ? 201 : 200).send(payload);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return reply.code(400).send({ code: "memory_invalid", message: "Memory request is invalid." });
|
||||
}
|
||||
return reply.code(503).send({ code: "memory_unavailable", message: "Memory archive is unavailable." });
|
||||
}
|
||||
};
|
||||
app.get("/workspaces/:workspaceId/memory", invoke("list"));
|
||||
app.post("/workspaces/:workspaceId/memory", invoke("create"));
|
||||
app.get("/workspaces/:workspaceId/memory/pending", invoke("pending"));
|
||||
app.get("/workspaces/:workspaceId/memory/:cardId", invoke("show"));
|
||||
app.put("/workspaces/:workspaceId/memory/:cardId", invoke("update"));
|
||||
app.delete("/workspaces/:workspaceId/memory/:cardId", invoke("delete"));
|
||||
app.post("/workspaces/:workspaceId/memory/:cardId/retry", invoke("retry"));
|
||||
}
|
||||
+10
-15
@@ -1,10 +1,8 @@
|
||||
import { readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import type { PiModel } from "../pi/list-models.js";
|
||||
import { isPrincipalContext, requirePermission } from "../auth/authorization.js";
|
||||
|
||||
export type ListModelsFn = () => Promise<PiModel[]>;
|
||||
import type { RuntimeModelCatalog } from "../models/runtime-model-catalog.js";
|
||||
|
||||
/**
|
||||
* List YAML workspace configs found in <harnessDir>/workspaces/*.yaml.
|
||||
@@ -25,20 +23,17 @@ export function listWorkspaces(harnessDir: string): { name: string; file: string
|
||||
|
||||
export function metaRoutes(
|
||||
app: FastifyInstance,
|
||||
deps: { harnessDir: string; listModels?: ListModelsFn },
|
||||
deps: { harnessDir: string; modelCatalog: RuntimeModelCatalog },
|
||||
): void {
|
||||
app.get("/models", async (request, reply) => {
|
||||
if (!isPrincipalContext(requirePermission(request, reply, "session.use"))) return reply;
|
||||
const fn = deps.listModels ?? (async () => []);
|
||||
try {
|
||||
return { models: await fn() };
|
||||
} catch (error) {
|
||||
app.log.warn({
|
||||
component: "pi-model-list",
|
||||
errorType: error instanceof Error ? error.name : typeof error,
|
||||
}, "Pi model listing failed");
|
||||
// Graceful fallback: Pi may not be running; don't crash the server.
|
||||
return { models: [] as PiModel[] };
|
||||
}
|
||||
return {
|
||||
models: deps.modelCatalog.sessionModels().map((entry) => ({
|
||||
provider: entry.provider,
|
||||
id: entry.model,
|
||||
name: entry.label,
|
||||
reasoning: entry.session?.reasoning ?? false,
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,13 +11,6 @@ export function piManagementRoutes(
|
||||
deps: { service: PiManagementService },
|
||||
): void {
|
||||
app.get("/pi-management/status", async (request, reply) => run(request, reply, deps, () => deps.service.status()));
|
||||
app.get("/pi-management/options", async (request, reply) => run(request, reply, deps, () => deps.service.options()));
|
||||
app.put("/pi-management/config", async (request, reply) => run(
|
||||
request,
|
||||
reply,
|
||||
deps,
|
||||
() => deps.service.configure((request.body ?? {}) as Record<string, unknown>),
|
||||
));
|
||||
app.post("/pi-management/test", async (request, reply) => run(request, reply, deps, () => deps.service.test()));
|
||||
app.get("/pi-management/logs", async (request, reply) => run(request, reply, deps, () => deps.service.logs()));
|
||||
}
|
||||
@@ -38,8 +31,7 @@ async function run<T>(
|
||||
return await action();
|
||||
} catch (error) {
|
||||
if (error instanceof PiManagementError) {
|
||||
const statusCode = error.code === "pi_management_invalid_config" ? 400 : 503;
|
||||
return reply.code(statusCode).send({ code: error.code, error: error.message });
|
||||
return reply.code(503).send({ code: error.code, error: error.message });
|
||||
}
|
||||
return reply.code(503).send({ code: "pi_management_unavailable", error: "Pi management is unavailable" });
|
||||
}
|
||||
|
||||
+196
-31
@@ -6,11 +6,14 @@ import type { Settings } from "../settings/settings-store.js";
|
||||
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 { ListModelsFn } from "../pi/list-models.js";
|
||||
import type { WorkspaceRegistry } from "../workspaces/registry.js";
|
||||
import { validateOperationalWorkspace, type WorkspaceDescriptor } from "../workspaces/schema.js";
|
||||
import type { MaintenanceBarrier } from "../runtime/maintenance-gate.js";
|
||||
import { hasPermission, isPrincipalContext, requirePermission } from "../auth/authorization.js";
|
||||
import { splitCanonicalModelId, type RuntimeModelCatalog } from "../models/runtime-model-catalog.js";
|
||||
import type { CatalogRepository } from "../catalog/types.js";
|
||||
import { interactionLanguage } from "../tht/interaction-language.js";
|
||||
|
||||
const BOOTSTRAP_FAILURE_MESSAGE =
|
||||
"Session startup failed. Check configuration and connectivity, then Resume the session.";
|
||||
@@ -40,9 +43,40 @@ export function sessionRoutes(
|
||||
/** Fail-closed installation/runtime transport capability check. */
|
||||
workspaceRuntimeSupport: (workspace: WorkspaceDescriptor) => boolean;
|
||||
maintenanceBarrier: MaintenanceBarrier;
|
||||
modelCatalog: RuntimeModelCatalog;
|
||||
/** PostgreSQL authority for mandatory preprocessing admission. */
|
||||
catalogRepository?: CatalogRepository;
|
||||
},
|
||||
) {
|
||||
const lifecycleTails = new Map<string, Promise<void>>();
|
||||
|
||||
const preprocessingIsCurrent = async (
|
||||
workspaceId: string,
|
||||
inputFingerprint?: string,
|
||||
): Promise<boolean> => {
|
||||
if (!d.catalogRepository) return true;
|
||||
const database = await d.catalogRepository.getByWorkspace(workspaceId);
|
||||
return database !== undefined
|
||||
&& database.preprocessingStatus === "succeeded"
|
||||
&& database.preprocessedMetadataRevision === database.metadataContentRevision
|
||||
&& (inputFingerprint === undefined
|
||||
|| database.preprocessingInputFingerprint === inputFingerprint);
|
||||
};
|
||||
|
||||
const catalogTransportSupportsSessionRuntime = async (workspaceId: string): Promise<boolean> => {
|
||||
if (!d.catalogRepository) return true;
|
||||
const database = await d.catalogRepository.getByWorkspace(workspaceId);
|
||||
return database === undefined || database.binding.transport !== "ssh_tunnel";
|
||||
};
|
||||
|
||||
const currentInputFingerprint = async (
|
||||
runner: any,
|
||||
workspaceConfigPath: string,
|
||||
): Promise<string | undefined> => (
|
||||
typeof runner.workspaceInputFingerprint === "function"
|
||||
? await runner.workspaceInputFingerprint(workspaceConfigPath)
|
||||
: undefined
|
||||
);
|
||||
const boundRuntimes = new Map<
|
||||
string,
|
||||
ReturnType<PiProcessManager["createFor"]>
|
||||
@@ -84,11 +118,18 @@ export function sessionRoutes(
|
||||
isAdmin: hasPermission(principal, permission),
|
||||
});
|
||||
|
||||
const optionsWithRuntimeConfig = (runner: any, workspaceConfigPath: string | undefined, options: any) => (
|
||||
workspaceConfigPath && typeof runner.acquireWorkspaceRuntime === "function"
|
||||
? { ...options, runtimeConfig: runner.acquireWorkspaceRuntime(workspaceConfigPath) }
|
||||
: options
|
||||
);
|
||||
const optionsWithRuntimeConfig = async (
|
||||
runner: any,
|
||||
workspaceConfigPath: string | undefined,
|
||||
_workspaceId: string | undefined,
|
||||
options: any,
|
||||
) => {
|
||||
if (!workspaceConfigPath || typeof runner.acquireWorkspaceRuntime !== "function") return options;
|
||||
return {
|
||||
...options,
|
||||
runtimeConfig: await runner.acquireWorkspaceRuntime(workspaceConfigPath),
|
||||
};
|
||||
};
|
||||
|
||||
const maintenanceReply = (reply: any) => reply.code(503).send({
|
||||
code: "maintenance",
|
||||
@@ -110,13 +151,34 @@ export function sessionRoutes(
|
||||
});
|
||||
app.addHook("onResponse", async (req) => { admissionLeases.get(req)?.(); });
|
||||
|
||||
type SessionRevisionScan = {
|
||||
revisions: Awaited<ReturnType<typeof d.workspaceRegistry.list>>;
|
||||
retainedComplete: boolean;
|
||||
};
|
||||
let retainedSnapshotWarning: string | null = null;
|
||||
|
||||
/** Include retained historical descriptors so removed workspaces remain resumable. */
|
||||
const sessionRevisions = async () => {
|
||||
const sessionRevisions = async (): Promise<SessionRevisionScan> => {
|
||||
const registry = d.workspaceRegistry as Partial<WorkspaceRegistry>;
|
||||
if (typeof registry.listRetainedSnapshots === "function") {
|
||||
return await registry.listRetainedSnapshots();
|
||||
try {
|
||||
const revisions = await registry.listRetainedSnapshots();
|
||||
retainedSnapshotWarning = null;
|
||||
return { revisions, retainedComplete: true };
|
||||
} catch (error) {
|
||||
// A legacy/corrupt historical snapshot must not make active sessions (and their SSE
|
||||
// reviewer gates) unreachable. The registry still rejects that snapshot; this fallback
|
||||
// exposes only descriptors from the verified active state and deliberately disables
|
||||
// retention reconciliation because the resulting session view is incomplete.
|
||||
const detail = error instanceof Error ? error.message : "unknown error";
|
||||
if (retainedSnapshotWarning !== detail) {
|
||||
console.warn("[sessions] retained snapshot discovery failed; using active snapshots:", detail);
|
||||
retainedSnapshotWarning = detail;
|
||||
}
|
||||
return { revisions: await d.workspaceRegistry.list(), retainedComplete: false };
|
||||
}
|
||||
}
|
||||
return await d.workspaceRegistry.list();
|
||||
return { revisions: await d.workspaceRegistry.list(), retainedComplete: true };
|
||||
};
|
||||
|
||||
const isNotFound = (error: unknown) =>
|
||||
@@ -160,7 +222,7 @@ export function sessionRoutes(
|
||||
};
|
||||
let revisions: Awaited<ReturnType<typeof d.workspaceRegistry.list>>;
|
||||
try {
|
||||
revisions = await sessionRevisions();
|
||||
({ revisions } = await sessionRevisions());
|
||||
} catch (registryError) {
|
||||
// Sessions created before revision pinning still live under the installation's legacy
|
||||
// default config. Keep that compatibility path available when a fresh installation has
|
||||
@@ -320,8 +382,17 @@ export function sessionRoutes(
|
||||
app.post("/sessions", async (req, reply) => {
|
||||
const b = req.body as {
|
||||
question: string; name?: string; workspace?: string; workspaceId?: string;
|
||||
provider?: string; model?: string; thinking?: string;
|
||||
provider?: string; model?: string; thinking?: string; interactionLanguage?: unknown;
|
||||
};
|
||||
const language = interactionLanguage(b?.interactionLanguage);
|
||||
if (!language) return reply.code(400).send({
|
||||
code: "invalid_interaction_language",
|
||||
error: "interactionLanguage must be a well-formed BCP-47 language tag",
|
||||
});
|
||||
if ((b.provider === undefined) !== (b.model === undefined)
|
||||
|| (b.provider !== undefined && (typeof b.provider !== "string" || typeof b.model !== "string" || !b.provider || !b.model))) {
|
||||
return reply.code(400).send({ error: "provider and model must be supplied together" });
|
||||
}
|
||||
const principal = getPrincipal(req);
|
||||
let s: Settings;
|
||||
try { s = await d.getSettings(principal); } catch { return storageFailure(reply); }
|
||||
@@ -345,7 +416,6 @@ export function sessionRoutes(
|
||||
let workspaceId: string | undefined;
|
||||
let workspaceRevision: string | undefined;
|
||||
let workspaceDescriptor: WorkspaceDescriptor | undefined;
|
||||
let allowedModels: readonly string[] | undefined;
|
||||
if (requestedWorkspaceId) {
|
||||
try {
|
||||
const registry = d.workspaceRegistry as Partial<WorkspaceRegistry>;
|
||||
@@ -365,7 +435,19 @@ export function sessionRoutes(
|
||||
workspaceId = resolved.revision.id;
|
||||
workspaceRevision = resolved.revision.commit;
|
||||
workspaceDescriptor = resolved.workspace;
|
||||
allowedModels = resolved.workspace.llm_policy.allowed;
|
||||
if (!await catalogTransportSupportsSessionRuntime(workspaceId)) {
|
||||
return reply.code(409).send({
|
||||
error: "This workspace transport is not available to runtime sessions.",
|
||||
code: "workspace_not_activatable",
|
||||
});
|
||||
}
|
||||
const fingerprint = await currentInputFingerprint(runner, workspaceConfigPath);
|
||||
if (!await preprocessingIsCurrent(workspaceId, fingerprint)) {
|
||||
return reply.code(409).send({
|
||||
error: "Run workspace preprocessing before starting the core.",
|
||||
code: "preprocessing_required",
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
return reply.code(409).send({
|
||||
error: WORKSPACE_REVISION_UNAVAILABLE_MESSAGE,
|
||||
@@ -373,12 +455,15 @@ export function sessionRoutes(
|
||||
});
|
||||
}
|
||||
}
|
||||
const provider = b.provider ?? s.provider;
|
||||
const model = b.model ?? s.model;
|
||||
const requestedCanonical = b.provider && b.model ? `${b.provider}/${b.model}` : undefined;
|
||||
const selectedCanonical = requestedCanonical ?? d.modelCatalog.defaultInteraction;
|
||||
if (selectedCanonical && d.modelCatalog.defaultInteraction && !d.modelCatalog.hasSession(selectedCanonical)) {
|
||||
return reply.code(503).send({ error: MODEL_UNAVAILABLE_MESSAGE, code: "model_unavailable" });
|
||||
}
|
||||
const selected = selectedCanonical ? splitCanonicalModelId(selectedCanonical) : undefined;
|
||||
const provider = selected?.provider ?? b.provider;
|
||||
const model = selected?.model ?? b.model;
|
||||
const thinking = b.thinking ?? s.thinking;
|
||||
if (allowedModels && provider && model && !allowedModels.includes(`${provider}/${model}`)) {
|
||||
return reply.code(400).send({ error: "Selected model is not allowed by this workspace." });
|
||||
}
|
||||
// A persisted session is resumable without keeping Pi alive. New work replaces every
|
||||
// runtime owned by this principal, while runtimes belonging to other users remain intact.
|
||||
// Optional chaining preserves the deliberately narrow manager stubs used by route tests.
|
||||
@@ -424,11 +509,15 @@ export function sessionRoutes(
|
||||
// registry snapshot. The legacy fallback stays available for sessions created before
|
||||
// the browser-local preference migration.
|
||||
let id: string;
|
||||
let sessionLanguage = language;
|
||||
try {
|
||||
({ id } = await runner.sessionNew({
|
||||
const created = await runner.sessionNew({
|
||||
question: b.question, name: b.name, workspaceConfigPath,
|
||||
workspaceId, workspaceRevision, provider, model, thinking,
|
||||
}));
|
||||
interactionLanguage: language,
|
||||
});
|
||||
id = created.id;
|
||||
sessionLanguage = created.interaction_language ?? language;
|
||||
manifestPersisted = true;
|
||||
if (revisionLease) {
|
||||
await revisionLease.markPersisted().catch((error: unknown) => {
|
||||
@@ -441,6 +530,7 @@ export function sessionRoutes(
|
||||
} catch { return storageFailure(reply); }
|
||||
const options = {
|
||||
provider, model, thinking,
|
||||
interactionLanguage: sessionLanguage,
|
||||
author: principal.displayName ?? principal.subject,
|
||||
principal,
|
||||
question: b.question,
|
||||
@@ -448,7 +538,12 @@ export function sessionRoutes(
|
||||
let runtimeOptions = options;
|
||||
let rt: ReturnType<PiProcessManager["createFor"]> | undefined;
|
||||
try {
|
||||
runtimeOptions = optionsWithRuntimeConfig(runner, workspaceConfigPath, options);
|
||||
runtimeOptions = await optionsWithRuntimeConfig(
|
||||
runner,
|
||||
workspaceConfigPath,
|
||||
workspaceId,
|
||||
options,
|
||||
);
|
||||
rt = d.mgr.createFor(id, runtimeOptions);
|
||||
bindRuntime(id, rt, runner, workspaceConfigPath);
|
||||
} catch (error) {
|
||||
@@ -465,7 +560,11 @@ export function sessionRoutes(
|
||||
info(id, "Session created");
|
||||
bootstrap(
|
||||
id, rt, runner, workspaceConfigPath, d.mgr.configure(rt, runtimeOptions),
|
||||
runner.searchPack(b.question, id, workspaceConfigPath),
|
||||
runner.searchPack(
|
||||
b.question,
|
||||
id,
|
||||
(runtimeOptions as any).runtimeConfig?.path ?? workspaceConfigPath,
|
||||
),
|
||||
() => d.mgr.start(id, rt, runtimeOptions),
|
||||
);
|
||||
return { id };
|
||||
@@ -494,8 +593,8 @@ export function sessionRoutes(
|
||||
? { ...principal, isAdmin: false }
|
||||
: ownershipPrincipal(principal, "session.read_all");
|
||||
const runner = runnerFor(scopedPrincipal);
|
||||
const revisions = await sessionRevisions();
|
||||
const lists = await Promise.all(revisions
|
||||
const revisionScan = await sessionRevisions();
|
||||
const lists = await Promise.all(revisionScan.revisions
|
||||
.map((revision) => runner.sessionList(revision.snapshotPath) as Promise<SessionRow[]>));
|
||||
const sessions = new Map<string, SessionRow>();
|
||||
for (const row of lists.flat()) {
|
||||
@@ -505,7 +604,8 @@ export function sessionRoutes(
|
||||
// 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.issuer === "local")
|
||||
const hasCompleteRetentionView = revisionScan.retainedComplete
|
||||
&& (scope === "all" || principal.issuer === "local")
|
||||
&& hasPermission(principal, "session.read_all");
|
||||
if (hasCompleteRetentionView && typeof reconcileSnapshotRetention === "function") {
|
||||
const retained = [...new Set(list
|
||||
@@ -539,6 +639,23 @@ export function sessionRoutes(
|
||||
} catch (error) { return lifecycleFailure(reply, error); }
|
||||
const rt = d.mgr.get(id);
|
||||
if (!rt) return reply.code(404).send({ error: "sessione non attiva" });
|
||||
const pending = rt.bridge.pendingWidget?.() as any;
|
||||
const response = (req.body as any)?.ui_response;
|
||||
if (pending?.widget === "archive-repair" && response?.id === pending.id && !response.control) {
|
||||
const choices = response.choices;
|
||||
if (!Array.isArray(choices) || choices.length !== 1)
|
||||
return reply.code(400).send({ error: "Select one archive repair choice" });
|
||||
if (choices[0] !== "continue" && choices[0] !== "reject") {
|
||||
const option = pending.repair?.options?.find((item: any) => item.id === choices[0]);
|
||||
if (!option || !["memory", "evidence"].includes(option.archive))
|
||||
return reply.code(400).send({ error: "Unknown archive repair choice" });
|
||||
const permission = option.archive === "memory" ? "memory.manage" : "evidence.manage";
|
||||
if (!principal.isAdmin || !hasPermission(principal, permission))
|
||||
return reply.code(403).send({ error: "Archive corrections require an administrator" });
|
||||
if (rt.ownerKey !== `${principal.issuer}\0${principal.subject}`)
|
||||
return reply.code(409).send({ error: "Resume the session with your account before correcting an archive" });
|
||||
}
|
||||
}
|
||||
if (!rt.bridge.respond((req.body as any).ui_response)) {
|
||||
return reply.code(409).send({ error: "risposta non corrispondente al gate in attesa" });
|
||||
}
|
||||
@@ -558,6 +675,13 @@ export function sessionRoutes(
|
||||
app.post("/sessions/:id/resume", async (req, reply) => {
|
||||
const id = (req.params as any).id;
|
||||
const principal = getPrincipal(req);
|
||||
if (Object.hasOwn(req.body ?? {}, "interactionLanguage")
|
||||
|| Object.hasOwn(req.body ?? {}, "interaction_language")) {
|
||||
return reply.code(400).send({
|
||||
code: "interaction_language_pinned",
|
||||
error: "Resume uses the session's persisted interaction language; overrides are not accepted",
|
||||
});
|
||||
}
|
||||
return withSessionLifecycle(id, async () => {
|
||||
let settings: Settings;
|
||||
let located: LocatedSession | undefined;
|
||||
@@ -575,7 +699,19 @@ export function sessionRoutes(
|
||||
const saved = manifest as {
|
||||
provider?: string; model?: string; thinking?: string;
|
||||
workspace_id?: string; workspace_revision?: string;
|
||||
interaction_language?: string | null;
|
||||
};
|
||||
const requested = (req.body ?? {}) as { provider?: string; model?: string; thinking?: string };
|
||||
if ((requested.provider === undefined) !== (requested.model === undefined)
|
||||
|| (requested.provider !== undefined && (typeof requested.provider !== "string" || typeof requested.model !== "string" || !requested.provider || !requested.model))) {
|
||||
return reply.code(400).send({ error: "provider and model must be supplied together" });
|
||||
}
|
||||
const selectedCanonical = requested.provider && requested.model
|
||||
? `${requested.provider}/${requested.model}` : d.modelCatalog.defaultInteraction;
|
||||
if (d.modelCatalog.defaultInteraction && (!selectedCanonical || !d.modelCatalog.hasSession(selectedCanonical))) {
|
||||
return reply.code(503).send({ error: MODEL_UNAVAILABLE_MESSAGE, code: "model_unavailable" });
|
||||
}
|
||||
const selected = selectedCanonical ? splitCanonicalModelId(selectedCanonical) : saved;
|
||||
let workspaceConfigPath: string;
|
||||
let workspaceDescriptor: WorkspaceDescriptor | undefined;
|
||||
try {
|
||||
@@ -584,14 +720,37 @@ export function sessionRoutes(
|
||||
workspaceDescriptor = resolved.workspace;
|
||||
}
|
||||
catch { return unavailableWorkspaceReply(reply); }
|
||||
if (d.catalogRepository && saved.workspace_id
|
||||
&& !await catalogTransportSupportsSessionRuntime(saved.workspace_id)) {
|
||||
return reply.code(409).send({
|
||||
error: "This workspace transport is not available to runtime sessions.",
|
||||
code: "workspace_not_activatable",
|
||||
});
|
||||
}
|
||||
const fingerprint = d.catalogRepository
|
||||
? await currentInputFingerprint(runner, workspaceConfigPath)
|
||||
: undefined;
|
||||
if (d.catalogRepository
|
||||
&& (!saved.workspace_id || !await preprocessingIsCurrent(saved.workspace_id, fingerprint))) {
|
||||
return reply.code(409).send({
|
||||
error: "Run workspace preprocessing before starting the core.",
|
||||
code: "preprocessing_required",
|
||||
});
|
||||
}
|
||||
try { settings = await d.getSettings(principal); } catch { return storageFailure(reply); }
|
||||
let language = saved.interaction_language;
|
||||
if (language == null) {
|
||||
try {
|
||||
language = (await runner.ensureInteractionLanguage(id, workspaceConfigPath)).interaction_language;
|
||||
} catch { return reply.code(503).send({ error: RESUME_FAILURE_MESSAGE }); }
|
||||
}
|
||||
// This check belongs inside the per-session lock: a preceding cold Resume may have
|
||||
// installed a running runtime while this request was waiting.
|
||||
const existing = d.mgr.get(id);
|
||||
if (existing) {
|
||||
const state = existing.bridge.turnState();
|
||||
if (state === "running" || state === "waiting") {
|
||||
return reply.code(200).send({ id, alreadyActive: true });
|
||||
return reply.code(200).send({ id, alreadyActive: true, workspaceId: saved.workspace_id });
|
||||
}
|
||||
}
|
||||
const ensure = await d.readiness.ensure(
|
||||
@@ -602,8 +761,9 @@ export function sessionRoutes(
|
||||
...(ensure.code ? { code: ensure.code } : {}),
|
||||
});
|
||||
const options = {
|
||||
provider: saved?.provider,
|
||||
model: saved?.model,
|
||||
provider: selected.provider,
|
||||
interactionLanguage: language,
|
||||
model: selected.model,
|
||||
thinking: saved?.thinking ?? settings.thinking,
|
||||
author: principal.displayName ?? principal.subject,
|
||||
principal,
|
||||
@@ -625,7 +785,7 @@ export function sessionRoutes(
|
||||
if (current) {
|
||||
const state = current.bridge.turnState();
|
||||
if (state === "running" || state === "waiting") {
|
||||
return reply.code(200).send({ id, alreadyActive: true });
|
||||
return reply.code(200).send({ id, alreadyActive: true, workspaceId: saved.workspace_id });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -639,7 +799,12 @@ export function sessionRoutes(
|
||||
if (boundRuntimes.get(id) === current) boundRuntimes.delete(id);
|
||||
d.mgr.teardownIfCurrent(id, current);
|
||||
}
|
||||
runtimeOptions = optionsWithRuntimeConfig(runner, workspaceConfigPath, options);
|
||||
runtimeOptions = await optionsWithRuntimeConfig(
|
||||
runner,
|
||||
workspaceConfigPath,
|
||||
saved.workspace_id,
|
||||
options,
|
||||
);
|
||||
rt = d.mgr.createFor(id, runtimeOptions);
|
||||
bindRuntime(id, rt, runner, workspaceConfigPath);
|
||||
} catch {
|
||||
@@ -661,7 +826,7 @@ export function sessionRoutes(
|
||||
d.mgr.configure(rt, runtimeOptions), null,
|
||||
() => d.mgr.start(id, rt, runtimeOptions),
|
||||
);
|
||||
return reply.code(200).send({ id, alreadyActive: false });
|
||||
return reply.code(200).send({ id, alreadyActive: false, workspaceId: saved.workspace_id });
|
||||
});
|
||||
});
|
||||
app.post("/sessions/:id/close", async (req, reply) => {
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import type { AppConfig } from "../config.js";
|
||||
import type { Settings } from "../settings/settings-store.js";
|
||||
import { listWorkspaces, type ListModelsFn } from "./meta.js";
|
||||
import { listWorkspaces } from "./meta.js";
|
||||
import type { PrincipalContext } from "../auth/principal.js";
|
||||
import { isPrincipalContext, requirePermission } from "../auth/authorization.js";
|
||||
import {
|
||||
splitCanonicalModelId,
|
||||
type RuntimeModelCatalog,
|
||||
} from "../models/runtime-model-catalog.js";
|
||||
|
||||
/** Merge stored settings over env/first-workspace defaults. */
|
||||
export function effectiveSettings(cfg: AppConfig, stored: Settings): Settings {
|
||||
/** Merge only workspace and runtime-thinking preferences; model defaults belong to modelCatalog. */
|
||||
export function effectiveSettings(
|
||||
cfg: AppConfig,
|
||||
stored: Settings,
|
||||
modelCatalog?: RuntimeModelCatalog,
|
||||
): Settings {
|
||||
const workspaces = listWorkspaces(cfg.harnessDir);
|
||||
const selected = modelCatalog?.defaultInteraction
|
||||
? splitCanonicalModelId(modelCatalog.defaultInteraction)
|
||||
: undefined;
|
||||
return {
|
||||
workspace: stored.workspace ?? workspaces[0]?.name,
|
||||
provider: cfg.defaults.provider ?? stored.provider,
|
||||
model: cfg.defaults.model ?? stored.model,
|
||||
...(selected ?? {}),
|
||||
thinking: cfg.defaults.thinking ?? stored.thinking,
|
||||
};
|
||||
}
|
||||
@@ -19,7 +29,7 @@ export function effectiveSettings(cfg: AppConfig, stored: Settings): Settings {
|
||||
export function settingsRoutes(
|
||||
app: FastifyInstance,
|
||||
deps: {
|
||||
cfg: AppConfig; listModels: ListModelsFn;
|
||||
cfg: AppConfig;
|
||||
getSettings: (principal: PrincipalContext) => Promise<Settings>;
|
||||
},
|
||||
): void {
|
||||
@@ -37,22 +47,6 @@ export function settingsRoutes(
|
||||
const principal = requirePermission(req, reply, "settings.manage");
|
||||
if (!isPrincipalContext(principal)) return principal;
|
||||
const b = (req.body ?? {}) as Settings;
|
||||
if (b.model) {
|
||||
let available: { provider: string; id: string }[] = [];
|
||||
try {
|
||||
available = await deps.listModels();
|
||||
} catch {
|
||||
available = [];
|
||||
}
|
||||
// Only validate when Pi gave us a non-empty list; otherwise allow (degraded).
|
||||
if (available.length > 0 && !available.some(
|
||||
(candidate) => candidate.provider === b.provider && candidate.id === b.model,
|
||||
)) {
|
||||
return reply.code(400).send({
|
||||
error: `Unknown model: ${b.provider ?? "unknown"}/${b.model}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
try {
|
||||
// Retain this endpoint as a validating compatibility surface for older clients, but do
|
||||
// not write anonymous users' choices to shared server storage.
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { isPrincipalContext, requirePermission } from "../auth/authorization.js";
|
||||
import {
|
||||
CatalogUnavailableError,
|
||||
type CatalogRepository,
|
||||
type WorkspaceDatabase,
|
||||
} from "../catalog/types.js";
|
||||
import type { ThtRunner } from "../tht/tht-runner.js";
|
||||
import type { WorkspacePreprocessingService } from "../workspaces/preprocessing-service.js";
|
||||
import type { PreprocessingJobState } from "../workspaces/preprocessing-state.js";
|
||||
import type { WorkspaceRegistry } from "../workspaces/registry.js";
|
||||
|
||||
const workspaceIdSchema = z.string().regex(/^[a-z][a-z0-9-]{2,62}$/);
|
||||
const ACTIVE_SYNC_STATES = new Set(["queued", "running", "awaiting_confirmation", "applying"]);
|
||||
|
||||
export type WorkspacePreprocessingUiState =
|
||||
| "ready"
|
||||
| "required"
|
||||
| "running"
|
||||
| "blocked"
|
||||
| "failed";
|
||||
|
||||
export interface WorkspacePreprocessingStatus {
|
||||
schemaVersion: 1;
|
||||
workspaceId: string;
|
||||
state: WorkspacePreprocessingUiState;
|
||||
actionable: boolean;
|
||||
clearable: boolean;
|
||||
detail: string;
|
||||
reason?: string;
|
||||
nextStep?: string;
|
||||
metadataRevision?: number;
|
||||
preprocessedMetadataRevision?: number;
|
||||
startedAt?: string;
|
||||
finishedAt?: string;
|
||||
progress?: {
|
||||
stage: "catalog_snapshot" | "schema_index" | "evidence" | "finalizing";
|
||||
step: number;
|
||||
totalSteps: 4;
|
||||
};
|
||||
lastFailure?: {
|
||||
stage: string;
|
||||
errorCode: string;
|
||||
finishedAt: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface WorkspacePreprocessingRouteDeps {
|
||||
repository: CatalogRepository;
|
||||
registry: WorkspaceRegistry;
|
||||
service: Pick<WorkspacePreprocessingService, "run" | "clear">;
|
||||
inputFingerprint?: Pick<ThtRunner, "workspaceInputFingerprint">;
|
||||
readLatestJob?: (workspaceId: string) => PreprocessingJobState | undefined;
|
||||
}
|
||||
|
||||
const PROGRESS_DETAILS = {
|
||||
catalog_snapshot: "Preparing the PostgreSQL Catalog snapshot.",
|
||||
schema_index: "Building schema vectors and LSH indexes.",
|
||||
evidence: "Indexing Evidence.",
|
||||
finalizing: "Publishing the completed preprocessing state.",
|
||||
} as const;
|
||||
|
||||
function runningProgress(
|
||||
workspaceId: string,
|
||||
deps: WorkspacePreprocessingRouteDeps,
|
||||
): NonNullable<WorkspacePreprocessingStatus["progress"]> {
|
||||
let job: PreprocessingJobState | undefined;
|
||||
try {
|
||||
job = deps.readLatestJob?.(workspaceId);
|
||||
} catch {
|
||||
// Progress is supplemental. A damaged or temporarily unavailable checkpoint must not hide
|
||||
// the authoritative running state held by PostgreSQL.
|
||||
}
|
||||
const completed = new Set(job?.status === "active" ? job.completedStages : []);
|
||||
if (completed.has("evidence")) return { stage: "finalizing", step: 4, totalSteps: 4 };
|
||||
if (completed.has("schema_index")) return { stage: "evidence", step: 3, totalSteps: 4 };
|
||||
if (completed.has("catalog_snapshot")) return { stage: "schema_index", step: 2, totalSteps: 4 };
|
||||
return { stage: "catalog_snapshot", step: 1, totalSteps: 4 };
|
||||
}
|
||||
|
||||
function base(
|
||||
workspaceId: string,
|
||||
state: WorkspacePreprocessingUiState,
|
||||
detail: string,
|
||||
database?: WorkspaceDatabase,
|
||||
): WorkspacePreprocessingStatus {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
workspaceId,
|
||||
state,
|
||||
actionable: state === "ready" || state === "required" || state === "failed",
|
||||
clearable: Boolean(
|
||||
database
|
||||
&& state !== "running"
|
||||
&& database.preprocessingErrorCode !== "derived_data_cleared"
|
||||
),
|
||||
detail,
|
||||
...(database ? {
|
||||
metadataRevision: database.metadataContentRevision,
|
||||
...(database.preprocessedMetadataRevision === undefined
|
||||
? {}
|
||||
: { preprocessedMetadataRevision: database.preprocessedMetadataRevision }),
|
||||
...(database.preprocessingStartedAt ? { startedAt: database.preprocessingStartedAt } : {}),
|
||||
...(database.preprocessingFinishedAt ? { finishedAt: database.preprocessingFinishedAt } : {}),
|
||||
} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function blocked(
|
||||
workspaceId: string,
|
||||
detail: string,
|
||||
reason: string,
|
||||
nextStep: string,
|
||||
database?: WorkspaceDatabase,
|
||||
): WorkspacePreprocessingStatus {
|
||||
return { ...base(workspaceId, "blocked", detail, database), reason, nextStep };
|
||||
}
|
||||
|
||||
function failureDiagnostic(errorCode: string): {
|
||||
stage: string;
|
||||
detail: string;
|
||||
reason: string;
|
||||
nextStep: string;
|
||||
} {
|
||||
switch (errorCode) {
|
||||
case "catalog_snapshot_failed":
|
||||
return {
|
||||
stage: "catalog_snapshot",
|
||||
detail: "The Catalog snapshot could not be prepared.",
|
||||
reason: "PostgreSQL Catalog metadata could not be read into a consistent preprocessing snapshot.",
|
||||
nextStep: "Check Catalog availability, then retry preprocessing.",
|
||||
};
|
||||
case "schema_index_failed":
|
||||
return {
|
||||
stage: "schema_index",
|
||||
detail: "The schema index could not be rebuilt.",
|
||||
reason: "The schema indexing worker stopped before the Catalog snapshot was published to Qdrant.",
|
||||
nextStep: "Open Last run details below, check the core service log for this error code, then retry.",
|
||||
};
|
||||
case "evidence_preprocessing_failed":
|
||||
return {
|
||||
stage: "evidence",
|
||||
detail: "Evidence preprocessing did not complete.",
|
||||
reason: "The Evidence indexing worker stopped before it finished publishing the current workspace data.",
|
||||
nextStep: "Open Last run details below, check the core service log for this error code, then retry.",
|
||||
};
|
||||
case "semantic_index_incompatible":
|
||||
return {
|
||||
stage: "semantic_preflight",
|
||||
detail: "The semantic index configuration is incompatible.",
|
||||
reason: "Qdrant rejected the collection configuration for the active embedding model.",
|
||||
nextStep: "Check embedding dimensions and Qdrant collection settings, then retry.",
|
||||
};
|
||||
case "egress_policy_refused":
|
||||
return {
|
||||
stage: "evidence",
|
||||
detail: "The Evidence source was refused by policy.",
|
||||
reason: "The configured Evidence endpoint is not allowed by the installation egress policy.",
|
||||
nextStep: "Correct the Evidence source or its allowlist configuration, then retry.",
|
||||
};
|
||||
case "workspace_not_activatable":
|
||||
return {
|
||||
stage: "runtime_preflight",
|
||||
detail: "The workspace runtime could not be prepared.",
|
||||
reason: "The active workspace or one of its required runtime bindings is not usable.",
|
||||
nextStep: "Check Workspace management and Database management, then retry.",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
stage: "preprocessing",
|
||||
detail: "Preprocessing did not complete.",
|
||||
reason: "The preprocessing worker stopped before the current Catalog revision was published.",
|
||||
nextStep: "Open Last run details below, check the core service log for this error code, then retry.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function readWorkspacePreprocessingStatus(
|
||||
workspaceId: string,
|
||||
deps: WorkspacePreprocessingRouteDeps,
|
||||
): Promise<WorkspacePreprocessingStatus> {
|
||||
const database = await deps.repository.getByWorkspace(workspaceId);
|
||||
if (!database) {
|
||||
return blocked(
|
||||
workspaceId,
|
||||
"Database configuration is required.",
|
||||
"This workspace has no database configuration in the PostgreSQL Catalog.",
|
||||
"Open Database management and configure the workspace database.",
|
||||
);
|
||||
}
|
||||
|
||||
if (database.preprocessingStatus === "running") {
|
||||
const progress = runningProgress(workspaceId, deps);
|
||||
return {
|
||||
...base(workspaceId, "running", PROGRESS_DETAILS[progress.stage], database),
|
||||
progress,
|
||||
};
|
||||
}
|
||||
if (database.binding.transport === "ssh_tunnel") {
|
||||
return blocked(
|
||||
workspaceId,
|
||||
"The database transport is not supported by the core runtime.",
|
||||
"The current database binding uses an SSH tunnel, which cannot be used by a ThothII session.",
|
||||
"Open Database management and select a supported runtime transport.",
|
||||
database,
|
||||
);
|
||||
}
|
||||
if (database.schemaSyncedVersion !== database.version) {
|
||||
return blocked(
|
||||
workspaceId,
|
||||
"Catalog synchronization is required.",
|
||||
`Database configuration v${database.version} is newer than the latest Catalog synchronization${database.schemaSyncedVersion === undefined ? "." : ` v${database.schemaSyncedVersion}.`}`,
|
||||
"Open Database management and run Synchronize schema.",
|
||||
database,
|
||||
);
|
||||
}
|
||||
|
||||
const [syncRuns, activeDescriptionRun, sensitivityRuns] = await Promise.all([
|
||||
deps.repository.listSyncRuns(database.id, 10),
|
||||
deps.repository.getActiveDescriptionGenerationRun(),
|
||||
deps.repository.listSensitivityAnalysisRuns(50),
|
||||
]);
|
||||
if (syncRuns.some((run) => ACTIVE_SYNC_STATES.has(run.state))) {
|
||||
return blocked(
|
||||
workspaceId,
|
||||
"Catalog synchronization is in progress.",
|
||||
"The Catalog is being synchronized and its metadata revision is not stable yet.",
|
||||
"Wait for schema synchronization to finish, then run preprocessing.",
|
||||
database,
|
||||
);
|
||||
}
|
||||
if (activeDescriptionRun?.databaseId === database.id
|
||||
&& ["queued", "running"].includes(activeDescriptionRun.status)) {
|
||||
return blocked(
|
||||
workspaceId,
|
||||
"Description generation is in progress.",
|
||||
"Catalog descriptions are still being generated for this database.",
|
||||
"Wait for description generation to finish, then run preprocessing.",
|
||||
database,
|
||||
);
|
||||
}
|
||||
if (sensitivityRuns.some((run) => run.databaseId === database.id && run.status === "running")) {
|
||||
return blocked(
|
||||
workspaceId,
|
||||
"Sensitivity analysis is in progress.",
|
||||
"Catalog sensitivity metadata is still being analyzed for this database.",
|
||||
"Wait for sensitivity analysis to finish, then run preprocessing.",
|
||||
database,
|
||||
);
|
||||
}
|
||||
|
||||
let inputFingerprint: string | undefined;
|
||||
try {
|
||||
const record = await deps.registry.read(workspaceId);
|
||||
if (deps.inputFingerprint) {
|
||||
inputFingerprint = await deps.inputFingerprint.workspaceInputFingerprint(
|
||||
record.revision.snapshotPath,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
return blocked(
|
||||
workspaceId,
|
||||
"The workspace runtime configuration is unavailable.",
|
||||
"The active workspace revision or one of its required database secrets could not be resolved.",
|
||||
"Check Workspace management and Database management before running preprocessing.",
|
||||
database,
|
||||
);
|
||||
}
|
||||
|
||||
const current = database.preprocessingStatus === "succeeded"
|
||||
&& database.preprocessedMetadataRevision === database.metadataContentRevision
|
||||
&& (inputFingerprint === undefined
|
||||
|| database.preprocessingInputFingerprint === inputFingerprint);
|
||||
if (current) {
|
||||
return base(
|
||||
workspaceId,
|
||||
"ready",
|
||||
`Catalog revision ${database.metadataContentRevision} is indexed.`,
|
||||
database,
|
||||
);
|
||||
}
|
||||
|
||||
if (database.preprocessingErrorCode === "derived_data_cleared") {
|
||||
return base(
|
||||
workspaceId,
|
||||
"required",
|
||||
"Reference vectors and LSH are empty. Memory is preserved.",
|
||||
database,
|
||||
);
|
||||
}
|
||||
|
||||
if (database.preprocessingStatus === "failed"
|
||||
&& database.preprocessingErrorCode
|
||||
&& database.preprocessingErrorCode !== "catalog_changed"
|
||||
&& database.preprocessingErrorCode !== "derived_data_cleared"
|
||||
&& database.preprocessingFinishedAt) {
|
||||
const diagnostic = failureDiagnostic(database.preprocessingErrorCode);
|
||||
return {
|
||||
...base(workspaceId, "failed", diagnostic.detail, database),
|
||||
reason: diagnostic.reason,
|
||||
nextStep: diagnostic.nextStep,
|
||||
lastFailure: {
|
||||
stage: diagnostic.stage,
|
||||
errorCode: database.preprocessingErrorCode,
|
||||
finishedAt: database.preprocessingFinishedAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return base(
|
||||
workspaceId,
|
||||
"required",
|
||||
`Catalog revision ${database.metadataContentRevision} is not indexed.`,
|
||||
database,
|
||||
);
|
||||
}
|
||||
|
||||
function safeError(reply: FastifyReply, error: unknown) {
|
||||
if (error instanceof CatalogUnavailableError) {
|
||||
return reply.code(503).send({
|
||||
code: "catalog_unavailable",
|
||||
message: "Database catalog is unavailable.",
|
||||
});
|
||||
}
|
||||
if (error instanceof z.ZodError) {
|
||||
return reply.code(400).send({
|
||||
code: "preprocessing_request_invalid",
|
||||
message: "Preprocessing request is invalid.",
|
||||
});
|
||||
}
|
||||
return reply.code(500).send({
|
||||
code: "preprocessing_run_failed",
|
||||
message: "Preprocessing status could not be resolved.",
|
||||
});
|
||||
}
|
||||
|
||||
function workspaceIdFrom(request: FastifyRequest): string {
|
||||
return workspaceIdSchema.parse((request.params as { workspaceId?: unknown }).workspaceId);
|
||||
}
|
||||
|
||||
export function workspacePreprocessingRoutes(
|
||||
app: FastifyInstance,
|
||||
deps: WorkspacePreprocessingRouteDeps,
|
||||
): void {
|
||||
app.get("/workspaces/:workspaceId/preprocessing", async (request, reply) => {
|
||||
if (!isPrincipalContext(requirePermission(request, reply, "session.use"))) return reply;
|
||||
try {
|
||||
return await readWorkspacePreprocessingStatus(workspaceIdFrom(request), deps);
|
||||
} catch (error) {
|
||||
return safeError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/workspaces/:workspaceId/preprocessing", async (request, reply) => {
|
||||
if (!isPrincipalContext(requirePermission(request, reply, "database.manage"))) return reply;
|
||||
try {
|
||||
const workspaceId = workspaceIdFrom(request);
|
||||
const before = await readWorkspacePreprocessingStatus(workspaceId, deps);
|
||||
if (!before.actionable) {
|
||||
return reply.code(409).send({ ...before, code: "preprocessing_blocked" });
|
||||
}
|
||||
|
||||
const result = await deps.service.run({ workspaceId });
|
||||
const after = await readWorkspacePreprocessingStatus(workspaceId, deps);
|
||||
if (after.state === "ready") return after;
|
||||
if (after.state === "failed") {
|
||||
return reply.code(422).send({ ...after, code: "preprocessing_run_failed" });
|
||||
}
|
||||
if (after.state === "blocked" || after.state === "running") {
|
||||
return reply.code(409).send({ ...after, code: "preprocessing_blocked" });
|
||||
}
|
||||
return reply.code(500).send({
|
||||
code: "preprocessing_run_failed",
|
||||
message: `Preprocessing ended with ${result.code}.`,
|
||||
});
|
||||
} catch (error) {
|
||||
return safeError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.delete("/workspaces/:workspaceId/preprocessing", async (request, reply) => {
|
||||
if (!isPrincipalContext(requirePermission(request, reply, "database.manage"))) return reply;
|
||||
try {
|
||||
const workspaceId = workspaceIdFrom(request);
|
||||
const before = await readWorkspacePreprocessingStatus(workspaceId, deps);
|
||||
if (!before.clearable) {
|
||||
return reply.code(409).send({ ...before, code: "preprocessing_clear_blocked" });
|
||||
}
|
||||
const result = await deps.service.clear({ workspaceId });
|
||||
if (result.status !== "succeeded") {
|
||||
return reply.code(result.code === "preprocessing_conflict" ? 409 : 500).send({
|
||||
code: result.code,
|
||||
message: "Preprocessing data could not be cleared.",
|
||||
});
|
||||
}
|
||||
return await readWorkspacePreprocessingStatus(workspaceId, deps);
|
||||
} catch (error) {
|
||||
return safeError(reply, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -16,23 +16,33 @@ import {
|
||||
type WorkspaceDescriptor,
|
||||
} from "../workspaces/schema.js";
|
||||
import type { RuntimeBindings } from "../workspaces/runtime-renderer.js";
|
||||
import type { ConnectorDiagnostics } from "../workspaces/diagnostics.js";
|
||||
import type {
|
||||
ConnectorDiagnostics,
|
||||
Diagnostic,
|
||||
WorkspaceDiagnosticOptions,
|
||||
} from "../workspaces/diagnostics.js";
|
||||
import { isPrincipalContext, requirePermission } from "../auth/authorization.js";
|
||||
import type { AuthDiagnoser } from "../auth/diagnostics.js";
|
||||
import { decodeAuthDiagnostics, type AuthDiagnostics } from "../auth/group-catalog.js";
|
||||
import type { WorkspaceDatabase } from "../catalog/types.js";
|
||||
|
||||
export type WorkspaceDiagnoser = (
|
||||
workspace: WorkspaceDescriptor,
|
||||
bindings: RuntimeBindings,
|
||||
options: { writeProbe: boolean },
|
||||
options: WorkspaceDiagnosticOptions,
|
||||
) => Promise<ConnectorDiagnostics>;
|
||||
|
||||
export type WorkspaceDatabaseTester = (
|
||||
workspaceId: string,
|
||||
) => Promise<WorkspaceDatabase | undefined>;
|
||||
|
||||
interface WorkspaceRoutesDeps {
|
||||
registry: WorkspaceRegistry;
|
||||
config: WorkspaceRegistryConfig;
|
||||
diagnose: WorkspaceDiagnoser;
|
||||
authDiagnoser: AuthDiagnoser;
|
||||
secretStore: WorkspaceSecretStore;
|
||||
testDatabaseConnection: WorkspaceDatabaseTester;
|
||||
}
|
||||
|
||||
const workspaceId = z.string().regex(/^[a-z][a-z0-9-]{2,62}$/);
|
||||
@@ -56,6 +66,20 @@ const SAFE_MESSAGES = {
|
||||
semantic_index_incompatible: "Semantic index is incompatible with this workspace.",
|
||||
} as const;
|
||||
|
||||
const catalogConnectionUnavailable = (): Diagnostic => ({
|
||||
level: "error",
|
||||
code: "connector_unavailable",
|
||||
field: "dwh",
|
||||
message: "The configured database could not be reached or authenticated.",
|
||||
});
|
||||
|
||||
const catalogConnectionMissing = (): Diagnostic => ({
|
||||
level: "error",
|
||||
code: "binding_missing",
|
||||
field: "dwh",
|
||||
message: "Configure this workspace in Database Management before testing connections.",
|
||||
});
|
||||
|
||||
function authenticationReport(value: unknown): AuthDiagnostics {
|
||||
const report = decodeAuthDiagnostics(value);
|
||||
if (!report) throw new Error("invalid authentication diagnostic report");
|
||||
@@ -238,14 +262,33 @@ export function workspaceRoutes(app: FastifyInstance, deps: WorkspaceRoutesDeps)
|
||||
deps.secretStore,
|
||||
);
|
||||
try {
|
||||
const [workspaceDiagnostics, inspectedAuthentication] = await Promise.all([
|
||||
deps.diagnose(operational, lease.bindings, { writeProbe: false }),
|
||||
const [workspaceDiagnostics, testedDatabase, inspectedAuthentication] = await Promise.all([
|
||||
deps.diagnose(operational, lease.bindings, {
|
||||
writeProbe: false,
|
||||
skipDwh: true,
|
||||
}),
|
||||
deps.testDatabaseConnection(id),
|
||||
deps.authDiagnoser.inspect({ live: true }),
|
||||
]);
|
||||
const authentication = authenticationReport(inspectedAuthentication);
|
||||
const catalogConnectionReady = testedDatabase?.connectionStatus === "reachable";
|
||||
const catalogConnectionDiagnostic = !testedDatabase
|
||||
? catalogConnectionMissing()
|
||||
: catalogConnectionReady
|
||||
? undefined
|
||||
: catalogConnectionUnavailable();
|
||||
const diagnostics = catalogConnectionDiagnostic
|
||||
? [
|
||||
...workspaceDiagnostics.diagnostics.filter(({ code }) => code !== "binding_ok"),
|
||||
catalogConnectionDiagnostic,
|
||||
]
|
||||
: workspaceDiagnostics.diagnostics;
|
||||
return {
|
||||
...workspaceDiagnostics,
|
||||
activatable: workspaceDiagnostics.activatable && authentication.ready,
|
||||
activatable: workspaceDiagnostics.activatable
|
||||
&& catalogConnectionReady
|
||||
&& authentication.ready,
|
||||
diagnostics,
|
||||
authentication,
|
||||
};
|
||||
} finally {
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { AppConfig } from "../config.js";
|
||||
|
||||
export interface Settings {
|
||||
workspace?: string;
|
||||
/** Legacy input fields are ignored when loading/evaluating installation settings. */
|
||||
provider?: string;
|
||||
model?: string;
|
||||
thinking?: string;
|
||||
@@ -37,7 +38,13 @@ export function loadSettings(cfg: AppConfig): Settings {
|
||||
try {
|
||||
const raw = readFileSync(cfg.settingsFile, "utf8");
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === "object") return parsed as Settings;
|
||||
if (parsed && typeof parsed === "object") {
|
||||
const value = parsed as Record<string, unknown>;
|
||||
return {
|
||||
...(typeof value.workspace === "string" ? { workspace: value.workspace } : {}),
|
||||
...(typeof value.thinking === "string" ? { thinking: value.thinking } : {}),
|
||||
};
|
||||
}
|
||||
return {};
|
||||
} catch {
|
||||
return {};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user