@jini-ai/chat 0.6.5 → 0.6.7
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/CHANGELOG.md +12 -0
- package/dist/core/accepted-input.d.ts +54 -0
- package/dist/core/accepted-input.js +165 -0
- package/dist/core/agent-plugin-chat-state.d.ts +43 -0
- package/dist/core/agent-plugin-chat-state.js +48 -0
- package/dist/core/assistant-client/a2ui-action-poster.js +32 -28
- package/dist/core/assistant-client/messages.d.ts +1 -1
- package/dist/core/assistant-client/messages.js +2 -2
- package/dist/core/assistant-client/transport.d.ts +2 -2
- package/dist/core/assistant-client/transport.js +184 -97
- package/dist/core/assistant-content.d.ts +2 -1
- package/dist/core/assistant-content.js +9 -11
- package/dist/core/browser/page-actions.js +4 -2
- package/dist/core/composer-history.d.ts +16 -9
- package/dist/core/composer-history.js +28 -16
- package/dist/core/conversation-title.d.ts +3 -0
- package/dist/core/conversation-title.js +3 -0
- package/dist/core/durable-projection.js +20 -13
- package/dist/core/durable-runs/continuation.d.ts +4 -0
- package/dist/core/durable-runs/continuation.js +35 -6
- package/dist/core/durable-runs/ledger-contracts.d.ts +2 -1
- package/dist/core/durable-runs/policies.d.ts +1 -0
- package/dist/core/durable-runs/policies.js +1 -0
- package/dist/core/durable-runs/ports.d.ts +6 -2
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.js +2 -0
- package/dist/core/messages.d.ts +2 -0
- package/dist/core/partial-json.d.ts +6 -7
- package/dist/core/partial-json.js +81 -47
- package/dist/core/persistence/title.js +4 -2
- package/dist/core/question-answer.d.ts +23 -0
- package/dist/core/question-answer.js +66 -0
- package/dist/core/question-form/format.d.ts +6 -6
- package/dist/core/question-form/format.js +16 -17
- package/dist/core/question-form/normalize.d.ts +18 -0
- package/dist/core/question-form/normalize.js +112 -122
- package/dist/core/question-form/scan.js +141 -203
- package/dist/core/run-activity.js +62 -49
- package/dist/core/run-events/entry.d.ts +10 -1
- package/dist/core/run-events/entry.js +117 -87
- package/dist/core/todos.d.ts +1 -1
- package/dist/core/todos.js +60 -85
- package/dist/core/tool-events.js +14 -12
- package/dist/core/transcript.js +62 -16
- package/dist/core/turn-usage.d.ts +2 -1
- package/dist/core/turn-usage.js +56 -27
- package/dist/core/user-text-redaction.js +3 -0
- package/dist/core/util/manifest.js +77 -82
- package/dist/core/util/markdown-context.js +51 -53
- package/dist/core/util/parser.js +136 -144
- package/dist/core/util/pointer.js +16 -16
- package/dist/core/util/strip.js +57 -67
- package/dist/core/util/validate.js +15 -17
- package/dist/embed-widget/browser/session-store.js +21 -12
- package/dist/embed-widget/transports/fetch-sse.js +108 -95
- package/dist/embed-widget/vanilla/index.js +81 -70
- package/dist/react/agent-bridge/frontend-session-bridge.js +80 -52
- package/dist/react/components/AttachmentPreviewModal.js +25 -1
- package/dist/react/components/ChatSettingsDialog.d.ts +13 -0
- package/dist/react/components/ChatSettingsDialog.hooks.d.ts +115 -0
- package/dist/react/components/ChatSettingsDialog.hooks.js +40 -0
- package/dist/react/components/ChatSettingsDialog.js +72 -0
- package/dist/react/components/ChatSettingsSegmented.d.ts +18 -0
- package/dist/react/components/ChatSettingsSegmented.js +11 -0
- package/dist/react/components/Composer.d.ts +5 -1
- package/dist/react/components/Composer.js +126 -82
- package/dist/react/components/ConversationList.js +25 -39
- package/dist/react/components/ConversationTitleEditor.d.ts +27 -0
- package/dist/react/components/ConversationTitleEditor.js +54 -0
- package/dist/react/components/Icon.d.ts +1 -1
- package/dist/react/components/Icon.js +25 -45
- package/dist/react/components/Markdown.js +134 -123
- package/dist/react/components/McpUiSurfaceCard.d.ts +2 -2
- package/dist/react/components/McpUiSurfaceCard.js +30 -9
- package/dist/react/components/MessageList.d.ts +13 -13
- package/dist/react/components/MessageList.hooks.d.ts +6 -1
- package/dist/react/components/MessageList.hooks.js +19 -3
- package/dist/react/components/MessageList.js +34 -10
- package/dist/react/components/MessageRow.js +48 -32
- package/dist/react/components/MessageRow.usage.d.ts +5 -2
- package/dist/react/components/MessageRow.usage.js +20 -11
- package/dist/react/components/MidRunUserMessage.js +16 -3
- package/dist/react/components/QuestionForm.js +148 -76
- package/dist/react/components/QuestionsPanel.js +1 -1
- package/dist/react/components/TodoCard.js +12 -6
- package/dist/react/components/ToolCard.js +47 -40
- package/dist/react/components/composer-discovery.js +6 -1
- package/dist/react/components/mcp-ui-surface-state.d.ts +6 -11
- package/dist/react/components/mcp-ui-surface-state.js +23 -8
- package/dist/react/ext-event-renderer-registry.d.ts +8 -0
- package/dist/react/ext-event-renderer-registry.js +5 -2
- package/dist/react/features/chat-pane/components/AgentPluginUsage.d.ts +7 -0
- package/dist/react/features/chat-pane/components/AgentPluginUsage.js +45 -0
- package/dist/react/features/chat-pane/components/AgentRuntimePicker.js +55 -19
- package/dist/react/features/chat-pane/components/ChatPane.d.ts +1 -1
- package/dist/react/features/chat-pane/components/ChatPane.js +192 -74
- package/dist/react/features/chat-pane/components/RuntimeDropUp.d.ts +29 -0
- package/dist/react/features/chat-pane/components/RuntimeDropUp.js +45 -0
- package/dist/react/features/chat-pane/create-daemon-attachment-uploader.js +80 -47
- package/dist/react/features/chat-pane/create-mcp-ui-tool-caller.js +44 -40
- package/dist/react/features/chat-pane/create-mid-run-message-poster.d.ts +6 -6
- package/dist/react/features/chat-pane/create-mid-run-message-poster.js +8 -8
- package/dist/react/features/chat-pane/create-typed-answer-poster.d.ts +3 -3
- package/dist/react/features/chat-pane/create-typed-answer-poster.js +3 -3
- package/dist/react/features/chat-pane/hooks/useAgentRuntimePicker.hooks.d.ts +6 -1
- package/dist/react/features/chat-pane/hooks/useAgentRuntimePicker.hooks.js +11 -5
- package/dist/react/features/chat-pane/hooks/useChatPane.hooks.d.ts +12 -7
- package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +340 -149
- package/dist/react/features/chat-pane/hooks/useChatPaneControlsHeight.hooks.js +7 -6
- package/dist/react/features/chat-pane/hooks/useChatPaneNewTabLinks.hooks.js +28 -10
- package/dist/react/features/chat-pane/hooks/useChatPaneRuntimeInventory.hooks.d.ts +3 -3
- package/dist/react/features/chat-pane/hooks/useChatPaneRuntimeInventory.hooks.js +60 -12
- package/dist/react/features/chat-pane/hooks/useRuntimeDropUp.hooks.d.ts +49 -0
- package/dist/react/features/chat-pane/hooks/useRuntimeDropUp.hooks.js +143 -0
- package/dist/react/features/chat-pane/model-options.d.ts +18 -1
- package/dist/react/features/chat-pane/model-options.js +49 -20
- package/dist/react/features/chat-pane/rules.d.ts +8 -9
- package/dist/react/features/chat-pane/rules.js +84 -46
- package/dist/react/features/chat-pane/styles.d.ts +1 -1
- package/dist/react/features/chat-pane/styles.js +156 -37
- package/dist/react/features/chat-pane/types.d.ts +17 -8
- package/dist/react/hooks/assistant-chats.js +12 -5
- package/dist/react/hooks/composer-draft-cache.js +116 -118
- package/dist/react/hooks/composer-history-storage.js +5 -5
- package/dist/react/hooks/fab-position.js +7 -4
- package/dist/react/hooks/useAnsweredCards.d.ts +21 -4
- package/dist/react/hooks/useAnsweredCards.js +38 -11
- package/dist/react/hooks/useArtifactStream.js +28 -22
- package/dist/react/hooks/useComposer.d.ts +20 -0
- package/dist/react/hooks/useComposer.js +22 -9
- package/dist/react/hooks/useComposerHistory.hooks.d.ts +6 -1
- package/dist/react/hooks/useComposerHistory.hooks.js +39 -19
- package/dist/react/hooks/useComposerHistoryKeys.hooks.js +24 -14
- package/dist/react/hooks/useConversation.d.ts +7 -0
- package/dist/react/hooks/useConversation.js +23 -10
- package/dist/react/hooks/useExtEventGroups.js +39 -39
- package/dist/react/hooks/useQuestionForms.js +21 -26
- package/dist/react/hooks/useRunStream.js +10 -2
- package/dist/react/index.d.ts +7 -1
- package/dist/react/index.js +9 -0
- package/dist/react/message-blocks.d.ts +6 -4
- package/dist/react/message-blocks.js +94 -72
- package/dist/react/slots.d.ts +2 -2
- package/dist/react/tool-call-display.js +19 -17
- package/dist/server/durable-runs/recover.d.ts +1 -1
- package/dist/server/durable-runs/recover.js +139 -76
- package/dist/server/run-finalizer/entry.js +25 -15
- package/dist/store/legacy/sqlite/conversations.d.ts +11 -11
- package/dist/store/legacy/sqlite/conversations.js +69 -98
- package/dist/store/legacy/sqlite/messages.d.ts +10 -2
- package/dist/store/legacy/sqlite/messages.js +113 -71
- package/dist/store/runtime/chat-history-store.js +9 -3
- package/dist/store/runtime/durable-run-store.d.ts +2 -2
- package/dist/store/runtime/durable-run-store.js +165 -117
- package/dist/store/runtime/history-tools.js +118 -82
- package/dist/store/runtime/run-ledger.d.ts +4 -1
- package/dist/store/runtime/run-ledger.js +56 -16
- package/dist/store/sql/cursor.js +38 -18
- package/dist/store/sql/store.d.ts +18 -0
- package/dist/store/sql/store.js +46 -15
- package/package.json +12 -10
|
@@ -1,25 +1,33 @@
|
|
|
1
1
|
import { recoveredRunEvents } from "../../core/index.js";
|
|
2
2
|
import { chatKernelTables } from "../sql/boundary.js";
|
|
3
|
+
import { sql } from "kysely";
|
|
3
4
|
import { hasUnknownToolCall, CONTINUATION_DIVIDER } from "../../core/durable-runs/continuation.js";
|
|
4
5
|
import { RECOVERY_WINDOW_MS } from "../../core/durable-runs/policies.js";
|
|
5
6
|
import { createChatHistoryStore } from "./chat-history-store.js";
|
|
6
7
|
import { runLockKey } from "../../core/durable-runs/run-lock.js";
|
|
7
8
|
import { runContentFromEvents } from "../../core/run-events/entry.js";
|
|
9
|
+
import { toMessage } from "../sql/store.js";
|
|
8
10
|
export const RUN_SLOT_BUSY = "Another answer in this chat is still running. This turn is queued in your browser.";
|
|
9
11
|
export class RunSlotBusyError extends Error {
|
|
10
12
|
constructor() { super(RUN_SLOT_BUSY); }
|
|
11
13
|
}
|
|
14
|
+
function isAcceptedUserShape({ message }, _optional = {}) {
|
|
15
|
+
return Boolean(message) && typeof message.id === "string" && Boolean(message.id) && message.role === "user" && typeof message.content === "string"
|
|
16
|
+
&& (message.createdAt === undefined || Number.isFinite(message.createdAt));
|
|
17
|
+
}
|
|
18
|
+
function areAcceptedAttachments({ attachments }, _optional = {}) {
|
|
19
|
+
return Array.isArray(attachments) && attachments.every((attachment) => attachment && typeof attachment.path === "string" && typeof attachment.name === "string"
|
|
20
|
+
&& (attachment.kind === "file" || attachment.kind === "image"));
|
|
21
|
+
}
|
|
12
22
|
function acceptedUserMessage({ request, now }, _optional = {}) {
|
|
13
23
|
const context = JSON.parse(request.contextRef);
|
|
14
24
|
// Older clients supplied only a prompt; never invent a user identity from that transcript.
|
|
15
25
|
const message = context.userMessage;
|
|
16
26
|
if (message === undefined)
|
|
17
27
|
return null;
|
|
18
|
-
if (!
|
|
19
|
-
|| (message.createdAt !== undefined && !Number.isFinite(message.createdAt)))
|
|
28
|
+
if (!isAcceptedUserShape({ message }, {}))
|
|
20
29
|
throw new Error("Invalid accepted user message");
|
|
21
|
-
if (message.attachments !== undefined &&
|
|
22
|
-
&& (attachment.kind === "file" || attachment.kind === "image"))))
|
|
30
|
+
if (message.attachments !== undefined && !areAcceptedAttachments({ attachments: message.attachments }, {}))
|
|
23
31
|
throw new Error("Invalid accepted user attachments");
|
|
24
32
|
// User input cannot claim an assistant run's status/events. Keep the same identity and payload
|
|
25
33
|
// as its browser PUT, including the original clock and attachment order.
|
|
@@ -27,9 +35,10 @@ function acceptedUserMessage({ request, now }, _optional = {}) {
|
|
|
27
35
|
...(message.attachments ? { attachments: message.attachments } : {}) };
|
|
28
36
|
}
|
|
29
37
|
function recoveryFields({ row, metadata }, _optional = {}) {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
38
|
+
const saved = metadata ?? {};
|
|
39
|
+
return { recoveryCount: saved.recovery_count ?? 0, recoveryDeadline: saved.recovery_deadline ?? null,
|
|
40
|
+
attemptStartedAt: saved.attempt_started_at ?? 0, lastProgressAt: saved.last_progress_at ?? row.started_at ?? row.created_at,
|
|
41
|
+
cancelReason: saved.cancel_reason ?? null };
|
|
33
42
|
}
|
|
34
43
|
function sessionFields({ metadata }, _optional = {}) {
|
|
35
44
|
return { sessionId: metadata?.session_id ?? null, sessionConfirmed: metadata?.session_confirmed === 1,
|
|
@@ -42,6 +51,16 @@ function acceptedRequest({ row, metadata, transcript }, _optional = {}) {
|
|
|
42
51
|
return { agentId: row.agent_id ?? "claude", contextRef: JSON.stringify({ conversationId: row.conversation_id,
|
|
43
52
|
prompt: transcript.map((item) => `${item.role}: ${item.content}`).join("\n\n") }) };
|
|
44
53
|
}
|
|
54
|
+
function runHead({ row, runId, metadata, message, transcript }, { isDaemonRunId }) {
|
|
55
|
+
return {
|
|
56
|
+
messageId: row.id, conversationId: row.conversation_id, runId,
|
|
57
|
+
workspaceId: row.scope_id,
|
|
58
|
+
principalId: row.owner_kind === "user" ? row.owner_id : null,
|
|
59
|
+
engine: metadata?.engine ?? (isDaemonRunId({ runId }) ? "daemon" : "request"), message,
|
|
60
|
+
request: acceptedRequest({ row, metadata, transcript }, {}),
|
|
61
|
+
...recoveryFields({ row, metadata }, {}), ...sessionFields({ metadata }, {}),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
45
64
|
function metadataForRun({ run }, _optional = {}) {
|
|
46
65
|
return { message_id: run.messageId, engine: run.engine, accepted_json: JSON.stringify(run.request),
|
|
47
66
|
recovery_count: run.recoveryCount, recovery_deadline: run.recoveryDeadline, recovery_elapsed_ms: 0,
|
|
@@ -61,33 +80,31 @@ export function savedRunEvents({ json }, _optional = {}) {
|
|
|
61
80
|
return [];
|
|
62
81
|
}
|
|
63
82
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
// Scan backward to keep the latest
|
|
67
|
-
for (let index =
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
83
|
+
/** Index of the latest matching event, or -1. */
|
|
84
|
+
function lastIndexOf({ events, matches }, _optional = {}) {
|
|
85
|
+
// Scan backward to keep the latest match without requiring ES2023 array methods.
|
|
86
|
+
for (let index = events.length - 1; index >= 0; index--)
|
|
87
|
+
if (matches(events[index]))
|
|
88
|
+
return index;
|
|
89
|
+
return -1;
|
|
90
|
+
}
|
|
91
|
+
/** Saved projection, with legacy content restored as text when no text event carries it. */
|
|
92
|
+
function savedProjection({ current }, _optional = {}) {
|
|
74
93
|
const saved = [...(current.message.events ?? [])];
|
|
75
94
|
if (!saved.some((event) => event.kind === "text") && current.message.content)
|
|
76
95
|
saved.unshift({ kind: "text", text: current.message.content });
|
|
96
|
+
return saved;
|
|
97
|
+
}
|
|
98
|
+
function continuationEvents({ current, incoming }, _optional = {}) {
|
|
99
|
+
const separator = lastIndexOf({ events: incoming, matches: (event) => event.kind === "text" && event.text === CONTINUATION_DIVIDER });
|
|
100
|
+
const saved = savedProjection({ current });
|
|
77
101
|
// A trailing checkpoint/tool barrier may land after the recovery probe but before its CAS.
|
|
78
102
|
// Build the continuation segment from the projection inside the transaction, never that probe.
|
|
79
103
|
if (separator >= 0)
|
|
80
104
|
return [...saved, ...incoming.slice(separator)];
|
|
81
105
|
// Empty startup retries have a recovery marker but no divider. Rebase their saved work under
|
|
82
106
|
// the same lock too: a checkpoint may have arrived since the recovery probe.
|
|
83
|
-
|
|
84
|
-
for (let index = incoming.length - 1; index >= 0; index--) {
|
|
85
|
-
const event = incoming[index];
|
|
86
|
-
if (event.kind === "status" && event.code === "run_recovering") {
|
|
87
|
-
marker = index;
|
|
88
|
-
break;
|
|
89
|
-
}
|
|
90
|
-
}
|
|
107
|
+
const marker = lastIndexOf({ events: incoming, matches: (event) => event.kind === "status" && event.code === "run_recovering" });
|
|
91
108
|
if (marker >= 0) {
|
|
92
109
|
const event = incoming[marker];
|
|
93
110
|
const notice = event.kind === 'status' && event.label === '' ? 'silent' : 'visible';
|
|
@@ -95,87 +112,132 @@ function continuationEvents({ current, incoming }, _optional = {}) {
|
|
|
95
112
|
}
|
|
96
113
|
return runContentFromEvents({ events: incoming }).length >= current.message.content.length ? incoming : saved;
|
|
97
114
|
}
|
|
115
|
+
/** `events_json` with one more event, built in SQL: no dialect parses or re-serializes the saved
|
|
116
|
+
* array in JS. Writers store `JSON.stringify` output (no whitespace), so a non-empty array is
|
|
117
|
+
* `[...]` and gains `,event`; `[]`, NULL and anything not array-shaped (a truncated legacy value,
|
|
118
|
+
* which the old JS read also treated as no events) become `[event]`. Plain `substr`/`length`/`||`
|
|
119
|
+
* run unchanged on SQLite and PostgreSQL. */
|
|
120
|
+
function appendedEventsJson({ event }, _optional = {}) {
|
|
121
|
+
const item = sql `cast(${JSON.stringify(event)} as text)`;
|
|
122
|
+
return sql `case when events_json like '[_%]' then substr(events_json, 1, length(events_json) - 1) || ',' || ${item} || ']' else '[' || ${item} || ']' end`;
|
|
123
|
+
}
|
|
124
|
+
function sameOwner({ run, required }, _optional = {}) {
|
|
125
|
+
return run.principalId === required.principalId && run.conversationId === required.conversationId;
|
|
126
|
+
}
|
|
98
127
|
/** Same kernel and message lock as checkpoint/settle. Claim changes run_id before any spawn;
|
|
99
128
|
* old attempts cannot write projections or dispatch tools after that transaction commits. */
|
|
100
129
|
export function createDurableRunStore({ kernel: hostKernel, isDaemonRunId }, _optional = {}) {
|
|
101
130
|
const kernel = chatKernelTables({ kernel: hostKernel }, {});
|
|
102
|
-
|
|
103
|
-
|
|
131
|
+
/** The run's own message row, with its events only when asked: never another message's. */
|
|
132
|
+
async function runRow({ match }, { events = false } = {}) {
|
|
133
|
+
const eventsColumn = events ? sql `${sql.ref("ai_chat_messages.events_json")}` : sql `null`;
|
|
134
|
+
return kernel.run((db) => db.selectFrom("ai_chat_messages")
|
|
104
135
|
.innerJoin("ai_chats", "ai_chats.id", "ai_chat_messages.conversation_id")
|
|
105
|
-
.
|
|
106
|
-
|
|
136
|
+
.select(["ai_chat_messages.id", "conversation_id", "role", "content", "agent_id", "agent_name", "attachments_json",
|
|
137
|
+
"run_id", "run_status", "position", "ai_chat_messages.created_at", "started_at", "ended_at", "owner_kind", "owner_id", "scope_id"])
|
|
138
|
+
.select(eventsColumn.as("events_json"))
|
|
139
|
+
.where(match.column, "=", match.value).where("role", "=", "assistant").executeTakeFirst());
|
|
140
|
+
}
|
|
141
|
+
/** Every message of the run's conversation; only `load` and legacy rows without attempt metadata pay this. */
|
|
142
|
+
async function conversationTranscript({ row }, _options = {}) {
|
|
143
|
+
const history = createChatHistoryStore({ kernel, isDaemonRunId, scope: { scopeId: row.scope_id, ownerKind: row.owner_kind, ownerId: row.owner_id } }, {});
|
|
144
|
+
return history.messages({ conversationId: row.conversation_id });
|
|
145
|
+
}
|
|
146
|
+
/** One run's head, plus its transcript when that was read (`[]` otherwise). */
|
|
147
|
+
async function readRun({ match }, options = {}) {
|
|
148
|
+
const row = await runRow({ match }, options);
|
|
107
149
|
if (!row || !row.run_id)
|
|
108
150
|
return null;
|
|
151
|
+
const messageId = row.id;
|
|
109
152
|
const metadata = await kernel.run((db) => db.selectFrom("assistant_run_attempts").selectAll().where("message_id", "=", messageId).executeTakeFirst());
|
|
110
|
-
|
|
111
|
-
const transcript = await
|
|
112
|
-
const message = transcript.find((item) => item.id === messageId);
|
|
153
|
+
// A legacy row's accepted request is derived from its transcript, so only it reads one here.
|
|
154
|
+
const transcript = options.transcript || !metadata ? await conversationTranscript({ row }) : [];
|
|
155
|
+
const message = options.transcript ? transcript.find((item) => item.id === messageId) : toMessage(row);
|
|
113
156
|
if (!message)
|
|
114
157
|
return null; // Deletion may have cascaded between the two reads.
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
158
|
+
return { head: runHead({ row, runId: row.run_id, metadata, message, transcript }, { isDaemonRunId }), transcript };
|
|
159
|
+
}
|
|
160
|
+
/** Locked read of one message; `transcript` decides whether it is a whole run or only its head. */
|
|
161
|
+
async function lockedRead({ messageId, conversationId }, options = {}) {
|
|
162
|
+
return kernel.transaction(async () => {
|
|
163
|
+
// Message, attempt metadata and transcript must describe the same generation. A CAS in
|
|
164
|
+
// the gap between independent reads must never pair a new projection with an old session.
|
|
165
|
+
await kernel.lockKey(runLockKey({ messageId, conversationId, runId: "" }));
|
|
166
|
+
return readRun({ match: { column: "ai_chat_messages.id", value: messageId } }, options);
|
|
167
|
+
});
|
|
123
168
|
}
|
|
124
169
|
async function load({ messageId }, _options = {}) {
|
|
125
170
|
const binding = await kernel.run((db) => db.selectFrom("ai_chat_messages").select("conversation_id")
|
|
126
171
|
.where("id", "=", messageId).executeTakeFirst());
|
|
127
172
|
if (!binding)
|
|
128
173
|
return null;
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
174
|
+
// The message comes from the transcript, so its row's events are not selected twice.
|
|
175
|
+
const read = await lockedRead({ messageId, conversationId: binding.conversation_id }, { transcript: true });
|
|
176
|
+
return read && { ...read.head, transcript: read.transcript };
|
|
177
|
+
}
|
|
178
|
+
/** Unlocked probe: the message and conversation an attempt id currently names. */
|
|
179
|
+
async function probeRun({ runId }, _options = {}) {
|
|
180
|
+
return kernel.run((db) => db.selectFrom("ai_chat_messages").select(["id", "conversation_id"])
|
|
181
|
+
.where("run_id", "=", runId).where("role", "=", "assistant").executeTakeFirst());
|
|
135
182
|
}
|
|
136
183
|
async function find({ runId, principalId }, _options = {}) {
|
|
137
|
-
const row = await
|
|
184
|
+
const row = await probeRun({ runId });
|
|
138
185
|
if (!row)
|
|
139
186
|
return null;
|
|
140
|
-
const run = await
|
|
187
|
+
const run = (await lockedRead({ messageId: row.id, conversationId: row.conversation_id }, { events: true }))?.head ?? null;
|
|
141
188
|
return principalId !== undefined && run?.principalId !== principalId ? null : run;
|
|
142
189
|
}
|
|
190
|
+
/** Slot and run locks, then the owner-scoped conversation; null when the caller does not own it. */
|
|
191
|
+
async function lockOwnedConversation({ required }, _options = {}) {
|
|
192
|
+
await kernel.lockKey(`chat-slot:${required.conversationId}`);
|
|
193
|
+
await kernel.lockKey(runLockKey(required));
|
|
194
|
+
const history = createChatHistoryStore({ kernel, isDaemonRunId, scope: { scopeId: required.workspaceId, ownerKind: "user", ownerId: required.principalId } }, {});
|
|
195
|
+
return await history.get({ id: required.conversationId }) ? history : null;
|
|
196
|
+
}
|
|
197
|
+
async function assertSlotFree({ conversationId }, _options = {}) {
|
|
198
|
+
const occupied = await kernel.run((db) => db.selectFrom("ai_chat_messages").select("id")
|
|
199
|
+
.where("conversation_id", "=", conversationId).where("role", "=", "assistant")
|
|
200
|
+
.where("run_status", "in", ["queued", "running"]).executeTakeFirst());
|
|
201
|
+
if (occupied)
|
|
202
|
+
throw new RunSlotBusyError();
|
|
203
|
+
}
|
|
204
|
+
async function appendAcceptedQuestion({ history, required, userMessage }, _options = {}) {
|
|
205
|
+
if (userMessage.id === required.messageId)
|
|
206
|
+
throw new Error("User and assistant messages must have different IDs");
|
|
207
|
+
// A pending/failed browser PUT must never let the assistant take the first position.
|
|
208
|
+
// Nested appends join this transaction and retain the conversation lock through both
|
|
209
|
+
// writes, so either arrival order of the idempotent PUT yields user then assistant.
|
|
210
|
+
const saved = await history.appendMessage({ conversationId: required.conversationId, message: userMessage });
|
|
211
|
+
if (!saved || saved.role !== "user")
|
|
212
|
+
throw new Error("Accepted user message could not be persisted");
|
|
213
|
+
}
|
|
214
|
+
/** @returns false when the answer id collides and no question was written. */
|
|
215
|
+
async function appendQueuedAnswer({ history, required, hasQuestion }, _options = {}) {
|
|
216
|
+
const message = { id: required.messageId, role: "assistant", content: "", events: [],
|
|
217
|
+
runId: required.runId, runStatus: "queued", createdAt: required.now, startedAt: required.now,
|
|
218
|
+
...(required.request.agentId ? { agentId: required.request.agentId } : {}) };
|
|
219
|
+
const savedMessage = await history.appendMessage({ conversationId: required.conversationId, message });
|
|
220
|
+
if (savedMessage?.role === "assistant")
|
|
221
|
+
return true;
|
|
222
|
+
// Returning null here would commit the question alone after an ID collision.
|
|
223
|
+
if (hasQuestion)
|
|
224
|
+
throw new Error("Accepted assistant message could not be persisted");
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
143
227
|
async function accept(required, _options = {}) {
|
|
144
228
|
return kernel.transaction(async () => {
|
|
145
|
-
await
|
|
146
|
-
|
|
147
|
-
const history = createChatHistoryStore({ kernel, isDaemonRunId, scope: { scopeId: required.workspaceId, ownerKind: "user", ownerId: required.principalId } }, {});
|
|
148
|
-
if (!await history.get({ id: required.conversationId }))
|
|
229
|
+
const history = await lockOwnedConversation({ required });
|
|
230
|
+
if (!history)
|
|
149
231
|
return null;
|
|
150
232
|
const existing = await load({ messageId: required.messageId }, {});
|
|
151
233
|
if (existing)
|
|
152
|
-
return
|
|
153
|
-
|
|
154
|
-
.where("conversation_id", "=", required.conversationId).where("role", "=", "assistant")
|
|
155
|
-
.where("run_status", "in", ["queued", "running"]).executeTakeFirst());
|
|
156
|
-
if (occupied)
|
|
157
|
-
throw new RunSlotBusyError();
|
|
234
|
+
return sameOwner({ run: existing, required }) ? existing : null;
|
|
235
|
+
await assertSlotFree({ conversationId: required.conversationId });
|
|
158
236
|
const userMessage = acceptedUserMessage({ request: required.request, now: required.now }, {});
|
|
159
|
-
if (userMessage)
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
// A pending/failed browser PUT must never let the assistant take the first position.
|
|
163
|
-
// Nested appends join this transaction and retain the conversation lock through both
|
|
164
|
-
// writes, so either arrival order of the idempotent PUT yields user then assistant.
|
|
165
|
-
const saved = await history.appendMessage({ conversationId: required.conversationId, message: userMessage });
|
|
166
|
-
if (!saved || saved.role !== "user")
|
|
167
|
-
throw new Error("Accepted user message could not be persisted");
|
|
168
|
-
}
|
|
169
|
-
const message = { id: required.messageId, role: "assistant", content: "", events: [],
|
|
170
|
-
runId: required.runId, runStatus: "queued", createdAt: required.now, startedAt: required.now,
|
|
171
|
-
...(required.request.agentId ? { agentId: required.request.agentId } : {}) };
|
|
172
|
-
const savedMessage = await history.appendMessage({ conversationId: required.conversationId, message });
|
|
173
|
-
if (!savedMessage || savedMessage.role !== "assistant") {
|
|
174
|
-
// Returning null here would commit the question alone after an ID collision.
|
|
175
|
-
if (userMessage)
|
|
176
|
-
throw new Error("Accepted assistant message could not be persisted");
|
|
237
|
+
if (userMessage)
|
|
238
|
+
await appendAcceptedQuestion({ history, required, userMessage });
|
|
239
|
+
if (!await appendQueuedAnswer({ history, required, hasQuestion: userMessage !== null }))
|
|
177
240
|
return null;
|
|
178
|
-
}
|
|
179
241
|
await kernel.run((db) => db.insertInto("assistant_run_attempts").values({
|
|
180
242
|
message_id: required.messageId, engine: "daemon", accepted_json: JSON.stringify(required.request),
|
|
181
243
|
recovery_count: 0, recovery_deadline: null, last_progress_at: required.now, cancel_reason: null,
|
|
@@ -188,7 +250,8 @@ export function createDurableRunStore({ kernel: hostKernel, isDaemonRunId }, _op
|
|
|
188
250
|
async function advance({ run, nextRunId, now, events }, _options = {}) {
|
|
189
251
|
return kernel.transaction(async () => {
|
|
190
252
|
await kernel.lockKey(runLockKey(run));
|
|
191
|
-
|
|
253
|
+
// The projection rebase needs this run's events, never the rest of its conversation.
|
|
254
|
+
const current = (await lockedRead(run, { events: true }))?.head;
|
|
192
255
|
if (!current || current.cancelReason || current.runId !== run.runId)
|
|
193
256
|
return false;
|
|
194
257
|
const projection = continuationEvents({ current, incoming: events }, {});
|
|
@@ -212,28 +275,33 @@ export function createDurableRunStore({ kernel: hostKernel, isDaemonRunId }, _op
|
|
|
212
275
|
return true;
|
|
213
276
|
});
|
|
214
277
|
}
|
|
278
|
+
/** Probe without the run lock, take it, then read: a run deleted or advanced in the gap reads as gone.
|
|
279
|
+
* One read under the lock serves the whole operation; the unlocked probe only names the lock.
|
|
280
|
+
* @returns The run as of the lock, or null when the attempt id is unknown or no longer current. */
|
|
281
|
+
async function lockedRun({ runId }, options = {}) {
|
|
282
|
+
const probe = await probeRun({ runId });
|
|
283
|
+
if (!probe)
|
|
284
|
+
return null;
|
|
285
|
+
await kernel.lockKey(runLockKey({ messageId: probe.id, conversationId: probe.conversation_id, runId }));
|
|
286
|
+
return (await readRun({ match: { column: "ai_chat_messages.run_id", value: runId } }, options))?.head ?? null;
|
|
287
|
+
}
|
|
215
288
|
async function cancel({ runId, reason }, _options = {}) {
|
|
216
289
|
await kernel.transaction(async () => {
|
|
217
|
-
const run = await
|
|
290
|
+
const run = await lockedRun({ runId });
|
|
218
291
|
if (!run)
|
|
219
292
|
return;
|
|
220
|
-
await kernel.lockKey(runLockKey(run));
|
|
221
|
-
if (!await find({ runId }, {}))
|
|
222
|
-
return;
|
|
223
293
|
await kernel.run((db) => db.insertInto("assistant_run_attempts").values({ ...metadataForRun({ run }, {}), cancel_reason: reason })
|
|
224
294
|
.onConflict((oc) => oc.column("message_id").doUpdateSet({ cancel_reason: reason })).execute());
|
|
225
295
|
});
|
|
226
296
|
}
|
|
227
297
|
async function cancelPending(required, _options = {}) {
|
|
228
298
|
return kernel.transaction(async () => {
|
|
229
|
-
await
|
|
230
|
-
|
|
231
|
-
const history = createChatHistoryStore({ kernel, isDaemonRunId, scope: { scopeId: required.workspaceId, ownerKind: "user", ownerId: required.principalId } }, {});
|
|
232
|
-
if (!await history.get({ id: required.conversationId }))
|
|
299
|
+
const history = await lockOwnedConversation({ required });
|
|
300
|
+
if (!history)
|
|
233
301
|
return null;
|
|
234
302
|
const existing = await load({ messageId: required.messageId }, {});
|
|
235
303
|
if (existing) {
|
|
236
|
-
if (
|
|
304
|
+
if (!sameOwner({ run: existing, required }))
|
|
237
305
|
return null;
|
|
238
306
|
await cancel({ runId: existing.runId, reason: "user-stop" }, {});
|
|
239
307
|
return load({ messageId: existing.messageId }, {});
|
|
@@ -247,15 +315,11 @@ export function createDurableRunStore({ kernel: hostKernel, isDaemonRunId }, _op
|
|
|
247
315
|
}
|
|
248
316
|
async function captureSession({ runId, sessionId, child, confirmed = true }, _options = {}) {
|
|
249
317
|
return kernel.transaction(async () => {
|
|
250
|
-
const run = await
|
|
251
|
-
if (!run)
|
|
252
|
-
return false;
|
|
253
|
-
await kernel.lockKey(runLockKey(run));
|
|
254
|
-
const current = await find({ runId }, {});
|
|
255
|
-
if (!attemptCanExecute({ run: current }, {}) || !current)
|
|
318
|
+
const run = await lockedRun({ runId });
|
|
319
|
+
if (!run || !attemptCanExecute({ run }, {}))
|
|
256
320
|
return false;
|
|
257
321
|
const fields = { session_id: sessionId, session_confirmed: confirmed ? 1 : 0, ...(child ? { child_pid: child.pid, child_started_at: child.startedAt } : {}) };
|
|
258
|
-
await kernel.run((db) => db.insertInto("assistant_run_attempts").values({ ...metadataForRun({ run
|
|
322
|
+
await kernel.run((db) => db.insertInto("assistant_run_attempts").values({ ...metadataForRun({ run }, {}), ...fields })
|
|
259
323
|
.onConflict((oc) => oc.column("message_id").doUpdateSet(fields)).execute());
|
|
260
324
|
// Session and generation share the same lock/transaction; an old callback cannot write
|
|
261
325
|
// the conversation's locator in the gap between a successful guard and a later CAS.
|
|
@@ -267,11 +331,7 @@ export function createDurableRunStore({ kernel: hostKernel, isDaemonRunId }, _op
|
|
|
267
331
|
}
|
|
268
332
|
async function clearSession({ runId, sessionId }, _options = {}) {
|
|
269
333
|
await kernel.transaction(async () => {
|
|
270
|
-
const
|
|
271
|
-
if (!before)
|
|
272
|
-
return;
|
|
273
|
-
await kernel.lockKey(runLockKey(before));
|
|
274
|
-
const run = await find({ runId }, {});
|
|
334
|
+
const run = await lockedRun({ runId });
|
|
275
335
|
if (!run)
|
|
276
336
|
return;
|
|
277
337
|
await kernel.run((db) => db.deleteFrom("assistant_agent_sessions").where("conversation_id", "=", run.conversationId)
|
|
@@ -282,14 +342,9 @@ export function createDurableRunStore({ kernel: hostKernel, isDaemonRunId }, _op
|
|
|
282
342
|
}
|
|
283
343
|
async function guardTool(required, _options = {}) {
|
|
284
344
|
return kernel.transaction(async () => {
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
await kernel.lockKey(runLockKey(before));
|
|
289
|
-
const run = await find({ runId: required.runId }, {});
|
|
290
|
-
if (!attemptCanExecute({ run }, {}))
|
|
291
|
-
return "stale";
|
|
292
|
-
if (!run)
|
|
345
|
+
// The unknown-call check needs this run's saved events (one row), never other messages'.
|
|
346
|
+
const run = await lockedRun({ runId: required.runId }, { events: true });
|
|
347
|
+
if (!run || !attemptCanExecute({ run }, {}))
|
|
293
348
|
return "stale";
|
|
294
349
|
const events = run.message.events ?? [];
|
|
295
350
|
const canonical = events.filter((event) => event.kind !== "tool_use" || event.name === required.toolId);
|
|
@@ -301,7 +356,7 @@ export function createDurableRunStore({ kernel: hostKernel, isDaemonRunId }, _op
|
|
|
301
356
|
});
|
|
302
357
|
}
|
|
303
358
|
async function writeToolEvent(run, event) {
|
|
304
|
-
await kernel.run((db) => db.updateTable("ai_chat_messages").set({ events_json:
|
|
359
|
+
await kernel.run((db) => db.updateTable("ai_chat_messages").set({ events_json: appendedEventsJson({ event }) })
|
|
305
360
|
.where("id", "=", run.messageId).where("run_id", "=", run.runId).where("run_status", "in", ["queued", "running"]).execute());
|
|
306
361
|
await kernel.run((db) => db.updateTable("assistant_run_attempts")
|
|
307
362
|
.set({ recovery_count: 0, recovery_deadline: null, recovery_elapsed_ms: 0, last_progress_at: Date.now() })
|
|
@@ -309,23 +364,16 @@ export function createDurableRunStore({ kernel: hostKernel, isDaemonRunId }, _op
|
|
|
309
364
|
}
|
|
310
365
|
async function completeTool({ runId, toolUseId, content }, _options = {}) {
|
|
311
366
|
await kernel.transaction(async () => {
|
|
312
|
-
const
|
|
313
|
-
if (!before)
|
|
314
|
-
return;
|
|
315
|
-
await kernel.lockKey(runLockKey(before));
|
|
316
|
-
const run = await find({ runId }, {});
|
|
367
|
+
const run = await lockedRun({ runId });
|
|
317
368
|
if (run)
|
|
318
369
|
await writeToolEvent(run, { kind: "tool_result", toolUseId, content, isError: false });
|
|
319
370
|
});
|
|
320
371
|
}
|
|
321
372
|
async function recoveryClock({ runId, now, active }, _options = {}) {
|
|
322
373
|
await kernel.transaction(async () => {
|
|
323
|
-
const run = await
|
|
374
|
+
const run = await lockedRun({ runId });
|
|
324
375
|
if (!run)
|
|
325
376
|
return;
|
|
326
|
-
await kernel.lockKey(runLockKey(run));
|
|
327
|
-
if (!await find({ runId }, {}))
|
|
328
|
-
return;
|
|
329
377
|
const row = await kernel.run((db) => db.selectFrom("assistant_run_attempts").selectAll().where("message_id", "=", run.messageId).executeTakeFirst())
|
|
330
378
|
?? metadataForRun({ run }, {});
|
|
331
379
|
let deadline = row.recovery_deadline;
|