Skip to content

Commit f11a78f

Browse files
committed
feat: implement unique default chatbot agent constraint and enhance related logic for attachment management
1 parent 4785db5 commit f11a78f

15 files changed

Lines changed: 187 additions & 92 deletions

File tree

.github/workflows/docker-publish.yml

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -101,8 +101,15 @@ jobs:
101101
# cosign signs by immutable digest; the tags only resolve the digest.
102102
# We sign each unique image reference so verification works against
103103
# any tag the user pulled (latest, semver, edge, sha-…).
104-
for tag in $TAGS; do
105-
image="${tag%%:*}"
106-
echo "Signing ${image}@${DIGEST}"
107-
cosign sign --yes "${image}@${DIGEST}"
108-
done
104+
# `${tag%:*}` (single %) strips only the trailing :tag so registries
105+
# with ports (e.g. registry.local:5000/repo:tag) survive intact.
106+
# Pipe through `sort -u` so the same image+digest is signed once
107+
# even when the tag list contains many aliases.
108+
printf '%s\n' $TAGS \
109+
| awk 'NF' \
110+
| sed -E 's#:[^:/]+$##' \
111+
| sort -u \
112+
| while IFS= read -r image; do
113+
echo "Signing ${image}@${DIGEST}"
114+
cosign sign --yes "${image}@${DIGEST}"
115+
done

.github/workflows/pr-title-lint.yml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ name: PR Title Lint
1515
# WIP
1616

1717
on:
18-
pull_request_target:
18+
pull_request:
1919
types: [opened, edited, synchronize, reopened]
2020

2121
permissions:
@@ -42,7 +42,6 @@ jobs:
4242
build
4343
ci
4444
chore
45-
revert
4645
requireScope: false
4746
subjectPattern: ^[A-Za-z0-9].+$
4847
subjectPatternError: |

.github/workflows/release-verification.yml

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ jobs:
3535
smoke-test:
3636
name: Smoke-test published image
3737
runs-on: ubuntu-latest
38-
timeout-minutes: 20
38+
timeout-minutes: 35
3939
steps:
4040
- name: Resolve release tag
4141
id: tag
@@ -100,10 +100,20 @@ jobs:
100100
- name: Assert migrations + S3 bucket ready
101101
run: |
102102
set -euo pipefail
103-
docker compose -f docker-compose.production.yml logs app | grep -q "Database migrations applied successfully" \
104-
|| { echo "❌ Migrations message missing"; docker compose -f docker-compose.production.yml logs app; exit 1; }
105-
docker compose -f docker-compose.production.yml logs app | grep -q 'S3 bucket "reqcore" is ready' \
106-
|| { echo "❌ S3 bucket message missing"; docker compose -f docker-compose.production.yml logs app; exit 1; }
103+
# Startup messages can land slightly after the HTTP port opens, so
104+
# poll instead of one-shot grepping to avoid flaky failures.
105+
for i in $(seq 40); do
106+
logs="$(docker compose -f docker-compose.production.yml logs app || true)"
107+
if grep -q "Database migrations applied successfully" <<<"$logs" \
108+
&& grep -q 'S3 bucket "reqcore" is ready' <<<"$logs"; then
109+
echo "✅ Migrations + S3 ready messages found (attempt $i)"
110+
exit 0
111+
fi
112+
sleep 3
113+
done
114+
echo "❌ Required startup messages missing after polling"
115+
docker compose -f docker-compose.production.yml logs app
116+
exit 1
107117
108118
- name: Demote release to pre-release on failure
109119
if: failure() && github.event_name == 'release'

app/composables/useChatbot.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -419,8 +419,13 @@ export function useChatbot() {
419419
const attachmentIds = userMessage.attachments?.map((a) => a.id) ?? []
420420
pendingAttachments.value = []
421421

422+
// Use a per-call controller and only mutate the shared `abortController`
423+
// / `isStreaming` state when this call still owns it. This prevents a
424+
// late `finally` from a previous (aborted) send from clobbering the
425+
// controller and streaming flag of a newer in-flight request.
422426
isStreaming.value = true
423-
abortController = new AbortController()
427+
const controller = new AbortController()
428+
abortController = controller
424429

425430
try {
426431
const res = await fetch('/api/chatbot/chat', {
@@ -442,7 +447,7 @@ export function useChatbot() {
442447
: {}),
443448
})),
444449
}),
445-
signal: abortController.signal,
450+
signal: controller.signal,
446451
})
447452

