@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.
- package/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +18 -0
- package/dist/index.d.mts +283 -23
- package/dist/index.d.ts +283 -23
- package/dist/index.js +1867 -224
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1854 -217
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/ChannelBindingStore.test.ts +122 -0
- package/src/__tests__/PostgreSQLCapabilityBundleStore.migrations.test.ts +67 -0
- package/src/__tests__/PostgreSQLCapabilityBundleStore.test.ts +383 -0
- package/src/__tests__/PostgreSQLChannelInstallationStore.test.ts +16 -0
- package/src/__tests__/PostgreSQLProjectBotMembershipStore.test.ts +117 -0
- package/src/__tests__/PostgreSQLProjectMembershipStore.test.ts +235 -0
- package/src/__tests__/PostgreSQLProjectRoomMessageStore.test.ts +103 -0
- package/src/__tests__/PostgreSQLProjectRoomStore.migrations.test.ts +114 -0
- package/src/__tests__/PostgreSQLProjectRoomStore.test.ts +48 -0
- package/src/__tests__/PostgreSQLTaskStore.integration.test.ts +27 -0
- package/src/__tests__/PostgreSQLTaskStore.test.ts +57 -0
- package/src/__tests__/PostgreSQLTaskWorkItemStore.integration.test.ts +107 -0
- package/src/__tests__/PostgreSQLTaskWorkItemStore.migrations.test.ts +62 -0
- package/src/__tests__/PostgreSQLTaskWorkItemStore.test.ts +162 -1
- package/src/__tests__/ThreadMessageQueueStore.migrations.test.ts +63 -0
- package/src/__tests__/ThreadMessageQueueStore.test.ts +209 -4
- package/src/__tests__/add_workspace_project_to_queue.test.ts +15 -0
- package/src/__tests__/migration-name-version-compatibility.test.ts +67 -0
- package/src/__tests__/task-files.test.ts +4 -3
- package/src/createPgStoreConfig.ts +25 -1
- package/src/index.ts +13 -0
- package/src/migrations/add_trusted_run_context_column.ts +18 -0
- package/src/migrations/capability_bundle_migration.ts +20 -0
- package/src/migrations/migration.ts +2 -1
- package/src/migrations/project_room_migration.ts +128 -0
- package/src/migrations/task_migration.ts +15 -0
- package/src/migrations/task_work_items_migration.ts +45 -1
- package/src/stores/ChannelBindingStore.ts +99 -59
- package/src/stores/PostgreSQLCapabilityBundleStore.ts +306 -0
- package/src/stores/PostgreSQLChannelInstallationStore.ts +12 -30
- package/src/stores/PostgreSQLProjectBotMembershipStore.ts +199 -0
- package/src/stores/PostgreSQLProjectMembershipStore.ts +140 -0
- package/src/stores/PostgreSQLProjectRoomMessageStore.ts +177 -0
- package/src/stores/PostgreSQLProjectRoomStore.ts +57 -0
- package/src/stores/PostgreSQLProjectStore.ts +230 -50
- package/src/stores/PostgreSQLTaskStore.ts +89 -3
- package/src/stores/PostgreSQLTaskWorkItemStore.ts +198 -8
- package/src/stores/ThreadMessageQueueStore.ts +130 -32
|
@@ -1,16 +1,97 @@
|
|
|
1
|
-
import
|
|
2
|
-
import type {
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
205
|
-
projectId: row.project_id
|
|
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
|
-
|
|
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
|
+
}
|