Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 40 additions & 39 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -68,24 +68,29 @@ ENCRYPTION_SECRET_HEX=3143246f44def075d40141fb849faffcf409fbbeb7a282a3a7c2f4396f
# Services will fail to start if this is not set. Change this password in production.
DB_PASSWORD=tale_password_change_me

# Advanced: Override the auto-generated database connection URL
# If not set, POSTGRES_URL is constructed as postgresql://tale:${DB_PASSWORD}@db:5432
# Note: Convex expects URL without database name; DB name derived from INSTANCE_NAME
# POSTGRES_URL=postgresql://user:password@host:port
# The application backend (the `backend-api` and `backend-worker` services)
# stores its state in the `tale_app` database on the `db` service. Compose
# builds its DATABASE_URL from DB_PASSWORD — nothing to configure here.
# Optional override for the application database name:
# APP_DB_NAME=tale_app

# Knowledge datastore (ParadeDB: pg_search + pgvector). Holds the RAG/crawler
# corpus (tale_knowledge DB, private_knowledge + public_web schemas). Split from
# the platform DB so it can be relocated/replaced independently for data
# residency. The platform's Convex node-actions connect here via postgres.js.
# the application DB so it can be relocated/replaced independently for data
# residency. The backend (api + worker) connects here via postgres.js.
# If not set, defaults to postgresql://tale:${DB_PASSWORD}@knowledge-db:5432/tale_knowledge
# KNOWLEDGE_DATABASE_URL=postgresql://user:password@host:port/tale_knowledge
KNOWLEDGE_DB_NAME=tale_knowledge

# ============================================================================
# REQUIRED: Convex Instance Configuration
# REQUIRED: Instance secret
# ============================================================================
# Used to derive the Convex admin key for `tale deploy`; deploy fails if unset.
# Auto-generated by `tale init`, or generate with: openssl rand -hex 32
# The deployment marker the app-side key derivations hang off: the WebDAV
# app-password HMAC key (sha256("<INSTANCE_SECRET>:webdav-hmac:v1"), unless
# WEBDAV_APP_PASSWORD_HMAC_KEY is set explicitly), and with it the in-sandbox
# host-call and stage tokens. Auto-generated by `tale init`; keep it STABLE
# across deploys — rotating it stops every issued WebDAV app password from
# verifying. Generate manually with: openssl rand -hex 32
INSTANCE_SECRET=0516d5cddc8b9bbc01238b8696f13c711983f45f6dc4dbf9dc66ba42fc16f504

# ============================================================================
Expand Down Expand Up @@ -123,8 +128,9 @@ TALE_AUDIT_SIGNING_KEY=4f8c2a9e7b1d6035e4a8c2f9d7b3061a5e8c4f2a9d7b30615e4c8a2f9
# METRICS_BEARER_TOKEN=your-secret-token-here
#
# Endpoints (requires valid bearer token):
# https://<host>/metrics/platform
# https://<host>/metrics/convex (Convex backend: 261 built-in metrics)
# https://<host>/metrics/platform (the web tier)
# https://<host>/metrics/backend (the application backend: api + worker)
# https://<host>/metrics/sla-rules
#
# Prometheus scrape config example:
# scrape_configs:
Expand Down Expand Up @@ -284,9 +290,9 @@ SOPS_AGE_KEY=
# must start with the reserved prefix TALE_PROVIDER_KEY_ — any other name is
# rejected (fail-closed), so `secretsEnv` can never point at a deployment secret
# (SOPS_AGE_KEY, BETTER_AUTH_SECRET, DB_PASSWORD, ...) and have it sent as a
# bearer token to a provider's base URL. Names must be 40 characters or fewer
# (the platform→Convex env-sync limit). Define the variable here / in your
# secret manager so the platform and the Convex backend can read it.
# bearer token to a provider's base URL. The suffix is 1-64 characters of
# A-Z, 0-9 and _. Define the variable here / in your secret manager so the
# backend (api + worker) can read it.
#
# TALE_PROVIDER_KEY_OPENROUTER=sk-or-...

Expand All @@ -301,7 +307,7 @@ SOPS_AGE_KEY=
# rejected (fail-closed), so `secretEnv` can never point at an unrelated
# deployment secret and leak it to the broker. The suffix is per-credential
# (one var per broker). Define the variable here / in your secret manager so
# the platform and the Convex backend can read it.
# the backend (api + worker) can read it.
#
# TALE_TOKEN_SOURCE_COOLAI=brk_live_...

