@opengeni/db 0.10.7 → 0.12.1

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.
Files changed (35) hide show
  1. package/dist/{chunk-P6PKXY5W.js → chunk-VUKRIBO5.js} +485 -12
  2. package/dist/chunk-VUKRIBO5.js.map +1 -0
  3. package/dist/{chunk-KW526IJA.js → chunk-Y5WZZVQK.js} +80 -4
  4. package/dist/chunk-Y5WZZVQK.js.map +1 -0
  5. package/dist/index.d.ts +3 -2
  6. package/dist/index.js +5114 -2085
  7. package/dist/index.js.map +1 -1
  8. package/dist/migrate.d.ts +6 -3
  9. package/dist/migrate.js +1 -1
  10. package/dist/provision-roles.d.ts +720 -63
  11. package/dist/{schema-CqkzrBRS.d.ts → schema-BejThLcd.d.ts} +2965 -1194
  12. package/dist/schema.d.ts +1 -1
  13. package/dist/schema.js +17 -1
  14. package/drizzle/0109_nested_agent_depth_expand.sql +42 -0
  15. package/drizzle/0110_nested_agent_depth_boundary.sql +480 -0
  16. package/drizzle/0111_nested_agent_depth_backfill.sql +49 -0
  17. package/drizzle/0112_nested_agent_depth_contract.sql +38 -0
  18. package/drizzle/0113_nested_agent_depth_validate.sql +13 -0
  19. package/drizzle/0114_nested_agent_depth_contract.sql +49 -0
  20. package/drizzle/0115_nested_agent_depth_validate.sql +11 -0
  21. package/drizzle/0116_nested_agent_depth_index.sql +4 -0
  22. package/drizzle/0117_sandbox_recovery_generations.sql +699 -0
  23. package/drizzle/0118_new_session_drafts.sql +59 -0
  24. package/drizzle/0119_pending_tool_output_policy.sql +5 -0
  25. package/drizzle/0120_durable_goal_wake.sql +360 -0
  26. package/drizzle/0121_goal_update_idempotency.sql +11 -0
  27. package/package.json +3 -3
  28. package/src/index.ts +5961 -1240
  29. package/src/migrate.ts +131 -2
  30. package/src/new-session-drafts.ts +144 -0
  31. package/src/schema.ts +519 -15
  32. package/src/session-control.ts +42 -18
  33. package/src/session-tool-call-settlement.ts +6 -1
  34. package/dist/chunk-KW526IJA.js.map +0 -1
  35. package/dist/chunk-P6PKXY5W.js.map +0 -1
package/src/migrate.ts CHANGED
@@ -4,6 +4,10 @@ import { fileURLToPath } from "node:url";
4
4
  import postgres from "postgres";
5
5
 
6
6
  const DEFAULT_DATABASE_URL = "postgres://opengeni:opengeni@127.0.0.1:5432/opengeni";
7
+ const DEFAULT_MAX_NESTED_AGENT_DEPTH = 3;
8
+ const MAX_NESTED_AGENT_DEPTH = 2_147_483_647;
9
+ const batchedBackfillDirective =
10
+ /^-- opengeni:batched-backfill batch-size=(\d+) lock-timeout=(\d+(?:ms|s|min)) statement-timeout=(\d+(?:ms|s|min))$/;
7
11
  const concurrentIndexDirective = /^-- opengeni:concurrent-index lock-timeout=(\d+(?:ms|s|min))$/;
8
12
  const concurrentIndexStatement =
9
13
  /^CREATE\s+(?:UNIQUE\s+)?INDEX\s+CONCURRENTLY\s+(?:(IF\s+NOT\s+EXISTS)\s+)?(?:"((?:[^"]|"")+)"|([A-Za-z_][A-Za-z0-9_]*))\s+ON\b/is;
@@ -24,6 +28,15 @@ export interface ConcurrentIndexMigration {
24
28
  statement: string;
25
29
  }
