@jskit-ai/assistant-runtime 0.2.28 → 0.2.30

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 (37) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +6 -3
  3. package/package.json +16 -10
  4. package/patterns/assistant-surface/PATTERN.md +5 -2
  5. package/src/client/components/AssistantSettingsClientElement.vue +5 -3
  6. package/src/server/AssistantProvider.js +8 -13
  7. package/src/server/AssistantStoreFeature.js +59 -0
  8. package/src/server/actions.js +5 -5
  9. package/src/server/createAssistantRuntime.js +2 -1
  10. package/src/server/createAssistantSettingsActions.js +17 -9
  11. package/src/server/index.js +1 -0
  12. package/src/server/registerConversationSubscriptions.js +5 -2
  13. package/src/server/registerRoutes.js +11 -8
  14. package/src/server/registerSettingsRoutes.js +4 -4
  15. package/src/server/repositories/assistantConfigRepository.js +52 -67
  16. package/src/server/repositories/assistantResources.js +110 -0
  17. package/src/server/repositories/conversationsRepository.js +164 -147
  18. package/src/server/repositories/messagesRepository.js +136 -87
  19. package/src/server/repositories/repositoryPersistenceUtils.js +11 -22
  20. package/src/server/repositories/turnRequestsRepository.js +50 -16
  21. package/src/server/services/assistantConfigService.js +22 -0
  22. package/src/server/services/chatService.js +15 -7
  23. package/src/server/services/transcriptService.js +3 -3
  24. package/src/server/sqlConversationStorage.js +23 -19
  25. package/src/server/types.js +1 -0
  26. package/test/assistantIntegrationBoundary.test.js +2 -0
  27. package/test/assistantSettings.browser.test.js +23 -2
  28. package/test/assistantSettingsAccess.test.js +46 -1
  29. package/test/chatServiceLifecycle.test.js +32 -1
  30. package/test/featureRuntime.test.js +17 -3
  31. package/test/repositories.mysql.test.js +474 -0
  32. package/test/repositoryContracts.test.js +13 -13
  33. package/test/sqlConversationStorage.mysql.test.js +9 -6
  34. package/test/sqlConversationStorage.test.js +27 -20
  35. package/test/support/assistantStore.js +39 -0
  36. package/test/support/memoryAssistantRows.js +11 -4
  37. package/test/turnRequests.test.js +37 -14
@@ -1,16 +1,15 @@
1
- import { applyForUpdate, createWithTransaction, normalizeDbRecordId, runInTransaction } from "@jskit-ai/database-runtime/shared/repositoryOptions";
2
1
  import { normalizeSurfaceId } from "@jskit-ai/kernel/shared/surface/registry";