Expand All @@ -324,7 +330,7 @@ SOPS_AGE_KEY=
#
# PO-token provider (bgutil HTTP server) base URL — supplies the GVS tokens
# that dissolve the bot wall for the mweb/web/tv_simply clients. ZERO CONFIG in
# the self-hosted stack: the bgutil plugin is baked into the convex image and
# the self-hosted stack: the bgutil plugin is baked into the platform image and
# the `bgutil-provider` compose sidecar serves tokens, so this defaults to
# http://bgutil-provider:4416 automatically. Only set this to point at a
# provider on a different host/port.
Expand Down Expand Up @@ -361,7 +367,7 @@ SOPS_AGE_KEY=
# image; leave unset unless you know it's available.
# VIDEO_INGEST_IMPERSONATE=safari
#
# Toolchain location (self-provisioned yt-dlp/ffmpeg). The convex image bakes
# Toolchain location (self-provisioned yt-dlp/ffmpeg). The platform image bakes
# both into the pinned PATH, so leave these unset there; set them on a host or
# dev box that provides its own binaries.
# VIDEO_INGEST_BIN_DIR: directory PREPENDED to the child PATH so a yt-dlp (and
Expand Down Expand Up @@ -409,30 +415,25 @@ SOPS_AGE_KEY=
ELEVENLABS_API_KEY=

# ============================================================================
# 0.5 backend (parallel build) — opt-in compose profile `backend`
# Application backend — always part of the stack
# ============================================================================
# The platform image can also start as the Postgres-backed 0.5 backend:
# docker compose --profile backend up -d
# brings up one `backend-api` and one `backend-worker` container (scale either
# with --scale). No extra secrets: the containers reuse DB_PASSWORD and store
# state in the `tale_app` database on the `db` service. TALE_ROLE and
# DATABASE_URL are set by compose — nothing to configure here.
# Optional override for the application database name:
# APP_DB_NAME=tale_app
#
# --- Cutting a deployment over to the Postgres backend --------------------
# BACKEND_UPSTREAM is the single switch. Set it and:
# * the proxy routes auth, the app API, the hint stream, both machine
# doors, SSO/SCIM/trusted-headers, the control channel, cloud-import
# OAuth and WebDAV to the backend instead of Convex;
# * `tale deploy` rolls `backend-api` / `backend-worker` with the platform
# image and drains in-flight chat turns through the control door first;
# * `tale migrate` re-provisions every org through that door (schema
# migrations apply themselves at backend boot).
# Unset it and every lane stays on Convex — the cutover is reversible.
# The platform image runs the Postgres-backed backend as two services that
# every deployment brings up: `backend-api` (auth, the app API, the hint
# stream, both machine doors, SSO/SCIM/trusted headers, the control channel,
# cloud-import OAuth, WebDAV) and `backend-worker` (schedules, watchdogs,
# agent turns). Scale either with --scale. No extra secrets: they reuse
# DB_PASSWORD and store state in the `tale_app` database (APP_DB_NAME, above);
# TALE_ROLE and DATABASE_URL are set by compose. Schema migrations apply
# themselves at backend boot, and `tale migrate` re-provisions the built-in
# defaults into every org through the control door.
#
# BACKEND_UPSTREAM is where the proxy reaches the api. The default
# `backend-api:3005` is the in-compose service; set it only for a split
# deployment that runs the backend elsewhere.
# BACKEND_UPSTREAM=backend-api:3005
#
# The deploy-time machine door (`/api/control/*`) exists only when this is
# set; `tale deploy`'s drain and `tale migrate` authenticate with it. Any
# high-entropy value works — keep it out of version control.
# set (unset, the door answers 404); `tale deploy`'s drain of in-flight chat
# turns and `tale migrate` authenticate with it. Any high-entropy value works
# — keep it out of version control.
# TALE_CONTROL_TOKEN=
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ describe('exchangeAuthorizationCode', () => {
});
});