26
30
 
31
+ export type MigrationRuntimeOptions = {
32
+ maxNestedAgentDepth?: number;
33
+ };
34
+
35
+ type DeploymentDepthPolicy = {
36
+ maxNestedAgentDepth: number;
37
+ source: "deployment" | "default";
38
+ };
39
+
27
40
  /** A bare Postgres identifier (schema/role name) safe to interpolate into DDL. */
28
41
  function assertIdentifier(name: string, value: string): string {
29
42
  if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) {
@@ -95,11 +108,69 @@ export function parseConcurrentIndexMigration(
95
108
  };
96
109
  }
97
110
 
111
+ function parseBatchedBackfillMigration(
112
+ file: string,
113
+ sqlText: string,
114
+ ): { batchSize: number; lockTimeout: string; statementTimeout: string; statement: string } | null {
115
+ const lines = sqlText.replaceAll("\r\n", "\n").split("\n");
116
+ const directiveIndex = /^-- deployment-mode: (?:rolling|maintenance)$/.test(
117
+ lines[0]?.trim() ?? "",
118
+ )
119
+ ? 1
120
+ : 0;
121
+ const directive = batchedBackfillDirective.exec(lines[directiveIndex]?.trim() ?? "");
122
+ if (!directive) return null;
123
+ const statement = lines
124
+ .slice(directiveIndex + 1)
125
+ .filter((line) => !line.trim().startsWith("--"))
126
+ .join("\n")
127
+ .trim();
128
+ const withoutTrailingSemicolon = statement.endsWith(";")
129
+ ? statement.slice(0, -1).trimEnd()
130
+ : statement;
131
+ const batchSize = Number(directive[1]!);
132
+ if (
133
+ !Number.isSafeInteger(batchSize) ||
134
+ batchSize < 1 ||
135
+ batchSize > 10_000 ||
136
+ !/^WITH\b/is.test(withoutTrailingSemicolon) ||
137
+ !/\bUPDATE\b/is.test(withoutTrailingSemicolon) ||
138
+ !/\bRETURNING\b/is.test(withoutTrailingSemicolon) ||
139
+ !new RegExp(`\\bLIMIT\\s+${batchSize}\\b`, "i").test(withoutTrailingSemicolon) ||
140
+ withoutTrailingSemicolon.includes(";")
141
+ ) {
142
+ throw new Error(
143
+ `${file}: opengeni:batched-backfill requires one bounded WITH ... UPDATE ... RETURNING statement whose LIMIT matches batch-size`,
144
+ );
145
+ }
146
+ return {
147
+ batchSize,
148
+ lockTimeout: directive[2]!,
149
+ statementTimeout: directive[3]!,
150
+ statement,
151
+ };
152
+ }
153
+
98
154
  async function executeMigrationFile(
99
155
  sql: postgres.Sql,
100
156
  file: string,
101
157
  sqlText: string,
102
158
  ): Promise<void> {
159
+ const batchedBackfill = parseBatchedBackfillMigration(file, sqlText);
160
+ if (batchedBackfill) {
161
+ await sql`select set_config('lock_timeout', ${batchedBackfill.lockTimeout}, false)`;
162
+ await sql`select set_config('statement_timeout', ${batchedBackfill.statementTimeout}, false)`;
163
+ try {
164
+ for (;;) {
165
+ const result = await sql.unsafe(batchedBackfill.statement);
166
+ if (result.length === 0) break;
167
+ }
168
+ } finally {
169
+ await sql`select set_config('statement_timeout', '0', false)`;
170
+ await sql`select set_config('lock_timeout', '0', false)`;
171
+ }
172
+ return;
173
+ }
103
174
  const concurrentIndex = parseConcurrentIndexMigration(file, sqlText);
104
175
  if (!concurrentIndex) {
105
176
  await sql.unsafe(sqlText);
@@ -127,6 +198,53 @@ async function executeMigrationFile(
127
198
  }
128
199
  }
129
200
 
201
+ function deploymentDepthPolicy(
202
+ options: MigrationRuntimeOptions | undefined,
203
+ ): DeploymentDepthPolicy {
204
+ const raw =
205
+ options === undefined
206
+ ? process.env.OPENGENI_MAX_NESTED_AGENT_DEPTH?.trim() || undefined
207
+ : options.maxNestedAgentDepth;
208
+ if (raw === undefined) {
209
+ return { maxNestedAgentDepth: DEFAULT_MAX_NESTED_AGENT_DEPTH, source: "default" };
210
+ }
211
+ const value = typeof raw === "number" ? raw : Number(raw);
212
+ if (
213
+ !Number.isSafeInteger(value) ||
214
+ value < 0 ||
215
+ value > MAX_NESTED_AGENT_DEPTH ||
216
+ (typeof raw === "string" && !/^(0|[1-9][0-9]*)$/.test(raw))
217
+ ) {
218
+ throw new Error(
219
+ `OPENGENI_MAX_NESTED_AGENT_DEPTH must be a non-negative 32-bit integer: ${raw}`,
220
+ );
221
+ }
222
+ return { maxNestedAgentDepth: value, source: "deployment" };
223
+ }
224
+
225
+ async function persistDeploymentDepthPolicy(
226
+ sql: postgres.Sql,
227
+ policy: DeploymentDepthPolicy,
228
+ ): Promise<void> {
229
+ const [relation] = await sql<{ exists: boolean }[]>`
230
+ select to_regclass('nested_agent_depth_configuration') is not null as exists
231
+ `;
232
+ if (!relation?.exists) return;
233
+ await sql`
234
+ insert into "nested_agent_depth_configuration" (
235
+ "singleton", "max_nested_agent_depth", "policy_source", "updated_at"
236
+ ) values (true, ${policy.maxNestedAgentDepth}, ${policy.source}, now())
237
+ on conflict ("singleton") do update
238
+ set "max_nested_agent_depth" = excluded."max_nested_agent_depth",
239
+ "policy_source" = excluded."policy_source",
240
+ "updated_at" = now()
241
+ where "nested_agent_depth_configuration"."max_nested_agent_depth"
242
+ is distinct from excluded."max_nested_agent_depth"
243
+ or "nested_agent_depth_configuration"."policy_source"
244
+ is distinct from excluded."policy_source"
245
+ `;
246
+ }
247
+
130
248
  /**
131
249
  * Apply the OpenGeni SQL migration chain.
132
250
  *
@@ -160,9 +278,11 @@ export async function migrate(
160
278
  process.env.OPENGENI_DATABASE_URL ??
161
279
  DEFAULT_DATABASE_URL,
162
280
  schema: string | undefined = process.env.OPENGENI_DB_SCHEMA?.trim() || undefined,
281
+ runtimeOptions?: MigrationRuntimeOptions,
163
282
  ): Promise<void> {
164
283
  const migrationsDir = join(dirname(fileURLToPath(import.meta.url)), "../drizzle");
165
284
  const files = (await readdir(migrationsDir)).filter((file) => file.endsWith(".sql")).sort();
285
+ const depthPolicy = deploymentDepthPolicy(runtimeOptions);
166
286
  const sql = postgres(databaseUrl, { max: 1 });
167
287
  try {
168
288
  // Serialize concurrent migrate() runs; the session-level lock is released
@@ -177,6 +297,8 @@ export async function migrate(
177
297
  await sql.unsafe(`CREATE SCHEMA IF NOT EXISTS "opengeni_private"`);
178
298
  await sql.unsafe(`SET search_path = "${schema}", "opengeni_private", "public"`);
179
299
  }
300
+ await sql`select set_config('opengeni.max_nested_agent_depth', ${String(depthPolicy.maxNestedAgentDepth)}, false)`;
301
+ await sql`select set_config('opengeni.nested_agent_depth_policy_source', ${depthPolicy.source}, false)`;
180
302
  await sql.unsafe(
181
303
  `CREATE TABLE IF NOT EXISTS "schema_migrations" ("name" text PRIMARY KEY, "applied_at" timestamptz NOT NULL DEFAULT now())`,
182
304
  );
@@ -190,6 +312,9 @@ export async function migrate(
190
312
  await executeMigrationFile(sql, file, sqlText);
191
313
  await sql`INSERT INTO "schema_migrations" ("name") VALUES (${file}) ON CONFLICT DO NOTHING`;
192
314
  }
315
+ // Reconcile even when all migration names were already recorded. This is
316
+ // the only supported way to change deployment policy in a live database.
317
+ await persistDeploymentDepthPolicy(sql, depthPolicy);
193
318
  } finally {
194
319
  await sql.end();
195
320
  }
@@ -203,8 +328,12 @@ export async function migrate(
203
328
  * `targetSchema` undefined → `public` → standalone behavior. Thin wrapper over
204
329
  * `migrate` so there is one migration engine.
205
330
  */
206
- export async function runMigrations(adminConnection: string, targetSchema?: string): Promise<void> {
207
- await migrate(adminConnection, targetSchema);
331
+ export async function runMigrations(
332
+ adminConnection: string,
333
+ targetSchema?: string,
334
+ runtimeOptions?: MigrationRuntimeOptions,
335
+ ): Promise<void> {
336
+ await migrate(adminConnection, targetSchema, runtimeOptions);
208
337
  }
209
338
 
210
339
  if (import.meta.main) {
@@ -0,0 +1,144 @@
1
+ import type {
2
+ NewSessionDraftOptions,
3
+ ReasoningEffort,
4
+ ResourceRef,
5
+ ToolRef,
6
+ } from "@opengeni/contracts";
7
+ import { and, eq } from "drizzle-orm";
8
+ import type { Database } from "./index";
9
+ import * as schema from "./schema";
10
+
11
+ export type NewSessionDraftRow = typeof schema.newSessionDrafts.$inferSelect;
12
+
13
+ export class NewSessionDraftConflictError extends Error {
14
+ readonly name = "NewSessionDraftConflictError";
15
+
16
+ constructor(readonly currentRevision: number) {
17
+ super("New-session draft changed in another client");
18
+ }
19
+ }
20
+
21
+ export class NewSessionDraftAccessError extends Error {
22
+ readonly name = "NewSessionDraftAccessError";
23
+
24
+ constructor() {
25
+ super("New-session draft access changed");
26
+ }
27
+ }
28
+
29
+ export async function getNewSessionDraftInTransaction(
30
+ db: Database,
31
+ input: { workspaceId: string; subjectId: string; lock?: boolean },
32
+ ): Promise<NewSessionDraftRow | null> {
33
+ const query = db
34
+ .select()
35
+ .from(schema.newSessionDrafts)
36
+ .where(
37
+ and(
38
+ eq(schema.newSessionDrafts.workspaceId, input.workspaceId),
39
+ eq(schema.newSessionDrafts.subjectId, input.subjectId),
40
+ ),
41
+ )
42
+ .limit(1);
43
+ const rows = input.lock ? await query.for("update") : await query;
44
+ return rows[0] ?? null;
45
+ }
46
+
47
+ export async function saveNewSessionDraftInTransaction(
48
+ db: Database,
49
+ input: {
50
+ accountId: string;
51
+ workspaceId: string;
52
+ subjectId: string;
53
+ expectedRevision: number;
54
+ text: string;
55
+ resources: ResourceRef[];
56
+ tools: ToolRef[];
57
+ model: string;
58
+ reasoningEffort: ReasoningEffort;
59
+ options: NewSessionDraftOptions;
60
+ /** API-key and delegated service subjects have no workspace-membership row. */
61
+ requireWorkspaceMembership?: boolean;
62
+ },
63
+ ): Promise<NewSessionDraftRow> {
64
+ if (input.requireWorkspaceMembership !== false) {
65
+ // Serialize with removeWorkspaceMember(), which takes FOR UPDATE before it
66
+ // deletes private rows and the membership. A save that wins first commits
67
+ // before removal's cleanup; a removal that wins first leaves no membership
68
+ // for a stale, already-authorized request to recreate after re-invitation.
69
+ const [membership] = await db
70
+ .select({ id: schema.workspaceMemberships.id })
71
+ .from(schema.workspaceMemberships)
72
+ .where(
73
+ and(
74
+ eq(schema.workspaceMemberships.workspaceId, input.workspaceId),
75
+ eq(schema.workspaceMemberships.subjectId, input.subjectId),
76
+ ),
77
+ )
78
+ .for("key share")
79
+ .limit(1);
80
+ if (!membership) throw new NewSessionDraftAccessError();
81
+ }
82
+ const current = await getNewSessionDraftInTransaction(db, { ...input, lock: true });
83
+ const currentRevision = current?.revision ?? 0;
84
+ if (currentRevision !== input.expectedRevision) {
85
+ throw new NewSessionDraftConflictError(currentRevision);
86
+ }
87
+
88
+ const revision = currentRevision + 1;
89
+ const values = {
90
+ accountId: input.accountId,
91
+ workspaceId: input.workspaceId,
92
+ subjectId: input.subjectId,
93
+ revision,
94
+ text: input.text,
95
+ resources: input.resources,
96
+ tools: input.tools,
97
+ model: input.model,
98
+ reasoningEffort: input.reasoningEffort,
99
+ sessionOptions: input.options,
100
+ updatedAt: new Date(),
101
+ };
102
+ if (current) {
103
+ const [saved] = await db
104
+ .update(schema.newSessionDrafts)
105
+ .set(values)
106
+ .where(eq(schema.newSessionDrafts.id, current.id))
107
+ .returning();
108
+ if (!saved) throw new Error("New-session draft did not save");
109
+ return saved;
110
+ }
111
+
112
+ // SELECT FOR UPDATE cannot lock an absent key. Two first saves may therefore
113
+ // race; ON CONFLICT keeps the loser transaction usable so it can report the
114
+ // winner's authoritative revision instead of leaking a unique violation.
115
+ const [inserted] = await db
116
+ .insert(schema.newSessionDrafts)
117
+ .values(values)
118
+ .onConflictDoNothing({
119
+ target: [schema.newSessionDrafts.workspaceId, schema.newSessionDrafts.subjectId],
120
+ })
121
+ .returning();
122
+ if (inserted) return inserted;
123
+ const raced = await getNewSessionDraftInTransaction(db, { ...input, lock: true });
124
+ throw new NewSessionDraftConflictError(raced?.revision ?? 0);
125
+ }
126
+
127
+ /** Delete only the submitted revision; a newer sibling-tab revision survives. */
128
+ export async function consumeNewSessionDraftInTransaction(
129
+ db: Database,
130
+ input: { workspaceId: string; subjectId: string; expectedRevision: number },
131
+ ): Promise<boolean> {
132
+ if (input.expectedRevision === 0) return false;
133
+ const deleted = await db
134
+ .delete(schema.newSessionDrafts)
135
+ .where(
136
+ and(
137
+ eq(schema.newSessionDrafts.workspaceId, input.workspaceId),
138
+ eq(schema.newSessionDrafts.subjectId, input.subjectId),
139
+ eq(schema.newSessionDrafts.revision, input.expectedRevision),
140
+ ),
141
+ )
142
+ .returning({ id: schema.newSessionDrafts.id });
143
+ return deleted.length > 0;
144
+ }