3
- import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
2
+ import { normalizeRecordId, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
3
+ import { passedOn } from "@jskit-ai/resource-core/shared/callOptions";
4
4
  import {
5
- applyWorkspaceScope,
6
- normalizeDbWorkspaceId,
7
5
  normalizeInputRecordId,
8
6
  parseJsonObject,
9
- resolveInsertedId,
10
7
  stringifyJsonObject,
11
- toIso
8
+ toIso,
9
+ toWrittenDateTime
12
10
  } from "./repositoryPersistenceUtils.js";
13
- import { DEFAULT_CONVERSATION_TITLE, assistantRuntimeConfig } from "../../shared/assistantRuntimeConfig.js";
11
+ import { DEFAULT_CONVERSATION_TITLE } from "../../shared/assistantRuntimeConfig.js";
12
+
14
13
 
15
14
  function normalizeRequiredSurfaceId(value) {
16
15
  const normalizedSurfaceId = normalizeSurfaceId(value);
@@ -21,22 +20,22 @@ function normalizeRequiredSurfaceId(value) {
21
20
  return normalizedSurfaceId;
22
21
  }
23
22
 
24
- function mapConversationRow(row = {}) {
23
+ function mapConversationRecord(record = {}) {
25
24
  return {
26
- id: normalizeDbRecordId(row.id, { fallback: "" }),
27
- workspaceId: normalizeDbWorkspaceId(row.workspace_id),
28
- title: String(row.title || DEFAULT_CONVERSATION_TITLE),
29
- createdByUserId: row.created_by_user_id == null ? null : normalizeDbRecordId(row.created_by_user_id, { fallback: null }),
30
- status: String(row.status || "active"),
31
- provider: String(row.provider || ""),
32
- model: String(row.model || ""),
33
- surfaceId: String(row.surface_id || ""),
34
- startedAt: toIso(row.started_at),
35
- endedAt: row.ended_at ? toIso(row.ended_at) : null,
36
- messageCount: Number(row.message_count || 0),
37
- metadata: parseJsonObject(row.metadata_json),
38
- createdAt: toIso(row.created_at),
39
- updatedAt: toIso(row.updated_at)
25
+ id: normalizeRecordId(record.id, { fallback: "" }),
26
+ workspaceId: normalizeInputRecordId(record.workspaceId),
27
+ title: String(record.title || DEFAULT_CONVERSATION_TITLE),
28
+ createdByUserId: record.userId == null ? null : normalizeInputRecordId(record.userId),
29
+ status: String(record.status || "active"),
30
+ provider: String(record.provider || ""),
31
+ model: String(record.model || ""),
32
+ surfaceId: String(record.surfaceId || ""),
33
+ startedAt: toIso(record.startedAt),
34
+ endedAt: record.endedAt ? toIso(record.endedAt) : null,
35
+ messageCount: Number(record.messageCount || 0),
36
+ metadata: parseJsonObject(record.metadataJson),
37
+ createdAt: toIso(record.createdAt),
38
+ updatedAt: toIso(record.updatedAt)
40
39
  };
41
40
  }
42
41
 
@@ -53,13 +52,77 @@ function normalizeCursorPagination(pagination = {}, { defaultLimit = 20, maxLimi
53
52
  };
54
53
  }
55
54
 
56
- function createConversationBaseQuery(client) {
57
- return client(`${assistantRuntimeConfig.conversationsTable} as c`).select("c.*");
55
+ // json-rest-api refuses a row lock outside a transaction; callers have always seen this TypeError.
56
+ function rethrowLockError(error) {
57
+ if (error?.type === "rest_api_lock_outside_transaction") {
58
+ throw new TypeError("Locking a conversation requires its database transaction.");
59
+ }
60
+ throw error;
61
+ }
62
+
63
+ // A record patch as stored fields, with updatedAt stamped when anything changes.
64
+ function conversationChanges(patch) {
65
+ const changes = {};
66
+
67
+ if (Object.hasOwn(patch, "title")) {
68
+ changes.title = normalizeText(patch.title) || DEFAULT_CONVERSATION_TITLE;
69
+ }
70
+ if (Object.hasOwn(patch, "status")) {
71
+ changes.status = normalizeText(patch.status).toLowerCase() || "active";
72
+ }
73
+ if (Object.hasOwn(patch, "provider")) {
74
+ changes.provider = normalizeText(patch.provider);
75
+ }
76
+ if (Object.hasOwn(patch, "model")) {
77
+ changes.model = normalizeText(patch.model);
78
+ }
79
+ if (Object.hasOwn(patch, "surfaceId")) {
80
+ changes.surfaceId = normalizeRequiredSurfaceId(patch.surfaceId);
81
+ }
82
+ if (Object.hasOwn(patch, "messageCount")) {
83
+ changes.messageCount = Math.max(0, Number(patch.messageCount || 0));
84
+ }
85
+ if (Object.hasOwn(patch, "endedAt")) {
86
+ changes.endedAt = patch.endedAt ? toWrittenDateTime(patch.endedAt) : null;
87
+ }
88
+ if (Object.hasOwn(patch, "metadata")) {
89
+ changes.metadataJson = stringifyJsonObject(patch.metadata);
90
+ }
91
+ if (Object.keys(changes).length > 0) {
92
+ changes.updatedAt = toWrittenDateTime();
93
+ }
94
+ return changes;
58
95
  }
59
96
 
60
- /** @param {import("@jskit-ai/database-runtime/server/types").RuntimeKnex} knex */
61
- function createRepository(knex) {
62
- const withTransaction = createWithTransaction(knex);
97
+ /**
98
+ * The assistant's conversations, on the assistantConversations table. A
99
+ * conversation's actor reads it in that actor's user context; the transcript
100
+ * storage, which reaches a conversation by id after its caller's actor check,
101
+ * and creates without a valid creator use the system context.
102
+ * @param {object} dependencies
103
+ * @param {import("@jskit-ai/resource-runtime/server/types").ResourceHandle} dependencies.conversations
104
+ * @param {(reason: string) => import("@jskit-ai/resource-runtime/server/types").SystemContext} dependencies.systemContext
105
+ */
106
+ function createRepository({ conversations, systemContext }) {
107
+ const conversationContext = systemContext("a conversation reached by id after its caller checked the actor scope, or created without a valid creator");
108
+
109
+ function withTransaction(options, work) {
110
+ return conversations.withTransaction(options, work);
111
+ }
112
+
113
+ function transaction(work, { retries } = {}) {
114
+ return conversations.withTransaction({ retry: retries !== 0 }, work);
115
+ }
116
+
117
+ // The record as `context` sees it, under a row lock when asked.
118
+ async function readRecord(id, { forUpdate = false, ...options }, context) {
119
+ const scoped = { ...passedOn(options), context };
120
+ if (forUpdate !== true) {
121
+ return conversations.get(id, {}, { ...scoped, missing: "skip" });
122
+ }
123
+ const [locked] = await conversations.lockMany([id], { ...scoped, missing: "skip" }).catch(rethrowLockError);
124
+ return locked || null;
125
+ }
63
126
 
64
127
  async function findById(conversationId, options = {}) {
65
128
  const normalizedConversationId = normalizeInputRecordId(conversationId);
@@ -67,18 +130,8 @@ function createRepository(knex) {
67
130
  return null;
68
131
  }
69
132
 
70
- const client = options.trx || knex;
71
- if (options.forUpdate === true) {
72
- if (!options.trx) throw new TypeError("Locking a conversation requires its database transaction.");
73
- // Acquire the write reservation before reading, including SQLite where
74
- // SELECT FOR UPDATE is not supported. This does not change row contents.
75
- await client(assistantRuntimeConfig.conversationsTable).where({ id: normalizedConversationId })
76
- .update({ id: normalizedConversationId });
77
- }
78
- const query = createConversationBaseQuery(client).where("c.id", normalizedConversationId);
79
- const row = await applyForUpdate(query, options).first();
80
-
81
- return row ? mapConversationRow(row) : null;
133
+ const record = await readRecord(normalizedConversationId, options, conversationContext);
134
+ return record ? mapConversationRecord(record) : null;
82
135
  }
83
136
 
84
137
  async function findByIdForActorScope(
@@ -93,45 +146,39 @@ function createRepository(knex) {
93
146
  return null;
94
147
  }
95
148
 
96
- const client = options.trx || knex;
97
- if (options.forUpdate === true) await findById(normalizedConversationId, options);
98
- const query = createConversationBaseQuery(client)
99
- .where("c.id", normalizedConversationId)
100
- .where("c.created_by_user_id", normalizedActorUserId)
101
- .where("c.surface_id", normalizedSurfaceId);
102
- applyWorkspaceScope(query, "c.workspace_id", workspaceId);
103
- const row = await applyForUpdate(query, options).first();
149
+ const record = await readRecord(normalizedConversationId, options, conversationContext.forUser(normalizedActorUserId));
150
+ if (!record || record.surfaceId !== normalizedSurfaceId ||
151
+ normalizeInputRecordId(record.workspaceId) !== normalizeInputRecordId(workspaceId)) {
152
+ return null;
153
+ }
104
154
 
105
- return row ? mapConversationRow(row) : null;
155
+ return mapConversationRecord(record);
106
156
  }
107
157
 
108
158
  async function create(payload = {}, options = {}) {
109
- const client = options.trx || knex;
110
- const now = new Date();
159
+ const now = toWrittenDateTime();
111
160
  const surfaceId = normalizeRequiredSurfaceId(payload.surfaceId);
112
- const insertResult = await client(assistantRuntimeConfig.conversationsTable).insert({
113
- workspace_id: normalizeInputRecordId(payload.workspaceId),
114
- created_by_user_id: normalizeInputRecordId(payload.createdByUserId),
161
+ const createdByUserId = normalizeInputRecordId(payload.createdByUserId);
162
+ const attributes = {
163
+ workspaceId: normalizeInputRecordId(payload.workspaceId),
115
164
  title: normalizeText(payload.title) || DEFAULT_CONVERSATION_TITLE,
116
165
  status: normalizeText(payload.status).toLowerCase() || "active",
117
166
  provider: normalizeText(payload.provider),
118
167
  model: normalizeText(payload.model),
119
- surface_id: surfaceId,
120
- message_count: Math.max(0, Number(payload.messageCount || 0)),
121
- metadata_json: stringifyJsonObject(payload.metadata),
122
- started_at: payload.startedAt ? new Date(payload.startedAt) : now,
123
- ended_at: payload.endedAt ? new Date(payload.endedAt) : null,
124
- created_at: now,
125
- updated_at: now
126
- });
127
- const id = resolveInsertedId(insertResult);
128
- if (!id) {
129
- throw new Error("conversationsRepository.create could not resolve inserted id.");
130
- }
168
+ surfaceId,
169
+ messageCount: Math.max(0, Number(payload.messageCount || 0)),
170
+ metadataJson: stringifyJsonObject(payload.metadata),
171
+ startedAt: payload.startedAt ? toWrittenDateTime(payload.startedAt) : now,
172
+ endedAt: payload.endedAt ? toWrittenDateTime(payload.endedAt) : null,
173
+ createdAt: now,
174
+ updatedAt: now
175
+ };
176
+ // A conversation without a valid creator is stored with none, so it is created in the system context.
177
+ const created = createdByUserId
178
+ ? await conversations.create(attributes, { ...passedOn(options), context: conversationContext.forUser(createdByUserId) })
179
+ : await conversations.create({ ...attributes, userId: null }, { ...passedOn(options), context: conversationContext });
131
180
 
132
- return findById(id, {
133
- trx: client
134
- });
181
+ return mapConversationRecord(created);
135
182
  }
136
183
 
137
184
  async function updateById(conversationId, patch = {}, options = {}) {
@@ -140,70 +187,54 @@ function createRepository(knex) {
140
187
  return null;
141
188
  }
142
189
 
143
- const client = options.trx || knex;
144
- const updatePatch = {};
145
-
146
- if (Object.hasOwn(patch, "title")) {
147
- updatePatch.title = normalizeText(patch.title) || DEFAULT_CONVERSATION_TITLE;
148
- }
149
- if (Object.hasOwn(patch, "status")) {
150
- updatePatch.status = normalizeText(patch.status).toLowerCase() || "active";
151
- }
152
- if (Object.hasOwn(patch, "provider")) {
153
- updatePatch.provider = normalizeText(patch.provider);
154
- }
155
- if (Object.hasOwn(patch, "model")) {
156
- updatePatch.model = normalizeText(patch.model);
157
- }
158
- if (Object.hasOwn(patch, "surfaceId")) {
159
- updatePatch.surface_id = normalizeRequiredSurfaceId(patch.surfaceId);
160
- }
161
- if (Object.hasOwn(patch, "messageCount")) {
162
- updatePatch.message_count = Math.max(0, Number(patch.messageCount || 0));
163
- }
164
- if (Object.hasOwn(patch, "endedAt")) {
165
- updatePatch.ended_at = patch.endedAt ? new Date(patch.endedAt) : null;
166
- }
167
- if (Object.hasOwn(patch, "metadata")) {
168
- updatePatch.metadata_json = stringifyJsonObject(patch.metadata);
190
+ const changes = conversationChanges(patch);
191
+ if (Object.keys(changes).length > 0) {
192
+ await conversations.patch(normalizedConversationId, changes, {
193
+ ...passedOn(options),
194
+ context: conversationContext,
195
+ missing: "skip",
196
+ returning: "none"
197
+ });
169
198
  }
170
199
 
171
- if (Object.keys(updatePatch).length > 0) {
172
- updatePatch.updated_at = new Date();
173
- await client(assistantRuntimeConfig.conversationsTable)
174
- .where({ id: normalizedConversationId })
175
- .update(updatePatch);
176
- }
177
-
178
- return findById(normalizedConversationId, {
179
- trx: client
180
- });
200
+ return findById(normalizedConversationId, passedOn(options));
181
201
  }
182
202
 
183
- // The transcript's own state stays out of conversation records, which reach the browser.
184
- async function readTranscriptState(conversationId, options = {}) {
203
+ /**
204
+ * The conversation and its transcript's own state, its revision and summary, from one read (under a row lock with
205
+ * `forUpdate`), or null. The transcript state stays out of the record, which reaches the browser.
206
+ * @param {unknown} conversationId
207
+ * @param {{ forUpdate?: boolean, context?: object | null, trx?: object | null }} [options]
208
+ */
209
+ async function findWithTranscriptState(conversationId, options = {}) {
185
210
  const id = normalizeInputRecordId(conversationId);
186
211
  if (!id) return null;
187
- const client = options.trx || knex;
188
- const row = await client(assistantRuntimeConfig.conversationsTable).where({ id })
189
- .first("summary_text", "summary_through_turn_id", "transcript_revision");
190
- if (!row) return null;
212
+ const record = await readRecord(id, options, conversationContext);
213
+ if (!record) return null;
191
214
  return {
192
- revision: row.transcript_revision || "",
193
- summary: row.summary_text == null ? null
194
- : { text: String(row.summary_text), throughTurnId: String(row.summary_through_turn_id || "") }
215
+ conversation: mapConversationRecord(record),
216
+ revision: record.transcriptRevision || "",
217
+ summary: record.summaryText == null ? null
218
+ : { text: String(record.summaryText), throughTurnId: String(record.summaryThroughTurnId || "") }
195
219
  };
196
220
  }
197
221
 
198
- async function writeTranscriptState(conversationId, { revision, summary }, options = {}) {
222
+ /**
223
+ * Writes the transcript's revision and summary and, in the same statement, the record changes of `changes` (as
224
+ * updateById takes them), so a save that appends messages updates its conversation once. An unknown id writes nothing.
225
+ * @param {unknown} conversationId
226
+ * @param {{ revision: string, summary: { text: string, throughTurnId: string } | null, changes?: Record<string, unknown> }} state
227
+ * @param {{ context?: object | null, trx?: object | null }} [options]
228
+ */
229
+ async function writeTranscriptState(conversationId, { revision, summary, changes = {} }, options = {}) {
199
230
  const id = normalizeInputRecordId(conversationId);
200
231
  if (!id) throw new TypeError("writeTranscriptState requires conversationId.");
201
- const client = options.trx || knex;
202
- await client(assistantRuntimeConfig.conversationsTable).where({ id }).update({
203
- transcript_revision: revision,
204
- summary_text: summary ? summary.text : null,
205
- summary_through_turn_id: summary ? summary.throughTurnId : null
206
- });
232
+ await conversations.patch(id, {
233
+ ...conversationChanges(changes),
234
+ transcriptRevision: revision,
235
+ summaryText: summary ? summary.text : null,
236
+ summaryThroughTurnId: summary ? summary.throughTurnId : null
237
+ }, { ...passedOn(options), context: conversationContext, missing: "skip", returning: "none" });
207
238
  }
208
239
 
209
240
  async function listForActorScope(
@@ -219,31 +250,21 @@ function createRepository(knex) {
219
250
  };
220
251
  }
221
252
 
222
- const client = options.trx || knex;
223
253
  const { cursor, limit } = normalizeCursorPagination(pagination);
224
- let query = createConversationBaseQuery(client)
225
- .where("c.created_by_user_id", normalizedActorUserId)
226
- .where("c.surface_id", normalizedSurfaceId);
227
- query = applyWorkspaceScope(query, "c.workspace_id", workspaceId);
228
-
229
254
  const normalizedStatus = normalizeText(filters.status).toLowerCase();
230
- if (normalizedStatus) {
231
- query = query.where("c.status", normalizedStatus);
232
- }
233
- if (cursor) {
234
- query = query.where("c.id", "<", cursor);
235
- }
236
-
237
- const rows = await query
238
- .orderBy("c.id", "desc")
239
- .limit(limit + 1);
255
+ const { records, page } = await conversations.query({
256
+ filters: {
257
+ surfaceId: normalizedSurfaceId,
258
+ workspaceId: normalizeInputRecordId(workspaceId),
259
+ ...(normalizedStatus ? { status: normalizedStatus } : {}),
260
+ ...(cursor ? { beforeId: cursor } : {})
261
+ },
262
+ sort: ["-id"],
263
+ page: { size: limit }
264
+ }, { ...passedOn(options), context: conversationContext.forUser(normalizedActorUserId) });
240
265
 
241
- const hasMore = rows.length > limit;
242
- const pageRows = hasMore ? rows.slice(0, limit) : rows;
243
- const items = pageRows.map(mapConversationRow);
244
- const nextCursor = hasMore && pageRows.length > 0
245
- ? normalizeDbRecordId(pageRows[pageRows.length - 1].id, { fallback: null })
246
- : null;
266
+ const items = records.map(mapConversationRecord);
267
+ const nextCursor = page.hasMore && items.length > 0 ? items[items.length - 1].id : null;
247
268
 
248
269
  return {
249
270
  items,
@@ -251,10 +272,6 @@ function createRepository(knex) {
251
272
  };
252
273
  }
253
274
 
254
- async function transaction(callback, options) {
255
- return runInTransaction(knex, callback, options);
256
- }
257
-
258
275
  return Object.freeze({
259
276
  withTransaction,
260
277
  findById,
@@ -262,7 +279,7 @@ function createRepository(knex) {
262
279
  create,
263
280
  updateById,
264
281
  listForActorScope,
265
- readTranscriptState,
282
+ findWithTranscriptState,
266
283
  writeTranscriptState,
267
284
  transaction
268
285
  });