From 2dd177da47c8e6fc5b47508e714ea2e3e8f8c52d Mon Sep 17 00:00:00 2001 From: David Cramer Date: Mon, 13 Jul 2026 15:37:13 -0700 Subject: [PATCH 1/4] feat(migrations): Unify schema and data journals Add a publishable migration package that executes SQL and TypeScript entries from one Drizzle-compatible journal. Route core and plugin upgrades through the shared runner so schema changes and resumable data migrations use one ordered ledger. Keep historical scripts isolated behind versioned capabilities, and wire the package into release, CI, Craft, documentation, and package validation. Co-Authored-By: GPT-5 Codex --- .craft.yml | 3 + .github/workflows/ci.yml | 3 + CONTRIBUTING.md | 1 + README.md | 1 + .../rules/no-core-plugin-dynamic-imports.yml | 2 +- ast-grep/rules/no-core-plugin-reexports.yml | 2 +- .../rules/no-core-plugin-static-imports.yml | 2 +- package.json | 8 +- packages/docs/src/content/docs/cli/upgrade.md | 34 +- .../src/content/docs/contribute/releasing.md | 1 + packages/junior-memory/README.md | 2 + packages/junior-memory/package.json | 2 + packages/junior-migrations/README.md | 39 + packages/junior-migrations/package.json | 47 + packages/junior-migrations/src/cli.ts | 24 + packages/junior-migrations/src/generate.ts | 68 ++ packages/junior-migrations/src/index.ts | 18 + packages/junior-migrations/src/journal.ts | 142 +++ packages/junior-migrations/src/runner.ts | 311 ++++++ packages/junior-migrations/src/types.ts | 90 ++ .../junior-migrations/tests/generate.test.ts | 99 ++ .../junior-migrations/tests/journal.test.ts | 77 ++ .../junior-migrations/tests/runner.test.ts | 276 +++++ .../junior-migrations/tsconfig.build.json | 11 + packages/junior-migrations/tsconfig.json | 14 + packages/junior-migrations/tsup.config.ts | 22 + packages/junior-plugin-api/README.md | 14 +- packages/junior-plugin-api/src/hooks.ts | 8 - packages/junior-plugin-api/src/operations.ts | 22 +- .../junior-plugin-api/src/registration.ts | 3 + packages/junior-scheduler/README.md | 4 + .../migrations/0002_scheduler_state_to_sql.ts | 10 + .../migrations/meta/0002_snapshot.json | 251 +++++ .../migrations/meta/_journal.json | 7 + packages/junior-scheduler/package.json | 2 + packages/junior-scheduler/src/plugin.ts | 9 +- .../0004_agent_turn_session_actor.ts | 10 + .../0005_redis_conversation_state.ts | 10 + .../migrations/0006_conversations_to_sql.ts | 10 + .../0007_conversation_history_to_sql.ts | 10 + packages/junior/migrations/README.md | 17 +- .../junior/migrations/meta/0004_snapshot.json | 993 ++++++++++++++++++ .../junior/migrations/meta/0005_snapshot.json | 993 ++++++++++++++++++ .../junior/migrations/meta/0006_snapshot.json | 993 ++++++++++++++++++ .../junior/migrations/meta/0007_snapshot.json | 993 ++++++++++++++++++ packages/junior/migrations/meta/_journal.json | 30 +- packages/junior/package.json | 2 + .../src/chat/conversations/sql/migrations.ts | 87 +- .../junior/src/chat/plugins/migrations.ts | 166 ++- packages/junior/src/cli/upgrade.ts | 35 +- .../migrations/agent-turn-session-actor.ts | 14 +- .../migrations/conversations-history-sql.ts | 5 - .../upgrade/migrations/conversations-sql.ts | 5 - .../cli/upgrade/migrations/core-journal.ts | 60 ++ .../src/cli/upgrade/migrations/core-sql.ts | 26 - .../cli/upgrade/migrations/plugin-journal.ts | 70 ++ .../src/cli/upgrade/migrations/plugin-sql.ts | 39 - .../cli/upgrade/migrations/plugin-storage.ts | 93 -- .../migrations/redis-conversation-state.ts | 16 +- packages/junior/src/cli/upgrade/types.ts | 9 +- packages/junior/src/db/db.ts | 2 - packages/junior/src/db/neon.ts | 9 - packages/junior/src/db/postgres.ts | 9 - .../tests/component/cli/upgrade-cli.test.ts | 108 +- .../component/memory-plugin-storage.test.ts | 21 +- .../component/scheduler-sql-plugin.test.ts | 213 ++-- .../tests/fixtures/postgres/executor.ts | 6 - packages/junior/tests/fixtures/sql.ts | 2 - .../integration/conversation-sql.test.ts | 2 +- .../junior/tests/unit/sql/executor.test.ts | 1 - packages/junior/tsup.config.ts | 1 + pnpm-lock.yaml | 83 +- scripts/bump-release-versions.mjs | 1 + 73 files changed, 6257 insertions(+), 516 deletions(-) create mode 100644 packages/junior-migrations/README.md create mode 100644 packages/junior-migrations/package.json create mode 100644 packages/junior-migrations/src/cli.ts create mode 100644 packages/junior-migrations/src/generate.ts create mode 100644 packages/junior-migrations/src/index.ts create mode 100644 packages/junior-migrations/src/journal.ts create mode 100644 packages/junior-migrations/src/runner.ts create mode 100644 packages/junior-migrations/src/types.ts create mode 100644 packages/junior-migrations/tests/generate.test.ts create mode 100644 packages/junior-migrations/tests/journal.test.ts create mode 100644 packages/junior-migrations/tests/runner.test.ts create mode 100644 packages/junior-migrations/tsconfig.build.json create mode 100644 packages/junior-migrations/tsconfig.json create mode 100644 packages/junior-migrations/tsup.config.ts create mode 100644 packages/junior-scheduler/migrations/0002_scheduler_state_to_sql.ts create mode 100644 packages/junior-scheduler/migrations/meta/0002_snapshot.json create mode 100644 packages/junior/migrations/0004_agent_turn_session_actor.ts create mode 100644 packages/junior/migrations/0005_redis_conversation_state.ts create mode 100644 packages/junior/migrations/0006_conversations_to_sql.ts create mode 100644 packages/junior/migrations/0007_conversation_history_to_sql.ts create mode 100644 packages/junior/migrations/meta/0004_snapshot.json create mode 100644 packages/junior/migrations/meta/0005_snapshot.json create mode 100644 packages/junior/migrations/meta/0006_snapshot.json create mode 100644 packages/junior/migrations/meta/0007_snapshot.json create mode 100644 packages/junior/src/cli/upgrade/migrations/core-journal.ts delete mode 100644 packages/junior/src/cli/upgrade/migrations/core-sql.ts create mode 100644 packages/junior/src/cli/upgrade/migrations/plugin-journal.ts delete mode 100644 packages/junior/src/cli/upgrade/migrations/plugin-sql.ts delete mode 100644 packages/junior/src/cli/upgrade/migrations/plugin-storage.ts diff --git a/.craft.yml b/.craft.yml index 2b05578d7..e4c71a09a 100644 --- a/.craft.yml +++ b/.craft.yml @@ -5,6 +5,9 @@ targets: - name: npm id: "@sentry/junior" includeNames: /^sentry-junior-\d.*\.tgz$/ + - name: npm + id: "@sentry/junior-migrations" + includeNames: /^sentry-junior-migrations-\d.*\.tgz$/ - name: npm id: "@sentry/junior-plugin-api" includeNames: /^sentry-junior-plugin-api-\d.*\.tgz$/ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2a639ce61..c05d85642 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,6 +49,7 @@ jobs: - .github/actions/** core: - packages/junior/** + - packages/junior-migrations/** - packages/junior-plugin-api/** - packages/junior-scheduler/** - packages/junior-testing/** @@ -215,6 +216,7 @@ jobs: - uses: ./.github/actions/setup-node-pnpm - run: pnpm --filter @sentry/junior-memory build - run: pnpm --filter @sentry/junior-github build + - run: pnpm --filter @sentry/junior-migrations test - run: pnpm --filter @sentry/junior-memory test - run: pnpm --filter @sentry/junior-github test @@ -341,6 +343,7 @@ jobs: run: | mkdir -p artifacts pnpm --filter @sentry/junior pack --pack-destination artifacts + pnpm --filter @sentry/junior-migrations pack --pack-destination artifacts pnpm --filter @sentry/junior-plugin-api pack --pack-destination artifacts pnpm --filter @sentry/junior-agent-browser pack --pack-destination artifacts pnpm --filter @sentry/junior-amplitude pack --pack-destination artifacts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0aed6be2b..25619e00f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -119,6 +119,7 @@ pnpm build:pkg This repo uses Craft for manual lockstep npm releases of: - `@sentry/junior` +- `@sentry/junior-migrations` - `@sentry/junior-plugin-api` - `@sentry/junior-agent-browser` - `@sentry/junior-amplitude` diff --git a/README.md b/README.md index a24241acd..7dfe164c3 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Start here: | Package | Purpose | | ------------------------------ | ---------------------------------------------------------------------------- | | `@sentry/junior` | Core Slack bot runtime | +| `@sentry/junior-migrations` | Mixed Drizzle SQL and TypeScript migration format and runner | | `@sentry/junior-plugin-api` | Lightweight plugin API types and helpers | | `@sentry/junior-agent-browser` | Agent Browser plugin package for browser automation | | `@sentry/junior-amplitude` | Read-only Amplitude product analytics through Amplitude's hosted MCP server | diff --git a/ast-grep/rules/no-core-plugin-dynamic-imports.yml b/ast-grep/rules/no-core-plugin-dynamic-imports.yml index 9ae0f5e4d..a36ee7071 100644 --- a/ast-grep/rules/no-core-plugin-dynamic-imports.yml +++ b/ast-grep/rules/no-core-plugin-dynamic-imports.yml @@ -12,4 +12,4 @@ constraints: all: - regex: '^["@'']@sentry/junior-.+["@'']$' - not: - regex: '^["@'']@sentry/junior-plugin-api["@'']$' + regex: '^["@'']@sentry/junior-(?:plugin-api|migrations)["@'']$' diff --git a/ast-grep/rules/no-core-plugin-reexports.yml b/ast-grep/rules/no-core-plugin-reexports.yml index adb19e779..c4504cd33 100644 --- a/ast-grep/rules/no-core-plugin-reexports.yml +++ b/ast-grep/rules/no-core-plugin-reexports.yml @@ -14,4 +14,4 @@ rule: - not: has: kind: string - regex: '^["@'']@sentry/junior-plugin-api["@'']$' + regex: '^["@'']@sentry/junior-(?:plugin-api|migrations)["@'']$' diff --git a/ast-grep/rules/no-core-plugin-static-imports.yml b/ast-grep/rules/no-core-plugin-static-imports.yml index c2af1d8e9..c26903f92 100644 --- a/ast-grep/rules/no-core-plugin-static-imports.yml +++ b/ast-grep/rules/no-core-plugin-static-imports.yml @@ -14,4 +14,4 @@ rule: - not: has: kind: string - regex: '^["@'']@sentry/junior-plugin-api["@'']$' + regex: '^["@'']@sentry/junior-(?:plugin-api|migrations)["@'']$' diff --git a/package.json b/package.json index bd59a8af1..99e5fd539 100644 --- a/package.json +++ b/package.json @@ -12,19 +12,19 @@ "worktree:setup": "node scripts/worktree.mjs setup", "cloudflare:token": "node scripts/refresh-cloudflare-tunnel-token.mjs", "prepare": "simple-git-hooks", - "lint": "pnpm --filter @sentry/junior lint && pnpm --filter @sentry/junior-memory lint && pnpm --filter @sentry/junior-github lint && pnpm ast-grep:lint && pnpm package:lint", + "lint": "pnpm --filter @sentry/junior-migrations lint && pnpm --filter @sentry/junior lint && pnpm --filter @sentry/junior-memory lint && pnpm --filter @sentry/junior-github lint && pnpm ast-grep:lint && pnpm package:lint", "lint:fix": "pnpm --filter @sentry/junior lint:fix", "ast-grep:lint": "ast-grep scan", "lint-staged": "lint-staged", - "build": "pnpm --filter @sentry/junior build && pnpm --filter @sentry/junior-memory build && pnpm --filter @sentry/junior-github build && pnpm --filter @sentry/junior-dashboard build", + "build": "pnpm --filter @sentry/junior-migrations build && pnpm --filter @sentry/junior build && pnpm --filter @sentry/junior-memory build && pnpm --filter @sentry/junior-github build && pnpm --filter @sentry/junior-dashboard build", "build:example": "pnpm --filter @sentry/junior-example build", "docs:dev": "pnpm --filter @sentry/junior-docs dev", "docs:build": "pnpm --filter @sentry/junior-docs build", "docs:check": "pnpm --filter @sentry/junior-docs check", - "package:lint": "for pkg in packages/junior packages/junior-plugin-api packages/junior-scheduler packages/junior-memory packages/junior-dashboard packages/junior-github packages/junior-agent-browser packages/junior-amplitude packages/junior-cloudflare packages/junior-datadog packages/junior-hex packages/junior-linear packages/junior-maintenance packages/junior-notion packages/junior-sentry packages/junior-vercel; do pnpm exec publint \"$pkg\" || exit $?; done", + "package:lint": "for pkg in packages/junior packages/junior-migrations packages/junior-plugin-api packages/junior-scheduler packages/junior-memory packages/junior-dashboard packages/junior-github packages/junior-agent-browser packages/junior-amplitude packages/junior-cloudflare packages/junior-datadog packages/junior-hex packages/junior-linear packages/junior-maintenance packages/junior-notion packages/junior-sentry packages/junior-vercel; do pnpm exec publint \"$pkg\" || exit $?; done", "release:check": "node scripts/check-release-config.mjs", "start": "pnpm --filter @sentry/junior-example dev", - "test": "pnpm --filter @sentry/junior build && pnpm --filter @sentry/junior-memory build && pnpm --filter @sentry/junior-github build && pnpm --filter @sentry/junior-dashboard build && pnpm --filter @sentry/junior test && pnpm --filter @sentry/junior-memory test && pnpm --filter @sentry/junior-github test && pnpm --filter @sentry/junior-dashboard test", + "test": "pnpm --filter @sentry/junior-migrations test && pnpm --filter @sentry/junior build && pnpm --filter @sentry/junior-memory build && pnpm --filter @sentry/junior-github build && pnpm --filter @sentry/junior-dashboard build && pnpm --filter @sentry/junior test && pnpm --filter @sentry/junior-memory test && pnpm --filter @sentry/junior-github test && pnpm --filter @sentry/junior-dashboard test", "test:e2e:dashboard": "pnpm --filter @sentry/junior-dashboard build && playwright test -c packages/junior-dashboard/playwright.config.ts", "test:watch": "pnpm --filter @sentry/junior test:watch", "evals": "pnpm --filter @sentry/junior-evals evals", diff --git a/packages/docs/src/content/docs/cli/upgrade.md b/packages/docs/src/content/docs/cli/upgrade.md index e491c6b51..4673d0b1a 100644 --- a/packages/docs/src/content/docs/cli/upgrade.md +++ b/packages/docs/src/content/docs/cli/upgrade.md @@ -1,8 +1,8 @@ --- title: "junior upgrade" -description: "Run one-shot Junior state upgrade migrations." +description: "Apply Junior schema and data migrations." type: reference -summary: Move persisted Junior state forward after upgrading packages. +summary: Move configured SQL schemas and persisted state forward after upgrades. prerequisites: - /start-here/quickstart/ related: @@ -11,7 +11,10 @@ related: - /cli/snapshot-create/ --- -Use `junior upgrade` after installing a Junior release that includes a one-shot state migration. The command mutates the configured state stores, so run it from the same app environment that has the production state and SQL environment variables configured for the deployment you are upgrading. +Use `junior upgrade` after installing a Junior release that includes schema or +data migrations. The command mutates the configured SQL database and state +stores, so run it from the same app environment that has the production state +and SQL environment variables configured for the deployment you are upgrading. ## Usage @@ -25,12 +28,25 @@ The command takes no extra arguments. ## What it does -`junior upgrade` runs registered migrations sequentially. Current migrations: +`junior upgrade` runs migrations sequentially. Core and plugin migration +directories use Drizzle Kit's ordered journal and may contain either generated +SQL schema migrations or TypeScript data migrations targeting a versioned +host capability API. Current +upgrade work includes: +- Apply core and enabled-plugin SQL schema migrations. +- Rewrite retained turn-session records from legacy storage shapes before the + new runtime reads them. - Move legacy `junior:conversation-work:*` Redis state into the newer conversation record and index state used by the durable worker and dashboard feed. - Backfill retained conversation records into the shared Junior SQL database. The upgrade requires `DATABASE_URL`. -The migrations are idempotent: rerunning them skips records that were already moved, removes stale legacy index entries that no longer have a record, and upserts SQL conversation rows. The SQL conversation backfill copies a bounded legacy slice of Redis conversation metadata; after cutover, durable conversation metadata is written to SQL while Redis remains the transcript and execution/cache store. +Completed journal entries are tracked individually, and TypeScript migrations +can checkpoint progress for safe retries. Legacy backfills remain idempotent: +rerunning them skips records that were already moved, removes stale legacy +index entries that no longer have a record, and upserts SQL conversation rows. +The SQL conversation backfill copies a bounded legacy slice of Redis +conversation metadata; after cutover, durable conversation metadata is written +to SQL while Redis remains the transcript and execution/cache store. ## Vercel deploys @@ -56,10 +72,10 @@ Typical logs look like this: ```text Running Junior upgrade migrations... -Running migration migrate-redis-conversation-state... -Finished migration migrate-redis-conversation-state: scanned=2 migrated=1 existing=0 missing=1 -Running migration backfill-conversations-sql... -Finished migration backfill-conversations-sql: scanned=2 migrated=2 existing=0 missing=0 +Running migration core-migrations... +Finished migration core-migrations: scanned=8 migrated=4 existing=4 missing=0 skipped=0 +Running migration plugin-migrations... +Finished migration plugin-migrations: scanned=8 migrated=8 existing=0 missing=0 skipped=0 Junior upgrade complete. ``` diff --git a/packages/docs/src/content/docs/contribute/releasing.md b/packages/docs/src/content/docs/contribute/releasing.md index 5556fcb5c..9950005a3 100644 --- a/packages/docs/src/content/docs/contribute/releasing.md +++ b/packages/docs/src/content/docs/contribute/releasing.md @@ -12,6 +12,7 @@ related: Junior uses lockstep package releases for: - `@sentry/junior` +- `@sentry/junior-migrations` - `@sentry/junior-plugin-api` - `@sentry/junior-agent-browser` - `@sentry/junior-amplitude` diff --git a/packages/junior-memory/README.md b/packages/junior-memory/README.md index f6c634a13..1eb814815 100644 --- a/packages/junior-memory/README.md +++ b/packages/junior-memory/README.md @@ -55,6 +55,8 @@ exported types, tools, and tests are authoritative. - `MEMORY_RECALL_MAX_VECTOR_DISTANCE` or `recallMaxVectorDistance` configures the vector candidate threshold. - Generate schema changes with `pnpm --filter @sentry/junior-memory db:generate`. +- Generate self-contained data migrations with + `pnpm --filter @sentry/junior-memory db:generate:data --name `. Follow `../../policies/data-redaction.md`, `../../policies/security.md`, and the plugin contract in `../junior-plugin-api/README.md`. diff --git a/packages/junior-memory/package.json b/packages/junior-memory/package.json index 28e3c84ca..8dbd204c6 100644 --- a/packages/junior-memory/package.json +++ b/packages/junior-memory/package.json @@ -25,6 +25,7 @@ "scripts": { "build": "tsup && tsc -p tsconfig.build.json --emitDeclarationOnly", "db:generate": "pnpm exec drizzle-kit generate --config drizzle.config.ts", + "db:generate:data": "pnpm exec junior-migrations generate --config drizzle.config.ts --out migrations", "lint": "oxlint --config ../junior/.oxlintrc.json --deny-warnings src tests tsup.config.ts", "prepare": "pnpm run build", "prepack": "pnpm run build", @@ -39,6 +40,7 @@ "zod": "catalog:" }, "devDependencies": { + "@sentry/junior-migrations": "workspace:*", "@sentry/junior-testing": "workspace:*", "@types/node": "^25.9.1", "drizzle-kit": "catalog:", diff --git a/packages/junior-migrations/README.md b/packages/junior-migrations/README.md new file mode 100644 index 000000000..c78ddc14c --- /dev/null +++ b/packages/junior-migrations/README.md @@ -0,0 +1,39 @@ +# `@sentry/junior-migrations` + +Junior's migration format extends a Drizzle Kit journal with TypeScript data +migrations. Every journal entry resolves to exactly one `.sql` or +`.ts` file. Drizzle Kit continues to own numbering and schema snapshots; +the Junior migration runner owns execution and exact per-entry tracking. + +TypeScript migrations target a versioned capability API and must not import +Junior runtime internals. New parsers and transforms belong in the migration +file so application refactors cannot break pending upgrades. A journal can +also invoke a versioned host task when adopting legacy migration code that +already shipped before the mixed journal existed; the task name then becomes +part of the permanent migration ABI. + +## Authoring + +Generate schema migrations with the owning package's normal Drizzle command. +Generate data migrations through this package's wrapper: + +```bash +junior-migrations generate \ + --config drizzle.config.ts \ + --out migrations \ + --name backfill_actor +``` + +The wrapper asks Drizzle Kit to create a custom journal entry and snapshot, +then replaces the empty SQL file with a `MigrationV1` TypeScript scaffold. + +## Compatibility + +Drizzle Kit remains the supported authoring tool. Drizzle ORM's stock +`migrate()` function is not a supported executor for mixed journals because it +requires every entry to have a SQL file. Call `runMigrationJournal` instead and +provide the SQL, state, loader, and locking capabilities owned by the host. + +The runner validates that each TypeScript migration has no runtime imports. +Only a type-only import from `@sentry/junior-migrations` is accepted. Add a new +API version rather than changing an existing migration capability contract. diff --git a/packages/junior-migrations/package.json b/packages/junior-migrations/package.json new file mode 100644 index 000000000..391e14d0f --- /dev/null +++ b/packages/junior-migrations/package.json @@ -0,0 +1,47 @@ +{ + "name": "@sentry/junior-migrations", + "version": "0.101.0", + "private": false, + "publishConfig": { + "access": "public" + }, + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/getsentry/junior.git", + "directory": "packages/junior-migrations" + }, + "bin": { + "junior-migrations": "dist/cli.js" + }, + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist", + "src" + ], + "scripts": { + "build": "tsup && tsc -p tsconfig.build.json --emitDeclarationOnly", + "prepare": "pnpm run build", + "prepack": "pnpm run build", + "lint": "oxlint --config ../junior/.oxlintrc.json --deny-warnings src tests tsup.config.ts", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "peerDependencies": { + "drizzle-kit": "catalog:" + }, + "devDependencies": { + "@types/node": "^25.9.1", + "drizzle-kit": "catalog:", + "drizzle-orm": "catalog:", + "oxlint": "^1.66.0", + "tsup": "^8.5.1", + "typescript": "^6.0.3", + "vitest": "^4.1.7" + } +} diff --git a/packages/junior-migrations/src/cli.ts b/packages/junior-migrations/src/cli.ts new file mode 100644 index 000000000..22714b788 --- /dev/null +++ b/packages/junior-migrations/src/cli.ts @@ -0,0 +1,24 @@ +import { parseArgs } from "node:util"; +import { generateTypeScriptMigration } from "./generate"; + +const { positionals, values } = parseArgs({ + allowPositionals: true, + options: { + config: { type: "string" }, + name: { type: "string" }, + out: { type: "string", default: "./migrations" }, + }, +}); + +if (positionals[0] !== "generate" || !values.config || !values.name) { + throw new Error( + "Usage: junior-migrations generate --config --name [--out ]", + ); +} + +const path = await generateTypeScriptMigration({ + configPath: values.config, + migrationsFolder: values.out, + name: values.name, +}); +console.log(`Created TypeScript migration ${path}`); diff --git a/packages/junior-migrations/src/generate.ts b/packages/junior-migrations/src/generate.ts new file mode 100644 index 000000000..3c78c79a3 --- /dev/null +++ b/packages/junior-migrations/src/generate.ts @@ -0,0 +1,68 @@ +import { spawn } from "node:child_process"; +import { readFile, rename, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { readMigrationJournal } from "./journal"; + +/** Drizzle Kit inputs used to create one TypeScript migration entry. */ +export interface GenerateTypeScriptMigrationOptions { + configPath: string; + cwd?: string; + migrationsFolder: string; + name: string; +} + +function run(command: string, args: string[], cwd: string): Promise { + return new Promise((resolvePromise, reject) => { + const child = spawn(command, args, { cwd, stdio: "inherit" }); + child.on("error", reject); + child.on("exit", (code) => { + if (code === 0) { + resolvePromise(); + } else { + reject(new Error(`${command} exited with code ${code ?? "unknown"}`)); + } + }); + }); +} + +function scaffold(): string { + return `import type { MigrationV1 } from "@sentry/junior-migrations";\n\nconst migration = {\n apiVersion: 1,\n async up(context) {\n void context;\n },\n} satisfies MigrationV1;\n\nexport default migration;\n`; +} + +/** Create a journaled TypeScript migration through Drizzle Kit's custom generator. */ +export async function generateTypeScriptMigration( + options: GenerateTypeScriptMigrationOptions, +): Promise { + const cwd = resolve(options.cwd ?? process.cwd()); + const folder = resolve(cwd, options.migrationsFolder); + const before = await readMigrationJournal(folder).catch(() => []); + await run( + "drizzle-kit", + [ + "generate", + "--custom", + "--config", + options.configPath, + "--name", + options.name, + ], + cwd, + ); + const after = await readMigrationJournal(folder); + if (after.length !== before.length + 1) { + throw new Error("Drizzle Kit did not create exactly one migration entry"); + } + const entry = after.at(-1); + if (!entry) { + throw new Error("Drizzle Kit did not create a migration entry"); + } + const sqlPath = resolve(folder, `${entry.tag}.sql`); + const source = await readFile(sqlPath, "utf8"); + if (!source.includes("Custom SQL migration file")) { + throw new Error(`Refusing to replace non-custom migration ${entry.tag}`); + } + const typescriptPath = resolve(folder, `${entry.tag}.ts`); + await rename(sqlPath, typescriptPath); + await writeFile(typescriptPath, scaffold(), "utf8"); + return typescriptPath; +} diff --git a/packages/junior-migrations/src/index.ts b/packages/junior-migrations/src/index.ts new file mode 100644 index 000000000..44e810595 --- /dev/null +++ b/packages/junior-migrations/src/index.ts @@ -0,0 +1,18 @@ +export { generateTypeScriptMigration } from "./generate"; +export { readMigrationJournal, resolveMigrations } from "./journal"; +export { runMigrationJournal } from "./runner"; +export type { GenerateTypeScriptMigrationOptions } from "./generate"; +export type { RunMigrationJournalOptions } from "./runner"; +export type { + MigrationContextV1, + MigrationJsonValue, + MigrationJournalEntry, + MigrationProgressV1, + MigrationRunResult, + MigrationSqlExecutor, + MigrationStateV1, + MigrationTasksV1, + MigrationV1, + ResolvedMigration, + TypeScriptMigrationLoader, +} from "./types"; diff --git a/packages/junior-migrations/src/journal.ts b/packages/junior-migrations/src/journal.ts new file mode 100644 index 000000000..14bd1dc8b --- /dev/null +++ b/packages/junior-migrations/src/journal.ts @@ -0,0 +1,142 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import type { MigrationJournalEntry, ResolvedMigration } from "./types"; + +interface DrizzleJournalEntry { + breakpoints?: unknown; + idx?: unknown; + tag?: unknown; + when?: unknown; +} + +interface DrizzleJournal { + dialect?: unknown; + entries?: unknown; +} + +function parseJournalEntry( + value: DrizzleJournalEntry, + position: number, +): MigrationJournalEntry { + if ( + typeof value.idx !== "number" || + !Number.isInteger(value.idx) || + value.idx !== position || + typeof value.when !== "number" || + !Number.isFinite(value.when) || + typeof value.tag !== "string" || + !value.tag || + typeof value.breakpoints !== "boolean" + ) { + throw new Error(`Invalid Drizzle journal entry at index ${position}`); + } + return { + breakpoints: value.breakpoints, + index: value.idx, + tag: value.tag, + when: value.when, + }; +} + +async function optionalFile(path: string): Promise { + try { + return await readFile(path, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return undefined; + } + throw error; + } +} + +function validateTypeScriptSource(tag: string, source: string): void { + const imports = source.matchAll( + /^\s*import\s+([^;]+?)\s+from\s+["']([^"']+)["'];?/gm, + ); + for (const match of imports) { + const clause = match[1]?.trim(); + const specifier = match[2]; + if ( + !clause?.startsWith("type ") || + specifier !== "@sentry/junior-migrations" + ) { + throw new Error( + `TypeScript migration ${tag} may only import migration types`, + ); + } + } + if ( + /^\s*import\s*["']/m.test(source) || + /^\s*export\s+.+\s+from\s+["']/m.test(source) || + /\b(?:import\s*\(|require\s*\()/.test(source) + ) { + throw new Error(`TypeScript migration ${tag} cannot load runtime modules`); + } +} + +/** Read and validate the ordered Drizzle journal entries in one migration directory. */ +export async function readMigrationJournal( + migrationsFolder: string, +): Promise { + const journalPath = join(migrationsFolder, "meta", "_journal.json"); + let source: string; + try { + source = await readFile(journalPath, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + throw new Error("Can't find meta/_journal.json file", { cause: error }); + } + throw error; + } + const parsed = JSON.parse(source) as DrizzleJournal; + if (parsed.dialect !== "postgresql" || !Array.isArray(parsed.entries)) { + throw new Error(`Unsupported Drizzle journal in ${journalPath}`); + } + const entries = parsed.entries.map((entry, index) => + parseJournalEntry(entry as DrizzleJournalEntry, index), + ); + const timestamps = new Set(); + for (const entry of entries) { + if (timestamps.has(entry.when)) { + throw new Error(`Duplicate migration timestamp ${entry.when}`); + } + timestamps.add(entry.when); + } + return entries; +} + +/** Resolve every journal entry to exactly one immutable SQL or TypeScript source file. */ +export async function resolveMigrations( + migrationsFolder: string, +): Promise { + const entries = await readMigrationJournal(migrationsFolder); + return await Promise.all( + entries.map(async (entry) => { + const sqlPath = join(migrationsFolder, `${entry.tag}.sql`); + const typescriptPath = join(migrationsFolder, `${entry.tag}.ts`); + const [sqlSource, typescriptSource] = await Promise.all([ + optionalFile(sqlPath), + optionalFile(typescriptPath), + ]); + if ((sqlSource === undefined) === (typescriptSource === undefined)) { + throw new Error( + `Migration ${entry.tag} must have exactly one .sql or .ts file`, + ); + } + const kind = sqlSource === undefined ? "typescript" : "sql"; + const source = sqlSource ?? typescriptSource ?? ""; + const path = kind === "sql" ? sqlPath : typescriptPath; + if (kind === "typescript") { + validateTypeScriptSource(entry.tag, source); + } + return { + ...entry, + hash: createHash("sha256").update(source).digest("hex"), + kind, + path, + source, + }; + }), + ); +} diff --git a/packages/junior-migrations/src/runner.ts b/packages/junior-migrations/src/runner.ts new file mode 100644 index 000000000..48912d40b --- /dev/null +++ b/packages/junior-migrations/src/runner.ts @@ -0,0 +1,311 @@ +import type { + MigrationContextV1, + MigrationRunResult, + MigrationSqlExecutor, + MigrationV1, + ResolvedMigration, + TypeScriptMigrationLoader, +} from "./types"; +import { resolveMigrations } from "./journal"; + +interface MigrationRow { + createdAt: string; + hash: string; + progress: unknown; + status: string | null; +} + +interface RunMigrationJournalBaseOptions { + beforeRun?: () => Promise; + executor: MigrationSqlExecutor; + migrationsFolder: string; + migrationsTable: string; +} + +interface RunAllMigrationJournalOptions extends RunMigrationJournalBaseOptions { + createContext: (args: { + migration: ResolvedMigration; + progress: MigrationContextV1["progress"]; + }) => MigrationContextV1; + loadTypeScript: TypeScriptMigrationLoader; + mode?: "all"; +} + +interface RunSqlMigrationJournalOptions extends RunMigrationJournalBaseOptions { + createContext?: never; + loadTypeScript?: never; + mode: "sql"; +} + +/** Host capabilities and execution mode for one mixed migration journal. */ +export type RunMigrationJournalOptions = + | RunAllMigrationJournalOptions + | RunSqlMigrationJournalOptions; + +function identifier(value: string): string { + if (!/^[a-z_][a-z0-9_]*$/.test(value)) { + throw new Error(`Invalid migration table identifier: ${value}`); + } + return value; +} + +function qualifiedTable(table: string): string { + return `drizzle.${identifier(table)}`; +} + +async function ensureMigrationTable( + executor: MigrationSqlExecutor, + table: string, +): Promise { + const qualified = qualifiedTable(table); + await executor.execute("CREATE SCHEMA IF NOT EXISTS drizzle"); + await executor.execute(` +CREATE TABLE IF NOT EXISTS ${qualified} ( + id SERIAL PRIMARY KEY, + hash TEXT NOT NULL, + created_at BIGINT +) +`); + await executor.execute( + `ALTER TABLE ${qualified} ADD COLUMN IF NOT EXISTS name TEXT`, + ); + await executor.execute( + `ALTER TABLE ${qualified} ADD COLUMN IF NOT EXISTS kind TEXT`, + ); + await executor.execute( + `ALTER TABLE ${qualified} ADD COLUMN IF NOT EXISTS status TEXT`, + ); + await executor.execute( + `ALTER TABLE ${qualified} ADD COLUMN IF NOT EXISTS progress JSONB`, + ); + await executor.execute( + `ALTER TABLE ${qualified} ADD COLUMN IF NOT EXISTS result JSONB`, + ); + await executor.execute( + `ALTER TABLE ${qualified} ADD COLUMN IF NOT EXISTS started_at TIMESTAMPTZ`, + ); + await executor.execute( + `ALTER TABLE ${qualified} ADD COLUMN IF NOT EXISTS completed_at TIMESTAMPTZ`, + ); +} + +async function migrationRows( + executor: MigrationSqlExecutor, + table: string, +): Promise> { + const rows = await executor.query(` +SELECT + created_at::text AS "createdAt", + hash, + progress, + status +FROM ${qualifiedTable(table)} +WHERE created_at IS NOT NULL +ORDER BY created_at ASC, id ASC +`); + return new Map(rows.map((row) => [Number(row.createdAt), row])); +} + +async function adoptLegacySqlPrefix(args: { + executor: MigrationSqlExecutor; + migrations: readonly ResolvedMigration[]; + rows: Map; + table: string; +}): Promise { + const latestAppliedAt = Math.max( + ...args.rows.keys(), + Number.NEGATIVE_INFINITY, + ); + if (!Number.isFinite(latestAppliedAt)) { + return; + } + for (const migration of args.migrations) { + if ( + migration.when >= latestAppliedAt || + migration.kind !== "sql" || + args.rows.has(migration.when) + ) { + continue; + } + await args.executor.execute( + `INSERT INTO ${qualifiedTable(args.table)} + (hash, created_at, name, kind, status, completed_at) + VALUES ($1, $2, $3, 'sql', 'completed', NOW())`, + [migration.hash, migration.when, migration.tag], + ); + } +} + +function sqlStatements(migration: ResolvedMigration): string[] { + return migration.source + .split("--> statement-breakpoint") + .map((statement) => statement.trim()) + .filter(Boolean); +} + +function migrationV1(value: unknown, tag: string): MigrationV1 { + const candidate = + typeof value === "object" && value !== null && "default" in value + ? (value as { default?: unknown }).default + : value; + if ( + typeof candidate !== "object" || + candidate === null || + (candidate as { apiVersion?: unknown }).apiVersion !== 1 || + typeof (candidate as { up?: unknown }).up !== "function" + ) { + throw new Error(`TypeScript migration ${tag} does not export MigrationV1`); + } + return candidate as MigrationV1; +} + +async function runSqlMigration(args: { + executor: MigrationSqlExecutor; + migration: ResolvedMigration; + table: string; +}): Promise { + await args.executor.transaction(async () => { + for (const statement of sqlStatements(args.migration)) { + await args.executor.execute(statement); + } + await args.executor.execute( + `INSERT INTO ${qualifiedTable(args.table)} + (hash, created_at, name, kind, status, started_at, completed_at) + VALUES ($1, $2, $3, 'sql', 'completed', NOW(), NOW())`, + [args.migration.hash, args.migration.when, args.migration.tag], + ); + }); +} + +async function runTypeScriptMigration(args: { + createContext: RunAllMigrationJournalOptions["createContext"]; + executor: MigrationSqlExecutor; + loadTypeScript: TypeScriptMigrationLoader; + migration: ResolvedMigration; + row: MigrationRow | undefined; + table: string; +}): Promise { + const qualified = qualifiedTable(args.table); + if (args.row && args.row.hash !== args.migration.hash) { + throw new Error(`Migration ${args.migration.tag} changed after it started`); + } + if (args.row) { + await args.executor.execute( + `UPDATE ${qualified} + SET name = $1, kind = 'typescript', status = 'running', started_at = NOW() + WHERE created_at = $2`, + [args.migration.tag, args.migration.when], + ); + } else { + await args.executor.execute( + `INSERT INTO ${qualified} + (hash, created_at, name, kind, status, started_at) + VALUES ($1, $2, $3, 'typescript', 'running', NOW())`, + [args.migration.hash, args.migration.when, args.migration.tag], + ); + } + const progress: MigrationContextV1["progress"] = { + async load() { + const [current] = await args.executor.query<{ + progress: Awaited>; + }>(`SELECT progress FROM ${qualified} WHERE created_at = $1 LIMIT 1`, [ + args.migration.when, + ]); + return current?.progress ?? undefined; + }, + async save(value) { + await args.executor.execute( + `UPDATE ${qualified} SET progress = $1, status = 'running' WHERE created_at = $2`, + [value, args.migration.when], + ); + }, + }; + try { + const context = args.createContext({ migration: args.migration, progress }); + const migration = migrationV1( + await args.loadTypeScript(args.migration.path), + args.migration.tag, + ); + const result = await migration.up(context); + await args.executor.execute( + `UPDATE ${qualified} + SET status = 'completed', result = $1, completed_at = NOW() + WHERE created_at = $2`, + [result ?? null, args.migration.when], + ); + } catch (error) { + await args.executor.execute( + `UPDATE ${qualified} SET status = 'failed' WHERE created_at = $1`, + [args.migration.when], + ); + throw error; + } +} + +/** Execute one mixed Drizzle journal with exact SQL and TypeScript entry tracking. */ +export async function runMigrationJournal( + options: RunMigrationJournalOptions, +): Promise { + const migrations = await resolveMigrations(options.migrationsFolder); + const mode = options.mode ?? "all"; + return await options.executor.withMigrationLock( + options.migrationsTable, + async () => { + await options.beforeRun?.(); + await ensureMigrationTable(options.executor, options.migrationsTable); + let rows = await migrationRows(options.executor, options.migrationsTable); + await adoptLegacySqlPrefix({ + executor: options.executor, + migrations, + rows, + table: options.migrationsTable, + }); + rows = await migrationRows(options.executor, options.migrationsTable); + const result: MigrationRunResult = { + existing: 0, + migrated: 0, + scanned: migrations.length, + skipped: 0, + }; + for (const migration of migrations) { + const row = rows.get(migration.when); + if (row && row.hash !== migration.hash) { + throw new Error( + `Migration ${migration.tag} changed after it started`, + ); + } + if (row?.status === "completed" || (row && row.status === null)) { + result.existing += 1; + continue; + } + if (mode === "sql" && migration.kind === "typescript") { + result.skipped += 1; + continue; + } + if (migration.kind === "sql") { + await runSqlMigration({ + executor: options.executor, + migration, + table: options.migrationsTable, + }); + } else { + if (!options.createContext || !options.loadTypeScript) { + throw new Error( + `TypeScript migration ${migration.tag} requires a loader and context`, + ); + } + await runTypeScriptMigration({ + createContext: options.createContext, + executor: options.executor, + loadTypeScript: options.loadTypeScript, + migration, + row, + table: options.migrationsTable, + }); + } + result.migrated += 1; + } + return result; + }, + ); +} diff --git a/packages/junior-migrations/src/types.ts b/packages/junior-migrations/src/types.ts new file mode 100644 index 000000000..20a45206d --- /dev/null +++ b/packages/junior-migrations/src/types.ts @@ -0,0 +1,90 @@ +/** SQL capabilities available to the mixed migration runner. */ +export interface MigrationSqlExecutor { + execute(statement: string, parameters?: readonly unknown[]): Promise; + query( + statement: string, + parameters?: readonly unknown[], + ): Promise; + transaction(callback: () => Promise): Promise; + withMigrationLock( + migrationTable: string, + callback: () => Promise, + ): Promise; +} + +/** Stable state-store capabilities exposed to v1 TypeScript migrations. */ +export interface MigrationStateV1 { + appendToList( + key: string, + value: unknown, + options?: { maxLength?: number; ttlMs?: number }, + ): Promise; + delete(key: string): Promise; + get(key: string): Promise; + getList(key: string): Promise; + set(key: string, value: unknown, ttlMs?: number): Promise; +} + +/** Resumable progress storage scoped to one TypeScript migration. */ +export interface MigrationProgressV1 { + load(): Promise; + save(value: MigrationJsonValue): Promise; +} + +/** Versioned host tasks available to migrations that predate the public ABI. */ +export interface MigrationTasksV1 { + run(name: string): Promise; +} + +/** JSON-compatible value persisted in migration progress and result columns. */ +export type MigrationJsonValue = + | boolean + | number + | string + | null + | MigrationJsonValue[] + | { [key: string]: MigrationJsonValue }; + +/** Permanent capability contract for apiVersion 1 migrations. */ +export interface MigrationContextV1 { + log(message: string): void; + progress: MigrationProgressV1; + sql: Pick; + state: MigrationStateV1; + tasks: MigrationTasksV1; +} + +/** Isolated TypeScript data migration targeting the v1 ABI. */ +export interface MigrationV1 { + apiVersion: 1; + up(context: MigrationContextV1): Promise; +} + +/** One ordered entry from Drizzle Kit's journal metadata. */ +export interface MigrationJournalEntry { + breakpoints: boolean; + index: number; + tag: string; + when: number; +} + +/** Journal entry paired with its unique SQL or TypeScript source file. */ +export interface ResolvedMigration extends MigrationJournalEntry { + hash: string; + kind: "sql" | "typescript"; + path: string; + source: string; +} + +/** Aggregate counts returned after one journal execution. */ +export interface MigrationRunResult { + existing: number; + migrated: number; + scanned: number; + skipped: number; +} + +/** Host-provided loader for an isolated TypeScript migration module. */ +export interface TypeScriptMigrationLoader { + (path: string): Promise; +} diff --git a/packages/junior-migrations/tests/generate.test.ts b/packages/junior-migrations/tests/generate.test.ts new file mode 100644 index 000000000..399d368a8 --- /dev/null +++ b/packages/junior-migrations/tests/generate.test.ts @@ -0,0 +1,99 @@ +import { spawn } from "node:child_process"; +import { access, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, expect, it } from "vitest"; +import { generateTypeScriptMigration } from "../src/generate"; +import { readMigrationJournal } from "../src/journal"; + +const temporaryDirectories: string[] = []; + +function runDrizzle(args: string[], cwd: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn("drizzle-kit", args, { cwd, stdio: "pipe" }); + let output = ""; + child.stdout.on("data", (chunk) => { + output += String(chunk); + }); + child.stderr.on("data", (chunk) => { + output += String(chunk); + }); + child.on("error", reject); + child.on("exit", (code) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(output)); + } + }); + }); +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +it("keeps Drizzle schema generation working across a TypeScript entry", async () => { + const relativeRoot = `.tmp-mixed-migrations-${Date.now()}`; + const root = join(process.cwd(), relativeRoot); + temporaryDirectories.push(root); + await mkdir(root); + await writeFile( + join(root, "schema.ts"), + 'import { pgTable, text } from "drizzle-orm/pg-core";\nexport const users = pgTable("users", { id: text("id").primaryKey() });\n', + ); + await writeFile( + join(root, "drizzle.config.ts"), + `import { defineConfig } from "drizzle-kit";\nexport default defineConfig({ dialect: "postgresql", schema: "./${relativeRoot}/schema.ts", out: "./${relativeRoot}/migrations" });\n`, + ); + + await runDrizzle( + [ + "generate", + "--config", + `${relativeRoot}/drizzle.config.ts`, + "--name", + "initial", + ], + process.cwd(), + ); + const typescriptPath = await generateTypeScriptMigration({ + configPath: `${relativeRoot}/drizzle.config.ts`, + cwd: process.cwd(), + migrationsFolder: `${relativeRoot}/migrations`, + name: "backfill", + }); + await expect(access(typescriptPath)).resolves.toBeUndefined(); + await expect( + access(join(root, "migrations", "0001_backfill.sql")), + ).rejects.toThrow("ENOENT"); + + await writeFile( + join(root, "schema.ts"), + 'import { pgTable, text } from "drizzle-orm/pg-core";\nexport const users = pgTable("users", { id: text("id").primaryKey(), name: text("name") });\n', + ); + await runDrizzle( + [ + "generate", + "--config", + `${relativeRoot}/drizzle.config.ts`, + "--name", + "add_name", + ], + process.cwd(), + ); + + await expect( + readMigrationJournal(join(root, "migrations")), + ).resolves.toMatchObject([ + { index: 0, tag: "0000_initial" }, + { index: 1, tag: "0001_backfill" }, + { index: 2, tag: "0002_add_name" }, + ]); + await expect( + readFile(join(root, "migrations", "0002_add_name.sql"), "utf8"), + ).resolves.toContain('ADD COLUMN "name" text'); +}); diff --git a/packages/junior-migrations/tests/journal.test.ts b/packages/junior-migrations/tests/journal.test.ts new file mode 100644 index 000000000..005586efd --- /dev/null +++ b/packages/junior-migrations/tests/journal.test.ts @@ -0,0 +1,77 @@ +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { resolveMigrations } from "../src/journal"; + +const temporaryDirectories: string[] = []; + +async function migrationFolder(entries: string[]): Promise { + const root = await mkdtemp(join(tmpdir(), "junior-migrations-")); + temporaryDirectories.push(root); + await mkdir(join(root, "meta")); + await writeFile( + join(root, "meta", "_journal.json"), + JSON.stringify({ + version: "7", + dialect: "postgresql", + entries: entries.map((tag, index) => ({ + idx: index, + version: "7", + when: 1_000 + index, + tag, + breakpoints: true, + })), + }), + ); + return root; +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +describe("resolveMigrations", () => { + it("resolves one ordered SQL or TypeScript file per journal entry", async () => { + const folder = await migrationFolder(["0000_initial", "0001_backfill"]); + await writeFile(join(folder, "0000_initial.sql"), "SELECT 1;"); + await writeFile( + join(folder, "0001_backfill.ts"), + 'import type { MigrationV1 } from "@sentry/junior-migrations";\nexport default { apiVersion: 1, async up() {} } satisfies MigrationV1;\n', + ); + + await expect(resolveMigrations(folder)).resolves.toMatchObject([ + { index: 0, kind: "sql", tag: "0000_initial", when: 1_000 }, + { index: 1, kind: "typescript", tag: "0001_backfill", when: 1_001 }, + ]); + }); + + it("rejects migrations that import runtime modules", async () => { + const folder = await migrationFolder(["0000_unsafe"]); + await writeFile( + join(folder, "0000_unsafe.ts"), + 'import { getDb } from "@/db";\nexport default { apiVersion: 1, async up() { getDb(); } };\n', + ); + + await expect(resolveMigrations(folder)).rejects.toThrow( + "may only import migration types", + ); + }); + + it("rejects ambiguous migration files", async () => { + const folder = await migrationFolder(["0000_ambiguous"]); + await writeFile(join(folder, "0000_ambiguous.sql"), "SELECT 1;"); + await writeFile( + join(folder, "0000_ambiguous.ts"), + "export default { apiVersion: 1, async up() {} };\n", + ); + + await expect(resolveMigrations(folder)).rejects.toThrow( + "exactly one .sql or .ts file", + ); + }); +}); diff --git a/packages/junior-migrations/tests/runner.test.ts b/packages/junior-migrations/tests/runner.test.ts new file mode 100644 index 000000000..0c1c21575 --- /dev/null +++ b/packages/junior-migrations/tests/runner.test.ts @@ -0,0 +1,276 @@ +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { runMigrationJournal } from "../src/runner"; +import type { + MigrationContextV1, + MigrationSqlExecutor, + MigrationV1, +} from "../src/types"; + +interface StoredRow { + createdAt: number; + hash: string; + progress?: unknown; + status: string | null; +} + +class FakeExecutor implements MigrationSqlExecutor { + readonly rows = new Map(); + readonly statements: string[] = []; + + async execute(statement: string, parameters: readonly unknown[] = []) { + const normalized = statement.trim(); + if ( + normalized.startsWith("CREATE SCHEMA") || + normalized.startsWith("CREATE TABLE IF NOT EXISTS drizzle.") || + normalized.startsWith("ALTER TABLE drizzle.") + ) { + return; + } + if (normalized.startsWith("INSERT INTO drizzle.")) { + const hash = String(parameters[0]); + const createdAt = Number(parameters[1]); + const kind = normalized.includes("'typescript'") + ? "running" + : "completed"; + this.rows.set(createdAt, { createdAt, hash, status: kind }); + return; + } + if (normalized.startsWith("UPDATE drizzle.")) { + const createdAt = Number(parameters.at(-1)); + const row = this.rows.get(createdAt); + if (!row) { + throw new Error(`Missing row ${createdAt}`); + } + if (normalized.includes("progress = $1")) { + row.progress = parameters[0]; + } + if (normalized.includes("status = 'running'")) { + row.status = "running"; + } else if (normalized.includes("status = 'completed'")) { + row.status = "completed"; + } else if (normalized.includes("status = 'failed'")) { + row.status = "failed"; + } + return; + } + this.statements.push(normalized); + } + + async query(statement: string, parameters: readonly unknown[] = []) { + if (statement.includes('created_at::text AS "createdAt"')) { + return [...this.rows.values()].map((row) => ({ + createdAt: String(row.createdAt), + hash: row.hash, + progress: row.progress, + status: row.status, + })) as T[]; + } + if (statement.includes("SELECT progress")) { + const row = this.rows.get(Number(parameters[0])); + return (row ? [{ progress: row.progress ?? null }] : []) as T[]; + } + return []; + } + + async transaction(callback: () => Promise): Promise { + return await callback(); + } + + async withMigrationLock( + _migrationTable: string, + callback: () => Promise, + ): Promise { + return await callback(); + } +} + +const temporaryDirectories: string[] = []; + +async function mixedFolder(): Promise { + const root = await mkdtemp(join(tmpdir(), "junior-migrations-runner-")); + temporaryDirectories.push(root); + await mkdir(join(root, "meta")); + await writeFile( + join(root, "meta", "_journal.json"), + JSON.stringify({ + version: "7", + dialect: "postgresql", + entries: ["0000_schema", "0001_data", "0002_schema"].map( + (tag, index) => ({ + idx: index, + version: "7", + when: 2_000 + index, + tag, + breakpoints: true, + }), + ), + }), + ); + await writeFile(join(root, "0000_schema.sql"), "SELECT 'schema-zero';"); + await writeFile( + join(root, "0001_data.ts"), + 'import type { MigrationV1 } from "@sentry/junior-migrations";\nexport default {} satisfies MigrationV1;\n', + ); + await writeFile(join(root, "0002_schema.sql"), "SELECT 'schema-two';"); + return root; +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +describe("runMigrationJournal", () => { + it("runs mixed entries in journal order and is a no-op on rerun", async () => { + const folder = await mixedFolder(); + const executor = new FakeExecutor(); + const order: string[] = []; + const migration: MigrationV1 = { + apiVersion: 1, + async up(context) { + order.push("typescript"); + await context.progress.save({ cursor: 1 }); + }, + }; + + await expect( + runMigrationJournal({ + executor, + migrationsFolder: folder, + migrationsTable: "__drizzle_test", + loadTypeScript: async () => ({ default: migration }), + createContext: ({ progress }) => ({ + log: () => {}, + progress, + sql: executor, + state: { + appendToList: async () => {}, + delete: async () => {}, + get: async () => undefined, + getList: async () => [], + set: async () => {}, + }, + tasks: { + run: async () => undefined, + }, + }), + }), + ).resolves.toEqual({ existing: 0, migrated: 3, scanned: 3, skipped: 0 }); + expect(executor.statements).toEqual([ + "SELECT 'schema-zero';", + "SELECT 'schema-two';", + ]); + expect(order).toEqual(["typescript"]); + + await expect( + runMigrationJournal({ + executor, + migrationsFolder: folder, + migrationsTable: "__drizzle_test", + loadTypeScript: async () => ({ default: migration }), + createContext: ({ progress }) => ({ + log: () => {}, + progress, + sql: executor, + state: { + appendToList: async () => {}, + delete: async () => {}, + get: async () => undefined, + getList: async () => [], + set: async () => {}, + }, + tasks: { + run: async () => undefined, + }, + }), + }), + ).resolves.toEqual({ existing: 3, migrated: 0, scanned: 3, skipped: 0 }); + }); + + it("allows schema-only execution to leave a TypeScript gap pending", async () => { + const folder = await mixedFolder(); + const executor = new FakeExecutor(); + + await expect( + runMigrationJournal({ + executor, + migrationsFolder: folder, + migrationsTable: "__drizzle_test", + mode: "sql", + }), + ).resolves.toEqual({ existing: 0, migrated: 2, scanned: 3, skipped: 1 }); + expect([...executor.rows.keys()].sort()).toEqual([2_000, 2_002]); + }); + + it("resumes a failed TypeScript migration from saved progress", async () => { + const folder = await mixedFolder(); + const executor = new FakeExecutor(); + let attempts = 0; + const migration: MigrationV1 = { + apiVersion: 1, + async up(context) { + attempts += 1; + const progress = await context.progress.load(); + if (!progress) { + await context.progress.save({ cursor: 1 }); + throw new Error("interrupted"); + } + return { resumed: true }; + }, + }; + const options = { + executor, + migrationsFolder: folder, + migrationsTable: "__drizzle_test", + loadTypeScript: async () => ({ default: migration }), + createContext: ({ + progress, + }: { + progress: MigrationContextV1["progress"]; + }) => ({ + log: () => {}, + progress, + sql: executor, + state: { + appendToList: async () => {}, + delete: async () => {}, + get: async () => undefined, + getList: async () => [], + set: async () => {}, + }, + tasks: { + run: async () => undefined, + }, + }), + }; + + await expect(runMigrationJournal(options)).rejects.toThrow("interrupted"); + expect(executor.rows.get(2_001)).toMatchObject({ + progress: { cursor: 1 }, + status: "failed", + }); + expect(executor.statements).toEqual(["SELECT 'schema-zero';"]); + + await expect(runMigrationJournal(options)).resolves.toEqual({ + existing: 1, + migrated: 2, + scanned: 3, + skipped: 0, + }); + expect(attempts).toBe(2); + expect(executor.rows.get(2_001)).toMatchObject({ + progress: { cursor: 1 }, + status: "completed", + }); + expect(executor.statements).toEqual([ + "SELECT 'schema-zero';", + "SELECT 'schema-two';", + ]); + }); +}); diff --git a/packages/junior-migrations/tsconfig.build.json b/packages/junior-migrations/tsconfig.build.json new file mode 100644 index 000000000..7d583629c --- /dev/null +++ b/packages/junior-migrations/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "emitDeclarationOnly": true, + "noEmit": false, + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/junior-migrations/tsconfig.json b/packages/junior-migrations/tsconfig.json new file mode 100644 index 000000000..165b4315c --- /dev/null +++ b/packages/junior-migrations/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM"], + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "isolatedModules": true, + "noEmit": true, + "types": ["node", "vitest/globals"] + }, + "include": ["src/**/*.ts", "tests/**/*.ts"] +} diff --git a/packages/junior-migrations/tsup.config.ts b/packages/junior-migrations/tsup.config.ts new file mode 100644 index 000000000..254c4d2c6 --- /dev/null +++ b/packages/junior-migrations/tsup.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from "tsup"; + +const shared = { + format: "esm" as const, + tsconfig: "tsconfig.build.json", + dts: false, + outDir: "dist", +}; + +export default defineConfig([ + { + ...shared, + entry: { index: "src/index.ts" }, + clean: true, + }, + { + ...shared, + entry: { cli: "src/cli.ts" }, + clean: false, + banner: { js: "#!/usr/bin/env node" }, + }, +]); diff --git a/packages/junior-plugin-api/README.md b/packages/junior-plugin-api/README.md index 7074f6222..3eeae30d0 100644 --- a/packages/junior-plugin-api/README.md +++ b/packages/junior-plugin-api/README.md @@ -5,12 +5,14 @@ exported TypeScript types and runtime validators are authoritative. ## Registration -Use `defineJuniorPlugin({ manifest, hooks, tasks, cli, model })`. A plugin name -is a lowercase identifier and is unique within the enabled app plugin set. +Use `defineJuniorPlugin({ manifest, hooks, tasks, migrationTasks, cli, model })`. +A plugin name is a lowercase identifier and is unique within the enabled app +plugin set. A plugin may instead be a declarative `plugin.yaml` package when it has no -host-executed hooks. Do not combine an inline manifest with a second YAML -definition for the same plugin. +host-executed hooks, tasks, migration tasks, CLI, or model implementation. Do +not combine an inline manifest with a second YAML definition for the same +plugin. ## Manifest @@ -30,7 +32,7 @@ safe. ## Hooks Plugins may contribute tools, prompt messages, lifecycle work, operational -reports, migrations, and other typed hook surfaces exported by this package. +reports, and other typed hook surfaces exported by this package. - Hook context carries the active source, actor, conversation, plugin metadata, database, logging, and only the host capabilities required by that hook. @@ -58,6 +60,8 @@ reports, migrations, and other typed hook surfaces exported by this package. - Packaged migrations create plugin-owned tables through the host migration runner. +- TypeScript journal entries may invoke explicitly versioned `migrationTasks`; + tasks run only when their owning journal entry is pending. - Generate migration artifacts from the package schema; do not hand-maintain a second schema contract. - Runtime hooks and CLI actions use host-provided `ctx.db`. diff --git a/packages/junior-plugin-api/src/hooks.ts b/packages/junior-plugin-api/src/hooks.ts index b4d05ad16..a787a3f56 100644 --- a/packages/junior-plugin-api/src/hooks.ts +++ b/packages/junior-plugin-api/src/hooks.ts @@ -18,8 +18,6 @@ import type { RouteRegistrationHookContext, SlackConversationLink, SlackConversationLinkHookContext, - StorageMigrationContext, - StorageMigrationResult, } from "./operations"; import type { BeforeToolExecuteHookContext, @@ -73,10 +71,4 @@ export interface PluginHooks { tools?( ctx: ToolRegistrationHookContext, ): Record; - migrateStorage?( - ctx: StorageMigrationContext, - ): - | Promise - | StorageMigrationResult - | undefined; } diff --git a/packages/junior-plugin-api/src/operations.ts b/packages/junior-plugin-api/src/operations.ts index 51a179c67..631c43acb 100644 --- a/packages/junior-plugin-api/src/operations.ts +++ b/packages/junior-plugin-api/src/operations.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import type { PluginContext } from "./context"; +import type { PluginContext, PluginLogger } from "./context"; import type { Dispatch, DispatchOptions, DispatchResult } from "./dispatch"; import { nonBlankStringSchema } from "./schemas"; import type { PluginReadState, PluginState } from "./state"; @@ -47,18 +47,32 @@ export interface HeartbeatResult { dispatchCount?: number; } -export interface StorageMigrationResult { +/** Per-record outcome reported by one versioned plugin migration task. */ +export type PluginMigrationResult = { existing: number; migrated: number; missing: number; scanned: number; skipped?: number; -} +}; -export interface StorageMigrationContext extends PluginContext { +/** Stable host capabilities available to a plugin migration task. */ +export interface PluginMigrationContext { + db: unknown; + log: PluginLogger; state: PluginState; } +/** Idempotent task invoked only by its owning pending journal entry. */ +export interface PluginMigrationTask { + ( + context: PluginMigrationContext, + ): + | Promise + | PluginMigrationResult + | undefined; +} + export type PluginOperationalTone = "danger" | "good" | "neutral" | "warning"; export interface PluginOperationalMetric { diff --git a/packages/junior-plugin-api/src/registration.ts b/packages/junior-plugin-api/src/registration.ts index 1800108ef..17c2b282e 100644 --- a/packages/junior-plugin-api/src/registration.ts +++ b/packages/junior-plugin-api/src/registration.ts @@ -1,6 +1,7 @@ import type { PluginCliDefinition } from "./cli"; import type { PluginHooks } from "./hooks"; import type { PluginManifest } from "./manifest"; +import type { PluginMigrationTask } from "./operations"; import type { PluginTasks } from "./tasks"; export interface PluginModelConfig { @@ -14,6 +15,8 @@ export type PluginRegistrationInput = { cli?: PluginCliDefinition; hooks?: PluginHooks; manifest: PluginManifest; + /** Permanently versioned data tasks referenced by this plugin's migration journal. */ + migrationTasks?: Record; model?: PluginModelConfig; packageName?: string; tasks?: PluginTasks; diff --git a/packages/junior-scheduler/README.md b/packages/junior-scheduler/README.md index a952c07bd..974e8a8b6 100644 --- a/packages/junior-scheduler/README.md +++ b/packages/junior-scheduler/README.md @@ -46,6 +46,10 @@ Junior's durable agent runtime. The plugin exposes create, update, delete, list, and run-now tools plus bounded operational reporting. Generate schema changes with `pnpm --filter @sentry/junior-scheduler db:generate`. +Use `pnpm --filter @sentry/junior-scheduler db:generate:data --name ` +for a TypeScript data migration in the same journal. New migrations should use +the stable migration capabilities directly; the existing scheduler backfill +uses a versioned task to preserve its pre-journal implementation. Follow `../../policies/serverless-background-work.md`, `../../policies/context-bound-systems.md`, and diff --git a/packages/junior-scheduler/migrations/0002_scheduler_state_to_sql.ts b/packages/junior-scheduler/migrations/0002_scheduler_state_to_sql.ts new file mode 100644 index 000000000..c4affbb22 --- /dev/null +++ b/packages/junior-scheduler/migrations/0002_scheduler_state_to_sql.ts @@ -0,0 +1,10 @@ +import type { MigrationV1 } from "@sentry/junior-migrations"; + +const migration = { + apiVersion: 1, + async up(context) { + return await context.tasks.run("scheduler-state-to-sql-v1"); + }, +} satisfies MigrationV1; + +export default migration; diff --git a/packages/junior-scheduler/migrations/meta/0002_snapshot.json b/packages/junior-scheduler/migrations/meta/0002_snapshot.json new file mode 100644 index 000000000..3452e36a6 --- /dev/null +++ b/packages/junior-scheduler/migrations/meta/0002_snapshot.json @@ -0,0 +1,251 @@ +{ + "id": "1c41ba67-8755-4577-ac1e-16538249993e", + "prevId": "ccd0c14d-8a21-41aa-82b9-69a3d80cdc88", + "version": "7", + "dialect": "postgresql", + "tables": { + "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, + "with": {}, + "method": "btree", + "concurrently": false + }, + "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, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "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 + }, + "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 + }, + "record": { + "name": "record", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "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, + "with": {}, + "method": "btree", + "where": "\"junior_scheduler_tasks\".\"status\" <> 'deleted'", + "concurrently": false + }, + "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, + "with": {}, + "method": "btree", + "where": "\"junior_scheduler_tasks\".\"status\" = 'active' AND \"junior_scheduler_tasks\".\"run_now_at_ms\" IS NOT NULL", + "concurrently": false + }, + "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, + "with": {}, + "method": "btree", + "where": "\"junior_scheduler_tasks\".\"status\" = 'active' AND \"junior_scheduler_tasks\".\"next_run_at_ms\" IS NOT NULL", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "views": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/junior-scheduler/migrations/meta/_journal.json b/packages/junior-scheduler/migrations/meta/_journal.json index bf8784579..5552ed530 100644 --- a/packages/junior-scheduler/migrations/meta/_journal.json +++ b/packages/junior-scheduler/migrations/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1783964295816, "tag": "0001_explicit_credential_mode", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1783977988234, + "tag": "0002_scheduler_state_to_sql", + "breakpoints": true } ] } diff --git a/packages/junior-scheduler/package.json b/packages/junior-scheduler/package.json index 97c127110..c432ad89b 100644 --- a/packages/junior-scheduler/package.json +++ b/packages/junior-scheduler/package.json @@ -25,6 +25,7 @@ "scripts": { "build": "tsup && tsc -p tsconfig.build.json --emitDeclarationOnly", "db:generate": "pnpm exec drizzle-kit generate --config drizzle.config.ts", + "db:generate:data": "pnpm exec junior-migrations generate --config drizzle.config.ts --out migrations", "prepare": "pnpm run build", "prepack": "pnpm run build", "typecheck": "tsc --noEmit" @@ -35,6 +36,7 @@ "zod": "catalog:" }, "devDependencies": { + "@sentry/junior-migrations": "workspace:*", "@types/node": "^25.9.1", "drizzle-kit": "catalog:", "tsup": "^8.5.1", diff --git a/packages/junior-scheduler/src/plugin.ts b/packages/junior-scheduler/src/plugin.ts index 108b7ce97..f5caec1eb 100644 --- a/packages/junior-scheduler/src/plugin.ts +++ b/packages/junior-scheduler/src/plugin.ts @@ -578,12 +578,13 @@ export function createSchedulerPlugin() { store: schedulerOperationalStore(ctx), }); }, - async migrateStorage(ctx) { - return await migrateSchedulerStateToSql({ + }, + migrationTasks: { + "scheduler-state-to-sql-v1": async (ctx) => + await migrateSchedulerStateToSql({ db: ctx.db as SchedulerDb, state: ctx.state, - }); - }, + }), }, }); } diff --git a/packages/junior/migrations/0004_agent_turn_session_actor.ts b/packages/junior/migrations/0004_agent_turn_session_actor.ts new file mode 100644 index 000000000..0a9dced8f --- /dev/null +++ b/packages/junior/migrations/0004_agent_turn_session_actor.ts @@ -0,0 +1,10 @@ +import type { MigrationV1 } from "@sentry/junior-migrations"; + +const migration = { + apiVersion: 1, + async up(context) { + return await context.tasks.run("agent-turn-session-actor-v1"); + }, +} satisfies MigrationV1; + +export default migration; diff --git a/packages/junior/migrations/0005_redis_conversation_state.ts b/packages/junior/migrations/0005_redis_conversation_state.ts new file mode 100644 index 000000000..fb83b28a3 --- /dev/null +++ b/packages/junior/migrations/0005_redis_conversation_state.ts @@ -0,0 +1,10 @@ +import type { MigrationV1 } from "@sentry/junior-migrations"; + +const migration = { + apiVersion: 1, + async up(context) { + return await context.tasks.run("redis-conversation-state-v1"); + }, +} satisfies MigrationV1; + +export default migration; diff --git a/packages/junior/migrations/0006_conversations_to_sql.ts b/packages/junior/migrations/0006_conversations_to_sql.ts new file mode 100644 index 000000000..d0996c225 --- /dev/null +++ b/packages/junior/migrations/0006_conversations_to_sql.ts @@ -0,0 +1,10 @@ +import type { MigrationV1 } from "@sentry/junior-migrations"; + +const migration = { + apiVersion: 1, + async up(context) { + return await context.tasks.run("conversations-to-sql-v1"); + }, +} satisfies MigrationV1; + +export default migration; diff --git a/packages/junior/migrations/0007_conversation_history_to_sql.ts b/packages/junior/migrations/0007_conversation_history_to_sql.ts new file mode 100644 index 000000000..69ae7abd4 --- /dev/null +++ b/packages/junior/migrations/0007_conversation_history_to_sql.ts @@ -0,0 +1,10 @@ +import type { MigrationV1 } from "@sentry/junior-migrations"; + +const migration = { + apiVersion: 1, + async up(context) { + return await context.tasks.run("conversation-history-to-sql-v1"); + }, +} satisfies MigrationV1; + +export default migration; diff --git a/packages/junior/migrations/README.md b/packages/junior/migrations/README.md index a0b5c2aea..03785be4a 100644 --- a/packages/junior/migrations/README.md +++ b/packages/junior/migrations/README.md @@ -1,14 +1,23 @@ # SQL migrations for `@sentry/junior` -This is a standard Drizzle migration folder. `drizzle-kit generate` owns each -SQL file, snapshot, and journal entry; `junior upgrade` applies the folder with -Drizzle ORM's migrator before any data backfills run. +This migration folder extends Drizzle Kit's journal with self-contained +TypeScript data migrations. Drizzle Kit owns numbering, snapshots, and journal +entries; `junior upgrade` executes each entry as either `.sql` or +`.ts` through `@sentry/junior-migrations`. 1. Edit the schema under `src/db/schema/`. 2. Run `pnpm --filter @sentry/junior db:generate --name `. 3. Commit the generated SQL file and `meta/` changes together. +For a data migration, run +`pnpm --filter @sentry/junior db:generate:data --name `. +TypeScript migrations target the versioned migration capability API and must +not import Junior runtime internals or current feature schemas. Entries that +adopt pre-journal upgrade code call a versioned host task; new data migrations +should use the stable SQL, state, and progress capabilities directly. + The `0000_initial.sql` baseline represents the schema already deployed by the pre-Drizzle Junior migration runner. During upgrade, existing installations adopt that baseline once; new installations execute it normally. All later -migrations are applied by Drizzle in journal order. +migrations are applied by Junior in journal order. Drizzle ORM's stock +`migrate()` function is not compatible with TypeScript entries in this folder. diff --git a/packages/junior/migrations/meta/0004_snapshot.json b/packages/junior/migrations/meta/0004_snapshot.json new file mode 100644 index 000000000..ef06e06c4 --- /dev/null +++ b/packages/junior/migrations/meta/0004_snapshot.json @@ -0,0 +1,993 @@ +{ + "id": "8d217382-e047-4c68-95c4-b8568a5298e5", + "prevId": "3668b705-7991-4e7e-8fb2-6e4ef8147aac", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.junior_agent_steps": { + "name": "junior_agent_steps", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "context_epoch": { + "name": "context_epoch", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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_agent_steps_epoch_idx": { + "name": "junior_agent_steps_epoch_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "context_epoch", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "junior_agent_steps_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_steps_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_steps", + "columnsFrom": ["conversation_id"], + "tableTo": "junior_conversations", + "columnsTo": ["conversation_id"], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": { + "junior_agent_steps_conversation_id_seq_pk": { + "name": "junior_agent_steps_conversation_id_seq_pk", + "columns": ["conversation_id", "seq"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_messages": { + "name": "junior_conversation_messages", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_identity_id": { + "name": "author_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "replied_at": { + "name": "replied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_conversation_messages_activity_idx": { + "name": "junior_conversation_messages_activity_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "junior_conversation_messages_search_idx": { + "name": "junior_conversation_messages_search_idx", + "columns": [ + { + "expression": "to_tsvector('english', \"text\")", + "isExpression": true, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "gin", + "concurrently": false + } + }, + "foreignKeys": { + "junior_conversation_messages_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_messages_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_messages", + "columnsFrom": ["conversation_id"], + "tableTo": "junior_conversations", + "columnsTo": ["conversation_id"], + "onUpdate": "no action", + "onDelete": "no action" + }, + "junior_conversation_messages_author_identity_id_junior_identities_id_fk": { + "name": "junior_conversation_messages_author_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversation_messages", + "columnsFrom": ["author_identity_id"], + "tableTo": "junior_identities", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_messages_conversation_id_message_id_pk": { + "name": "junior_conversation_messages_conversation_id_message_id_pk", + "columns": ["conversation_id", "message_id"] + } + }, + "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 + }, + "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 + }, + "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 + }, + "metric_run_id": { + "name": "metric_run_id", + "type": "text", + "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 + } + }, + "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, + "with": {}, + "method": "btree", + "concurrently": false + }, + "junior_conversations_active_idx": { + "name": "junior_conversations_active_idx", + "columns": [ + { + "expression": "coalesce(\"execution_updated_at\", \"updated_at\")", + "isExpression": true, + "asc": true, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"junior_conversations\".\"execution_status\" <> 'idle'", + "concurrently": false + }, + "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, + "with": {}, + "method": "btree", + "concurrently": false + }, + "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, + "with": {}, + "method": "btree", + "concurrently": false + }, + "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, + "with": {}, + "method": "btree", + "concurrently": false + }, + "junior_conversations_parent_idx": { + "name": "junior_conversations_parent_idx", + "columns": [ + { + "expression": "parent_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "junior_conversations_destination_id_junior_destinations_id_fk": { + "name": "junior_conversations_destination_id_junior_destinations_id_fk", + "tableFrom": "junior_conversations", + "columnsFrom": ["destination_id"], + "tableTo": "junior_destinations", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "no action" + }, + "junior_conversations_actor_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_actor_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "columnsFrom": ["actor_identity_id"], + "tableTo": "junior_identities", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "no action" + }, + "junior_conversations_creator_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_creator_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "columnsFrom": ["creator_identity_id"], + "tableTo": "junior_identities", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "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", + "columnsFrom": ["credential_subject_identity_id"], + "tableTo": "junior_identities", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "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", + "columnsFrom": ["parent_conversation_id"], + "tableTo": "junior_conversations", + "columnsTo": ["conversation_id"], + "onUpdate": "no action", + "onDelete": "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, + "with": {}, + "method": "btree", + "concurrently": false + }, + "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, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "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, + "with": {}, + "method": "btree", + "concurrently": false + }, + "junior_identities_user_idx": { + "name": "junior_identities_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "junior_identities_verified_email_idx": { + "name": "junior_identities_verified_email_idx", + "columns": [ + { + "expression": "email_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"junior_identities\".\"email_verified\" = true AND \"junior_identities\".\"email_normalized\" IS NOT NULL", + "concurrently": false + }, + "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, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "junior_identities_user_id_junior_users_id_fk": { + "name": "junior_identities_user_id_junior_users_id_fk", + "tableFrom": "junior_identities", + "columnsFrom": ["user_id"], + "tableTo": "junior_users", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "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, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "views": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/junior/migrations/meta/0005_snapshot.json b/packages/junior/migrations/meta/0005_snapshot.json new file mode 100644 index 000000000..af4d384b2 --- /dev/null +++ b/packages/junior/migrations/meta/0005_snapshot.json @@ -0,0 +1,993 @@ +{ + "id": "15906b35-8fea-417f-a9f1-a008a884418d", + "prevId": "8d217382-e047-4c68-95c4-b8568a5298e5", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.junior_agent_steps": { + "name": "junior_agent_steps", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "context_epoch": { + "name": "context_epoch", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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_agent_steps_epoch_idx": { + "name": "junior_agent_steps_epoch_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "context_epoch", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "junior_agent_steps_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_steps_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_steps", + "columnsFrom": ["conversation_id"], + "tableTo": "junior_conversations", + "columnsTo": ["conversation_id"], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": { + "junior_agent_steps_conversation_id_seq_pk": { + "name": "junior_agent_steps_conversation_id_seq_pk", + "columns": ["conversation_id", "seq"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_messages": { + "name": "junior_conversation_messages", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_identity_id": { + "name": "author_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "replied_at": { + "name": "replied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_conversation_messages_activity_idx": { + "name": "junior_conversation_messages_activity_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "junior_conversation_messages_search_idx": { + "name": "junior_conversation_messages_search_idx", + "columns": [ + { + "expression": "to_tsvector('english', \"text\")", + "isExpression": true, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "gin", + "concurrently": false + } + }, + "foreignKeys": { + "junior_conversation_messages_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_messages_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_messages", + "columnsFrom": ["conversation_id"], + "tableTo": "junior_conversations", + "columnsTo": ["conversation_id"], + "onUpdate": "no action", + "onDelete": "no action" + }, + "junior_conversation_messages_author_identity_id_junior_identities_id_fk": { + "name": "junior_conversation_messages_author_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversation_messages", + "columnsFrom": ["author_identity_id"], + "tableTo": "junior_identities", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_messages_conversation_id_message_id_pk": { + "name": "junior_conversation_messages_conversation_id_message_id_pk", + "columns": ["conversation_id", "message_id"] + } + }, + "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 + }, + "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 + }, + "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 + }, + "metric_run_id": { + "name": "metric_run_id", + "type": "text", + "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 + } + }, + "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, + "with": {}, + "method": "btree", + "concurrently": false + }, + "junior_conversations_active_idx": { + "name": "junior_conversations_active_idx", + "columns": [ + { + "expression": "coalesce(\"execution_updated_at\", \"updated_at\")", + "isExpression": true, + "asc": true, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"junior_conversations\".\"execution_status\" <> 'idle'", + "concurrently": false + }, + "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, + "with": {}, + "method": "btree", + "concurrently": false + }, + "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, + "with": {}, + "method": "btree", + "concurrently": false + }, + "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, + "with": {}, + "method": "btree", + "concurrently": false + }, + "junior_conversations_parent_idx": { + "name": "junior_conversations_parent_idx", + "columns": [ + { + "expression": "parent_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "junior_conversations_destination_id_junior_destinations_id_fk": { + "name": "junior_conversations_destination_id_junior_destinations_id_fk", + "tableFrom": "junior_conversations", + "columnsFrom": ["destination_id"], + "tableTo": "junior_destinations", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "no action" + }, + "junior_conversations_actor_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_actor_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "columnsFrom": ["actor_identity_id"], + "tableTo": "junior_identities", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "no action" + }, + "junior_conversations_creator_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_creator_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "columnsFrom": ["creator_identity_id"], + "tableTo": "junior_identities", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "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", + "columnsFrom": ["credential_subject_identity_id"], + "tableTo": "junior_identities", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "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", + "columnsFrom": ["parent_conversation_id"], + "tableTo": "junior_conversations", + "columnsTo": ["conversation_id"], + "onUpdate": "no action", + "onDelete": "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, + "with": {}, + "method": "btree", + "concurrently": false + }, + "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, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "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, + "with": {}, + "method": "btree", + "concurrently": false + }, + "junior_identities_user_idx": { + "name": "junior_identities_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "junior_identities_verified_email_idx": { + "name": "junior_identities_verified_email_idx", + "columns": [ + { + "expression": "email_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"junior_identities\".\"email_verified\" = true AND \"junior_identities\".\"email_normalized\" IS NOT NULL", + "concurrently": false + }, + "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, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "junior_identities_user_id_junior_users_id_fk": { + "name": "junior_identities_user_id_junior_users_id_fk", + "tableFrom": "junior_identities", + "columnsFrom": ["user_id"], + "tableTo": "junior_users", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "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, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "views": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/junior/migrations/meta/0006_snapshot.json b/packages/junior/migrations/meta/0006_snapshot.json new file mode 100644 index 000000000..8bba60b00 --- /dev/null +++ b/packages/junior/migrations/meta/0006_snapshot.json @@ -0,0 +1,993 @@ +{ + "id": "703cec39-e95b-4e04-98fc-6af9d70cb157", + "prevId": "15906b35-8fea-417f-a9f1-a008a884418d", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.junior_agent_steps": { + "name": "junior_agent_steps", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "context_epoch": { + "name": "context_epoch", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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_agent_steps_epoch_idx": { + "name": "junior_agent_steps_epoch_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "context_epoch", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "junior_agent_steps_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_steps_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_steps", + "columnsFrom": ["conversation_id"], + "tableTo": "junior_conversations", + "columnsTo": ["conversation_id"], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": { + "junior_agent_steps_conversation_id_seq_pk": { + "name": "junior_agent_steps_conversation_id_seq_pk", + "columns": ["conversation_id", "seq"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_messages": { + "name": "junior_conversation_messages", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_identity_id": { + "name": "author_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "replied_at": { + "name": "replied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_conversation_messages_activity_idx": { + "name": "junior_conversation_messages_activity_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "junior_conversation_messages_search_idx": { + "name": "junior_conversation_messages_search_idx", + "columns": [ + { + "expression": "to_tsvector('english', \"text\")", + "isExpression": true, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "gin", + "concurrently": false + } + }, + "foreignKeys": { + "junior_conversation_messages_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_messages_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_messages", + "columnsFrom": ["conversation_id"], + "tableTo": "junior_conversations", + "columnsTo": ["conversation_id"], + "onUpdate": "no action", + "onDelete": "no action" + }, + "junior_conversation_messages_author_identity_id_junior_identities_id_fk": { + "name": "junior_conversation_messages_author_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversation_messages", + "columnsFrom": ["author_identity_id"], + "tableTo": "junior_identities", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_messages_conversation_id_message_id_pk": { + "name": "junior_conversation_messages_conversation_id_message_id_pk", + "columns": ["conversation_id", "message_id"] + } + }, + "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 + }, + "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 + }, + "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 + }, + "metric_run_id": { + "name": "metric_run_id", + "type": "text", + "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 + } + }, + "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, + "with": {}, + "method": "btree", + "concurrently": false + }, + "junior_conversations_active_idx": { + "name": "junior_conversations_active_idx", + "columns": [ + { + "expression": "coalesce(\"execution_updated_at\", \"updated_at\")", + "isExpression": true, + "asc": true, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"junior_conversations\".\"execution_status\" <> 'idle'", + "concurrently": false + }, + "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, + "with": {}, + "method": "btree", + "concurrently": false + }, + "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, + "with": {}, + "method": "btree", + "concurrently": false + }, + "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, + "with": {}, + "method": "btree", + "concurrently": false + }, + "junior_conversations_parent_idx": { + "name": "junior_conversations_parent_idx", + "columns": [ + { + "expression": "parent_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "junior_conversations_destination_id_junior_destinations_id_fk": { + "name": "junior_conversations_destination_id_junior_destinations_id_fk", + "tableFrom": "junior_conversations", + "columnsFrom": ["destination_id"], + "tableTo": "junior_destinations", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "no action" + }, + "junior_conversations_actor_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_actor_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "columnsFrom": ["actor_identity_id"], + "tableTo": "junior_identities", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "no action" + }, + "junior_conversations_creator_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_creator_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "columnsFrom": ["creator_identity_id"], + "tableTo": "junior_identities", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "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", + "columnsFrom": ["credential_subject_identity_id"], + "tableTo": "junior_identities", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "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", + "columnsFrom": ["parent_conversation_id"], + "tableTo": "junior_conversations", + "columnsTo": ["conversation_id"], + "onUpdate": "no action", + "onDelete": "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, + "with": {}, + "method": "btree", + "concurrently": false + }, + "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, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "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, + "with": {}, + "method": "btree", + "concurrently": false + }, + "junior_identities_user_idx": { + "name": "junior_identities_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "junior_identities_verified_email_idx": { + "name": "junior_identities_verified_email_idx", + "columns": [ + { + "expression": "email_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"junior_identities\".\"email_verified\" = true AND \"junior_identities\".\"email_normalized\" IS NOT NULL", + "concurrently": false + }, + "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, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "junior_identities_user_id_junior_users_id_fk": { + "name": "junior_identities_user_id_junior_users_id_fk", + "tableFrom": "junior_identities", + "columnsFrom": ["user_id"], + "tableTo": "junior_users", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "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, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "views": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/junior/migrations/meta/0007_snapshot.json b/packages/junior/migrations/meta/0007_snapshot.json new file mode 100644 index 000000000..9f8fb4170 --- /dev/null +++ b/packages/junior/migrations/meta/0007_snapshot.json @@ -0,0 +1,993 @@ +{ + "id": "ba68decd-aae8-46ce-a1e5-db2b7dc111db", + "prevId": "703cec39-e95b-4e04-98fc-6af9d70cb157", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.junior_agent_steps": { + "name": "junior_agent_steps", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "context_epoch": { + "name": "context_epoch", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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_agent_steps_epoch_idx": { + "name": "junior_agent_steps_epoch_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "context_epoch", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "junior_agent_steps_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_steps_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_steps", + "columnsFrom": ["conversation_id"], + "tableTo": "junior_conversations", + "columnsTo": ["conversation_id"], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": { + "junior_agent_steps_conversation_id_seq_pk": { + "name": "junior_agent_steps_conversation_id_seq_pk", + "columns": ["conversation_id", "seq"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_messages": { + "name": "junior_conversation_messages", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_identity_id": { + "name": "author_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "meta": { + "name": "meta", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "replied_at": { + "name": "replied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_conversation_messages_activity_idx": { + "name": "junior_conversation_messages_activity_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "junior_conversation_messages_search_idx": { + "name": "junior_conversation_messages_search_idx", + "columns": [ + { + "expression": "to_tsvector('english', \"text\")", + "isExpression": true, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "gin", + "concurrently": false + } + }, + "foreignKeys": { + "junior_conversation_messages_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_messages_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_messages", + "columnsFrom": ["conversation_id"], + "tableTo": "junior_conversations", + "columnsTo": ["conversation_id"], + "onUpdate": "no action", + "onDelete": "no action" + }, + "junior_conversation_messages_author_identity_id_junior_identities_id_fk": { + "name": "junior_conversation_messages_author_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversation_messages", + "columnsFrom": ["author_identity_id"], + "tableTo": "junior_identities", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_messages_conversation_id_message_id_pk": { + "name": "junior_conversation_messages_conversation_id_message_id_pk", + "columns": ["conversation_id", "message_id"] + } + }, + "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 + }, + "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 + }, + "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 + }, + "metric_run_id": { + "name": "metric_run_id", + "type": "text", + "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 + } + }, + "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, + "with": {}, + "method": "btree", + "concurrently": false + }, + "junior_conversations_active_idx": { + "name": "junior_conversations_active_idx", + "columns": [ + { + "expression": "coalesce(\"execution_updated_at\", \"updated_at\")", + "isExpression": true, + "asc": true, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"junior_conversations\".\"execution_status\" <> 'idle'", + "concurrently": false + }, + "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, + "with": {}, + "method": "btree", + "concurrently": false + }, + "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, + "with": {}, + "method": "btree", + "concurrently": false + }, + "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, + "with": {}, + "method": "btree", + "concurrently": false + }, + "junior_conversations_parent_idx": { + "name": "junior_conversations_parent_idx", + "columns": [ + { + "expression": "parent_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "junior_conversations_destination_id_junior_destinations_id_fk": { + "name": "junior_conversations_destination_id_junior_destinations_id_fk", + "tableFrom": "junior_conversations", + "columnsFrom": ["destination_id"], + "tableTo": "junior_destinations", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "no action" + }, + "junior_conversations_actor_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_actor_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "columnsFrom": ["actor_identity_id"], + "tableTo": "junior_identities", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "no action" + }, + "junior_conversations_creator_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_creator_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "columnsFrom": ["creator_identity_id"], + "tableTo": "junior_identities", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "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", + "columnsFrom": ["credential_subject_identity_id"], + "tableTo": "junior_identities", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "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", + "columnsFrom": ["parent_conversation_id"], + "tableTo": "junior_conversations", + "columnsTo": ["conversation_id"], + "onUpdate": "no action", + "onDelete": "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, + "with": {}, + "method": "btree", + "concurrently": false + }, + "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, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "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, + "with": {}, + "method": "btree", + "concurrently": false + }, + "junior_identities_user_idx": { + "name": "junior_identities_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "concurrently": false + }, + "junior_identities_verified_email_idx": { + "name": "junior_identities_verified_email_idx", + "columns": [ + { + "expression": "email_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "with": {}, + "method": "btree", + "where": "\"junior_identities\".\"email_verified\" = true AND \"junior_identities\".\"email_normalized\" IS NOT NULL", + "concurrently": false + }, + "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, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "junior_identities_user_id_junior_users_id_fk": { + "name": "junior_identities_user_id_junior_users_id_fk", + "tableFrom": "junior_identities", + "columnsFrom": ["user_id"], + "tableTo": "junior_users", + "columnsTo": ["id"], + "onUpdate": "no action", + "onDelete": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "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, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "views": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/junior/migrations/meta/_journal.json b/packages/junior/migrations/meta/_journal.json index 1da614f71..6b57916cb 100644 --- a/packages/junior/migrations/meta/_journal.json +++ b/packages/junior/migrations/meta/_journal.json @@ -29,6 +29,34 @@ "when": 1783915376595, "tag": "0003_peaceful_scalphunter", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1783976225853, + "tag": "0004_agent_turn_session_actor", + "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1783977955563, + "tag": "0005_redis_conversation_state", + "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1783977956391, + "tag": "0006_conversations_to_sql", + "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1783977957210, + "tag": "0007_conversation_history_to_sql", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/packages/junior/package.json b/packages/junior/package.json index 31c231cff..2abf3c93c 100644 --- a/packages/junior/package.json +++ b/packages/junior/package.json @@ -51,6 +51,7 @@ "prepack": "pnpm run build", "build": "tsup && tsc -p tsconfig.build.json --emitDeclarationOnly", "db:generate": "pnpm exec drizzle-kit generate --config drizzle.config.ts", + "db:generate:data": "pnpm exec junior-migrations generate --config drizzle.config.ts --out migrations", "lint": "oxlint --config .oxlintrc.json --deny-warnings src tests scripts bin tsup.config.ts && depcruise --config .dependency-cruiser.mjs src/chat", "lint:fix": "oxlint --config .oxlintrc.json --deny-warnings --fix src tests scripts bin tsup.config.ts", "test": "vitest run --maxWorkers=4", @@ -70,6 +71,7 @@ "@modelcontextprotocol/sdk": "1.29.0", "@neondatabase/serverless": "^1.1.0", "@sentry/junior-plugin-api": "workspace:*", + "@sentry/junior-migrations": "workspace:*", "@sentry/node": "catalog:", "@sinclair/typebox": "^0.34.49", "@slack/web-api": "^7.16.0", diff --git a/packages/junior/src/chat/conversations/sql/migrations.ts b/packages/junior/src/chat/conversations/sql/migrations.ts index 29c897b46..43cc18bfd 100644 --- a/packages/junior/src/chat/conversations/sql/migrations.ts +++ b/packages/junior/src/chat/conversations/sql/migrations.ts @@ -1,7 +1,15 @@ /** SQL schema migrations for durable Junior records. */ import { basename, dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { readMigrationFiles } from "drizzle-orm/migrator"; +import { + resolveMigrations, + runMigrationJournal, + type MigrationContextV1, + type MigrationRunResult, + type MigrationStateV1, + type TypeScriptMigrationLoader, +} from "@sentry/junior-migrations"; +import type { StateAdapter } from "chat"; import type { JuniorSqlMigrationExecutor } from "@/db/db"; import { juniorSqlSchema as schema } from "@/db/schema"; @@ -43,7 +51,7 @@ SELECT return; } - const migrations = readMigrationFiles({ migrationsFolder }); + const migrations = await resolveMigrations(migrationsFolder); const [metrics] = await executor.query<{ complete: boolean }>(` SELECT count(*) = 4 AS complete FROM information_schema.columns @@ -92,23 +100,80 @@ CREATE TABLE IF NOT EXISTS drizzle.__drizzle_junior_core ( await executor.execute( `INSERT INTO drizzle.__drizzle_junior_core (hash, created_at) VALUES ($1, $2)`, - [migration.hash, migration.folderMillis], + [migration.hash, migration.when], ); }); } +function migrationState(stateAdapter: StateAdapter): MigrationStateV1 { + return { + appendToList: async (key, value, options) => { + await stateAdapter.appendToList(key, value, options); + }, + delete: async (key) => { + await stateAdapter.delete(key); + }, + get: async (key) => await stateAdapter.get(key), + getList: async (key) => await stateAdapter.getList(key), + set: async (key, value, ttlMs) => { + await stateAdapter.set(key, value, ttlMs); + }, + }; +} + +/** Project the executor onto the permanent SQL migration capability. */ +function migrationSql( + executor: JuniorSqlMigrationExecutor, +): MigrationContextV1["sql"] { + return { + execute: executor.execute.bind(executor), + query: executor.query.bind(executor), + transaction: executor.transaction.bind(executor), + }; +} + +export type MigrateSchemaOptions = + | { mode?: "sql" } + | { + loadTypeScript: TypeScriptMigrationLoader; + log?: MigrationContextV1["log"]; + mode: "all"; + runTask: MigrationContextV1["tasks"]["run"]; + stateAdapter: StateAdapter; + }; + export { schema }; -/** Apply the packaged Drizzle migrations during `junior upgrade`. */ +/** Apply the packaged mixed migration journal, or SQL entries alone in tests. */ export async function migrateSchema( executor: JuniorSqlMigrationExecutor, -): Promise { + options: MigrateSchemaOptions = { mode: "sql" }, +): Promise { const migrationsFolder = migrationFolder(); - await executor.withMigrationLock(MIGRATIONS_TABLE, async () => { - await adoptLegacyMigrationState(executor, migrationsFolder); - await executor.migrate({ - migrationsFolder, - migrationsTable: MIGRATIONS_TABLE, - }); + const runAll = options.mode === "all"; + const baseOptions = { + beforeRun: async () => { + await adoptLegacyMigrationState(executor, migrationsFolder); + }, + executor, + migrationsFolder, + migrationsTable: MIGRATIONS_TABLE, + }; + if (!runAll) { + return await runMigrationJournal({ ...baseOptions, mode: "sql" }); + } + return await runMigrationJournal({ + ...baseOptions, + createContext: ({ progress }): MigrationContextV1 => ({ + log: options.log ?? (() => {}), + progress, + sql: migrationSql(executor), + state: migrationState(options.stateAdapter), + tasks: { + run: options.runTask, + }, + }), + loadTypeScript: options.loadTypeScript, + mode: "all", }); } diff --git a/packages/junior/src/chat/plugins/migrations.ts b/packages/junior/src/chat/plugins/migrations.ts index d054f6e11..8a6dfe442 100644 --- a/packages/junior/src/chat/plugins/migrations.ts +++ b/packages/junior/src/chat/plugins/migrations.ts @@ -1,5 +1,14 @@ import { createHash } from "node:crypto"; -import { readMigrationFiles, type MigrationMeta } from "drizzle-orm/migrator"; +import { + resolveMigrations, + runMigrationJournal, + type MigrationContextV1, + type MigrationJsonValue, + type MigrationStateV1, + type ResolvedMigration, + type TypeScriptMigrationLoader, +} from "@sentry/junior-migrations"; +import type { StateAdapter } from "chat"; import type { JuniorSqlMigrationExecutor } from "@/db/db"; interface PluginMigrationRoot { @@ -8,11 +17,25 @@ interface PluginMigrationRoot { pluginName: string; } -interface PluginMigrationResult { +type PluginMigrationResult = { existing: number; migrated: number; scanned: number; -} + skipped?: number; +}; + +type PluginMigrationOptions = + | { mode?: "sql" } + | { + loadTypeScript: TypeScriptMigrationLoader; + log?: MigrationContextV1["log"]; + mode: "all"; + runTask?: ( + pluginName: string, + taskName: string, + ) => Promise; + stateAdapter: StateAdapter; + }; const LEGACY_SCHEDULER_BASELINE_HASH = "d1d2f712181dd3a0557808f0fc67fd0722691d25f4c8cfb816b77c71d19e1e42"; @@ -30,28 +53,6 @@ function migrationTable(pluginName: string): string { return `__drizzle_${label}_${hash}`; } -async function appliedMigrationTime( - executor: JuniorSqlMigrationExecutor, - table: string, -): Promise { - const [exists] = await executor.query<{ tableName: string | null }>( - `SELECT to_regclass($1)::text AS "tableName"`, - [`drizzle.${table}`], - ); - if (!exists?.tableName) { - return undefined; - } - const [row] = await executor.query<{ createdAt: string | null }>( - `SELECT created_at::text AS "createdAt" - FROM drizzle.${table} - ORDER BY created_at DESC - LIMIT 1`, - ); - return row?.createdAt === null || row?.createdAt === undefined - ? undefined - : Number(row.createdAt); -} - async function legacyMigrationHashes( executor: JuniorSqlMigrationExecutor, pluginName: string, @@ -73,13 +74,13 @@ async function legacyMigrationHashes( } function adoptedMigration( - migrations: readonly MigrationMeta[], + migrations: readonly ResolvedMigration[], legacyHashes: ReadonlySet, pluginName: string, -): MigrationMeta | undefined { - let adopted: MigrationMeta | undefined; +): ResolvedMigration | undefined { + let adopted: ResolvedMigration | undefined; for (const migration of migrations) { - if (!legacyHashes.has(migration.hash)) { + if (migration.kind !== "sql" || !legacyHashes.has(migration.hash)) { break; } adopted = migration; @@ -96,10 +97,17 @@ function adoptedMigration( async function adoptLegacyMigrationState(args: { executor: JuniorSqlMigrationExecutor; - migrations: readonly MigrationMeta[]; + migrations: readonly ResolvedMigration[]; pluginName: string; table: string; -}): Promise { +}): Promise { + const [exists] = await args.executor.query<{ tableName: string | null }>( + `SELECT to_regclass($1)::text AS "tableName"`, + [`drizzle.${args.table}`], + ); + if (exists?.tableName) { + return; + } const legacyHashes = await legacyMigrationHashes( args.executor, args.pluginName, @@ -110,7 +118,7 @@ async function adoptLegacyMigrationState(args: { args.pluginName, ); if (!migration) { - return undefined; + return; } await args.executor.transaction(async () => { await args.executor.execute("CREATE SCHEMA IF NOT EXISTS drizzle"); @@ -123,26 +131,43 @@ CREATE TABLE IF NOT EXISTS drizzle.${args.table} ( `); await args.executor.execute( `INSERT INTO drizzle.${args.table} (hash, created_at) VALUES ($1, $2)`, - [migration.hash, migration.folderMillis], + [migration.hash, migration.when], ); }); - return migration.folderMillis; } -function appliedCount( - migrations: readonly MigrationMeta[], - createdAt: number | undefined, -): number { - return createdAt === undefined - ? 0 - : migrations.filter((migration) => migration.folderMillis <= createdAt) - .length; +function migrationState(stateAdapter: StateAdapter): MigrationStateV1 { + return { + appendToList: async (key, value, options) => { + await stateAdapter.appendToList(key, value, options); + }, + delete: async (key) => { + await stateAdapter.delete(key); + }, + get: async (key) => await stateAdapter.get(key), + getList: async (key) => await stateAdapter.getList(key), + set: async (key, value, ttlMs) => { + await stateAdapter.set(key, value, ttlMs); + }, + }; +} + +/** Project the executor onto the permanent SQL migration capability. */ +function migrationSql( + executor: JuniorSqlMigrationExecutor, +): MigrationContextV1["sql"] { + return { + execute: executor.execute.bind(executor), + query: executor.query.bind(executor), + transaction: executor.transaction.bind(executor), + }; } -/** Apply enabled plugins' packaged Drizzle migrations in plugin-name order. */ +/** Apply enabled plugins' mixed migrations in plugin-name and journal order. */ export async function migratePluginSchemas( executor: JuniorSqlMigrationExecutor, roots: readonly PluginMigrationRoot[], + options: PluginMigrationOptions = { mode: "sql" }, ): Promise { const result: PluginMigrationResult = { existing: 0, @@ -153,26 +178,51 @@ export async function migratePluginSchemas( left.pluginName.localeCompare(right.pluginName), ); for (const root of orderedRoots) { - const migrations = readMigrationFiles({ migrationsFolder: root.dir }); + const migrations = await resolveMigrations(root.dir); const table = migrationTable(root.pluginName); - await executor.withMigrationLock(table, async () => { - const currentTime = - (await appliedMigrationTime(executor, table)) ?? - (await adoptLegacyMigrationState({ + const runAll = options.mode === "all"; + const baseOptions = { + beforeRun: async () => { + await adoptLegacyMigrationState({ executor, migrations, pluginName: root.pluginName, table, - })); - const existing = appliedCount(migrations, currentTime); - await executor.migrate({ - migrationsFolder: root.dir, - migrationsTable: table, - }); - result.scanned += migrations.length; - result.existing += existing; - result.migrated += migrations.length - existing; - }); + }); + }, + executor, + migrationsFolder: root.dir, + migrationsTable: table, + }; + const pluginResult = runAll + ? await runMigrationJournal({ + ...baseOptions, + createContext: ({ progress }): MigrationContextV1 => ({ + log: options.log ?? (() => {}), + progress, + sql: migrationSql(executor), + state: migrationState(options.stateAdapter), + tasks: { + run: async (taskName) => { + if (!options.runTask) { + throw new Error( + `Unsupported migration task for plugin ${root.pluginName}: ${taskName}`, + ); + } + return await options.runTask(root.pluginName, taskName); + }, + }, + }), + loadTypeScript: options.loadTypeScript, + mode: "all", + }) + : await runMigrationJournal({ ...baseOptions, mode: "sql" }); + result.scanned += pluginResult.scanned; + result.existing += pluginResult.existing; + result.migrated += pluginResult.migrated; + if (pluginResult.skipped > 0) { + result.skipped = (result.skipped ?? 0) + pluginResult.skipped; + } } return result; } diff --git a/packages/junior/src/cli/upgrade.ts b/packages/junior/src/cli/upgrade.ts index f566c1afe..b5ff82c17 100644 --- a/packages/junior/src/cli/upgrade.ts +++ b/packages/junior/src/cli/upgrade.ts @@ -4,19 +4,13 @@ import { } from "@/chat/state/adapter"; import { createJiti } from "jiti"; import { loadAppPluginSet } from "@/plugin-module"; -import { sqlConversationMigration } from "./upgrade/migrations/conversations-sql"; -import { coreSqlSchemaMigration } from "./upgrade/migrations/core-sql"; -import { sqlConversationHistoryMigration } from "./upgrade/migrations/conversations-history-sql"; -import { pluginStorageMigration } from "./upgrade/migrations/plugin-storage"; -import { sqlPluginMigration } from "./upgrade/migrations/plugin-sql"; +import { migrateCoreJournal } from "./upgrade/migrations/core-journal"; +import { migratePluginJournals } from "./upgrade/migrations/plugin-journal"; import { resolveUpgradePlugins } from "./upgrade/migrations/upgrade-plugins"; -import { redisConversationStateMigration } from "./upgrade/migrations/redis-conversation-state"; -import { agentTurnSessionActorMigration } from "./upgrade/migrations/agent-turn-session-actor"; import type { MigrationContext, MigrationResult, UpgradeIo, - UpgradeMigration, } from "./upgrade/types"; import { type JuniorPluginSet } from "@/plugins"; @@ -25,16 +19,6 @@ const DEFAULT_IO: UpgradeIo = { }; const localPluginLoader = createJiti(import.meta.url, { moduleCache: false }); -const MIGRATIONS: UpgradeMigration[] = [ - agentTurnSessionActorMigration, - redisConversationStateMigration, - coreSqlSchemaMigration, - sqlConversationMigration, - sqlConversationHistoryMigration, - sqlPluginMigration, - pluginStorageMigration, -]; - function isMissingVirtualConfig(error: unknown): boolean { if (!(error instanceof Error)) { return false; @@ -88,14 +72,19 @@ export async function runUpgradeMigrations( const plugins = await resolveUpgradePlugins(context); const migrationContext = { ...context, ...plugins }; const results: MigrationResult[] = []; - for (const migration of MIGRATIONS) { - migrationContext.io.info(`Running migration ${migration.name}...`); - const result = await migration.run(migrationContext); + const run = async ( + name: string, + migrate: (context: MigrationContext) => Promise, + ): Promise => { + migrationContext.io.info(`Running migration ${name}...`); + const result = await migrate(migrationContext); migrationContext.io.info( - `Finished migration ${migration.name}: ${formatMigrationResult(result)}`, + `Finished migration ${name}: ${formatMigrationResult(result)}`, ); results.push(result); - } + }; + await run("core-migrations", migrateCoreJournal); + await run("plugin-migrations", migratePluginJournals); return results; } diff --git a/packages/junior/src/cli/upgrade/migrations/agent-turn-session-actor.ts b/packages/junior/src/cli/upgrade/migrations/agent-turn-session-actor.ts index 116598fd4..d6183cbbd 100644 --- a/packages/junior/src/cli/upgrade/migrations/agent-turn-session-actor.ts +++ b/packages/junior/src/cli/upgrade/migrations/agent-turn-session-actor.ts @@ -2,11 +2,7 @@ import type { RedisStateAdapter } from "@chat-adapter/state-redis"; import { THREAD_STATE_TTL_MS, type StateAdapter } from "chat"; import { isRecord, toOptionalString } from "@/chat/coerce"; import { getChatConfig } from "@/chat/config"; -import type { - MigrationContext, - MigrationResult, - UpgradeMigration, -} from "../types"; +import type { MigrationContext, MigrationResult } from "../types"; const AGENT_TURN_SESSION_PREFIX = "junior:agent_turn_session"; const AGENT_TURN_SESSION_INDEX_KEY = `${AGENT_TURN_SESSION_PREFIX}:index`; @@ -151,7 +147,8 @@ async function migrateSessionRecord(args: { return "migrated"; } -async function migrateAgentTurnSessionActor( +/** Rewrite retained turn-session requester fields to actor fields. */ +export async function migrateAgentTurnSessionActor( context: MigrationContext, ): Promise { const result: MigrationResult = { @@ -229,8 +226,3 @@ async function migrateAgentTurnSessionActor( return result; } - -export const agentTurnSessionActorMigration: UpgradeMigration = { - name: "migrate-agent-turn-session-requester-to-actor", - run: migrateAgentTurnSessionActor, -}; diff --git a/packages/junior/src/cli/upgrade/migrations/conversations-history-sql.ts b/packages/junior/src/cli/upgrade/migrations/conversations-history-sql.ts index 66e3e39a0..f9d6a76ca 100644 --- a/packages/junior/src/cli/upgrade/migrations/conversations-history-sql.ts +++ b/packages/junior/src/cli/upgrade/migrations/conversations-history-sql.ts @@ -80,8 +80,3 @@ export async function migrateConversationHistoryToSql( await closeExecutor?.(); } } - -export const sqlConversationHistoryMigration = { - name: "backfill-agent-steps-sql", - run: migrateConversationHistoryToSql, -}; diff --git a/packages/junior/src/cli/upgrade/migrations/conversations-sql.ts b/packages/junior/src/cli/upgrade/migrations/conversations-sql.ts index bb24c4283..5b4194e92 100644 --- a/packages/junior/src/cli/upgrade/migrations/conversations-sql.ts +++ b/packages/junior/src/cli/upgrade/migrations/conversations-sql.ts @@ -99,8 +99,3 @@ export async function migrateConversationsToSql( await closeTarget?.(); } } - -export const sqlConversationMigration = { - name: "backfill-conversations-sql", - run: migrateConversationsToSql, -}; diff --git a/packages/junior/src/cli/upgrade/migrations/core-journal.ts b/packages/junior/src/cli/upgrade/migrations/core-journal.ts new file mode 100644 index 000000000..d753825bb --- /dev/null +++ b/packages/junior/src/cli/upgrade/migrations/core-journal.ts @@ -0,0 +1,60 @@ +import { getChatConfig } from "@/chat/config"; +import { migrateSchema } from "@/chat/conversations/sql/migrations"; +import { createJuniorSqlExecutor } from "@/db/executor"; +import type { MigrationJsonValue } from "@sentry/junior-migrations"; +import { createJiti } from "jiti"; +import { migrateAgentTurnSessionActor } from "./agent-turn-session-actor"; +import { migrateConversationHistoryToSql } from "./conversations-history-sql"; +import { migrateConversationsToSql } from "./conversations-sql"; +import { migrateRedisConversationState } from "./redis-conversation-state"; +import type { MigrationContext, MigrationResult } from "../types"; + +const migrationLoader = createJiti(import.meta.url, { moduleCache: false }); + +async function runCoreMigrationTask( + context: MigrationContext, + name: string, +): Promise { + switch (name) { + case "agent-turn-session-actor-v1": + return await migrateAgentTurnSessionActor(context); + case "redis-conversation-state-v1": + return await migrateRedisConversationState(context); + case "conversations-to-sql-v1": + return await migrateConversationsToSql(context); + case "conversation-history-to-sql-v1": + return await migrateConversationHistoryToSql(context); + default: + throw new Error(`Unknown core migration task: ${name}`); + } +} + +/** Apply core schema migrations and versioned legacy data tasks in journal order. */ +export async function migrateCoreJournal( + context: MigrationContext, +): Promise { + const { sql } = getChatConfig(); + const executor = createJuniorSqlExecutor({ + connectionString: sql.databaseUrl, + driver: sql.driver, + }); + try { + const result = await migrateSchema(executor, { + loadTypeScript: async (path) => + await migrationLoader.import>(path), + log: context.io.info, + mode: "all", + runTask: async (name) => await runCoreMigrationTask(context, name), + stateAdapter: context.stateAdapter, + }); + return { + existing: result.existing, + migrated: result.migrated, + missing: 0, + scanned: result.scanned, + skipped: result.skipped, + }; + } finally { + await executor.close(); + } +} diff --git a/packages/junior/src/cli/upgrade/migrations/core-sql.ts b/packages/junior/src/cli/upgrade/migrations/core-sql.ts deleted file mode 100644 index a8b07ef98..000000000 --- a/packages/junior/src/cli/upgrade/migrations/core-sql.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { getChatConfig } from "@/chat/config"; -import { migrateSchema } from "@/chat/conversations/sql/migrations"; -import { createJuniorSqlExecutor } from "@/db/executor"; -import type { MigrationContext, MigrationResult } from "../types"; - -/** Apply core SQL schema migrations before upgrade backfills run. */ -export async function migrateCoreSqlSchema( - _context: MigrationContext, -): Promise { - const { sql } = getChatConfig(); - const executor = createJuniorSqlExecutor({ - connectionString: sql.databaseUrl, - driver: sql.driver, - }); - try { - await migrateSchema(executor); - return { existing: 0, migrated: 0, missing: 0, scanned: 0 }; - } finally { - await executor.close(); - } -} - -export const coreSqlSchemaMigration = { - name: "core-sql-schema", - run: migrateCoreSqlSchema, -}; diff --git a/packages/junior/src/cli/upgrade/migrations/plugin-journal.ts b/packages/junior/src/cli/upgrade/migrations/plugin-journal.ts new file mode 100644 index 000000000..4ab03e05f --- /dev/null +++ b/packages/junior/src/cli/upgrade/migrations/plugin-journal.ts @@ -0,0 +1,70 @@ +import { getChatConfig } from "@/chat/config"; +import { migratePluginSchemas } from "@/chat/plugins/migrations"; +import { pluginCatalogRuntime } from "@/chat/plugins/catalog-runtime"; +import { createPluginLogger } from "@/chat/plugins/logging"; +import { createPluginState } from "@/chat/plugins/state"; +import { createJuniorSqlExecutor } from "@/db/executor"; +import { createJiti } from "jiti"; +import { resolveUpgradePlugins } from "./upgrade-plugins"; +import type { MigrationContext, MigrationResult } from "../types"; + +const migrationLoader = createJiti(import.meta.url, { moduleCache: false }); + +/** Apply mixed migration journals owned by explicitly enabled plugins. */ +export async function migratePluginJournals( + context: MigrationContext, +): Promise { + const { sql } = getChatConfig(); + const { pluginCatalogConfig, pluginSet } = + await resolveUpgradePlugins(context); + const previousConfig = pluginCatalogRuntime.setConfig(pluginCatalogConfig); + const executor = createJuniorSqlExecutor({ + connectionString: sql.databaseUrl, + driver: sql.driver, + }); + try { + const registrations = new Map( + pluginSet + ? pluginSet.registrations.map((plugin) => [ + plugin.manifest.name, + plugin, + ]) + : [], + ); + const result = await migratePluginSchemas( + executor, + pluginCatalogRuntime.getMigrationRoots(), + { + loadTypeScript: async (path) => + await migrationLoader.import>(path), + log: context.io.info, + mode: "all", + runTask: async (pluginName, taskName) => { + const task = + registrations.get(pluginName)?.migrationTasks?.[taskName]; + if (!task) { + throw new Error( + `Plugin ${pluginName} does not provide migration task ${taskName}`, + ); + } + return await task({ + db: context.db ?? executor.db(), + log: createPluginLogger(pluginName), + state: createPluginState(pluginName, context.stateAdapter), + }); + }, + stateAdapter: context.stateAdapter, + }, + ); + return { + existing: result.existing, + migrated: result.migrated, + missing: 0, + scanned: result.scanned, + ...(result.skipped === undefined ? {} : { skipped: result.skipped }), + }; + } finally { + pluginCatalogRuntime.setConfig(previousConfig); + await executor.close(); + } +} diff --git a/packages/junior/src/cli/upgrade/migrations/plugin-sql.ts b/packages/junior/src/cli/upgrade/migrations/plugin-sql.ts deleted file mode 100644 index c41ef3dc7..000000000 --- a/packages/junior/src/cli/upgrade/migrations/plugin-sql.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { getChatConfig } from "@/chat/config"; -import { migratePluginSchemas } from "@/chat/plugins/migrations"; -import { pluginCatalogRuntime } from "@/chat/plugins/catalog-runtime"; -import { createJuniorSqlExecutor } from "@/db/executor"; -import { resolveUpgradePlugins } from "./upgrade-plugins"; -import type { MigrationContext, MigrationResult } from "../types"; - -/** Apply SQL schema migrations owned by explicitly enabled plugins. */ -export async function migratePluginsToSql( - context: MigrationContext, -): Promise { - const { sql } = getChatConfig(); - const { pluginCatalogConfig } = await resolveUpgradePlugins(context); - const previousConfig = pluginCatalogRuntime.setConfig(pluginCatalogConfig); - const executor = createJuniorSqlExecutor({ - connectionString: sql.databaseUrl, - driver: sql.driver, - }); - try { - const result = await migratePluginSchemas( - executor, - pluginCatalogRuntime.getMigrationRoots(), - ); - return { - existing: result.existing, - migrated: result.migrated, - missing: 0, - scanned: result.scanned, - }; - } finally { - pluginCatalogRuntime.setConfig(previousConfig); - await executor.close(); - } -} - -export const sqlPluginMigration = { - name: "migrate-plugin-sql", - run: migratePluginsToSql, -}; diff --git a/packages/junior/src/cli/upgrade/migrations/plugin-storage.ts b/packages/junior/src/cli/upgrade/migrations/plugin-storage.ts deleted file mode 100644 index bc340d266..000000000 --- a/packages/junior/src/cli/upgrade/migrations/plugin-storage.ts +++ /dev/null @@ -1,93 +0,0 @@ -import type { StorageMigrationResult } from "@sentry/junior-plugin-api"; -import { pluginRuntimeRegistrationsFromPluginSet } from "@/plugins"; -import { getDb } from "@/chat/db"; -import { createPluginLogger } from "@/chat/plugins/logging"; -import { createPluginState } from "@/chat/plugins/state"; -import { pluginCatalogRuntime } from "@/chat/plugins/catalog-runtime"; -import { getChatConfig } from "@/chat/config"; -import { createJuniorSqlExecutor } from "@/db/executor"; -import { resolveUpgradePlugins } from "./upgrade-plugins"; -import type { MigrationContext, MigrationResult } from "../types"; - -function emptyResult(): MigrationResult { - return { - existing: 0, - migrated: 0, - missing: 0, - scanned: 0, - }; -} - -function addResult( - left: MigrationResult, - right: StorageMigrationResult, -): MigrationResult { - return { - existing: left.existing + right.existing, - migrated: left.migrated + right.migrated, - missing: left.missing + right.missing, - scanned: left.scanned + right.scanned, - ...(left.skipped !== undefined || right.skipped !== undefined - ? { skipped: (left.skipped ?? 0) + (right.skipped ?? 0) } - : {}), - }; -} - -function dbForPlugin(context: MigrationContext, sqlUrlDb: unknown): unknown { - return context.db ?? sqlUrlDb ?? getDb(); -} - -/** Run plugin-owned storage migrations after plugin SQL schemas are available. */ -export async function runPluginStorageMigrations( - context: MigrationContext, -): Promise { - const { pluginCatalogConfig, pluginSet } = - await resolveUpgradePlugins(context); - if (!pluginSet) { - return emptyResult(); - } - - const previousConfig = pluginCatalogRuntime.setConfig(pluginCatalogConfig); - const sql = getChatConfig().sql; - const ownedExecutor = context.db - ? undefined - : createJuniorSqlExecutor({ - connectionString: sql.databaseUrl, - driver: sql.driver, - }); - const sqlUrlDb = ownedExecutor ? ownedExecutor.db() : undefined; - try { - let result = emptyResult(); - const plugins = pluginRuntimeRegistrationsFromPluginSet(pluginSet) - .filter((plugin) => plugin.hooks?.migrateStorage) - .sort((left, right) => - left.manifest.name.localeCompare(right.manifest.name), - ); - for (const plugin of plugins) { - const pluginName = plugin.manifest.name; - const hook = plugin.hooks?.migrateStorage; - if (!hook) { - continue; - } - const db = dbForPlugin(context, sqlUrlDb); - const pluginResult = await hook({ - db, - log: createPluginLogger(pluginName), - plugin: { name: pluginName }, - state: createPluginState(pluginName, context.stateAdapter), - }); - if (pluginResult) { - result = addResult(result, pluginResult); - } - } - return result; - } finally { - pluginCatalogRuntime.setConfig(previousConfig); - await ownedExecutor?.close(); - } -} - -export const pluginStorageMigration = { - name: "run-plugin-storage-migrations", - run: runPluginStorageMigrations, -}; diff --git a/packages/junior/src/cli/upgrade/migrations/redis-conversation-state.ts b/packages/junior/src/cli/upgrade/migrations/redis-conversation-state.ts index d2b903865..35137ded9 100644 --- a/packages/junior/src/cli/upgrade/migrations/redis-conversation-state.ts +++ b/packages/junior/src/cli/upgrade/migrations/redis-conversation-state.ts @@ -15,11 +15,7 @@ import { type Lease, type Source, } from "@/chat/task-execution/state"; -import type { - MigrationContext, - MigrationResult, - UpgradeMigration, -} from "../types"; +import type { MigrationContext, MigrationResult } from "../types"; const CONVERSATION_PREFIX = "junior:conversation"; const CONVERSATION_SCHEMA_VERSION = 1; @@ -771,17 +767,11 @@ async function seedAwaitingContinuationConversationWork( } } -async function migrateRedisConversationState( +/** Move legacy Redis conversation work into the retained conversation model. */ +export async function migrateRedisConversationState( context: MigrationContext, ): Promise { const result = await migrateLegacyConversationWorkRedisState(context); await seedAwaitingContinuationConversationWork(context, result); return result; } - -export const redisConversationStateMigration: UpgradeMigration = { - // TODO(after 2026-07-01): remove after deployed installs have had a release - // window to move legacy conversation-work Redis state forward. - name: "migrate-redis-conversation-state", - run: migrateRedisConversationState, -}; diff --git a/packages/junior/src/cli/upgrade/types.ts b/packages/junior/src/cli/upgrade/types.ts index 0168d025d..218ce83bf 100644 --- a/packages/junior/src/cli/upgrade/types.ts +++ b/packages/junior/src/cli/upgrade/types.ts @@ -16,15 +16,10 @@ export interface MigrationContext { stateAdapter: StateAdapter; } -export interface MigrationResult { +export type MigrationResult = { existing: number; migrated: number; missing: number; scanned: number; skipped?: number; -} - -export interface UpgradeMigration { - name: string; - run(context: MigrationContext): Promise; -} +}; diff --git a/packages/junior/src/db/db.ts b/packages/junior/src/db/db.ts index 35c037e43..321184858 100644 --- a/packages/junior/src/db/db.ts +++ b/packages/junior/src/db/db.ts @@ -7,7 +7,6 @@ */ import type { PgDatabase } from "drizzle-orm/pg-core"; import type { PgQueryResultHKT } from "drizzle-orm/pg-core/session"; -import type { MigrationConfig } from "drizzle-orm/migrator"; import type { juniorSqlSchema } from "./schema"; export type JuniorDatabase = PgDatabase< @@ -23,7 +22,6 @@ export interface JuniorSqlDatabase { export interface JuniorSqlMigrationExecutor extends JuniorSqlDatabase { execute(statement: string, params?: readonly unknown[]): Promise; - migrate(config: MigrationConfig): Promise; query( statement: string, params?: readonly unknown[], diff --git a/packages/junior/src/db/neon.ts b/packages/junior/src/db/neon.ts index 62e7aa041..0ce11d06a 100644 --- a/packages/junior/src/db/neon.ts +++ b/packages/junior/src/db/neon.ts @@ -6,8 +6,6 @@ import { type QueryResultRow, } from "@neondatabase/serverless"; import { drizzle } from "drizzle-orm/neon-serverless"; -import { migrate } from "drizzle-orm/neon-serverless/migrator"; -import type { MigrationConfig } from "drizzle-orm/migrator"; import type { JuniorDatabase, JuniorSqlExecutor } from "./db"; import { juniorSqlSchema } from "./schema"; @@ -45,13 +43,6 @@ class NeonExecutor implements NeonJuniorSqlExecutor { return result.rows as T[]; } - async migrate(config: MigrationConfig): Promise { - await migrate( - drizzle(this.queryClient(), { schema: juniorSqlSchema }), - config, - ); - } - async transaction(callback: () => Promise): Promise { const existingClient = this.transactionClient.getStore(); if (existingClient) { diff --git a/packages/junior/src/db/postgres.ts b/packages/junior/src/db/postgres.ts index 4124bd9ff..53020ce44 100644 --- a/packages/junior/src/db/postgres.ts +++ b/packages/junior/src/db/postgres.ts @@ -5,8 +5,6 @@ import pg, { type QueryResultRow, } from "pg"; import { drizzle } from "drizzle-orm/node-postgres"; -import { migrate } from "drizzle-orm/node-postgres/migrator"; -import type { MigrationConfig } from "drizzle-orm/migrator"; import type { JuniorDatabase, JuniorSqlExecutor } from "./db"; import { juniorSqlSchema } from "./schema"; @@ -43,13 +41,6 @@ class PostgresExecutor implements JuniorSqlExecutor { return result.rows as T[]; } - async migrate(config: MigrationConfig): Promise { - await migrate( - drizzle(this.queryClient(), { schema: juniorSqlSchema }), - config, - ); - } - async transaction(callback: () => Promise): Promise { const existingClient = this.transactionClient.getStore(); if (existingClient) { diff --git a/packages/junior/tests/component/cli/upgrade-cli.test.ts b/packages/junior/tests/component/cli/upgrade-cli.test.ts index 0afa4f023..ef8e1c9d2 100644 --- a/packages/junior/tests/component/cli/upgrade-cli.test.ts +++ b/packages/junior/tests/component/cli/upgrade-cli.test.ts @@ -2,6 +2,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import type { RedisStateAdapter } from "@chat-adapter/state-redis"; +import { createJiti } from "jiti"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { getChatConfig } from "@/chat/config"; import { disconnectStateAdapter, getStateAdapter } from "@/chat/state/adapter"; @@ -17,9 +18,9 @@ import type { ConversationStore } from "@/chat/conversations/store"; import { persistThreadStateById } from "@/chat/runtime/thread-state"; import { recordAgentTurnSessionSummary } from "@/chat/state/turn-session"; import { resolveUpgradePluginSet } from "@/cli/upgrade"; -import { agentTurnSessionActorMigration } from "@/cli/upgrade/migrations/agent-turn-session-actor"; +import { migrateAgentTurnSessionActor } from "@/cli/upgrade/migrations/agent-turn-session-actor"; import { migrateConversationsToSql } from "@/cli/upgrade/migrations/conversations-sql"; -import { redisConversationStateMigration } from "@/cli/upgrade/migrations/redis-conversation-state"; +import { migrateRedisConversationState } from "@/cli/upgrade/migrations/redis-conversation-state"; import { CONVERSATION_ID, SLACK_DESTINATION, @@ -41,6 +42,7 @@ const OTHER_SLACK_DESTINATION = { ...SLACK_DESTINATION, channelId: "C999", } as const; +const migrationLoader = createJiti(import.meta.url, { moduleCache: false }); const stateOnlyConversationStore: ConversationStore = { get: async () => undefined, @@ -166,7 +168,7 @@ export const plugins = { ); await expect( - agentTurnSessionActorMigration.run({ + migrateAgentTurnSessionActor({ io: { info: () => {} }, stateAdapter, }), @@ -193,7 +195,7 @@ export const plugins = { expect(migratedRecord).not.toHaveProperty("requester"); await expect( - agentTurnSessionActorMigration.run({ + migrateAgentTurnSessionActor({ io: { info: () => {} }, stateAdapter, }), @@ -262,7 +264,7 @@ export const plugins = { } as unknown as RedisStateAdapter; await expect( - agentTurnSessionActorMigration.run({ + migrateAgentTurnSessionActor({ io: { info: () => {} }, redisStateAdapter, stateAdapter, @@ -287,6 +289,88 @@ export const plugins = { ), ).resolves.toEqual(expect.objectContaining({ actor: requester })); }); + it("runs journaled TypeScript state migrations exactly once", async () => { + const stateAdapter = getStateAdapter(); + await stateAdapter.connect(); + const fixture = await createLocalJuniorSqlFixture(); + const actor = { platform: "api", userId: "migration-user" }; + const summary = { + conversationId: CONVERSATION_ID, + sessionId: "session-one", + requester: actor, + }; + await stateAdapter.appendToList("junior:agent_turn_session:index", summary); + await stateAdapter.appendToList( + `junior:agent_turn_session:conversation:${CONVERSATION_ID}:index`, + summary, + ); + await stateAdapter.set( + `junior:agent_turn_session:${CONVERSATION_ID}:session-one`, + summary, + ); + + try { + await expect( + migrateSchema(fixture.sql, { + loadTypeScript: async (migrationPath) => + await migrationLoader.import>( + migrationPath, + ), + mode: "all", + runTask: async (name) => { + if (name === "agent-turn-session-actor-v1") { + return await migrateAgentTurnSessionActor({ + io: { info: () => {} }, + stateAdapter, + }); + } + }, + stateAdapter, + }), + ).resolves.toEqual({ + existing: 0, + migrated: 8, + scanned: 8, + skipped: 0, + }); + await expect( + stateAdapter.getList("junior:agent_turn_session:index"), + ).resolves.toEqual([ + { + actor, + conversationId: CONVERSATION_ID, + sessionId: "session-one", + }, + ]); + await expect( + stateAdapter.get( + `junior:agent_turn_session:${CONVERSATION_ID}:session-one`, + ), + ).resolves.toEqual({ + actor, + conversationId: CONVERSATION_ID, + sessionId: "session-one", + }); + await expect( + migrateSchema(fixture.sql, { + loadTypeScript: async (migrationPath) => + await migrationLoader.import>( + migrationPath, + ), + mode: "all", + runTask: async () => undefined, + stateAdapter, + }), + ).resolves.toEqual({ + existing: 8, + migrated: 0, + scanned: 8, + skipped: 0, + }); + } finally { + await fixture.close(); + } + }, 15_000); it("migrates legacy conversation work before SQL conversation backfill", async () => { const stateAdapter = getStateAdapter(); @@ -314,7 +398,7 @@ export const plugins = { stateAdapter, }; const results = [ - await redisConversationStateMigration.run(context), + await migrateRedisConversationState(context), await migrateConversationsToSql(context, { target: sqlStore }), ]; @@ -558,7 +642,7 @@ WHERE conversation_id = $1 await persistActiveTurn(CONVERSATION_ID, "turn-timeout"); await expect( - redisConversationStateMigration.run({ + migrateRedisConversationState({ io: { info: () => {} }, stateAdapter, }), @@ -615,7 +699,7 @@ WHERE conversation_id = $1 await stateAdapter.set("junior:conversation-work:index", [CONVERSATION_ID]); await expect( - redisConversationStateMigration.run({ + migrateRedisConversationState({ io: { info: () => {} }, stateAdapter, }), @@ -692,7 +776,7 @@ WHERE conversation_id = $1 await stateAdapter.set("junior:conversation-work:index", [CONVERSATION_ID]); await expect( - redisConversationStateMigration.run({ + migrateRedisConversationState({ io: { info: () => {} }, stateAdapter, }), @@ -726,7 +810,7 @@ WHERE conversation_id = $1 await stateAdapter.set("junior:conversation-work:index", [CONVERSATION_ID]); await expect( - redisConversationStateMigration.run({ + migrateRedisConversationState({ io: { info: () => {} }, stateAdapter, }), @@ -746,7 +830,7 @@ WHERE conversation_id = $1 }); await expect( - redisConversationStateMigration.run({ + migrateRedisConversationState({ io: { info: () => {} }, stateAdapter, }), @@ -778,7 +862,7 @@ WHERE conversation_id = $1 stateAdapter, }; const results = [ - await redisConversationStateMigration.run(context), + await migrateRedisConversationState(context), await migrateConversationsToSql(context, { target: sqlStore }), ]; diff --git a/packages/junior/tests/component/memory-plugin-storage.test.ts b/packages/junior/tests/component/memory-plugin-storage.test.ts index c6cbdbe29..d9600d282 100644 --- a/packages/junior/tests/component/memory-plugin-storage.test.ts +++ b/packages/junior/tests/component/memory-plugin-storage.test.ts @@ -1,6 +1,7 @@ import path from "node:path"; import { readdirSync } from "node:fs"; import { createMemoryState } from "@chat-adapter/state-memory"; +import { resolveMigrations } from "@sentry/junior-migrations"; import { afterAll, describe, expect, it, vi } from "vitest"; import { createMemoryPlugin, @@ -14,9 +15,8 @@ import { import { defineJuniorPlugins } from "@/plugins"; import { getPluginTools, setPlugins } from "@/chat/plugins/agent-hooks"; import { migratePluginSchemas } from "@/chat/plugins/migrations"; -import { readMigrationFiles } from "drizzle-orm/migrator"; import { closeDb } from "@/chat/db"; -import { migratePluginsToSql } from "@/cli/upgrade/migrations/plugin-sql"; +import { migratePluginJournals } from "@/cli/upgrade/migrations/plugin-journal"; import { createLocalJuniorSqlFixture } from "../fixtures/sql"; const NEON = vi.hoisted(() => ({ @@ -39,7 +39,6 @@ vi.mock("@/db/executor", () => ({ db: NEON.sql.db.bind(NEON.sql), execute: NEON.sql.execute.bind(NEON.sql), query: NEON.sql.query.bind(NEON.sql), - migrate: NEON.sql.migrate.bind(NEON.sql), transaction: NEON.sql.transaction.bind(NEON.sql), withLock: NEON.sql.withLock.bind(NEON.sql), withMigrationLock: NEON.sql.withMigrationLock.bind(NEON.sql), @@ -98,9 +97,7 @@ async function migrateMemorySchema( describe("memory plugin host wiring", () => { it("adopts exact legacy migration hashes without replaying them", async () => { const fixture = await createLocalJuniorSqlFixture(); - const migrations = readMigrationFiles({ - migrationsFolder: memoryMigrationsDir(), - }); + const migrations = await resolveMigrations(memoryMigrationsDir()); const migrationFiles = memoryMigrationFiles(); expect(migrationFiles).toHaveLength(migrations.length); @@ -149,9 +146,8 @@ CREATE TABLE junior_schema_migrations ( it("does not adopt an unknown memory legacy checksum", async () => { const fixture = await createLocalJuniorSqlFixture(); - const migrationCount = readMigrationFiles({ - migrationsFolder: memoryMigrationsDir(), - }).length; + const migrationCount = (await resolveMigrations(memoryMigrationsDir())) + .length; const [baselineFile] = memoryMigrationFiles(); expect(baselineFile).toBeDefined(); @@ -192,11 +188,10 @@ CREATE TABLE junior_schema_migrations ( NEON.sql = fixture.sql; try { - const migrationCount = readMigrationFiles({ - migrationsFolder: memoryMigrationsDir(), - }).length; + const migrationCount = (await resolveMigrations(memoryMigrationsDir())) + .length; await expect( - migratePluginsToSql({ + migratePluginJournals({ io: { info: () => {} }, pluginSet: defineJuniorPlugins([createMemoryPlugin()]), stateAdapter, diff --git a/packages/junior/tests/component/scheduler-sql-plugin.test.ts b/packages/junior/tests/component/scheduler-sql-plugin.test.ts index 5304eb74a..8a015530e 100644 --- a/packages/junior/tests/component/scheduler-sql-plugin.test.ts +++ b/packages/junior/tests/component/scheduler-sql-plugin.test.ts @@ -2,9 +2,9 @@ import path from "node:path"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { createMemoryState } from "@chat-adapter/state-memory"; -import { readMigrationFiles } from "drizzle-orm/migrator"; -import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; +import { resolveMigrations } from "@sentry/junior-migrations"; import { defineJuniorPlugin } from "@sentry/junior-plugin-api"; +import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; import { createSchedulerSqlStore, schedulerPlugin, @@ -15,9 +15,8 @@ import { createSchedulerStore } from "../../../junior-scheduler/src/store"; import { defineJuniorPlugins } from "@/plugins"; import { migratePluginSchemas } from "@/chat/plugins/migrations"; import { createPluginState } from "@/chat/plugins/state"; -import { disconnectStateAdapter, getStateAdapter } from "@/chat/state/adapter"; -import { runPluginStorageMigrations } from "@/cli/upgrade/migrations/plugin-storage"; -import { migratePluginsToSql } from "@/cli/upgrade/migrations/plugin-sql"; +import { disconnectStateAdapter } from "@/chat/state/adapter"; +import { migratePluginJournals } from "@/cli/upgrade/migrations/plugin-journal"; import { createLocalJuniorSqlFixture } from "../fixtures/sql"; const NEON = vi.hoisted(() => ({ @@ -41,7 +40,6 @@ vi.mock("@/db/executor", () => ({ db: NEON.sql.db.bind(NEON.sql), execute: NEON.sql.execute.bind(NEON.sql), query: NEON.sql.query.bind(NEON.sql), - migrate: NEON.sql.migrate.bind(NEON.sql), transaction: NEON.sql.transaction.bind(NEON.sql), withLock: NEON.sql.withLock.bind(NEON.sql), withMigrationLock: NEON.sql.withMigrationLock.bind(NEON.sql), @@ -74,6 +72,22 @@ async function migrateSchedulerSchema( ]); } +async function runSchedulerMigrationTask(args: { + db: SchedulerDb; + stateAdapter: ReturnType; +}) { + const plugin = schedulerPlugin(); + const task = plugin.migrationTasks?.["scheduler-state-to-sql-v1"]; + if (!task) { + throw new Error("Missing scheduler migration task"); + } + return await task({ + db: args.db, + log: { error: () => {}, info: () => {}, warn: () => {} }, + state: createPluginState("scheduler", args.stateAdapter), + }); +} + function createTask(overrides: Partial = {}): ScheduledTask { return { id: "sched_sql_1", @@ -180,10 +194,13 @@ INSERT INTO junior_scheduler_tasks ( pluginName: "scheduler", }, ]), - ).resolves.toEqual({ existing: 1, migrated: 1, scanned: 2 }); - const migrations = readMigrationFiles({ - migrationsFolder: schedulerMigrationsDir(), + ).resolves.toEqual({ + existing: 1, + migrated: 1, + scanned: 3, + skipped: 1, }); + const migrations = await resolveMigrations(schedulerMigrationsDir()); const migrationRows = await fixture.sql.query<{ createdAt: string; hash: string; @@ -193,10 +210,12 @@ FROM drizzle.${migrationTable!.tablename} ORDER BY created_at `); expect(migrationRows).toEqual( - migrations.map((migration) => ({ - createdAt: String(migration.folderMillis), - hash: migration.hash, - })), + migrations + .filter((migration) => migration.kind === "sql") + .map((migration) => ({ + createdAt: String(migration.when), + hash: migration.hash, + })), ); const [migratedTask] = await fixture.sql.query<{ record: unknown }>( `SELECT record FROM junior_scheduler_tasks WHERE id = $1`, @@ -213,7 +232,12 @@ ORDER BY created_at pluginName: "scheduler", }, ]), - ).resolves.toEqual({ existing: 2, migrated: 0, scanned: 2 }); + ).resolves.toEqual({ + existing: 2, + migrated: 0, + scanned: 3, + skipped: 1, + }); await expect( fixture.sql.query<{ createdAt: string; @@ -235,17 +259,21 @@ ORDER BY created_at { dir: schedulerMigrationsDir(), pluginName: "scheduler" }, { dir: memoryMigrationsDir(), pluginName: "memory" }, ]; - const migrationCount = roots.reduce( - (count, root) => - count + readMigrationFiles({ migrationsFolder: root.dir }).length, - 0, - ); - + const migrations = ( + await Promise.all( + roots.map(async (root) => await resolveMigrations(root.dir)), + ) + ).flat(); + const migrationCount = migrations.length; + const sqlMigrationCount = migrations.filter( + (migration) => migration.kind === "sql", + ).length; try { await expect(migratePluginSchemas(fixture.sql, roots)).resolves.toEqual({ existing: 0, - migrated: migrationCount, + migrated: sqlMigrationCount, scanned: migrationCount, + skipped: 1, }); const migrationTables = await fixture.sql.query<{ tablename: string }>(` SELECT tablename @@ -258,9 +286,10 @@ ORDER BY tablename await expect( migratePluginSchemas(fixture.sql, [...roots].reverse()), ).resolves.toEqual({ - existing: migrationCount, + existing: sqlMigrationCount, migrated: 0, scanned: migrationCount, + skipped: 1, }); } finally { await fixture.close(); @@ -515,20 +544,17 @@ ORDER BY tablename const run = await stateStore.claimDueRun({ nowMs: TEST_NOW_MS }); expect(run).toBeDefined(); - const context = { - db, - io: { info: () => {} }, - pluginSet: defineJuniorPlugins([schedulerPlugin()]), - stateAdapter, - }; - - await expect(runPluginStorageMigrations(context)).resolves.toEqual({ + await expect( + runSchedulerMigrationTask({ db, stateAdapter }), + ).resolves.toEqual({ existing: 0, migrated: 2, missing: 0, scanned: 2, }); - await expect(runPluginStorageMigrations(context)).resolves.toEqual({ + await expect( + runSchedulerMigrationTask({ db, stateAdapter }), + ).resolves.toEqual({ existing: 2, migrated: 0, missing: 0, @@ -603,12 +629,7 @@ ORDER BY tablename ); await expect( - runPluginStorageMigrations({ - db, - io: { info: () => {} }, - pluginSet: defineJuniorPlugins([schedulerPlugin()]), - stateAdapter, - }), + runSchedulerMigrationTask({ db, stateAdapter }), ).resolves.toEqual({ existing: 0, migrated: 1, @@ -631,27 +652,17 @@ ORDER BY tablename } }, 15_000); - it("does not load scheduler storage migration from package-only plugin set", async () => { + it("does not apply scheduler SQL migrations from package-only config", async () => { const stateAdapter = createMemoryState(); await stateAdapter.connect(); const fixture = await createLocalJuniorSqlFixture(); + NEON.sql = fixture.sql; try { - await migrateSchedulerSchema(fixture); - const db = fixture.sql.db() as unknown as SchedulerDb; - const stateStore = createSchedulerStore( - createPluginState("scheduler", stateAdapter), - ); - const task = createTask({ id: "sched_package_config" }); - await stateStore.saveTask(task); - const run = await stateStore.claimDueRun({ nowMs: TEST_NOW_MS }); - expect(run).toBeDefined(); - await expect( - runPluginStorageMigrations({ - db, + migratePluginJournals({ io: { info: () => {} }, - pluginSet: defineJuniorPlugins(["@sentry/junior-scheduler"]), + pluginCatalogConfig: { packages: ["@sentry/junior-scheduler"] }, stateAdapter, }), ).resolves.toEqual({ @@ -660,17 +671,13 @@ ORDER BY tablename missing: 0, scanned: 0, }); - - const sqlStore = createSchedulerSqlStore(db); - await expect(sqlStore.getTask(task.id)).resolves.toBe(undefined); - await expect(sqlStore.getRun(run!.id)).resolves.toBe(undefined); } finally { await stateAdapter.disconnect(); await fixture.close(); } - }, 15_000); + }); - it("does not apply scheduler SQL migrations from package-only config", async () => { + it("applies scheduler SQL migrations from registration-only config", async () => { const stateAdapter = createMemoryState(); await stateAdapter.connect(); const fixture = await createLocalJuniorSqlFixture(); @@ -678,16 +685,24 @@ ORDER BY tablename try { await expect( - migratePluginsToSql({ + migratePluginJournals({ io: { info: () => {} }, - pluginCatalogConfig: { packages: ["@sentry/junior-scheduler"] }, + pluginSet: defineJuniorPlugins([schedulerPlugin()]), stateAdapter, }), ).resolves.toEqual({ existing: 0, - migrated: 0, + migrated: 3, missing: 0, - scanned: 0, + scanned: 3, + }); + + const db = fixture.sql.db() as unknown as SchedulerDb; + const store = createSchedulerSqlStore(db); + const task = createTask({ id: "sched_schema_registration_config" }); + await store.saveTask(task); + await expect(store.getTask(task.id)).resolves.toMatchObject({ + id: task.id, }); } finally { await stateAdapter.disconnect(); @@ -695,32 +710,30 @@ ORDER BY tablename } }); - it("applies scheduler SQL migrations from registration-only config", async () => { + it("runs a migration-only plugin registration", async () => { const stateAdapter = createMemoryState(); await stateAdapter.connect(); const fixture = await createLocalJuniorSqlFixture(); NEON.sql = fixture.sql; + const scheduler = schedulerPlugin(); + const migrationOnly = defineJuniorPlugin({ + manifest: scheduler.manifest, + migrationTasks: scheduler.migrationTasks, + packageName: scheduler.packageName, + }); try { await expect( - migratePluginsToSql({ + migratePluginJournals({ io: { info: () => {} }, - pluginSet: defineJuniorPlugins([schedulerPlugin()]), + pluginSet: defineJuniorPlugins([migrationOnly]), stateAdapter, }), ).resolves.toEqual({ existing: 0, - migrated: 2, + migrated: 3, missing: 0, - scanned: 2, - }); - - const db = fixture.sql.db() as unknown as SchedulerDb; - const store = createSchedulerSqlStore(db); - const task = createTask({ id: "sched_schema_registration_config" }); - await store.saveTask(task); - await expect(store.getTask(task.id)).resolves.toMatchObject({ - id: task.id, + scanned: 3, }); } finally { await stateAdapter.disconnect(); @@ -736,7 +749,7 @@ ORDER BY tablename try { await expect( - migratePluginsToSql({ + migratePluginJournals({ io: { info: () => {} }, pluginSet: defineJuniorPlugins([ "@sentry/junior-scheduler", @@ -746,9 +759,9 @@ ORDER BY tablename }), ).resolves.toEqual({ existing: 0, - migrated: 2, + migrated: 3, missing: 0, - scanned: 2, + scanned: 3, }); } finally { await stateAdapter.disconnect(); @@ -861,50 +874,4 @@ INSERT INTO junior_scheduler_runs ( await fixture.close(); } }, 15_000); - - it("passes database access to plugin storage migrations", async () => { - const stateAdapter = getStateAdapter(); - await stateAdapter.connect(); - const fixture = await createLocalJuniorSqlFixture(); - - try { - const db = fixture.sql.db() as unknown as SchedulerDb; - let receivedDb: unknown; - const plugin = defineJuniorPlugin({ - manifest: { - name: "stateless", - displayName: "Stateless", - description: "Storage migration with database access", - }, - hooks: { - migrateStorage(ctx) { - receivedDb = ctx.db; - return { - existing: 0, - migrated: 0, - missing: 0, - scanned: 1, - }; - }, - }, - }); - - await expect( - runPluginStorageMigrations({ - db, - io: { info: () => {} }, - pluginSet: defineJuniorPlugins([plugin]), - stateAdapter, - }), - ).resolves.toEqual({ - existing: 0, - migrated: 0, - missing: 0, - scanned: 1, - }); - expect(receivedDb).toBe(db); - } finally { - await fixture.close(); - } - }, 15_000); }); diff --git a/packages/junior/tests/fixtures/postgres/executor.ts b/packages/junior/tests/fixtures/postgres/executor.ts index 2e90d9ad0..3757405c3 100644 --- a/packages/junior/tests/fixtures/postgres/executor.ts +++ b/packages/junior/tests/fixtures/postgres/executor.ts @@ -1,7 +1,5 @@ import { type PoolClient, type QueryResultRow } from "pg"; import { drizzle } from "drizzle-orm/node-postgres"; -import { migrate } from "drizzle-orm/node-postgres/migrator"; -import type { MigrationConfig } from "drizzle-orm/migrator"; import type { JuniorDatabase, JuniorSqlExecutor } from "@/db/db"; import { createPostgresJuniorSqlExecutor } from "@/db/postgres"; import { juniorSqlSchema } from "@/db/schema"; @@ -37,10 +35,6 @@ class ClientJuniorSqlExecutor implements JuniorSqlExecutor { return result.rows as T[]; } - async migrate(config: MigrationConfig): Promise { - await migrate(drizzle(this.client, { schema: juniorSqlSchema }), config); - } - async transaction(callback: () => Promise): Promise { const savepoint = `junior_test_savepoint_${++this.savepointId}`; await this.client.query(`SAVEPOINT ${savepoint}`); diff --git a/packages/junior/tests/fixtures/sql.ts b/packages/junior/tests/fixtures/sql.ts index 2d88accac..61f562daa 100644 --- a/packages/junior/tests/fixtures/sql.ts +++ b/packages/junior/tests/fixtures/sql.ts @@ -5,7 +5,6 @@ import { createLocalPgliteFixture, type LocalPgliteFixture, } from "@sentry/junior-testing/pglite"; -import { migrate } from "drizzle-orm/pglite/migrator"; import type { PgliteDatabase } from "drizzle-orm/pglite"; import { createEmptyJuniorSqlFixture, @@ -43,7 +42,6 @@ export async function createLocalJuniorSqlFixture(): Promise fixture.close(), db: () => fixture.db() as unknown as JuniorDatabase, execute: (statement, params) => fixture.execute(statement, params), - migrate: (config) => migrate(fixture.db(), config), query: (statement: string, params?: readonly unknown[]) => fixture.query(statement, params), transaction: (callback) => fixture.transaction(callback), diff --git a/packages/junior/tests/integration/conversation-sql.test.ts b/packages/junior/tests/integration/conversation-sql.test.ts index 482ed0251..1882582ca 100644 --- a/packages/junior/tests/integration/conversation-sql.test.ts +++ b/packages/junior/tests/integration/conversation-sql.test.ts @@ -399,7 +399,7 @@ WHERE table_schema = 'public' ) ORDER BY column_name `); - expect(migrationRows?.count).toBe(3); + expect(migrationRows?.count).toBe(4); expect(searchIndex?.exists).toBe(true); expect(metricColumns.map((row) => row.column_name)).toEqual([ "duration_ms", diff --git a/packages/junior/tests/unit/sql/executor.test.ts b/packages/junior/tests/unit/sql/executor.test.ts index 4950889e4..329ca8e0f 100644 --- a/packages/junior/tests/unit/sql/executor.test.ts +++ b/packages/junior/tests/unit/sql/executor.test.ts @@ -13,7 +13,6 @@ function executor(name: string): JuniorSqlExecutor { throw new Error(`${name} test executor does not expose Drizzle`); }), execute: vi.fn(), - migrate: vi.fn(), query: vi.fn(), transaction: vi.fn(async (callback) => await callback()), withLock: vi.fn(async (_lockName, callback) => await callback()), diff --git a/packages/junior/tsup.config.ts b/packages/junior/tsup.config.ts index a1791cd3b..13863ad31 100644 --- a/packages/junior/tsup.config.ts +++ b/packages/junior/tsup.config.ts @@ -35,6 +35,7 @@ export default defineConfig({ "@chat-adapter/state-redis", "@earendil-works/pi-agent-core", "@earendil-works/pi-ai", + "@sentry/junior-migrations", "@sinclair/typebox", "@slack/web-api", "@vercel/functions", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5628a4e19..2621bc436 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -169,6 +169,9 @@ importers: '@neondatabase/serverless': specifier: ^1.1.0 version: 1.1.0 + '@sentry/junior-migrations': + specifier: workspace:* + version: link:../junior-migrations '@sentry/junior-plugin-api': specifier: workspace:* version: link:../junior-plugin-api @@ -466,6 +469,9 @@ importers: specifier: 'catalog:' version: 4.4.3 devDependencies: + '@sentry/junior-migrations': + specifier: workspace:* + version: link:../junior-migrations '@sentry/junior-testing': specifier: workspace:* version: file:packages/junior-testing @@ -488,6 +494,30 @@ importers: specifier: ^4.1.7 version: 4.1.7(@types/node@25.9.1)(tsx@4.22.3) + packages/junior-migrations: + devDependencies: + '@types/node': + specifier: ^25.9.1 + version: 25.9.1 + drizzle-kit: + specifier: 'catalog:' + version: 0.31.10 + drizzle-orm: + specifier: 'catalog:' + version: 0.45.2 + oxlint: + specifier: ^1.66.0 + version: 1.66.0 + tsup: + specifier: ^8.5.1 + version: 8.5.1(tsx@4.22.3)(typescript@6.0.3) + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vitest: + specifier: ^4.1.7 + version: 4.1.7(@types/node@25.9.1)(tsx@4.22.3) + packages/junior-notion: {} packages/junior-plugin-api: @@ -521,6 +551,9 @@ importers: specifier: 'catalog:' version: 4.4.3 devDependencies: + '@sentry/junior-migrations': + specifier: workspace:* + version: link:../junior-migrations '@types/node': specifier: ^25.9.1 version: 25.9.1 @@ -3164,6 +3197,10 @@ packages: '@sentry/junior-memory@file:packages/junior-memory': resolution: {directory: packages/junior-memory, type: directory} + '@sentry/junior-migrations@file:packages/junior-migrations': + resolution: {directory: packages/junior-migrations, type: directory} + hasBin: true + '@sentry/junior-plugin-api@file:packages/junior-plugin-api': resolution: {directory: packages/junior-plugin-api, type: directory} @@ -6879,8 +6916,8 @@ packages: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} - string-width@8.2.1: - resolution: {integrity: sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==} + string-width@8.2.2: + resolution: {integrity: sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==} engines: {node: '>=20'} string_decoder@1.3.0: @@ -10102,6 +10139,8 @@ snapshots: - sql.js - sqlite3 + '@sentry/junior-migrations@file:packages/junior-migrations': {} + '@sentry/junior-plugin-api@file:packages/junior-plugin-api': dependencies: commander: 14.0.3 @@ -10262,6 +10301,7 @@ snapshots: '@logtape/logtape': 2.1.1 '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) '@neondatabase/serverless': 1.1.0 + '@sentry/junior-migrations': file:packages/junior-migrations '@sentry/junior-plugin-api': file:packages/junior-plugin-api '@sentry/node': 10.53.1 '@sinclair/typebox': 0.34.49 @@ -11713,7 +11753,7 @@ snapshots: cli-truncate@5.2.0: dependencies: slice-ansi: 8.0.0 - string-width: 8.2.1 + string-width: 8.2.2 cli-width@4.1.0: {} @@ -14362,6 +14402,12 @@ snapshots: postcss: 8.5.15 tsx: 4.22.3 + postcss-load-config@6.0.1(tsx@4.22.3): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + tsx: 4.22.3 + postcss-nested@6.2.0(postcss@8.5.15): dependencies: postcss: 8.5.15 @@ -15145,7 +15191,7 @@ snapshots: get-east-asian-width: 1.6.0 strip-ansi: 7.2.0 - string-width@8.2.1: + string-width@8.2.2: dependencies: get-east-asian-width: 1.6.0 strip-ansi: 7.2.0 @@ -15412,6 +15458,33 @@ snapshots: - tsx - yaml + tsup@8.5.1(tsx@4.22.3)(typescript@6.0.3): + dependencies: + bundle-require: 5.1.0(esbuild@0.27.7) + cac: 6.7.14 + chokidar: 4.0.3 + consola: 3.4.2 + debug: 4.4.3 + esbuild: 0.27.7 + fix-dts-default-cjs-exports: 1.0.1 + joycon: 3.1.1 + picocolors: 1.1.1 + postcss-load-config: 6.0.1(tsx@4.22.3) + resolve-from: 5.0.0 + rollup: 4.60.4 + source-map: 0.7.6 + sucrase: 3.35.1 + tinyexec: 1.1.2 + tinyglobby: 0.2.16 + tree-kill: 1.2.2 + optionalDependencies: + typescript: 6.0.3 + transitivePeerDependencies: + - jiti + - supports-color + - tsx + - yaml + tsx@4.21.0: dependencies: esbuild: 0.27.7 @@ -15979,7 +16052,7 @@ snapshots: wrap-ansi@10.0.0: dependencies: ansi-styles: 6.2.3 - string-width: 8.2.1 + string-width: 8.2.2 strip-ansi: 7.2.0 wrap-ansi@7.0.0: diff --git a/scripts/bump-release-versions.mjs b/scripts/bump-release-versions.mjs index 07646a8d5..d37091931 100644 --- a/scripts/bump-release-versions.mjs +++ b/scripts/bump-release-versions.mjs @@ -10,6 +10,7 @@ if (!newVersion) { const files = [ "packages/junior/package.json", + "packages/junior-migrations/package.json", "packages/junior-plugin-api/package.json", "packages/junior-agent-browser/package.json", "packages/junior-amplitude/package.json", From efd628c0e0557c11a0649cd227e166345af90d23 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Mon, 13 Jul 2026 16:12:39 -0700 Subject: [PATCH 2/4] fix(migrations): Embed durable migration implementations Remove the migration task registry and keep historical data migration code in each journal entry. This prevents future runtime refactors or deletions from breaking pending upgrades. Expose versioned migration capabilities, move scheduler and core backfills into their migration files, and update migration tests and documentation. Co-Authored-By: GPT-5.6 Codex --- packages/junior-migrations/README.md | 16 +- packages/junior-migrations/src/index.ts | 3 +- packages/junior-migrations/src/journal.ts | 15 +- packages/junior-migrations/src/types.ts | 27 +- .../junior-migrations/tests/journal.test.ts | 2 +- .../junior-migrations/tests/runner.test.ts | 51 +- packages/junior-plugin-api/README.md | 11 +- packages/junior-plugin-api/src/operations.ts | 28 +- .../junior-plugin-api/src/registration.ts | 3 - packages/junior-scheduler/README.md | 4 +- .../migrations/0002_scheduler_state_to_sql.ts | 205 +- packages/junior-scheduler/src/index.ts | 1 - packages/junior-scheduler/src/plugin.ts | 8 - packages/junior-scheduler/src/store.ts | 55 - .../0004_agent_turn_session_actor.ts | 240 +- .../0005_redis_conversation_state.ts | 1873 +++++++- .../migrations/0006_conversations_to_sql.ts | 4011 ++++++++++++++++- .../0007_conversation_history_to_sql.ts | 3842 +++++++++++++++- packages/junior/migrations/README.md | 6 +- .../src/chat/conversations/sql/migrations.ts | 27 +- .../junior/src/chat/plugins/migrations.ts | 26 +- .../migrations/agent-turn-session-actor.ts | 228 - .../migrations/conversations-history-sql.ts | 82 - .../upgrade/migrations/conversations-sql.ts | 101 - .../cli/upgrade/migrations/core-journal.ts | 27 +- .../cli/upgrade/migrations/plugin-journal.ts | 27 +- .../migrations/redis-conversation-state.ts | 777 ---- .../tests/component/cli/upgrade-cli.test.ts | 44 +- .../conversations/legacy-import.test.ts | 2 +- .../component/scheduler-sql-plugin.test.ts | 29 +- 30 files changed, 10295 insertions(+), 1476 deletions(-) delete mode 100644 packages/junior/src/cli/upgrade/migrations/agent-turn-session-actor.ts delete mode 100644 packages/junior/src/cli/upgrade/migrations/conversations-history-sql.ts delete mode 100644 packages/junior/src/cli/upgrade/migrations/conversations-sql.ts delete mode 100644 packages/junior/src/cli/upgrade/migrations/redis-conversation-state.ts diff --git a/packages/junior-migrations/README.md b/packages/junior-migrations/README.md index c78ddc14c..ca4a3e1dd 100644 --- a/packages/junior-migrations/README.md +++ b/packages/junior-migrations/README.md @@ -6,11 +6,9 @@ migrations. Every journal entry resolves to exactly one `.sql` or the Junior migration runner owns execution and exact per-entry tracking. TypeScript migrations target a versioned capability API and must not import -Junior runtime internals. New parsers and transforms belong in the migration -file so application refactors cannot break pending upgrades. A journal can -also invoke a versioned host task when adopting legacy migration code that -already shipped before the mixed journal existed; the task name then becomes -part of the permanent migration ABI. +Junior runtime internals. Every parser, transform, and migration-specific +helper belongs permanently in the migration file so application refactors or +deletions cannot break pending upgrades. ## Authoring @@ -34,6 +32,8 @@ Drizzle Kit remains the supported authoring tool. Drizzle ORM's stock requires every entry to have a SQL file. Call `runMigrationJournal` instead and provide the SQL, state, loader, and locking capabilities owned by the host. -The runner validates that each TypeScript migration has no runtime imports. -Only a type-only import from `@sentry/junior-migrations` is accepted. Add a new -API version rather than changing an existing migration capability contract. +The runner rejects runtime imports of application source, relative modules, +and `@sentry/junior`. External package imports are allowed when the migration +needs a stable library dependency; migration-specific implementation must +still remain in the migration file. Add a new API version rather than changing +an existing migration capability contract. diff --git a/packages/junior-migrations/src/index.ts b/packages/junior-migrations/src/index.ts index 44e810595..0723969b1 100644 --- a/packages/junior-migrations/src/index.ts +++ b/packages/junior-migrations/src/index.ts @@ -7,11 +7,12 @@ export type { MigrationContextV1, MigrationJsonValue, MigrationJournalEntry, + MigrationLockV1, MigrationProgressV1, + MigrationRedisV1, MigrationRunResult, MigrationSqlExecutor, MigrationStateV1, - MigrationTasksV1, MigrationV1, ResolvedMigration, TypeScriptMigrationLoader, diff --git a/packages/junior-migrations/src/journal.ts b/packages/junior-migrations/src/journal.ts index 14bd1dc8b..397326e30 100644 --- a/packages/junior-migrations/src/journal.ts +++ b/packages/junior-migrations/src/journal.ts @@ -57,18 +57,25 @@ function validateTypeScriptSource(tag: string, source: string): void { for (const match of imports) { const clause = match[1]?.trim(); const specifier = match[2]; + if (clause?.startsWith("type ")) { + continue; + } if ( - !clause?.startsWith("type ") || - specifier !== "@sentry/junior-migrations" + !specifier || + specifier.startsWith(".") || + specifier.startsWith("/") || + specifier.startsWith("@/") || + specifier === "@sentry/junior" || + specifier.startsWith("@sentry/junior/") ) { throw new Error( - `TypeScript migration ${tag} may only import migration types`, + `TypeScript migration ${tag} cannot import application runtime code`, ); } } if ( /^\s*import\s*["']/m.test(source) || - /^\s*export\s+.+\s+from\s+["']/m.test(source) || + /^\s*export\s+.+\s+from\s+["'][./]/m.test(source) || /\b(?:import\s*\(|require\s*\()/.test(source) ) { throw new Error(`TypeScript migration ${tag} cannot load runtime modules`); diff --git a/packages/junior-migrations/src/types.ts b/packages/junior-migrations/src/types.ts index 20a45206d..f5f9eb4a4 100644 --- a/packages/junior-migrations/src/types.ts +++ b/packages/junior-migrations/src/types.ts @@ -12,17 +12,33 @@ export interface MigrationSqlExecutor { ): Promise; } +/** Stable state-store capabilities exposed to v1 TypeScript migrations. */ +export interface MigrationLockV1 { + expiresAt: number; + threadId: string; + token: string; +} + /** Stable state-store capabilities exposed to v1 TypeScript migrations. */ export interface MigrationStateV1 { + acquireLock(threadId: string, ttlMs: number): Promise; appendToList( key: string, value: unknown, options?: { maxLength?: number; ttlMs?: number }, ): Promise; + connect(): Promise; delete(key: string): Promise; get(key: string): Promise; getList(key: string): Promise; + releaseLock(lock: MigrationLockV1): Promise; set(key: string, value: unknown, ttlMs?: number): Promise; + setIfNotExists(key: string, value: unknown, ttlMs?: number): Promise; +} + +/** Optional raw Redis capability for migrations preserving Redis indexes. */ +export interface MigrationRedisV1 { + sendCommand(args: readonly string[]): Promise; } /** Resumable progress storage scoped to one TypeScript migration. */ @@ -31,11 +47,6 @@ export interface MigrationProgressV1 { save(value: MigrationJsonValue): Promise; } -/** Versioned host tasks available to migrations that predate the public ABI. */ -export interface MigrationTasksV1 { - run(name: string): Promise; -} - /** JSON-compatible value persisted in migration progress and result columns. */ export type MigrationJsonValue = | boolean @@ -49,9 +60,11 @@ export type MigrationJsonValue = export interface MigrationContextV1 { log(message: string): void; progress: MigrationProgressV1; - sql: Pick; + redis?: MigrationRedisV1; + sql: Pick & { + db(): unknown; + }; state: MigrationStateV1; - tasks: MigrationTasksV1; } /** Isolated TypeScript data migration targeting the v1 ABI. */ diff --git a/packages/junior-migrations/tests/journal.test.ts b/packages/junior-migrations/tests/journal.test.ts index 005586efd..56100fb34 100644 --- a/packages/junior-migrations/tests/journal.test.ts +++ b/packages/junior-migrations/tests/journal.test.ts @@ -58,7 +58,7 @@ describe("resolveMigrations", () => { ); await expect(resolveMigrations(folder)).rejects.toThrow( - "may only import migration types", + "cannot import application runtime code", ); }); diff --git a/packages/junior-migrations/tests/runner.test.ts b/packages/junior-migrations/tests/runner.test.ts index 0c1c21575..ca3a51415 100644 --- a/packages/junior-migrations/tests/runner.test.ts +++ b/packages/junior-migrations/tests/runner.test.ts @@ -20,6 +20,10 @@ class FakeExecutor implements MigrationSqlExecutor { readonly rows = new Map(); readonly statements: string[] = []; + db(): undefined { + return undefined; + } + async execute(statement: string, parameters: readonly unknown[] = []) { const normalized = statement.trim(); if ( @@ -87,6 +91,20 @@ class FakeExecutor implements MigrationSqlExecutor { } } +function fakeMigrationState(): MigrationContextV1["state"] { + return { + acquireLock: async () => null, + appendToList: async () => {}, + connect: async () => {}, + delete: async () => {}, + get: async () => undefined, + getList: async () => [], + releaseLock: async () => {}, + set: async () => {}, + setIfNotExists: async () => true, + }; +} + const temporaryDirectories: string[] = []; async function mixedFolder(): Promise { @@ -149,16 +167,7 @@ describe("runMigrationJournal", () => { log: () => {}, progress, sql: executor, - state: { - appendToList: async () => {}, - delete: async () => {}, - get: async () => undefined, - getList: async () => [], - set: async () => {}, - }, - tasks: { - run: async () => undefined, - }, + state: fakeMigrationState(), }), }), ).resolves.toEqual({ existing: 0, migrated: 3, scanned: 3, skipped: 0 }); @@ -178,16 +187,7 @@ describe("runMigrationJournal", () => { log: () => {}, progress, sql: executor, - state: { - appendToList: async () => {}, - delete: async () => {}, - get: async () => undefined, - getList: async () => [], - set: async () => {}, - }, - tasks: { - run: async () => undefined, - }, + state: fakeMigrationState(), }), }), ).resolves.toEqual({ existing: 3, migrated: 0, scanned: 3, skipped: 0 }); @@ -237,16 +237,7 @@ describe("runMigrationJournal", () => { log: () => {}, progress, sql: executor, - state: { - appendToList: async () => {}, - delete: async () => {}, - get: async () => undefined, - getList: async () => [], - set: async () => {}, - }, - tasks: { - run: async () => undefined, - }, + state: fakeMigrationState(), }), }; diff --git a/packages/junior-plugin-api/README.md b/packages/junior-plugin-api/README.md index 3eeae30d0..407d7925b 100644 --- a/packages/junior-plugin-api/README.md +++ b/packages/junior-plugin-api/README.md @@ -5,14 +5,13 @@ exported TypeScript types and runtime validators are authoritative. ## Registration -Use `defineJuniorPlugin({ manifest, hooks, tasks, migrationTasks, cli, model })`. +Use `defineJuniorPlugin({ manifest, hooks, tasks, cli, model })`. A plugin name is a lowercase identifier and is unique within the enabled app plugin set. A plugin may instead be a declarative `plugin.yaml` package when it has no -host-executed hooks, tasks, migration tasks, CLI, or model implementation. Do -not combine an inline manifest with a second YAML definition for the same -plugin. +host-executed hooks, tasks, CLI, or model implementation. Do not combine an +inline manifest with a second YAML definition for the same plugin. ## Manifest @@ -60,8 +59,8 @@ reports, and other typed hook surfaces exported by this package. - Packaged migrations create plugin-owned tables through the host migration runner. -- TypeScript journal entries may invoke explicitly versioned `migrationTasks`; - tasks run only when their owning journal entry is pending. +- TypeScript journal entries contain their complete durable implementation and + execute through the versioned migration capability API. - Generate migration artifacts from the package schema; do not hand-maintain a second schema contract. - Runtime hooks and CLI actions use host-provided `ctx.db`. diff --git a/packages/junior-plugin-api/src/operations.ts b/packages/junior-plugin-api/src/operations.ts index 631c43acb..429529afd 100644 --- a/packages/junior-plugin-api/src/operations.ts +++ b/packages/junior-plugin-api/src/operations.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import type { PluginContext, PluginLogger } from "./context"; +import type { PluginContext } from "./context"; import type { Dispatch, DispatchOptions, DispatchResult } from "./dispatch"; import { nonBlankStringSchema } from "./schemas"; import type { PluginReadState, PluginState } from "./state"; @@ -47,32 +47,6 @@ export interface HeartbeatResult { dispatchCount?: number; } -/** Per-record outcome reported by one versioned plugin migration task. */ -export type PluginMigrationResult = { - existing: number; - migrated: number; - missing: number; - scanned: number; - skipped?: number; -}; - -/** Stable host capabilities available to a plugin migration task. */ -export interface PluginMigrationContext { - db: unknown; - log: PluginLogger; - state: PluginState; -} - -/** Idempotent task invoked only by its owning pending journal entry. */ -export interface PluginMigrationTask { - ( - context: PluginMigrationContext, - ): - | Promise - | PluginMigrationResult - | undefined; -} - export type PluginOperationalTone = "danger" | "good" | "neutral" | "warning"; export interface PluginOperationalMetric { diff --git a/packages/junior-plugin-api/src/registration.ts b/packages/junior-plugin-api/src/registration.ts index 17c2b282e..1800108ef 100644 --- a/packages/junior-plugin-api/src/registration.ts +++ b/packages/junior-plugin-api/src/registration.ts @@ -1,7 +1,6 @@ import type { PluginCliDefinition } from "./cli"; import type { PluginHooks } from "./hooks"; import type { PluginManifest } from "./manifest"; -import type { PluginMigrationTask } from "./operations"; import type { PluginTasks } from "./tasks"; export interface PluginModelConfig { @@ -15,8 +14,6 @@ export type PluginRegistrationInput = { cli?: PluginCliDefinition; hooks?: PluginHooks; manifest: PluginManifest; - /** Permanently versioned data tasks referenced by this plugin's migration journal. */ - migrationTasks?: Record; model?: PluginModelConfig; packageName?: string; tasks?: PluginTasks; diff --git a/packages/junior-scheduler/README.md b/packages/junior-scheduler/README.md index 974e8a8b6..4681e43f4 100644 --- a/packages/junior-scheduler/README.md +++ b/packages/junior-scheduler/README.md @@ -48,8 +48,8 @@ operational reporting. Generate schema changes with `pnpm --filter @sentry/junior-scheduler db:generate`. Use `pnpm --filter @sentry/junior-scheduler db:generate:data --name ` for a TypeScript data migration in the same journal. New migrations should use -the stable migration capabilities directly; the existing scheduler backfill -uses a versioned task to preserve its pre-journal implementation. +the stable migration capabilities directly and keep their complete +implementation in the migration file. Follow `../../policies/serverless-background-work.md`, `../../policies/context-bound-systems.md`, and diff --git a/packages/junior-scheduler/migrations/0002_scheduler_state_to_sql.ts b/packages/junior-scheduler/migrations/0002_scheduler_state_to_sql.ts index c4affbb22..9502a6422 100644 --- a/packages/junior-scheduler/migrations/0002_scheduler_state_to_sql.ts +++ b/packages/junior-scheduler/migrations/0002_scheduler_state_to_sql.ts @@ -1,9 +1,210 @@ -import type { MigrationV1 } from "@sentry/junior-migrations"; +import type { + MigrationJsonValue, + MigrationV1, +} from "@sentry/junior-migrations"; + +const TASK_INDEX_KEY = "junior:scheduler:tasks"; +const TASK_KEY_PREFIX = "junior:scheduler:task:"; +const ACTIVE_RUN_KEY_PREFIX = "junior:scheduler:active:"; +const RUN_KEY_PREFIX = "junior:scheduler:run:"; +const TASK_STATUSES = new Set(["active", "paused", "blocked", "deleted"]); +const RUN_STATUSES = new Set([ + "pending", + "running", + "completed", + "failed", + "blocked", + "skipped", +]); + +type JsonRecord = Record; + +function record(value: unknown): JsonRecord | undefined { + if (typeof value === "string") { + try { + return record(JSON.parse(value)); + } catch { + return undefined; + } + } + return value && typeof value === "object" && !Array.isArray(value) + ? (value as JsonRecord) + : undefined; +} + +function strings(value: unknown): string[] { + return Array.isArray(value) + ? [ + ...new Set( + value.filter((item): item is string => typeof item === "string"), + ), + ] + : []; +} + +function finiteNumber( + value: MigrationJsonValue | undefined, +): number | undefined { + return typeof value === "number" && Number.isFinite(value) + ? value + : undefined; +} + +function taskRecord(value: unknown): JsonRecord | undefined { + const task = record(value); + const destination = record(task?.destination); + const spec = record(task?.task); + const id = task?.id; + const status = task?.status; + const createdAtMs = finiteNumber(task?.createdAtMs); + if ( + !task || + typeof id !== "string" || + typeof destination?.teamId !== "string" || + typeof spec?.text !== "string" || + typeof status !== "string" || + !TASK_STATUSES.has(status) || + createdAtMs === undefined + ) { + return undefined; + } + const { + credentialSubject: _credentialSubject, + version: _version, + ...current + } = task; + return { + ...current, + credentialMode: current.credentialMode === "creator" ? "creator" : "system", + }; +} + +function runRecord(value: unknown): JsonRecord | undefined { + const run = record(value); + const id = run?.id; + const taskId = run?.taskId; + const status = run?.status; + const scheduledForMs = finiteNumber(run?.scheduledForMs); + if ( + !run || + typeof id !== "string" || + typeof taskId !== "string" || + typeof status !== "string" || + !RUN_STATUSES.has(status) || + scheduledForMs === undefined + ) { + return undefined; + } + const { + idempotencyKey: _idempotencyKey, + taskVersion: _taskVersion, + ...current + } = run; + return current; +} const migration = { apiVersion: 1, async up(context) { - return await context.tasks.run("scheduler-state-to-sql-v1"); + const ids = strings(await context.state.get(TASK_INDEX_KEY)); + let existing = 0; + let migrated = 0; + let missing = 0; + const tasks: JsonRecord[] = []; + + for (const id of ids) { + const task = taskRecord( + await context.state.get(`${TASK_KEY_PREFIX}${id}`), + ); + if (!task) { + missing += 1; + continue; + } + tasks.push(task); + const [stored] = await context.sql.query<{ id: string }>( + "SELECT id FROM junior_scheduler_tasks WHERE id = $1 LIMIT 1", + [id], + ); + if (stored) { + existing += 1; + continue; + } + const destination = record(task.destination)!; + await context.sql.execute( + `INSERT INTO junior_scheduler_tasks + (id, team_id, status, next_run_at_ms, run_now_at_ms, created_at_ms, record) + VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb) + ON CONFLICT (id) DO UPDATE SET + team_id = excluded.team_id, + status = excluded.status, + next_run_at_ms = excluded.next_run_at_ms, + run_now_at_ms = excluded.run_now_at_ms, + created_at_ms = excluded.created_at_ms, + record = excluded.record`, + [ + id, + destination.teamId, + task.status, + finiteNumber(task.nextRunAtMs) ?? null, + finiteNumber(task.runNowAtMs) ?? null, + finiteNumber(task.createdAtMs), + JSON.stringify(task), + ], + ); + migrated += 1; + } + + const runs: JsonRecord[] = []; + for (const task of tasks) { + const active = record( + await context.state.get(`${ACTIVE_RUN_KEY_PREFIX}${task.id}`), + ); + if (typeof active?.runId !== "string") { + continue; + } + const run = runRecord( + await context.state.get(`${RUN_KEY_PREFIX}${active.runId}`), + ); + if (run && (run.status === "pending" || run.status === "running")) { + runs.push(run); + } + } + + for (const run of runs) { + const [stored] = await context.sql.query<{ id: string }>( + "SELECT id FROM junior_scheduler_runs WHERE id = $1 LIMIT 1", + [run.id], + ); + if (stored) { + existing += 1; + continue; + } + await context.sql.execute( + `INSERT INTO junior_scheduler_runs + (id, task_id, status, scheduled_for_ms, record) + VALUES ($1, $2, $3, $4, $5::jsonb) + ON CONFLICT (id) DO UPDATE SET + task_id = excluded.task_id, + status = excluded.status, + scheduled_for_ms = excluded.scheduled_for_ms, + record = excluded.record`, + [ + run.id, + run.taskId, + run.status, + finiteNumber(run.scheduledForMs), + JSON.stringify(run), + ], + ); + migrated += 1; + } + + return { + existing, + migrated, + missing, + scanned: ids.length + runs.length, + }; }, } satisfies MigrationV1; diff --git a/packages/junior-scheduler/src/index.ts b/packages/junior-scheduler/src/index.ts index eec29d6ac..a145e9d74 100644 --- a/packages/junior-scheduler/src/index.ts +++ b/packages/junior-scheduler/src/index.ts @@ -10,7 +10,6 @@ export { export { createSchedulerOperationalSqlStore, createSchedulerSqlStore, - migrateSchedulerStateToSql, type SchedulerDb, } from "./store"; export type { diff --git a/packages/junior-scheduler/src/plugin.ts b/packages/junior-scheduler/src/plugin.ts index f5caec1eb..6f8d9ab95 100644 --- a/packages/junior-scheduler/src/plugin.ts +++ b/packages/junior-scheduler/src/plugin.ts @@ -13,7 +13,6 @@ import { import { createSchedulerOperationalSqlStore, createSchedulerSqlStore, - migrateSchedulerStateToSql, type SchedulerDb, type SchedulerOperationalStore, type SchedulerStore, @@ -579,13 +578,6 @@ export function createSchedulerPlugin() { }); }, }, - migrationTasks: { - "scheduler-state-to-sql-v1": async (ctx) => - await migrateSchedulerStateToSql({ - db: ctx.db as SchedulerDb, - state: ctx.state, - }), - }, }); } diff --git a/packages/junior-scheduler/src/store.ts b/packages/junior-scheduler/src/store.ts index 7c0978be2..da310cf49 100644 --- a/packages/junior-scheduler/src/store.ts +++ b/packages/junior-scheduler/src/store.ts @@ -1692,58 +1692,3 @@ export function createSchedulerOperationalSqlStore( ): SchedulerOperationalStore { return new SqlSchedulerStore(db); } - -/** Copy retained scheduler plugin-state records into the scheduler SQL tables. */ -export async function migrateSchedulerStateToSql(args: { - db: SchedulerDb; - state: PluginState; -}): Promise<{ - existing: number; - migrated: number; - missing: number; - scanned: number; -}> { - const store = createSchedulerSqlStore(args.db); - const ids = await getIndex(args.state, globalTaskIndexKey()); - let existing = 0; - let migrated = 0; - let missing = 0; - const migratedTasks: ScheduledTask[] = []; - - for (const id of ids) { - const rawTask = await args.state.get(taskKey(id)); - const task = - parseStoredTask(rawTask) ?? parseLegacyStoredTaskForMigration(rawTask); - if (!task) { - missing += 1; - continue; - } - migratedTasks.push(task); - if (await store.getTask(task.id)) { - existing += 1; - continue; - } - await store.saveTask(task); - migrated += 1; - } - - const runs = await listIncompleteRunsForTasksFromState( - args.state, - migratedTasks, - ); - for (const run of runs) { - if (await store.getRun(run.id)) { - existing += 1; - continue; - } - await upsertSqlRun(args.db, run); - migrated += 1; - } - - return { - existing, - migrated, - missing, - scanned: ids.length + runs.length, - }; -} diff --git a/packages/junior/migrations/0004_agent_turn_session_actor.ts b/packages/junior/migrations/0004_agent_turn_session_actor.ts index 0a9dced8f..1ca8089dc 100644 --- a/packages/junior/migrations/0004_agent_turn_session_actor.ts +++ b/packages/junior/migrations/0004_agent_turn_session_actor.ts @@ -1,10 +1,238 @@ -import type { MigrationV1 } from "@sentry/junior-migrations"; +// @ts-nocheck -- frozen migration capsule; do not refactor against current Junior internals. +/* eslint-disable no-unused-vars */ -const migration = { +// packages/junior/src/cli/upgrade/migrations/agent-turn-session-actor.ts +import { THREAD_STATE_TTL_MS } from "chat"; + +// packages/junior/src/chat/coerce.ts +function toOptionalString(value) { + return typeof value === "string" && value.trim() ? value : void 0; +} +function isRecord(value) { + return typeof value === "object" && value !== null; +} + +// migration:config +function getChatConfig() { + const databaseUrl = process.env.DATABASE_URL; + if (!databaseUrl) throw new Error("DATABASE_URL is required"); + const configured = process.env.JUNIOR_DATABASE_DRIVER; + const url = new URL(databaseUrl); + const driver = + configured === "postgres" || configured === "neon" + ? configured + : url.hostname === "localhost" || url.hostname === "127.0.0.1" + ? "postgres" + : "neon"; + return { + bot: { modelContextWindowTokens: void 0 }, + sql: { databaseUrl, driver }, + state: { + keyPrefix: process.env.JUNIOR_STATE_KEY_PREFIX?.trim() || void 0, + adapter: process.env.REDIS_URL ? "redis" : "memory", + redisUrl: process.env.REDIS_URL, + }, + }; +} + +// packages/junior/src/cli/upgrade/migrations/agent-turn-session-actor.ts +var AGENT_TURN_SESSION_PREFIX = "junior:agent_turn_session"; +var AGENT_TURN_SESSION_INDEX_KEY = `${AGENT_TURN_SESSION_PREFIX}:index`; +var AGENT_TURN_SESSION_INDEX_MAX_LENGTH = 5e3; +var REDIS_SCAN_COUNT = 500; +function conversationIndexKey(conversationId) { + return `${AGENT_TURN_SESSION_PREFIX}:conversation:${conversationId}:index`; +} +function sessionRecordKey(conversationId, sessionId) { + return `${AGENT_TURN_SESSION_PREFIX}:${conversationId}:${sessionId}`; +} +function logicalConversationIndexPrefix() { + const prefix = getChatConfig().state.keyPrefix; + return [ + ...(prefix ? [prefix] : []), + `${AGENT_TURN_SESSION_PREFIX}:conversation:`, + ].join(":"); +} +function conversationIdFromRedisListKey(key) { + const marker = `:list:${logicalConversationIndexPrefix()}`; + const markerIndex = key.indexOf(marker); + if (markerIndex < 0 || !key.endsWith(":index")) { + return void 0; + } + return toOptionalString( + key.slice(markerIndex + marker.length, -":index".length), + ); +} +async function discoverRedisConversationIds(redisStateAdapter) { + const client = redisStateAdapter?.getClient(); + if (!client) { + return []; + } + const conversationIds = /* @__PURE__ */ new Set(); + const match = `*:list:${logicalConversationIndexPrefix()}*:index`; + let cursor = "0"; + do { + const reply = await client.sendCommand([ + "SCAN", + cursor, + "MATCH", + match, + "COUNT", + String(REDIS_SCAN_COUNT), + ]); + if ( + !Array.isArray(reply) || + reply.length !== 2 || + (typeof reply[0] !== "string" && typeof reply[0] !== "number") || + !Array.isArray(reply[1]) + ) { + throw new Error( + "Unexpected Redis SCAN response while migrating turn sessions", + ); + } + cursor = String(reply[0]); + for (const key of reply[1]) { + if (typeof key !== "string") { + continue; + } + const conversationId = conversationIdFromRedisListKey(key); + if (conversationId) { + conversationIds.add(conversationId); + } + } + } while (cursor !== "0"); + return [...conversationIds]; +} +function migrateRequesterToActor(value) { + if (!isRecord(value) || value.requester === void 0) { + return { changed: false, value }; + } + const { requester, ...record } = value; + return { + changed: true, + value: { + ...record, + ...(record.actor === void 0 ? { actor: requester } : {}), + }, + }; +} +async function rewriteList(args) { + const values = await args.stateAdapter.getList(args.key); + const migrated = values.map(migrateRequesterToActor); + const changed = migrated.filter((entry) => entry.changed).length; + if (changed === 0) { + return { migrated: 0, values }; + } + await args.stateAdapter.delete(args.key); + for (const entry of migrated) { + await args.stateAdapter.appendToList(args.key, entry.value, { + ...(args.maxLength !== void 0 ? { maxLength: args.maxLength } : {}), + ttlMs: THREAD_STATE_TTL_MS, + }); + } + return { migrated: changed, values: migrated.map((entry) => entry.value) }; +} +async function migrateSessionRecord(args) { + const key = sessionRecordKey(args.conversationId, args.sessionId); + const existing = await args.stateAdapter.get(key); + if (existing === void 0) { + return "missing"; + } + const migrated = migrateRequesterToActor(existing); + if (!migrated.changed) { + return "existing"; + } + await args.stateAdapter.set(key, migrated.value, THREAD_STATE_TTL_MS); + return "migrated"; +} +async function migrateAgentTurnSessionActor(context) { + const result = { + existing: 0, + migrated: 0, + missing: 0, + scanned: 0, + }; + const global = await rewriteList({ + key: AGENT_TURN_SESSION_INDEX_KEY, + maxLength: AGENT_TURN_SESSION_INDEX_MAX_LENGTH, + stateAdapter: context.stateAdapter, + }); + result.scanned += global.values.length; + result.migrated += global.migrated; + const conversations = /* @__PURE__ */ new Set(); + const sessions = /* @__PURE__ */ new Set(); + for (const conversationId of await discoverRedisConversationIds( + context.redisStateAdapter, + )) { + conversations.add(conversationId); + } + for (const value of global.values) { + if (!isRecord(value)) { + continue; + } + const conversationId = toOptionalString(value.conversationId); + const sessionId = toOptionalString(value.sessionId); + if (!conversationId) { + continue; + } + conversations.add(conversationId); + if (sessionId) { + sessions.add(`${conversationId}\0${sessionId}`); + } + } + for (const conversationId of conversations) { + const conversation = await rewriteList({ + key: conversationIndexKey(conversationId), + stateAdapter: context.stateAdapter, + }); + result.scanned += conversation.values.length; + result.migrated += conversation.migrated; + for (const value of conversation.values) { + if (!isRecord(value)) { + continue; + } + const sessionId = toOptionalString(value.sessionId); + if (sessionId) { + sessions.add(`${conversationId}\0${sessionId}`); + } + } + } + for (const session of sessions) { + const separator = session.indexOf("\0"); + const conversationId = session.slice(0, separator); + const sessionId = session.slice(separator + 1); + result.scanned += 1; + const status = await migrateSessionRecord({ + conversationId, + sessionId, + stateAdapter: context.stateAdapter, + }); + if (status === "migrated") { + result.migrated += 1; + } else if (status === "existing") { + result.existing += 1; + } else { + result.missing += 1; + } + } + return result; +} + +// ../../../../../../private/tmp/0004_agent_turn_session_actor-entry.ts +var migration = { apiVersion: 1, async up(context) { - return await context.tasks.run("agent-turn-session-actor-v1"); + return await migrateAgentTurnSessionActor({ + stateAdapter: context.state, + redisStateAdapter: context.redis + ? { getClient: () => ({ sendCommand: context.redis.sendCommand }) } + : void 0, + io: { info: context.log }, + }); }, -} satisfies MigrationV1; - -export default migration; +}; +var agent_turn_session_actor_entry_default = migration; +export { + agent_turn_session_actor_entry_default as default, + migrateAgentTurnSessionActor, +}; diff --git a/packages/junior/migrations/0005_redis_conversation_state.ts b/packages/junior/migrations/0005_redis_conversation_state.ts index fb83b28a3..08e635414 100644 --- a/packages/junior/migrations/0005_redis_conversation_state.ts +++ b/packages/junior/migrations/0005_redis_conversation_state.ts @@ -1,10 +1,1871 @@ -import type { MigrationV1 } from "@sentry/junior-migrations"; +// @ts-nocheck -- frozen migration capsule; do not refactor against current Junior internals. +/* eslint-disable no-unused-vars */ -const migration = { +// packages/junior/src/chat/coerce.ts +function toOptionalString(value) { + return typeof value === "string" && value.trim() ? value : void 0; +} +function toOptionalNumber(value) { + return typeof value === "number" && Number.isFinite(value) ? value : void 0; +} +function isRecord(value) { + return typeof value === "object" && value !== null; +} + +// migration:config +function getChatConfig() { + const databaseUrl = process.env.DATABASE_URL; + if (!databaseUrl) throw new Error("DATABASE_URL is required"); + const configured = process.env.JUNIOR_DATABASE_DRIVER; + const url = new URL(databaseUrl); + const driver = + configured === "postgres" || configured === "neon" + ? configured + : url.hostname === "localhost" || url.hostname === "127.0.0.1" + ? "postgres" + : "neon"; + return { + bot: { modelContextWindowTokens: void 0 }, + sql: { databaseUrl, driver }, + state: { + keyPrefix: process.env.JUNIOR_STATE_KEY_PREFIX?.trim() || void 0, + adapter: process.env.REDIS_URL ? "redis" : "memory", + redisUrl: process.env.REDIS_URL, + }, + }; +} + +// packages/junior/src/chat/destination.ts +import { destinationSchema } from "@sentry/junior-plugin-api"; + +// packages/junior/src/chat/slack/ids.ts +import { z as z2 } from "zod"; + +// packages/junior/src/chat/slack/timestamp.ts +import { z } from "zod"; +var slackMessageTsSchema = z + .string() + .trim() + .regex(/^\d+(?:\.\d+)?$/) + .brand(); + +// packages/junior/src/chat/slack/ids.ts +var slackChannelIdSchema = z2 + .string() + .regex(/^[CDG][A-Z0-9]+$/) + .brand(); +var slackTeamIdSchema = z2 + .string() + .regex(/^T[A-Z0-9]+$/) + .brand(); +var slackUserIdSchema = z2 + .string() + .regex(/^[UW][A-Z0-9]+$/) + .brand(); +function parseSlackTeamId(value) { + if (typeof value !== "string") return void 0; + const parsed = slackTeamIdSchema.safeParse(value.trim()); + return parsed.success ? parsed.data : void 0; +} + +// packages/junior/src/chat/destination.ts +function parseDestination(value) { + const parsed = destinationSchema.safeParse(value); + return parsed.success ? parsed.data : void 0; +} +function sameDestination(left, right) { + if (left.platform !== right.platform) { + return false; + } + if (left.platform === "local" && right.platform === "local") { + return left.conversationId === right.conversationId; + } + if (left.platform === "slack" && right.platform === "slack") { + return left.teamId === right.teamId && left.channelId === right.channelId; + } + return false; +} + +// packages/junior/src/chat/state/conversation.ts +function defaultConversationState() { + const nowMs = Date.now(); + return { + schemaVersion: 1, + messages: [], + compactions: [], + backfill: {}, + processing: {}, + stats: { + estimatedContextTokens: 0, + totalMessageCount: 0, + compactedMessageCount: 0, + updatedAtMs: nowMs, + }, + vision: { + byFileId: {}, + }, + }; +} +function coercePendingAuthState(value) { + if (!isRecord(value)) { + return void 0; + } + const kind = value.kind; + const provider = toOptionalString(value.provider); + const actorId = toOptionalString(value.actorId); + const authSessionId = toOptionalString(value.authSessionId); + const scope = toOptionalString(value.scope); + const sessionId = toOptionalString(value.sessionId); + const linkSentAtMs = toOptionalNumber(value.linkSentAtMs); + if ( + (kind !== "mcp" && kind !== "plugin") || + !provider || + !actorId || + (kind === "mcp" && !authSessionId) || + !sessionId || + typeof linkSentAtMs !== "number" + ) { + return void 0; + } + const base = { + provider, + actorId, + ...(scope ? { scope } : {}), + sessionId, + linkSentAtMs, + }; + return kind === "mcp" ? { ...base, authSessionId, kind } : { ...base, kind }; +} +function coerceThreadConversationState(value) { + if (!isRecord(value)) { + return defaultConversationState(); + } + const root = value; + const rawConversation = isRecord(root.conversation) ? root.conversation : {}; + const base = defaultConversationState(); + const messages = []; + const rawCompactions = Array.isArray(rawConversation.compactions) + ? rawConversation.compactions + : []; + const compactions = []; + for (const item of rawCompactions) { + if (!isRecord(item)) continue; + const id = toOptionalString(item.id); + const summary = toOptionalString(item.summary); + const createdAtMs = toOptionalNumber(item.createdAtMs); + if (!id || !summary || !createdAtMs) continue; + const coveredMessageIds = Array.isArray(item.coveredMessageIds) + ? item.coveredMessageIds.filter( + (entry) => typeof entry === "string" && entry.length > 0, + ) + : []; + compactions.push({ + id, + summary, + createdAtMs, + coveredMessageIds, + }); + } + const rawBackfill = isRecord(rawConversation.backfill) + ? rawConversation.backfill + : {}; + const backfill = { + completedAtMs: toOptionalNumber(rawBackfill.completedAtMs), + source: + rawBackfill.source === "recent_messages" || + rawBackfill.source === "thread_fetch" + ? rawBackfill.source + : void 0, + }; + const rawProcessing = isRecord(rawConversation.processing) + ? rawConversation.processing + : {}; + const processing = { + activeTurnId: toOptionalString(rawProcessing.activeTurnId), + lastCompletedAtMs: toOptionalNumber(rawProcessing.lastCompletedAtMs), + pendingAuth: coercePendingAuthState(rawProcessing.pendingAuth), + }; + const rawStats = isRecord(rawConversation.stats) ? rawConversation.stats : {}; + const stats = { + estimatedContextTokens: + toOptionalNumber(rawStats.estimatedContextTokens) ?? + base.stats.estimatedContextTokens, + totalMessageCount: + toOptionalNumber(rawStats.totalMessageCount) ?? messages.length, + compactedMessageCount: + toOptionalNumber(rawStats.compactedMessageCount) ?? 0, + updatedAtMs: + toOptionalNumber(rawStats.updatedAtMs) ?? base.stats.updatedAtMs, + }; + const rawVision = isRecord(rawConversation.vision) + ? rawConversation.vision + : {}; + const rawVisionByFileId = isRecord(rawVision.byFileId) + ? rawVision.byFileId + : {}; + const byFileId = {}; + for (const [fileId, value2] of Object.entries(rawVisionByFileId)) { + if (typeof fileId !== "string" || fileId.trim().length === 0) continue; + if (!isRecord(value2)) continue; + const summary = toOptionalString(value2.summary); + const analyzedAtMs = toOptionalNumber(value2.analyzedAtMs); + if (!summary || !analyzedAtMs) continue; + byFileId[fileId] = { + summary, + analyzedAtMs, + }; + } + return { + schemaVersion: 1, + messages, + compactions, + backfill, + processing, + stats, + vision: { + backfillCompletedAtMs: toOptionalNumber(rawVision.backfillCompletedAtMs), + byFileId, + }, + }; +} + +// packages/junior/src/chat/state/ttl.ts +var JUNIOR_THREAD_STATE_TTL_MS = 7 * 24 * 60 * 60 * 1e3; + +// packages/junior/src/chat/actor.ts +import { z as z3 } from "zod"; +import { actorSchema } from "@sentry/junior-plugin-api"; +var exactStoredStringSchema = z3 + .string() + .min(1) + .refine((value) => value === value.trim()); +var storedSlackActorSchema = z3 + .object({ + email: exactStoredStringSchema.optional(), + fullName: exactStoredStringSchema.optional(), + platform: z3.literal("slack").optional(), + slackUserId: exactStoredStringSchema.optional(), + slackUserName: exactStoredStringSchema.optional(), + teamId: exactStoredStringSchema.optional(), + }) + .strict(); +function isSyntheticActorUserId(value) { + return value.toLowerCase() === "unknown"; +} +function parseActorUserId(value) { + if (typeof value !== "string" || value.length === 0) { + return void 0; + } + if (value !== value.trim() || isSyntheticActorUserId(value)) { + return void 0; + } + return value; +} +function parseStoredSlackActor(value) { + const parsed = storedSlackActorSchema.safeParse(value); + if (!parsed.success) { + return void 0; + } + if ( + parsed.data.slackUserId !== void 0 && + !parseActorUserId(parsed.data.slackUserId) + ) { + return void 0; + } + if (parsed.data.teamId !== void 0 && !parseSlackTeamId(parsed.data.teamId)) { + return void 0; + } + if ( + (parsed.data.platform !== void 0 || parsed.data.teamId !== void 0) && + (!parsed.data.platform || !parsed.data.teamId) + ) { + return void 0; + } + return parsed.data; +} + +// packages/junior/src/chat/state/adapter.ts +import { createMemoryState } from "@chat-adapter/state-memory"; +import { createRedisState } from "@chat-adapter/state-redis"; + +// packages/junior/src/chat/state/locks.ts +var ACTIVE_LOCK_TTL_MS = 9e4; + +// packages/junior/src/chat/state/adapter.ts +var ACTIVE_LOCK_HEARTBEAT_MS = 3e4; +var stateAdapter; +var redisStateAdapter; +function createPrefixedStateAdapter(base, prefix) { + const prefixed = (value) => `${prefix}:${value}`; + const unprefixed = (value) => + value.startsWith(`${prefix}:`) ? value.slice(prefix.length + 1) : value; + const prefixLock = (lock) => ({ + ...lock, + threadId: prefixed(lock.threadId), + }); + const unprefixLock = (lock) => ({ + ...lock, + threadId: unprefixed(lock.threadId), + }); + return { + appendToList: (key, value, options) => + base.appendToList(prefixed(key), value, options), + connect: () => base.connect(), + disconnect: () => base.disconnect(), + subscribe: (threadId) => base.subscribe(prefixed(threadId)), + unsubscribe: (threadId) => base.unsubscribe(prefixed(threadId)), + isSubscribed: (threadId) => base.isSubscribed(prefixed(threadId)), + acquireLock: async (threadId, ttlMs) => { + const lock = await base.acquireLock(prefixed(threadId), ttlMs); + return lock ? unprefixLock(lock) : null; + }, + releaseLock: (lock) => base.releaseLock(prefixLock(lock)), + extendLock: async (lock, ttlMs) => { + const prefixedLock = prefixLock(lock); + const extended = await base.extendLock(prefixedLock, ttlMs); + if (extended) { + lock.expiresAt = prefixedLock.expiresAt; + } + return extended; + }, + forceReleaseLock: (threadId) => base.forceReleaseLock(prefixed(threadId)), + enqueue: (threadId, entry, maxSize) => + base.enqueue(prefixed(threadId), entry, maxSize), + dequeue: (threadId) => base.dequeue(prefixed(threadId)), + queueDepth: (threadId) => base.queueDepth(prefixed(threadId)), + get: (key) => base.get(prefixed(key)), + getList: (key) => base.getList(prefixed(key)), + set: (key, value, ttlMs) => base.set(prefixed(key), value, ttlMs), + setIfNotExists: (key, value, ttlMs) => + base.setIfNotExists(prefixed(key), value, ttlMs), + delete: (key) => base.delete(prefixed(key)), + }; +} +function createQueuedStateAdapter(base, options) { + const heartbeats = /* @__PURE__ */ new Map(); + const shouldHeartbeatLock = (ttlMs) => ttlMs === ACTIVE_LOCK_TTL_MS; + const heartbeatKey = (lock) => `${lock.threadId}:${lock.token}`; + const stopHeartbeatByKey = (key) => { + const heartbeat = heartbeats.get(key); + if (!heartbeat) { + return; + } + clearInterval(heartbeat.timer); + heartbeats.delete(key); + }; + const stopHeartbeat = (lock) => { + stopHeartbeatByKey(heartbeatKey(lock)); + }; + const stopHeartbeatsForThread = (threadId) => { + for (const [key, heartbeat] of heartbeats) { + if (heartbeat.lock.threadId === threadId) { + stopHeartbeatByKey(key); + } + } + }; + const stopAllHeartbeats = () => { + for (const key of heartbeats.keys()) { + stopHeartbeatByKey(key); + } + }; + const runHeartbeat = async (key) => { + const heartbeat = heartbeats.get(key); + if (!heartbeat || heartbeat.inFlight) { + return; + } + heartbeat.inFlight = true; + try { + if (Date.now() - heartbeat.startedAtMs >= options.activeLockMaxAgeMs) { + stopHeartbeatByKey(key); + return; + } + const extended = await base.extendLock(heartbeat.lock, heartbeat.ttlMs); + if (!extended) { + stopHeartbeatByKey(key); + return; + } + heartbeat.lock.expiresAt = Date.now() + heartbeat.ttlMs; + } catch { + } finally { + const current = heartbeats.get(key); + if (current === heartbeat) { + current.inFlight = false; + } + } + }; + const startOrUpdateHeartbeat = (lock, ttlMs) => { + const key = heartbeatKey(lock); + const existing = heartbeats.get(key); + if (existing) { + existing.ttlMs = ttlMs; + return; + } + const timer = setInterval(() => { + void runHeartbeat(key); + }, ACTIVE_LOCK_HEARTBEAT_MS); + timer.unref?.(); + heartbeats.set(key, { + inFlight: false, + lock, + startedAtMs: Date.now(), + timer, + ttlMs, + }); + }; + const acquireLock = async (threadId, ttlMs) => { + const lock = await base.acquireLock(threadId, ttlMs); + if (lock && shouldHeartbeatLock(ttlMs)) { + startOrUpdateHeartbeat(lock, ttlMs); + } + return lock; + }; + return { + appendToList: (key, value, options2) => + base.appendToList(key, value, options2), + connect: () => base.connect(), + disconnect: async () => { + stopAllHeartbeats(); + await base.disconnect(); + }, + subscribe: (threadId) => base.subscribe(threadId), + unsubscribe: (threadId) => base.unsubscribe(threadId), + isSubscribed: (threadId) => base.isSubscribed(threadId), + acquireLock, + releaseLock: async (lock) => { + stopHeartbeat(lock); + await base.releaseLock(lock); + }, + extendLock: async (lock, ttlMs) => { + const extended = await base.extendLock(lock, ttlMs); + if (extended) { + lock.expiresAt = Date.now() + ttlMs; + if (shouldHeartbeatLock(ttlMs)) { + startOrUpdateHeartbeat(lock, ttlMs); + } else { + stopHeartbeat(lock); + } + } else { + stopHeartbeat(lock); + } + return extended; + }, + forceReleaseLock: async (threadId) => { + stopHeartbeatsForThread(threadId); + await base.forceReleaseLock(threadId); + }, + enqueue: (threadId, entry, maxSize) => + base.enqueue(threadId, entry, maxSize), + dequeue: (threadId) => base.dequeue(threadId), + queueDepth: (threadId) => base.queueDepth(threadId), + get: (key) => base.get(key), + getList: (key) => base.getList(key), + set: (key, value, ttlMs) => base.set(key, value, ttlMs), + setIfNotExists: (key, value, ttlMs) => + base.setIfNotExists(key, value, ttlMs), + delete: (key) => base.delete(key), + }; +} +function withOptionalPrefix(base, prefix) { + return prefix ? createPrefixedStateAdapter(base, prefix) : base; +} +function createStateAdapter() { + const config = getChatConfig(); + const activeLockMaxAgeMs = config.bot.turnTimeoutMs + ACTIVE_LOCK_TTL_MS; + if (config.state.adapter === "memory") { + redisStateAdapter = void 0; + return createQueuedStateAdapter( + withOptionalPrefix(createMemoryState(), config.state.keyPrefix), + { activeLockMaxAgeMs }, + ); + } + if (!config.state.redisUrl) { + throw new Error("REDIS_URL is required for durable Slack thread state"); + } + const redisState = createRedisState({ + url: config.state.redisUrl, + }); + redisStateAdapter = redisState; + return createQueuedStateAdapter( + withOptionalPrefix(redisState, config.state.keyPrefix), + { activeLockMaxAgeMs }, + ); +} +function getOptionalRedisStateAdapter() { + getStateAdapter(); + return redisStateAdapter; +} +async function getConnectedStateContext() { + const adapter = getStateAdapter(); + await adapter.connect(); + return { + redisStateAdapter: getOptionalRedisStateAdapter(), + stateAdapter: adapter, + }; +} +async function getDefaultRedisStateAdapterFor(adapter) { + if (adapter !== stateAdapter) { + return void 0; + } + const context = await getConnectedStateContext(); + return context.stateAdapter === adapter ? context.redisStateAdapter : void 0; +} +function getStateAdapter() { + if (!stateAdapter) { + stateAdapter = createStateAdapter(); + } + return stateAdapter; +} + +// packages/junior/src/chat/task-execution/state.ts +var CONVERSATION_PREFIX = "junior:conversation"; +var CONVERSATION_SCHEMA_VERSION = 1; +var CONVERSATION_ACTIVITY_INDEX_MAX_LENGTH = 1e4; +var CONVERSATION_INDEX_LOCK_TTL_MS = 1e4; +var CONVERSATION_INDEX_LOCK_WAIT_MS = 2e3; +var CONVERSATION_INDEX_LOCK_RETRY_MS = 25; +var CONVERSATION_MUTATION_LOCK_TTL_MS = 1e4; +var CONVERSATION_MUTATION_WAIT_MS = 1e4; +var CONVERSATION_MUTATION_RETRY_MS = 25; +var InvalidConversationRecordError = class extends Error { + constructor(conversationId) { + super(`Conversation record is invalid for ${conversationId}`); + this.name = "InvalidConversationRecordError"; + } +}; +var ConversationMutationFencedError = class extends Error { + constructor(conversationId) { + super( + `Conversation mutation lock was lost before write for ${conversationId}`, + ); + this.name = "ConversationMutationFencedError"; + } +}; +var CONVERSATION_BY_ACTIVITY_INDEX_KEY = `${CONVERSATION_PREFIX}:by-activity`; +var CONVERSATION_ACTIVE_INDEX_KEY = `${CONVERSATION_PREFIX}:active`; +function conversationKey(conversationId) { + return `${CONVERSATION_PREFIX}:${conversationId}`; +} +function indexLockKey(indexKey) { + return `${indexKey}:lock`; +} +function mutationLockKey(conversationId) { + return `${CONVERSATION_PREFIX}:mutation:${conversationId}`; +} +function now() { + return Date.now(); +} +function compareMessages(left, right) { + return ( + left.createdAtMs - right.createdAtMs || + left.receivedAtMs - right.receivedAtMs || + left.inboundMessageId.localeCompare(right.inboundMessageId) + ); +} +function compareIndexDescending(left, right) { + return ( + right.score - left.score || + right.conversationId.localeCompare(left.conversationId) + ); +} +function compareIndexAscending(left, right) { + return ( + left.score - right.score || + left.conversationId.localeCompare(right.conversationId) + ); +} +function uniqueStrings(values) { + return [...new Set(values)]; +} +function normalizeSource(value) { + if ( + value === "api" || + value === "internal" || + value === "local" || + value === "plugin" || + value === "resource_event" || + value === "scheduler" || + value === "slack" + ) { + return value; + } + return void 0; +} +function normalizeExecutionStatus(value) { + if ( + value === "awaiting_resume" || + value === "failed" || + value === "idle" || + value === "pending" || + value === "running" + ) { + return value; + } + return void 0; +} +function normalizeMetadata(value) { + if (!isRecord(value)) { + return void 0; + } + return value; +} +function normalizeInput(value) { + if (!isRecord(value)) { + return void 0; + } + const text = toOptionalString(value.text); + if (!text) { + return void 0; + } + return { + text, + authorId: toOptionalString(value.authorId), + attachments: Array.isArray(value.attachments) + ? [...value.attachments] + : void 0, + metadata: normalizeMetadata(value.metadata), + }; +} +function normalizeMessage(value) { + if (!isRecord(value)) { + return void 0; + } + const conversationId = toOptionalString(value.conversationId); + const inboundMessageId = toOptionalString(value.inboundMessageId); + const source = normalizeSource(value.source); + const destination = parseDestination(value.destination); + const createdAtMs = toOptionalNumber(value.createdAtMs); + const receivedAtMs = toOptionalNumber(value.receivedAtMs); + const input = normalizeInput(value.input); + if ( + !conversationId || + !destination || + !inboundMessageId || + !source || + typeof createdAtMs !== "number" || + typeof receivedAtMs !== "number" || + !input + ) { + return void 0; + } + return { + conversationId, + destination, + inboundMessageId, + source, + createdAtMs, + receivedAtMs, + input, + injectedAtMs: toOptionalNumber(value.injectedAtMs), + attemptCount: toOptionalNumber(value.attemptCount), + }; +} +function normalizeActor(value) { + return parseStoredSlackActor(value); +} +function normalizeLease(value) { + if (!isRecord(value)) { + return void 0; + } + const token = toOptionalString(value.token); + const acquiredAtMs = toOptionalNumber(value.acquiredAtMs); + const lastCheckInAtMs = toOptionalNumber(value.lastCheckInAtMs); + const expiresAtMs = toOptionalNumber(value.expiresAtMs); + if ( + !token || + typeof acquiredAtMs !== "number" || + typeof lastCheckInAtMs !== "number" || + typeof expiresAtMs !== "number" + ) { + return void 0; + } + return { + token, + acquiredAtMs, + lastCheckInAtMs, + expiresAtMs, + }; +} +function normalizeExecution(conversationId, value) { + if (!isRecord(value)) { + return void 0; + } + const status = normalizeExecutionStatus(value.status); + if (!status) { + return void 0; + } + const pendingMessages2 = Array.isArray(value.pendingMessages) + ? value.pendingMessages + .map(normalizeMessage) + .filter((message) => Boolean(message)) + .filter((message) => message.conversationId === conversationId) + .filter((message) => message.injectedAtMs === void 0) + .sort(compareMessages) + : []; + const inboundMessageIds = Array.isArray(value.inboundMessageIds) + ? uniqueStrings( + value.inboundMessageIds + .map((id) => (typeof id === "string" ? id : void 0)) + .filter((id) => Boolean(id)), + ) + : []; + const lease = normalizeLease(value.lease); + const normalizedStatus = + status === "idle" && lease + ? "running" + : status === "idle" && pendingMessages2.length > 0 + ? "pending" + : status; + return { + status: normalizedStatus, + inboundMessageIds: uniqueStrings([ + ...inboundMessageIds, + ...pendingMessages2.map((message) => message.inboundMessageId), + ]), + pendingCount: pendingMessages2.length, + pendingMessages: pendingMessages2, + lease, + lastCheckpointAtMs: toOptionalNumber(value.lastCheckpointAtMs), + lastEnqueuedAtMs: toOptionalNumber(value.lastEnqueuedAtMs), + runId: toOptionalString(value.runId), + updatedAtMs: toOptionalNumber(value.updatedAtMs), + }; +} +function normalizeConversation(conversationId, value) { + if (!isRecord(value) || value.schemaVersion !== CONVERSATION_SCHEMA_VERSION) { + return void 0; + } + const storedConversationId = toOptionalString(value.conversationId); + const createdAtMs = toOptionalNumber(value.createdAtMs); + const lastActivityAtMs = toOptionalNumber(value.lastActivityAtMs); + const updatedAtMs = toOptionalNumber(value.updatedAtMs); + const execution = normalizeExecution(conversationId, value.execution); + const destination = + value.destination === void 0 ? void 0 : parseDestination(value.destination); + if ( + storedConversationId !== conversationId || + typeof createdAtMs !== "number" || + typeof lastActivityAtMs !== "number" || + typeof updatedAtMs !== "number" || + !execution || + (value.destination !== void 0 && !destination) + ) { + return void 0; + } + if ( + execution.pendingMessages.length > 0 && + (!destination || + execution.pendingMessages.some( + (message) => !sameDestination(message.destination, destination), + )) + ) { + return void 0; + } + return { + schemaVersion: CONVERSATION_SCHEMA_VERSION, + conversationId, + createdAtMs, + lastActivityAtMs, + updatedAtMs, + execution, + ...(destination ? { destination } : {}), + ...(toOptionalString(value.title) + ? { title: toOptionalString(value.title) } + : {}), + ...(toOptionalString(value.channelName) + ? { channelName: toOptionalString(value.channelName) } + : {}), + ...(normalizeActor(value.actor) + ? { actor: normalizeActor(value.actor) } + : {}), + ...(normalizeSource(value.source) + ? { source: normalizeSource(value.source) } + : {}), + }; +} +function emptyConversation(args) { + return { + schemaVersion: CONVERSATION_SCHEMA_VERSION, + conversationId: args.conversationId, + createdAtMs: args.nowMs, + lastActivityAtMs: args.nowMs, + updatedAtMs: args.nowMs, + ...(args.destination ? { destination: args.destination } : {}), + ...(args.source ? { source: args.source } : {}), + execution: { + status: "idle", + inboundMessageIds: [], + pendingCount: 0, + pendingMessages: [], + updatedAtMs: args.nowMs, + }, + }; +} +function pendingMessages(conversation) { + return [...conversation.execution.pendingMessages].sort(compareMessages); +} +function isRunnableStatus(status) { + return status !== "failed" && status !== "idle"; +} +function hasRunnableWork(conversation) { + return ( + isRunnableStatus(conversation.execution.status) || + pendingMessages(conversation).length > 0 + ); +} +function executionWithPendingMessages(execution, pending) { + const pendingMessages2 = [...pending].sort(compareMessages); + const status = + execution.status === "idle" && execution.lease + ? "running" + : execution.status === "idle" && pendingMessages2.length > 0 + ? "pending" + : execution.status; + return { + ...execution, + status, + inboundMessageIds: uniqueStrings([ + ...execution.inboundMessageIds, + ...pendingMessages2.map((message) => message.inboundMessageId), + ]), + pendingMessages: pendingMessages2, + pendingCount: pendingMessages2.length, + }; +} +function withExecutionUpdate(conversation, execution, nowMs) { + return { + ...conversation, + updatedAtMs: nowMs, + execution: { + ...executionWithPendingMessages(execution, execution.pendingMessages), + updatedAtMs: nowMs, + }, + }; +} +async function getConnectedState(stateAdapter2) { + const state = stateAdapter2 ?? getStateAdapter(); + await state.connect(); + return state; +} +async function sleep(ms) { + await new Promise((resolve) => { + const timer = setTimeout(resolve, ms); + timer.unref?.(); + }); +} +async function withIndexLock(state, indexKey, callback) { + const startedAtMs = now(); + let lock; + while (true) { + lock = await state.acquireLock( + indexLockKey(indexKey), + CONVERSATION_INDEX_LOCK_TTL_MS, + ); + if (lock) { + break; + } + if (now() - startedAtMs >= CONVERSATION_INDEX_LOCK_WAIT_MS) { + throw new Error( + `Could not acquire conversation index lock for ${indexKey}`, + ); + } + await sleep(CONVERSATION_INDEX_LOCK_RETRY_MS); + } + try { + return await callback(); + } finally { + await state.releaseLock(lock); + } +} +function normalizeIndexEntry(value) { + if (!isRecord(value)) { + return void 0; + } + const conversationId = toOptionalString(value.conversationId); + const score = toOptionalNumber(value.score); + if (!conversationId || typeof score !== "number") { + return void 0; + } + return { conversationId, score }; +} +function uniqueIndexEntries(value) { + if (!Array.isArray(value)) { + return []; + } + const entries = /* @__PURE__ */ new Map(); + for (const item of value) { + const entry = normalizeIndexEntry(item); + if (!entry) { + continue; + } + const existing = entries.get(entry.conversationId); + if (!existing || entry.score > existing.score) { + entries.set(entry.conversationId, entry); + } + } + return [...entries.values()]; +} +function retainedIndexEntries(indexKey, entries) { + if (indexKey === CONVERSATION_BY_ACTIVITY_INDEX_KEY) { + return entries + .sort(compareIndexDescending) + .slice(0, CONVERSATION_ACTIVITY_INDEX_MAX_LENGTH); + } + if (indexKey === CONVERSATION_ACTIVE_INDEX_KEY) { + return entries.sort(compareIndexAscending); + } + throw new Error(`Unknown conversation index ${indexKey}`); +} +function redisIndexKey(indexKey) { + const prefix = getChatConfig().state.keyPrefix; + return [...(prefix ? [prefix] : []), indexKey].join(":"); +} +function parseRedisIndexEntries(values) { + if (!Array.isArray(values)) { + return []; + } + const entries = []; + for (let index = 0; index < values.length; index += 2) { + const conversationId = toOptionalString(values[index]); + const score = + typeof values[index + 1] === "number" + ? values[index + 1] + : Number(values[index + 1]); + if (!conversationId || !Number.isFinite(score)) { + continue; + } + entries.push({ conversationId, score }); + } + return entries; +} +function redisConversationIndexStore(client) { + const upsertBoundedActivityScript = ` + redis.call("ZADD", KEYS[1], ARGV[1], ARGV[2]) + redis.call("PEXPIRE", KEYS[1], ARGV[3]) + local extra = redis.call("ZCARD", KEYS[1]) - tonumber(ARGV[4]) + if extra > 0 then + redis.call("ZREMRANGEBYRANK", KEYS[1], 0, extra - 1) + end + return 1 + `; + return { + async list(args) { + const key = redisIndexKey(args.indexKey); + const limit = args.limit; + const offset = Math.max(0, args.offset ?? 0); + if (limit === 0) { + return []; + } + const values = + args.scoreMax !== void 0 + ? await client.sendCommand([ + "ZRANGEBYSCORE", + key, + "-inf", + String(args.scoreMax), + "WITHSCORES", + ...(limit !== void 0 || offset > 0 + ? ["LIMIT", String(offset), String(limit ?? 1e9)] + : []), + ]) + : await client.sendCommand([ + args.order === "asc" ? "ZRANGE" : "ZREVRANGE", + key, + String(offset), + String(limit === void 0 ? -1 : offset + Math.max(0, limit - 1)), + "WITHSCORES", + ]); + return parseRedisIndexEntries(values); + }, + async remove(args) { + await client.sendCommand([ + "ZREM", + redisIndexKey(args.indexKey), + args.conversationId, + ]); + }, + async upsert(args) { + const key = redisIndexKey(args.indexKey); + if (args.indexKey === CONVERSATION_BY_ACTIVITY_INDEX_KEY) { + await client.sendCommand([ + "EVAL", + upsertBoundedActivityScript, + "1", + key, + String(args.score), + args.conversationId, + String(JUNIOR_THREAD_STATE_TTL_MS), + String(CONVERSATION_ACTIVITY_INDEX_MAX_LENGTH), + ]); + return; + } + if (args.indexKey === CONVERSATION_ACTIVE_INDEX_KEY) { + await client.sendCommand([ + "ZADD", + key, + String(args.score), + args.conversationId, + ]); + await client.sendCommand([ + "PEXPIRE", + key, + String(JUNIOR_THREAD_STATE_TTL_MS), + ]); + return; + } + throw new Error(`Unknown conversation index ${args.indexKey}`); + }, + }; +} +function emulatedConversationIndexStore(state) { + const readIndex = async (indexKey) => + uniqueIndexEntries(await state.get(indexKey)); + const writeIndex = async (indexKey, entries) => { + await state.set(indexKey, entries, JUNIOR_THREAD_STATE_TTL_MS); + }; + return { + async list(args) { + const entries = (await readIndex(args.indexKey)) + .filter((entry) => + args.scoreMax === void 0 ? true : entry.score <= args.scoreMax, + ) + .sort( + args.order === "asc" ? compareIndexAscending : compareIndexDescending, + ); + const offset = Math.max(0, args.offset ?? 0); + return entries.slice( + offset, + args.limit === void 0 ? entries.length : offset + args.limit, + ); + }, + async remove(args) { + await withIndexLock(state, args.indexKey, async () => { + const entries = await readIndex(args.indexKey); + const next = entries.filter( + (entry) => entry.conversationId !== args.conversationId, + ); + if (next.length === entries.length) { + return; + } + await writeIndex(args.indexKey, next); + }); + }, + async upsert(args) { + await withIndexLock(state, args.indexKey, async () => { + const entries = await readIndex(args.indexKey); + const withoutCurrent = entries.filter( + (entry) => entry.conversationId !== args.conversationId, + ); + const next = retainedIndexEntries(args.indexKey, [ + ...withoutCurrent, + { conversationId: args.conversationId, score: args.score }, + ]); + await writeIndex(args.indexKey, next); + }); + }, + }; +} +async function getConversationIndexStore(state) { + const redisStateAdapter2 = await getDefaultRedisStateAdapterFor(state); + if (redisStateAdapter2) { + return redisConversationIndexStore(redisStateAdapter2.getClient()); + } + return emulatedConversationIndexStore(state); +} +async function upsertIndexEntry(args) { + const index = await getConversationIndexStore(args.state); + await index.upsert({ + conversationId: args.conversationId, + indexKey: args.indexKey, + score: args.score, + }); +} +async function removeIndexEntry(args) { + const index = await getConversationIndexStore(args.state); + await index.remove({ + conversationId: args.conversationId, + indexKey: args.indexKey, + }); +} +async function acquireMutationLock(state, conversationId) { + const startedAtMs = now(); + while (true) { + const lock = await state.acquireLock( + mutationLockKey(conversationId), + CONVERSATION_MUTATION_LOCK_TTL_MS, + ); + if (lock) { + return lock; + } + if (now() - startedAtMs >= CONVERSATION_MUTATION_WAIT_MS) { + throw new Error( + `Could not acquire conversation mutation lock for ${conversationId}`, + ); + } + await sleep(CONVERSATION_MUTATION_RETRY_MS); + } +} +async function withConversationMutation(args, callback) { + const state = await getConnectedState(args.state); + const lock = await acquireMutationLock(state, args.conversationId); + try { + return await callback(state, lock); + } finally { + await state.releaseLock(lock); + } +} +async function readConversation(state, conversationId) { + const raw = await state.get(conversationKey(conversationId)); + if (raw == null) { + return void 0; + } + const conversation = normalizeConversation(conversationId, raw); + if (!conversation) { + throw new InvalidConversationRecordError(conversationId); + } + return conversation; +} +async function writeConversation(state, lock, conversation) { + const execution = executionWithPendingMessages( + conversation.execution, + conversation.execution.pendingMessages, + ); + const next = { + ...conversation, + execution, + }; + const fenced = await state.extendLock( + lock, + CONVERSATION_MUTATION_LOCK_TTL_MS, + ); + if (!fenced) { + throw new ConversationMutationFencedError(next.conversationId); + } + await state.set( + conversationKey(next.conversationId), + next, + JUNIOR_THREAD_STATE_TTL_MS, + ); + await upsertIndexEntry({ + state, + indexKey: CONVERSATION_BY_ACTIVITY_INDEX_KEY, + conversationId: next.conversationId, + score: next.lastActivityAtMs, + }); + if (!hasRunnableWork(next)) { + await removeIndexEntry({ + state, + indexKey: CONVERSATION_ACTIVE_INDEX_KEY, + conversationId: next.conversationId, + }); + return; + } + await upsertIndexEntry({ + state, + indexKey: CONVERSATION_ACTIVE_INDEX_KEY, + conversationId: next.conversationId, + score: next.execution.updatedAtMs ?? next.updatedAtMs, + }); +} +function assertSameConversationDestination(args) { + if (!args.current || sameDestination(args.current, args.next)) { + return; + } + throw new Error( + `Conversation destination changed for ${args.conversationId}`, + ); +} +async function getConversation(args) { + const state = await getConnectedState(args.state); + return await readConversation(state, args.conversationId); +} +async function requestConversationWork(args) { + const nowMs = args.nowMs ?? now(); + return await withConversationMutation(args, async (state, lock) => { + const existing = await readConversation(state, args.conversationId); + if (existing) { + assertSameConversationDestination({ + conversationId: args.conversationId, + current: existing.destination, + next: args.destination, + }); + } + const current = + existing ?? + emptyConversation({ + conversationId: args.conversationId, + destination: args.destination, + nowMs, + }); + const status = current.execution.lease ? "awaiting_resume" : "pending"; + await writeConversation( + state, + lock, + withExecutionUpdate( + { + ...current, + destination: current.destination ?? args.destination, + }, + { + ...current.execution, + status, + }, + nowMs, + ), + ); + return { status: existing === void 0 ? "created" : "updated" }; + }); +} + +// packages/junior/src/cli/upgrade/migrations/redis-conversation-state.ts +var CONVERSATION_PREFIX2 = "junior:conversation"; +var CONVERSATION_SCHEMA_VERSION2 = 1; +var CONVERSATION_ACTIVITY_INDEX_MAX_LENGTH2 = 1e4; +var CONVERSATION_BY_ACTIVITY_INDEX_KEY2 = `${CONVERSATION_PREFIX2}:by-activity`; +var CONVERSATION_ACTIVE_INDEX_KEY2 = `${CONVERSATION_PREFIX2}:active`; +var LEGACY_CONVERSATION_WORK_PREFIX = "junior:conversation-work"; +var LEGACY_CONVERSATION_WORK_SCHEMA_VERSION = 1; +var LEGACY_CONVERSATION_WORK_INDEX_KEY = `${LEGACY_CONVERSATION_WORK_PREFIX}:index`; +var AGENT_TURN_SESSION_PREFIX = "junior:agent_turn_session"; +var AGENT_TURN_SESSION_INDEX_KEY = `${AGENT_TURN_SESSION_PREFIX}:index`; +var THREAD_STATE_PREFIX = "thread-state"; +function conversationKey2(conversationId) { + return `${CONVERSATION_PREFIX2}:${conversationId}`; +} +function legacyConversationWorkKey(conversationId) { + return `${LEGACY_CONVERSATION_WORK_PREFIX}:state:${conversationId}`; +} +function threadStateKey(conversationId) { + return `${THREAD_STATE_PREFIX}:${conversationId}`; +} +function uniqueStrings2(values) { + return [...new Set(values)]; +} +function uniqueStringValues(value) { + if (!Array.isArray(value)) { + return []; + } + return uniqueStrings2( + value + .map((value2) => (typeof value2 === "string" ? value2 : void 0)) + .filter((value2) => Boolean(value2)), + ); +} +function normalizeSource2(value) { + if ( + value === "api" || + value === "internal" || + value === "plugin" || + value === "scheduler" || + value === "slack" + ) { + return value; + } + return void 0; +} +function normalizeMetadata2(value) { + if (!isRecord(value)) { + return void 0; + } + return value; +} +function normalizeInput2(value) { + if (!isRecord(value)) { + return void 0; + } + const text = toOptionalString(value.text); + if (!text) { + return void 0; + } + return { + text, + authorId: toOptionalString(value.authorId), + attachments: Array.isArray(value.attachments) + ? [...value.attachments] + : void 0, + metadata: normalizeMetadata2(value.metadata), + }; +} +function normalizeMessage2(value) { + if (!isRecord(value)) { + return void 0; + } + const conversationId = toOptionalString(value.conversationId); + const inboundMessageId = toOptionalString(value.inboundMessageId); + const source = normalizeSource2(value.source); + const destination = parseDestination(value.destination); + const createdAtMs = toOptionalNumber(value.createdAtMs); + const receivedAtMs = toOptionalNumber(value.receivedAtMs); + const input = normalizeInput2(value.input); + if ( + !conversationId || + !destination || + !inboundMessageId || + !source || + typeof createdAtMs !== "number" || + typeof receivedAtMs !== "number" || + !input + ) { + return void 0; + } + return { + conversationId, + destination, + inboundMessageId, + source, + createdAtMs, + receivedAtMs, + input, + injectedAtMs: toOptionalNumber(value.injectedAtMs), + }; +} +function normalizeLegacyLease(value) { + if (!isRecord(value)) { + return void 0; + } + const token = toOptionalString(value.leaseToken); + const acquiredAtMs = toOptionalNumber(value.acquiredAtMs); + const lastCheckInAtMs = toOptionalNumber(value.lastCheckInAtMs); + const expiresAtMs = toOptionalNumber(value.leaseExpiresAtMs); + if ( + !token || + typeof acquiredAtMs !== "number" || + typeof lastCheckInAtMs !== "number" || + typeof expiresAtMs !== "number" + ) { + return void 0; + } + return { + token, + acquiredAtMs, + lastCheckInAtMs, + expiresAtMs, + }; +} +function compareMessages2(left, right) { + return ( + left.createdAtMs - right.createdAtMs || + left.receivedAtMs - right.receivedAtMs || + left.inboundMessageId.localeCompare(right.inboundMessageId) + ); +} +function normalizeLegacyConversation(conversationId, value) { + if ( + !isRecord(value) || + value.schemaVersion !== LEGACY_CONVERSATION_WORK_SCHEMA_VERSION + ) { + return void 0; + } + const storedConversationId = toOptionalString(value.conversationId); + const destination = parseDestination(value.destination); + const updatedAtMs = toOptionalNumber(value.updatedAtMs); + if ( + storedConversationId !== conversationId || + !destination || + typeof updatedAtMs !== "number" + ) { + return void 0; + } + const normalizedMessages = Array.isArray(value.messages) + ? value.messages + .map(normalizeMessage2) + .filter((message) => Boolean(message)) + : []; + if ( + normalizedMessages.some( + (message) => + message.conversationId === conversationId && + !sameDestination(message.destination, destination), + ) + ) { + return void 0; + } + const messages = normalizedMessages + .filter((message) => message.conversationId === conversationId) + .sort(compareMessages2); + const pendingMessages2 = messages.filter( + (message) => message.injectedAtMs === void 0, + ); + const lease = normalizeLegacyLease(value.lease); + const needsRun = value.needsRun === true || pendingMessages2.length > 0; + const status = lease + ? value.needsRun === true + ? "awaiting_resume" + : "running" + : needsRun + ? "pending" + : "idle"; + const messageTimes = messages.flatMap((message) => [ + message.createdAtMs, + message.receivedAtMs, + ]); + const createdAtMs = + messageTimes.length > 0 ? Math.min(...messageTimes) : updatedAtMs; + const lastActivityAtMs = + messageTimes.length > 0 ? Math.max(...messageTimes) : updatedAtMs; + return { + schemaVersion: CONVERSATION_SCHEMA_VERSION2, + conversationId, + createdAtMs, + destination, + lastActivityAtMs, + source: messages[0]?.source, + updatedAtMs, + execution: { + status, + inboundMessageIds: uniqueStrings2( + messages.map((message) => message.inboundMessageId), + ), + pendingCount: pendingMessages2.length, + pendingMessages: pendingMessages2, + ...(lease ? { lease } : {}), + lastEnqueuedAtMs: toOptionalNumber(value.lastEnqueuedAtMs), + updatedAtMs, + }, + }; +} +function mergeLegacyConversation(existing, legacy) { + if ( + existing.destination && + legacy.destination && + !sameDestination(existing.destination, legacy.destination) + ) { + throw new Error( + `Legacy conversation work destination does not match conversation ${existing.conversationId}`, + ); + } + const knownInboundIds = new Set(existing.execution.inboundMessageIds); + const pendingMessages2 = [ + ...existing.execution.pendingMessages, + ...legacy.execution.pendingMessages.filter( + (message) => !knownInboundIds.has(message.inboundMessageId), + ), + ].sort(compareMessages2); + const legacyIsRunnable = legacy.execution.status !== "idle"; + const existingIsIdle = existing.execution.status === "idle"; + const legacyLease = + existingIsIdle && legacy.execution.lease + ? { lease: legacy.execution.lease } + : {}; + const legacyRunId = + existingIsIdle && legacy.execution.runId + ? { runId: legacy.execution.runId } + : {}; + const legacyCheckpoint = + existing.execution.lastCheckpointAtMs === void 0 && + legacy.execution.lastCheckpointAtMs !== void 0 + ? { lastCheckpointAtMs: legacy.execution.lastCheckpointAtMs } + : {}; + const legacyEnqueue = + existing.execution.lastEnqueuedAtMs === void 0 && + legacy.execution.lastEnqueuedAtMs !== void 0 + ? { lastEnqueuedAtMs: legacy.execution.lastEnqueuedAtMs } + : {}; + const executionUpdatedAtMs = Math.max( + existing.execution.updatedAtMs ?? existing.updatedAtMs, + legacy.execution.updatedAtMs ?? legacy.updatedAtMs, + ); + const status = + existingIsIdle && legacyIsRunnable + ? legacy.execution.lease + ? legacy.execution.status + : "pending" + : pendingMessages2.length > 0 && existingIsIdle + ? "pending" + : existing.execution.status; + return { + ...existing, + destination: existing.destination ?? legacy.destination, + source: existing.source ?? legacy.source, + createdAtMs: Math.min(existing.createdAtMs, legacy.createdAtMs), + lastActivityAtMs: Math.max( + existing.lastActivityAtMs, + legacy.lastActivityAtMs, + ), + updatedAtMs: Math.max(existing.updatedAtMs, legacy.updatedAtMs), + execution: { + ...existing.execution, + ...legacyLease, + ...legacyRunId, + ...legacyCheckpoint, + ...legacyEnqueue, + status, + inboundMessageIds: uniqueStrings2([ + ...existing.execution.inboundMessageIds, + ...legacy.execution.inboundMessageIds, + ]), + pendingCount: pendingMessages2.length, + pendingMessages: pendingMessages2, + updatedAtMs: executionUpdatedAtMs, + }, + }; +} +function compareIndexDescending2(left, right) { + return ( + right.score - left.score || + right.conversationId.localeCompare(left.conversationId) + ); +} +function compareIndexAscending2(left, right) { + return ( + left.score - right.score || + left.conversationId.localeCompare(right.conversationId) + ); +} +function normalizeIndexEntry2(value) { + if (!isRecord(value)) { + return void 0; + } + const conversationId = toOptionalString(value.conversationId); + const score = toOptionalNumber(value.score); + if (!conversationId || typeof score !== "number") { + return void 0; + } + return { conversationId, score }; +} +function uniqueIndexEntries2(value) { + if (!Array.isArray(value)) { + return []; + } + const entries = /* @__PURE__ */ new Map(); + for (const item of value) { + const entry = normalizeIndexEntry2(item); + if (!entry) { + continue; + } + const existing = entries.get(entry.conversationId); + if (!existing || entry.score > existing.score) { + entries.set(entry.conversationId, entry); + } + } + return [...entries.values()]; +} +function normalizeAwaitingContinuationSummary(value) { + if (!isRecord(value)) { + return void 0; + } + const conversationId = toOptionalString(value.conversationId); + const sessionId = toOptionalString(value.sessionId); + const state = value.state; + const resumeReason = value.resumeReason; + const destination = parseDestination(value.destination); + const updatedAtMs = toOptionalNumber(value.updatedAtMs); + if ( + !conversationId || + !sessionId || + state !== "awaiting_resume" || + (resumeReason !== "timeout" && resumeReason !== "yield") || + !destination || + typeof updatedAtMs !== "number" + ) { + return void 0; + } + return { + conversationId, + destination, + resumeReason, + sessionId, + state, + updatedAtMs, + }; +} +function uniqueAwaitingContinuationSummaries(values) { + const summaries = /* @__PURE__ */ new Map(); + for (const value of [...values].reverse()) { + const summary = normalizeAwaitingContinuationSummary(value); + if (!summary) { + continue; + } + const key = `${summary.conversationId}:${summary.sessionId}`; + if (!summaries.has(key)) { + summaries.set(key, summary); + } + } + return [...summaries.values()]; +} +function redisIndexKey2(indexKey) { + const prefix = getChatConfig().state.keyPrefix; + return [...(prefix ? [prefix] : []), indexKey].join(":"); +} +async function upsertEmulatedIndexEntry(args) { + const existing = uniqueIndexEntries2( + await args.stateAdapter.get(args.indexKey), + ); + const next = [ + ...existing.filter((entry) => entry.conversationId !== args.conversationId), + { conversationId: args.conversationId, score: args.score }, + ]; + const retained = + args.indexKey === CONVERSATION_BY_ACTIVITY_INDEX_KEY2 + ? next + .sort(compareIndexDescending2) + .slice(0, CONVERSATION_ACTIVITY_INDEX_MAX_LENGTH2) + : next.sort(compareIndexAscending2); + await args.stateAdapter.set( + args.indexKey, + retained, + JUNIOR_THREAD_STATE_TTL_MS, + ); +} +async function removeEmulatedIndexEntry(args) { + const existing = uniqueIndexEntries2( + await args.stateAdapter.get(args.indexKey), + ); + const next = existing.filter( + (entry) => entry.conversationId !== args.conversationId, + ); + if (next.length === existing.length) { + return; + } + await args.stateAdapter.set(args.indexKey, next, JUNIOR_THREAD_STATE_TTL_MS); +} +async function upsertRedisIndexEntry(args) { + const key = redisIndexKey2(args.indexKey); + if (args.indexKey === CONVERSATION_BY_ACTIVITY_INDEX_KEY2) { + const upsertBoundedActivityScript = ` + redis.call("ZADD", KEYS[1], ARGV[1], ARGV[2]) + redis.call("PEXPIRE", KEYS[1], ARGV[3]) + local extra = redis.call("ZCARD", KEYS[1]) - tonumber(ARGV[4]) + if extra > 0 then + redis.call("ZREMRANGEBYRANK", KEYS[1], 0, extra - 1) + end + return 1 + `; + await args.client.sendCommand([ + "EVAL", + upsertBoundedActivityScript, + "1", + key, + String(args.score), + args.conversationId, + String(JUNIOR_THREAD_STATE_TTL_MS), + String(CONVERSATION_ACTIVITY_INDEX_MAX_LENGTH2), + ]); + return; + } + await args.client.sendCommand([ + "ZADD", + key, + String(args.score), + args.conversationId, + ]); + await args.client.sendCommand([ + "PEXPIRE", + key, + String(JUNIOR_THREAD_STATE_TTL_MS), + ]); +} +async function removeRedisIndexEntry(args) { + await args.client.sendCommand([ + "ZREM", + redisIndexKey2(args.indexKey), + args.conversationId, + ]); +} +async function upsertConversationIndexes(args) { + const redisClient = args.redisStateAdapter?.getClient(); + const upsert = redisClient + ? (indexKey, score) => + upsertRedisIndexEntry({ + client: redisClient, + conversationId: args.conversation.conversationId, + indexKey, + score, + }) + : (indexKey, score) => + upsertEmulatedIndexEntry({ + stateAdapter: args.stateAdapter, + conversationId: args.conversation.conversationId, + indexKey, + score, + }); + const remove = redisClient + ? (indexKey) => + removeRedisIndexEntry({ + client: redisClient, + conversationId: args.conversation.conversationId, + indexKey, + }) + : (indexKey) => + removeEmulatedIndexEntry({ + stateAdapter: args.stateAdapter, + conversationId: args.conversation.conversationId, + indexKey, + }); + await upsert( + CONVERSATION_BY_ACTIVITY_INDEX_KEY2, + args.conversation.lastActivityAtMs, + ); + if (args.conversation.execution.status === "idle") { + await remove(CONVERSATION_ACTIVE_INDEX_KEY2); + return; + } + await upsert( + CONVERSATION_ACTIVE_INDEX_KEY2, + args.conversation.execution.updatedAtMs ?? args.conversation.updatedAtMs, + ); +} +async function removeLegacyIndexEntry(args) { + const existing = uniqueStringValues( + await args.stateAdapter.get(LEGACY_CONVERSATION_WORK_INDEX_KEY), + ); + const next = existing.filter((id) => id !== args.conversationId); + if (next.length === existing.length) { + return; + } + if (next.length === 0) { + await args.stateAdapter.delete(LEGACY_CONVERSATION_WORK_INDEX_KEY); + return; + } + await args.stateAdapter.set( + LEGACY_CONVERSATION_WORK_INDEX_KEY, + next, + JUNIOR_THREAD_STATE_TTL_MS, + ); +} +async function migrateLegacyConversationWorkRedisState(context) { + const legacyIds = uniqueStringValues( + await context.stateAdapter.get(LEGACY_CONVERSATION_WORK_INDEX_KEY), + ); + const result = { + existing: 0, + migrated: 0, + missing: 0, + scanned: legacyIds.length, + }; + for (const conversationId of legacyIds) { + const legacyKey = legacyConversationWorkKey(conversationId); + const raw = await context.stateAdapter.get(legacyKey); + if (raw == null) { + result.missing += 1; + await removeLegacyIndexEntry({ + conversationId, + stateAdapter: context.stateAdapter, + }); + continue; + } + const conversation = normalizeLegacyConversation(conversationId, raw); + if (!conversation) { + throw new Error( + `Legacy conversation work state is invalid for ${conversationId}`, + ); + } + const existingConversation = await getConversation({ + conversationId, + state: context.stateAdapter, + }); + if (existingConversation) { + const mergedConversation = mergeLegacyConversation( + existingConversation, + conversation, + ); + await context.stateAdapter.set( + conversationKey2(conversationId), + mergedConversation, + JUNIOR_THREAD_STATE_TTL_MS, + ); + await upsertConversationIndexes({ + conversation: mergedConversation, + redisStateAdapter: context.redisStateAdapter, + stateAdapter: context.stateAdapter, + }); + result.existing += 1; + await context.stateAdapter.delete(legacyKey); + await removeLegacyIndexEntry({ + conversationId, + stateAdapter: context.stateAdapter, + }); + continue; + } + await context.stateAdapter.set( + conversationKey2(conversationId), + conversation, + JUNIOR_THREAD_STATE_TTL_MS, + ); + await upsertConversationIndexes({ + conversation, + redisStateAdapter: context.redisStateAdapter, + stateAdapter: context.stateAdapter, + }); + await context.stateAdapter.delete(legacyKey); + await removeLegacyIndexEntry({ + conversationId, + stateAdapter: context.stateAdapter, + }); + result.migrated += 1; + } + return result; +} +async function isActiveContinuationSummary(context, summary) { + const rawState = + (await context.stateAdapter.get(threadStateKey(summary.conversationId))) ?? + {}; + const conversation = coerceThreadConversationState(rawState); + return conversation.processing.activeTurnId === summary.sessionId; +} +async function seedAwaitingContinuationConversationWork(context, result) { + const summaries = uniqueAwaitingContinuationSummaries( + await context.stateAdapter.getList(AGENT_TURN_SESSION_INDEX_KEY), + ); + result.scanned += summaries.length; + for (const summary of summaries) { + if (!(await isActiveContinuationSummary(context, summary))) { + continue; + } + const existingConversation = await getConversation({ + conversationId: summary.conversationId, + state: context.stateAdapter, + }); + if ( + existingConversation?.destination && + !sameDestination(existingConversation.destination, summary.destination) + ) { + throw new Error( + `Awaiting continuation destination does not match conversation ${summary.conversationId}`, + ); + } + if ( + existingConversation && + existingConversation.execution.status !== "idle" + ) { + continue; + } + await requestConversationWork({ + conversationId: summary.conversationId, + destination: summary.destination, + nowMs: Math.max( + summary.updatedAtMs, + existingConversation?.updatedAtMs ?? 0, + ), + state: context.stateAdapter, + }); + if (existingConversation) { + result.existing += 1; + } else { + result.migrated += 1; + } + } +} +async function migrateRedisConversationState(context) { + const result = await migrateLegacyConversationWorkRedisState(context); + await seedAwaitingContinuationConversationWork(context, result); + return result; +} + +// ../../../../../../private/tmp/0005_redis_conversation_state-entry.ts +var migration = { apiVersion: 1, async up(context) { - return await context.tasks.run("redis-conversation-state-v1"); + return await migrateRedisConversationState({ + stateAdapter: context.state, + redisStateAdapter: context.redis + ? { getClient: () => ({ sendCommand: context.redis.sendCommand }) } + : void 0, + io: { info: context.log }, + }); }, -} satisfies MigrationV1; - -export default migration; +}; +var redis_conversation_state_entry_default = migration; +export { + redis_conversation_state_entry_default as default, + migrateRedisConversationState, +}; diff --git a/packages/junior/migrations/0006_conversations_to_sql.ts b/packages/junior/migrations/0006_conversations_to_sql.ts index d0996c225..120139bf7 100644 --- a/packages/junior/migrations/0006_conversations_to_sql.ts +++ b/packages/junior/migrations/0006_conversations_to_sql.ts @@ -1,10 +1,4009 @@ -import type { MigrationV1 } from "@sentry/junior-migrations"; +// @ts-nocheck -- frozen migration capsule; do not refactor against current Junior internals. +/* eslint-disable no-unused-vars */ +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __esm = (fn, res) => + function __init() { + return (fn && (res = (0, fn[__getOwnPropNames(fn)[0]])((fn = 0))), res); + }; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc3) => { + if ((from && typeof from === "object") || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { + get: () => from[key], + enumerable: + !(desc3 = __getOwnPropDesc(from, key)) || desc3.enumerable, + }); + } + return to; +}; +var __reExport = (target, mod, secondTarget) => ( + __copyProps(target, mod, "default"), + secondTarget && __copyProps(secondTarget, mod, "default") +); -const migration = { +// migration:config +function getChatConfig() { + const databaseUrl = process.env.DATABASE_URL; + if (!databaseUrl) throw new Error("DATABASE_URL is required"); + const configured = process.env.JUNIOR_DATABASE_DRIVER; + const url = new URL(databaseUrl); + const driver = + configured === "postgres" || configured === "neon" + ? configured + : url.hostname === "localhost" || url.hostname === "127.0.0.1" + ? "postgres" + : "neon"; + return { + bot: { modelContextWindowTokens: void 0 }, + sql: { databaseUrl, driver }, + state: { + keyPrefix: process.env.JUNIOR_STATE_KEY_PREFIX?.trim() || void 0, + adapter: process.env.REDIS_URL ? "redis" : "memory", + redisUrl: process.env.REDIS_URL, + }, + }; +} +var init_config = __esm({ + "migration:config"() { + "use strict"; + }, +}); + +// packages/junior/src/chat/slack/timestamp.ts +import { z } from "zod"; +var slackMessageTsSchema; +var init_timestamp = __esm({ + "packages/junior/src/chat/slack/timestamp.ts"() { + "use strict"; + slackMessageTsSchema = z + .string() + .trim() + .regex(/^\d+(?:\.\d+)?$/) + .brand(); + }, +}); + +// packages/junior/src/chat/slack/ids.ts +import { z as z2 } from "zod"; +function parseSlackTeamId(value) { + if (typeof value !== "string") return void 0; + const parsed = slackTeamIdSchema.safeParse(value.trim()); + return parsed.success ? parsed.data : void 0; +} +var slackChannelIdSchema, slackTeamIdSchema, slackUserIdSchema; +var init_ids = __esm({ + "packages/junior/src/chat/slack/ids.ts"() { + "use strict"; + init_timestamp(); + slackChannelIdSchema = z2 + .string() + .regex(/^[CDG][A-Z0-9]+$/) + .brand(); + slackTeamIdSchema = z2 + .string() + .regex(/^T[A-Z0-9]+$/) + .brand(); + slackUserIdSchema = z2 + .string() + .regex(/^[UW][A-Z0-9]+$/) + .brand(); + }, +}); + +// packages/junior/src/chat/destination.ts +import { destinationSchema } from "@sentry/junior-plugin-api"; +function parseDestination(value) { + const parsed = destinationSchema.safeParse(value); + return parsed.success ? parsed.data : void 0; +} +function sameDestination(left, right) { + if (left.platform !== right.platform) { + return false; + } + if (left.platform === "local" && right.platform === "local") { + return left.conversationId === right.conversationId; + } + if (left.platform === "slack" && right.platform === "slack") { + return left.teamId === right.teamId && left.channelId === right.channelId; + } + return false; +} +var init_destination = __esm({ + "packages/junior/src/chat/destination.ts"() { + "use strict"; + init_ids(); + }, +}); + +// packages/junior/src/db/schema/timestamps.ts +import { timestamp } from "drizzle-orm/pg-core"; +var timestamptz; +var init_timestamps = __esm({ + "packages/junior/src/db/schema/timestamps.ts"() { + "use strict"; + timestamptz = (name) => timestamp(name, { withTimezone: true }); + }, +}); + +// packages/junior/src/db/schema/destinations.ts +import { index, jsonb, pgTable, text, uniqueIndex } from "drizzle-orm/pg-core"; +import { z as z3 } from "zod"; +var juniorDestinationKindSchema, juniorDestinations; +var init_destinations = __esm({ + "packages/junior/src/db/schema/destinations.ts"() { + "use strict"; + init_timestamps(); + juniorDestinationKindSchema = z3.enum([ + "channel", + "dm", + "group", + "local_conversation", + "thread", + "web_session", + ]); + juniorDestinations = pgTable( + "junior_destinations", + { + id: text("id").primaryKey(), + provider: text("provider").notNull(), + providerTenantId: text("provider_tenant_id").notNull().default(""), + providerDestinationId: text("provider_destination_id").notNull(), + kind: text("kind").$type().notNull(), + parentDestinationId: text("parent_destination_id"), + displayName: text("display_name"), + visibility: text("visibility").$type().notNull().default("unknown"), + metadata: jsonb("metadata_json"), + createdAt: timestamptz("created_at").notNull(), + updatedAt: timestamptz("updated_at").notNull(), + }, + (table) => [ + uniqueIndex("junior_destinations_provider_destination_uidx").on( + table.provider, + table.providerTenantId, + table.providerDestinationId, + ), + index("junior_destinations_provider_kind_idx").on( + table.provider, + table.kind, + ), + ], + ); + }, +}); + +// packages/junior/src/db/schema/users.ts +import { + text as text2, + pgTable as pgTable2, + uniqueIndex as uniqueIndex2, +} from "drizzle-orm/pg-core"; +var juniorUsers; +var init_users = __esm({ + "packages/junior/src/db/schema/users.ts"() { + "use strict"; + init_timestamps(); + juniorUsers = pgTable2( + "junior_users", + { + id: text2("id").primaryKey(), + primaryEmail: text2("primary_email").notNull(), + primaryEmailNormalized: text2("primary_email_normalized").notNull(), + displayName: text2("display_name"), + createdAt: timestamptz("created_at").notNull(), + updatedAt: timestamptz("updated_at").notNull(), + }, + (table) => [ + uniqueIndex2("junior_users_primary_email_normalized_uidx").on( + table.primaryEmailNormalized, + ), + ], + ); + }, +}); + +// packages/junior/src/db/schema/identities.ts +import { sql } from "drizzle-orm"; +import { + boolean, + index as index2, + jsonb as jsonb2, + pgTable as pgTable3, + text as text3, + uniqueIndex as uniqueIndex3, +} from "drizzle-orm/pg-core"; +var juniorIdentities; +var init_identities = __esm({ + "packages/junior/src/db/schema/identities.ts"() { + "use strict"; + init_timestamps(); + init_users(); + juniorIdentities = pgTable3( + "junior_identities", + { + id: text3("id").primaryKey(), + kind: text3("kind").$type().notNull(), + provider: text3("provider").notNull(), + providerTenantId: text3("provider_tenant_id").notNull().default(""), + providerSubjectId: text3("provider_subject_id").notNull(), + displayName: text3("display_name"), + handle: text3("handle"), + email: text3("email"), + avatarUrl: text3("avatar_url"), + metadata: jsonb2("metadata_json"), + createdAt: timestamptz("created_at").notNull(), + updatedAt: timestamptz("updated_at").notNull(), + userId: text3("user_id").references(() => juniorUsers.id), + emailNormalized: text3("email_normalized"), + emailVerified: boolean("email_verified").notNull().default(false), + }, + (table) => [ + uniqueIndex3("junior_identities_provider_subject_uidx").on( + table.provider, + table.providerTenantId, + table.providerSubjectId, + ), + index2("junior_identities_user_idx").on(table.userId), + index2("junior_identities_verified_email_idx") + .on(table.emailNormalized) + .where( + sql`${table.emailVerified} = true AND ${table.emailNormalized} IS NOT NULL`, + ), + index2("junior_identities_kind_provider_idx").on( + table.kind, + table.provider, + ), + ], + ); + }, +}); + +// packages/junior/src/db/schema/conversations.ts +import { sql as sql2 } from "drizzle-orm"; +import { + index as index3, + integer, + jsonb as jsonb3, + pgTable as pgTable4, + text as text4, +} from "drizzle-orm/pg-core"; +var juniorConversations; +var init_conversations = __esm({ + "packages/junior/src/db/schema/conversations.ts"() { + "use strict"; + init_destinations(); + init_identities(); + init_timestamps(); + juniorConversations = pgTable4( + "junior_conversations", + { + conversationId: text4("conversation_id").primaryKey(), + schemaVersion: integer("schema_version").notNull().default(1), + source: text4("source").$type(), + originType: text4("origin_type"), + originId: text4("origin_id"), + originRunId: text4("origin_run_id"), + destinationId: text4("destination_id").references( + () => juniorDestinations.id, + ), + destination: jsonb3("destination_json").$type(), + actorIdentityId: text4("actor_identity_id").references( + () => juniorIdentities.id, + ), + creatorIdentityId: text4("creator_identity_id").references( + () => juniorIdentities.id, + ), + credentialSubjectIdentityId: text4( + "credential_subject_identity_id", + ).references(() => juniorIdentities.id), + actor: jsonb3("actor_json").$type(), + channelName: text4("channel_name"), + title: text4("title"), + createdAt: timestamptz("created_at").notNull(), + lastActivityAt: timestamptz("last_activity_at").notNull(), + updatedAt: timestamptz("updated_at").notNull(), + executionUpdatedAt: timestamptz("execution_updated_at"), + executionStatus: text4("execution_status").$type().notNull(), + runId: text4("run_id"), + lastCheckpointAt: timestamptz("last_checkpoint_at"), + lastEnqueuedAt: timestamptz("last_enqueued_at"), + // Subagent runs are child conversations; top-level listings filter + // parent_conversation_id IS NULL. Historical advisor children use this too. + parentConversationId: text4("parent_conversation_id").references( + () => juniorConversations.conversationId, + ), + transcriptPurgedAt: timestamptz("transcript_purged_at"), + durationMs: integer("duration_ms").notNull().default(0), + usage: jsonb3("usage_json").$type(), + executionDurationMs: integer("execution_duration_ms") + .notNull() + .default(0), + executionUsage: jsonb3("execution_usage_json").$type(), + metricRunId: text4("metric_run_id"), + }, + (table) => [ + index3("junior_conversations_last_activity_idx").on( + table.lastActivityAt.desc(), + table.conversationId, + ), + index3("junior_conversations_active_idx") + .using( + "btree", + sql2`coalesce(${table.executionUpdatedAt}, ${table.updatedAt})`, + table.conversationId, + ) + .where(sql2`${table.executionStatus} <> 'idle'`), + index3("junior_conversations_destination_activity_idx").on( + table.destinationId, + table.lastActivityAt.desc(), + ), + index3("junior_conversations_actor_activity_idx").on( + table.actorIdentityId, + table.lastActivityAt.desc(), + ), + index3("junior_conversations_origin_idx").on( + table.originType, + table.originId, + table.lastActivityAt.desc(), + ), + index3("junior_conversations_parent_idx").on( + table.parentConversationId, + ), + ], + ); + }, +}); + +// packages/junior/src/db/schema/agent-steps.ts +import { + index as index4, + integer as integer2, + jsonb as jsonb4, + pgTable as pgTable5, + primaryKey, + text as text5, +} from "drizzle-orm/pg-core"; +var juniorAgentSteps; +var init_agent_steps = __esm({ + "packages/junior/src/db/schema/agent-steps.ts"() { + "use strict"; + init_conversations(); + init_timestamps(); + juniorAgentSteps = pgTable5( + "junior_agent_steps", + { + conversationId: text5("conversation_id") + .notNull() + .references(() => juniorConversations.conversationId), + seq: integer2("seq").notNull(), + contextEpoch: integer2("context_epoch").notNull(), + type: text5("type").notNull(), + role: text5("role"), + payload: jsonb4("payload").$type().notNull(), + createdAt: timestamptz("created_at").notNull(), + }, + (table) => [ + primaryKey({ columns: [table.conversationId, table.seq] }), + index4("junior_agent_steps_epoch_idx").on( + table.conversationId, + table.contextEpoch, + table.seq, + ), + ], + ); + }, +}); + +// packages/junior/src/db/schema/conversation-messages.ts +import { sql as sql3 } from "drizzle-orm"; +import { + index as index5, + jsonb as jsonb5, + pgTable as pgTable6, + primaryKey as primaryKey2, + text as text6, +} from "drizzle-orm/pg-core"; +var juniorConversationMessages; +var init_conversation_messages = __esm({ + "packages/junior/src/db/schema/conversation-messages.ts"() { + "use strict"; + init_conversations(); + init_identities(); + init_timestamps(); + juniorConversationMessages = pgTable6( + "junior_conversation_messages", + { + conversationId: text6("conversation_id") + .notNull() + .references(() => juniorConversations.conversationId), + messageId: text6("message_id").notNull(), + role: text6("role").$type().notNull(), + authorIdentityId: text6("author_identity_id").references( + () => juniorIdentities.id, + ), + text: text6("text").notNull(), + meta: jsonb5("meta").$type(), + repliedAt: timestamptz("replied_at"), + createdAt: timestamptz("created_at").notNull(), + }, + (table) => [ + primaryKey2({ columns: [table.conversationId, table.messageId] }), + index5("junior_conversation_messages_activity_idx").on( + table.conversationId, + table.createdAt, + ), + index5("junior_conversation_messages_search_idx").using( + "gin", + sql3`to_tsvector('english', ${table.text})`, + ), + ], + ); + }, +}); + +// packages/junior/src/db/schema.ts +var juniorSqlSchema; +var init_schema = __esm({ + "packages/junior/src/db/schema.ts"() { + "use strict"; + init_agent_steps(); + init_conversation_messages(); + init_conversations(); + init_destinations(); + init_identities(); + init_users(); + juniorSqlSchema = { + juniorAgentSteps, + juniorConversationMessages, + juniorConversations, + juniorDestinations, + juniorIdentities, + juniorUsers, + }; + }, +}); + +// packages/junior/src/chat/identities/identity.ts +function normalizeIdentityEmail(email) { + const normalized = email?.trim().toLowerCase(); + return normalized || void 0; +} +var init_identity = __esm({ + "packages/junior/src/chat/identities/identity.ts"() { + "use strict"; + }, +}); + +// packages/junior/src/chat/identities/sql.ts +import { randomUUID } from "node:crypto"; +import { and, eq, sql as sql4 } from "drizzle-orm"; +function dateFromMs(ms) { + return new Date(ms); +} +function tenantId(value) { + return value ?? ""; +} +async function upsertUser(executor, args) { + const rows = await executor + .db() + .insert(juniorUsers) + .values({ + id: randomUUID(), + primaryEmail: args.email, + primaryEmailNormalized: args.emailNormalized, + displayName: args.displayName ?? null, + createdAt: dateFromMs(args.nowMs), + updatedAt: dateFromMs(args.nowMs), + }) + .onConflictDoUpdate({ + target: juniorUsers.primaryEmailNormalized, + set: { + displayName: sql4`coalesce(${juniorUsers.displayName}, excluded.display_name)`, + updatedAt: sql4`excluded.updated_at`, + }, + }) + .returning({ id: juniorUsers.id }); + const id = rows[0]?.id; + if (!id) { + throw new Error("User identity upsert returned no row"); + } + return id; +} +async function existingIdentity(executor, identity) { + const rows = await executor + .db() + .select() + .from(juniorIdentities) + .where( + and( + eq(juniorIdentities.provider, identity.provider), + eq( + juniorIdentities.providerTenantId, + tenantId(identity.providerTenantId), + ), + eq(juniorIdentities.providerSubjectId, identity.providerSubjectId), + ), + ); + return rows[0]; +} +async function upsertIdentity(executor, identity, nowMs = Date.now()) { + const emailNormalized = normalizeIdentityEmail(identity.email); + const email = emailNormalized + ? identity.email?.trim() || emailNormalized + : void 0; + const existing = await existingIdentity(executor, identity); + const userEmailNormalized = + existing?.emailVerified && existing.emailNormalized + ? existing.emailNormalized + : identity.emailVerified + ? emailNormalized + : void 0; + const userEmail = + existing?.emailVerified && existing.email + ? existing.email + : (email ?? userEmailNormalized); + const verifiedUserId = + identity.kind === "user" && userEmailNormalized + ? await upsertUser(executor, { + email: userEmail ?? userEmailNormalized, + emailNormalized: userEmailNormalized, + nowMs, + ...(existing?.displayName || identity.displayName + ? { displayName: existing?.displayName ?? identity.displayName } + : {}), + }) + : void 0; + if ( + existing?.userId && + verifiedUserId && + existing.userId !== verifiedUserId + ) { + throw new Error("Identity verified email conflicts with linked user"); + } + const userId = existing?.userId ?? verifiedUserId; + const rows = await executor + .db() + .insert(juniorIdentities) + .values({ + id: randomUUID(), + userId: userId ?? null, + kind: identity.kind, + provider: identity.provider, + providerTenantId: tenantId(identity.providerTenantId), + providerSubjectId: identity.providerSubjectId, + displayName: identity.displayName ?? null, + handle: identity.handle ?? null, + email: email ?? null, + emailNormalized: emailNormalized ?? null, + emailVerified: Boolean(identity.emailVerified && emailNormalized), + avatarUrl: null, + metadata: identity.metadata ?? null, + createdAt: dateFromMs(nowMs), + updatedAt: dateFromMs(nowMs), + }) + .onConflictDoUpdate({ + target: [ + juniorIdentities.provider, + juniorIdentities.providerTenantId, + juniorIdentities.providerSubjectId, + ], + set: { + kind: sql4`excluded.kind`, + userId: sql4`coalesce(${juniorIdentities.userId}, excluded.user_id)`, + displayName: sql4`coalesce(${juniorIdentities.displayName}, excluded.display_name)`, + handle: sql4`coalesce(${juniorIdentities.handle}, excluded.handle)`, + email: sql4`case when ${juniorIdentities.emailVerified} then coalesce(${juniorIdentities.email}, excluded.email) when excluded.email_verified then excluded.email else coalesce(${juniorIdentities.email}, excluded.email) end`, + emailNormalized: sql4`case when ${juniorIdentities.emailVerified} then coalesce(${juniorIdentities.emailNormalized}, excluded.email_normalized) when excluded.email_verified then excluded.email_normalized else coalesce(${juniorIdentities.emailNormalized}, excluded.email_normalized) end`, + emailVerified: sql4`${juniorIdentities.emailVerified} OR excluded.email_verified`, + avatarUrl: sql4`coalesce(${juniorIdentities.avatarUrl}, excluded.avatar_url)`, + metadata: sql4`coalesce(${juniorIdentities.metadata}, excluded.metadata_json)`, + updatedAt: sql4`excluded.updated_at`, + }, + }) + .returning({ + id: juniorIdentities.id, + userId: juniorIdentities.userId, + }); + const row = rows[0]; + if (!row) { + throw new Error("Identity upsert returned no row"); + } + return { + id: row.id, + ...(row.userId ? { userId: row.userId } : {}), + }; +} +var init_sql = __esm({ + "packages/junior/src/chat/identities/sql.ts"() { + "use strict"; + init_schema(); + init_identity(); + }, +}); + +// packages/junior/src/chat/conversations/sql/store.ts +import { randomUUID as randomUUID2 } from "node:crypto"; +import { + and as and2, + asc, + desc, + eq as eq2, + isNull, + sql as sql5, +} from "drizzle-orm"; +function now() { + return Date.now(); +} +function dateFromMs2(ms) { + return new Date(ms); +} +function msFromDate(value) { + if (value === null || value === void 0) { + return void 0; + } + const date = value instanceof Date ? value : new Date(value); + return date.getTime(); +} +function requiredMsFromDate(value) { + const ms = msFromDate(value); + if (typeof ms !== "number" || Number.isNaN(ms)) { + throw new Error("Conversation record timestamp is invalid"); + } + return ms; +} +function tenantId2(value) { + return value ?? ""; +} +function sourceFromValue(value) { + if ( + value === "api" || + value === "internal" || + value === "local" || + value === "plugin" || + value === "resource_event" || + value === "scheduler" || + value === "slack" + ) { + return value; + } + return void 0; +} +function identityFromActor(actor) { + if (!actor?.slackUserId) { + return void 0; + } + return { + kind: "user", + provider: "slack", + providerTenantId: actor.teamId, + providerSubjectId: actor.slackUserId, + ...(actor.fullName ? { displayName: actor.fullName } : {}), + ...(actor.slackUserName ? { handle: actor.slackUserName } : {}), + ...(actor.email ? { email: actor.email, emailVerified: true } : {}), + metadata: { platform: "slack" }, + }; +} +function systemIdentityFromSource(source) { + if (source === "scheduler") { + return { + kind: "system", + provider: "junior", + providerSubjectId: "scheduler", + displayName: "Junior Scheduler", + }; + } + if (source === "local") { + return { + kind: "system", + provider: "junior", + providerSubjectId: "local-cli", + displayName: "Local CLI", + }; + } + if (source === "resource_event") { + return { + kind: "system", + provider: "junior", + providerSubjectId: "resource-event", + displayName: "Resource Event", + }; + } + return void 0; +} +function actorIdentityForConversation(conversation) { + return ( + identityFromActor(conversation.actor) ?? + systemIdentityFromSource(conversation.source) + ); +} +function originTypeFromSource(source) { + return source; +} +function localWorkspaceFromConversationId(conversationId) { + const match = /^local:([^:]+):/.exec(conversationId); + return match?.[1]; +} +function destinationUpsertFromDestination(args) { + const { destination } = args; + if (!destination) { + return void 0; + } + if (destination.platform === "slack") { + const channelId = destination.channelId; + const channelKind = channelId.startsWith("D") + ? "dm" + : channelId.startsWith("G") + ? "group" + : "channel"; + return { + kind: channelKind, + provider: "slack", + providerTenantId: destination.teamId, + providerDestinationId: channelId, + refreshVisibility: args.visibility !== void 0, + visibility: args.visibility ?? "private", + ...(args.channelName ? { displayName: args.channelName } : {}), + metadata: { platform: "slack" }, + }; + } + return { + kind: "local_conversation", + provider: "local", + providerTenantId: + localWorkspaceFromConversationId(destination.conversationId) ?? + localWorkspaceFromConversationId(args.conversationId ?? ""), + providerDestinationId: destination.conversationId, + refreshVisibility: true, + visibility: "direct", + metadata: { platform: "local" }, + }; +} +function executionStatusFromValue(value) { + if ( + value === "awaiting_resume" || + value === "failed" || + value === "idle" || + value === "pending" || + value === "running" + ) { + return value; + } + throw new Error("Conversation record execution status is invalid"); +} +function privacyFromRow(row) { + if (row.destination === null) { + return void 0; + } + return row.destination.visibility === "public" ? "public" : "private"; +} +function actorFromIdentityRow(identity) { + if (!identity) { + return void 0; + } + if (identity.provider !== "slack") { + return void 0; + } + return { + ...(identity.emailNormalized + ? { email: identity.emailNormalized } + : identity.email + ? { email: identity.email } + : {}), + ...(identity.displayName ? { fullName: identity.displayName } : {}), + platform: "slack", + slackUserId: identity.providerSubjectId, + ...(identity.handle ? { slackUserName: identity.handle } : {}), + ...(identity.providerTenantId ? { teamId: identity.providerTenantId } : {}), + }; +} +function destinationFromRow(destination) { + const value = + destination?.provider === "slack" + ? { + platform: "slack", + teamId: destination.providerTenantId, + channelId: destination.providerDestinationId, + } + : destination?.provider === "local" + ? { + platform: "local", + conversationId: destination.providerDestinationId, + } + : void 0; + return parseDestination(value); +} +function conversationFromRow(readRow) { + const row = readRow.conversation; + const visibility = privacyFromRow(readRow); + if (row.schemaVersion !== 1) { + throw new Error("Conversation record schema version is invalid"); + } + if (row.destination !== null && readRow.destination === null) { + throw new Error("Conversation legacy destination is not migrated"); + } + if (row.actor !== null && readRow.actorIdentity === null) { + throw new Error("Conversation legacy actor is not migrated"); + } + const destination = destinationFromRow(readRow.destination); + const actor = actorFromIdentityRow(readRow.actorIdentity); + if (readRow.destination !== null && !destination) { + throw new Error("Conversation record destination is invalid"); + } + const source = + row.source === void 0 || row.source === null + ? void 0 + : sourceFromValue(row.source); + if (row.source !== void 0 && row.source !== null && !source) { + throw new Error("Conversation record source is invalid"); + } + const execution = { + status: executionStatusFromValue(row.executionStatus), + lastCheckpointAtMs: msFromDate(row.lastCheckpointAt), + lastEnqueuedAtMs: msFromDate(row.lastEnqueuedAt), + ...(row.runId ? { runId: row.runId } : {}), + updatedAtMs: + msFromDate(row.executionUpdatedAt) ?? requiredMsFromDate(row.updatedAt), + }; + return { + schemaVersion: 1, + conversationId: row.conversationId, + createdAtMs: requiredMsFromDate(row.createdAt), + lastActivityAtMs: requiredMsFromDate(row.lastActivityAt), + updatedAtMs: requiredMsFromDate(row.updatedAt), + execution, + ...(destination ? { destination } : {}), + ...(actor ? { actor } : {}), + ...(row.channelName ? { channelName: row.channelName } : {}), + ...(source ? { source } : {}), + ...(row.title ? { title: row.title } : {}), + ...(msFromDate(row.transcriptPurgedAt) !== void 0 + ? { transcriptPurgedAtMs: msFromDate(row.transcriptPurgedAt) } + : {}), + ...(visibility ? { visibility } : {}), + }; +} +function emptyConversation(args) { + return { + schemaVersion: 1, + conversationId: args.conversationId, + createdAtMs: args.nowMs, + lastActivityAtMs: args.nowMs, + updatedAtMs: args.nowMs, + ...(args.destination ? { destination: args.destination } : {}), + ...(args.source ? { source: args.source } : {}), + execution: { + status: "idle", + updatedAtMs: args.nowMs, + }, + }; +} +function assertSameConversationDestination(args) { + if (!args.current || sameDestination(args.current, args.next)) { + return; + } + throw new Error( + `Conversation destination changed for ${args.conversationId}`, + ); +} +function mergeActor(current, next) { + if (!current) { + return next; + } + if (!next) { + return current; + } + if ( + current.slackUserId && + next.slackUserId && + current.slackUserId !== next.slackUserId + ) { + return current; + } + return { + ...current, + ...((current.email ?? next.email) + ? { email: current.email ?? next.email } + : {}), + ...((current.fullName ?? next.fullName) + ? { fullName: current.fullName ?? next.fullName } + : {}), + ...((current.platform ?? next.platform) + ? { platform: current.platform ?? next.platform } + : {}), + ...((current.slackUserId ?? next.slackUserId) + ? { slackUserId: current.slackUserId ?? next.slackUserId } + : {}), + ...((current.slackUserName ?? next.slackUserName) + ? { slackUserName: current.slackUserName ?? next.slackUserName } + : {}), + ...((current.teamId ?? next.teamId) + ? { teamId: current.teamId ?? next.teamId } + : {}), + }; +} +function tokenTotal(usage) { + if (!usage) return 0; + if (usage.totalTokens !== void 0) return usage.totalTokens; + return ( + (usage.inputTokens ?? 0) + + (usage.outputTokens ?? 0) + + (usage.cachedInputTokens ?? 0) + + (usage.cacheCreationTokens ?? 0) + ); +} +function updateConversationUsage(args) { + const usage = { + totalTokens: + tokenTotal(args.current) - + tokenTotal(args.previousExecution) + + tokenTotal(args.nextExecution), + }; + if ( + args.current?.reasoningTokens !== void 0 || + args.previousExecution?.reasoningTokens !== void 0 || + args.nextExecution.reasoningTokens !== void 0 + ) { + usage.reasoningTokens = + (args.current?.reasoningTokens ?? 0) - + (args.previousExecution?.reasoningTokens ?? 0) + + (args.nextExecution.reasoningTokens ?? 0); + } + const costFields = ["input", "output", "cacheRead", "cacheWrite", "total"]; + const cost = {}; + for (const field of costFields) { + if ( + args.current?.cost?.[field] === void 0 && + args.previousExecution?.cost?.[field] === void 0 && + args.nextExecution.cost?.[field] === void 0 + ) { + continue; + } + cost[field] = + Math.round( + ((args.current?.cost?.[field] ?? 0) - + (args.previousExecution?.cost?.[field] ?? 0) + + (args.nextExecution.cost?.[field] ?? 0)) * + 1e12, + ) / 1e12; + } + if (Object.keys(cost).length > 0) usage.cost = cost; + return usage; +} +function createSqlStore(executor) { + return new SqlStore(executor); +} +var CONVERSATION_MUTATION_LOCK_PREFIX, SqlStore; +var init_store = __esm({ + "packages/junior/src/chat/conversations/sql/store.ts"() { + "use strict"; + init_destination(); + init_sql(); + init_schema(); + CONVERSATION_MUTATION_LOCK_PREFIX = "junior_conversation"; + SqlStore = class { + constructor(executor) { + this.executor = executor; + } + executor; + async get(args) { + const row = await this.readConversationRow(args.conversationId); + if (!row) { + return void 0; + } + return conversationFromRow(row); + } + async recordActivity(args) { + const nowMs = args.nowMs ?? now(); + const activityAtMs = args.activityAtMs ?? nowMs; + await this.withConversationMutation(args.conversationId, async () => { + const existing = await this.get({ + conversationId: args.conversationId, + }); + if (existing && args.destination) { + assertSameConversationDestination({ + conversationId: args.conversationId, + current: existing.destination, + next: args.destination, + }); + } + const current = + existing ?? + emptyConversation({ + conversationId: args.conversationId, + destination: args.destination, + nowMs, + source: args.source, + }); + const { visibility: _persisted, ...currentWithoutVisibility } = + current; + await this.upsertConversation({ + conversation: { + ...currentWithoutVisibility, + destination: current.destination ?? args.destination, + source: current.source ?? args.source, + channelName: current.channelName ?? args.channelName, + actor: mergeActor(current.actor, args.actor), + title: current.title ?? args.title, + lastActivityAtMs: Math.max( + current.lastActivityAtMs, + activityAtMs, + ), + updatedAtMs: nowMs, + execution: { + ...current.execution, + updatedAtMs: current.execution.updatedAtMs ?? nowMs, + }, + ...(args.visibility ? { visibility: args.visibility } : {}), + }, + }); + }); + } + async recordExecution(args) { + await this.withConversationMutation(args.conversationId, async () => { + const existingRow = await this.readConversationRow( + args.conversationId, + ); + const existing = existingRow + ? conversationFromRow(existingRow) + : void 0; + const incomingExecutionAt = + args.execution.updatedAtMs ?? args.updatedAtMs; + const existingExecutionAt = + existing?.execution.updatedAtMs ?? existing?.updatedAtMs ?? 0; + const incomingIsFresh = incomingExecutionAt >= existingExecutionAt; + const metricRunId = existingRow?.conversation.metricRunId; + const sameRun = + Boolean(metricRunId) && metricRunId === args.execution.runId; + const execution = incomingIsFresh + ? args.execution + : (existing?.execution ?? args.execution); + await this.upsertConversation({ + conversation: { + schemaVersion: 1, + conversationId: args.conversationId, + createdAtMs: args.createdAtMs, + lastActivityAtMs: args.lastActivityAtMs, + updatedAtMs: args.updatedAtMs, + ...(args.channelName ? { channelName: args.channelName } : {}), + ...(args.destination ? { destination: args.destination } : {}), + ...(args.actor ? { actor: args.actor } : {}), + ...(args.source ? { source: args.source } : {}), + ...(args.title ? { title: args.title } : {}), + ...(args.visibility ? { visibility: args.visibility } : {}), + execution, + }, + }); + if (incomingIsFresh && args.metrics) { + const row = existingRow?.conversation; + const usage = args.metrics.usage + ? updateConversationUsage({ + current: row?.usage ?? void 0, + previousExecution: sameRun + ? (row?.executionUsage ?? void 0) + : void 0, + nextExecution: args.metrics.usage, + }) + : (row?.usage ?? void 0); + await this.executor + .db() + .update(juniorConversations) + .set({ + durationMs: + (row?.durationMs ?? 0) - + (sameRun ? (row?.executionDurationMs ?? 0) : 0) + + args.metrics.durationMs, + usage: usage ?? null, + metricRunId: args.execution.runId ?? null, + executionDurationMs: args.metrics.durationMs, + executionUsage: + args.metrics.usage ?? + (sameRun ? (row?.executionUsage ?? null) : null), + }) + .where( + eq2(juniorConversations.conversationId, args.conversationId), + ); + } + }); + } + async ensureChildConversation(args) { + const at = dateFromMs2(args.nowMs ?? now()); + await this.executor + .db() + .insert(juniorConversations) + .values({ + conversationId: args.parentConversationId, + schemaVersion: 1, + createdAt: at, + lastActivityAt: at, + updatedAt: at, + executionStatus: "idle", + }) + .onConflictDoNothing({ target: juniorConversations.conversationId }); + await this.executor + .db() + .insert(juniorConversations) + .values({ + conversationId: args.conversationId, + schemaVersion: 1, + parentConversationId: args.parentConversationId, + createdAt: at, + lastActivityAt: at, + updatedAt: at, + executionStatus: "idle", + }) + .onConflictDoUpdate({ + target: juniorConversations.conversationId, + set: { + parentConversationId: sql5`coalesce(${juniorConversations.parentConversationId}, excluded.parent_conversation_id)`, + }, + }); + } + /** Copy one conversation record and retained metrics into SQL during backfill. */ + async backfillConversation(sourceConversation, metrics) { + const { visibility: _visibility, ...conversation } = sourceConversation; + await this.withConversationMutation( + conversation.conversationId, + async () => { + const existing = await this.get({ + conversationId: conversation.conversationId, + }); + const sourceExecutionAtMs = + conversation.execution.updatedAtMs ?? conversation.updatedAtMs; + const existingExecutionAtMs = + existing === void 0 + ? void 0 + : (existing.execution.updatedAtMs ?? existing.updatedAtMs); + const refreshExecutionFromSource = + existingExecutionAtMs === void 0 || + sourceExecutionAtMs >= existingExecutionAtMs; + const mergedConversation = existing + ? { + ...conversation, + channelName: existing.channelName ?? conversation.channelName, + createdAtMs: Math.min( + existing.createdAtMs, + conversation.createdAtMs, + ), + destination: existing.destination ?? conversation.destination, + lastActivityAtMs: Math.max( + existing.lastActivityAtMs, + conversation.lastActivityAtMs, + ), + actor: existing.actor ?? conversation.actor, + source: existing.source ?? conversation.source, + title: existing.title ?? conversation.title, + updatedAtMs: Math.max( + existing.updatedAtMs, + conversation.updatedAtMs, + ), + execution: refreshExecutionFromSource + ? conversation.execution + : existing.execution, + } + : conversation; + await this.upsertConversation({ conversation: mergedConversation }); + if (metrics) { + await this.executor + .db() + .update(juniorConversations) + .set({ + durationMs: metrics.durationMs, + usage: metrics.usage ?? null, + ...(refreshExecutionFromSource + ? { + metricRunId: conversation.execution.runId ?? null, + executionDurationMs: metrics.executionDurationMs, + executionUsage: metrics.executionUsage ?? null, + } + : {}), + }) + .where( + and2( + eq2( + juniorConversations.conversationId, + conversation.conversationId, + ), + eq2(juniorConversations.durationMs, 0), + isNull(juniorConversations.usage), + ), + ); + } + }, + ); + } + async listByActivity(args = {}) { + const rows = await this.executor + .db() + .select({ + conversation: juniorConversations, + destination: juniorDestinations, + actorIdentity: juniorIdentities, + }) + .from(juniorConversations) + .leftJoin( + juniorDestinations, + eq2(juniorDestinations.id, juniorConversations.destinationId), + ) + .leftJoin( + juniorIdentities, + eq2(juniorIdentities.id, juniorConversations.actorIdentityId), + ) + .where(isNull(juniorConversations.parentConversationId)) + .orderBy( + desc(juniorConversations.lastActivityAt), + asc(juniorConversations.conversationId), + ) + .limit(Math.max(0, args.limit ?? 1e4)) + .offset(Math.max(0, args.offset ?? 0)); + const conversations = []; + for (const row of rows) { + conversations.push(conversationFromRow(row)); + } + return conversations; + } + async getDestinationVisibility(args) { + const rows = await this.executor + .db() + .select({ + visibility: juniorDestinations.visibility, + }) + .from(juniorDestinations) + .where( + and2( + eq2(juniorDestinations.provider, args.provider), + eq2( + juniorDestinations.providerTenantId, + tenantId2(args.providerTenantId), + ), + eq2( + juniorDestinations.providerDestinationId, + args.providerDestinationId, + ), + ), + ); + const row = rows[0]; + if (!row) { + return void 0; + } + return row.visibility === "public" ? "public" : "private"; + } + /** Serialize all durable mutations for one conversation inside a SQL transaction. */ + async withConversationMutation(conversationId, callback) { + return await this.executor.withLock( + `${CONVERSATION_MUTATION_LOCK_PREFIX}:${conversationId}`, + async () => await this.executor.transaction(callback), + ); + } + async readConversationRow(conversationId) { + const rows = await this.executor + .db() + .select({ + conversation: juniorConversations, + destination: juniorDestinations, + actorIdentity: juniorIdentities, + }) + .from(juniorConversations) + .leftJoin( + juniorDestinations, + eq2(juniorDestinations.id, juniorConversations.destinationId), + ) + .leftJoin( + juniorIdentities, + eq2(juniorIdentities.id, juniorConversations.actorIdentityId), + ) + .where(eq2(juniorConversations.conversationId, conversationId)); + return rows[0]; + } + /** Upsert the conversation row while preserving previously discovered nullable metadata fields. */ + async upsertConversation(args) { + const { conversation } = args; + const incomingExecutionVersion = sql5`coalesce(excluded.execution_updated_at, excluded.updated_at)`; + const currentExecutionVersion = sql5`coalesce(${juniorConversations.executionUpdatedAt}, ${juniorConversations.updatedAt})`; + const incomingExecutionIsFresh = sql5`${incomingExecutionVersion} >= ${currentExecutionVersion}`; + const destinationId = await this.upsertDestination( + destinationUpsertFromDestination({ + channelName: conversation.channelName, + conversationId: conversation.conversationId, + destination: conversation.destination, + ...(conversation.visibility + ? { visibility: conversation.visibility } + : {}), + }), + conversation.updatedAtMs, + ); + const actorIdentityObservation = + actorIdentityForConversation(conversation); + const actorIdentity = actorIdentityObservation + ? await upsertIdentity( + this.executor, + actorIdentityObservation, + conversation.updatedAtMs, + ) + : void 0; + await this.executor + .db() + .insert(juniorConversations) + .values({ + conversationId: conversation.conversationId, + schemaVersion: 1, + source: conversation.source ?? null, + originType: originTypeFromSource(conversation.source) ?? null, + originId: null, + originRunId: null, + destinationId: destinationId ?? null, + destination: null, + actorIdentityId: actorIdentity?.id ?? null, + creatorIdentityId: null, + credentialSubjectIdentityId: null, + actor: null, + channelName: conversation.channelName ?? null, + title: conversation.title ?? null, + createdAt: dateFromMs2(conversation.createdAtMs), + lastActivityAt: dateFromMs2(conversation.lastActivityAtMs), + updatedAt: dateFromMs2(conversation.updatedAtMs), + executionUpdatedAt: + conversation.execution.updatedAtMs === void 0 + ? null + : dateFromMs2(conversation.execution.updatedAtMs), + executionStatus: conversation.execution.status, + runId: conversation.execution.runId ?? null, + lastCheckpointAt: + conversation.execution.lastCheckpointAtMs === void 0 + ? null + : dateFromMs2(conversation.execution.lastCheckpointAtMs), + lastEnqueuedAt: + conversation.execution.lastEnqueuedAtMs === void 0 + ? null + : dateFromMs2(conversation.execution.lastEnqueuedAtMs), + }) + .onConflictDoUpdate({ + target: juniorConversations.conversationId, + set: { + source: sql5`coalesce(excluded.source, ${juniorConversations.source})`, + originType: sql5`coalesce(excluded.origin_type, ${juniorConversations.originType})`, + originId: sql5`coalesce(excluded.origin_id, ${juniorConversations.originId})`, + originRunId: sql5`coalesce(excluded.origin_run_id, ${juniorConversations.originRunId})`, + destinationId: sql5`coalesce(excluded.destination_id, ${juniorConversations.destinationId})`, + actorIdentityId: sql5`coalesce(excluded.actor_identity_id, ${juniorConversations.actorIdentityId})`, + creatorIdentityId: sql5`coalesce(excluded.creator_identity_id, ${juniorConversations.creatorIdentityId})`, + credentialSubjectIdentityId: sql5`coalesce(excluded.credential_subject_identity_id, ${juniorConversations.credentialSubjectIdentityId})`, + channelName: sql5`coalesce(excluded.channel_name, ${juniorConversations.channelName})`, + title: sql5`coalesce(excluded.title, ${juniorConversations.title})`, + createdAt: sql5`least(${juniorConversations.createdAt}, excluded.created_at)`, + lastActivityAt: sql5`greatest(${juniorConversations.lastActivityAt}, excluded.last_activity_at)`, + updatedAt: sql5`greatest(${juniorConversations.updatedAt}, excluded.updated_at)`, + executionUpdatedAt: sql5`case when ${incomingExecutionIsFresh} then excluded.execution_updated_at else ${juniorConversations.executionUpdatedAt} end`, + executionStatus: sql5`case when ${incomingExecutionIsFresh} then excluded.execution_status else ${juniorConversations.executionStatus} end`, + runId: sql5`case when ${incomingExecutionIsFresh} then excluded.run_id else ${juniorConversations.runId} end`, + lastCheckpointAt: sql5`case when ${incomingExecutionIsFresh} then coalesce(excluded.last_checkpoint_at, ${juniorConversations.lastCheckpointAt}) else ${juniorConversations.lastCheckpointAt} end`, + lastEnqueuedAt: sql5`case when ${incomingExecutionIsFresh} then coalesce(excluded.last_enqueued_at, ${juniorConversations.lastEnqueuedAt}) else ${juniorConversations.lastEnqueuedAt} end`, + }, + }); + } + async upsertDestination(destination, nowMs) { + if (!destination) { + return void 0; + } + const visibilityUpdate = destination.refreshVisibility + ? sql5`excluded.visibility` + : juniorDestinations.visibility; + const rows = await this.executor + .db() + .insert(juniorDestinations) + .values({ + id: randomUUID2(), + provider: destination.provider, + providerTenantId: tenantId2(destination.providerTenantId), + providerDestinationId: destination.providerDestinationId, + kind: destination.kind, + parentDestinationId: null, + displayName: destination.displayName ?? null, + visibility: destination.visibility, + metadata: destination.metadata ?? null, + createdAt: dateFromMs2(nowMs), + updatedAt: dateFromMs2(nowMs), + }) + .onConflictDoUpdate({ + target: [ + juniorDestinations.provider, + juniorDestinations.providerTenantId, + juniorDestinations.providerDestinationId, + ], + set: { + kind: sql5`excluded.kind`, + displayName: sql5`coalesce(excluded.display_name, ${juniorDestinations.displayName})`, + // Signal-less writes insert as private but must not clobber an + // existing public/private value. Live source signals refresh this + // field so converted channels converge on the next message. + visibility: visibilityUpdate, + metadata: sql5`coalesce(excluded.metadata_json, ${juniorDestinations.metadata})`, + updatedAt: sql5`excluded.updated_at`, + }, + }) + .returning({ id: juniorDestinations.id }); + return rows[0]?.id; + } + }; + }, +}); + +// packages/junior/src/chat/coerce.ts +function toOptionalString(value) { + return typeof value === "string" && value.trim() ? value : void 0; +} +function toOptionalNumber(value) { + return typeof value === "number" && Number.isFinite(value) ? value : void 0; +} +function isRecord(value) { + return typeof value === "object" && value !== null; +} +var init_coerce = __esm({ + "packages/junior/src/chat/coerce.ts"() { + "use strict"; + }, +}); + +// packages/junior/src/chat/actor.ts +import { z as z4 } from "zod"; +import { actorSchema } from "@sentry/junior-plugin-api"; +function isSyntheticActorUserId(value) { + return value.toLowerCase() === "unknown"; +} +function parseActorUserId(value) { + if (typeof value !== "string" || value.length === 0) { + return void 0; + } + if (value !== value.trim() || isSyntheticActorUserId(value)) { + return void 0; + } + return value; +} +function parseStoredSlackActor(value) { + const parsed = storedSlackActorSchema.safeParse(value); + if (!parsed.success) { + return void 0; + } + if ( + parsed.data.slackUserId !== void 0 && + !parseActorUserId(parsed.data.slackUserId) + ) { + return void 0; + } + if (parsed.data.teamId !== void 0 && !parseSlackTeamId(parsed.data.teamId)) { + return void 0; + } + if ( + (parsed.data.platform !== void 0 || parsed.data.teamId !== void 0) && + (!parsed.data.platform || !parsed.data.teamId) + ) { + return void 0; + } + return parsed.data; +} +var exactStoredStringSchema, storedSlackActorSchema; +var init_actor = __esm({ + "packages/junior/src/chat/actor.ts"() { + "use strict"; + init_ids(); + exactStoredStringSchema = z4 + .string() + .min(1) + .refine((value) => value === value.trim()); + storedSlackActorSchema = z4 + .object({ + email: exactStoredStringSchema.optional(), + fullName: exactStoredStringSchema.optional(), + platform: z4.literal("slack").optional(), + slackUserId: exactStoredStringSchema.optional(), + slackUserName: exactStoredStringSchema.optional(), + teamId: exactStoredStringSchema.optional(), + }) + .strict(); + }, +}); + +// packages/junior/src/chat/state/locks.ts +var ACTIVE_LOCK_TTL_MS; +var init_locks = __esm({ + "packages/junior/src/chat/state/locks.ts"() { + "use strict"; + ACTIVE_LOCK_TTL_MS = 9e4; + }, +}); + +// packages/junior/src/chat/state/adapter.ts +import { createMemoryState } from "@chat-adapter/state-memory"; +import { createRedisState } from "@chat-adapter/state-redis"; +function createPrefixedStateAdapter(base, prefix) { + const prefixed = (value) => `${prefix}:${value}`; + const unprefixed = (value) => + value.startsWith(`${prefix}:`) ? value.slice(prefix.length + 1) : value; + const prefixLock = (lock) => ({ + ...lock, + threadId: prefixed(lock.threadId), + }); + const unprefixLock = (lock) => ({ + ...lock, + threadId: unprefixed(lock.threadId), + }); + return { + appendToList: (key, value, options) => + base.appendToList(prefixed(key), value, options), + connect: () => base.connect(), + disconnect: () => base.disconnect(), + subscribe: (threadId) => base.subscribe(prefixed(threadId)), + unsubscribe: (threadId) => base.unsubscribe(prefixed(threadId)), + isSubscribed: (threadId) => base.isSubscribed(prefixed(threadId)), + acquireLock: async (threadId, ttlMs) => { + const lock = await base.acquireLock(prefixed(threadId), ttlMs); + return lock ? unprefixLock(lock) : null; + }, + releaseLock: (lock) => base.releaseLock(prefixLock(lock)), + extendLock: async (lock, ttlMs) => { + const prefixedLock = prefixLock(lock); + const extended = await base.extendLock(prefixedLock, ttlMs); + if (extended) { + lock.expiresAt = prefixedLock.expiresAt; + } + return extended; + }, + forceReleaseLock: (threadId) => base.forceReleaseLock(prefixed(threadId)), + enqueue: (threadId, entry, maxSize) => + base.enqueue(prefixed(threadId), entry, maxSize), + dequeue: (threadId) => base.dequeue(prefixed(threadId)), + queueDepth: (threadId) => base.queueDepth(prefixed(threadId)), + get: (key) => base.get(prefixed(key)), + getList: (key) => base.getList(prefixed(key)), + set: (key, value, ttlMs) => base.set(prefixed(key), value, ttlMs), + setIfNotExists: (key, value, ttlMs) => + base.setIfNotExists(prefixed(key), value, ttlMs), + delete: (key) => base.delete(prefixed(key)), + }; +} +function createQueuedStateAdapter(base, options) { + const heartbeats = /* @__PURE__ */ new Map(); + const shouldHeartbeatLock = (ttlMs) => ttlMs === ACTIVE_LOCK_TTL_MS; + const heartbeatKey = (lock) => `${lock.threadId}:${lock.token}`; + const stopHeartbeatByKey = (key) => { + const heartbeat = heartbeats.get(key); + if (!heartbeat) { + return; + } + clearInterval(heartbeat.timer); + heartbeats.delete(key); + }; + const stopHeartbeat = (lock) => { + stopHeartbeatByKey(heartbeatKey(lock)); + }; + const stopHeartbeatsForThread = (threadId) => { + for (const [key, heartbeat] of heartbeats) { + if (heartbeat.lock.threadId === threadId) { + stopHeartbeatByKey(key); + } + } + }; + const stopAllHeartbeats = () => { + for (const key of heartbeats.keys()) { + stopHeartbeatByKey(key); + } + }; + const runHeartbeat = async (key) => { + const heartbeat = heartbeats.get(key); + if (!heartbeat || heartbeat.inFlight) { + return; + } + heartbeat.inFlight = true; + try { + if (Date.now() - heartbeat.startedAtMs >= options.activeLockMaxAgeMs) { + stopHeartbeatByKey(key); + return; + } + const extended = await base.extendLock(heartbeat.lock, heartbeat.ttlMs); + if (!extended) { + stopHeartbeatByKey(key); + return; + } + heartbeat.lock.expiresAt = Date.now() + heartbeat.ttlMs; + } catch { + } finally { + const current = heartbeats.get(key); + if (current === heartbeat) { + current.inFlight = false; + } + } + }; + const startOrUpdateHeartbeat = (lock, ttlMs) => { + const key = heartbeatKey(lock); + const existing = heartbeats.get(key); + if (existing) { + existing.ttlMs = ttlMs; + return; + } + const timer = setInterval(() => { + void runHeartbeat(key); + }, ACTIVE_LOCK_HEARTBEAT_MS); + timer.unref?.(); + heartbeats.set(key, { + inFlight: false, + lock, + startedAtMs: Date.now(), + timer, + ttlMs, + }); + }; + const acquireLock = async (threadId, ttlMs) => { + const lock = await base.acquireLock(threadId, ttlMs); + if (lock && shouldHeartbeatLock(ttlMs)) { + startOrUpdateHeartbeat(lock, ttlMs); + } + return lock; + }; + return { + appendToList: (key, value, options2) => + base.appendToList(key, value, options2), + connect: () => base.connect(), + disconnect: async () => { + stopAllHeartbeats(); + await base.disconnect(); + }, + subscribe: (threadId) => base.subscribe(threadId), + unsubscribe: (threadId) => base.unsubscribe(threadId), + isSubscribed: (threadId) => base.isSubscribed(threadId), + acquireLock, + releaseLock: async (lock) => { + stopHeartbeat(lock); + await base.releaseLock(lock); + }, + extendLock: async (lock, ttlMs) => { + const extended = await base.extendLock(lock, ttlMs); + if (extended) { + lock.expiresAt = Date.now() + ttlMs; + if (shouldHeartbeatLock(ttlMs)) { + startOrUpdateHeartbeat(lock, ttlMs); + } else { + stopHeartbeat(lock); + } + } else { + stopHeartbeat(lock); + } + return extended; + }, + forceReleaseLock: async (threadId) => { + stopHeartbeatsForThread(threadId); + await base.forceReleaseLock(threadId); + }, + enqueue: (threadId, entry, maxSize) => + base.enqueue(threadId, entry, maxSize), + dequeue: (threadId) => base.dequeue(threadId), + queueDepth: (threadId) => base.queueDepth(threadId), + get: (key) => base.get(key), + getList: (key) => base.getList(key), + set: (key, value, ttlMs) => base.set(key, value, ttlMs), + setIfNotExists: (key, value, ttlMs) => + base.setIfNotExists(key, value, ttlMs), + delete: (key) => base.delete(key), + }; +} +function withOptionalPrefix(base, prefix) { + return prefix ? createPrefixedStateAdapter(base, prefix) : base; +} +function createStateAdapter() { + const config = getChatConfig(); + const activeLockMaxAgeMs = config.bot.turnTimeoutMs + ACTIVE_LOCK_TTL_MS; + if (config.state.adapter === "memory") { + redisStateAdapter = void 0; + return createQueuedStateAdapter( + withOptionalPrefix(createMemoryState(), config.state.keyPrefix), + { activeLockMaxAgeMs }, + ); + } + if (!config.state.redisUrl) { + throw new Error("REDIS_URL is required for durable Slack thread state"); + } + const redisState = createRedisState({ + url: config.state.redisUrl, + }); + redisStateAdapter = redisState; + return createQueuedStateAdapter( + withOptionalPrefix(redisState, config.state.keyPrefix), + { activeLockMaxAgeMs }, + ); +} +function getOptionalRedisStateAdapter() { + getStateAdapter(); + return redisStateAdapter; +} +async function getConnectedStateContext() { + const adapter = getStateAdapter(); + await adapter.connect(); + return { + redisStateAdapter: getOptionalRedisStateAdapter(), + stateAdapter: adapter, + }; +} +async function getDefaultRedisStateAdapterFor(adapter) { + if (adapter !== stateAdapter) { + return void 0; + } + const context = await getConnectedStateContext(); + return context.stateAdapter === adapter ? context.redisStateAdapter : void 0; +} +function getStateAdapter() { + if (!stateAdapter) { + stateAdapter = createStateAdapter(); + } + return stateAdapter; +} +var ACTIVE_LOCK_HEARTBEAT_MS, stateAdapter, redisStateAdapter; +var init_adapter = __esm({ + "packages/junior/src/chat/state/adapter.ts"() { + "use strict"; + init_config(); + init_locks(); + ACTIVE_LOCK_HEARTBEAT_MS = 3e4; + }, +}); + +// packages/junior/src/chat/pi/messages.ts +import { z as z6 } from "zod"; +var piMessageSchema, piContentMessageSchema; +var init_messages = __esm({ + "packages/junior/src/chat/pi/messages.ts"() { + "use strict"; + piMessageSchema = z6 + .object({ + role: z6.string(), + }) + .passthrough() + .transform((value) => value); + piContentMessageSchema = z6 + .object({ + content: z6.array(z6.unknown()), + role: z6.string().min(1), + }) + .passthrough() + .transform((value) => value); + }, +}); + +// packages/junior/src/chat/state/session-log.ts +import { z as z7 } from "zod"; +import { actorSchema as actorSchema2 } from "@sentry/junior-plugin-api"; +var INITIAL_SESSION_ID, + schemaVersionSchema, + piMessageAuthoritySchema, + piMessageProvenanceSchema, + piMessageEntrySchema, + projectionResetEntrySchema, + actorRecordedEntrySchema, + mcpProviderConnectedEntrySchema, + authorizationKindSchema, + authorizationRequestedEntrySchema, + authorizationCompletedEntrySchema, + transcriptRefSchema, + toolExecutionStartedEntrySchema, + subagentStartedEntrySchema, + subagentEndedEntrySchema, + sessionLogEntrySchema; +var init_session_log = __esm({ + "packages/junior/src/chat/state/session-log.ts"() { + "use strict"; + init_config(); + init_messages(); + init_actor(); + init_adapter(); + INITIAL_SESSION_ID = "session_0"; + schemaVersionSchema = z7.union([z7.literal(1), z7.literal(2)]); + piMessageAuthoritySchema = z7.union([ + z7.literal("instruction"), + z7.literal("context"), + ]); + piMessageProvenanceSchema = z7 + .object({ + authority: piMessageAuthoritySchema, + actor: actorSchema2.optional(), + }) + .strict(); + piMessageEntrySchema = z7.object({ + schemaVersion: schemaVersionSchema, + type: z7.literal("pi_message"), + sessionId: z7.string().min(1).default(INITIAL_SESSION_ID), + message: piMessageSchema, + provenance: piMessageProvenanceSchema.optional(), + // Legacy v1 latest-wins actor, decoded into provenance on read. + actor: storedSlackActorSchema.optional(), + }); + projectionResetEntrySchema = z7.object({ + schemaVersion: schemaVersionSchema, + type: z7.literal("projection_reset"), + sessionId: z7.string().min(1).default(INITIAL_SESSION_ID), + messages: z7.array(piMessageSchema), + provenance: z7.array(piMessageProvenanceSchema).optional(), + // Legacy v1 latest-wins actor; v1 resets carry no per-message provenance. + actor: storedSlackActorSchema.optional(), + }); + actorRecordedEntrySchema = z7.object({ + schemaVersion: schemaVersionSchema, + type: z7.literal("actor_recorded"), + sessionId: z7.string().min(1).default(INITIAL_SESSION_ID), + actor: storedSlackActorSchema, + }); + mcpProviderConnectedEntrySchema = z7.object({ + schemaVersion: schemaVersionSchema, + type: z7.literal("mcp_provider_connected"), + sessionId: z7.string().min(1).default(INITIAL_SESSION_ID), + provider: z7.string().min(1), + }); + authorizationKindSchema = z7.union([ + z7.literal("plugin"), + z7.literal("mcp"), + ]); + authorizationRequestedEntrySchema = z7.object({ + schemaVersion: schemaVersionSchema, + type: z7.literal("authorization_requested"), + sessionId: z7.string().min(1).default(INITIAL_SESSION_ID), + createdAtMs: z7.number().int().nonnegative(), + kind: authorizationKindSchema, + provider: z7.string().min(1), + actorId: z7.string().min(1), + authorizationId: z7.string().min(1), + delivery: z7.union([ + z7.literal("private_link_sent"), + z7.literal("private_link_reused"), + ]), + }); + authorizationCompletedEntrySchema = z7.object({ + schemaVersion: schemaVersionSchema, + type: z7.literal("authorization_completed"), + sessionId: z7.string().min(1).default(INITIAL_SESSION_ID), + createdAtMs: z7.number().int().nonnegative(), + kind: authorizationKindSchema, + provider: z7.string().min(1), + actorId: z7.string().min(1), + authorizationId: z7.string().min(1), + }); + transcriptRefSchema = z7.object({ + type: z7.literal("advisor_session"), + parentConversationId: z7.string().min(1), + key: z7.string().min(1), + }); + toolExecutionStartedEntrySchema = z7.object({ + schemaVersion: schemaVersionSchema, + type: z7.literal("tool_execution_started"), + sessionId: z7.string().min(1).default(INITIAL_SESSION_ID), + createdAtMs: z7.number().int().nonnegative(), + toolCallId: z7.string().min(1), + toolName: z7.string().min(1), + args: z7.unknown().optional(), + }); + subagentStartedEntrySchema = z7.object({ + schemaVersion: schemaVersionSchema, + type: z7.literal("subagent_started"), + sessionId: z7.string().min(1).default(INITIAL_SESSION_ID), + subagentInvocationId: z7.string().min(1), + subagentKind: z7.string().min(1), + parentToolCallId: z7.string().min(1).optional(), + parentConversationId: z7.string().min(1), + parentSessionId: z7.string().min(1).optional(), + transcriptRef: transcriptRefSchema, + historyMode: z7.literal("shared"), + modelId: z7.string().min(1).optional(), + reasoningLevel: z7.string().min(1).optional(), + createdAtMs: z7.number().int().nonnegative(), + }); + subagentEndedEntrySchema = z7.object({ + schemaVersion: schemaVersionSchema, + type: z7.literal("subagent_ended"), + sessionId: z7.string().min(1).default(INITIAL_SESSION_ID), + subagentInvocationId: z7.string().min(1), + outcome: z7.union([ + z7.literal("success"), + z7.literal("error"), + z7.literal("aborted"), + ]), + errorCode: z7.string().min(1).optional(), + transcriptEndMessageIndex: z7.number().int().nonnegative().optional(), + transcriptStartMessageIndex: z7.number().int().nonnegative().optional(), + createdAtMs: z7.number().int().nonnegative(), + }); + sessionLogEntrySchema = z7.discriminatedUnion("type", [ + piMessageEntrySchema, + projectionResetEntrySchema, + actorRecordedEntrySchema, + mcpProviderConnectedEntrySchema, + authorizationRequestedEntrySchema, + authorizationCompletedEntrySchema, + toolExecutionStartedEntrySchema, + subagentStartedEntrySchema, + subagentEndedEntrySchema, + ]); + }, +}); + +// packages/junior/src/chat/model-profile.ts +import { z as z8 } from "zod"; +var modelProfileSchema; +var init_model_profile = __esm({ + "packages/junior/src/chat/model-profile.ts"() { + "use strict"; + modelProfileSchema = z8.string().regex(/^[a-z][a-z0-9_-]*$/); + }, +}); + +// packages/junior/src/chat/conversations/history.ts +import { z as z9 } from "zod"; +var handoffModelProfileSchema, + piMessageStepEntrySchema, + contextEpochStartedEntrySchema, + piMessageStepSchema, + contextEpochStartSchema, + mcpProviderConnectedEntrySchema2, + authorizationKindSchema2, + authorizationRequestedEntrySchema2, + authorizationCompletedEntrySchema2, + toolExecutionStartedEntrySchema2, + visibleContextCompactedEntrySchema, + subagentStartedEntrySchema2, + subagentEndedEntrySchema2, + appendableAgentStepEntrySchema, + agentStepEntrySchema, + newAgentStepSchema; +var init_history = __esm({ + "packages/junior/src/chat/conversations/history.ts"() { + "use strict"; + init_messages(); + init_session_log(); + init_model_profile(); + handoffModelProfileSchema = modelProfileSchema.refine( + (profile) => profile !== "standard", + "handoff profile must not be standard", + ); + piMessageStepEntrySchema = z9.object({ + type: z9.literal("pi_message"), + schemaVersion: z9.number().int().optional(), + message: piMessageSchema, + provenance: piMessageProvenanceSchema.optional(), + }); + contextEpochStartedEntrySchema = z9.union([ + z9 + .object({ + type: z9.literal("context_epoch_started"), + reason: z9.literal("initial"), + modelProfile: z9.literal("standard"), + modelId: z9.string().min(1), + }) + .strict(), + z9 + .object({ + type: z9.literal("context_epoch_started"), + reason: z9.literal("handoff"), + modelProfile: handoffModelProfileSchema, + modelId: z9.string().min(1), + }) + .strict(), + z9 + .object({ + type: z9.literal("context_epoch_started"), + reason: z9.union([z9.literal("compaction"), z9.literal("rollback")]), + // TODO(v0.97.0): Remove support for deployed compaction/rollback markers + // without model bindings after those rows pass the retention horizon. + modelProfile: z9.undefined().optional(), + modelId: z9.undefined().optional(), + }) + .strict(), + z9 + .object({ + type: z9.literal("context_epoch_started"), + reason: z9.union([z9.literal("compaction"), z9.literal("rollback")]), + modelProfile: modelProfileSchema, + modelId: z9.string().min(1), + }) + .strict(), + ]); + piMessageStepSchema = z9 + .object({ + message: piMessageSchema, + createdAtMs: z9.number().finite(), + provenance: piMessageProvenanceSchema.optional(), + }) + .strict(); + contextEpochStartSchema = z9.discriminatedUnion("reason", [ + z9 + .object({ + reason: z9.literal("initial"), + modelProfile: z9.literal("standard"), + modelId: z9.string().min(1), + messages: z9.array(piMessageStepSchema), + }) + .strict(), + z9 + .object({ + reason: z9.literal("handoff"), + modelProfile: handoffModelProfileSchema, + modelId: z9.string().min(1), + messages: z9.array(piMessageStepSchema), + }) + .strict(), + z9 + .object({ + reason: z9.union([z9.literal("compaction"), z9.literal("rollback")]), + modelProfile: modelProfileSchema, + modelId: z9.string().min(1), + messages: z9.array(piMessageStepSchema), + }) + .strict(), + ]); + mcpProviderConnectedEntrySchema2 = z9.object({ + type: z9.literal("mcp_provider_connected"), + provider: z9.string().min(1), + }); + authorizationKindSchema2 = z9.union([ + z9.literal("plugin"), + z9.literal("mcp"), + ]); + authorizationRequestedEntrySchema2 = z9.object({ + type: z9.literal("authorization_requested"), + kind: authorizationKindSchema2, + provider: z9.string().min(1), + actorId: z9.string().min(1), + authorizationId: z9.string().min(1), + delivery: z9.union([ + z9.literal("private_link_sent"), + z9.literal("private_link_reused"), + ]), + }); + authorizationCompletedEntrySchema2 = z9.object({ + type: z9.literal("authorization_completed"), + kind: authorizationKindSchema2, + provider: z9.string().min(1), + actorId: z9.string().min(1), + authorizationId: z9.string().min(1), + }); + toolExecutionStartedEntrySchema2 = z9.object({ + type: z9.literal("tool_execution_started"), + toolCallId: z9.string().min(1), + toolName: z9.string().min(1), + args: z9.unknown().optional(), + }); + visibleContextCompactedEntrySchema = z9.object({ + type: z9.literal("visible_context_compacted"), + compactions: z9.array( + z9.object({ + coveredMessageIds: z9.array(z9.string()), + createdAtMs: z9.number(), + id: z9.string().min(1), + summary: z9.string(), + }), + ), + }); + subagentStartedEntrySchema2 = z9 + .object({ + type: z9.literal("subagent_started"), + subagentInvocationId: z9.string().min(1), + subagentKind: z9.string().min(1), + modelId: z9.string().min(1).optional(), + parentToolCallId: z9.string().min(1).optional(), + reasoningLevel: z9.string().min(1).optional(), + childConversationId: z9.string().min(1), + historyMode: z9.union([z9.literal("isolated"), z9.literal("shared")]), + }) + .strict(); + subagentEndedEntrySchema2 = z9.object({ + type: z9.literal("subagent_ended"), + subagentInvocationId: z9.string().min(1), + outcome: z9.union([ + z9.literal("success"), + z9.literal("error"), + z9.literal("aborted"), + ]), + errorCode: z9.string().min(1).optional(), + }); + appendableAgentStepEntrySchema = z9.union([ + piMessageStepEntrySchema, + mcpProviderConnectedEntrySchema2, + authorizationRequestedEntrySchema2, + authorizationCompletedEntrySchema2, + toolExecutionStartedEntrySchema2, + visibleContextCompactedEntrySchema, + subagentStartedEntrySchema2, + subagentEndedEntrySchema2, + ]); + agentStepEntrySchema = z9.union([ + appendableAgentStepEntrySchema, + contextEpochStartedEntrySchema, + ]); + newAgentStepSchema = z9 + .object({ + entry: appendableAgentStepEntrySchema, + createdAtMs: z9.number().finite(), + }) + .strict(); + }, +}); + +// packages/junior/src/chat/conversations/sql/conversation-row.ts +import { sql as sql6 } from "drizzle-orm"; +var init_conversation_row = __esm({ + "packages/junior/src/chat/conversations/sql/conversation-row.ts"() { + "use strict"; + init_schema(); + }, +}); + +// packages/junior/src/chat/conversations/sql/history.ts +import { and as and3, asc as asc2, eq as eq3, sql as sql7 } from "drizzle-orm"; +var init_history2 = __esm({ + "packages/junior/src/chat/conversations/sql/history.ts"() { + "use strict"; + init_history(); + init_conversation_row(); + init_schema(); + }, +}); + +// packages/junior/src/chat/conversations/sql/messages.ts +import { + and as and4, + asc as asc3, + eq as eq4, + isNull as isNull2, + sql as sql8, +} from "drizzle-orm"; +var init_messages2 = __esm({ + "packages/junior/src/chat/conversations/sql/messages.ts"() { + "use strict"; + init_conversation_row(); + init_schema(); + }, +}); + +// packages/junior/src/chat/conversations/sql/search.ts +import { + and as and5, + desc as desc2, + eq as eq5, + inArray, + isNull as isNull3, + ne, + sql as sql9, +} from "drizzle-orm"; +var init_search = __esm({ + "packages/junior/src/chat/conversations/sql/search.ts"() { + "use strict"; + init_schema(); + }, +}); + +// packages/junior/src/db/neon.ts +import { AsyncLocalStorage } from "node:async_hooks"; +import { Pool } from "@neondatabase/serverless"; +import { drizzle } from "drizzle-orm/neon-serverless"; +function createNeonJuniorSqlExecutor(args) { + return new NeonExecutor( + new Pool({ + connectionString: args.connectionString, + max: 3, + }), + ); +} +var NeonExecutor; +var init_neon = __esm({ + "packages/junior/src/db/neon.ts"() { + "use strict"; + init_schema(); + NeonExecutor = class { + constructor(pool) { + this.pool = pool; + } + pool; + transactionClient = new AsyncLocalStorage(); + savepointId = 0; + db() { + return drizzle(this.queryClient(), { + schema: juniorSqlSchema, + }); + } + async execute(statement, params = []) { + await this.queryClient().query(statement, [...params]); + } + async query(statement, params = []) { + const result = await this.queryClient().query(statement, [...params]); + return result.rows; + } + async transaction(callback) { + const existingClient = this.transactionClient.getStore(); + if (existingClient) { + const savepoint = `junior_savepoint_${++this.savepointId}`; + await existingClient.query(`SAVEPOINT ${savepoint}`); + try { + const result = await callback(); + await existingClient.query(`RELEASE SAVEPOINT ${savepoint}`); + return result; + } catch (error) { + await existingClient.query(`ROLLBACK TO SAVEPOINT ${savepoint}`); + await existingClient.query(`RELEASE SAVEPOINT ${savepoint}`); + throw error; + } + } + const client = await this.pool.connect(); + try { + await client.query("BEGIN"); + const result = await this.transactionClient.run(client, callback); + await client.query("COMMIT"); + return result; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } + } + async withLock(lockName, callback) { + if (!lockName) { + throw new Error("SQL lock name is required"); + } + const existingClient = this.transactionClient.getStore(); + if (existingClient) { + await existingClient.query( + "SELECT pg_advisory_xact_lock(hashtext($1))", + [lockName], + ); + return await callback(); + } + const client = await this.pool.connect(); + try { + await client.query("BEGIN"); + return await this.transactionClient.run(client, async () => { + try { + await client.query("SELECT pg_advisory_xact_lock(hashtext($1))", [ + lockName, + ]); + const result = await callback(); + await client.query("COMMIT"); + return result; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } + }); + } finally { + client.release(); + } + } + async withMigrationLock(migrationTable, callback) { + const client = await this.pool.connect(); + const lockName = `junior:migrate:${migrationTable}`; + try { + await client.query("SELECT pg_advisory_lock(hashtext($1))", [ + lockName, + ]); + return await callback(); + } finally { + client.release(true); + } + } + async close() { + await this.pool.end(); + } + queryClient() { + return this.transactionClient.getStore() ?? this.pool; + } + }; + }, +}); + +// packages/junior/src/db/postgres.ts +import { AsyncLocalStorage as AsyncLocalStorage2 } from "node:async_hooks"; +import pg from "pg"; +import { drizzle as drizzle2 } from "drizzle-orm/node-postgres"; +function createPostgresJuniorSqlExecutor(args) { + return new PostgresExecutor( + new Pool2({ + application_name: args.applicationName, + connectionString: args.connectionString, + max: 3, + }), + ); +} +var Pool2, PostgresExecutor; +var init_postgres = __esm({ + "packages/junior/src/db/postgres.ts"() { + "use strict"; + init_schema(); + ({ Pool: Pool2 } = pg); + PostgresExecutor = class { + constructor(pool) { + this.pool = pool; + } + pool; + transactionClient = new AsyncLocalStorage2(); + savepointId = 0; + db() { + return drizzle2(this.queryClient(), { + schema: juniorSqlSchema, + }); + } + async execute(statement, params = []) { + await this.queryClient().query(statement, [...params]); + } + async query(statement, params = []) { + const result = await this.queryClient().query(statement, [...params]); + return result.rows; + } + async transaction(callback) { + const existingClient = this.transactionClient.getStore(); + if (existingClient) { + const savepoint = `junior_savepoint_${++this.savepointId}`; + await existingClient.query(`SAVEPOINT ${savepoint}`); + try { + const result = await callback(); + await existingClient.query(`RELEASE SAVEPOINT ${savepoint}`); + return result; + } catch (error) { + await existingClient.query(`ROLLBACK TO SAVEPOINT ${savepoint}`); + await existingClient.query(`RELEASE SAVEPOINT ${savepoint}`); + throw error; + } + } + const client = await this.pool.connect(); + try { + await client.query("BEGIN"); + const result = await this.transactionClient.run(client, callback); + await client.query("COMMIT"); + return result; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } + } + async withLock(lockName, callback) { + if (!lockName) { + throw new Error("SQL lock name is required"); + } + const existingClient = this.transactionClient.getStore(); + if (existingClient) { + await existingClient.query( + "SELECT pg_advisory_xact_lock(hashtext($1))", + [lockName], + ); + return await callback(); + } + const client = await this.pool.connect(); + try { + await client.query("BEGIN"); + return await this.transactionClient.run(client, async () => { + try { + await client.query("SELECT pg_advisory_xact_lock(hashtext($1))", [ + lockName, + ]); + const result = await callback(); + await client.query("COMMIT"); + return result; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } + }); + } finally { + client.release(); + } + } + async withMigrationLock(migrationTable, callback) { + const client = await this.pool.connect(); + const lockName = `junior:migrate:${migrationTable}`; + try { + await client.query("SELECT pg_advisory_lock(hashtext($1))", [ + lockName, + ]); + return await callback(); + } finally { + client.release(true); + } + } + async close() { + await this.pool.end(); + } + queryClient() { + return this.transactionClient.getStore() ?? this.pool; + } + }; + }, +}); + +// packages/junior/src/db/executor.ts +function createJuniorSqlExecutor(args) { + if (args.driver === "postgres") { + return createPostgresJuniorSqlExecutor(args); + } + return createNeonJuniorSqlExecutor(args); +} +var init_executor = __esm({ + "packages/junior/src/db/executor.ts"() { + "use strict"; + init_neon(); + init_postgres(); + }, +}); + +// packages/junior/src/chat/db.ts +var init_db = __esm({ + "packages/junior/src/chat/db.ts"() { + "use strict"; + init_config(); + init_store(); + init_history2(); + init_messages2(); + init_search(); + init_executor(); + }, +}); + +// packages/junior/src/chat/conversations/visible-compactions.ts +var init_visible_compactions = __esm({ + "packages/junior/src/chat/conversations/visible-compactions.ts"() { + "use strict"; + init_db(); + }, +}); + +// packages/junior/src/chat/sentry.ts +var sentry_exports = {}; +__export(sentry_exports, { + captureException: () => captureException, + captureMessage: () => captureMessage, + continueTrace: () => continueTrace, + flush: () => flush, + getActiveSpan: () => getActiveSpan, + getClient: () => getClient, + getGlobalScope: () => getGlobalScope, + getTraceData: () => getTraceData, + init: () => init, + logger: () => logger, + setTag: () => setTag, + setUser: () => setUser, + spanToJSON: () => spanToJSON, + startInactiveSpan: () => startInactiveSpan, + startSpan: () => startSpan, + vercelAIIntegration: () => vercelAIIntegration, + withActiveSpan: () => withActiveSpan, + withScope: () => withScope, + withStreamedSpan: () => withStreamedSpan, +}); +import { + captureException, + captureMessage, + continueTrace, + flush, + getActiveSpan, + getClient, + getGlobalScope, + getTraceData, + init, + logger, + setTag, + setUser, + spanToJSON, + startInactiveSpan, + startSpan, + vercelAIIntegration, + withActiveSpan, + withScope, + withStreamedSpan, +} from "@sentry/node"; +import * as node_star from "@sentry/node"; +var init_sentry = __esm({ + "packages/junior/src/chat/sentry.ts"() { + "use strict"; + __reExport(sentry_exports, node_star); + }, +}); + +// packages/junior/src/deployment.ts +function toOptionalTrimmed(value) { + const trimmed = value?.trim(); + return trimmed ? trimmed : void 0; +} +function getDeploymentServiceVersion() { + return ( + toOptionalTrimmed(process.env.SENTRY_RELEASE) ?? + toOptionalTrimmed(process.env.VERCEL_GIT_COMMIT_SHA) + ); +} +function getDeploymentTelemetryAttributes() { + const attributes = {}; + const serviceVersion = getDeploymentServiceVersion(); + const deploymentId = toOptionalTrimmed(process.env.VERCEL_DEPLOYMENT_ID); + if (serviceVersion) { + attributes["service.version"] = serviceVersion; + } + if (deploymentId) { + attributes["deployment.id"] = deploymentId; + } + return attributes; +} +var init_deployment = __esm({ + "packages/junior/src/deployment.ts"() { + "use strict"; + }, +}); + +// packages/junior/src/chat/logging.ts +import { AsyncLocalStorage as AsyncLocalStorage3 } from "node:async_hooks"; +import { + ConfigError, + configureSync, + getConfig, + getLogger, +} from "@logtape/logtape"; +var contextStorage, + deploymentLogAttributes, + CONSOLE_PRIORITY_KEYS, + CONSOLE_PRIORITY_INDEX; +var init_logging = __esm({ + "packages/junior/src/chat/logging.ts"() { + "use strict"; + init_coerce(); + init_identity(); + init_sentry(); + init_sentry(); + init_deployment(); + contextStorage = new AsyncLocalStorage3(); + deploymentLogAttributes = getDeploymentTelemetryAttributes(); + CONSOLE_PRIORITY_KEYS = [ + "gen_ai.conversation.id", + "event.name", + "app.log.source", + "exception.message", + "messaging.message.id", + "trace_id", + "span_id", + "messaging.message.conversation_id", + "messaging.destination.name", + "app.run.id", + "app.message.kind", + ]; + CONSOLE_PRIORITY_INDEX = new Map( + CONSOLE_PRIORITY_KEYS.map((key, index6) => [key, index6]), + ); + }, +}); + +// packages/junior/src/chat/pi/sdk.ts +import { + completeSimple, + getEnvApiKey, + getModels, + registerApiProvider, +} from "@earendil-works/pi-ai/compat"; +import { + stream, + streamSimple, +} from "@earendil-works/pi-ai/api/anthropic-messages"; +var init_sdk = __esm({ + "packages/junior/src/chat/pi/sdk.ts"() { + "use strict"; + }, +}); + +// packages/junior/src/chat/optional-string.ts +var init_optional_string = __esm({ + "packages/junior/src/chat/optional-string.ts"() { + "use strict"; + }, +}); + +// packages/junior/src/chat/slack/context.ts +import { z as z10 } from "zod"; +var slackMessageEnvelopeSchema, + rawSlackMessageSchema, + nestedRawSlackMessageSchema; +var init_context = __esm({ + "packages/junior/src/chat/slack/context.ts"() { + "use strict"; + init_coerce(); + init_ids(); + init_timestamp(); + slackMessageEnvelopeSchema = z10.object({ + raw: z10.unknown().optional(), + }); + rawSlackMessageSchema = z10.object({ + channel: z10.unknown().optional(), + message: z10.unknown().optional(), + team: z10.unknown().optional(), + team_id: z10.unknown().optional(), + thread_ts: z10.unknown().optional(), + ts: z10.unknown().optional(), + user_team: z10.unknown().optional(), + }); + nestedRawSlackMessageSchema = z10.object({ + ts: z10.unknown().optional(), + }); + }, +}); + +// packages/junior/src/chat/conversation-privacy.ts +import { AsyncLocalStorage as AsyncLocalStorage4 } from "node:async_hooks"; +var conversationPrivacyStorage; +var init_conversation_privacy = __esm({ + "packages/junior/src/chat/conversation-privacy.ts"() { + "use strict"; + init_context(); + conversationPrivacyStorage = new AsyncLocalStorage4(); + }, +}); + +// packages/junior/src/chat/xml.ts +var init_xml = __esm({ + "packages/junior/src/chat/xml.ts"() { + "use strict"; + }, +}); + +// packages/junior/src/chat/current-instruction.ts +var CURRENT_INSTRUCTION_TAG, + CURRENT_INSTRUCTION_OPEN_PREFIX, + CURRENT_INSTRUCTION_OPEN_BARE, + CURRENT_INSTRUCTION_OPEN_ATTR_PREFIX, + CURRENT_INSTRUCTION_CLOSE; +var init_current_instruction = __esm({ + "packages/junior/src/chat/current-instruction.ts"() { + "use strict"; + init_xml(); + CURRENT_INSTRUCTION_TAG = "current-instruction"; + CURRENT_INSTRUCTION_OPEN_PREFIX = `<${CURRENT_INSTRUCTION_TAG}`; + CURRENT_INSTRUCTION_OPEN_BARE = `<${CURRENT_INSTRUCTION_TAG}>`; + CURRENT_INSTRUCTION_OPEN_ATTR_PREFIX = `<${CURRENT_INSTRUCTION_TAG} `; + CURRENT_INSTRUCTION_CLOSE = ` +`; + }, +}); + +// packages/junior/src/chat/turn-context-tag.ts +var TURN_CONTEXT_TAG; +var init_turn_context_tag = __esm({ + "packages/junior/src/chat/turn-context-tag.ts"() { + "use strict"; + TURN_CONTEXT_TAG = "runtime-turn-context"; + }, +}); + +// packages/junior/src/chat/pi/transcript.ts +var RUNTIME_TURN_CONTEXT_START, + EMBEDDED_THREAD_CONTEXT_TAGS, + EMBEDDED_THREAD_CONTEXT_PATTERN; +var init_transcript = __esm({ + "packages/junior/src/chat/pi/transcript.ts"() { + "use strict"; + init_current_instruction(); + init_turn_context_tag(); + RUNTIME_TURN_CONTEXT_START = `<${TURN_CONTEXT_TAG}>`; + EMBEDDED_THREAD_CONTEXT_TAGS = [ + "recent-thread-messages", + "thread-compactions", + "thread-transcript", + "thread-background", + ]; + EMBEDDED_THREAD_CONTEXT_PATTERN = new RegExp( + `<(${EMBEDDED_THREAD_CONTEXT_TAGS.join("|")})(?:\\s[^>]*)?>[\\s\\S]*?`, + "g", + ); + }, +}); + +// packages/junior/src/chat/services/provider-retry.ts +var init_provider_retry = __esm({ + "packages/junior/src/chat/services/provider-retry.ts"() { + "use strict"; + init_transcript(); + }, +}); + +// packages/junior/src/chat/pi/client.ts +import { createGatewayProvider } from "@ai-sdk/gateway"; +import { embedMany, generateObject } from "ai"; +var init_client = __esm({ + "packages/junior/src/chat/pi/client.ts"() { + "use strict"; + init_sdk(); + init_logging(); + init_logging(); + init_optional_string(); + init_conversation_privacy(); + init_provider_retry(); + registerApiProvider({ + api: "anthropic-messages", + stream, + streamSimple, + }); + }, +}); + +// packages/junior/src/chat/services/context-budget.ts +var init_context_budget = __esm({ + "packages/junior/src/chat/services/context-budget.ts"() { + "use strict"; + init_config(); + init_client(); + }, +}); + +// packages/junior/src/chat/services/conversation-memory.ts +var init_conversation_memory = __esm({ + "packages/junior/src/chat/services/conversation-memory.ts"() { + "use strict"; + init_config(); + init_logging(); + init_context_budget(); + init_timestamp(); + init_xml(); + }, +}); + +// packages/junior/src/chat/conversations/visible-messages.ts +var init_visible_messages = __esm({ + "packages/junior/src/chat/conversations/visible-messages.ts"() { + "use strict"; + init_db(); + init_visible_compactions(); + init_conversation_memory(); + }, +}); + +// packages/junior/src/chat/conversations/legacy-advisor-session.ts +import { z as z11 } from "zod"; +var legacyAdvisorMessagesSchema; +var init_legacy_advisor_session = __esm({ + "packages/junior/src/chat/conversations/legacy-advisor-session.ts"() { + "use strict"; + init_messages(); + init_adapter(); + legacyAdvisorMessagesSchema = z11.array(piMessageSchema); + }, +}); + +// packages/junior/src/chat/conversations/sql/legacy-history-import.ts +import { eq as eq6, sql as sql10 } from "drizzle-orm"; +var init_legacy_history_import = __esm({ + "packages/junior/src/chat/conversations/sql/legacy-history-import.ts"() { + "use strict"; + init_xml(); + init_session_log(); + init_history(); + init_schema(); + }, +}); + +// packages/junior/src/chat/conversations/legacy-import.ts +import { eq as eq7 } from "drizzle-orm"; +import { z as z12 } from "zod"; +var legacyVisibleMessageSchema, + legacyCompactionSchema, + legacyThreadStateSnapshotSchema; +var init_legacy_import = __esm({ + "packages/junior/src/chat/conversations/legacy-import.ts"() { + "use strict"; + init_visible_messages(); + init_adapter(); + init_session_log(); + init_legacy_advisor_session(); + init_schema(); + init_db(); + init_history2(); + init_legacy_history_import(); + legacyVisibleMessageSchema = z12.object({ + id: z12.string(), + role: z12.enum(["user", "assistant", "system"]), + text: z12.string(), + createdAtMs: z12.number().finite(), + author: z12.object({}).passthrough().optional(), + meta: z12.object({}).passthrough().optional(), + }); + legacyCompactionSchema = z12.object({ + coveredMessageIds: z12.array(z12.string()), + createdAtMs: z12.number().finite(), + id: z12.string(), + summary: z12.string(), + }); + legacyThreadStateSnapshotSchema = z12.object({ + conversation: z12 + .object({ + compactions: z12.array(legacyCompactionSchema).optional(), + messages: z12.array(legacyVisibleMessageSchema).optional(), + stats: z12 + .object({ updatedAtMs: z12.number().finite().optional() }) + .passthrough() + .optional(), + }) + .passthrough() + .optional(), + }); + }, +}); + +// packages/junior/src/cli/upgrade/migrations/conversations-sql.ts +init_config(); +init_store(); + +// packages/junior/src/chat/task-execution/state.ts +init_coerce(); +init_config(); +init_destination(); +init_actor(); +init_adapter(); + +// packages/junior/src/chat/state/ttl.ts +var JUNIOR_THREAD_STATE_TTL_MS = 7 * 24 * 60 * 60 * 1e3; + +// packages/junior/src/chat/task-execution/state.ts +var CONVERSATION_PREFIX = "junior:conversation"; +var CONVERSATION_SCHEMA_VERSION = 1; +var CONVERSATION_ACTIVITY_INDEX_MAX_LENGTH = 1e4; +var CONVERSATION_INDEX_LOCK_TTL_MS = 1e4; +var CONVERSATION_INDEX_LOCK_WAIT_MS = 2e3; +var CONVERSATION_INDEX_LOCK_RETRY_MS = 25; +var CONVERSATION_MUTATION_LOCK_TTL_MS = 1e4; +var CONVERSATION_MUTATION_WAIT_MS = 1e4; +var CONVERSATION_MUTATION_RETRY_MS = 25; +var InvalidConversationRecordError = class extends Error { + constructor(conversationId) { + super(`Conversation record is invalid for ${conversationId}`); + this.name = "InvalidConversationRecordError"; + } +}; +var ConversationMutationFencedError = class extends Error { + constructor(conversationId) { + super( + `Conversation mutation lock was lost before write for ${conversationId}`, + ); + this.name = "ConversationMutationFencedError"; + } +}; +var CONVERSATION_BY_ACTIVITY_INDEX_KEY = `${CONVERSATION_PREFIX}:by-activity`; +var CONVERSATION_ACTIVE_INDEX_KEY = `${CONVERSATION_PREFIX}:active`; +function conversationKey(conversationId) { + return `${CONVERSATION_PREFIX}:${conversationId}`; +} +function indexLockKey(indexKey) { + return `${indexKey}:lock`; +} +function mutationLockKey(conversationId) { + return `${CONVERSATION_PREFIX}:mutation:${conversationId}`; +} +function now2() { + return Date.now(); +} +function compareMessages(left, right) { + return ( + left.createdAtMs - right.createdAtMs || + left.receivedAtMs - right.receivedAtMs || + left.inboundMessageId.localeCompare(right.inboundMessageId) + ); +} +function compareIndexDescending(left, right) { + return ( + right.score - left.score || + right.conversationId.localeCompare(left.conversationId) + ); +} +function compareIndexAscending(left, right) { + return ( + left.score - right.score || + left.conversationId.localeCompare(right.conversationId) + ); +} +function uniqueStrings(values) { + return [...new Set(values)]; +} +function normalizeSource(value) { + if ( + value === "api" || + value === "internal" || + value === "local" || + value === "plugin" || + value === "resource_event" || + value === "scheduler" || + value === "slack" + ) { + return value; + } + return void 0; +} +function normalizeExecutionStatus(value) { + if ( + value === "awaiting_resume" || + value === "failed" || + value === "idle" || + value === "pending" || + value === "running" + ) { + return value; + } + return void 0; +} +function normalizeMetadata(value) { + if (!isRecord(value)) { + return void 0; + } + return value; +} +function normalizeInput(value) { + if (!isRecord(value)) { + return void 0; + } + const text7 = toOptionalString(value.text); + if (!text7) { + return void 0; + } + return { + text: text7, + authorId: toOptionalString(value.authorId), + attachments: Array.isArray(value.attachments) + ? [...value.attachments] + : void 0, + metadata: normalizeMetadata(value.metadata), + }; +} +function normalizeMessage(value) { + if (!isRecord(value)) { + return void 0; + } + const conversationId = toOptionalString(value.conversationId); + const inboundMessageId = toOptionalString(value.inboundMessageId); + const source = normalizeSource(value.source); + const destination = parseDestination(value.destination); + const createdAtMs = toOptionalNumber(value.createdAtMs); + const receivedAtMs = toOptionalNumber(value.receivedAtMs); + const input = normalizeInput(value.input); + if ( + !conversationId || + !destination || + !inboundMessageId || + !source || + typeof createdAtMs !== "number" || + typeof receivedAtMs !== "number" || + !input + ) { + return void 0; + } + return { + conversationId, + destination, + inboundMessageId, + source, + createdAtMs, + receivedAtMs, + input, + injectedAtMs: toOptionalNumber(value.injectedAtMs), + attemptCount: toOptionalNumber(value.attemptCount), + }; +} +function normalizeActor(value) { + return parseStoredSlackActor(value); +} +function normalizeLease(value) { + if (!isRecord(value)) { + return void 0; + } + const token = toOptionalString(value.token); + const acquiredAtMs = toOptionalNumber(value.acquiredAtMs); + const lastCheckInAtMs = toOptionalNumber(value.lastCheckInAtMs); + const expiresAtMs = toOptionalNumber(value.expiresAtMs); + if ( + !token || + typeof acquiredAtMs !== "number" || + typeof lastCheckInAtMs !== "number" || + typeof expiresAtMs !== "number" + ) { + return void 0; + } + return { + token, + acquiredAtMs, + lastCheckInAtMs, + expiresAtMs, + }; +} +function normalizeExecution(conversationId, value) { + if (!isRecord(value)) { + return void 0; + } + const status = normalizeExecutionStatus(value.status); + if (!status) { + return void 0; + } + const pendingMessages2 = Array.isArray(value.pendingMessages) + ? value.pendingMessages + .map(normalizeMessage) + .filter((message) => Boolean(message)) + .filter((message) => message.conversationId === conversationId) + .filter((message) => message.injectedAtMs === void 0) + .sort(compareMessages) + : []; + const inboundMessageIds = Array.isArray(value.inboundMessageIds) + ? uniqueStrings( + value.inboundMessageIds + .map((id) => (typeof id === "string" ? id : void 0)) + .filter((id) => Boolean(id)), + ) + : []; + const lease = normalizeLease(value.lease); + const normalizedStatus = + status === "idle" && lease + ? "running" + : status === "idle" && pendingMessages2.length > 0 + ? "pending" + : status; + return { + status: normalizedStatus, + inboundMessageIds: uniqueStrings([ + ...inboundMessageIds, + ...pendingMessages2.map((message) => message.inboundMessageId), + ]), + pendingCount: pendingMessages2.length, + pendingMessages: pendingMessages2, + lease, + lastCheckpointAtMs: toOptionalNumber(value.lastCheckpointAtMs), + lastEnqueuedAtMs: toOptionalNumber(value.lastEnqueuedAtMs), + runId: toOptionalString(value.runId), + updatedAtMs: toOptionalNumber(value.updatedAtMs), + }; +} +function normalizeConversation(conversationId, value) { + if (!isRecord(value) || value.schemaVersion !== CONVERSATION_SCHEMA_VERSION) { + return void 0; + } + const storedConversationId = toOptionalString(value.conversationId); + const createdAtMs = toOptionalNumber(value.createdAtMs); + const lastActivityAtMs = toOptionalNumber(value.lastActivityAtMs); + const updatedAtMs = toOptionalNumber(value.updatedAtMs); + const execution = normalizeExecution(conversationId, value.execution); + const destination = + value.destination === void 0 ? void 0 : parseDestination(value.destination); + if ( + storedConversationId !== conversationId || + typeof createdAtMs !== "number" || + typeof lastActivityAtMs !== "number" || + typeof updatedAtMs !== "number" || + !execution || + (value.destination !== void 0 && !destination) + ) { + return void 0; + } + if ( + execution.pendingMessages.length > 0 && + (!destination || + execution.pendingMessages.some( + (message) => !sameDestination(message.destination, destination), + )) + ) { + return void 0; + } + return { + schemaVersion: CONVERSATION_SCHEMA_VERSION, + conversationId, + createdAtMs, + lastActivityAtMs, + updatedAtMs, + execution, + ...(destination ? { destination } : {}), + ...(toOptionalString(value.title) + ? { title: toOptionalString(value.title) } + : {}), + ...(toOptionalString(value.channelName) + ? { channelName: toOptionalString(value.channelName) } + : {}), + ...(normalizeActor(value.actor) + ? { actor: normalizeActor(value.actor) } + : {}), + ...(normalizeSource(value.source) + ? { source: normalizeSource(value.source) } + : {}), + }; +} +function emptyConversation2(args) { + return { + schemaVersion: CONVERSATION_SCHEMA_VERSION, + conversationId: args.conversationId, + createdAtMs: args.nowMs, + lastActivityAtMs: args.nowMs, + updatedAtMs: args.nowMs, + ...(args.destination ? { destination: args.destination } : {}), + ...(args.source ? { source: args.source } : {}), + execution: { + status: "idle", + inboundMessageIds: [], + pendingCount: 0, + pendingMessages: [], + updatedAtMs: args.nowMs, + }, + }; +} +function pendingMessages(conversation) { + return [...conversation.execution.pendingMessages].sort(compareMessages); +} +function isRunnableStatus(status) { + return status !== "failed" && status !== "idle"; +} +function hasRunnableWork(conversation) { + return ( + isRunnableStatus(conversation.execution.status) || + pendingMessages(conversation).length > 0 + ); +} +function executionWithPendingMessages(execution, pending) { + const pendingMessages2 = [...pending].sort(compareMessages); + const status = + execution.status === "idle" && execution.lease + ? "running" + : execution.status === "idle" && pendingMessages2.length > 0 + ? "pending" + : execution.status; + return { + ...execution, + status, + inboundMessageIds: uniqueStrings([ + ...execution.inboundMessageIds, + ...pendingMessages2.map((message) => message.inboundMessageId), + ]), + pendingMessages: pendingMessages2, + pendingCount: pendingMessages2.length, + }; +} +function withExecutionUpdate(conversation, execution, nowMs) { + return { + ...conversation, + updatedAtMs: nowMs, + execution: { + ...executionWithPendingMessages(execution, execution.pendingMessages), + updatedAtMs: nowMs, + }, + }; +} +async function getConnectedState(stateAdapter2) { + const state = stateAdapter2 ?? getStateAdapter(); + await state.connect(); + return state; +} +async function sleep(ms) { + await new Promise((resolve) => { + const timer = setTimeout(resolve, ms); + timer.unref?.(); + }); +} +async function withIndexLock(state, indexKey, callback) { + const startedAtMs = now2(); + let lock; + while (true) { + lock = await state.acquireLock( + indexLockKey(indexKey), + CONVERSATION_INDEX_LOCK_TTL_MS, + ); + if (lock) { + break; + } + if (now2() - startedAtMs >= CONVERSATION_INDEX_LOCK_WAIT_MS) { + throw new Error( + `Could not acquire conversation index lock for ${indexKey}`, + ); + } + await sleep(CONVERSATION_INDEX_LOCK_RETRY_MS); + } + try { + return await callback(); + } finally { + await state.releaseLock(lock); + } +} +function normalizeIndexEntry(value) { + if (!isRecord(value)) { + return void 0; + } + const conversationId = toOptionalString(value.conversationId); + const score = toOptionalNumber(value.score); + if (!conversationId || typeof score !== "number") { + return void 0; + } + return { conversationId, score }; +} +function uniqueIndexEntries(value) { + if (!Array.isArray(value)) { + return []; + } + const entries = /* @__PURE__ */ new Map(); + for (const item of value) { + const entry = normalizeIndexEntry(item); + if (!entry) { + continue; + } + const existing = entries.get(entry.conversationId); + if (!existing || entry.score > existing.score) { + entries.set(entry.conversationId, entry); + } + } + return [...entries.values()]; +} +function retainedIndexEntries(indexKey, entries) { + if (indexKey === CONVERSATION_BY_ACTIVITY_INDEX_KEY) { + return entries + .sort(compareIndexDescending) + .slice(0, CONVERSATION_ACTIVITY_INDEX_MAX_LENGTH); + } + if (indexKey === CONVERSATION_ACTIVE_INDEX_KEY) { + return entries.sort(compareIndexAscending); + } + throw new Error(`Unknown conversation index ${indexKey}`); +} +function redisIndexKey(indexKey) { + const prefix = getChatConfig().state.keyPrefix; + return [...(prefix ? [prefix] : []), indexKey].join(":"); +} +function parseRedisIndexEntries(values) { + if (!Array.isArray(values)) { + return []; + } + const entries = []; + for (let index6 = 0; index6 < values.length; index6 += 2) { + const conversationId = toOptionalString(values[index6]); + const score = + typeof values[index6 + 1] === "number" + ? values[index6 + 1] + : Number(values[index6 + 1]); + if (!conversationId || !Number.isFinite(score)) { + continue; + } + entries.push({ conversationId, score }); + } + return entries; +} +function redisConversationIndexStore(client) { + const upsertBoundedActivityScript = ` + redis.call("ZADD", KEYS[1], ARGV[1], ARGV[2]) + redis.call("PEXPIRE", KEYS[1], ARGV[3]) + local extra = redis.call("ZCARD", KEYS[1]) - tonumber(ARGV[4]) + if extra > 0 then + redis.call("ZREMRANGEBYRANK", KEYS[1], 0, extra - 1) + end + return 1 + `; + return { + async list(args) { + const key = redisIndexKey(args.indexKey); + const limit = args.limit; + const offset = Math.max(0, args.offset ?? 0); + if (limit === 0) { + return []; + } + const values = + args.scoreMax !== void 0 + ? await client.sendCommand([ + "ZRANGEBYSCORE", + key, + "-inf", + String(args.scoreMax), + "WITHSCORES", + ...(limit !== void 0 || offset > 0 + ? ["LIMIT", String(offset), String(limit ?? 1e9)] + : []), + ]) + : await client.sendCommand([ + args.order === "asc" ? "ZRANGE" : "ZREVRANGE", + key, + String(offset), + String(limit === void 0 ? -1 : offset + Math.max(0, limit - 1)), + "WITHSCORES", + ]); + return parseRedisIndexEntries(values); + }, + async remove(args) { + await client.sendCommand([ + "ZREM", + redisIndexKey(args.indexKey), + args.conversationId, + ]); + }, + async upsert(args) { + const key = redisIndexKey(args.indexKey); + if (args.indexKey === CONVERSATION_BY_ACTIVITY_INDEX_KEY) { + await client.sendCommand([ + "EVAL", + upsertBoundedActivityScript, + "1", + key, + String(args.score), + args.conversationId, + String(JUNIOR_THREAD_STATE_TTL_MS), + String(CONVERSATION_ACTIVITY_INDEX_MAX_LENGTH), + ]); + return; + } + if (args.indexKey === CONVERSATION_ACTIVE_INDEX_KEY) { + await client.sendCommand([ + "ZADD", + key, + String(args.score), + args.conversationId, + ]); + await client.sendCommand([ + "PEXPIRE", + key, + String(JUNIOR_THREAD_STATE_TTL_MS), + ]); + return; + } + throw new Error(`Unknown conversation index ${args.indexKey}`); + }, + }; +} +function emulatedConversationIndexStore(state) { + const readIndex = async (indexKey) => + uniqueIndexEntries(await state.get(indexKey)); + const writeIndex = async (indexKey, entries) => { + await state.set(indexKey, entries, JUNIOR_THREAD_STATE_TTL_MS); + }; + return { + async list(args) { + const entries = (await readIndex(args.indexKey)) + .filter((entry) => + args.scoreMax === void 0 ? true : entry.score <= args.scoreMax, + ) + .sort( + args.order === "asc" ? compareIndexAscending : compareIndexDescending, + ); + const offset = Math.max(0, args.offset ?? 0); + return entries.slice( + offset, + args.limit === void 0 ? entries.length : offset + args.limit, + ); + }, + async remove(args) { + await withIndexLock(state, args.indexKey, async () => { + const entries = await readIndex(args.indexKey); + const next = entries.filter( + (entry) => entry.conversationId !== args.conversationId, + ); + if (next.length === entries.length) { + return; + } + await writeIndex(args.indexKey, next); + }); + }, + async upsert(args) { + await withIndexLock(state, args.indexKey, async () => { + const entries = await readIndex(args.indexKey); + const withoutCurrent = entries.filter( + (entry) => entry.conversationId !== args.conversationId, + ); + const next = retainedIndexEntries(args.indexKey, [ + ...withoutCurrent, + { conversationId: args.conversationId, score: args.score }, + ]); + await writeIndex(args.indexKey, next); + }); + }, + }; +} +async function getConversationIndexStore(state) { + const redisStateAdapter2 = await getDefaultRedisStateAdapterFor(state); + if (redisStateAdapter2) { + return redisConversationIndexStore(redisStateAdapter2.getClient()); + } + return emulatedConversationIndexStore(state); +} +async function upsertIndexEntry(args) { + const index6 = await getConversationIndexStore(args.state); + await index6.upsert({ + conversationId: args.conversationId, + indexKey: args.indexKey, + score: args.score, + }); +} +async function removeIndexEntry(args) { + const index6 = await getConversationIndexStore(args.state); + await index6.remove({ + conversationId: args.conversationId, + indexKey: args.indexKey, + }); +} +async function acquireMutationLock(state, conversationId) { + const startedAtMs = now2(); + while (true) { + const lock = await state.acquireLock( + mutationLockKey(conversationId), + CONVERSATION_MUTATION_LOCK_TTL_MS, + ); + if (lock) { + return lock; + } + if (now2() - startedAtMs >= CONVERSATION_MUTATION_WAIT_MS) { + throw new Error( + `Could not acquire conversation mutation lock for ${conversationId}`, + ); + } + await sleep(CONVERSATION_MUTATION_RETRY_MS); + } +} +async function withConversationMutation(args, callback) { + const state = await getConnectedState(args.state); + const lock = await acquireMutationLock(state, args.conversationId); + try { + return await callback(state, lock); + } finally { + await state.releaseLock(lock); + } +} +async function readConversation(state, conversationId) { + const raw = await state.get(conversationKey(conversationId)); + if (raw == null) { + return void 0; + } + const conversation = normalizeConversation(conversationId, raw); + if (!conversation) { + throw new InvalidConversationRecordError(conversationId); + } + return conversation; +} +async function writeConversation(state, lock, conversation) { + const execution = executionWithPendingMessages( + conversation.execution, + conversation.execution.pendingMessages, + ); + const next = { + ...conversation, + execution, + }; + const fenced = await state.extendLock( + lock, + CONVERSATION_MUTATION_LOCK_TTL_MS, + ); + if (!fenced) { + throw new ConversationMutationFencedError(next.conversationId); + } + await state.set( + conversationKey(next.conversationId), + next, + JUNIOR_THREAD_STATE_TTL_MS, + ); + await upsertIndexEntry({ + state, + indexKey: CONVERSATION_BY_ACTIVITY_INDEX_KEY, + conversationId: next.conversationId, + score: next.lastActivityAtMs, + }); + if (!hasRunnableWork(next)) { + await removeIndexEntry({ + state, + indexKey: CONVERSATION_ACTIVE_INDEX_KEY, + conversationId: next.conversationId, + }); + return; + } + await upsertIndexEntry({ + state, + indexKey: CONVERSATION_ACTIVE_INDEX_KEY, + conversationId: next.conversationId, + score: next.execution.updatedAtMs ?? next.updatedAtMs, + }); +} +function assertSameConversationDestination2(args) { + if (!args.current || sameDestination(args.current, args.next)) { + return; + } + throw new Error( + `Conversation destination changed for ${args.conversationId}`, + ); +} +async function getConversation(args) { + const state = await getConnectedState(args.state); + return await readConversation(state, args.conversationId); +} +async function recordConversationActivity(args) { + const nowMs = args.nowMs ?? now2(); + const activityAtMs = args.activityAtMs ?? nowMs; + await withConversationMutation(args, async (state, lock) => { + const existing = await readConversation(state, args.conversationId); + if (existing && args.destination) { + assertSameConversationDestination2({ + conversationId: args.conversationId, + current: existing.destination, + next: args.destination, + }); + } + const current = + existing ?? + emptyConversation2({ + conversationId: args.conversationId, + destination: args.destination, + nowMs, + source: args.source, + }); + await writeConversation(state, lock, { + ...current, + ...((current.destination ?? args.destination) + ? { destination: current.destination ?? args.destination } + : {}), + ...((current.source ?? args.source) + ? { source: current.source ?? args.source } + : {}), + ...((current.channelName ?? args.channelName) + ? { channelName: current.channelName ?? args.channelName } + : {}), + ...((current.actor ?? args.actor) + ? { actor: current.actor ?? args.actor } + : {}), + ...((current.title ?? args.title) + ? { title: current.title ?? args.title } + : {}), + lastActivityAtMs: Math.max(current.lastActivityAtMs, activityAtMs), + updatedAtMs: nowMs, + execution: executionWithPendingMessages( + current.execution, + current.execution.pendingMessages, + ), + }); + }); +} +async function recordConversationExecution(args) { + const nowMs = args.updatedAtMs; + await withConversationMutation(args, async (state, lock) => { + const existing = await readConversation(state, args.conversationId); + if (existing && args.destination) { + assertSameConversationDestination2({ + conversationId: args.conversationId, + current: existing.destination, + next: args.destination, + }); + } + const current = + existing ?? + emptyConversation2({ + conversationId: args.conversationId, + destination: args.destination, + nowMs, + source: args.source, + }); + await writeConversation( + state, + lock, + withExecutionUpdate( + { + ...current, + ...((current.destination ?? args.destination) + ? { destination: current.destination ?? args.destination } + : {}), + ...((current.source ?? args.source) + ? { source: current.source ?? args.source } + : {}), + ...((current.channelName ?? args.channelName) + ? { channelName: current.channelName ?? args.channelName } + : {}), + ...((current.actor ?? args.actor) + ? { actor: current.actor ?? args.actor } + : {}), + ...((current.title ?? args.title) + ? { title: current.title ?? args.title } + : {}), + createdAtMs: Math.min(current.createdAtMs, args.createdAtMs), + lastActivityAtMs: Math.max( + current.lastActivityAtMs, + args.lastActivityAtMs, + ), + }, + { + ...current.execution, + ...args.execution, + }, + nowMs, + ), + ); + }); +} +async function listConversationsByActivity(args = {}) { + const state = await getConnectedState(args.state); + const index6 = await getConversationIndexStore(state); + const entries = await index6.list({ + indexKey: CONVERSATION_BY_ACTIVITY_INDEX_KEY, + limit: args.limit ?? CONVERSATION_ACTIVITY_INDEX_MAX_LENGTH, + offset: args.offset, + order: "desc", + }); + const conversations = []; + for (const entry of entries) { + try { + const conversation = await readConversation(state, entry.conversationId); + if (conversation) { + conversations.push(conversation); + } + } catch (error) { + if (!(error instanceof InvalidConversationRecordError)) { + throw error; + } + await removeIndexEntry({ + state, + indexKey: CONVERSATION_BY_ACTIVITY_INDEX_KEY, + conversationId: entry.conversationId, + }); + } + } + return conversations; +} + +// packages/junior/src/chat/conversations/state.ts +function createStateConversationStore(state) { + return { + get: (args) => getConversation({ ...args, state }), + // Task-execution state has no destination records, so visibility is never + // source-confirmed here and cross-context reads fail closed to private. + getDestinationVisibility: async () => void 0, + // Child conversations are a SQL-only concept and never use this legacy + // metadata path. + ensureChildConversation: async () => void 0, + recordActivity: (args) => recordConversationActivity({ ...args, state }), + recordExecution: (args) => recordConversationExecution({ ...args, state }), + listByActivity: (args) => listConversationsByActivity({ ...args, state }), + }; +} + +// packages/junior/src/usage-schema.ts +import { z as z5 } from "zod"; +var usageCostSchema = z5 + .object({ + input: z5.number().finite().nonnegative().optional(), + output: z5.number().finite().nonnegative().optional(), + cacheRead: z5.number().finite().nonnegative().optional(), + cacheWrite: z5.number().finite().nonnegative().optional(), + total: z5.number().finite().nonnegative().optional(), + }) + .strict(); +var usageSchema = z5 + .object({ + inputTokens: z5.number().int().nonnegative().optional(), + outputTokens: z5.number().int().nonnegative().optional(), + cachedInputTokens: z5.number().int().nonnegative().optional(), + cacheCreationTokens: z5.number().int().nonnegative().optional(), + reasoningTokens: z5.number().int().nonnegative().optional(), + totalTokens: z5.number().int().nonnegative().optional(), + cost: usageCostSchema.optional(), + }) + .strict(); + +// packages/junior/src/chat/usage.ts +var agentTurnUsageSchema = usageSchema; +var COMPONENT_USAGE_FIELDS = [ + "inputTokens", + "outputTokens", + "cachedInputTokens", + "cacheCreationTokens", +]; +var COST_COMPONENT_FIELDS = ["input", "output", "cacheRead", "cacheWrite"]; +function hasAgentTurnUsage(usage) { + return Boolean( + usage && + (Object.entries(usage).some( + ([field, value]) => + field !== "cost" && typeof value === "number" && Number.isFinite(value), + ) || + Object.values(usage.cost ?? {}).some( + (value) => typeof value === "number" && Number.isFinite(value), + )), + ); +} +function getFiniteCount(value) { + return typeof value === "number" && Number.isFinite(value) + ? Math.max(0, Math.floor(value)) + : void 0; +} +function getFiniteCost(value) { + return typeof value === "number" && Number.isFinite(value) + ? Math.max(0, value) + : void 0; +} +function addCost(left, right) { + return Math.round(((left ?? 0) + right) * 1e12) / 1e12; +} +function getComponentTotal(usage) { + let total; + for (const field of COMPONENT_USAGE_FIELDS) { + const value = getFiniteCount(usage[field]); + if (value === void 0) continue; + total = (total ?? 0) + value; + } + return total; +} +function addAgentTurnUsage(...usages) { + const components = {}; + let componentTotal; + let totalOnlyTokens; + let reasoningTokens; + const cost = {}; + for (const usage of usages) { + if (!usage) continue; + const reasoning = getFiniteCount(usage.reasoningTokens); + if (reasoning !== void 0) { + reasoningTokens = (reasoningTokens ?? 0) + reasoning; + } + if (usage.cost) { + for (const field of [...COST_COMPONENT_FIELDS, "total"]) { + const value = getFiniteCost(usage.cost[field]); + if (value === void 0) continue; + cost[field] = addCost(cost[field], value); + } + } + const usageComponentTotal = getComponentTotal(usage); + if (usageComponentTotal !== void 0) { + componentTotal = (componentTotal ?? 0) + usageComponentTotal; + for (const field of COMPONENT_USAGE_FIELDS) { + const value = getFiniteCount(usage[field]); + if (value === void 0) continue; + components[field] = (components[field] ?? 0) + value; + } + continue; + } + const totalTokens = getFiniteCount(usage.totalTokens); + if (totalTokens !== void 0) { + totalOnlyTokens = (totalOnlyTokens ?? 0) + totalTokens; + } + } + if (totalOnlyTokens !== void 0) { + return { + totalTokens: totalOnlyTokens + (componentTotal ?? 0), + ...(reasoningTokens !== void 0 ? { reasoningTokens } : {}), + ...(Object.keys(cost).length > 0 ? { cost } : {}), + }; + } + const result = { + ...components, + ...(reasoningTokens !== void 0 ? { reasoningTokens } : {}), + ...(Object.keys(cost).length > 0 ? { cost } : {}), + }; + return hasAgentTurnUsage(result) ? result : void 0; +} + +// packages/junior/src/chat/state/turn-session.ts +init_actor(); +init_session_log(); +import { THREAD_STATE_TTL_MS } from "chat"; +import { + actorSchema as actorSchema3, + destinationSchema as destinationSchema2, + sourceSchema, +} from "@sentry/junior-plugin-api"; +import { z as z13 } from "zod"; + +// packages/junior/src/chat/conversations/projection.ts +init_session_log(); +init_db(); +init_legacy_import(); + +// packages/junior/src/chat/state/turn-session.ts +init_adapter(); +init_db(); +var AGENT_TURN_SESSION_PREFIX = "junior:agent_turn_session"; +var AGENT_TURN_SESSION_INDEX_KEY = `${AGENT_TURN_SESSION_PREFIX}:index`; +var AGENT_TURN_SESSION_INDEX_READ_CONCURRENCY = 25; +var agentTurnSessionStatusSchema = z13.enum([ + "running", + "awaiting_resume", + "completed", + "failed", + "abandoned", +]); +var agentTurnSurfaceSchema = z13.enum([ + "slack", + "api", + "scheduler", + "internal", +]); +var agentTurnResumeReasonSchema = z13.enum(["timeout", "auth", "yield"]); +var nonNegativeNumberSchema = z13.number().finite().nonnegative(); +var seqCursorSchema = z13.number().int().min(-1); +var agentTurnSessionSummarySchema = z13 + .object({ + channelName: z13.string().min(1).optional(), + version: z13.number().int().nonnegative(), + conversationId: z13.string().min(1), + cumulativeDurationMs: nonNegativeNumberSchema, + cumulativeUsage: agentTurnUsageSchema.optional(), + destination: destinationSchema2.optional(), + source: sourceSchema.optional(), + lastProgressAtMs: nonNegativeNumberSchema, + loadedSkillNames: z13.array(z13.string()).optional(), + modelId: z13.string().min(1).optional(), + reasoningLevel: z13.string().min(1).optional(), + actor: actorSchema3.optional(), + resumeReason: agentTurnResumeReasonSchema.optional(), + resumedFromSliceId: z13.number().int().nonnegative().optional(), + sessionId: z13.string().min(1), + sliceId: z13.number().int().nonnegative(), + startedAtMs: nonNegativeNumberSchema, + state: agentTurnSessionStatusSchema, + surface: agentTurnSurfaceSchema.optional(), + traceId: z13.string().optional(), + updatedAtMs: nonNegativeNumberSchema, + }) + .strict(); +var storedAgentTurnSessionRecordSchema = agentTurnSessionSummarySchema + .extend({ + actors: z13.array(actorSchema3).optional(), + committedSeq: seqCursorSchema, + errorMessage: z13.string().optional(), + turnStartSeq: seqCursorSchema.optional(), + }) + .strict(); +function agentTurnSessionConversationIndexKey(conversationId) { + return `${AGENT_TURN_SESSION_PREFIX}:conversation:${conversationId}:index`; +} +function parseAgentTurnSessionSummary(value) { + return agentTurnSessionSummarySchema.parse(value); +} +async function readAgentTurnSessionSummariesFromIndex(key, stateAdapter2) { + await stateAdapter2.connect(); + const values = await stateAdapter2.getList(key); + const summaries = /* @__PURE__ */ new Map(); + for (const value of [...values].reverse()) { + const summary = parseAgentTurnSessionSummary(value); + const key2 = `${summary.conversationId}:${summary.sessionId}`; + if (!summaries.has(key2)) { + summaries.set(key2, summary); + } + } + return [...summaries.values()].sort( + (left, right) => right.updatedAtMs - left.updatedAtMs, + ); +} +async function listAgentTurnSessionSummariesForConversations( + stateAdapter2, + conversationIds, +) { + const ids = [...new Set(conversationIds)]; + const globalSummaries = await readAgentTurnSessionSummariesFromIndex( + AGENT_TURN_SESSION_INDEX_KEY, + stateAdapter2, + ); + const globalByConversation = /* @__PURE__ */ new Map(); + for (const summary of globalSummaries) { + globalByConversation.set(summary.conversationId, [ + ...(globalByConversation.get(summary.conversationId) ?? []), + summary, + ]); + } + const summariesByConversation = /* @__PURE__ */ new Map(); + let nextIndex = 0; + const readNext = async () => { + while (nextIndex < ids.length) { + const conversationId = ids[nextIndex]; + nextIndex += 1; + if (!conversationId) continue; + const summaries = await readAgentTurnSessionSummariesFromIndex( + agentTurnSessionConversationIndexKey(conversationId), + stateAdapter2, + ); + summariesByConversation.set( + conversationId, + summaries.length > 0 + ? summaries + : (globalByConversation.get(conversationId) ?? []), + ); + } + }; + await Promise.all( + Array.from( + { + length: Math.min(AGENT_TURN_SESSION_INDEX_READ_CONCURRENCY, ids.length), + }, + readNext, + ), + ); + return summariesByConversation; +} + +// packages/junior/src/cli/upgrade/migrations/conversations-sql.ts +init_executor(); +var CONVERSATION_BACKFILL_LIMIT = 1e4; +async function migrateConversationsToSql(context, options = {}) { + const source = createStateConversationStore(context.stateAdapter); + let target = options.target; + let closeTarget; + if (!target) { + const { sql: sql11 } = getChatConfig(); + const executor = createJuniorSqlExecutor({ + connectionString: sql11.databaseUrl, + driver: sql11.driver, + }); + target = createSqlStore(executor); + closeTarget = () => executor.close(); + } + const limit = Math.max(1, options.batchSize ?? CONVERSATION_BACKFILL_LIMIT); + try { + const [stateConversations, sqlConversations] = await Promise.all([ + source.listByActivity({ limit }), + target.listByActivity({ limit }), + ]); + const byId = new Map( + sqlConversations.map((conversation) => [ + conversation.conversationId, + conversation, + ]), + ); + for (const conversation of stateConversations) { + const existing = byId.get(conversation.conversationId); + const existingExecutionAt = + existing?.execution.updatedAtMs ?? existing?.updatedAtMs ?? 0; + const stateExecutionAt = + conversation.execution.updatedAtMs ?? conversation.updatedAtMs; + if (!existing || stateExecutionAt >= existingExecutionAt) { + byId.set(conversation.conversationId, conversation); + } + } + const conversations = [...byId.values()] + .sort( + (left, right) => + right.lastActivityAtMs - left.lastActivityAtMs || + left.conversationId.localeCompare(right.conversationId), + ) + .slice(0, limit); + const summaries = await listAgentTurnSessionSummariesForConversations( + context.stateAdapter, + conversations.map((conversation) => conversation.conversationId), + ); + for (const conversation of conversations) { + const conversationSummaries = + summaries.get(conversation.conversationId) ?? []; + const executionSummary = conversation.execution.runId + ? conversationSummaries.find( + (summary) => summary.sessionId === conversation.execution.runId, + ) + : void 0; + await target.backfillConversation( + conversation, + conversationSummaries.length > 0 + ? { + durationMs: conversationSummaries.reduce( + (total, summary) => total + summary.cumulativeDurationMs, + 0, + ), + usage: addAgentTurnUsage( + ...conversationSummaries.map( + (summary) => summary.cumulativeUsage, + ), + ), + executionDurationMs: executionSummary?.cumulativeDurationMs ?? 0, + executionUsage: executionSummary?.cumulativeUsage, + } + : void 0, + ); + } + return { + existing: 0, + migrated: conversations.length, + missing: 0, + scanned: conversations.length, + }; + } finally { + await closeTarget?.(); + } +} + +// ../../../../../../private/tmp/0006_conversations_to_sql-entry.ts +var migration = { apiVersion: 1, async up(context) { - return await context.tasks.run("conversations-to-sql-v1"); + return await migrateConversationsToSql( + { stateAdapter: context.state, io: { info: context.log } }, + { target: createSqlStore(context.sql) }, + ); }, -} satisfies MigrationV1; - -export default migration; +}; +var conversations_to_sql_entry_default = migration; +export { + conversations_to_sql_entry_default as default, + migrateConversationsToSql, +}; diff --git a/packages/junior/migrations/0007_conversation_history_to_sql.ts b/packages/junior/migrations/0007_conversation_history_to_sql.ts index 69ae7abd4..a5416145c 100644 --- a/packages/junior/migrations/0007_conversation_history_to_sql.ts +++ b/packages/junior/migrations/0007_conversation_history_to_sql.ts @@ -1,10 +1,3840 @@ -import type { MigrationV1 } from "@sentry/junior-migrations"; +// @ts-nocheck -- frozen migration capsule; do not refactor against current Junior internals. +/* eslint-disable no-unused-vars */ +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __esm = (fn, res) => + function __init() { + return (fn && (res = (0, fn[__getOwnPropNames(fn)[0]])((fn = 0))), res); + }; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc3) => { + if ((from && typeof from === "object") || typeof from === "function") { + for (let key3 of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key3) && key3 !== except) + __defProp(to, key3, { + get: () => from[key3], + enumerable: + !(desc3 = __getOwnPropDesc(from, key3)) || desc3.enumerable, + }); + } + return to; +}; +var __reExport = (target, mod, secondTarget) => ( + __copyProps(target, mod, "default"), + secondTarget && __copyProps(secondTarget, mod, "default") +); -const migration = { +// migration:config +function getChatConfig() { + const databaseUrl = process.env.DATABASE_URL; + if (!databaseUrl) throw new Error("DATABASE_URL is required"); + const configured = process.env.JUNIOR_DATABASE_DRIVER; + const url = new URL(databaseUrl); + const driver = + configured === "postgres" || configured === "neon" + ? configured + : url.hostname === "localhost" || url.hostname === "127.0.0.1" + ? "postgres" + : "neon"; + return { + bot: { modelContextWindowTokens: void 0 }, + sql: { databaseUrl, driver }, + state: { + keyPrefix: process.env.JUNIOR_STATE_KEY_PREFIX?.trim() || void 0, + adapter: process.env.REDIS_URL ? "redis" : "memory", + redisUrl: process.env.REDIS_URL, + }, + }; +} +var init_config = __esm({ + "migration:config"() { + "use strict"; + }, +}); + +// packages/junior/src/chat/slack/timestamp.ts +import { z } from "zod"; +var slackMessageTsSchema; +var init_timestamp = __esm({ + "packages/junior/src/chat/slack/timestamp.ts"() { + "use strict"; + slackMessageTsSchema = z + .string() + .trim() + .regex(/^\d+(?:\.\d+)?$/) + .brand(); + }, +}); + +// packages/junior/src/chat/slack/ids.ts +import { z as z2 } from "zod"; +function parseSlackTeamId(value) { + if (typeof value !== "string") return void 0; + const parsed = slackTeamIdSchema.safeParse(value.trim()); + return parsed.success ? parsed.data : void 0; +} +var slackChannelIdSchema, slackTeamIdSchema, slackUserIdSchema; +var init_ids = __esm({ + "packages/junior/src/chat/slack/ids.ts"() { + "use strict"; + init_timestamp(); + slackChannelIdSchema = z2 + .string() + .regex(/^[CDG][A-Z0-9]+$/) + .brand(); + slackTeamIdSchema = z2 + .string() + .regex(/^T[A-Z0-9]+$/) + .brand(); + slackUserIdSchema = z2 + .string() + .regex(/^[UW][A-Z0-9]+$/) + .brand(); + }, +}); + +// packages/junior/src/chat/destination.ts +import { destinationSchema } from "@sentry/junior-plugin-api"; +function parseDestination(value) { + const parsed = destinationSchema.safeParse(value); + return parsed.success ? parsed.data : void 0; +} +function sameDestination(left, right) { + if (left.platform !== right.platform) { + return false; + } + if (left.platform === "local" && right.platform === "local") { + return left.conversationId === right.conversationId; + } + if (left.platform === "slack" && right.platform === "slack") { + return left.teamId === right.teamId && left.channelId === right.channelId; + } + return false; +} +var init_destination = __esm({ + "packages/junior/src/chat/destination.ts"() { + "use strict"; + init_ids(); + }, +}); + +// packages/junior/src/db/schema/timestamps.ts +import { timestamp } from "drizzle-orm/pg-core"; +var timestamptz; +var init_timestamps = __esm({ + "packages/junior/src/db/schema/timestamps.ts"() { + "use strict"; + timestamptz = (name) => timestamp(name, { withTimezone: true }); + }, +}); + +// packages/junior/src/db/schema/destinations.ts +import { index, jsonb, pgTable, text, uniqueIndex } from "drizzle-orm/pg-core"; +import { z as z3 } from "zod"; +var juniorDestinationKindSchema, juniorDestinations; +var init_destinations = __esm({ + "packages/junior/src/db/schema/destinations.ts"() { + "use strict"; + init_timestamps(); + juniorDestinationKindSchema = z3.enum([ + "channel", + "dm", + "group", + "local_conversation", + "thread", + "web_session", + ]); + juniorDestinations = pgTable( + "junior_destinations", + { + id: text("id").primaryKey(), + provider: text("provider").notNull(), + providerTenantId: text("provider_tenant_id").notNull().default(""), + providerDestinationId: text("provider_destination_id").notNull(), + kind: text("kind").$type().notNull(), + parentDestinationId: text("parent_destination_id"), + displayName: text("display_name"), + visibility: text("visibility").$type().notNull().default("unknown"), + metadata: jsonb("metadata_json"), + createdAt: timestamptz("created_at").notNull(), + updatedAt: timestamptz("updated_at").notNull(), + }, + (table) => [ + uniqueIndex("junior_destinations_provider_destination_uidx").on( + table.provider, + table.providerTenantId, + table.providerDestinationId, + ), + index("junior_destinations_provider_kind_idx").on( + table.provider, + table.kind, + ), + ], + ); + }, +}); + +// packages/junior/src/db/schema/users.ts +import { + text as text2, + pgTable as pgTable2, + uniqueIndex as uniqueIndex2, +} from "drizzle-orm/pg-core"; +var juniorUsers; +var init_users = __esm({ + "packages/junior/src/db/schema/users.ts"() { + "use strict"; + init_timestamps(); + juniorUsers = pgTable2( + "junior_users", + { + id: text2("id").primaryKey(), + primaryEmail: text2("primary_email").notNull(), + primaryEmailNormalized: text2("primary_email_normalized").notNull(), + displayName: text2("display_name"), + createdAt: timestamptz("created_at").notNull(), + updatedAt: timestamptz("updated_at").notNull(), + }, + (table) => [ + uniqueIndex2("junior_users_primary_email_normalized_uidx").on( + table.primaryEmailNormalized, + ), + ], + ); + }, +}); + +// packages/junior/src/db/schema/identities.ts +import { sql } from "drizzle-orm"; +import { + boolean, + index as index2, + jsonb as jsonb2, + pgTable as pgTable3, + text as text3, + uniqueIndex as uniqueIndex3, +} from "drizzle-orm/pg-core"; +var juniorIdentities; +var init_identities = __esm({ + "packages/junior/src/db/schema/identities.ts"() { + "use strict"; + init_timestamps(); + init_users(); + juniorIdentities = pgTable3( + "junior_identities", + { + id: text3("id").primaryKey(), + kind: text3("kind").$type().notNull(), + provider: text3("provider").notNull(), + providerTenantId: text3("provider_tenant_id").notNull().default(""), + providerSubjectId: text3("provider_subject_id").notNull(), + displayName: text3("display_name"), + handle: text3("handle"), + email: text3("email"), + avatarUrl: text3("avatar_url"), + metadata: jsonb2("metadata_json"), + createdAt: timestamptz("created_at").notNull(), + updatedAt: timestamptz("updated_at").notNull(), + userId: text3("user_id").references(() => juniorUsers.id), + emailNormalized: text3("email_normalized"), + emailVerified: boolean("email_verified").notNull().default(false), + }, + (table) => [ + uniqueIndex3("junior_identities_provider_subject_uidx").on( + table.provider, + table.providerTenantId, + table.providerSubjectId, + ), + index2("junior_identities_user_idx").on(table.userId), + index2("junior_identities_verified_email_idx") + .on(table.emailNormalized) + .where( + sql`${table.emailVerified} = true AND ${table.emailNormalized} IS NOT NULL`, + ), + index2("junior_identities_kind_provider_idx").on( + table.kind, + table.provider, + ), + ], + ); + }, +}); + +// packages/junior/src/db/schema/conversations.ts +import { sql as sql2 } from "drizzle-orm"; +import { + index as index3, + integer, + jsonb as jsonb3, + pgTable as pgTable4, + text as text4, +} from "drizzle-orm/pg-core"; +var juniorConversations; +var init_conversations = __esm({ + "packages/junior/src/db/schema/conversations.ts"() { + "use strict"; + init_destinations(); + init_identities(); + init_timestamps(); + juniorConversations = pgTable4( + "junior_conversations", + { + conversationId: text4("conversation_id").primaryKey(), + schemaVersion: integer("schema_version").notNull().default(1), + source: text4("source").$type(), + originType: text4("origin_type"), + originId: text4("origin_id"), + originRunId: text4("origin_run_id"), + destinationId: text4("destination_id").references( + () => juniorDestinations.id, + ), + destination: jsonb3("destination_json").$type(), + actorIdentityId: text4("actor_identity_id").references( + () => juniorIdentities.id, + ), + creatorIdentityId: text4("creator_identity_id").references( + () => juniorIdentities.id, + ), + credentialSubjectIdentityId: text4( + "credential_subject_identity_id", + ).references(() => juniorIdentities.id), + actor: jsonb3("actor_json").$type(), + channelName: text4("channel_name"), + title: text4("title"), + createdAt: timestamptz("created_at").notNull(), + lastActivityAt: timestamptz("last_activity_at").notNull(), + updatedAt: timestamptz("updated_at").notNull(), + executionUpdatedAt: timestamptz("execution_updated_at"), + executionStatus: text4("execution_status").$type().notNull(), + runId: text4("run_id"), + lastCheckpointAt: timestamptz("last_checkpoint_at"), + lastEnqueuedAt: timestamptz("last_enqueued_at"), + // Subagent runs are child conversations; top-level listings filter + // parent_conversation_id IS NULL. Historical advisor children use this too. + parentConversationId: text4("parent_conversation_id").references( + () => juniorConversations.conversationId, + ), + transcriptPurgedAt: timestamptz("transcript_purged_at"), + durationMs: integer("duration_ms").notNull().default(0), + usage: jsonb3("usage_json").$type(), + executionDurationMs: integer("execution_duration_ms") + .notNull() + .default(0), + executionUsage: jsonb3("execution_usage_json").$type(), + metricRunId: text4("metric_run_id"), + }, + (table) => [ + index3("junior_conversations_last_activity_idx").on( + table.lastActivityAt.desc(), + table.conversationId, + ), + index3("junior_conversations_active_idx") + .using( + "btree", + sql2`coalesce(${table.executionUpdatedAt}, ${table.updatedAt})`, + table.conversationId, + ) + .where(sql2`${table.executionStatus} <> 'idle'`), + index3("junior_conversations_destination_activity_idx").on( + table.destinationId, + table.lastActivityAt.desc(), + ), + index3("junior_conversations_actor_activity_idx").on( + table.actorIdentityId, + table.lastActivityAt.desc(), + ), + index3("junior_conversations_origin_idx").on( + table.originType, + table.originId, + table.lastActivityAt.desc(), + ), + index3("junior_conversations_parent_idx").on( + table.parentConversationId, + ), + ], + ); + }, +}); + +// packages/junior/src/db/schema/agent-steps.ts +import { + index as index4, + integer as integer2, + jsonb as jsonb4, + pgTable as pgTable5, + primaryKey, + text as text5, +} from "drizzle-orm/pg-core"; +var juniorAgentSteps; +var init_agent_steps = __esm({ + "packages/junior/src/db/schema/agent-steps.ts"() { + "use strict"; + init_conversations(); + init_timestamps(); + juniorAgentSteps = pgTable5( + "junior_agent_steps", + { + conversationId: text5("conversation_id") + .notNull() + .references(() => juniorConversations.conversationId), + seq: integer2("seq").notNull(), + contextEpoch: integer2("context_epoch").notNull(), + type: text5("type").notNull(), + role: text5("role"), + payload: jsonb4("payload").$type().notNull(), + createdAt: timestamptz("created_at").notNull(), + }, + (table) => [ + primaryKey({ columns: [table.conversationId, table.seq] }), + index4("junior_agent_steps_epoch_idx").on( + table.conversationId, + table.contextEpoch, + table.seq, + ), + ], + ); + }, +}); + +// packages/junior/src/db/schema/conversation-messages.ts +import { sql as sql3 } from "drizzle-orm"; +import { + index as index5, + jsonb as jsonb5, + pgTable as pgTable6, + primaryKey as primaryKey2, + text as text6, +} from "drizzle-orm/pg-core"; +var juniorConversationMessages; +var init_conversation_messages = __esm({ + "packages/junior/src/db/schema/conversation-messages.ts"() { + "use strict"; + init_conversations(); + init_identities(); + init_timestamps(); + juniorConversationMessages = pgTable6( + "junior_conversation_messages", + { + conversationId: text6("conversation_id") + .notNull() + .references(() => juniorConversations.conversationId), + messageId: text6("message_id").notNull(), + role: text6("role").$type().notNull(), + authorIdentityId: text6("author_identity_id").references( + () => juniorIdentities.id, + ), + text: text6("text").notNull(), + meta: jsonb5("meta").$type(), + repliedAt: timestamptz("replied_at"), + createdAt: timestamptz("created_at").notNull(), + }, + (table) => [ + primaryKey2({ columns: [table.conversationId, table.messageId] }), + index5("junior_conversation_messages_activity_idx").on( + table.conversationId, + table.createdAt, + ), + index5("junior_conversation_messages_search_idx").using( + "gin", + sql3`to_tsvector('english', ${table.text})`, + ), + ], + ); + }, +}); + +// packages/junior/src/db/schema.ts +var juniorSqlSchema; +var init_schema = __esm({ + "packages/junior/src/db/schema.ts"() { + "use strict"; + init_agent_steps(); + init_conversation_messages(); + init_conversations(); + init_destinations(); + init_identities(); + init_users(); + juniorSqlSchema = { + juniorAgentSteps, + juniorConversationMessages, + juniorConversations, + juniorDestinations, + juniorIdentities, + juniorUsers, + }; + }, +}); + +// packages/junior/src/chat/identities/identity.ts +var init_identity = __esm({ + "packages/junior/src/chat/identities/identity.ts"() { + "use strict"; + }, +}); + +// packages/junior/src/chat/identities/sql.ts +import { and, eq, sql as sql4 } from "drizzle-orm"; +var init_sql = __esm({ + "packages/junior/src/chat/identities/sql.ts"() { + "use strict"; + init_schema(); + init_identity(); + }, +}); + +// packages/junior/src/chat/conversations/sql/store.ts +import { + and as and2, + asc, + desc, + eq as eq2, + isNull, + sql as sql5, +} from "drizzle-orm"; +var init_store = __esm({ + "packages/junior/src/chat/conversations/sql/store.ts"() { + "use strict"; + init_destination(); + init_sql(); + init_schema(); + }, +}); + +// packages/junior/src/chat/pi/messages.ts +import { z as z4 } from "zod"; +var piMessageSchema, piContentMessageSchema; +var init_messages = __esm({ + "packages/junior/src/chat/pi/messages.ts"() { + "use strict"; + piMessageSchema = z4 + .object({ + role: z4.string(), + }) + .passthrough() + .transform((value) => value); + piContentMessageSchema = z4 + .object({ + content: z4.array(z4.unknown()), + role: z4.string().min(1), + }) + .passthrough() + .transform((value) => value); + }, +}); + +// packages/junior/src/chat/actor.ts +import { z as z5 } from "zod"; +import { actorSchema } from "@sentry/junior-plugin-api"; +function isSyntheticActorUserId(value) { + return value.toLowerCase() === "unknown"; +} +function parseActorUserId(value) { + if (typeof value !== "string" || value.length === 0) { + return void 0; + } + if (value !== value.trim() || isSyntheticActorUserId(value)) { + return void 0; + } + return value; +} +function parseStoredSlackActor(value) { + const parsed = storedSlackActorSchema.safeParse(value); + if (!parsed.success) { + return void 0; + } + if ( + parsed.data.slackUserId !== void 0 && + !parseActorUserId(parsed.data.slackUserId) + ) { + return void 0; + } + if (parsed.data.teamId !== void 0 && !parseSlackTeamId(parsed.data.teamId)) { + return void 0; + } + if ( + (parsed.data.platform !== void 0 || parsed.data.teamId !== void 0) && + (!parsed.data.platform || !parsed.data.teamId) + ) { + return void 0; + } + return parsed.data; +} +var exactStoredStringSchema, storedSlackActorSchema; +var init_actor = __esm({ + "packages/junior/src/chat/actor.ts"() { + "use strict"; + init_ids(); + exactStoredStringSchema = z5 + .string() + .min(1) + .refine((value) => value === value.trim()); + storedSlackActorSchema = z5 + .object({ + email: exactStoredStringSchema.optional(), + fullName: exactStoredStringSchema.optional(), + platform: z5.literal("slack").optional(), + slackUserId: exactStoredStringSchema.optional(), + slackUserName: exactStoredStringSchema.optional(), + teamId: exactStoredStringSchema.optional(), + }) + .strict(); + }, +}); + +// packages/junior/src/chat/state/locks.ts +var ACTIVE_LOCK_TTL_MS; +var init_locks = __esm({ + "packages/junior/src/chat/state/locks.ts"() { + "use strict"; + ACTIVE_LOCK_TTL_MS = 9e4; + }, +}); + +// packages/junior/src/chat/state/adapter.ts +import { createMemoryState } from "@chat-adapter/state-memory"; +import { createRedisState } from "@chat-adapter/state-redis"; +function createPrefixedStateAdapter(base, prefix) { + const prefixed = (value) => `${prefix}:${value}`; + const unprefixed = (value) => + value.startsWith(`${prefix}:`) ? value.slice(prefix.length + 1) : value; + const prefixLock = (lock) => ({ + ...lock, + threadId: prefixed(lock.threadId), + }); + const unprefixLock = (lock) => ({ + ...lock, + threadId: unprefixed(lock.threadId), + }); + return { + appendToList: (key3, value, options) => + base.appendToList(prefixed(key3), value, options), + connect: () => base.connect(), + disconnect: () => base.disconnect(), + subscribe: (threadId) => base.subscribe(prefixed(threadId)), + unsubscribe: (threadId) => base.unsubscribe(prefixed(threadId)), + isSubscribed: (threadId) => base.isSubscribed(prefixed(threadId)), + acquireLock: async (threadId, ttlMs) => { + const lock = await base.acquireLock(prefixed(threadId), ttlMs); + return lock ? unprefixLock(lock) : null; + }, + releaseLock: (lock) => base.releaseLock(prefixLock(lock)), + extendLock: async (lock, ttlMs) => { + const prefixedLock = prefixLock(lock); + const extended = await base.extendLock(prefixedLock, ttlMs); + if (extended) { + lock.expiresAt = prefixedLock.expiresAt; + } + return extended; + }, + forceReleaseLock: (threadId) => base.forceReleaseLock(prefixed(threadId)), + enqueue: (threadId, entry, maxSize) => + base.enqueue(prefixed(threadId), entry, maxSize), + dequeue: (threadId) => base.dequeue(prefixed(threadId)), + queueDepth: (threadId) => base.queueDepth(prefixed(threadId)), + get: (key3) => base.get(prefixed(key3)), + getList: (key3) => base.getList(prefixed(key3)), + set: (key3, value, ttlMs) => base.set(prefixed(key3), value, ttlMs), + setIfNotExists: (key3, value, ttlMs) => + base.setIfNotExists(prefixed(key3), value, ttlMs), + delete: (key3) => base.delete(prefixed(key3)), + }; +} +function createQueuedStateAdapter(base, options) { + const heartbeats = /* @__PURE__ */ new Map(); + const shouldHeartbeatLock = (ttlMs) => ttlMs === ACTIVE_LOCK_TTL_MS; + const heartbeatKey = (lock) => `${lock.threadId}:${lock.token}`; + const stopHeartbeatByKey = (key3) => { + const heartbeat = heartbeats.get(key3); + if (!heartbeat) { + return; + } + clearInterval(heartbeat.timer); + heartbeats.delete(key3); + }; + const stopHeartbeat = (lock) => { + stopHeartbeatByKey(heartbeatKey(lock)); + }; + const stopHeartbeatsForThread = (threadId) => { + for (const [key3, heartbeat] of heartbeats) { + if (heartbeat.lock.threadId === threadId) { + stopHeartbeatByKey(key3); + } + } + }; + const stopAllHeartbeats = () => { + for (const key3 of heartbeats.keys()) { + stopHeartbeatByKey(key3); + } + }; + const runHeartbeat = async (key3) => { + const heartbeat = heartbeats.get(key3); + if (!heartbeat || heartbeat.inFlight) { + return; + } + heartbeat.inFlight = true; + try { + if (Date.now() - heartbeat.startedAtMs >= options.activeLockMaxAgeMs) { + stopHeartbeatByKey(key3); + return; + } + const extended = await base.extendLock(heartbeat.lock, heartbeat.ttlMs); + if (!extended) { + stopHeartbeatByKey(key3); + return; + } + heartbeat.lock.expiresAt = Date.now() + heartbeat.ttlMs; + } catch { + } finally { + const current = heartbeats.get(key3); + if (current === heartbeat) { + current.inFlight = false; + } + } + }; + const startOrUpdateHeartbeat = (lock, ttlMs) => { + const key3 = heartbeatKey(lock); + const existing = heartbeats.get(key3); + if (existing) { + existing.ttlMs = ttlMs; + return; + } + const timer = setInterval(() => { + void runHeartbeat(key3); + }, ACTIVE_LOCK_HEARTBEAT_MS); + timer.unref?.(); + heartbeats.set(key3, { + inFlight: false, + lock, + startedAtMs: Date.now(), + timer, + ttlMs, + }); + }; + const acquireLock = async (threadId, ttlMs) => { + const lock = await base.acquireLock(threadId, ttlMs); + if (lock && shouldHeartbeatLock(ttlMs)) { + startOrUpdateHeartbeat(lock, ttlMs); + } + return lock; + }; + return { + appendToList: (key3, value, options2) => + base.appendToList(key3, value, options2), + connect: () => base.connect(), + disconnect: async () => { + stopAllHeartbeats(); + await base.disconnect(); + }, + subscribe: (threadId) => base.subscribe(threadId), + unsubscribe: (threadId) => base.unsubscribe(threadId), + isSubscribed: (threadId) => base.isSubscribed(threadId), + acquireLock, + releaseLock: async (lock) => { + stopHeartbeat(lock); + await base.releaseLock(lock); + }, + extendLock: async (lock, ttlMs) => { + const extended = await base.extendLock(lock, ttlMs); + if (extended) { + lock.expiresAt = Date.now() + ttlMs; + if (shouldHeartbeatLock(ttlMs)) { + startOrUpdateHeartbeat(lock, ttlMs); + } else { + stopHeartbeat(lock); + } + } else { + stopHeartbeat(lock); + } + return extended; + }, + forceReleaseLock: async (threadId) => { + stopHeartbeatsForThread(threadId); + await base.forceReleaseLock(threadId); + }, + enqueue: (threadId, entry, maxSize) => + base.enqueue(threadId, entry, maxSize), + dequeue: (threadId) => base.dequeue(threadId), + queueDepth: (threadId) => base.queueDepth(threadId), + get: (key3) => base.get(key3), + getList: (key3) => base.getList(key3), + set: (key3, value, ttlMs) => base.set(key3, value, ttlMs), + setIfNotExists: (key3, value, ttlMs) => + base.setIfNotExists(key3, value, ttlMs), + delete: (key3) => base.delete(key3), + }; +} +function withOptionalPrefix(base, prefix) { + return prefix ? createPrefixedStateAdapter(base, prefix) : base; +} +function createStateAdapter() { + const config = getChatConfig(); + const activeLockMaxAgeMs = config.bot.turnTimeoutMs + ACTIVE_LOCK_TTL_MS; + if (config.state.adapter === "memory") { + redisStateAdapter = void 0; + return createQueuedStateAdapter( + withOptionalPrefix(createMemoryState(), config.state.keyPrefix), + { activeLockMaxAgeMs }, + ); + } + if (!config.state.redisUrl) { + throw new Error("REDIS_URL is required for durable Slack thread state"); + } + const redisState = createRedisState({ + url: config.state.redisUrl, + }); + redisStateAdapter = redisState; + return createQueuedStateAdapter( + withOptionalPrefix(redisState, config.state.keyPrefix), + { activeLockMaxAgeMs }, + ); +} +function getOptionalRedisStateAdapter() { + getStateAdapter(); + return redisStateAdapter; +} +async function getConnectedStateContext() { + const adapter = getStateAdapter(); + await adapter.connect(); + return { + redisStateAdapter: getOptionalRedisStateAdapter(), + stateAdapter: adapter, + }; +} +async function getDefaultRedisStateAdapterFor(adapter) { + if (adapter !== stateAdapter) { + return void 0; + } + const context = await getConnectedStateContext(); + return context.stateAdapter === adapter ? context.redisStateAdapter : void 0; +} +function getStateAdapter() { + if (!stateAdapter) { + stateAdapter = createStateAdapter(); + } + return stateAdapter; +} +var ACTIVE_LOCK_HEARTBEAT_MS, stateAdapter, redisStateAdapter; +var init_adapter = __esm({ + "packages/junior/src/chat/state/adapter.ts"() { + "use strict"; + init_config(); + init_locks(); + ACTIVE_LOCK_HEARTBEAT_MS = 3e4; + }, +}); + +// packages/junior/src/chat/state/session-log.ts +import { z as z6 } from "zod"; +import { actorSchema as actorSchema2 } from "@sentry/junior-plugin-api"; +function instructionProvenance(actor) { + return actor + ? { authority: "instruction", actor } + : { authority: "instruction" }; +} +function isDefaultContextProvenance(provenance) { + return provenance.authority === "context" && !provenance.actor; +} +function legacyActorProvenance(actor) { + if (actor.teamId && actor.slackUserId && actor.platform) { + return instructionProvenance({ + platform: "slack", + teamId: actor.teamId, + userId: actor.slackUserId, + ...(actor.slackUserName ? { userName: actor.slackUserName } : {}), + ...(actor.fullName ? { fullName: actor.fullName } : {}), + ...(actor.email ? { email: actor.email } : {}), + }); + } + return unattributedContextProvenance; +} +function key(scope) { + const prefix = getChatConfig().state.keyPrefix; + return [ + ...(prefix ? [prefix] : []), + AGENT_SESSION_LOG_PREFIX, + scope.conversationId, + ].join(":"); +} +function rawKey(scope) { + return [AGENT_SESSION_LOG_PREFIX, scope.conversationId].join(":"); +} +function storedRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? value + : void 0; +} +function migrateStoredEntry(value) { + const record = storedRecord(value); + if (!record) { + return value; + } + const migrated = { ...record }; + if ("requester" in migrated && !("actor" in migrated)) { + migrated.actor = migrated.requester; + } + delete migrated.requester; + if (migrated.type === "requester_recorded") { + migrated.type = "actor_recorded"; + } + if ("requesterId" in migrated && !("actorId" in migrated)) { + migrated.actorId = migrated.requesterId; + } + delete migrated.requesterId; + return migrated; +} +function piEntry(message, sessionId, provenance) { + return { + schemaVersion: AGENT_SESSION_LOG_SCHEMA_VERSION, + type: "pi_message", + sessionId, + message, + // Ambient context is the decode default, so only attributed/instruction + // provenance needs to be persisted on the entry. + ...(provenance && !isDefaultContextProvenance(provenance) + ? { provenance } + : {}), + }; +} +function decode(value) { + if (typeof value === "string") { + return decode(JSON.parse(value)); + } + const parsed = sessionLogEntrySchema.safeParse(migrateStoredEntry(value)); + if (parsed.success) { + return parsed.data; + } + return piEntry(piMessageSchema.parse(value), INITIAL_SESSION_ID); +} +function redisStore(redisStateAdapter2) { + const client = redisStateAdapter2.getClient(); + return { + async append({ entries, scope, ttlMs }) { + const listKey = key(scope); + if (entries.length > 0) { + await client.rPush( + listKey, + entries.map((entry) => JSON.stringify(entry)), + ); + } + await client.pExpire(listKey, Math.max(1, ttlMs)); + }, + async read(scope) { + const values = await client.lRange(key(scope), 0, -1); + return values.map(decode); + }, + }; +} +function stateStore() { + const stateAdapter2 = getStateAdapter(); + return { + async append({ entries, scope, ttlMs }) { + const listKey = rawKey(scope); + const lock = await stateAdapter2.acquireLock( + `${listKey}:commit`, + STATE_STORE_LOCK_TTL_MS, + ); + if (!lock) { + throw new Error("Could not acquire session log commit lock"); + } + try { + const existingValue = await stateAdapter2.get(listKey); + const existingEntries = Array.isArray(existingValue) + ? existingValue.map(decode) + : (await stateAdapter2.getList(listKey)).map(decode); + await stateAdapter2.set( + listKey, + [...existingEntries, ...entries], + Math.max(1, ttlMs), + ); + } finally { + await stateAdapter2.releaseLock(lock); + } + }, + async read(scope) { + const listKey = rawKey(scope); + const value = await stateAdapter2.get(listKey); + if (Array.isArray(value)) { + return value.map(decode); + } + const values = await stateAdapter2.getList(listKey); + return values.map(decode); + }, + }; +} +async function defaultStore() { + const { redisStateAdapter: redisStateAdapter2, stateAdapter: stateAdapter2 } = + await getConnectedStateContext(); + if (redisStateAdapter2) { + return redisStore(redisStateAdapter2); + } + await stateAdapter2.connect(); + return stateStore(); +} +async function loadEntries(args) { + const store = args.store ?? (await defaultStore()); + return (await store.read(args)).map(decode); +} +async function readSessionLogEntries(args) { + return loadEntries(args); +} +var AGENT_SESSION_LOG_PREFIX, + AGENT_SESSION_LOG_SCHEMA_VERSION, + INITIAL_SESSION_ID, + STATE_STORE_LOCK_TTL_MS, + schemaVersionSchema, + piMessageAuthoritySchema, + piMessageProvenanceSchema, + unattributedContextProvenance, + piMessageEntrySchema, + projectionResetEntrySchema, + actorRecordedEntrySchema, + mcpProviderConnectedEntrySchema, + authorizationKindSchema, + authorizationRequestedEntrySchema, + authorizationCompletedEntrySchema, + transcriptRefSchema, + toolExecutionStartedEntrySchema, + subagentStartedEntrySchema, + subagentEndedEntrySchema, + sessionLogEntrySchema, + contextProvenance; +var init_session_log = __esm({ + "packages/junior/src/chat/state/session-log.ts"() { + "use strict"; + init_config(); + init_messages(); + init_actor(); + init_adapter(); + AGENT_SESSION_LOG_PREFIX = "junior:agent-session-log"; + AGENT_SESSION_LOG_SCHEMA_VERSION = 2; + INITIAL_SESSION_ID = "session_0"; + STATE_STORE_LOCK_TTL_MS = 5e3; + schemaVersionSchema = z6.union([z6.literal(1), z6.literal(2)]); + piMessageAuthoritySchema = z6.union([ + z6.literal("instruction"), + z6.literal("context"), + ]); + piMessageProvenanceSchema = z6 + .object({ + authority: piMessageAuthoritySchema, + actor: actorSchema2.optional(), + }) + .strict(); + unattributedContextProvenance = { + authority: "context", + }; + piMessageEntrySchema = z6.object({ + schemaVersion: schemaVersionSchema, + type: z6.literal("pi_message"), + sessionId: z6.string().min(1).default(INITIAL_SESSION_ID), + message: piMessageSchema, + provenance: piMessageProvenanceSchema.optional(), + // Legacy v1 latest-wins actor, decoded into provenance on read. + actor: storedSlackActorSchema.optional(), + }); + projectionResetEntrySchema = z6.object({ + schemaVersion: schemaVersionSchema, + type: z6.literal("projection_reset"), + sessionId: z6.string().min(1).default(INITIAL_SESSION_ID), + messages: z6.array(piMessageSchema), + provenance: z6.array(piMessageProvenanceSchema).optional(), + // Legacy v1 latest-wins actor; v1 resets carry no per-message provenance. + actor: storedSlackActorSchema.optional(), + }); + actorRecordedEntrySchema = z6.object({ + schemaVersion: schemaVersionSchema, + type: z6.literal("actor_recorded"), + sessionId: z6.string().min(1).default(INITIAL_SESSION_ID), + actor: storedSlackActorSchema, + }); + mcpProviderConnectedEntrySchema = z6.object({ + schemaVersion: schemaVersionSchema, + type: z6.literal("mcp_provider_connected"), + sessionId: z6.string().min(1).default(INITIAL_SESSION_ID), + provider: z6.string().min(1), + }); + authorizationKindSchema = z6.union([ + z6.literal("plugin"), + z6.literal("mcp"), + ]); + authorizationRequestedEntrySchema = z6.object({ + schemaVersion: schemaVersionSchema, + type: z6.literal("authorization_requested"), + sessionId: z6.string().min(1).default(INITIAL_SESSION_ID), + createdAtMs: z6.number().int().nonnegative(), + kind: authorizationKindSchema, + provider: z6.string().min(1), + actorId: z6.string().min(1), + authorizationId: z6.string().min(1), + delivery: z6.union([ + z6.literal("private_link_sent"), + z6.literal("private_link_reused"), + ]), + }); + authorizationCompletedEntrySchema = z6.object({ + schemaVersion: schemaVersionSchema, + type: z6.literal("authorization_completed"), + sessionId: z6.string().min(1).default(INITIAL_SESSION_ID), + createdAtMs: z6.number().int().nonnegative(), + kind: authorizationKindSchema, + provider: z6.string().min(1), + actorId: z6.string().min(1), + authorizationId: z6.string().min(1), + }); + transcriptRefSchema = z6.object({ + type: z6.literal("advisor_session"), + parentConversationId: z6.string().min(1), + key: z6.string().min(1), + }); + toolExecutionStartedEntrySchema = z6.object({ + schemaVersion: schemaVersionSchema, + type: z6.literal("tool_execution_started"), + sessionId: z6.string().min(1).default(INITIAL_SESSION_ID), + createdAtMs: z6.number().int().nonnegative(), + toolCallId: z6.string().min(1), + toolName: z6.string().min(1), + args: z6.unknown().optional(), + }); + subagentStartedEntrySchema = z6.object({ + schemaVersion: schemaVersionSchema, + type: z6.literal("subagent_started"), + sessionId: z6.string().min(1).default(INITIAL_SESSION_ID), + subagentInvocationId: z6.string().min(1), + subagentKind: z6.string().min(1), + parentToolCallId: z6.string().min(1).optional(), + parentConversationId: z6.string().min(1), + parentSessionId: z6.string().min(1).optional(), + transcriptRef: transcriptRefSchema, + historyMode: z6.literal("shared"), + modelId: z6.string().min(1).optional(), + reasoningLevel: z6.string().min(1).optional(), + createdAtMs: z6.number().int().nonnegative(), + }); + subagentEndedEntrySchema = z6.object({ + schemaVersion: schemaVersionSchema, + type: z6.literal("subagent_ended"), + sessionId: z6.string().min(1).default(INITIAL_SESSION_ID), + subagentInvocationId: z6.string().min(1), + outcome: z6.union([ + z6.literal("success"), + z6.literal("error"), + z6.literal("aborted"), + ]), + errorCode: z6.string().min(1).optional(), + transcriptEndMessageIndex: z6.number().int().nonnegative().optional(), + transcriptStartMessageIndex: z6.number().int().nonnegative().optional(), + createdAtMs: z6.number().int().nonnegative(), + }); + sessionLogEntrySchema = z6.discriminatedUnion("type", [ + piMessageEntrySchema, + projectionResetEntrySchema, + actorRecordedEntrySchema, + mcpProviderConnectedEntrySchema, + authorizationRequestedEntrySchema, + authorizationCompletedEntrySchema, + toolExecutionStartedEntrySchema, + subagentStartedEntrySchema, + subagentEndedEntrySchema, + ]); + contextProvenance = unattributedContextProvenance; + }, +}); + +// packages/junior/src/chat/model-profile.ts +import { z as z7 } from "zod"; +var modelProfileSchema; +var init_model_profile = __esm({ + "packages/junior/src/chat/model-profile.ts"() { + "use strict"; + modelProfileSchema = z7.string().regex(/^[a-z][a-z0-9_-]*$/); + }, +}); + +// packages/junior/src/chat/conversations/history.ts +import { z as z8 } from "zod"; +var handoffModelProfileSchema, + piMessageStepEntrySchema, + contextEpochStartedEntrySchema, + piMessageStepSchema, + contextEpochStartSchema, + mcpProviderConnectedEntrySchema2, + authorizationKindSchema2, + authorizationRequestedEntrySchema2, + authorizationCompletedEntrySchema2, + toolExecutionStartedEntrySchema2, + visibleContextCompactedEntrySchema, + subagentStartedEntrySchema2, + subagentEndedEntrySchema2, + appendableAgentStepEntrySchema, + agentStepEntrySchema, + newAgentStepSchema; +var init_history = __esm({ + "packages/junior/src/chat/conversations/history.ts"() { + "use strict"; + init_messages(); + init_session_log(); + init_model_profile(); + handoffModelProfileSchema = modelProfileSchema.refine( + (profile) => profile !== "standard", + "handoff profile must not be standard", + ); + piMessageStepEntrySchema = z8.object({ + type: z8.literal("pi_message"), + schemaVersion: z8.number().int().optional(), + message: piMessageSchema, + provenance: piMessageProvenanceSchema.optional(), + }); + contextEpochStartedEntrySchema = z8.union([ + z8 + .object({ + type: z8.literal("context_epoch_started"), + reason: z8.literal("initial"), + modelProfile: z8.literal("standard"), + modelId: z8.string().min(1), + }) + .strict(), + z8 + .object({ + type: z8.literal("context_epoch_started"), + reason: z8.literal("handoff"), + modelProfile: handoffModelProfileSchema, + modelId: z8.string().min(1), + }) + .strict(), + z8 + .object({ + type: z8.literal("context_epoch_started"), + reason: z8.union([z8.literal("compaction"), z8.literal("rollback")]), + // TODO(v0.97.0): Remove support for deployed compaction/rollback markers + // without model bindings after those rows pass the retention horizon. + modelProfile: z8.undefined().optional(), + modelId: z8.undefined().optional(), + }) + .strict(), + z8 + .object({ + type: z8.literal("context_epoch_started"), + reason: z8.union([z8.literal("compaction"), z8.literal("rollback")]), + modelProfile: modelProfileSchema, + modelId: z8.string().min(1), + }) + .strict(), + ]); + piMessageStepSchema = z8 + .object({ + message: piMessageSchema, + createdAtMs: z8.number().finite(), + provenance: piMessageProvenanceSchema.optional(), + }) + .strict(); + contextEpochStartSchema = z8.discriminatedUnion("reason", [ + z8 + .object({ + reason: z8.literal("initial"), + modelProfile: z8.literal("standard"), + modelId: z8.string().min(1), + messages: z8.array(piMessageStepSchema), + }) + .strict(), + z8 + .object({ + reason: z8.literal("handoff"), + modelProfile: handoffModelProfileSchema, + modelId: z8.string().min(1), + messages: z8.array(piMessageStepSchema), + }) + .strict(), + z8 + .object({ + reason: z8.union([z8.literal("compaction"), z8.literal("rollback")]), + modelProfile: modelProfileSchema, + modelId: z8.string().min(1), + messages: z8.array(piMessageStepSchema), + }) + .strict(), + ]); + mcpProviderConnectedEntrySchema2 = z8.object({ + type: z8.literal("mcp_provider_connected"), + provider: z8.string().min(1), + }); + authorizationKindSchema2 = z8.union([ + z8.literal("plugin"), + z8.literal("mcp"), + ]); + authorizationRequestedEntrySchema2 = z8.object({ + type: z8.literal("authorization_requested"), + kind: authorizationKindSchema2, + provider: z8.string().min(1), + actorId: z8.string().min(1), + authorizationId: z8.string().min(1), + delivery: z8.union([ + z8.literal("private_link_sent"), + z8.literal("private_link_reused"), + ]), + }); + authorizationCompletedEntrySchema2 = z8.object({ + type: z8.literal("authorization_completed"), + kind: authorizationKindSchema2, + provider: z8.string().min(1), + actorId: z8.string().min(1), + authorizationId: z8.string().min(1), + }); + toolExecutionStartedEntrySchema2 = z8.object({ + type: z8.literal("tool_execution_started"), + toolCallId: z8.string().min(1), + toolName: z8.string().min(1), + args: z8.unknown().optional(), + }); + visibleContextCompactedEntrySchema = z8.object({ + type: z8.literal("visible_context_compacted"), + compactions: z8.array( + z8.object({ + coveredMessageIds: z8.array(z8.string()), + createdAtMs: z8.number(), + id: z8.string().min(1), + summary: z8.string(), + }), + ), + }); + subagentStartedEntrySchema2 = z8 + .object({ + type: z8.literal("subagent_started"), + subagentInvocationId: z8.string().min(1), + subagentKind: z8.string().min(1), + modelId: z8.string().min(1).optional(), + parentToolCallId: z8.string().min(1).optional(), + reasoningLevel: z8.string().min(1).optional(), + childConversationId: z8.string().min(1), + historyMode: z8.union([z8.literal("isolated"), z8.literal("shared")]), + }) + .strict(); + subagentEndedEntrySchema2 = z8.object({ + type: z8.literal("subagent_ended"), + subagentInvocationId: z8.string().min(1), + outcome: z8.union([ + z8.literal("success"), + z8.literal("error"), + z8.literal("aborted"), + ]), + errorCode: z8.string().min(1).optional(), + }); + appendableAgentStepEntrySchema = z8.union([ + piMessageStepEntrySchema, + mcpProviderConnectedEntrySchema2, + authorizationRequestedEntrySchema2, + authorizationCompletedEntrySchema2, + toolExecutionStartedEntrySchema2, + visibleContextCompactedEntrySchema, + subagentStartedEntrySchema2, + subagentEndedEntrySchema2, + ]); + agentStepEntrySchema = z8.union([ + appendableAgentStepEntrySchema, + contextEpochStartedEntrySchema, + ]); + newAgentStepSchema = z8 + .object({ + entry: appendableAgentStepEntrySchema, + createdAtMs: z8.number().finite(), + }) + .strict(); + }, +}); + +// packages/junior/src/chat/conversations/sql/conversation-row.ts +import { sql as sql6 } from "drizzle-orm"; +async function ensureConversationRow(executor, conversationId, atMs) { + const at = new Date(atMs); + await executor + .db() + .insert(juniorConversations) + .values({ + conversationId, + createdAt: at, + lastActivityAt: at, + updatedAt: at, + executionStatus: "idle", + }) + .onConflictDoUpdate({ + target: juniorConversations.conversationId, + set: { + lastActivityAt: sql6`greatest(${juniorConversations.lastActivityAt}, excluded.last_activity_at)`, + updatedAt: sql6`greatest(${juniorConversations.updatedAt}, excluded.updated_at)`, + transcriptPurgedAt: null, + }, + }); +} +var init_conversation_row = __esm({ + "packages/junior/src/chat/conversations/sql/conversation-row.ts"() { + "use strict"; + init_schema(); + }, +}); + +// packages/junior/src/chat/conversations/sql/history.ts +import { and as and3, asc as asc2, eq as eq3, sql as sql7 } from "drizzle-orm"; +function messageRole(entry) { + if (entry.type !== "pi_message") { + return null; + } + const role = entry.message.role; + return typeof role === "string" ? role : null; +} +function insertFromStep(conversationId, seq, contextEpoch, step) { + const { type, ...payload } = agentStepEntrySchema.parse(step.entry); + return { + conversationId, + seq, + contextEpoch, + type, + role: messageRole(step.entry), + payload, + createdAt: new Date(step.createdAtMs), + }; +} +function stepFromRow(row) { + const entry = agentStepEntrySchema.parse({ type: row.type, ...row.payload }); + return { + seq: row.seq, + contextEpoch: row.contextEpoch, + createdAtMs: row.createdAt.getTime(), + entry, + }; +} +function piMessageStep(step) { + return { + entry: { + type: "pi_message", + message: step.message, + ...(step.provenance ? { provenance: step.provenance } : {}), + }, + createdAtMs: step.createdAtMs, + }; +} +function createSqlAgentStepStore(executor) { + return new SqlAgentStepStore(executor); +} +var SqlAgentStepStore; +var init_history2 = __esm({ + "packages/junior/src/chat/conversations/sql/history.ts"() { + "use strict"; + init_history(); + init_conversation_row(); + init_schema(); + SqlAgentStepStore = class { + constructor(executor) { + this.executor = executor; + } + executor; + async append(conversationId, steps) { + const parsed = steps.map((step) => newAgentStepSchema.parse(step)); + if (parsed.length === 0) { + return; + } + const newestCreatedAtMs = Math.max( + ...parsed.map((step) => step.createdAtMs), + ); + await this.executor.transaction(async () => { + await ensureConversationRow( + this.executor, + conversationId, + newestCreatedAtMs, + ); + const cursor = await this.readCursor(conversationId); + const contextEpoch = cursor.maxEpoch ?? 0; + let seq = cursor.nextSeq; + const rows = parsed.map((step) => + insertFromStep(conversationId, seq++, contextEpoch, step), + ); + await this.executor.db().insert(juniorAgentSteps).values(rows); + }); + } + async startEpoch(conversationId, opts) { + const parsed = contextEpochStartSchema.parse(opts); + await this.executor.transaction(async () => { + await ensureConversationRow( + this.executor, + conversationId, + Date.now(), + ); + const cursor = await this.readCursor(conversationId); + const contextEpoch = + parsed.reason === "initial" + ? (cursor.maxEpoch ?? 0) + : (cursor.maxEpoch ?? -1) + 1; + let seq = cursor.nextSeq; + const { messages, ...binding } = parsed; + const marker = { + entry: { type: "context_epoch_started", ...binding }, + createdAtMs: Date.now(), + }; + const rows = [marker, ...messages.map(piMessageStep)].map((step) => + insertFromStep(conversationId, seq++, contextEpoch, step), + ); + await this.executor.db().insert(juniorAgentSteps).values(rows); + }); + } + async loadCurrentEpoch(conversationId) { + const cursor = await this.readCursor(conversationId); + if (cursor.maxEpoch === null) { + return []; + } + const rows = await this.executor + .db() + .select() + .from(juniorAgentSteps) + .where( + and3( + eq3(juniorAgentSteps.conversationId, conversationId), + eq3(juniorAgentSteps.contextEpoch, cursor.maxEpoch), + ), + ) + .orderBy(asc2(juniorAgentSteps.seq)); + return rows.map(stepFromRow); + } + async loadHistory(conversationId) { + const rows = await this.executor + .db() + .select() + .from(juniorAgentSteps) + .where(eq3(juniorAgentSteps.conversationId, conversationId)) + .orderBy(asc2(juniorAgentSteps.seq)); + return rows.map(stepFromRow); + } + /** Read the next `seq` and current highest epoch for one conversation. */ + async readCursor(conversationId) { + const rows = await this.executor + .db() + .select({ + maxSeq: sql7`max(${juniorAgentSteps.seq})`, + maxEpoch: sql7`max(${juniorAgentSteps.contextEpoch})`, + }) + .from(juniorAgentSteps) + .where(eq3(juniorAgentSteps.conversationId, conversationId)); + const maxSeq = rows[0]?.maxSeq; + const maxEpoch = rows[0]?.maxEpoch; + return { + maxEpoch: + maxEpoch === null || maxEpoch === void 0 ? null : Number(maxEpoch), + nextSeq: + maxSeq === null || maxSeq === void 0 ? 0 : Number(maxSeq) + 1, + }; + } + }; + }, +}); + +// packages/junior/src/chat/conversations/sql/messages.ts +import { + and as and4, + asc as asc3, + eq as eq4, + isNull as isNull2, + sql as sql8, +} from "drizzle-orm"; +function messageFromRow(row) { + return { + conversationId: row.conversationId, + messageId: row.messageId, + role: row.role, + text: row.text, + createdAtMs: row.createdAt.getTime(), + ...(row.authorIdentityId ? { authorIdentityId: row.authorIdentityId } : {}), + ...(row.meta ? { meta: row.meta } : {}), + ...(row.repliedAt ? { repliedAtMs: row.repliedAt.getTime() } : {}), + }; +} +function createSqlConversationMessageStore(executor) { + return new SqlConversationMessageStore(executor); +} +var SqlConversationMessageStore; +var init_messages2 = __esm({ + "packages/junior/src/chat/conversations/sql/messages.ts"() { + "use strict"; + init_conversation_row(); + init_schema(); + SqlConversationMessageStore = class { + constructor(executor) { + this.executor = executor; + } + executor; + async record(conversationId, messages) { + if (messages.length === 0) { + return; + } + const newestCreatedAtMs = Math.max( + ...messages.map((message) => message.createdAtMs), + ); + await this.executor.transaction(async () => { + await ensureConversationRow( + this.executor, + conversationId, + newestCreatedAtMs, + ); + await this.executor + .db() + .insert(juniorConversationMessages) + .values( + messages.map((message) => ({ + conversationId, + messageId: message.messageId, + role: message.role, + authorIdentityId: message.authorIdentityId ?? null, + text: message.text, + meta: message.meta ?? null, + repliedAt: null, + createdAt: new Date(message.createdAtMs), + })), + ) + .onConflictDoUpdate({ + target: [ + juniorConversationMessages.conversationId, + juniorConversationMessages.messageId, + ], + set: { + meta: sql8`nullif(coalesce(${juniorConversationMessages.meta}, '{}'::jsonb) || coalesce(excluded.meta, '{}'::jsonb), '{}'::jsonb)`, + }, + }); + }); + } + async markReplied(conversationId, messageId, repliedAtMs) { + await this.executor + .db() + .update(juniorConversationMessages) + .set({ + repliedAt: sql8`coalesce(${juniorConversationMessages.repliedAt}, ${new Date(repliedAtMs)})`, + }) + .where( + and4( + eq4(juniorConversationMessages.conversationId, conversationId), + eq4(juniorConversationMessages.messageId, messageId), + isNull2(juniorConversationMessages.repliedAt), + ), + ); + } + async list(conversationId, opts = {}) { + const query = this.executor + .db() + .select() + .from(juniorConversationMessages) + .where(eq4(juniorConversationMessages.conversationId, conversationId)) + .orderBy( + asc3(juniorConversationMessages.createdAt), + asc3(juniorConversationMessages.messageId), + ); + const rows = + opts.limit === void 0 + ? await query + : await query.limit(Math.max(0, opts.limit)); + return rows.map(messageFromRow); + } + }; + }, +}); + +// packages/junior/src/chat/conversations/sql/search.ts +import { + and as and5, + desc as desc2, + eq as eq5, + inArray, + isNull as isNull3, + ne, + sql as sql9, +} from "drizzle-orm"; +var init_search = __esm({ + "packages/junior/src/chat/conversations/sql/search.ts"() { + "use strict"; + init_schema(); + }, +}); + +// packages/junior/src/db/neon.ts +import { AsyncLocalStorage } from "node:async_hooks"; +import { Pool } from "@neondatabase/serverless"; +import { drizzle } from "drizzle-orm/neon-serverless"; +function createNeonJuniorSqlExecutor(args) { + return new NeonExecutor( + new Pool({ + connectionString: args.connectionString, + max: 3, + }), + ); +} +var NeonExecutor; +var init_neon = __esm({ + "packages/junior/src/db/neon.ts"() { + "use strict"; + init_schema(); + NeonExecutor = class { + constructor(pool) { + this.pool = pool; + } + pool; + transactionClient = new AsyncLocalStorage(); + savepointId = 0; + db() { + return drizzle(this.queryClient(), { + schema: juniorSqlSchema, + }); + } + async execute(statement, params = []) { + await this.queryClient().query(statement, [...params]); + } + async query(statement, params = []) { + const result = await this.queryClient().query(statement, [...params]); + return result.rows; + } + async transaction(callback) { + const existingClient = this.transactionClient.getStore(); + if (existingClient) { + const savepoint = `junior_savepoint_${++this.savepointId}`; + await existingClient.query(`SAVEPOINT ${savepoint}`); + try { + const result = await callback(); + await existingClient.query(`RELEASE SAVEPOINT ${savepoint}`); + return result; + } catch (error) { + await existingClient.query(`ROLLBACK TO SAVEPOINT ${savepoint}`); + await existingClient.query(`RELEASE SAVEPOINT ${savepoint}`); + throw error; + } + } + const client = await this.pool.connect(); + try { + await client.query("BEGIN"); + const result = await this.transactionClient.run(client, callback); + await client.query("COMMIT"); + return result; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } + } + async withLock(lockName, callback) { + if (!lockName) { + throw new Error("SQL lock name is required"); + } + const existingClient = this.transactionClient.getStore(); + if (existingClient) { + await existingClient.query( + "SELECT pg_advisory_xact_lock(hashtext($1))", + [lockName], + ); + return await callback(); + } + const client = await this.pool.connect(); + try { + await client.query("BEGIN"); + return await this.transactionClient.run(client, async () => { + try { + await client.query("SELECT pg_advisory_xact_lock(hashtext($1))", [ + lockName, + ]); + const result = await callback(); + await client.query("COMMIT"); + return result; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } + }); + } finally { + client.release(); + } + } + async withMigrationLock(migrationTable, callback) { + const client = await this.pool.connect(); + const lockName = `junior:migrate:${migrationTable}`; + try { + await client.query("SELECT pg_advisory_lock(hashtext($1))", [ + lockName, + ]); + return await callback(); + } finally { + client.release(true); + } + } + async close() { + await this.pool.end(); + } + queryClient() { + return this.transactionClient.getStore() ?? this.pool; + } + }; + }, +}); + +// packages/junior/src/db/postgres.ts +import { AsyncLocalStorage as AsyncLocalStorage2 } from "node:async_hooks"; +import pg from "pg"; +import { drizzle as drizzle2 } from "drizzle-orm/node-postgres"; +function createPostgresJuniorSqlExecutor(args) { + return new PostgresExecutor( + new Pool2({ + application_name: args.applicationName, + connectionString: args.connectionString, + max: 3, + }), + ); +} +var Pool2, PostgresExecutor; +var init_postgres = __esm({ + "packages/junior/src/db/postgres.ts"() { + "use strict"; + init_schema(); + ({ Pool: Pool2 } = pg); + PostgresExecutor = class { + constructor(pool) { + this.pool = pool; + } + pool; + transactionClient = new AsyncLocalStorage2(); + savepointId = 0; + db() { + return drizzle2(this.queryClient(), { + schema: juniorSqlSchema, + }); + } + async execute(statement, params = []) { + await this.queryClient().query(statement, [...params]); + } + async query(statement, params = []) { + const result = await this.queryClient().query(statement, [...params]); + return result.rows; + } + async transaction(callback) { + const existingClient = this.transactionClient.getStore(); + if (existingClient) { + const savepoint = `junior_savepoint_${++this.savepointId}`; + await existingClient.query(`SAVEPOINT ${savepoint}`); + try { + const result = await callback(); + await existingClient.query(`RELEASE SAVEPOINT ${savepoint}`); + return result; + } catch (error) { + await existingClient.query(`ROLLBACK TO SAVEPOINT ${savepoint}`); + await existingClient.query(`RELEASE SAVEPOINT ${savepoint}`); + throw error; + } + } + const client = await this.pool.connect(); + try { + await client.query("BEGIN"); + const result = await this.transactionClient.run(client, callback); + await client.query("COMMIT"); + return result; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } + } + async withLock(lockName, callback) { + if (!lockName) { + throw new Error("SQL lock name is required"); + } + const existingClient = this.transactionClient.getStore(); + if (existingClient) { + await existingClient.query( + "SELECT pg_advisory_xact_lock(hashtext($1))", + [lockName], + ); + return await callback(); + } + const client = await this.pool.connect(); + try { + await client.query("BEGIN"); + return await this.transactionClient.run(client, async () => { + try { + await client.query("SELECT pg_advisory_xact_lock(hashtext($1))", [ + lockName, + ]); + const result = await callback(); + await client.query("COMMIT"); + return result; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } + }); + } finally { + client.release(); + } + } + async withMigrationLock(migrationTable, callback) { + const client = await this.pool.connect(); + const lockName = `junior:migrate:${migrationTable}`; + try { + await client.query("SELECT pg_advisory_lock(hashtext($1))", [ + lockName, + ]); + return await callback(); + } finally { + client.release(true); + } + } + async close() { + await this.pool.end(); + } + queryClient() { + return this.transactionClient.getStore() ?? this.pool; + } + }; + }, +}); + +// packages/junior/src/db/executor.ts +function createJuniorSqlExecutor(args) { + if (args.driver === "postgres") { + return createPostgresJuniorSqlExecutor(args); + } + return createNeonJuniorSqlExecutor(args); +} +var init_executor = __esm({ + "packages/junior/src/db/executor.ts"() { + "use strict"; + init_neon(); + init_postgres(); + }, +}); + +// packages/junior/src/chat/db.ts +var init_db = __esm({ + "packages/junior/src/chat/db.ts"() { + "use strict"; + init_config(); + init_store(); + init_history2(); + init_messages2(); + init_search(); + init_executor(); + }, +}); + +// packages/junior/src/chat/conversations/visible-compactions.ts +var init_visible_compactions = __esm({ + "packages/junior/src/chat/conversations/visible-compactions.ts"() { + "use strict"; + init_db(); + }, +}); + +// packages/junior/src/chat/coerce.ts +function toOptionalString(value) { + return typeof value === "string" && value.trim() ? value : void 0; +} +function toOptionalNumber(value) { + return typeof value === "number" && Number.isFinite(value) ? value : void 0; +} +function isRecord(value) { + return typeof value === "object" && value !== null; +} +var init_coerce = __esm({ + "packages/junior/src/chat/coerce.ts"() { + "use strict"; + }, +}); + +// packages/junior/src/chat/sentry.ts +var sentry_exports = {}; +__export(sentry_exports, { + captureException: () => captureException, + captureMessage: () => captureMessage, + continueTrace: () => continueTrace, + flush: () => flush, + getActiveSpan: () => getActiveSpan, + getClient: () => getClient, + getGlobalScope: () => getGlobalScope, + getTraceData: () => getTraceData, + init: () => init, + logger: () => logger, + setTag: () => setTag, + setUser: () => setUser, + spanToJSON: () => spanToJSON, + startInactiveSpan: () => startInactiveSpan, + startSpan: () => startSpan, + vercelAIIntegration: () => vercelAIIntegration, + withActiveSpan: () => withActiveSpan, + withScope: () => withScope, + withStreamedSpan: () => withStreamedSpan, +}); +import { + captureException, + captureMessage, + continueTrace, + flush, + getActiveSpan, + getClient, + getGlobalScope, + getTraceData, + init, + logger, + setTag, + setUser, + spanToJSON, + startInactiveSpan, + startSpan, + vercelAIIntegration, + withActiveSpan, + withScope, + withStreamedSpan, +} from "@sentry/node"; +import * as node_star from "@sentry/node"; +var init_sentry = __esm({ + "packages/junior/src/chat/sentry.ts"() { + "use strict"; + __reExport(sentry_exports, node_star); + }, +}); + +// packages/junior/src/deployment.ts +function toOptionalTrimmed(value) { + const trimmed = value?.trim(); + return trimmed ? trimmed : void 0; +} +function getDeploymentServiceVersion() { + return ( + toOptionalTrimmed(process.env.SENTRY_RELEASE) ?? + toOptionalTrimmed(process.env.VERCEL_GIT_COMMIT_SHA) + ); +} +function getDeploymentTelemetryAttributes() { + const attributes = {}; + const serviceVersion = getDeploymentServiceVersion(); + const deploymentId = toOptionalTrimmed(process.env.VERCEL_DEPLOYMENT_ID); + if (serviceVersion) { + attributes["service.version"] = serviceVersion; + } + if (deploymentId) { + attributes["deployment.id"] = deploymentId; + } + return attributes; +} +var init_deployment = __esm({ + "packages/junior/src/deployment.ts"() { + "use strict"; + }, +}); + +// packages/junior/src/chat/logging.ts +import { AsyncLocalStorage as AsyncLocalStorage3 } from "node:async_hooks"; +import { + ConfigError, + configureSync, + getConfig, + getLogger, +} from "@logtape/logtape"; +var contextStorage, + deploymentLogAttributes, + CONSOLE_PRIORITY_KEYS, + CONSOLE_PRIORITY_INDEX; +var init_logging = __esm({ + "packages/junior/src/chat/logging.ts"() { + "use strict"; + init_coerce(); + init_identity(); + init_sentry(); + init_sentry(); + init_deployment(); + contextStorage = new AsyncLocalStorage3(); + deploymentLogAttributes = getDeploymentTelemetryAttributes(); + CONSOLE_PRIORITY_KEYS = [ + "gen_ai.conversation.id", + "event.name", + "app.log.source", + "exception.message", + "messaging.message.id", + "trace_id", + "span_id", + "messaging.message.conversation_id", + "messaging.destination.name", + "app.run.id", + "app.message.kind", + ]; + CONSOLE_PRIORITY_INDEX = new Map( + CONSOLE_PRIORITY_KEYS.map((key3, index6) => [key3, index6]), + ); + }, +}); + +// packages/junior/src/chat/pi/sdk.ts +import { + completeSimple, + getEnvApiKey, + getModels, + registerApiProvider, +} from "@earendil-works/pi-ai/compat"; +import { + stream, + streamSimple, +} from "@earendil-works/pi-ai/api/anthropic-messages"; +var init_sdk = __esm({ + "packages/junior/src/chat/pi/sdk.ts"() { + "use strict"; + }, +}); + +// packages/junior/src/chat/optional-string.ts +var init_optional_string = __esm({ + "packages/junior/src/chat/optional-string.ts"() { + "use strict"; + }, +}); + +// packages/junior/src/chat/slack/context.ts +import { z as z9 } from "zod"; +var slackMessageEnvelopeSchema, + rawSlackMessageSchema, + nestedRawSlackMessageSchema; +var init_context = __esm({ + "packages/junior/src/chat/slack/context.ts"() { + "use strict"; + init_coerce(); + init_ids(); + init_timestamp(); + slackMessageEnvelopeSchema = z9.object({ + raw: z9.unknown().optional(), + }); + rawSlackMessageSchema = z9.object({ + channel: z9.unknown().optional(), + message: z9.unknown().optional(), + team: z9.unknown().optional(), + team_id: z9.unknown().optional(), + thread_ts: z9.unknown().optional(), + ts: z9.unknown().optional(), + user_team: z9.unknown().optional(), + }); + nestedRawSlackMessageSchema = z9.object({ + ts: z9.unknown().optional(), + }); + }, +}); + +// packages/junior/src/chat/conversation-privacy.ts +import { AsyncLocalStorage as AsyncLocalStorage4 } from "node:async_hooks"; +var conversationPrivacyStorage; +var init_conversation_privacy = __esm({ + "packages/junior/src/chat/conversation-privacy.ts"() { + "use strict"; + init_context(); + conversationPrivacyStorage = new AsyncLocalStorage4(); + }, +}); + +// packages/junior/src/chat/xml.ts +function unescapeXml(value) { + return value + .replaceAll(""", '"') + .replaceAll("'", "'") + .replaceAll(">", ">") + .replaceAll("<", "<") + .replaceAll("&", "&"); +} +var init_xml = __esm({ + "packages/junior/src/chat/xml.ts"() { + "use strict"; + }, +}); + +// packages/junior/src/chat/current-instruction.ts +var CURRENT_INSTRUCTION_TAG, + CURRENT_INSTRUCTION_OPEN_PREFIX, + CURRENT_INSTRUCTION_OPEN_BARE, + CURRENT_INSTRUCTION_OPEN_ATTR_PREFIX, + CURRENT_INSTRUCTION_CLOSE; +var init_current_instruction = __esm({ + "packages/junior/src/chat/current-instruction.ts"() { + "use strict"; + init_xml(); + CURRENT_INSTRUCTION_TAG = "current-instruction"; + CURRENT_INSTRUCTION_OPEN_PREFIX = `<${CURRENT_INSTRUCTION_TAG}`; + CURRENT_INSTRUCTION_OPEN_BARE = `<${CURRENT_INSTRUCTION_TAG}>`; + CURRENT_INSTRUCTION_OPEN_ATTR_PREFIX = `<${CURRENT_INSTRUCTION_TAG} `; + CURRENT_INSTRUCTION_CLOSE = ` +`; + }, +}); + +// packages/junior/src/chat/turn-context-tag.ts +var TURN_CONTEXT_TAG; +var init_turn_context_tag = __esm({ + "packages/junior/src/chat/turn-context-tag.ts"() { + "use strict"; + TURN_CONTEXT_TAG = "runtime-turn-context"; + }, +}); + +// packages/junior/src/chat/pi/transcript.ts +var RUNTIME_TURN_CONTEXT_START, + EMBEDDED_THREAD_CONTEXT_TAGS, + EMBEDDED_THREAD_CONTEXT_PATTERN; +var init_transcript = __esm({ + "packages/junior/src/chat/pi/transcript.ts"() { + "use strict"; + init_current_instruction(); + init_turn_context_tag(); + RUNTIME_TURN_CONTEXT_START = `<${TURN_CONTEXT_TAG}>`; + EMBEDDED_THREAD_CONTEXT_TAGS = [ + "recent-thread-messages", + "thread-compactions", + "thread-transcript", + "thread-background", + ]; + EMBEDDED_THREAD_CONTEXT_PATTERN = new RegExp( + `<(${EMBEDDED_THREAD_CONTEXT_TAGS.join("|")})(?:\\s[^>]*)?>[\\s\\S]*?`, + "g", + ); + }, +}); + +// packages/junior/src/chat/services/provider-retry.ts +var init_provider_retry = __esm({ + "packages/junior/src/chat/services/provider-retry.ts"() { + "use strict"; + init_transcript(); + }, +}); + +// packages/junior/src/chat/pi/client.ts +import { createGatewayProvider } from "@ai-sdk/gateway"; +import { embedMany, generateObject } from "ai"; +var init_client = __esm({ + "packages/junior/src/chat/pi/client.ts"() { + "use strict"; + init_sdk(); + init_logging(); + init_logging(); + init_optional_string(); + init_conversation_privacy(); + init_provider_retry(); + registerApiProvider({ + api: "anthropic-messages", + stream, + streamSimple, + }); + }, +}); + +// packages/junior/src/chat/services/context-budget.ts +var init_context_budget = __esm({ + "packages/junior/src/chat/services/context-budget.ts"() { + "use strict"; + init_config(); + init_client(); + }, +}); + +// packages/junior/src/chat/services/conversation-memory.ts +var init_conversation_memory = __esm({ + "packages/junior/src/chat/services/conversation-memory.ts"() { + "use strict"; + init_config(); + init_logging(); + init_context_budget(); + init_timestamp(); + init_xml(); + }, +}); + +// packages/junior/src/chat/conversations/visible-messages.ts +function toStoredConversationMessage(message) { + const meta = {}; + if (message.author) { + meta.author = message.author; + } + const { replied, ...restMeta } = message.meta ?? {}; + Object.assign(meta, restMeta); + if (replied === false) { + meta.replied = false; + } + return { + messageId: message.id, + role: message.role, + text: message.text, + ...(Object.keys(meta).length > 0 ? { meta } : {}), + createdAtMs: message.createdAtMs, + }; +} +var init_visible_messages = __esm({ + "packages/junior/src/chat/conversations/visible-messages.ts"() { + "use strict"; + init_db(); + init_visible_compactions(); + init_conversation_memory(); + }, +}); + +// packages/junior/src/chat/conversations/legacy-advisor-session.ts +import { z as z10 } from "zod"; +function key2(conversationId) { + return `junior:${conversationId}:advisor_session`; +} +function createLegacyAdvisorSessionReader() { + return { + load: async (conversationId) => { + const stateAdapter2 = getStateAdapter(); + await stateAdapter2.connect(); + return legacyAdvisorMessagesSchema.parse( + structuredClone((await stateAdapter2.get(key2(conversationId))) ?? []), + ); + }, + }; +} +var legacyAdvisorMessagesSchema; +var init_legacy_advisor_session = __esm({ + "packages/junior/src/chat/conversations/legacy-advisor-session.ts"() { + "use strict"; + init_messages(); + init_adapter(); + legacyAdvisorMessagesSchema = z10.array(piMessageSchema); + }, +}); + +// packages/junior/src/chat/conversations/sql/legacy-history-import.ts +import { eq as eq6, sql as sql10 } from "drizzle-orm"; +function importedAdvisorChildConversationId(parentConversationId) { + return `advisor:${parentConversationId}`; +} +function epochFromSessionId(sessionId) { + const match = /^session_(\d+)$/.exec(sessionId); + return match ? Number(match[1]) : 0; +} +function messageTimestampMs(message) { + const timestamp2 = message.timestamp; + return typeof timestamp2 === "number" ? timestamp2 : void 0; +} +function readAdvisorRequest(text7) { + if ( + !text7.startsWith(ADVISOR_TASK_OPEN) || + !text7.endsWith(ADVISOR_CONTEXT_CLOSE) + ) { + return void 0; + } + const taskEnd = text7.indexOf(ADVISOR_TASK_CLOSE, ADVISOR_TASK_OPEN.length); + if (taskEnd < 0) { + return void 0; + } + const contextStart = taskEnd + ADVISOR_TASK_CLOSE.length + 2; + if (!text7.startsWith(ADVISOR_CONTEXT_OPEN, contextStart)) { + return void 0; + } + const task = text7.slice(ADVISOR_TASK_OPEN.length, taskEnd); + const context = text7.slice( + contextStart + ADVISOR_CONTEXT_OPEN.length, + -ADVISOR_CONTEXT_CLOSE.length, + ); + return `${unescapeXml(task)} + +Executor context: +${unescapeXml(context)}`; +} +function normalizeAdvisorMessage(message) { + const record = message; + if (record.role !== "user" || !Array.isArray(record.content)) { + return message; + } + let changed = false; + const content = record.content.map((part) => { + if ( + !part || + typeof part !== "object" || + part.type !== "text" || + typeof part.text !== "string" + ) { + return part; + } + const text7 = readAdvisorRequest(part.text); + if (text7 === void 0) { + return part; + } + changed = true; + return { ...part, text: text7 }; + }); + return changed ? { ...record, content } : message; +} +function piEntryProvenance(entry) { + if (entry.provenance) { + return entry.provenance; + } + if (entry.actor) { + return legacyActorProvenance(entry.actor); + } + return contextProvenance; +} +function convertLegacySessionLog(args) { + const steps = []; + const fallback = args.fallbackCreatedAtMs; + let advisorChildConversationId; + let seq = 0; + const push = (contextEpoch, entry, createdAtMs) => { + steps.push({ seq, contextEpoch, entry, createdAtMs }); + seq += 1; + }; + for (const entry of args.entries) { + const epoch = epochFromSessionId(entry.sessionId ?? INITIAL_SESSION_ID2); + switch (entry.type) { + case "pi_message": { + push( + epoch, + { + type: "pi_message", + message: entry.message, + provenance: piEntryProvenance(entry), + }, + messageTimestampMs(entry.message) ?? fallback, + ); + break; + } + case "projection_reset": { + const provenance = + entry.provenance ?? entry.messages.map(() => contextProvenance); + if (provenance.length !== entry.messages.length) { + throw new Error( + "projection_reset provenance must align one-to-one with messages", + ); + } + push( + epoch, + { type: "context_epoch_started", reason: "compaction" }, + fallback, + ); + entry.messages.forEach((message, index6) => { + push( + epoch, + { + type: "pi_message", + message, + provenance: provenance[index6], + }, + messageTimestampMs(message) ?? fallback, + ); + }); + break; + } + case "mcp_provider_connected": { + push( + epoch, + { type: "mcp_provider_connected", provider: entry.provider }, + fallback, + ); + break; + } + case "authorization_requested": { + push( + epoch, + { + type: "authorization_requested", + kind: entry.kind, + provider: entry.provider, + actorId: entry.actorId, + authorizationId: entry.authorizationId, + delivery: entry.delivery, + }, + entry.createdAtMs, + ); + break; + } + case "authorization_completed": { + push( + epoch, + { + type: "authorization_completed", + kind: entry.kind, + provider: entry.provider, + actorId: entry.actorId, + authorizationId: entry.authorizationId, + }, + entry.createdAtMs, + ); + break; + } + case "tool_execution_started": { + push( + epoch, + { + type: "tool_execution_started", + toolCallId: entry.toolCallId, + toolName: entry.toolName, + ...(entry.args !== void 0 ? { args: entry.args } : {}), + }, + entry.createdAtMs, + ); + break; + } + case "subagent_started": { + const childConversationId = importedAdvisorChildConversationId( + args.conversationId, + ); + advisorChildConversationId = childConversationId; + push( + epoch, + { + type: "subagent_started", + subagentInvocationId: entry.subagentInvocationId, + subagentKind: entry.subagentKind, + ...(entry.parentToolCallId + ? { parentToolCallId: entry.parentToolCallId } + : {}), + childConversationId, + historyMode: "shared", + }, + entry.createdAtMs, + ); + break; + } + case "subagent_ended": { + push( + epoch, + { + type: "subagent_ended", + subagentInvocationId: entry.subagentInvocationId, + outcome: entry.outcome, + ...(entry.errorCode ? { errorCode: entry.errorCode } : {}), + }, + entry.createdAtMs, + ); + break; + } + case "actor_recorded": { + break; + } + } + } + return { + steps, + ...(advisorChildConversationId ? { advisorChildConversationId } : {}), + }; +} +function convertAdvisorMessages(messages, fallbackCreatedAtMs) { + return messages.map((sourceMessage, seq) => { + const message = normalizeAdvisorMessage(sourceMessage); + return { + seq, + contextEpoch: 0, + entry: { type: "pi_message", message, provenance: contextProvenance }, + createdAtMs: messageTimestampMs(message) ?? fallbackCreatedAtMs, + }; + }); +} +function messageRole2(entry) { + if (entry.type !== "pi_message") { + return null; + } + const role = entry.message.role; + return typeof role === "string" ? role : null; +} +function insertRow(conversationId, step) { + const { type, ...payload } = agentStepEntrySchema.parse(step.entry); + return { + conversationId, + seq: step.seq, + contextEpoch: step.contextEpoch, + type, + role: messageRole2(step.entry), + payload, + createdAt: new Date(step.createdAtMs), + }; +} +async function writeLegacyImport(executor, args) { + return executor.withLock( + `junior_conversation:legacy-import:${args.conversationId}`, + () => + executor.transaction(async () => { + const db = executor.db(); + const conversations = await db + .select({ + transcriptPurgedAt: juniorConversations.transcriptPurgedAt, + }) + .from(juniorConversations) + .where(eq6(juniorConversations.conversationId, args.conversationId)) + .for("update"); + if (conversations[0]?.transcriptPurgedAt) { + return false; + } + const existing = await db + .select({ seq: juniorAgentSteps.seq }) + .from(juniorAgentSteps) + .where(eq6(juniorAgentSteps.conversationId, args.conversationId)) + .limit(1); + if (existing.length > 0) { + return false; + } + const createdAt = new Date(args.fallbackCreatedAtMs); + await ensureConversationRow2( + executor, + args.conversationId, + createdAt, + new Date(args.lastActivityAtMs), + ); + if (args.messages && args.messages.length > 0) { + await db + .insert(juniorConversationMessages) + .values( + args.messages.map((message) => ({ + conversationId: args.conversationId, + messageId: message.messageId, + role: message.role, + authorIdentityId: message.authorIdentityId ?? null, + text: message.text, + meta: message.meta ?? null, + repliedAt: + message.repliedAtMs === void 0 + ? null + : new Date(message.repliedAtMs), + createdAt: new Date(message.createdAtMs), + })), + ) + .onConflictDoUpdate({ + target: [ + juniorConversationMessages.conversationId, + juniorConversationMessages.messageId, + ], + set: { + meta: sql10`nullif(coalesce(${juniorConversationMessages.meta}, '{}'::jsonb) || coalesce(excluded.meta, '{}'::jsonb), '{}'::jsonb)`, + repliedAt: sql10`coalesce(${juniorConversationMessages.repliedAt}, excluded.replied_at)`, + }, + }); + } + if (args.steps.length > 0) { + await db + .insert(juniorAgentSteps) + .values( + args.steps.map((step) => insertRow(args.conversationId, step)), + ); + } + if (args.child) { + const childCreatedAtMs = + args.child.steps.length > 0 + ? Math.min(...args.child.steps.map((step) => step.createdAtMs)) + : args.fallbackCreatedAtMs; + const childLastActivityAtMs = + args.child.steps.length > 0 + ? Math.max(...args.child.steps.map((step) => step.createdAtMs)) + : childCreatedAtMs; + await ensureChildConversationRow( + executor, + args.child.conversationId, + args.conversationId, + new Date(childCreatedAtMs), + new Date(childLastActivityAtMs), + ); + if (args.child.steps.length > 0) { + await db + .insert(juniorAgentSteps) + .values( + args.child.steps.map((step) => + insertRow(args.child.conversationId, step), + ), + ); + } + } + return true; + }), + ); +} +async function ensureConversationRow2( + executor, + conversationId, + createdAt, + lastActivityAt, +) { + await executor + .db() + .insert(juniorConversations) + .values({ + conversationId, + schemaVersion: 1, + createdAt, + lastActivityAt, + updatedAt: lastActivityAt, + executionStatus: "idle", + }) + .onConflictDoUpdate({ + target: juniorConversations.conversationId, + set: { + createdAt: sql10`least(${juniorConversations.createdAt}, excluded.created_at)`, + lastActivityAt: sql10`greatest(${juniorConversations.lastActivityAt}, excluded.last_activity_at)`, + updatedAt: sql10`greatest(${juniorConversations.updatedAt}, excluded.updated_at)`, + }, + }); +} +async function ensureChildConversationRow( + executor, + childConversationId, + parentConversationId, + createdAt, + lastActivityAt, +) { + await ensureConversationRow2( + executor, + parentConversationId, + createdAt, + lastActivityAt, + ); + await executor + .db() + .insert(juniorConversations) + .values({ + conversationId: childConversationId, + schemaVersion: 1, + parentConversationId, + createdAt, + lastActivityAt, + updatedAt: lastActivityAt, + executionStatus: "idle", + }) + .onConflictDoUpdate({ + target: juniorConversations.conversationId, + set: { + parentConversationId: sql10`coalesce(${juniorConversations.parentConversationId}, excluded.parent_conversation_id)`, + createdAt: sql10`least(${juniorConversations.createdAt}, excluded.created_at)`, + lastActivityAt: sql10`greatest(${juniorConversations.lastActivityAt}, excluded.last_activity_at)`, + updatedAt: sql10`greatest(${juniorConversations.updatedAt}, excluded.updated_at)`, + }, + }); +} +var INITIAL_SESSION_ID2, + ADVISOR_TASK_OPEN, + ADVISOR_TASK_CLOSE, + ADVISOR_CONTEXT_OPEN, + ADVISOR_CONTEXT_CLOSE; +var init_legacy_history_import = __esm({ + "packages/junior/src/chat/conversations/sql/legacy-history-import.ts"() { + "use strict"; + init_xml(); + init_session_log(); + init_history(); + init_schema(); + INITIAL_SESSION_ID2 = "session_0"; + ADVISOR_TASK_OPEN = "\n"; + ADVISOR_TASK_CLOSE = "\n"; + ADVISOR_CONTEXT_OPEN = "\n"; + ADVISOR_CONTEXT_CLOSE = "\n"; + }, +}); + +// packages/junior/src/chat/conversations/legacy-import.ts +import { isDeepStrictEqual } from "node:util"; +import { eq as eq7 } from "drizzle-orm"; +import { z as z11 } from "zod"; +async function loadThreadStateSnapshot(conversationId) { + const stateAdapter2 = getStateAdapter(); + await stateAdapter2.connect(); + const raw = await stateAdapter2.get(`thread-state:${conversationId}`); + if (!raw) { + return { compactions: [], messages: [] }; + } + const conversation = legacyThreadStateSnapshotSchema.parse(raw).conversation; + return { + compactions: conversation?.compactions ?? [], + messages: conversation?.messages ?? [], + ...(conversation?.stats?.updatedAtMs !== void 0 + ? { lastActivityAtMs: conversation.stats.updatedAtMs } + : {}), + }; +} +function intrinsicTimestamps(entries, visible, compactions) { + const candidates = []; + const pushMessageTs = (message) => { + const timestamp2 = message.timestamp; + if (typeof timestamp2 === "number") { + candidates.push(timestamp2); + } + }; + for (const entry of entries) { + if (entry.type === "pi_message") { + pushMessageTs(entry.message); + } else if (entry.type === "projection_reset") { + entry.messages.forEach(pushMessageTs); + } else if ("createdAtMs" in entry) { + candidates.push(entry.createdAtMs); + } + } + for (const message of visible) { + candidates.push(message.createdAtMs); + } + for (const compaction of compactions) { + candidates.push(compaction.createdAtMs); + } + return candidates; +} +async function importConversationFromLegacy(conversationId, deps) { + const stepStore = createSqlAgentStepStore(deps.executor); + const existing = await stepStore.loadCurrentEpoch(conversationId); + if (existing.length > 0) { + return { imported: false }; + } + const entries = deps.sessionLogStore + ? await deps.sessionLogStore.read({ conversationId }) + : await readSessionLogEntries({ conversationId }); + const snapshot = deps.loadVisibleMessages + ? { + compactions: deps.legacyCompactions ?? [], + messages: await deps.loadVisibleMessages(conversationId), + } + : await loadThreadStateSnapshot(conversationId); + const { compactions, messages: visible } = snapshot; + if ( + entries.length === 0 && + visible.length === 0 && + compactions.length === 0 + ) { + return { imported: false }; + } + const hasAdvisor = entries.some((entry) => entry.type === "subagent_started"); + const advisorMessages = hasAdvisor + ? await ( + deps.advisorSessionStore ?? createLegacyAdvisorSessionReader() + ).load(conversationId) + : []; + const intrinsic = intrinsicTimestamps(entries, visible, compactions); + for (const message of advisorMessages) { + const timestamp2 = message.timestamp; + if (typeof timestamp2 === "number") { + intrinsic.push(timestamp2); + } + } + const fallbackCreatedAtMs = + deps.conversationRecord?.createdAtMs ?? + (intrinsic.length > 0 ? Math.min(...intrinsic) : void 0) ?? + 0; + const lastActivityAtMs = Math.max( + fallbackCreatedAtMs, + deps.conversationRecord?.lastActivityAtMs ?? 0, + deps.legacyLastActivityAtMs ?? 0, + intrinsic.length > 0 ? Math.max(...intrinsic) : 0, + ); + const converted = convertLegacySessionLog({ + conversationId, + entries, + fallbackCreatedAtMs, + }); + if (compactions.length > 0) { + converted.steps.push({ + seq: converted.steps.length, + contextEpoch: converted.steps.at(-1)?.contextEpoch ?? 0, + entry: { type: "visible_context_compacted", compactions }, + createdAtMs: compactions.at(-1)?.createdAtMs ?? fallbackCreatedAtMs, + }); + } + if (converted.steps.length === 0 && visible.length > 0) { + const existingMessages = new Map( + (await deps.messageStore.list(conversationId)).map((message) => [ + message.messageId, + message, + ]), + ); + const fullyImported = visible.every((message) => { + const existingMessage = existingMessages.get(message.id); + const projected = toStoredConversationMessage(message); + return ( + existingMessage !== void 0 && + Object.entries(projected.meta ?? {}).every(([key3, value]) => + isDeepStrictEqual(existingMessage.meta?.[key3], value), + ) && + (message.meta?.replied !== true || + existingMessage.repliedAtMs !== void 0) + ); + }); + if (fullyImported) { + await writeLegacyImport(deps.executor, { + conversationId, + fallbackCreatedAtMs, + lastActivityAtMs, + steps: [], + }); + return { imported: false }; + } + } + let child; + if (converted.advisorChildConversationId) { + child = { + conversationId: converted.advisorChildConversationId, + steps: convertAdvisorMessages(advisorMessages, fallbackCreatedAtMs), + }; + } + const messages = visible.map((message) => ({ + ...toStoredConversationMessage(message), + ...(message.meta?.replied ? { repliedAtMs: message.createdAtMs } : {}), + })); + const imported = await writeLegacyImport(deps.executor, { + conversationId, + fallbackCreatedAtMs, + lastActivityAtMs, + ...(messages.length > 0 ? { messages } : {}), + steps: converted.steps, + ...(child ? { child } : {}), + }); + return { imported }; +} +var legacyVisibleMessageSchema, + legacyCompactionSchema, + legacyThreadStateSnapshotSchema; +var init_legacy_import = __esm({ + "packages/junior/src/chat/conversations/legacy-import.ts"() { + "use strict"; + init_visible_messages(); + init_adapter(); + init_session_log(); + init_legacy_advisor_session(); + init_schema(); + init_db(); + init_history2(); + init_legacy_history_import(); + legacyVisibleMessageSchema = z11.object({ + id: z11.string(), + role: z11.enum(["user", "assistant", "system"]), + text: z11.string(), + createdAtMs: z11.number().finite(), + author: z11.object({}).passthrough().optional(), + meta: z11.object({}).passthrough().optional(), + }); + legacyCompactionSchema = z11.object({ + coveredMessageIds: z11.array(z11.string()), + createdAtMs: z11.number().finite(), + id: z11.string(), + summary: z11.string(), + }); + legacyThreadStateSnapshotSchema = z11.object({ + conversation: z11 + .object({ + compactions: z11.array(legacyCompactionSchema).optional(), + messages: z11.array(legacyVisibleMessageSchema).optional(), + stats: z11 + .object({ updatedAtMs: z11.number().finite().optional() }) + .passthrough() + .optional(), + }) + .passthrough() + .optional(), + }); + }, +}); + +// packages/junior/src/cli/upgrade/migrations/conversations-history-sql.ts +init_config(); +init_legacy_import(); +init_messages2(); + +// packages/junior/src/chat/task-execution/state.ts +init_coerce(); +init_config(); +init_destination(); +init_actor(); +init_adapter(); + +// packages/junior/src/chat/state/ttl.ts +var JUNIOR_THREAD_STATE_TTL_MS = 7 * 24 * 60 * 60 * 1e3; + +// packages/junior/src/chat/task-execution/state.ts +var CONVERSATION_PREFIX = "junior:conversation"; +var CONVERSATION_SCHEMA_VERSION = 1; +var CONVERSATION_ACTIVITY_INDEX_MAX_LENGTH = 1e4; +var CONVERSATION_INDEX_LOCK_TTL_MS = 1e4; +var CONVERSATION_INDEX_LOCK_WAIT_MS = 2e3; +var CONVERSATION_INDEX_LOCK_RETRY_MS = 25; +var CONVERSATION_MUTATION_LOCK_TTL_MS = 1e4; +var CONVERSATION_MUTATION_WAIT_MS = 1e4; +var CONVERSATION_MUTATION_RETRY_MS = 25; +var InvalidConversationRecordError = class extends Error { + constructor(conversationId) { + super(`Conversation record is invalid for ${conversationId}`); + this.name = "InvalidConversationRecordError"; + } +}; +var ConversationMutationFencedError = class extends Error { + constructor(conversationId) { + super( + `Conversation mutation lock was lost before write for ${conversationId}`, + ); + this.name = "ConversationMutationFencedError"; + } +}; +var CONVERSATION_BY_ACTIVITY_INDEX_KEY = `${CONVERSATION_PREFIX}:by-activity`; +var CONVERSATION_ACTIVE_INDEX_KEY = `${CONVERSATION_PREFIX}:active`; +function conversationKey(conversationId) { + return `${CONVERSATION_PREFIX}:${conversationId}`; +} +function indexLockKey(indexKey) { + return `${indexKey}:lock`; +} +function mutationLockKey(conversationId) { + return `${CONVERSATION_PREFIX}:mutation:${conversationId}`; +} +function now() { + return Date.now(); +} +function compareMessages(left, right) { + return ( + left.createdAtMs - right.createdAtMs || + left.receivedAtMs - right.receivedAtMs || + left.inboundMessageId.localeCompare(right.inboundMessageId) + ); +} +function compareIndexDescending(left, right) { + return ( + right.score - left.score || + right.conversationId.localeCompare(left.conversationId) + ); +} +function compareIndexAscending(left, right) { + return ( + left.score - right.score || + left.conversationId.localeCompare(right.conversationId) + ); +} +function uniqueStrings(values) { + return [...new Set(values)]; +} +function normalizeSource(value) { + if ( + value === "api" || + value === "internal" || + value === "local" || + value === "plugin" || + value === "resource_event" || + value === "scheduler" || + value === "slack" + ) { + return value; + } + return void 0; +} +function normalizeExecutionStatus(value) { + if ( + value === "awaiting_resume" || + value === "failed" || + value === "idle" || + value === "pending" || + value === "running" + ) { + return value; + } + return void 0; +} +function normalizeMetadata(value) { + if (!isRecord(value)) { + return void 0; + } + return value; +} +function normalizeInput(value) { + if (!isRecord(value)) { + return void 0; + } + const text7 = toOptionalString(value.text); + if (!text7) { + return void 0; + } + return { + text: text7, + authorId: toOptionalString(value.authorId), + attachments: Array.isArray(value.attachments) + ? [...value.attachments] + : void 0, + metadata: normalizeMetadata(value.metadata), + }; +} +function normalizeMessage(value) { + if (!isRecord(value)) { + return void 0; + } + const conversationId = toOptionalString(value.conversationId); + const inboundMessageId = toOptionalString(value.inboundMessageId); + const source = normalizeSource(value.source); + const destination = parseDestination(value.destination); + const createdAtMs = toOptionalNumber(value.createdAtMs); + const receivedAtMs = toOptionalNumber(value.receivedAtMs); + const input = normalizeInput(value.input); + if ( + !conversationId || + !destination || + !inboundMessageId || + !source || + typeof createdAtMs !== "number" || + typeof receivedAtMs !== "number" || + !input + ) { + return void 0; + } + return { + conversationId, + destination, + inboundMessageId, + source, + createdAtMs, + receivedAtMs, + input, + injectedAtMs: toOptionalNumber(value.injectedAtMs), + attemptCount: toOptionalNumber(value.attemptCount), + }; +} +function normalizeActor(value) { + return parseStoredSlackActor(value); +} +function normalizeLease(value) { + if (!isRecord(value)) { + return void 0; + } + const token = toOptionalString(value.token); + const acquiredAtMs = toOptionalNumber(value.acquiredAtMs); + const lastCheckInAtMs = toOptionalNumber(value.lastCheckInAtMs); + const expiresAtMs = toOptionalNumber(value.expiresAtMs); + if ( + !token || + typeof acquiredAtMs !== "number" || + typeof lastCheckInAtMs !== "number" || + typeof expiresAtMs !== "number" + ) { + return void 0; + } + return { + token, + acquiredAtMs, + lastCheckInAtMs, + expiresAtMs, + }; +} +function normalizeExecution(conversationId, value) { + if (!isRecord(value)) { + return void 0; + } + const status = normalizeExecutionStatus(value.status); + if (!status) { + return void 0; + } + const pendingMessages2 = Array.isArray(value.pendingMessages) + ? value.pendingMessages + .map(normalizeMessage) + .filter((message) => Boolean(message)) + .filter((message) => message.conversationId === conversationId) + .filter((message) => message.injectedAtMs === void 0) + .sort(compareMessages) + : []; + const inboundMessageIds = Array.isArray(value.inboundMessageIds) + ? uniqueStrings( + value.inboundMessageIds + .map((id) => (typeof id === "string" ? id : void 0)) + .filter((id) => Boolean(id)), + ) + : []; + const lease = normalizeLease(value.lease); + const normalizedStatus = + status === "idle" && lease + ? "running" + : status === "idle" && pendingMessages2.length > 0 + ? "pending" + : status; + return { + status: normalizedStatus, + inboundMessageIds: uniqueStrings([ + ...inboundMessageIds, + ...pendingMessages2.map((message) => message.inboundMessageId), + ]), + pendingCount: pendingMessages2.length, + pendingMessages: pendingMessages2, + lease, + lastCheckpointAtMs: toOptionalNumber(value.lastCheckpointAtMs), + lastEnqueuedAtMs: toOptionalNumber(value.lastEnqueuedAtMs), + runId: toOptionalString(value.runId), + updatedAtMs: toOptionalNumber(value.updatedAtMs), + }; +} +function normalizeConversation(conversationId, value) { + if (!isRecord(value) || value.schemaVersion !== CONVERSATION_SCHEMA_VERSION) { + return void 0; + } + const storedConversationId = toOptionalString(value.conversationId); + const createdAtMs = toOptionalNumber(value.createdAtMs); + const lastActivityAtMs = toOptionalNumber(value.lastActivityAtMs); + const updatedAtMs = toOptionalNumber(value.updatedAtMs); + const execution = normalizeExecution(conversationId, value.execution); + const destination = + value.destination === void 0 ? void 0 : parseDestination(value.destination); + if ( + storedConversationId !== conversationId || + typeof createdAtMs !== "number" || + typeof lastActivityAtMs !== "number" || + typeof updatedAtMs !== "number" || + !execution || + (value.destination !== void 0 && !destination) + ) { + return void 0; + } + if ( + execution.pendingMessages.length > 0 && + (!destination || + execution.pendingMessages.some( + (message) => !sameDestination(message.destination, destination), + )) + ) { + return void 0; + } + return { + schemaVersion: CONVERSATION_SCHEMA_VERSION, + conversationId, + createdAtMs, + lastActivityAtMs, + updatedAtMs, + execution, + ...(destination ? { destination } : {}), + ...(toOptionalString(value.title) + ? { title: toOptionalString(value.title) } + : {}), + ...(toOptionalString(value.channelName) + ? { channelName: toOptionalString(value.channelName) } + : {}), + ...(normalizeActor(value.actor) + ? { actor: normalizeActor(value.actor) } + : {}), + ...(normalizeSource(value.source) + ? { source: normalizeSource(value.source) } + : {}), + }; +} +function emptyConversation(args) { + return { + schemaVersion: CONVERSATION_SCHEMA_VERSION, + conversationId: args.conversationId, + createdAtMs: args.nowMs, + lastActivityAtMs: args.nowMs, + updatedAtMs: args.nowMs, + ...(args.destination ? { destination: args.destination } : {}), + ...(args.source ? { source: args.source } : {}), + execution: { + status: "idle", + inboundMessageIds: [], + pendingCount: 0, + pendingMessages: [], + updatedAtMs: args.nowMs, + }, + }; +} +function pendingMessages(conversation) { + return [...conversation.execution.pendingMessages].sort(compareMessages); +} +function isRunnableStatus(status) { + return status !== "failed" && status !== "idle"; +} +function hasRunnableWork(conversation) { + return ( + isRunnableStatus(conversation.execution.status) || + pendingMessages(conversation).length > 0 + ); +} +function executionWithPendingMessages(execution, pending) { + const pendingMessages2 = [...pending].sort(compareMessages); + const status = + execution.status === "idle" && execution.lease + ? "running" + : execution.status === "idle" && pendingMessages2.length > 0 + ? "pending" + : execution.status; + return { + ...execution, + status, + inboundMessageIds: uniqueStrings([ + ...execution.inboundMessageIds, + ...pendingMessages2.map((message) => message.inboundMessageId), + ]), + pendingMessages: pendingMessages2, + pendingCount: pendingMessages2.length, + }; +} +function withExecutionUpdate(conversation, execution, nowMs) { + return { + ...conversation, + updatedAtMs: nowMs, + execution: { + ...executionWithPendingMessages(execution, execution.pendingMessages), + updatedAtMs: nowMs, + }, + }; +} +async function getConnectedState(stateAdapter2) { + const state = stateAdapter2 ?? getStateAdapter(); + await state.connect(); + return state; +} +async function sleep(ms) { + await new Promise((resolve) => { + const timer = setTimeout(resolve, ms); + timer.unref?.(); + }); +} +async function withIndexLock(state, indexKey, callback) { + const startedAtMs = now(); + let lock; + while (true) { + lock = await state.acquireLock( + indexLockKey(indexKey), + CONVERSATION_INDEX_LOCK_TTL_MS, + ); + if (lock) { + break; + } + if (now() - startedAtMs >= CONVERSATION_INDEX_LOCK_WAIT_MS) { + throw new Error( + `Could not acquire conversation index lock for ${indexKey}`, + ); + } + await sleep(CONVERSATION_INDEX_LOCK_RETRY_MS); + } + try { + return await callback(); + } finally { + await state.releaseLock(lock); + } +} +function normalizeIndexEntry(value) { + if (!isRecord(value)) { + return void 0; + } + const conversationId = toOptionalString(value.conversationId); + const score = toOptionalNumber(value.score); + if (!conversationId || typeof score !== "number") { + return void 0; + } + return { conversationId, score }; +} +function uniqueIndexEntries(value) { + if (!Array.isArray(value)) { + return []; + } + const entries = /* @__PURE__ */ new Map(); + for (const item of value) { + const entry = normalizeIndexEntry(item); + if (!entry) { + continue; + } + const existing = entries.get(entry.conversationId); + if (!existing || entry.score > existing.score) { + entries.set(entry.conversationId, entry); + } + } + return [...entries.values()]; +} +function retainedIndexEntries(indexKey, entries) { + if (indexKey === CONVERSATION_BY_ACTIVITY_INDEX_KEY) { + return entries + .sort(compareIndexDescending) + .slice(0, CONVERSATION_ACTIVITY_INDEX_MAX_LENGTH); + } + if (indexKey === CONVERSATION_ACTIVE_INDEX_KEY) { + return entries.sort(compareIndexAscending); + } + throw new Error(`Unknown conversation index ${indexKey}`); +} +function redisIndexKey(indexKey) { + const prefix = getChatConfig().state.keyPrefix; + return [...(prefix ? [prefix] : []), indexKey].join(":"); +} +function parseRedisIndexEntries(values) { + if (!Array.isArray(values)) { + return []; + } + const entries = []; + for (let index6 = 0; index6 < values.length; index6 += 2) { + const conversationId = toOptionalString(values[index6]); + const score = + typeof values[index6 + 1] === "number" + ? values[index6 + 1] + : Number(values[index6 + 1]); + if (!conversationId || !Number.isFinite(score)) { + continue; + } + entries.push({ conversationId, score }); + } + return entries; +} +function redisConversationIndexStore(client) { + const upsertBoundedActivityScript = ` + redis.call("ZADD", KEYS[1], ARGV[1], ARGV[2]) + redis.call("PEXPIRE", KEYS[1], ARGV[3]) + local extra = redis.call("ZCARD", KEYS[1]) - tonumber(ARGV[4]) + if extra > 0 then + redis.call("ZREMRANGEBYRANK", KEYS[1], 0, extra - 1) + end + return 1 + `; + return { + async list(args) { + const key3 = redisIndexKey(args.indexKey); + const limit = args.limit; + const offset = Math.max(0, args.offset ?? 0); + if (limit === 0) { + return []; + } + const values = + args.scoreMax !== void 0 + ? await client.sendCommand([ + "ZRANGEBYSCORE", + key3, + "-inf", + String(args.scoreMax), + "WITHSCORES", + ...(limit !== void 0 || offset > 0 + ? ["LIMIT", String(offset), String(limit ?? 1e9)] + : []), + ]) + : await client.sendCommand([ + args.order === "asc" ? "ZRANGE" : "ZREVRANGE", + key3, + String(offset), + String(limit === void 0 ? -1 : offset + Math.max(0, limit - 1)), + "WITHSCORES", + ]); + return parseRedisIndexEntries(values); + }, + async remove(args) { + await client.sendCommand([ + "ZREM", + redisIndexKey(args.indexKey), + args.conversationId, + ]); + }, + async upsert(args) { + const key3 = redisIndexKey(args.indexKey); + if (args.indexKey === CONVERSATION_BY_ACTIVITY_INDEX_KEY) { + await client.sendCommand([ + "EVAL", + upsertBoundedActivityScript, + "1", + key3, + String(args.score), + args.conversationId, + String(JUNIOR_THREAD_STATE_TTL_MS), + String(CONVERSATION_ACTIVITY_INDEX_MAX_LENGTH), + ]); + return; + } + if (args.indexKey === CONVERSATION_ACTIVE_INDEX_KEY) { + await client.sendCommand([ + "ZADD", + key3, + String(args.score), + args.conversationId, + ]); + await client.sendCommand([ + "PEXPIRE", + key3, + String(JUNIOR_THREAD_STATE_TTL_MS), + ]); + return; + } + throw new Error(`Unknown conversation index ${args.indexKey}`); + }, + }; +} +function emulatedConversationIndexStore(state) { + const readIndex = async (indexKey) => + uniqueIndexEntries(await state.get(indexKey)); + const writeIndex = async (indexKey, entries) => { + await state.set(indexKey, entries, JUNIOR_THREAD_STATE_TTL_MS); + }; + return { + async list(args) { + const entries = (await readIndex(args.indexKey)) + .filter((entry) => + args.scoreMax === void 0 ? true : entry.score <= args.scoreMax, + ) + .sort( + args.order === "asc" ? compareIndexAscending : compareIndexDescending, + ); + const offset = Math.max(0, args.offset ?? 0); + return entries.slice( + offset, + args.limit === void 0 ? entries.length : offset + args.limit, + ); + }, + async remove(args) { + await withIndexLock(state, args.indexKey, async () => { + const entries = await readIndex(args.indexKey); + const next = entries.filter( + (entry) => entry.conversationId !== args.conversationId, + ); + if (next.length === entries.length) { + return; + } + await writeIndex(args.indexKey, next); + }); + }, + async upsert(args) { + await withIndexLock(state, args.indexKey, async () => { + const entries = await readIndex(args.indexKey); + const withoutCurrent = entries.filter( + (entry) => entry.conversationId !== args.conversationId, + ); + const next = retainedIndexEntries(args.indexKey, [ + ...withoutCurrent, + { conversationId: args.conversationId, score: args.score }, + ]); + await writeIndex(args.indexKey, next); + }); + }, + }; +} +async function getConversationIndexStore(state) { + const redisStateAdapter2 = await getDefaultRedisStateAdapterFor(state); + if (redisStateAdapter2) { + return redisConversationIndexStore(redisStateAdapter2.getClient()); + } + return emulatedConversationIndexStore(state); +} +async function upsertIndexEntry(args) { + const index6 = await getConversationIndexStore(args.state); + await index6.upsert({ + conversationId: args.conversationId, + indexKey: args.indexKey, + score: args.score, + }); +} +async function removeIndexEntry(args) { + const index6 = await getConversationIndexStore(args.state); + await index6.remove({ + conversationId: args.conversationId, + indexKey: args.indexKey, + }); +} +async function acquireMutationLock(state, conversationId) { + const startedAtMs = now(); + while (true) { + const lock = await state.acquireLock( + mutationLockKey(conversationId), + CONVERSATION_MUTATION_LOCK_TTL_MS, + ); + if (lock) { + return lock; + } + if (now() - startedAtMs >= CONVERSATION_MUTATION_WAIT_MS) { + throw new Error( + `Could not acquire conversation mutation lock for ${conversationId}`, + ); + } + await sleep(CONVERSATION_MUTATION_RETRY_MS); + } +} +async function withConversationMutation(args, callback) { + const state = await getConnectedState(args.state); + const lock = await acquireMutationLock(state, args.conversationId); + try { + return await callback(state, lock); + } finally { + await state.releaseLock(lock); + } +} +async function readConversation(state, conversationId) { + const raw = await state.get(conversationKey(conversationId)); + if (raw == null) { + return void 0; + } + const conversation = normalizeConversation(conversationId, raw); + if (!conversation) { + throw new InvalidConversationRecordError(conversationId); + } + return conversation; +} +async function writeConversation(state, lock, conversation) { + const execution = executionWithPendingMessages( + conversation.execution, + conversation.execution.pendingMessages, + ); + const next = { + ...conversation, + execution, + }; + const fenced = await state.extendLock( + lock, + CONVERSATION_MUTATION_LOCK_TTL_MS, + ); + if (!fenced) { + throw new ConversationMutationFencedError(next.conversationId); + } + await state.set( + conversationKey(next.conversationId), + next, + JUNIOR_THREAD_STATE_TTL_MS, + ); + await upsertIndexEntry({ + state, + indexKey: CONVERSATION_BY_ACTIVITY_INDEX_KEY, + conversationId: next.conversationId, + score: next.lastActivityAtMs, + }); + if (!hasRunnableWork(next)) { + await removeIndexEntry({ + state, + indexKey: CONVERSATION_ACTIVE_INDEX_KEY, + conversationId: next.conversationId, + }); + return; + } + await upsertIndexEntry({ + state, + indexKey: CONVERSATION_ACTIVE_INDEX_KEY, + conversationId: next.conversationId, + score: next.execution.updatedAtMs ?? next.updatedAtMs, + }); +} +function assertSameConversationDestination(args) { + if (!args.current || sameDestination(args.current, args.next)) { + return; + } + throw new Error( + `Conversation destination changed for ${args.conversationId}`, + ); +} +async function getConversation(args) { + const state = await getConnectedState(args.state); + return await readConversation(state, args.conversationId); +} +async function recordConversationActivity(args) { + const nowMs = args.nowMs ?? now(); + const activityAtMs = args.activityAtMs ?? nowMs; + await withConversationMutation(args, async (state, lock) => { + const existing = await readConversation(state, args.conversationId); + if (existing && args.destination) { + assertSameConversationDestination({ + conversationId: args.conversationId, + current: existing.destination, + next: args.destination, + }); + } + const current = + existing ?? + emptyConversation({ + conversationId: args.conversationId, + destination: args.destination, + nowMs, + source: args.source, + }); + await writeConversation(state, lock, { + ...current, + ...((current.destination ?? args.destination) + ? { destination: current.destination ?? args.destination } + : {}), + ...((current.source ?? args.source) + ? { source: current.source ?? args.source } + : {}), + ...((current.channelName ?? args.channelName) + ? { channelName: current.channelName ?? args.channelName } + : {}), + ...((current.actor ?? args.actor) + ? { actor: current.actor ?? args.actor } + : {}), + ...((current.title ?? args.title) + ? { title: current.title ?? args.title } + : {}), + lastActivityAtMs: Math.max(current.lastActivityAtMs, activityAtMs), + updatedAtMs: nowMs, + execution: executionWithPendingMessages( + current.execution, + current.execution.pendingMessages, + ), + }); + }); +} +async function recordConversationExecution(args) { + const nowMs = args.updatedAtMs; + await withConversationMutation(args, async (state, lock) => { + const existing = await readConversation(state, args.conversationId); + if (existing && args.destination) { + assertSameConversationDestination({ + conversationId: args.conversationId, + current: existing.destination, + next: args.destination, + }); + } + const current = + existing ?? + emptyConversation({ + conversationId: args.conversationId, + destination: args.destination, + nowMs, + source: args.source, + }); + await writeConversation( + state, + lock, + withExecutionUpdate( + { + ...current, + ...((current.destination ?? args.destination) + ? { destination: current.destination ?? args.destination } + : {}), + ...((current.source ?? args.source) + ? { source: current.source ?? args.source } + : {}), + ...((current.channelName ?? args.channelName) + ? { channelName: current.channelName ?? args.channelName } + : {}), + ...((current.actor ?? args.actor) + ? { actor: current.actor ?? args.actor } + : {}), + ...((current.title ?? args.title) + ? { title: current.title ?? args.title } + : {}), + createdAtMs: Math.min(current.createdAtMs, args.createdAtMs), + lastActivityAtMs: Math.max( + current.lastActivityAtMs, + args.lastActivityAtMs, + ), + }, + { + ...current.execution, + ...args.execution, + }, + nowMs, + ), + ); + }); +} +async function listConversationsByActivity(args = {}) { + const state = await getConnectedState(args.state); + const index6 = await getConversationIndexStore(state); + const entries = await index6.list({ + indexKey: CONVERSATION_BY_ACTIVITY_INDEX_KEY, + limit: args.limit ?? CONVERSATION_ACTIVITY_INDEX_MAX_LENGTH, + offset: args.offset, + order: "desc", + }); + const conversations = []; + for (const entry of entries) { + try { + const conversation = await readConversation(state, entry.conversationId); + if (conversation) { + conversations.push(conversation); + } + } catch (error) { + if (!(error instanceof InvalidConversationRecordError)) { + throw error; + } + await removeIndexEntry({ + state, + indexKey: CONVERSATION_BY_ACTIVITY_INDEX_KEY, + conversationId: entry.conversationId, + }); + } + } + return conversations; +} + +// packages/junior/src/chat/conversations/state.ts +function createStateConversationStore(state) { + return { + get: (args) => getConversation({ ...args, state }), + // Task-execution state has no destination records, so visibility is never + // source-confirmed here and cross-context reads fail closed to private. + getDestinationVisibility: async () => void 0, + // Child conversations are a SQL-only concept and never use this legacy + // metadata path. + ensureChildConversation: async () => void 0, + recordActivity: (args) => recordConversationActivity({ ...args, state }), + recordExecution: (args) => recordConversationExecution({ ...args, state }), + listByActivity: (args) => listConversationsByActivity({ ...args, state }), + }; +} + +// packages/junior/src/cli/upgrade/migrations/conversations-history-sql.ts +init_executor(); +var HISTORY_BACKFILL_LIMIT = 1e4; +async function migrateConversationHistoryToSql(context, options = {}) { + const source = createStateConversationStore(context.stateAdapter); + const sessionLogStore = options.sessionLogStore ?? { + read: async ({ conversationId }) => { + const entries = await context.stateAdapter.get( + `${AGENT_SESSION_LOG_PREFIX}:${conversationId}`, + ); + return Array.isArray(entries) ? entries.map(decode) : []; + }, + append: async () => {}, + }; + let executor = options.executor; + let closeExecutor; + if (!executor) { + const { sql: sql11 } = getChatConfig(); + executor = createJuniorSqlExecutor({ + connectionString: sql11.databaseUrl, + driver: sql11.driver, + }); + closeExecutor = () => executor.close(); + } + const limit = Math.max(1, options.batchSize ?? HISTORY_BACKFILL_LIMIT); + try { + const messageStore = createSqlConversationMessageStore(executor); + const conversations = await source.listByActivity({ limit }); + let migrated = 0; + let existing = 0; + for (const conversation of conversations) { + const snapshot = options.loadVisibleMessages + ? void 0 + : await (async () => { + const raw = await context.stateAdapter.get( + `thread-state:${conversation.conversationId}`, + ); + if (!raw) { + return { compactions: [], messages: [] }; + } + const stored = + legacyThreadStateSnapshotSchema.parse(raw).conversation; + return { + compactions: stored?.compactions ?? [], + messages: stored?.messages ?? [], + }; + })(); + const result = await importConversationFromLegacy( + conversation.conversationId, + { + executor, + messageStore, + conversationRecord: conversation, + sessionLogStore, + ...(options.advisorSessionStore + ? { advisorSessionStore: options.advisorSessionStore } + : {}), + loadVisibleMessages: + options.loadVisibleMessages ?? (async () => snapshot.messages), + legacyCompactions: options.legacyCompactions ?? snapshot.compactions, + }, + ); + if (result.imported) { + migrated += 1; + } else { + existing += 1; + } + } + return { + existing, + migrated, + missing: 0, + scanned: conversations.length, + }; + } finally { + await closeExecutor?.(); + } +} + +// ../../../../../../private/tmp/0007_conversation_history_to_sql-entry.ts +var migration = { apiVersion: 1, async up(context) { - return await context.tasks.run("conversation-history-to-sql-v1"); + return await migrateConversationHistoryToSql( + { stateAdapter: context.state, io: { info: context.log } }, + { executor: context.sql }, + ); }, -} satisfies MigrationV1; - -export default migration; +}; +var conversation_history_to_sql_entry_default = migration; +export { + conversation_history_to_sql_entry_default as default, + migrateConversationHistoryToSql, +}; diff --git a/packages/junior/migrations/README.md b/packages/junior/migrations/README.md index 03785be4a..f0e8657bf 100644 --- a/packages/junior/migrations/README.md +++ b/packages/junior/migrations/README.md @@ -12,9 +12,9 @@ entries; `junior upgrade` executes each entry as either `.sql` or For a data migration, run `pnpm --filter @sentry/junior db:generate:data --name `. TypeScript migrations target the versioned migration capability API and must -not import Junior runtime internals or current feature schemas. Entries that -adopt pre-journal upgrade code call a versioned host task; new data migrations -should use the stable SQL, state, and progress capabilities directly. +not import Junior runtime internals or current feature schemas. Their complete +implementation remains in the migration file and uses only the stable SQL, +state, Redis, and progress capabilities supplied by the runner. The `0000_initial.sql` baseline represents the schema already deployed by the pre-Drizzle Junior migration runner. During upgrade, existing installations diff --git a/packages/junior/src/chat/conversations/sql/migrations.ts b/packages/junior/src/chat/conversations/sql/migrations.ts index 43cc18bfd..9591ae7ac 100644 --- a/packages/junior/src/chat/conversations/sql/migrations.ts +++ b/packages/junior/src/chat/conversations/sql/migrations.ts @@ -10,6 +10,7 @@ import { type TypeScriptMigrationLoader, } from "@sentry/junior-migrations"; import type { StateAdapter } from "chat"; +import type { RedisStateAdapter } from "@chat-adapter/state-redis"; import type { JuniorSqlMigrationExecutor } from "@/db/db"; import { juniorSqlSchema as schema } from "@/db/schema"; @@ -107,17 +108,27 @@ CREATE TABLE IF NOT EXISTS drizzle.__drizzle_junior_core ( function migrationState(stateAdapter: StateAdapter): MigrationStateV1 { return { + acquireLock: async (threadId, ttlMs) => + await stateAdapter.acquireLock(threadId, ttlMs), appendToList: async (key, value, options) => { await stateAdapter.appendToList(key, value, options); }, + connect: async () => { + await stateAdapter.connect(); + }, delete: async (key) => { await stateAdapter.delete(key); }, get: async (key) => await stateAdapter.get(key), getList: async (key) => await stateAdapter.getList(key), + releaseLock: async (lock) => { + await stateAdapter.releaseLock(lock); + }, set: async (key, value, ttlMs) => { await stateAdapter.set(key, value, ttlMs); }, + setIfNotExists: async (key, value, ttlMs) => + await stateAdapter.setIfNotExists(key, value, ttlMs), }; } @@ -126,6 +137,7 @@ function migrationSql( executor: JuniorSqlMigrationExecutor, ): MigrationContextV1["sql"] { return { + db: executor.db.bind(executor), execute: executor.execute.bind(executor), query: executor.query.bind(executor), transaction: executor.transaction.bind(executor), @@ -138,7 +150,7 @@ export type MigrateSchemaOptions = loadTypeScript: TypeScriptMigrationLoader; log?: MigrationContextV1["log"]; mode: "all"; - runTask: MigrationContextV1["tasks"]["run"]; + redisStateAdapter?: RedisStateAdapter; stateAdapter: StateAdapter; }; @@ -167,11 +179,18 @@ export async function migrateSchema( createContext: ({ progress }): MigrationContextV1 => ({ log: options.log ?? (() => {}), progress, + ...(options.redisStateAdapter + ? { + redis: { + sendCommand: async (args: readonly string[]) => + await options + .redisStateAdapter!.getClient() + .sendCommand([...args]), + }, + } + : {}), sql: migrationSql(executor), state: migrationState(options.stateAdapter), - tasks: { - run: options.runTask, - }, }), loadTypeScript: options.loadTypeScript, mode: "all", diff --git a/packages/junior/src/chat/plugins/migrations.ts b/packages/junior/src/chat/plugins/migrations.ts index 8a6dfe442..da16629d3 100644 --- a/packages/junior/src/chat/plugins/migrations.ts +++ b/packages/junior/src/chat/plugins/migrations.ts @@ -3,7 +3,6 @@ import { resolveMigrations, runMigrationJournal, type MigrationContextV1, - type MigrationJsonValue, type MigrationStateV1, type ResolvedMigration, type TypeScriptMigrationLoader, @@ -30,10 +29,6 @@ type PluginMigrationOptions = loadTypeScript: TypeScriptMigrationLoader; log?: MigrationContextV1["log"]; mode: "all"; - runTask?: ( - pluginName: string, - taskName: string, - ) => Promise; stateAdapter: StateAdapter; }; @@ -138,17 +133,27 @@ CREATE TABLE IF NOT EXISTS drizzle.${args.table} ( function migrationState(stateAdapter: StateAdapter): MigrationStateV1 { return { + acquireLock: async (threadId, ttlMs) => + await stateAdapter.acquireLock(threadId, ttlMs), appendToList: async (key, value, options) => { await stateAdapter.appendToList(key, value, options); }, + connect: async () => { + await stateAdapter.connect(); + }, delete: async (key) => { await stateAdapter.delete(key); }, get: async (key) => await stateAdapter.get(key), getList: async (key) => await stateAdapter.getList(key), + releaseLock: async (lock) => { + await stateAdapter.releaseLock(lock); + }, set: async (key, value, ttlMs) => { await stateAdapter.set(key, value, ttlMs); }, + setIfNotExists: async (key, value, ttlMs) => + await stateAdapter.setIfNotExists(key, value, ttlMs), }; } @@ -157,6 +162,7 @@ function migrationSql( executor: JuniorSqlMigrationExecutor, ): MigrationContextV1["sql"] { return { + db: executor.db.bind(executor), execute: executor.execute.bind(executor), query: executor.query.bind(executor), transaction: executor.transaction.bind(executor), @@ -202,16 +208,6 @@ export async function migratePluginSchemas( progress, sql: migrationSql(executor), state: migrationState(options.stateAdapter), - tasks: { - run: async (taskName) => { - if (!options.runTask) { - throw new Error( - `Unsupported migration task for plugin ${root.pluginName}: ${taskName}`, - ); - } - return await options.runTask(root.pluginName, taskName); - }, - }, }), loadTypeScript: options.loadTypeScript, mode: "all", diff --git a/packages/junior/src/cli/upgrade/migrations/agent-turn-session-actor.ts b/packages/junior/src/cli/upgrade/migrations/agent-turn-session-actor.ts deleted file mode 100644 index d6183cbbd..000000000 --- a/packages/junior/src/cli/upgrade/migrations/agent-turn-session-actor.ts +++ /dev/null @@ -1,228 +0,0 @@ -import type { RedisStateAdapter } from "@chat-adapter/state-redis"; -import { THREAD_STATE_TTL_MS, type StateAdapter } from "chat"; -import { isRecord, toOptionalString } from "@/chat/coerce"; -import { getChatConfig } from "@/chat/config"; -import type { MigrationContext, MigrationResult } from "../types"; - -const AGENT_TURN_SESSION_PREFIX = "junior:agent_turn_session"; -const AGENT_TURN_SESSION_INDEX_KEY = `${AGENT_TURN_SESSION_PREFIX}:index`; -const AGENT_TURN_SESSION_INDEX_MAX_LENGTH = 5_000; -const REDIS_SCAN_COUNT = 500; - -type RedisCommandClient = { - sendCommand(args: readonly string[]): Promise; -}; - -interface MigratedValue { - changed: boolean; - value: unknown; -} - -function conversationIndexKey(conversationId: string): string { - return `${AGENT_TURN_SESSION_PREFIX}:conversation:${conversationId}:index`; -} - -function sessionRecordKey(conversationId: string, sessionId: string): string { - return `${AGENT_TURN_SESSION_PREFIX}:${conversationId}:${sessionId}`; -} - -function logicalConversationIndexPrefix(): string { - const prefix = getChatConfig().state.keyPrefix; - return [ - ...(prefix ? [prefix] : []), - `${AGENT_TURN_SESSION_PREFIX}:conversation:`, - ].join(":"); -} - -function conversationIdFromRedisListKey(key: string): string | undefined { - const marker = `:list:${logicalConversationIndexPrefix()}`; - const markerIndex = key.indexOf(marker); - if (markerIndex < 0 || !key.endsWith(":index")) { - return undefined; - } - return toOptionalString( - key.slice(markerIndex + marker.length, -":index".length), - ); -} - -async function discoverRedisConversationIds( - redisStateAdapter: RedisStateAdapter | undefined, -): Promise { - const client = redisStateAdapter?.getClient() as - | RedisCommandClient - | undefined; - if (!client) { - return []; - } - - const conversationIds = new Set(); - const match = `*:list:${logicalConversationIndexPrefix()}*:index`; - let cursor = "0"; - do { - const reply = await client.sendCommand([ - "SCAN", - cursor, - "MATCH", - match, - "COUNT", - String(REDIS_SCAN_COUNT), - ]); - if ( - !Array.isArray(reply) || - reply.length !== 2 || - (typeof reply[0] !== "string" && typeof reply[0] !== "number") || - !Array.isArray(reply[1]) - ) { - throw new Error( - "Unexpected Redis SCAN response while migrating turn sessions", - ); - } - cursor = String(reply[0]); - for (const key of reply[1]) { - if (typeof key !== "string") { - continue; - } - const conversationId = conversationIdFromRedisListKey(key); - if (conversationId) { - conversationIds.add(conversationId); - } - } - } while (cursor !== "0"); - - return [...conversationIds]; -} - -function migrateRequesterToActor(value: unknown): MigratedValue { - if (!isRecord(value) || value.requester === undefined) { - return { changed: false, value }; - } - - const { requester, ...record } = value; - return { - changed: true, - value: { - ...record, - ...(record.actor === undefined ? { actor: requester } : {}), - }, - }; -} - -async function rewriteList(args: { - key: string; - maxLength?: number; - stateAdapter: StateAdapter; -}): Promise<{ migrated: number; values: unknown[] }> { - const values = await args.stateAdapter.getList(args.key); - const migrated = values.map(migrateRequesterToActor); - const changed = migrated.filter((entry) => entry.changed).length; - if (changed === 0) { - return { migrated: 0, values }; - } - - await args.stateAdapter.delete(args.key); - for (const entry of migrated) { - await args.stateAdapter.appendToList(args.key, entry.value, { - ...(args.maxLength !== undefined ? { maxLength: args.maxLength } : {}), - ttlMs: THREAD_STATE_TTL_MS, - }); - } - return { migrated: changed, values: migrated.map((entry) => entry.value) }; -} - -async function migrateSessionRecord(args: { - conversationId: string; - sessionId: string; - stateAdapter: StateAdapter; -}): Promise<"existing" | "migrated" | "missing"> { - const key = sessionRecordKey(args.conversationId, args.sessionId); - const existing = await args.stateAdapter.get(key); - if (existing === undefined) { - return "missing"; - } - const migrated = migrateRequesterToActor(existing); - if (!migrated.changed) { - return "existing"; - } - await args.stateAdapter.set(key, migrated.value, THREAD_STATE_TTL_MS); - return "migrated"; -} - -/** Rewrite retained turn-session requester fields to actor fields. */ -export async function migrateAgentTurnSessionActor( - context: MigrationContext, -): Promise { - const result: MigrationResult = { - existing: 0, - migrated: 0, - missing: 0, - scanned: 0, - }; - const global = await rewriteList({ - key: AGENT_TURN_SESSION_INDEX_KEY, - maxLength: AGENT_TURN_SESSION_INDEX_MAX_LENGTH, - stateAdapter: context.stateAdapter, - }); - result.scanned += global.values.length; - result.migrated += global.migrated; - - const conversations = new Set(); - const sessions = new Set(); - for (const conversationId of await discoverRedisConversationIds( - context.redisStateAdapter, - )) { - conversations.add(conversationId); - } - for (const value of global.values) { - if (!isRecord(value)) { - continue; - } - const conversationId = toOptionalString(value.conversationId); - const sessionId = toOptionalString(value.sessionId); - if (!conversationId) { - continue; - } - conversations.add(conversationId); - if (sessionId) { - sessions.add(`${conversationId}\u0000${sessionId}`); - } - } - - for (const conversationId of conversations) { - const conversation = await rewriteList({ - key: conversationIndexKey(conversationId), - stateAdapter: context.stateAdapter, - }); - result.scanned += conversation.values.length; - result.migrated += conversation.migrated; - for (const value of conversation.values) { - if (!isRecord(value)) { - continue; - } - const sessionId = toOptionalString(value.sessionId); - if (sessionId) { - sessions.add(`${conversationId}\u0000${sessionId}`); - } - } - } - - for (const session of sessions) { - const separator = session.indexOf("\u0000"); - const conversationId = session.slice(0, separator); - const sessionId = session.slice(separator + 1); - result.scanned += 1; - const status = await migrateSessionRecord({ - conversationId, - sessionId, - stateAdapter: context.stateAdapter, - }); - if (status === "migrated") { - result.migrated += 1; - } else if (status === "existing") { - result.existing += 1; - } else { - result.missing += 1; - } - } - - return result; -} diff --git a/packages/junior/src/cli/upgrade/migrations/conversations-history-sql.ts b/packages/junior/src/cli/upgrade/migrations/conversations-history-sql.ts deleted file mode 100644 index f9d6a76ca..000000000 --- a/packages/junior/src/cli/upgrade/migrations/conversations-history-sql.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { getChatConfig } from "@/chat/config"; -import { importConversationFromLegacy } from "@/chat/conversations/legacy-import"; -import { createSqlConversationMessageStore } from "@/chat/conversations/sql/messages"; -import { createStateConversationStore } from "@/chat/conversations/state"; -import type { LegacyAdvisorSessionReader } from "@/chat/conversations/legacy-advisor-session"; -import type { ConversationMessage as ThreadConversationMessage } from "@/chat/state/conversation"; -import type { SessionLogStore } from "@/chat/state/session-log"; -import { createJuniorSqlExecutor } from "@/db/executor"; -import type { JuniorSqlExecutor } from "@/db/db"; -import type { MigrationContext, MigrationResult } from "../types"; - -const HISTORY_BACKFILL_LIMIT = 10_000; - -/** - * Bulk-import legacy Redis conversation history (session logs, advisor blobs, - * and visible messages) into SQL, bounded newest-first over the same activity - * scan as the metadata backfill. Idempotent per conversation: it skips any - * conversation that already has step rows. - */ -export async function migrateConversationHistoryToSql( - context: MigrationContext, - options: { - batchSize?: number; - executor?: JuniorSqlExecutor; - sessionLogStore?: SessionLogStore; - advisorSessionStore?: LegacyAdvisorSessionReader; - loadVisibleMessages?: ( - conversationId: string, - ) => Promise; - } = {}, -): Promise { - const source = createStateConversationStore(context.stateAdapter); - let executor = options.executor; - let closeExecutor: (() => Promise) | undefined; - if (!executor) { - const { sql } = getChatConfig(); - executor = createJuniorSqlExecutor({ - connectionString: sql.databaseUrl, - driver: sql.driver, - }); - closeExecutor = () => executor!.close(); - } - const limit = Math.max(1, options.batchSize ?? HISTORY_BACKFILL_LIMIT); - try { - const messageStore = createSqlConversationMessageStore(executor); - const conversations = await source.listByActivity({ limit }); - let migrated = 0; - let existing = 0; - for (const conversation of conversations) { - const result = await importConversationFromLegacy( - conversation.conversationId, - { - executor, - messageStore, - conversationRecord: conversation, - ...(options.sessionLogStore - ? { sessionLogStore: options.sessionLogStore } - : {}), - ...(options.advisorSessionStore - ? { advisorSessionStore: options.advisorSessionStore } - : {}), - ...(options.loadVisibleMessages - ? { loadVisibleMessages: options.loadVisibleMessages } - : {}), - }, - ); - if (result.imported) { - migrated += 1; - } else { - existing += 1; - } - } - return { - existing, - migrated, - missing: 0, - scanned: conversations.length, - }; - } finally { - await closeExecutor?.(); - } -} diff --git a/packages/junior/src/cli/upgrade/migrations/conversations-sql.ts b/packages/junior/src/cli/upgrade/migrations/conversations-sql.ts deleted file mode 100644 index 5b4194e92..000000000 --- a/packages/junior/src/cli/upgrade/migrations/conversations-sql.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { getChatConfig } from "@/chat/config"; -import { createSqlStore, type SqlStore } from "@/chat/conversations/sql/store"; -import { createStateConversationStore } from "@/chat/conversations/state"; -import { addAgentTurnUsage } from "@/chat/usage"; -import { listAgentTurnSessionSummariesForConversations } from "@/chat/state/turn-session"; -import { createJuniorSqlExecutor } from "@/db/executor"; -import type { MigrationContext, MigrationResult } from "../types"; - -const CONVERSATION_BACKFILL_LIMIT = 10_000; - -/** Copy retained conversation records into the configured SQL store. */ -export async function migrateConversationsToSql( - context: MigrationContext, - options: { - batchSize?: number; - target?: Pick; - } = {}, -): Promise { - const source = createStateConversationStore(context.stateAdapter); - let target = options.target; - let closeTarget: (() => Promise) | undefined; - if (!target) { - const { sql } = getChatConfig(); - const executor = createJuniorSqlExecutor({ - connectionString: sql.databaseUrl, - driver: sql.driver, - }); - target = createSqlStore(executor); - closeTarget = () => executor.close(); - } - const limit = Math.max(1, options.batchSize ?? CONVERSATION_BACKFILL_LIMIT); - try { - const [stateConversations, sqlConversations] = await Promise.all([ - source.listByActivity({ limit }), - target.listByActivity({ limit }), - ]); - const byId = new Map( - sqlConversations.map((conversation) => [ - conversation.conversationId, - conversation, - ]), - ); - for (const conversation of stateConversations) { - const existing = byId.get(conversation.conversationId); - const existingExecutionAt = - existing?.execution.updatedAtMs ?? existing?.updatedAtMs ?? 0; - const stateExecutionAt = - conversation.execution.updatedAtMs ?? conversation.updatedAtMs; - if (!existing || stateExecutionAt >= existingExecutionAt) { - byId.set(conversation.conversationId, conversation); - } - } - const conversations = [...byId.values()] - .sort( - (left, right) => - right.lastActivityAtMs - left.lastActivityAtMs || - left.conversationId.localeCompare(right.conversationId), - ) - .slice(0, limit); - const summaries = await listAgentTurnSessionSummariesForConversations( - context.stateAdapter, - conversations.map((conversation) => conversation.conversationId), - ); - for (const conversation of conversations) { - const conversationSummaries = - summaries.get(conversation.conversationId) ?? []; - const executionSummary = conversation.execution.runId - ? conversationSummaries.find( - (summary) => summary.sessionId === conversation.execution.runId, - ) - : undefined; - await target.backfillConversation( - conversation, - conversationSummaries.length > 0 - ? { - durationMs: conversationSummaries.reduce( - (total, summary) => total + summary.cumulativeDurationMs, - 0, - ), - usage: addAgentTurnUsage( - ...conversationSummaries.map( - (summary) => summary.cumulativeUsage, - ), - ), - executionDurationMs: executionSummary?.cumulativeDurationMs ?? 0, - executionUsage: executionSummary?.cumulativeUsage, - } - : undefined, - ); - } - - return { - existing: 0, - migrated: conversations.length, - missing: 0, - scanned: conversations.length, - }; - } finally { - await closeTarget?.(); - } -} diff --git a/packages/junior/src/cli/upgrade/migrations/core-journal.ts b/packages/junior/src/cli/upgrade/migrations/core-journal.ts index d753825bb..7951c77d8 100644 --- a/packages/junior/src/cli/upgrade/migrations/core-journal.ts +++ b/packages/junior/src/cli/upgrade/migrations/core-journal.ts @@ -1,35 +1,12 @@ import { getChatConfig } from "@/chat/config"; import { migrateSchema } from "@/chat/conversations/sql/migrations"; import { createJuniorSqlExecutor } from "@/db/executor"; -import type { MigrationJsonValue } from "@sentry/junior-migrations"; import { createJiti } from "jiti"; -import { migrateAgentTurnSessionActor } from "./agent-turn-session-actor"; -import { migrateConversationHistoryToSql } from "./conversations-history-sql"; -import { migrateConversationsToSql } from "./conversations-sql"; -import { migrateRedisConversationState } from "./redis-conversation-state"; import type { MigrationContext, MigrationResult } from "../types"; const migrationLoader = createJiti(import.meta.url, { moduleCache: false }); -async function runCoreMigrationTask( - context: MigrationContext, - name: string, -): Promise { - switch (name) { - case "agent-turn-session-actor-v1": - return await migrateAgentTurnSessionActor(context); - case "redis-conversation-state-v1": - return await migrateRedisConversationState(context); - case "conversations-to-sql-v1": - return await migrateConversationsToSql(context); - case "conversation-history-to-sql-v1": - return await migrateConversationHistoryToSql(context); - default: - throw new Error(`Unknown core migration task: ${name}`); - } -} - -/** Apply core schema migrations and versioned legacy data tasks in journal order. */ +/** Apply core schema and self-contained data migrations in journal order. */ export async function migrateCoreJournal( context: MigrationContext, ): Promise { @@ -44,7 +21,7 @@ export async function migrateCoreJournal( await migrationLoader.import>(path), log: context.io.info, mode: "all", - runTask: async (name) => await runCoreMigrationTask(context, name), + redisStateAdapter: context.redisStateAdapter, stateAdapter: context.stateAdapter, }); return { diff --git a/packages/junior/src/cli/upgrade/migrations/plugin-journal.ts b/packages/junior/src/cli/upgrade/migrations/plugin-journal.ts index 4ab03e05f..d03c89249 100644 --- a/packages/junior/src/cli/upgrade/migrations/plugin-journal.ts +++ b/packages/junior/src/cli/upgrade/migrations/plugin-journal.ts @@ -1,8 +1,6 @@ import { getChatConfig } from "@/chat/config"; import { migratePluginSchemas } from "@/chat/plugins/migrations"; import { pluginCatalogRuntime } from "@/chat/plugins/catalog-runtime"; -import { createPluginLogger } from "@/chat/plugins/logging"; -import { createPluginState } from "@/chat/plugins/state"; import { createJuniorSqlExecutor } from "@/db/executor"; import { createJiti } from "jiti"; import { resolveUpgradePlugins } from "./upgrade-plugins"; @@ -15,22 +13,13 @@ export async function migratePluginJournals( context: MigrationContext, ): Promise { const { sql } = getChatConfig(); - const { pluginCatalogConfig, pluginSet } = - await resolveUpgradePlugins(context); + const { pluginCatalogConfig } = await resolveUpgradePlugins(context); const previousConfig = pluginCatalogRuntime.setConfig(pluginCatalogConfig); const executor = createJuniorSqlExecutor({ connectionString: sql.databaseUrl, driver: sql.driver, }); try { - const registrations = new Map( - pluginSet - ? pluginSet.registrations.map((plugin) => [ - plugin.manifest.name, - plugin, - ]) - : [], - ); const result = await migratePluginSchemas( executor, pluginCatalogRuntime.getMigrationRoots(), @@ -39,20 +28,6 @@ export async function migratePluginJournals( await migrationLoader.import>(path), log: context.io.info, mode: "all", - runTask: async (pluginName, taskName) => { - const task = - registrations.get(pluginName)?.migrationTasks?.[taskName]; - if (!task) { - throw new Error( - `Plugin ${pluginName} does not provide migration task ${taskName}`, - ); - } - return await task({ - db: context.db ?? executor.db(), - log: createPluginLogger(pluginName), - state: createPluginState(pluginName, context.stateAdapter), - }); - }, stateAdapter: context.stateAdapter, }, ); diff --git a/packages/junior/src/cli/upgrade/migrations/redis-conversation-state.ts b/packages/junior/src/cli/upgrade/migrations/redis-conversation-state.ts deleted file mode 100644 index 35137ded9..000000000 --- a/packages/junior/src/cli/upgrade/migrations/redis-conversation-state.ts +++ /dev/null @@ -1,777 +0,0 @@ -import type { RedisStateAdapter } from "@chat-adapter/state-redis"; -import type { StateAdapter } from "chat"; -import type { Destination } from "@sentry/junior-plugin-api"; -import { isRecord, toOptionalNumber, toOptionalString } from "@/chat/coerce"; -import { getChatConfig } from "@/chat/config"; -import { parseDestination, sameDestination } from "@/chat/destination"; -import { coerceThreadConversationState } from "@/chat/state/conversation"; -import { JUNIOR_THREAD_STATE_TTL_MS } from "@/chat/state/ttl"; -import { - getConversation, - requestConversationWork, - type Conversation, - type ExecutionStatus, - type InboundMessage, - type Lease, - type Source, -} from "@/chat/task-execution/state"; -import type { MigrationContext, MigrationResult } from "../types"; - -const CONVERSATION_PREFIX = "junior:conversation"; -const CONVERSATION_SCHEMA_VERSION = 1; -const CONVERSATION_ACTIVITY_INDEX_MAX_LENGTH = 10_000; -const CONVERSATION_BY_ACTIVITY_INDEX_KEY = `${CONVERSATION_PREFIX}:by-activity`; -const CONVERSATION_ACTIVE_INDEX_KEY = `${CONVERSATION_PREFIX}:active`; -const LEGACY_CONVERSATION_WORK_PREFIX = "junior:conversation-work"; -const LEGACY_CONVERSATION_WORK_SCHEMA_VERSION = 1; -const LEGACY_CONVERSATION_WORK_INDEX_KEY = `${LEGACY_CONVERSATION_WORK_PREFIX}:index`; -const AGENT_TURN_SESSION_PREFIX = "junior:agent_turn_session"; -const AGENT_TURN_SESSION_INDEX_KEY = `${AGENT_TURN_SESSION_PREFIX}:index`; -const THREAD_STATE_PREFIX = "thread-state"; - -interface ConversationIndexEntry { - conversationId: string; - score: number; -} - -interface AwaitingContinuationSummary { - conversationId: string; - destination: Destination; - resumeReason: "timeout" | "yield"; - sessionId: string; - state: "awaiting_resume"; - updatedAtMs: number; -} - -type RedisCommandClient = { - sendCommand(args: readonly string[]): Promise; -}; - -function conversationKey(conversationId: string): string { - return `${CONVERSATION_PREFIX}:${conversationId}`; -} - -function legacyConversationWorkKey(conversationId: string): string { - return `${LEGACY_CONVERSATION_WORK_PREFIX}:state:${conversationId}`; -} - -function threadStateKey(conversationId: string): string { - return `${THREAD_STATE_PREFIX}:${conversationId}`; -} - -function uniqueStrings(values: string[]): string[] { - return [...new Set(values)]; -} - -function uniqueStringValues(value: unknown): string[] { - if (!Array.isArray(value)) { - return []; - } - return uniqueStrings( - value - .map((value) => (typeof value === "string" ? value : undefined)) - .filter((value): value is string => Boolean(value)), - ); -} - -function normalizeSource(value: unknown): Source | undefined { - if ( - value === "api" || - value === "internal" || - value === "plugin" || - value === "scheduler" || - value === "slack" - ) { - return value; - } - return undefined; -} - -function normalizeMetadata( - value: unknown, -): Record | undefined { - if (!isRecord(value)) { - return undefined; - } - return value; -} - -function normalizeInput(value: unknown): InboundMessage["input"] | undefined { - if (!isRecord(value)) { - return undefined; - } - const text = toOptionalString(value.text); - if (!text) { - return undefined; - } - return { - text, - authorId: toOptionalString(value.authorId), - attachments: Array.isArray(value.attachments) - ? [...value.attachments] - : undefined, - metadata: normalizeMetadata(value.metadata), - }; -} - -function normalizeMessage(value: unknown): InboundMessage | undefined { - if (!isRecord(value)) { - return undefined; - } - const conversationId = toOptionalString(value.conversationId); - const inboundMessageId = toOptionalString(value.inboundMessageId); - const source = normalizeSource(value.source); - const destination = parseDestination(value.destination); - const createdAtMs = toOptionalNumber(value.createdAtMs); - const receivedAtMs = toOptionalNumber(value.receivedAtMs); - const input = normalizeInput(value.input); - if ( - !conversationId || - !destination || - !inboundMessageId || - !source || - typeof createdAtMs !== "number" || - typeof receivedAtMs !== "number" || - !input - ) { - return undefined; - } - return { - conversationId, - destination, - inboundMessageId, - source, - createdAtMs, - receivedAtMs, - input, - injectedAtMs: toOptionalNumber(value.injectedAtMs), - }; -} - -function normalizeLegacyLease(value: unknown): Lease | undefined { - if (!isRecord(value)) { - return undefined; - } - const token = toOptionalString(value.leaseToken); - const acquiredAtMs = toOptionalNumber(value.acquiredAtMs); - const lastCheckInAtMs = toOptionalNumber(value.lastCheckInAtMs); - const expiresAtMs = toOptionalNumber(value.leaseExpiresAtMs); - if ( - !token || - typeof acquiredAtMs !== "number" || - typeof lastCheckInAtMs !== "number" || - typeof expiresAtMs !== "number" - ) { - return undefined; - } - return { - token, - acquiredAtMs, - lastCheckInAtMs, - expiresAtMs, - }; -} - -function compareMessages(left: InboundMessage, right: InboundMessage): number { - return ( - left.createdAtMs - right.createdAtMs || - left.receivedAtMs - right.receivedAtMs || - left.inboundMessageId.localeCompare(right.inboundMessageId) - ); -} - -/** - * Decode legacy schema-v1 conversation-work state into the new conversation - * execution record, preserving pending work and active lease/run intent. - */ -function normalizeLegacyConversation( - conversationId: string, - value: unknown, -): Conversation | undefined { - if ( - !isRecord(value) || - value.schemaVersion !== LEGACY_CONVERSATION_WORK_SCHEMA_VERSION - ) { - return undefined; - } - const storedConversationId = toOptionalString(value.conversationId); - const destination = parseDestination(value.destination); - const updatedAtMs = toOptionalNumber(value.updatedAtMs); - if ( - storedConversationId !== conversationId || - !destination || - typeof updatedAtMs !== "number" - ) { - return undefined; - } - const normalizedMessages = Array.isArray(value.messages) - ? value.messages - .map(normalizeMessage) - .filter((message): message is InboundMessage => Boolean(message)) - : []; - if ( - normalizedMessages.some( - (message) => - message.conversationId === conversationId && - !sameDestination(message.destination, destination), - ) - ) { - return undefined; - } - const messages = normalizedMessages - .filter((message) => message.conversationId === conversationId) - .sort(compareMessages); - const pendingMessages = messages.filter( - (message) => message.injectedAtMs === undefined, - ); - const lease = normalizeLegacyLease(value.lease); - const needsRun = value.needsRun === true || pendingMessages.length > 0; - const status: ExecutionStatus = lease - ? value.needsRun === true - ? "awaiting_resume" - : "running" - : needsRun - ? "pending" - : "idle"; - const messageTimes = messages.flatMap((message) => [ - message.createdAtMs, - message.receivedAtMs, - ]); - const createdAtMs = - messageTimes.length > 0 ? Math.min(...messageTimes) : updatedAtMs; - const lastActivityAtMs = - messageTimes.length > 0 ? Math.max(...messageTimes) : updatedAtMs; - - return { - schemaVersion: CONVERSATION_SCHEMA_VERSION, - conversationId, - createdAtMs, - destination, - lastActivityAtMs, - source: messages[0]?.source, - updatedAtMs, - execution: { - status, - inboundMessageIds: uniqueStrings( - messages.map((message) => message.inboundMessageId), - ), - pendingCount: pendingMessages.length, - pendingMessages, - ...(lease ? { lease } : {}), - lastEnqueuedAtMs: toOptionalNumber(value.lastEnqueuedAtMs), - updatedAtMs, - }, - }; -} - -function mergeLegacyConversation( - existing: Conversation, - legacy: Conversation, -): Conversation { - if ( - existing.destination && - legacy.destination && - !sameDestination(existing.destination, legacy.destination) - ) { - throw new Error( - `Legacy conversation work destination does not match conversation ${existing.conversationId}`, - ); - } - const knownInboundIds = new Set(existing.execution.inboundMessageIds); - const pendingMessages = [ - ...existing.execution.pendingMessages, - ...legacy.execution.pendingMessages.filter( - (message) => !knownInboundIds.has(message.inboundMessageId), - ), - ].sort(compareMessages); - const legacyIsRunnable = legacy.execution.status !== "idle"; - const existingIsIdle = existing.execution.status === "idle"; - const legacyLease = - existingIsIdle && legacy.execution.lease - ? { lease: legacy.execution.lease } - : {}; - const legacyRunId = - existingIsIdle && legacy.execution.runId - ? { runId: legacy.execution.runId } - : {}; - const legacyCheckpoint = - existing.execution.lastCheckpointAtMs === undefined && - legacy.execution.lastCheckpointAtMs !== undefined - ? { lastCheckpointAtMs: legacy.execution.lastCheckpointAtMs } - : {}; - const legacyEnqueue = - existing.execution.lastEnqueuedAtMs === undefined && - legacy.execution.lastEnqueuedAtMs !== undefined - ? { lastEnqueuedAtMs: legacy.execution.lastEnqueuedAtMs } - : {}; - const executionUpdatedAtMs = Math.max( - existing.execution.updatedAtMs ?? existing.updatedAtMs, - legacy.execution.updatedAtMs ?? legacy.updatedAtMs, - ); - const status: ExecutionStatus = - existingIsIdle && legacyIsRunnable - ? legacy.execution.lease - ? legacy.execution.status - : "pending" - : pendingMessages.length > 0 && existingIsIdle - ? "pending" - : existing.execution.status; - - return { - ...existing, - destination: existing.destination ?? legacy.destination, - source: existing.source ?? legacy.source, - createdAtMs: Math.min(existing.createdAtMs, legacy.createdAtMs), - lastActivityAtMs: Math.max( - existing.lastActivityAtMs, - legacy.lastActivityAtMs, - ), - updatedAtMs: Math.max(existing.updatedAtMs, legacy.updatedAtMs), - execution: { - ...existing.execution, - ...legacyLease, - ...legacyRunId, - ...legacyCheckpoint, - ...legacyEnqueue, - status, - inboundMessageIds: uniqueStrings([ - ...existing.execution.inboundMessageIds, - ...legacy.execution.inboundMessageIds, - ]), - pendingCount: pendingMessages.length, - pendingMessages, - updatedAtMs: executionUpdatedAtMs, - }, - }; -} - -function compareIndexDescending( - left: ConversationIndexEntry, - right: ConversationIndexEntry, -): number { - return ( - right.score - left.score || - right.conversationId.localeCompare(left.conversationId) - ); -} - -function compareIndexAscending( - left: ConversationIndexEntry, - right: ConversationIndexEntry, -): number { - return ( - left.score - right.score || - left.conversationId.localeCompare(right.conversationId) - ); -} - -function normalizeIndexEntry( - value: unknown, -): ConversationIndexEntry | undefined { - if (!isRecord(value)) { - return undefined; - } - const conversationId = toOptionalString(value.conversationId); - const score = toOptionalNumber(value.score); - if (!conversationId || typeof score !== "number") { - return undefined; - } - return { conversationId, score }; -} - -function uniqueIndexEntries(value: unknown): ConversationIndexEntry[] { - if (!Array.isArray(value)) { - return []; - } - const entries = new Map(); - for (const item of value) { - const entry = normalizeIndexEntry(item); - if (!entry) { - continue; - } - const existing = entries.get(entry.conversationId); - if (!existing || entry.score > existing.score) { - entries.set(entry.conversationId, entry); - } - } - return [...entries.values()]; -} - -function normalizeAwaitingContinuationSummary( - value: unknown, -): AwaitingContinuationSummary | undefined { - if (!isRecord(value)) { - return undefined; - } - const conversationId = toOptionalString(value.conversationId); - const sessionId = toOptionalString(value.sessionId); - const state = value.state; - const resumeReason = value.resumeReason; - const destination = parseDestination(value.destination); - const updatedAtMs = toOptionalNumber(value.updatedAtMs); - if ( - !conversationId || - !sessionId || - state !== "awaiting_resume" || - (resumeReason !== "timeout" && resumeReason !== "yield") || - !destination || - typeof updatedAtMs !== "number" - ) { - return undefined; - } - return { - conversationId, - destination, - resumeReason, - sessionId, - state, - updatedAtMs, - }; -} - -function uniqueAwaitingContinuationSummaries( - values: unknown[], -): AwaitingContinuationSummary[] { - const summaries = new Map(); - for (const value of [...values].reverse()) { - const summary = normalizeAwaitingContinuationSummary(value); - if (!summary) { - continue; - } - const key = `${summary.conversationId}:${summary.sessionId}`; - if (!summaries.has(key)) { - summaries.set(key, summary); - } - } - return [...summaries.values()]; -} - -function redisIndexKey(indexKey: string): string { - const prefix = getChatConfig().state.keyPrefix; - return [...(prefix ? [prefix] : []), indexKey].join(":"); -} - -async function upsertEmulatedIndexEntry(args: { - conversationId: string; - indexKey: string; - score: number; - stateAdapter: StateAdapter; -}): Promise { - const existing = uniqueIndexEntries( - await args.stateAdapter.get(args.indexKey), - ); - const next = [ - ...existing.filter((entry) => entry.conversationId !== args.conversationId), - { conversationId: args.conversationId, score: args.score }, - ]; - const retained = - args.indexKey === CONVERSATION_BY_ACTIVITY_INDEX_KEY - ? next - .sort(compareIndexDescending) - .slice(0, CONVERSATION_ACTIVITY_INDEX_MAX_LENGTH) - : next.sort(compareIndexAscending); - await args.stateAdapter.set( - args.indexKey, - retained, - JUNIOR_THREAD_STATE_TTL_MS, - ); -} - -async function removeEmulatedIndexEntry(args: { - conversationId: string; - indexKey: string; - stateAdapter: StateAdapter; -}): Promise { - const existing = uniqueIndexEntries( - await args.stateAdapter.get(args.indexKey), - ); - const next = existing.filter( - (entry) => entry.conversationId !== args.conversationId, - ); - if (next.length === existing.length) { - return; - } - await args.stateAdapter.set(args.indexKey, next, JUNIOR_THREAD_STATE_TTL_MS); -} - -async function upsertRedisIndexEntry(args: { - client: RedisCommandClient; - conversationId: string; - indexKey: string; - score: number; -}): Promise { - const key = redisIndexKey(args.indexKey); - if (args.indexKey === CONVERSATION_BY_ACTIVITY_INDEX_KEY) { - const upsertBoundedActivityScript = ` - redis.call("ZADD", KEYS[1], ARGV[1], ARGV[2]) - redis.call("PEXPIRE", KEYS[1], ARGV[3]) - local extra = redis.call("ZCARD", KEYS[1]) - tonumber(ARGV[4]) - if extra > 0 then - redis.call("ZREMRANGEBYRANK", KEYS[1], 0, extra - 1) - end - return 1 - `; - await args.client.sendCommand([ - "EVAL", - upsertBoundedActivityScript, - "1", - key, - String(args.score), - args.conversationId, - String(JUNIOR_THREAD_STATE_TTL_MS), - String(CONVERSATION_ACTIVITY_INDEX_MAX_LENGTH), - ]); - return; - } - - await args.client.sendCommand([ - "ZADD", - key, - String(args.score), - args.conversationId, - ]); - await args.client.sendCommand([ - "PEXPIRE", - key, - String(JUNIOR_THREAD_STATE_TTL_MS), - ]); -} - -async function removeRedisIndexEntry(args: { - client: RedisCommandClient; - conversationId: string; - indexKey: string; -}): Promise { - await args.client.sendCommand([ - "ZREM", - redisIndexKey(args.indexKey), - args.conversationId, - ]); -} - -async function upsertConversationIndexes(args: { - conversation: Conversation; - redisStateAdapter?: RedisStateAdapter; - stateAdapter: StateAdapter; -}): Promise { - const redisClient = args.redisStateAdapter?.getClient() as - | RedisCommandClient - | undefined; - const upsert = redisClient - ? (indexKey: string, score: number) => - upsertRedisIndexEntry({ - client: redisClient, - conversationId: args.conversation.conversationId, - indexKey, - score, - }) - : (indexKey: string, score: number) => - upsertEmulatedIndexEntry({ - stateAdapter: args.stateAdapter, - conversationId: args.conversation.conversationId, - indexKey, - score, - }); - const remove = redisClient - ? (indexKey: string) => - removeRedisIndexEntry({ - client: redisClient, - conversationId: args.conversation.conversationId, - indexKey, - }) - : (indexKey: string) => - removeEmulatedIndexEntry({ - stateAdapter: args.stateAdapter, - conversationId: args.conversation.conversationId, - indexKey, - }); - - await upsert( - CONVERSATION_BY_ACTIVITY_INDEX_KEY, - args.conversation.lastActivityAtMs, - ); - if (args.conversation.execution.status === "idle") { - await remove(CONVERSATION_ACTIVE_INDEX_KEY); - return; - } - await upsert( - CONVERSATION_ACTIVE_INDEX_KEY, - args.conversation.execution.updatedAtMs ?? args.conversation.updatedAtMs, - ); -} - -async function removeLegacyIndexEntry(args: { - conversationId: string; - stateAdapter: StateAdapter; -}): Promise { - const existing = uniqueStringValues( - await args.stateAdapter.get(LEGACY_CONVERSATION_WORK_INDEX_KEY), - ); - const next = existing.filter((id) => id !== args.conversationId); - if (next.length === existing.length) { - return; - } - if (next.length === 0) { - await args.stateAdapter.delete(LEGACY_CONVERSATION_WORK_INDEX_KEY); - return; - } - await args.stateAdapter.set( - LEGACY_CONVERSATION_WORK_INDEX_KEY, - next, - JUNIOR_THREAD_STATE_TTL_MS, - ); -} - -/** - * Move indexed legacy work records into conversation records, update the new - * indexes, then delete each legacy key only after its write or merge succeeds. - */ -async function migrateLegacyConversationWorkRedisState( - context: MigrationContext, -): Promise { - const legacyIds = uniqueStringValues( - await context.stateAdapter.get(LEGACY_CONVERSATION_WORK_INDEX_KEY), - ); - const result: MigrationResult = { - existing: 0, - migrated: 0, - missing: 0, - scanned: legacyIds.length, - }; - - for (const conversationId of legacyIds) { - const legacyKey = legacyConversationWorkKey(conversationId); - const raw = await context.stateAdapter.get(legacyKey); - if (raw == null) { - result.missing += 1; - await removeLegacyIndexEntry({ - conversationId, - stateAdapter: context.stateAdapter, - }); - continue; - } - - const conversation = normalizeLegacyConversation(conversationId, raw); - if (!conversation) { - throw new Error( - `Legacy conversation work state is invalid for ${conversationId}`, - ); - } - - const existingConversation = await getConversation({ - conversationId, - state: context.stateAdapter, - }); - if (existingConversation) { - const mergedConversation = mergeLegacyConversation( - existingConversation, - conversation, - ); - await context.stateAdapter.set( - conversationKey(conversationId), - mergedConversation, - JUNIOR_THREAD_STATE_TTL_MS, - ); - await upsertConversationIndexes({ - conversation: mergedConversation, - redisStateAdapter: context.redisStateAdapter, - stateAdapter: context.stateAdapter, - }); - result.existing += 1; - await context.stateAdapter.delete(legacyKey); - await removeLegacyIndexEntry({ - conversationId, - stateAdapter: context.stateAdapter, - }); - continue; - } - - await context.stateAdapter.set( - conversationKey(conversationId), - conversation, - JUNIOR_THREAD_STATE_TTL_MS, - ); - await upsertConversationIndexes({ - conversation, - redisStateAdapter: context.redisStateAdapter, - stateAdapter: context.stateAdapter, - }); - await context.stateAdapter.delete(legacyKey); - await removeLegacyIndexEntry({ - conversationId, - stateAdapter: context.stateAdapter, - }); - result.migrated += 1; - } - - return result; -} - -async function isActiveContinuationSummary( - context: MigrationContext, - summary: AwaitingContinuationSummary, -): Promise { - const rawState = - (await context.stateAdapter.get>( - threadStateKey(summary.conversationId), - )) ?? {}; - const conversation = coerceThreadConversationState(rawState); - return conversation.processing.activeTurnId === summary.sessionId; -} - -async function seedAwaitingContinuationConversationWork( - context: MigrationContext, - result: MigrationResult, -): Promise { - const summaries = uniqueAwaitingContinuationSummaries( - await context.stateAdapter.getList(AGENT_TURN_SESSION_INDEX_KEY), - ); - result.scanned += summaries.length; - - for (const summary of summaries) { - if (!(await isActiveContinuationSummary(context, summary))) { - continue; - } - const existingConversation = await getConversation({ - conversationId: summary.conversationId, - state: context.stateAdapter, - }); - if ( - existingConversation?.destination && - !sameDestination(existingConversation.destination, summary.destination) - ) { - throw new Error( - `Awaiting continuation destination does not match conversation ${summary.conversationId}`, - ); - } - if ( - existingConversation && - existingConversation.execution.status !== "idle" - ) { - continue; - } - await requestConversationWork({ - conversationId: summary.conversationId, - destination: summary.destination, - nowMs: Math.max( - summary.updatedAtMs, - existingConversation?.updatedAtMs ?? 0, - ), - state: context.stateAdapter, - }); - if (existingConversation) { - result.existing += 1; - } else { - result.migrated += 1; - } - } -} - -/** Move legacy Redis conversation work into the retained conversation model. */ -export async function migrateRedisConversationState( - context: MigrationContext, -): Promise { - const result = await migrateLegacyConversationWorkRedisState(context); - await seedAwaitingContinuationConversationWork(context, result); - return result; -} diff --git a/packages/junior/tests/component/cli/upgrade-cli.test.ts b/packages/junior/tests/component/cli/upgrade-cli.test.ts index ef8e1c9d2..9c5f6e66e 100644 --- a/packages/junior/tests/component/cli/upgrade-cli.test.ts +++ b/packages/junior/tests/component/cli/upgrade-cli.test.ts @@ -18,9 +18,9 @@ import type { ConversationStore } from "@/chat/conversations/store"; import { persistThreadStateById } from "@/chat/runtime/thread-state"; import { recordAgentTurnSessionSummary } from "@/chat/state/turn-session"; import { resolveUpgradePluginSet } from "@/cli/upgrade"; -import { migrateAgentTurnSessionActor } from "@/cli/upgrade/migrations/agent-turn-session-actor"; -import { migrateConversationsToSql } from "@/cli/upgrade/migrations/conversations-sql"; -import { migrateRedisConversationState } from "@/cli/upgrade/migrations/redis-conversation-state"; +import { migrateAgentTurnSessionActor } from "../../../migrations/0004_agent_turn_session_actor"; +import { migrateRedisConversationState } from "../../../migrations/0005_redis_conversation_state"; +import { migrateConversationsToSql } from "../../../migrations/0006_conversations_to_sql"; import { CONVERSATION_ID, SLACK_DESTINATION, @@ -293,10 +293,21 @@ export const plugins = { const stateAdapter = getStateAdapter(); await stateAdapter.connect(); const fixture = await createLocalJuniorSqlFixture(); - const actor = { platform: "api", userId: "migration-user" }; + const actor = { + platform: "slack", + teamId: "T123", + userId: "migration-user", + } as const; const summary = { + version: 1, conversationId: CONVERSATION_ID, + cumulativeDurationMs: 0, + lastProgressAtMs: 2, sessionId: "session-one", + sliceId: 1, + startedAtMs: 1, + state: "completed", + updatedAtMs: 3, requester: actor, }; await stateAdapter.appendToList("junior:agent_turn_session:index", summary); @@ -317,14 +328,6 @@ export const plugins = { migrationPath, ), mode: "all", - runTask: async (name) => { - if (name === "agent-turn-session-actor-v1") { - return await migrateAgentTurnSessionActor({ - io: { info: () => {} }, - stateAdapter, - }); - } - }, stateAdapter, }), ).resolves.toEqual({ @@ -336,21 +339,23 @@ export const plugins = { await expect( stateAdapter.getList("junior:agent_turn_session:index"), ).resolves.toEqual([ - { + expect.objectContaining({ actor, conversationId: CONVERSATION_ID, sessionId: "session-one", - }, + }), ]); await expect( stateAdapter.get( `junior:agent_turn_session:${CONVERSATION_ID}:session-one`, ), - ).resolves.toEqual({ - actor, - conversationId: CONVERSATION_ID, - sessionId: "session-one", - }); + ).resolves.toEqual( + expect.objectContaining({ + actor, + conversationId: CONVERSATION_ID, + sessionId: "session-one", + }), + ); await expect( migrateSchema(fixture.sql, { loadTypeScript: async (migrationPath) => @@ -358,7 +363,6 @@ export const plugins = { migrationPath, ), mode: "all", - runTask: async () => undefined, stateAdapter, }), ).resolves.toEqual({ diff --git a/packages/junior/tests/component/conversations/legacy-import.test.ts b/packages/junior/tests/component/conversations/legacy-import.test.ts index c14924843..806561b2c 100644 --- a/packages/junior/tests/component/conversations/legacy-import.test.ts +++ b/packages/junior/tests/component/conversations/legacy-import.test.ts @@ -18,7 +18,7 @@ import { createSqlConversationMessageStore } from "@/chat/conversations/sql/mess import { migrateSchema } from "@/chat/conversations/sql/migrations"; import { hydrateConversationMessages } from "@/chat/conversations/visible-messages"; import { coerceThreadConversationState } from "@/chat/state/conversation"; -import { migrateConversationHistoryToSql } from "@/cli/upgrade/migrations/conversations-history-sql"; +import { migrateConversationHistoryToSql } from "../../../migrations/0007_conversation_history_to_sql"; import type { LegacyAdvisorSessionReader } from "@/chat/conversations/legacy-advisor-session"; import type { Conversation } from "@/chat/conversations/store"; import type { PiMessage } from "@/chat/pi/messages"; diff --git a/packages/junior/tests/component/scheduler-sql-plugin.test.ts b/packages/junior/tests/component/scheduler-sql-plugin.test.ts index 8a015530e..b16c18e64 100644 --- a/packages/junior/tests/component/scheduler-sql-plugin.test.ts +++ b/packages/junior/tests/component/scheduler-sql-plugin.test.ts @@ -11,6 +11,7 @@ import { type SchedulerDb, type ScheduledTask, } from "@sentry/junior-scheduler"; +import schedulerStateToSqlMigration from "../../../junior-scheduler/migrations/0002_scheduler_state_to_sql"; import { createSchedulerStore } from "../../../junior-scheduler/src/store"; import { defineJuniorPlugins } from "@/plugins"; import { migratePluginSchemas } from "@/chat/plugins/migrations"; @@ -72,19 +73,18 @@ async function migrateSchedulerSchema( ]); } -async function runSchedulerMigrationTask(args: { - db: SchedulerDb; +async function runSchedulerStateMigration(args: { + fixture: Awaited>; stateAdapter: ReturnType; }) { - const plugin = schedulerPlugin(); - const task = plugin.migrationTasks?.["scheduler-state-to-sql-v1"]; - if (!task) { - throw new Error("Missing scheduler migration task"); - } - return await task({ - db: args.db, - log: { error: () => {}, info: () => {}, warn: () => {} }, - state: createPluginState("scheduler", args.stateAdapter), + return await schedulerStateToSqlMigration.up({ + log: () => {}, + progress: { + load: async () => undefined, + save: async () => {}, + }, + sql: args.fixture.sql, + state: args.stateAdapter, }); } @@ -545,7 +545,7 @@ ORDER BY tablename expect(run).toBeDefined(); await expect( - runSchedulerMigrationTask({ db, stateAdapter }), + runSchedulerStateMigration({ fixture, stateAdapter }), ).resolves.toEqual({ existing: 0, migrated: 2, @@ -553,7 +553,7 @@ ORDER BY tablename scanned: 2, }); await expect( - runSchedulerMigrationTask({ db, stateAdapter }), + runSchedulerStateMigration({ fixture, stateAdapter }), ).resolves.toEqual({ existing: 2, migrated: 0, @@ -629,7 +629,7 @@ ORDER BY tablename ); await expect( - runSchedulerMigrationTask({ db, stateAdapter }), + runSchedulerStateMigration({ fixture, stateAdapter }), ).resolves.toEqual({ existing: 0, migrated: 1, @@ -718,7 +718,6 @@ ORDER BY tablename const scheduler = schedulerPlugin(); const migrationOnly = defineJuniorPlugin({ manifest: scheduler.manifest, - migrationTasks: scheduler.migrationTasks, packageName: scheduler.packageName, }); From b26c2185f7dcee41f2cd5701579b1753c71d5557 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Mon, 13 Jul 2026 16:23:56 -0700 Subject: [PATCH 3/4] ref(migrations): Inject the database adapter Expose the exact host database adapter through MigrationContextV1 instead of reconstructing a partial SQL capability. Rebuild the SQL-heavy frozen migrations without connection setup and driver implementations so migrations retain transformation logic without duplicating database infrastructure. Co-Authored-By: GPT-5.6 Codex --- packages/junior-migrations/README.md | 4 +- packages/junior-migrations/src/index.ts | 2 +- packages/junior-migrations/src/runner.ts | 14 +- packages/junior-migrations/src/types.ts | 10 +- .../junior-migrations/tests/runner.test.ts | 14 +- .../migrations/0002_scheduler_state_to_sql.ts | 8 +- .../migrations/0006_conversations_to_sql.ts | 296 +----------------- .../0007_conversation_history_to_sql.ts | 295 +---------------- packages/junior/migrations/README.md | 6 +- .../src/chat/conversations/sql/migrations.ts | 14 +- .../junior/src/chat/plugins/migrations.ts | 14 +- .../component/scheduler-sql-plugin.test.ts | 2 +- 12 files changed, 67 insertions(+), 612 deletions(-) diff --git a/packages/junior-migrations/README.md b/packages/junior-migrations/README.md index ca4a3e1dd..653329708 100644 --- a/packages/junior-migrations/README.md +++ b/packages/junior-migrations/README.md @@ -30,7 +30,9 @@ then replaces the empty SQL file with a `MigrationV1` TypeScript scaffold. Drizzle Kit remains the supported authoring tool. Drizzle ORM's stock `migrate()` function is not a supported executor for mixed journals because it requires every entry to have a SQL file. Call `runMigrationJournal` instead and -provide the SQL, state, loader, and locking capabilities owned by the host. +provide the host database adapter, state adapter, and TypeScript loader. The +same database adapter drives the journal ledger and is exposed as +`context.database`, so migration files never own connection or driver setup. The runner rejects runtime imports of application source, relative modules, and `@sentry/junior`. External package imports are allowed when the migration diff --git a/packages/junior-migrations/src/index.ts b/packages/junior-migrations/src/index.ts index 0723969b1..6c07a55ad 100644 --- a/packages/junior-migrations/src/index.ts +++ b/packages/junior-migrations/src/index.ts @@ -5,13 +5,13 @@ export type { GenerateTypeScriptMigrationOptions } from "./generate"; export type { RunMigrationJournalOptions } from "./runner"; export type { MigrationContextV1, + MigrationDatabaseAdapter, MigrationJsonValue, MigrationJournalEntry, MigrationLockV1, MigrationProgressV1, MigrationRedisV1, MigrationRunResult, - MigrationSqlExecutor, MigrationStateV1, MigrationV1, ResolvedMigration, diff --git a/packages/junior-migrations/src/runner.ts b/packages/junior-migrations/src/runner.ts index 48912d40b..d0d043e93 100644 --- a/packages/junior-migrations/src/runner.ts +++ b/packages/junior-migrations/src/runner.ts @@ -1,7 +1,7 @@ import type { MigrationContextV1, + MigrationDatabaseAdapter, MigrationRunResult, - MigrationSqlExecutor, MigrationV1, ResolvedMigration, TypeScriptMigrationLoader, @@ -17,7 +17,7 @@ interface MigrationRow { interface RunMigrationJournalBaseOptions { beforeRun?: () => Promise; - executor: MigrationSqlExecutor; + executor: MigrationDatabaseAdapter; migrationsFolder: string; migrationsTable: string; } @@ -54,7 +54,7 @@ function qualifiedTable(table: string): string { } async function ensureMigrationTable( - executor: MigrationSqlExecutor, + executor: MigrationDatabaseAdapter, table: string, ): Promise { const qualified = qualifiedTable(table); @@ -90,7 +90,7 @@ CREATE TABLE IF NOT EXISTS ${qualified} ( } async function migrationRows( - executor: MigrationSqlExecutor, + executor: MigrationDatabaseAdapter, table: string, ): Promise> { const rows = await executor.query(` @@ -107,7 +107,7 @@ ORDER BY created_at ASC, id ASC } async function adoptLegacySqlPrefix(args: { - executor: MigrationSqlExecutor; + executor: MigrationDatabaseAdapter; migrations: readonly ResolvedMigration[]; rows: Map; table: string; @@ -160,7 +160,7 @@ function migrationV1(value: unknown, tag: string): MigrationV1 { } async function runSqlMigration(args: { - executor: MigrationSqlExecutor; + executor: MigrationDatabaseAdapter; migration: ResolvedMigration; table: string; }): Promise { @@ -179,7 +179,7 @@ async function runSqlMigration(args: { async function runTypeScriptMigration(args: { createContext: RunAllMigrationJournalOptions["createContext"]; - executor: MigrationSqlExecutor; + executor: MigrationDatabaseAdapter; loadTypeScript: TypeScriptMigrationLoader; migration: ResolvedMigration; row: MigrationRow | undefined; diff --git a/packages/junior-migrations/src/types.ts b/packages/junior-migrations/src/types.ts index f5f9eb4a4..b49b3ac6f 100644 --- a/packages/junior-migrations/src/types.ts +++ b/packages/junior-migrations/src/types.ts @@ -1,11 +1,13 @@ -/** SQL capabilities available to the mixed migration runner. */ -export interface MigrationSqlExecutor { +/** Database adapter used by the mixed migration runner and TypeScript entries. */ +export interface MigrationDatabaseAdapter { + db(): unknown; execute(statement: string, parameters?: readonly unknown[]): Promise; query( statement: string, parameters?: readonly unknown[], ): Promise; transaction(callback: () => Promise): Promise; + withLock(lockName: string, callback: () => Promise): Promise; withMigrationLock( migrationTable: string, callback: () => Promise, @@ -58,12 +60,10 @@ export type MigrationJsonValue = /** Permanent capability contract for apiVersion 1 migrations. */ export interface MigrationContextV1 { + database: MigrationDatabaseAdapter; log(message: string): void; progress: MigrationProgressV1; redis?: MigrationRedisV1; - sql: Pick & { - db(): unknown; - }; state: MigrationStateV1; } diff --git a/packages/junior-migrations/tests/runner.test.ts b/packages/junior-migrations/tests/runner.test.ts index ca3a51415..c072487b5 100644 --- a/packages/junior-migrations/tests/runner.test.ts +++ b/packages/junior-migrations/tests/runner.test.ts @@ -5,7 +5,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { runMigrationJournal } from "../src/runner"; import type { MigrationContextV1, - MigrationSqlExecutor, + MigrationDatabaseAdapter, MigrationV1, } from "../src/types"; @@ -16,7 +16,7 @@ interface StoredRow { status: string | null; } -class FakeExecutor implements MigrationSqlExecutor { +class FakeExecutor implements MigrationDatabaseAdapter { readonly rows = new Map(); readonly statements: string[] = []; @@ -89,6 +89,10 @@ class FakeExecutor implements MigrationSqlExecutor { ): Promise { return await callback(); } + + async withLock(_lockName: string, callback: () => Promise): Promise { + return await callback(); + } } function fakeMigrationState(): MigrationContextV1["state"] { @@ -166,7 +170,7 @@ describe("runMigrationJournal", () => { createContext: ({ progress }) => ({ log: () => {}, progress, - sql: executor, + database: executor, state: fakeMigrationState(), }), }), @@ -186,7 +190,7 @@ describe("runMigrationJournal", () => { createContext: ({ progress }) => ({ log: () => {}, progress, - sql: executor, + database: executor, state: fakeMigrationState(), }), }), @@ -236,7 +240,7 @@ describe("runMigrationJournal", () => { }) => ({ log: () => {}, progress, - sql: executor, + database: executor, state: fakeMigrationState(), }), }; diff --git a/packages/junior-scheduler/migrations/0002_scheduler_state_to_sql.ts b/packages/junior-scheduler/migrations/0002_scheduler_state_to_sql.ts index 9502a6422..5ac29f8fc 100644 --- a/packages/junior-scheduler/migrations/0002_scheduler_state_to_sql.ts +++ b/packages/junior-scheduler/migrations/0002_scheduler_state_to_sql.ts @@ -121,7 +121,7 @@ const migration = { continue; } tasks.push(task); - const [stored] = await context.sql.query<{ id: string }>( + const [stored] = await context.database.query<{ id: string }>( "SELECT id FROM junior_scheduler_tasks WHERE id = $1 LIMIT 1", [id], ); @@ -130,7 +130,7 @@ const migration = { continue; } const destination = record(task.destination)!; - await context.sql.execute( + await context.database.execute( `INSERT INTO junior_scheduler_tasks (id, team_id, status, next_run_at_ms, run_now_at_ms, created_at_ms, record) VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb) @@ -171,7 +171,7 @@ const migration = { } for (const run of runs) { - const [stored] = await context.sql.query<{ id: string }>( + const [stored] = await context.database.query<{ id: string }>( "SELECT id FROM junior_scheduler_runs WHERE id = $1 LIMIT 1", [run.id], ); @@ -179,7 +179,7 @@ const migration = { existing += 1; continue; } - await context.sql.execute( + await context.database.execute( `INSERT INTO junior_scheduler_runs (id, task_id, status, scheduled_for_ms, record) VALUES ($1, $2, $3, $4, $5::jsonb) diff --git a/packages/junior/migrations/0006_conversations_to_sql.ts b/packages/junior/migrations/0006_conversations_to_sql.ts index 120139bf7..dbbbf7a58 100644 --- a/packages/junior/migrations/0006_conversations_to_sql.ts +++ b/packages/junior/migrations/0006_conversations_to_sql.ts @@ -31,29 +31,16 @@ var __reExport = (target, mod, secondTarget) => ( // migration:config function getChatConfig() { - const databaseUrl = process.env.DATABASE_URL; - if (!databaseUrl) throw new Error("DATABASE_URL is required"); - const configured = process.env.JUNIOR_DATABASE_DRIVER; - const url = new URL(databaseUrl); - const driver = - configured === "postgres" || configured === "neon" - ? configured - : url.hostname === "localhost" || url.hostname === "127.0.0.1" - ? "postgres" - : "neon"; return { - bot: { modelContextWindowTokens: void 0 }, - sql: { databaseUrl, driver }, - state: { - keyPrefix: process.env.JUNIOR_STATE_KEY_PREFIX?.trim() || void 0, - adapter: process.env.REDIS_URL ? "redis" : "memory", - redisUrl: process.env.REDIS_URL, - }, + bot: botConfig, + state: { keyPrefix: process.env.JUNIOR_STATE_KEY_PREFIX?.trim() || void 0 }, }; } +var botConfig; var init_config = __esm({ "migration:config"() { "use strict"; + botConfig = { modelContextWindowTokens: void 0 }; }, }); @@ -449,7 +436,6 @@ var init_conversation_messages = __esm({ }); // packages/junior/src/db/schema.ts -var juniorSqlSchema; var init_schema = __esm({ "packages/junior/src/db/schema.ts"() { "use strict"; @@ -459,14 +445,6 @@ var init_schema = __esm({ init_destinations(); init_identities(); init_users(); - juniorSqlSchema = { - juniorAgentSteps, - juniorConversationMessages, - juniorConversations, - juniorDestinations, - juniorIdentities, - juniorUsers, - }; }, }); @@ -2181,256 +2159,13 @@ var init_search = __esm({ }, }); -// packages/junior/src/db/neon.ts -import { AsyncLocalStorage } from "node:async_hooks"; -import { Pool } from "@neondatabase/serverless"; -import { drizzle } from "drizzle-orm/neon-serverless"; -function createNeonJuniorSqlExecutor(args) { - return new NeonExecutor( - new Pool({ - connectionString: args.connectionString, - max: 3, - }), - ); -} -var NeonExecutor; -var init_neon = __esm({ - "packages/junior/src/db/neon.ts"() { - "use strict"; - init_schema(); - NeonExecutor = class { - constructor(pool) { - this.pool = pool; - } - pool; - transactionClient = new AsyncLocalStorage(); - savepointId = 0; - db() { - return drizzle(this.queryClient(), { - schema: juniorSqlSchema, - }); - } - async execute(statement, params = []) { - await this.queryClient().query(statement, [...params]); - } - async query(statement, params = []) { - const result = await this.queryClient().query(statement, [...params]); - return result.rows; - } - async transaction(callback) { - const existingClient = this.transactionClient.getStore(); - if (existingClient) { - const savepoint = `junior_savepoint_${++this.savepointId}`; - await existingClient.query(`SAVEPOINT ${savepoint}`); - try { - const result = await callback(); - await existingClient.query(`RELEASE SAVEPOINT ${savepoint}`); - return result; - } catch (error) { - await existingClient.query(`ROLLBACK TO SAVEPOINT ${savepoint}`); - await existingClient.query(`RELEASE SAVEPOINT ${savepoint}`); - throw error; - } - } - const client = await this.pool.connect(); - try { - await client.query("BEGIN"); - const result = await this.transactionClient.run(client, callback); - await client.query("COMMIT"); - return result; - } catch (error) { - await client.query("ROLLBACK"); - throw error; - } finally { - client.release(); - } - } - async withLock(lockName, callback) { - if (!lockName) { - throw new Error("SQL lock name is required"); - } - const existingClient = this.transactionClient.getStore(); - if (existingClient) { - await existingClient.query( - "SELECT pg_advisory_xact_lock(hashtext($1))", - [lockName], - ); - return await callback(); - } - const client = await this.pool.connect(); - try { - await client.query("BEGIN"); - return await this.transactionClient.run(client, async () => { - try { - await client.query("SELECT pg_advisory_xact_lock(hashtext($1))", [ - lockName, - ]); - const result = await callback(); - await client.query("COMMIT"); - return result; - } catch (error) { - await client.query("ROLLBACK"); - throw error; - } - }); - } finally { - client.release(); - } - } - async withMigrationLock(migrationTable, callback) { - const client = await this.pool.connect(); - const lockName = `junior:migrate:${migrationTable}`; - try { - await client.query("SELECT pg_advisory_lock(hashtext($1))", [ - lockName, - ]); - return await callback(); - } finally { - client.release(true); - } - } - async close() { - await this.pool.end(); - } - queryClient() { - return this.transactionClient.getStore() ?? this.pool; - } - }; - }, -}); - -// packages/junior/src/db/postgres.ts -import { AsyncLocalStorage as AsyncLocalStorage2 } from "node:async_hooks"; -import pg from "pg"; -import { drizzle as drizzle2 } from "drizzle-orm/node-postgres"; -function createPostgresJuniorSqlExecutor(args) { - return new PostgresExecutor( - new Pool2({ - application_name: args.applicationName, - connectionString: args.connectionString, - max: 3, - }), - ); -} -var Pool2, PostgresExecutor; -var init_postgres = __esm({ - "packages/junior/src/db/postgres.ts"() { - "use strict"; - init_schema(); - ({ Pool: Pool2 } = pg); - PostgresExecutor = class { - constructor(pool) { - this.pool = pool; - } - pool; - transactionClient = new AsyncLocalStorage2(); - savepointId = 0; - db() { - return drizzle2(this.queryClient(), { - schema: juniorSqlSchema, - }); - } - async execute(statement, params = []) { - await this.queryClient().query(statement, [...params]); - } - async query(statement, params = []) { - const result = await this.queryClient().query(statement, [...params]); - return result.rows; - } - async transaction(callback) { - const existingClient = this.transactionClient.getStore(); - if (existingClient) { - const savepoint = `junior_savepoint_${++this.savepointId}`; - await existingClient.query(`SAVEPOINT ${savepoint}`); - try { - const result = await callback(); - await existingClient.query(`RELEASE SAVEPOINT ${savepoint}`); - return result; - } catch (error) { - await existingClient.query(`ROLLBACK TO SAVEPOINT ${savepoint}`); - await existingClient.query(`RELEASE SAVEPOINT ${savepoint}`); - throw error; - } - } - const client = await this.pool.connect(); - try { - await client.query("BEGIN"); - const result = await this.transactionClient.run(client, callback); - await client.query("COMMIT"); - return result; - } catch (error) { - await client.query("ROLLBACK"); - throw error; - } finally { - client.release(); - } - } - async withLock(lockName, callback) { - if (!lockName) { - throw new Error("SQL lock name is required"); - } - const existingClient = this.transactionClient.getStore(); - if (existingClient) { - await existingClient.query( - "SELECT pg_advisory_xact_lock(hashtext($1))", - [lockName], - ); - return await callback(); - } - const client = await this.pool.connect(); - try { - await client.query("BEGIN"); - return await this.transactionClient.run(client, async () => { - try { - await client.query("SELECT pg_advisory_xact_lock(hashtext($1))", [ - lockName, - ]); - const result = await callback(); - await client.query("COMMIT"); - return result; - } catch (error) { - await client.query("ROLLBACK"); - throw error; - } - }); - } finally { - client.release(); - } - } - async withMigrationLock(migrationTable, callback) { - const client = await this.pool.connect(); - const lockName = `junior:migrate:${migrationTable}`; - try { - await client.query("SELECT pg_advisory_lock(hashtext($1))", [ - lockName, - ]); - return await callback(); - } finally { - client.release(true); - } - } - async close() { - await this.pool.end(); - } - queryClient() { - return this.transactionClient.getStore() ?? this.pool; - } - }; - }, -}); - -// packages/junior/src/db/executor.ts -function createJuniorSqlExecutor(args) { - if (args.driver === "postgres") { - return createPostgresJuniorSqlExecutor(args); - } - return createNeonJuniorSqlExecutor(args); +// migration:executor +function createJuniorSqlExecutor() { + throw new Error("Migration database adapter is required"); } var init_executor = __esm({ - "packages/junior/src/db/executor.ts"() { + "migration:executor"() { "use strict"; - init_neon(); - init_postgres(); }, }); @@ -2537,7 +2272,7 @@ var init_deployment = __esm({ }); // packages/junior/src/chat/logging.ts -import { AsyncLocalStorage as AsyncLocalStorage3 } from "node:async_hooks"; +import { AsyncLocalStorage } from "node:async_hooks"; import { ConfigError, configureSync, @@ -2556,7 +2291,7 @@ var init_logging = __esm({ init_sentry(); init_sentry(); init_deployment(); - contextStorage = new AsyncLocalStorage3(); + contextStorage = new AsyncLocalStorage(); deploymentLogAttributes = getDeploymentTelemetryAttributes(); CONSOLE_PRIORITY_KEYS = [ "gen_ai.conversation.id", @@ -2631,13 +2366,13 @@ var init_context = __esm({ }); // packages/junior/src/chat/conversation-privacy.ts -import { AsyncLocalStorage as AsyncLocalStorage4 } from "node:async_hooks"; +import { AsyncLocalStorage as AsyncLocalStorage2 } from "node:async_hooks"; var conversationPrivacyStorage; var init_conversation_privacy = __esm({ "packages/junior/src/chat/conversation-privacy.ts"() { "use strict"; init_context(); - conversationPrivacyStorage = new AsyncLocalStorage4(); + conversationPrivacyStorage = new AsyncLocalStorage2(); }, }); @@ -2829,7 +2564,7 @@ var init_legacy_import = __esm({ }, }); -// packages/junior/src/cli/upgrade/migrations/conversations-sql.ts +// ../../../../../../private/tmp/conversations-sql.ts init_config(); init_store(); @@ -3905,7 +3640,7 @@ async function listAgentTurnSessionSummariesForConversations( return summariesByConversation; } -// packages/junior/src/cli/upgrade/migrations/conversations-sql.ts +// ../../../../../../private/tmp/conversations-sql.ts init_executor(); var CONVERSATION_BACKFILL_LIMIT = 1e4; async function migrateConversationsToSql(context, options = {}) { @@ -3993,12 +3728,13 @@ async function migrateConversationsToSql(context, options = {}) { } // ../../../../../../private/tmp/0006_conversations_to_sql-entry.ts +init_store(); var migration = { apiVersion: 1, async up(context) { return await migrateConversationsToSql( { stateAdapter: context.state, io: { info: context.log } }, - { target: createSqlStore(context.sql) }, + { target: createSqlStore(context.database) }, ); }, }; diff --git a/packages/junior/migrations/0007_conversation_history_to_sql.ts b/packages/junior/migrations/0007_conversation_history_to_sql.ts index a5416145c..22fd052c9 100644 --- a/packages/junior/migrations/0007_conversation_history_to_sql.ts +++ b/packages/junior/migrations/0007_conversation_history_to_sql.ts @@ -31,29 +31,16 @@ var __reExport = (target, mod, secondTarget) => ( // migration:config function getChatConfig() { - const databaseUrl = process.env.DATABASE_URL; - if (!databaseUrl) throw new Error("DATABASE_URL is required"); - const configured = process.env.JUNIOR_DATABASE_DRIVER; - const url = new URL(databaseUrl); - const driver = - configured === "postgres" || configured === "neon" - ? configured - : url.hostname === "localhost" || url.hostname === "127.0.0.1" - ? "postgres" - : "neon"; return { - bot: { modelContextWindowTokens: void 0 }, - sql: { databaseUrl, driver }, - state: { - keyPrefix: process.env.JUNIOR_STATE_KEY_PREFIX?.trim() || void 0, - adapter: process.env.REDIS_URL ? "redis" : "memory", - redisUrl: process.env.REDIS_URL, - }, + bot: botConfig, + state: { keyPrefix: process.env.JUNIOR_STATE_KEY_PREFIX?.trim() || void 0 }, }; } +var botConfig; var init_config = __esm({ "migration:config"() { "use strict"; + botConfig = { modelContextWindowTokens: void 0 }; }, }); @@ -449,7 +436,6 @@ var init_conversation_messages = __esm({ }); // packages/junior/src/db/schema.ts -var juniorSqlSchema; var init_schema = __esm({ "packages/junior/src/db/schema.ts"() { "use strict"; @@ -459,14 +445,6 @@ var init_schema = __esm({ init_destinations(); init_identities(); init_users(); - juniorSqlSchema = { - juniorAgentSteps, - juniorConversationMessages, - juniorConversations, - juniorDestinations, - juniorIdentities, - juniorUsers, - }; }, }); @@ -1654,256 +1632,13 @@ var init_search = __esm({ }, }); -// packages/junior/src/db/neon.ts -import { AsyncLocalStorage } from "node:async_hooks"; -import { Pool } from "@neondatabase/serverless"; -import { drizzle } from "drizzle-orm/neon-serverless"; -function createNeonJuniorSqlExecutor(args) { - return new NeonExecutor( - new Pool({ - connectionString: args.connectionString, - max: 3, - }), - ); -} -var NeonExecutor; -var init_neon = __esm({ - "packages/junior/src/db/neon.ts"() { - "use strict"; - init_schema(); - NeonExecutor = class { - constructor(pool) { - this.pool = pool; - } - pool; - transactionClient = new AsyncLocalStorage(); - savepointId = 0; - db() { - return drizzle(this.queryClient(), { - schema: juniorSqlSchema, - }); - } - async execute(statement, params = []) { - await this.queryClient().query(statement, [...params]); - } - async query(statement, params = []) { - const result = await this.queryClient().query(statement, [...params]); - return result.rows; - } - async transaction(callback) { - const existingClient = this.transactionClient.getStore(); - if (existingClient) { - const savepoint = `junior_savepoint_${++this.savepointId}`; - await existingClient.query(`SAVEPOINT ${savepoint}`); - try { - const result = await callback(); - await existingClient.query(`RELEASE SAVEPOINT ${savepoint}`); - return result; - } catch (error) { - await existingClient.query(`ROLLBACK TO SAVEPOINT ${savepoint}`); - await existingClient.query(`RELEASE SAVEPOINT ${savepoint}`); - throw error; - } - } - const client = await this.pool.connect(); - try { - await client.query("BEGIN"); - const result = await this.transactionClient.run(client, callback); - await client.query("COMMIT"); - return result; - } catch (error) { - await client.query("ROLLBACK"); - throw error; - } finally { - client.release(); - } - } - async withLock(lockName, callback) { - if (!lockName) { - throw new Error("SQL lock name is required"); - } - const existingClient = this.transactionClient.getStore(); - if (existingClient) { - await existingClient.query( - "SELECT pg_advisory_xact_lock(hashtext($1))", - [lockName], - ); - return await callback(); - } - const client = await this.pool.connect(); - try { - await client.query("BEGIN"); - return await this.transactionClient.run(client, async () => { - try { - await client.query("SELECT pg_advisory_xact_lock(hashtext($1))", [ - lockName, - ]); - const result = await callback(); - await client.query("COMMIT"); - return result; - } catch (error) { - await client.query("ROLLBACK"); - throw error; - } - }); - } finally { - client.release(); - } - } - async withMigrationLock(migrationTable, callback) { - const client = await this.pool.connect(); - const lockName = `junior:migrate:${migrationTable}`; - try { - await client.query("SELECT pg_advisory_lock(hashtext($1))", [ - lockName, - ]); - return await callback(); - } finally { - client.release(true); - } - } - async close() { - await this.pool.end(); - } - queryClient() { - return this.transactionClient.getStore() ?? this.pool; - } - }; - }, -}); - -// packages/junior/src/db/postgres.ts -import { AsyncLocalStorage as AsyncLocalStorage2 } from "node:async_hooks"; -import pg from "pg"; -import { drizzle as drizzle2 } from "drizzle-orm/node-postgres"; -function createPostgresJuniorSqlExecutor(args) { - return new PostgresExecutor( - new Pool2({ - application_name: args.applicationName, - connectionString: args.connectionString, - max: 3, - }), - ); -} -var Pool2, PostgresExecutor; -var init_postgres = __esm({ - "packages/junior/src/db/postgres.ts"() { - "use strict"; - init_schema(); - ({ Pool: Pool2 } = pg); - PostgresExecutor = class { - constructor(pool) { - this.pool = pool; - } - pool; - transactionClient = new AsyncLocalStorage2(); - savepointId = 0; - db() { - return drizzle2(this.queryClient(), { - schema: juniorSqlSchema, - }); - } - async execute(statement, params = []) { - await this.queryClient().query(statement, [...params]); - } - async query(statement, params = []) { - const result = await this.queryClient().query(statement, [...params]); - return result.rows; - } - async transaction(callback) { - const existingClient = this.transactionClient.getStore(); - if (existingClient) { - const savepoint = `junior_savepoint_${++this.savepointId}`; - await existingClient.query(`SAVEPOINT ${savepoint}`); - try { - const result = await callback(); - await existingClient.query(`RELEASE SAVEPOINT ${savepoint}`); - return result; - } catch (error) { - await existingClient.query(`ROLLBACK TO SAVEPOINT ${savepoint}`); - await existingClient.query(`RELEASE SAVEPOINT ${savepoint}`); - throw error; - } - } - const client = await this.pool.connect(); - try { - await client.query("BEGIN"); - const result = await this.transactionClient.run(client, callback); - await client.query("COMMIT"); - return result; - } catch (error) { - await client.query("ROLLBACK"); - throw error; - } finally { - client.release(); - } - } - async withLock(lockName, callback) { - if (!lockName) { - throw new Error("SQL lock name is required"); - } - const existingClient = this.transactionClient.getStore(); - if (existingClient) { - await existingClient.query( - "SELECT pg_advisory_xact_lock(hashtext($1))", - [lockName], - ); - return await callback(); - } - const client = await this.pool.connect(); - try { - await client.query("BEGIN"); - return await this.transactionClient.run(client, async () => { - try { - await client.query("SELECT pg_advisory_xact_lock(hashtext($1))", [ - lockName, - ]); - const result = await callback(); - await client.query("COMMIT"); - return result; - } catch (error) { - await client.query("ROLLBACK"); - throw error; - } - }); - } finally { - client.release(); - } - } - async withMigrationLock(migrationTable, callback) { - const client = await this.pool.connect(); - const lockName = `junior:migrate:${migrationTable}`; - try { - await client.query("SELECT pg_advisory_lock(hashtext($1))", [ - lockName, - ]); - return await callback(); - } finally { - client.release(true); - } - } - async close() { - await this.pool.end(); - } - queryClient() { - return this.transactionClient.getStore() ?? this.pool; - } - }; - }, -}); - -// packages/junior/src/db/executor.ts -function createJuniorSqlExecutor(args) { - if (args.driver === "postgres") { - return createPostgresJuniorSqlExecutor(args); - } - return createNeonJuniorSqlExecutor(args); +// migration:executor +function createJuniorSqlExecutor() { + throw new Error("Migration database adapter is required"); } var init_executor = __esm({ - "packages/junior/src/db/executor.ts"() { + "migration:executor"() { "use strict"; - init_neon(); - init_postgres(); }, }); @@ -2026,7 +1761,7 @@ var init_deployment = __esm({ }); // packages/junior/src/chat/logging.ts -import { AsyncLocalStorage as AsyncLocalStorage3 } from "node:async_hooks"; +import { AsyncLocalStorage } from "node:async_hooks"; import { ConfigError, configureSync, @@ -2045,7 +1780,7 @@ var init_logging = __esm({ init_sentry(); init_sentry(); init_deployment(); - contextStorage = new AsyncLocalStorage3(); + contextStorage = new AsyncLocalStorage(); deploymentLogAttributes = getDeploymentTelemetryAttributes(); CONSOLE_PRIORITY_KEYS = [ "gen_ai.conversation.id", @@ -2120,13 +1855,13 @@ var init_context = __esm({ }); // packages/junior/src/chat/conversation-privacy.ts -import { AsyncLocalStorage as AsyncLocalStorage4 } from "node:async_hooks"; +import { AsyncLocalStorage as AsyncLocalStorage2 } from "node:async_hooks"; var conversationPrivacyStorage; var init_conversation_privacy = __esm({ "packages/junior/src/chat/conversation-privacy.ts"() { "use strict"; init_context(); - conversationPrivacyStorage = new AsyncLocalStorage4(); + conversationPrivacyStorage = new AsyncLocalStorage2(); }, }); @@ -2920,7 +2655,7 @@ var init_legacy_import = __esm({ }, }); -// packages/junior/src/cli/upgrade/migrations/conversations-history-sql.ts +// ../../../../../../private/tmp/conversations-history-sql.ts init_config(); init_legacy_import(); init_messages2(); @@ -3744,7 +3479,7 @@ function createStateConversationStore(state) { }; } -// packages/junior/src/cli/upgrade/migrations/conversations-history-sql.ts +// ../../../../../../private/tmp/conversations-history-sql.ts init_executor(); var HISTORY_BACKFILL_LIMIT = 1e4; async function migrateConversationHistoryToSql(context, options = {}) { @@ -3829,7 +3564,7 @@ var migration = { async up(context) { return await migrateConversationHistoryToSql( { stateAdapter: context.state, io: { info: context.log } }, - { executor: context.sql }, + { executor: context.database }, ); }, }; diff --git a/packages/junior/migrations/README.md b/packages/junior/migrations/README.md index f0e8657bf..1c2e33d89 100644 --- a/packages/junior/migrations/README.md +++ b/packages/junior/migrations/README.md @@ -13,8 +13,10 @@ For a data migration, run `pnpm --filter @sentry/junior db:generate:data --name `. TypeScript migrations target the versioned migration capability API and must not import Junior runtime internals or current feature schemas. Their complete -implementation remains in the migration file and uses only the stable SQL, -state, Redis, and progress capabilities supplied by the runner. +implementation remains in the migration file and uses only the stable +database, state, Redis, and progress capabilities supplied by the runner. The +host database adapter owns connections, drivers, transactions, and locks so +those infrastructure modules are not frozen into every migration. The `0000_initial.sql` baseline represents the schema already deployed by the pre-Drizzle Junior migration runner. During upgrade, existing installations diff --git a/packages/junior/src/chat/conversations/sql/migrations.ts b/packages/junior/src/chat/conversations/sql/migrations.ts index 9591ae7ac..2891cf8b3 100644 --- a/packages/junior/src/chat/conversations/sql/migrations.ts +++ b/packages/junior/src/chat/conversations/sql/migrations.ts @@ -132,18 +132,6 @@ function migrationState(stateAdapter: StateAdapter): MigrationStateV1 { }; } -/** Project the executor onto the permanent SQL migration capability. */ -function migrationSql( - executor: JuniorSqlMigrationExecutor, -): MigrationContextV1["sql"] { - return { - db: executor.db.bind(executor), - execute: executor.execute.bind(executor), - query: executor.query.bind(executor), - transaction: executor.transaction.bind(executor), - }; -} - export type MigrateSchemaOptions = | { mode?: "sql" } | { @@ -177,6 +165,7 @@ export async function migrateSchema( return await runMigrationJournal({ ...baseOptions, createContext: ({ progress }): MigrationContextV1 => ({ + database: executor, log: options.log ?? (() => {}), progress, ...(options.redisStateAdapter @@ -189,7 +178,6 @@ export async function migrateSchema( }, } : {}), - sql: migrationSql(executor), state: migrationState(options.stateAdapter), }), loadTypeScript: options.loadTypeScript, diff --git a/packages/junior/src/chat/plugins/migrations.ts b/packages/junior/src/chat/plugins/migrations.ts index da16629d3..5583b60b8 100644 --- a/packages/junior/src/chat/plugins/migrations.ts +++ b/packages/junior/src/chat/plugins/migrations.ts @@ -157,18 +157,6 @@ function migrationState(stateAdapter: StateAdapter): MigrationStateV1 { }; } -/** Project the executor onto the permanent SQL migration capability. */ -function migrationSql( - executor: JuniorSqlMigrationExecutor, -): MigrationContextV1["sql"] { - return { - db: executor.db.bind(executor), - execute: executor.execute.bind(executor), - query: executor.query.bind(executor), - transaction: executor.transaction.bind(executor), - }; -} - /** Apply enabled plugins' mixed migrations in plugin-name and journal order. */ export async function migratePluginSchemas( executor: JuniorSqlMigrationExecutor, @@ -204,9 +192,9 @@ export async function migratePluginSchemas( ? await runMigrationJournal({ ...baseOptions, createContext: ({ progress }): MigrationContextV1 => ({ + database: executor, log: options.log ?? (() => {}), progress, - sql: migrationSql(executor), state: migrationState(options.stateAdapter), }), loadTypeScript: options.loadTypeScript, diff --git a/packages/junior/tests/component/scheduler-sql-plugin.test.ts b/packages/junior/tests/component/scheduler-sql-plugin.test.ts index b16c18e64..b2ce0516d 100644 --- a/packages/junior/tests/component/scheduler-sql-plugin.test.ts +++ b/packages/junior/tests/component/scheduler-sql-plugin.test.ts @@ -78,12 +78,12 @@ async function runSchedulerStateMigration(args: { stateAdapter: ReturnType; }) { return await schedulerStateToSqlMigration.up({ + database: args.fixture.sql, log: () => {}, progress: { load: async () => undefined, save: async () => {}, }, - sql: args.fixture.sql, state: args.stateAdapter, }); } From fd37ff2ba38dddafa9a5c81408f7b00cdc59f83c Mon Sep 17 00:00:00 2001 From: David Cramer Date: Mon, 13 Jul 2026 17:11:42 -0700 Subject: [PATCH 4/4] ref(migrations): Add a versioned helper boundary Allow migrations to import append-only infrastructure helpers from @sentry/junior/migration-helpers/v1 while continuing to reject unversioned Junior runtime imports. Keep one-off Redis transformations and backfill decisions in their journal entries, replace generated capsules for migrations 0004 through 0006 with readable source, and publish explicit helper types without private module references. Co-Authored-By: GPT-5.6 Codex --- packages/junior-migrations/README.md | 9 +- packages/junior-migrations/src/journal.ts | 8 +- packages/junior-migrations/src/types.ts | 4 +- .../junior-migrations/tests/journal.test.ts | 12 + .../0004_agent_turn_session_actor.ts | 176 +- .../0005_redis_conversation_state.ts | 1616 ++----- .../migrations/0006_conversations_to_sql.ts | 3815 +---------------- packages/junior/migrations/README.md | 6 + packages/junior/package.json | 4 + .../src/chat/conversations/sql/migrations.ts | 5 +- .../junior/src/chat/plugins/migrations.ts | 5 +- packages/junior/src/cli/upgrade/types.ts | 1 - .../junior/src/migration-helpers/README.md | 13 + packages/junior/src/migration-helpers/v1.ts | 234 + .../component/scheduler-sql-plugin.test.ts | 7 +- packages/junior/tsconfig.build.json | 1 + packages/junior/tsup.config.ts | 1 + 17 files changed, 760 insertions(+), 5157 deletions(-) create mode 100644 packages/junior/src/migration-helpers/README.md create mode 100644 packages/junior/src/migration-helpers/v1.ts diff --git a/packages/junior-migrations/README.md b/packages/junior-migrations/README.md index 653329708..1abc59712 100644 --- a/packages/junior-migrations/README.md +++ b/packages/junior-migrations/README.md @@ -35,7 +35,8 @@ same database adapter drives the journal ledger and is exposed as `context.database`, so migration files never own connection or driver setup. The runner rejects runtime imports of application source, relative modules, -and `@sentry/junior`. External package imports are allowed when the migration -needs a stable library dependency; migration-specific implementation must -still remain in the migration file. Add a new API version rather than changing -an existing migration capability contract. +and unversioned `@sentry/junior` modules. Migrations may import the append-only +`@sentry/junior/migration-helpers/v1` surface for parsing primitives, adapters, +stores, and key resolution. One-off migration decisions and data transforms +must still remain in the journal entry. Add a new helper or capability version +rather than changing an existing contract. diff --git a/packages/junior-migrations/src/journal.ts b/packages/junior-migrations/src/journal.ts index 397326e30..826ffef26 100644 --- a/packages/junior-migrations/src/journal.ts +++ b/packages/junior-migrations/src/journal.ts @@ -51,6 +51,9 @@ async function optionalFile(path: string): Promise { } function validateTypeScriptSource(tag: string, source: string): void { + const allowedRuntimeImports = new Set([ + "@sentry/junior/migration-helpers/v1", + ]); const imports = source.matchAll( /^\s*import\s+([^;]+?)\s+from\s+["']([^"']+)["'];?/gm, ); @@ -65,8 +68,9 @@ function validateTypeScriptSource(tag: string, source: string): void { specifier.startsWith(".") || specifier.startsWith("/") || specifier.startsWith("@/") || - specifier === "@sentry/junior" || - specifier.startsWith("@sentry/junior/") + ((specifier === "@sentry/junior" || + specifier.startsWith("@sentry/junior/")) && + !allowedRuntimeImports.has(specifier)) ) { throw new Error( `TypeScript migration ${tag} cannot import application runtime code`, diff --git a/packages/junior-migrations/src/types.ts b/packages/junior-migrations/src/types.ts index b49b3ac6f..dd2aa6813 100644 --- a/packages/junior-migrations/src/types.ts +++ b/packages/junior-migrations/src/types.ts @@ -31,8 +31,8 @@ export interface MigrationStateV1 { ): Promise; connect(): Promise; delete(key: string): Promise; - get(key: string): Promise; - getList(key: string): Promise; + get(key: string): Promise; + getList(key: string): Promise; releaseLock(lock: MigrationLockV1): Promise; set(key: string, value: unknown, ttlMs?: number): Promise; setIfNotExists(key: string, value: unknown, ttlMs?: number): Promise; diff --git a/packages/junior-migrations/tests/journal.test.ts b/packages/junior-migrations/tests/journal.test.ts index 56100fb34..0acef14cf 100644 --- a/packages/junior-migrations/tests/journal.test.ts +++ b/packages/junior-migrations/tests/journal.test.ts @@ -62,6 +62,18 @@ describe("resolveMigrations", () => { ); }); + it("allows versioned Junior migration helpers", async () => { + const folder = await migrationFolder(["0000_helpers"]); + await writeFile( + join(folder, "0000_helpers.ts"), + 'import { isRecord } from "@sentry/junior/migration-helpers/v1";\nexport default { apiVersion: 1, async up() { isRecord({}); } };\n', + ); + + await expect(resolveMigrations(folder)).resolves.toMatchObject([ + { kind: "typescript", tag: "0000_helpers" }, + ]); + }); + it("rejects ambiguous migration files", async () => { const folder = await migrationFolder(["0000_ambiguous"]); await writeFile(join(folder, "0000_ambiguous.sql"), "SELECT 1;"); diff --git a/packages/junior/migrations/0004_agent_turn_session_actor.ts b/packages/junior/migrations/0004_agent_turn_session_actor.ts index 1ca8089dc..7018c1653 100644 --- a/packages/junior/migrations/0004_agent_turn_session_actor.ts +++ b/packages/junior/migrations/0004_agent_turn_session_actor.ts @@ -1,78 +1,81 @@ -// @ts-nocheck -- frozen migration capsule; do not refactor against current Junior internals. -/* eslint-disable no-unused-vars */ - -// packages/junior/src/cli/upgrade/migrations/agent-turn-session-actor.ts import { THREAD_STATE_TTL_MS } from "chat"; +import type { + MigrationRedisV1, + MigrationStateV1, + MigrationV1, +} from "@sentry/junior-migrations"; +import { + isRecord, + migrationRedisKey, + toOptionalString, +} from "@sentry/junior/migration-helpers/v1"; -// packages/junior/src/chat/coerce.ts -function toOptionalString(value) { - return typeof value === "string" && value.trim() ? value : void 0; -} -function isRecord(value) { - return typeof value === "object" && value !== null; -} +type StateAdapter = MigrationStateV1; +type RedisStateAdapter = { getClient(): MigrationRedisV1 }; +type MigrationContext = { + io?: { info(message: string): void }; + redisStateAdapter?: RedisStateAdapter; + stateAdapter: StateAdapter; +}; +type MigrationResult = { + existing: number; + migrated: number; + missing: number; + scanned: number; +}; -// migration:config -function getChatConfig() { - const databaseUrl = process.env.DATABASE_URL; - if (!databaseUrl) throw new Error("DATABASE_URL is required"); - const configured = process.env.JUNIOR_DATABASE_DRIVER; - const url = new URL(databaseUrl); - const driver = - configured === "postgres" || configured === "neon" - ? configured - : url.hostname === "localhost" || url.hostname === "127.0.0.1" - ? "postgres" - : "neon"; - return { - bot: { modelContextWindowTokens: void 0 }, - sql: { databaseUrl, driver }, - state: { - keyPrefix: process.env.JUNIOR_STATE_KEY_PREFIX?.trim() || void 0, - adapter: process.env.REDIS_URL ? "redis" : "memory", - redisUrl: process.env.REDIS_URL, - }, - }; +const AGENT_TURN_SESSION_PREFIX = "junior:agent_turn_session"; +const AGENT_TURN_SESSION_INDEX_KEY = `${AGENT_TURN_SESSION_PREFIX}:index`; +const AGENT_TURN_SESSION_INDEX_MAX_LENGTH = 5_000; +const REDIS_SCAN_COUNT = 500; + +type RedisCommandClient = { + sendCommand(args: readonly string[]): Promise; +}; + +interface MigratedValue { + changed: boolean; + value: unknown; } -// packages/junior/src/cli/upgrade/migrations/agent-turn-session-actor.ts -var AGENT_TURN_SESSION_PREFIX = "junior:agent_turn_session"; -var AGENT_TURN_SESSION_INDEX_KEY = `${AGENT_TURN_SESSION_PREFIX}:index`; -var AGENT_TURN_SESSION_INDEX_MAX_LENGTH = 5e3; -var REDIS_SCAN_COUNT = 500; -function conversationIndexKey(conversationId) { +function conversationIndexKey(conversationId: string): string { return `${AGENT_TURN_SESSION_PREFIX}:conversation:${conversationId}:index`; } -function sessionRecordKey(conversationId, sessionId) { + +function sessionRecordKey(conversationId: string, sessionId: string): string { return `${AGENT_TURN_SESSION_PREFIX}:${conversationId}:${sessionId}`; } -function logicalConversationIndexPrefix() { - const prefix = getChatConfig().state.keyPrefix; - return [ - ...(prefix ? [prefix] : []), - `${AGENT_TURN_SESSION_PREFIX}:conversation:`, - ].join(":"); + +function logicalConversationIndexPrefix(): string { + return migrationRedisKey(`${AGENT_TURN_SESSION_PREFIX}:conversation:`); } -function conversationIdFromRedisListKey(key) { + +function conversationIdFromRedisListKey(key: string): string | undefined { const marker = `:list:${logicalConversationIndexPrefix()}`; const markerIndex = key.indexOf(marker); if (markerIndex < 0 || !key.endsWith(":index")) { - return void 0; + return undefined; } return toOptionalString( key.slice(markerIndex + marker.length, -":index".length), ); } -async function discoverRedisConversationIds(redisStateAdapter) { - const client = redisStateAdapter?.getClient(); + +async function discoverRedisConversationIds( + redisStateAdapter: RedisStateAdapter | undefined, +): Promise { + const client = redisStateAdapter?.getClient() as + | RedisCommandClient + | undefined; if (!client) { return []; } - const conversationIds = /* @__PURE__ */ new Set(); + + const conversationIds = new Set(); const match = `*:list:${logicalConversationIndexPrefix()}*:index`; let cursor = "0"; do { - const reply = await client.sendCommand([ + const reply = await client.sendCommand([ "SCAN", cursor, "MATCH", @@ -101,41 +104,55 @@ async function discoverRedisConversationIds(redisStateAdapter) { } } } while (cursor !== "0"); + return [...conversationIds]; } -function migrateRequesterToActor(value) { - if (!isRecord(value) || value.requester === void 0) { + +function migrateRequesterToActor(value: unknown): MigratedValue { + if (!isRecord(value) || value.requester === undefined) { return { changed: false, value }; } + const { requester, ...record } = value; return { changed: true, value: { ...record, - ...(record.actor === void 0 ? { actor: requester } : {}), + ...(record.actor === undefined ? { actor: requester } : {}), }, }; } -async function rewriteList(args) { + +async function rewriteList(args: { + key: string; + maxLength?: number; + stateAdapter: StateAdapter; +}): Promise<{ migrated: number; values: unknown[] }> { const values = await args.stateAdapter.getList(args.key); const migrated = values.map(migrateRequesterToActor); const changed = migrated.filter((entry) => entry.changed).length; if (changed === 0) { return { migrated: 0, values }; } + await args.stateAdapter.delete(args.key); for (const entry of migrated) { await args.stateAdapter.appendToList(args.key, entry.value, { - ...(args.maxLength !== void 0 ? { maxLength: args.maxLength } : {}), + ...(args.maxLength !== undefined ? { maxLength: args.maxLength } : {}), ttlMs: THREAD_STATE_TTL_MS, }); } return { migrated: changed, values: migrated.map((entry) => entry.value) }; } -async function migrateSessionRecord(args) { + +async function migrateSessionRecord(args: { + conversationId: string; + sessionId: string; + stateAdapter: StateAdapter; +}): Promise<"existing" | "migrated" | "missing"> { const key = sessionRecordKey(args.conversationId, args.sessionId); - const existing = await args.stateAdapter.get(key); - if (existing === void 0) { + const existing = await args.stateAdapter.get(key); + if (existing === undefined) { return "missing"; } const migrated = migrateRequesterToActor(existing); @@ -145,8 +162,12 @@ async function migrateSessionRecord(args) { await args.stateAdapter.set(key, migrated.value, THREAD_STATE_TTL_MS); return "migrated"; } -async function migrateAgentTurnSessionActor(context) { - const result = { + +/** Rewrite retained turn-session requester fields to actor fields. */ +export async function migrateAgentTurnSessionActor( + context: MigrationContext, +): Promise { + const result: MigrationResult = { existing: 0, migrated: 0, missing: 0, @@ -159,8 +180,9 @@ async function migrateAgentTurnSessionActor(context) { }); result.scanned += global.values.length; result.migrated += global.migrated; - const conversations = /* @__PURE__ */ new Set(); - const sessions = /* @__PURE__ */ new Set(); + + const conversations = new Set(); + const sessions = new Set(); for (const conversationId of await discoverRedisConversationIds( context.redisStateAdapter, )) { @@ -177,9 +199,10 @@ async function migrateAgentTurnSessionActor(context) { } conversations.add(conversationId); if (sessionId) { - sessions.add(`${conversationId}\0${sessionId}`); + sessions.add(`${conversationId}\u0000${sessionId}`); } } + for (const conversationId of conversations) { const conversation = await rewriteList({ key: conversationIndexKey(conversationId), @@ -193,12 +216,13 @@ async function migrateAgentTurnSessionActor(context) { } const sessionId = toOptionalString(value.sessionId); if (sessionId) { - sessions.add(`${conversationId}\0${sessionId}`); + sessions.add(`${conversationId}\u0000${sessionId}`); } } } + for (const session of sessions) { - const separator = session.indexOf("\0"); + const separator = session.indexOf("\u0000"); const conversationId = session.slice(0, separator); const sessionId = session.slice(separator + 1); result.scanned += 1; @@ -215,24 +239,20 @@ async function migrateAgentTurnSessionActor(context) { result.missing += 1; } } + return result; } -// ../../../../../../private/tmp/0004_agent_turn_session_actor-entry.ts -var migration = { +const migration = { apiVersion: 1, async up(context) { return await migrateAgentTurnSessionActor({ stateAdapter: context.state, - redisStateAdapter: context.redis - ? { getClient: () => ({ sendCommand: context.redis.sendCommand }) } - : void 0, - io: { info: context.log }, + ...(context.redis + ? { redisStateAdapter: { getClient: () => context.redis! } } + : {}), }); }, -}; -var agent_turn_session_actor_entry_default = migration; -export { - agent_turn_session_actor_entry_default as default, - migrateAgentTurnSessionActor, -}; +} satisfies MigrationV1; + +export default migration; diff --git a/packages/junior/migrations/0005_redis_conversation_state.ts b/packages/junior/migrations/0005_redis_conversation_state.ts index 08e635414..0463efb51 100644 --- a/packages/junior/migrations/0005_redis_conversation_state.ts +++ b/packages/junior/migrations/0005_redis_conversation_state.ts @@ -1,1256 +1,99 @@ -// @ts-nocheck -- frozen migration capsule; do not refactor against current Junior internals. -/* eslint-disable no-unused-vars */ +import type { Destination } from "@sentry/junior-plugin-api"; +import type { + MigrationRedisV1, + MigrationStateV1, + MigrationV1, +} from "@sentry/junior-migrations"; +import { + coerceThreadConversationState, + getMigrationConversation as getConversation, + isRecord, + JUNIOR_THREAD_STATE_TTL_MS, + migrationRedisKey, + parseDestination, + requestMigrationConversationWork as requestConversationWork, + sameDestination, + toOptionalNumber, + toOptionalString, + type MigrationRetainedConversationV1 as Conversation, + type MigrationExecutionStatusV1 as ExecutionStatus, + type MigrationInboundMessageV1 as InboundMessage, + type MigrationLeaseV1 as Lease, + type MigrationSourceV1 as Source, +} from "@sentry/junior/migration-helpers/v1"; -// packages/junior/src/chat/coerce.ts -function toOptionalString(value) { - return typeof value === "string" && value.trim() ? value : void 0; -} -function toOptionalNumber(value) { - return typeof value === "number" && Number.isFinite(value) ? value : void 0; -} -function isRecord(value) { - return typeof value === "object" && value !== null; -} - -// migration:config -function getChatConfig() { - const databaseUrl = process.env.DATABASE_URL; - if (!databaseUrl) throw new Error("DATABASE_URL is required"); - const configured = process.env.JUNIOR_DATABASE_DRIVER; - const url = new URL(databaseUrl); - const driver = - configured === "postgres" || configured === "neon" - ? configured - : url.hostname === "localhost" || url.hostname === "127.0.0.1" - ? "postgres" - : "neon"; - return { - bot: { modelContextWindowTokens: void 0 }, - sql: { databaseUrl, driver }, - state: { - keyPrefix: process.env.JUNIOR_STATE_KEY_PREFIX?.trim() || void 0, - adapter: process.env.REDIS_URL ? "redis" : "memory", - redisUrl: process.env.REDIS_URL, - }, - }; -} - -// packages/junior/src/chat/destination.ts -import { destinationSchema } from "@sentry/junior-plugin-api"; - -// packages/junior/src/chat/slack/ids.ts -import { z as z2 } from "zod"; - -// packages/junior/src/chat/slack/timestamp.ts -import { z } from "zod"; -var slackMessageTsSchema = z - .string() - .trim() - .regex(/^\d+(?:\.\d+)?$/) - .brand(); +type StateAdapter = MigrationStateV1; +type RedisStateAdapter = { getClient(): MigrationRedisV1 }; +type MigrationContext = { + io?: { info(message: string): void }; + redisStateAdapter?: RedisStateAdapter; + stateAdapter: StateAdapter; +}; +type MigrationResult = { + existing: number; + migrated: number; + missing: number; + scanned: number; +}; -// packages/junior/src/chat/slack/ids.ts -var slackChannelIdSchema = z2 - .string() - .regex(/^[CDG][A-Z0-9]+$/) - .brand(); -var slackTeamIdSchema = z2 - .string() - .regex(/^T[A-Z0-9]+$/) - .brand(); -var slackUserIdSchema = z2 - .string() - .regex(/^[UW][A-Z0-9]+$/) - .brand(); -function parseSlackTeamId(value) { - if (typeof value !== "string") return void 0; - const parsed = slackTeamIdSchema.safeParse(value.trim()); - return parsed.success ? parsed.data : void 0; -} +const CONVERSATION_PREFIX = "junior:conversation"; +const CONVERSATION_SCHEMA_VERSION = 1; +const CONVERSATION_ACTIVITY_INDEX_MAX_LENGTH = 10_000; +const CONVERSATION_BY_ACTIVITY_INDEX_KEY = `${CONVERSATION_PREFIX}:by-activity`; +const CONVERSATION_ACTIVE_INDEX_KEY = `${CONVERSATION_PREFIX}:active`; +const LEGACY_CONVERSATION_WORK_PREFIX = "junior:conversation-work"; +const LEGACY_CONVERSATION_WORK_SCHEMA_VERSION = 1; +const LEGACY_CONVERSATION_WORK_INDEX_KEY = `${LEGACY_CONVERSATION_WORK_PREFIX}:index`; +const AGENT_TURN_SESSION_PREFIX = "junior:agent_turn_session"; +const AGENT_TURN_SESSION_INDEX_KEY = `${AGENT_TURN_SESSION_PREFIX}:index`; +const THREAD_STATE_PREFIX = "thread-state"; -// packages/junior/src/chat/destination.ts -function parseDestination(value) { - const parsed = destinationSchema.safeParse(value); - return parsed.success ? parsed.data : void 0; -} -function sameDestination(left, right) { - if (left.platform !== right.platform) { - return false; - } - if (left.platform === "local" && right.platform === "local") { - return left.conversationId === right.conversationId; - } - if (left.platform === "slack" && right.platform === "slack") { - return left.teamId === right.teamId && left.channelId === right.channelId; - } - return false; +interface ConversationIndexEntry { + conversationId: string; + score: number; } -// packages/junior/src/chat/state/conversation.ts -function defaultConversationState() { - const nowMs = Date.now(); - return { - schemaVersion: 1, - messages: [], - compactions: [], - backfill: {}, - processing: {}, - stats: { - estimatedContextTokens: 0, - totalMessageCount: 0, - compactedMessageCount: 0, - updatedAtMs: nowMs, - }, - vision: { - byFileId: {}, - }, - }; -} -function coercePendingAuthState(value) { - if (!isRecord(value)) { - return void 0; - } - const kind = value.kind; - const provider = toOptionalString(value.provider); - const actorId = toOptionalString(value.actorId); - const authSessionId = toOptionalString(value.authSessionId); - const scope = toOptionalString(value.scope); - const sessionId = toOptionalString(value.sessionId); - const linkSentAtMs = toOptionalNumber(value.linkSentAtMs); - if ( - (kind !== "mcp" && kind !== "plugin") || - !provider || - !actorId || - (kind === "mcp" && !authSessionId) || - !sessionId || - typeof linkSentAtMs !== "number" - ) { - return void 0; - } - const base = { - provider, - actorId, - ...(scope ? { scope } : {}), - sessionId, - linkSentAtMs, - }; - return kind === "mcp" ? { ...base, authSessionId, kind } : { ...base, kind }; -} -function coerceThreadConversationState(value) { - if (!isRecord(value)) { - return defaultConversationState(); - } - const root = value; - const rawConversation = isRecord(root.conversation) ? root.conversation : {}; - const base = defaultConversationState(); - const messages = []; - const rawCompactions = Array.isArray(rawConversation.compactions) - ? rawConversation.compactions - : []; - const compactions = []; - for (const item of rawCompactions) { - if (!isRecord(item)) continue; - const id = toOptionalString(item.id); - const summary = toOptionalString(item.summary); - const createdAtMs = toOptionalNumber(item.createdAtMs); - if (!id || !summary || !createdAtMs) continue; - const coveredMessageIds = Array.isArray(item.coveredMessageIds) - ? item.coveredMessageIds.filter( - (entry) => typeof entry === "string" && entry.length > 0, - ) - : []; - compactions.push({ - id, - summary, - createdAtMs, - coveredMessageIds, - }); - } - const rawBackfill = isRecord(rawConversation.backfill) - ? rawConversation.backfill - : {}; - const backfill = { - completedAtMs: toOptionalNumber(rawBackfill.completedAtMs), - source: - rawBackfill.source === "recent_messages" || - rawBackfill.source === "thread_fetch" - ? rawBackfill.source - : void 0, - }; - const rawProcessing = isRecord(rawConversation.processing) - ? rawConversation.processing - : {}; - const processing = { - activeTurnId: toOptionalString(rawProcessing.activeTurnId), - lastCompletedAtMs: toOptionalNumber(rawProcessing.lastCompletedAtMs), - pendingAuth: coercePendingAuthState(rawProcessing.pendingAuth), - }; - const rawStats = isRecord(rawConversation.stats) ? rawConversation.stats : {}; - const stats = { - estimatedContextTokens: - toOptionalNumber(rawStats.estimatedContextTokens) ?? - base.stats.estimatedContextTokens, - totalMessageCount: - toOptionalNumber(rawStats.totalMessageCount) ?? messages.length, - compactedMessageCount: - toOptionalNumber(rawStats.compactedMessageCount) ?? 0, - updatedAtMs: - toOptionalNumber(rawStats.updatedAtMs) ?? base.stats.updatedAtMs, - }; - const rawVision = isRecord(rawConversation.vision) - ? rawConversation.vision - : {}; - const rawVisionByFileId = isRecord(rawVision.byFileId) - ? rawVision.byFileId - : {}; - const byFileId = {}; - for (const [fileId, value2] of Object.entries(rawVisionByFileId)) { - if (typeof fileId !== "string" || fileId.trim().length === 0) continue; - if (!isRecord(value2)) continue; - const summary = toOptionalString(value2.summary); - const analyzedAtMs = toOptionalNumber(value2.analyzedAtMs); - if (!summary || !analyzedAtMs) continue; - byFileId[fileId] = { - summary, - analyzedAtMs, - }; - } - return { - schemaVersion: 1, - messages, - compactions, - backfill, - processing, - stats, - vision: { - backfillCompletedAtMs: toOptionalNumber(rawVision.backfillCompletedAtMs), - byFileId, - }, - }; +interface AwaitingContinuationSummary { + conversationId: string; + destination: Destination; + resumeReason: "timeout" | "yield"; + sessionId: string; + state: "awaiting_resume"; + updatedAtMs: number; } -// packages/junior/src/chat/state/ttl.ts -var JUNIOR_THREAD_STATE_TTL_MS = 7 * 24 * 60 * 60 * 1e3; - -// packages/junior/src/chat/actor.ts -import { z as z3 } from "zod"; -import { actorSchema } from "@sentry/junior-plugin-api"; -var exactStoredStringSchema = z3 - .string() - .min(1) - .refine((value) => value === value.trim()); -var storedSlackActorSchema = z3 - .object({ - email: exactStoredStringSchema.optional(), - fullName: exactStoredStringSchema.optional(), - platform: z3.literal("slack").optional(), - slackUserId: exactStoredStringSchema.optional(), - slackUserName: exactStoredStringSchema.optional(), - teamId: exactStoredStringSchema.optional(), - }) - .strict(); -function isSyntheticActorUserId(value) { - return value.toLowerCase() === "unknown"; -} -function parseActorUserId(value) { - if (typeof value !== "string" || value.length === 0) { - return void 0; - } - if (value !== value.trim() || isSyntheticActorUserId(value)) { - return void 0; - } - return value; -} -function parseStoredSlackActor(value) { - const parsed = storedSlackActorSchema.safeParse(value); - if (!parsed.success) { - return void 0; - } - if ( - parsed.data.slackUserId !== void 0 && - !parseActorUserId(parsed.data.slackUserId) - ) { - return void 0; - } - if (parsed.data.teamId !== void 0 && !parseSlackTeamId(parsed.data.teamId)) { - return void 0; - } - if ( - (parsed.data.platform !== void 0 || parsed.data.teamId !== void 0) && - (!parsed.data.platform || !parsed.data.teamId) - ) { - return void 0; - } - return parsed.data; -} - -// packages/junior/src/chat/state/adapter.ts -import { createMemoryState } from "@chat-adapter/state-memory"; -import { createRedisState } from "@chat-adapter/state-redis"; - -// packages/junior/src/chat/state/locks.ts -var ACTIVE_LOCK_TTL_MS = 9e4; - -// packages/junior/src/chat/state/adapter.ts -var ACTIVE_LOCK_HEARTBEAT_MS = 3e4; -var stateAdapter; -var redisStateAdapter; -function createPrefixedStateAdapter(base, prefix) { - const prefixed = (value) => `${prefix}:${value}`; - const unprefixed = (value) => - value.startsWith(`${prefix}:`) ? value.slice(prefix.length + 1) : value; - const prefixLock = (lock) => ({ - ...lock, - threadId: prefixed(lock.threadId), - }); - const unprefixLock = (lock) => ({ - ...lock, - threadId: unprefixed(lock.threadId), - }); - return { - appendToList: (key, value, options) => - base.appendToList(prefixed(key), value, options), - connect: () => base.connect(), - disconnect: () => base.disconnect(), - subscribe: (threadId) => base.subscribe(prefixed(threadId)), - unsubscribe: (threadId) => base.unsubscribe(prefixed(threadId)), - isSubscribed: (threadId) => base.isSubscribed(prefixed(threadId)), - acquireLock: async (threadId, ttlMs) => { - const lock = await base.acquireLock(prefixed(threadId), ttlMs); - return lock ? unprefixLock(lock) : null; - }, - releaseLock: (lock) => base.releaseLock(prefixLock(lock)), - extendLock: async (lock, ttlMs) => { - const prefixedLock = prefixLock(lock); - const extended = await base.extendLock(prefixedLock, ttlMs); - if (extended) { - lock.expiresAt = prefixedLock.expiresAt; - } - return extended; - }, - forceReleaseLock: (threadId) => base.forceReleaseLock(prefixed(threadId)), - enqueue: (threadId, entry, maxSize) => - base.enqueue(prefixed(threadId), entry, maxSize), - dequeue: (threadId) => base.dequeue(prefixed(threadId)), - queueDepth: (threadId) => base.queueDepth(prefixed(threadId)), - get: (key) => base.get(prefixed(key)), - getList: (key) => base.getList(prefixed(key)), - set: (key, value, ttlMs) => base.set(prefixed(key), value, ttlMs), - setIfNotExists: (key, value, ttlMs) => - base.setIfNotExists(prefixed(key), value, ttlMs), - delete: (key) => base.delete(prefixed(key)), - }; -} -function createQueuedStateAdapter(base, options) { - const heartbeats = /* @__PURE__ */ new Map(); - const shouldHeartbeatLock = (ttlMs) => ttlMs === ACTIVE_LOCK_TTL_MS; - const heartbeatKey = (lock) => `${lock.threadId}:${lock.token}`; - const stopHeartbeatByKey = (key) => { - const heartbeat = heartbeats.get(key); - if (!heartbeat) { - return; - } - clearInterval(heartbeat.timer); - heartbeats.delete(key); - }; - const stopHeartbeat = (lock) => { - stopHeartbeatByKey(heartbeatKey(lock)); - }; - const stopHeartbeatsForThread = (threadId) => { - for (const [key, heartbeat] of heartbeats) { - if (heartbeat.lock.threadId === threadId) { - stopHeartbeatByKey(key); - } - } - }; - const stopAllHeartbeats = () => { - for (const key of heartbeats.keys()) { - stopHeartbeatByKey(key); - } - }; - const runHeartbeat = async (key) => { - const heartbeat = heartbeats.get(key); - if (!heartbeat || heartbeat.inFlight) { - return; - } - heartbeat.inFlight = true; - try { - if (Date.now() - heartbeat.startedAtMs >= options.activeLockMaxAgeMs) { - stopHeartbeatByKey(key); - return; - } - const extended = await base.extendLock(heartbeat.lock, heartbeat.ttlMs); - if (!extended) { - stopHeartbeatByKey(key); - return; - } - heartbeat.lock.expiresAt = Date.now() + heartbeat.ttlMs; - } catch { - } finally { - const current = heartbeats.get(key); - if (current === heartbeat) { - current.inFlight = false; - } - } - }; - const startOrUpdateHeartbeat = (lock, ttlMs) => { - const key = heartbeatKey(lock); - const existing = heartbeats.get(key); - if (existing) { - existing.ttlMs = ttlMs; - return; - } - const timer = setInterval(() => { - void runHeartbeat(key); - }, ACTIVE_LOCK_HEARTBEAT_MS); - timer.unref?.(); - heartbeats.set(key, { - inFlight: false, - lock, - startedAtMs: Date.now(), - timer, - ttlMs, - }); - }; - const acquireLock = async (threadId, ttlMs) => { - const lock = await base.acquireLock(threadId, ttlMs); - if (lock && shouldHeartbeatLock(ttlMs)) { - startOrUpdateHeartbeat(lock, ttlMs); - } - return lock; - }; - return { - appendToList: (key, value, options2) => - base.appendToList(key, value, options2), - connect: () => base.connect(), - disconnect: async () => { - stopAllHeartbeats(); - await base.disconnect(); - }, - subscribe: (threadId) => base.subscribe(threadId), - unsubscribe: (threadId) => base.unsubscribe(threadId), - isSubscribed: (threadId) => base.isSubscribed(threadId), - acquireLock, - releaseLock: async (lock) => { - stopHeartbeat(lock); - await base.releaseLock(lock); - }, - extendLock: async (lock, ttlMs) => { - const extended = await base.extendLock(lock, ttlMs); - if (extended) { - lock.expiresAt = Date.now() + ttlMs; - if (shouldHeartbeatLock(ttlMs)) { - startOrUpdateHeartbeat(lock, ttlMs); - } else { - stopHeartbeat(lock); - } - } else { - stopHeartbeat(lock); - } - return extended; - }, - forceReleaseLock: async (threadId) => { - stopHeartbeatsForThread(threadId); - await base.forceReleaseLock(threadId); - }, - enqueue: (threadId, entry, maxSize) => - base.enqueue(threadId, entry, maxSize), - dequeue: (threadId) => base.dequeue(threadId), - queueDepth: (threadId) => base.queueDepth(threadId), - get: (key) => base.get(key), - getList: (key) => base.getList(key), - set: (key, value, ttlMs) => base.set(key, value, ttlMs), - setIfNotExists: (key, value, ttlMs) => - base.setIfNotExists(key, value, ttlMs), - delete: (key) => base.delete(key), - }; -} -function withOptionalPrefix(base, prefix) { - return prefix ? createPrefixedStateAdapter(base, prefix) : base; -} -function createStateAdapter() { - const config = getChatConfig(); - const activeLockMaxAgeMs = config.bot.turnTimeoutMs + ACTIVE_LOCK_TTL_MS; - if (config.state.adapter === "memory") { - redisStateAdapter = void 0; - return createQueuedStateAdapter( - withOptionalPrefix(createMemoryState(), config.state.keyPrefix), - { activeLockMaxAgeMs }, - ); - } - if (!config.state.redisUrl) { - throw new Error("REDIS_URL is required for durable Slack thread state"); - } - const redisState = createRedisState({ - url: config.state.redisUrl, - }); - redisStateAdapter = redisState; - return createQueuedStateAdapter( - withOptionalPrefix(redisState, config.state.keyPrefix), - { activeLockMaxAgeMs }, - ); -} -function getOptionalRedisStateAdapter() { - getStateAdapter(); - return redisStateAdapter; -} -async function getConnectedStateContext() { - const adapter = getStateAdapter(); - await adapter.connect(); - return { - redisStateAdapter: getOptionalRedisStateAdapter(), - stateAdapter: adapter, - }; -} -async function getDefaultRedisStateAdapterFor(adapter) { - if (adapter !== stateAdapter) { - return void 0; - } - const context = await getConnectedStateContext(); - return context.stateAdapter === adapter ? context.redisStateAdapter : void 0; -} -function getStateAdapter() { - if (!stateAdapter) { - stateAdapter = createStateAdapter(); - } - return stateAdapter; -} - -// packages/junior/src/chat/task-execution/state.ts -var CONVERSATION_PREFIX = "junior:conversation"; -var CONVERSATION_SCHEMA_VERSION = 1; -var CONVERSATION_ACTIVITY_INDEX_MAX_LENGTH = 1e4; -var CONVERSATION_INDEX_LOCK_TTL_MS = 1e4; -var CONVERSATION_INDEX_LOCK_WAIT_MS = 2e3; -var CONVERSATION_INDEX_LOCK_RETRY_MS = 25; -var CONVERSATION_MUTATION_LOCK_TTL_MS = 1e4; -var CONVERSATION_MUTATION_WAIT_MS = 1e4; -var CONVERSATION_MUTATION_RETRY_MS = 25; -var InvalidConversationRecordError = class extends Error { - constructor(conversationId) { - super(`Conversation record is invalid for ${conversationId}`); - this.name = "InvalidConversationRecordError"; - } +type RedisCommandClient = { + sendCommand(args: readonly string[]): Promise; }; -var ConversationMutationFencedError = class extends Error { - constructor(conversationId) { - super( - `Conversation mutation lock was lost before write for ${conversationId}`, - ); - this.name = "ConversationMutationFencedError"; - } -}; -var CONVERSATION_BY_ACTIVITY_INDEX_KEY = `${CONVERSATION_PREFIX}:by-activity`; -var CONVERSATION_ACTIVE_INDEX_KEY = `${CONVERSATION_PREFIX}:active`; -function conversationKey(conversationId) { + +function conversationKey(conversationId: string): string { return `${CONVERSATION_PREFIX}:${conversationId}`; } -function indexLockKey(indexKey) { - return `${indexKey}:lock`; -} -function mutationLockKey(conversationId) { - return `${CONVERSATION_PREFIX}:mutation:${conversationId}`; -} -function now() { - return Date.now(); -} -function compareMessages(left, right) { - return ( - left.createdAtMs - right.createdAtMs || - left.receivedAtMs - right.receivedAtMs || - left.inboundMessageId.localeCompare(right.inboundMessageId) - ); -} -function compareIndexDescending(left, right) { - return ( - right.score - left.score || - right.conversationId.localeCompare(left.conversationId) - ); -} -function compareIndexAscending(left, right) { - return ( - left.score - right.score || - left.conversationId.localeCompare(right.conversationId) - ); -} -function uniqueStrings(values) { - return [...new Set(values)]; -} -function normalizeSource(value) { - if ( - value === "api" || - value === "internal" || - value === "local" || - value === "plugin" || - value === "resource_event" || - value === "scheduler" || - value === "slack" - ) { - return value; - } - return void 0; -} -function normalizeExecutionStatus(value) { - if ( - value === "awaiting_resume" || - value === "failed" || - value === "idle" || - value === "pending" || - value === "running" - ) { - return value; - } - return void 0; -} -function normalizeMetadata(value) { - if (!isRecord(value)) { - return void 0; - } - return value; -} -function normalizeInput(value) { - if (!isRecord(value)) { - return void 0; - } - const text = toOptionalString(value.text); - if (!text) { - return void 0; - } - return { - text, - authorId: toOptionalString(value.authorId), - attachments: Array.isArray(value.attachments) - ? [...value.attachments] - : void 0, - metadata: normalizeMetadata(value.metadata), - }; -} -function normalizeMessage(value) { - if (!isRecord(value)) { - return void 0; - } - const conversationId = toOptionalString(value.conversationId); - const inboundMessageId = toOptionalString(value.inboundMessageId); - const source = normalizeSource(value.source); - const destination = parseDestination(value.destination); - const createdAtMs = toOptionalNumber(value.createdAtMs); - const receivedAtMs = toOptionalNumber(value.receivedAtMs); - const input = normalizeInput(value.input); - if ( - !conversationId || - !destination || - !inboundMessageId || - !source || - typeof createdAtMs !== "number" || - typeof receivedAtMs !== "number" || - !input - ) { - return void 0; - } - return { - conversationId, - destination, - inboundMessageId, - source, - createdAtMs, - receivedAtMs, - input, - injectedAtMs: toOptionalNumber(value.injectedAtMs), - attemptCount: toOptionalNumber(value.attemptCount), - }; -} -function normalizeActor(value) { - return parseStoredSlackActor(value); -} -function normalizeLease(value) { - if (!isRecord(value)) { - return void 0; - } - const token = toOptionalString(value.token); - const acquiredAtMs = toOptionalNumber(value.acquiredAtMs); - const lastCheckInAtMs = toOptionalNumber(value.lastCheckInAtMs); - const expiresAtMs = toOptionalNumber(value.expiresAtMs); - if ( - !token || - typeof acquiredAtMs !== "number" || - typeof lastCheckInAtMs !== "number" || - typeof expiresAtMs !== "number" - ) { - return void 0; - } - return { - token, - acquiredAtMs, - lastCheckInAtMs, - expiresAtMs, - }; -} -function normalizeExecution(conversationId, value) { - if (!isRecord(value)) { - return void 0; - } - const status = normalizeExecutionStatus(value.status); - if (!status) { - return void 0; - } - const pendingMessages2 = Array.isArray(value.pendingMessages) - ? value.pendingMessages - .map(normalizeMessage) - .filter((message) => Boolean(message)) - .filter((message) => message.conversationId === conversationId) - .filter((message) => message.injectedAtMs === void 0) - .sort(compareMessages) - : []; - const inboundMessageIds = Array.isArray(value.inboundMessageIds) - ? uniqueStrings( - value.inboundMessageIds - .map((id) => (typeof id === "string" ? id : void 0)) - .filter((id) => Boolean(id)), - ) - : []; - const lease = normalizeLease(value.lease); - const normalizedStatus = - status === "idle" && lease - ? "running" - : status === "idle" && pendingMessages2.length > 0 - ? "pending" - : status; - return { - status: normalizedStatus, - inboundMessageIds: uniqueStrings([ - ...inboundMessageIds, - ...pendingMessages2.map((message) => message.inboundMessageId), - ]), - pendingCount: pendingMessages2.length, - pendingMessages: pendingMessages2, - lease, - lastCheckpointAtMs: toOptionalNumber(value.lastCheckpointAtMs), - lastEnqueuedAtMs: toOptionalNumber(value.lastEnqueuedAtMs), - runId: toOptionalString(value.runId), - updatedAtMs: toOptionalNumber(value.updatedAtMs), - }; -} -function normalizeConversation(conversationId, value) { - if (!isRecord(value) || value.schemaVersion !== CONVERSATION_SCHEMA_VERSION) { - return void 0; - } - const storedConversationId = toOptionalString(value.conversationId); - const createdAtMs = toOptionalNumber(value.createdAtMs); - const lastActivityAtMs = toOptionalNumber(value.lastActivityAtMs); - const updatedAtMs = toOptionalNumber(value.updatedAtMs); - const execution = normalizeExecution(conversationId, value.execution); - const destination = - value.destination === void 0 ? void 0 : parseDestination(value.destination); - if ( - storedConversationId !== conversationId || - typeof createdAtMs !== "number" || - typeof lastActivityAtMs !== "number" || - typeof updatedAtMs !== "number" || - !execution || - (value.destination !== void 0 && !destination) - ) { - return void 0; - } - if ( - execution.pendingMessages.length > 0 && - (!destination || - execution.pendingMessages.some( - (message) => !sameDestination(message.destination, destination), - )) - ) { - return void 0; - } - return { - schemaVersion: CONVERSATION_SCHEMA_VERSION, - conversationId, - createdAtMs, - lastActivityAtMs, - updatedAtMs, - execution, - ...(destination ? { destination } : {}), - ...(toOptionalString(value.title) - ? { title: toOptionalString(value.title) } - : {}), - ...(toOptionalString(value.channelName) - ? { channelName: toOptionalString(value.channelName) } - : {}), - ...(normalizeActor(value.actor) - ? { actor: normalizeActor(value.actor) } - : {}), - ...(normalizeSource(value.source) - ? { source: normalizeSource(value.source) } - : {}), - }; -} -function emptyConversation(args) { - return { - schemaVersion: CONVERSATION_SCHEMA_VERSION, - conversationId: args.conversationId, - createdAtMs: args.nowMs, - lastActivityAtMs: args.nowMs, - updatedAtMs: args.nowMs, - ...(args.destination ? { destination: args.destination } : {}), - ...(args.source ? { source: args.source } : {}), - execution: { - status: "idle", - inboundMessageIds: [], - pendingCount: 0, - pendingMessages: [], - updatedAtMs: args.nowMs, - }, - }; -} -function pendingMessages(conversation) { - return [...conversation.execution.pendingMessages].sort(compareMessages); -} -function isRunnableStatus(status) { - return status !== "failed" && status !== "idle"; -} -function hasRunnableWork(conversation) { - return ( - isRunnableStatus(conversation.execution.status) || - pendingMessages(conversation).length > 0 - ); -} -function executionWithPendingMessages(execution, pending) { - const pendingMessages2 = [...pending].sort(compareMessages); - const status = - execution.status === "idle" && execution.lease - ? "running" - : execution.status === "idle" && pendingMessages2.length > 0 - ? "pending" - : execution.status; - return { - ...execution, - status, - inboundMessageIds: uniqueStrings([ - ...execution.inboundMessageIds, - ...pendingMessages2.map((message) => message.inboundMessageId), - ]), - pendingMessages: pendingMessages2, - pendingCount: pendingMessages2.length, - }; -} -function withExecutionUpdate(conversation, execution, nowMs) { - return { - ...conversation, - updatedAtMs: nowMs, - execution: { - ...executionWithPendingMessages(execution, execution.pendingMessages), - updatedAtMs: nowMs, - }, - }; -} -async function getConnectedState(stateAdapter2) { - const state = stateAdapter2 ?? getStateAdapter(); - await state.connect(); - return state; -} -async function sleep(ms) { - await new Promise((resolve) => { - const timer = setTimeout(resolve, ms); - timer.unref?.(); - }); -} -async function withIndexLock(state, indexKey, callback) { - const startedAtMs = now(); - let lock; - while (true) { - lock = await state.acquireLock( - indexLockKey(indexKey), - CONVERSATION_INDEX_LOCK_TTL_MS, - ); - if (lock) { - break; - } - if (now() - startedAtMs >= CONVERSATION_INDEX_LOCK_WAIT_MS) { - throw new Error( - `Could not acquire conversation index lock for ${indexKey}`, - ); - } - await sleep(CONVERSATION_INDEX_LOCK_RETRY_MS); - } - try { - return await callback(); - } finally { - await state.releaseLock(lock); - } -} -function normalizeIndexEntry(value) { - if (!isRecord(value)) { - return void 0; - } - const conversationId = toOptionalString(value.conversationId); - const score = toOptionalNumber(value.score); - if (!conversationId || typeof score !== "number") { - return void 0; - } - return { conversationId, score }; -} -function uniqueIndexEntries(value) { - if (!Array.isArray(value)) { - return []; - } - const entries = /* @__PURE__ */ new Map(); - for (const item of value) { - const entry = normalizeIndexEntry(item); - if (!entry) { - continue; - } - const existing = entries.get(entry.conversationId); - if (!existing || entry.score > existing.score) { - entries.set(entry.conversationId, entry); - } - } - return [...entries.values()]; -} -function retainedIndexEntries(indexKey, entries) { - if (indexKey === CONVERSATION_BY_ACTIVITY_INDEX_KEY) { - return entries - .sort(compareIndexDescending) - .slice(0, CONVERSATION_ACTIVITY_INDEX_MAX_LENGTH); - } - if (indexKey === CONVERSATION_ACTIVE_INDEX_KEY) { - return entries.sort(compareIndexAscending); - } - throw new Error(`Unknown conversation index ${indexKey}`); -} -function redisIndexKey(indexKey) { - const prefix = getChatConfig().state.keyPrefix; - return [...(prefix ? [prefix] : []), indexKey].join(":"); -} -function parseRedisIndexEntries(values) { - if (!Array.isArray(values)) { - return []; - } - const entries = []; - for (let index = 0; index < values.length; index += 2) { - const conversationId = toOptionalString(values[index]); - const score = - typeof values[index + 1] === "number" - ? values[index + 1] - : Number(values[index + 1]); - if (!conversationId || !Number.isFinite(score)) { - continue; - } - entries.push({ conversationId, score }); - } - return entries; -} -function redisConversationIndexStore(client) { - const upsertBoundedActivityScript = ` - redis.call("ZADD", KEYS[1], ARGV[1], ARGV[2]) - redis.call("PEXPIRE", KEYS[1], ARGV[3]) - local extra = redis.call("ZCARD", KEYS[1]) - tonumber(ARGV[4]) - if extra > 0 then - redis.call("ZREMRANGEBYRANK", KEYS[1], 0, extra - 1) - end - return 1 - `; - return { - async list(args) { - const key = redisIndexKey(args.indexKey); - const limit = args.limit; - const offset = Math.max(0, args.offset ?? 0); - if (limit === 0) { - return []; - } - const values = - args.scoreMax !== void 0 - ? await client.sendCommand([ - "ZRANGEBYSCORE", - key, - "-inf", - String(args.scoreMax), - "WITHSCORES", - ...(limit !== void 0 || offset > 0 - ? ["LIMIT", String(offset), String(limit ?? 1e9)] - : []), - ]) - : await client.sendCommand([ - args.order === "asc" ? "ZRANGE" : "ZREVRANGE", - key, - String(offset), - String(limit === void 0 ? -1 : offset + Math.max(0, limit - 1)), - "WITHSCORES", - ]); - return parseRedisIndexEntries(values); - }, - async remove(args) { - await client.sendCommand([ - "ZREM", - redisIndexKey(args.indexKey), - args.conversationId, - ]); - }, - async upsert(args) { - const key = redisIndexKey(args.indexKey); - if (args.indexKey === CONVERSATION_BY_ACTIVITY_INDEX_KEY) { - await client.sendCommand([ - "EVAL", - upsertBoundedActivityScript, - "1", - key, - String(args.score), - args.conversationId, - String(JUNIOR_THREAD_STATE_TTL_MS), - String(CONVERSATION_ACTIVITY_INDEX_MAX_LENGTH), - ]); - return; - } - if (args.indexKey === CONVERSATION_ACTIVE_INDEX_KEY) { - await client.sendCommand([ - "ZADD", - key, - String(args.score), - args.conversationId, - ]); - await client.sendCommand([ - "PEXPIRE", - key, - String(JUNIOR_THREAD_STATE_TTL_MS), - ]); - return; - } - throw new Error(`Unknown conversation index ${args.indexKey}`); - }, - }; -} -function emulatedConversationIndexStore(state) { - const readIndex = async (indexKey) => - uniqueIndexEntries(await state.get(indexKey)); - const writeIndex = async (indexKey, entries) => { - await state.set(indexKey, entries, JUNIOR_THREAD_STATE_TTL_MS); - }; - return { - async list(args) { - const entries = (await readIndex(args.indexKey)) - .filter((entry) => - args.scoreMax === void 0 ? true : entry.score <= args.scoreMax, - ) - .sort( - args.order === "asc" ? compareIndexAscending : compareIndexDescending, - ); - const offset = Math.max(0, args.offset ?? 0); - return entries.slice( - offset, - args.limit === void 0 ? entries.length : offset + args.limit, - ); - }, - async remove(args) { - await withIndexLock(state, args.indexKey, async () => { - const entries = await readIndex(args.indexKey); - const next = entries.filter( - (entry) => entry.conversationId !== args.conversationId, - ); - if (next.length === entries.length) { - return; - } - await writeIndex(args.indexKey, next); - }); - }, - async upsert(args) { - await withIndexLock(state, args.indexKey, async () => { - const entries = await readIndex(args.indexKey); - const withoutCurrent = entries.filter( - (entry) => entry.conversationId !== args.conversationId, - ); - const next = retainedIndexEntries(args.indexKey, [ - ...withoutCurrent, - { conversationId: args.conversationId, score: args.score }, - ]); - await writeIndex(args.indexKey, next); - }); - }, - }; -} -async function getConversationIndexStore(state) { - const redisStateAdapter2 = await getDefaultRedisStateAdapterFor(state); - if (redisStateAdapter2) { - return redisConversationIndexStore(redisStateAdapter2.getClient()); - } - return emulatedConversationIndexStore(state); -} -async function upsertIndexEntry(args) { - const index = await getConversationIndexStore(args.state); - await index.upsert({ - conversationId: args.conversationId, - indexKey: args.indexKey, - score: args.score, - }); -} -async function removeIndexEntry(args) { - const index = await getConversationIndexStore(args.state); - await index.remove({ - conversationId: args.conversationId, - indexKey: args.indexKey, - }); -} -async function acquireMutationLock(state, conversationId) { - const startedAtMs = now(); - while (true) { - const lock = await state.acquireLock( - mutationLockKey(conversationId), - CONVERSATION_MUTATION_LOCK_TTL_MS, - ); - if (lock) { - return lock; - } - if (now() - startedAtMs >= CONVERSATION_MUTATION_WAIT_MS) { - throw new Error( - `Could not acquire conversation mutation lock for ${conversationId}`, - ); - } - await sleep(CONVERSATION_MUTATION_RETRY_MS); - } -} -async function withConversationMutation(args, callback) { - const state = await getConnectedState(args.state); - const lock = await acquireMutationLock(state, args.conversationId); - try { - return await callback(state, lock); - } finally { - await state.releaseLock(lock); - } -} -async function readConversation(state, conversationId) { - const raw = await state.get(conversationKey(conversationId)); - if (raw == null) { - return void 0; - } - const conversation = normalizeConversation(conversationId, raw); - if (!conversation) { - throw new InvalidConversationRecordError(conversationId); - } - return conversation; -} -async function writeConversation(state, lock, conversation) { - const execution = executionWithPendingMessages( - conversation.execution, - conversation.execution.pendingMessages, - ); - const next = { - ...conversation, - execution, - }; - const fenced = await state.extendLock( - lock, - CONVERSATION_MUTATION_LOCK_TTL_MS, - ); - if (!fenced) { - throw new ConversationMutationFencedError(next.conversationId); - } - await state.set( - conversationKey(next.conversationId), - next, - JUNIOR_THREAD_STATE_TTL_MS, - ); - await upsertIndexEntry({ - state, - indexKey: CONVERSATION_BY_ACTIVITY_INDEX_KEY, - conversationId: next.conversationId, - score: next.lastActivityAtMs, - }); - if (!hasRunnableWork(next)) { - await removeIndexEntry({ - state, - indexKey: CONVERSATION_ACTIVE_INDEX_KEY, - conversationId: next.conversationId, - }); - return; - } - await upsertIndexEntry({ - state, - indexKey: CONVERSATION_ACTIVE_INDEX_KEY, - conversationId: next.conversationId, - score: next.execution.updatedAtMs ?? next.updatedAtMs, - }); -} -function assertSameConversationDestination(args) { - if (!args.current || sameDestination(args.current, args.next)) { - return; - } - throw new Error( - `Conversation destination changed for ${args.conversationId}`, - ); -} -async function getConversation(args) { - const state = await getConnectedState(args.state); - return await readConversation(state, args.conversationId); -} -async function requestConversationWork(args) { - const nowMs = args.nowMs ?? now(); - return await withConversationMutation(args, async (state, lock) => { - const existing = await readConversation(state, args.conversationId); - if (existing) { - assertSameConversationDestination({ - conversationId: args.conversationId, - current: existing.destination, - next: args.destination, - }); - } - const current = - existing ?? - emptyConversation({ - conversationId: args.conversationId, - destination: args.destination, - nowMs, - }); - const status = current.execution.lease ? "awaiting_resume" : "pending"; - await writeConversation( - state, - lock, - withExecutionUpdate( - { - ...current, - destination: current.destination ?? args.destination, - }, - { - ...current.execution, - status, - }, - nowMs, - ), - ); - return { status: existing === void 0 ? "created" : "updated" }; - }); -} -// packages/junior/src/cli/upgrade/migrations/redis-conversation-state.ts -var CONVERSATION_PREFIX2 = "junior:conversation"; -var CONVERSATION_SCHEMA_VERSION2 = 1; -var CONVERSATION_ACTIVITY_INDEX_MAX_LENGTH2 = 1e4; -var CONVERSATION_BY_ACTIVITY_INDEX_KEY2 = `${CONVERSATION_PREFIX2}:by-activity`; -var CONVERSATION_ACTIVE_INDEX_KEY2 = `${CONVERSATION_PREFIX2}:active`; -var LEGACY_CONVERSATION_WORK_PREFIX = "junior:conversation-work"; -var LEGACY_CONVERSATION_WORK_SCHEMA_VERSION = 1; -var LEGACY_CONVERSATION_WORK_INDEX_KEY = `${LEGACY_CONVERSATION_WORK_PREFIX}:index`; -var AGENT_TURN_SESSION_PREFIX = "junior:agent_turn_session"; -var AGENT_TURN_SESSION_INDEX_KEY = `${AGENT_TURN_SESSION_PREFIX}:index`; -var THREAD_STATE_PREFIX = "thread-state"; -function conversationKey2(conversationId) { - return `${CONVERSATION_PREFIX2}:${conversationId}`; -} -function legacyConversationWorkKey(conversationId) { +function legacyConversationWorkKey(conversationId: string): string { return `${LEGACY_CONVERSATION_WORK_PREFIX}:state:${conversationId}`; } -function threadStateKey(conversationId) { + +function threadStateKey(conversationId: string): string { return `${THREAD_STATE_PREFIX}:${conversationId}`; } -function uniqueStrings2(values) { + +function uniqueStrings(values: string[]): string[] { return [...new Set(values)]; } -function uniqueStringValues(value) { + +function uniqueStringValues(value: unknown): string[] { if (!Array.isArray(value)) { return []; } - return uniqueStrings2( + return uniqueStrings( value - .map((value2) => (typeof value2 === "string" ? value2 : void 0)) - .filter((value2) => Boolean(value2)), + .map((value) => (typeof value === "string" ? value : undefined)) + .filter((value): value is string => Boolean(value)), ); } -function normalizeSource2(value) { + +function normalizeSource(value: unknown): Source | undefined { if ( value === "api" || value === "internal" || @@ -1260,42 +103,47 @@ function normalizeSource2(value) { ) { return value; } - return void 0; + return undefined; } -function normalizeMetadata2(value) { + +function normalizeMetadata( + value: unknown, +): Record | undefined { if (!isRecord(value)) { - return void 0; + return undefined; } return value; } -function normalizeInput2(value) { + +function normalizeInput(value: unknown): InboundMessage["input"] | undefined { if (!isRecord(value)) { - return void 0; + return undefined; } const text = toOptionalString(value.text); if (!text) { - return void 0; + return undefined; } return { text, authorId: toOptionalString(value.authorId), attachments: Array.isArray(value.attachments) ? [...value.attachments] - : void 0, - metadata: normalizeMetadata2(value.metadata), + : undefined, + metadata: normalizeMetadata(value.metadata), }; } -function normalizeMessage2(value) { + +function normalizeMessage(value: unknown): InboundMessage | undefined { if (!isRecord(value)) { - return void 0; + return undefined; } const conversationId = toOptionalString(value.conversationId); const inboundMessageId = toOptionalString(value.inboundMessageId); - const source = normalizeSource2(value.source); + const source = normalizeSource(value.source); const destination = parseDestination(value.destination); const createdAtMs = toOptionalNumber(value.createdAtMs); const receivedAtMs = toOptionalNumber(value.receivedAtMs); - const input = normalizeInput2(value.input); + const input = normalizeInput(value.input); if ( !conversationId || !destination || @@ -1305,7 +153,7 @@ function normalizeMessage2(value) { typeof receivedAtMs !== "number" || !input ) { - return void 0; + return undefined; } return { conversationId, @@ -1318,9 +166,10 @@ function normalizeMessage2(value) { injectedAtMs: toOptionalNumber(value.injectedAtMs), }; } -function normalizeLegacyLease(value) { + +function normalizeLegacyLease(value: unknown): Lease | undefined { if (!isRecord(value)) { - return void 0; + return undefined; } const token = toOptionalString(value.leaseToken); const acquiredAtMs = toOptionalNumber(value.acquiredAtMs); @@ -1332,7 +181,7 @@ function normalizeLegacyLease(value) { typeof lastCheckInAtMs !== "number" || typeof expiresAtMs !== "number" ) { - return void 0; + return undefined; } return { token, @@ -1341,19 +190,28 @@ function normalizeLegacyLease(value) { expiresAtMs, }; } -function compareMessages2(left, right) { + +function compareMessages(left: InboundMessage, right: InboundMessage): number { return ( left.createdAtMs - right.createdAtMs || left.receivedAtMs - right.receivedAtMs || left.inboundMessageId.localeCompare(right.inboundMessageId) ); } -function normalizeLegacyConversation(conversationId, value) { + +/** + * Decode legacy schema-v1 conversation-work state into the new conversation + * execution record, preserving pending work and active lease/run intent. + */ +function normalizeLegacyConversation( + conversationId: string, + value: unknown, +): Conversation | undefined { if ( !isRecord(value) || value.schemaVersion !== LEGACY_CONVERSATION_WORK_SCHEMA_VERSION ) { - return void 0; + return undefined; } const storedConversationId = toOptionalString(value.conversationId); const destination = parseDestination(value.destination); @@ -1363,12 +221,12 @@ function normalizeLegacyConversation(conversationId, value) { !destination || typeof updatedAtMs !== "number" ) { - return void 0; + return undefined; } const normalizedMessages = Array.isArray(value.messages) ? value.messages - .map(normalizeMessage2) - .filter((message) => Boolean(message)) + .map(normalizeMessage) + .filter((message): message is InboundMessage => Boolean(message)) : []; if ( normalizedMessages.some( @@ -1377,17 +235,17 @@ function normalizeLegacyConversation(conversationId, value) { !sameDestination(message.destination, destination), ) ) { - return void 0; + return undefined; } const messages = normalizedMessages .filter((message) => message.conversationId === conversationId) - .sort(compareMessages2); - const pendingMessages2 = messages.filter( - (message) => message.injectedAtMs === void 0, + .sort(compareMessages); + const pendingMessages = messages.filter( + (message) => message.injectedAtMs === undefined, ); const lease = normalizeLegacyLease(value.lease); - const needsRun = value.needsRun === true || pendingMessages2.length > 0; - const status = lease + const needsRun = value.needsRun === true || pendingMessages.length > 0; + const status: ExecutionStatus = lease ? value.needsRun === true ? "awaiting_resume" : "running" @@ -1402,8 +260,9 @@ function normalizeLegacyConversation(conversationId, value) { messageTimes.length > 0 ? Math.min(...messageTimes) : updatedAtMs; const lastActivityAtMs = messageTimes.length > 0 ? Math.max(...messageTimes) : updatedAtMs; + return { - schemaVersion: CONVERSATION_SCHEMA_VERSION2, + schemaVersion: CONVERSATION_SCHEMA_VERSION, conversationId, createdAtMs, destination, @@ -1412,18 +271,22 @@ function normalizeLegacyConversation(conversationId, value) { updatedAtMs, execution: { status, - inboundMessageIds: uniqueStrings2( + inboundMessageIds: uniqueStrings( messages.map((message) => message.inboundMessageId), ), - pendingCount: pendingMessages2.length, - pendingMessages: pendingMessages2, + pendingCount: pendingMessages.length, + pendingMessages, ...(lease ? { lease } : {}), lastEnqueuedAtMs: toOptionalNumber(value.lastEnqueuedAtMs), updatedAtMs, }, }; } -function mergeLegacyConversation(existing, legacy) { + +function mergeLegacyConversation( + existing: Conversation, + legacy: Conversation, +): Conversation { if ( existing.destination && legacy.destination && @@ -1434,12 +297,12 @@ function mergeLegacyConversation(existing, legacy) { ); } const knownInboundIds = new Set(existing.execution.inboundMessageIds); - const pendingMessages2 = [ + const pendingMessages = [ ...existing.execution.pendingMessages, ...legacy.execution.pendingMessages.filter( (message) => !knownInboundIds.has(message.inboundMessageId), ), - ].sort(compareMessages2); + ].sort(compareMessages); const legacyIsRunnable = legacy.execution.status !== "idle"; const existingIsIdle = existing.execution.status === "idle"; const legacyLease = @@ -1451,27 +314,28 @@ function mergeLegacyConversation(existing, legacy) { ? { runId: legacy.execution.runId } : {}; const legacyCheckpoint = - existing.execution.lastCheckpointAtMs === void 0 && - legacy.execution.lastCheckpointAtMs !== void 0 + existing.execution.lastCheckpointAtMs === undefined && + legacy.execution.lastCheckpointAtMs !== undefined ? { lastCheckpointAtMs: legacy.execution.lastCheckpointAtMs } : {}; const legacyEnqueue = - existing.execution.lastEnqueuedAtMs === void 0 && - legacy.execution.lastEnqueuedAtMs !== void 0 + existing.execution.lastEnqueuedAtMs === undefined && + legacy.execution.lastEnqueuedAtMs !== undefined ? { lastEnqueuedAtMs: legacy.execution.lastEnqueuedAtMs } : {}; const executionUpdatedAtMs = Math.max( existing.execution.updatedAtMs ?? existing.updatedAtMs, legacy.execution.updatedAtMs ?? legacy.updatedAtMs, ); - const status = + const status: ExecutionStatus = existingIsIdle && legacyIsRunnable ? legacy.execution.lease ? legacy.execution.status : "pending" - : pendingMessages2.length > 0 && existingIsIdle + : pendingMessages.length > 0 && existingIsIdle ? "pending" : existing.execution.status; + return { ...existing, destination: existing.destination ?? legacy.destination, @@ -1489,46 +353,58 @@ function mergeLegacyConversation(existing, legacy) { ...legacyCheckpoint, ...legacyEnqueue, status, - inboundMessageIds: uniqueStrings2([ + inboundMessageIds: uniqueStrings([ ...existing.execution.inboundMessageIds, ...legacy.execution.inboundMessageIds, ]), - pendingCount: pendingMessages2.length, - pendingMessages: pendingMessages2, + pendingCount: pendingMessages.length, + pendingMessages, updatedAtMs: executionUpdatedAtMs, }, }; } -function compareIndexDescending2(left, right) { + +function compareIndexDescending( + left: ConversationIndexEntry, + right: ConversationIndexEntry, +): number { return ( right.score - left.score || right.conversationId.localeCompare(left.conversationId) ); } -function compareIndexAscending2(left, right) { + +function compareIndexAscending( + left: ConversationIndexEntry, + right: ConversationIndexEntry, +): number { return ( left.score - right.score || left.conversationId.localeCompare(right.conversationId) ); } -function normalizeIndexEntry2(value) { + +function normalizeIndexEntry( + value: unknown, +): ConversationIndexEntry | undefined { if (!isRecord(value)) { - return void 0; + return undefined; } const conversationId = toOptionalString(value.conversationId); const score = toOptionalNumber(value.score); if (!conversationId || typeof score !== "number") { - return void 0; + return undefined; } return { conversationId, score }; } -function uniqueIndexEntries2(value) { + +function uniqueIndexEntries(value: unknown): ConversationIndexEntry[] { if (!Array.isArray(value)) { return []; } - const entries = /* @__PURE__ */ new Map(); + const entries = new Map(); for (const item of value) { - const entry = normalizeIndexEntry2(item); + const entry = normalizeIndexEntry(item); if (!entry) { continue; } @@ -1539,9 +415,12 @@ function uniqueIndexEntries2(value) { } return [...entries.values()]; } -function normalizeAwaitingContinuationSummary(value) { + +function normalizeAwaitingContinuationSummary( + value: unknown, +): AwaitingContinuationSummary | undefined { if (!isRecord(value)) { - return void 0; + return undefined; } const conversationId = toOptionalString(value.conversationId); const sessionId = toOptionalString(value.sessionId); @@ -1557,7 +436,7 @@ function normalizeAwaitingContinuationSummary(value) { !destination || typeof updatedAtMs !== "number" ) { - return void 0; + return undefined; } return { conversationId, @@ -1568,8 +447,11 @@ function normalizeAwaitingContinuationSummary(value) { updatedAtMs, }; } -function uniqueAwaitingContinuationSummaries(values) { - const summaries = /* @__PURE__ */ new Map(); + +function uniqueAwaitingContinuationSummaries( + values: unknown[], +): AwaitingContinuationSummary[] { + const summaries = new Map(); for (const value of [...values].reverse()) { const summary = normalizeAwaitingContinuationSummary(value); if (!summary) { @@ -1582,33 +464,44 @@ function uniqueAwaitingContinuationSummaries(values) { } return [...summaries.values()]; } -function redisIndexKey2(indexKey) { - const prefix = getChatConfig().state.keyPrefix; - return [...(prefix ? [prefix] : []), indexKey].join(":"); + +function redisIndexKey(indexKey: string): string { + return migrationRedisKey(indexKey); } -async function upsertEmulatedIndexEntry(args) { - const existing = uniqueIndexEntries2( - await args.stateAdapter.get(args.indexKey), + +async function upsertEmulatedIndexEntry(args: { + conversationId: string; + indexKey: string; + score: number; + stateAdapter: StateAdapter; +}): Promise { + const existing = uniqueIndexEntries( + await args.stateAdapter.get(args.indexKey), ); const next = [ ...existing.filter((entry) => entry.conversationId !== args.conversationId), { conversationId: args.conversationId, score: args.score }, ]; const retained = - args.indexKey === CONVERSATION_BY_ACTIVITY_INDEX_KEY2 + args.indexKey === CONVERSATION_BY_ACTIVITY_INDEX_KEY ? next - .sort(compareIndexDescending2) - .slice(0, CONVERSATION_ACTIVITY_INDEX_MAX_LENGTH2) - : next.sort(compareIndexAscending2); + .sort(compareIndexDescending) + .slice(0, CONVERSATION_ACTIVITY_INDEX_MAX_LENGTH) + : next.sort(compareIndexAscending); await args.stateAdapter.set( args.indexKey, retained, JUNIOR_THREAD_STATE_TTL_MS, ); } -async function removeEmulatedIndexEntry(args) { - const existing = uniqueIndexEntries2( - await args.stateAdapter.get(args.indexKey), + +async function removeEmulatedIndexEntry(args: { + conversationId: string; + indexKey: string; + stateAdapter: StateAdapter; +}): Promise { + const existing = uniqueIndexEntries( + await args.stateAdapter.get(args.indexKey), ); const next = existing.filter( (entry) => entry.conversationId !== args.conversationId, @@ -1618,9 +511,15 @@ async function removeEmulatedIndexEntry(args) { } await args.stateAdapter.set(args.indexKey, next, JUNIOR_THREAD_STATE_TTL_MS); } -async function upsertRedisIndexEntry(args) { - const key = redisIndexKey2(args.indexKey); - if (args.indexKey === CONVERSATION_BY_ACTIVITY_INDEX_KEY2) { + +async function upsertRedisIndexEntry(args: { + client: RedisCommandClient; + conversationId: string; + indexKey: string; + score: number; +}): Promise { + const key = redisIndexKey(args.indexKey); + if (args.indexKey === CONVERSATION_BY_ACTIVITY_INDEX_KEY) { const upsertBoundedActivityScript = ` redis.call("ZADD", KEYS[1], ARGV[1], ARGV[2]) redis.call("PEXPIRE", KEYS[1], ARGV[3]) @@ -1638,10 +537,11 @@ async function upsertRedisIndexEntry(args) { String(args.score), args.conversationId, String(JUNIOR_THREAD_STATE_TTL_MS), - String(CONVERSATION_ACTIVITY_INDEX_MAX_LENGTH2), + String(CONVERSATION_ACTIVITY_INDEX_MAX_LENGTH), ]); return; } + await args.client.sendCommand([ "ZADD", key, @@ -1654,24 +554,36 @@ async function upsertRedisIndexEntry(args) { String(JUNIOR_THREAD_STATE_TTL_MS), ]); } -async function removeRedisIndexEntry(args) { + +async function removeRedisIndexEntry(args: { + client: RedisCommandClient; + conversationId: string; + indexKey: string; +}): Promise { await args.client.sendCommand([ "ZREM", - redisIndexKey2(args.indexKey), + redisIndexKey(args.indexKey), args.conversationId, ]); } -async function upsertConversationIndexes(args) { - const redisClient = args.redisStateAdapter?.getClient(); + +async function upsertConversationIndexes(args: { + conversation: Conversation; + redisStateAdapter?: RedisStateAdapter; + stateAdapter: StateAdapter; +}): Promise { + const redisClient = args.redisStateAdapter?.getClient() as + | RedisCommandClient + | undefined; const upsert = redisClient - ? (indexKey, score) => + ? (indexKey: string, score: number) => upsertRedisIndexEntry({ client: redisClient, conversationId: args.conversation.conversationId, indexKey, score, }) - : (indexKey, score) => + : (indexKey: string, score: number) => upsertEmulatedIndexEntry({ stateAdapter: args.stateAdapter, conversationId: args.conversation.conversationId, @@ -1679,34 +591,39 @@ async function upsertConversationIndexes(args) { score, }); const remove = redisClient - ? (indexKey) => + ? (indexKey: string) => removeRedisIndexEntry({ client: redisClient, conversationId: args.conversation.conversationId, indexKey, }) - : (indexKey) => + : (indexKey: string) => removeEmulatedIndexEntry({ stateAdapter: args.stateAdapter, conversationId: args.conversation.conversationId, indexKey, }); + await upsert( - CONVERSATION_BY_ACTIVITY_INDEX_KEY2, + CONVERSATION_BY_ACTIVITY_INDEX_KEY, args.conversation.lastActivityAtMs, ); if (args.conversation.execution.status === "idle") { - await remove(CONVERSATION_ACTIVE_INDEX_KEY2); + await remove(CONVERSATION_ACTIVE_INDEX_KEY); return; } await upsert( - CONVERSATION_ACTIVE_INDEX_KEY2, + CONVERSATION_ACTIVE_INDEX_KEY, args.conversation.execution.updatedAtMs ?? args.conversation.updatedAtMs, ); } -async function removeLegacyIndexEntry(args) { + +async function removeLegacyIndexEntry(args: { + conversationId: string; + stateAdapter: StateAdapter; +}): Promise { const existing = uniqueStringValues( - await args.stateAdapter.get(LEGACY_CONVERSATION_WORK_INDEX_KEY), + await args.stateAdapter.get(LEGACY_CONVERSATION_WORK_INDEX_KEY), ); const next = existing.filter((id) => id !== args.conversationId); if (next.length === existing.length) { @@ -1722,16 +639,24 @@ async function removeLegacyIndexEntry(args) { JUNIOR_THREAD_STATE_TTL_MS, ); } -async function migrateLegacyConversationWorkRedisState(context) { + +/** + * Move indexed legacy work records into conversation records, update the new + * indexes, then delete each legacy key only after its write or merge succeeds. + */ +async function migrateLegacyConversationWorkRedisState( + context: MigrationContext, +): Promise { const legacyIds = uniqueStringValues( - await context.stateAdapter.get(LEGACY_CONVERSATION_WORK_INDEX_KEY), + await context.stateAdapter.get(LEGACY_CONVERSATION_WORK_INDEX_KEY), ); - const result = { + const result: MigrationResult = { existing: 0, migrated: 0, missing: 0, scanned: legacyIds.length, }; + for (const conversationId of legacyIds) { const legacyKey = legacyConversationWorkKey(conversationId); const raw = await context.stateAdapter.get(legacyKey); @@ -1743,12 +668,14 @@ async function migrateLegacyConversationWorkRedisState(context) { }); continue; } + const conversation = normalizeLegacyConversation(conversationId, raw); if (!conversation) { throw new Error( `Legacy conversation work state is invalid for ${conversationId}`, ); } + const existingConversation = await getConversation({ conversationId, state: context.stateAdapter, @@ -1759,7 +686,7 @@ async function migrateLegacyConversationWorkRedisState(context) { conversation, ); await context.stateAdapter.set( - conversationKey2(conversationId), + conversationKey(conversationId), mergedConversation, JUNIOR_THREAD_STATE_TTL_MS, ); @@ -1776,8 +703,9 @@ async function migrateLegacyConversationWorkRedisState(context) { }); continue; } + await context.stateAdapter.set( - conversationKey2(conversationId), + conversationKey(conversationId), conversation, JUNIOR_THREAD_STATE_TTL_MS, ); @@ -1793,20 +721,31 @@ async function migrateLegacyConversationWorkRedisState(context) { }); result.migrated += 1; } + return result; } -async function isActiveContinuationSummary(context, summary) { + +async function isActiveContinuationSummary( + context: MigrationContext, + summary: AwaitingContinuationSummary, +): Promise { const rawState = - (await context.stateAdapter.get(threadStateKey(summary.conversationId))) ?? - {}; + (await context.stateAdapter.get>( + threadStateKey(summary.conversationId), + )) ?? {}; const conversation = coerceThreadConversationState(rawState); return conversation.processing.activeTurnId === summary.sessionId; } -async function seedAwaitingContinuationConversationWork(context, result) { + +async function seedAwaitingContinuationConversationWork( + context: MigrationContext, + result: MigrationResult, +): Promise { const summaries = uniqueAwaitingContinuationSummaries( await context.stateAdapter.getList(AGENT_TURN_SESSION_INDEX_KEY), ); result.scanned += summaries.length; + for (const summary of summaries) { if (!(await isActiveContinuationSummary(context, summary))) { continue; @@ -1845,27 +784,26 @@ async function seedAwaitingContinuationConversationWork(context, result) { } } } -async function migrateRedisConversationState(context) { + +/** Move legacy Redis conversation work into the retained conversation model. */ +export async function migrateRedisConversationState( + context: MigrationContext, +): Promise { const result = await migrateLegacyConversationWorkRedisState(context); await seedAwaitingContinuationConversationWork(context, result); return result; } -// ../../../../../../private/tmp/0005_redis_conversation_state-entry.ts -var migration = { +const migration = { apiVersion: 1, async up(context) { return await migrateRedisConversationState({ stateAdapter: context.state, - redisStateAdapter: context.redis - ? { getClient: () => ({ sendCommand: context.redis.sendCommand }) } - : void 0, - io: { info: context.log }, + ...(context.redis + ? { redisStateAdapter: { getClient: () => context.redis! } } + : {}), }); }, -}; -var redis_conversation_state_entry_default = migration; -export { - redis_conversation_state_entry_default as default, - migrateRedisConversationState, -}; +} satisfies MigrationV1; + +export default migration; diff --git a/packages/junior/migrations/0006_conversations_to_sql.ts b/packages/junior/migrations/0006_conversations_to_sql.ts index dbbbf7a58..51c62687b 100644 --- a/packages/junior/migrations/0006_conversations_to_sql.ts +++ b/packages/junior/migrations/0006_conversations_to_sql.ts @@ -1,3745 +1,110 @@ -// @ts-nocheck -- frozen migration capsule; do not refactor against current Junior internals. -/* eslint-disable no-unused-vars */ -var __defProp = Object.defineProperty; -var __getOwnPropDesc = Object.getOwnPropertyDescriptor; -var __getOwnPropNames = Object.getOwnPropertyNames; -var __hasOwnProp = Object.prototype.hasOwnProperty; -var __esm = (fn, res) => - function __init() { - return (fn && (res = (0, fn[__getOwnPropNames(fn)[0]])((fn = 0))), res); - }; -var __export = (target, all) => { - for (var name in all) - __defProp(target, name, { get: all[name], enumerable: true }); -}; -var __copyProps = (to, from, except, desc3) => { - if ((from && typeof from === "object") || typeof from === "function") { - for (let key of __getOwnPropNames(from)) - if (!__hasOwnProp.call(to, key) && key !== except) - __defProp(to, key, { - get: () => from[key], - enumerable: - !(desc3 = __getOwnPropDesc(from, key)) || desc3.enumerable, - }); - } - return to; -}; -var __reExport = (target, mod, secondTarget) => ( - __copyProps(target, mod, "default"), - secondTarget && __copyProps(secondTarget, mod, "default") -); - -// migration:config -function getChatConfig() { - return { - bot: botConfig, - state: { keyPrefix: process.env.JUNIOR_STATE_KEY_PREFIX?.trim() || void 0 }, - }; -} -var botConfig; -var init_config = __esm({ - "migration:config"() { - "use strict"; - botConfig = { modelContextWindowTokens: void 0 }; - }, -}); - -// packages/junior/src/chat/slack/timestamp.ts -import { z } from "zod"; -var slackMessageTsSchema; -var init_timestamp = __esm({ - "packages/junior/src/chat/slack/timestamp.ts"() { - "use strict"; - slackMessageTsSchema = z - .string() - .trim() - .regex(/^\d+(?:\.\d+)?$/) - .brand(); - }, -}); - -// packages/junior/src/chat/slack/ids.ts -import { z as z2 } from "zod"; -function parseSlackTeamId(value) { - if (typeof value !== "string") return void 0; - const parsed = slackTeamIdSchema.safeParse(value.trim()); - return parsed.success ? parsed.data : void 0; -} -var slackChannelIdSchema, slackTeamIdSchema, slackUserIdSchema; -var init_ids = __esm({ - "packages/junior/src/chat/slack/ids.ts"() { - "use strict"; - init_timestamp(); - slackChannelIdSchema = z2 - .string() - .regex(/^[CDG][A-Z0-9]+$/) - .brand(); - slackTeamIdSchema = z2 - .string() - .regex(/^T[A-Z0-9]+$/) - .brand(); - slackUserIdSchema = z2 - .string() - .regex(/^[UW][A-Z0-9]+$/) - .brand(); - }, -}); - -// packages/junior/src/chat/destination.ts -import { destinationSchema } from "@sentry/junior-plugin-api"; -function parseDestination(value) { - const parsed = destinationSchema.safeParse(value); - return parsed.success ? parsed.data : void 0; -} -function sameDestination(left, right) { - if (left.platform !== right.platform) { - return false; - } - if (left.platform === "local" && right.platform === "local") { - return left.conversationId === right.conversationId; - } - if (left.platform === "slack" && right.platform === "slack") { - return left.teamId === right.teamId && left.channelId === right.channelId; - } - return false; -} -var init_destination = __esm({ - "packages/junior/src/chat/destination.ts"() { - "use strict"; - init_ids(); - }, -}); - -// packages/junior/src/db/schema/timestamps.ts -import { timestamp } from "drizzle-orm/pg-core"; -var timestamptz; -var init_timestamps = __esm({ - "packages/junior/src/db/schema/timestamps.ts"() { - "use strict"; - timestamptz = (name) => timestamp(name, { withTimezone: true }); - }, -}); - -// packages/junior/src/db/schema/destinations.ts -import { index, jsonb, pgTable, text, uniqueIndex } from "drizzle-orm/pg-core"; -import { z as z3 } from "zod"; -var juniorDestinationKindSchema, juniorDestinations; -var init_destinations = __esm({ - "packages/junior/src/db/schema/destinations.ts"() { - "use strict"; - init_timestamps(); - juniorDestinationKindSchema = z3.enum([ - "channel", - "dm", - "group", - "local_conversation", - "thread", - "web_session", - ]); - juniorDestinations = pgTable( - "junior_destinations", - { - id: text("id").primaryKey(), - provider: text("provider").notNull(), - providerTenantId: text("provider_tenant_id").notNull().default(""), - providerDestinationId: text("provider_destination_id").notNull(), - kind: text("kind").$type().notNull(), - parentDestinationId: text("parent_destination_id"), - displayName: text("display_name"), - visibility: text("visibility").$type().notNull().default("unknown"), - metadata: jsonb("metadata_json"), - createdAt: timestamptz("created_at").notNull(), - updatedAt: timestamptz("updated_at").notNull(), - }, - (table) => [ - uniqueIndex("junior_destinations_provider_destination_uidx").on( - table.provider, - table.providerTenantId, - table.providerDestinationId, - ), - index("junior_destinations_provider_kind_idx").on( - table.provider, - table.kind, - ), - ], - ); - }, -}); - -// packages/junior/src/db/schema/users.ts -import { - text as text2, - pgTable as pgTable2, - uniqueIndex as uniqueIndex2, -} from "drizzle-orm/pg-core"; -var juniorUsers; -var init_users = __esm({ - "packages/junior/src/db/schema/users.ts"() { - "use strict"; - init_timestamps(); - juniorUsers = pgTable2( - "junior_users", - { - id: text2("id").primaryKey(), - primaryEmail: text2("primary_email").notNull(), - primaryEmailNormalized: text2("primary_email_normalized").notNull(), - displayName: text2("display_name"), - createdAt: timestamptz("created_at").notNull(), - updatedAt: timestamptz("updated_at").notNull(), - }, - (table) => [ - uniqueIndex2("junior_users_primary_email_normalized_uidx").on( - table.primaryEmailNormalized, - ), - ], - ); - }, -}); - -// packages/junior/src/db/schema/identities.ts -import { sql } from "drizzle-orm"; -import { - boolean, - index as index2, - jsonb as jsonb2, - pgTable as pgTable3, - text as text3, - uniqueIndex as uniqueIndex3, -} from "drizzle-orm/pg-core"; -var juniorIdentities; -var init_identities = __esm({ - "packages/junior/src/db/schema/identities.ts"() { - "use strict"; - init_timestamps(); - init_users(); - juniorIdentities = pgTable3( - "junior_identities", - { - id: text3("id").primaryKey(), - kind: text3("kind").$type().notNull(), - provider: text3("provider").notNull(), - providerTenantId: text3("provider_tenant_id").notNull().default(""), - providerSubjectId: text3("provider_subject_id").notNull(), - displayName: text3("display_name"), - handle: text3("handle"), - email: text3("email"), - avatarUrl: text3("avatar_url"), - metadata: jsonb2("metadata_json"), - createdAt: timestamptz("created_at").notNull(), - updatedAt: timestamptz("updated_at").notNull(), - userId: text3("user_id").references(() => juniorUsers.id), - emailNormalized: text3("email_normalized"), - emailVerified: boolean("email_verified").notNull().default(false), - }, - (table) => [ - uniqueIndex3("junior_identities_provider_subject_uidx").on( - table.provider, - table.providerTenantId, - table.providerSubjectId, - ), - index2("junior_identities_user_idx").on(table.userId), - index2("junior_identities_verified_email_idx") - .on(table.emailNormalized) - .where( - sql`${table.emailVerified} = true AND ${table.emailNormalized} IS NOT NULL`, - ), - index2("junior_identities_kind_provider_idx").on( - table.kind, - table.provider, - ), - ], - ); - }, -}); - -// packages/junior/src/db/schema/conversations.ts -import { sql as sql2 } from "drizzle-orm"; -import { - index as index3, - integer, - jsonb as jsonb3, - pgTable as pgTable4, - text as text4, -} from "drizzle-orm/pg-core"; -var juniorConversations; -var init_conversations = __esm({ - "packages/junior/src/db/schema/conversations.ts"() { - "use strict"; - init_destinations(); - init_identities(); - init_timestamps(); - juniorConversations = pgTable4( - "junior_conversations", - { - conversationId: text4("conversation_id").primaryKey(), - schemaVersion: integer("schema_version").notNull().default(1), - source: text4("source").$type(), - originType: text4("origin_type"), - originId: text4("origin_id"), - originRunId: text4("origin_run_id"), - destinationId: text4("destination_id").references( - () => juniorDestinations.id, - ), - destination: jsonb3("destination_json").$type(), - actorIdentityId: text4("actor_identity_id").references( - () => juniorIdentities.id, - ), - creatorIdentityId: text4("creator_identity_id").references( - () => juniorIdentities.id, - ), - credentialSubjectIdentityId: text4( - "credential_subject_identity_id", - ).references(() => juniorIdentities.id), - actor: jsonb3("actor_json").$type(), - channelName: text4("channel_name"), - title: text4("title"), - createdAt: timestamptz("created_at").notNull(), - lastActivityAt: timestamptz("last_activity_at").notNull(), - updatedAt: timestamptz("updated_at").notNull(), - executionUpdatedAt: timestamptz("execution_updated_at"), - executionStatus: text4("execution_status").$type().notNull(), - runId: text4("run_id"), - lastCheckpointAt: timestamptz("last_checkpoint_at"), - lastEnqueuedAt: timestamptz("last_enqueued_at"), - // Subagent runs are child conversations; top-level listings filter - // parent_conversation_id IS NULL. Historical advisor children use this too. - parentConversationId: text4("parent_conversation_id").references( - () => juniorConversations.conversationId, - ), - transcriptPurgedAt: timestamptz("transcript_purged_at"), - durationMs: integer("duration_ms").notNull().default(0), - usage: jsonb3("usage_json").$type(), - executionDurationMs: integer("execution_duration_ms") - .notNull() - .default(0), - executionUsage: jsonb3("execution_usage_json").$type(), - metricRunId: text4("metric_run_id"), - }, - (table) => [ - index3("junior_conversations_last_activity_idx").on( - table.lastActivityAt.desc(), - table.conversationId, - ), - index3("junior_conversations_active_idx") - .using( - "btree", - sql2`coalesce(${table.executionUpdatedAt}, ${table.updatedAt})`, - table.conversationId, - ) - .where(sql2`${table.executionStatus} <> 'idle'`), - index3("junior_conversations_destination_activity_idx").on( - table.destinationId, - table.lastActivityAt.desc(), - ), - index3("junior_conversations_actor_activity_idx").on( - table.actorIdentityId, - table.lastActivityAt.desc(), - ), - index3("junior_conversations_origin_idx").on( - table.originType, - table.originId, - table.lastActivityAt.desc(), - ), - index3("junior_conversations_parent_idx").on( - table.parentConversationId, - ), - ], - ); - }, -}); - -// packages/junior/src/db/schema/agent-steps.ts -import { - index as index4, - integer as integer2, - jsonb as jsonb4, - pgTable as pgTable5, - primaryKey, - text as text5, -} from "drizzle-orm/pg-core"; -var juniorAgentSteps; -var init_agent_steps = __esm({ - "packages/junior/src/db/schema/agent-steps.ts"() { - "use strict"; - init_conversations(); - init_timestamps(); - juniorAgentSteps = pgTable5( - "junior_agent_steps", - { - conversationId: text5("conversation_id") - .notNull() - .references(() => juniorConversations.conversationId), - seq: integer2("seq").notNull(), - contextEpoch: integer2("context_epoch").notNull(), - type: text5("type").notNull(), - role: text5("role"), - payload: jsonb4("payload").$type().notNull(), - createdAt: timestamptz("created_at").notNull(), - }, - (table) => [ - primaryKey({ columns: [table.conversationId, table.seq] }), - index4("junior_agent_steps_epoch_idx").on( - table.conversationId, - table.contextEpoch, - table.seq, - ), - ], - ); - }, -}); - -// packages/junior/src/db/schema/conversation-messages.ts -import { sql as sql3 } from "drizzle-orm"; +import type { MigrationStateV1, MigrationV1 } from "@sentry/junior-migrations"; import { - index as index5, - jsonb as jsonb5, - pgTable as pgTable6, - primaryKey as primaryKey2, - text as text6, -} from "drizzle-orm/pg-core"; -var juniorConversationMessages; -var init_conversation_messages = __esm({ - "packages/junior/src/db/schema/conversation-messages.ts"() { - "use strict"; - init_conversations(); - init_identities(); - init_timestamps(); - juniorConversationMessages = pgTable6( - "junior_conversation_messages", - { - conversationId: text6("conversation_id") - .notNull() - .references(() => juniorConversations.conversationId), - messageId: text6("message_id").notNull(), - role: text6("role").$type().notNull(), - authorIdentityId: text6("author_identity_id").references( - () => juniorIdentities.id, - ), - text: text6("text").notNull(), - meta: jsonb5("meta").$type(), - repliedAt: timestamptz("replied_at"), - createdAt: timestamptz("created_at").notNull(), - }, - (table) => [ - primaryKey2({ columns: [table.conversationId, table.messageId] }), - index5("junior_conversation_messages_activity_idx").on( - table.conversationId, - table.createdAt, - ), - index5("junior_conversation_messages_search_idx").using( - "gin", - sql3`to_tsvector('english', ${table.text})`, - ), - ], - ); - }, -}); - -// packages/junior/src/db/schema.ts -var init_schema = __esm({ - "packages/junior/src/db/schema.ts"() { - "use strict"; - init_agent_steps(); - init_conversation_messages(); - init_conversations(); - init_destinations(); - init_identities(); - init_users(); - }, -}); + addAgentTurnUsage, + createMigrationSqlStore, + createMigrationStateConversationStore, + listMigrationTurnSessionSummaries, +} from "@sentry/junior/migration-helpers/v1"; + +const CONVERSATION_BACKFILL_LIMIT = 10_000; + +type MigrationResult = { + existing: number; + migrated: number; + missing: number; + scanned: number; +}; -// packages/junior/src/chat/identities/identity.ts -function normalizeIdentityEmail(email) { - const normalized = email?.trim().toLowerCase(); - return normalized || void 0; -} -var init_identity = __esm({ - "packages/junior/src/chat/identities/identity.ts"() { - "use strict"; - }, -}); +type ConversationTarget = Pick< + ReturnType, + "backfillConversation" | "listByActivity" +>; -// packages/junior/src/chat/identities/sql.ts -import { randomUUID } from "node:crypto"; -import { and, eq, sql as sql4 } from "drizzle-orm"; -function dateFromMs(ms) { - return new Date(ms); -} -function tenantId(value) { - return value ?? ""; -} -async function upsertUser(executor, args) { - const rows = await executor - .db() - .insert(juniorUsers) - .values({ - id: randomUUID(), - primaryEmail: args.email, - primaryEmailNormalized: args.emailNormalized, - displayName: args.displayName ?? null, - createdAt: dateFromMs(args.nowMs), - updatedAt: dateFromMs(args.nowMs), - }) - .onConflictDoUpdate({ - target: juniorUsers.primaryEmailNormalized, - set: { - displayName: sql4`coalesce(${juniorUsers.displayName}, excluded.display_name)`, - updatedAt: sql4`excluded.updated_at`, - }, - }) - .returning({ id: juniorUsers.id }); - const id = rows[0]?.id; - if (!id) { - throw new Error("User identity upsert returned no row"); - } - return id; -} -async function existingIdentity(executor, identity) { - const rows = await executor - .db() - .select() - .from(juniorIdentities) - .where( - and( - eq(juniorIdentities.provider, identity.provider), - eq( - juniorIdentities.providerTenantId, - tenantId(identity.providerTenantId), - ), - eq(juniorIdentities.providerSubjectId, identity.providerSubjectId), - ), - ); - return rows[0]; -} -async function upsertIdentity(executor, identity, nowMs = Date.now()) { - const emailNormalized = normalizeIdentityEmail(identity.email); - const email = emailNormalized - ? identity.email?.trim() || emailNormalized - : void 0; - const existing = await existingIdentity(executor, identity); - const userEmailNormalized = - existing?.emailVerified && existing.emailNormalized - ? existing.emailNormalized - : identity.emailVerified - ? emailNormalized - : void 0; - const userEmail = - existing?.emailVerified && existing.email - ? existing.email - : (email ?? userEmailNormalized); - const verifiedUserId = - identity.kind === "user" && userEmailNormalized - ? await upsertUser(executor, { - email: userEmail ?? userEmailNormalized, - emailNormalized: userEmailNormalized, - nowMs, - ...(existing?.displayName || identity.displayName - ? { displayName: existing?.displayName ?? identity.displayName } - : {}), - }) - : void 0; - if ( - existing?.userId && - verifiedUserId && - existing.userId !== verifiedUserId - ) { - throw new Error("Identity verified email conflicts with linked user"); - } - const userId = existing?.userId ?? verifiedUserId; - const rows = await executor - .db() - .insert(juniorIdentities) - .values({ - id: randomUUID(), - userId: userId ?? null, - kind: identity.kind, - provider: identity.provider, - providerTenantId: tenantId(identity.providerTenantId), - providerSubjectId: identity.providerSubjectId, - displayName: identity.displayName ?? null, - handle: identity.handle ?? null, - email: email ?? null, - emailNormalized: emailNormalized ?? null, - emailVerified: Boolean(identity.emailVerified && emailNormalized), - avatarUrl: null, - metadata: identity.metadata ?? null, - createdAt: dateFromMs(nowMs), - updatedAt: dateFromMs(nowMs), - }) - .onConflictDoUpdate({ - target: [ - juniorIdentities.provider, - juniorIdentities.providerTenantId, - juniorIdentities.providerSubjectId, - ], - set: { - kind: sql4`excluded.kind`, - userId: sql4`coalesce(${juniorIdentities.userId}, excluded.user_id)`, - displayName: sql4`coalesce(${juniorIdentities.displayName}, excluded.display_name)`, - handle: sql4`coalesce(${juniorIdentities.handle}, excluded.handle)`, - email: sql4`case when ${juniorIdentities.emailVerified} then coalesce(${juniorIdentities.email}, excluded.email) when excluded.email_verified then excluded.email else coalesce(${juniorIdentities.email}, excluded.email) end`, - emailNormalized: sql4`case when ${juniorIdentities.emailVerified} then coalesce(${juniorIdentities.emailNormalized}, excluded.email_normalized) when excluded.email_verified then excluded.email_normalized else coalesce(${juniorIdentities.emailNormalized}, excluded.email_normalized) end`, - emailVerified: sql4`${juniorIdentities.emailVerified} OR excluded.email_verified`, - avatarUrl: sql4`coalesce(${juniorIdentities.avatarUrl}, excluded.avatar_url)`, - metadata: sql4`coalesce(${juniorIdentities.metadata}, excluded.metadata_json)`, - updatedAt: sql4`excluded.updated_at`, - }, - }) - .returning({ - id: juniorIdentities.id, - userId: juniorIdentities.userId, - }); - const row = rows[0]; - if (!row) { - throw new Error("Identity upsert returned no row"); - } - return { - id: row.id, - ...(row.userId ? { userId: row.userId } : {}), - }; -} -var init_sql = __esm({ - "packages/junior/src/chat/identities/sql.ts"() { - "use strict"; - init_schema(); - init_identity(); +export async function migrateConversationsToSql( + context: { + io?: { info(message: string): void }; + stateAdapter: MigrationStateV1; }, -}); - -// packages/junior/src/chat/conversations/sql/store.ts -import { randomUUID as randomUUID2 } from "node:crypto"; -import { - and as and2, - asc, - desc, - eq as eq2, - isNull, - sql as sql5, -} from "drizzle-orm"; -function now() { - return Date.now(); -} -function dateFromMs2(ms) { - return new Date(ms); -} -function msFromDate(value) { - if (value === null || value === void 0) { - return void 0; - } - const date = value instanceof Date ? value : new Date(value); - return date.getTime(); -} -function requiredMsFromDate(value) { - const ms = msFromDate(value); - if (typeof ms !== "number" || Number.isNaN(ms)) { - throw new Error("Conversation record timestamp is invalid"); - } - return ms; -} -function tenantId2(value) { - return value ?? ""; -} -function sourceFromValue(value) { - if ( - value === "api" || - value === "internal" || - value === "local" || - value === "plugin" || - value === "resource_event" || - value === "scheduler" || - value === "slack" - ) { - return value; - } - return void 0; -} -function identityFromActor(actor) { - if (!actor?.slackUserId) { - return void 0; - } - return { - kind: "user", - provider: "slack", - providerTenantId: actor.teamId, - providerSubjectId: actor.slackUserId, - ...(actor.fullName ? { displayName: actor.fullName } : {}), - ...(actor.slackUserName ? { handle: actor.slackUserName } : {}), - ...(actor.email ? { email: actor.email, emailVerified: true } : {}), - metadata: { platform: "slack" }, - }; -} -function systemIdentityFromSource(source) { - if (source === "scheduler") { - return { - kind: "system", - provider: "junior", - providerSubjectId: "scheduler", - displayName: "Junior Scheduler", - }; - } - if (source === "local") { - return { - kind: "system", - provider: "junior", - providerSubjectId: "local-cli", - displayName: "Local CLI", - }; - } - if (source === "resource_event") { - return { - kind: "system", - provider: "junior", - providerSubjectId: "resource-event", - displayName: "Resource Event", - }; - } - return void 0; -} -function actorIdentityForConversation(conversation) { - return ( - identityFromActor(conversation.actor) ?? - systemIdentityFromSource(conversation.source) + options: { batchSize?: number; target: ConversationTarget }, +): Promise { + const source = createMigrationStateConversationStore(context.stateAdapter); + const target = options.target; + const limit = Math.max(1, options.batchSize ?? CONVERSATION_BACKFILL_LIMIT); + const [stateConversations, sqlConversations] = await Promise.all([ + source.listByActivity({ limit }), + target.listByActivity({ limit }), + ]); + const byId = new Map( + sqlConversations.map((conversation) => [ + conversation.conversationId, + conversation, + ]), ); -} -function originTypeFromSource(source) { - return source; -} -function localWorkspaceFromConversationId(conversationId) { - const match = /^local:([^:]+):/.exec(conversationId); - return match?.[1]; -} -function destinationUpsertFromDestination(args) { - const { destination } = args; - if (!destination) { - return void 0; - } - if (destination.platform === "slack") { - const channelId = destination.channelId; - const channelKind = channelId.startsWith("D") - ? "dm" - : channelId.startsWith("G") - ? "group" - : "channel"; - return { - kind: channelKind, - provider: "slack", - providerTenantId: destination.teamId, - providerDestinationId: channelId, - refreshVisibility: args.visibility !== void 0, - visibility: args.visibility ?? "private", - ...(args.channelName ? { displayName: args.channelName } : {}), - metadata: { platform: "slack" }, - }; - } - return { - kind: "local_conversation", - provider: "local", - providerTenantId: - localWorkspaceFromConversationId(destination.conversationId) ?? - localWorkspaceFromConversationId(args.conversationId ?? ""), - providerDestinationId: destination.conversationId, - refreshVisibility: true, - visibility: "direct", - metadata: { platform: "local" }, - }; -} -function executionStatusFromValue(value) { - if ( - value === "awaiting_resume" || - value === "failed" || - value === "idle" || - value === "pending" || - value === "running" - ) { - return value; - } - throw new Error("Conversation record execution status is invalid"); -} -function privacyFromRow(row) { - if (row.destination === null) { - return void 0; - } - return row.destination.visibility === "public" ? "public" : "private"; -} -function actorFromIdentityRow(identity) { - if (!identity) { - return void 0; - } - if (identity.provider !== "slack") { - return void 0; - } - return { - ...(identity.emailNormalized - ? { email: identity.emailNormalized } - : identity.email - ? { email: identity.email } - : {}), - ...(identity.displayName ? { fullName: identity.displayName } : {}), - platform: "slack", - slackUserId: identity.providerSubjectId, - ...(identity.handle ? { slackUserName: identity.handle } : {}), - ...(identity.providerTenantId ? { teamId: identity.providerTenantId } : {}), - }; -} -function destinationFromRow(destination) { - const value = - destination?.provider === "slack" - ? { - platform: "slack", - teamId: destination.providerTenantId, - channelId: destination.providerDestinationId, - } - : destination?.provider === "local" + for (const conversation of stateConversations) { + const existing = byId.get(conversation.conversationId); + const existingExecutionAt = + existing?.execution.updatedAtMs ?? existing?.updatedAtMs ?? 0; + const stateExecutionAt = + conversation.execution.updatedAtMs ?? conversation.updatedAtMs; + if (!existing || stateExecutionAt >= existingExecutionAt) { + byId.set(conversation.conversationId, conversation); + } + } + const conversations = [...byId.values()] + .sort( + (left, right) => + right.lastActivityAtMs - left.lastActivityAtMs || + left.conversationId.localeCompare(right.conversationId), + ) + .slice(0, limit); + const summaries = await listMigrationTurnSessionSummaries( + context.stateAdapter, + conversations.map((conversation) => conversation.conversationId), + ); + for (const conversation of conversations) { + const conversationSummaries = + summaries.get(conversation.conversationId) ?? []; + const executionSummary = conversation.execution.runId + ? conversationSummaries.find( + (summary) => summary.sessionId === conversation.execution.runId, + ) + : undefined; + await target.backfillConversation( + conversation, + conversationSummaries.length > 0 ? { - platform: "local", - conversationId: destination.providerDestinationId, - } - : void 0; - return parseDestination(value); -} -function conversationFromRow(readRow) { - const row = readRow.conversation; - const visibility = privacyFromRow(readRow); - if (row.schemaVersion !== 1) { - throw new Error("Conversation record schema version is invalid"); - } - if (row.destination !== null && readRow.destination === null) { - throw new Error("Conversation legacy destination is not migrated"); - } - if (row.actor !== null && readRow.actorIdentity === null) { - throw new Error("Conversation legacy actor is not migrated"); - } - const destination = destinationFromRow(readRow.destination); - const actor = actorFromIdentityRow(readRow.actorIdentity); - if (readRow.destination !== null && !destination) { - throw new Error("Conversation record destination is invalid"); - } - const source = - row.source === void 0 || row.source === null - ? void 0 - : sourceFromValue(row.source); - if (row.source !== void 0 && row.source !== null && !source) { - throw new Error("Conversation record source is invalid"); - } - const execution = { - status: executionStatusFromValue(row.executionStatus), - lastCheckpointAtMs: msFromDate(row.lastCheckpointAt), - lastEnqueuedAtMs: msFromDate(row.lastEnqueuedAt), - ...(row.runId ? { runId: row.runId } : {}), - updatedAtMs: - msFromDate(row.executionUpdatedAt) ?? requiredMsFromDate(row.updatedAt), - }; - return { - schemaVersion: 1, - conversationId: row.conversationId, - createdAtMs: requiredMsFromDate(row.createdAt), - lastActivityAtMs: requiredMsFromDate(row.lastActivityAt), - updatedAtMs: requiredMsFromDate(row.updatedAt), - execution, - ...(destination ? { destination } : {}), - ...(actor ? { actor } : {}), - ...(row.channelName ? { channelName: row.channelName } : {}), - ...(source ? { source } : {}), - ...(row.title ? { title: row.title } : {}), - ...(msFromDate(row.transcriptPurgedAt) !== void 0 - ? { transcriptPurgedAtMs: msFromDate(row.transcriptPurgedAt) } - : {}), - ...(visibility ? { visibility } : {}), - }; -} -function emptyConversation(args) { - return { - schemaVersion: 1, - conversationId: args.conversationId, - createdAtMs: args.nowMs, - lastActivityAtMs: args.nowMs, - updatedAtMs: args.nowMs, - ...(args.destination ? { destination: args.destination } : {}), - ...(args.source ? { source: args.source } : {}), - execution: { - status: "idle", - updatedAtMs: args.nowMs, - }, - }; -} -function assertSameConversationDestination(args) { - if (!args.current || sameDestination(args.current, args.next)) { - return; - } - throw new Error( - `Conversation destination changed for ${args.conversationId}`, - ); -} -function mergeActor(current, next) { - if (!current) { - return next; - } - if (!next) { - return current; - } - if ( - current.slackUserId && - next.slackUserId && - current.slackUserId !== next.slackUserId - ) { - return current; - } - return { - ...current, - ...((current.email ?? next.email) - ? { email: current.email ?? next.email } - : {}), - ...((current.fullName ?? next.fullName) - ? { fullName: current.fullName ?? next.fullName } - : {}), - ...((current.platform ?? next.platform) - ? { platform: current.platform ?? next.platform } - : {}), - ...((current.slackUserId ?? next.slackUserId) - ? { slackUserId: current.slackUserId ?? next.slackUserId } - : {}), - ...((current.slackUserName ?? next.slackUserName) - ? { slackUserName: current.slackUserName ?? next.slackUserName } - : {}), - ...((current.teamId ?? next.teamId) - ? { teamId: current.teamId ?? next.teamId } - : {}), - }; -} -function tokenTotal(usage) { - if (!usage) return 0; - if (usage.totalTokens !== void 0) return usage.totalTokens; - return ( - (usage.inputTokens ?? 0) + - (usage.outputTokens ?? 0) + - (usage.cachedInputTokens ?? 0) + - (usage.cacheCreationTokens ?? 0) - ); -} -function updateConversationUsage(args) { - const usage = { - totalTokens: - tokenTotal(args.current) - - tokenTotal(args.previousExecution) + - tokenTotal(args.nextExecution), - }; - if ( - args.current?.reasoningTokens !== void 0 || - args.previousExecution?.reasoningTokens !== void 0 || - args.nextExecution.reasoningTokens !== void 0 - ) { - usage.reasoningTokens = - (args.current?.reasoningTokens ?? 0) - - (args.previousExecution?.reasoningTokens ?? 0) + - (args.nextExecution.reasoningTokens ?? 0); - } - const costFields = ["input", "output", "cacheRead", "cacheWrite", "total"]; - const cost = {}; - for (const field of costFields) { - if ( - args.current?.cost?.[field] === void 0 && - args.previousExecution?.cost?.[field] === void 0 && - args.nextExecution.cost?.[field] === void 0 - ) { - continue; - } - cost[field] = - Math.round( - ((args.current?.cost?.[field] ?? 0) - - (args.previousExecution?.cost?.[field] ?? 0) + - (args.nextExecution.cost?.[field] ?? 0)) * - 1e12, - ) / 1e12; - } - if (Object.keys(cost).length > 0) usage.cost = cost; - return usage; -} -function createSqlStore(executor) { - return new SqlStore(executor); -} -var CONVERSATION_MUTATION_LOCK_PREFIX, SqlStore; -var init_store = __esm({ - "packages/junior/src/chat/conversations/sql/store.ts"() { - "use strict"; - init_destination(); - init_sql(); - init_schema(); - CONVERSATION_MUTATION_LOCK_PREFIX = "junior_conversation"; - SqlStore = class { - constructor(executor) { - this.executor = executor; - } - executor; - async get(args) { - const row = await this.readConversationRow(args.conversationId); - if (!row) { - return void 0; - } - return conversationFromRow(row); - } - async recordActivity(args) { - const nowMs = args.nowMs ?? now(); - const activityAtMs = args.activityAtMs ?? nowMs; - await this.withConversationMutation(args.conversationId, async () => { - const existing = await this.get({ - conversationId: args.conversationId, - }); - if (existing && args.destination) { - assertSameConversationDestination({ - conversationId: args.conversationId, - current: existing.destination, - next: args.destination, - }); - } - const current = - existing ?? - emptyConversation({ - conversationId: args.conversationId, - destination: args.destination, - nowMs, - source: args.source, - }); - const { visibility: _persisted, ...currentWithoutVisibility } = - current; - await this.upsertConversation({ - conversation: { - ...currentWithoutVisibility, - destination: current.destination ?? args.destination, - source: current.source ?? args.source, - channelName: current.channelName ?? args.channelName, - actor: mergeActor(current.actor, args.actor), - title: current.title ?? args.title, - lastActivityAtMs: Math.max( - current.lastActivityAtMs, - activityAtMs, - ), - updatedAtMs: nowMs, - execution: { - ...current.execution, - updatedAtMs: current.execution.updatedAtMs ?? nowMs, - }, - ...(args.visibility ? { visibility: args.visibility } : {}), - }, - }); - }); - } - async recordExecution(args) { - await this.withConversationMutation(args.conversationId, async () => { - const existingRow = await this.readConversationRow( - args.conversationId, - ); - const existing = existingRow - ? conversationFromRow(existingRow) - : void 0; - const incomingExecutionAt = - args.execution.updatedAtMs ?? args.updatedAtMs; - const existingExecutionAt = - existing?.execution.updatedAtMs ?? existing?.updatedAtMs ?? 0; - const incomingIsFresh = incomingExecutionAt >= existingExecutionAt; - const metricRunId = existingRow?.conversation.metricRunId; - const sameRun = - Boolean(metricRunId) && metricRunId === args.execution.runId; - const execution = incomingIsFresh - ? args.execution - : (existing?.execution ?? args.execution); - await this.upsertConversation({ - conversation: { - schemaVersion: 1, - conversationId: args.conversationId, - createdAtMs: args.createdAtMs, - lastActivityAtMs: args.lastActivityAtMs, - updatedAtMs: args.updatedAtMs, - ...(args.channelName ? { channelName: args.channelName } : {}), - ...(args.destination ? { destination: args.destination } : {}), - ...(args.actor ? { actor: args.actor } : {}), - ...(args.source ? { source: args.source } : {}), - ...(args.title ? { title: args.title } : {}), - ...(args.visibility ? { visibility: args.visibility } : {}), - execution, - }, - }); - if (incomingIsFresh && args.metrics) { - const row = existingRow?.conversation; - const usage = args.metrics.usage - ? updateConversationUsage({ - current: row?.usage ?? void 0, - previousExecution: sameRun - ? (row?.executionUsage ?? void 0) - : void 0, - nextExecution: args.metrics.usage, - }) - : (row?.usage ?? void 0); - await this.executor - .db() - .update(juniorConversations) - .set({ - durationMs: - (row?.durationMs ?? 0) - - (sameRun ? (row?.executionDurationMs ?? 0) : 0) + - args.metrics.durationMs, - usage: usage ?? null, - metricRunId: args.execution.runId ?? null, - executionDurationMs: args.metrics.durationMs, - executionUsage: - args.metrics.usage ?? - (sameRun ? (row?.executionUsage ?? null) : null), - }) - .where( - eq2(juniorConversations.conversationId, args.conversationId), - ); - } - }); - } - async ensureChildConversation(args) { - const at = dateFromMs2(args.nowMs ?? now()); - await this.executor - .db() - .insert(juniorConversations) - .values({ - conversationId: args.parentConversationId, - schemaVersion: 1, - createdAt: at, - lastActivityAt: at, - updatedAt: at, - executionStatus: "idle", - }) - .onConflictDoNothing({ target: juniorConversations.conversationId }); - await this.executor - .db() - .insert(juniorConversations) - .values({ - conversationId: args.conversationId, - schemaVersion: 1, - parentConversationId: args.parentConversationId, - createdAt: at, - lastActivityAt: at, - updatedAt: at, - executionStatus: "idle", - }) - .onConflictDoUpdate({ - target: juniorConversations.conversationId, - set: { - parentConversationId: sql5`coalesce(${juniorConversations.parentConversationId}, excluded.parent_conversation_id)`, - }, - }); - } - /** Copy one conversation record and retained metrics into SQL during backfill. */ - async backfillConversation(sourceConversation, metrics) { - const { visibility: _visibility, ...conversation } = sourceConversation; - await this.withConversationMutation( - conversation.conversationId, - async () => { - const existing = await this.get({ - conversationId: conversation.conversationId, - }); - const sourceExecutionAtMs = - conversation.execution.updatedAtMs ?? conversation.updatedAtMs; - const existingExecutionAtMs = - existing === void 0 - ? void 0 - : (existing.execution.updatedAtMs ?? existing.updatedAtMs); - const refreshExecutionFromSource = - existingExecutionAtMs === void 0 || - sourceExecutionAtMs >= existingExecutionAtMs; - const mergedConversation = existing - ? { - ...conversation, - channelName: existing.channelName ?? conversation.channelName, - createdAtMs: Math.min( - existing.createdAtMs, - conversation.createdAtMs, - ), - destination: existing.destination ?? conversation.destination, - lastActivityAtMs: Math.max( - existing.lastActivityAtMs, - conversation.lastActivityAtMs, - ), - actor: existing.actor ?? conversation.actor, - source: existing.source ?? conversation.source, - title: existing.title ?? conversation.title, - updatedAtMs: Math.max( - existing.updatedAtMs, - conversation.updatedAtMs, - ), - execution: refreshExecutionFromSource - ? conversation.execution - : existing.execution, - } - : conversation; - await this.upsertConversation({ conversation: mergedConversation }); - if (metrics) { - await this.executor - .db() - .update(juniorConversations) - .set({ - durationMs: metrics.durationMs, - usage: metrics.usage ?? null, - ...(refreshExecutionFromSource - ? { - metricRunId: conversation.execution.runId ?? null, - executionDurationMs: metrics.executionDurationMs, - executionUsage: metrics.executionUsage ?? null, - } - : {}), - }) - .where( - and2( - eq2( - juniorConversations.conversationId, - conversation.conversationId, - ), - eq2(juniorConversations.durationMs, 0), - isNull(juniorConversations.usage), - ), - ); - } - }, - ); - } - async listByActivity(args = {}) { - const rows = await this.executor - .db() - .select({ - conversation: juniorConversations, - destination: juniorDestinations, - actorIdentity: juniorIdentities, - }) - .from(juniorConversations) - .leftJoin( - juniorDestinations, - eq2(juniorDestinations.id, juniorConversations.destinationId), - ) - .leftJoin( - juniorIdentities, - eq2(juniorIdentities.id, juniorConversations.actorIdentityId), - ) - .where(isNull(juniorConversations.parentConversationId)) - .orderBy( - desc(juniorConversations.lastActivityAt), - asc(juniorConversations.conversationId), - ) - .limit(Math.max(0, args.limit ?? 1e4)) - .offset(Math.max(0, args.offset ?? 0)); - const conversations = []; - for (const row of rows) { - conversations.push(conversationFromRow(row)); - } - return conversations; - } - async getDestinationVisibility(args) { - const rows = await this.executor - .db() - .select({ - visibility: juniorDestinations.visibility, - }) - .from(juniorDestinations) - .where( - and2( - eq2(juniorDestinations.provider, args.provider), - eq2( - juniorDestinations.providerTenantId, - tenantId2(args.providerTenantId), - ), - eq2( - juniorDestinations.providerDestinationId, - args.providerDestinationId, + durationMs: conversationSummaries.reduce( + (total, summary) => total + summary.cumulativeDurationMs, + 0, + ), + usage: addAgentTurnUsage( + ...conversationSummaries.map( + (summary) => summary.cumulativeUsage, ), ), - ); - const row = rows[0]; - if (!row) { - return void 0; - } - return row.visibility === "public" ? "public" : "private"; - } - /** Serialize all durable mutations for one conversation inside a SQL transaction. */ - async withConversationMutation(conversationId, callback) { - return await this.executor.withLock( - `${CONVERSATION_MUTATION_LOCK_PREFIX}:${conversationId}`, - async () => await this.executor.transaction(callback), - ); - } - async readConversationRow(conversationId) { - const rows = await this.executor - .db() - .select({ - conversation: juniorConversations, - destination: juniorDestinations, - actorIdentity: juniorIdentities, - }) - .from(juniorConversations) - .leftJoin( - juniorDestinations, - eq2(juniorDestinations.id, juniorConversations.destinationId), - ) - .leftJoin( - juniorIdentities, - eq2(juniorIdentities.id, juniorConversations.actorIdentityId), - ) - .where(eq2(juniorConversations.conversationId, conversationId)); - return rows[0]; - } - /** Upsert the conversation row while preserving previously discovered nullable metadata fields. */ - async upsertConversation(args) { - const { conversation } = args; - const incomingExecutionVersion = sql5`coalesce(excluded.execution_updated_at, excluded.updated_at)`; - const currentExecutionVersion = sql5`coalesce(${juniorConversations.executionUpdatedAt}, ${juniorConversations.updatedAt})`; - const incomingExecutionIsFresh = sql5`${incomingExecutionVersion} >= ${currentExecutionVersion}`; - const destinationId = await this.upsertDestination( - destinationUpsertFromDestination({ - channelName: conversation.channelName, - conversationId: conversation.conversationId, - destination: conversation.destination, - ...(conversation.visibility - ? { visibility: conversation.visibility } - : {}), - }), - conversation.updatedAtMs, - ); - const actorIdentityObservation = - actorIdentityForConversation(conversation); - const actorIdentity = actorIdentityObservation - ? await upsertIdentity( - this.executor, - actorIdentityObservation, - conversation.updatedAtMs, - ) - : void 0; - await this.executor - .db() - .insert(juniorConversations) - .values({ - conversationId: conversation.conversationId, - schemaVersion: 1, - source: conversation.source ?? null, - originType: originTypeFromSource(conversation.source) ?? null, - originId: null, - originRunId: null, - destinationId: destinationId ?? null, - destination: null, - actorIdentityId: actorIdentity?.id ?? null, - creatorIdentityId: null, - credentialSubjectIdentityId: null, - actor: null, - channelName: conversation.channelName ?? null, - title: conversation.title ?? null, - createdAt: dateFromMs2(conversation.createdAtMs), - lastActivityAt: dateFromMs2(conversation.lastActivityAtMs), - updatedAt: dateFromMs2(conversation.updatedAtMs), - executionUpdatedAt: - conversation.execution.updatedAtMs === void 0 - ? null - : dateFromMs2(conversation.execution.updatedAtMs), - executionStatus: conversation.execution.status, - runId: conversation.execution.runId ?? null, - lastCheckpointAt: - conversation.execution.lastCheckpointAtMs === void 0 - ? null - : dateFromMs2(conversation.execution.lastCheckpointAtMs), - lastEnqueuedAt: - conversation.execution.lastEnqueuedAtMs === void 0 - ? null - : dateFromMs2(conversation.execution.lastEnqueuedAtMs), - }) - .onConflictDoUpdate({ - target: juniorConversations.conversationId, - set: { - source: sql5`coalesce(excluded.source, ${juniorConversations.source})`, - originType: sql5`coalesce(excluded.origin_type, ${juniorConversations.originType})`, - originId: sql5`coalesce(excluded.origin_id, ${juniorConversations.originId})`, - originRunId: sql5`coalesce(excluded.origin_run_id, ${juniorConversations.originRunId})`, - destinationId: sql5`coalesce(excluded.destination_id, ${juniorConversations.destinationId})`, - actorIdentityId: sql5`coalesce(excluded.actor_identity_id, ${juniorConversations.actorIdentityId})`, - creatorIdentityId: sql5`coalesce(excluded.creator_identity_id, ${juniorConversations.creatorIdentityId})`, - credentialSubjectIdentityId: sql5`coalesce(excluded.credential_subject_identity_id, ${juniorConversations.credentialSubjectIdentityId})`, - channelName: sql5`coalesce(excluded.channel_name, ${juniorConversations.channelName})`, - title: sql5`coalesce(excluded.title, ${juniorConversations.title})`, - createdAt: sql5`least(${juniorConversations.createdAt}, excluded.created_at)`, - lastActivityAt: sql5`greatest(${juniorConversations.lastActivityAt}, excluded.last_activity_at)`, - updatedAt: sql5`greatest(${juniorConversations.updatedAt}, excluded.updated_at)`, - executionUpdatedAt: sql5`case when ${incomingExecutionIsFresh} then excluded.execution_updated_at else ${juniorConversations.executionUpdatedAt} end`, - executionStatus: sql5`case when ${incomingExecutionIsFresh} then excluded.execution_status else ${juniorConversations.executionStatus} end`, - runId: sql5`case when ${incomingExecutionIsFresh} then excluded.run_id else ${juniorConversations.runId} end`, - lastCheckpointAt: sql5`case when ${incomingExecutionIsFresh} then coalesce(excluded.last_checkpoint_at, ${juniorConversations.lastCheckpointAt}) else ${juniorConversations.lastCheckpointAt} end`, - lastEnqueuedAt: sql5`case when ${incomingExecutionIsFresh} then coalesce(excluded.last_enqueued_at, ${juniorConversations.lastEnqueuedAt}) else ${juniorConversations.lastEnqueuedAt} end`, - }, - }); - } - async upsertDestination(destination, nowMs) { - if (!destination) { - return void 0; - } - const visibilityUpdate = destination.refreshVisibility - ? sql5`excluded.visibility` - : juniorDestinations.visibility; - const rows = await this.executor - .db() - .insert(juniorDestinations) - .values({ - id: randomUUID2(), - provider: destination.provider, - providerTenantId: tenantId2(destination.providerTenantId), - providerDestinationId: destination.providerDestinationId, - kind: destination.kind, - parentDestinationId: null, - displayName: destination.displayName ?? null, - visibility: destination.visibility, - metadata: destination.metadata ?? null, - createdAt: dateFromMs2(nowMs), - updatedAt: dateFromMs2(nowMs), - }) - .onConflictDoUpdate({ - target: [ - juniorDestinations.provider, - juniorDestinations.providerTenantId, - juniorDestinations.providerDestinationId, - ], - set: { - kind: sql5`excluded.kind`, - displayName: sql5`coalesce(excluded.display_name, ${juniorDestinations.displayName})`, - // Signal-less writes insert as private but must not clobber an - // existing public/private value. Live source signals refresh this - // field so converted channels converge on the next message. - visibility: visibilityUpdate, - metadata: sql5`coalesce(excluded.metadata_json, ${juniorDestinations.metadata})`, - updatedAt: sql5`excluded.updated_at`, - }, - }) - .returning({ id: juniorDestinations.id }); - return rows[0]?.id; - } - }; - }, -}); - -// packages/junior/src/chat/coerce.ts -function toOptionalString(value) { - return typeof value === "string" && value.trim() ? value : void 0; -} -function toOptionalNumber(value) { - return typeof value === "number" && Number.isFinite(value) ? value : void 0; -} -function isRecord(value) { - return typeof value === "object" && value !== null; -} -var init_coerce = __esm({ - "packages/junior/src/chat/coerce.ts"() { - "use strict"; - }, -}); - -// packages/junior/src/chat/actor.ts -import { z as z4 } from "zod"; -import { actorSchema } from "@sentry/junior-plugin-api"; -function isSyntheticActorUserId(value) { - return value.toLowerCase() === "unknown"; -} -function parseActorUserId(value) { - if (typeof value !== "string" || value.length === 0) { - return void 0; - } - if (value !== value.trim() || isSyntheticActorUserId(value)) { - return void 0; - } - return value; -} -function parseStoredSlackActor(value) { - const parsed = storedSlackActorSchema.safeParse(value); - if (!parsed.success) { - return void 0; - } - if ( - parsed.data.slackUserId !== void 0 && - !parseActorUserId(parsed.data.slackUserId) - ) { - return void 0; - } - if (parsed.data.teamId !== void 0 && !parseSlackTeamId(parsed.data.teamId)) { - return void 0; - } - if ( - (parsed.data.platform !== void 0 || parsed.data.teamId !== void 0) && - (!parsed.data.platform || !parsed.data.teamId) - ) { - return void 0; - } - return parsed.data; -} -var exactStoredStringSchema, storedSlackActorSchema; -var init_actor = __esm({ - "packages/junior/src/chat/actor.ts"() { - "use strict"; - init_ids(); - exactStoredStringSchema = z4 - .string() - .min(1) - .refine((value) => value === value.trim()); - storedSlackActorSchema = z4 - .object({ - email: exactStoredStringSchema.optional(), - fullName: exactStoredStringSchema.optional(), - platform: z4.literal("slack").optional(), - slackUserId: exactStoredStringSchema.optional(), - slackUserName: exactStoredStringSchema.optional(), - teamId: exactStoredStringSchema.optional(), - }) - .strict(); - }, -}); - -// packages/junior/src/chat/state/locks.ts -var ACTIVE_LOCK_TTL_MS; -var init_locks = __esm({ - "packages/junior/src/chat/state/locks.ts"() { - "use strict"; - ACTIVE_LOCK_TTL_MS = 9e4; - }, -}); - -// packages/junior/src/chat/state/adapter.ts -import { createMemoryState } from "@chat-adapter/state-memory"; -import { createRedisState } from "@chat-adapter/state-redis"; -function createPrefixedStateAdapter(base, prefix) { - const prefixed = (value) => `${prefix}:${value}`; - const unprefixed = (value) => - value.startsWith(`${prefix}:`) ? value.slice(prefix.length + 1) : value; - const prefixLock = (lock) => ({ - ...lock, - threadId: prefixed(lock.threadId), - }); - const unprefixLock = (lock) => ({ - ...lock, - threadId: unprefixed(lock.threadId), - }); - return { - appendToList: (key, value, options) => - base.appendToList(prefixed(key), value, options), - connect: () => base.connect(), - disconnect: () => base.disconnect(), - subscribe: (threadId) => base.subscribe(prefixed(threadId)), - unsubscribe: (threadId) => base.unsubscribe(prefixed(threadId)), - isSubscribed: (threadId) => base.isSubscribed(prefixed(threadId)), - acquireLock: async (threadId, ttlMs) => { - const lock = await base.acquireLock(prefixed(threadId), ttlMs); - return lock ? unprefixLock(lock) : null; - }, - releaseLock: (lock) => base.releaseLock(prefixLock(lock)), - extendLock: async (lock, ttlMs) => { - const prefixedLock = prefixLock(lock); - const extended = await base.extendLock(prefixedLock, ttlMs); - if (extended) { - lock.expiresAt = prefixedLock.expiresAt; - } - return extended; - }, - forceReleaseLock: (threadId) => base.forceReleaseLock(prefixed(threadId)), - enqueue: (threadId, entry, maxSize) => - base.enqueue(prefixed(threadId), entry, maxSize), - dequeue: (threadId) => base.dequeue(prefixed(threadId)), - queueDepth: (threadId) => base.queueDepth(prefixed(threadId)), - get: (key) => base.get(prefixed(key)), - getList: (key) => base.getList(prefixed(key)), - set: (key, value, ttlMs) => base.set(prefixed(key), value, ttlMs), - setIfNotExists: (key, value, ttlMs) => - base.setIfNotExists(prefixed(key), value, ttlMs), - delete: (key) => base.delete(prefixed(key)), - }; -} -function createQueuedStateAdapter(base, options) { - const heartbeats = /* @__PURE__ */ new Map(); - const shouldHeartbeatLock = (ttlMs) => ttlMs === ACTIVE_LOCK_TTL_MS; - const heartbeatKey = (lock) => `${lock.threadId}:${lock.token}`; - const stopHeartbeatByKey = (key) => { - const heartbeat = heartbeats.get(key); - if (!heartbeat) { - return; - } - clearInterval(heartbeat.timer); - heartbeats.delete(key); - }; - const stopHeartbeat = (lock) => { - stopHeartbeatByKey(heartbeatKey(lock)); - }; - const stopHeartbeatsForThread = (threadId) => { - for (const [key, heartbeat] of heartbeats) { - if (heartbeat.lock.threadId === threadId) { - stopHeartbeatByKey(key); - } - } - }; - const stopAllHeartbeats = () => { - for (const key of heartbeats.keys()) { - stopHeartbeatByKey(key); - } - }; - const runHeartbeat = async (key) => { - const heartbeat = heartbeats.get(key); - if (!heartbeat || heartbeat.inFlight) { - return; - } - heartbeat.inFlight = true; - try { - if (Date.now() - heartbeat.startedAtMs >= options.activeLockMaxAgeMs) { - stopHeartbeatByKey(key); - return; - } - const extended = await base.extendLock(heartbeat.lock, heartbeat.ttlMs); - if (!extended) { - stopHeartbeatByKey(key); - return; - } - heartbeat.lock.expiresAt = Date.now() + heartbeat.ttlMs; - } catch { - } finally { - const current = heartbeats.get(key); - if (current === heartbeat) { - current.inFlight = false; - } - } - }; - const startOrUpdateHeartbeat = (lock, ttlMs) => { - const key = heartbeatKey(lock); - const existing = heartbeats.get(key); - if (existing) { - existing.ttlMs = ttlMs; - return; - } - const timer = setInterval(() => { - void runHeartbeat(key); - }, ACTIVE_LOCK_HEARTBEAT_MS); - timer.unref?.(); - heartbeats.set(key, { - inFlight: false, - lock, - startedAtMs: Date.now(), - timer, - ttlMs, - }); - }; - const acquireLock = async (threadId, ttlMs) => { - const lock = await base.acquireLock(threadId, ttlMs); - if (lock && shouldHeartbeatLock(ttlMs)) { - startOrUpdateHeartbeat(lock, ttlMs); - } - return lock; - }; - return { - appendToList: (key, value, options2) => - base.appendToList(key, value, options2), - connect: () => base.connect(), - disconnect: async () => { - stopAllHeartbeats(); - await base.disconnect(); - }, - subscribe: (threadId) => base.subscribe(threadId), - unsubscribe: (threadId) => base.unsubscribe(threadId), - isSubscribed: (threadId) => base.isSubscribed(threadId), - acquireLock, - releaseLock: async (lock) => { - stopHeartbeat(lock); - await base.releaseLock(lock); - }, - extendLock: async (lock, ttlMs) => { - const extended = await base.extendLock(lock, ttlMs); - if (extended) { - lock.expiresAt = Date.now() + ttlMs; - if (shouldHeartbeatLock(ttlMs)) { - startOrUpdateHeartbeat(lock, ttlMs); - } else { - stopHeartbeat(lock); - } - } else { - stopHeartbeat(lock); - } - return extended; - }, - forceReleaseLock: async (threadId) => { - stopHeartbeatsForThread(threadId); - await base.forceReleaseLock(threadId); - }, - enqueue: (threadId, entry, maxSize) => - base.enqueue(threadId, entry, maxSize), - dequeue: (threadId) => base.dequeue(threadId), - queueDepth: (threadId) => base.queueDepth(threadId), - get: (key) => base.get(key), - getList: (key) => base.getList(key), - set: (key, value, ttlMs) => base.set(key, value, ttlMs), - setIfNotExists: (key, value, ttlMs) => - base.setIfNotExists(key, value, ttlMs), - delete: (key) => base.delete(key), - }; -} -function withOptionalPrefix(base, prefix) { - return prefix ? createPrefixedStateAdapter(base, prefix) : base; -} -function createStateAdapter() { - const config = getChatConfig(); - const activeLockMaxAgeMs = config.bot.turnTimeoutMs + ACTIVE_LOCK_TTL_MS; - if (config.state.adapter === "memory") { - redisStateAdapter = void 0; - return createQueuedStateAdapter( - withOptionalPrefix(createMemoryState(), config.state.keyPrefix), - { activeLockMaxAgeMs }, - ); - } - if (!config.state.redisUrl) { - throw new Error("REDIS_URL is required for durable Slack thread state"); - } - const redisState = createRedisState({ - url: config.state.redisUrl, - }); - redisStateAdapter = redisState; - return createQueuedStateAdapter( - withOptionalPrefix(redisState, config.state.keyPrefix), - { activeLockMaxAgeMs }, - ); -} -function getOptionalRedisStateAdapter() { - getStateAdapter(); - return redisStateAdapter; -} -async function getConnectedStateContext() { - const adapter = getStateAdapter(); - await adapter.connect(); - return { - redisStateAdapter: getOptionalRedisStateAdapter(), - stateAdapter: adapter, - }; -} -async function getDefaultRedisStateAdapterFor(adapter) { - if (adapter !== stateAdapter) { - return void 0; - } - const context = await getConnectedStateContext(); - return context.stateAdapter === adapter ? context.redisStateAdapter : void 0; -} -function getStateAdapter() { - if (!stateAdapter) { - stateAdapter = createStateAdapter(); - } - return stateAdapter; -} -var ACTIVE_LOCK_HEARTBEAT_MS, stateAdapter, redisStateAdapter; -var init_adapter = __esm({ - "packages/junior/src/chat/state/adapter.ts"() { - "use strict"; - init_config(); - init_locks(); - ACTIVE_LOCK_HEARTBEAT_MS = 3e4; - }, -}); - -// packages/junior/src/chat/pi/messages.ts -import { z as z6 } from "zod"; -var piMessageSchema, piContentMessageSchema; -var init_messages = __esm({ - "packages/junior/src/chat/pi/messages.ts"() { - "use strict"; - piMessageSchema = z6 - .object({ - role: z6.string(), - }) - .passthrough() - .transform((value) => value); - piContentMessageSchema = z6 - .object({ - content: z6.array(z6.unknown()), - role: z6.string().min(1), - }) - .passthrough() - .transform((value) => value); - }, -}); - -// packages/junior/src/chat/state/session-log.ts -import { z as z7 } from "zod"; -import { actorSchema as actorSchema2 } from "@sentry/junior-plugin-api"; -var INITIAL_SESSION_ID, - schemaVersionSchema, - piMessageAuthoritySchema, - piMessageProvenanceSchema, - piMessageEntrySchema, - projectionResetEntrySchema, - actorRecordedEntrySchema, - mcpProviderConnectedEntrySchema, - authorizationKindSchema, - authorizationRequestedEntrySchema, - authorizationCompletedEntrySchema, - transcriptRefSchema, - toolExecutionStartedEntrySchema, - subagentStartedEntrySchema, - subagentEndedEntrySchema, - sessionLogEntrySchema; -var init_session_log = __esm({ - "packages/junior/src/chat/state/session-log.ts"() { - "use strict"; - init_config(); - init_messages(); - init_actor(); - init_adapter(); - INITIAL_SESSION_ID = "session_0"; - schemaVersionSchema = z7.union([z7.literal(1), z7.literal(2)]); - piMessageAuthoritySchema = z7.union([ - z7.literal("instruction"), - z7.literal("context"), - ]); - piMessageProvenanceSchema = z7 - .object({ - authority: piMessageAuthoritySchema, - actor: actorSchema2.optional(), - }) - .strict(); - piMessageEntrySchema = z7.object({ - schemaVersion: schemaVersionSchema, - type: z7.literal("pi_message"), - sessionId: z7.string().min(1).default(INITIAL_SESSION_ID), - message: piMessageSchema, - provenance: piMessageProvenanceSchema.optional(), - // Legacy v1 latest-wins actor, decoded into provenance on read. - actor: storedSlackActorSchema.optional(), - }); - projectionResetEntrySchema = z7.object({ - schemaVersion: schemaVersionSchema, - type: z7.literal("projection_reset"), - sessionId: z7.string().min(1).default(INITIAL_SESSION_ID), - messages: z7.array(piMessageSchema), - provenance: z7.array(piMessageProvenanceSchema).optional(), - // Legacy v1 latest-wins actor; v1 resets carry no per-message provenance. - actor: storedSlackActorSchema.optional(), - }); - actorRecordedEntrySchema = z7.object({ - schemaVersion: schemaVersionSchema, - type: z7.literal("actor_recorded"), - sessionId: z7.string().min(1).default(INITIAL_SESSION_ID), - actor: storedSlackActorSchema, - }); - mcpProviderConnectedEntrySchema = z7.object({ - schemaVersion: schemaVersionSchema, - type: z7.literal("mcp_provider_connected"), - sessionId: z7.string().min(1).default(INITIAL_SESSION_ID), - provider: z7.string().min(1), - }); - authorizationKindSchema = z7.union([ - z7.literal("plugin"), - z7.literal("mcp"), - ]); - authorizationRequestedEntrySchema = z7.object({ - schemaVersion: schemaVersionSchema, - type: z7.literal("authorization_requested"), - sessionId: z7.string().min(1).default(INITIAL_SESSION_ID), - createdAtMs: z7.number().int().nonnegative(), - kind: authorizationKindSchema, - provider: z7.string().min(1), - actorId: z7.string().min(1), - authorizationId: z7.string().min(1), - delivery: z7.union([ - z7.literal("private_link_sent"), - z7.literal("private_link_reused"), - ]), - }); - authorizationCompletedEntrySchema = z7.object({ - schemaVersion: schemaVersionSchema, - type: z7.literal("authorization_completed"), - sessionId: z7.string().min(1).default(INITIAL_SESSION_ID), - createdAtMs: z7.number().int().nonnegative(), - kind: authorizationKindSchema, - provider: z7.string().min(1), - actorId: z7.string().min(1), - authorizationId: z7.string().min(1), - }); - transcriptRefSchema = z7.object({ - type: z7.literal("advisor_session"), - parentConversationId: z7.string().min(1), - key: z7.string().min(1), - }); - toolExecutionStartedEntrySchema = z7.object({ - schemaVersion: schemaVersionSchema, - type: z7.literal("tool_execution_started"), - sessionId: z7.string().min(1).default(INITIAL_SESSION_ID), - createdAtMs: z7.number().int().nonnegative(), - toolCallId: z7.string().min(1), - toolName: z7.string().min(1), - args: z7.unknown().optional(), - }); - subagentStartedEntrySchema = z7.object({ - schemaVersion: schemaVersionSchema, - type: z7.literal("subagent_started"), - sessionId: z7.string().min(1).default(INITIAL_SESSION_ID), - subagentInvocationId: z7.string().min(1), - subagentKind: z7.string().min(1), - parentToolCallId: z7.string().min(1).optional(), - parentConversationId: z7.string().min(1), - parentSessionId: z7.string().min(1).optional(), - transcriptRef: transcriptRefSchema, - historyMode: z7.literal("shared"), - modelId: z7.string().min(1).optional(), - reasoningLevel: z7.string().min(1).optional(), - createdAtMs: z7.number().int().nonnegative(), - }); - subagentEndedEntrySchema = z7.object({ - schemaVersion: schemaVersionSchema, - type: z7.literal("subagent_ended"), - sessionId: z7.string().min(1).default(INITIAL_SESSION_ID), - subagentInvocationId: z7.string().min(1), - outcome: z7.union([ - z7.literal("success"), - z7.literal("error"), - z7.literal("aborted"), - ]), - errorCode: z7.string().min(1).optional(), - transcriptEndMessageIndex: z7.number().int().nonnegative().optional(), - transcriptStartMessageIndex: z7.number().int().nonnegative().optional(), - createdAtMs: z7.number().int().nonnegative(), - }); - sessionLogEntrySchema = z7.discriminatedUnion("type", [ - piMessageEntrySchema, - projectionResetEntrySchema, - actorRecordedEntrySchema, - mcpProviderConnectedEntrySchema, - authorizationRequestedEntrySchema, - authorizationCompletedEntrySchema, - toolExecutionStartedEntrySchema, - subagentStartedEntrySchema, - subagentEndedEntrySchema, - ]); - }, -}); - -// packages/junior/src/chat/model-profile.ts -import { z as z8 } from "zod"; -var modelProfileSchema; -var init_model_profile = __esm({ - "packages/junior/src/chat/model-profile.ts"() { - "use strict"; - modelProfileSchema = z8.string().regex(/^[a-z][a-z0-9_-]*$/); - }, -}); - -// packages/junior/src/chat/conversations/history.ts -import { z as z9 } from "zod"; -var handoffModelProfileSchema, - piMessageStepEntrySchema, - contextEpochStartedEntrySchema, - piMessageStepSchema, - contextEpochStartSchema, - mcpProviderConnectedEntrySchema2, - authorizationKindSchema2, - authorizationRequestedEntrySchema2, - authorizationCompletedEntrySchema2, - toolExecutionStartedEntrySchema2, - visibleContextCompactedEntrySchema, - subagentStartedEntrySchema2, - subagentEndedEntrySchema2, - appendableAgentStepEntrySchema, - agentStepEntrySchema, - newAgentStepSchema; -var init_history = __esm({ - "packages/junior/src/chat/conversations/history.ts"() { - "use strict"; - init_messages(); - init_session_log(); - init_model_profile(); - handoffModelProfileSchema = modelProfileSchema.refine( - (profile) => profile !== "standard", - "handoff profile must not be standard", - ); - piMessageStepEntrySchema = z9.object({ - type: z9.literal("pi_message"), - schemaVersion: z9.number().int().optional(), - message: piMessageSchema, - provenance: piMessageProvenanceSchema.optional(), - }); - contextEpochStartedEntrySchema = z9.union([ - z9 - .object({ - type: z9.literal("context_epoch_started"), - reason: z9.literal("initial"), - modelProfile: z9.literal("standard"), - modelId: z9.string().min(1), - }) - .strict(), - z9 - .object({ - type: z9.literal("context_epoch_started"), - reason: z9.literal("handoff"), - modelProfile: handoffModelProfileSchema, - modelId: z9.string().min(1), - }) - .strict(), - z9 - .object({ - type: z9.literal("context_epoch_started"), - reason: z9.union([z9.literal("compaction"), z9.literal("rollback")]), - // TODO(v0.97.0): Remove support for deployed compaction/rollback markers - // without model bindings after those rows pass the retention horizon. - modelProfile: z9.undefined().optional(), - modelId: z9.undefined().optional(), - }) - .strict(), - z9 - .object({ - type: z9.literal("context_epoch_started"), - reason: z9.union([z9.literal("compaction"), z9.literal("rollback")]), - modelProfile: modelProfileSchema, - modelId: z9.string().min(1), - }) - .strict(), - ]); - piMessageStepSchema = z9 - .object({ - message: piMessageSchema, - createdAtMs: z9.number().finite(), - provenance: piMessageProvenanceSchema.optional(), - }) - .strict(); - contextEpochStartSchema = z9.discriminatedUnion("reason", [ - z9 - .object({ - reason: z9.literal("initial"), - modelProfile: z9.literal("standard"), - modelId: z9.string().min(1), - messages: z9.array(piMessageStepSchema), - }) - .strict(), - z9 - .object({ - reason: z9.literal("handoff"), - modelProfile: handoffModelProfileSchema, - modelId: z9.string().min(1), - messages: z9.array(piMessageStepSchema), - }) - .strict(), - z9 - .object({ - reason: z9.union([z9.literal("compaction"), z9.literal("rollback")]), - modelProfile: modelProfileSchema, - modelId: z9.string().min(1), - messages: z9.array(piMessageStepSchema), - }) - .strict(), - ]); - mcpProviderConnectedEntrySchema2 = z9.object({ - type: z9.literal("mcp_provider_connected"), - provider: z9.string().min(1), - }); - authorizationKindSchema2 = z9.union([ - z9.literal("plugin"), - z9.literal("mcp"), - ]); - authorizationRequestedEntrySchema2 = z9.object({ - type: z9.literal("authorization_requested"), - kind: authorizationKindSchema2, - provider: z9.string().min(1), - actorId: z9.string().min(1), - authorizationId: z9.string().min(1), - delivery: z9.union([ - z9.literal("private_link_sent"), - z9.literal("private_link_reused"), - ]), - }); - authorizationCompletedEntrySchema2 = z9.object({ - type: z9.literal("authorization_completed"), - kind: authorizationKindSchema2, - provider: z9.string().min(1), - actorId: z9.string().min(1), - authorizationId: z9.string().min(1), - }); - toolExecutionStartedEntrySchema2 = z9.object({ - type: z9.literal("tool_execution_started"), - toolCallId: z9.string().min(1), - toolName: z9.string().min(1), - args: z9.unknown().optional(), - }); - visibleContextCompactedEntrySchema = z9.object({ - type: z9.literal("visible_context_compacted"), - compactions: z9.array( - z9.object({ - coveredMessageIds: z9.array(z9.string()), - createdAtMs: z9.number(), - id: z9.string().min(1), - summary: z9.string(), - }), - ), - }); - subagentStartedEntrySchema2 = z9 - .object({ - type: z9.literal("subagent_started"), - subagentInvocationId: z9.string().min(1), - subagentKind: z9.string().min(1), - modelId: z9.string().min(1).optional(), - parentToolCallId: z9.string().min(1).optional(), - reasoningLevel: z9.string().min(1).optional(), - childConversationId: z9.string().min(1), - historyMode: z9.union([z9.literal("isolated"), z9.literal("shared")]), - }) - .strict(); - subagentEndedEntrySchema2 = z9.object({ - type: z9.literal("subagent_ended"), - subagentInvocationId: z9.string().min(1), - outcome: z9.union([ - z9.literal("success"), - z9.literal("error"), - z9.literal("aborted"), - ]), - errorCode: z9.string().min(1).optional(), - }); - appendableAgentStepEntrySchema = z9.union([ - piMessageStepEntrySchema, - mcpProviderConnectedEntrySchema2, - authorizationRequestedEntrySchema2, - authorizationCompletedEntrySchema2, - toolExecutionStartedEntrySchema2, - visibleContextCompactedEntrySchema, - subagentStartedEntrySchema2, - subagentEndedEntrySchema2, - ]); - agentStepEntrySchema = z9.union([ - appendableAgentStepEntrySchema, - contextEpochStartedEntrySchema, - ]); - newAgentStepSchema = z9 - .object({ - entry: appendableAgentStepEntrySchema, - createdAtMs: z9.number().finite(), - }) - .strict(); - }, -}); - -// packages/junior/src/chat/conversations/sql/conversation-row.ts -import { sql as sql6 } from "drizzle-orm"; -var init_conversation_row = __esm({ - "packages/junior/src/chat/conversations/sql/conversation-row.ts"() { - "use strict"; - init_schema(); - }, -}); - -// packages/junior/src/chat/conversations/sql/history.ts -import { and as and3, asc as asc2, eq as eq3, sql as sql7 } from "drizzle-orm"; -var init_history2 = __esm({ - "packages/junior/src/chat/conversations/sql/history.ts"() { - "use strict"; - init_history(); - init_conversation_row(); - init_schema(); - }, -}); - -// packages/junior/src/chat/conversations/sql/messages.ts -import { - and as and4, - asc as asc3, - eq as eq4, - isNull as isNull2, - sql as sql8, -} from "drizzle-orm"; -var init_messages2 = __esm({ - "packages/junior/src/chat/conversations/sql/messages.ts"() { - "use strict"; - init_conversation_row(); - init_schema(); - }, -}); - -// packages/junior/src/chat/conversations/sql/search.ts -import { - and as and5, - desc as desc2, - eq as eq5, - inArray, - isNull as isNull3, - ne, - sql as sql9, -} from "drizzle-orm"; -var init_search = __esm({ - "packages/junior/src/chat/conversations/sql/search.ts"() { - "use strict"; - init_schema(); - }, -}); - -// migration:executor -function createJuniorSqlExecutor() { - throw new Error("Migration database adapter is required"); -} -var init_executor = __esm({ - "migration:executor"() { - "use strict"; - }, -}); - -// packages/junior/src/chat/db.ts -var init_db = __esm({ - "packages/junior/src/chat/db.ts"() { - "use strict"; - init_config(); - init_store(); - init_history2(); - init_messages2(); - init_search(); - init_executor(); - }, -}); - -// packages/junior/src/chat/conversations/visible-compactions.ts -var init_visible_compactions = __esm({ - "packages/junior/src/chat/conversations/visible-compactions.ts"() { - "use strict"; - init_db(); - }, -}); - -// packages/junior/src/chat/sentry.ts -var sentry_exports = {}; -__export(sentry_exports, { - captureException: () => captureException, - captureMessage: () => captureMessage, - continueTrace: () => continueTrace, - flush: () => flush, - getActiveSpan: () => getActiveSpan, - getClient: () => getClient, - getGlobalScope: () => getGlobalScope, - getTraceData: () => getTraceData, - init: () => init, - logger: () => logger, - setTag: () => setTag, - setUser: () => setUser, - spanToJSON: () => spanToJSON, - startInactiveSpan: () => startInactiveSpan, - startSpan: () => startSpan, - vercelAIIntegration: () => vercelAIIntegration, - withActiveSpan: () => withActiveSpan, - withScope: () => withScope, - withStreamedSpan: () => withStreamedSpan, -}); -import { - captureException, - captureMessage, - continueTrace, - flush, - getActiveSpan, - getClient, - getGlobalScope, - getTraceData, - init, - logger, - setTag, - setUser, - spanToJSON, - startInactiveSpan, - startSpan, - vercelAIIntegration, - withActiveSpan, - withScope, - withStreamedSpan, -} from "@sentry/node"; -import * as node_star from "@sentry/node"; -var init_sentry = __esm({ - "packages/junior/src/chat/sentry.ts"() { - "use strict"; - __reExport(sentry_exports, node_star); - }, -}); - -// packages/junior/src/deployment.ts -function toOptionalTrimmed(value) { - const trimmed = value?.trim(); - return trimmed ? trimmed : void 0; -} -function getDeploymentServiceVersion() { - return ( - toOptionalTrimmed(process.env.SENTRY_RELEASE) ?? - toOptionalTrimmed(process.env.VERCEL_GIT_COMMIT_SHA) - ); -} -function getDeploymentTelemetryAttributes() { - const attributes = {}; - const serviceVersion = getDeploymentServiceVersion(); - const deploymentId = toOptionalTrimmed(process.env.VERCEL_DEPLOYMENT_ID); - if (serviceVersion) { - attributes["service.version"] = serviceVersion; - } - if (deploymentId) { - attributes["deployment.id"] = deploymentId; - } - return attributes; -} -var init_deployment = __esm({ - "packages/junior/src/deployment.ts"() { - "use strict"; - }, -}); - -// packages/junior/src/chat/logging.ts -import { AsyncLocalStorage } from "node:async_hooks"; -import { - ConfigError, - configureSync, - getConfig, - getLogger, -} from "@logtape/logtape"; -var contextStorage, - deploymentLogAttributes, - CONSOLE_PRIORITY_KEYS, - CONSOLE_PRIORITY_INDEX; -var init_logging = __esm({ - "packages/junior/src/chat/logging.ts"() { - "use strict"; - init_coerce(); - init_identity(); - init_sentry(); - init_sentry(); - init_deployment(); - contextStorage = new AsyncLocalStorage(); - deploymentLogAttributes = getDeploymentTelemetryAttributes(); - CONSOLE_PRIORITY_KEYS = [ - "gen_ai.conversation.id", - "event.name", - "app.log.source", - "exception.message", - "messaging.message.id", - "trace_id", - "span_id", - "messaging.message.conversation_id", - "messaging.destination.name", - "app.run.id", - "app.message.kind", - ]; - CONSOLE_PRIORITY_INDEX = new Map( - CONSOLE_PRIORITY_KEYS.map((key, index6) => [key, index6]), - ); - }, -}); - -// packages/junior/src/chat/pi/sdk.ts -import { - completeSimple, - getEnvApiKey, - getModels, - registerApiProvider, -} from "@earendil-works/pi-ai/compat"; -import { - stream, - streamSimple, -} from "@earendil-works/pi-ai/api/anthropic-messages"; -var init_sdk = __esm({ - "packages/junior/src/chat/pi/sdk.ts"() { - "use strict"; - }, -}); - -// packages/junior/src/chat/optional-string.ts -var init_optional_string = __esm({ - "packages/junior/src/chat/optional-string.ts"() { - "use strict"; - }, -}); - -// packages/junior/src/chat/slack/context.ts -import { z as z10 } from "zod"; -var slackMessageEnvelopeSchema, - rawSlackMessageSchema, - nestedRawSlackMessageSchema; -var init_context = __esm({ - "packages/junior/src/chat/slack/context.ts"() { - "use strict"; - init_coerce(); - init_ids(); - init_timestamp(); - slackMessageEnvelopeSchema = z10.object({ - raw: z10.unknown().optional(), - }); - rawSlackMessageSchema = z10.object({ - channel: z10.unknown().optional(), - message: z10.unknown().optional(), - team: z10.unknown().optional(), - team_id: z10.unknown().optional(), - thread_ts: z10.unknown().optional(), - ts: z10.unknown().optional(), - user_team: z10.unknown().optional(), - }); - nestedRawSlackMessageSchema = z10.object({ - ts: z10.unknown().optional(), - }); - }, -}); - -// packages/junior/src/chat/conversation-privacy.ts -import { AsyncLocalStorage as AsyncLocalStorage2 } from "node:async_hooks"; -var conversationPrivacyStorage; -var init_conversation_privacy = __esm({ - "packages/junior/src/chat/conversation-privacy.ts"() { - "use strict"; - init_context(); - conversationPrivacyStorage = new AsyncLocalStorage2(); - }, -}); - -// packages/junior/src/chat/xml.ts -var init_xml = __esm({ - "packages/junior/src/chat/xml.ts"() { - "use strict"; - }, -}); - -// packages/junior/src/chat/current-instruction.ts -var CURRENT_INSTRUCTION_TAG, - CURRENT_INSTRUCTION_OPEN_PREFIX, - CURRENT_INSTRUCTION_OPEN_BARE, - CURRENT_INSTRUCTION_OPEN_ATTR_PREFIX, - CURRENT_INSTRUCTION_CLOSE; -var init_current_instruction = __esm({ - "packages/junior/src/chat/current-instruction.ts"() { - "use strict"; - init_xml(); - CURRENT_INSTRUCTION_TAG = "current-instruction"; - CURRENT_INSTRUCTION_OPEN_PREFIX = `<${CURRENT_INSTRUCTION_TAG}`; - CURRENT_INSTRUCTION_OPEN_BARE = `<${CURRENT_INSTRUCTION_TAG}>`; - CURRENT_INSTRUCTION_OPEN_ATTR_PREFIX = `<${CURRENT_INSTRUCTION_TAG} `; - CURRENT_INSTRUCTION_CLOSE = ` -`; - }, -}); - -// packages/junior/src/chat/turn-context-tag.ts -var TURN_CONTEXT_TAG; -var init_turn_context_tag = __esm({ - "packages/junior/src/chat/turn-context-tag.ts"() { - "use strict"; - TURN_CONTEXT_TAG = "runtime-turn-context"; - }, -}); - -// packages/junior/src/chat/pi/transcript.ts -var RUNTIME_TURN_CONTEXT_START, - EMBEDDED_THREAD_CONTEXT_TAGS, - EMBEDDED_THREAD_CONTEXT_PATTERN; -var init_transcript = __esm({ - "packages/junior/src/chat/pi/transcript.ts"() { - "use strict"; - init_current_instruction(); - init_turn_context_tag(); - RUNTIME_TURN_CONTEXT_START = `<${TURN_CONTEXT_TAG}>`; - EMBEDDED_THREAD_CONTEXT_TAGS = [ - "recent-thread-messages", - "thread-compactions", - "thread-transcript", - "thread-background", - ]; - EMBEDDED_THREAD_CONTEXT_PATTERN = new RegExp( - `<(${EMBEDDED_THREAD_CONTEXT_TAGS.join("|")})(?:\\s[^>]*)?>[\\s\\S]*?`, - "g", - ); - }, -}); - -// packages/junior/src/chat/services/provider-retry.ts -var init_provider_retry = __esm({ - "packages/junior/src/chat/services/provider-retry.ts"() { - "use strict"; - init_transcript(); - }, -}); - -// packages/junior/src/chat/pi/client.ts -import { createGatewayProvider } from "@ai-sdk/gateway"; -import { embedMany, generateObject } from "ai"; -var init_client = __esm({ - "packages/junior/src/chat/pi/client.ts"() { - "use strict"; - init_sdk(); - init_logging(); - init_logging(); - init_optional_string(); - init_conversation_privacy(); - init_provider_retry(); - registerApiProvider({ - api: "anthropic-messages", - stream, - streamSimple, - }); - }, -}); - -// packages/junior/src/chat/services/context-budget.ts -var init_context_budget = __esm({ - "packages/junior/src/chat/services/context-budget.ts"() { - "use strict"; - init_config(); - init_client(); - }, -}); - -// packages/junior/src/chat/services/conversation-memory.ts -var init_conversation_memory = __esm({ - "packages/junior/src/chat/services/conversation-memory.ts"() { - "use strict"; - init_config(); - init_logging(); - init_context_budget(); - init_timestamp(); - init_xml(); - }, -}); - -// packages/junior/src/chat/conversations/visible-messages.ts -var init_visible_messages = __esm({ - "packages/junior/src/chat/conversations/visible-messages.ts"() { - "use strict"; - init_db(); - init_visible_compactions(); - init_conversation_memory(); - }, -}); - -// packages/junior/src/chat/conversations/legacy-advisor-session.ts -import { z as z11 } from "zod"; -var legacyAdvisorMessagesSchema; -var init_legacy_advisor_session = __esm({ - "packages/junior/src/chat/conversations/legacy-advisor-session.ts"() { - "use strict"; - init_messages(); - init_adapter(); - legacyAdvisorMessagesSchema = z11.array(piMessageSchema); - }, -}); - -// packages/junior/src/chat/conversations/sql/legacy-history-import.ts -import { eq as eq6, sql as sql10 } from "drizzle-orm"; -var init_legacy_history_import = __esm({ - "packages/junior/src/chat/conversations/sql/legacy-history-import.ts"() { - "use strict"; - init_xml(); - init_session_log(); - init_history(); - init_schema(); - }, -}); - -// packages/junior/src/chat/conversations/legacy-import.ts -import { eq as eq7 } from "drizzle-orm"; -import { z as z12 } from "zod"; -var legacyVisibleMessageSchema, - legacyCompactionSchema, - legacyThreadStateSnapshotSchema; -var init_legacy_import = __esm({ - "packages/junior/src/chat/conversations/legacy-import.ts"() { - "use strict"; - init_visible_messages(); - init_adapter(); - init_session_log(); - init_legacy_advisor_session(); - init_schema(); - init_db(); - init_history2(); - init_legacy_history_import(); - legacyVisibleMessageSchema = z12.object({ - id: z12.string(), - role: z12.enum(["user", "assistant", "system"]), - text: z12.string(), - createdAtMs: z12.number().finite(), - author: z12.object({}).passthrough().optional(), - meta: z12.object({}).passthrough().optional(), - }); - legacyCompactionSchema = z12.object({ - coveredMessageIds: z12.array(z12.string()), - createdAtMs: z12.number().finite(), - id: z12.string(), - summary: z12.string(), - }); - legacyThreadStateSnapshotSchema = z12.object({ - conversation: z12 - .object({ - compactions: z12.array(legacyCompactionSchema).optional(), - messages: z12.array(legacyVisibleMessageSchema).optional(), - stats: z12 - .object({ updatedAtMs: z12.number().finite().optional() }) - .passthrough() - .optional(), - }) - .passthrough() - .optional(), - }); - }, -}); - -// ../../../../../../private/tmp/conversations-sql.ts -init_config(); -init_store(); - -// packages/junior/src/chat/task-execution/state.ts -init_coerce(); -init_config(); -init_destination(); -init_actor(); -init_adapter(); - -// packages/junior/src/chat/state/ttl.ts -var JUNIOR_THREAD_STATE_TTL_MS = 7 * 24 * 60 * 60 * 1e3; - -// packages/junior/src/chat/task-execution/state.ts -var CONVERSATION_PREFIX = "junior:conversation"; -var CONVERSATION_SCHEMA_VERSION = 1; -var CONVERSATION_ACTIVITY_INDEX_MAX_LENGTH = 1e4; -var CONVERSATION_INDEX_LOCK_TTL_MS = 1e4; -var CONVERSATION_INDEX_LOCK_WAIT_MS = 2e3; -var CONVERSATION_INDEX_LOCK_RETRY_MS = 25; -var CONVERSATION_MUTATION_LOCK_TTL_MS = 1e4; -var CONVERSATION_MUTATION_WAIT_MS = 1e4; -var CONVERSATION_MUTATION_RETRY_MS = 25; -var InvalidConversationRecordError = class extends Error { - constructor(conversationId) { - super(`Conversation record is invalid for ${conversationId}`); - this.name = "InvalidConversationRecordError"; - } -}; -var ConversationMutationFencedError = class extends Error { - constructor(conversationId) { - super( - `Conversation mutation lock was lost before write for ${conversationId}`, - ); - this.name = "ConversationMutationFencedError"; - } -}; -var CONVERSATION_BY_ACTIVITY_INDEX_KEY = `${CONVERSATION_PREFIX}:by-activity`; -var CONVERSATION_ACTIVE_INDEX_KEY = `${CONVERSATION_PREFIX}:active`; -function conversationKey(conversationId) { - return `${CONVERSATION_PREFIX}:${conversationId}`; -} -function indexLockKey(indexKey) { - return `${indexKey}:lock`; -} -function mutationLockKey(conversationId) { - return `${CONVERSATION_PREFIX}:mutation:${conversationId}`; -} -function now2() { - return Date.now(); -} -function compareMessages(left, right) { - return ( - left.createdAtMs - right.createdAtMs || - left.receivedAtMs - right.receivedAtMs || - left.inboundMessageId.localeCompare(right.inboundMessageId) - ); -} -function compareIndexDescending(left, right) { - return ( - right.score - left.score || - right.conversationId.localeCompare(left.conversationId) - ); -} -function compareIndexAscending(left, right) { - return ( - left.score - right.score || - left.conversationId.localeCompare(right.conversationId) - ); -} -function uniqueStrings(values) { - return [...new Set(values)]; -} -function normalizeSource(value) { - if ( - value === "api" || - value === "internal" || - value === "local" || - value === "plugin" || - value === "resource_event" || - value === "scheduler" || - value === "slack" - ) { - return value; - } - return void 0; -} -function normalizeExecutionStatus(value) { - if ( - value === "awaiting_resume" || - value === "failed" || - value === "idle" || - value === "pending" || - value === "running" - ) { - return value; - } - return void 0; -} -function normalizeMetadata(value) { - if (!isRecord(value)) { - return void 0; - } - return value; -} -function normalizeInput(value) { - if (!isRecord(value)) { - return void 0; - } - const text7 = toOptionalString(value.text); - if (!text7) { - return void 0; - } - return { - text: text7, - authorId: toOptionalString(value.authorId), - attachments: Array.isArray(value.attachments) - ? [...value.attachments] - : void 0, - metadata: normalizeMetadata(value.metadata), - }; -} -function normalizeMessage(value) { - if (!isRecord(value)) { - return void 0; - } - const conversationId = toOptionalString(value.conversationId); - const inboundMessageId = toOptionalString(value.inboundMessageId); - const source = normalizeSource(value.source); - const destination = parseDestination(value.destination); - const createdAtMs = toOptionalNumber(value.createdAtMs); - const receivedAtMs = toOptionalNumber(value.receivedAtMs); - const input = normalizeInput(value.input); - if ( - !conversationId || - !destination || - !inboundMessageId || - !source || - typeof createdAtMs !== "number" || - typeof receivedAtMs !== "number" || - !input - ) { - return void 0; - } - return { - conversationId, - destination, - inboundMessageId, - source, - createdAtMs, - receivedAtMs, - input, - injectedAtMs: toOptionalNumber(value.injectedAtMs), - attemptCount: toOptionalNumber(value.attemptCount), - }; -} -function normalizeActor(value) { - return parseStoredSlackActor(value); -} -function normalizeLease(value) { - if (!isRecord(value)) { - return void 0; - } - const token = toOptionalString(value.token); - const acquiredAtMs = toOptionalNumber(value.acquiredAtMs); - const lastCheckInAtMs = toOptionalNumber(value.lastCheckInAtMs); - const expiresAtMs = toOptionalNumber(value.expiresAtMs); - if ( - !token || - typeof acquiredAtMs !== "number" || - typeof lastCheckInAtMs !== "number" || - typeof expiresAtMs !== "number" - ) { - return void 0; - } - return { - token, - acquiredAtMs, - lastCheckInAtMs, - expiresAtMs, - }; -} -function normalizeExecution(conversationId, value) { - if (!isRecord(value)) { - return void 0; - } - const status = normalizeExecutionStatus(value.status); - if (!status) { - return void 0; - } - const pendingMessages2 = Array.isArray(value.pendingMessages) - ? value.pendingMessages - .map(normalizeMessage) - .filter((message) => Boolean(message)) - .filter((message) => message.conversationId === conversationId) - .filter((message) => message.injectedAtMs === void 0) - .sort(compareMessages) - : []; - const inboundMessageIds = Array.isArray(value.inboundMessageIds) - ? uniqueStrings( - value.inboundMessageIds - .map((id) => (typeof id === "string" ? id : void 0)) - .filter((id) => Boolean(id)), - ) - : []; - const lease = normalizeLease(value.lease); - const normalizedStatus = - status === "idle" && lease - ? "running" - : status === "idle" && pendingMessages2.length > 0 - ? "pending" - : status; - return { - status: normalizedStatus, - inboundMessageIds: uniqueStrings([ - ...inboundMessageIds, - ...pendingMessages2.map((message) => message.inboundMessageId), - ]), - pendingCount: pendingMessages2.length, - pendingMessages: pendingMessages2, - lease, - lastCheckpointAtMs: toOptionalNumber(value.lastCheckpointAtMs), - lastEnqueuedAtMs: toOptionalNumber(value.lastEnqueuedAtMs), - runId: toOptionalString(value.runId), - updatedAtMs: toOptionalNumber(value.updatedAtMs), - }; -} -function normalizeConversation(conversationId, value) { - if (!isRecord(value) || value.schemaVersion !== CONVERSATION_SCHEMA_VERSION) { - return void 0; - } - const storedConversationId = toOptionalString(value.conversationId); - const createdAtMs = toOptionalNumber(value.createdAtMs); - const lastActivityAtMs = toOptionalNumber(value.lastActivityAtMs); - const updatedAtMs = toOptionalNumber(value.updatedAtMs); - const execution = normalizeExecution(conversationId, value.execution); - const destination = - value.destination === void 0 ? void 0 : parseDestination(value.destination); - if ( - storedConversationId !== conversationId || - typeof createdAtMs !== "number" || - typeof lastActivityAtMs !== "number" || - typeof updatedAtMs !== "number" || - !execution || - (value.destination !== void 0 && !destination) - ) { - return void 0; - } - if ( - execution.pendingMessages.length > 0 && - (!destination || - execution.pendingMessages.some( - (message) => !sameDestination(message.destination, destination), - )) - ) { - return void 0; - } - return { - schemaVersion: CONVERSATION_SCHEMA_VERSION, - conversationId, - createdAtMs, - lastActivityAtMs, - updatedAtMs, - execution, - ...(destination ? { destination } : {}), - ...(toOptionalString(value.title) - ? { title: toOptionalString(value.title) } - : {}), - ...(toOptionalString(value.channelName) - ? { channelName: toOptionalString(value.channelName) } - : {}), - ...(normalizeActor(value.actor) - ? { actor: normalizeActor(value.actor) } - : {}), - ...(normalizeSource(value.source) - ? { source: normalizeSource(value.source) } - : {}), - }; -} -function emptyConversation2(args) { - return { - schemaVersion: CONVERSATION_SCHEMA_VERSION, - conversationId: args.conversationId, - createdAtMs: args.nowMs, - lastActivityAtMs: args.nowMs, - updatedAtMs: args.nowMs, - ...(args.destination ? { destination: args.destination } : {}), - ...(args.source ? { source: args.source } : {}), - execution: { - status: "idle", - inboundMessageIds: [], - pendingCount: 0, - pendingMessages: [], - updatedAtMs: args.nowMs, - }, - }; -} -function pendingMessages(conversation) { - return [...conversation.execution.pendingMessages].sort(compareMessages); -} -function isRunnableStatus(status) { - return status !== "failed" && status !== "idle"; -} -function hasRunnableWork(conversation) { - return ( - isRunnableStatus(conversation.execution.status) || - pendingMessages(conversation).length > 0 - ); -} -function executionWithPendingMessages(execution, pending) { - const pendingMessages2 = [...pending].sort(compareMessages); - const status = - execution.status === "idle" && execution.lease - ? "running" - : execution.status === "idle" && pendingMessages2.length > 0 - ? "pending" - : execution.status; - return { - ...execution, - status, - inboundMessageIds: uniqueStrings([ - ...execution.inboundMessageIds, - ...pendingMessages2.map((message) => message.inboundMessageId), - ]), - pendingMessages: pendingMessages2, - pendingCount: pendingMessages2.length, - }; -} -function withExecutionUpdate(conversation, execution, nowMs) { - return { - ...conversation, - updatedAtMs: nowMs, - execution: { - ...executionWithPendingMessages(execution, execution.pendingMessages), - updatedAtMs: nowMs, - }, - }; -} -async function getConnectedState(stateAdapter2) { - const state = stateAdapter2 ?? getStateAdapter(); - await state.connect(); - return state; -} -async function sleep(ms) { - await new Promise((resolve) => { - const timer = setTimeout(resolve, ms); - timer.unref?.(); - }); -} -async function withIndexLock(state, indexKey, callback) { - const startedAtMs = now2(); - let lock; - while (true) { - lock = await state.acquireLock( - indexLockKey(indexKey), - CONVERSATION_INDEX_LOCK_TTL_MS, - ); - if (lock) { - break; - } - if (now2() - startedAtMs >= CONVERSATION_INDEX_LOCK_WAIT_MS) { - throw new Error( - `Could not acquire conversation index lock for ${indexKey}`, - ); - } - await sleep(CONVERSATION_INDEX_LOCK_RETRY_MS); - } - try { - return await callback(); - } finally { - await state.releaseLock(lock); - } -} -function normalizeIndexEntry(value) { - if (!isRecord(value)) { - return void 0; - } - const conversationId = toOptionalString(value.conversationId); - const score = toOptionalNumber(value.score); - if (!conversationId || typeof score !== "number") { - return void 0; - } - return { conversationId, score }; -} -function uniqueIndexEntries(value) { - if (!Array.isArray(value)) { - return []; - } - const entries = /* @__PURE__ */ new Map(); - for (const item of value) { - const entry = normalizeIndexEntry(item); - if (!entry) { - continue; - } - const existing = entries.get(entry.conversationId); - if (!existing || entry.score > existing.score) { - entries.set(entry.conversationId, entry); - } - } - return [...entries.values()]; -} -function retainedIndexEntries(indexKey, entries) { - if (indexKey === CONVERSATION_BY_ACTIVITY_INDEX_KEY) { - return entries - .sort(compareIndexDescending) - .slice(0, CONVERSATION_ACTIVITY_INDEX_MAX_LENGTH); - } - if (indexKey === CONVERSATION_ACTIVE_INDEX_KEY) { - return entries.sort(compareIndexAscending); - } - throw new Error(`Unknown conversation index ${indexKey}`); -} -function redisIndexKey(indexKey) { - const prefix = getChatConfig().state.keyPrefix; - return [...(prefix ? [prefix] : []), indexKey].join(":"); -} -function parseRedisIndexEntries(values) { - if (!Array.isArray(values)) { - return []; - } - const entries = []; - for (let index6 = 0; index6 < values.length; index6 += 2) { - const conversationId = toOptionalString(values[index6]); - const score = - typeof values[index6 + 1] === "number" - ? values[index6 + 1] - : Number(values[index6 + 1]); - if (!conversationId || !Number.isFinite(score)) { - continue; - } - entries.push({ conversationId, score }); - } - return entries; -} -function redisConversationIndexStore(client) { - const upsertBoundedActivityScript = ` - redis.call("ZADD", KEYS[1], ARGV[1], ARGV[2]) - redis.call("PEXPIRE", KEYS[1], ARGV[3]) - local extra = redis.call("ZCARD", KEYS[1]) - tonumber(ARGV[4]) - if extra > 0 then - redis.call("ZREMRANGEBYRANK", KEYS[1], 0, extra - 1) - end - return 1 - `; - return { - async list(args) { - const key = redisIndexKey(args.indexKey); - const limit = args.limit; - const offset = Math.max(0, args.offset ?? 0); - if (limit === 0) { - return []; - } - const values = - args.scoreMax !== void 0 - ? await client.sendCommand([ - "ZRANGEBYSCORE", - key, - "-inf", - String(args.scoreMax), - "WITHSCORES", - ...(limit !== void 0 || offset > 0 - ? ["LIMIT", String(offset), String(limit ?? 1e9)] - : []), - ]) - : await client.sendCommand([ - args.order === "asc" ? "ZRANGE" : "ZREVRANGE", - key, - String(offset), - String(limit === void 0 ? -1 : offset + Math.max(0, limit - 1)), - "WITHSCORES", - ]); - return parseRedisIndexEntries(values); - }, - async remove(args) { - await client.sendCommand([ - "ZREM", - redisIndexKey(args.indexKey), - args.conversationId, - ]); - }, - async upsert(args) { - const key = redisIndexKey(args.indexKey); - if (args.indexKey === CONVERSATION_BY_ACTIVITY_INDEX_KEY) { - await client.sendCommand([ - "EVAL", - upsertBoundedActivityScript, - "1", - key, - String(args.score), - args.conversationId, - String(JUNIOR_THREAD_STATE_TTL_MS), - String(CONVERSATION_ACTIVITY_INDEX_MAX_LENGTH), - ]); - return; - } - if (args.indexKey === CONVERSATION_ACTIVE_INDEX_KEY) { - await client.sendCommand([ - "ZADD", - key, - String(args.score), - args.conversationId, - ]); - await client.sendCommand([ - "PEXPIRE", - key, - String(JUNIOR_THREAD_STATE_TTL_MS), - ]); - return; - } - throw new Error(`Unknown conversation index ${args.indexKey}`); - }, - }; -} -function emulatedConversationIndexStore(state) { - const readIndex = async (indexKey) => - uniqueIndexEntries(await state.get(indexKey)); - const writeIndex = async (indexKey, entries) => { - await state.set(indexKey, entries, JUNIOR_THREAD_STATE_TTL_MS); - }; - return { - async list(args) { - const entries = (await readIndex(args.indexKey)) - .filter((entry) => - args.scoreMax === void 0 ? true : entry.score <= args.scoreMax, - ) - .sort( - args.order === "asc" ? compareIndexAscending : compareIndexDescending, - ); - const offset = Math.max(0, args.offset ?? 0); - return entries.slice( - offset, - args.limit === void 0 ? entries.length : offset + args.limit, - ); - }, - async remove(args) { - await withIndexLock(state, args.indexKey, async () => { - const entries = await readIndex(args.indexKey); - const next = entries.filter( - (entry) => entry.conversationId !== args.conversationId, - ); - if (next.length === entries.length) { - return; - } - await writeIndex(args.indexKey, next); - }); - }, - async upsert(args) { - await withIndexLock(state, args.indexKey, async () => { - const entries = await readIndex(args.indexKey); - const withoutCurrent = entries.filter( - (entry) => entry.conversationId !== args.conversationId, - ); - const next = retainedIndexEntries(args.indexKey, [ - ...withoutCurrent, - { conversationId: args.conversationId, score: args.score }, - ]); - await writeIndex(args.indexKey, next); - }); - }, - }; -} -async function getConversationIndexStore(state) { - const redisStateAdapter2 = await getDefaultRedisStateAdapterFor(state); - if (redisStateAdapter2) { - return redisConversationIndexStore(redisStateAdapter2.getClient()); - } - return emulatedConversationIndexStore(state); -} -async function upsertIndexEntry(args) { - const index6 = await getConversationIndexStore(args.state); - await index6.upsert({ - conversationId: args.conversationId, - indexKey: args.indexKey, - score: args.score, - }); -} -async function removeIndexEntry(args) { - const index6 = await getConversationIndexStore(args.state); - await index6.remove({ - conversationId: args.conversationId, - indexKey: args.indexKey, - }); -} -async function acquireMutationLock(state, conversationId) { - const startedAtMs = now2(); - while (true) { - const lock = await state.acquireLock( - mutationLockKey(conversationId), - CONVERSATION_MUTATION_LOCK_TTL_MS, - ); - if (lock) { - return lock; - } - if (now2() - startedAtMs >= CONVERSATION_MUTATION_WAIT_MS) { - throw new Error( - `Could not acquire conversation mutation lock for ${conversationId}`, - ); - } - await sleep(CONVERSATION_MUTATION_RETRY_MS); - } -} -async function withConversationMutation(args, callback) { - const state = await getConnectedState(args.state); - const lock = await acquireMutationLock(state, args.conversationId); - try { - return await callback(state, lock); - } finally { - await state.releaseLock(lock); - } -} -async function readConversation(state, conversationId) { - const raw = await state.get(conversationKey(conversationId)); - if (raw == null) { - return void 0; - } - const conversation = normalizeConversation(conversationId, raw); - if (!conversation) { - throw new InvalidConversationRecordError(conversationId); - } - return conversation; -} -async function writeConversation(state, lock, conversation) { - const execution = executionWithPendingMessages( - conversation.execution, - conversation.execution.pendingMessages, - ); - const next = { - ...conversation, - execution, - }; - const fenced = await state.extendLock( - lock, - CONVERSATION_MUTATION_LOCK_TTL_MS, - ); - if (!fenced) { - throw new ConversationMutationFencedError(next.conversationId); - } - await state.set( - conversationKey(next.conversationId), - next, - JUNIOR_THREAD_STATE_TTL_MS, - ); - await upsertIndexEntry({ - state, - indexKey: CONVERSATION_BY_ACTIVITY_INDEX_KEY, - conversationId: next.conversationId, - score: next.lastActivityAtMs, - }); - if (!hasRunnableWork(next)) { - await removeIndexEntry({ - state, - indexKey: CONVERSATION_ACTIVE_INDEX_KEY, - conversationId: next.conversationId, - }); - return; - } - await upsertIndexEntry({ - state, - indexKey: CONVERSATION_ACTIVE_INDEX_KEY, - conversationId: next.conversationId, - score: next.execution.updatedAtMs ?? next.updatedAtMs, - }); -} -function assertSameConversationDestination2(args) { - if (!args.current || sameDestination(args.current, args.next)) { - return; - } - throw new Error( - `Conversation destination changed for ${args.conversationId}`, - ); -} -async function getConversation(args) { - const state = await getConnectedState(args.state); - return await readConversation(state, args.conversationId); -} -async function recordConversationActivity(args) { - const nowMs = args.nowMs ?? now2(); - const activityAtMs = args.activityAtMs ?? nowMs; - await withConversationMutation(args, async (state, lock) => { - const existing = await readConversation(state, args.conversationId); - if (existing && args.destination) { - assertSameConversationDestination2({ - conversationId: args.conversationId, - current: existing.destination, - next: args.destination, - }); - } - const current = - existing ?? - emptyConversation2({ - conversationId: args.conversationId, - destination: args.destination, - nowMs, - source: args.source, - }); - await writeConversation(state, lock, { - ...current, - ...((current.destination ?? args.destination) - ? { destination: current.destination ?? args.destination } - : {}), - ...((current.source ?? args.source) - ? { source: current.source ?? args.source } - : {}), - ...((current.channelName ?? args.channelName) - ? { channelName: current.channelName ?? args.channelName } - : {}), - ...((current.actor ?? args.actor) - ? { actor: current.actor ?? args.actor } - : {}), - ...((current.title ?? args.title) - ? { title: current.title ?? args.title } - : {}), - lastActivityAtMs: Math.max(current.lastActivityAtMs, activityAtMs), - updatedAtMs: nowMs, - execution: executionWithPendingMessages( - current.execution, - current.execution.pendingMessages, - ), - }); - }); -} -async function recordConversationExecution(args) { - const nowMs = args.updatedAtMs; - await withConversationMutation(args, async (state, lock) => { - const existing = await readConversation(state, args.conversationId); - if (existing && args.destination) { - assertSameConversationDestination2({ - conversationId: args.conversationId, - current: existing.destination, - next: args.destination, - }); - } - const current = - existing ?? - emptyConversation2({ - conversationId: args.conversationId, - destination: args.destination, - nowMs, - source: args.source, - }); - await writeConversation( - state, - lock, - withExecutionUpdate( - { - ...current, - ...((current.destination ?? args.destination) - ? { destination: current.destination ?? args.destination } - : {}), - ...((current.source ?? args.source) - ? { source: current.source ?? args.source } - : {}), - ...((current.channelName ?? args.channelName) - ? { channelName: current.channelName ?? args.channelName } - : {}), - ...((current.actor ?? args.actor) - ? { actor: current.actor ?? args.actor } - : {}), - ...((current.title ?? args.title) - ? { title: current.title ?? args.title } - : {}), - createdAtMs: Math.min(current.createdAtMs, args.createdAtMs), - lastActivityAtMs: Math.max( - current.lastActivityAtMs, - args.lastActivityAtMs, - ), - }, - { - ...current.execution, - ...args.execution, - }, - nowMs, - ), + executionDurationMs: executionSummary?.cumulativeDurationMs ?? 0, + executionUsage: executionSummary?.cumulativeUsage, + } + : undefined, ); - }); -} -async function listConversationsByActivity(args = {}) { - const state = await getConnectedState(args.state); - const index6 = await getConversationIndexStore(state); - const entries = await index6.list({ - indexKey: CONVERSATION_BY_ACTIVITY_INDEX_KEY, - limit: args.limit ?? CONVERSATION_ACTIVITY_INDEX_MAX_LENGTH, - offset: args.offset, - order: "desc", - }); - const conversations = []; - for (const entry of entries) { - try { - const conversation = await readConversation(state, entry.conversationId); - if (conversation) { - conversations.push(conversation); - } - } catch (error) { - if (!(error instanceof InvalidConversationRecordError)) { - throw error; - } - await removeIndexEntry({ - state, - indexKey: CONVERSATION_BY_ACTIVITY_INDEX_KEY, - conversationId: entry.conversationId, - }); - } } - return conversations; -} -// packages/junior/src/chat/conversations/state.ts -function createStateConversationStore(state) { return { - get: (args) => getConversation({ ...args, state }), - // Task-execution state has no destination records, so visibility is never - // source-confirmed here and cross-context reads fail closed to private. - getDestinationVisibility: async () => void 0, - // Child conversations are a SQL-only concept and never use this legacy - // metadata path. - ensureChildConversation: async () => void 0, - recordActivity: (args) => recordConversationActivity({ ...args, state }), - recordExecution: (args) => recordConversationExecution({ ...args, state }), - listByActivity: (args) => listConversationsByActivity({ ...args, state }), - }; -} - -// packages/junior/src/usage-schema.ts -import { z as z5 } from "zod"; -var usageCostSchema = z5 - .object({ - input: z5.number().finite().nonnegative().optional(), - output: z5.number().finite().nonnegative().optional(), - cacheRead: z5.number().finite().nonnegative().optional(), - cacheWrite: z5.number().finite().nonnegative().optional(), - total: z5.number().finite().nonnegative().optional(), - }) - .strict(); -var usageSchema = z5 - .object({ - inputTokens: z5.number().int().nonnegative().optional(), - outputTokens: z5.number().int().nonnegative().optional(), - cachedInputTokens: z5.number().int().nonnegative().optional(), - cacheCreationTokens: z5.number().int().nonnegative().optional(), - reasoningTokens: z5.number().int().nonnegative().optional(), - totalTokens: z5.number().int().nonnegative().optional(), - cost: usageCostSchema.optional(), - }) - .strict(); - -// packages/junior/src/chat/usage.ts -var agentTurnUsageSchema = usageSchema; -var COMPONENT_USAGE_FIELDS = [ - "inputTokens", - "outputTokens", - "cachedInputTokens", - "cacheCreationTokens", -]; -var COST_COMPONENT_FIELDS = ["input", "output", "cacheRead", "cacheWrite"]; -function hasAgentTurnUsage(usage) { - return Boolean( - usage && - (Object.entries(usage).some( - ([field, value]) => - field !== "cost" && typeof value === "number" && Number.isFinite(value), - ) || - Object.values(usage.cost ?? {}).some( - (value) => typeof value === "number" && Number.isFinite(value), - )), - ); -} -function getFiniteCount(value) { - return typeof value === "number" && Number.isFinite(value) - ? Math.max(0, Math.floor(value)) - : void 0; -} -function getFiniteCost(value) { - return typeof value === "number" && Number.isFinite(value) - ? Math.max(0, value) - : void 0; -} -function addCost(left, right) { - return Math.round(((left ?? 0) + right) * 1e12) / 1e12; -} -function getComponentTotal(usage) { - let total; - for (const field of COMPONENT_USAGE_FIELDS) { - const value = getFiniteCount(usage[field]); - if (value === void 0) continue; - total = (total ?? 0) + value; - } - return total; -} -function addAgentTurnUsage(...usages) { - const components = {}; - let componentTotal; - let totalOnlyTokens; - let reasoningTokens; - const cost = {}; - for (const usage of usages) { - if (!usage) continue; - const reasoning = getFiniteCount(usage.reasoningTokens); - if (reasoning !== void 0) { - reasoningTokens = (reasoningTokens ?? 0) + reasoning; - } - if (usage.cost) { - for (const field of [...COST_COMPONENT_FIELDS, "total"]) { - const value = getFiniteCost(usage.cost[field]); - if (value === void 0) continue; - cost[field] = addCost(cost[field], value); - } - } - const usageComponentTotal = getComponentTotal(usage); - if (usageComponentTotal !== void 0) { - componentTotal = (componentTotal ?? 0) + usageComponentTotal; - for (const field of COMPONENT_USAGE_FIELDS) { - const value = getFiniteCount(usage[field]); - if (value === void 0) continue; - components[field] = (components[field] ?? 0) + value; - } - continue; - } - const totalTokens = getFiniteCount(usage.totalTokens); - if (totalTokens !== void 0) { - totalOnlyTokens = (totalOnlyTokens ?? 0) + totalTokens; - } - } - if (totalOnlyTokens !== void 0) { - return { - totalTokens: totalOnlyTokens + (componentTotal ?? 0), - ...(reasoningTokens !== void 0 ? { reasoningTokens } : {}), - ...(Object.keys(cost).length > 0 ? { cost } : {}), - }; - } - const result = { - ...components, - ...(reasoningTokens !== void 0 ? { reasoningTokens } : {}), - ...(Object.keys(cost).length > 0 ? { cost } : {}), + existing: 0, + migrated: conversations.length, + missing: 0, + scanned: conversations.length, }; - return hasAgentTurnUsage(result) ? result : void 0; } -// packages/junior/src/chat/state/turn-session.ts -init_actor(); -init_session_log(); -import { THREAD_STATE_TTL_MS } from "chat"; -import { - actorSchema as actorSchema3, - destinationSchema as destinationSchema2, - sourceSchema, -} from "@sentry/junior-plugin-api"; -import { z as z13 } from "zod"; - -// packages/junior/src/chat/conversations/projection.ts -init_session_log(); -init_db(); -init_legacy_import(); - -// packages/junior/src/chat/state/turn-session.ts -init_adapter(); -init_db(); -var AGENT_TURN_SESSION_PREFIX = "junior:agent_turn_session"; -var AGENT_TURN_SESSION_INDEX_KEY = `${AGENT_TURN_SESSION_PREFIX}:index`; -var AGENT_TURN_SESSION_INDEX_READ_CONCURRENCY = 25; -var agentTurnSessionStatusSchema = z13.enum([ - "running", - "awaiting_resume", - "completed", - "failed", - "abandoned", -]); -var agentTurnSurfaceSchema = z13.enum([ - "slack", - "api", - "scheduler", - "internal", -]); -var agentTurnResumeReasonSchema = z13.enum(["timeout", "auth", "yield"]); -var nonNegativeNumberSchema = z13.number().finite().nonnegative(); -var seqCursorSchema = z13.number().int().min(-1); -var agentTurnSessionSummarySchema = z13 - .object({ - channelName: z13.string().min(1).optional(), - version: z13.number().int().nonnegative(), - conversationId: z13.string().min(1), - cumulativeDurationMs: nonNegativeNumberSchema, - cumulativeUsage: agentTurnUsageSchema.optional(), - destination: destinationSchema2.optional(), - source: sourceSchema.optional(), - lastProgressAtMs: nonNegativeNumberSchema, - loadedSkillNames: z13.array(z13.string()).optional(), - modelId: z13.string().min(1).optional(), - reasoningLevel: z13.string().min(1).optional(), - actor: actorSchema3.optional(), - resumeReason: agentTurnResumeReasonSchema.optional(), - resumedFromSliceId: z13.number().int().nonnegative().optional(), - sessionId: z13.string().min(1), - sliceId: z13.number().int().nonnegative(), - startedAtMs: nonNegativeNumberSchema, - state: agentTurnSessionStatusSchema, - surface: agentTurnSurfaceSchema.optional(), - traceId: z13.string().optional(), - updatedAtMs: nonNegativeNumberSchema, - }) - .strict(); -var storedAgentTurnSessionRecordSchema = agentTurnSessionSummarySchema - .extend({ - actors: z13.array(actorSchema3).optional(), - committedSeq: seqCursorSchema, - errorMessage: z13.string().optional(), - turnStartSeq: seqCursorSchema.optional(), - }) - .strict(); -function agentTurnSessionConversationIndexKey(conversationId) { - return `${AGENT_TURN_SESSION_PREFIX}:conversation:${conversationId}:index`; -} -function parseAgentTurnSessionSummary(value) { - return agentTurnSessionSummarySchema.parse(value); -} -async function readAgentTurnSessionSummariesFromIndex(key, stateAdapter2) { - await stateAdapter2.connect(); - const values = await stateAdapter2.getList(key); - const summaries = /* @__PURE__ */ new Map(); - for (const value of [...values].reverse()) { - const summary = parseAgentTurnSessionSummary(value); - const key2 = `${summary.conversationId}:${summary.sessionId}`; - if (!summaries.has(key2)) { - summaries.set(key2, summary); - } - } - return [...summaries.values()].sort( - (left, right) => right.updatedAtMs - left.updatedAtMs, - ); -} -async function listAgentTurnSessionSummariesForConversations( - stateAdapter2, - conversationIds, -) { - const ids = [...new Set(conversationIds)]; - const globalSummaries = await readAgentTurnSessionSummariesFromIndex( - AGENT_TURN_SESSION_INDEX_KEY, - stateAdapter2, - ); - const globalByConversation = /* @__PURE__ */ new Map(); - for (const summary of globalSummaries) { - globalByConversation.set(summary.conversationId, [ - ...(globalByConversation.get(summary.conversationId) ?? []), - summary, - ]); - } - const summariesByConversation = /* @__PURE__ */ new Map(); - let nextIndex = 0; - const readNext = async () => { - while (nextIndex < ids.length) { - const conversationId = ids[nextIndex]; - nextIndex += 1; - if (!conversationId) continue; - const summaries = await readAgentTurnSessionSummariesFromIndex( - agentTurnSessionConversationIndexKey(conversationId), - stateAdapter2, - ); - summariesByConversation.set( - conversationId, - summaries.length > 0 - ? summaries - : (globalByConversation.get(conversationId) ?? []), - ); - } - }; - await Promise.all( - Array.from( - { - length: Math.min(AGENT_TURN_SESSION_INDEX_READ_CONCURRENCY, ids.length), - }, - readNext, - ), - ); - return summariesByConversation; -} - -// ../../../../../../private/tmp/conversations-sql.ts -init_executor(); -var CONVERSATION_BACKFILL_LIMIT = 1e4; -async function migrateConversationsToSql(context, options = {}) { - const source = createStateConversationStore(context.stateAdapter); - let target = options.target; - let closeTarget; - if (!target) { - const { sql: sql11 } = getChatConfig(); - const executor = createJuniorSqlExecutor({ - connectionString: sql11.databaseUrl, - driver: sql11.driver, - }); - target = createSqlStore(executor); - closeTarget = () => executor.close(); - } - const limit = Math.max(1, options.batchSize ?? CONVERSATION_BACKFILL_LIMIT); - try { - const [stateConversations, sqlConversations] = await Promise.all([ - source.listByActivity({ limit }), - target.listByActivity({ limit }), - ]); - const byId = new Map( - sqlConversations.map((conversation) => [ - conversation.conversationId, - conversation, - ]), - ); - for (const conversation of stateConversations) { - const existing = byId.get(conversation.conversationId); - const existingExecutionAt = - existing?.execution.updatedAtMs ?? existing?.updatedAtMs ?? 0; - const stateExecutionAt = - conversation.execution.updatedAtMs ?? conversation.updatedAtMs; - if (!existing || stateExecutionAt >= existingExecutionAt) { - byId.set(conversation.conversationId, conversation); - } - } - const conversations = [...byId.values()] - .sort( - (left, right) => - right.lastActivityAtMs - left.lastActivityAtMs || - left.conversationId.localeCompare(right.conversationId), - ) - .slice(0, limit); - const summaries = await listAgentTurnSessionSummariesForConversations( - context.stateAdapter, - conversations.map((conversation) => conversation.conversationId), - ); - for (const conversation of conversations) { - const conversationSummaries = - summaries.get(conversation.conversationId) ?? []; - const executionSummary = conversation.execution.runId - ? conversationSummaries.find( - (summary) => summary.sessionId === conversation.execution.runId, - ) - : void 0; - await target.backfillConversation( - conversation, - conversationSummaries.length > 0 - ? { - durationMs: conversationSummaries.reduce( - (total, summary) => total + summary.cumulativeDurationMs, - 0, - ), - usage: addAgentTurnUsage( - ...conversationSummaries.map( - (summary) => summary.cumulativeUsage, - ), - ), - executionDurationMs: executionSummary?.cumulativeDurationMs ?? 0, - executionUsage: executionSummary?.cumulativeUsage, - } - : void 0, - ); - } - return { - existing: 0, - migrated: conversations.length, - missing: 0, - scanned: conversations.length, - }; - } finally { - await closeTarget?.(); - } -} - -// ../../../../../../private/tmp/0006_conversations_to_sql-entry.ts -init_store(); -var migration = { +const migration = { apiVersion: 1, async up(context) { return await migrateConversationsToSql( - { stateAdapter: context.state, io: { info: context.log } }, - { target: createSqlStore(context.database) }, + { stateAdapter: context.state }, + { target: createMigrationSqlStore(context.database) }, ); }, -}; -var conversations_to_sql_entry_default = migration; -export { - conversations_to_sql_entry_default as default, - migrateConversationsToSql, -}; +} satisfies MigrationV1; + +export default migration; diff --git a/packages/junior/migrations/README.md b/packages/junior/migrations/README.md index 1c2e33d89..b80f7c67a 100644 --- a/packages/junior/migrations/README.md +++ b/packages/junior/migrations/README.md @@ -18,6 +18,12 @@ database, state, Redis, and progress capabilities supplied by the runner. The host database adapter owns connections, drivers, transactions, and locks so those infrastructure modules are not frozen into every migration. +Reusable infrastructure belongs in the append-only +`@sentry/junior/migration-helpers/v1` export. It may provide stable parsers, +stores, adapter projections, and key resolution, but must not implement a +specific data migration. The journal entry remains the only owner of one-off +record transformations. + The `0000_initial.sql` baseline represents the schema already deployed by the pre-Drizzle Junior migration runner. During upgrade, existing installations adopt that baseline once; new installations execute it normally. All later diff --git a/packages/junior/package.json b/packages/junior/package.json index 2abf3c93c..a83e88448 100644 --- a/packages/junior/package.json +++ b/packages/junior/package.json @@ -24,6 +24,10 @@ "types": "./dist/instrumentation.d.ts", "default": "./dist/instrumentation.js" }, + "./migration-helpers/v1": { + "types": "./dist/migration-helpers/v1.d.ts", + "default": "./dist/migration-helpers/v1.js" + }, "./nitro": { "types": "./dist/nitro.d.ts", "default": "./dist/nitro.js" diff --git a/packages/junior/src/chat/conversations/sql/migrations.ts b/packages/junior/src/chat/conversations/sql/migrations.ts index 2891cf8b3..3e53ba9da 100644 --- a/packages/junior/src/chat/conversations/sql/migrations.ts +++ b/packages/junior/src/chat/conversations/sql/migrations.ts @@ -119,8 +119,9 @@ function migrationState(stateAdapter: StateAdapter): MigrationStateV1 { delete: async (key) => { await stateAdapter.delete(key); }, - get: async (key) => await stateAdapter.get(key), - getList: async (key) => await stateAdapter.getList(key), + get: async (key: string) => + (await stateAdapter.get(key)) ?? undefined, + getList: async (key: string) => await stateAdapter.getList(key), releaseLock: async (lock) => { await stateAdapter.releaseLock(lock); }, diff --git a/packages/junior/src/chat/plugins/migrations.ts b/packages/junior/src/chat/plugins/migrations.ts index 5583b60b8..a0321beea 100644 --- a/packages/junior/src/chat/plugins/migrations.ts +++ b/packages/junior/src/chat/plugins/migrations.ts @@ -144,8 +144,9 @@ function migrationState(stateAdapter: StateAdapter): MigrationStateV1 { delete: async (key) => { await stateAdapter.delete(key); }, - get: async (key) => await stateAdapter.get(key), - getList: async (key) => await stateAdapter.getList(key), + get: async (key: string) => + (await stateAdapter.get(key)) ?? undefined, + getList: async (key: string) => await stateAdapter.getList(key), releaseLock: async (lock) => { await stateAdapter.releaseLock(lock); }, diff --git a/packages/junior/src/cli/upgrade/types.ts b/packages/junior/src/cli/upgrade/types.ts index 218ce83bf..913af8b3b 100644 --- a/packages/junior/src/cli/upgrade/types.ts +++ b/packages/junior/src/cli/upgrade/types.ts @@ -8,7 +8,6 @@ export interface UpgradeIo { } export interface MigrationContext { - db?: unknown; io: UpgradeIo; pluginCatalogConfig?: PluginCatalogConfig; pluginSet?: JuniorPluginSet; diff --git a/packages/junior/src/migration-helpers/README.md b/packages/junior/src/migration-helpers/README.md new file mode 100644 index 000000000..9f2f3637f --- /dev/null +++ b/packages/junior/src/migration-helpers/README.md @@ -0,0 +1,13 @@ +# Migration Helpers + +This directory owns versioned, append-only infrastructure helpers available to +packaged TypeScript migrations through `@sentry/junior/migration-helpers/v1`. + +Helpers may expose stable parsing primitives, state/database adapters, stores, +and key resolution. They must not contain one-off migration decisions or data +transforms. Logic such as mapping one retired record shape into another belongs +only in the journal entry that performs that migration. + +Never change the behavior or signature of an existing version in a way that can +break a pending migration. Add a new versioned entrypoint when the helper +contract must change. diff --git a/packages/junior/src/migration-helpers/v1.ts b/packages/junior/src/migration-helpers/v1.ts new file mode 100644 index 000000000..f6c922991 --- /dev/null +++ b/packages/junior/src/migration-helpers/v1.ts @@ -0,0 +1,234 @@ +import type { Destination } from "@sentry/junior-plugin-api"; +import type { + MigrationDatabaseAdapter, + MigrationStateV1, +} from "@sentry/junior-migrations"; +import type { StateAdapter } from "chat"; +import { + isRecord as runtimeIsRecord, + toOptionalNumber as runtimeToOptionalNumber, + toOptionalString as runtimeToOptionalString, +} from "@/chat/coerce"; +import { getChatConfig } from "@/chat/config"; +import { createStateConversationStore } from "@/chat/conversations/state"; +import { createSqlStore } from "@/chat/conversations/sql/store"; +import { + parseDestination as runtimeParseDestination, + sameDestination as runtimeSameDestination, +} from "@/chat/destination"; +import { coerceThreadConversationState as runtimeCoerceThreadConversationState } from "@/chat/state/conversation"; +import { listAgentTurnSessionSummariesForConversations } from "@/chat/state/turn-session"; +import { + getConversation, + requestConversationWork, +} from "@/chat/task-execution/state"; +import { addAgentTurnUsage as runtimeAddAgentTurnUsage } from "@/chat/usage"; +import type { JuniorSqlDatabase } from "@/db/db"; + +export const JUNIOR_THREAD_STATE_TTL_MS = 7 * 24 * 60 * 60 * 1_000; + +export type MigrationSourceV1 = + | "api" + | "internal" + | "local" + | "plugin" + | "resource_event" + | "scheduler" + | "slack"; + +export type MigrationExecutionStatusV1 = + | "awaiting_resume" + | "failed" + | "idle" + | "pending" + | "running"; + +export interface MigrationInboundMessageV1 { + attemptCount?: number; + conversationId: string; + createdAtMs: number; + destination: Destination; + inboundMessageId: string; + injectedAtMs?: number; + input: { + attachments?: unknown[]; + authorId?: string; + metadata?: Record; + text: string; + }; + receivedAtMs: number; + source: MigrationSourceV1; +} + +export interface MigrationLeaseV1 { + acquiredAtMs: number; + expiresAtMs: number; + lastCheckInAtMs: number; + token: string; +} + +export interface MigrationConversationV1 { + actor?: unknown; + channelName?: string; + conversationId: string; + createdAtMs: number; + destination?: Destination; + execution: { + inboundMessageIds?: string[]; + lastCheckpointAtMs?: number; + lastEnqueuedAtMs?: number; + lease?: MigrationLeaseV1; + pendingCount?: number; + pendingMessages?: MigrationInboundMessageV1[]; + runId?: string; + status: MigrationExecutionStatusV1; + updatedAtMs?: number; + }; + lastActivityAtMs: number; + schemaVersion: 1; + source?: MigrationSourceV1; + title?: string; + updatedAtMs: number; +} + +export interface MigrationRetainedConversationV1 extends MigrationConversationV1 { + execution: MigrationConversationV1["execution"] & { + inboundMessageIds: string[]; + pendingCount: number; + pendingMessages: MigrationInboundMessageV1[]; + }; +} + +export interface MigrationThreadConversationStateV1 { + processing: { activeTurnId?: string }; +} + +export interface MigrationTurnSessionSummaryV1 { + cumulativeDurationMs: number; + cumulativeUsage?: Record; + sessionId: string; +} + +export interface MigrationStateConversationStoreV1 { + listByActivity(args: { limit: number }): Promise; +} + +export interface MigrationSqlConversationStoreV1 { + backfillConversation( + conversation: MigrationConversationV1, + metrics?: { + durationMs: number; + executionDurationMs: number; + executionUsage?: Record; + usage?: Record; + }, + ): Promise; + listByActivity(args: { limit: number }): Promise; +} + +/** Return whether a value is a non-null object record. */ +export function isRecord(value: unknown): value is Record { + return runtimeIsRecord(value); +} + +/** Return a finite number or undefined. */ +export function toOptionalNumber(value: unknown): number | undefined { + return runtimeToOptionalNumber(value); +} + +/** Return a non-empty string or undefined. */ +export function toOptionalString(value: unknown): string | undefined { + return runtimeToOptionalString(value); +} + +/** Parse one persisted destination through the stable destination schema. */ +export function parseDestination(value: unknown): Destination | undefined { + return runtimeParseDestination(value); +} + +/** Compare two persisted destinations by routing identity. */ +export function sameDestination( + left: Destination, + right: Destination, +): boolean { + return runtimeSameDestination(left, right); +} + +/** Coerce one legacy thread-state value into the retained conversation shape. */ +export function coerceThreadConversationState( + value: unknown, +): MigrationThreadConversationStateV1 { + return runtimeCoerceThreadConversationState( + value, + ) as unknown as MigrationThreadConversationStateV1; +} + +/** Merge retained turn usage records. */ +export function addAgentTurnUsage( + ...values: Array | undefined> +): Record | undefined { + return runtimeAddAgentTurnUsage(...values) as + | Record + | undefined; +} + +/** Resolve a logical state key to the configured physical Redis key. */ +export function migrationRedisKey(key: string): string { + const prefix = getChatConfig().state.keyPrefix; + return [...(prefix ? [prefix] : []), key].join(":"); +} + +/** Create the retained state-backed conversation store for a v1 migration. */ +export function createMigrationStateConversationStore( + state: MigrationStateV1, +): MigrationStateConversationStoreV1 { + return createStateConversationStore( + state as StateAdapter, + ) as unknown as MigrationStateConversationStoreV1; +} + +/** Create the SQL conversation store for a v1 migration database adapter. */ +export function createMigrationSqlStore( + database: MigrationDatabaseAdapter, +): MigrationSqlConversationStoreV1 { + return createSqlStore( + database as unknown as JuniorSqlDatabase, + ) as unknown as MigrationSqlConversationStoreV1; +} + +/** Read one retained conversation through the v1 migration state adapter. */ +export async function getMigrationConversation(args: { + conversationId: string; + state: MigrationStateV1; +}): Promise { + return (await getConversation({ + conversationId: args.conversationId, + state: args.state as StateAdapter, + })) as unknown as MigrationRetainedConversationV1 | undefined; +} + +/** Mark one retained conversation runnable through the v1 migration state adapter. */ +export async function requestMigrationConversationWork(args: { + conversationId: string; + destination: Destination; + nowMs?: number; + state: MigrationStateV1; +}): Promise { + await requestConversationWork({ + conversationId: args.conversationId, + destination: args.destination, + ...(args.nowMs === undefined ? {} : { nowMs: args.nowMs }), + state: args.state as StateAdapter, + }); +} + +/** Read retained turn summaries through the v1 migration state adapter. */ +export async function listMigrationTurnSessionSummaries( + state: MigrationStateV1, + conversationIds: string[], +): Promise> { + return (await listAgentTurnSessionSummariesForConversations( + state as StateAdapter, + conversationIds, + )) as Map; +} diff --git a/packages/junior/tests/component/scheduler-sql-plugin.test.ts b/packages/junior/tests/component/scheduler-sql-plugin.test.ts index b2ce0516d..507cadf64 100644 --- a/packages/junior/tests/component/scheduler-sql-plugin.test.ts +++ b/packages/junior/tests/component/scheduler-sql-plugin.test.ts @@ -2,7 +2,10 @@ import path from "node:path"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { createMemoryState } from "@chat-adapter/state-memory"; -import { resolveMigrations } from "@sentry/junior-migrations"; +import { + resolveMigrations, + type MigrationContextV1, +} from "@sentry/junior-migrations"; import { defineJuniorPlugin } from "@sentry/junior-plugin-api"; import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; import { @@ -84,7 +87,7 @@ async function runSchedulerStateMigration(args: { load: async () => undefined, save: async () => {}, }, - state: args.stateAdapter, + state: args.stateAdapter as unknown as MigrationContextV1["state"], }); } diff --git a/packages/junior/tsconfig.build.json b/packages/junior/tsconfig.build.json index ca568009e..3edb702bb 100644 --- a/packages/junior/tsconfig.build.json +++ b/packages/junior/tsconfig.build.json @@ -13,6 +13,7 @@ "src/app.ts", "src/handlers/**/*.ts", "src/instrumentation.ts", + "src/migration-helpers/**/*.ts", "src/nitro.ts", "src/reporting.ts", "src/vercel.ts", diff --git a/packages/junior/tsup.config.ts b/packages/junior/tsup.config.ts index 13863ad31..38fe661b6 100644 --- a/packages/junior/tsup.config.ts +++ b/packages/junior/tsup.config.ts @@ -15,6 +15,7 @@ export default defineConfig({ api: "src/api.ts", "api/schema": "src/api/schema.ts", instrumentation: "src/instrumentation.ts", + "migration-helpers/v1": "src/migration-helpers/v1.ts", nitro: "src/nitro.ts", vercel: "src/vercel.ts", },