@m6d/cortex-server 2.3.0 → 2.4.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.
Files changed (62) hide show
  1. package/contracts/src/runtime/index.ts +99 -3
  2. package/dist/contracts/src/graph/helpers.d.ts +5 -5
  3. package/dist/contracts/src/runtime/index.d.ts +175 -3
  4. package/dist/src/lib/adapters/database/index.d.ts +20 -2
  5. package/dist/src/lib/adapters/database/mssql/attachments.d.ts +9 -9
  6. package/dist/src/lib/adapters/database/mssql/index.d.ts +133 -28
  7. package/dist/src/lib/adapters/database/mssql/llm-requests.d.ts +3 -2
  8. package/dist/src/lib/adapters/database/mssql/messages.d.ts +6 -5
  9. package/dist/src/lib/adapters/database/mssql/outbox.d.ts +103 -0
  10. package/dist/src/lib/adapters/database/mssql/threads.d.ts +21 -14
  11. package/dist/src/lib/adapters/database/postgres/attachments.d.ts +9 -9
  12. package/dist/src/lib/adapters/database/postgres/index.d.ts +133 -28
  13. package/dist/src/lib/adapters/database/postgres/llm-requests.d.ts +3 -2
  14. package/dist/src/lib/adapters/database/postgres/messages.d.ts +6 -5
  15. package/dist/src/lib/adapters/database/postgres/outbox.d.ts +103 -0
  16. package/dist/src/lib/adapters/database/postgres/threads.d.ts +21 -14
  17. package/dist/src/lib/ai/attachments.d.ts +2 -2
  18. package/dist/src/lib/ai/client-tools.d.ts +1 -1
  19. package/dist/src/lib/auth/middleware.d.ts +2 -2
  20. package/dist/src/lib/auth/playground.d.ts +14 -0
  21. package/dist/src/lib/cc/client.d.ts +9 -1
  22. package/dist/src/lib/cc/flusher.d.ts +40 -0
  23. package/dist/src/lib/cc/sync-events.d.ts +45 -0
  24. package/dist/src/lib/cc/types.d.ts +1 -1
  25. package/dist/src/lib/config.d.ts +11 -0
  26. package/dist/src/lib/db/schema.mssql.d.ts +161 -0
  27. package/dist/src/lib/db/schema.pg.d.ts +199 -0
  28. package/dist/src/lib/factory.d.ts +3 -0
  29. package/dist/src/lib/routes/owned-thread.d.ts +5 -4
  30. package/package.json +1 -1
  31. package/src/lib/adapters/database/index.ts +15 -1
  32. package/src/lib/adapters/database/mssql/index.ts +13 -4
  33. package/src/lib/adapters/database/mssql/llm-requests.ts +35 -15
  34. package/src/lib/adapters/database/mssql/messages.ts +81 -30
  35. package/src/lib/adapters/database/mssql/outbox.ts +71 -0
  36. package/src/lib/adapters/database/mssql/threads.ts +42 -11
  37. package/src/lib/adapters/database/postgres/index.ts +13 -4
  38. package/src/lib/adapters/database/postgres/llm-requests.ts +35 -15
  39. package/src/lib/adapters/database/postgres/messages.ts +81 -30
  40. package/src/lib/adapters/database/postgres/outbox.ts +66 -0
  41. package/src/lib/adapters/database/postgres/threads.ts +39 -11
  42. package/src/lib/ai/finish-turn.ts +3 -1
  43. package/src/lib/ai/prompt.ts +3 -1
  44. package/src/lib/auth/middleware.ts +34 -8
  45. package/src/lib/auth/playground.ts +72 -0
  46. package/src/lib/cc/client.ts +21 -1
  47. package/src/lib/cc/flusher.ts +147 -0
  48. package/src/lib/cc/sync-events.ts +65 -0
  49. package/src/lib/cc/types.ts +3 -0
  50. package/src/lib/config.ts +11 -0
  51. package/src/lib/db/migrations/mssql/20260829050547_friendly_red_shift/migration.sql +8 -0
  52. package/src/lib/db/migrations/mssql/20260829050547_friendly_red_shift/snapshot.json +581 -0
  53. package/src/lib/db/migrations/mssql/20260830022424_easy_nicolaos/migration.sql +5 -0
  54. package/src/lib/db/migrations/mssql/20260830022424_easy_nicolaos/snapshot.json +614 -0
  55. package/src/lib/db/migrations/pg/20260829050547_perfect_wildside/migration.sql +7 -0
  56. package/src/lib/db/migrations/pg/20260829050547_perfect_wildside/snapshot.json +650 -0
  57. package/src/lib/db/migrations/pg/20260830022424_serious_deadpool/migration.sql +4 -0
  58. package/src/lib/db/migrations/pg/20260830022424_serious_deadpool/snapshot.json +690 -0
  59. package/src/lib/db/schema.mssql.ts +32 -1
  60. package/src/lib/db/schema.pg.ts +24 -0
  61. package/src/lib/factory.ts +22 -8
  62. package/src/lib/routes/threads.ts +5 -1
