@axiom-lattice/pg-stores 3.1.0 → 3.1.2

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 (47) hide show
  1. package/.turbo/turbo-build.log +10 -10
  2. package/CHANGELOG.md +18 -0
  3. package/dist/index.d.mts +283 -23
  4. package/dist/index.d.ts +283 -23
  5. package/dist/index.js +1867 -224
  6. package/dist/index.js.map +1 -1
  7. package/dist/index.mjs +1854 -217
  8. package/dist/index.mjs.map +1 -1
  9. package/package.json +3 -3
  10. package/src/__tests__/ChannelBindingStore.test.ts +122 -0
  11. package/src/__tests__/PostgreSQLCapabilityBundleStore.migrations.test.ts +67 -0
  12. package/src/__tests__/PostgreSQLCapabilityBundleStore.test.ts +383 -0
  13. package/src/__tests__/PostgreSQLChannelInstallationStore.test.ts +16 -0
  14. package/src/__tests__/PostgreSQLProjectBotMembershipStore.test.ts +117 -0
  15. package/src/__tests__/PostgreSQLProjectMembershipStore.test.ts +235 -0
  16. package/src/__tests__/PostgreSQLProjectRoomMessageStore.test.ts +103 -0
  17. package/src/__tests__/PostgreSQLProjectRoomStore.migrations.test.ts +114 -0
  18. package/src/__tests__/PostgreSQLProjectRoomStore.test.ts +48 -0
  19. package/src/__tests__/PostgreSQLTaskStore.integration.test.ts +27 -0
  20. package/src/__tests__/PostgreSQLTaskStore.test.ts +57 -0
  21. package/src/__tests__/PostgreSQLTaskWorkItemStore.integration.test.ts +107 -0
  22. package/src/__tests__/PostgreSQLTaskWorkItemStore.migrations.test.ts +62 -0
  23. package/src/__tests__/PostgreSQLTaskWorkItemStore.test.ts +162 -1
  24. package/src/__tests__/ThreadMessageQueueStore.migrations.test.ts +63 -0
  25. package/src/__tests__/ThreadMessageQueueStore.test.ts +209 -4
  26. package/src/__tests__/add_workspace_project_to_queue.test.ts +15 -0
  27. package/src/__tests__/migration-name-version-compatibility.test.ts +67 -0
  28. package/src/__tests__/task-files.test.ts +4 -3
  29. package/src/createPgStoreConfig.ts +25 -1
  30. package/src/index.ts +13 -0
  31. package/src/migrations/add_trusted_run_context_column.ts +18 -0
  32. package/src/migrations/capability_bundle_migration.ts +20 -0
  33. package/src/migrations/migration.ts +2 -1
  34. package/src/migrations/project_room_migration.ts +128 -0
  35. package/src/migrations/task_migration.ts +15 -0
  36. package/src/migrations/task_work_items_migration.ts +45 -1
  37. package/src/stores/ChannelBindingStore.ts +99 -59
  38. package/src/stores/PostgreSQLCapabilityBundleStore.ts +306 -0
  39. package/src/stores/PostgreSQLChannelInstallationStore.ts +12 -30
  40. package/src/stores/PostgreSQLProjectBotMembershipStore.ts +199 -0
  41. package/src/stores/PostgreSQLProjectMembershipStore.ts +140 -0
  42. package/src/stores/PostgreSQLProjectRoomMessageStore.ts +177 -0
  43. package/src/stores/PostgreSQLProjectRoomStore.ts +57 -0
  44. package/src/stores/PostgreSQLProjectStore.ts +230 -50
  45. package/src/stores/PostgreSQLTaskStore.ts +89 -3
  46. package/src/stores/PostgreSQLTaskWorkItemStore.ts +198 -8
  47. package/src/stores/ThreadMessageQueueStore.ts +130 -32
@@ -1,16 +1,97 @@
1
- import type { Pool } from "pg";
2
- import type { TaskWorkItemStore, TaskWorkItem, CreateWorkItemRequest, CreateWorkItemIfAbsentRequest, TaskWorkItemListFilter } from "@axiom-lattice/protocols";
1
+ import { Pool } from "pg";
2
+ import type { PoolConfig } from "pg";
3
+ import type { TaskWorkItemStore, TaskWorkItem, CreateWorkItemRequest, CreateWorkItemIfAbsentRequest, TaskWorkItemListFilter, ProjectLifecycleEventQuery, TaskMutationSnapshot } from "@axiom-lattice/protocols";
4
+ import { MAX_PENDING_EXECUTION_RESULTS_LIMIT } from "@axiom-lattice/protocols";
3
5
  import { v4 } from "uuid";
