@k2b/cloud 0.6.0 → 0.8.0
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/README.md +2 -2
- package/package.json +22 -11
- package/scripts/README.md +10 -0
- package/scripts/browser-performance.ts +14 -0
- package/scripts/build-pdf-renderer.ts +39 -0
- package/scripts/build.ts +20 -35
- package/scripts/preload.ts +16 -12
- package/scripts/sync-recovery-smoke.ts +22 -6
- package/src/_internal/app-presentation.ts +14 -2
- package/src/_internal/build-metadata.ts +8 -1
- package/src/_internal/capabilities.ts +131 -7
- package/src/_internal/capability-streams.ts +81 -0
- package/src/_internal/define-app.ts +80 -37
- package/src/_internal/heartbeat.ts +4 -0
- package/src/_internal/help-catalog.ts +27 -172
- package/src/_internal/help.ts +13 -76
- package/src/_internal/page-responses.ts +2 -0
- package/src/_internal/postgres-application-name.ts +30 -0
- package/src/_internal/process-identity.ts +15 -0
- package/src/_internal/process-sync.ts +1 -1
- package/src/_internal/readiness.ts +4 -1
- package/src/_internal/registry-validation.ts +29 -25
- package/src/_internal/registry.ts +2 -53
- package/src/_internal/runtime-context.ts +5 -16
- package/src/_internal/server-timing.ts +13 -0
- package/src/_internal/status-preserving-ssr.ts +18 -6
- package/src/_internal/web-vitals-asset.ts +10 -0
- package/src/access/PermissionEditor.tsx +18 -151
- package/src/access/PrincipalPicker.tsx +110 -0
- package/src/access/ResourceApiKeys.tsx +1 -1
- package/src/access/ui.ts +1 -0
- package/src/ai/admin.ts +14 -13
- package/src/ai/assistant-models.ts +12 -1
- package/src/ai/audio-format.ts +34 -0
- package/src/ai/audio-tool.ts +90 -0
- package/src/ai/browser-code-contracts.ts +201 -0
- package/src/ai/browser.ts +4 -0
- package/src/ai/capabilities.ts +55 -36
- package/src/ai/capability-execution.ts +47 -12
- package/src/ai/chat/blocks.tsx +229 -90
- package/src/ai/chat/builtin-tools.tsx +2 -4
- package/src/ai/chat/capability-result.ts +15 -0
- package/src/ai/chat/capability-table.tsx +2 -10
- package/src/ai/chat/composer-adapter.ts +83 -3
- package/src/ai/chat/message-actions.tsx +8 -7
- package/src/ai/chat/message-utils.ts +25 -1
- package/src/ai/chat/messages.ts +2 -0
- package/src/ai/chat/presentation.tsx +31 -47
- package/src/ai/chat/primitives.tsx +43 -1
- package/src/ai/chat/tool-disclosure.tsx +21 -2
- package/src/ai/chat/tool-groups.ts +58 -0
- package/src/ai/chat-quotas.ts +25 -0
- package/src/ai/chat-task-contracts.ts +14 -10
- package/src/ai/chat-tasks.ts +297 -43
- package/src/ai/client/controller.ts +165 -39
- package/src/ai/client/file-source.ts +12 -6
- package/src/ai/client/live-connection.ts +1 -0
- package/src/ai/client/projection.ts +29 -11
- package/src/ai/client/transport.ts +6 -0
- package/src/ai/code-capability-routes.ts +105 -0
- package/src/ai/code-capability-transport.ts +39 -0
- package/src/ai/code-execution.ts +53 -0
- package/src/ai/code-mode-skill.ts +118 -0
- package/src/ai/code-runtime-tools.ts +179 -0
- package/src/ai/code-source-contracts.ts +270 -0
- package/src/ai/code-source-tools.ts +188 -0
- package/src/ai/compaction.ts +27 -6
- package/src/ai/data-analysis-skill.ts +10 -0
- package/src/ai/default-tools.ts +99 -3
- package/src/ai/dictation-runtime.ts +171 -0
- package/src/ai/dictations.ts +188 -0
- package/src/ai/draft-content.ts +49 -0
- package/src/ai/enrich.ts +6 -0
- package/src/ai/executor.ts +189 -33
- package/src/ai/fetch-file-tool.ts +2 -2
- package/src/ai/file-content-version.ts +27 -0
- package/src/ai/file-context.ts +2 -1
- package/src/ai/file-media-type.ts +12 -0
- package/src/ai/file-reference-contracts.ts +15 -0
- package/src/ai/file-tools.ts +8 -1
- package/src/ai/files-store.ts +212 -71
- package/src/ai/fixtures/vision-encrypted.pdf +0 -0
- package/src/ai/grids-skill.ts +42 -10
- package/src/ai/http.ts +26 -8
- package/src/ai/index.ts +26 -3
- package/src/ai/inference-calls.ts +197 -0
- package/src/ai/live-events.ts +5 -1
- package/src/ai/live.ts +1 -1
- package/src/ai/markdown-pdf-tool.ts +2 -2
- package/src/ai/memories.ts +30 -27
- package/src/ai/memory-learning-runs.ts +1 -1
- package/src/ai/memory-learning.ts +64 -32
- package/src/ai/memory-tool.ts +1 -5
- package/src/ai/memory-workflow-evidence.ts +9 -4
- package/src/ai/message-queue.ts +178 -0
- package/src/ai/migrate.ts +189 -11
- package/src/ai/model-pricing.ts +43 -0
- package/src/ai/pdf-render-worker.ts +75 -0
- package/src/ai/pdf-render.fixture.ts +28 -0
- package/src/ai/pdf-render.ts +77 -0
- package/src/ai/projects-routes.ts +18 -43
- package/src/ai/projects.ts +33 -14
- package/src/ai/protocol.ts +3 -0
- package/src/ai/provider.ts +1 -8
- package/src/ai/quota-provider.ts +192 -0
- package/src/ai/quota-report.ts +152 -0
- package/src/ai/quotas-migrate.ts +45 -0
- package/src/ai/quotas.ts +168 -0
- package/src/ai/routes.ts +241 -11
- package/src/ai/run-timeout.ts +12 -0
- package/src/ai/runtime-tools.ts +7 -5
- package/src/ai/runtime.ts +74 -17
- package/src/ai/settings.ts +36 -8
- package/src/ai/short-id.ts +4 -5
- package/src/ai/skill-catalog.ts +0 -10
- package/src/ai/skill-search.ts +58 -0
- package/src/ai/skill-seeds.ts +114 -20
- package/src/ai/skill-tool.ts +21 -5
- package/src/ai/skills-routes.ts +49 -4
- package/src/ai/skills.ts +358 -163
- package/src/ai/store.ts +416 -201
- package/src/ai/stream.ts +5 -0
- package/src/ai/structured-runs.ts +4 -3
- package/src/ai/structured.ts +12 -1
- package/src/ai/system-prompt.ts +14 -2
- package/src/ai/task-contracts.ts +84 -0
- package/src/ai/task-execution.ts +92 -0
- package/src/ai/timeline.ts +2 -5
- package/src/ai/todo-contracts.ts +32 -0
- package/src/ai/todo-tool.ts +19 -0
- package/src/ai/tool-audit.ts +30 -40
- package/src/ai/tools.ts +6 -0
- package/src/ai/transcription.ts +202 -0
- package/src/ai/turn-timing.ts +114 -0
- package/src/ai/types.ts +58 -10
- package/src/ai/ui.tsx +3 -0
- package/src/ai/usage.ts +118 -118
- package/src/ai/vision-tool.ts +60 -10
- package/src/api/admin-ai-projects.ts +2 -2
- package/src/api/admin-ai-quotas.ts +80 -0
- package/src/api/admin-ai-skills.ts +48 -5
- package/src/api/admin-ai-usage.ts +2 -2
- package/src/api/admin-core-settings.ts +25 -0
- package/src/api/admin-identity.ts +91 -51
- package/src/api/admin-linux-identities.ts +10 -4
- package/src/api/admin-rail.ts +58 -0
- package/src/api/announcements.ts +14 -0
- package/src/api/app-approval.ts +2 -2
- package/src/api/auth.ts +1 -1
- package/src/api/capabilities.ts +324 -43
- package/src/api/capability-streams.ts +262 -0
- package/src/api/help.ts +8 -44
- package/src/api/index.ts +8 -2
- package/src/api/mcp.ts +45 -34
- package/src/api/me-web-vitals.ts +35 -0
- package/src/api/me.ts +2 -0
- package/src/api/search/schemas.ts +26 -15
- package/src/api/search.bench.ts +1 -1
- package/src/api/search.ts +54 -17
- package/src/browser/CloudResourceSearch.tsx +640 -702
- package/src/browser/app-approval-vault.ts +19 -7
- package/src/browser/command-bridge.ts +109 -0
- package/src/browser/command-shortcuts.ts +76 -0
- package/src/browser/commands.ts +67 -0
- package/src/browser/navigation-search.ts +48 -0
- package/src/browser/notifications.ts +1 -1
- package/src/browser/resource-picker.tsx +5 -18
- package/src/browser/resource-search-dialog.ts +9 -0
- package/src/browser/resource-search-input.ts +46 -0
- package/src/browser/resource-search-messages.ts +76 -40
- package/src/browser/resource-search.ts +7 -0
- package/src/browser/search-bridge.ts +102 -0
- package/src/browser/search-commands.ts +59 -0
- package/src/browser/search.ts +13 -0
- package/src/browser/spotlight-position.ts +208 -0
- package/src/browser/testing.ts +6 -0
- package/src/browser/web-vitals.ts +25 -0
- package/src/capabilities/claims.ts +179 -0
- package/src/capabilities/client.ts +34 -0
- package/src/capabilities/command-link.ts +16 -0
- package/src/capabilities/executions.ts +298 -0
- package/src/capabilities/migrate.ts +81 -0
- package/src/capabilities/server.ts +116 -35
- package/src/capabilities/stream-body.ts +16 -0
- package/src/capabilities/streams.ts +28 -0
- package/src/cli/admin/ai-quotas.ts +161 -0
- package/src/cli/admin/ai-skills.ts +64 -0
- package/src/cli/admin/ai-usage.ts +53 -30
- package/src/cli/admin/app-credentials.ts +77 -0
- package/src/cli/admin/data.ts +17 -0
- package/src/cli/admin/index.ts +10 -1
- package/src/cli/admin/instance.ts +2 -2
- package/src/cli/admin/linux.ts +4 -2
- package/src/cli/admin/sync.ts +1 -1
- package/src/cli/capabilities.ts +42 -0
- package/src/cli/capability-streams.ts +25 -0
- package/src/cli/index.ts +3 -2
- package/src/config/define-env.ts +97 -0
- package/src/config/env.ts +147 -51
- package/src/config/index.ts +3 -1
- package/src/contracts/app.ts +23 -3
- package/src/contracts/capabilities.ts +75 -5
- package/src/contracts/capability-streams.ts +46 -0
- package/src/contracts/commands.ts +49 -0
- package/src/contracts/index.ts +5 -2
- package/src/contracts/rail-admin.ts +38 -0
- package/src/contracts/rail-preferences.ts +16 -0
- package/src/contracts/registry.ts +5 -27
- package/src/contracts/settings-types.ts +1 -1
- package/src/contracts/shared.ts +18 -5
- package/src/contracts/web-vitals.ts +27 -0
- package/src/desktop/index.ts +1 -0
- package/src/desktop/solid.tsx +1 -1
- package/src/index.ts +8 -6
- package/src/server/index.ts +3 -0
- package/src/server/middleware/auth.ts +3 -2
- package/src/server/middleware/route-template.ts +9 -3
- package/src/server/middleware/runtime.ts +2 -1
- package/src/server/middleware/settings.ts +17 -11
- package/src/server/ratelimit.ts +1 -1
- package/src/server/services/access-revision.ts +10 -0
- package/src/server/services/access.ts +3 -1
- package/src/server/services/index.ts +1 -0
- package/src/services/account-category-policy.ts +9 -0
- package/src/services/account-lifecycle/audit.ts +1 -1
- package/src/services/accounts/app.ts +12 -2
- package/src/services/accounts/email-write.ts +23 -0
- package/src/services/accounts/groups.ts +15 -4
- package/src/services/accounts/identities.ts +249 -0
- package/src/services/accounts/identity-reconciliation.ts +87 -0
- package/src/services/accounts/ipa-data.ts +2 -2
- package/src/services/accounts/local-groups.ts +2 -2
- package/src/services/accounts/notification-sender.ts +12 -2
- package/src/services/accounts/posix.ts +45 -29
- package/src/services/accounts/users.ts +2 -2
- package/src/services/announcements/index.ts +69 -8
- package/src/services/app-approval.ts +11 -10
- package/src/services/audit/index.ts +1 -1
- package/src/services/cache-fill.ts +35 -0
- package/src/services/help/index.ts +109 -0
- package/src/services/help/maintenance.ts +25 -0
- package/src/services/help/store.ts +180 -0
- package/src/services/help/types.ts +46 -0
- package/src/services/identity/invocation-actor.ts +2 -2
- package/src/services/identity/key-config.ts +8 -5
- package/src/services/identity/key-ring.ts +1 -1
- package/src/services/identity/runtime-config.ts +6 -5
- package/src/services/index.ts +15 -0
- package/src/services/ipa/posix.ts +1 -1
- package/src/services/ipa/sync.ts +1 -1
- package/src/services/ipa/users.ts +268 -221
- package/src/services/legal-consent.ts +1 -1
- package/src/services/logging/index.ts +15 -13
- package/src/services/logging/trace.ts +2 -1
- package/src/services/logging/web-vitals.ts +81 -0
- package/src/services/mandates/index.ts +21 -4
- package/src/services/mandates/policy.ts +165 -2
- package/src/services/notifications/batches.ts +125 -57
- package/src/services/notifications/browser.ts +12 -0
- package/src/services/notifications/index.ts +0 -2
- package/src/services/notifications/platform.ts +0 -4
- package/src/services/oauth-tokens.ts +3 -3
- package/src/services/pdf/gotenberg.ts +131 -72
- package/src/services/pdf/index.ts +11 -8
- package/src/services/providers/local/users.ts +25 -16
- package/src/services/public-http.ts +101 -0
- package/src/services/rail-shortcuts.ts +95 -0
- package/src/services/rail-snapshot.ts +53 -0
- package/src/services/request-cache-redis.ts +29 -0
- package/src/services/session/index.ts +15 -2
- package/src/services/session/user.ts +62 -37
- package/src/services/settings/app.ts +5 -1
- package/src/services/settings/core-settings.ts +26 -35
- package/src/services/settings/defaults.ts +5 -0
- package/src/services/settings/index.ts +3 -3
- package/src/services/settings/store.ts +58 -84
- package/src/services/webauthn.ts +1 -1
- package/src/shared/ai-costs.ts +42 -0
- package/src/shared/ai-platform-prompt.ts +1 -1
- package/src/shared/ai-quotas.ts +136 -0
- package/src/shared/ai-usage.ts +6 -16
- package/src/shared/app-presentation.ts +21 -15
- package/src/shared/capability-messages.ts +14 -0
- package/src/shared/help.ts +6 -42
- package/src/shared/index.ts +3 -0
- package/src/shared/markdown/extensions/info-blocks.ts +1 -1
- package/src/ssr/AdminLayout.tsx +7 -5
- package/src/ssr/AdminSidebar.tsx +30 -16
- package/src/ssr/AppLaunchpad.island.tsx +33 -136
- package/src/ssr/AppLaunchpadPanel.tsx +181 -0
- package/src/ssr/BrowserPushRegistration.island.tsx +12 -0
- package/src/ssr/GlobalSearchDialog.tsx +167 -20
- package/src/ssr/GlobalSearchTrigger.island.tsx +33 -22
- package/src/ssr/Layout.tsx +17 -6
- package/src/ssr/LayoutBreadcrumbs.island.tsx +3 -5
- package/src/ssr/LayoutFooter.tsx +1 -1
- package/src/ssr/LayoutHeader.tsx +46 -14
- package/src/ssr/LayoutHelp.tsx +22 -23
- package/src/ssr/{HotkeysHelpRail.island.tsx → LayoutHelpTrigger.island.tsx} +18 -12
- package/src/ssr/LayoutRail.tsx +17 -9
- package/src/ssr/MinimalLayout.tsx +1 -1
- package/src/ssr/MobileNavigation.tsx +71 -0
- package/src/ssr/MobileProfileActions.tsx +49 -0
- package/src/ssr/ProfilePreferences.island.tsx +3 -36
- package/src/ssr/RailApps.island.tsx +2 -2
- package/src/ssr/RailEditor.tsx +17 -2
- package/src/ssr/WorkspaceNavigation.island.tsx +7 -0
- package/src/ssr/WorkspaceNavigationProvider.tsx +16 -0
- package/src/ssr/admin-navigation.ts +11 -1
- package/src/ssr/help.ts +29 -0
- package/src/ssr/islands/SearchBar.tsx +67 -0
- package/src/ssr/islands/index.ts +3 -1
- package/src/ssr/layout-context.ts +2 -2
- package/src/ssr/layout-help-search.ts +15 -0
- package/src/ssr/mobile-menu-history.ts +70 -0
- package/src/ssr/platform-messages.ts +16 -8
- package/src/ssr/profile-actions.ts +42 -0
- package/src/ssr/rail-context.ts +8 -4
- package/src/ssr/rail-messages.ts +2 -0
- package/src/ssr/rail-navigation.ts +3 -3
- package/src/ssr/workspace-navigation.ts +75 -0
- package/src/styles/effects.css +6 -6
- package/src/styles/global.css +54 -0
- package/src/styles/input.css +43 -41
- package/src/styles/resource-search.css +505 -0
- package/src/styles/utilities-feedback.css +13 -0
- package/src/styles/utilities-layout.css +1 -5
- package/src/styles/utilities-navigation.css +3 -39
- package/src/types/ambient.d.ts +5 -0
- package/src/workflows/ai/index.ts +2 -2
- package/src/workflows/ai/runtime.ts +19 -90
- package/src/workflows/ai/store.ts +2 -0
- package/src/workflows/ai/types.ts +6 -82
- package/src/workflows/ai-actions.ts +16 -13
- package/src/workflows/definition.ts +10 -5
- package/src/workflows/store/actions.ts +44 -20
- package/src/workflows/store/index.ts +4 -2
- package/src/workflows/store/runs.ts +36 -0
- package/src/workflows/store/worker-pool.ts +55 -0
- package/src/workflows/store/worker-runtime.ts +90 -0
- package/src/ai/kit-skill.ts +0 -22
- package/src/ai/usage-migrate.ts +0 -81
- package/src/browser/notification-target.ts +0 -27
- package/src/contracts/notification-live.ts +0 -66
- package/src/server/services/freeipa/test-certificates.ts +0 -44
- package/src/services/notifications/live.ts +0 -50
- package/src/services/session/test-fixture.ts +0 -18
- package/src/ssr/BrowserNotifications.island.tsx +0 -135
- package/src/ssr/islands/SearchBar.island.tsx +0 -80
package/src/ai/store.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import type { DoneReason, InboundEvent, LoopAggregate, Message, SessionStore, StoreEntry } from "@k2b/nessi";
|
|
2
2
|
import type { Usage } from "@k2b/nessi/ai";
|
|
3
|
-
import { sql } from "bun";
|
|
3
|
+
import { type SQL, sql } from "bun";
|
|
4
4
|
import { type CapabilityActionReview, CapabilityActionReviewSchema } from "../contracts/capabilities";
|
|
5
5
|
import { logger } from "../services/logging";
|
|
6
6
|
import { toPgTextArray } from "../services/postgres";
|
|
7
7
|
import type { AiTurnBlock } from "./protocol";
|
|
8
8
|
import { withAiShortId, withAiShortIdForDb } from "./short-id";
|
|
9
|
+
import { parseAiTodoPlan } from "./todo-contracts";
|
|
9
10
|
import type {
|
|
10
11
|
AiConversation,
|
|
11
12
|
AiConversationDraft,
|
|
@@ -89,6 +90,7 @@ const isSearchCapabilityError = (error: unknown): boolean =>
|
|
|
89
90
|
typeof error === "object" && error !== null && "code" in error && SEARCH_CAPABILITY_ERROR_CODES.has(String(error.code));
|
|
90
91
|
|
|
91
92
|
type ConversationRow = {
|
|
93
|
+
activity?: AiConversation["activity"];
|
|
92
94
|
id: string;
|
|
93
95
|
short_id: string;
|
|
94
96
|
title: string;
|
|
@@ -98,8 +100,14 @@ type ConversationRow = {
|
|
|
98
100
|
keywords: string[] | null;
|
|
99
101
|
pinned_at: Date | string | null;
|
|
100
102
|
archived_at: Date | string | null;
|
|
103
|
+
done: boolean | null;
|
|
104
|
+
last_used_at: Date | string;
|
|
105
|
+
is_done?: boolean;
|
|
106
|
+
has_active_schedule?: boolean;
|
|
107
|
+
background_received_at?: Date | string | null;
|
|
101
108
|
last_viewed_at: Date | string | null;
|
|
102
109
|
latest_turn_status?: AiTurnStatus | null;
|
|
110
|
+
latest_browser_pending?: boolean;
|
|
103
111
|
latest_turn_error?: string | null;
|
|
104
112
|
latest_turn_completed_at?: Date | string | null;
|
|
105
113
|
enrich_fail_count: number | null;
|
|
@@ -157,6 +165,9 @@ type MessageRow = {
|
|
|
157
165
|
};
|
|
158
166
|
|
|
159
167
|
type TurnRow = {
|
|
168
|
+
deadline?: Date | string | null;
|
|
169
|
+
run_budget_ms?: number | null;
|
|
170
|
+
cancel_requested_at?: Date | string | null;
|
|
160
171
|
id: string;
|
|
161
172
|
short_id: string;
|
|
162
173
|
conversation_id: string;
|
|
@@ -360,7 +371,7 @@ const rowToInterChatMessage = (row: InterChatMessageRow): AiInterChatMessage =>
|
|
|
360
371
|
deliveredAt: row.delivered_at ? iso(row.delivered_at) : null,
|
|
361
372
|
});
|
|
362
373
|
|
|
363
|
-
const interChatMessageSelect = sql`
|
|
374
|
+
const interChatMessageSelect = () => sql`
|
|
364
375
|
SELECT message.id, message.short_id, message.source_conversation_id,
|
|
365
376
|
source.short_id AS source_chat_id, source.title AS source_title,
|
|
366
377
|
message.source_turn_id, source_turn.short_id AS source_turn_short_id, message.source_call_id,
|
|
@@ -470,16 +481,55 @@ const parseCapabilityActionReview = (value: unknown): CapabilityActionReview | u
|
|
|
470
481
|
|
|
471
482
|
const fieldSource = (value: string | null): AiConversation["titleSource"] => (value === "auto" || value === "user" ? value : "default");
|
|
472
483
|
|
|
473
|
-
|
|
484
|
+
// Pins stay active; otherwise explicit choices win and automatic completion never hides pending work.
|
|
485
|
+
const effectiveDone = () => sql`(conversation.pinned_at IS NULL AND COALESCE(conversation.done,
|
|
486
|
+
conversation.last_used_at <= now() - interval '7 days'
|
|
487
|
+
AND NOT EXISTS (SELECT 1 FROM ai.chat_tasks schedule WHERE schedule.conversation_id = conversation.id AND schedule.state = 'active')
|
|
488
|
+
AND NOT EXISTS (SELECT 1 FROM ai.turns active_turn
|
|
489
|
+
WHERE active_turn.conversation_id = conversation.id
|
|
490
|
+
AND NOT COALESCE(active_turn.run_config ? 'background', false)
|
|
491
|
+
AND active_turn.status IN ('queued', 'running', 'waiting_for_action'))
|
|
492
|
+
AND NOT EXISTS (SELECT 1 FROM ai.queued_messages pending WHERE pending.conversation_id = conversation.id AND pending.status IN ('pending','failed'))))`;
|
|
493
|
+
|
|
494
|
+
const browserWorkPending = () => sql`(
|
|
495
|
+
latest.status = 'waiting_for_action'
|
|
496
|
+
AND EXISTS (
|
|
497
|
+
SELECT 1 FROM jsonb_array_elements(CASE WHEN jsonb_typeof(latest.live_blocks) = 'array' THEN latest.live_blocks ELSE '[]'::jsonb END) block
|
|
498
|
+
WHERE block->>'kind' = 'tool' AND block->>'status' = 'awaiting_client' AND block->>'frontendMode' = 'client'
|
|
499
|
+
)
|
|
500
|
+
AND NOT EXISTS (
|
|
501
|
+
SELECT 1 FROM jsonb_array_elements(CASE WHEN jsonb_typeof(latest.live_blocks) = 'array' THEN latest.live_blocks ELSE '[]'::jsonb END) block
|
|
502
|
+
WHERE block->>'kind' = 'tool' AND (
|
|
503
|
+
block->>'status' = 'awaiting_approval'
|
|
504
|
+
OR (block->>'status' = 'awaiting_client' AND COALESCE(block->>'frontendMode', '') <> 'client')
|
|
505
|
+
)
|
|
506
|
+
)
|
|
507
|
+
)`;
|
|
508
|
+
|
|
509
|
+
const conversationRunStatus = (status: AiTurnStatus | null | undefined, browserPending = false): AiConversation["runStatus"] => {
|
|
474
510
|
if (status === "queued") return "queued";
|
|
475
511
|
if (status === "running") return "running";
|
|
476
|
-
if (status === "waiting_for_action") return "needs_attention";
|
|
512
|
+
if (status === "waiting_for_action") return browserPending ? "waiting_for_browser" : "needs_attention";
|
|
477
513
|
if (status === "failed") return "failed";
|
|
478
514
|
return "idle";
|
|
479
515
|
};
|
|
480
516
|
|
|
517
|
+
// Bun SQL may return historical JSON payloads as string-encoded JSON.
|
|
518
|
+
const todoMessageJson = () => sql`(CASE WHEN jsonb_typeof(message) = 'string' THEN (message #>> '{}')::jsonb ELSE message END)`;
|
|
519
|
+
const todoMetaJson = () => sql`(CASE WHEN jsonb_typeof(meta) = 'string' THEN (meta #>> '{}')::jsonb ELSE meta END)`;
|
|
520
|
+
|
|
521
|
+
// A scheduled result arriving during an interactive turn becomes context on the next turn.
|
|
522
|
+
const visibleToTurn = (turnId?: string) => sql`(
|
|
523
|
+
${turnId ?? null}::text IS NULL OR NOT COALESCE(meta ? 'scheduledTask', false) OR seq < ALL (
|
|
524
|
+
SELECT input_message.seq FROM ai.messages input_message
|
|
525
|
+
WHERE input_message.loop_id = ${turnId ?? null} AND input_message.role = 'user'
|
|
526
|
+
)
|
|
527
|
+
)`;
|
|
528
|
+
|
|
481
529
|
const rowToConversation = (row: ConversationRow): AiConversation => ({
|
|
530
|
+
...(row.activity ? { activity: row.activity } : {}),
|
|
482
531
|
id: row.id,
|
|
532
|
+
hasActiveSchedule: Boolean(row.has_active_schedule),
|
|
483
533
|
shortId: row.short_id,
|
|
484
534
|
title: row.title,
|
|
485
535
|
titleSource: fieldSource(row.title_source),
|
|
@@ -488,12 +538,25 @@ const rowToConversation = (row: ConversationRow): AiConversation => ({
|
|
|
488
538
|
keywords: row.keywords ?? [],
|
|
489
539
|
pinnedAt: row.pinned_at ? iso(row.pinned_at) : null,
|
|
490
540
|
archivedAt: row.archived_at ? iso(row.archived_at) : null,
|
|
491
|
-
|
|
541
|
+
done: row.done,
|
|
542
|
+
isDone: row.pinned_at
|
|
543
|
+
? false
|
|
544
|
+
: (row.is_done ??
|
|
545
|
+
row.done ??
|
|
546
|
+
(new Date(row.last_used_at).getTime() <= Date.now() - 7 * 86400000 &&
|
|
547
|
+
!row.has_active_schedule &&
|
|
548
|
+
!["queued", "running", "waiting_for_action"].includes(row.latest_turn_status ?? ""))),
|
|
549
|
+
lastUsedAt: iso(row.last_used_at),
|
|
550
|
+
runStatus: conversationRunStatus(row.latest_turn_status, row.latest_browser_pending),
|
|
492
551
|
runError: row.latest_turn_status === "failed" ? row.latest_turn_error?.trim() || "Assistant response failed." : null,
|
|
493
552
|
unreadCompletion:
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
553
|
+
Boolean(
|
|
554
|
+
row.background_received_at &&
|
|
555
|
+
(!row.last_viewed_at || new Date(row.background_received_at).getTime() > new Date(row.last_viewed_at).getTime()),
|
|
556
|
+
) ||
|
|
557
|
+
(row.latest_turn_status === "completed" &&
|
|
558
|
+
Boolean(row.latest_turn_completed_at) &&
|
|
559
|
+
(!row.last_viewed_at || new Date(row.latest_turn_completed_at!).getTime() > new Date(row.last_viewed_at).getTime())),
|
|
497
560
|
projectId: row.project_id,
|
|
498
561
|
draft: {
|
|
499
562
|
content: parseJsonValue<AiConversationDraft["content"]>(row.draft_content ?? []),
|
|
@@ -566,6 +629,9 @@ const rowToMessage = (row: MessageRow): AiStoredMessage => {
|
|
|
566
629
|
};
|
|
567
630
|
|
|
568
631
|
const rowToTurn = (row: TurnRow): AiTurn => ({
|
|
632
|
+
deadline: row.deadline ? iso(row.deadline) : null,
|
|
633
|
+
runBudgetMs: row.run_budget_ms ?? null,
|
|
634
|
+
cancelRequestedAt: row.cancel_requested_at ? iso(row.cancel_requested_at) : null,
|
|
569
635
|
id: row.id,
|
|
570
636
|
shortId: row.short_id,
|
|
571
637
|
conversationId: row.conversation_id,
|
|
@@ -591,7 +657,7 @@ const pendingActionToPublicEvent = (row: PendingActionRow): AiPendingTurnAction
|
|
|
591
657
|
conversationId: row.conversation_id,
|
|
592
658
|
callId: row.call_id,
|
|
593
659
|
name: row.tool_name,
|
|
594
|
-
args:
|
|
660
|
+
args: row.args,
|
|
595
661
|
mode: row.frontend_mode ?? "client",
|
|
596
662
|
}
|
|
597
663
|
: {
|
|
@@ -600,7 +666,7 @@ const pendingActionToPublicEvent = (row: PendingActionRow): AiPendingTurnAction
|
|
|
600
666
|
conversationId: row.conversation_id,
|
|
601
667
|
callId: row.call_id,
|
|
602
668
|
name: row.tool_name,
|
|
603
|
-
args:
|
|
669
|
+
args: row.args,
|
|
604
670
|
message: row.message ?? undefined,
|
|
605
671
|
review: parseCapabilityActionReview(row.review),
|
|
606
672
|
allowAlways: row.allow_always,
|
|
@@ -613,7 +679,7 @@ const rowToPendingActionRecord = (row: PendingActionRow): AiPendingTurnActionRec
|
|
|
613
679
|
kind: row.kind,
|
|
614
680
|
status: row.status,
|
|
615
681
|
name: row.tool_name,
|
|
616
|
-
args:
|
|
682
|
+
args: row.args,
|
|
617
683
|
message: row.message ?? undefined,
|
|
618
684
|
review: parseCapabilityActionReview(row.review),
|
|
619
685
|
approvalScope: row.approval_scope,
|
|
@@ -649,14 +715,17 @@ const loadConversationSummary = async (input: {
|
|
|
649
715
|
const rows = await sql<ConversationRow[]>`
|
|
650
716
|
SELECT
|
|
651
717
|
conversation.*,
|
|
718
|
+
${effectiveDone()} AS is_done,
|
|
719
|
+
EXISTS (SELECT 1 FROM ai.chat_tasks schedule WHERE schedule.conversation_id = conversation.id AND schedule.state = 'active') AS has_active_schedule,
|
|
652
720
|
latest.status AS latest_turn_status,
|
|
721
|
+
${browserWorkPending()} AS latest_browser_pending,
|
|
653
722
|
latest.error AS latest_turn_error,
|
|
654
723
|
latest.completed_at AS latest_turn_completed_at
|
|
655
724
|
FROM ai.conversations conversation
|
|
656
725
|
LEFT JOIN LATERAL (
|
|
657
|
-
SELECT status, error, completed_at
|
|
726
|
+
SELECT status, error, completed_at, live_blocks
|
|
658
727
|
FROM ai.turns
|
|
659
|
-
WHERE conversation_id = conversation.id
|
|
728
|
+
WHERE conversation_id = conversation.id AND NOT COALESCE(run_config ? 'background', false)
|
|
660
729
|
ORDER BY created_at DESC, id DESC
|
|
661
730
|
LIMIT 1
|
|
662
731
|
) latest ON TRUE
|
|
@@ -666,7 +735,15 @@ const loadConversationSummary = async (input: {
|
|
|
666
735
|
AND (${Boolean(input.archived)}::boolean = (conversation.archived_at IS NOT NULL))
|
|
667
736
|
LIMIT 1
|
|
668
737
|
`;
|
|
669
|
-
|
|
738
|
+
if (!rows[0]) return null;
|
|
739
|
+
const plans = await sql<{ seq: number; plan: unknown }[]>`
|
|
740
|
+
SELECT seq, CASE WHEN role = 'tool_result' THEN ${todoMessageJson()}->'result' ELSE ${todoMetaJson()}->'todoPlan' END AS plan
|
|
741
|
+
FROM ai.messages WHERE conversation_id = ${rows[0].id}
|
|
742
|
+
AND ((role = 'tool_result' AND ${todoMessageJson()}->>'name' = 'todo_write' AND COALESCE(${todoMessageJson()}->>'isError','false') = 'false') OR ${todoMetaJson()} ? 'todoPlan')
|
|
743
|
+
ORDER BY seq DESC, id DESC LIMIT 1
|
|
744
|
+
`;
|
|
745
|
+
const plan = parseAiTodoPlan(plans[0]?.plan);
|
|
746
|
+
return { ...rowToConversation(rows[0]), ...(plan ? { todoPlan: { seq: Number(plans[0]!.seq), todos: plan.todos } } : {}) };
|
|
670
747
|
};
|
|
671
748
|
|
|
672
749
|
const firstText = (message: Message): string => {
|
|
@@ -745,14 +822,14 @@ const insertMessageLocked = async (
|
|
|
745
822
|
${seq},
|
|
746
823
|
${input.kind ?? "message"},
|
|
747
824
|
${input.message.role},
|
|
748
|
-
${JSON.stringify(input.message)}::jsonb,
|
|
825
|
+
${JSON.stringify(input.message)}::text::jsonb,
|
|
749
826
|
${messageSearchText(input.message)},
|
|
750
827
|
${input.loopId ?? null},
|
|
751
828
|
${input.modelProfileId ?? null},
|
|
752
829
|
${providerModel},
|
|
753
|
-
${usage ? JSON.stringify(usage) : null}::jsonb,
|
|
830
|
+
${usage ? JSON.stringify(usage) : null}::text::jsonb,
|
|
754
831
|
${stopReason},
|
|
755
|
-
${input.meta ? JSON.stringify(input.meta) : null}::jsonb
|
|
832
|
+
${input.meta ? JSON.stringify(input.meta) : null}::text::jsonb
|
|
756
833
|
)
|
|
757
834
|
RETURNING *
|
|
758
835
|
`,
|
|
@@ -776,6 +853,7 @@ const insertMessageLocked = async (
|
|
|
776
853
|
|
|
777
854
|
/** Append a turn-owned (assistant/tool_result/summary) message, guarded by lease ownership in one statement. */
|
|
778
855
|
const appendTurnOwnedMessage = async (input: {
|
|
856
|
+
background?: boolean;
|
|
779
857
|
conversationId: string;
|
|
780
858
|
turnId: string;
|
|
781
859
|
leaseOwner: string;
|
|
@@ -785,12 +863,15 @@ const appendTurnOwnedMessage = async (input: {
|
|
|
785
863
|
loopId: string | null;
|
|
786
864
|
modelProfileId?: string | null;
|
|
787
865
|
meta?: AiStoredMessage["meta"];
|
|
788
|
-
}): Promise<
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
866
|
+
}): Promise<AiStoredMessage | null> => {
|
|
867
|
+
return sql.begin(async (tx) => {
|
|
868
|
+
if (!input.background) await tx`SELECT id FROM ai.conversations WHERE id = ${input.conversationId}::uuid FOR UPDATE`;
|
|
869
|
+
const { usage, providerModel, stopReason } = messageColumns(input.message);
|
|
870
|
+
const rows = await withAiShortIdForDb(
|
|
871
|
+
tx,
|
|
872
|
+
"idx_ai_messages_conversation_short_id",
|
|
873
|
+
(attempt, shortId) => attempt<MessageRow[]>`
|
|
874
|
+
INSERT INTO ${sql(input.background ? "ai.task_messages" : "ai.messages")} (
|
|
794
875
|
short_id,
|
|
795
876
|
conversation_id,
|
|
796
877
|
seq,
|
|
@@ -808,18 +889,18 @@ const appendTurnOwnedMessage = async (input: {
|
|
|
808
889
|
${shortId},
|
|
809
890
|
${input.conversationId},
|
|
810
891
|
CASE
|
|
811
|
-
WHEN ${input.seq ?? null}::int IS NOT NULL AND ${input.seq ?? null}::int > 0 THEN ${input.seq ?? null}::int
|
|
812
|
-
ELSE (SELECT COALESCE(MAX(seq), 0) + 1 FROM ai.messages WHERE conversation_id = ${input.conversationId} AND seq > 0)
|
|
892
|
+
WHEN NOT ${Boolean(input.background)} AND ${input.kind === "summary"} AND ${input.seq ?? null}::int IS NOT NULL AND ${input.seq ?? null}::int > 0 THEN ${input.seq ?? null}::int
|
|
893
|
+
ELSE (SELECT COALESCE(MAX(seq), 0) + 1 FROM ${sql(input.background ? "ai.task_messages" : "ai.messages")} WHERE conversation_id = ${input.conversationId} AND seq > 0 AND (NOT ${Boolean(input.background)} OR loop_id = ${input.turnId}))
|
|
813
894
|
END,
|
|
814
895
|
${input.kind ?? "message"},
|
|
815
896
|
${input.message.role},
|
|
816
|
-
${JSON.stringify(input.message)}::jsonb,
|
|
897
|
+
${JSON.stringify(input.message)}::text::jsonb,
|
|
817
898
|
${input.loopId},
|
|
818
899
|
${input.modelProfileId ?? null},
|
|
819
900
|
${providerModel},
|
|
820
|
-
${usage ? JSON.stringify(usage) : null}::jsonb,
|
|
901
|
+
${usage ? JSON.stringify(usage) : null}::text::jsonb,
|
|
821
902
|
${stopReason},
|
|
822
|
-
${input.meta ? JSON.stringify(input.meta) : null}::jsonb
|
|
903
|
+
${input.meta ? JSON.stringify(input.meta) : null}::text::jsonb
|
|
823
904
|
WHERE EXISTS (
|
|
824
905
|
SELECT 1
|
|
825
906
|
FROM ai.turns
|
|
@@ -828,14 +909,15 @@ const appendTurnOwnedMessage = async (input: {
|
|
|
828
909
|
AND status IN ('running', 'waiting_for_action')
|
|
829
910
|
AND lease_owner = ${input.leaseOwner}
|
|
830
911
|
)
|
|
831
|
-
RETURNING
|
|
912
|
+
RETURNING *
|
|
832
913
|
`,
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
914
|
+
);
|
|
915
|
+
if (rows[0]) {
|
|
916
|
+
if (!input.background) await tx`UPDATE ai.conversations SET updated_at = now() WHERE id = ${input.conversationId}`;
|
|
917
|
+
return rowToMessage(rows[0]);
|
|
918
|
+
}
|
|
919
|
+
return null;
|
|
920
|
+
});
|
|
839
921
|
};
|
|
840
922
|
|
|
841
923
|
const toolMessageMeta = (
|
|
@@ -857,6 +939,69 @@ const toolMessageMeta = (
|
|
|
857
939
|
return Object.keys(toolPresentations).length > 0 ? { toolPresentations } : null;
|
|
858
940
|
};
|
|
859
941
|
|
|
942
|
+
export const saveAiDraftInTransaction = async (
|
|
943
|
+
tx: SQL,
|
|
944
|
+
input: Parameters<AiConversationService["saveDraft"]>[0],
|
|
945
|
+
): ReturnType<AiConversationService["saveDraft"]> => {
|
|
946
|
+
const [conversation] = await tx<{ draft_content: unknown; draft_revision: number | string; draft_updated_at: Date | string | null }[]>`
|
|
947
|
+
SELECT draft_content, draft_revision, draft_updated_at
|
|
948
|
+
FROM ai.conversations
|
|
949
|
+
WHERE id = ${input.conversationId}::uuid
|
|
950
|
+
AND created_by_user_id = ${input.ownerUserId}::uuid
|
|
951
|
+
AND archived_at IS NULL
|
|
952
|
+
FOR UPDATE
|
|
953
|
+
`;
|
|
954
|
+
if (!conversation) return { ok: false as const, reason: "not_found" as const };
|
|
955
|
+
if (Number(conversation.draft_revision) !== input.expectedRevision) {
|
|
956
|
+
return { ok: false as const, reason: "conflict" as const };
|
|
957
|
+
}
|
|
958
|
+
const [sameDraft] = await tx<{ same: boolean }[]>`
|
|
959
|
+
SELECT ${JSON.stringify(input.content)}::text::jsonb = ${JSON.stringify(parseJsonValue(conversation.draft_content))}::text::jsonb AS same
|
|
960
|
+
`;
|
|
961
|
+
if (sameDraft?.same) {
|
|
962
|
+
return {
|
|
963
|
+
ok: true as const,
|
|
964
|
+
draft: {
|
|
965
|
+
content: input.content,
|
|
966
|
+
revision: Number(conversation.draft_revision),
|
|
967
|
+
updatedAt: conversation.draft_updated_at ? iso(conversation.draft_updated_at) : null,
|
|
968
|
+
},
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
for (const part of input.content) {
|
|
973
|
+
if (part.type !== "file") continue;
|
|
974
|
+
const [file] = await tx<{ exists: boolean }[]>`
|
|
975
|
+
SELECT EXISTS (
|
|
976
|
+
SELECT 1 FROM ai.files
|
|
977
|
+
WHERE conversation_id = ${input.conversationId}::uuid
|
|
978
|
+
AND path = ${part.path}
|
|
979
|
+
AND media_type = ${part.mediaType}
|
|
980
|
+
AND size = ${part.size}
|
|
981
|
+
AND version = ${part.version}
|
|
982
|
+
) AS exists
|
|
983
|
+
`;
|
|
984
|
+
if (!file?.exists) throw new Error(`Conversation file changed or no longer exists: ${part.path}`);
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
const [saved] = await tx<{ draft_content: unknown; draft_revision: number | string; draft_updated_at: Date | string }[]>`
|
|
988
|
+
UPDATE ai.conversations
|
|
989
|
+
SET draft_content = ${JSON.stringify(input.content)}::text::jsonb,
|
|
990
|
+
draft_revision = draft_revision + 1,
|
|
991
|
+
draft_updated_at = now()
|
|
992
|
+
WHERE id = ${input.conversationId}::uuid
|
|
993
|
+
RETURNING draft_content, draft_revision, draft_updated_at
|
|
994
|
+
`;
|
|
995
|
+
return {
|
|
996
|
+
ok: true as const,
|
|
997
|
+
draft: {
|
|
998
|
+
content: parseJsonValue<AiConversationDraft["content"]>(saved!.draft_content),
|
|
999
|
+
revision: Number(saved!.draft_revision),
|
|
1000
|
+
updatedAt: iso(saved!.draft_updated_at),
|
|
1001
|
+
},
|
|
1002
|
+
};
|
|
1003
|
+
};
|
|
1004
|
+
|
|
860
1005
|
export const aiConversations: AiConversationService = {
|
|
861
1006
|
createConversation: async (input) => {
|
|
862
1007
|
const rows = await withAiShortId(
|
|
@@ -880,7 +1025,7 @@ export const aiConversations: AiConversationService = {
|
|
|
880
1025
|
${input.title?.trim() || "New chat"},
|
|
881
1026
|
${input.description?.trim() ?? ""},
|
|
882
1027
|
${input.projectId ?? null}::uuid,
|
|
883
|
-
${JSON.stringify(input.draft ?? [])}::jsonb,
|
|
1028
|
+
${JSON.stringify(input.draft ?? [])}::text::jsonb,
|
|
884
1029
|
${input.draft?.length ? 1 : 0},
|
|
885
1030
|
${toPgTextArray(input.preloadTools ?? [])}::text[],
|
|
886
1031
|
${input.draft?.length ? new Date() : null},
|
|
@@ -920,11 +1065,12 @@ export const aiConversations: AiConversationService = {
|
|
|
920
1065
|
await tx`
|
|
921
1066
|
INSERT INTO ai.messages (
|
|
922
1067
|
short_id, conversation_id, seq, kind, role, message, search_text, loop_id,
|
|
923
|
-
model_profile_id, provider_model, usage, stop_reason, loop_aggregate, loop_done_reason
|
|
1068
|
+
model_profile_id, provider_model, usage, stop_reason, loop_aggregate, loop_done_reason, meta
|
|
924
1069
|
)
|
|
925
1070
|
SELECT
|
|
926
1071
|
short_id, ${target.id}::uuid, seq, kind, role, message, search_text, loop_id,
|
|
927
|
-
model_profile_id, provider_model, usage, stop_reason, loop_aggregate, loop_done_reason
|
|
1072
|
+
model_profile_id, provider_model, usage, stop_reason, loop_aggregate, loop_done_reason,
|
|
1073
|
+
CASE WHEN ${todoMetaJson()} ? 'todoPlan' THEN jsonb_build_object('todoPlan', ${todoMetaJson()}->'todoPlan') ELSE NULL END
|
|
928
1074
|
FROM ai.messages
|
|
929
1075
|
WHERE conversation_id = ${input.sourceConversationId}::uuid
|
|
930
1076
|
AND compacted_at IS NULL
|
|
@@ -932,8 +1078,8 @@ export const aiConversations: AiConversationService = {
|
|
|
932
1078
|
ORDER BY seq ASC
|
|
933
1079
|
`;
|
|
934
1080
|
await tx`
|
|
935
|
-
INSERT INTO ai.files (conversation_id, path, bytes, media_type, size, origin, updated_at)
|
|
936
|
-
SELECT ${target.id}::uuid, path, bytes, media_type, size, origin, updated_at
|
|
1081
|
+
INSERT INTO ai.files (conversation_id, path, bytes, media_type, size, origin, dictation_recorded_at, updated_at, version)
|
|
1082
|
+
SELECT ${target.id}::uuid, path, bytes, media_type, size, origin, dictation_recorded_at, updated_at, version
|
|
937
1083
|
FROM ai.files WHERE conversation_id = ${input.sourceConversationId}::uuid
|
|
938
1084
|
`;
|
|
939
1085
|
return rowToConversation(target);
|
|
@@ -950,23 +1096,29 @@ export const aiConversations: AiConversationService = {
|
|
|
950
1096
|
const rows = await withConversationSearchBackend(query, async (backend) => {
|
|
951
1097
|
const order = query
|
|
952
1098
|
? sql`${conversationSearchRank(backend, query)} DESC, conversation.pinned_at DESC NULLS LAST, conversation.updated_at DESC, conversation.created_at DESC`
|
|
953
|
-
:
|
|
1099
|
+
: input.done === true
|
|
1100
|
+
? sql`conversation.last_used_at DESC, conversation.id DESC`
|
|
1101
|
+
: sql`conversation.pinned_at DESC NULLS LAST, conversation.updated_at DESC, conversation.created_at DESC`;
|
|
954
1102
|
return sql<ConversationRow[]>`
|
|
955
1103
|
SELECT
|
|
956
1104
|
conversation.*,
|
|
1105
|
+
${effectiveDone()} AS is_done,
|
|
1106
|
+
EXISTS (SELECT 1 FROM ai.chat_tasks schedule WHERE schedule.conversation_id = conversation.id AND schedule.state = 'active') AS has_active_schedule,
|
|
957
1107
|
latest.status AS latest_turn_status,
|
|
1108
|
+
${browserWorkPending()} AS latest_browser_pending,
|
|
958
1109
|
latest.error AS latest_turn_error,
|
|
959
1110
|
latest.completed_at AS latest_turn_completed_at
|
|
960
1111
|
FROM ai.conversations conversation
|
|
961
1112
|
LEFT JOIN LATERAL (
|
|
962
|
-
SELECT status, error, completed_at
|
|
1113
|
+
SELECT status, error, completed_at, live_blocks
|
|
963
1114
|
FROM ai.turns
|
|
964
|
-
WHERE conversation_id = conversation.id
|
|
1115
|
+
WHERE NOT COALESCE(run_config ? 'background', false) AND conversation_id = conversation.id
|
|
965
1116
|
ORDER BY created_at DESC, id DESC
|
|
966
1117
|
LIMIT 1
|
|
967
1118
|
) latest ON TRUE
|
|
968
1119
|
WHERE conversation.created_by_user_id = ${input.ownerUserId}
|
|
969
1120
|
AND (${archived}::boolean = (conversation.archived_at IS NOT NULL))
|
|
1121
|
+
AND (${input.done ?? null}::boolean IS NULL OR ${input.done ?? null}::boolean = ${effectiveDone()})
|
|
970
1122
|
AND (${input.projectId ?? null}::uuid IS NULL OR conversation.project_id = ${input.projectId ?? null}::uuid)
|
|
971
1123
|
AND (NOT ${Boolean(input.unassigned)}::boolean OR conversation.project_id IS NULL)
|
|
972
1124
|
AND (${refs.length === 0}::boolean OR NOT EXISTS (
|
|
@@ -993,10 +1145,12 @@ export const aiConversations: AiConversationService = {
|
|
|
993
1145
|
OR message.search_document @@ websearch_to_tsquery('simple', ${query ?? ""}))
|
|
994
1146
|
))
|
|
995
1147
|
AND (${status}::text IS NULL
|
|
996
|
-
OR (${status} = 'running' AND latest.status IN ('queued', 'running'))
|
|
997
|
-
OR (${status} = 'needs_attention' AND latest.status = 'waiting_for_action')
|
|
1148
|
+
OR (${status} = 'running' AND (latest.status IN ('queued', 'running') OR ${browserWorkPending()}))
|
|
1149
|
+
OR (${status} = 'needs_attention' AND latest.status = 'waiting_for_action' AND NOT ${browserWorkPending()})
|
|
998
1150
|
OR (${status} = 'failed' AND latest.status = 'failed')
|
|
999
|
-
OR (${status} = 'unread' AND
|
|
1151
|
+
OR (${status} = 'unread' AND (
|
|
1152
|
+
(latest.status = 'completed' AND latest.completed_at > COALESCE(conversation.last_viewed_at, '-infinity'))
|
|
1153
|
+
OR conversation.background_received_at > COALESCE(conversation.last_viewed_at, '-infinity'))))
|
|
1000
1154
|
ORDER BY ${order}
|
|
1001
1155
|
LIMIT ${limit}
|
|
1002
1156
|
`;
|
|
@@ -1005,35 +1159,33 @@ export const aiConversations: AiConversationService = {
|
|
|
1005
1159
|
},
|
|
1006
1160
|
|
|
1007
1161
|
listSidebarConversations: async (input) => {
|
|
1008
|
-
const unassignedLimit = Math.min(Math.max(input.unassignedLimit ?? 15, 1), 50);
|
|
1009
|
-
const perProjectLimit = Math.min(Math.max(input.perProjectLimit ?? 10, 1), 50);
|
|
1010
1162
|
const rows = await sql<ConversationRow[]>`
|
|
1011
|
-
|
|
1012
|
-
SELECT
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
latest.
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
WHERE
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
WHERE
|
|
1035
|
-
|
|
1036
|
-
ORDER BY pinned_at DESC NULLS LAST,
|
|
1163
|
+
SELECT conversation.*, ${effectiveDone()} AS is_done,
|
|
1164
|
+
EXISTS (SELECT 1 FROM ai.chat_tasks schedule WHERE schedule.conversation_id = conversation.id AND schedule.state = 'active') AS has_active_schedule,
|
|
1165
|
+
latest.status AS latest_turn_status, ${browserWorkPending()} AS latest_browser_pending,
|
|
1166
|
+
latest.error AS latest_turn_error, latest.completed_at AS latest_turn_completed_at,
|
|
1167
|
+
jsonb_build_object('completed', COALESCE(progress.completed,0), 'total', COALESCE(progress.total,0),
|
|
1168
|
+
'step', progress.step, 'tool', ai.sidebar_tool_label(latest.live_blocks)->>'label') AS activity
|
|
1169
|
+
FROM ai.conversations conversation
|
|
1170
|
+
LEFT JOIN LATERAL (
|
|
1171
|
+
SELECT status, error, completed_at, live_blocks FROM ai.turns
|
|
1172
|
+
WHERE NOT COALESCE(run_config ? 'background', false) AND conversation_id = conversation.id ORDER BY created_at DESC, id DESC LIMIT 1
|
|
1173
|
+
) latest ON TRUE
|
|
1174
|
+
LEFT JOIN LATERAL (
|
|
1175
|
+
SELECT CASE WHEN role = 'tool_result' THEN ${todoMessageJson()}->'result' ELSE ${todoMetaJson()}->'todoPlan' END AS plan
|
|
1176
|
+
FROM ai.messages WHERE conversation_id = conversation.id
|
|
1177
|
+
AND ((role = 'tool_result' AND ${todoMessageJson()}->>'name' = 'todo_write' AND COALESCE(${todoMessageJson()}->>'isError','false') = 'false') OR ${todoMetaJson()} ? 'todoPlan')
|
|
1178
|
+
ORDER BY seq DESC, id DESC LIMIT 1
|
|
1179
|
+
) working ON TRUE
|
|
1180
|
+
LEFT JOIN LATERAL (
|
|
1181
|
+
SELECT count(*) FILTER (WHERE task->>'status' = 'completed')::int AS completed,
|
|
1182
|
+
count(*) FILTER (WHERE task->>'status' <> 'cancelled')::int AS total,
|
|
1183
|
+
max(task->>'content') FILTER (WHERE task->>'status' = 'in_progress') AS step
|
|
1184
|
+
FROM jsonb_array_elements(CASE WHEN jsonb_typeof(working.plan->'todos') = 'array' THEN working.plan->'todos' ELSE '[]'::jsonb END) task
|
|
1185
|
+
) progress ON TRUE
|
|
1186
|
+
WHERE conversation.created_by_user_id = ${input.ownerUserId}::uuid
|
|
1187
|
+
AND conversation.archived_at IS NULL AND NOT ${effectiveDone()}
|
|
1188
|
+
ORDER BY conversation.pinned_at DESC NULLS LAST, conversation.last_used_at DESC, conversation.id
|
|
1037
1189
|
`;
|
|
1038
1190
|
return rows.map(rowToConversation);
|
|
1039
1191
|
},
|
|
@@ -1047,23 +1199,29 @@ export const aiConversations: AiConversationService = {
|
|
|
1047
1199
|
const rows = await withConversationSearchBackend(query, async (backend) => {
|
|
1048
1200
|
const order = query
|
|
1049
1201
|
? sql`${conversationSearchRank(backend, query)} DESC, conversation.pinned_at DESC NULLS LAST, conversation.updated_at DESC, conversation.created_at DESC`
|
|
1050
|
-
:
|
|
1202
|
+
: input.done === true
|
|
1203
|
+
? sql`conversation.last_used_at DESC, conversation.id DESC`
|
|
1204
|
+
: sql`conversation.pinned_at DESC NULLS LAST, conversation.updated_at DESC, conversation.created_at DESC`;
|
|
1051
1205
|
return sql<ConversationRow[]>`
|
|
1052
1206
|
SELECT
|
|
1053
1207
|
conversation.*,
|
|
1208
|
+
${effectiveDone()} AS is_done,
|
|
1209
|
+
EXISTS (SELECT 1 FROM ai.chat_tasks schedule WHERE schedule.conversation_id = conversation.id AND schedule.state = 'active') AS has_active_schedule,
|
|
1054
1210
|
latest.status AS latest_turn_status,
|
|
1211
|
+
${browserWorkPending()} AS latest_browser_pending,
|
|
1055
1212
|
latest.error AS latest_turn_error,
|
|
1056
1213
|
latest.completed_at AS latest_turn_completed_at
|
|
1057
1214
|
FROM ai.conversations conversation
|
|
1058
1215
|
LEFT JOIN LATERAL (
|
|
1059
|
-
SELECT status, error, completed_at
|
|
1216
|
+
SELECT status, error, completed_at, live_blocks
|
|
1060
1217
|
FROM ai.turns
|
|
1061
|
-
WHERE conversation_id = conversation.id
|
|
1218
|
+
WHERE NOT COALESCE(run_config ? 'background', false) AND conversation_id = conversation.id
|
|
1062
1219
|
ORDER BY created_at DESC, id DESC
|
|
1063
1220
|
LIMIT 1
|
|
1064
1221
|
) latest ON TRUE
|
|
1065
1222
|
WHERE conversation.created_by_user_id = ${input.ownerUserId}
|
|
1066
1223
|
AND (${archived}::boolean = (conversation.archived_at IS NOT NULL))
|
|
1224
|
+
AND (${input.done ?? null}::boolean IS NULL OR ${input.done ?? null}::boolean = ${effectiveDone()})
|
|
1067
1225
|
AND (${input.projectId ?? null}::uuid IS NULL OR conversation.project_id = ${input.projectId ?? null}::uuid)
|
|
1068
1226
|
AND (NOT ${Boolean(input.unassigned)}::boolean OR conversation.project_id IS NULL)
|
|
1069
1227
|
AND (${pattern}::text IS NULL
|
|
@@ -1079,10 +1237,12 @@ export const aiConversations: AiConversationService = {
|
|
|
1079
1237
|
OR message.search_document @@ websearch_to_tsquery('simple', ${query ?? ""}))
|
|
1080
1238
|
))
|
|
1081
1239
|
AND (${status}::text IS NULL
|
|
1082
|
-
OR (${status} = 'running' AND latest.status IN ('queued', 'running'))
|
|
1083
|
-
OR (${status} = 'needs_attention' AND latest.status = 'waiting_for_action')
|
|
1240
|
+
OR (${status} = 'running' AND (latest.status IN ('queued', 'running') OR ${browserWorkPending()}))
|
|
1241
|
+
OR (${status} = 'needs_attention' AND latest.status = 'waiting_for_action' AND NOT ${browserWorkPending()})
|
|
1084
1242
|
OR (${status} = 'failed' AND latest.status = 'failed')
|
|
1085
|
-
OR (${status} = 'unread' AND
|
|
1243
|
+
OR (${status} = 'unread' AND (
|
|
1244
|
+
(latest.status = 'completed' AND latest.completed_at > COALESCE(conversation.last_viewed_at, '-infinity'))
|
|
1245
|
+
OR conversation.background_received_at > COALESCE(conversation.last_viewed_at, '-infinity'))))
|
|
1086
1246
|
ORDER BY ${order}
|
|
1087
1247
|
LIMIT ${perPage}
|
|
1088
1248
|
OFFSET ${offset}
|
|
@@ -1092,14 +1252,15 @@ export const aiConversations: AiConversationService = {
|
|
|
1092
1252
|
SELECT COUNT(*) AS total
|
|
1093
1253
|
FROM ai.conversations conversation
|
|
1094
1254
|
LEFT JOIN LATERAL (
|
|
1095
|
-
SELECT status, completed_at
|
|
1255
|
+
SELECT status, completed_at, live_blocks
|
|
1096
1256
|
FROM ai.turns
|
|
1097
|
-
WHERE conversation_id = conversation.id
|
|
1257
|
+
WHERE NOT COALESCE(run_config ? 'background', false) AND conversation_id = conversation.id
|
|
1098
1258
|
ORDER BY created_at DESC, id DESC
|
|
1099
1259
|
LIMIT 1
|
|
1100
1260
|
) latest ON TRUE
|
|
1101
1261
|
WHERE conversation.created_by_user_id = ${input.ownerUserId}
|
|
1102
1262
|
AND (${archived}::boolean = (conversation.archived_at IS NOT NULL))
|
|
1263
|
+
AND (${input.done ?? null}::boolean IS NULL OR ${input.done ?? null}::boolean = ${effectiveDone()})
|
|
1103
1264
|
AND (${input.projectId ?? null}::uuid IS NULL OR conversation.project_id = ${input.projectId ?? null}::uuid)
|
|
1104
1265
|
AND (NOT ${Boolean(input.unassigned)}::boolean OR conversation.project_id IS NULL)
|
|
1105
1266
|
AND (${pattern}::text IS NULL
|
|
@@ -1115,10 +1276,12 @@ export const aiConversations: AiConversationService = {
|
|
|
1115
1276
|
OR message.search_document @@ websearch_to_tsquery('simple', ${query ?? ""}))
|
|
1116
1277
|
))
|
|
1117
1278
|
AND (${status}::text IS NULL
|
|
1118
|
-
OR (${status} = 'running' AND latest.status IN ('queued', 'running'))
|
|
1119
|
-
OR (${status} = 'needs_attention' AND latest.status = 'waiting_for_action')
|
|
1279
|
+
OR (${status} = 'running' AND (latest.status IN ('queued', 'running') OR ${browserWorkPending()}))
|
|
1280
|
+
OR (${status} = 'needs_attention' AND latest.status = 'waiting_for_action' AND NOT ${browserWorkPending()})
|
|
1120
1281
|
OR (${status} = 'failed' AND latest.status = 'failed')
|
|
1121
|
-
OR (${status} = 'unread' AND
|
|
1282
|
+
OR (${status} = 'unread' AND (
|
|
1283
|
+
(latest.status = 'completed' AND latest.completed_at > COALESCE(conversation.last_viewed_at, '-infinity'))
|
|
1284
|
+
OR conversation.background_received_at > COALESCE(conversation.last_viewed_at, '-infinity'))))
|
|
1122
1285
|
`;
|
|
1123
1286
|
const total = Number(countRows[0]?.total ?? 0);
|
|
1124
1287
|
return {
|
|
@@ -1138,68 +1301,7 @@ export const aiConversations: AiConversationService = {
|
|
|
1138
1301
|
return loadConversationSummary(input);
|
|
1139
1302
|
},
|
|
1140
1303
|
|
|
1141
|
-
saveDraft: async (input) =>
|
|
1142
|
-
sql.begin(async (tx) => {
|
|
1143
|
-
const [conversation] = await tx<
|
|
1144
|
-
{ draft_content: unknown; draft_revision: number | string; draft_updated_at: Date | string | null }[]
|
|
1145
|
-
>`
|
|
1146
|
-
SELECT draft_content, draft_revision, draft_updated_at
|
|
1147
|
-
FROM ai.conversations
|
|
1148
|
-
WHERE id = ${input.conversationId}::uuid
|
|
1149
|
-
AND created_by_user_id = ${input.ownerUserId}::uuid
|
|
1150
|
-
AND archived_at IS NULL
|
|
1151
|
-
FOR UPDATE
|
|
1152
|
-
`;
|
|
1153
|
-
if (!conversation) return { ok: false as const, reason: "not_found" as const };
|
|
1154
|
-
if (Number(conversation.draft_revision) !== input.expectedRevision) {
|
|
1155
|
-
return { ok: false as const, reason: "conflict" as const };
|
|
1156
|
-
}
|
|
1157
|
-
const [sameDraft] = await tx<{ same: boolean }[]>`
|
|
1158
|
-
SELECT ${JSON.stringify(input.content)}::jsonb = ${JSON.stringify(parseJsonValue(conversation.draft_content))}::jsonb AS same
|
|
1159
|
-
`;
|
|
1160
|
-
if (sameDraft?.same) {
|
|
1161
|
-
return {
|
|
1162
|
-
ok: true as const,
|
|
1163
|
-
draft: {
|
|
1164
|
-
content: input.content,
|
|
1165
|
-
revision: Number(conversation.draft_revision),
|
|
1166
|
-
updatedAt: conversation.draft_updated_at ? iso(conversation.draft_updated_at) : null,
|
|
1167
|
-
},
|
|
1168
|
-
};
|
|
1169
|
-
}
|
|
1170
|
-
|
|
1171
|
-
for (const part of input.content) {
|
|
1172
|
-
if (part.type !== "file") continue;
|
|
1173
|
-
const [file] = await tx<{ exists: boolean }[]>`
|
|
1174
|
-
SELECT EXISTS (
|
|
1175
|
-
SELECT 1 FROM ai.files
|
|
1176
|
-
WHERE conversation_id = ${input.conversationId}::uuid
|
|
1177
|
-
AND path = ${part.path}
|
|
1178
|
-
AND media_type = ${part.mediaType}
|
|
1179
|
-
AND size = ${part.size}
|
|
1180
|
-
AND version = ${part.version}
|
|
1181
|
-
) AS exists
|
|
1182
|
-
`;
|
|
1183
|
-
if (!file?.exists) throw new Error(`Conversation file changed or no longer exists: ${part.path}`);
|
|
1184
|
-
}
|
|
1185
|
-
|
|
1186
|
-
const [saved] = await tx<{ draft_content: unknown; draft_revision: number | string; draft_updated_at: Date | string }[]>`
|
|
1187
|
-
UPDATE ai.conversations
|
|
1188
|
-
SET draft_content = ${JSON.stringify(input.content)}::jsonb,
|
|
1189
|
-
draft_revision = draft_revision + 1,
|
|
1190
|
-
draft_updated_at = now()
|
|
1191
|
-
WHERE id = ${input.conversationId}::uuid
|
|
1192
|
-
RETURNING draft_content, draft_revision, draft_updated_at
|
|
1193
|
-
`;
|
|
1194
|
-
return {
|
|
1195
|
-
ok: true as const,
|
|
1196
|
-
draft: {
|
|
1197
|
-
content: parseJsonValue<AiConversationDraft["content"]>(saved!.draft_content),
|
|
1198
|
-
revision: Number(saved!.draft_revision),
|
|
1199
|
-
updatedAt: iso(saved!.draft_updated_at),
|
|
1200
|
-
},
|
|
1201
|
-
};
|
|
1202
|
-
}),
|
|
1304
|
+
saveDraft: async (input) => sql.begin((tx) => saveAiDraftInTransaction(tx, input)),
|
|
1203
1305
|
|
|
1204
1306
|
getLoadedTools: async (input) => {
|
|
1205
1307
|
const rows = await sql<{ loaded_tools: string[] | null }[]>`
|
|
@@ -1420,7 +1522,7 @@ export const aiConversations: AiConversationService = {
|
|
|
1420
1522
|
},
|
|
1421
1523
|
|
|
1422
1524
|
createInterChatMessage: async (input) => {
|
|
1423
|
-
const existing = await sql<InterChatMessageRow[]>`${interChatMessageSelect}
|
|
1525
|
+
const existing = await sql<InterChatMessageRow[]>`${interChatMessageSelect()}
|
|
1424
1526
|
WHERE message.idempotency_key = ${input.idempotencyKey}
|
|
1425
1527
|
AND message.source_conversation_id = ${input.sourceConversationId}::uuid
|
|
1426
1528
|
AND message.actor_user_id = ${input.actorUserId}::uuid
|
|
@@ -1465,21 +1567,22 @@ export const aiConversations: AiConversationService = {
|
|
|
1465
1567
|
RETURNING id
|
|
1466
1568
|
`,
|
|
1467
1569
|
);
|
|
1468
|
-
const rows = await sql<InterChatMessageRow[]>`${interChatMessageSelect} WHERE message.id = ${inserted[0]!.id}::uuid LIMIT 1`;
|
|
1570
|
+
const rows = await sql<InterChatMessageRow[]>`${interChatMessageSelect()} WHERE message.id = ${inserted[0]!.id}::uuid LIMIT 1`;
|
|
1469
1571
|
return { ok: true, message: rowToInterChatMessage(rows[0]!) };
|
|
1470
1572
|
},
|
|
1471
1573
|
|
|
1472
1574
|
listPendingInterChatMessages: async (input = {}) => {
|
|
1473
1575
|
const limit = Math.min(Math.max(Math.floor(input.limit ?? 50), 1), 100);
|
|
1474
1576
|
const rows = await sql<InterChatMessageRow[]>`
|
|
1475
|
-
${interChatMessageSelect}
|
|
1577
|
+
${interChatMessageSelect()}
|
|
1476
1578
|
WHERE message.status = 'pending'
|
|
1477
1579
|
AND (${input.targetConversationId ?? null}::uuid IS NULL OR message.target_conversation_id = ${input.targetConversationId ?? null}::uuid)
|
|
1478
1580
|
AND (${input.targetConversationId ?? null}::uuid IS NOT NULL OR NOT EXISTS (
|
|
1479
1581
|
SELECT 1
|
|
1480
1582
|
FROM ai.turns active_turn
|
|
1481
1583
|
WHERE active_turn.conversation_id = message.target_conversation_id
|
|
1482
|
-
AND active_turn.
|
|
1584
|
+
AND NOT COALESCE(active_turn.run_config ? 'background', false)
|
|
1585
|
+
AND active_turn.status IN ('queued', 'running', 'waiting_for_action')
|
|
1483
1586
|
))
|
|
1484
1587
|
AND (${input.targetConversationId ?? null}::uuid IS NOT NULL OR message.id = (
|
|
1485
1588
|
SELECT oldest.id
|
|
@@ -1508,7 +1611,7 @@ export const aiConversations: AiConversationService = {
|
|
|
1508
1611
|
deliverInterChatMessage: async (input) => {
|
|
1509
1612
|
const delivered = await sql.begin(async (tx) => {
|
|
1510
1613
|
const rows = await tx<InterChatMessageRow[]>`
|
|
1511
|
-
${interChatMessageSelect}
|
|
1614
|
+
${interChatMessageSelect()}
|
|
1512
1615
|
WHERE message.id = ${input.messageId}::uuid
|
|
1513
1616
|
FOR UPDATE OF message
|
|
1514
1617
|
`;
|
|
@@ -1534,6 +1637,7 @@ export const aiConversations: AiConversationService = {
|
|
|
1534
1637
|
const [active] = await tx<{ id: string }[]>`
|
|
1535
1638
|
SELECT id FROM ai.turns
|
|
1536
1639
|
WHERE conversation_id = ${message.target_conversation_id}::uuid
|
|
1640
|
+
AND NOT COALESCE(run_config ? 'background', false)
|
|
1537
1641
|
AND status IN ('queued', 'running', 'waiting_for_action')
|
|
1538
1642
|
LIMIT 1
|
|
1539
1643
|
`;
|
|
@@ -1548,6 +1652,7 @@ export const aiConversations: AiConversationService = {
|
|
|
1548
1652
|
RETURNING *
|
|
1549
1653
|
`,
|
|
1550
1654
|
);
|
|
1655
|
+
await tx`UPDATE ai.conversations SET done = CASE WHEN done IS TRUE THEN NULL ELSE done END, last_used_at = now() WHERE id = ${message.target_conversation_id}::uuid`;
|
|
1551
1656
|
const turn = rowToTurn(turnRows[0]!);
|
|
1552
1657
|
const targetMeta: NonNullable<AiStoredMessage["meta"]> = {
|
|
1553
1658
|
agentMessage: {
|
|
@@ -1579,7 +1684,7 @@ export const aiConversations: AiConversationService = {
|
|
|
1579
1684
|
SET status = 'delivered', target_turn_id = ${turn.id}, target_message_id = ${messageRow.id}, delivered_at = now()
|
|
1580
1685
|
WHERE id = ${message.id}::uuid
|
|
1581
1686
|
`;
|
|
1582
|
-
const updated = await tx<InterChatMessageRow[]>`${interChatMessageSelect} WHERE message.id = ${message.id}::uuid LIMIT 1`;
|
|
1687
|
+
const updated = await tx<InterChatMessageRow[]>`${interChatMessageSelect()} WHERE message.id = ${message.id}::uuid LIMIT 1`;
|
|
1583
1688
|
return { delivered: true as const, message: rowToInterChatMessage(updated[0]!), turn };
|
|
1584
1689
|
});
|
|
1585
1690
|
return delivered;
|
|
@@ -1624,12 +1729,16 @@ export const aiConversations: AiConversationService = {
|
|
|
1624
1729
|
`;
|
|
1625
1730
|
if (!conversations[0]) return { ok: false as const, reason: "not_found" as const };
|
|
1626
1731
|
|
|
1732
|
+
if (input.onlyUnassigned && conversations[0].project_id) return { ok: false as const, reason: "already_assigned" as const };
|
|
1733
|
+
|
|
1627
1734
|
const [activity] = await tx<{ exists: boolean }[]>`
|
|
1628
1735
|
SELECT EXISTS (
|
|
1629
1736
|
SELECT 1 FROM ai.turns
|
|
1630
1737
|
WHERE conversation_id = ${input.conversationId}::uuid
|
|
1631
1738
|
AND status IN ('queued', 'running', 'waiting_for_action')
|
|
1632
|
-
)
|
|
1739
|
+
) OR (${Boolean(input.onlyUnassigned)}::boolean AND EXISTS (
|
|
1740
|
+
SELECT 1 FROM ai.queued_messages WHERE conversation_id = ${input.conversationId}::uuid AND status IN ('pending','failed')
|
|
1741
|
+
)) AS exists
|
|
1633
1742
|
`;
|
|
1634
1743
|
if (activity?.exists) return { ok: false as const, reason: "active_turn" as const };
|
|
1635
1744
|
|
|
@@ -1659,6 +1768,35 @@ export const aiConversations: AiConversationService = {
|
|
|
1659
1768
|
return rows[0] ? loadConversationSummary(input) : null;
|
|
1660
1769
|
},
|
|
1661
1770
|
|
|
1771
|
+
setConversationDone: async (input) => {
|
|
1772
|
+
const result = await sql.begin(async (tx) => {
|
|
1773
|
+
// Same lock as turn submission: completion cannot race a new message.
|
|
1774
|
+
const [conversation] = await tx<{ id: string }[]>`
|
|
1775
|
+
SELECT id FROM ai.conversations
|
|
1776
|
+
WHERE id = ${input.conversationId}::uuid AND created_by_user_id = ${input.ownerUserId}::uuid
|
|
1777
|
+
AND archived_at IS NULL FOR UPDATE
|
|
1778
|
+
`;
|
|
1779
|
+
if (!conversation) return { ok: false as const, reason: "not_found" as const };
|
|
1780
|
+
if (input.done) {
|
|
1781
|
+
const [active] = await tx<{ id: string }[]>`
|
|
1782
|
+
SELECT id FROM ai.turns WHERE conversation_id = ${input.conversationId}::uuid
|
|
1783
|
+
AND NOT COALESCE(run_config ? 'background', false) AND status IN ('queued', 'running', 'waiting_for_action') LIMIT 1
|
|
1784
|
+
`;
|
|
1785
|
+
const [pending] =
|
|
1786
|
+
await tx`SELECT id FROM ai.queued_messages WHERE conversation_id = ${input.conversationId}::uuid AND status IN ('pending','failed') LIMIT 1`;
|
|
1787
|
+
if (active || pending) return { ok: false as const, reason: "active_turn" as const };
|
|
1788
|
+
}
|
|
1789
|
+
await tx`UPDATE ai.conversations
|
|
1790
|
+
SET done = ${input.done}::boolean,
|
|
1791
|
+
last_used_at = CASE WHEN ${input.done}::boolean IS FALSE THEN now() ELSE last_used_at END
|
|
1792
|
+
WHERE id = ${input.conversationId}::uuid`;
|
|
1793
|
+
return { ok: true as const };
|
|
1794
|
+
});
|
|
1795
|
+
if (!result.ok) return result;
|
|
1796
|
+
const conversation = await loadConversationSummary(input);
|
|
1797
|
+
return conversation ? { ok: true as const, conversation } : { ok: false as const, reason: "not_found" as const };
|
|
1798
|
+
},
|
|
1799
|
+
|
|
1662
1800
|
archiveConversation: async (input) => {
|
|
1663
1801
|
const rows = await sql<{ id: string }[]>`
|
|
1664
1802
|
UPDATE ai.conversations
|
|
@@ -1672,6 +1810,7 @@ export const aiConversations: AiConversationService = {
|
|
|
1672
1810
|
WHERE conversation_id = ai.conversations.id
|
|
1673
1811
|
AND status IN ('queued', 'running', 'waiting_for_action')
|
|
1674
1812
|
)
|
|
1813
|
+
AND NOT EXISTS (SELECT 1 FROM ai.queued_messages WHERE conversation_id = ai.conversations.id AND status IN ('pending','failed'))
|
|
1675
1814
|
RETURNING id
|
|
1676
1815
|
`;
|
|
1677
1816
|
return Boolean(rows[0]);
|
|
@@ -1727,7 +1866,7 @@ export const aiConversations: AiConversationService = {
|
|
|
1727
1866
|
AND EXISTS (SELECT 1 FROM ai.messages m WHERE m.conversation_id = c.id)
|
|
1728
1867
|
AND NOT EXISTS (
|
|
1729
1868
|
SELECT 1 FROM ai.turns t
|
|
1730
|
-
WHERE t.conversation_id = c.id AND t.status IN ('queued', 'running', 'waiting_for_action')
|
|
1869
|
+
WHERE t.conversation_id = c.id AND NOT COALESCE(t.run_config ? 'background', false) AND t.status IN ('queued', 'running', 'waiting_for_action')
|
|
1731
1870
|
)
|
|
1732
1871
|
ORDER BY c.updated_at ASC
|
|
1733
1872
|
LIMIT ${limit}
|
|
@@ -2075,7 +2214,7 @@ export const aiConversations: AiConversationService = {
|
|
|
2075
2214
|
listTurnMessages: async (input) => {
|
|
2076
2215
|
const rows = await sql<MessageRow[]>`
|
|
2077
2216
|
SELECT *
|
|
2078
|
-
FROM ai.messages
|
|
2217
|
+
FROM (SELECT * FROM ai.messages UNION ALL SELECT * FROM ai.task_messages) messages
|
|
2079
2218
|
WHERE conversation_id = ${input.conversationId}
|
|
2080
2219
|
AND loop_id = ${input.loopId}
|
|
2081
2220
|
AND (${input.includeCompacted ?? false} OR compacted_at IS NULL)
|
|
@@ -2139,7 +2278,8 @@ export const aiConversations: AiConversationService = {
|
|
|
2139
2278
|
usage,
|
|
2140
2279
|
stop_reason,
|
|
2141
2280
|
loop_aggregate,
|
|
2142
|
-
loop_done_reason
|
|
2281
|
+
loop_done_reason,
|
|
2282
|
+
meta
|
|
2143
2283
|
)
|
|
2144
2284
|
SELECT
|
|
2145
2285
|
short_id,
|
|
@@ -2155,7 +2295,8 @@ export const aiConversations: AiConversationService = {
|
|
|
2155
2295
|
usage,
|
|
2156
2296
|
stop_reason,
|
|
2157
2297
|
loop_aggregate,
|
|
2158
|
-
loop_done_reason
|
|
2298
|
+
loop_done_reason,
|
|
2299
|
+
CASE WHEN ${todoMetaJson()} ? 'todoPlan' THEN jsonb_build_object('todoPlan', ${todoMetaJson()}->'todoPlan') ELSE NULL END
|
|
2159
2300
|
FROM ai.messages
|
|
2160
2301
|
WHERE conversation_id = ${input.sourceConversationId}
|
|
2161
2302
|
AND compacted_at IS NULL
|
|
@@ -2184,13 +2325,15 @@ export const aiConversations: AiConversationService = {
|
|
|
2184
2325
|
|
|
2185
2326
|
setLatestAssistantLoopAggregate: async (input) => {
|
|
2186
2327
|
const loopId = input.loopId ?? null;
|
|
2328
|
+
const config = loopId ? await aiConversations.getTurnRunConfig({ conversationId: input.conversationId, turnId: loopId }) : null;
|
|
2329
|
+
const table = config?.kind === "chat" && config.background ? "ai.task_messages" : "ai.messages";
|
|
2187
2330
|
await sql`
|
|
2188
|
-
UPDATE
|
|
2189
|
-
SET loop_aggregate = ${JSON.stringify(input.aggregate)}::jsonb,
|
|
2331
|
+
UPDATE ${sql(table)}
|
|
2332
|
+
SET loop_aggregate = ${JSON.stringify(input.aggregate)}::text::jsonb,
|
|
2190
2333
|
loop_done_reason = ${input.doneReason}
|
|
2191
2334
|
WHERE id = (
|
|
2192
2335
|
SELECT id
|
|
2193
|
-
FROM
|
|
2336
|
+
FROM ${sql(table)}
|
|
2194
2337
|
WHERE conversation_id = ${input.conversationId}
|
|
2195
2338
|
AND compacted_at IS NULL
|
|
2196
2339
|
AND kind = 'message'
|
|
@@ -2214,9 +2357,18 @@ export const aiConversations: AiConversationService = {
|
|
|
2214
2357
|
WHERE conversation_id = ${input.conversationId}
|
|
2215
2358
|
AND compacted_at IS NULL
|
|
2216
2359
|
AND seq <= ${checkpointSeq}
|
|
2360
|
+
AND ${visibleToTurn(input.turnId)}
|
|
2217
2361
|
`;
|
|
2218
2362
|
if ((rows[0]?.count ?? 0) === 0) return;
|
|
2219
2363
|
|
|
2364
|
+
const plans = await tx<{ plan: unknown }[]>`
|
|
2365
|
+
SELECT CASE WHEN role = 'tool_result' THEN ${todoMessageJson()}->'result' ELSE ${todoMetaJson()}->'todoPlan' END AS plan
|
|
2366
|
+
FROM ai.messages WHERE conversation_id = ${input.conversationId} AND seq <= ${checkpointSeq}
|
|
2367
|
+
AND ${visibleToTurn(input.turnId)}
|
|
2368
|
+
AND ((role = 'tool_result' AND ${todoMessageJson()}->>'name' = 'todo_write' AND COALESCE(${todoMessageJson()}->>'isError','false') = 'false') OR ${todoMetaJson()} ? 'todoPlan')
|
|
2369
|
+
ORDER BY seq DESC, id DESC LIMIT 1
|
|
2370
|
+
`;
|
|
2371
|
+
const todoPlan = parseAiTodoPlan(plans[0]?.plan);
|
|
2220
2372
|
const archived = await tx<{ count: number }[]>`
|
|
2221
2373
|
WITH archived AS (
|
|
2222
2374
|
UPDATE ai.messages
|
|
@@ -2224,6 +2376,7 @@ export const aiConversations: AiConversationService = {
|
|
|
2224
2376
|
WHERE conversation_id = ${input.conversationId}
|
|
2225
2377
|
AND compacted_at IS NULL
|
|
2226
2378
|
AND seq <= ${checkpointSeq}
|
|
2379
|
+
AND ${visibleToTurn(input.turnId)}
|
|
2227
2380
|
RETURNING id
|
|
2228
2381
|
)
|
|
2229
2382
|
SELECT COUNT(*)::int AS count FROM archived
|
|
@@ -2232,41 +2385,46 @@ export const aiConversations: AiConversationService = {
|
|
|
2232
2385
|
await insertMessageLocked(
|
|
2233
2386
|
{
|
|
2234
2387
|
conversationId: input.conversationId,
|
|
2235
|
-
message:
|
|
2388
|
+
message:
|
|
2389
|
+
todoPlan && input.summary.role === "assistant"
|
|
2390
|
+
? {
|
|
2391
|
+
...input.summary,
|
|
2392
|
+
content: [
|
|
2393
|
+
...input.summary.content,
|
|
2394
|
+
{ type: "text", text: `Working plan at this checkpoint:\n${JSON.stringify(todoPlan)}` },
|
|
2395
|
+
],
|
|
2396
|
+
}
|
|
2397
|
+
: input.summary,
|
|
2236
2398
|
kind: "summary",
|
|
2237
2399
|
seq: checkpointSeq,
|
|
2238
2400
|
loopId: null,
|
|
2239
2401
|
modelProfileId: input.modelProfileId ?? null,
|
|
2240
|
-
meta: { compactedCount: archived[0]?.count ?? 0 },
|
|
2402
|
+
meta: { compactedCount: archived[0]?.count ?? 0, ...(todoPlan ? { todoPlan } : {}) },
|
|
2241
2403
|
},
|
|
2242
2404
|
tx,
|
|
2243
2405
|
);
|
|
2244
2406
|
});
|
|
2245
2407
|
},
|
|
2246
2408
|
|
|
2247
|
-
createCompactionTurn: async (input) =>
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
)
|
|
2258
|
-
VALUES (
|
|
2259
|
-
${
|
|
2260
|
-
${input.conversationId},
|
|
2261
|
-
${input.modelProfileId},
|
|
2262
|
-
'queued',
|
|
2263
|
-
(${input.runConfig ? JSON.stringify(input.runConfig) : null}::text)::jsonb
|
|
2264
|
-
)
|
|
2409
|
+
createCompactionTurn: async (input) =>
|
|
2410
|
+
sql.begin(async (tx) => {
|
|
2411
|
+
const [conversation] = await tx<{ id: string }[]>`
|
|
2412
|
+
SELECT id FROM ai.conversations WHERE id = ${input.conversationId}::uuid AND archived_at IS NULL FOR UPDATE
|
|
2413
|
+
`;
|
|
2414
|
+
if (!conversation) throw new Error("Conversation not found.");
|
|
2415
|
+
const rows = await withAiShortIdForDb(
|
|
2416
|
+
tx,
|
|
2417
|
+
"idx_ai_turns_conversation_short_id",
|
|
2418
|
+
(attempt, shortId) => attempt<TurnRow[]>`
|
|
2419
|
+
INSERT INTO ai.turns (short_id, conversation_id, model_profile_id, status, run_config)
|
|
2420
|
+
VALUES (${shortId}, ${input.conversationId}, ${input.modelProfileId}, 'queued',
|
|
2421
|
+
(${input.runConfig ? JSON.stringify(input.runConfig) : null}::text)::jsonb)
|
|
2265
2422
|
RETURNING *
|
|
2266
2423
|
`,
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2424
|
+
);
|
|
2425
|
+
await tx`UPDATE ai.conversations SET done = CASE WHEN done IS TRUE THEN NULL ELSE done END, last_used_at = now() WHERE id = ${input.conversationId}::uuid`;
|
|
2426
|
+
return rowToTurn(rows[0]!);
|
|
2427
|
+
}),
|
|
2270
2428
|
|
|
2271
2429
|
submitChatTurn: async (input) => {
|
|
2272
2430
|
return await sql.begin(async (tx) => {
|
|
@@ -2274,6 +2432,20 @@ export const aiConversations: AiConversationService = {
|
|
|
2274
2432
|
SELECT draft_revision, project_id FROM ai.conversations WHERE id = ${input.conversationId} FOR UPDATE
|
|
2275
2433
|
`;
|
|
2276
2434
|
if (!conversation) throw new Error("Conversation not found.");
|
|
2435
|
+
if (input.queuedMessageId) {
|
|
2436
|
+
const [head] = await tx<
|
|
2437
|
+
{ id: string; status: string }[]
|
|
2438
|
+
>`SELECT id, status FROM ai.queued_messages WHERE conversation_id = ${input.conversationId}::uuid AND status IN ('pending','failed') ORDER BY position LIMIT 1`;
|
|
2439
|
+
const [busy] =
|
|
2440
|
+
await tx`SELECT id FROM ai.turns WHERE conversation_id = ${input.conversationId}::uuid AND NOT COALESCE(run_config ? 'background', false) AND status IN ('queued','running','waiting_for_action') LIMIT 1`;
|
|
2441
|
+
if (!head || head.id !== input.queuedMessageId || head.status !== "pending" || busy)
|
|
2442
|
+
throw new Error("Queued message is no longer ready.");
|
|
2443
|
+
}
|
|
2444
|
+
if (!input.queuedMessageId) {
|
|
2445
|
+
const [queued] =
|
|
2446
|
+
await tx`SELECT id FROM ai.queued_messages WHERE conversation_id = ${input.conversationId}::uuid AND status IN ('pending','failed') LIMIT 1`;
|
|
2447
|
+
if (queued) throw new Error("Queued messages must be sent first.");
|
|
2448
|
+
}
|
|
2277
2449
|
if (input.expectedDraftRevision !== undefined && Number(conversation.draft_revision) !== input.expectedDraftRevision) {
|
|
2278
2450
|
throw new Error("Conversation draft changed before the turn was submitted.");
|
|
2279
2451
|
}
|
|
@@ -2309,13 +2481,22 @@ export const aiConversations: AiConversationService = {
|
|
|
2309
2481
|
RETURNING *
|
|
2310
2482
|
`,
|
|
2311
2483
|
);
|
|
2484
|
+
// Queue acceptance already recorded the user's interaction time.
|
|
2485
|
+
if (!input.queuedMessageId)
|
|
2486
|
+
await tx`UPDATE ai.conversations SET done = CASE WHEN done IS TRUE THEN NULL ELSE done END, last_used_at = now() WHERE id = ${input.conversationId}::uuid`;
|
|
2312
2487
|
const turn = rowToTurn(turnRows[0]!);
|
|
2313
2488
|
const attachedFiles = input.runConfig.files?.attached ?? [];
|
|
2314
2489
|
for (const file of attachedFiles) {
|
|
2315
|
-
const copied = input.
|
|
2316
|
-
? await tx<
|
|
2317
|
-
|
|
2318
|
-
|
|
2490
|
+
const copied = input.queuedMessageId
|
|
2491
|
+
? await tx<
|
|
2492
|
+
{ path: string }[]
|
|
2493
|
+
>`INSERT INTO ai.turn_files(turn_id,path,bytes,media_type,size,origin,dictation_recorded_at,updated_at,version)
|
|
2494
|
+
SELECT ${turn.id}::uuid,path,bytes,media_type,size,origin,dictation_recorded_at,updated_at,version FROM ai.queued_message_files
|
|
2495
|
+
WHERE message_id = ${input.queuedMessageId}::uuid AND path = ${file.path} RETURNING path`
|
|
2496
|
+
: input.retrySourceTurnId
|
|
2497
|
+
? await tx<{ path: string }[]>`
|
|
2498
|
+
INSERT INTO ai.turn_files (turn_id, path, bytes, media_type, size, origin, dictation_recorded_at, updated_at, version)
|
|
2499
|
+
SELECT ${turn.id}::uuid, path, bytes, media_type, size, origin, dictation_recorded_at, updated_at, version
|
|
2319
2500
|
FROM ai.turn_files
|
|
2320
2501
|
WHERE turn_id = ${input.retrySourceTurnId}::uuid
|
|
2321
2502
|
AND path = ${file.path}
|
|
@@ -2324,9 +2505,9 @@ export const aiConversations: AiConversationService = {
|
|
|
2324
2505
|
AND version = ${file.version ?? -1}
|
|
2325
2506
|
RETURNING path
|
|
2326
2507
|
`
|
|
2327
|
-
|
|
2328
|
-
INSERT INTO ai.turn_files (turn_id, path, bytes, media_type, size, origin, updated_at, version)
|
|
2329
|
-
SELECT ${turn.id}::uuid, path, bytes, media_type, size, origin, updated_at, version
|
|
2508
|
+
: await tx<{ path: string }[]>`
|
|
2509
|
+
INSERT INTO ai.turn_files (turn_id, path, bytes, media_type, size, origin, dictation_recorded_at, updated_at, version)
|
|
2510
|
+
SELECT ${turn.id}::uuid, path, bytes, media_type, size, origin, dictation_recorded_at, updated_at, version
|
|
2330
2511
|
FROM ai.files
|
|
2331
2512
|
WHERE conversation_id = ${input.conversationId}::uuid
|
|
2332
2513
|
AND path = ${file.path}
|
|
@@ -2342,6 +2523,7 @@ export const aiConversations: AiConversationService = {
|
|
|
2342
2523
|
conversationId: input.conversationId,
|
|
2343
2524
|
message: input.userMessage,
|
|
2344
2525
|
loopId: turn.id,
|
|
2526
|
+
meta: input.expectedDraftRevision === undefined ? undefined : { submittedDraftRevision: input.expectedDraftRevision },
|
|
2345
2527
|
},
|
|
2346
2528
|
tx,
|
|
2347
2529
|
);
|
|
@@ -2375,6 +2557,10 @@ export const aiConversations: AiConversationService = {
|
|
|
2375
2557
|
WHERE id = ${input.conversationId}::uuid
|
|
2376
2558
|
`;
|
|
2377
2559
|
}
|
|
2560
|
+
if (input.queuedMessageId) {
|
|
2561
|
+
await tx`UPDATE ai.queued_messages SET status = 'submitted', submission = '{}'::jsonb, content = '[]'::jsonb WHERE id = ${input.queuedMessageId}::uuid`;
|
|
2562
|
+
await tx`DELETE FROM ai.queued_message_files WHERE message_id = ${input.queuedMessageId}::uuid`;
|
|
2563
|
+
}
|
|
2378
2564
|
return { turn, message: rowToMessage(messageRow) };
|
|
2379
2565
|
});
|
|
2380
2566
|
},
|
|
@@ -2390,6 +2576,14 @@ export const aiConversations: AiConversationService = {
|
|
|
2390
2576
|
return rows[0]?.run_config ? parseJsonValue<AiTurnRunConfig>(rows[0].run_config) : null;
|
|
2391
2577
|
},
|
|
2392
2578
|
|
|
2579
|
+
getLatestTurn: async (input) => {
|
|
2580
|
+
const [row] = await sql<
|
|
2581
|
+
TurnRow[]
|
|
2582
|
+
>`SELECT * FROM ai.turns WHERE conversation_id = ${input.conversationId}::uuid AND NOT COALESCE(run_config ? 'background', false)
|
|
2583
|
+
ORDER BY created_at DESC, id DESC LIMIT 1`;
|
|
2584
|
+
return row ? rowToTurn(row) : null;
|
|
2585
|
+
},
|
|
2586
|
+
|
|
2393
2587
|
getTurn: async (input) => {
|
|
2394
2588
|
const rows = await sql<TurnRow[]>`
|
|
2395
2589
|
SELECT *
|
|
@@ -2417,6 +2611,7 @@ export const aiConversations: AiConversationService = {
|
|
|
2417
2611
|
SELECT *
|
|
2418
2612
|
FROM ai.turns
|
|
2419
2613
|
WHERE conversation_id = ${input.conversationId}
|
|
2614
|
+
AND NOT COALESCE(run_config ? 'background', false)
|
|
2420
2615
|
AND status IN ('queued', 'running', 'waiting_for_action')
|
|
2421
2616
|
ORDER BY created_at DESC
|
|
2422
2617
|
LIMIT 1
|
|
@@ -2431,7 +2626,7 @@ export const aiConversations: AiConversationService = {
|
|
|
2431
2626
|
|
|
2432
2627
|
claimTurn: async (input) => {
|
|
2433
2628
|
const leaseMs = boundedMs(input.leaseMs, 60_000, 5_000, 5 * 60_000);
|
|
2434
|
-
const runBudgetMs =
|
|
2629
|
+
const runBudgetMs = Number.isFinite(input.runBudgetMs) && input.runBudgetMs >= 0 ? input.runBudgetMs : 30 * 60_000;
|
|
2435
2630
|
const maxAttempts = Math.max(1, Math.floor(input.maxAttempts));
|
|
2436
2631
|
const rows = await sql<TurnRow[]>`
|
|
2437
2632
|
UPDATE ai.turns
|
|
@@ -2440,9 +2635,11 @@ export const aiConversations: AiConversationService = {
|
|
|
2440
2635
|
lease_owner = ${input.leaseOwner},
|
|
2441
2636
|
lease_expires_at = now() + (${leaseMs} * interval '1 millisecond'),
|
|
2442
2637
|
heartbeat_at = now(),
|
|
2638
|
+
run_budget_ms = COALESCE(run_budget_ms, ${runBudgetMs}),
|
|
2443
2639
|
deadline = CASE
|
|
2444
|
-
WHEN
|
|
2445
|
-
|
|
2640
|
+
WHEN COALESCE(run_budget_ms, ${runBudgetMs}) = 0 THEN NULL
|
|
2641
|
+
WHEN ${input.from} = 'waiting' OR deadline IS NULL THEN now() + (COALESCE(run_budget_ms, ${runBudgetMs}) * interval '1 millisecond')
|
|
2642
|
+
ELSE deadline
|
|
2446
2643
|
END
|
|
2447
2644
|
WHERE id = ${input.turnId}
|
|
2448
2645
|
AND conversation_id = ${input.conversationId}
|
|
@@ -2686,7 +2883,7 @@ export const aiConversations: AiConversationService = {
|
|
|
2686
2883
|
UPDATE ai.turns
|
|
2687
2884
|
SET status = 'failed',
|
|
2688
2885
|
completed_at = now(),
|
|
2689
|
-
error = '
|
|
2886
|
+
error = 'Run time limit reached. You can continue the task in a new message.',
|
|
2690
2887
|
lease_owner = NULL,
|
|
2691
2888
|
lease_expires_at = NULL,
|
|
2692
2889
|
live_blocks = NULL
|
|
@@ -2853,14 +3050,14 @@ export const aiConversations: AiConversationService = {
|
|
|
2853
3050
|
${input.callId},
|
|
2854
3051
|
${input.kind},
|
|
2855
3052
|
${input.name},
|
|
2856
|
-
${JSON.stringify(input.args ?? null)}::jsonb,
|
|
3053
|
+
${JSON.stringify(input.args ?? null)}::text::jsonb,
|
|
2857
3054
|
${input.message ?? null},
|
|
2858
|
-
${input.review ? JSON.stringify(input.review) : null}::jsonb,
|
|
3055
|
+
${input.review ? JSON.stringify(input.review) : null}::text::jsonb,
|
|
2859
3056
|
${input.approvalScope},
|
|
2860
3057
|
${input.allowAlways},
|
|
2861
3058
|
${input.frontendMode ?? null},
|
|
2862
3059
|
${input.resolvedEvent ? "resolved" : "pending"},
|
|
2863
|
-
${input.resolvedEvent ? JSON.stringify(input.resolvedEvent) : null}::jsonb,
|
|
3060
|
+
${input.resolvedEvent ? JSON.stringify(input.resolvedEvent) : null}::text::jsonb,
|
|
2864
3061
|
CASE WHEN ${Boolean(input.resolvedEvent)} THEN now() ELSE NULL END
|
|
2865
3062
|
)
|
|
2866
3063
|
ON CONFLICT (turn_id, call_id)
|
|
@@ -2928,7 +3125,7 @@ export const aiConversations: AiConversationService = {
|
|
|
2928
3125
|
const rows = await sql<PendingActionRow[]>`
|
|
2929
3126
|
UPDATE ai.pending_actions
|
|
2930
3127
|
SET status = 'resolved',
|
|
2931
|
-
resolved_event = ${JSON.stringify(input.event)}::jsonb,
|
|
3128
|
+
resolved_event = ${JSON.stringify(input.event)}::text::jsonb,
|
|
2932
3129
|
resolved_at = now()
|
|
2933
3130
|
WHERE conversation_id = ${input.conversationId}
|
|
2934
3131
|
AND turn_id = ${input.turnId}
|
|
@@ -3054,7 +3251,23 @@ export const aiConversations: AiConversationService = {
|
|
|
3054
3251
|
createSessionStore: (input): SessionStore => ({
|
|
3055
3252
|
load: async (): Promise<StoreEntry[]> => {
|
|
3056
3253
|
// The loop must only ever see the active model context, never archived history.
|
|
3057
|
-
|
|
3254
|
+
if (input.background && input.turnId) {
|
|
3255
|
+
const own = await aiConversations.listTurnMessages({ conversationId: input.conversationId, loopId: input.turnId });
|
|
3256
|
+
const context = input.background.context;
|
|
3257
|
+
return [...context, ...own.map((row) => ({ seq: context.length + row.seq, kind: row.kind, message: row.message }))];
|
|
3258
|
+
}
|
|
3259
|
+
// Results delivered after this turn started belong to the next turn's context.
|
|
3260
|
+
// Compare durable message sequences rather than wall-clock timestamps.
|
|
3261
|
+
const rows = input.turnId
|
|
3262
|
+
? (
|
|
3263
|
+
await sql<MessageRow[]>`
|
|
3264
|
+
SELECT * FROM ai.messages
|
|
3265
|
+
WHERE conversation_id = ${input.conversationId}::uuid AND compacted_at IS NULL
|
|
3266
|
+
AND ${visibleToTurn(input.turnId)}
|
|
3267
|
+
ORDER BY seq ASC
|
|
3268
|
+
`
|
|
3269
|
+
).map(rowToMessage)
|
|
3270
|
+
: await aiConversations.listContextMessages({ conversationId: input.conversationId });
|
|
3058
3271
|
return rows.map((row) => ({
|
|
3059
3272
|
seq: row.seq,
|
|
3060
3273
|
kind: row.kind,
|
|
@@ -3077,19 +3290,21 @@ export const aiConversations: AiConversationService = {
|
|
|
3077
3290
|
|
|
3078
3291
|
if (input.turnId && input.leaseOwner) {
|
|
3079
3292
|
const appended = await appendTurnOwnedMessage({
|
|
3293
|
+
background: Boolean(input.background),
|
|
3080
3294
|
conversationId: input.conversationId,
|
|
3081
3295
|
turnId: input.turnId,
|
|
3082
3296
|
leaseOwner: input.leaseOwner,
|
|
3083
3297
|
message,
|
|
3084
3298
|
kind: opts?.kind,
|
|
3085
3299
|
seq: opts?.seq,
|
|
3086
|
-
loopId: opts?.kind === "summary" ? null : input.turnId,
|
|
3300
|
+
loopId: opts?.kind === "summary" && !input.background ? null : input.turnId,
|
|
3087
3301
|
modelProfileId: input.modelProfileId,
|
|
3088
3302
|
meta,
|
|
3089
3303
|
});
|
|
3090
3304
|
if (!appended) {
|
|
3091
3305
|
throw new Error("AI turn lost its lease while writing a message.");
|
|
3092
3306
|
}
|
|
3307
|
+
await input.onMessage?.(appended);
|
|
3093
3308
|
return;
|
|
3094
3309
|
}
|
|
3095
3310
|
|