@@ -2,13 +2,19 @@
2
2
  // Drizzle types query builders per dialect. See DatabaseAdapter in ../index.ts.
3
3
  import { and, desc, eq } from "drizzle-orm";
4
4
  import { attachments, threads } from "@/db/schema.pg";
5
+ import { threadDeleteEvent, threadUpsertEvent } from "@/cc/sync-events";
5
6
  import type { Thread } from "@/types";
6
7
  import type { ThreadContextMeta } from "@/ai/context/types";
7
8
  import type { DatabaseAdapter } from "@/adapters/database/index";
8
9
  import type { PostgresDb } from "./client";
10
+ import { enqueueSyncEvents } from "./outbox";
9
11
  import type { StorageAdapter } from "@/adapters/storage/index";
10
12
 
11
- export function createThreadsRepository(db: PostgresDb, storage?: StorageAdapter) {
13
+ export function createThreadsRepository(
14
+ db: PostgresDb,
15
+ storage?: StorageAdapter,
16
+ enqueue: typeof enqueueSyncEvents = enqueueSyncEvents,
17
+ ) {
12
18
  return {
13
19
  async list(userId, agentId) {
14
20
  return await db
@@ -28,9 +34,16 @@ export function createThreadsRepository(db: PostgresDb, storage?: StorageAdapter
28
34
  return result.length ? (result[0] as Thread) : null;
29
35
  },
30
36
 
31
- async create(userId, agentId) {
32
- const result = await db.insert(threads).values({ userId, agentId }).returning();
33
- return result[0] as Thread;
37
+ async create(userId, agentId, options) {
38
+ return await db.transaction(async function (tx) {
39
+ const result = await tx
40
+ .insert(threads)
41
+ .values({ userId, agentId, isTest: options?.isTest ?? false })
42
+ .returning();
43
+ const thread = result[0] as Thread;
44
+ await enqueue(tx, [threadUpsertEvent(thread)]);
45
+ return thread;
46
+ });
34
47
  },
35
48
 
36
49
  /** Object storage has no foreign keys, so attachment paths must be swept before row cascade. */
@@ -46,13 +59,20 @@ export function createThreadsRepository(db: PostgresDb, storage?: StorageAdapter
46
59
  await storage.delete(attachmentPaths);
47
60
  }
48
61
 
49
- // attachments.message_id has no cascade, so those rows go first.
50
- await db.delete(attachments).where(eq(attachments.threadId, threadId)).execute();
62
+ await db.transaction(async function (tx) {
63
+ // attachments.message_id has no cascade, so those rows go first.
64
+ await tx.delete(attachments).where(eq(attachments.threadId, threadId)).execute();
51
65
 
52
- await db
53
- .delete(threads)
54
- .where(and(eq(threads.id, threadId), eq(threads.userId, userId)))
55
- .execute();
66
+ const deleted = await tx
67
+ .delete(threads)
68
+ .where(and(eq(threads.id, threadId), eq(threads.userId, userId)))
69
+ .returning({ id: threads.id });
70
+ // The ownership filter can match nothing; a delete event for
71
+ // someone else's thread id must not touch cc's mirror.
72
+ if (deleted.length) {
73
+ await enqueue(tx, [threadDeleteEvent(threadId)]);
74
+ }
75
+ });
56
76
  },
57
77
 
58
78
  async touch(threadId) {
@@ -66,7 +86,15 @@ export function createThreadsRepository(db: PostgresDb, storage?: StorageAdapter
66
86
  },
67
87
 
68
88
  async updateTitle(threadId, title) {
69
- await db.update(threads).set({ title }).where(eq(threads.id, threadId)).execute();
89
+ await db.transaction(async function (tx) {
90
+ const result = await tx
91
+ .update(threads)
92
+ .set({ title })
93
+ .where(eq(threads.id, threadId))
94
+ .returning();
95
+ const thread = result[0];
96
+ if (thread) await enqueue(tx, [threadUpsertEvent(thread)]);
97
+ });
70
98
  },
71
99
 
72
100
  async updateSession(threadId, session) {
@@ -32,7 +32,7 @@ export async function finishTurn(options: FinishTurnOptions) {
32
32
  if (!persistedThread) return;
33
33
 
34
34
  await config.db.messages.upsert(thread.id, messages);
35
- await persistCapturedSteps(capturedSteps, assistantMessageId, config);
35
+ await persistCapturedSteps(capturedSteps, assistantMessageId, thread.id, config);
36
36
 
37
37
  config.onStreamFinish?.({ messages: threadMessages, isAborted });
38
38
 
@@ -55,12 +55,14 @@ export async function finishTurn(options: FinishTurnOptions) {
55
55
  async function persistCapturedSteps(
56
56
  capturedSteps: CapturedStep[],
57
57
  messageId: string | undefined,
58
+ threadId: string,
58
59
  config: ResolvedCortexAgentConfig,
59
60
  ) {
60
61
  if (capturedSteps.length === 0 || !messageId) return;
61
62
 
62
63
  try {
63
64
  await config.db.llmRequests.insert(
65
+ threadId,
64
66
  capturedSteps.map((step, index) => ({
65
67
  messageId,
66
68
  stepNumber: index,
@@ -15,7 +15,9 @@ export async function resolveSession(
15
15
  ) {
16
16
  let session = thread.session;
17
17
 
18
- if (!session && config.loadSessionData) {
18
+ // Test threads carry cc-minted tokens the customer's session hook has
19
+ // never seen — do not hand those to it.
20
+ if (!session && config.loadSessionData && !thread.isTest) {
19
21
  session = await config.loadSessionData(token);
20
22
  // Persist to DB for future cache hits
21
23
  await config.db.threads.updateSession(thread.id, session);
@@ -3,11 +3,33 @@ import { getCookie } from "hono/cookie";
3
3
  import { HTTPException } from "hono/http-exception";
4
4
  import { createRemoteJWKSet, jwtVerify } from "jose";
5
5
  import type { AppEnv, AuthedAppEnv } from "@/types";
6
- import type { CortexConfig } from "@/config";
6
+ import type { ControlCenterConfig, CortexConfig } from "@/config";
7
+ import { isPlaygroundToken, verifyPlaygroundToken } from "./playground";
7
8
 
8
- export function createUserLoaderMiddleware(authConfig: CortexConfig["auth"]) {
9
+ export function createUserLoaderMiddleware(
10
+ authConfig: CortexConfig["auth"],
11
+ controlCenter?: ControlCenterConfig,
12
+ ) {
9
13
  if (!authConfig) {
10
14
  return createMiddleware<AppEnv>(async (c, next) => {
15
+ // Even without primary auth, a cc playground token names a real
16
+ // caller: verifying it keeps playground users from collapsing
17
+ // into the shared anonymous identity (and thread namespace).
18
+ const header = c.req.header("Authorization");
19
+ const token = header?.startsWith("Bearer ") ? header.slice(7) : null;
20
+ if (token && isPlaygroundToken(controlCenter, token)) {
21
+ try {
22
+ const payload = await verifyPlaygroundToken(controlCenter!, token);
23
+ if (payload.sub) {
24
+ c.set("user", { id: payload.sub, token });
25
+ await next();
26
+ return;
27
+ }
28
+ } catch {
29
+ // Invalid playground token: same anonymous fallback as
30
+ // the primary path's failed verification.
31
+ }
32
+ }
11
33
  c.set("user", { id: "00000000-0000-0000-0000-000000000000", token: "" });
12
34
  await next();
13
35
  });
@@ -45,12 +67,16 @@ export function createUserLoaderMiddleware(authConfig: CortexConfig["auth"]) {
45
67
  }
46
68
 
47
69
  try {
48
- const { payload } = await jwtVerify(token, jwks, {
49
- issuer: authConfig.issuer,
50
- // Absorbs clock skew between the IdP and this server; jose
51
- // rejects non-finite values, so this must stay a real number.
52
- clockTolerance: 60,
53
- });
70
+ // The unverified `iss` claim routes the token to its issuer's
71
+ // keys; each is then fully verified against that issuer's JWKS.
72
+ const { payload } = isPlaygroundToken(controlCenter, token)
73
+ ? await verifyPlaygroundToken(controlCenter!, token).then((p) => ({ payload: p }))
74
+ : await jwtVerify(token, jwks, {
75
+ issuer: authConfig.issuer,
76
+ // Absorbs clock skew between the IdP and this server; jose
77
+ // rejects non-finite values, so this must stay a real number.
78
+ clockTolerance: 60,
79
+ });
54
80
 
55
81
  if (payload.sub) {
56
82
  c.set("user", { id: payload.sub, token });
@@ -0,0 +1,72 @@
1
+ import { createRemoteJWKSet, decodeJwt, jwtVerify } from "jose";
2
+ import type { ControlCenterConfig } from "@/config";
3
+
4
+ /*
5
+ * cc as a secondary issuer (opt-in via `controlCenter.playground`): its
6
+ * short-lived end-user JWTs verify against `{url}/api/auth/jwks`, and its
7
+ * signed `X-Cortex-Playground` header marks threads as test data. The remote
8
+ * JWKS set is cached per cc URL — jose already caches the fetched keys and
9
+ * refetches on unknown-kid, so one set per URL is all the state needed.
10
+ */
11
+
12
+ const jwksByUrl = new Map<string, ReturnType<typeof createRemoteJWKSet>>();
13
+
14
+ function ccJwks(config: ControlCenterConfig) {
15
+ const url = `${config.url.replace(/\/+$/, "")}/api/auth/jwks`;
16
+ let jwks = jwksByUrl.get(url);
17
+ if (!jwks) {
18
+ jwks = createRemoteJWKSet(new URL(url));
19
+ jwksByUrl.set(url, jwks);
20
+ }
21
+ return jwks;
22
+ }
23
+
24
+ export function ccIssuer(config: ControlCenterConfig) {
25
+ return config.playgroundIssuer ?? config.url.replace(/\/+$/, "");
26
+ }
27
+
28
+ /**
29
+ * True when the token's (unverified) `iss` names the playground-enabled cc —
30
+ * the routing decision only; verification still happens against the JWKS.
31
+ */
32
+ export function isPlaygroundToken(controlCenter: ControlCenterConfig | undefined, token: string) {
33
+ if (!controlCenter?.playground) return false;
34
+ try {
35
+ return decodeJwt(token).iss === ccIssuer(controlCenter);
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
40
+
41
+ export async function verifyPlaygroundToken(controlCenter: ControlCenterConfig, token: string) {
42
+ const { payload } = await jwtVerify(token, ccJwks(controlCenter), {
43
+ issuer: ccIssuer(controlCenter),
44
+ clockTolerance: 60,
45
+ });
46
+ // cc gates this claim on its playground permission, so a token any other
47
+ // cc user minted cannot authenticate here even with a valid signature.
48
+ if (payload.playground !== true) {
49
+ throw new Error("cc token lacks the playground claim");
50
+ }
51
+ return payload;
52
+ }
53
+
54
+ /**
55
+ * Verifies the cc-signed `X-Cortex-Playground` header. Works for both
56
+ * cc-minted and pasted-real-user-token modes: the header always carries the
57
+ * caller's own cc JWT, independent of the Authorization token.
58
+ */
59
+ export async function isPlaygroundRequest(
60
+ controlCenter: ControlCenterConfig | undefined,
61
+ request: Request,
62
+ ) {
63
+ if (!controlCenter?.playground) return false;
64
+ const header = request.headers.get("x-cortex-playground");
65
+ if (!header) return false;
66
+ try {
67
+ await verifyPlaygroundToken(controlCenter, header);
68
+ return true;
69
+ } catch {
70
+ return false;
71
+ }
72
+ }
@@ -9,8 +9,9 @@ import {
9
9
  searchKnowledgeResponseSchema,
10
10
  searchServicesResponseSchema,
11
11
  searchToolsResponseSchema,
12
+ syncResponseSchema,
12
13
  } from "./types";
13
- import type { ExecuteRequest, ResolveRequest, SearchRequest } from "./types";
14
+ import type { ExecuteRequest, ResolveRequest, SearchRequest, SyncRequest } from "./types";
14
15
 
15
16
  type ControlCenterFetch = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
16
17
 
@@ -190,6 +191,25 @@ export class ControlCenterClient {
190
191
  }
191
192
  }
192
193
 
194
+ /**
195
+ * Pushes one outbox batch to `POST /api/runtime/sync` (server-level, not
196
+ * under an agent). Returns true — the flusher's license to delete the
197
+ * rows — only when cc itself confirms the whole batch: a 2xx whose body
198
+ * parses as the sync response and counts every event. A gateway serving
199
+ * HTML with a 200, or a partial count, keeps the rows queued for retry.
200
+ */
201
+ public async sync(request: SyncRequest, abortSignal?: AbortSignal) {
202
+ const response = await this.postResponse(
203
+ "/api/runtime/sync",
204
+ request,
205
+ undefined,
206
+ abortSignal,
207
+ );
208
+ if (!response?.ok) return false;
209
+ const result = await this.parse(response, syncResponseSchema);
210
+ return result?.accepted === request.events.length;
211
+ }
212
+
193
213
  private agentPath(agentId: string, suffix: string) {
194
214
  return `/api/runtime/agents/${encodeURIComponent(agentId)}${suffix}`;
195
215
  }
@@ -0,0 +1,147 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { Redis } from "ioredis";
3
+ import type { DatabaseAdapter } from "@/adapters/database/index";
4
+ import type { ControlCenterClient } from "./client";
5
+
6
+ const TICK_MS = 10_000;
7
+ const HEARTBEAT_MS = 60_000;
8
+ const BATCH_SIZE = 500;
9
+ // cc rejects runtime bodies over 256 KiB; the envelope around the events is
10
+ // tiny, so this leaves comfortable headroom.
11
+ const MAX_BATCH_BYTES = 192 * 1024;
12
+ // A lease outlives any tick by construction: the sync POST hard-aborts at
13
+ // the client's 15s request timeout, and peek/ack are local DB calls, so a
14
+ // flush cannot still be in flight when the lease lapses.
15
+ const LEASE_MS = 60_000;
16
+
17
+ /**
18
+ * Drains the sync outbox to cc every 10s: peek → sync → ack on success, rows
19
+ * stay put on failure so the next tick retries. A quiet outbox still POSTs an
20
+ * empty batch once a minute — that is the heartbeat behind cc's online badge.
21
+ *
22
+ * With Redis configured (multi-replica deployments), a per-tick lease keeps
23
+ * exactly one replica flushing at a time, so batches cannot overlap or land
24
+ * out of order. Without Redis the deployment is single-process and the
25
+ * in-flight flag alone suffices.
26
+ */
27
+ type OutboxRow = Awaited<ReturnType<DatabaseAdapter["outbox"]["peek"]>>[number];
28
+ type SyncEvent = OutboxRow["event"];
29
+
30
+ /** A contract-valid stand-in for a message too large for cc's request cap:
31
+ * enough text for the transcript, `content` reduced to a truncation marker.
32
+ * The full message stays in the server database. Only messages can grow
33
+ * unbounded; anything else oversized has no meaningful reduction. */
34
+ export function shrinkOversizedEvent(event: SyncEvent) {
35
+ if (event.type !== "message.upsert") return null;
36
+ return {
37
+ ...event,
38
+ text: event.text === null ? null : event.text.slice(0, 4096),
39
+ content: { truncated: true },
40
+ } satisfies SyncEvent;
41
+ }
42
+
43
+ /** Longest row prefix whose serialized events stay under cc's request body
44
+ * cap, or null when the head row alone can never fit (a poison pill that
45
+ * would wedge the queue if kept). */
46
+ export function sliceBatch(rows: OutboxRow[], maxBytes = MAX_BATCH_BYTES) {
47
+ const batch = new Array<OutboxRow>();
48
+ let bytes = 0;
49
+ for (const row of rows) {
50
+ const size = Buffer.byteLength(JSON.stringify(row.event));
51
+ if (batch.length === 0 && size > maxBytes) return null;
52
+ if (bytes + size > maxBytes) break;
53
+ batch.push(row);
54
+ bytes += size;
55
+ }
56
+ return batch;
57
+ }
58
+
59
+ export function startSyncFlusher(db: DatabaseAdapter, client: ControlCenterClient, redis?: Redis) {
60
+ const instanceId = randomUUID();
61
+ // Keyed by cc target + key hash so only replicas of this deployment
62
+ // contend; unrelated deployments sharing a Redis flush independently.
63
+ const leaseKey = `cortex:cc-sync-flusher:${client.cacheKey}`;
64
+ let inFlight = false;
65
+
66
+ async function withLease(run: () => Promise<void>) {
67
+ if (!redis) return run();
68
+ const acquired = await redis.set(leaseKey, instanceId, "PX", LEASE_MS, "NX");
69
+ if (acquired !== "OK") return;
70
+ try {
71
+ await run();
72
+ } finally {
73
+ // Only the owner releases; an expired lease belongs to whoever
74
+ // claimed it next, so a plain DEL would steal it.
75
+ const release = `if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end`;
76
+ await redis.eval(release, 1, leaseKey, instanceId);
77
+ }
78
+ }
79
+
80
+ let lastSyncedAt = 0;
81
+ let epoch: string | undefined;
82
+
83
+ async function flush() {
84
+ const rows = await db.outbox.peek(BATCH_SIZE);
85
+ if (rows.length === 0 && Date.now() - lastSyncedAt < HEARTBEAT_MS) return;
86
+
87
+ // The epoch names this database generation; a recreated database
88
+ // mints a new one, so cc knows its restarted outbox ids are not a
89
+ // stale sender. Minted once, then cached for the process lifetime.
90
+ epoch ??= await db.outbox.epoch();
91
+
92
+ // Batches are additionally bounded by serialized size so they fit
93
+ // cc's request body cap. A lone event that can never fit is mirrored
94
+ // in truncated form when possible; only an unshrinkable one is
95
+ // dropped (the server DB stays system of record), so it cannot wedge
96
+ // every later event and heartbeat behind it.
97
+ let batch = sliceBatch(rows);
98
+ if (batch === null) {
99
+ const oversized = rows[0]!;
100
+ const shrunk = shrinkOversizedEvent(oversized.event);
101
+ if (shrunk === null) {
102
+ console.error(
103
+ `[cortex-server] sync event ${oversized.id} exceeds cc's request size cap; dropping it from the mirror`,
104
+ );
105
+ await db.outbox.ack([oversized.id]);
106
+ return;
107
+ }
108
+ batch = [{ id: oversized.id, event: shrunk }];
109
+ }
110
+
111
+ // Rows are peeked id-asc, so the last id is the batch's fence: cc
112
+ // discards any same-epoch batch at or below its per-server
113
+ // high-water mark, so even a flush that somehow outlives its lease
114
+ // (a stalled process, a response cc finishes applying after the
115
+ // client timed out) cannot overwrite state a newer flush already
116
+ // mirrored.
117
+ const last = batch.at(-1);
118
+ const accepted = await client.sync(
119
+ last
120
+ ? { events: batch.map(({ event }) => event), epoch, seq: last.id }
121
+ : { events: [], epoch },
122
+ );
123
+ if (!accepted) return;
124
+
125
+ lastSyncedAt = Date.now();
126
+ await db.outbox.ack(batch.map(({ id }) => id));
127
+ }
128
+
129
+ async function tick() {
130
+ if (inFlight) return;
131
+ inFlight = true;
132
+ try {
133
+ await withLease(flush);
134
+ } catch (err) {
135
+ console.error("[cortex-server] cc sync flush failed:", err);
136
+ } finally {
137
+ inFlight = false;
138
+ }
139
+ }
140
+
141
+ const interval = setInterval(() => void tick(), TICK_MS);
142
+ void tick();
143
+
144
+ return function stop() {
145
+ clearInterval(interval);
146
+ };
147
+ }
@@ -0,0 +1,65 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { SyncEvent } from "@cortex/contracts/runtime";
3
+ import type { ChatMessage, Thread, TokenUsage } from "@/types";
4
+
5
+ /*
6
+ * Contract event constructors shared by both dialect repositories, so the
7
+ * mssql/postgres twins only differ in SQL. Events are built once, inside the
8
+ * write's transaction, and replayed batches reuse the ids stamped here —
9
+ * that is what makes cc's (serverId, id) upserts idempotent.
10
+ */
11
+
12
+ export function threadUpsertEvent(thread: Thread) {
13
+ return {
14
+ type: "thread.upsert",
15
+ id: thread.id,
16
+ agentSlug: thread.agentId,
17
+ userId: thread.userId,
18
+ title: thread.title,
19
+ isTest: thread.isTest,
20
+ createdAt: thread.createdAt.toISOString(),
21
+ updatedAt: thread.updatedAt.toISOString(),
22
+ } satisfies SyncEvent;
23
+ }
24
+
25
+ export function threadDeleteEvent(threadId: string) {
26
+ return { type: "thread.delete", id: threadId } satisfies SyncEvent;
27
+ }
28
+
29
+ export function messageUpsertEvent(input: {
30
+ threadId: string;
31
+ message: ChatMessage;
32
+ text: string | null | undefined;
33
+ ordinal: number;
34
+ createdAt: Date;
35
+ }) {
36
+ return {
37
+ type: "message.upsert",
38
+ id: input.message.id,
39
+ threadId: input.threadId,
40
+ role: input.message.role,
41
+ text: input.text ?? null,
42
+ ordinal: input.ordinal,
43
+ content: input.message as unknown as Record<string, unknown>,
44
+ createdAt: input.createdAt.toISOString(),
45
+ } satisfies SyncEvent;
46
+ }
47
+
48
+ export function usageRecordEvent(input: {
49
+ threadId: string;
50
+ messageId: string;
51
+ stepNumber: number;
52
+ tokenUsage: TokenUsage;
53
+ }) {
54
+ return {
55
+ type: "usage.record",
56
+ // llm_requests ids are DB-generated and never read back; the event
57
+ // carries its own id, minted once at enqueue time.
58
+ id: randomUUID(),
59
+ messageId: input.messageId,
60
+ threadId: input.threadId,
61
+ stepNumber: input.stepNumber,
62
+ tokenUsage: input.tokenUsage,
63
+ createdAt: new Date().toISOString(),
64
+ } satisfies SyncEvent;
65
+ }
@@ -12,11 +12,14 @@ export {
12
12
  searchKnowledgeResponseSchema,
13
13
  searchServicesResponseSchema,
14
14
  searchToolsResponseSchema,
15
+ syncResponseSchema,
15
16
  type ExecuteRequest,
16
17
  type ResolveRequest,
17
18
  type ResolveResponse,
18
19
  type RuntimeAgentConfig,
19
20
  type SearchRequest,
21
+ type SyncEvent,
22
+ type SyncRequest,
20
23
  type ToolEmbed,
21
24
  } from "@cortex/contracts/runtime";
22
25
 
package/src/lib/config.ts CHANGED
@@ -42,6 +42,17 @@ type RerankerConfig = {
42
42
  export type ControlCenterConfig = {
43
43
  url: string;
44
44
  apiKey: string;
45
+ /**
46
+ * Trust cc as a secondary token issuer (its playground). cc-minted
47
+ * end-user JWTs then authenticate against `{url}/api/auth/jwks`, and a
48
+ * cc-signed `X-Cortex-Playground` header marks new threads as test data.
49
+ */
50
+ playground?: boolean;
51
+ /**
52
+ * The `iss` claim cc stamps (its Better Auth `baseURL`), when it differs
53
+ * from the URL this server dials. Defaults to `url`.
54
+ */
55
+ playgroundIssuer?: string;
45
56
  };
46
57
 
47
58
  export type KnowledgeConfig = {
@@ -0,0 +1,8 @@
1
+ CREATE TABLE [ai].[sync_outbox] (
2
+ [id] bigint IDENTITY(1, 1),
3
+ [event] nvarchar(max) NOT NULL,
4
+ [created_at] datetime2 NOT NULL,
5
+ CONSTRAINT [sync_outbox_pkey] PRIMARY KEY([id])
6
+ );
7
+ --> statement-breakpoint
8
+ ALTER TABLE [ai].[threads] ADD [is_test] bit NOT NULL CONSTRAINT [threads_is_test_default] DEFAULT ((0));