@johpaz/hive-sdk 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +1 -1
- package/packages/core/src/agent/capability-search.ts +49 -10
- package/packages/core/src/agent/context-compiler.ts +2 -2
- package/packages/core/src/agent/conversation-store.ts +3 -3
- package/packages/core/src/agent/reflector.ts +2 -2
- package/packages/core/src/services/models.ts +9 -3
- package/packages/core/src/storage/bootstrap.ts +27 -1
- package/packages/core/src/storage/causal-events.ts +30 -0
- package/packages/core/src/storage/hive.ts +15 -1
- package/packages/core/src/storage/index.ts +16 -0
- package/packages/core/src/storage/reconcile.ts +16 -8
- package/packages/core/src/storage/tenant.ts +147 -0
package/README.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@johpaz/hive-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Hive SDK — The Agent Harness SDK. Build, deploy, and scale AI agent applications with multi-channel support, context engineering, and swarm orchestration.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
|
|
21
21
|
import type { IndexDoc } from "@johpaz/hive-db";
|
|
22
22
|
import { getHiveDb } from "../storage/hivedb.ts";
|
|
23
|
+
import { currentTenant, qualifyDocId, unqualifyDocId, scopedFilterValue } from "../storage/tenant.ts";
|
|
23
24
|
import { logger } from "../utils/logger.ts";
|
|
24
25
|
|
|
25
26
|
const log = logger.child("capability-search");
|
|
@@ -87,12 +88,19 @@ export async function searchCapabilities(
|
|
|
87
88
|
|
|
88
89
|
// Filters are AND-ed by the engine, so multi-type search runs one query per
|
|
89
90
|
// type; the single-type and all-types cases are one engine call.
|
|
91
|
+
//
|
|
92
|
+
// El índice semántico es UNO solo para todos los inquilinos (no hay
|
|
93
|
+
// "colección" que prefijar), así que el tenant entra como un filtro más que
|
|
94
|
+
// el motor AND-ea con el resto. Sin tenant no se añade nada: así un índice ya
|
|
95
|
+
// construido por la app de escritorio sigue respondiendo igual.
|
|
96
|
+
const tenant = currentTenant();
|
|
97
|
+
const tenantFilter = tenant ? [{ field: "tenant", value: tenant }] : [];
|
|
90
98
|
const queries = types
|
|
91
99
|
? types.map((t) => ({
|
|
92
100
|
type: t,
|
|
93
|
-
filters: [{ field: "type", value: t }],
|
|
101
|
+
filters: [...tenantFilter, { field: "type", value: t }],
|
|
94
102
|
}))
|
|
95
|
-
: [{ type: undefined, filters: undefined }];
|
|
103
|
+
: [{ type: undefined, filters: tenantFilter.length ? tenantFilter : undefined }];
|
|
96
104
|
|
|
97
105
|
const merged = new Map<string, CapabilityHit>();
|
|
98
106
|
for (const q of queries) {
|
|
@@ -103,12 +111,15 @@ export async function searchCapabilities(
|
|
|
103
111
|
boosts: opts.boosts,
|
|
104
112
|
});
|
|
105
113
|
for (const hit of hits) {
|
|
106
|
-
|
|
114
|
+
// El id indexado lleva el prefijo del tenant; quien consume esto espera
|
|
115
|
+
// el id canónico ("tool:web_search"), no la forma física.
|
|
116
|
+
const id = unqualifyDocId(hit.id);
|
|
117
|
+
const parsed = splitId(id);
|
|
107
118
|
if (!parsed) continue;
|
|
108
|
-
const existing = merged.get(
|
|
119
|
+
const existing = merged.get(id);
|
|
109
120
|
if (!existing || hit.score > existing.score) {
|
|
110
|
-
merged.set(
|
|
111
|
-
id
|
|
121
|
+
merged.set(id, {
|
|
122
|
+
id,
|
|
112
123
|
type: parsed.type,
|
|
113
124
|
rawId: parsed.rawId,
|
|
114
125
|
score: hit.score,
|
|
@@ -154,7 +165,15 @@ export async function replaceCapabilityDocs(
|
|
|
154
165
|
docs: CapabilityDoc[]
|
|
155
166
|
): Promise<void> {
|
|
156
167
|
const db = await getHiveDb();
|
|
157
|
-
|
|
168
|
+
// `deleteByFilter` acepta UN SOLO filtro, así que en una base compartida
|
|
169
|
+
// borrar por `type` se llevaría por delante los documentos de todos los
|
|
170
|
+
// inquilinos. El campo sintético `tenant__type` (ver scopedFilterValue)
|
|
171
|
+
// mantiene el borrado en un filtro y acotado a este tenant.
|
|
172
|
+
await db.deleteByFilter(
|
|
173
|
+
currentTenant()
|
|
174
|
+
? { field: "tenant__type", value: scopedFilterValue(type) }
|
|
175
|
+
: { field: "type", value: type }
|
|
176
|
+
);
|
|
158
177
|
if (docs.length === 0) return;
|
|
159
178
|
await db.upsertBatch(docs.map(toIndexDoc));
|
|
160
179
|
}
|
|
@@ -169,18 +188,38 @@ export async function upsertCapabilityDocs(docs: CapabilityDoc[]): Promise<void>
|
|
|
169
188
|
/** Delete every MCP doc belonging to a server (hot-reload/disconnect). */
|
|
170
189
|
export async function deleteCapabilitiesByServer(serverId: string): Promise<void> {
|
|
171
190
|
const db = await getHiveDb();
|
|
172
|
-
await db.deleteByFilter(
|
|
191
|
+
await db.deleteByFilter(
|
|
192
|
+
currentTenant()
|
|
193
|
+
? { field: "tenant__server_id", value: scopedFilterValue(serverId) }
|
|
194
|
+
: { field: "server_id", value: serverId }
|
|
195
|
+
);
|
|
173
196
|
}
|
|
174
197
|
|
|
175
198
|
function toIndexDoc(doc: CapabilityDoc): IndexDoc {
|
|
199
|
+
const extra = doc.extraFilters ?? [];
|
|
200
|
+
const tenant = currentTenant();
|
|
176
201
|
return {
|
|
177
|
-
id: `${doc.type}:${doc.rawId}
|
|
202
|
+
id: qualifyDocId(`${doc.type}:${doc.rawId}`),
|
|
178
203
|
name: doc.name,
|
|
179
204
|
body: doc.body,
|
|
180
205
|
tags: doc.tags,
|
|
181
206
|
filters: [
|
|
182
207
|
{ field: "type", value: doc.type },
|
|
183
|
-
...
|
|
208
|
+
...extra,
|
|
209
|
+
// Con tenant activo, cada filtro lleva además un gemelo `tenant__<campo>`.
|
|
210
|
+
// Es lo que permite que los borrados masivos —que sólo aceptan un
|
|
211
|
+
// filtro— sigan acotados a este inquilino. Sin tenant no se emite nada,
|
|
212
|
+
// así que un índice ya construido conserva exactamente su forma.
|
|
213
|
+
...(tenant
|
|
214
|
+
? [
|
|
215
|
+
{ field: "tenant", value: tenant },
|
|
216
|
+
{ field: "tenant__type", value: scopedFilterValue(doc.type) },
|
|
217
|
+
...extra.map((f) => ({
|
|
218
|
+
field: `tenant__${f.field}`,
|
|
219
|
+
value: scopedFilterValue(f.value),
|
|
220
|
+
})),
|
|
221
|
+
]
|
|
222
|
+
: []),
|
|
184
223
|
],
|
|
185
224
|
};
|
|
186
225
|
}
|
|
@@ -37,8 +37,8 @@ import { resolveUserId } from "../storage/onboarding.ts"
|
|
|
37
37
|
import { getMCPManager as getSingletonMCPManager } from "../mcp/singleton.ts"
|
|
38
38
|
import { syncMCPToolsToDB, syncMCPToolsToIndex } from "../mcp/tool-sync.ts"
|
|
39
39
|
import { getUserDate, getUserTime } from "../utils/date.ts"
|
|
40
|
-
import { loadConfig } from "../config/loader.ts"
|
|
41
40
|
import { getHiveDb } from "../storage/hivedb.ts"
|
|
41
|
+
import { causalReadsEnabled } from "../storage/causal-events.ts"
|
|
42
42
|
import { listCatalogAgents, renderAgentRoutingCatalog } from "./catalog-selector.ts"
|
|
43
43
|
import { expandToolAllowlist } from "./delegation-runtime.ts"
|
|
44
44
|
import { MINIMAL_TOOLS } from "./minimal-loadout.ts"
|
|
@@ -588,7 +588,7 @@ export async function compileContext(opts: {
|
|
|
588
588
|
// applies this turn (a real DB round-trip, not a per-turn cost) and
|
|
589
589
|
// there's a causal stream to build it from. episodicSimilarity is omitted:
|
|
590
590
|
// it requires embeddings hive doesn't generate anywhere yet.
|
|
591
|
-
if (summaryApplies && opts.causalStreamId &&
|
|
591
|
+
if (summaryApplies && opts.causalStreamId && causalReadsEnabled()) {
|
|
592
592
|
try {
|
|
593
593
|
const causalDb = await getHiveDb()
|
|
594
594
|
const objectiveSource = taskContext || userMessage
|
|
@@ -6,7 +6,6 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { col, nextId, bumpRollup } from "../storage/hive.ts"
|
|
9
|
-
import { getHiveDb } from "../storage/hivedb.ts"
|
|
10
9
|
import { logger } from "../utils/logger.ts"
|
|
11
10
|
import type { LLMMessage, ContentPart } from "./llm-client.ts"
|
|
12
11
|
import { estimateTokens } from "../utils/toon.ts"
|
|
@@ -478,8 +477,9 @@ function scratchpadNoteId(threadId: string, key: string): string {
|
|
|
478
477
|
}
|
|
479
478
|
|
|
480
479
|
async function scratchpadCollection() {
|
|
481
|
-
|
|
482
|
-
|
|
480
|
+
// Vía `col()` y no `db.collection()` a pelo: es lo que aplica el prefijo de
|
|
481
|
+
// tenant y mantiene las notas de cada enjambre en su propia partición.
|
|
482
|
+
return col<ScratchpadDoc>("scratchpad")
|
|
483
483
|
}
|
|
484
484
|
|
|
485
485
|
export async function saveScratchpadNote(
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import { logger } from "../utils/logger.ts"
|
|
15
15
|
import { col, nextId } from "../storage/hive.ts"
|
|
16
16
|
import { getHiveDb } from "../storage/hivedb.ts"
|
|
17
|
-
import {
|
|
17
|
+
import { causalReadsEnabled } from "../storage/causal-events.ts"
|
|
18
18
|
import type { HiveDB, ToolStats } from "@johpaz/hive-db"
|
|
19
19
|
import type { TraceDoc, ReflectionDoc, CursorDoc } from "../storage/collections.ts"
|
|
20
20
|
import { parseThreadId } from "./thread-id.ts"
|
|
@@ -70,7 +70,7 @@ export async function runReflector(): Promise<void> {
|
|
|
70
70
|
// G9: when enabled, per-tool insights use whole-history stats from HiveDB's
|
|
71
71
|
// event log (toolStats) instead of counters built from just this batch, and
|
|
72
72
|
// causal threads add root-cause/learning-proposal insights on top.
|
|
73
|
-
const causalDb =
|
|
73
|
+
const causalDb = causalReadsEnabled() ? await getHiveDb() : null
|
|
74
74
|
const reflectionsCol = await col<ReflectionDoc>("reflections")
|
|
75
75
|
let totalInsights = 0
|
|
76
76
|
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { col, toIndexable, fromIndexable } from "../storage/hive.ts";
|
|
19
|
+
import { qualify } from "../storage/tenant.ts";
|
|
19
20
|
import { getHiveDb } from "../storage/hivedb.ts";
|
|
20
21
|
import type { BatchOp } from "@johpaz/hive-db";
|
|
21
22
|
import type { ModelDoc, AgentDoc } from "../storage/collections.ts";
|
|
@@ -161,12 +162,17 @@ export async function renameModel(id: string, nuevoNombre: string): Promise<Mode
|
|
|
161
162
|
const afectados = await agentesCol.findBy("model_id", toIndexable(id));
|
|
162
163
|
|
|
163
164
|
const db = await getHiveDb();
|
|
165
|
+
// `batch()` va contra el handle crudo de la base, así que no pasa por `col()`:
|
|
166
|
+
// los nombres de colección hay que resolverlos contra el tenant a mano o el
|
|
167
|
+
// rename escribiría en la partición de otro inquilino.
|
|
168
|
+
const MODELS = qualify("models");
|
|
169
|
+
const AGENTS = qualify("agents");
|
|
164
170
|
const ops: BatchOp[] = [
|
|
165
|
-
{ op: "put", collection:
|
|
166
|
-
...(nuevoId !== id ? [{ op: "delete" as const, collection:
|
|
171
|
+
{ op: "put", collection: MODELS, id: nuevoId, doc },
|
|
172
|
+
...(nuevoId !== id ? [{ op: "delete" as const, collection: MODELS, id }] : []),
|
|
167
173
|
...afectados.map((a) => ({
|
|
168
174
|
op: "put" as const,
|
|
169
|
-
collection:
|
|
175
|
+
collection: AGENTS,
|
|
170
176
|
id: a.id,
|
|
171
177
|
doc: { ...a.doc, model_id: toIndexable(nuevoId), updated_at: Date.now() },
|
|
172
178
|
expectedVersion: a.version,
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import { getHiveDb, getOpenHiveDb } from "./hivedb.ts";
|
|
15
15
|
import { col } from "./hive.ts";
|
|
16
|
+
import { currentTenant } from "./tenant.ts";
|
|
16
17
|
import { seedAllData, type SeedOptions } from "./seed.ts";
|
|
17
18
|
import { ensureSecretsBackend } from "./crypto.ts";
|
|
18
19
|
import { ensureLegacyThread } from "../agent/thread-store.ts";
|
|
@@ -136,6 +137,16 @@ async function ensureSeedData(opts?: SeedOptions): Promise<void> {
|
|
|
136
137
|
// tests or long-running processes open a fresh instance in the same runtime.
|
|
137
138
|
let bootstrappedDb: Awaited<ReturnType<typeof getHiveDb>> | null = null;
|
|
138
139
|
|
|
140
|
+
// ...y, dentro de esa instancia, a un tenant concreto. Una sola base compartida
|
|
141
|
+
// por muchos enjambres significa que "ya arrancó" no es una propiedad de la
|
|
142
|
+
// base: el enjambre A puede tener sus 72 índices creados y el B ninguno.
|
|
143
|
+
const bootstrappedTenants = new Set<string>();
|
|
144
|
+
|
|
145
|
+
/** Clave de bootstrap del tenant activo ("_" en modo local/escritorio). */
|
|
146
|
+
function bootstrapScope(): string {
|
|
147
|
+
return currentTenant() ?? "_";
|
|
148
|
+
}
|
|
149
|
+
|
|
139
150
|
/**
|
|
140
151
|
* Antes de la separación por canal todos los canales compartían un solo hilo cuyo
|
|
141
152
|
* `thread_id` era el `userId`. Esa conversación se registra —sin mover un solo
|
|
@@ -223,6 +234,16 @@ async function ensureLegacyThreads(): Promise<void> {
|
|
|
223
234
|
*/
|
|
224
235
|
export async function ensureHiveDb(opts?: SeedOptions): Promise<void> {
|
|
225
236
|
const db = await getHiveDb();
|
|
237
|
+
if (db !== bootstrappedDb) bootstrappedTenants.clear();
|
|
238
|
+
const scope = bootstrapScope();
|
|
239
|
+
|
|
240
|
+
// Con varios inquilinos en la misma base, esto se llama una vez por enjambre
|
|
241
|
+
// y por proceso, no una vez por proceso: repetir los ~72 `createIndex` más el
|
|
242
|
+
// seed en cada llamada es exactamente el coste que la consolidación viene a
|
|
243
|
+
// quitar. En modo local (sin tenant) no se cortocircuita nada, para conservar
|
|
244
|
+
// el "reseed en cada arranque" del que dependen los cambios de catálogo.
|
|
245
|
+
if (currentTenant() && bootstrappedDb === db && bootstrappedTenants.has(scope)) return;
|
|
246
|
+
|
|
226
247
|
await ensureIndexes();
|
|
227
248
|
// Mint the master key before anything can save an API key, so a fresh
|
|
228
249
|
// install is durable from the first keystroke rather than after a restart
|
|
@@ -239,8 +260,13 @@ export async function ensureHiveDb(opts?: SeedOptions): Promise<void> {
|
|
|
239
260
|
if (!existing) await meta.put("schemaVersion", { value: 1 }, { expectedVersion: 0 });
|
|
240
261
|
|
|
241
262
|
bootstrappedDb = db;
|
|
263
|
+
bootstrappedTenants.add(scope);
|
|
242
264
|
}
|
|
243
265
|
|
|
244
266
|
export function isBootstrapped(): boolean {
|
|
245
|
-
return
|
|
267
|
+
return (
|
|
268
|
+
bootstrappedDb !== null &&
|
|
269
|
+
bootstrappedDb === getOpenHiveDb() &&
|
|
270
|
+
bootstrappedTenants.has(bootstrapScope())
|
|
271
|
+
);
|
|
246
272
|
}
|
|
@@ -7,10 +7,30 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { getHiveDb } from "./hivedb.ts"
|
|
10
|
+
import { currentTenant } from "./tenant.ts"
|
|
11
|
+
import { loadConfig } from "../config/loader.ts"
|
|
10
12
|
import type { Event, EventPattern } from "@johpaz/hive-db"
|
|
11
13
|
|
|
12
14
|
export type { Event as CausalEvent, EventPattern as CausalEventPattern }
|
|
13
15
|
|
|
16
|
+
/**
|
|
17
|
+
* ¿Se pueden hacer lecturas AGREGADAS del log causal en este contexto?
|
|
18
|
+
*
|
|
19
|
+
* `causalThread`, `buildAgentContext` y `toolStats` recorren todos los shards
|
|
20
|
+
* de la base y mezclan sus resultados (`log.rs` — `read_stream_all_agents` y
|
|
21
|
+
* `project::<P>()` para proyecciones con scope Agent). Sobre una base
|
|
22
|
+
* compartida eso devolvería hilos y estadísticas de otros inquilinos. Mientras
|
|
23
|
+
* el motor no exponga las variantes acotadas por agente, con un tenant activo
|
|
24
|
+
* estas lecturas se apagan: perder una señal de reflexión es aceptable, cruzar
|
|
25
|
+
* datos entre agencias no.
|
|
26
|
+
*
|
|
27
|
+
* La ESCRITURA (`append`) no entra aquí: va al shard del propio agente, y los
|
|
28
|
+
* agentes de Hive Cloud ya llevan el enjambre en el id.
|
|
29
|
+
*/
|
|
30
|
+
export function causalReadsEnabled(): boolean {
|
|
31
|
+
return !!loadConfig().causalLog?.enabled && !currentTenant()
|
|
32
|
+
}
|
|
33
|
+
|
|
14
34
|
/**
|
|
15
35
|
* Live-tail causal events matching `pattern`. Forward-only: only events
|
|
16
36
|
* appended AFTER this call resolves are delivered. hive-db's subscribe()/
|
|
@@ -32,6 +52,16 @@ export type { Event as CausalEvent, EventPattern as CausalEventPattern }
|
|
|
32
52
|
export async function watchCausalEvents(
|
|
33
53
|
pattern: EventPattern
|
|
34
54
|
): Promise<AsyncIterable<Event> & { close(): void }> {
|
|
55
|
+
// El log de eventos no tiene colecciones que prefijar: el aislamiento entre
|
|
56
|
+
// inquilinos lo da el shard por `agentId`. Un patrón sin `agentId` sobre una
|
|
57
|
+
// base compartida entregaría los eventos de todos los enjambres, así que se
|
|
58
|
+
// exige explícitamente en vez de filtrar a medias.
|
|
59
|
+
if (currentTenant() && !pattern.agentId) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
"watchCausalEvents: con un tenant activo el patrón debe fijar agentId; " +
|
|
62
|
+
"un tail sin agente cruzaría eventos de otros inquilinos."
|
|
63
|
+
)
|
|
64
|
+
}
|
|
35
65
|
const db = await getHiveDb()
|
|
36
66
|
return db.events(pattern)
|
|
37
67
|
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { getHiveDb } from "./hivedb.ts";
|
|
11
|
+
import { qualify } from "./tenant.ts";
|
|
11
12
|
|
|
12
13
|
const MAX_RETRIES = 5;
|
|
13
14
|
|
|
@@ -32,9 +33,22 @@ export function fromIndexable(value: string | null | undefined): string | null {
|
|
|
32
33
|
return value === NO_PARENT || value == null ? null : value;
|
|
33
34
|
}
|
|
34
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Handle a una colección, resuelta contra el tenant activo.
|
|
38
|
+
*
|
|
39
|
+
* `qualify()` es lo que aísla a los inquilinos entre sí en una base compartida
|
|
40
|
+
* (ver storage/tenant.ts). Sin tenant en scope devuelve el nombre pelado, así
|
|
41
|
+
* que el modo local se comporta igual que siempre. Como todo el CRUD del SDK
|
|
42
|
+
* pasa por aquí —incluidos `nextId`, `updateDoc`, `updateManyByIndex`,
|
|
43
|
+
* `findByAny` y `bumpRollup`, más abajo— este es el único punto que hay que
|
|
44
|
+
* mantener honesto.
|
|
45
|
+
*
|
|
46
|
+
* El handle se construye en cada llamada a propósito: cachearlo en una variable
|
|
47
|
+
* de módulo lo dejaría atado al tenant que lo creó primero.
|
|
48
|
+
*/
|
|
35
49
|
export async function col<T>(name: string) {
|
|
36
50
|
const db = await getHiveDb();
|
|
37
|
-
return db.collection<T>(name);
|
|
51
|
+
return db.collection<T>(qualify(name));
|
|
38
52
|
}
|
|
39
53
|
|
|
40
54
|
/**
|
|
@@ -11,6 +11,22 @@
|
|
|
11
11
|
export { getHiveDbPath, getHiveDb, closeHiveDb } from "./hivedb.ts";
|
|
12
12
|
export { ensureHiveDb, isBootstrapped } from "./bootstrap.ts";
|
|
13
13
|
|
|
14
|
+
// ─── Aislamiento multi-inquilino ─────────────────────────────────────────────
|
|
15
|
+
// Varios enjambres dentro de una sola HiveDB, prefijando el nombre de colección.
|
|
16
|
+
// Sin tenant en scope todo se comporta como siempre — ver storage/tenant.ts.
|
|
17
|
+
export {
|
|
18
|
+
runInTenant,
|
|
19
|
+
currentTenant,
|
|
20
|
+
requireTenant,
|
|
21
|
+
tenantKeyFromId,
|
|
22
|
+
isTenantKey,
|
|
23
|
+
qualify,
|
|
24
|
+
unqualify,
|
|
25
|
+
qualifyDocId,
|
|
26
|
+
unqualifyDocId,
|
|
27
|
+
scopedFilterValue,
|
|
28
|
+
} from "./tenant.ts";
|
|
29
|
+
|
|
14
30
|
// ─── Acceso a colecciones ────────────────────────────────────────────────────
|
|
15
31
|
export {
|
|
16
32
|
col,
|
|
@@ -85,13 +85,19 @@ export async function reconcileOnBoot(bootId: string): Promise<ReconcileResult>
|
|
|
85
85
|
log.warn(`[reconcileOnBoot] Failed to repair meetings: ${(err as Error).message}`);
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
// 3. agentRuns:
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
88
|
+
// 3. agentRuns: a "running" row is orphaned if it belongs to ANOTHER boot.
|
|
89
|
+
//
|
|
90
|
+
// Antes bastaba con mirar el estado: una base por enjambre más un proceso por
|
|
91
|
+
// turno garantizaban que al arrancar no podía haber ninguna corrida viva. Con
|
|
92
|
+
// la base compartida entre inquilinos esa premisa desaparece —este proceso
|
|
93
|
+
// atiende varios enjambres a la vez— y barrer por estado abortaría corridas
|
|
94
|
+
// realmente en vuelo. El `boot_id` de la fila es lo que distingue "muerta en
|
|
95
|
+
// un proceso anterior" de "viva aquí y ahora".
|
|
92
96
|
try {
|
|
93
97
|
const agentRunsCol = await col<AgentRunDoc>("agentRuns");
|
|
94
|
-
const runningRuns = await agentRunsCol.findBy("status", "running")
|
|
98
|
+
const runningRuns = (await agentRunsCol.findBy("status", "running")).filter(
|
|
99
|
+
(entry) => entry.doc.boot_id !== bootId
|
|
100
|
+
);
|
|
95
101
|
for (const entry of runningRuns) {
|
|
96
102
|
const run = entry.doc;
|
|
97
103
|
|
|
@@ -124,11 +130,13 @@ export async function reconcileOnBoot(bootId: string): Promise<ReconcileResult>
|
|
|
124
130
|
log.warn(`[reconcileOnBoot] Failed to repair agentRuns: ${(err as Error).message}`);
|
|
125
131
|
}
|
|
126
132
|
|
|
127
|
-
// 4. jobQueue:
|
|
128
|
-
//
|
|
133
|
+
// 4. jobQueue: mismo criterio que arriba — sólo las filas de otro boot están
|
|
134
|
+
// realmente huérfanas → reclaim (pending) o interrupt, ignorando el lease.
|
|
129
135
|
try {
|
|
130
136
|
const jobsCol = await col<JobDoc>("jobQueue");
|
|
131
|
-
const runningJobs = await jobsCol.findBy("status", "running")
|
|
137
|
+
const runningJobs = (await jobsCol.findBy("status", "running")).filter(
|
|
138
|
+
(entry) => entry.doc.boot_id !== bootId
|
|
139
|
+
);
|
|
132
140
|
for (const entry of runningJobs) {
|
|
133
141
|
const job = entry.doc;
|
|
134
142
|
const result_doc = await reclaimOrInterrupt(job.id, { force: true });
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contexto de tenant — aislamiento de varios enjambres dentro de UNA sola HiveDB.
|
|
3
|
+
*
|
|
4
|
+
* Hive Cloud creaba una base física por enjambre porque `getHiveDb()` es un
|
|
5
|
+
* singleton de proceso: para aislar dos enjambres había que aislar dos procesos.
|
|
6
|
+
* Aquí el aislamiento pasa a ser lógico, prefijando el nombre de la colección.
|
|
7
|
+
*
|
|
8
|
+
* El prefijo va en el NOMBRE DE COLECCIÓN y no en el id del documento a
|
|
9
|
+
* propósito. Las colecciones de HiveDB no son tablas separadas: son tres tablas
|
|
10
|
+
* redb con el nombre de colección como primera componente de una clave
|
|
11
|
+
* compuesta (`col_docs:(collection,id)`, `col_index_entries:(collection,field,
|
|
12
|
+
* value,id)`). Prefijar el nombre sale casi gratis y, sobre todo, aísla también
|
|
13
|
+
* los índices secundarios — que es justo lo que un prefijo en el id NO consigue,
|
|
14
|
+
* porque `findBy(field, value)` recorre el índice de la colección entera y
|
|
15
|
+
* devolvería filas de otros tenants.
|
|
16
|
+
*
|
|
17
|
+
* Sin tenant en scope `qualify()` es la identidad, así que la app de escritorio
|
|
18
|
+
* y los tests siguen viendo exactamente los mismos nombres de colección que
|
|
19
|
+
* antes. `HIVE_TENANT_REQUIRED=1` invierte esa tolerancia para los despliegues
|
|
20
|
+
* multi-inquilino: una ruta que se olvide de entrar al contexto falla ruidosa en
|
|
21
|
+
* la primera colección que toque, en vez de escribir en silencio fuera de su
|
|
22
|
+
* partición.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
26
|
+
|
|
27
|
+
interface TenantContext {
|
|
28
|
+
key: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const storage = new AsyncLocalStorage<TenantContext>();
|
|
32
|
+
|
|
33
|
+
/** Separador entre el tenant y el nombre de colección. */
|
|
34
|
+
const SEP = "__";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* `t_` + un uuid sin guiones. Corta a propósito: la clave se repite en cada
|
|
38
|
+
* entrada de `col_docs` y de `col_index_entries`, así que cada carácter de más
|
|
39
|
+
* se paga por documento y por entrada de índice.
|
|
40
|
+
*/
|
|
41
|
+
const KEY_PATTERN = /^t_[a-z0-9]{8,48}$/;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Separador de los filtros escalares compuestos del índice BM25. U+001F (unit
|
|
45
|
+
* separator) porque el motor construye el término como `field<US>value` y ese
|
|
46
|
+
* byte no aparece nunca en un id ni en un tipo real.
|
|
47
|
+
*/
|
|
48
|
+
const FILTER_SEP = "\u001f";
|
|
49
|
+
|
|
50
|
+
/** Construye una tenant key válida a partir de un uuid (o cualquier id opaco). */
|
|
51
|
+
export function tenantKeyFromId(id: string): string {
|
|
52
|
+
const normalized = id.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
53
|
+
if (normalized.length < 8) {
|
|
54
|
+
throw new Error(`tenantKeyFromId: id demasiado corto para ser único: "${id}"`);
|
|
55
|
+
}
|
|
56
|
+
return `t_${normalized.slice(0, 48)}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** `true` si la cadena tiene forma de tenant key. */
|
|
60
|
+
export function isTenantKey(value: string): boolean {
|
|
61
|
+
return KEY_PATTERN.test(value);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Ejecuta `fn` con `key` como tenant activo. Todo lo que `fn` haga por debajo
|
|
66
|
+
* —incluidos los `await` encadenados— ve ese tenant; dos llamadas concurrentes
|
|
67
|
+
* no se pisan porque cada una corre en su propio contexto de AsyncLocalStorage.
|
|
68
|
+
*
|
|
69
|
+
* Ojo: el contexto NO cruza a un `Worker`. Ningún código que corra dentro de un
|
|
70
|
+
* worker puede tocar HiveDB esperando estar aislado.
|
|
71
|
+
*/
|
|
72
|
+
export function runInTenant<T>(key: string, fn: () => T): T {
|
|
73
|
+
if (!isTenantKey(key)) {
|
|
74
|
+
throw new Error(`runInTenant: tenant key inválida "${key}" (esperado ${KEY_PATTERN})`);
|
|
75
|
+
}
|
|
76
|
+
return storage.run({ key }, fn);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Tenant activo, o `null` si no hay ninguno (modo local/escritorio). */
|
|
80
|
+
export function currentTenant(): string | null {
|
|
81
|
+
return storage.getStore()?.key ?? null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Tenant activo; lanza si no hay. Para código que sólo tiene sentido aislado. */
|
|
85
|
+
export function requireTenant(context: string): string {
|
|
86
|
+
const key = currentTenant();
|
|
87
|
+
if (!key) {
|
|
88
|
+
throw new Error(`${context} requiere un tenant activo (envolver en runInTenant)`);
|
|
89
|
+
}
|
|
90
|
+
return key;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Nombre físico de una colección para el tenant activo.
|
|
95
|
+
*
|
|
96
|
+
* Es el único punto por el que pasa el aislamiento documental: `col()` lo llama
|
|
97
|
+
* y con eso quedan cubiertos los ~300 sitios que hacen CRUD, más `nextId`,
|
|
98
|
+
* `updateDoc`, `updateManyByIndex`, `findByAny` y `bumpRollup`.
|
|
99
|
+
*/
|
|
100
|
+
export function qualify(collection: string): string {
|
|
101
|
+
const key = currentTenant();
|
|
102
|
+
if (key) return `${key}${SEP}${collection}`;
|
|
103
|
+
if (process.env.HIVE_TENANT_REQUIRED === "1") {
|
|
104
|
+
throw new Error(
|
|
105
|
+
`qualify("${collection}"): HIVE_TENANT_REQUIRED=1 y no hay tenant en scope. ` +
|
|
106
|
+
`Falta envolver esta ruta en runInTenant().`
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
return collection;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Quita el prefijo de tenant de un nombre de colección físico. Devuelve el
|
|
114
|
+
* nombre tal cual si no lo lleva.
|
|
115
|
+
*/
|
|
116
|
+
export function unqualify(collection: string): string {
|
|
117
|
+
const match = /^t_[a-z0-9]{8,48}__(.+)$/.exec(collection);
|
|
118
|
+
return match ? match[1] : collection;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Id de documento para el índice semántico (BM25), que es UNO solo compartido
|
|
123
|
+
* por todos los tenants: ahí el aislamiento va en el id y en los filtros, no en
|
|
124
|
+
* un nombre de colección.
|
|
125
|
+
*/
|
|
126
|
+
export function qualifyDocId(id: string): string {
|
|
127
|
+
const key = currentTenant();
|
|
128
|
+
return key ? `${key}:${id}` : id;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Inversa de {@link qualifyDocId}, para devolver ids limpios en los hits. */
|
|
132
|
+
export function unqualifyDocId(id: string): string {
|
|
133
|
+
const match = /^t_[a-z0-9]{8,48}:(.+)$/.exec(id);
|
|
134
|
+
return match ? match[1] : id;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Valor de un filtro escalar compuesto para el índice BM25.
|
|
139
|
+
*
|
|
140
|
+
* Hace falta porque `deleteByFilter` acepta UN SOLO filtro: un borrado masivo
|
|
141
|
+
* por `type` en una base compartida se llevaría por delante los documentos de
|
|
142
|
+
* todos los tenants. Con un campo sintético (`tenant__type`) el borrado vuelve a
|
|
143
|
+
* ser de un solo filtro y sigue estando acotado al tenant.
|
|
144
|
+
*/
|
|
145
|
+
export function scopedFilterValue(...parts: string[]): string {
|
|
146
|
+
return [currentTenant() ?? "_", ...parts].join(FILTER_SEP);
|
|
147
|
+
}
|