6
+ import { MigrationManager } from "../migrations/migration";
7
+ import { taskWorkItemMigrations } from "../migrations/task_work_items_migration";
4
8
 
9
+ const PROJECT_LIFECYCLE_ACTIONS: ReadonlySet<string> = new Set([
10
+ "in_progress", "interrupted", "failed", "completed", "cancelled", "reassigned",
11
+ ]);
12
+
13
+ /** PostgreSQL TaskWorkItem store connection and migration options. */
14
+ export interface PostgreSQLTaskWorkItemStoreOptions {
15
+ /** External caller-owned pool; central migrations must already be applied. */
16
+ pool?: Pool;
17
+ /** Connection configuration for a standalone, store-owned pool. */
18
+ poolConfig?: string | PoolConfig;
19
+ /** Whether a standalone store eagerly applies migrations. Defaults to true. */
20
+ autoMigrate?: boolean;
21
+ }
22
+
23
+ function isRecord(value: unknown): value is Record<string, unknown> {
24
+ return value !== null && typeof value === "object" && !Array.isArray(value);
25
+ }
26
+
27
+ function isPool(value: Pool | PostgreSQLTaskWorkItemStoreOptions): value is Pool {
28
+ return typeof (value as { query?: unknown }).query === "function";
29
+ }
30
+
31
+ /** PostgreSQL TaskWorkItem persistence with optional standalone migrations. */
5
32
  export class PostgreSQLTaskWorkItemStore implements TaskWorkItemStore {
6
- constructor(private pool: Pool) {}
33
+ private pool: Pool;
34
+ private migrationManager!: MigrationManager;
35
+ private initialized = false;
36
+ private ownsPool = false;
37
+ private initPromise: Promise<void> | null = null;
38
+
39
+ constructor(pool: Pool);
40
+ constructor(options: PostgreSQLTaskWorkItemStoreOptions);
41
+ constructor(poolOrOptions: Pool | PostgreSQLTaskWorkItemStoreOptions) {
42
+ if (isPool(poolOrOptions)) {
43
+ this.pool = poolOrOptions;
44
+ this.initialized = true;
45
+ return;
46
+ }
47
+ const options = poolOrOptions as PostgreSQLTaskWorkItemStoreOptions;
48
+ if (options.pool) {
49
+ this.pool = options.pool;
50
+ this.initialized = true;
51
+ return;
52
+ }
53
+ this.pool = typeof options.poolConfig === "string"
54
+ ? new Pool({ connectionString: options.poolConfig })
55
+ : options.poolConfig
56
+ ? new Pool(options.poolConfig)
57
+ : (() => { throw new Error("Either pool or poolConfig must be provided"); })();
58
+ this.ownsPool = true;
59
+ this.migrationManager = new MigrationManager(this.pool);
60
+ for (const migration of taskWorkItemMigrations) this.migrationManager.register(migration);
61
+ if (options.autoMigrate !== false) this.startInitialization();
62
+ }
63
+
64
+ /** Applies the complete standalone TaskWorkItem migration chain once. */
65
+ async initialize(): Promise<void> {
66
+ if (this.initialized) return;
67
+ if (this.initPromise) return this.initPromise;
68
+ return this.startInitialization();
69
+ }
70
+
71
+ /** Closes the pool only when this store created it from connection configuration. */
72
+ async dispose(): Promise<void> {
73
+ if (this.ownsPool) await this.pool.end();
74
+ }
75
+
76
+ private startInitialization(): Promise<void> {
77
+ this.initPromise = this.migrationManager.migrate().then(() => {
78
+ this.initialized = true;
79
+ });
80
+ void this.initPromise.catch(() => undefined);
81
+ return this.initPromise;
82
+ }
83
+
84
+ private async ensureInitialized(): Promise<void> {
85
+ if (!this.initialized) await this.initialize();
86
+ }
7
87
 
8
88
  async create(params: CreateWorkItemRequest): Promise<TaskWorkItem> {
89
+ await this.ensureInitialized();
9
90
  const id = v4();
10
91
  const result = await this.pool.query(
11
92
  `INSERT INTO lattice_task_work_items
12
- (id, tenant_id, task_id, action, actor, thread_id, summary, detail, attempt, workspace_id, project_id)
13
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
93
+ (id, tenant_id, task_id, action, actor, thread_id, summary, detail, attempt, workspace_id, project_id, created_at)
94
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, date_trunc('milliseconds', clock_timestamp()))
14
95
  RETURNING *`,
15
96
  [
16
97
  id, params.tenantId, params.taskId, params.action, params.actor,
@@ -24,8 +105,36 @@ export class PostgreSQLTaskWorkItemStore implements TaskWorkItemStore {
24
105
  return this.rowToItem(result.rows[0]);
25
106
  }
26
107
 
108
+ /** Atomically inserts a work item by selecting one exact task snapshot. */
109
+ async createIfTaskSnapshot(
110
+ params: CreateWorkItemRequest,
111
+ snapshot: TaskMutationSnapshot,
112
+ ): Promise<TaskWorkItem | null> {
113
+ await this.ensureInitialized();
114
+ const result = await this.pool.query(
115
+ `INSERT INTO lattice_task_work_items
116
+ (id, tenant_id, task_id, action, actor, thread_id, summary, detail, attempt, workspace_id, project_id, created_at)
117
+ SELECT $1, task.tenant_id, task.id, $2, $3, $4, $5, $6, $7, task.workspace_id, task.project_id,
118
+ date_trunc('milliseconds', clock_timestamp())
119
+ FROM lattice_tasks AS task
120
+ WHERE task.tenant_id = $8 AND task.id = $9 AND task.status = $10
121
+ AND date_trunc('milliseconds', task.updated_at::timestamptz) = $11::timestamptz
122
+ AND task.owner_type = $12 AND task.owner_id = $13
123
+ AND task.workspace_id IS NOT DISTINCT FROM $14 AND task.project_id IS NOT DISTINCT FROM $15
124
+ AND $14 IS NOT DISTINCT FROM $16 AND $15 IS NOT DISTINCT FROM $17
125
+ RETURNING *`,
126
+ [v4(), params.action, params.actor, params.threadId || null, params.summary || null,
127
+ params.detail ? JSON.stringify(params.detail) : null, params.attempt ?? null,
128
+ params.tenantId, params.taskId, snapshot.status, new Date(snapshot.updatedAt).toISOString(),
129
+ snapshot.ownerType, snapshot.ownerId, snapshot.workspaceId, snapshot.projectId,
130
+ params.workspaceId, params.projectId],
131
+ );
132
+ return result.rows[0] ? this.rowToItem(result.rows[0]) : null;
133
+ }
134
+
27
135
  /** Find an event by its tenant- and task-scoped key without pagination. */
28
136
  async findByEventKey(tenantId: string, taskId: string, eventKey: string): Promise<TaskWorkItem | null> {
137
+ await this.ensureInitialized();
29
138
  const result = await this.pool.query(
30
139
  `SELECT * FROM lattice_task_work_items
31
140
  WHERE tenant_id = $1 AND task_id = $2 AND event_key = $3`,
@@ -36,10 +145,11 @@ export class PostgreSQLTaskWorkItemStore implements TaskWorkItemStore {
36
145
 
37
146
  /** Atomically return an existing event or create it once. */
38
147
  async createIfAbsentByEventKey(params: CreateWorkItemIfAbsentRequest): Promise<TaskWorkItem> {
148
+ await this.ensureInitialized();
39
149
  const result = await this.pool.query(
40
150
  `INSERT INTO lattice_task_work_items
41
- (id, tenant_id, task_id, action, actor, thread_id, summary, detail, attempt, workspace_id, project_id, event_key)
42
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
151
+ (id, tenant_id, task_id, action, actor, thread_id, summary, detail, attempt, workspace_id, project_id, event_key, created_at)
152
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, date_trunc('milliseconds', clock_timestamp()))
43
153
  ON CONFLICT (tenant_id, task_id, event_key) WHERE event_key IS NOT NULL
44
154
  DO UPDATE SET event_key = EXCLUDED.event_key
45
155
  RETURNING *`,
@@ -55,6 +165,7 @@ export class PostgreSQLTaskWorkItemStore implements TaskWorkItemStore {
55
165
  }
56
166
 
57
167
  async list(filter: TaskWorkItemListFilter): Promise<TaskWorkItem[]> {
168
+ await this.ensureInitialized();
58
169
  let query = `SELECT * FROM lattice_task_work_items WHERE tenant_id = $1 AND task_id = $2`;
59
170
  const params: unknown[] = [filter.tenantId, filter.taskId];
60
171
 
@@ -87,6 +198,85 @@ export class PostgreSQLTaskWorkItemStore implements TaskWorkItemStore {
87
198
  return result.rows.map((row: Record<string, unknown>) => this.rowToItem(row));
88
199
  }
89
200
 
201
+ /** List pending execution results using one bounded PostgreSQL anti-join query. */
202
+ async listPendingExecutionResults(params: {
203
+ tenantId: string;
204
+ taskId: string;
205
+ limit: number;
206
+ }): Promise<TaskWorkItem[]> {
207
+ if (!Number.isSafeInteger(params.limit)
208
+ || params.limit < 0
209
+ || params.limit > MAX_PENDING_EXECUTION_RESULTS_LIMIT) {
210
+ const error = new RangeError(`limit must be a safe integer between 0 and ${MAX_PENDING_EXECUTION_RESULTS_LIMIT}`) as RangeError & {
211
+ code: "INVALID_LIMIT";
212
+ };
213
+ error.code = "INVALID_LIMIT";
214
+ throw error;
215
+ }
216
+ if (params.limit === 0) return [];
217
+ const result = await this.pool.query(
218
+ `SELECT result.*
219
+ FROM lattice_task_work_items AS result
220
+ WHERE result.tenant_id = $1
221
+ AND result.task_id = $2
222
+ AND result.action = 'execution_result'
223
+ AND result.event_key COLLATE "C" ~ '^execution-result:[A-Za-z0-9._:-]+$'
224
+ AND NOT EXISTS (
225
+ SELECT 1
226
+ FROM lattice_task_work_items AS reconciled
227
+ WHERE reconciled.tenant_id = result.tenant_id
228
+ AND reconciled.task_id = result.task_id
229
+ AND reconciled.action = 'execution_reconciled'
230
+ AND reconciled.detail ->> 'executionResultId' = result.event_key
231
+ )
232
+ ORDER BY result.created_at DESC, result.id DESC
233
+ LIMIT $3`,
234
+ [params.tenantId, params.taskId, params.limit],
235
+ );
236
+ return result.rows.map((row: Record<string, unknown>) => this.rowToItem(row));
237
+ }
238
+
239
+ /** Lists canonical project lifecycle events with an exclusive cursor. */
240
+ async listProjectLifecycleEvents(query: ProjectLifecycleEventQuery): Promise<TaskWorkItem[]> {
241
+ let cursorMilliseconds: number | null = null;
242
+ if (!Number.isSafeInteger(query.limit) || query.limit < 1 || query.limit > 100
243
+ || query.actions.length === 0
244
+ || query.actions.some((action) => !PROJECT_LIFECYCLE_ACTIONS.has(action))) {
245
+ throw new RangeError("Invalid project lifecycle event page");
246
+ }
247
+ if (query.before) {
248
+ try { cursorMilliseconds = Date.prototype.getTime.call(query.before.createdAt) as number; }
249
+ catch { throw new RangeError("Invalid project lifecycle event cursor"); }
250
+ if (!Number.isFinite(cursorMilliseconds) || typeof query.before.id !== "string" || query.before.id.length === 0) {
251
+ throw new RangeError("Invalid project lifecycle event cursor");
252
+ }
253
+ }
254
+ const cursor = cursorMilliseconds === null ? null : new Date(cursorMilliseconds);
255
+ await this.ensureInitialized();
256
+ const result = await this.pool.query(
257
+ `SELECT * FROM lattice_task_work_items
258
+ WHERE tenant_id=$1 AND workspace_id=$2 AND project_id=$3
259
+ AND action = ANY($4::text[]) AND event_key IS NOT NULL AND event_key <> ''
260
+ AND ($5::timestamptz IS NULL OR created_at < $5::timestamptz
261
+ OR (created_at = $5::timestamptz AND id < $6))
262
+ ORDER BY created_at DESC, id DESC LIMIT $7`,
263
+ [query.tenantId, query.workspaceId, query.projectId, query.actions,
264
+ cursor, query.before?.id ?? null, query.limit],
265
+ );
266
+ return result.rows.map((row: Record<string, unknown>) => this.assertProjectLifecycleEvent(this.rowToItem(row)));
267
+ }
268
+
269
+ private assertProjectLifecycleEvent(item: TaskWorkItem): TaskWorkItem {
270
+ let milliseconds: number;
271
+ try { milliseconds = Date.prototype.getTime.call(item.createdAt) as number; }
272
+ catch { throw new Error("Invalid project lifecycle event row"); }
273
+ if (!Number.isFinite(milliseconds) || typeof item.id !== "string" || item.id.length === 0
274
+ || typeof item.eventKey !== "string" || item.eventKey.length === 0) {
275
+ throw new Error("Invalid project lifecycle event row");
276
+ }
277
+ return { ...item, createdAt: new Date(milliseconds) };
278
+ }
279
+
90
280
  private rowToItem(row: Record<string, unknown>): TaskWorkItem {
91
281
  return {
92
282
  id: row.id as string,
@@ -96,7 +286,7 @@ export class PostgreSQLTaskWorkItemStore implements TaskWorkItemStore {
96
286
  actor: row.actor as string,
97
287
  threadId: row.thread_id as string | undefined,
98
288
  summary: row.summary as string | undefined,
99
- detail: row.detail as Record<string, unknown> | undefined,
289
+ detail: isRecord(row.detail) ? row.detail : undefined,
100
290
  attempt: row.attempt as number | undefined,
101
291
  workspaceId: row.workspace_id as string | undefined,
102
292
  projectId: row.project_id as string | undefined,
@@ -11,13 +11,21 @@ import type {
11
11
  PendingMessage,
12
12
  AddMessageParams,
13
13
  ThreadInfo,
14
+ QueueScope,
14
15
  } from "@axiom-lattice/core";
16
+ import {
17
+ parseQueuedExecutionMode,
18
+ parseTrustedRunContext,
19
+ type QueuedExecutionMode,
20
+ type TrustedRunContext,
21
+ } from "@axiom-lattice/protocols";
15
22
  import { MigrationManager } from "../migrations/migration";
16
23
  import { createThreadMessageQueueTable } from "../migrations/thread_message_queue_migrations";
17
24
  import { addPriorityAndCommandColumns } from "../migrations/add_priority_command_columns";
18
25
  import { addCustomRunConfigColumn } from "../migrations/add_custom_run_config_column";
19
26
  import { alterMessageQueueIdColumn } from "../migrations/alter_message_queue_id_column";
20
27
  import { addWorkspaceProjectToQueue } from "../migrations/add_workspace_project_to_queue";
28
+ import { addTrustedRunContextColumn } from "../migrations/add_trusted_run_context_column";
21
29
 
22
30
  export type { PendingMessage, AddMessageParams, ThreadInfo };
23
31
 
@@ -58,6 +66,7 @@ export class ThreadMessageQueueStore implements IMessageQueueStore {
58
66
  this.migrationManager.register(addCustomRunConfigColumn);
59
67
  this.migrationManager.register(alterMessageQueueIdColumn);
60
68
  this.migrationManager.register(addWorkspaceProjectToQueue);
69
+ this.migrationManager.register(addTrustedRunContextColumn);
61
70
 
62
71
  if (options.autoMigrate !== false) {
63
72
  this.initialize().catch((error) => {
@@ -92,6 +101,7 @@ export class ThreadMessageQueueStore implements IMessageQueueStore {
92
101
  * Add message to queue
93
102
  */
94
103
  async addMessage(params: AddMessageParams): Promise<PendingMessage> {
104
+ const trusted = validateQueueTrust(params);
95
105
  const { threadId, tenantId, assistantId, workspaceId, projectId, content, type = "human", priority = 0, command, custom_run_config, id } = params;
96
106
 
97
107
  // Get current max sequence (no lock needed without unique constraint)
@@ -105,20 +115,61 @@ export class ThreadMessageQueueStore implements IMessageQueueStore {
105
115
 
106
116
  const result = await this.pool.query(
107
117
  `INSERT INTO lattice_thread_message_queue
108
- (id, thread_id, tenant_id, assistant_id, workspace_id, project_id, message_content, message_type, sequence_order, priority, command, custom_run_config)
109
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
118
+ (id, thread_id, tenant_id, assistant_id, workspace_id, project_id, message_content, message_type, sequence_order, priority, command, custom_run_config, trusted_run_context, execution_mode)
119
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
110
120
  RETURNING *`,
111
- [id || crypto.randomUUID(), threadId, tenantId, assistantId, workspaceId || null, projectId || null, JSON.stringify(content), type, nextSeq, priority, command ? JSON.stringify(command) : null, custom_run_config ? JSON.stringify(custom_run_config) : null]
121
+ [id || crypto.randomUUID(), threadId, tenantId, assistantId, workspaceId ?? null, projectId ?? null, JSON.stringify(content), type, nextSeq, priority, command ? JSON.stringify(command) : null, custom_run_config ? JSON.stringify(custom_run_config) : null, trusted.trustedRunContext ? JSON.stringify(trusted.trustedRunContext) : null, trusted.executionMode ?? null]
112
122
  );
113
123
 
114
124
  return this.rowToMessage(result.rows[0]);
115
125
  }
116
126
 
127
+ async addMessageIfCapacity(params: AddMessageParams, maxSize: number): Promise<boolean> {
128
+ const trusted = validateQueueTrust(params);
129
+ if (maxSize === Infinity) {
130
+ await this.addMessage(params);
131
+ return true;
132
+ }
133
+ const client = await this.pool.connect();
134
+ try {
135
+ await client.query("BEGIN");
136
+ const scope = { tenantId: params.tenantId, assistantId: params.assistantId, workspaceId: params.workspaceId, projectId: params.projectId };
137
+ const lockKey = `${params.tenantId}:${params.assistantId}:${params.threadId}:${params.workspaceId ?? ""}:${params.projectId ?? ""}`;
138
+ await client.query("SELECT pg_advisory_xact_lock(hashtext($1))", [lockKey]);
139
+ const filter = scopeClause(scope, 2);
140
+ const count = await client.query(
141
+ `SELECT COUNT(*) as count FROM lattice_thread_message_queue WHERE thread_id = $1 AND status = 'pending'${filter.sql}`,
142
+ [params.threadId, ...filter.params],
143
+ );
144
+ if (parseInt(count.rows[0].count, 10) >= maxSize) {
145
+ await client.query("ROLLBACK");
146
+ return false;
147
+ }
148
+ const seq = await client.query(
149
+ `SELECT COALESCE(MAX(sequence_order), 0) + 1 as next_seq FROM lattice_thread_message_queue WHERE thread_id = $1`,
150
+ [params.threadId],
151
+ );
152
+ const result = await client.query(
153
+ `INSERT INTO lattice_thread_message_queue (id, thread_id, tenant_id, assistant_id, workspace_id, project_id, message_content, message_type, sequence_order, priority, command, custom_run_config, trusted_run_context, execution_mode)
154
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING *`,
155
+ [params.id || crypto.randomUUID(), params.threadId, params.tenantId, params.assistantId, params.workspaceId ?? null, params.projectId ?? null, JSON.stringify(params.content), params.type || "human", seq.rows[0].next_seq, params.priority ?? 0, params.command ? JSON.stringify(params.command) : null, params.custom_run_config ? JSON.stringify(params.custom_run_config) : null, trusted.trustedRunContext ? JSON.stringify(trusted.trustedRunContext) : null, trusted.executionMode ?? null],
156
+ );
157
+ await client.query("COMMIT");
158
+ return Boolean(result.rows[0]);
159
+ } catch (error) {
160
+ await client.query("ROLLBACK");
161
+ throw error;
162
+ } finally {
163
+ client.release();
164
+ }
165
+ }
166
+
117
167
  /**
118
168
  * Add message at head of queue (high priority, e.g., STEER/Command messages)
119
169
  * Uses priority=100 to ensure message is processed first
120
170
  */
121
171
  async addMessageAtHead(params: AddMessageParams): Promise<PendingMessage> {
172
+ const trusted = validateQueueTrust(params);
122
173
  const { threadId, tenantId, assistantId, workspaceId, projectId, content, type = "human", command, custom_run_config, id } = params;
123
174
 
124
175
  const resolvedTenantId = tenantId!;
@@ -136,10 +187,10 @@ export class ThreadMessageQueueStore implements IMessageQueueStore {
136
187
  // Insert with high priority (100)
137
188
  const result = await this.pool.query(
138
189
  `INSERT INTO lattice_thread_message_queue
139
- (id, thread_id, tenant_id, assistant_id, workspace_id, project_id, message_content, message_type, sequence_order, priority, command, custom_run_config)
140
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 100, $10, $11)
190
+ (id, thread_id, tenant_id, assistant_id, workspace_id, project_id, message_content, message_type, sequence_order, priority, command, custom_run_config, trusted_run_context, execution_mode)
191
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 100, $10, $11, $12, $13)
141
192
  RETURNING *`,
142
- [id || crypto.randomUUID(), threadId, resolvedTenantId, resolvedAssistantId, workspaceId || null, projectId || null, JSON.stringify(content), type, nextSeq, command ? JSON.stringify(command) : null, custom_run_config ? JSON.stringify(custom_run_config) : null]
193
+ [id || crypto.randomUUID(), threadId, resolvedTenantId, resolvedAssistantId, workspaceId ?? null, projectId ?? null, JSON.stringify(content), type, nextSeq, command ? JSON.stringify(command) : null, custom_run_config ? JSON.stringify(custom_run_config) : null, trusted.trustedRunContext ? JSON.stringify(trusted.trustedRunContext) : null, trusted.executionMode ?? null]
143
194
  );
144
195
 
145
196
  return this.rowToMessage(result.rows[0]);
@@ -148,12 +199,13 @@ export class ThreadMessageQueueStore implements IMessageQueueStore {
148
199
  /**
149
200
  * Get pending messages for thread
150
201
  */
151
- async getPendingMessages(threadId: string): Promise<PendingMessage[]> {
202
+ async getPendingMessages(threadId: string, scope?: QueueScope): Promise<PendingMessage[]> {
203
+ const filter = scopeClause(scope, 2);
152
204
  const result = await this.pool.query(
153
- `SELECT * FROM lattice_thread_message_queue
154
- WHERE thread_id = $1 AND status = 'pending'
205
+ `SELECT * FROM lattice_thread_message_queue
206
+ WHERE thread_id = $1 AND status = 'pending'${filter.sql}
155
207
  ORDER BY priority DESC, sequence_order ASC`,
156
- [threadId]
208
+ [threadId, ...filter.params]
157
209
  );
158
210
 
159
211
  return result.rows.map(row => this.rowToMessage(row));
@@ -162,12 +214,13 @@ export class ThreadMessageQueueStore implements IMessageQueueStore {
162
214
  /**
163
215
  * Get processing messages for a thread
164
216
  */
165
- async getProcessingMessages(threadId: string): Promise<PendingMessage[]> {
217
+ async getProcessingMessages(threadId: string, scope?: QueueScope): Promise<PendingMessage[]> {
218
+ const filter = scopeClause(scope, 2);
166
219
  const result = await this.pool.query(
167
220
  `SELECT * FROM lattice_thread_message_queue
168
- WHERE thread_id = $1 AND status = 'processing'
221
+ WHERE thread_id = $1 AND status = 'processing'${filter.sql}
169
222
  ORDER BY priority DESC, sequence_order ASC`,
170
- [threadId]
223
+ [threadId, ...filter.params]
171
224
  );
172
225
 
173
226
  return result.rows.map(row => this.rowToMessage(row));
@@ -176,11 +229,12 @@ export class ThreadMessageQueueStore implements IMessageQueueStore {
176
229
  /**
177
230
  * Get queue size
178
231
  */
179
- async getQueueSize(threadId: string): Promise<number> {
232
+ async getQueueSize(threadId: string, scope?: QueueScope): Promise<number> {
233
+ const filter = scopeClause(scope, 2);
180
234
  const result = await this.pool.query(
181
235
  `SELECT COUNT(*) as count FROM lattice_thread_message_queue
182
- WHERE thread_id = $1 AND status = 'pending'`,
183
- [threadId]
236
+ WHERE thread_id = $1 AND status = 'pending'${filter.sql}`,
237
+ [threadId, ...filter.params]
184
238
  );
185
239
 
186
240
  return parseInt(result.rows[0].count, 10);
@@ -191,28 +245,29 @@ export class ThreadMessageQueueStore implements IMessageQueueStore {
191
245
  */
192
246
  async getThreadsWithPendingMessages(): Promise<ThreadInfo[]> {
193
247
  const result = await this.pool.query(
194
- `SELECT DISTINCT ON (thread_id) tenant_id, assistant_id, thread_id, workspace_id, project_id
248
+ `SELECT DISTINCT ON (tenant_id, assistant_id, workspace_id, project_id, thread_id) tenant_id, assistant_id, thread_id, workspace_id, project_id
195
249
  FROM lattice_thread_message_queue
196
250
  WHERE status IN ('pending', 'processing')
197
- ORDER BY thread_id`
251
+ ORDER BY tenant_id, assistant_id, workspace_id, project_id, thread_id`
198
252
  );
199
253
 
200
254
  return result.rows.map(row => ({
201
255
  tenantId: row.tenant_id,
202
256
  assistantId: row.assistant_id,
203
257
  threadId: row.thread_id,
204
- workspaceId: row.workspace_id || undefined,
205
- projectId: row.project_id || undefined,
258
+ workspaceId: row.workspace_id,
259
+ projectId: row.project_id,
206
260
  }));
207
261
  }
208
262
 
209
263
  /**
210
264
  * Remove message
211
265
  */
212
- async removeMessage(messageId: string): Promise<boolean> {
266
+ async removeMessage(messageId: string, scope?: QueueScope): Promise<boolean> {
267
+ const filter = scopeClause(scope, 2);
213
268
  const result = await this.pool.query(
214
- `DELETE FROM lattice_thread_message_queue WHERE id = $1 RETURNING id`,
215
- [messageId]
269
+ `DELETE FROM lattice_thread_message_queue WHERE id = $1${filter.sql} RETURNING id`,
270
+ [messageId, ...filter.params]
216
271
  );
217
272
 
218
273
  return (result.rowCount ?? 0) > 0;
@@ -221,20 +276,30 @@ export class ThreadMessageQueueStore implements IMessageQueueStore {
221
276
  /**
222
277
  * Clear all messages for thread
223
278
  */
224
- async clearMessages(threadId: string): Promise<void> {
279
+ async clearMessages(threadId: string, scope?: QueueScope): Promise<void> {
280
+ const filter = scopeClause(scope, 2);
225
281
  await this.pool.query(
226
- `DELETE FROM lattice_thread_message_queue WHERE thread_id = $1`,
227
- [threadId]
282
+ `DELETE FROM lattice_thread_message_queue WHERE thread_id = $1${filter.sql}`,
283
+ [threadId, ...filter.params]
228
284
  );
229
285
  }
230
286
 
231
287
  /**
232
288
  * Mark message as processing
233
289
  */
234
- async markProcessing(messageId: string): Promise<void> {
290
+ async markProcessing(messageId: string, customRunConfig?: unknown, scope?: QueueScope): Promise<void> {
291
+ if (customRunConfig !== undefined) {
292
+ const filter = scopeClause(scope, 3);
293
+ await this.pool.query(
294
+ `UPDATE lattice_thread_message_queue SET status = 'processing', custom_run_config = $2 WHERE id = $1${filter.sql}`,
295
+ [messageId, JSON.stringify(customRunConfig), ...filter.params]
296
+ );
297
+ return;
298
+ }
299
+ const filter = scopeClause(scope, 2);
235
300
  await this.pool.query(
236
- `UPDATE lattice_thread_message_queue SET status = 'processing' WHERE id = $1`,
237
- [messageId]
301
+ `UPDATE lattice_thread_message_queue SET status = 'processing' WHERE id = $1${filter.sql}`,
302
+ [messageId, ...filter.params]
238
303
  );
239
304
  }
240
305
 
@@ -242,13 +307,14 @@ export class ThreadMessageQueueStore implements IMessageQueueStore {
242
307
  * Reset all processing messages to pending state for a thread
243
308
  * Returns the number of messages reset
244
309
  */
245
- async resetProcessingToPending(threadId: string): Promise<number> {
310
+ async resetProcessingToPending(threadId: string, scope?: QueueScope): Promise<number> {
311
+ const filter = scopeClause(scope, 2);
246
312
  const result = await this.pool.query(
247
313
  `UPDATE lattice_thread_message_queue
248
314
  SET status = 'pending'
249
- WHERE thread_id = $1 AND status = 'processing'
315
+ WHERE thread_id = $1 AND status = 'processing'${filter.sql}
250
316
  RETURNING id`,
251
- [threadId]
317
+ [threadId, ...filter.params]
252
318
  );
253
319
  return result.rowCount ?? 0;
254
320
  }
@@ -267,6 +333,38 @@ export class ThreadMessageQueueStore implements IMessageQueueStore {
267
333
  custom_run_config: row.custom_run_config
268
334
  ? (typeof row.custom_run_config === "string" ? JSON.parse(row.custom_run_config) : row.custom_run_config)
269
335
  : undefined,
336
+ trusted_run_context: row.trusted_run_context
337
+ ? parseTrustedRunContext(typeof row.trusted_run_context === "string" ? JSON.parse(row.trusted_run_context) : row.trusted_run_context)
338
+ : undefined,
339
+ execution_mode: row.execution_mode == null ? undefined : parseQueuedExecutionMode(row.execution_mode),
270
340
  };
271
341
  }
272
342
  }
343
+
344
+ function validateQueueTrust(params: AddMessageParams): {
345
+ trustedRunContext: TrustedRunContext | undefined;
346
+ executionMode: QueuedExecutionMode | undefined;
347
+ } {
348
+ return {
349
+ trustedRunContext: params.trusted_run_context === undefined
350
+ ? undefined
351
+ : parseTrustedRunContext(params.trusted_run_context),
352
+ executionMode: params.execution_mode === undefined
353
+ ? undefined
354
+ : parseQueuedExecutionMode(params.execution_mode),
355
+ };
356
+ }
357
+
358
+ function scopeClause(scope: QueueScope | undefined, start: number): { sql: string; params: unknown[] } {
359
+ if (!scope) return { sql: "", params: [] };
360
+ const params: unknown[] = [];
361
+ const entries = (["tenantId", "assistantId", "workspaceId", "projectId"] as const)
362
+ .map((key) => [key, scope[key]] as const);
363
+ const sql = entries.map(([key, value]) => {
364
+ const column = key.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
365
+ if (value == null) return ` AND ${column} IS NULL`;
366
+ params.push(value);
367
+ return ` AND ${column} = $${start + params.length - 1}`;
368
+ }).join("");
369
+ return { sql, params };
370
+ }