it('splits comma-separated scopes and surfaces the Slack team id', async () => {
it('splits comma-separated scopes and surfaces the Slack team id and name', async () => {
const fetchImpl = vi.fn().mockResolvedValue(
jsonResponse({
ok: true,
Expand All @@ -84,6 +84,8 @@ describe('exchangeAuthorizationCode', () => {
expiresAt: undefined,
scopes: ['chat:write', 'channels:read'],
teamId: 'T0EXCHANGE',
// Labels a second workspace's credential (`Slack (Workspace)`).
teamName: 'Workspace',
},
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ export interface Oauth2Tokens {
* generically instead of switching on the connector.
*/
readonly teamId?: string;
/**
* The workspace's display name (`team.name`), when the vendor sends one
* alongside the id — it labels a second workspace's credential so an
* organization connecting several can tell them apart.
*/
readonly teamName?: string;
}

export type TokenExchangeResult =
Expand Down Expand Up @@ -199,6 +205,7 @@ export async function exchangeAuthorizationCode(
: undefined,
scopes: parseScopes(payload.scope),
teamId: team ? getString(team, 'id') : undefined,
teamName: team ? getString(team, 'name') : undefined,
},
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
-- One row per (thread, order, step) slot in app.messages.
--
-- The message store orders a thread by ("order", step_order), and every
-- appender claims `max("order") + 1` — a read followed by a write. Under
-- READ COMMITTED two concurrent appends to one thread read the same max and
-- both land on it; the rows then TIE: readers sort them arbitrarily, a branch
-- fork "up to this order" copies both, and nothing ever corrects it. The slot
-- is now UNIQUE, so the second appender is refused at the index and re-claims
-- the next slot (`domains/threads/store.ts`, `domains/chat/store.ts` —
-- `INSERT … ON CONFLICT DO NOTHING` plus a bounded retry).
--
-- Existing ties are repaired FIRST, deterministically and without deleting a
-- row: within every (thread_id, "order") group that holds a tie, the rows are
-- renumbered 0..n-1 by (step_order, created_at_ms, id). That keeps the order
-- readers already observe (step first, then arrival) and touches only the
-- step numbers of rows in affected groups — every other row keeps its slot.
--
-- Rolling-deploy safe: the previous image is still serving while this applies.
-- Its appenders carry no ON CONFLICT clause, so a race it loses during the
-- roll answers an error for that one send instead of writing a tie — strictly
-- better than the corruption, and gone once the new image serves.

WITH tied_groups AS (
SELECT DISTINCT thread_id, "order"
FROM app.messages
GROUP BY thread_id, "order", step_order
HAVING count(*) > 1
),
renumbered AS (
SELECT m.id,
row_number() OVER (
PARTITION BY m.thread_id, m."order"
ORDER BY m.step_order, m.created_at_ms, m.id
) - 1 AS step_order
FROM app.messages m
JOIN tied_groups g ON g.thread_id = m.thread_id AND g."order" = m."order"
)
UPDATE app.messages m
SET step_order = r.step_order
FROM renumbered r
WHERE m.id = r.id AND m.step_order <> r.step_order;

-- The unique slot replaces the plain ordering index: same columns, so every
-- (thread_id, "order", step_order) read keeps its plan.
CREATE UNIQUE INDEX IF NOT EXISTS messages_thread_slot
ON app.messages (thread_id, "order", step_order);

DROP INDEX IF EXISTS app.messages_thread_order;
89 changes: 89 additions & 0 deletions services/platform/backend/domains/chat/append-message.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// @vitest-environment node

/**
* The chat appender claims its (order, step) slot the same way the generic
* store does: one statement that reads max+1 and is refused by the unique
* slot index when a concurrent turn got there first — after which it claims
* the next slot, so two racing sends never tie a thread's ordering.
*/

import type { Sql } from 'postgres';
import { describe, expect, it, vi } from 'vitest';

vi.mock('../../lib/org-config.ts', () => ({ resolveOrgSlug: vi.fn() }));
vi.mock('../../core/lib/providers/org_providers.ts', () => ({
resolveProvidersForOrg: vi.fn(),
}));
vi.mock('../../core/lib/providers/catalog_fetch.ts', () => ({
getProviderCatalog: vi.fn(),
}));
vi.mock('../../jobs/enqueue.ts', () => ({ addJobInTx: vi.fn() }));

import { MESSAGE_SLOT_ATTEMPTS } from '../threads/store.ts';
import { appendMessageRow } from './store.ts';

/** A `sql` whose INSERTs answer from `outcomes` in order (an empty array is a
* lost race); every other statement finds nothing. */
function fakeSql(outcomes: { id: string; order: number }[][]): {
sql: Sql;
statements: string[];
} {
const statements: string[] = [];
let inserts = 0;
const tag = (strings: TemplateStringsArray): Promise<unknown[]> => {
const text = strings.join('?');
statements.push(text);
if (text.includes('INSERT INTO app.messages')) {
const outcome = outcomes[inserts] ?? [];
inserts += 1;
return Promise.resolve(outcome);
}
return Promise.resolve([]);
};
Object.assign(tag, { json: (value: unknown) => value });
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- only the tag call and `json` are exercised
return { sql: tag as unknown as Sql, statements };
}

const MESSAGE = {
organizationId: 'org-1',
threadId: 't-1',
role: 'assistant',
parts: [],
status: 'pending',
};

const insertsOf = (statements: string[]): number =>
statements.filter((text) => text.includes('INSERT INTO app.messages')).length;

describe('appendMessageRow — claiming a unique slot', () => {
it('lands on the computed slot when nobody raced it', async () => {
const { sql, statements } = fakeSql([[{ id: 'm-1', order: 7 }]]);
await expect(appendMessageRow(sql, MESSAGE)).resolves.toEqual({
id: 'm-1',
sequence: 7,
});
expect(insertsOf(statements)).toBe(1);
expect(statements[0]).toContain(
'ON CONFLICT (thread_id, "order", step_order) DO NOTHING',
);
});

it('re-claims the next slot after losing the race for one', async () => {
const { sql, statements } = fakeSql([[], [], [{ id: 'm-3', order: 9 }]]);
await expect(appendMessageRow(sql, MESSAGE)).resolves.toEqual({
id: 'm-3',
sequence: 9,
});
expect(insertsOf(statements)).toBe(3);
});

it('fails loudly, and writes nothing else, once the attempts are spent', async () => {
const { sql, statements } = fakeSql([]);
await expect(appendMessageRow(sql, MESSAGE)).rejects.toThrow(
/no free slot/,
);
expect(insertsOf(statements)).toBe(MESSAGE_SLOT_ATTEMPTS);
expect(statements.some((text) => text.includes('UPDATE'))).toBe(false);
});
});
56 changes: 35 additions & 21 deletions services/platform/backend/domains/chat/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { toJson } from '../../db/sql.ts';
import { addJobInTx } from '../../jobs/enqueue.ts';
import { resolveOrgSlug } from '../../lib/org-config.ts';
import { incrementUsageLedger } from '../governance/service.ts';
import { MESSAGE_SLOT_ATTEMPTS } from '../threads/store.ts';

/**
* The Postgres-backed ports the turn pipeline writes through — the 0.5 twin
Expand Down Expand Up @@ -51,28 +52,41 @@ export async function appendMessageRow(
status?: string;
},
): Promise<{ id: string; sequence: number }> {
const rows = await sql<{ id: string; order: number }[]>`
INSERT INTO app.messages (
thread_id, org_id, "order", step_order, role, parts, text, model,
provider_slug, usage, blocked_reason, truncation, error, status,
created_at_ms
)
SELECT ${message.threadId}, ${message.organizationId},
coalesce(max("order"), -1) + 1, 0, ${message.role},
${message.parts === undefined ? null : sql.json(toJson(message.parts))},
${message.text ?? null}, ${message.model ?? null},
${message.providerSlug ?? null},
${message.usage === undefined ? null : sql.json(toJson(message.usage))},
${message.blockedReason ?? null},
${message.truncation === undefined ? null : sql.json(toJson(message.truncation))},
${message.error ?? null}, ${message.status ?? 'complete'},
${Date.now()}
FROM app.messages WHERE thread_id = ${message.threadId}
RETURNING id, "order"
`;
const row = rows[0];
// The slot is UNIQUE: two turns appending to one thread at once both read
// the same max, and the one the index refuses re-claims the next slot on
// a fresh statement instead of tying the winner's ordering.
let row: { id: string; order: number } | undefined;
for (
let attempt = 0;
row === undefined && attempt < MESSAGE_SLOT_ATTEMPTS;
attempt += 1
) {
const rows = await sql<{ id: string; order: number }[]>`
INSERT INTO app.messages (
thread_id, org_id, "order", step_order, role, parts, text, model,
provider_slug, usage, blocked_reason, truncation, error, status,
created_at_ms
)
SELECT ${message.threadId}, ${message.organizationId},
coalesce(max("order"), -1) + 1, 0, ${message.role},
${message.parts === undefined ? null : sql.json(toJson(message.parts))},
${message.text ?? null}, ${message.model ?? null},
${message.providerSlug ?? null},
${message.usage === undefined ? null : sql.json(toJson(message.usage))},
${message.blockedReason ?? null},
${message.truncation === undefined ? null : sql.json(toJson(message.truncation))},
${message.error ?? null}, ${message.status ?? 'complete'},
${Date.now()}
FROM app.messages WHERE thread_id = ${message.threadId}
ON CONFLICT (thread_id, "order", step_order) DO NOTHING
RETURNING id, "order"
`;
row = rows[0];
}
if (!row) {
throw new Error('message insert failed');
throw new Error(
`message insert failed: no free slot after ${MESSAGE_SLOT_ATTEMPTS} attempts`,
);
}
// A turn just wrote to the thread; keep its list ordering fresh. An
// assistant row also stamps the unread watermark; activity on a hidden
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,10 @@ const CREDENTIAL_COLUMNS = `
created_at_ms::float8 AS "createdAt", updated_at_ms::float8 AS "updatedAt"
`;

const NAME_MAX = 100;
/** The longest label a credential may carry — the OAuth callback derives
* workspace-named labels and must stay within it. */
export const CREDENTIAL_NAME_MAX = 100;
const NAME_MAX = CREDENTIAL_NAME_MAX;
const SECRET_VALUE_MAX = 8192;

function normalizeName(raw: string): string {
Expand Down
Loading