@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,29 +1,26 @@
1
- import { createWithTransaction, normalizeDbRecordId, runInTransaction } from "@jskit-ai/database-runtime/shared/repositoryOptions";
2
- import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
1
+ import { normalizeRecordId, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
2
+ import { passedOn } from "@jskit-ai/resource-core/shared/callOptions";
3
3
  import {
4
- applyWorkspaceScope,
5
- normalizeDbWorkspaceId,
6
4
  normalizeInputRecordId,
7
5
  parseJsonObject,
8
- resolveInsertedId,
9
6
  stringifyJsonObject,
10
- toIso
7
+ toIso,
8
+ toWrittenDateTime
11
9
  } from "./repositoryPersistenceUtils.js";
12
- import { assistantRuntimeConfig } from "../../shared/assistantRuntimeConfig.js";
13
10
 
14
- function mapMessageRow(row = {}) {
11
+ function mapMessageRecord(record = {}) {
15
12
  return {
16
- id: normalizeDbRecordId(row.id, { fallback: "" }),
17
- conversationId: normalizeDbRecordId(row.conversation_id, { fallback: "" }),
18
- workspaceId: normalizeDbWorkspaceId(row.workspace_id),
19
- seq: Number(row.seq),
20
- role: String(row.role || ""),
21
- kind: String(row.kind || "chat"),
22
- clientMessageSid: String(row.client_message_sid || ""),
23
- actorUserId: row.actor_user_id == null ? null : normalizeDbRecordId(row.actor_user_id, { fallback: null }),
24
- contentText: row.content_text == null ? null : String(row.content_text),
25
- metadata: parseJsonObject(row.metadata_json),
26
- createdAt: toIso(row.created_at)
13
+ id: normalizeRecordId(record.id, { fallback: "" }),
14
+ conversationId: normalizeRecordId(record.conversationId, { fallback: "" }),
15
+ workspaceId: normalizeInputRecordId(record.workspaceId),
16
+ seq: Number(record.seq),
17
+ role: String(record.role || ""),
18
+ kind: String(record.kind || "chat"),
19
+ clientMessageSid: String(record.clientMessageSid || ""),
20
+ actorUserId: record.actorUserId == null ? null : normalizeInputRecordId(record.actorUserId),
21
+ contentText: record.contentText == null ? null : String(record.contentText),
22
+ metadata: parseJsonObject(record.metadataJson),
23
+ createdAt: toIso(record.createdAt)
27
24
  };
28
25
  }
29
26
 
@@ -41,22 +38,43 @@ function normalizePagination(pagination = {}, { defaultPage = 1, defaultPageSize
41
38
  };
42
39
  }
43
40
 
44
- async function resolveNextSequence(client, conversationId) {
45
- const row = await client(assistantRuntimeConfig.messagesTable)
46
- .where({ conversation_id: conversationId })
47
- .max({ maxSeq: "seq" })
48
- .first();
49
- const maxSeq = Number(row?.maxSeq || 0);
50
- if (!Number.isInteger(maxSeq) || maxSeq < 0) {
51
- return 1;
52
- }
41
+ // A conversation id list per query, so its "in" list stays well inside every database's limits.
42
+ const CONVERSATION_IDS_PER_QUERY = 1000;
53
43
 
54
- return maxSeq + 1;
44
+ // Whether message a was written after b: the later createdAt, then the higher id.
45
+ function isNewer(a, b) {
46
+ const byTime = Date.parse(toIso(a.createdAt)) - Date.parse(toIso(b.createdAt));
47
+ return byTime !== 0 ? byTime > 0 : BigInt(a.id) > BigInt(b.id);
55
48
  }
56
49
 
57
- /** @param {import("@jskit-ai/database-runtime/server/types").RuntimeKnex} knex */
58
- function createRepository(knex) {
59
- const withTransaction = createWithTransaction(knex);
50
+ /**
51
+ * A conversation's messages, on the assistantMessages table. Callers reach
52
+ * them by conversation id after checking that conversation's actor scope; a
53
+ * workspace's messages are read in that workspace, the rest in the system context.
54
+ * @param {object} dependencies
55
+ * @param {import("@jskit-ai/resource-runtime/server/types").ResourceHandle} dependencies.messages
56
+ * @param {import("@jskit-ai/resource-runtime/server/types").ResourceHandle} dependencies.conversations The
57
+ * assistantConversations table's handle, for the conversations of one user.
58
+ * @param {(reason: string) => import("@jskit-ai/resource-runtime/server/types").SystemContext} dependencies.systemContext
59
+ */
60
+ function createRepository({ messages, conversations, systemContext }) {
61
+ const messageContext = systemContext("messages reached by conversation id after the caller checked the conversation's actor scope");
62
+ const userContext = systemContext("the newest assistant message among one user's conversations, which the caller is entitled to read");
63
+
64
+ // A workspace's messages in that workspace; messages without one in the system context, filtered to none.
65
+ function scoped(workspaceId, options) {
66
+ return workspaceId
67
+ ? { filters: {}, options: { ...passedOn(options), context: messageContext.forWorkspace(workspaceId) } }
68
+ : { filters: { workspaceId: null }, options: { ...passedOn(options), context: messageContext } };
69
+ }
70
+
71
+ function withTransaction(options, work) {
72
+ return messages.withTransaction(options, work);
73
+ }
74
+
75
+ function transaction(work) {
76
+ return messages.withTransaction({}, work);
77
+ }
60
78
 
61
79
  async function findById(messageId, options = {}) {
62
80
  const normalizedMessageId = normalizeInputRecordId(messageId);
@@ -64,43 +82,43 @@ function createRepository(knex) {
64
82
  return null;
65
83
  }
66
84
 
67
- const client = options.trx || knex;
68
- const row = await client(assistantRuntimeConfig.messagesTable)
69
- .where({ id: normalizedMessageId })
70
- .first();
85
+ const record = await messages.get(normalizedMessageId, {}, { ...passedOn(options), context: messageContext, missing: "skip" });
86
+ return record ? mapMessageRecord(record) : null;
87
+ }
71
88
 
72
- return row ? mapMessageRow(row) : null;
89
+ // The conversation's highest seq, across its workspaces, or 0.
90
+ async function highestSequence(conversationId, options) {
91
+ const [row] = await messages.aggregate({
92
+ measures: { maxSeq: { max: "seq" } },
93
+ filters: { conversationId }
94
+ }, { ...passedOn(options), context: messageContext });
95
+ const maxSeq = Number(row?.maxSeq || 0);
96
+ return Number.isInteger(maxSeq) && maxSeq > 0 ? maxSeq : 0;
73
97
  }
74
98
 
75
99
  async function create(payload = {}, options = {}) {
76
- const client = options.trx || knex;
77
100
  const conversationId = normalizeInputRecordId(payload.conversationId);
78
101
  if (!conversationId) {
79
102
  throw new TypeError("messagesRepository.create requires conversationId.");
80
103
  }
81
104
 
82
105
  const providedSeq = Number(payload.seq);
83
- const seq = Number.isInteger(providedSeq) && providedSeq > 0 ? providedSeq : (await resolveNextSequence(client, conversationId));
84
- const insertResult = await client(assistantRuntimeConfig.messagesTable).insert({
85
- conversation_id: conversationId,
86
- workspace_id: normalizeInputRecordId(payload.workspaceId),
106
+ const seq = Number.isInteger(providedSeq) && providedSeq > 0 ? providedSeq : (await highestSequence(conversationId, options)) + 1;
107
+ const workspaceId = normalizeInputRecordId(payload.workspaceId);
108
+ const created = await messages.create({
109
+ conversationId,
110
+ workspaceId,
87
111
  seq,
88
112
  role: normalizeText(payload.role).toLowerCase(),
89
113
  kind: normalizeText(payload.kind).toLowerCase() || "chat",
90
- client_message_sid: normalizeText(payload.clientMessageSid),
91
- actor_user_id: normalizeInputRecordId(payload.actorUserId),
92
- content_text: payload.contentText == null ? null : String(payload.contentText),
93
- metadata_json: stringifyJsonObject(payload.metadata),
94
- created_at: payload.createdAt ? new Date(payload.createdAt) : new Date()
95
- });
96
- const id = resolveInsertedId(insertResult);
97
- if (!id) {
98
- throw new Error("messagesRepository.create could not resolve inserted id.");
99
- }
100
-
101
- return findById(id, {
102
- trx: client
103
- });
114
+ clientMessageSid: normalizeText(payload.clientMessageSid),
115
+ actorUserId: normalizeInputRecordId(payload.actorUserId),
116
+ contentText: payload.contentText == null ? null : String(payload.contentText),
117
+ metadataJson: stringifyJsonObject(payload.metadata),
118
+ createdAt: toWrittenDateTime(payload.createdAt)
119
+ }, scoped(workspaceId, options).options);
120
+
121
+ return mapMessageRecord(created);
104
122
  }
105
123
 
106
124
  async function countByConversationScope(conversationId, { workspaceId = null } = {}, options = {}) {
@@ -109,12 +127,11 @@ function createRepository(knex) {
109
127
  return 0;
110
128
  }
111
129
 
112
- const client = options.trx || knex;
113
- const query = client(assistantRuntimeConfig.messagesTable).where({
114
- conversation_id: normalizedConversationId
115
- });
116
- applyWorkspaceScope(query, "workspace_id", workspaceId);
117
- const row = await query.count({ total: "*" }).first();
130
+ const scope = scoped(normalizeInputRecordId(workspaceId), options);
131
+ const [row] = await messages.aggregate({
132
+ measures: { total: { count: "*" } },
133
+ filters: { ...scope.filters, conversationId: normalizedConversationId }
134
+ }, scope.options);
118
135
 
119
136
  const total = Number(row?.total || 0);
120
137
  return Number.isFinite(total) && total > 0 ? total : 0;
@@ -123,45 +140,76 @@ function createRepository(knex) {
123
140
  async function updateById(messageId, patch = {}, options = {}) {
124
141
  const id = normalizeInputRecordId(messageId);
125
142
  if (!id) return null;
126
- const client = options.trx || knex;
127
- const update = {};
128
- if (Object.hasOwn(patch, "contentText")) update.content_text = patch.contentText == null ? null : String(patch.contentText);
129
- if (Object.hasOwn(patch, "metadata")) update.metadata_json = stringifyJsonObject(patch.metadata);
130
- if (Object.keys(update).length) await client(assistantRuntimeConfig.messagesTable).where({ id }).update(update);
131
- return findById(id, { trx: client });
143
+ const changes = {};
144
+ if (Object.hasOwn(patch, "contentText")) changes.contentText = patch.contentText == null ? null : String(patch.contentText);
145
+ if (Object.hasOwn(patch, "metadata")) changes.metadataJson = stringifyJsonObject(patch.metadata);
146
+ if (Object.keys(changes).length) {
147
+ await messages.patch(id, changes, { ...passedOn(options), context: messageContext, missing: "skip", returning: "none" });
148
+ }
149
+ return findById(id, options);
132
150
  }
133
151
 
152
+ // One numbered page of the conversation's messages in seq order, in one statement.
134
153
  async function listByConversationScope(conversationId, { workspaceId = null } = {}, pagination = {}, options = {}) {
135
154
  const normalizedConversationId = normalizeInputRecordId(conversationId);
136
155
  if (!normalizedConversationId) {
137
156
  return [];
138
157
  }
139
158
 
140
- const client = options.trx || knex;
141
159
  const { page, pageSize } = normalizePagination(pagination);
142
- const offset = (page - 1) * pageSize;
143
-
144
- const query = client(assistantRuntimeConfig.messagesTable).where({
145
- conversation_id: normalizedConversationId
146
- });
147
- applyWorkspaceScope(query, "workspace_id", workspaceId);
148
- const rows = await query
149
- .orderBy("seq", "asc")
150
- .orderBy("id", "asc")
151
- .limit(pageSize)
152
- .offset(offset);
153
-
154
- return rows.map(mapMessageRow);
160
+ const scope = scoped(normalizeInputRecordId(workspaceId), options);
161
+ const { records } = await messages.query({
162
+ filters: { ...scope.filters, conversationId: normalizedConversationId },
163
+ sort: ["seq", "id"],
164
+ page: { size: pageSize, number: page }
165
+ }, scope.options);
166
+ return records.map(mapMessageRecord);
167
+ }
168
+
169
+ /**
170
+ * The newest assistant message (the latest createdAt, then the highest id)
171
+ * in any of one user's conversations, in every workspace and surface, or
172
+ * null; with `kind`, the newest of that kind, such as "chat" for a reply.
173
+ * Two reads for a user with up to 1,000 conversations: their ids, then the
174
+ * newest message among them.
175
+ * @param {unknown} userId
176
+ * @param {{ kind?: string }} [criteria]
177
+ * @param {{ context?: object | null, trx?: object | null }} [options]
178
+ */
179
+ async function findNewestAssistantByUserId(userId, { kind = "" } = {}, options = {}) {
180
+ const normalizedUserId = normalizeInputRecordId(userId);
181
+ if (!normalizedUserId) {
182
+ return null;
183
+ }
184
+
185
+ const owned = await conversations.queryAll(
186
+ { filters: { userId: normalizedUserId }, fields: ["userId"] },
187
+ { ...passedOn(options), context: userContext.forUser(normalizedUserId) }
188
+ );
189
+ const conversationIds = owned.map((conversation) => normalizeRecordId(conversation.id, { fallback: "" })).filter(Boolean);
190
+ const normalizedKind = normalizeText(kind).toLowerCase();
191
+ let newest = null;
192
+ for (let start = 0; start < conversationIds.length; start += CONVERSATION_IDS_PER_QUERY) {
193
+ const { records: [candidate] } = await messages.query({
194
+ filters: {
195
+ conversationIds: conversationIds.slice(start, start + CONVERSATION_IDS_PER_QUERY),
196
+ role: "assistant",
197
+ ...(normalizedKind ? { kind: normalizedKind } : {})
198
+ },
199
+ sort: ["-createdAt", "-id"],
200
+ page: { size: 1 }
201
+ }, { ...passedOn(options), context: messageContext });
202
+ if (candidate && (!newest || isNewer(candidate, newest))) {
203
+ newest = candidate;
204
+ }
205
+ }
206
+ return newest ? mapMessageRecord(newest) : null;
155
207
  }
156
208
 
157
209
  async function lastSequence(conversationId, options = {}) {
158
210
  const normalizedConversationId = normalizeInputRecordId(conversationId);
159
211
  if (!normalizedConversationId) return 0;
160
- return (await resolveNextSequence(options.trx || knex, normalizedConversationId)) - 1;
161
- }
162
-
163
- async function transaction(callback) {
164
- return runInTransaction(knex, callback);
212
+ return highestSequence(normalizedConversationId, options);
165
213
  }
166
214
 
167
215
  return Object.freeze({
@@ -171,6 +219,7 @@ function createRepository(knex) {
171
219
  updateById,
172
220
  countByConversationScope,
173
221
  listByConversationScope,
222
+ findNewestAssistantByUserId,
174
223
  lastSequence,
175
224
  transaction
176
225
  });
@@ -1,26 +1,12 @@
1
- import { resolveInsertedRecordId } from "@jskit-ai/database-runtime/shared";
2
- import { normalizeDbRecordId } from "@jskit-ai/database-runtime/shared/repositoryOptions";
3
1
  import { normalizeRecordId } from "@jskit-ai/kernel/shared/support/normalize";
4
2
  import { parseJsonObject } from "@jskit-ai/assistant-core/shared";
5
3
 
6
- function normalizeDbWorkspaceId(value) {
7
- return normalizeDbRecordId(value, { fallback: null });
8
- }
9
-
4
+ /** @param {unknown} value */
10
5
  function normalizeInputRecordId(value) {
11
6
  return normalizeRecordId(value, { fallback: null });
12
7
  }
13
8
 
14
- function applyWorkspaceScope(query, columnName, workspaceId) {
15
- const normalizedWorkspaceId = normalizeInputRecordId(workspaceId);
16
- if (normalizedWorkspaceId) {
17
- return query.where(columnName, normalizedWorkspaceId);
18
- }
19
-
20
- return query.whereNull(columnName);
21
- }
22
-
23
-
9
+ /** @param {unknown} value */
24
10
  function stringifyJsonObject(value) {
25
11
  if (!value || typeof value !== "object" || Array.isArray(value)) {
26
12
  return null;
@@ -29,12 +15,13 @@ function stringifyJsonObject(value) {
29
15
  return JSON.stringify(value);
30
16
  }
31
17
 
18
+ /** @param {unknown} value */
32
19
  function toIso(value) {
33
20
  if (!value) {
34
21
  return "";
35
22
  }
36
23
 
37
- const date = value instanceof Date ? value : new Date(value);
24
+ const date = value instanceof Date ? value : new Date(/** @type {string | number} */ (value));
38
25
  if (Number.isNaN(date.getTime())) {
39
26
  return "";
40
27
  }
@@ -42,16 +29,18 @@ function toIso(value) {
42
29
  return date.toISOString();
43
30
  }
44
31
 
45
- function resolveInsertedId(insertResult) {
46
- return resolveInsertedRecordId(insertResult, { fallback: "" }) || "";
32
+ /**
33
+ * A date-time a repository writes: the caller's value as ISO text, or now.
34
+ * @param {unknown} [value]
35
+ */
36
+ function toWrittenDateTime(value = null) {
37
+ return (value ? new Date(/** @type {string | number | Date} */ (value)) : new Date()).toISOString();
47
38
  }
48
39
 
49
40
  export {
50
- applyWorkspaceScope,
51
- normalizeDbWorkspaceId,
52
41
  normalizeInputRecordId,
53
42
  parseJsonObject,
54
43
  stringifyJsonObject,
55
44
  toIso,
56
- resolveInsertedId
45
+ toWrittenDateTime
57
46
  };
@@ -1,33 +1,67 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { assistantRuntimeConfig } from "../../shared/assistantRuntimeConfig.js";
2
+ import { toWrittenDateTime } from "./repositoryPersistenceUtils.js";
3
3
 
4
- /** @param {import("@jskit-ai/database-runtime/server/types").RuntimeKnex} knex */
5
- function createRepository(knex) {
6
- const table = assistantRuntimeConfig.turnRequestsTable;
4
+ function scopeKey(scope) {
5
+ return JSON.stringify([scope.surfaceId, scope.workspaceId]);
6
+ }
7
7
 
8
- function key(scope, messageId) {
9
- return { actor_user_id: scope.actorUserId, scope_key: JSON.stringify([scope.surfaceId, scope.workspaceId]), message_sid: messageId };
10
- }
8
+ function mapTurnRequestRecord(record) {
9
+ return {
10
+ id: record.id,
11
+ token: record.claimToken,
12
+ request: JSON.parse(record.requestJson),
13
+ response: record.responseJson ? JSON.parse(record.responseJson) : null,
14
+ status: record.status
15
+ };
16
+ }
17
+
18
+ /**
19
+ * The receipt of each chat request, on the assistantTurnRequests table: its
20
+ * actor claims and reads it in that actor's user context; the claim that
21
+ * recorded it settles it.
22
+ * @param {object} dependencies
23
+ * @param {import("@jskit-ai/resource-runtime/server/types").ResourceHandle} dependencies.turnRequests
24
+ * @param {(reason: string) => import("@jskit-ai/resource-runtime/server/types").SystemContext} dependencies.systemContext
25
+ */
26
+ function createRepository({ turnRequests, systemContext }) {
27
+ const receiptContext = systemContext("a request receipt is settled by the claim that recorded it, after its actor's request was checked");
11
28
 
12
29
  async function find(scope, messageId) {
13
- const row = await knex(table).where(key(scope, messageId)).first();
14
- return row ? { id: row.id, token: row.claim_token, request: JSON.parse(row.request_json),
15
- response: row.response_json ? JSON.parse(row.response_json) : null, status: row.status } : null;
30
+ const { records } = await turnRequests.query({
31
+ filters: { scopeKey: scopeKey(scope), messageSid: messageId },
32
+ page: { size: 1 }
33
+ }, { context: receiptContext.forUser(scope.actorUserId) });
34
+ return records[0] ? mapTurnRequestRecord(records[0]) : null;
16
35
  }
17
36
 
18
37
  async function claim(scope, request) {
19
38
  const token = randomUUID();
20
- await knex(table).insert({ ...key(scope, request.messageId), claim_token: token, request_json: JSON.stringify(request) })
21
- .onConflict(["actor_user_id", "scope_key", "message_sid"]).ignore();
39
+ await turnRequests.createMany([{
40
+ scopeKey: scopeKey(scope),
41
+ messageSid: request.messageId,
42
+ claimToken: token,
43
+ requestJson: JSON.stringify(request)
44
+ }], {
45
+ context: receiptContext.forUser(scope.actorUserId),
46
+ onConflict: { keys: ["userId", "scopeKey", "messageSid"], action: "ignore" }
47
+ });
22
48
  const record = await find(scope, request.messageId);
23
49
  if (!record) throw new Error("Assistant request could not be recorded.");
24
50
  return { ...record, acquired: record.token === token };
25
51
  }
26
52
 
27
- async function update(claim, response, status = "running") {
28
- const changed = await knex(table).where({ id: claim.id, claim_token: claim.token, status: "running" })
29
- .update({ response_json: JSON.stringify(response), status, updated_at: new Date() });
30
- if (!changed) throw new Error("Assistant request ownership was lost.");
53
+ async function update(claimed, response, status = "running") {
54
+ const changed = await turnRequests.patch(claimed.id, {
55
+ responseJson: JSON.stringify(response),
56
+ status,
57
+ updatedAt: toWrittenDateTime()
58
+ }, {
59
+ context: receiptContext,
60
+ onlyIf: { claimToken: claimed.token, status: "running" },
61
+ missing: "skip",
62
+ returning: "id"
63
+ });
64
+ if (changed === null) throw new Error("Assistant request ownership was lost.");
31
65
  }
32
66
 
33
67
  return Object.freeze({
@@ -8,6 +8,8 @@ import { resolveAssistantServerConfig, resolveAssistantServerConfigMap } from ".
8
8
 
9
9
  const WORKSPACE_SETTINGS_UPDATE_POLICY = Object.freeze({ require: "all", permissions: Object.freeze(["workspace.settings.update"]) });
10
10
  const ASSISTANT_SETTINGS_UPDATE_POLICY = Object.freeze({ require: "all", permissions: Object.freeze(["assistant.settings.update"]) });
11
+ // The statuses of an access check's refusal (not signed in, not allowed), as against a failure.
12
+ const ACCESS_REFUSED_STATUSES = Object.freeze([401, 403]);
11
13
 
12
14
  /**
13
15
  * An assistant surface's saved settings: who may read and change them, and the
@@ -111,6 +113,25 @@ function createService({
111
113
  });
112
114
  }
113
115
 
116
+ /**
117
+ * Whether the context may change a surface's settings: the check updateSettings makes, answered instead of thrown,
118
+ * so the settings screen offers Save only to the people the server lets save.
119
+ */
120
+ async function canUpdateSettings(input = {}, options = {}) {
121
+ const assistantSurface = requireAssistantSurface(appConfig, input.targetSurfaceId);
122
+ try {
123
+ await requireSettingsAccess(assistantSurface, options.context, {
124
+ mode: "write"
125
+ });
126
+ return true;
127
+ } catch (error) {
128
+ if (ACCESS_REFUSED_STATUSES.includes(Number(error?.status))) {
129
+ return false;
130
+ }
131
+ throw error;
132
+ }
133
+ }
134
+
114
135
  async function updateSettings(input = {}, patch = {}, options = {}) {
115
136
  const assistantSurface = requireAssistantSurface(appConfig, input.targetSurfaceId);
116
137
  await requireSettingsAccess(assistantSurface, options.context, {
@@ -145,6 +166,7 @@ function createService({
145
166
 
146
167
  return Object.freeze({
147
168
  getSettings,
169
+ canUpdateSettings,
148
170
  updateSettings,
149
171
  resolveSystemPrompt
150
172
  });
@@ -1,6 +1,7 @@
1
1
  import { isDeepStrictEqual } from "node:util";
2
2
  import { resolveActor } from "@jskit-ai/kernel/server/actions/actionContributorHelpers";
3
- import { AppError, createValidationError } from "@jskit-ai/kernel/server/runtime";
3
+ import { AppError, createValidationError, hideDatabaseError } from "@jskit-ai/kernel/server/runtime";
4
+ import { silentLogger } from "@jskit-ai/kernel/server/runtime/logger";
4
5
  import { normalizeObject, normalizeRecordId, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
5
6
  import { resolveWorkspaceSlug } from "@jskit-ai/assistant-core/server";
6
7
  import { conversationAttachmentIds } from "@jskit-ai/assistant-core/server/conversation";
@@ -156,6 +157,7 @@ function buildAssistantActionContext(context = {}, assistantSurface = {}) {
156
157
  * @param {PersonContextPrompt} dependencies.personContextPrompt
157
158
  * @param {Record<string, any>} dependencies.appConfig
158
159
  * @param {WorkspaceScopeSupport | null} [dependencies.workspaceScopeSupport]
160
+ * @param {import("@jskit-ai/kernel/server/types").RuntimeLogger} [dependencies.logger]
159
161
  */
160
162
  function createChatService({
161
163
  conversationRuntime,
@@ -167,7 +169,8 @@ function createChatService({
167
169
  assistantConfigService,
168
170
  personContextPrompt,
169
171
  appConfig,
170
- workspaceScopeSupport
172
+ workspaceScopeSupport,
173
+ logger = silentLogger
171
174
  }) {
172
175
  function resolveRuntimeWorkspace(assistantSurface, context = {}, input = {}) {
173
176
  if (assistantSurface?.runtimeSurfaceRequiresWorkspace !== true) {
@@ -279,14 +282,16 @@ function createChatService({
279
282
  let result;
280
283
  try {
281
284
  result = await executeTurn();
282
- } catch (error) {
285
+ } catch (thrown) {
286
+ // A database error is saved, so replayed, only as its stand-in; the route answers and logs the error itself.
287
+ const error = hideDatabaseError(thrown);
283
288
  response.failure = { message: String(error?.message || "Assistant request failed."),
284
289
  status: errorStatus(error),
285
290
  ...(error instanceof AppError ? { code: error.code,
286
291
  ...(exposedAppErrorDetails(error) ? { details: error.details } : {}) } : {}),
287
292
  ...savedMessageFields(error) };
288
293
  await turnRequests.update(claim, response, "failed");
289
- throw error;
294
+ throw thrown;
290
295
  }
291
296
  await turnRequests.update(claim, response, result.status);
292
297
  if (response.done) output.sendDone(response.done);
@@ -413,10 +418,13 @@ function createChatService({
413
418
  streamWriter.sendAssistantMessage({ type: ASSISTANT_STREAM_EVENT_TYPES.ASSISTANT_MESSAGE, text });
414
419
  streamWriter.sendDone({ type: ASSISTANT_STREAM_EVENT_TYPES.DONE, messageId: source.messageId, status: "completed" });
415
420
  return { conversationId, messageId: source.messageId, status: "completed" };
416
- } catch (error) {
417
- const aborted = isAbortError(error) || options.abortSignal?.aborted;
421
+ } catch (thrown) {
422
+ const aborted = isAbortError(thrown) || options.abortSignal?.aborted;
418
423
  const status = aborted ? "aborted" : "failed";
419
- if (!streamed) throw error;
424
+ if (!streamed) throw thrown;
425
+ // A database error streams only as its stand-in (a generic 409 or 500), and its detail goes to the log.
426
+ const error = hideDatabaseError(thrown);
427
+ if (error !== thrown) logger.error({ err: thrown }, "Assistant turn failed on a database error.");
420
428
  await transcriptService.completeConversation(assistantSurface, conversationId, { status }, { context: assistantContext, workspace });
421
429
  streamWriter.sendError({ type: ASSISTANT_STREAM_EVENT_TYPES.ERROR, messageId: source.messageId,
422
430
  code: aborted ? "assistant_stream_aborted" : String(error?.code || "assistant_stream_failed"),
@@ -162,12 +162,12 @@ function createTranscriptService({ conversationsRepository, messagesRepository }
162
162
  required: true
163
163
  });
164
164
  const workspaceId = resolveExpectedWorkspaceId(resolvedAssistantSurface, options.workspace || context.workspace);
165
- return conversationsRepository.transaction(async trx => {
165
+ return conversationsRepository.transaction(async txOptions => {
166
166
  const existing = await conversationsRepository.findByIdForActorScope(normalizedConversationId, {
167
167
  workspaceId,
168
168
  actorUserId,
169
169
  surfaceId: resolvedAssistantSurface.targetSurfaceId
170
- }, { trx, forUpdate: true });
170
+ }, { ...txOptions, forUpdate: true });
171
171
  if (!existing) {
172
172
  throw new AppError(404, "Conversation not found.", { code: "assistant.runtime.conversation.notFound" });
173
173
  }
@@ -181,7 +181,7 @@ function createTranscriptService({ conversationsRepository, messagesRepository }
181
181
  ...normalizeObject(existing.metadata),
182
182
  ...normalizeObject(source.metadata)
183
183
  }
184
- }, { trx });
184
+ }, txOptions);
185
185
  });
186
186
  }
187
187