From ea09481129f240f20b36e99bf31ac69b12c0ac40 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:23:38 +0000 Subject: [PATCH 1/4] feat(conversations): add internal conversation fork Add forkConversation to create a new root conversation seeded with agent history through a cutoff, plus a native fork backlink event. Co-Authored-By: David Cramer --- .../junior/src/chat/conversations/README.md | 9 + .../junior/src/chat/conversations/fork.ts | 312 ++++++++++++++++++ .../chat/conversations/structured-events.ts | 32 ++ .../component/conversations/fork.test.ts | 251 ++++++++++++++ 4 files changed, 604 insertions(+) create mode 100644 packages/junior/src/chat/conversations/fork.ts create mode 100644 packages/junior/tests/component/conversations/fork.test.ts diff --git a/packages/junior/src/chat/conversations/README.md b/packages/junior/src/chat/conversations/README.md index a658caee3c..28efaee217 100644 --- a/packages/junior/src/chat/conversations/README.md +++ b/packages/junior/src/chat/conversations/README.md @@ -59,6 +59,15 @@ events append to it. The internal `history_version` column makes loading that active history efficient. There is no initial-history event. Database migrations normalize older history shapes before the runtime reads them. +## Conversation Fork + +`fork.ts` owns the internal fork path. A fork creates a **new root** conversation +(not a subagent child via `parent_conversation_id`) and seeds it with the source +conversation's active agent history through a cutoff seq or platform message id. +It records a `junior/conversation_forked` structured event as the backlink. It +does not clone execution state, mailbox, schedules, watches, approvals, or live +tool side effects. + Volatile `` bootstrap is kept only in an unfinished turn's session record. It is removed before SQL history is written and restored for an auth or timeout resume, so agent replay does not need an automatic rollback. diff --git a/packages/junior/src/chat/conversations/fork.ts b/packages/junior/src/chat/conversations/fork.ts new file mode 100644 index 0000000000..1b064427d2 --- /dev/null +++ b/packages/junior/src/chat/conversations/fork.ts @@ -0,0 +1,312 @@ +/** + * Internal conversation fork. + * + * Creates a new root conversation and seeds it with the source conversation's + * active agent history through a cutoff. Does not clone execution state, + * mailbox, schedules, watches, approvals, or live tool side effects. + */ +import { createHash } from "node:crypto"; +import { + createWebSource, + localDestinationSchema, +} from "@sentry/junior-plugin-api"; +import type { ConversationPrivacy } from "@/chat/conversation-privacy"; +import type { + ConversationEvent, + ConversationEventStore, +} from "@/chat/conversations/history"; +import { + commitMessages, + loadTurnProjection, +} from "@/chat/conversations/projection"; +import type { ConversationStore } from "@/chat/conversations/store"; +import { + conversationForkedEvent, + JUNIOR_NATIVE_EVENT_NAMESPACE, +} from "@/chat/conversations/structured-events"; +import { + getConversationEventStore, + getConversationStore, +} from "@/chat/db"; + +export type ForkConversationCutoff = + | { kind: "seq"; throughSeq: number } + | { kind: "message"; messageId: string }; + +export interface ForkConversationInput { + sourceConversationId: string; + cutoff: ForkConversationCutoff; + /** Client-supplied key; retries with the same key return the same fork. */ + idempotencyKey: string; + /** New fork root visibility. Defaults to public. */ + visibility?: ConversationPrivacy; +} + +export interface ForkConversationResult { + conversationId: string; + sourceConversationId: string; + throughSeq: number; + sourceMessageId?: string; + status: "created" | "duplicate"; +} + +export interface ForkConversationDeps { + conversationStore?: ConversationStore; + eventStore?: ConversationEventStore; + nowMs?: number; +} + +function stableHex(...parts: string[]): string { + return createHash("sha256") + .update(parts.join("\u0000")) + .digest("hex") + .slice(0, 24); +} + +/** Deterministic fork conversation id for one source + idempotency key. */ +export function createForkConversationId(args: { + sourceConversationId: string; + idempotencyKey: string; +}): string { + return `local:fork:${stableHex(args.sourceConversationId, args.idempotencyKey)}`; +} + +function forkIdempotencyKey(args: { + sourceConversationId: string; + idempotencyKey: string; +}): string { + return `fork:${stableHex(args.sourceConversationId, args.idempotencyKey)}`; +} + +function requireLocalDestination(conversationId: string) { + const parsed = localDestinationSchema.safeParse({ + platform: "local", + conversationId, + }); + if (!parsed.success) { + throw new Error(`Invalid local conversation id: ${conversationId}`); + } + return parsed.data; +} + +/** Resolve a platform message id to the agent-history seq at that cutoff. */ +export async function resolveForkCutoffSeq(args: { + sourceConversationId: string; + cutoff: ForkConversationCutoff; + eventStore?: ConversationEventStore; +}): Promise<{ throughSeq: number; sourceMessageId?: string }> { + const eventStore = args.eventStore ?? getConversationEventStore(); + if (args.cutoff.kind === "seq") { + if (args.cutoff.throughSeq < 0) { + throw new Error("Fork cutoff seq must be non-negative"); + } + const history = await eventStore.loadHistoryContaining( + args.sourceConversationId, + args.cutoff.throughSeq, + args.cutoff.throughSeq, + ); + if (!history || history.length === 0) { + throw new Error( + `Fork cutoff seq ${args.cutoff.throughSeq} was not found in ${args.sourceConversationId}`, + ); + } + return { throughSeq: args.cutoff.throughSeq }; + } + + const messageId = args.cutoff.messageId.trim(); + if (!messageId) { + throw new Error("Fork cutoff message id must not be empty"); + } + + // Walk the full log so the cutoff can sit on a platform message that is not + // itself an agent-history item. Agent history is then cut at the latest + // history-bearing seq at or before that message. + const events = await eventStore.loadHistory(args.sourceConversationId); + let messageSeq: number | undefined; + for (const event of events) { + if ( + (event.data.type === "message" || + event.data.type === "message_updated") && + event.data.messageId === messageId + ) { + messageSeq = event.seq; + break; + } + } + if (messageSeq === undefined) { + throw new Error( + `Fork cutoff message ${messageId} was not found in ${args.sourceConversationId}`, + ); + } + + const throughSeq = latestAgentHistorySeqAtOrBefore(events, messageSeq); + if (throughSeq === undefined) { + throw new Error( + `Fork cutoff message ${messageId} has no agent history at or before it in ${args.sourceConversationId}`, + ); + } + return { throughSeq, sourceMessageId: messageId }; +} + +function latestAgentHistorySeqAtOrBefore( + events: ConversationEvent[], + messageSeq: number, +): number | undefined { + let throughSeq: number | undefined; + for (const event of events) { + if (event.seq > messageSeq) break; + if ( + event.data.type === "user_message" || + event.data.type === "assistant_message" || + event.data.type === "tool_result" || + event.data.type === "compaction" || + event.data.type === "handoff" || + event.data.type === "authorization_completed" + ) { + throughSeq = event.seq; + } + } + return throughSeq; +} + +/** + * Fork a conversation into a new root at an agent-history cutoff. + * + * The fork is a new independent root (not a subagent child). Agent history + * through the cutoff is copied; runtime and task state are not. + */ +export async function forkConversation( + input: ForkConversationInput, + deps: ForkConversationDeps = {}, +): Promise { + const sourceConversationId = input.sourceConversationId.trim(); + const idempotencyKey = input.idempotencyKey.trim(); + if (!sourceConversationId) { + throw new Error("Fork source conversation id must not be empty"); + } + if (!idempotencyKey) { + throw new Error("Fork idempotency key must not be empty"); + } + + const conversationStore = deps.conversationStore ?? getConversationStore(); + const eventStore = deps.eventStore ?? getConversationEventStore(); + const nowMs = deps.nowMs ?? Date.now(); + + const source = await conversationStore.get({ + conversationId: sourceConversationId, + }); + if (!source) { + throw new Error(`Fork source conversation ${sourceConversationId} not found`); + } + if (source.lineage) { + throw new Error("Forking child conversations is not supported"); + } + if (source.transcriptPurgedAtMs !== undefined) { + throw new Error( + `Fork source conversation ${sourceConversationId} has a purged transcript`, + ); + } + + const conversationId = createForkConversationId({ + sourceConversationId, + idempotencyKey, + }); + const cutoff = await resolveForkCutoffSeq({ + sourceConversationId, + cutoff: input.cutoff, + eventStore, + }); + + const prior = await eventStore.loadLatestStructuredEvent( + conversationId, + JUNIOR_NATIVE_EVENT_NAMESPACE, + conversationForkedEvent.eventName, + ); + if (prior?.data.type === "structured_event") { + const content = conversationForkedEvent.parse(prior.data.content) as { + sourceConversationId: string; + throughSeq: number; + sourceMessageId?: string; + }; + return { + conversationId, + sourceConversationId, + throughSeq: content.throughSeq, + ...(content.sourceMessageId + ? { sourceMessageId: content.sourceMessageId } + : {}), + status: "duplicate", + }; + } + + const projection = await loadTurnProjection({ + conversationId: sourceConversationId, + committedSeq: cutoff.throughSeq, + includeTail: false, + }); + if (!projection) { + throw new Error( + `Fork cutoff seq ${cutoff.throughSeq} is not loadable in ${sourceConversationId}`, + ); + } + + const visibility = input.visibility === "private" ? "private" : "public"; + const destination = requireLocalDestination(conversationId); + await conversationStore.recordActivity({ + conversationId, + destination, + nowMs, + source: "internal", + sessionSource: createWebSource(conversationId, visibility), + visibility, + ...(source.title ? { title: source.title } : {}), + }); + + // Seed agent history before the fork marker. On retry after a partial write, + // skip reseeding when history is already present. The fork marker is the + // durable completion fact. + if (projection.messages.length > 0) { + const existingHistory = await eventStore.loadCurrentHistory(conversationId); + if (existingHistory.length === 0) { + await commitMessages({ + conversationId, + messages: projection.messages, + provenance: projection.provenance, + }); + } + } + + const forkContent = conversationForkedEvent.parse({ + sourceConversationId, + throughSeq: cutoff.throughSeq, + ...(cutoff.sourceMessageId + ? { sourceMessageId: cutoff.sourceMessageId } + : {}), + }); + await eventStore.append(conversationId, [ + { + createdAtMs: nowMs, + idempotencyKey: forkIdempotencyKey({ + sourceConversationId, + idempotencyKey, + }), + data: { + type: "structured_event", + namespace: JUNIOR_NATIVE_EVENT_NAMESPACE, + name: conversationForkedEvent.eventName, + version: conversationForkedEvent.version, + content: forkContent, + }, + }, + ]); + + return { + conversationId, + sourceConversationId, + throughSeq: cutoff.throughSeq, + ...(cutoff.sourceMessageId + ? { sourceMessageId: cutoff.sourceMessageId } + : {}), + status: "created", + }; +} diff --git a/packages/junior/src/chat/conversations/structured-events.ts b/packages/junior/src/chat/conversations/structured-events.ts index 6b329e98d1..9110295ddc 100644 --- a/packages/junior/src/chat/conversations/structured-events.ts +++ b/packages/junior/src/chat/conversations/structured-events.ts @@ -41,6 +41,14 @@ const agentsInstructionsUpdatedContentSchema = z }) .strict(); +const conversationForkedContentSchema = z + .object({ + sourceConversationId: z.string().min(1), + throughSeq: z.number().int().nonnegative(), + sourceMessageId: z.string().min(1).optional(), + }) + .strict(); + function providerTitle(content: { provider: string; providerLabel?: string; @@ -152,10 +160,34 @@ export const agentsInstructionsUpdatedEvent = defineConversationEvent({ }, }); +/** Junior-owned backlink for a conversation forked from an earlier cutoff. */ +export const conversationForkedEvent = defineConversationEvent({ + name: "conversation_forked", + version: 1, + schema: conversationForkedContentSchema, + renderEvent(event) { + return { + icon: "activity", + title: "Forked conversation", + preview: `From ${event.sourceConversationId} through seq ${event.throughSeq}`, + details: [ + { + title: "Forked conversation", + description: event.sourceMessageId + ? `Source message \`${event.sourceMessageId}\` in \`${event.sourceConversationId}\`` + : `Source conversation \`${event.sourceConversationId}\``, + metadata: [`seq ${event.throughSeq}`], + }, + ], + }; + }, +}); + const NATIVE_EVENT_DEFINITIONS: readonly PluginConversationEventDefinition[] = [ authenticationLinkedEvent, authenticationUnlinkedEvent, agentsInstructionsUpdatedEvent, + conversationForkedEvent, ]; /** Resolve a registered Junior-native conversation event definition. */ diff --git a/packages/junior/tests/component/conversations/fork.test.ts b/packages/junior/tests/component/conversations/fork.test.ts new file mode 100644 index 0000000000..db670b74da --- /dev/null +++ b/packages/junior/tests/component/conversations/fork.test.ts @@ -0,0 +1,251 @@ +import { describe, expect, it } from "vitest"; +import { + createForkConversationId, + forkConversation, +} from "@/chat/conversations/fork"; +import { + conversationForkedEvent, + JUNIOR_NATIVE_EVENT_NAMESPACE, + renderJuniorNativeConversationEvent, +} from "@/chat/conversations/structured-events"; +import { + conversationEventSchema, + type ConversationEventData, +} from "@/chat/conversations/history"; +import { + commitMessages, + openConversationProjection, +} from "@/chat/conversations/projection"; +import { + contextProvenance, + instructionProvenanceFor, +} from "@/chat/conversations/provenance"; +import { getConversationEventStore, getConversationStore } from "@/chat/db"; +import type { PiMessage } from "@/chat/pi/messages"; + +const SOURCE_ID = "local:web:fork-source-1"; +const instructionProvenance = instructionProvenanceFor(undefined); + +function userMessage(text: string, timestamp: number): PiMessage { + return { + role: "user", + content: [{ type: "text", text }], + timestamp, + }; +} + +function assistantMessage(text: string, timestamp: number): PiMessage { + return { + role: "assistant", + content: [{ type: "text", text }], + api: "openai-responses", + provider: "openai", + model: "test-model", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + }, + }, + stopReason: "stop", + timestamp, + } as PiMessage; +} + +describe("conversation fork", () => { + it("renders and accepts the native fork event", () => { + const data: ConversationEventData = { + type: "structured_event", + namespace: "junior", + name: "conversation_forked", + version: 1, + content: { + sourceConversationId: SOURCE_ID, + throughSeq: 2, + sourceMessageId: "msg-1", + }, + }; + + expect( + conversationEventSchema.parse({ + schemaVersion: 1, + seq: 0, + historyVersion: 0, + idempotencyKey: "fork-1", + createdAtMs: 1_000, + data, + }).data, + ).toEqual(data); + + expect( + renderJuniorNativeConversationEvent({ + namespace: JUNIOR_NATIVE_EVENT_NAMESPACE, + name: conversationForkedEvent.eventName, + version: conversationForkedEvent.version, + content: data.content, + }), + ).toMatchObject({ + icon: "activity", + title: "Forked conversation", + }); + }); + + it("creates a new root with agent history through the cutoff", async () => { + const store = getConversationStore(); + const events = getConversationEventStore(); + + await store.recordActivity({ + conversationId: SOURCE_ID, + nowMs: 1_000, + source: "web", + title: "Source thread", + destination: { + platform: "local", + conversationId: SOURCE_ID, + }, + visibility: "public", + }); + + const first = userMessage("start here", 1_000); + const second = assistantMessage("first answer", 1_100); + const third = userMessage("later branch point", 1_200); + const fourth = assistantMessage("should not copy", 1_300); + + // Interleave the platform message between history items so a UI + // fork-from-message cuts after the first assistant reply only. + await commitMessages({ + conversationId: SOURCE_ID, + messages: [first, second], + provenance: [instructionProvenance, contextProvenance], + }); + await events.append(SOURCE_ID, [ + { + createdAtMs: 1_150, + idempotencyKey: "message:msg-cutoff", + data: { + type: "message", + messageId: "msg-cutoff", + role: "assistant", + text: "first answer", + }, + }, + ]); + await commitMessages({ + conversationId: SOURCE_ID, + messages: [first, second, third, fourth], + provenance: [ + instructionProvenance, + contextProvenance, + instructionProvenance, + contextProvenance, + ], + }); + + const history = await events.loadCurrentHistory(SOURCE_ID); + const secondSeq = history.find( + (event) => event.data.type === "assistant_message", + )?.seq; + expect(secondSeq).toBeTypeOf("number"); + + const forked = await forkConversation({ + sourceConversationId: SOURCE_ID, + cutoff: { kind: "message", messageId: "msg-cutoff" }, + idempotencyKey: "fork-key-1", + }); + + expect(forked.status).toBe("created"); + expect(forked.conversationId).toBe( + createForkConversationId({ + sourceConversationId: SOURCE_ID, + idempotencyKey: "fork-key-1", + }), + ); + expect(forked.throughSeq).toBe(secondSeq); + expect(forked.sourceMessageId).toBe("msg-cutoff"); + + const forkRow = await store.get({ conversationId: forked.conversationId }); + expect(forkRow).toMatchObject({ + conversationId: forked.conversationId, + source: "internal", + title: "Source thread", + visibility: "public", + }); + expect(forkRow?.lineage).toBeUndefined(); + + const projection = await openConversationProjection({ + conversationId: forked.conversationId, + }); + expect(projection.messages).toHaveLength(2); + expect(projection.messages[0]).toMatchObject({ + role: "user", + content: [{ type: "text", text: "start here" }], + }); + expect(projection.messages[1]).toMatchObject({ + role: "assistant", + content: [{ type: "text", text: "first answer" }], + }); + + const forkEvents = await events.loadHistory(forked.conversationId); + expect( + forkEvents.some( + (event) => + event.data.type === "structured_event" && + event.data.name === "conversation_forked", + ), + ).toBe(true); + + const retry = await forkConversation({ + sourceConversationId: SOURCE_ID, + cutoff: { kind: "message", messageId: "msg-cutoff" }, + idempotencyKey: "fork-key-1", + }); + expect(retry).toEqual({ + ...forked, + status: "duplicate", + }); + + const retryProjection = await openConversationProjection({ + conversationId: forked.conversationId, + }); + expect(retryProjection.messages).toHaveLength(2); + }); + + it("rejects child conversation forks", async () => { + const store = getConversationStore(); + const parentId = "local:web:fork-parent-1"; + const childId = "local:web:fork-child-1"; + + await store.recordActivity({ + conversationId: parentId, + nowMs: 1_000, + source: "web", + destination: { + platform: "local", + conversationId: parentId, + }, + visibility: "public", + }); + await store.createChild({ + parentConversationId: parentId, + childConversationId: childId, + nowMs: 1_100, + source: "internal", + }); + + await expect( + forkConversation({ + sourceConversationId: childId, + cutoff: { kind: "seq", throughSeq: 0 }, + idempotencyKey: "child-fork", + }), + ).rejects.toThrow("Forking child conversations is not supported"); + }); +}); From 473ebfa741bc32dfc1d51d32479116f0cfdd9da0 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:30:44 +0000 Subject: [PATCH 2/4] fix(conversations): apply fork policy constraints Inherit source visibility, drop unused deps injection, and keep native event coverage in the existing unit suite. Co-Authored-By: David Cramer --- .../junior/src/chat/conversations/README.md | 7 +- .../junior/src/chat/conversations/fork.ts | 75 ++++++++++--------- .../component/conversations/fork.test.ts | 58 +------------- .../unit/conversations/native-events.test.ts | 46 ++++++++++++ 4 files changed, 95 insertions(+), 91 deletions(-) diff --git a/packages/junior/src/chat/conversations/README.md b/packages/junior/src/chat/conversations/README.md index 28efaee217..13c1f8973b 100644 --- a/packages/junior/src/chat/conversations/README.md +++ b/packages/junior/src/chat/conversations/README.md @@ -64,9 +64,10 @@ normalize older history shapes before the runtime reads them. `fork.ts` owns the internal fork path. A fork creates a **new root** conversation (not a subagent child via `parent_conversation_id`) and seeds it with the source conversation's active agent history through a cutoff seq or platform message id. -It records a `junior/conversation_forked` structured event as the backlink. It -does not clone execution state, mailbox, schedules, watches, approvals, or live -tool side effects. +It records a `junior/conversation_forked` structured event as the backlink. The +fork inherits the source destination visibility and never widens private or +unknown sources to public. It does not clone execution state, mailbox, schedules, +watches, approvals, or live tool side effects. Volatile `` bootstrap is kept only in an unfinished turn's session record. It is removed before SQL history is written and restored for an diff --git a/packages/junior/src/chat/conversations/fork.ts b/packages/junior/src/chat/conversations/fork.ts index 1b064427d2..2b985d20e5 100644 --- a/packages/junior/src/chat/conversations/fork.ts +++ b/packages/junior/src/chat/conversations/fork.ts @@ -4,6 +4,9 @@ * Creates a new root conversation and seeds it with the source conversation's * active agent history through a cutoff. Does not clone execution state, * mailbox, schedules, watches, approvals, or live tool side effects. + * + * Fork roots inherit source destination visibility and never widen it. + * They are not subagent children (`parentConversationId` stays for delegation). */ import { createHash } from "node:crypto"; import { @@ -11,15 +14,12 @@ import { localDestinationSchema, } from "@sentry/junior-plugin-api"; import type { ConversationPrivacy } from "@/chat/conversation-privacy"; -import type { - ConversationEvent, - ConversationEventStore, -} from "@/chat/conversations/history"; +import type { ConversationEvent } from "@/chat/conversations/history"; import { commitMessages, loadTurnProjection, } from "@/chat/conversations/projection"; -import type { ConversationStore } from "@/chat/conversations/store"; +import type { Conversation } from "@/chat/conversations/store"; import { conversationForkedEvent, JUNIOR_NATIVE_EVENT_NAMESPACE, @@ -38,8 +38,6 @@ export interface ForkConversationInput { cutoff: ForkConversationCutoff; /** Client-supplied key; retries with the same key return the same fork. */ idempotencyKey: string; - /** New fork root visibility. Defaults to public. */ - visibility?: ConversationPrivacy; } export interface ForkConversationResult { @@ -50,12 +48,6 @@ export interface ForkConversationResult { status: "created" | "duplicate"; } -export interface ForkConversationDeps { - conversationStore?: ConversationStore; - eventStore?: ConversationEventStore; - nowMs?: number; -} - function stableHex(...parts: string[]): string { return createHash("sha256") .update(parts.join("\u0000")) @@ -89,13 +81,25 @@ function requireLocalDestination(conversationId: string) { return parsed.data; } -/** Resolve a platform message id to the agent-history seq at that cutoff. */ -export async function resolveForkCutoffSeq(args: { +/** + * Inherit source visibility. Missing or unknown visibility stays private so a + * fork never widens access. + */ +function forkVisibility(source: Conversation): ConversationPrivacy { + return source.visibility === "public" ? "public" : "private"; +} + +type ForkCutoffResolution = { + throughSeq: number; + sourceMessageId?: string; +}; + +/** Resolve a cutoff to the agent-history seq included in the fork. */ +async function resolveForkCutoff(args: { sourceConversationId: string; cutoff: ForkConversationCutoff; - eventStore?: ConversationEventStore; -}): Promise<{ throughSeq: number; sourceMessageId?: string }> { - const eventStore = args.eventStore ?? getConversationEventStore(); +}): Promise { + const eventStore = getConversationEventStore(); if (args.cutoff.kind === "seq") { if (args.cutoff.throughSeq < 0) { throw new Error("Fork cutoff seq must be non-negative"); @@ -118,9 +122,8 @@ export async function resolveForkCutoffSeq(args: { throw new Error("Fork cutoff message id must not be empty"); } - // Walk the full log so the cutoff can sit on a platform message that is not - // itself an agent-history item. Agent history is then cut at the latest - // history-bearing seq at or before that message. + // Platform messages are not agent-history items. Cut agent history at the + // latest history-bearing seq at or before the selected message. const events = await eventStore.loadHistory(args.sourceConversationId); let messageSeq: number | undefined; for (const event of events) { @@ -169,6 +172,16 @@ function latestAgentHistorySeqAtOrBefore( return throughSeq; } +type ForkEventContent = { + sourceConversationId: string; + throughSeq: number; + sourceMessageId?: string; +}; + +function parseForkEventContent(content: unknown): ForkEventContent { + return conversationForkedEvent.parse(content) as ForkEventContent; +} + /** * Fork a conversation into a new root at an agent-history cutoff. * @@ -177,7 +190,6 @@ function latestAgentHistorySeqAtOrBefore( */ export async function forkConversation( input: ForkConversationInput, - deps: ForkConversationDeps = {}, ): Promise { const sourceConversationId = input.sourceConversationId.trim(); const idempotencyKey = input.idempotencyKey.trim(); @@ -188,9 +200,9 @@ export async function forkConversation( throw new Error("Fork idempotency key must not be empty"); } - const conversationStore = deps.conversationStore ?? getConversationStore(); - const eventStore = deps.eventStore ?? getConversationEventStore(); - const nowMs = deps.nowMs ?? Date.now(); + const conversationStore = getConversationStore(); + const eventStore = getConversationEventStore(); + const nowMs = Date.now(); const source = await conversationStore.get({ conversationId: sourceConversationId, @@ -211,10 +223,9 @@ export async function forkConversation( sourceConversationId, idempotencyKey, }); - const cutoff = await resolveForkCutoffSeq({ + const cutoff = await resolveForkCutoff({ sourceConversationId, cutoff: input.cutoff, - eventStore, }); const prior = await eventStore.loadLatestStructuredEvent( @@ -223,11 +234,7 @@ export async function forkConversation( conversationForkedEvent.eventName, ); if (prior?.data.type === "structured_event") { - const content = conversationForkedEvent.parse(prior.data.content) as { - sourceConversationId: string; - throughSeq: number; - sourceMessageId?: string; - }; + const content = parseForkEventContent(prior.data.content); return { conversationId, sourceConversationId, @@ -250,7 +257,7 @@ export async function forkConversation( ); } - const visibility = input.visibility === "private" ? "private" : "public"; + const visibility = forkVisibility(source); const destination = requireLocalDestination(conversationId); await conversationStore.recordActivity({ conversationId, @@ -276,7 +283,7 @@ export async function forkConversation( } } - const forkContent = conversationForkedEvent.parse({ + const forkContent = parseForkEventContent({ sourceConversationId, throughSeq: cutoff.throughSeq, ...(cutoff.sourceMessageId diff --git a/packages/junior/tests/component/conversations/fork.test.ts b/packages/junior/tests/component/conversations/fork.test.ts index db670b74da..906ef0822c 100644 --- a/packages/junior/tests/component/conversations/fork.test.ts +++ b/packages/junior/tests/component/conversations/fork.test.ts @@ -3,15 +3,6 @@ import { createForkConversationId, forkConversation, } from "@/chat/conversations/fork"; -import { - conversationForkedEvent, - JUNIOR_NATIVE_EVENT_NAMESPACE, - renderJuniorNativeConversationEvent, -} from "@/chat/conversations/structured-events"; -import { - conversationEventSchema, - type ConversationEventData, -} from "@/chat/conversations/history"; import { commitMessages, openConversationProjection, @@ -61,44 +52,7 @@ function assistantMessage(text: string, timestamp: number): PiMessage { } describe("conversation fork", () => { - it("renders and accepts the native fork event", () => { - const data: ConversationEventData = { - type: "structured_event", - namespace: "junior", - name: "conversation_forked", - version: 1, - content: { - sourceConversationId: SOURCE_ID, - throughSeq: 2, - sourceMessageId: "msg-1", - }, - }; - - expect( - conversationEventSchema.parse({ - schemaVersion: 1, - seq: 0, - historyVersion: 0, - idempotencyKey: "fork-1", - createdAtMs: 1_000, - data, - }).data, - ).toEqual(data); - - expect( - renderJuniorNativeConversationEvent({ - namespace: JUNIOR_NATIVE_EVENT_NAMESPACE, - name: conversationForkedEvent.eventName, - version: conversationForkedEvent.version, - content: data.content, - }), - ).toMatchObject({ - icon: "activity", - title: "Forked conversation", - }); - }); - - it("creates a new root with agent history through the cutoff", async () => { + it("creates a private root with agent history through the cutoff", async () => { const store = getConversationStore(); const events = getConversationEventStore(); @@ -111,7 +65,8 @@ describe("conversation fork", () => { platform: "local", conversationId: SOURCE_ID, }, - visibility: "public", + // Private source must stay private on the fork root. + visibility: "private", }); const first = userMessage("start here", 1_000); @@ -176,7 +131,7 @@ describe("conversation fork", () => { conversationId: forked.conversationId, source: "internal", title: "Source thread", - visibility: "public", + visibility: "private", }); expect(forkRow?.lineage).toBeUndefined(); @@ -211,11 +166,6 @@ describe("conversation fork", () => { ...forked, status: "duplicate", }); - - const retryProjection = await openConversationProjection({ - conversationId: forked.conversationId, - }); - expect(retryProjection.messages).toHaveLength(2); }); it("rejects child conversation forks", async () => { diff --git a/packages/junior/tests/unit/conversations/native-events.test.ts b/packages/junior/tests/unit/conversations/native-events.test.ts index d59e41bd3a..d4d70c5ddc 100644 --- a/packages/junior/tests/unit/conversations/native-events.test.ts +++ b/packages/junior/tests/unit/conversations/native-events.test.ts @@ -3,6 +3,7 @@ import { agentsInstructionsUpdatedEvent, authenticationLinkedEvent, authenticationUnlinkedEvent, + conversationForkedEvent, renderJuniorNativeConversationEvent, } from "@/chat/conversations/structured-events"; import { @@ -166,4 +167,49 @@ describe("junior native authentication events", () => { }).data, ).toEqual(data); }); + + it("renders and accepts conversation fork backlinks", () => { + expect( + conversationForkedEvent.renderEvent({ + sourceConversationId: "local:web:source-1", + throughSeq: 2, + sourceMessageId: "msg-1", + }), + ).toEqual({ + icon: "activity", + title: "Forked conversation", + preview: "From local:web:source-1 through seq 2", + details: [ + { + title: "Forked conversation", + description: + "Source message `msg-1` in `local:web:source-1`", + metadata: ["seq 2"], + }, + ], + }); + + const data: ConversationEventData = { + type: "structured_event", + namespace: "junior", + name: "conversation_forked", + version: 1, + content: { + sourceConversationId: "local:web:source-1", + throughSeq: 2, + sourceMessageId: "msg-1", + }, + }; + + expect( + conversationEventSchema.parse({ + schemaVersion: 1, + seq: 3, + historyVersion: 0, + idempotencyKey: "native-fork-1", + createdAtMs: 3_000, + data, + }).data, + ).toEqual(data); + }); }); From 205f793d82c41dc1d9ed22b73c1203cfb7d9f395 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:37:01 +0000 Subject: [PATCH 3/4] feat(conversations): link forks to source conversations Co-Authored-By: David Cramer --- .../0025_nifty_stepford_cuckoos.sql | 3 + .../junior/migrations/meta/0025_snapshot.json | 2309 +++++++++++++++++ packages/junior/migrations/meta/_journal.json | 7 + .../junior/src/chat/conversations/README.md | 10 +- .../junior/src/chat/conversations/fork.ts | 1 + .../src/chat/conversations/sql/store.ts | 18 + .../junior/src/chat/conversations/store.ts | 4 + .../junior/src/db/schema/conversations.ts | 9 + .../component/conversations/fork.test.ts | 16 +- 9 files changed, 2372 insertions(+), 5 deletions(-) create mode 100644 packages/junior/migrations/0025_nifty_stepford_cuckoos.sql create mode 100644 packages/junior/migrations/meta/0025_snapshot.json diff --git a/packages/junior/migrations/0025_nifty_stepford_cuckoos.sql b/packages/junior/migrations/0025_nifty_stepford_cuckoos.sql new file mode 100644 index 0000000000..3d4a36e927 --- /dev/null +++ b/packages/junior/migrations/0025_nifty_stepford_cuckoos.sql @@ -0,0 +1,3 @@ +ALTER TABLE "junior_conversations" ADD COLUMN "forked_from_conversation_id" text;--> statement-breakpoint +ALTER TABLE "junior_conversations" ADD CONSTRAINT "junior_conversations_forked_from_conversation_id_junior_conversations_conversation_id_fk" FOREIGN KEY ("forked_from_conversation_id") REFERENCES "public"."junior_conversations"("conversation_id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "junior_conversations_forked_from_idx" ON "junior_conversations" USING btree ("forked_from_conversation_id"); \ No newline at end of file diff --git a/packages/junior/migrations/meta/0025_snapshot.json b/packages/junior/migrations/meta/0025_snapshot.json new file mode 100644 index 0000000000..6d8dbc85cb --- /dev/null +++ b/packages/junior/migrations/meta/0025_snapshot.json @@ -0,0 +1,2309 @@ +{ + "id": "97cf5e18-0c38-4f6d-a2c8-cf46074a11de", + "prevId": "5b615b01-1e96-4396-a09d-82c0a70f4a58", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.junior_agent_bindings": { + "name": "junior_agent_bindings", + "schema": "", + "columns": { + "parent_conversation_id": { + "name": "parent_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_conversation_id": { + "name": "child_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_agent_bindings_child_idx": { + "name": "junior_agent_bindings_child_idx", + "columns": [ + { + "expression": "child_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_agent_bindings_parent_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_bindings_parent_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_bindings", + "tableTo": "junior_conversations", + "columnsFrom": [ + "parent_conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_agent_bindings_child_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_bindings_child_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_bindings", + "tableTo": "junior_conversations", + "columnsFrom": [ + "child_conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_agent_bindings_parent_conversation_id_name_pk": { + "name": "junior_agent_bindings_parent_conversation_id_name_pk", + "columns": [ + "parent_conversation_id", + "name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_agent_invocations": { + "name": "junior_agent_invocations", + "schema": "", + "columns": { + "invocation_id": { + "name": "invocation_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "parent_conversation_id": { + "name": "parent_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_conversation_id": { + "name": "child_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "input": { + "name": "input", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_json": { + "name": "actor_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "credential_context_json": { + "name": "credential_context_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "source_json": { + "name": "source_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_json": { + "name": "destination_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_visibility": { + "name": "destination_visibility", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reasoning_level": { + "name": "reasoning_level", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mailbox_status": { + "name": "mailbox_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "terminal_at": { + "name": "terminal_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_agent_invocations_child_idx": { + "name": "junior_agent_invocations_child_idx", + "columns": [ + { + "expression": "child_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_agent_invocations_mailbox_idx": { + "name": "junior_agent_invocations_mailbox_idx", + "columns": [ + { + "expression": "mailbox_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_agent_invocations_parent_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_invocations_parent_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_invocations", + "tableTo": "junior_conversations", + "columnsFrom": [ + "parent_conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_agent_invocations_child_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_invocations_child_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_invocations", + "tableTo": "junior_conversations", + "columnsFrom": [ + "child_conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_api_tokens": { + "name": "junior_api_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_email_normalized": { + "name": "owner_email_normalized", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_suffix": { + "name": "token_suffix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_api_tokens_token_hash_uidx": { + "name": "junior_api_tokens_token_hash_uidx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_api_tokens_owner_email_idx": { + "name": "junior_api_tokens_owner_email_idx", + "columns": [ + { + "expression": "owner_email_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_annotations": { + "name": "junior_conversation_annotations", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plugin": { + "name": "plugin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "annotation_json": { + "name": "annotation_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "junior_conversation_annotations_conversation_id_fk": { + "name": "junior_conversation_annotations_conversation_id_fk", + "tableFrom": "junior_conversation_annotations", + "tableTo": "junior_conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_annotations_pk": { + "name": "junior_conversation_annotations_pk", + "columns": [ + "conversation_id", + "plugin", + "kind", + "key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_bindings": { + "name": "junior_conversation_bindings", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider_destination_id": { + "name": "provider_destination_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_conversation_id": { + "name": "provider_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_conversation_bindings_conversation_idx": { + "name": "junior_conversation_bindings_conversation_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_conversation_bindings_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_bindings_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_bindings", + "tableTo": "junior_conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_bindings_provider_conversation_pk": { + "name": "junior_conversation_bindings_provider_conversation_pk", + "columns": [ + "provider", + "provider_tenant_id", + "provider_destination_id", + "provider_conversation_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_events": { + "name": "junior_conversation_events", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "history_version": { + "name": "history_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_conversation_events_history_version_idx": { + "name": "junior_conversation_events_history_version_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "history_version", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_events_type_idx": { + "name": "junior_conversation_events_type_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_events_message_search_idx": { + "name": "junior_conversation_events_message_search_idx", + "columns": [ + { + "expression": "to_tsvector('english', \"payload\"->>'text')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_conversation_events\".\"type\" = 'message'", + "concurrently": false, + "method": "gin", + "with": {} + }, + "junior_conversation_events_idempotency_idx": { + "name": "junior_conversation_events_idempotency_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_conversation_events_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_events_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_events", + "tableTo": "junior_conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_events_conversation_id_seq_pk": { + "name": "junior_conversation_events_conversation_id_seq_pk", + "columns": [ + "conversation_id", + "seq" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversations": { + "name": "junior_conversations", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_json": { + "name": "source_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "origin_type": { + "name": "origin_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_id": { + "name": "origin_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_run_id": { + "name": "origin_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destination_id": { + "name": "destination_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destination_json": { + "name": "destination_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "actor_identity_id": { + "name": "actor_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "creator_identity_id": { + "name": "creator_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_subject_identity_id": { + "name": "credential_subject_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_json": { + "name": "actor_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "channel_name": { + "name": "channel_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "execution_updated_at": { + "name": "execution_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "execution_status": { + "name": "execution_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_checkpoint_at": { + "name": "last_checkpoint_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_enqueued_at": { + "name": "last_enqueued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "parent_conversation_id": { + "name": "parent_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "forked_from_conversation_id": { + "name": "forked_from_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "root_conversation_id": { + "name": "root_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transcript_purged_at": { + "name": "transcript_purged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "usage_json": { + "name": "usage_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "execution_duration_ms": { + "name": "execution_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "execution_usage_json": { + "name": "execution_usage_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metric_run_id": { + "name": "metric_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_conversations_last_activity_idx": { + "name": "junior_conversations_last_activity_idx", + "columns": [ + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_active_idx": { + "name": "junior_conversations_active_idx", + "columns": [ + { + "expression": "coalesce(\"execution_updated_at\", \"updated_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_conversations\".\"execution_status\" <> 'idle'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_destination_activity_idx": { + "name": "junior_conversations_destination_activity_idx", + "columns": [ + { + "expression": "destination_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_actor_activity_idx": { + "name": "junior_conversations_actor_activity_idx", + "columns": [ + { + "expression": "actor_identity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_origin_idx": { + "name": "junior_conversations_origin_idx", + "columns": [ + { + "expression": "origin_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_parent_idx": { + "name": "junior_conversations_parent_idx", + "columns": [ + { + "expression": "parent_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_forked_from_idx": { + "name": "junior_conversations_forked_from_idx", + "columns": [ + { + "expression": "forked_from_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_root_idx": { + "name": "junior_conversations_root_idx", + "columns": [ + { + "expression": "root_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_conversations_destination_id_junior_destinations_id_fk": { + "name": "junior_conversations_destination_id_junior_destinations_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_destinations", + "columnsFrom": [ + "destination_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_actor_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_actor_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_identities", + "columnsFrom": [ + "actor_identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_creator_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_creator_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_identities", + "columnsFrom": [ + "creator_identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_credential_subject_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_credential_subject_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_identities", + "columnsFrom": [ + "credential_subject_identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_parent_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversations_parent_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_conversations", + "columnsFrom": [ + "parent_conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_forked_from_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversations_forked_from_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_conversations", + "columnsFrom": [ + "forked_from_conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "junior_conversations_root_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversations_root_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_conversations", + "columnsFrom": [ + "root_conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_destinations": { + "name": "junior_destinations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider_destination_id": { + "name": "provider_destination_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_destination_id": { + "name": "parent_destination_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "metadata_json": { + "name": "metadata_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_destinations_provider_destination_uidx": { + "name": "junior_destinations_provider_destination_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_destination_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_destinations_provider_kind_idx": { + "name": "junior_destinations_provider_kind_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_event_tasks": { + "name": "junior_event_tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "task_json": { + "name": "task_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_event_tasks_team_idx": { + "name": "junior_event_tasks_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_event_tasks_match_idx": { + "name": "junior_event_tasks_match_idx", + "columns": [ + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_identities": { + "name": "junior_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider_subject_id": { + "name": "provider_subject_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata_json": { + "name": "metadata_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_normalized": { + "name": "email_normalized", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "junior_identities_provider_subject_uidx": { + "name": "junior_identities_provider_subject_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_identities_user_idx": { + "name": "junior_identities_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_identities_verified_email_idx": { + "name": "junior_identities_verified_email_idx", + "columns": [ + { + "expression": "email_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_identities\".\"email_verified\" = true AND \"junior_identities\".\"email_normalized\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_identities_kind_provider_idx": { + "name": "junior_identities_kind_provider_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_identities_user_id_junior_users_id_fk": { + "name": "junior_identities_user_id_junior_users_id_fk", + "tableFrom": "junior_identities", + "tableTo": "junior_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_location_configurations": { + "name": "junior_location_configurations", + "schema": "", + "columns": { + "location_id": { + "name": "location_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "junior_location_configurations_location_id_junior_destinations_id_fk": { + "name": "junior_location_configurations_location_id_junior_destinations_id_fk", + "tableFrom": "junior_location_configurations", + "tableTo": "junior_destinations", + "columnsFrom": [ + "location_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_location_configurations_location_id_key_pk": { + "name": "junior_location_configurations_location_id_key_pk", + "columns": [ + "location_id", + "key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_scheduler_runs": { + "name": "junior_scheduler_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scheduled_for_ms": { + "name": "scheduled_for_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "record": { + "name": "record", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_scheduler_runs_task_status_idx": { + "name": "junior_scheduler_runs_task_status_idx", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scheduled_for_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_scheduler_runs_status_idx": { + "name": "junior_scheduler_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scheduled_for_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_scheduler_tasks": { + "name": "junior_scheduler_tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "creator_slack_user_id": { + "name": "creator_slack_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "creator_identity_id": { + "name": "creator_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "next_run_at_ms": { + "name": "next_run_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "run_now_at_ms": { + "name": "run_now_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "record": { + "name": "record", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_scheduler_tasks_creator_idx": { + "name": "junior_scheduler_tasks_creator_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "creator_slack_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_scheduler_tasks\".\"status\" <> 'deleted'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_scheduler_tasks_creator_identity_idx": { + "name": "junior_scheduler_tasks_creator_identity_idx", + "columns": [ + { + "expression": "creator_identity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_scheduler_tasks\".\"status\" <> 'deleted' AND \"junior_scheduler_tasks\".\"creator_identity_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_scheduler_tasks_team_status_idx": { + "name": "junior_scheduler_tasks_team_status_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_scheduler_tasks\".\"status\" <> 'deleted'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_scheduler_tasks_run_now_due_idx": { + "name": "junior_scheduler_tasks_run_now_due_idx", + "columns": [ + { + "expression": "run_now_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_scheduler_tasks\".\"status\" = 'active' AND \"junior_scheduler_tasks\".\"run_now_at_ms\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_scheduler_tasks_next_run_due_idx": { + "name": "junior_scheduler_tasks_next_run_due_idx", + "columns": [ + { + "expression": "next_run_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_scheduler_tasks\".\"status\" = 'active' AND \"junior_scheduler_tasks\".\"next_run_at_ms\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_stats": { + "name": "junior_stats", + "schema": "", + "columns": { + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "junior_stats_date_namespace_metric_name_pk": { + "name": "junior_stats_date_namespace_metric_name_pk", + "columns": [ + "date", + "namespace", + "metric", + "name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_task_executions": { + "name": "junior_task_executions", + "schema": "", + "columns": { + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "executed_at_ms": { + "name": "executed_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_task_executions_task_time_idx": { + "name": "junior_task_executions_task_time_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "executed_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_task_executions_time_kind_idx": { + "name": "junior_task_executions_time_kind_idx", + "columns": [ + { + "expression": "executed_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_task_executions_conversation_time_idx": { + "name": "junior_task_executions_conversation_time_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "executed_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_task_executions_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_task_executions_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_task_executions", + "tableTo": "junior_conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_task_executions_kind_namespace_execution_id_pk": { + "name": "junior_task_executions_kind_namespace_execution_id_pk", + "columns": [ + "kind", + "namespace", + "execution_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "junior_task_executions_kind_check": { + "name": "junior_task_executions_kind_check", + "value": "\"junior_task_executions\".\"kind\" in ('scheduled', 'event')" + }, + "junior_task_executions_status_check": { + "name": "junior_task_executions_status_check", + "value": "\"junior_task_executions\".\"status\" in ('blocked', 'completed', 'failed')" + } + }, + "isRLSEnabled": false + }, + "public.junior_users": { + "name": "junior_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "primary_email": { + "name": "primary_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "primary_email_normalized": { + "name": "primary_email_normalized", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_users_primary_email_normalized_uidx": { + "name": "junior_users_primary_email_normalized_uidx", + "columns": [ + { + "expression": "primary_email_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/junior/migrations/meta/_journal.json b/packages/junior/migrations/meta/_journal.json index 2fa668a218..9134d10013 100644 --- a/packages/junior/migrations/meta/_journal.json +++ b/packages/junior/migrations/meta/_journal.json @@ -176,6 +176,13 @@ "when": 1786313061094, "tag": "0024_misty_typhoid_mary", "breakpoints": true + }, + { + "idx": 25, + "version": "7", + "when": 1786469764922, + "tag": "0025_nifty_stepford_cuckoos", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/junior/src/chat/conversations/README.md b/packages/junior/src/chat/conversations/README.md index 13c1f8973b..3b529f964d 100644 --- a/packages/junior/src/chat/conversations/README.md +++ b/packages/junior/src/chat/conversations/README.md @@ -64,10 +64,12 @@ normalize older history shapes before the runtime reads them. `fork.ts` owns the internal fork path. A fork creates a **new root** conversation (not a subagent child via `parent_conversation_id`) and seeds it with the source conversation's active agent history through a cutoff seq or platform message id. -It records a `junior/conversation_forked` structured event as the backlink. The -fork inherits the source destination visibility and never widens private or -unknown sources to public. It does not clone execution state, mailbox, schedules, -watches, approvals, or live tool side effects. +The fork row stores `forked_from_conversation_id` as an indexed self-reference, +so callers can resolve fork → source and source → forks without scanning events. +It also records a `junior/conversation_forked` structured event with the cutoff +for transcript display. The fork inherits source destination visibility and +never widens private or unknown sources to public. It does not clone execution +state, mailbox, schedules, watches, approvals, or live tool side effects. Volatile `` bootstrap is kept only in an unfinished turn's session record. It is removed before SQL history is written and restored for an diff --git a/packages/junior/src/chat/conversations/fork.ts b/packages/junior/src/chat/conversations/fork.ts index 2b985d20e5..4b4b993b11 100644 --- a/packages/junior/src/chat/conversations/fork.ts +++ b/packages/junior/src/chat/conversations/fork.ts @@ -262,6 +262,7 @@ export async function forkConversation( await conversationStore.recordActivity({ conversationId, destination, + forkedFromConversationId: sourceConversationId, nowMs, source: "internal", sessionSource: createWebSource(conversationId, visibility), diff --git a/packages/junior/src/chat/conversations/sql/store.ts b/packages/junior/src/chat/conversations/sql/store.ts index 8bdeb49b27..30f73c8c3e 100644 --- a/packages/junior/src/chat/conversations/sql/store.ts +++ b/packages/junior/src/chat/conversations/sql/store.ts @@ -282,6 +282,9 @@ function conversationFromRow(readRow: ConversationReadRow): Conversation { }, } : {}), + ...(row.forkedFromConversationId + ? { forkedFromConversationId: row.forkedFromConversationId } + : {}), ...(destination ? { destination } : {}), ...(location ? { location } : {}), ...(actor ? { actor } : {}), @@ -477,6 +480,7 @@ export class SqlStore implements ConversationStore { channelName?: string; conversationId: string; destination?: Destination; + forkedFromConversationId?: string; nowMs?: number; actor?: StoredSlackActor; source?: ConversationSource; @@ -498,6 +502,15 @@ export class SqlStore implements ConversationStore { next: args.destination, }); } + if ( + existing?.forkedFromConversationId && + args.forkedFromConversationId && + existing.forkedFromConversationId !== args.forkedFromConversationId + ) { + throw new Error( + `Conversation fork source changed for ${args.conversationId}`, + ); + } const current = existing ?? emptyConversation({ @@ -519,6 +532,8 @@ export class SqlStore implements ConversationStore { ...currentWithoutPersistedSignals, destination: current.destination ?? args.destination, source: current.source ?? args.source, + forkedFromConversationId: + current.forkedFromConversationId ?? args.forkedFromConversationId, ...(sessionSource ? { sessionSource } : {}), channelName: current.channelName ?? args.channelName, actor: mergeActor(current.actor, args.actor), @@ -796,6 +811,8 @@ export class SqlStore implements ConversationStore { : dateFromMs(conversation.execution.lastEnqueuedAtMs), parentConversationId: conversation.lineage?.parentConversationId ?? null, + forkedFromConversationId: + conversation.forkedFromConversationId ?? null, rootConversationId, }) .onConflictDoUpdate({ @@ -821,6 +838,7 @@ export class SqlStore implements ConversationStore { runId: sql`case when ${incomingExecutionIsFresh} then excluded.run_id else ${juniorConversations.runId} end`, lastCheckpointAt: sql`case when ${incomingExecutionIsFresh} then coalesce(excluded.last_checkpoint_at, ${juniorConversations.lastCheckpointAt}) else ${juniorConversations.lastCheckpointAt} end`, lastEnqueuedAt: sql`case when ${incomingExecutionIsFresh} then coalesce(excluded.last_enqueued_at, ${juniorConversations.lastEnqueuedAt}) else ${juniorConversations.lastEnqueuedAt} end`, + forkedFromConversationId: sql`coalesce(${juniorConversations.forkedFromConversationId}, excluded.forked_from_conversation_id)`, rootConversationId: sql`coalesce(${juniorConversations.rootConversationId}, excluded.root_conversation_id)`, }, }) diff --git a/packages/junior/src/chat/conversations/store.ts b/packages/junior/src/chat/conversations/store.ts index d2aae089c0..f846a0e410 100644 --- a/packages/junior/src/chat/conversations/store.ts +++ b/packages/junior/src/chat/conversations/store.ts @@ -49,6 +49,8 @@ export interface Conversation { }; lastActivityAtMs: number; lineage?: ConversationLineage; + /** Source conversation for an independent fork root. */ + forkedFromConversationId?: string; location?: Location; actor?: StoredSlackActor; schemaVersion: 1; @@ -107,6 +109,8 @@ export interface ConversationStore { channelName?: string; conversationId: string; destination?: Destination; + /** Set only when creating an independent fork root. */ + forkedFromConversationId?: string; nowMs?: number; actor?: StoredSlackActor; source?: ConversationSource; diff --git a/packages/junior/src/db/schema/conversations.ts b/packages/junior/src/db/schema/conversations.ts index 8f3fb685a6..231c66392f 100644 --- a/packages/junior/src/db/schema/conversations.ts +++ b/packages/junior/src/db/schema/conversations.ts @@ -61,6 +61,12 @@ export const juniorConversations = pgTable( parentConversationId: text("parent_conversation_id").references( (): AnyPgColumn => juniorConversations.conversationId, ), + // Forks are independent roots. This edge supports source/fork lookup and + // does not affect root ownership or child conversation traversal. + forkedFromConversationId: text("forked_from_conversation_id").references( + (): AnyPgColumn => juniorConversations.conversationId, + { onDelete: "set null" }, + ), // Roots reference themselves; descendants reference the root whose actor // and destination own their privacy boundary. rootConversationId: text("root_conversation_id").references( @@ -100,6 +106,9 @@ export const juniorConversations = pgTable( table.lastActivityAt.desc(), ), index("junior_conversations_parent_idx").on(table.parentConversationId), + index("junior_conversations_forked_from_idx").on( + table.forkedFromConversationId, + ), index("junior_conversations_root_idx").on(table.rootConversationId), ], ); diff --git a/packages/junior/tests/component/conversations/fork.test.ts b/packages/junior/tests/component/conversations/fork.test.ts index 906ef0822c..9c1a28e722 100644 --- a/packages/junior/tests/component/conversations/fork.test.ts +++ b/packages/junior/tests/component/conversations/fork.test.ts @@ -1,3 +1,4 @@ +import { eq } from "drizzle-orm"; import { describe, expect, it } from "vitest"; import { createForkConversationId, @@ -11,8 +12,13 @@ import { contextProvenance, instructionProvenanceFor, } from "@/chat/conversations/provenance"; -import { getConversationEventStore, getConversationStore } from "@/chat/db"; +import { + getConversationEventStore, + getConversationStore, + getSqlExecutor, +} from "@/chat/db"; import type { PiMessage } from "@/chat/pi/messages"; +import { juniorConversations } from "@/db/schema"; const SOURCE_ID = "local:web:fork-source-1"; const instructionProvenance = instructionProvenanceFor(undefined); @@ -130,11 +136,19 @@ describe("conversation fork", () => { expect(forkRow).toMatchObject({ conversationId: forked.conversationId, source: "internal", + forkedFromConversationId: SOURCE_ID, title: "Source thread", visibility: "private", }); expect(forkRow?.lineage).toBeUndefined(); + const forks = await getSqlExecutor() + .db() + .select({ conversationId: juniorConversations.conversationId }) + .from(juniorConversations) + .where(eq(juniorConversations.forkedFromConversationId, SOURCE_ID)); + expect(forks).toEqual([{ conversationId: forked.conversationId }]); + const projection = await openConversationProjection({ conversationId: forked.conversationId, }); From 66207674222b25f37ef9056b0eb53f7fa474ad28 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:43:01 +0000 Subject: [PATCH 4/4] test(migrations): update applied migration count --- packages/junior/tests/component/scheduled-tasks-sql.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/junior/tests/component/scheduled-tasks-sql.test.ts b/packages/junior/tests/component/scheduled-tasks-sql.test.ts index cc00cfd011..6729992cab 100644 --- a/packages/junior/tests/component/scheduled-tasks-sql.test.ts +++ b/packages/junior/tests/component/scheduled-tasks-sql.test.ts @@ -175,7 +175,7 @@ describe("scheduled-task SQL storage", () => { await expect(migrateSchema(fixture.sql)).resolves.toMatchObject({ existing: 16, - migrated: 9, + migrated: 10, }); const [migrated] = await fixture.sql.query<{ creatorIdentityId: string | null;