448453
if (!res.ok || !res.body) {
@@ -472,8 +477,11 @@ export function useChatbot() {
472477
} finally {
473478
// Force reactivity since we mutated assistantMessage in place.
474479
messages.value = [...messages.value]
475-
isStreaming.value = false
476-
abortController = null
480+
// Only reset shared state if a newer send hasn't already taken over.
481+
if (abortController === controller) {
482+
isStreaming.value = false
483+
abortController = null
484+
}
477485
}
478486
}
479487

nuxt.config.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ export default defineNuxtConfig({
9494
// Enable source maps so PostHog error tracking can display readable stack traces
9595
sourcemap: { client: "hidden" },
9696

97-
// @ts-expect-error - posthogConfig types only available when @posthog/nuxt module is loaded
97+
// @ts-ignore - posthogConfig types only available when @posthog/nuxt module is loaded
9898
posthogConfig: {
9999
publicKey: process.env.POSTHOG_PUBLIC_KEY || "",
100100
host: process.env.POSTHOG_HOST || "https://eu.i.posthog.com",
@@ -244,7 +244,14 @@ export default defineNuxtConfig({
244244
* Self-hosters use these to enable/disable flags without running PostHog.
245245
* See `shared/feature-flags.ts` for the full registry and resolution order.
246246
*/
247-
featureFlagOverrides: readEnvFlagOverrides(),
247+
// Cast: Nuxt narrows public runtime config from the registry's literal
248+
// `defaultValue` types (boolean here), but env overrides can also be
249+
// multivariate strings — and entries are partial. The override map is
250+
// validated at runtime by `parseFlagOverride`, so the cast is safe.
251+
featureFlagOverrides: readEnvFlagOverrides() as Record<
252+
string,
253+
boolean | string
254+
>,
248255
},
249256
},
250257

server/api/ai-config/[id]/set-default.post.ts

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { and, eq } from 'drizzle-orm'
1+
import { and, eq, sql } from 'drizzle-orm'
22
import { z } from 'zod'
33
import { aiConfig } from '../../../database/schema'
44
import { setAiConfigDefaultSchema } from '../../../utils/schemas/scoring'
@@ -9,8 +9,11 @@ const paramsSchema = z.object({ id: z.string().min(1) })
99
* POST /api/ai-config/:id/set-default
1010
*
1111
* Atomically claims one or more "default" slots (chatbot, analysis) for this
12-
* configuration. Clears the same flag on every other config in the org so
13-
* exactly one default exists per purpose.
12+
* configuration. Uses a single UPDATE per purpose that sets the flag to true
13+
* for the chosen row and false for every other row in the same organization,
14+
* so the "exactly one default per purpose" invariant is preserved even under
15+
* concurrent requests. The partial unique indexes on `is_default_chatbot` and
16+
* `is_default_analysis` provide a DB-level backstop.
1417
*/
1518
export default defineEventHandler(async (event) => {
1619
const session = await requirePermission(event, { scoring: ['create'] })
@@ -27,22 +30,20 @@ export default defineEventHandler(async (event) => {
2730
await db.transaction(async (tx) => {
2831
if (body.purposes.includes('chatbot')) {
2932
await tx.update(aiConfig)
30-
.set({ isDefaultChatbot: false })
33+
.set({
34+
isDefaultChatbot: sql`${aiConfig.id} = ${id}`,
35+
updatedAt: new Date(),
36+
})
3137
.where(eq(aiConfig.organizationId, orgId))
3238
}
3339
if (body.purposes.includes('analysis')) {
3440
await tx.update(aiConfig)
35-
.set({ isDefaultAnalysis: false })
41+
.set({
42+
isDefaultAnalysis: sql`${aiConfig.id} = ${id}`,
43+
updatedAt: new Date(),
44+
})
3645
.where(eq(aiConfig.organizationId, orgId))
3746
}
38-
39-
const promote: Record<string, unknown> = { updatedAt: new Date() }
40-
if (body.purposes.includes('chatbot')) promote.isDefaultChatbot = true
41-
if (body.purposes.includes('analysis')) promote.isDefaultAnalysis = true
42-
43-
await tx.update(aiConfig)
44-
.set(promote)
45-
.where(and(eq(aiConfig.id, id), eq(aiConfig.organizationId, orgId)))
4647
})
4748

4849
recordActivity({

server/api/chatbot/agents/[id].patch.ts

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -41,16 +41,6 @@ export default defineEventHandler(async (event): Promise<{ agent: ChatbotAgent }
4141
throw createError({ statusCode: 404, statusMessage: 'Agent not found.' })
4242
}
4343

44-
// If switching this agent to default, clear other defaults first.
45-
if (body.isDefault === true && !existing.isDefault) {
46-
await db.update(chatbotAgent)
47-
.set({ isDefault: false, updatedAt: new Date() })
48-
.where(and(
49-
eq(chatbotAgent.organizationId, orgId),
50-
eq(chatbotAgent.userId, userId),
51-
))
52-
}
53-
5444
const updates: Partial<typeof chatbotAgent.$inferInsert> = { updatedAt: new Date() }
5545
if (body.name !== undefined) updates.name = body.name
5646
if (body.description !== undefined) updates.description = body.description
@@ -61,14 +51,29 @@ export default defineEventHandler(async (event): Promise<{ agent: ChatbotAgent }
6151
}
6252
if (body.isDefault !== undefined) updates.isDefault = body.isDefault
6353

64-
const [updated] = await db.update(chatbotAgent)
65-
.set(updates)
66-
.where(and(
67-
eq(chatbotAgent.id, id),
68-
eq(chatbotAgent.organizationId, orgId),
69-
eq(chatbotAgent.userId, userId),
70-
))
71-
.returning()
54+
// Clear-and-set must be atomic so two concurrent "promote to default"
55+
// requests cannot both observe `existing.isDefault === false` and leave
56+
// multiple defaults. The partial unique index
57+
// `chatbot_agent_default_per_user_idx` is the DB-level backstop.
58+
const [updated] = await db.transaction(async (tx) => {
59+
if (body.isDefault === true && !existing.isDefault) {
60+
await tx.update(chatbotAgent)
61+
.set({ isDefault: false, updatedAt: new Date() })
62+
.where(and(
63+
eq(chatbotAgent.organizationId, orgId),
64+
eq(chatbotAgent.userId, userId),
65+
))
66+
}
67+
68+
return tx.update(chatbotAgent)
69+
.set(updates)
70+
.where(and(
71+
eq(chatbotAgent.id, id),
72+
eq(chatbotAgent.organizationId, orgId),
73+
eq(chatbotAgent.userId, userId),
74+
))
75+
.returning()
76+
})
7277

7378
if (!updated) {
7479
throw createError({ statusCode: 500, statusMessage: 'Failed to update agent.' })

server/api/chatbot/agents/index.post.ts

Lines changed: 49 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { and, count, eq } from 'drizzle-orm'
1+
import { and, count, eq, sql } from 'drizzle-orm'
22
import { z } from 'zod'
33
import { chatbotAgent } from '../../../database/schema'
44
import { requireChatbotAccess } from '../../../utils/chatbotAccess'
@@ -21,6 +21,14 @@ const bodySchema = z.object({
2121
* POST /api/chatbot/agents
2222
*
2323
* Create a custom AI agent (system prompt + persona) for the current user.
24+
*
25+
* The cap-check, default-clearing, and insert run inside a single
26+
* transaction guarded by a Postgres advisory lock keyed on (org, user).
27+
* The lock serialises concurrent create requests for the same user so the
28+
* `CHATBOT_AGENT_MAX_PER_USER` cap cannot be exceeded by overlapping calls,
29+
* and so a previous default is reliably cleared before a new default is
30+
* inserted. The partial unique index `chatbot_agent_default_per_user_idx`
31+
* is the DB-level backstop on the default invariant.
2432
*/
2533
export default defineEventHandler(async (event): Promise<{ agent: ChatbotAgent }> => {
2634
const session = await requireChatbotAccess(event)
@@ -29,42 +37,52 @@ export default defineEventHandler(async (event): Promise<{ agent: ChatbotAgent }
2937

3038
const body = await readValidatedBody(event, bodySchema.parse)
3139

32-
// Enforce per-user cap.
33-
const [{ value: existing } = { value: 0 }] = await db
34-
.select({ value: count() })
35-
.from(chatbotAgent)
36-
.where(and(
37-
eq(chatbotAgent.organizationId, orgId),
38-
eq(chatbotAgent.userId, userId),
39-
))
40-
41-
if (existing >= CHATBOT_AGENT_MAX_PER_USER) {
42-
throw createError({
43-
statusCode: 422,
44-
statusMessage: `Agent limit reached (${CHATBOT_AGENT_MAX_PER_USER}). Delete an agent before adding another.`,
45-
})
46-
}
40+
const created = await db.transaction(async (tx) => {
41+
// Serialise concurrent create requests for the same (org, user) so the
42+
// count → insert sequence is race-free. The lock is released on commit.
43+
await tx.execute(
44+
sql`select pg_advisory_xact_lock(hashtext(${`chatbot_agent:${orgId}:${userId}`}))`,
45+
)
4746

48-
// If marking this agent as default, unset any previous default.
49-
if (body.isDefault) {
50-
await db.update(chatbotAgent)
51-
.set({ isDefault: false, updatedAt: new Date() })
47+
// Enforce per-user cap (now race-safe under the advisory lock).
48+
const [{ value: existing } = { value: 0 }] = await tx
49+
.select({ value: count() })
50+
.from(chatbotAgent)
5251
.where(and(
5352
eq(chatbotAgent.organizationId, orgId),
5453
eq(chatbotAgent.userId, userId),
5554
))
56-
}
5755

58-
const [created] = await db.insert(chatbotAgent).values({
59-
organizationId: orgId,
60-
userId,
61-
name: body.name,
62-
description: body.description ?? null,
63-
icon: body.icon ?? null,
64-
systemPrompt: body.systemPrompt,
65-
temperature: typeof body.temperature === 'number' ? String(body.temperature) : null,
66-
isDefault: body.isDefault === true,
67-
}).returning()
56+
if (existing >= CHATBOT_AGENT_MAX_PER_USER) {
57+
throw createError({
58+
statusCode: 422,
59+
statusMessage: `Agent limit reached (${CHATBOT_AGENT_MAX_PER_USER}). Delete an agent before adding another.`,
60+
})
61+
}
62+
63+
// If marking this agent as default, unset any previous default.
64+
if (body.isDefault) {
65+
await tx.update(chatbotAgent)
66+
.set({ isDefault: false, updatedAt: new Date() })
67+
.where(and(
68+
eq(chatbotAgent.organizationId, orgId),
69+
eq(chatbotAgent.userId, userId),
70+
))
71+
}
72+
73+
const [row] = await tx.insert(chatbotAgent).values({
74+
organizationId: orgId,
75+
userId,
76+
name: body.name,
77+
description: body.description ?? null,
78+
icon: body.icon ?? null,
79+
systemPrompt: body.systemPrompt,
80+
temperature: typeof body.temperature === 'number' ? String(body.temperature) : null,
81+
isDefault: body.isDefault === true,
82+
}).returning()
83+
84+
return row
85+
})
6886

6987
if (!created) {
7088
throw createError({ statusCode: 500, statusMessage: 'Failed to create agent.' })

server/api/chatbot/chat.post.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ export default defineEventHandler(async (event) => {
182182
}
183183
const attachmentIds = lastUser.attachmentIds ?? []
184184
const attachmentRecords = attachmentIds.length
185-
? getChatbotAttachments(session.user.id, attachmentIds)
185+
? getChatbotAttachments(orgId, session.user.id, attachmentIds)
186186
: []
187187

188188
if (attachmentIds.length > 0 && attachmentRecords.length === 0) {
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
-- Migration: enforce single default chatbot agent per (organization, user).
2+
--
3+
-- The chatbot_agent table allows one "default" agent per user inside an
4+
-- organisation (the agent pre-selected when starting a new conversation).
5+
-- The application code clears any prior default before promoting a new one,
6+
-- but this is racy under concurrent requests. This partial unique index
7+
-- enforces the invariant at the DB layer: any concurrent transaction that
8+
-- would create a second default row will fail with a unique-constraint
9+
-- violation, preserving correctness even if application-level coordination
10+
-- breaks down.
11+
12+
CREATE UNIQUE INDEX IF NOT EXISTS "chatbot_agent_default_per_user_idx"
13+
ON "chatbot_agent" ("organization_id", "user_id")
14+
WHERE "is_default" = true;

0 commit comments

Comments
 (0)