From e63720b345e2295cd93c29d8c2f4a88946665fbe Mon Sep 17 00:00:00 2001 From: David Cramer Date: Mon, 13 Jul 2026 15:37:13 -0700 Subject: [PATCH 1/8] 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 | 39 +- .../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 +- .../0005_agent_turn_session_actor.ts | 10 + .../0006_redis_conversation_state.ts | 10 + .../migrations/0007_conversations_to_sql.ts | 10 + .../0008_conversation_history_to_sql.ts | 10 + packages/junior/migrations/README.md | 17 +- .../junior/migrations/meta/0008_snapshot.json | 1041 +++++++++++++++++ packages/junior/migrations/meta/_journal.json | 28 + 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 | 39 +- .../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 - .../junior/tests/unit/sql/executor.test.ts | 1 - packages/junior/tsup.config.ts | 1 + pnpm-lock.yaml | 83 +- scripts/bump-release-versions.mjs | 1 + 69 files changed, 3326 insertions(+), 521 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/0005_agent_turn_session_actor.ts create mode 100644 packages/junior/migrations/0006_redis_conversation_state.ts create mode 100644 packages/junior/migrations/0007_conversations_to_sql.ts create mode 100644 packages/junior/migrations/0008_conversation_history_to_sql.ts create mode 100644 packages/junior/migrations/meta/0008_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 f18765d37..3c2d72e44 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,13 +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`. -- Repair legacy token and estimated-cost rollups from durable SQL agent steps in bounded batches. Conversations that are active during the repair are left unchanged and can be repaired by rerunning the command after they become idle. -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 @@ -57,12 +72,12 @@ 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 repair-conversation-usage... Finished migration repair-conversation-usage: scanned=2 migrated=1 existing=1 missing=0 +Running migration plugin-migrations... +Finished migration plugin-migrations: scanned=8 migrated=8 existing=0 missing=0 skipped=0 Junior upgrade complete. ``` @@ -84,10 +99,6 @@ After running the command: 2. Confirm the migration summary has the expected `scanned` and `migrated` counts. 3. Run `pnpm exec junior check` before building or deploying the app. -A nonzero `missing` count for `repair-conversation-usage` means retained SQL assistant messages did not contain usable, schema-safe usage values. Junior leaves those totals unchanged. - -The command does not rewrite legacy duration totals. Run summaries are TTL-bound and do not carry an authoritative completeness marker, so even a non-empty retained index may omit a run. Replacing a total from that evidence could silently undercount it. - ## Next step Run [junior check](/cli/check/) after the upgrade, then continue with [junior snapshot create](/cli/snapshot-create/) if your plugins need sandbox dependencies. 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 f98388ae6..e0192171d 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 3e3cb8a3f..c34bb6fa5 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"; @@ -17,18 +17,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 e44822d10..01b2884aa 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/0005_agent_turn_session_actor.ts b/packages/junior/migrations/0005_agent_turn_session_actor.ts new file mode 100644 index 000000000..0a9dced8f --- /dev/null +++ b/packages/junior/migrations/0005_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/0006_redis_conversation_state.ts b/packages/junior/migrations/0006_redis_conversation_state.ts new file mode 100644 index 000000000..fb83b28a3 --- /dev/null +++ b/packages/junior/migrations/0006_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/0007_conversations_to_sql.ts b/packages/junior/migrations/0007_conversations_to_sql.ts new file mode 100644 index 000000000..d0996c225 --- /dev/null +++ b/packages/junior/migrations/0007_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/0008_conversation_history_to_sql.ts b/packages/junior/migrations/0008_conversation_history_to_sql.ts new file mode 100644 index 000000000..69ae7abd4 --- /dev/null +++ b/packages/junior/migrations/0008_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/0008_snapshot.json b/packages/junior/migrations/meta/0008_snapshot.json new file mode 100644 index 000000000..c82e8f4fa --- /dev/null +++ b/packages/junior/migrations/meta/0008_snapshot.json @@ -0,0 +1,1041 @@ +{ + "id": "f4f247d8-7b7d-420b-a78f-7a36ba8c1472", + "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, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "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", + "tableTo": "junior_conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "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, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_messages_search_idx": { + "name": "junior_conversation_messages_search_idx", + "columns": [ + { + "expression": "to_tsvector('english', \"text\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "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", + "tableTo": "junior_conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "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", + "tableTo": "junior_identities", + "columnsFrom": [ + "author_identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "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 + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "usage_json": { + "name": "usage_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "execution_duration_ms": { + "name": "execution_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "execution_usage_json": { + "name": "execution_usage_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metric_run_id": { + "name": "metric_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_conversations_last_activity_idx": { + "name": "junior_conversations_last_activity_idx", + "columns": [ + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_active_idx": { + "name": "junior_conversations_active_idx", + "columns": [ + { + "expression": "coalesce(\"execution_updated_at\", \"updated_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_conversations\".\"execution_status\" <> 'idle'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_destination_activity_idx": { + "name": "junior_conversations_destination_activity_idx", + "columns": [ + { + "expression": "destination_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_actor_activity_idx": { + "name": "junior_conversations_actor_activity_idx", + "columns": [ + { + "expression": "actor_identity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_origin_idx": { + "name": "junior_conversations_origin_idx", + "columns": [ + { + "expression": "origin_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "origin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_parent_idx": { + "name": "junior_conversations_parent_idx", + "columns": [ + { + "expression": "parent_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_conversations_destination_id_junior_destinations_id_fk": { + "name": "junior_conversations_destination_id_junior_destinations_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_destinations", + "columnsFrom": [ + "destination_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_actor_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_actor_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_identities", + "columnsFrom": [ + "actor_identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_creator_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_creator_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_identities", + "columnsFrom": [ + "creator_identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_credential_subject_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_credential_subject_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_identities", + "columnsFrom": [ + "credential_subject_identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_parent_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversations_parent_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_conversations", + "columnsFrom": [ + "parent_conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_destinations": { + "name": "junior_destinations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider_destination_id": { + "name": "provider_destination_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_destination_id": { + "name": "parent_destination_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "metadata_json": { + "name": "metadata_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_destinations_provider_destination_uidx": { + "name": "junior_destinations_provider_destination_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_destination_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_destinations_provider_kind_idx": { + "name": "junior_destinations_provider_kind_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_identities": { + "name": "junior_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider_subject_id": { + "name": "provider_subject_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata_json": { + "name": "metadata_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_normalized": { + "name": "email_normalized", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "junior_identities_provider_subject_uidx": { + "name": "junior_identities_provider_subject_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_identities_user_idx": { + "name": "junior_identities_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_identities_verified_email_idx": { + "name": "junior_identities_verified_email_idx", + "columns": [ + { + "expression": "email_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_identities\".\"email_verified\" = true AND \"junior_identities\".\"email_normalized\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_identities_kind_provider_idx": { + "name": "junior_identities_kind_provider_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_identities_user_id_junior_users_id_fk": { + "name": "junior_identities_user_id_junior_users_id_fk", + "tableFrom": "junior_identities", + "tableTo": "junior_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_users": { + "name": "junior_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "primary_email": { + "name": "primary_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "primary_email_normalized": { + "name": "primary_email_normalized", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_users_primary_email_normalized_uidx": { + "name": "junior_users_primary_email_normalized_uidx", + "columns": [ + { + "expression": "primary_email_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/junior/migrations/meta/_journal.json b/packages/junior/migrations/meta/_journal.json index e97022daa..50a14926d 100644 --- a/packages/junior/migrations/meta/_journal.json +++ b/packages/junior/migrations/meta/_journal.json @@ -36,6 +36,34 @@ "when": 1784137322420, "tag": "0004_useful_magus", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1784137322421, + "tag": "0005_agent_turn_session_actor", + "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1784137322422, + "tag": "0006_redis_conversation_state", + "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1784137322423, + "tag": "0007_conversations_to_sql", + "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1784137322424, + "tag": "0008_conversation_history_to_sql", + "breakpoints": true } ] } diff --git a/packages/junior/package.json b/packages/junior/package.json index d6bea10e8..70d23e4fe 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 f5688ee2d..225a651c6 100644 --- a/packages/junior/src/cli/upgrade.ts +++ b/packages/junior/src/cli/upgrade.ts @@ -4,20 +4,14 @@ 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 { resolveUpgradePlugins } from "./upgrade/migrations/upgrade-plugins"; -import { redisConversationStateMigration } from "./upgrade/migrations/redis-conversation-state"; -import { agentTurnSessionActorMigration } from "./upgrade/migrations/agent-turn-session-actor"; +import { migrateCoreJournal } from "./upgrade/migrations/core-journal"; +import { migratePluginJournals } from "./upgrade/migrations/plugin-journal"; import { conversationUsageRepairMigration } from "./upgrade/migrations/conversation-usage"; +import { resolveUpgradePlugins } from "./upgrade/migrations/upgrade-plugins"; import type { MigrationContext, MigrationResult, UpgradeIo, - UpgradeMigration, } from "./upgrade/types"; import { type JuniorPluginSet } from "@/plugins"; @@ -26,17 +20,6 @@ const DEFAULT_IO: UpgradeIo = { }; const localPluginLoader = createJiti(import.meta.url, { moduleCache: false }); -const MIGRATIONS: UpgradeMigration[] = [ - agentTurnSessionActorMigration, - redisConversationStateMigration, - coreSqlSchemaMigration, - sqlConversationMigration, - sqlConversationHistoryMigration, - conversationUsageRepairMigration, - sqlPluginMigration, - pluginStorageMigration, -]; - function isMissingVirtualConfig(error: unknown): boolean { if (!(error instanceof Error)) { return false; @@ -90,14 +73,20 @@ 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("repair-conversation-usage", conversationUsageRepairMigration.run); + 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 0a3cbbd3b..93dbd0315 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"; @@ -19,10 +20,10 @@ import type { PiMessage } from "@/chat/pi/messages"; 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 { repairConversationUsage } from "@/cli/upgrade/migrations/conversation-usage"; 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, @@ -44,6 +45,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, @@ -169,7 +171,7 @@ export const plugins = { ); await expect( - agentTurnSessionActorMigration.run({ + migrateAgentTurnSessionActor({ io: { info: () => {} }, stateAdapter, }), @@ -196,7 +198,7 @@ export const plugins = { expect(migratedRecord).not.toHaveProperty("requester"); await expect( - agentTurnSessionActorMigration.run({ + migrateAgentTurnSessionActor({ io: { info: () => {} }, stateAdapter, }), @@ -265,7 +267,7 @@ export const plugins = { } as unknown as RedisStateAdapter; await expect( - agentTurnSessionActorMigration.run({ + migrateAgentTurnSessionActor({ io: { info: () => {} }, redisStateAdapter, stateAdapter, @@ -290,6 +292,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(); @@ -317,7 +401,7 @@ export const plugins = { stateAdapter, }; const results = [ - await redisConversationStateMigration.run(context), + await migrateRedisConversationState(context), await migrateConversationsToSql(context, { target: sqlStore }), ]; @@ -986,7 +1070,7 @@ WHERE conversation_id = $1 await persistActiveTurn(CONVERSATION_ID, "turn-timeout"); await expect( - redisConversationStateMigration.run({ + migrateRedisConversationState({ io: { info: () => {} }, stateAdapter, }), @@ -1043,7 +1127,7 @@ WHERE conversation_id = $1 await stateAdapter.set("junior:conversation-work:index", [CONVERSATION_ID]); await expect( - redisConversationStateMigration.run({ + migrateRedisConversationState({ io: { info: () => {} }, stateAdapter, }), @@ -1120,7 +1204,7 @@ WHERE conversation_id = $1 await stateAdapter.set("junior:conversation-work:index", [CONVERSATION_ID]); await expect( - redisConversationStateMigration.run({ + migrateRedisConversationState({ io: { info: () => {} }, stateAdapter, }), @@ -1154,7 +1238,7 @@ WHERE conversation_id = $1 await stateAdapter.set("junior:conversation-work:index", [CONVERSATION_ID]); await expect( - redisConversationStateMigration.run({ + migrateRedisConversationState({ io: { info: () => {} }, stateAdapter, }), @@ -1174,7 +1258,7 @@ WHERE conversation_id = $1 }); await expect( - redisConversationStateMigration.run({ + migrateRedisConversationState({ io: { info: () => {} }, stateAdapter, }), @@ -1206,7 +1290,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/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 a85b6d4526d0212ca296ced9c8160ccec19c50b3 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Mon, 13 Jul 2026 16:12:39 -0700 Subject: [PATCH 2/8] 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 - .../0005_agent_turn_session_actor.ts | 240 +- .../0006_redis_conversation_state.ts | 1873 +++++++- .../migrations/0007_conversations_to_sql.ts | 4011 ++++++++++++++++- .../0008_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 c34bb6fa5..e57ffff1d 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"; @@ -17,32 +17,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/0005_agent_turn_session_actor.ts b/packages/junior/migrations/0005_agent_turn_session_actor.ts index 0a9dced8f..1ca8089dc 100644 --- a/packages/junior/migrations/0005_agent_turn_session_actor.ts +++ b/packages/junior/migrations/0005_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/0006_redis_conversation_state.ts b/packages/junior/migrations/0006_redis_conversation_state.ts index fb83b28a3..08e635414 100644 --- a/packages/junior/migrations/0006_redis_conversation_state.ts +++ b/packages/junior/migrations/0006_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/0007_conversations_to_sql.ts b/packages/junior/migrations/0007_conversations_to_sql.ts index d0996c225..120139bf7 100644 --- a/packages/junior/migrations/0007_conversations_to_sql.ts +++ b/packages/junior/migrations/0007_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/0008_conversation_history_to_sql.ts b/packages/junior/migrations/0008_conversation_history_to_sql.ts index 69ae7abd4..a5416145c 100644 --- a/packages/junior/migrations/0008_conversation_history_to_sql.ts +++ b/packages/junior/migrations/0008_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 93dbd0315..aa1ebf316 100644 --- a/packages/junior/tests/component/cli/upgrade-cli.test.ts +++ b/packages/junior/tests/component/cli/upgrade-cli.test.ts @@ -20,10 +20,10 @@ import type { PiMessage } from "@/chat/pi/messages"; 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 { repairConversationUsage } from "@/cli/upgrade/migrations/conversation-usage"; -import { migrateConversationsToSql } from "@/cli/upgrade/migrations/conversations-sql"; -import { migrateRedisConversationState } from "@/cli/upgrade/migrations/redis-conversation-state"; +import { migrateAgentTurnSessionActor } from "../../../migrations/0005_agent_turn_session_actor"; +import { migrateRedisConversationState } from "../../../migrations/0006_redis_conversation_state"; +import { migrateConversationsToSql } from "../../../migrations/0007_conversations_to_sql"; import { CONVERSATION_ID, SLACK_DESTINATION, @@ -296,10 +296,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); @@ -320,14 +331,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({ @@ -339,21 +342,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) => @@ -361,7 +366,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 fb14774db..5207b39e6 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/0008_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 04f568c8a34c5ecebb664b2804ee966d75423097 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Mon, 13 Jul 2026 16:23:56 -0700 Subject: [PATCH 3/8] 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/0007_conversations_to_sql.ts | 296 +----------------- .../0008_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/0007_conversations_to_sql.ts b/packages/junior/migrations/0007_conversations_to_sql.ts index 120139bf7..dbbbf7a58 100644 --- a/packages/junior/migrations/0007_conversations_to_sql.ts +++ b/packages/junior/migrations/0007_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/0008_conversation_history_to_sql.ts b/packages/junior/migrations/0008_conversation_history_to_sql.ts index a5416145c..22fd052c9 100644 --- a/packages/junior/migrations/0008_conversation_history_to_sql.ts +++ b/packages/junior/migrations/0008_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 f5684974de2816f3de518d3430bb2eabfc55a55b Mon Sep 17 00:00:00 2001 From: David Cramer Date: Mon, 13 Jul 2026 17:11:42 -0700 Subject: [PATCH 4/8] 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 + .../0005_agent_turn_session_actor.ts | 176 +- .../0006_redis_conversation_state.ts | 1616 ++----- .../migrations/0007_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/0005_agent_turn_session_actor.ts b/packages/junior/migrations/0005_agent_turn_session_actor.ts index 1ca8089dc..7018c1653 100644 --- a/packages/junior/migrations/0005_agent_turn_session_actor.ts +++ b/packages/junior/migrations/0005_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/0006_redis_conversation_state.ts b/packages/junior/migrations/0006_redis_conversation_state.ts index 08e635414..0463efb51 100644 --- a/packages/junior/migrations/0006_redis_conversation_state.ts +++ b/packages/junior/migrations/0006_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/0007_conversations_to_sql.ts b/packages/junior/migrations/0007_conversations_to_sql.ts index dbbbf7a58..51c62687b 100644 --- a/packages/junior/migrations/0007_conversations_to_sql.ts +++ b/packages/junior/migrations/0007_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 70d23e4fe..4e192c0aa 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", }, From e763a82ef634ed695602ddeda309744175a63b99 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Thu, 16 Jul 2026 12:08:21 -0700 Subject: [PATCH 5/8] ref(migrations): Reduce mixed journal bloat Rebase the mixed journal onto the latest schema, move the new conversation usage repair into the journal, and preserve its focused tests. Remove unchanged Drizzle snapshots for data entries, rebuild the legacy history migration around versioned infrastructure helpers, and reject runtime re-export bypasses. Co-Authored-By: GPT-5.6 Codex --- packages/docs/src/content/docs/cli/upgrade.md | 4 +- packages/junior-migrations/README.md | 6 +- packages/junior-migrations/src/generate.ts | 8 +- packages/junior-migrations/src/journal.ts | 32 +- .../junior-migrations/tests/generate.test.ts | 3 + .../junior-migrations/tests/journal.test.ts | 12 + .../migrations/meta/0002_snapshot.json | 251 -- .../0008_conversation_history_to_sql.ts | 3481 +++-------------- .../0009_conversation_usage.ts} | 122 +- packages/junior/migrations/README.md | 7 +- .../junior/migrations/meta/0008_snapshot.json | 1041 ----- packages/junior/migrations/meta/_journal.json | 7 + packages/junior/src/chat/state/session-log.ts | 17 +- packages/junior/src/cli/upgrade.ts | 2 - packages/junior/src/migration-helpers/v1.ts | 66 + .../tests/component/cli/upgrade-cli.test.ts | 10 +- .../integration/conversation-sql.test.ts | 2 +- 17 files changed, 721 insertions(+), 4350 deletions(-) delete mode 100644 packages/junior-scheduler/migrations/meta/0002_snapshot.json rename packages/junior/{src/cli/upgrade/migrations/conversation-usage.ts => migrations/0009_conversation_usage.ts} (85%) delete mode 100644 packages/junior/migrations/meta/0008_snapshot.json diff --git a/packages/docs/src/content/docs/cli/upgrade.md b/packages/docs/src/content/docs/cli/upgrade.md index 3c2d72e44..4775e2f22 100644 --- a/packages/docs/src/content/docs/cli/upgrade.md +++ b/packages/docs/src/content/docs/cli/upgrade.md @@ -73,9 +73,7 @@ Typical logs look like this: ```text Running Junior upgrade migrations... Running migration core-migrations... -Finished migration core-migrations: scanned=8 migrated=4 existing=4 missing=0 skipped=0 -Running migration repair-conversation-usage... -Finished migration repair-conversation-usage: scanned=2 migrated=1 existing=1 missing=0 +Finished migration core-migrations: scanned=10 migrated=6 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/junior-migrations/README.md b/packages/junior-migrations/README.md index 1abc59712..9772335d7 100644 --- a/packages/junior-migrations/README.md +++ b/packages/junior-migrations/README.md @@ -22,8 +22,10 @@ junior-migrations generate \ --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. +The wrapper asks Drizzle Kit to create a custom journal entry, replaces the +empty SQL file with a `MigrationV1` TypeScript scaffold, and removes the +unchanged custom snapshot. Schema generation continues from the latest real +schema snapshot while preserving the mixed journal order. ## Compatibility diff --git a/packages/junior-migrations/src/generate.ts b/packages/junior-migrations/src/generate.ts index 3c78c79a3..20e703fe8 100644 --- a/packages/junior-migrations/src/generate.ts +++ b/packages/junior-migrations/src/generate.ts @@ -1,5 +1,5 @@ import { spawn } from "node:child_process"; -import { readFile, rename, writeFile } from "node:fs/promises"; +import { readFile, rename, rm, writeFile } from "node:fs/promises"; import { resolve } from "node:path"; import { readMigrationJournal } from "./journal"; @@ -62,7 +62,13 @@ export async function generateTypeScriptMigration( throw new Error(`Refusing to replace non-custom migration ${entry.tag}`); } const typescriptPath = resolve(folder, `${entry.tag}.ts`); + const snapshotPath = resolve( + folder, + "meta", + `${String(entry.index).padStart(4, "0")}_snapshot.json`, + ); await rename(sqlPath, typescriptPath); + await rm(snapshotPath, { force: true }); await writeFile(typescriptPath, scaffold(), "utf8"); return typescriptPath; } diff --git a/packages/junior-migrations/src/journal.ts b/packages/junior-migrations/src/journal.ts index 826ffef26..685533b7e 100644 --- a/packages/junior-migrations/src/journal.ts +++ b/packages/junior-migrations/src/journal.ts @@ -54,15 +54,7 @@ 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, - ); - for (const match of imports) { - const clause = match[1]?.trim(); - const specifier = match[2]; - if (clause?.startsWith("type ")) { - continue; - } + const validateRuntimeSpecifier = (specifier: string | undefined): void => { if ( !specifier || specifier.startsWith(".") || @@ -76,10 +68,30 @@ function validateTypeScriptSource(tag: string, source: string): void { `TypeScript migration ${tag} cannot import application runtime code`, ); } + }; + 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 ")) { + continue; + } + validateRuntimeSpecifier(specifier); + } + const exports = source.matchAll( + /^\s*export\s+([^;]+?)\s+from\s+["']([^"']+)["'];?/gm, + ); + for (const match of exports) { + const clause = match[1]?.trim(); + if (clause?.startsWith("type ")) { + continue; + } + validateRuntimeSpecifier(match[2]); } 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`); diff --git a/packages/junior-migrations/tests/generate.test.ts b/packages/junior-migrations/tests/generate.test.ts index 399d368a8..1513a6312 100644 --- a/packages/junior-migrations/tests/generate.test.ts +++ b/packages/junior-migrations/tests/generate.test.ts @@ -70,6 +70,9 @@ it("keeps Drizzle schema generation working across a TypeScript entry", async () await expect( access(join(root, "migrations", "0001_backfill.sql")), ).rejects.toThrow("ENOENT"); + await expect( + access(join(root, "migrations", "meta", "0001_snapshot.json")), + ).rejects.toThrow("ENOENT"); await writeFile( join(root, "schema.ts"), diff --git a/packages/junior-migrations/tests/journal.test.ts b/packages/junior-migrations/tests/journal.test.ts index 0acef14cf..4309b58e6 100644 --- a/packages/junior-migrations/tests/journal.test.ts +++ b/packages/junior-migrations/tests/journal.test.ts @@ -74,6 +74,18 @@ describe("resolveMigrations", () => { ]); }); + it("rejects runtime re-exports of Junior internals", async () => { + const folder = await migrationFolder(["0000_reexport"]); + await writeFile( + join(folder, "0000_reexport.ts"), + 'export { getChatConfig } from "@sentry/junior/internal";\nexport default { apiVersion: 1, async up() {} };\n', + ); + + await expect(resolveMigrations(folder)).rejects.toThrow( + "cannot import application runtime code", + ); + }); + 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-scheduler/migrations/meta/0002_snapshot.json b/packages/junior-scheduler/migrations/meta/0002_snapshot.json deleted file mode 100644 index 3452e36a6..000000000 --- a/packages/junior-scheduler/migrations/meta/0002_snapshot.json +++ /dev/null @@ -1,251 +0,0 @@ -{ - "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/migrations/0008_conversation_history_to_sql.ts b/packages/junior/migrations/0008_conversation_history_to_sql.ts index 22fd052c9..cc2e75c7b 100644 --- a/packages/junior/migrations/0008_conversation_history_to_sql.ts +++ b/packages/junior/migrations/0008_conversation_history_to_sql.ts @@ -1,1336 +1,236 @@ // @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") -); - -// 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/conversations/legacy-import.ts +import { isDeepStrictEqual } from "node:util"; +import { eq as eq3 } from "drizzle-orm"; +import { z as z4 } from "zod"; -// 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(); - }, -}); +// migration:visible +import { toMigrationStoredConversationMessage } from "@sentry/junior/migration-helpers/v1"; -// 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; +// migration:state +function getStateAdapter() { + throw new Error("state adapter required"); } -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 +// migration:session-log 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, - ), - ], - ); - }, -}); + migrationContextProvenance, + migrationLegacyActorProvenance, + decodeMigrationSessionLogEntry, + migrationPiMessageProvenanceSchema, +} from "@sentry/junior/migration-helpers/v1"; -// 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, - ), - ], - ); - }, -}); +// migration:advisor +import { createMigrationLegacyAdvisorSessionReader } from "@sentry/junior/migration-helpers/v1"; -// packages/junior/src/db/schema/conversations.ts -import { sql as sql2 } from "drizzle-orm"; +// migration:db-schema 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, - ), - ], - ); - }, -}); + migrationJuniorAgentSteps, + migrationJuniorConversationMessages, + migrationJuniorConversations, +} from "@sentry/junior/migration-helpers/v1"; -// 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/chat/conversations/sql/history.ts +import { and, asc, eq, isNotNull, sql as sql2 } from "drizzle-orm"; -// 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/chat/conversations/history.ts +import { z as z3 } from "zod"; -// 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(); - }, -}); +// packages/junior/src/chat/pi/messages.ts +import { z } from "zod"; +var piMessageSchema = z + .object({ + role: z.string(), + }) + .passthrough() + .transform((value) => value); +var piContentMessageSchema = z + .object({ + content: z.array(z.unknown()), + role: z.string().min(1), + }) + .passthrough() + .transform((value) => value); -// packages/junior/src/chat/identities/identity.ts -var init_identity = __esm({ - "packages/junior/src/chat/identities/identity.ts"() { - "use strict"; - }, -}); +// packages/junior/src/chat/model-profile.ts +import { z as z2 } from "zod"; +var modelProfileSchema = z2.string().regex(/^[a-z][a-z0-9_-]*$/); -// 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/history.ts +var handoffModelProfileSchema = modelProfileSchema.refine( + (profile) => profile !== "standard", + "handoff profile must not be standard", +); +var piMessageStepEntrySchema = z3.object({ + type: z3.literal("pi_message"), + schemaVersion: z3.number().int().optional(), + message: piMessageSchema, + provenance: migrationPiMessageProvenanceSchema.optional(), }); - -// 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(); - }, +var contextEpochStartedEntrySchema = z3.union([ + z3 + .object({ + type: z3.literal("context_epoch_started"), + reason: z3.literal("initial"), + modelProfile: z3.literal("standard"), + modelId: z3.string().min(1), + }) + .strict(), + z3 + .object({ + type: z3.literal("context_epoch_started"), + reason: z3.literal("handoff"), + modelProfile: handoffModelProfileSchema, + modelId: z3.string().min(1), + }) + .strict(), + z3 + .object({ + type: z3.literal("context_epoch_started"), + reason: z3.union([z3.literal("compaction"), z3.literal("rollback")]), + // TODO(v0.97.0): Remove support for deployed compaction/rollback markers + // without model bindings after those rows pass the retention horizon. + modelProfile: z3.undefined().optional(), + modelId: z3.undefined().optional(), + }) + .strict(), + z3 + .object({ + type: z3.literal("context_epoch_started"), + reason: z3.union([z3.literal("compaction"), z3.literal("rollback")]), + modelProfile: modelProfileSchema, + modelId: z3.string().min(1), + }) + .strict(), +]); +var piMessageStepSchema = z3 + .object({ + message: piMessageSchema, + createdAtMs: z3.number().finite(), + provenance: migrationPiMessageProvenanceSchema.optional(), + }) + .strict(); +var contextEpochStartSchema = z3.discriminatedUnion("reason", [ + z3 + .object({ + reason: z3.literal("initial"), + modelProfile: z3.literal("standard"), + modelId: z3.string().min(1), + messages: z3.array(piMessageStepSchema), + }) + .strict(), + z3 + .object({ + reason: z3.literal("handoff"), + modelProfile: handoffModelProfileSchema, + modelId: z3.string().min(1), + messages: z3.array(piMessageStepSchema), + }) + .strict(), + z3 + .object({ + reason: z3.union([z3.literal("compaction"), z3.literal("rollback")]), + modelProfile: modelProfileSchema, + modelId: z3.string().min(1), + messages: z3.array(piMessageStepSchema), + }) + .strict(), +]); +var mcpProviderConnectedEntrySchema = z3.object({ + type: z3.literal("mcp_provider_connected"), + provider: z3.string().min(1), }); - -// 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); - }, +var authorizationKindSchema = z3.union([ + z3.literal("plugin"), + z3.literal("mcp"), +]); +var authorizationRequestedEntrySchema = z3.object({ + type: z3.literal("authorization_requested"), + kind: authorizationKindSchema, + provider: z3.string().min(1), + actorId: z3.string().min(1), + authorizationId: z3.string().min(1), + delivery: z3.union([ + z3.literal("private_link_sent"), + z3.literal("private_link_reused"), + ]), }); - -// 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(); - }, +var authorizationCompletedEntrySchema = z3.object({ + type: z3.literal("authorization_completed"), + kind: authorizationKindSchema, + provider: z3.string().min(1), + actorId: z3.string().min(1), + authorizationId: z3.string().min(1), }); - -// 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; - }, +var toolExecutionStartedEntrySchema = z3.object({ + type: z3.literal("tool_execution_started"), + toolCallId: z3.string().min(1), + toolName: z3.string().min(1), + args: z3.unknown().optional(), }); - -// 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; - }, +var visibleContextCompactedEntrySchema = z3.object({ + type: z3.literal("visible_context_compacted"), + compactions: z3.array( + z3.object({ + coveredMessageIds: z3.array(z3.string()), + createdAtMs: z3.number(), + id: z3.string().min(1), + summary: z3.string(), + }), + ), }); - -// 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, +var subagentStartedEntrySchema = z3 + .object({ + type: z3.literal("subagent_started"), + subagentInvocationId: z3.string().min(1), + subagentKind: z3.string().min(1), + modelId: z3.string().min(1).optional(), + parentToolCallId: z3.string().min(1).optional(), + reasoningLevel: z3.string().min(1).optional(), + childConversationId: z3.string().min(1), + historyMode: z3.union([z3.literal("isolated"), z3.literal("shared")]), + }) + .strict(); +var subagentEndedEntrySchema = z3.object({ + type: z3.literal("subagent_ended"), + subagentInvocationId: z3.string().min(1), + outcome: z3.union([ + z3.literal("success"), + z3.literal("error"), + z3.literal("aborted"), + ]), + errorCode: z3.string().min(1).optional(), +}); +var appendableAgentStepEntrySchema = z3.union([ + piMessageStepEntrySchema, mcpProviderConnectedEntrySchema, - authorizationKindSchema, authorizationRequestedEntrySchema, authorizationCompletedEntrySchema, - transcriptRefSchema, toolExecutionStartedEntrySchema, + visibleContextCompactedEntrySchema, 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, +]); +var agentStepEntrySchema = z3.union([ 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(); - }, -}); + contextEpochStartedEntrySchema, +]); +var newAgentStepSchema = z3 + .object({ + entry: appendableAgentStepEntrySchema, + createdAtMs: z3.number().finite(), + }) + .strict(); // packages/junior/src/chat/conversations/sql/conversation-row.ts -import { sql as sql6 } from "drizzle-orm"; +import { sql } from "drizzle-orm"; async function ensureConversationRow(executor, conversationId, atMs) { const at = new Date(atMs); await executor .db() - .insert(juniorConversations) + .insert(migrationJuniorConversations) .values({ conversationId, createdAt: at, @@ -1339,23 +239,39 @@ async function ensureConversationRow(executor, conversationId, atMs) { executionStatus: "idle", }) .onConflictDoUpdate({ - target: juniorConversations.conversationId, + target: migrationJuniorConversations.conversationId, set: { - lastActivityAt: sql6`greatest(${juniorConversations.lastActivityAt}, excluded.last_activity_at)`, - updatedAt: sql6`greatest(${juniorConversations.updatedAt}, excluded.updated_at)`, + lastActivityAt: sql`greatest(${migrationJuniorConversations.lastActivityAt}, excluded.last_activity_at)`, + updatedAt: sql`greatest(${migrationJuniorConversations.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/db/postgres-json.ts +function sanitizePostgresJson(value) { + if (typeof value === "string") { + return value.replaceAll("\0", " "); + } + if (Array.isArray(value)) { + return value.map((item) => sanitizePostgresJson(item)); + } + if (value === null || typeof value !== "object") { + return value; + } + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + return value; + } + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [ + key, + sanitizePostgresJson(item), + ]), + ); +} // 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; @@ -1371,7 +287,7 @@ function insertFromStep(conversationId, seq, contextEpoch, step) { contextEpoch, type, role: messageRole(step.entry), - payload, + payload: sanitizePostgresJson(payload), createdAt: new Date(step.createdAtMs), }; } @@ -1394,648 +310,137 @@ function piMessageStep(step) { 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(); - }, -}); - -// 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/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; +var SqlAgentStepStore = class { + constructor(executor) { + this.executor = executor; } - 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((key3, index6) => [key3, index6]), + 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), ); - }, -}); - -// 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(), + await this.executor.transaction(async () => { + await ensureConversationRow( + this.executor, + conversationId, + newestCreatedAtMs, + ); + await this.executor + .db() + .update(migrationJuniorConversations) + .set({ archivedAt: null }) + .where( + and( + eq(migrationJuniorConversations.conversationId, conversationId), + isNotNull(migrationJuniorConversations.archivedAt), + ), + ); + 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(migrationJuniorAgentSteps).values(rows); }); - }, -}); - -// 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 -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, + } + async startEpoch(conversationId, opts) { + const parsed = contextEpochStartSchema.parse(opts); + await this.executor.transaction(async () => { + await ensureConversationRow(this.executor, conversationId, Date.now()); + await this.executor + .db() + .update(migrationJuniorConversations) + .set({ archivedAt: null }) + .where( + and( + eq(migrationJuniorConversations.conversationId, conversationId), + isNotNull(migrationJuniorConversations.archivedAt), + ), + ); + 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(migrationJuniorAgentSteps).values(rows); }); - }, -}); - -// 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; + async loadCurrentEpoch(conversationId) { + const cursor = await this.readCursor(conversationId); + if (cursor.maxEpoch === null) { + return []; + } + const rows = await this.executor + .db() + .select() + .from(migrationJuniorAgentSteps) + .where( + and( + eq(migrationJuniorAgentSteps.conversationId, conversationId), + eq(migrationJuniorAgentSteps.contextEpoch, cursor.maxEpoch), + ), + ) + .orderBy(asc(migrationJuniorAgentSteps.seq)); + return rows.map(stepFromRow); + } + async loadHistory(conversationId) { + const rows = await this.executor + .db() + .select() + .from(migrationJuniorAgentSteps) + .where(eq(migrationJuniorAgentSteps.conversationId, conversationId)) + .orderBy(asc(migrationJuniorAgentSteps.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: sql2`max(${migrationJuniorAgentSteps.seq})`, + maxEpoch: sql2`max(${migrationJuniorAgentSteps.contextEpoch})`, + }) + .from(migrationJuniorAgentSteps) + .where(eq(migrationJuniorAgentSteps.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, + }; } - return { - messageId: message.id, - role: message.role, - text: message.text, - ...(Object.keys(meta).length > 0 ? { meta } : {}), - createdAtMs: message.createdAtMs, - }; +}; +function createSqlAgentStepStore(executor) { + return new SqlAgentStepStore(executor); } -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 eq2, sql as sql3 } from "drizzle-orm"; + +// migration:xml +import { migrationUnescapeXml } from "@sentry/junior/migration-helpers/v1"; // packages/junior/src/chat/conversations/sql/legacy-history-import.ts -import { eq as eq6, sql as sql10 } from "drizzle-orm"; +var INITIAL_SESSION_ID = "session_0"; +var ADVISOR_TASK_OPEN = "\n"; +var ADVISOR_TASK_CLOSE = "\n"; +var ADVISOR_CONTEXT_OPEN = "\n"; +var ADVISOR_CONTEXT_CLOSE = "\n"; function importedAdvisorChildConversationId(parentConversationId) { return `advisor:${parentConversationId}`; } @@ -2044,33 +449,33 @@ function epochFromSessionId(sessionId) { return match ? Number(match[1]) : 0; } function messageTimestampMs(message) { - const timestamp2 = message.timestamp; - return typeof timestamp2 === "number" ? timestamp2 : void 0; + const timestamp = message.timestamp; + return typeof timestamp === "number" ? timestamp : void 0; } -function readAdvisorRequest(text7) { +function readAdvisorRequest(text) { if ( - !text7.startsWith(ADVISOR_TASK_OPEN) || - !text7.endsWith(ADVISOR_CONTEXT_CLOSE) + !text.startsWith(ADVISOR_TASK_OPEN) || + !text.endsWith(ADVISOR_CONTEXT_CLOSE) ) { return void 0; } - const taskEnd = text7.indexOf(ADVISOR_TASK_CLOSE, ADVISOR_TASK_OPEN.length); + const taskEnd = text.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)) { + if (!text.startsWith(ADVISOR_CONTEXT_OPEN, contextStart)) { return void 0; } - const task = text7.slice(ADVISOR_TASK_OPEN.length, taskEnd); - const context = text7.slice( + const task = text.slice(ADVISOR_TASK_OPEN.length, taskEnd); + const context = text.slice( contextStart + ADVISOR_CONTEXT_OPEN.length, -ADVISOR_CONTEXT_CLOSE.length, ); - return `${unescapeXml(task)} + return `${migrationUnescapeXml(task)} Executor context: -${unescapeXml(context)}`; +${migrationUnescapeXml(context)}`; } function normalizeAdvisorMessage(message) { const record = message; @@ -2087,12 +492,12 @@ function normalizeAdvisorMessage(message) { ) { return part; } - const text7 = readAdvisorRequest(part.text); - if (text7 === void 0) { + const text = readAdvisorRequest(part.text); + if (text === void 0) { return part; } changed = true; - return { ...part, text: text7 }; + return { ...part, text }; }); return changed ? { ...record, content } : message; } @@ -2101,9 +506,9 @@ function piEntryProvenance(entry) { return entry.provenance; } if (entry.actor) { - return legacyActorProvenance(entry.actor); + return migrationLegacyActorProvenance(entry.actor); } - return contextProvenance; + return migrationContextProvenance; } function convertLegacySessionLog(args) { const steps = []; @@ -2115,7 +520,7 @@ function convertLegacySessionLog(args) { seq += 1; }; for (const entry of args.entries) { - const epoch = epochFromSessionId(entry.sessionId ?? INITIAL_SESSION_ID2); + const epoch = epochFromSessionId(entry.sessionId ?? INITIAL_SESSION_ID); switch (entry.type) { case "pi_message": { push( @@ -2131,7 +536,8 @@ function convertLegacySessionLog(args) { } case "projection_reset": { const provenance = - entry.provenance ?? entry.messages.map(() => contextProvenance); + entry.provenance ?? + entry.messages.map(() => migrationContextProvenance); if (provenance.length !== entry.messages.length) { throw new Error( "projection_reset provenance must align one-to-one with messages", @@ -2142,13 +548,13 @@ function convertLegacySessionLog(args) { { type: "context_epoch_started", reason: "compaction" }, fallback, ); - entry.messages.forEach((message, index6) => { + entry.messages.forEach((message, index) => { push( epoch, { type: "pi_message", message, - provenance: provenance[index6], + provenance: provenance[index], }, messageTimestampMs(message) ?? fallback, ); @@ -2255,7 +661,11 @@ function convertAdvisorMessages(messages, fallbackCreatedAtMs) { return { seq, contextEpoch: 0, - entry: { type: "pi_message", message, provenance: contextProvenance }, + entry: { + type: "pi_message", + message, + provenance: migrationContextProvenance, + }, createdAtMs: messageTimestampMs(message) ?? fallbackCreatedAtMs, }; }); @@ -2275,7 +685,7 @@ function insertRow(conversationId, step) { contextEpoch: step.contextEpoch, type, role: messageRole2(step.entry), - payload, + payload: sanitizePostgresJson(payload), createdAt: new Date(step.createdAtMs), }; } @@ -2287,18 +697,25 @@ async function writeLegacyImport(executor, args) { const db = executor.db(); const conversations = await db .select({ - transcriptPurgedAt: juniorConversations.transcriptPurgedAt, + transcriptPurgedAt: migrationJuniorConversations.transcriptPurgedAt, }) - .from(juniorConversations) - .where(eq6(juniorConversations.conversationId, args.conversationId)) + .from(migrationJuniorConversations) + .where( + eq2( + migrationJuniorConversations.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)) + .select({ seq: migrationJuniorAgentSteps.seq }) + .from(migrationJuniorAgentSteps) + .where( + eq2(migrationJuniorAgentSteps.conversationId, args.conversationId), + ) .limit(1); if (existing.length > 0) { return false; @@ -2312,7 +729,7 @@ async function writeLegacyImport(executor, args) { ); if (args.messages && args.messages.length > 0) { await db - .insert(juniorConversationMessages) + .insert(migrationJuniorConversationMessages) .values( args.messages.map((message) => ({ conversationId: args.conversationId, @@ -2330,18 +747,18 @@ async function writeLegacyImport(executor, args) { ) .onConflictDoUpdate({ target: [ - juniorConversationMessages.conversationId, - juniorConversationMessages.messageId, + migrationJuniorConversationMessages.conversationId, + migrationJuniorConversationMessages.messageId, ], set: { - meta: sql10`nullif(coalesce(${juniorConversationMessages.meta}, '{}'::jsonb) || coalesce(excluded.meta, '{}'::jsonb), '{}'::jsonb)`, - repliedAt: sql10`coalesce(${juniorConversationMessages.repliedAt}, excluded.replied_at)`, + meta: sql3`nullif(coalesce(${migrationJuniorConversationMessages.meta}, '{}'::jsonb) || coalesce(excluded.meta, '{}'::jsonb), '{}'::jsonb)`, + repliedAt: sql3`coalesce(${migrationJuniorConversationMessages.repliedAt}, excluded.replied_at)`, }, }); } if (args.steps.length > 0) { await db - .insert(juniorAgentSteps) + .insert(migrationJuniorAgentSteps) .values( args.steps.map((step) => insertRow(args.conversationId, step)), ); @@ -2364,7 +781,7 @@ async function writeLegacyImport(executor, args) { ); if (args.child.steps.length > 0) { await db - .insert(juniorAgentSteps) + .insert(migrationJuniorAgentSteps) .values( args.child.steps.map((step) => insertRow(args.child.conversationId, step), @@ -2384,7 +801,7 @@ async function ensureConversationRow2( ) { await executor .db() - .insert(juniorConversations) + .insert(migrationJuniorConversations) .values({ conversationId, schemaVersion: 1, @@ -2394,11 +811,11 @@ async function ensureConversationRow2( executionStatus: "idle", }) .onConflictDoUpdate({ - target: juniorConversations.conversationId, + target: migrationJuniorConversations.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)`, + createdAt: sql3`least(${migrationJuniorConversations.createdAt}, excluded.created_at)`, + lastActivityAt: sql3`greatest(${migrationJuniorConversations.lastActivityAt}, excluded.last_activity_at)`, + updatedAt: sql3`greatest(${migrationJuniorConversations.updatedAt}, excluded.updated_at)`, }, }); } @@ -2417,7 +834,7 @@ async function ensureChildConversationRow( ); await executor .db() - .insert(juniorConversations) + .insert(migrationJuniorConversations) .values({ conversationId: childConversationId, schemaVersion: 1, @@ -2428,43 +845,48 @@ async function ensureChildConversationRow( executionStatus: "idle", }) .onConflictDoUpdate({ - target: juniorConversations.conversationId, + target: migrationJuniorConversations.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)`, + parentConversationId: sql3`coalesce(${migrationJuniorConversations.parentConversationId}, excluded.parent_conversation_id)`, + createdAt: sql3`least(${migrationJuniorConversations.createdAt}, excluded.created_at)`, + lastActivityAt: sql3`greatest(${migrationJuniorConversations.lastActivityAt}, excluded.last_activity_at)`, + updatedAt: sql3`greatest(${migrationJuniorConversations.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"; +var legacyVisibleMessageSchema = z4.object({ + id: z4.string(), + role: z4.enum(["user", "assistant", "system"]), + text: z4.string(), + createdAtMs: z4.number().finite(), + author: z4.object({}).passthrough().optional(), + meta: z4.object({}).passthrough().optional(), +}); +var legacyCompactionSchema = z4.object({ + coveredMessageIds: z4.array(z4.string()), + createdAtMs: z4.number().finite(), + id: z4.string(), + summary: z4.string(), +}); +var legacyThreadStateSnapshotSchema = z4.object({ + conversation: z4 + .object({ + compactions: z4.array(legacyCompactionSchema).optional(), + messages: z4.array(legacyVisibleMessageSchema).optional(), + stats: z4 + .object({ updatedAtMs: z4.number().finite().optional() }) + .passthrough() + .optional(), + }) + .passthrough() + .optional(), +}); async function loadThreadStateSnapshot(conversationId) { - const stateAdapter2 = getStateAdapter(); - await stateAdapter2.connect(); - const raw = await stateAdapter2.get(`thread-state:${conversationId}`); + const stateAdapter = getStateAdapter(); + await stateAdapter.connect(); + const raw = await stateAdapter.get(`thread-state:${conversationId}`); if (!raw) { return { compactions: [], messages: [] }; } @@ -2480,9 +902,9 @@ async function loadThreadStateSnapshot(conversationId) { function intrinsicTimestamps(entries, visible, compactions) { const candidates = []; const pushMessageTs = (message) => { - const timestamp2 = message.timestamp; - if (typeof timestamp2 === "number") { - candidates.push(timestamp2); + const timestamp = message.timestamp; + if (typeof timestamp === "number") { + candidates.push(timestamp); } }; for (const entry of entries) { @@ -2510,7 +932,7 @@ async function importConversationFromLegacy(conversationId, deps) { } const entries = deps.sessionLogStore ? await deps.sessionLogStore.read({ conversationId }) - : await readSessionLogEntries({ conversationId }); + : await decodeMigrationSessionLogEntry({ conversationId }); const snapshot = deps.loadVisibleMessages ? { compactions: deps.legacyCompactions ?? [], @@ -2528,14 +950,14 @@ async function importConversationFromLegacy(conversationId, deps) { const hasAdvisor = entries.some((entry) => entry.type === "subagent_started"); const advisorMessages = hasAdvisor ? await ( - deps.advisorSessionStore ?? createLegacyAdvisorSessionReader() + deps.advisorSessionStore ?? createMigrationLegacyAdvisorSessionReader() ).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 timestamp = message.timestamp; + if (typeof timestamp === "number") { + intrinsic.push(timestamp); } } const fallbackCreatedAtMs = @@ -2570,11 +992,11 @@ async function importConversationFromLegacy(conversationId, deps) { ); const fullyImported = visible.every((message) => { const existingMessage = existingMessages.get(message.id); - const projected = toStoredConversationMessage(message); + const projected = toMigrationStoredConversationMessage(message); return ( existingMessage !== void 0 && - Object.entries(projected.meta ?? {}).every(([key3, value]) => - isDeepStrictEqual(existingMessage.meta?.[key3], value), + Object.entries(projected.meta ?? {}).every(([key, value]) => + isDeepStrictEqual(existingMessage.meta?.[key], value), ) && (message.meta?.replied !== true || existingMessage.repliedAtMs !== void 0) @@ -2598,7 +1020,7 @@ async function importConversationFromLegacy(conversationId, deps) { }; } const messages = visible.map((message) => ({ - ...toStoredConversationMessage(message), + ...toMigrationStoredConversationMessage(message), ...(message.meta?.replied ? { repliedAtMs: message.createdAtMs } : {}), })); const imported = await writeLegacyImport(deps.executor, { @@ -2611,965 +1033,96 @@ async function importConversationFromLegacy(conversationId, deps) { }); 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(), - }); - }, -}); - -// ../../../../../../private/tmp/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 }), - }; -} -// ../../../../../../private/tmp/conversations-history-sql.ts -init_executor(); +// ../../../../../../private/tmp/0008-entry.ts +import { + createMigrationSqlConversationMessageStore, + createMigrationStateConversationStore, + decodeMigrationSessionLogEntry as decodeMigrationSessionLogEntry2, +} from "@sentry/junior/migration-helpers/v1"; +import { z as z5 } from "zod"; +var AGENT_SESSION_LOG_PREFIX = "junior:agent-session-log"; var HISTORY_BACKFILL_LIMIT = 1e4; +var legacyVisibleMessageSchema2 = z5.object({ + id: z5.string(), + role: z5.enum(["user", "assistant", "system"]), + text: z5.string(), + createdAtMs: z5.number().finite(), + author: z5.object({}).passthrough().optional(), + meta: z5.object({}).passthrough().optional(), +}); +var legacyCompactionSchema2 = z5.object({ + coveredMessageIds: z5.array(z5.string()), + createdAtMs: z5.number().finite(), + id: z5.string(), + summary: z5.string(), +}); +var legacyThreadStateSnapshotSchema2 = z5.object({ + conversation: z5 + .object({ + compactions: z5.array(legacyCompactionSchema2).optional(), + messages: z5.array(legacyVisibleMessageSchema2).optional(), + }) + .passthrough() + .optional(), +}); async function migrateConversationHistoryToSql(context, options = {}) { - const source = createStateConversationStore(context.stateAdapter); + const source = createMigrationStateConversationStore(context.stateAdapter); + const executor = options.executor; 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) : []; + return Array.isArray(entries) + ? entries.map(decodeMigrationSessionLogEntry2) + : []; }, 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?.(); + const messageStore = createMigrationSqlConversationMessageStore(executor); + const conversations = await source.listByActivity({ limit }); + let migrated = 0; + let existing = 0; + for (const conversation of conversations) { + const raw = options.loadVisibleMessages + ? void 0 + : await context.stateAdapter.get( + `thread-state:${conversation.conversationId}`, + ); + const stored = raw + ? legacyThreadStateSnapshotSchema2.parse(raw).conversation + : void 0; + const result = await importConversationFromLegacy( + conversation.conversationId, + { + executor, + messageStore, + conversationRecord: conversation, + sessionLogStore, + ...(options.advisorSessionStore + ? { advisorSessionStore: options.advisorSessionStore } + : {}), + loadVisibleMessages: + options.loadVisibleMessages ?? (async () => stored?.messages ?? []), + legacyCompactions: + options.legacyCompactions ?? stored?.compactions ?? [], + }, + ); + if (result.imported) migrated += 1; + else existing += 1; } + return { existing, migrated, missing: 0, scanned: conversations.length }; } - -// ../../../../../../private/tmp/0007_conversation_history_to_sql-entry.ts var migration = { apiVersion: 1, async up(context) { return await migrateConversationHistoryToSql( - { stateAdapter: context.state, io: { info: context.log } }, + { stateAdapter: context.state }, { executor: context.database }, ); }, }; -var conversation_history_to_sql_entry_default = migration; -export { - conversation_history_to_sql_entry_default as default, - migrateConversationHistoryToSql, -}; +var entry_default = migration; +export { entry_default as default, migrateConversationHistoryToSql }; diff --git a/packages/junior/src/cli/upgrade/migrations/conversation-usage.ts b/packages/junior/migrations/0009_conversation_usage.ts similarity index 85% rename from packages/junior/src/cli/upgrade/migrations/conversation-usage.ts rename to packages/junior/migrations/0009_conversation_usage.ts index f3fb5a7ce..79378a234 100644 --- a/packages/junior/src/cli/upgrade/migrations/conversation-usage.ts +++ b/packages/junior/migrations/0009_conversation_usage.ts @@ -2,10 +2,18 @@ * Repair legacy usage from durable SQL steps in bounded, retry-safe batches. * Ephemeral run summaries are not an authority because TTL can erase evidence. */ -import { getChatConfig } from "@/chat/config"; -import { createJuniorSqlExecutor } from "@/db/executor"; -import type { JuniorSqlExecutor } from "@/db/db"; -import type { MigrationContext, MigrationResult } from "../types"; +import type { + MigrationDatabaseAdapter, + MigrationV1, +} from "@sentry/junior-migrations"; + +type MigrationResult = { + existing: number; + migrated: number; + missing: number; + scanned: number; + skipped?: number; +}; const CONVERSATION_USAGE_REPAIR_BATCH_SIZE = 500; const MAX_SAFE_INTEGER = 9_007_199_254_740_991; @@ -241,72 +249,68 @@ ORDER BY candidate.conversation_id /** Rebuild legacy conversation usage from canonical durable assistant messages. */ export async function repairConversationUsage( - _context: MigrationContext, + _context: { + io?: { info(message: string): void }; + stateAdapter?: unknown; + }, options: { batchSize?: number; - executor?: JuniorSqlExecutor; - } = {}, + executor: MigrationDatabaseAdapter; + }, ): Promise { - 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 executor = options.executor; const batchSize = Math.max( 1, Math.floor(options.batchSize ?? CONVERSATION_USAGE_REPAIR_BATCH_SIZE), ); - try { - let cursor = ""; - let existing = 0; - let migrated = 0; - let missing = 0; - let scanned = 0; - let skipped = 0; + let cursor = ""; + let existing = 0; + let migrated = 0; + let missing = 0; + let scanned = 0; + let skipped = 0; - while (true) { - const sources = await executor.withLock(REPAIR_LOCK, () => - executor.query(USAGE_REPAIR_BATCH_SQL, [ - cursor, - batchSize, - ]), - ); - if (sources.length === 0) break; + while (true) { + const sources = await executor.withLock(REPAIR_LOCK, () => + executor.query(USAGE_REPAIR_BATCH_SQL, [ + cursor, + batchSize, + ]), + ); + if (sources.length === 0) break; - scanned += sources.length; - for (const source of sources) { - if (!source.repairable) { - missing += 1; - } else if (source.changed) { - migrated += 1; - } else if (source.matched) { - existing += 1; - } else { - skipped += 1; - } + scanned += sources.length; + for (const source of sources) { + if (!source.repairable) { + missing += 1; + } else if (source.changed) { + migrated += 1; + } else if (source.matched) { + existing += 1; + } else { + skipped += 1; } - cursor = sources.at(-1)!.conversationId; } - - return { - existing, - migrated, - missing, - scanned, - ...(skipped > 0 ? { skipped } : {}), - }; - } finally { - await closeExecutor?.(); + cursor = sources.at(-1)!.conversationId; } + + return { + existing, + migrated, + missing, + scanned, + ...(skipped > 0 ? { skipped } : {}), + }; } -export const conversationUsageRepairMigration = { - name: "repair-conversation-usage", - run: repairConversationUsage, -}; +const migration = { + apiVersion: 1, + async up(context) { + return await repairConversationUsage( + { io: { info: context.log } }, + { executor: context.database }, + ); + }, +} satisfies MigrationV1; + +export default migration; diff --git a/packages/junior/migrations/README.md b/packages/junior/migrations/README.md index b80f7c67a..11604fd46 100644 --- a/packages/junior/migrations/README.md +++ b/packages/junior/migrations/README.md @@ -1,9 +1,10 @@ # SQL migrations for `@sentry/junior` 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`. +TypeScript data migrations. Drizzle Kit owns numbering, schema snapshots, and +journal entries; `junior upgrade` executes each entry as either `.sql` or +`.ts` through `@sentry/junior-migrations`. Data entries do not retain an +unchanged snapshot. 1. Edit the schema under `src/db/schema/`. 2. Run `pnpm --filter @sentry/junior db:generate --name `. diff --git a/packages/junior/migrations/meta/0008_snapshot.json b/packages/junior/migrations/meta/0008_snapshot.json deleted file mode 100644 index c82e8f4fa..000000000 --- a/packages/junior/migrations/meta/0008_snapshot.json +++ /dev/null @@ -1,1041 +0,0 @@ -{ - "id": "f4f247d8-7b7d-420b-a78f-7a36ba8c1472", - "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, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "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", - "tableTo": "junior_conversations", - "columnsFrom": [ - "conversation_id" - ], - "columnsTo": [ - "conversation_id" - ], - "onDelete": "no action", - "onUpdate": "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, - "concurrently": false, - "method": "btree", - "with": {} - }, - "junior_conversation_messages_search_idx": { - "name": "junior_conversation_messages_search_idx", - "columns": [ - { - "expression": "to_tsvector('english', \"text\")", - "asc": true, - "isExpression": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "gin", - "with": {} - } - }, - "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", - "tableTo": "junior_conversations", - "columnsFrom": [ - "conversation_id" - ], - "columnsTo": [ - "conversation_id" - ], - "onDelete": "no action", - "onUpdate": "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", - "tableTo": "junior_identities", - "columnsFrom": [ - "author_identity_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "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 - }, - "archived_at": { - "name": "archived_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "duration_ms": { - "name": "duration_ms", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "usage_json": { - "name": "usage_json", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "execution_duration_ms": { - "name": "execution_duration_ms", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "execution_usage_json": { - "name": "execution_usage_json", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "metric_run_id": { - "name": "metric_run_id", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "junior_conversations_last_activity_idx": { - "name": "junior_conversations_last_activity_idx", - "columns": [ - { - "expression": "last_activity_at", - "isExpression": false, - "asc": false, - "nulls": "last" - }, - { - "expression": "conversation_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "junior_conversations_active_idx": { - "name": "junior_conversations_active_idx", - "columns": [ - { - "expression": "coalesce(\"execution_updated_at\", \"updated_at\")", - "asc": true, - "isExpression": true, - "nulls": "last" - }, - { - "expression": "conversation_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "where": "\"junior_conversations\".\"execution_status\" <> 'idle'", - "concurrently": false, - "method": "btree", - "with": {} - }, - "junior_conversations_destination_activity_idx": { - "name": "junior_conversations_destination_activity_idx", - "columns": [ - { - "expression": "destination_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "last_activity_at", - "isExpression": false, - "asc": false, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "junior_conversations_actor_activity_idx": { - "name": "junior_conversations_actor_activity_idx", - "columns": [ - { - "expression": "actor_identity_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "last_activity_at", - "isExpression": false, - "asc": false, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "junior_conversations_origin_idx": { - "name": "junior_conversations_origin_idx", - "columns": [ - { - "expression": "origin_type", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "origin_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "last_activity_at", - "isExpression": false, - "asc": false, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "junior_conversations_parent_idx": { - "name": "junior_conversations_parent_idx", - "columns": [ - { - "expression": "parent_conversation_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "junior_conversations_destination_id_junior_destinations_id_fk": { - "name": "junior_conversations_destination_id_junior_destinations_id_fk", - "tableFrom": "junior_conversations", - "tableTo": "junior_destinations", - "columnsFrom": [ - "destination_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "junior_conversations_actor_identity_id_junior_identities_id_fk": { - "name": "junior_conversations_actor_identity_id_junior_identities_id_fk", - "tableFrom": "junior_conversations", - "tableTo": "junior_identities", - "columnsFrom": [ - "actor_identity_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "junior_conversations_creator_identity_id_junior_identities_id_fk": { - "name": "junior_conversations_creator_identity_id_junior_identities_id_fk", - "tableFrom": "junior_conversations", - "tableTo": "junior_identities", - "columnsFrom": [ - "creator_identity_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "junior_conversations_credential_subject_identity_id_junior_identities_id_fk": { - "name": "junior_conversations_credential_subject_identity_id_junior_identities_id_fk", - "tableFrom": "junior_conversations", - "tableTo": "junior_identities", - "columnsFrom": [ - "credential_subject_identity_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "junior_conversations_parent_conversation_id_junior_conversations_conversation_id_fk": { - "name": "junior_conversations_parent_conversation_id_junior_conversations_conversation_id_fk", - "tableFrom": "junior_conversations", - "tableTo": "junior_conversations", - "columnsFrom": [ - "parent_conversation_id" - ], - "columnsTo": [ - "conversation_id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.junior_destinations": { - "name": "junior_destinations", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "provider": { - "name": "provider", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "provider_tenant_id": { - "name": "provider_tenant_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "''" - }, - "provider_destination_id": { - "name": "provider_destination_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "parent_destination_id": { - "name": "parent_destination_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "display_name": { - "name": "display_name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "visibility": { - "name": "visibility", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'unknown'" - }, - "metadata_json": { - "name": "metadata_json", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "junior_destinations_provider_destination_uidx": { - "name": "junior_destinations_provider_destination_uidx", - "columns": [ - { - "expression": "provider", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "provider_tenant_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "provider_destination_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - }, - "junior_destinations_provider_kind_idx": { - "name": "junior_destinations_provider_kind_idx", - "columns": [ - { - "expression": "provider", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "kind", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.junior_identities": { - "name": "junior_identities", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "provider": { - "name": "provider", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "provider_tenant_id": { - "name": "provider_tenant_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "''" - }, - "provider_subject_id": { - "name": "provider_subject_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "display_name": { - "name": "display_name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "handle": { - "name": "handle", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "email": { - "name": "email", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "avatar_url": { - "name": "avatar_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "metadata_json": { - "name": "metadata_json", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "email_normalized": { - "name": "email_normalized", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "email_verified": { - "name": "email_verified", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - } - }, - "indexes": { - "junior_identities_provider_subject_uidx": { - "name": "junior_identities_provider_subject_uidx", - "columns": [ - { - "expression": "provider", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "provider_tenant_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "provider_subject_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - }, - "junior_identities_user_idx": { - "name": "junior_identities_user_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "junior_identities_verified_email_idx": { - "name": "junior_identities_verified_email_idx", - "columns": [ - { - "expression": "email_normalized", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "where": "\"junior_identities\".\"email_verified\" = true AND \"junior_identities\".\"email_normalized\" IS NOT NULL", - "concurrently": false, - "method": "btree", - "with": {} - }, - "junior_identities_kind_provider_idx": { - "name": "junior_identities_kind_provider_idx", - "columns": [ - { - "expression": "kind", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "provider", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "junior_identities_user_id_junior_users_id_fk": { - "name": "junior_identities_user_id_junior_users_id_fk", - "tableFrom": "junior_identities", - "tableTo": "junior_users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.junior_users": { - "name": "junior_users", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "primary_email": { - "name": "primary_email", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "primary_email_normalized": { - "name": "primary_email_normalized", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "display_name": { - "name": "display_name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "junior_users_primary_email_normalized_uidx": { - "name": "junior_users_primary_email_normalized_uidx", - "columns": [ - { - "expression": "primary_email_normalized", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": {}, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} \ No newline at end of file diff --git a/packages/junior/migrations/meta/_journal.json b/packages/junior/migrations/meta/_journal.json index 50a14926d..2bb84aa68 100644 --- a/packages/junior/migrations/meta/_journal.json +++ b/packages/junior/migrations/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1784137322424, "tag": "0008_conversation_history_to_sql", "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1784137322425, + "tag": "0009_conversation_usage", + "breakpoints": true } ] } diff --git a/packages/junior/src/chat/state/session-log.ts b/packages/junior/src/chat/state/session-log.ts index cb1be1908..559c07bac 100644 --- a/packages/junior/src/chat/state/session-log.ts +++ b/packages/junior/src/chat/state/session-log.ts @@ -554,9 +554,10 @@ function subagentEndedEntry(args: { }; } -function decode(value: unknown): SessionLogEntry { +/** Decode one persisted session-log entry for versioned migration helpers. */ +export function decodeSessionLogEntry(value: unknown): SessionLogEntry { if (typeof value === "string") { - return decode(JSON.parse(value) as unknown); + return decodeSessionLogEntry(JSON.parse(value) as unknown); } const parsed = sessionLogEntrySchema.safeParse(migrateStoredEntry(value)); @@ -812,7 +813,7 @@ function redisStore(redisStateAdapter: RedisStateAdapter): SessionLogStore { }, async read(scope) { const values = await client.lRange(key(scope), 0, -1); - return values.map(decode); + return values.map(decodeSessionLogEntry); }, }; } @@ -833,8 +834,8 @@ function stateStore(): SessionLogStore { try { const existingValue = await stateAdapter.get(listKey); const existingEntries = Array.isArray(existingValue) - ? existingValue.map(decode) - : (await stateAdapter.getList(listKey)).map(decode); + ? existingValue.map(decodeSessionLogEntry) + : (await stateAdapter.getList(listKey)).map(decodeSessionLogEntry); await stateAdapter.set( listKey, [...existingEntries, ...entries], @@ -848,10 +849,10 @@ function stateStore(): SessionLogStore { const listKey = rawKey(scope); const value = await stateAdapter.get(listKey); if (Array.isArray(value)) { - return value.map(decode); + return value.map(decodeSessionLogEntry); } const values = await stateAdapter.getList(listKey); - return values.map(decode); + return values.map(decodeSessionLogEntry); }, }; } @@ -872,7 +873,7 @@ async function loadEntries( }, ): Promise { const store = args.store ?? (await defaultStore()); - return (await store.read(args)).map(decode); + return (await store.read(args)).map(decodeSessionLogEntry); } /** diff --git a/packages/junior/src/cli/upgrade.ts b/packages/junior/src/cli/upgrade.ts index 225a651c6..b5ff82c17 100644 --- a/packages/junior/src/cli/upgrade.ts +++ b/packages/junior/src/cli/upgrade.ts @@ -6,7 +6,6 @@ import { createJiti } from "jiti"; import { loadAppPluginSet } from "@/plugin-module"; import { migrateCoreJournal } from "./upgrade/migrations/core-journal"; import { migratePluginJournals } from "./upgrade/migrations/plugin-journal"; -import { conversationUsageRepairMigration } from "./upgrade/migrations/conversation-usage"; import { resolveUpgradePlugins } from "./upgrade/migrations/upgrade-plugins"; import type { MigrationContext, @@ -85,7 +84,6 @@ export async function runUpgradeMigrations( results.push(result); }; await run("core-migrations", migrateCoreJournal); - await run("repair-conversation-usage", conversationUsageRepairMigration.run); await run("plugin-migrations", migratePluginJournals); return results; } diff --git a/packages/junior/src/migration-helpers/v1.ts b/packages/junior/src/migration-helpers/v1.ts index f6c922991..1c4561ead 100644 --- a/packages/junior/src/migration-helpers/v1.ts +++ b/packages/junior/src/migration-helpers/v1.ts @@ -10,22 +10,47 @@ import { toOptionalString as runtimeToOptionalString, } from "@/chat/coerce"; import { getChatConfig } from "@/chat/config"; +import { agentStepEntrySchema } from "@/chat/conversations/history"; +import { createLegacyAdvisorSessionReader } from "@/chat/conversations/legacy-advisor-session"; +import { createSqlAgentStepStore } from "@/chat/conversations/sql/history"; +import { createSqlConversationMessageStore } from "@/chat/conversations/sql/messages"; import { createStateConversationStore } from "@/chat/conversations/state"; import { createSqlStore } from "@/chat/conversations/sql/store"; +import { toStoredConversationMessage } from "@/chat/conversations/visible-messages"; 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 { + contextProvenance, + decodeSessionLogEntry, + legacyActorProvenance, + piMessageProvenanceSchema, +} from "@/chat/state/session-log"; import { getConversation, requestConversationWork, } from "@/chat/task-execution/state"; import { addAgentTurnUsage as runtimeAddAgentTurnUsage } from "@/chat/usage"; +import { unescapeXml } from "@/chat/xml"; import type { JuniorSqlDatabase } from "@/db/db"; +import { + juniorAgentSteps, + juniorConversationMessages, + juniorConversations, +} from "@/db/schema"; export const JUNIOR_THREAD_STATE_TTL_MS = 7 * 24 * 60 * 60 * 1_000; +export const migrationAgentStepEntrySchema: unknown = agentStepEntrySchema; +export const migrationContextProvenance: unknown = contextProvenance; +export const migrationJuniorAgentSteps: unknown = juniorAgentSteps; +export const migrationJuniorConversationMessages: unknown = + juniorConversationMessages; +export const migrationJuniorConversations: unknown = juniorConversations; +export const migrationPiMessageProvenanceSchema: unknown = + piMessageProvenanceSchema; export type MigrationSourceV1 = | "api" @@ -196,6 +221,47 @@ export function createMigrationSqlStore( ) as unknown as MigrationSqlConversationStoreV1; } +/** Create the SQL agent-step store used by legacy history migrations. */ +export function createMigrationSqlAgentStepStore( + database: MigrationDatabaseAdapter, +): unknown { + return createSqlAgentStepStore(database as unknown as JuniorSqlDatabase); +} + +/** Create the SQL message store used by legacy history migrations. */ +export function createMigrationSqlConversationMessageStore( + database: MigrationDatabaseAdapter, +): unknown { + return createSqlConversationMessageStore( + database as unknown as JuniorSqlDatabase, + ); +} + +/** Create the legacy advisor reader used by history migrations. */ +export function createMigrationLegacyAdvisorSessionReader(): unknown { + return createLegacyAdvisorSessionReader(); +} + +/** Decode one retained session-log value. */ +export function decodeMigrationSessionLogEntry(value: unknown): unknown { + return decodeSessionLogEntry(value); +} + +/** Recover provenance from one legacy actor value. */ +export function migrationLegacyActorProvenance(value: unknown): unknown { + return legacyActorProvenance(value as never); +} + +/** Convert one legacy visible message into its SQL projection. */ +export function toMigrationStoredConversationMessage(value: unknown): unknown { + return toStoredConversationMessage(value as never); +} + +/** Unescape one legacy XML text fragment. */ +export function migrationUnescapeXml(value: string): string { + return unescapeXml(value); +} + /** Read one retained conversation through the v1 migration state adapter. */ export async function getMigrationConversation(args: { conversationId: string; diff --git a/packages/junior/tests/component/cli/upgrade-cli.test.ts b/packages/junior/tests/component/cli/upgrade-cli.test.ts index aa1ebf316..73fe3d3ed 100644 --- a/packages/junior/tests/component/cli/upgrade-cli.test.ts +++ b/packages/junior/tests/component/cli/upgrade-cli.test.ts @@ -20,10 +20,10 @@ import type { PiMessage } from "@/chat/pi/messages"; import { persistThreadStateById } from "@/chat/runtime/thread-state"; import { recordAgentTurnSessionSummary } from "@/chat/state/turn-session"; import { resolveUpgradePluginSet } from "@/cli/upgrade"; -import { repairConversationUsage } from "@/cli/upgrade/migrations/conversation-usage"; import { migrateAgentTurnSessionActor } from "../../../migrations/0005_agent_turn_session_actor"; import { migrateRedisConversationState } from "../../../migrations/0006_redis_conversation_state"; import { migrateConversationsToSql } from "../../../migrations/0007_conversations_to_sql"; +import { repairConversationUsage } from "../../../migrations/0009_conversation_usage"; import { CONVERSATION_ID, SLACK_DESTINATION, @@ -335,8 +335,8 @@ export const plugins = { }), ).resolves.toEqual({ existing: 0, - migrated: 8, - scanned: 8, + migrated: 10, + scanned: 10, skipped: 0, }); await expect( @@ -369,9 +369,9 @@ export const plugins = { stateAdapter, }), ).resolves.toEqual({ - existing: 8, + existing: 10, migrated: 0, - scanned: 8, + scanned: 10, skipped: 0, }); } finally { diff --git a/packages/junior/tests/integration/conversation-sql.test.ts b/packages/junior/tests/integration/conversation-sql.test.ts index aee638578..511e60f70 100644 --- a/packages/junior/tests/integration/conversation-sql.test.ts +++ b/packages/junior/tests/integration/conversation-sql.test.ts @@ -404,7 +404,7 @@ WHERE table_schema = 'public' ) ORDER BY column_name `); - expect(migrationRows?.count).toBe(4); + expect(migrationRows?.count).toBe(5); expect(searchIndex?.exists).toBe(true); expect(metricColumns.map((row) => row.column_name)).toEqual([ "duration_ms", From c676c05bab2abf005c4eee82eed5b1cd86b4a11c Mon Sep 17 00:00:00 2001 From: David Cramer Date: Thu, 16 Jul 2026 12:52:22 -0700 Subject: [PATCH 6/8] fix(migrations): Address adversarial review findings Freeze complete scheduler migration schemas, repair malformed existing scheduler rows, and add focused regression coverage. Harden migration path and JSON boundaries, narrow the runner-only locking interface, remove dead helper surface, deduplicate state projection, and preserve original migration errors. Co-Authored-By: GPT-5.6 Codex --- packages/docs/src/content/docs/cli/upgrade.md | 2 +- packages/junior-migrations/README.md | 6 +- packages/junior-migrations/package.json | 2 +- packages/junior-migrations/src/generate.ts | 15 +- packages/junior-migrations/src/index.ts | 1 + packages/junior-migrations/src/journal.ts | 29 ++- packages/junior-migrations/src/runner.ts | 91 ++++++-- packages/junior-migrations/src/types.ts | 6 +- .../junior-migrations/tests/generate.test.ts | 20 ++ .../junior-migrations/tests/journal.test.ts | 24 ++- .../junior-migrations/tests/runner.test.ts | 27 +++ .../migrations/0002_scheduler_state_to_sql.ts | 201 +++++++++++++----- .../migrations/0007_conversations_to_sql.ts | 1 + .../0008_conversation_history_to_sql.ts | 5 +- .../src/chat/conversations/sql/migrations.ts | 31 +-- .../junior/src/chat/migrations/state-v1.ts | 32 +++ .../junior/src/chat/plugins/migrations.ts | 31 +-- packages/junior/src/migration-helpers/v1.ts | 18 +- .../component/scheduler-sql-plugin.test.ts | 46 +++- .../tests/fixtures/postgres/executor.ts | 4 +- packages/junior/tests/fixtures/sql.ts | 3 +- 21 files changed, 439 insertions(+), 156 deletions(-) create mode 100644 packages/junior/src/chat/migrations/state-v1.ts diff --git a/packages/docs/src/content/docs/cli/upgrade.md b/packages/docs/src/content/docs/cli/upgrade.md index 4775e2f22..768b8c2d9 100644 --- a/packages/docs/src/content/docs/cli/upgrade.md +++ b/packages/docs/src/content/docs/cli/upgrade.md @@ -34,7 +34,7 @@ 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. +- Apply core and enabled-plugin schema and data journal entries. - 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. diff --git a/packages/junior-migrations/README.md b/packages/junior-migrations/README.md index 9772335d7..abf230771 100644 --- a/packages/junior-migrations/README.md +++ b/packages/junior-migrations/README.md @@ -6,9 +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. Every parser, transform, and migration-specific -helper belongs permanently in the migration file so application refactors or -deletions cannot break pending upgrades. +Junior runtime internals. Migration-specific parsers and transforms belong +permanently in the migration file so application refactors or deletions cannot +break pending upgrades. ## Authoring diff --git a/packages/junior-migrations/package.json b/packages/junior-migrations/package.json index 391e14d0f..36af03dbf 100644 --- a/packages/junior-migrations/package.json +++ b/packages/junior-migrations/package.json @@ -1,6 +1,6 @@ { "name": "@sentry/junior-migrations", - "version": "0.101.0", + "version": "0.104.2", "private": false, "publishConfig": { "access": "public" diff --git a/packages/junior-migrations/src/generate.ts b/packages/junior-migrations/src/generate.ts index 20e703fe8..97beca0c7 100644 --- a/packages/junior-migrations/src/generate.ts +++ b/packages/junior-migrations/src/generate.ts @@ -29,13 +29,26 @@ 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`; } +function isMissingJournal(error: unknown): boolean { + return ( + error instanceof Error && + (error.cause as NodeJS.ErrnoException | undefined)?.code === "ENOENT" + ); +} + /** 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(() => []); + let before; + try { + before = await readMigrationJournal(folder); + } catch (error) { + if (!isMissingJournal(error)) throw error; + before = []; + } await run( "drizzle-kit", [ diff --git a/packages/junior-migrations/src/index.ts b/packages/junior-migrations/src/index.ts index 6c07a55ad..1770006de 100644 --- a/packages/junior-migrations/src/index.ts +++ b/packages/junior-migrations/src/index.ts @@ -8,6 +8,7 @@ export type { MigrationDatabaseAdapter, MigrationJsonValue, MigrationJournalEntry, + MigrationJournalExecutor, MigrationLockV1, MigrationProgressV1, MigrationRedisV1, diff --git a/packages/junior-migrations/src/journal.ts b/packages/junior-migrations/src/journal.ts index 685533b7e..56ab5c969 100644 --- a/packages/junior-migrations/src/journal.ts +++ b/packages/junior-migrations/src/journal.ts @@ -1,6 +1,6 @@ import { createHash } from "node:crypto"; -import { readFile } from "node:fs/promises"; -import { join } from "node:path"; +import { lstat, readFile, realpath } from "node:fs/promises"; +import { isAbsolute, join, relative, sep } from "node:path"; import type { MigrationJournalEntry, ResolvedMigration } from "./types"; interface DrizzleJournalEntry { @@ -26,7 +26,7 @@ function parseJournalEntry( typeof value.when !== "number" || !Number.isFinite(value.when) || typeof value.tag !== "string" || - !value.tag || + !/^[a-z0-9][a-z0-9_-]*$/.test(value.tag) || typeof value.breakpoints !== "boolean" ) { throw new Error(`Invalid Drizzle journal entry at index ${position}`); @@ -39,8 +39,24 @@ function parseJournalEntry( }; } -async function optionalFile(path: string): Promise { +async function optionalFile( + path: string, + migrationsRoot: string, +): Promise { try { + const stat = await lstat(path); + if (!stat.isFile() || stat.isSymbolicLink()) { + throw new Error(`Migration source must be a regular file: ${path}`); + } + const canonical = await realpath(path); + const fromRoot = relative(migrationsRoot, canonical); + if ( + fromRoot === ".." || + fromRoot.startsWith(`..${sep}`) || + isAbsolute(fromRoot) + ) { + throw new Error(`Migration source escapes its journal root: ${path}`); + } return await readFile(path, "utf8"); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { @@ -133,14 +149,15 @@ export async function readMigrationJournal( export async function resolveMigrations( migrationsFolder: string, ): Promise { + const migrationsRoot = await realpath(migrationsFolder); 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), + optionalFile(sqlPath, migrationsRoot), + optionalFile(typescriptPath, migrationsRoot), ]); if ((sqlSource === undefined) === (typescriptSource === undefined)) { throw new Error( diff --git a/packages/junior-migrations/src/runner.ts b/packages/junior-migrations/src/runner.ts index d0d043e93..7eb99035a 100644 --- a/packages/junior-migrations/src/runner.ts +++ b/packages/junior-migrations/src/runner.ts @@ -1,6 +1,10 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; import type { MigrationContextV1, MigrationDatabaseAdapter, + MigrationJournalExecutor, + MigrationJsonValue, MigrationRunResult, MigrationV1, ResolvedMigration, @@ -15,9 +19,51 @@ interface MigrationRow { status: string | null; } +function migrationJsonValue( + value: unknown, + label: string, + seen = new WeakSet(), +): MigrationJsonValue { + if ( + value === null || + typeof value === "string" || + typeof value === "boolean" + ) { + return value; + } + if (typeof value === "number") { + if (Number.isFinite(value)) return value; + throw new Error(`${label} must contain only finite JSON numbers`); + } + if (typeof value !== "object") { + throw new Error(`${label} must be JSON-compatible`); + } + if (seen.has(value)) { + throw new Error(`${label} must not contain circular references`); + } + seen.add(value); + if (Array.isArray(value)) { + const result = value.map((item) => migrationJsonValue(item, label, seen)); + seen.delete(value); + return result; + } + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw new Error(`${label} must contain only plain JSON objects`); + } + const result = Object.fromEntries( + Object.entries(value).map(([key, item]) => [ + key, + migrationJsonValue(item, label, seen), + ]), + ); + seen.delete(value); + return result; +} + interface RunMigrationJournalBaseOptions { beforeRun?: () => Promise; - executor: MigrationDatabaseAdapter; + executor: MigrationJournalExecutor; migrationsFolder: string; migrationsTable: string; } @@ -54,7 +100,7 @@ function qualifiedTable(table: string): string { } async function ensureMigrationTable( - executor: MigrationDatabaseAdapter, + executor: MigrationJournalExecutor, table: string, ): Promise { const qualified = qualifiedTable(table); @@ -90,7 +136,7 @@ CREATE TABLE IF NOT EXISTS ${qualified} ( } async function migrationRows( - executor: MigrationDatabaseAdapter, + executor: MigrationJournalExecutor, table: string, ): Promise> { const rows = await executor.query(` @@ -107,7 +153,7 @@ ORDER BY created_at ASC, id ASC } async function adoptLegacySqlPrefix(args: { - executor: MigrationDatabaseAdapter; + executor: MigrationJournalExecutor; migrations: readonly ResolvedMigration[]; rows: Map; table: string; @@ -160,7 +206,7 @@ function migrationV1(value: unknown, tag: string): MigrationV1 { } async function runSqlMigration(args: { - executor: MigrationDatabaseAdapter; + executor: MigrationJournalExecutor; migration: ResolvedMigration; table: string; }): Promise { @@ -211,33 +257,54 @@ async function runTypeScriptMigration(args: { }>(`SELECT progress FROM ${qualified} WHERE created_at = $1 LIMIT 1`, [ args.migration.when, ]); - return current?.progress ?? undefined; + return current?.progress == null + ? undefined + : migrationJsonValue(current.progress, "Migration progress"); }, async save(value) { + const progressValue = migrationJsonValue(value, "Migration progress"); await args.executor.execute( `UPDATE ${qualified} SET progress = $1, status = 'running' WHERE created_at = $2`, - [value, args.migration.when], + [progressValue, args.migration.when], ); }, }; try { const context = args.createContext({ migration: args.migration, progress }); + const currentSource = await readFile(args.migration.path, "utf8"); + const currentHash = createHash("sha256") + .update(currentSource) + .digest("hex"); + if (currentHash !== args.migration.hash) { + throw new Error(`Migration ${args.migration.tag} changed before loading`); + } const migration = migrationV1( await args.loadTypeScript(args.migration.path), args.migration.tag, ); const result = await migration.up(context); + const resultValue = + result === undefined + ? null + : migrationJsonValue(result, "Migration result"); await args.executor.execute( `UPDATE ${qualified} SET status = 'completed', result = $1, completed_at = NOW() WHERE created_at = $2`, - [result ?? null, args.migration.when], + [resultValue, args.migration.when], ); } catch (error) { - await args.executor.execute( - `UPDATE ${qualified} SET status = 'failed' WHERE created_at = $1`, - [args.migration.when], - ); + try { + await args.executor.execute( + `UPDATE ${qualified} SET status = 'failed' WHERE created_at = $1`, + [args.migration.when], + ); + } catch (statusError) { + throw new AggregateError( + [error, statusError], + `Migration ${args.migration.tag} failed and its failure status could not be persisted`, + ); + } throw error; } } diff --git a/packages/junior-migrations/src/types.ts b/packages/junior-migrations/src/types.ts index dd2aa6813..e3b3b2528 100644 --- a/packages/junior-migrations/src/types.ts +++ b/packages/junior-migrations/src/types.ts @@ -1,4 +1,4 @@ -/** Database adapter used by the mixed migration runner and TypeScript entries. */ +/** Database capabilities exposed to v1 TypeScript migrations. */ export interface MigrationDatabaseAdapter { db(): unknown; execute(statement: string, parameters?: readonly unknown[]): Promise; @@ -8,6 +8,10 @@ export interface MigrationDatabaseAdapter { ): Promise; transaction(callback: () => Promise): Promise; withLock(lockName: string, callback: () => Promise): Promise; +} + +/** Host executor used to serialize and persist one migration journal. */ +export interface MigrationJournalExecutor extends MigrationDatabaseAdapter { withMigrationLock( migrationTable: string, callback: () => Promise, diff --git a/packages/junior-migrations/tests/generate.test.ts b/packages/junior-migrations/tests/generate.test.ts index 1513a6312..d594a1a67 100644 --- a/packages/junior-migrations/tests/generate.test.ts +++ b/packages/junior-migrations/tests/generate.test.ts @@ -100,3 +100,23 @@ it("keeps Drizzle schema generation working across a TypeScript entry", async () readFile(join(root, "migrations", "0002_add_name.sql"), "utf8"), ).resolves.toContain('ADD COLUMN "name" text'); }); + +it("does not invoke Drizzle when an existing journal is invalid", async () => { + const relativeRoot = `.tmp-invalid-migrations-${Date.now()}`; + const root = join(process.cwd(), relativeRoot); + temporaryDirectories.push(root); + await mkdir(join(root, "migrations", "meta"), { recursive: true }); + await writeFile( + join(root, "migrations", "meta", "_journal.json"), + JSON.stringify({ dialect: "sqlite", entries: [] }), + ); + + await expect( + generateTypeScriptMigration({ + configPath: `${relativeRoot}/drizzle.config.ts`, + cwd: process.cwd(), + migrationsFolder: `${relativeRoot}/migrations`, + name: "backfill", + }), + ).rejects.toThrow("Unsupported Drizzle journal"); +}); diff --git a/packages/junior-migrations/tests/journal.test.ts b/packages/junior-migrations/tests/journal.test.ts index 4309b58e6..a25336a8b 100644 --- a/packages/junior-migrations/tests/journal.test.ts +++ b/packages/junior-migrations/tests/journal.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, mkdir, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; @@ -98,4 +98,26 @@ describe("resolveMigrations", () => { "exactly one .sql or .ts file", ); }); + + it("rejects journal tags that escape the migration directory", async () => { + const folder = await migrationFolder(["../outside"]); + + await expect(resolveMigrations(folder)).rejects.toThrow( + "Invalid Drizzle journal entry", + ); + }); + + it("rejects symlinked migration sources", async () => { + const folder = await migrationFolder(["0000_symlink"]); + const outside = join(folder, "..", "outside-migration.ts"); + await writeFile( + outside, + "export default { apiVersion: 1, async up() {} };\n", + ); + await symlink(outside, join(folder, "0000_symlink.ts")); + + await expect(resolveMigrations(folder)).rejects.toThrow( + "Migration source must be a regular file", + ); + }); }); diff --git a/packages/junior-migrations/tests/runner.test.ts b/packages/junior-migrations/tests/runner.test.ts index c072487b5..6779e4127 100644 --- a/packages/junior-migrations/tests/runner.test.ts +++ b/packages/junior-migrations/tests/runner.test.ts @@ -268,4 +268,31 @@ describe("runMigrationJournal", () => { "SELECT 'schema-two';", ]); }); + + it("rejects non-JSON migration results before completing the ledger row", async () => { + const folder = await mixedFolder(); + const executor = new FakeExecutor(); + const migration = { + apiVersion: 1, + async up() { + return Number.NaN as never; + }, + } satisfies MigrationV1; + + await expect( + runMigrationJournal({ + executor, + migrationsFolder: folder, + migrationsTable: "__drizzle_test", + loadTypeScript: async () => ({ default: migration }), + createContext: ({ progress }) => ({ + database: executor, + log: () => {}, + progress, + state: fakeMigrationState(), + }), + }), + ).rejects.toThrow("Migration result must contain only finite JSON numbers"); + expect(executor.rows.get(2_001)?.status).toBe("failed"); + }); }); 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 5ac29f8fc..2c77ee189 100644 --- a/packages/junior-scheduler/migrations/0002_scheduler_state_to_sql.ts +++ b/packages/junior-scheduler/migrations/0002_scheduler_state_to_sql.ts @@ -2,20 +2,135 @@ import type { MigrationJsonValue, MigrationV1, } from "@sentry/junior-migrations"; +import { z } from "zod"; 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", +const nonBlankStringSchema = z + .string() + .refine((value) => value.trim().length > 0); +const exactActorUserIdSchema = z + .string() + .min(1) + .refine( + (value) => value === value.trim() && value.toLowerCase() !== "unknown", + ); +const slackTeamIdSchema = z.string().regex(/^T[A-Z0-9]+$/); +const slackConversationIdSchema = z.string().regex(/^(C|G|D)[A-Z0-9]+$/); +const slackDestinationSchema = z + .object({ + platform: z.literal("slack"), + teamId: slackTeamIdSchema, + channelId: slackConversationIdSchema, + }) + .strict(); +const pluginCredentialSubjectSchema = z.discriminatedUnion("allowedWhen", [ + z + .object({ + type: z.literal("user"), + userId: exactActorUserIdSchema, + allowedWhen: z.literal("private-direct-conversation"), + }) + .strict(), + z + .object({ + type: z.literal("user"), + userId: exactActorUserIdSchema, + allowedWhen: z.literal("scheduled-task"), + taskId: nonBlankStringSchema.refine((value) => value === value.trim()), + }) + .strict(), ]); +const taskPrincipalSchema = z + .object({ + slackUserId: exactActorUserIdSchema, + fullName: z.string().optional(), + userName: z.string().optional(), + }) + .strict(); +const recurrenceSchema = z + .object({ + dayOfMonth: z.number().optional(), + frequency: z.enum(["daily", "weekly", "monthly", "yearly"]), + interval: z.number(), + month: z.number().optional(), + startDate: z.string(), + time: z.object({ hour: z.number(), minute: z.number() }).strict(), + weekdays: z.array(z.number()).optional(), + }) + .strict(); +const taskFields = { + id: z.string(), + conversationAccess: z + .object({ + audience: z.enum(["direct", "group", "channel"]), + visibility: z.enum(["private", "public", "unknown"]), + }) + .strict() + .optional(), + createdAtMs: z.number(), + createdBy: taskPrincipalSchema, + destination: slackDestinationSchema, + executionActor: z + .object({ platform: z.literal("system"), name: z.string() }) + .strict() + .optional(), + lastRunAtMs: z.number().optional(), + nextRunAtMs: z.number().optional(), + originalRequest: z.string().optional(), + runNowAtMs: z.number().optional(), + schedule: z + .object({ + description: z.string(), + kind: z.enum(["one_off", "recurring"]), + recurrence: recurrenceSchema.optional(), + timezone: z.string(), + }) + .strict(), + status: z.enum(["active", "paused", "blocked", "deleted"]), + statusReason: z.string().optional(), + task: z.object({ text: z.string() }).strict(), + updatedAtMs: z.number(), + version: z.number().optional(), +}; +const currentTaskSchema = z + .object({ + ...taskFields, + credentialMode: z.enum(["system", "creator"]), + }) + .strict(); +const legacyTaskSchema = z + .object({ + ...taskFields, + credentialSubject: pluginCredentialSubjectSchema.optional(), + }) + .strict(); +const runSchema = z + .object({ + id: z.string(), + attempt: z.number(), + claimedAtMs: z.number(), + completedAtMs: z.number().optional(), + dispatchId: z.string().optional(), + errorMessage: z.string().optional(), + idempotencyKey: z.string().optional(), + resultMessageTs: z.string().optional(), + scheduledForMs: z.number(), + startedAtMs: z.number().optional(), + status: z.enum([ + "pending", + "running", + "completed", + "failed", + "blocked", + "skipped", + ]), + taskId: z.string(), + taskVersion: z.number().optional(), + }) + .strict(); type JsonRecord = Record; @@ -51,56 +166,33 @@ function finiteNumber( } 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 current = currentTaskSchema.safeParse(record(value)); + if (current.success) { + const { version: _version, ...task } = current.data; + return task as JsonRecord; } + const legacy = legacyTaskSchema.safeParse(record(value)); + if (!legacy.success) return undefined; const { credentialSubject: _credentialSubject, version: _version, - ...current - } = task; + ...task + } = legacy.data; return { - ...current, - credentialMode: current.credentialMode === "creator" ? "creator" : "system", - }; + ...task, + credentialMode: "system", + } as JsonRecord; } 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 parsed = runSchema.safeParse(record(value)); + if (!parsed.success) return undefined; const { idempotencyKey: _idempotencyKey, taskVersion: _taskVersion, - ...current - } = run; - return current; + ...run + } = parsed.data; + return run as JsonRecord; } const migration = { @@ -121,11 +213,14 @@ const migration = { continue; } tasks.push(task); - const [stored] = await context.database.query<{ id: string }>( - "SELECT id FROM junior_scheduler_tasks WHERE id = $1 LIMIT 1", + const [stored] = await context.database.query<{ record: unknown }>( + "SELECT record FROM junior_scheduler_tasks WHERE id = $1 LIMIT 1", [id], ); - if (stored) { + if ( + stored && + currentTaskSchema.safeParse(record(stored.record)).success + ) { existing += 1; continue; } @@ -171,11 +266,11 @@ const migration = { } for (const run of runs) { - const [stored] = await context.database.query<{ id: string }>( - "SELECT id FROM junior_scheduler_runs WHERE id = $1 LIMIT 1", + const [stored] = await context.database.query<{ record: unknown }>( + "SELECT record FROM junior_scheduler_runs WHERE id = $1 LIMIT 1", [run.id], ); - if (stored) { + if (stored && runRecord(stored.record)) { existing += 1; continue; } diff --git a/packages/junior/migrations/0007_conversations_to_sql.ts b/packages/junior/migrations/0007_conversations_to_sql.ts index 51c62687b..4285900ba 100644 --- a/packages/junior/migrations/0007_conversations_to_sql.ts +++ b/packages/junior/migrations/0007_conversations_to_sql.ts @@ -20,6 +20,7 @@ type ConversationTarget = Pick< "backfillConversation" | "listByActivity" >; +/** Backfill retained state conversations and turn metrics into SQL. */ export async function migrateConversationsToSql( context: { io?: { info(message: string): void }; diff --git a/packages/junior/migrations/0008_conversation_history_to_sql.ts b/packages/junior/migrations/0008_conversation_history_to_sql.ts index cc2e75c7b..256c58ced 100644 --- a/packages/junior/migrations/0008_conversation_history_to_sql.ts +++ b/packages/junior/migrations/0008_conversation_history_to_sql.ts @@ -90,8 +90,8 @@ var contextEpochStartedEntrySchema = z3.union([ .object({ type: z3.literal("context_epoch_started"), reason: z3.union([z3.literal("compaction"), z3.literal("rollback")]), - // TODO(v0.97.0): Remove support for deployed compaction/rollback markers - // without model bindings after those rows pass the retention horizon. + // This historical migration permanently accepts deployed markers that + // predate model bindings. modelProfile: z3.undefined().optional(), modelId: z3.undefined().optional(), }) @@ -1066,6 +1066,7 @@ var legacyThreadStateSnapshotSchema2 = z5.object({ .passthrough() .optional(), }); +/** Import retained legacy session history and visible messages into SQL. */ async function migrateConversationHistoryToSql(context, options = {}) { const source = createMigrationStateConversationStore(context.stateAdapter); const executor = options.executor; diff --git a/packages/junior/src/chat/conversations/sql/migrations.ts b/packages/junior/src/chat/conversations/sql/migrations.ts index 3e53ba9da..12b5209bf 100644 --- a/packages/junior/src/chat/conversations/sql/migrations.ts +++ b/packages/junior/src/chat/conversations/sql/migrations.ts @@ -6,11 +6,11 @@ import { runMigrationJournal, type MigrationContextV1, type MigrationRunResult, - type MigrationStateV1, type TypeScriptMigrationLoader, } from "@sentry/junior-migrations"; import type { StateAdapter } from "chat"; import type { RedisStateAdapter } from "@chat-adapter/state-redis"; +import { createMigrationStateV1 } from "@/chat/migrations/state-v1"; import type { JuniorSqlMigrationExecutor } from "@/db/db"; import { juniorSqlSchema as schema } from "@/db/schema"; @@ -106,33 +106,6 @@ 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: string) => - (await stateAdapter.get(key)) ?? undefined, - getList: async (key: string) => 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), - }; -} - export type MigrateSchemaOptions = | { mode?: "sql" } | { @@ -179,7 +152,7 @@ export async function migrateSchema( }, } : {}), - state: migrationState(options.stateAdapter), + state: createMigrationStateV1(options.stateAdapter), }), loadTypeScript: options.loadTypeScript, mode: "all", diff --git a/packages/junior/src/chat/migrations/state-v1.ts b/packages/junior/src/chat/migrations/state-v1.ts new file mode 100644 index 000000000..dd93a9ab3 --- /dev/null +++ b/packages/junior/src/chat/migrations/state-v1.ts @@ -0,0 +1,32 @@ +import type { MigrationStateV1 } from "@sentry/junior-migrations"; +import type { StateAdapter } from "chat"; + +/** Project the host state adapter onto the permanent v1 migration capability. */ +export function createMigrationStateV1( + 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: string) => + (await stateAdapter.get(key)) ?? undefined, + getList: async (key: string) => 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), + }; +} diff --git a/packages/junior/src/chat/plugins/migrations.ts b/packages/junior/src/chat/plugins/migrations.ts index a0321beea..59fc2e406 100644 --- a/packages/junior/src/chat/plugins/migrations.ts +++ b/packages/junior/src/chat/plugins/migrations.ts @@ -3,11 +3,11 @@ import { resolveMigrations, runMigrationJournal, type MigrationContextV1, - type MigrationStateV1, type ResolvedMigration, type TypeScriptMigrationLoader, } from "@sentry/junior-migrations"; import type { StateAdapter } from "chat"; +import { createMigrationStateV1 } from "@/chat/migrations/state-v1"; import type { JuniorSqlMigrationExecutor } from "@/db/db"; interface PluginMigrationRoot { @@ -131,33 +131,6 @@ 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: string) => - (await stateAdapter.get(key)) ?? undefined, - getList: async (key: string) => 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), - }; -} - /** Apply enabled plugins' mixed migrations in plugin-name and journal order. */ export async function migratePluginSchemas( executor: JuniorSqlMigrationExecutor, @@ -196,7 +169,7 @@ export async function migratePluginSchemas( database: executor, log: options.log ?? (() => {}), progress, - state: migrationState(options.stateAdapter), + state: createMigrationStateV1(options.stateAdapter), }), loadTypeScript: options.loadTypeScript, mode: "all", diff --git a/packages/junior/src/migration-helpers/v1.ts b/packages/junior/src/migration-helpers/v1.ts index 1c4561ead..15665d5fd 100644 --- a/packages/junior/src/migration-helpers/v1.ts +++ b/packages/junior/src/migration-helpers/v1.ts @@ -10,9 +10,7 @@ import { toOptionalString as runtimeToOptionalString, } from "@/chat/coerce"; import { getChatConfig } from "@/chat/config"; -import { agentStepEntrySchema } from "@/chat/conversations/history"; import { createLegacyAdvisorSessionReader } from "@/chat/conversations/legacy-advisor-session"; -import { createSqlAgentStepStore } from "@/chat/conversations/sql/history"; import { createSqlConversationMessageStore } from "@/chat/conversations/sql/messages"; import { createStateConversationStore } from "@/chat/conversations/state"; import { createSqlStore } from "@/chat/conversations/sql/store"; @@ -43,7 +41,6 @@ import { } from "@/db/schema"; export const JUNIOR_THREAD_STATE_TTL_MS = 7 * 24 * 60 * 60 * 1_000; -export const migrationAgentStepEntrySchema: unknown = agentStepEntrySchema; export const migrationContextProvenance: unknown = contextProvenance; export const migrationJuniorAgentSteps: unknown = juniorAgentSteps; export const migrationJuniorConversationMessages: unknown = @@ -68,6 +65,7 @@ export type MigrationExecutionStatusV1 = | "pending" | "running"; +/** Frozen retained inbound-message shape used by v1 data migrations. */ export interface MigrationInboundMessageV1 { attemptCount?: number; conversationId: string; @@ -85,6 +83,7 @@ export interface MigrationInboundMessageV1 { source: MigrationSourceV1; } +/** Frozen retained execution-lease shape used by v1 data migrations. */ export interface MigrationLeaseV1 { acquiredAtMs: number; expiresAtMs: number; @@ -92,6 +91,7 @@ export interface MigrationLeaseV1 { token: string; } +/** Frozen conversation projection shared by v1 state and SQL helpers. */ export interface MigrationConversationV1 { actor?: unknown; channelName?: string; @@ -116,6 +116,7 @@ export interface MigrationConversationV1 { updatedAtMs: number; } +/** State-backed conversation projection with retained mailbox fields. */ export interface MigrationRetainedConversationV1 extends MigrationConversationV1 { execution: MigrationConversationV1["execution"] & { inboundMessageIds: string[]; @@ -124,20 +125,24 @@ export interface MigrationRetainedConversationV1 extends MigrationConversationV1 }; } +/** Legacy thread-state projection required by the v1 continuation migration. */ export interface MigrationThreadConversationStateV1 { processing: { activeTurnId?: string }; } +/** Frozen turn-summary projection used by the conversation SQL backfill. */ export interface MigrationTurnSessionSummaryV1 { cumulativeDurationMs: number; cumulativeUsage?: Record; sessionId: string; } +/** Narrow state conversation store exposed to v1 migrations. */ export interface MigrationStateConversationStoreV1 { listByActivity(args: { limit: number }): Promise; } +/** Narrow SQL conversation store exposed to v1 migrations. */ export interface MigrationSqlConversationStoreV1 { backfillConversation( conversation: MigrationConversationV1, @@ -221,13 +226,6 @@ export function createMigrationSqlStore( ) as unknown as MigrationSqlConversationStoreV1; } -/** Create the SQL agent-step store used by legacy history migrations. */ -export function createMigrationSqlAgentStepStore( - database: MigrationDatabaseAdapter, -): unknown { - return createSqlAgentStepStore(database as unknown as JuniorSqlDatabase); -} - /** Create the SQL message store used by legacy history migrations. */ export function createMigrationSqlConversationMessageStore( database: MigrationDatabaseAdapter, diff --git a/packages/junior/tests/component/scheduler-sql-plugin.test.ts b/packages/junior/tests/component/scheduler-sql-plugin.test.ts index 507cadf64..e2023ba8d 100644 --- a/packages/junior/tests/component/scheduler-sql-plugin.test.ts +++ b/packages/junior/tests/component/scheduler-sql-plugin.test.ts @@ -564,6 +564,30 @@ ORDER BY tablename scanned: 2, }); + const { credentialMode: _credentialMode, ...legacySqlTask } = task; + await fixture.sql.execute( + "UPDATE junior_scheduler_tasks SET record = $2::jsonb WHERE id = $1", + [ + task.id, + JSON.stringify({ + ...legacySqlTask, + credentialSubject: { + allowedWhen: "private-direct-conversation", + type: "user", + userId: "U123", + }, + }), + ], + ); + await expect( + runSchedulerStateMigration({ fixture, stateAdapter }), + ).resolves.toEqual({ + existing: 1, + migrated: 1, + missing: 0, + scanned: 2, + }); + const sqlStore = createSchedulerSqlStore(db); await expect(sqlStore.getTask(task.id)).resolves.toMatchObject({ id: task.id, @@ -592,7 +616,7 @@ ORDER BY tablename const badRunId = `${task.id}:${TEST_RUN_AT_MS}`; await state.set( "junior:scheduler:tasks", - ["sched_state_sql_bad", task.id], + ["sched_state_sql_bad", "sched_state_sql_missing_required", task.id], 5 * 60 * 1000, ); await state.set( @@ -616,6 +640,12 @@ ORDER BY tablename }, 5 * 60 * 1000, ); + const { schedule: _schedule, ...missingSchedule } = task; + await state.set( + "junior:scheduler:task:sched_state_sql_missing_required", + { ...missingSchedule, id: "sched_state_sql_missing_required" }, + 5 * 60 * 1000, + ); await state.set( `junior:scheduler:active:${task.id}`, { @@ -627,7 +657,12 @@ ORDER BY tablename ); await state.set( `junior:scheduler:run:${badRunId}`, - { id: badRunId }, + { + id: badRunId, + scheduledForMs: TEST_RUN_AT_MS, + status: "pending", + taskId: task.id, + }, 5 * 60 * 1000, ); @@ -636,8 +671,8 @@ ORDER BY tablename ).resolves.toEqual({ existing: 0, migrated: 1, - missing: 1, - scanned: 2, + missing: 2, + scanned: 3, }); const sqlStore = createSchedulerSqlStore(db); @@ -648,6 +683,9 @@ ORDER BY tablename await expect(sqlStore.getTask("sched_state_sql_bad")).resolves.toBe( undefined, ); + await expect( + sqlStore.getTask("sched_state_sql_missing_required"), + ).resolves.toBe(undefined); await expect(sqlStore.getRun(badRunId)).resolves.toBe(undefined); } finally { await stateAdapter.disconnect(); diff --git a/packages/junior/tests/fixtures/postgres/executor.ts b/packages/junior/tests/fixtures/postgres/executor.ts index 3757405c3..420d0f3f4 100644 --- a/packages/junior/tests/fixtures/postgres/executor.ts +++ b/packages/junior/tests/fixtures/postgres/executor.ts @@ -60,10 +60,10 @@ class ClientJuniorSqlExecutor implements JuniorSqlExecutor { } async withMigrationLock( - _migrationTable: string, + migrationTable: string, callback: () => Promise, ): Promise { - return await callback(); + return await this.withLock(`junior:migrate:${migrationTable}`, callback); } async close(): Promise { diff --git a/packages/junior/tests/fixtures/sql.ts b/packages/junior/tests/fixtures/sql.ts index 61f562daa..6b86e3524 100644 --- a/packages/junior/tests/fixtures/sql.ts +++ b/packages/junior/tests/fixtures/sql.ts @@ -46,7 +46,8 @@ export async function createLocalJuniorSqlFixture(): Promise(statement, params), transaction: (callback) => fixture.transaction(callback), withLock: (lockName, callback) => fixture.withLock(lockName, callback), - withMigrationLock: (_migrationTable, callback) => callback(), + withMigrationLock: (migrationTable, callback) => + fixture.withLock(`junior:migrate:${migrationTable}`, callback), }; return { From 360518a541afe9fd36ff5aedc659b7643af787d2 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Thu, 16 Jul 2026 14:48:59 -0700 Subject: [PATCH 7/8] ref(migrations): Make bootstrap and plugin state explicit Require explicit schema-bootstrap execution, scope plugin migration state by default while preserving historical plugin keys, and document permanent helper-version compatibility. Add real PostgreSQL progress, resume, and lock serialization contracts plus host-wiring isolation coverage. Co-Authored-By: GPT-5.6 Codex --- .../junior-evals/postgres-global-setup.ts | 4 +- packages/junior-migrations/README.md | 9 + packages/junior-migrations/src/runner.ts | 8 +- .../junior-migrations/tests/runner.test.ts | 33 +- .../src/chat/conversations/sql/migrations.ts | 11 +- .../src/chat/plugins/migration-state.ts | 49 +++ .../junior/src/chat/plugins/migrations.ts | 23 +- packages/junior/src/chat/plugins/state.ts | 6 +- .../junior/src/migration-helpers/README.md | 7 +- .../tests/component/cli/upgrade-cli.test.ts | 14 +- .../component/conversation-search.test.ts | 2 +- .../component/conversation-sql-store.test.ts | 36 +- .../conversation-transcripts-sql.test.ts | 22 +- .../conversations/legacy-import.test.ts | 20 +- .../component/conversations/retention.test.ts | 2 +- .../component/memory-plugin-storage.test.ts | 8 +- .../migrations/mixed-runner-database.test.ts | 307 ++++++++++++++++++ .../component/scheduler-sql-plugin.test.ts | 155 ++++++++- .../tests/fixtures/postgres/global-setup.ts | 6 +- .../api/conversations/list.test.ts | 2 +- .../api/conversations/stats.test.ts | 4 +- .../tests/integration/api/locations.test.ts | 2 +- .../tests/integration/api/people/fixture.ts | 2 +- .../integration/api/people/profile.test.ts | 2 +- .../integration/conversation-sql.test.ts | 31 +- .../slack-conversation-search.test.ts | 2 +- .../integration/slack-schedule-tools.test.ts | 4 +- 27 files changed, 657 insertions(+), 114 deletions(-) create mode 100644 packages/junior/src/chat/plugins/migration-state.ts create mode 100644 packages/junior/tests/component/migrations/mixed-runner-database.test.ts diff --git a/packages/junior-evals/postgres-global-setup.ts b/packages/junior-evals/postgres-global-setup.ts index b02a88587..0d1ab3336 100644 --- a/packages/junior-evals/postgres-global-setup.ts +++ b/packages/junior-evals/postgres-global-setup.ts @@ -2,7 +2,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import type { TestProject } from "vitest/node"; import { setupJuniorPostgresHarness } from "@junior-tests/fixtures/postgres/global-setup"; -import { migratePluginSchemas } from "@/chat/plugins/migrations"; +import { bootstrapPluginSchemas } from "@/chat/plugins/migrations"; const workspaceRoot = path.resolve( path.dirname(fileURLToPath(import.meta.url)), @@ -14,7 +14,7 @@ export default async function setup( ): Promise<() => Promise> { return await setupJuniorPostgresHarness(project, { migrateTemplate: async (executor) => { - await migratePluginSchemas(executor, [ + await bootstrapPluginSchemas(executor, [ { dir: path.resolve(workspaceRoot, "packages/junior-memory/migrations"), pluginName: "memory", diff --git a/packages/junior-migrations/README.md b/packages/junior-migrations/README.md index abf230771..5b652b25d 100644 --- a/packages/junior-migrations/README.md +++ b/packages/junior-migrations/README.md @@ -36,9 +36,18 @@ 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. +Normal upgrades run with `mode: "all"` and execute every SQL and TypeScript +entry in journal order. `mode: "schema-bootstrap"` is reserved for constructing +an empty database at the latest schema in tests or bootstrap tooling. It skips +TypeScript data migrations while executing SQL entries across the full journal, +so it must not be used to upgrade an existing installation. + The runner rejects runtime imports of application source, relative modules, 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. + +This source validation is an authoring guard for trusted packaged migration +code, not a security sandbox for untrusted scripts. diff --git a/packages/junior-migrations/src/runner.ts b/packages/junior-migrations/src/runner.ts index 7eb99035a..dd9ae0a83 100644 --- a/packages/junior-migrations/src/runner.ts +++ b/packages/junior-migrations/src/runner.ts @@ -77,16 +77,16 @@ interface RunAllMigrationJournalOptions extends RunMigrationJournalBaseOptions { mode?: "all"; } -interface RunSqlMigrationJournalOptions extends RunMigrationJournalBaseOptions { +interface RunSchemaBootstrapMigrationJournalOptions extends RunMigrationJournalBaseOptions { createContext?: never; loadTypeScript?: never; - mode: "sql"; + mode: "schema-bootstrap"; } /** Host capabilities and execution mode for one mixed migration journal. */ export type RunMigrationJournalOptions = | RunAllMigrationJournalOptions - | RunSqlMigrationJournalOptions; + | RunSchemaBootstrapMigrationJournalOptions; function identifier(value: string): string { if (!/^[a-z_][a-z0-9_]*$/.test(value)) { @@ -345,7 +345,7 @@ export async function runMigrationJournal( result.existing += 1; continue; } - if (mode === "sql" && migration.kind === "typescript") { + if (mode === "schema-bootstrap" && migration.kind === "typescript") { result.skipped += 1; continue; } diff --git a/packages/junior-migrations/tests/runner.test.ts b/packages/junior-migrations/tests/runner.test.ts index 6779e4127..7c1baa0cc 100644 --- a/packages/junior-migrations/tests/runner.test.ts +++ b/packages/junior-migrations/tests/runner.test.ts @@ -197,7 +197,7 @@ describe("runMigrationJournal", () => { ).resolves.toEqual({ existing: 3, migrated: 0, scanned: 3, skipped: 0 }); }); - it("allows schema-only execution to leave a TypeScript gap pending", async () => { + it("bootstraps the latest schema while leaving TypeScript entries pending", async () => { const folder = await mixedFolder(); const executor = new FakeExecutor(); @@ -206,10 +206,39 @@ describe("runMigrationJournal", () => { executor, migrationsFolder: folder, migrationsTable: "__drizzle_test", - mode: "sql", + mode: "schema-bootstrap", }), ).resolves.toEqual({ existing: 0, migrated: 2, scanned: 3, skipped: 1 }); expect([...executor.rows.keys()].sort()).toEqual([2_000, 2_002]); + expect(executor.statements).toEqual([ + "SELECT 'schema-zero';", + "SELECT 'schema-two';", + ]); + + const migration: MigrationV1 = { + apiVersion: 1, + async up() { + return { backfilled: true }; + }, + }; + await expect( + runMigrationJournal({ + executor, + migrationsFolder: folder, + migrationsTable: "__drizzle_test", + loadTypeScript: async () => ({ default: migration }), + createContext: ({ progress }) => ({ + database: executor, + log: () => {}, + progress, + state: fakeMigrationState(), + }), + }), + ).resolves.toEqual({ existing: 2, migrated: 1, scanned: 3, skipped: 0 }); + expect(executor.statements).toEqual([ + "SELECT 'schema-zero';", + "SELECT 'schema-two';", + ]); }); it("resumes a failed TypeScript migration from saved progress", async () => { diff --git a/packages/junior/src/chat/conversations/sql/migrations.ts b/packages/junior/src/chat/conversations/sql/migrations.ts index 12b5209bf..448bc6e35 100644 --- a/packages/junior/src/chat/conversations/sql/migrations.ts +++ b/packages/junior/src/chat/conversations/sql/migrations.ts @@ -107,7 +107,7 @@ CREATE TABLE IF NOT EXISTS drizzle.__drizzle_junior_core ( } export type MigrateSchemaOptions = - | { mode?: "sql" } + | { mode: "schema-bootstrap" } | { loadTypeScript: TypeScriptMigrationLoader; log?: MigrationContextV1["log"]; @@ -118,10 +118,10 @@ export type MigrateSchemaOptions = export { schema }; -/** Apply the packaged mixed migration journal, or SQL entries alone in tests. */ +/** Apply all migrations, or bootstrap an empty test database to the latest schema. */ export async function migrateSchema( executor: JuniorSqlMigrationExecutor, - options: MigrateSchemaOptions = { mode: "sql" }, + options: MigrateSchemaOptions, ): Promise { const migrationsFolder = migrationFolder(); const runAll = options.mode === "all"; @@ -134,7 +134,10 @@ export async function migrateSchema( migrationsTable: MIGRATIONS_TABLE, }; if (!runAll) { - return await runMigrationJournal({ ...baseOptions, mode: "sql" }); + return await runMigrationJournal({ + ...baseOptions, + mode: "schema-bootstrap", + }); } return await runMigrationJournal({ ...baseOptions, diff --git a/packages/junior/src/chat/plugins/migration-state.ts b/packages/junior/src/chat/plugins/migration-state.ts new file mode 100644 index 000000000..cea06e997 --- /dev/null +++ b/packages/junior/src/chat/plugins/migration-state.ts @@ -0,0 +1,49 @@ +import type { MigrationStateV1 } from "@sentry/junior-migrations"; +import type { StateAdapter } from "chat"; +import { + createPluginState, + pluginStateKey, + validatePluginStateKey, +} from "@/chat/plugins/state"; + +/** Project host state onto the permanent v1 migration API for one plugin. */ +export function createPluginMigrationStateV1( + plugin: string, + stateAdapter: StateAdapter, +): MigrationStateV1 { + const pluginState = createPluginState(plugin, stateAdapter); + const stateKey = (key: string): string => { + validatePluginStateKey(key); + return pluginStateKey(plugin, key); + }; + + return { + acquireLock: async (key, ttlMs) => { + await stateAdapter.connect(); + return await stateAdapter.acquireLock(stateKey(key), ttlMs); + }, + appendToList: async (key, value, options) => { + await stateAdapter.connect(); + await stateAdapter.appendToList(stateKey(key), value, options); + }, + connect: async () => { + await stateAdapter.connect(); + }, + delete: async (key) => { + await pluginState.delete(key); + }, + get: async (key: string) => await pluginState.get(key), + getList: async (key: string) => { + await stateAdapter.connect(); + return await stateAdapter.getList(stateKey(key)); + }, + releaseLock: async (lock) => { + await stateAdapter.releaseLock(lock); + }, + set: async (key, value, ttlMs) => { + await pluginState.set(key, value, ttlMs); + }, + setIfNotExists: async (key, value, ttlMs) => + await pluginState.setIfNotExists(key, value, ttlMs), + }; +} diff --git a/packages/junior/src/chat/plugins/migrations.ts b/packages/junior/src/chat/plugins/migrations.ts index 59fc2e406..e3c620489 100644 --- a/packages/junior/src/chat/plugins/migrations.ts +++ b/packages/junior/src/chat/plugins/migrations.ts @@ -7,7 +7,7 @@ import { type TypeScriptMigrationLoader, } from "@sentry/junior-migrations"; import type { StateAdapter } from "chat"; -import { createMigrationStateV1 } from "@/chat/migrations/state-v1"; +import { createPluginMigrationStateV1 } from "@/chat/plugins/migration-state"; import type { JuniorSqlMigrationExecutor } from "@/db/db"; interface PluginMigrationRoot { @@ -24,7 +24,7 @@ type PluginMigrationResult = { }; type PluginMigrationOptions = - | { mode?: "sql" } + | { mode: "schema-bootstrap" } | { loadTypeScript: TypeScriptMigrationLoader; log?: MigrationContextV1["log"]; @@ -135,7 +135,7 @@ CREATE TABLE IF NOT EXISTS drizzle.${args.table} ( export async function migratePluginSchemas( executor: JuniorSqlMigrationExecutor, roots: readonly PluginMigrationRoot[], - options: PluginMigrationOptions = { mode: "sql" }, + options: PluginMigrationOptions, ): Promise { const result: PluginMigrationResult = { existing: 0, @@ -169,12 +169,15 @@ export async function migratePluginSchemas( database: executor, log: options.log ?? (() => {}), progress, - state: createMigrationStateV1(options.stateAdapter), + state: createPluginMigrationStateV1( + root.pluginName, + options.stateAdapter, + ), }), loadTypeScript: options.loadTypeScript, mode: "all", }) - : await runMigrationJournal({ ...baseOptions, mode: "sql" }); + : await runMigrationJournal({ ...baseOptions, mode: "schema-bootstrap" }); result.scanned += pluginResult.scanned; result.existing += pluginResult.existing; result.migrated += pluginResult.migrated; @@ -184,3 +187,13 @@ export async function migratePluginSchemas( } return result; } + +/** Construct enabled plugins' latest SQL schemas without running data entries. */ +export async function bootstrapPluginSchemas( + executor: JuniorSqlMigrationExecutor, + roots: readonly PluginMigrationRoot[], +): Promise { + return await migratePluginSchemas(executor, roots, { + mode: "schema-bootstrap", + }); +} diff --git a/packages/junior/src/chat/plugins/state.ts b/packages/junior/src/chat/plugins/state.ts index 4812176f3..ccd090b2f 100644 --- a/packages/junior/src/chat/plugins/state.ts +++ b/packages/junior/src/chat/plugins/state.ts @@ -9,7 +9,8 @@ function hashKeyPart(value: string): string { return createHash("sha256").update(value).digest("hex").slice(0, 32); } -function pluginStateKey(plugin: string, key: string): string { +/** Resolve one logical plugin key to its isolated host-state key. */ +export function pluginStateKey(plugin: string, key: string): string { const pluginPrefix = `junior:${plugin}`; if (key === pluginPrefix || key.startsWith(`${pluginPrefix}:`)) { return key; @@ -17,7 +18,8 @@ function pluginStateKey(plugin: string, key: string): string { return `junior:plugin_state:${hashKeyPart(plugin)}:${hashKeyPart(key)}`; } -function validatePluginStateKey(key: string): void { +/** Validate one logical plugin state key before namespacing it. */ +export function validatePluginStateKey(key: string): void { if (!key.trim()) { throw new Error("Plugin state key is required"); } diff --git a/packages/junior/src/migration-helpers/README.md b/packages/junior/src/migration-helpers/README.md index 9f2f3637f..f2550f515 100644 --- a/packages/junior/src/migration-helpers/README.md +++ b/packages/junior/src/migration-helpers/README.md @@ -8,6 +8,7 @@ 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. +The behavior and signature of an existing version are permanent compatibility +contracts. Add a new versioned entrypoint when that contract must change. +Migration files may be updated while still unreleased, but shipped migration +sources are hash-verified ledger entries and must remain immutable. diff --git a/packages/junior/tests/component/cli/upgrade-cli.test.ts b/packages/junior/tests/component/cli/upgrade-cli.test.ts index 73fe3d3ed..9b1619989 100644 --- a/packages/junior/tests/component/cli/upgrade-cli.test.ts +++ b/packages/junior/tests/component/cli/upgrade-cli.test.ts @@ -399,7 +399,7 @@ export const plugins = { const sqlStore = createSqlStore(fixture.sql); try { - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); const context = { io: { info: () => {} }, stateAdapter, @@ -456,7 +456,7 @@ export const plugins = { const sqlStore = createSqlStore(fixture.sql); try { - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); for (let index = 0; index < 3; index++) { const conversationId = `slack:C123:page-${index}`; await appendInboundMessage({ @@ -497,7 +497,7 @@ export const plugins = { const sqlStore = createSqlStore(fixture.sql); try { - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); const seedMs = Date.now() - 1_000; await sqlStore.recordExecution({ conversationId: CONVERSATION_ID, @@ -692,7 +692,7 @@ WHERE conversation_id = $1 } as PiMessage; try { - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await sqlStore.recordExecution({ conversationId: CONVERSATION_ID, createdAtMs: 1_000, @@ -857,7 +857,7 @@ WHERE conversation_id = $1 const conversationIds = ["local:usage-batch-a", "local:usage-batch-b"]; try { - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); for (const [index, conversationId] of conversationIds.entries()) { await sqlStore.recordExecution({ conversationId, @@ -992,7 +992,7 @@ WHERE conversation_id = $1 const stepStore = createSqlAgentStepStore(fixture.sql); try { - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await sqlStore.recordExecution({ conversationId: CONVERSATION_ID, createdAtMs: 1_000, @@ -1288,7 +1288,7 @@ WHERE conversation_id = $1 const sqlStore = createSqlStore(fixture.sql); try { - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); const context = { io: { info: () => {} }, stateAdapter, diff --git a/packages/junior/tests/component/conversation-search.test.ts b/packages/junior/tests/component/conversation-search.test.ts index ffb7a47dd..4f587d4e8 100644 --- a/packages/junior/tests/component/conversation-search.test.ts +++ b/packages/junior/tests/component/conversation-search.test.ts @@ -12,7 +12,7 @@ describe("conversation search", () => { const fixture = await createLocalJuniorSqlFixture(); try { - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); const conversations = createSqlStore(fixture.sql); const messages = createSqlConversationMessageStore(fixture.sql); const search = createSqlConversationSearchStore(fixture.sql); diff --git a/packages/junior/tests/component/conversation-sql-store.test.ts b/packages/junior/tests/component/conversation-sql-store.test.ts index 164c0da33..afcf61cc2 100644 --- a/packages/junior/tests/component/conversation-sql-store.test.ts +++ b/packages/junior/tests/component/conversation-sql-store.test.ts @@ -36,7 +36,7 @@ describe("conversation SQL store", () => { try { const store = createSqlStore(fixture.sql); - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await store.recordActivity({ conversationId: CONVERSATION_ID, @@ -169,7 +169,7 @@ describe("conversation SQL store", () => { try { const store = createSqlStore(fixture.sql); - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); const identity = await upsertIdentity( fixture.sql, @@ -265,7 +265,7 @@ describe("conversation SQL store", () => { const fixture = await createLocalJuniorSqlFixture(); try { - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await fixture.sql .db() @@ -338,7 +338,7 @@ describe("conversation SQL store", () => { try { const store = createSqlStore(fixture.sql); - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await store.recordActivity({ conversationId: CONVERSATION_ID, @@ -385,7 +385,7 @@ describe("conversation SQL store", () => { try { const store = createSqlStore(fixture.sql); - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); const destination = inboundMessage("visibility").destination; // Slack reports this C-prefixed channel private (channel_type: group). @@ -452,7 +452,7 @@ describe("conversation SQL store", () => { try { const store = createSqlStore(fixture.sql); - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); // A write without a live source signal fails closed to private even // though the channel id is C-prefixed. @@ -482,7 +482,7 @@ describe("conversation SQL store", () => { try { const store = createSqlStore(fixture.sql); - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await fixture.sql.execute( ` INSERT INTO junior_conversations ( @@ -542,7 +542,7 @@ INSERT INTO junior_conversations ( try { const store = createSqlStore(fixture.sql); - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await store.recordExecution({ conversationId: CONVERSATION_ID, @@ -595,7 +595,7 @@ INSERT INTO junior_conversations ( try { const store = createSqlStore(fixture.sql); - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await store.recordExecution({ conversationId: CONVERSATION_ID, createdAtMs: 1_000, @@ -672,7 +672,7 @@ WHERE conversation_id = $1 try { const store = createSqlStore(fixture.sql); - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await store.recordExecution({ conversationId: CONVERSATION_ID, createdAtMs: 1_000, @@ -755,7 +755,7 @@ WHERE conversation_id = $1 try { const store = createSqlStore(fixture.sql); - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await store.recordExecution({ conversationId: CONVERSATION_ID, @@ -805,7 +805,7 @@ WHERE conversation_id = $1 try { const store = createSqlStore(fixture.sql); - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await store.recordActivity({ conversationId: CONVERSATION_ID, @@ -841,7 +841,7 @@ WHERE conversation_id = $1 try { await disconnectStateAdapter(); const store = createSqlStore(fixture.sql); - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await store.recordExecution({ conversationId: CONVERSATION_ID, createdAtMs: 1_000, @@ -875,7 +875,7 @@ WHERE conversation_id = $1 vi.useFakeTimers({ now: 302_000 }); await disconnectStateAdapter(); const store = createSqlStore(fixture.sql); - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await store.recordExecution({ conversationId: CONVERSATION_ID, createdAtMs: 1_000, @@ -910,7 +910,7 @@ WHERE conversation_id = $1 vi.useFakeTimers({ now: 2_000 }); await disconnectStateAdapter(); const store = createSqlStore(fixture.sql); - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await store.recordExecution({ conversationId: CONVERSATION_ID, createdAtMs: 1_000, @@ -948,7 +948,7 @@ WHERE conversation_id = $1 vi.useFakeTimers({ now: 600_000 }); await disconnectStateAdapter(); const store = createSqlStore(fixture.sql); - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await store.recordExecution({ conversationId: CONVERSATION_ID, createdAtMs: 1_000, @@ -1002,7 +1002,7 @@ WHERE conversation_id = $1 await disconnectStateAdapter(); const state = getStateAdapter(); const store = createSqlStore(fixture.sql); - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await appendInboundMessage({ message: inboundMessage("check-in"), conversationStore: store, @@ -1055,7 +1055,7 @@ WHERE conversation_id = $1 await disconnectStateAdapter(); const state = getStateAdapter(); const store = createSqlStore(fixture.sql); - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await appendInboundMessage({ message: inboundMessage("drain-sql"), conversationStore: store, diff --git a/packages/junior/tests/component/conversation-transcripts-sql.test.ts b/packages/junior/tests/component/conversation-transcripts-sql.test.ts index 89d47b208..00081acc0 100644 --- a/packages/junior/tests/component/conversation-transcripts-sql.test.ts +++ b/packages/junior/tests/component/conversation-transcripts-sql.test.ts @@ -223,8 +223,8 @@ describe("conversation transcript SQL stores", () => { const fixture = await createLocalJuniorSqlFixture(); try { - await migrateSchema(fixture.sql); - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); const [applied] = await fixture.sql.query<{ count: number }>( "SELECT count(*)::integer AS count FROM drizzle.__drizzle_junior_core", @@ -239,7 +239,7 @@ describe("conversation transcript SQL stores", () => { const fixture = await createLocalJuniorSqlFixture(); try { - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await seedConversation(fixture, CONVERSATION_ID); const store = createSqlAgentStepStore(fixture.sql); @@ -292,7 +292,7 @@ describe("conversation transcript SQL stores", () => { const fixture = await createLocalJuniorSqlFixture(); try { - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await seedConversation(fixture, CONVERSATION_ID); const store = createSqlAgentStepStore(fixture.sql); @@ -323,7 +323,7 @@ describe("conversation transcript SQL stores", () => { const fixture = await createLocalJuniorSqlFixture(); try { - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await seedConversation(fixture, CONVERSATION_ID); const store = createSqlAgentStepStore(fixture.sql); @@ -365,7 +365,7 @@ describe("conversation transcript SQL stores", () => { const fixture = await createLocalJuniorSqlFixture(); try { - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await seedConversation(fixture, CONVERSATION_ID); const store = createSqlAgentStepStore(fixture.sql); const entry = { @@ -390,7 +390,7 @@ describe("conversation transcript SQL stores", () => { const fixture = await createLocalJuniorSqlFixture(); try { - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await seedConversation(fixture, CONVERSATION_ID); const store = createSqlAgentStepStore(fixture.sql); await store.append(CONVERSATION_ID, [ @@ -435,7 +435,7 @@ describe("conversation transcript SQL stores", () => { const fixture = await createLocalJuniorSqlFixture(); try { - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await seedConversation(fixture, CONVERSATION_ID); const store = createSqlAgentStepStore(fixture.sql); @@ -467,7 +467,7 @@ INSERT INTO junior_agent_steps ( const fixture = await createLocalJuniorSqlFixture(); try { - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await seedConversation(fixture, CONVERSATION_ID); const store = createSqlConversationMessageStore(fixture.sql); @@ -524,7 +524,7 @@ INSERT INTO junior_agent_steps ( } try { - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); // Seed an old activity clock; content writes must refresh the window. await seedConversation(fixture, CONVERSATION_ID); await fixture.sql @@ -586,7 +586,7 @@ INSERT INTO junior_agent_steps ( const fixture = await createLocalJuniorSqlFixture(); try { - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await seedConversation(fixture, CONVERSATION_ID); await seedConversation(fixture, CHILD_CONVERSATION_ID, CONVERSATION_ID); const steps = createSqlAgentStepStore(fixture.sql); diff --git a/packages/junior/tests/component/conversations/legacy-import.test.ts b/packages/junior/tests/component/conversations/legacy-import.test.ts index 5207b39e6..ee3842f64 100644 --- a/packages/junior/tests/component/conversations/legacy-import.test.ts +++ b/packages/junior/tests/component/conversations/legacy-import.test.ts @@ -40,7 +40,7 @@ const ORIGINAL_ENV = vi.hoisted(() => ({ async function processSqlStores() { const executor = getSqlExecutor(); - await migrateSchema(executor); + await migrateSchema(executor, { mode: "schema-bootstrap" }); return { executor, stepStore: getAgentStepStore(), @@ -112,7 +112,7 @@ describe("legacy conversation import", () => { it("imports steps, advisor child, and visible messages once, idempotently", async () => { const fixture = await createLocalJuniorSqlFixture(); - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); const stepStore = createSqlAgentStepStore(fixture.sql); const messageStore = createSqlConversationMessageStore(fixture.sql); const childId = `advisor:${CONVERSATION_ID}`; @@ -255,7 +255,7 @@ describe("legacy conversation import", () => { it("rolls back steps when the transactional message import fails", async () => { const fixture = await createLocalJuniorSqlFixture(); - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); const stepStore = createSqlAgentStepStore(fixture.sql); const messageStore = createSqlConversationMessageStore(fixture.sql); @@ -316,7 +316,7 @@ describe("legacy conversation import", () => { it("seals a completed message-only import without step rows", async () => { const fixture = await createLocalJuniorSqlFixture(); - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); const stepStore = createSqlAgentStepStore(fixture.sql); const messageStore = createSqlConversationMessageStore(fixture.sql); const loadVisibleMessages = vi.fn(async () => [ @@ -381,7 +381,7 @@ describe("legacy conversation import", () => { it("never fabricates import-time timestamps for timestamp-less rows", async () => { const fixture = await createLocalJuniorSqlFixture(); - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); const stepStore = createSqlAgentStepStore(fixture.sql); const messageStore = createSqlConversationMessageStore(fixture.sql); const before = Date.now(); @@ -563,7 +563,7 @@ describe("legacy conversation import", () => { it("rejects a legacy import when the SQL transcript was already purged", async () => { const fixture = await createLocalJuniorSqlFixture(); - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); const stepStore = createSqlAgentStepStore(fixture.sql); const messageStore = createSqlConversationMessageStore(fixture.sql); @@ -648,7 +648,7 @@ describe("legacy conversation import", () => { ]); try { - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); const context = { io: { info: () => {} }, stateAdapter }; await expect( migrateConversationHistoryToSql(context, { executor: fixture.sql }), @@ -682,7 +682,7 @@ describe("legacy conversation import", () => { it("replaces NUL characters in imported agent steps", async () => { const fixture = await createLocalJuniorSqlFixture(); - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); const stepStore = createSqlAgentStepStore(fixture.sql); const messageStore = createSqlConversationMessageStore(fixture.sql); @@ -720,7 +720,7 @@ describe("legacy conversation import", () => { it("reads legacy visible messages from a real thread-state payload", async () => { const fixture = await createLocalJuniorSqlFixture(); - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); const messageStore = createSqlConversationMessageStore(fixture.sql); // Persisted pre-cutover shape: the visible transcript nested under @@ -783,7 +783,7 @@ describe("legacy conversation import", () => { it("rejects malformed legacy visible messages", async () => { const fixture = await createLocalJuniorSqlFixture(); - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); const messageStore = createSqlConversationMessageStore(fixture.sql); const stateAdapter = getStateAdapter(); await stateAdapter.connect(); diff --git a/packages/junior/tests/component/conversations/retention.test.ts b/packages/junior/tests/component/conversations/retention.test.ts index 752b11b52..36ce61d0a 100644 --- a/packages/junior/tests/component/conversations/retention.test.ts +++ b/packages/junior/tests/component/conversations/retention.test.ts @@ -152,7 +152,7 @@ describe("retention purge job", () => { beforeEach(async () => { fixture = await createLocalJuniorSqlFixture(); - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); }); afterEach(async () => { diff --git a/packages/junior/tests/component/memory-plugin-storage.test.ts b/packages/junior/tests/component/memory-plugin-storage.test.ts index d9600d282..02d219d6d 100644 --- a/packages/junior/tests/component/memory-plugin-storage.test.ts +++ b/packages/junior/tests/component/memory-plugin-storage.test.ts @@ -14,7 +14,7 @@ import { } from "@sentry/junior-plugin-api"; import { defineJuniorPlugins } from "@/plugins"; import { getPluginTools, setPlugins } from "@/chat/plugins/agent-hooks"; -import { migratePluginSchemas } from "@/chat/plugins/migrations"; +import { bootstrapPluginSchemas } from "@/chat/plugins/migrations"; import { closeDb } from "@/chat/db"; import { migratePluginJournals } from "@/cli/upgrade/migrations/plugin-journal"; import { createLocalJuniorSqlFixture } from "../fixtures/sql"; @@ -86,7 +86,7 @@ function memoryMigrationFiles(): string[] { async function migrateMemorySchema( fixture: Awaited>, ) { - await migratePluginSchemas(fixture.sql, [ + await bootstrapPluginSchemas(fixture.sql, [ { dir: memoryMigrationsDir(), pluginName: "memory", @@ -128,7 +128,7 @@ CREATE TABLE junior_schema_migrations ( } await expect( - migratePluginSchemas(fixture.sql, [ + bootstrapPluginSchemas(fixture.sql, [ { dir: memoryMigrationsDir(), pluginName: "memory", @@ -165,7 +165,7 @@ CREATE TABLE junior_schema_migrations ( ); await expect( - migratePluginSchemas(fixture.sql, [ + bootstrapPluginSchemas(fixture.sql, [ { dir: memoryMigrationsDir(), pluginName: "memory", diff --git a/packages/junior/tests/component/migrations/mixed-runner-database.test.ts b/packages/junior/tests/component/migrations/mixed-runner-database.test.ts new file mode 100644 index 000000000..a972b989d --- /dev/null +++ b/packages/junior/tests/component/migrations/mixed-runner-database.test.ts @@ -0,0 +1,307 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + runMigrationJournal, + type MigrationContextV1, + type MigrationStateV1, +} from "@sentry/junior-migrations"; +import { createJiti } from "jiti"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + createLocalJuniorSqlFixture, + type LocalJuniorSqlFixture, +} from "../../fixtures/sql"; +import { hasJuniorPostgresTestDatabase } from "../../fixtures/postgres/fixture"; + +const migrationLoader = createJiti(import.meta.url, { moduleCache: false }); +const temporaryDirectories: string[] = []; + +const unusedState: MigrationStateV1 = { + acquireLock: async () => null, + appendToList: async () => {}, + connect: async () => {}, + delete: async () => {}, + get: async () => undefined, + getList: async () => [], + releaseLock: async () => {}, + set: async () => {}, + setIfNotExists: async () => false, +}; + +async function createMigrationFolder(args: { + source: string; + tag: string; + when: number; +}): Promise { + const folder = await mkdtemp(join(tmpdir(), "junior-mixed-runner-")); + temporaryDirectories.push(folder); + await mkdir(join(folder, "meta")); + await writeFile( + join(folder, "meta", "_journal.json"), + JSON.stringify({ + version: "7", + dialect: "postgresql", + entries: [ + { + idx: 0, + version: "7", + when: args.when, + tag: args.tag, + breakpoints: true, + }, + ], + }), + ); + await writeFile(join(folder, `${args.tag}.ts`), args.source); + return folder; +} + +function migrationOptions(args: { + executor?: LocalJuniorSqlFixture["sql"]; + fixture: LocalJuniorSqlFixture; + folder: string; + table: string; +}) { + const executor = args.executor ?? args.fixture.sql; + return { + executor, + migrationsFolder: args.folder, + migrationsTable: args.table, + loadTypeScript: async (migrationPath: string) => + await migrationLoader.import>(migrationPath), + createContext: ({ + progress, + }: { + progress: MigrationContextV1["progress"]; + }): MigrationContextV1 => ({ + database: executor, + log: () => {}, + progress, + state: unusedState, + }), + }; +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +describe("mixed migration runner database contract", () => { + it("persists failed progress and resumes a TypeScript migration", async () => { + const fixture = await createLocalJuniorSqlFixture(); + const folder = await createMigrationFolder({ + source: ` +export default { + apiVersion: 1, + async up(context) { + const progress = await context.progress.load(); + if (progress === undefined) { + await context.database.execute( + "INSERT INTO mixed_runner_events (stage) VALUES ($1)", + ["before-failure"], + ); + await context.progress.save({ cursor: 1 }); + throw new Error("intentional migration interruption"); + } + await context.database.execute( + "INSERT INTO mixed_runner_events (stage) VALUES ($1)", + ["after-resume"], + ); + return { resumedFrom: progress.cursor }; + }, +}; +`, + tag: "0000_resumable", + when: 2_026_071_600_001, + }); + const table = "__junior_mixed_runner_resume"; + const options = migrationOptions({ fixture, folder, table }); + + try { + await fixture.sql.execute(` +CREATE TABLE mixed_runner_events ( + id SERIAL PRIMARY KEY, + stage TEXT NOT NULL +) +`); + + await expect(runMigrationJournal(options)).rejects.toThrow( + "intentional migration interruption", + ); + + await expect( + fixture.sql.query( + `SELECT stage FROM mixed_runner_events ORDER BY id ASC`, + ), + ).resolves.toEqual([{ stage: "before-failure" }]); + await expect( + fixture.sql.query(` +SELECT + name, + kind, + status, + progress, + result, + completed_at IS NOT NULL AS "completed" +FROM drizzle.${table} +`), + ).resolves.toEqual([ + { + completed: false, + kind: "typescript", + name: "0000_resumable", + progress: { cursor: 1 }, + result: null, + status: "failed", + }, + ]); + + await expect(runMigrationJournal(options)).resolves.toEqual({ + existing: 0, + migrated: 1, + scanned: 1, + skipped: 0, + }); + + await expect( + fixture.sql.query( + `SELECT stage FROM mixed_runner_events ORDER BY id ASC`, + ), + ).resolves.toEqual([ + { stage: "before-failure" }, + { stage: "after-resume" }, + ]); + await expect( + fixture.sql.query(` +SELECT + status, + progress, + result, + completed_at IS NOT NULL AS "completed" +FROM drizzle.${table} +`), + ).resolves.toEqual([ + { + completed: true, + progress: { cursor: 1 }, + result: { resumedFrom: 1 }, + status: "completed", + }, + ]); + } finally { + await fixture.close(); + } + }); + + it.skipIf(!hasJuniorPostgresTestDatabase())( + "serializes concurrent runs and executes a TypeScript migration once", + async () => { + const fixture = await createLocalJuniorSqlFixture(); + const folder = await createMigrationFolder({ + source: ` +export default { + apiVersion: 1, + async up(context) { + await context.database.execute( + "INSERT INTO mixed_runner_lock_events (stage) VALUES ($1)", + ["body"], + ); + while (true) { + const [barrier] = await context.database.query( + "SELECT released FROM mixed_runner_lock_barrier WHERE id = 1", + ); + if (barrier?.released === true) break; + await context.database.query("SELECT pg_sleep(0.01)"); + } + return { executed: true }; + }, +}; +`, + tag: "0000_locked", + when: 2_026_071_600_002, + }); + const table = "__junior_mixed_runner_lock"; + let lockAttempts = 0; + const observedExecutor = new Proxy(fixture.sql, { + get(target, key, receiver) { + if (key === "withMigrationLock") { + return async ( + migrationTable: string, + callback: () => Promise, + ) => { + lockAttempts += 1; + return await target.withMigrationLock(migrationTable, callback); + }; + } + const value = Reflect.get(target, key, receiver) as unknown; + return typeof value === "function" ? value.bind(target) : value; + }, + }); + const options = migrationOptions({ + executor: observedExecutor, + fixture, + folder, + table, + }); + + try { + await fixture.sql.execute(` +CREATE TABLE mixed_runner_lock_events ( + id SERIAL PRIMARY KEY, + stage TEXT NOT NULL +); +CREATE TABLE mixed_runner_lock_barrier ( + id INTEGER PRIMARY KEY, + released BOOLEAN NOT NULL +); +INSERT INTO mixed_runner_lock_barrier (id, released) VALUES (1, false) +`); + + const first = runMigrationJournal(options); + await vi.waitFor(async () => { + await expect( + fixture.sql.query( + "SELECT count(*)::integer AS count FROM mixed_runner_lock_events", + ), + ).resolves.toEqual([{ count: 1 }]); + }); + const second = runMigrationJournal(options); + await vi.waitFor(() => expect(lockAttempts).toBe(2)); + await expect( + fixture.sql.query( + "SELECT count(*)::integer AS count FROM mixed_runner_lock_events", + ), + ).resolves.toEqual([{ count: 1 }]); + await fixture.sql.execute( + "UPDATE mixed_runner_lock_barrier SET released = true WHERE id = 1", + ); + const results = await Promise.all([first, second]); + + expect(results).toEqual( + expect.arrayContaining([ + { existing: 0, migrated: 1, scanned: 1, skipped: 0 }, + { existing: 1, migrated: 0, scanned: 1, skipped: 0 }, + ]), + ); + await expect( + fixture.sql.query( + `SELECT stage FROM mixed_runner_lock_events ORDER BY id ASC`, + ), + ).resolves.toEqual([{ stage: "body" }]); + await expect( + fixture.sql.query(`SELECT status, result FROM drizzle.${table}`), + ).resolves.toEqual([ + { result: { executed: true }, status: "completed" }, + ]); + } finally { + await fixture.close(); + } + }, + 15_000, + ); +}); diff --git a/packages/junior/tests/component/scheduler-sql-plugin.test.ts b/packages/junior/tests/component/scheduler-sql-plugin.test.ts index e2023ba8d..14ef04386 100644 --- a/packages/junior/tests/component/scheduler-sql-plugin.test.ts +++ b/packages/junior/tests/component/scheduler-sql-plugin.test.ts @@ -17,7 +17,11 @@ import { 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"; +import { createPluginMigrationStateV1 } from "@/chat/plugins/migration-state"; +import { + bootstrapPluginSchemas, + migratePluginSchemas, +} from "@/chat/plugins/migrations"; import { createPluginState } from "@/chat/plugins/state"; import { disconnectStateAdapter } from "@/chat/state/adapter"; import { migratePluginJournals } from "@/cli/upgrade/migrations/plugin-journal"; @@ -68,7 +72,7 @@ function memoryMigrationsDir(): string { async function migrateSchedulerSchema( fixture: Awaited>, ) { - await migratePluginSchemas(fixture.sql, [ + await bootstrapPluginSchemas(fixture.sql, [ { dir: schedulerMigrationsDir(), pluginName: "scheduler", @@ -87,7 +91,7 @@ async function runSchedulerStateMigration(args: { load: async () => undefined, save: async () => {}, }, - state: args.stateAdapter as unknown as MigrationContextV1["state"], + state: createPluginMigrationStateV1("scheduler", args.stateAdapter), }); } @@ -131,6 +135,127 @@ describe("scheduler SQL plugin storage", () => { await disconnectStateAdapter(); }); + it("scopes migration state while preserving plugin legacy keys", async () => { + const stateAdapter = createMemoryState(); + await stateAdapter.connect(); + const state = createPluginMigrationStateV1("scheduler", stateAdapter); + + try { + await stateAdapter.set("global-key", "global-value"); + await stateAdapter.set("junior:memory:secret", "memory-value"); + await state.set("global-key", "scheduler-value"); + await state.set("junior:scheduler:legacy", "legacy-value"); + + await expect(state.get("global-key")).resolves.toBe("scheduler-value"); + await expect(stateAdapter.get("global-key")).resolves.toBe( + "global-value", + ); + await expect(state.get("junior:memory:secret")).resolves.toBeUndefined(); + await expect(state.get("junior:scheduler:legacy")).resolves.toBe( + "legacy-value", + ); + await expect(stateAdapter.get("junior:scheduler:legacy")).resolves.toBe( + "legacy-value", + ); + + await stateAdapter.appendToList("global-list", "global-item"); + await state.appendToList("global-list", "scheduler-item"); + await expect(state.getList("global-list")).resolves.toEqual([ + "scheduler-item", + ]); + await expect(stateAdapter.getList("global-list")).resolves.toEqual([ + "global-item", + ]); + + const scopedLock = await state.acquireLock("migration-lock", 1_000); + const globalLock = await stateAdapter.acquireLock( + "migration-lock", + 1_000, + ); + expect(scopedLock).not.toBeNull(); + expect(globalLock).not.toBeNull(); + if (scopedLock) { + await state.releaseLock(scopedLock); + } + if (globalLock) { + await stateAdapter.releaseLock(globalLock); + } + } finally { + await stateAdapter.disconnect(); + } + }); + + it("passes scoped state through the plugin migration host", async () => { + const stateAdapter = createMemoryState(); + await stateAdapter.connect(); + const fixture = await createLocalJuniorSqlFixture(); + const migrationsDir = mkdtempSync( + path.join(tmpdir(), "junior-plugin-state-migration-"), + ); + mkdirSync(path.join(migrationsDir, "meta")); + writeFileSync( + path.join(migrationsDir, "meta", "_journal.json"), + JSON.stringify({ + version: "7", + dialect: "postgresql", + entries: [ + { + idx: 0, + version: "7", + when: 2_026_071_600_003, + tag: "0000_state_scope", + breakpoints: true, + }, + ], + }), + ); + writeFileSync( + path.join(migrationsDir, "0000_state_scope.ts"), + "export default { apiVersion: 1, async up() {} };\n", + ); + const observed: unknown[] = []; + + try { + await stateAdapter.set("global-key", "global-value"); + await stateAdapter.set("junior:other:secret", "other-value"); + await migratePluginSchemas( + fixture.sql, + [{ dir: migrationsDir, pluginName: "isolation" }], + { + mode: "all", + stateAdapter, + loadTypeScript: async () => ({ + default: { + apiVersion: 1, + async up(context: MigrationContextV1) { + observed.push( + await context.state.get("global-key"), + await context.state.get("junior:other:secret"), + ); + await context.state.set("global-key", "scoped-value"); + return { scoped: true }; + }, + }, + }), + }, + ); + + expect(observed).toEqual([undefined, undefined]); + await expect(stateAdapter.get("global-key")).resolves.toBe( + "global-value", + ); + await expect( + createPluginMigrationStateV1("isolation", stateAdapter).get( + "global-key", + ), + ).resolves.toBe("scoped-value"); + } finally { + rmSync(migrationsDir, { force: true, recursive: true }); + await stateAdapter.disconnect(); + await fixture.close(); + } + }); + it("adopts deployed scheduler schema state into its Drizzle journal", async () => { const fixture = await createLocalJuniorSqlFixture(); @@ -191,7 +316,7 @@ INSERT INTO junior_scheduler_tasks ( ); await expect( - migratePluginSchemas(fixture.sql, [ + bootstrapPluginSchemas(fixture.sql, [ { dir: schedulerMigrationsDir(), pluginName: "scheduler", @@ -229,7 +354,7 @@ ORDER BY created_at }); expect(migratedTask?.record).not.toHaveProperty("credentialSubject"); await expect( - migratePluginSchemas(fixture.sql, [ + bootstrapPluginSchemas(fixture.sql, [ { dir: schedulerMigrationsDir(), pluginName: "scheduler", @@ -272,12 +397,14 @@ ORDER BY created_at (migration) => migration.kind === "sql", ).length; try { - await expect(migratePluginSchemas(fixture.sql, roots)).resolves.toEqual({ - existing: 0, - migrated: sqlMigrationCount, - scanned: migrationCount, - skipped: 1, - }); + await expect(bootstrapPluginSchemas(fixture.sql, roots)).resolves.toEqual( + { + existing: 0, + migrated: sqlMigrationCount, + scanned: migrationCount, + skipped: 1, + }, + ); const migrationTables = await fixture.sql.query<{ tablename: string }>(` SELECT tablename FROM pg_tables @@ -287,7 +414,7 @@ ORDER BY tablename `); expect(migrationTables).toHaveLength(2); await expect( - migratePluginSchemas(fixture.sql, [...roots].reverse()), + bootstrapPluginSchemas(fixture.sql, [...roots].reverse()), ).resolves.toEqual({ existing: sqlMigrationCount, migrated: 0, @@ -312,12 +439,12 @@ ORDER BY tablename try { await expect( - migratePluginSchemas(fixture.sql, [ + bootstrapPluginSchemas(fixture.sql, [ { dir: missingJournal, pluginName: "missing" }, ]), ).rejects.toThrow("Can't find meta/_journal.json file"); await expect( - migratePluginSchemas(fixture.sql, [ + bootstrapPluginSchemas(fixture.sql, [ { dir: invalidJournal, pluginName: "invalid" }, ]), ).rejects.toThrow("Expected property name"); diff --git a/packages/junior/tests/fixtures/postgres/global-setup.ts b/packages/junior/tests/fixtures/postgres/global-setup.ts index 8fa39271b..59a007636 100644 --- a/packages/junior/tests/fixtures/postgres/global-setup.ts +++ b/packages/junior/tests/fixtures/postgres/global-setup.ts @@ -6,7 +6,7 @@ import { type PostgresHarnessConfig, } from "@sentry/junior-testing/postgres"; import { migrateSchema } from "@/chat/conversations/sql/migrations"; -import { migratePluginSchemas } from "@/chat/plugins/migrations"; +import { bootstrapPluginSchemas } from "@/chat/plugins/migrations"; import type { JuniorSqlMigrationExecutor } from "@/db/db"; import { createPostgresJuniorSqlExecutor } from "@/db/postgres"; @@ -47,8 +47,8 @@ export async function setupJuniorPostgresHarness( migrateTemplate: async (connectionString) => { const executor = createPostgresJuniorSqlExecutor({ connectionString }); try { - await migrateSchema(executor); - await migratePluginSchemas(executor, [ + await migrateSchema(executor, { mode: "schema-bootstrap" }); + await bootstrapPluginSchemas(executor, [ { dir: path.resolve(process.cwd(), "../junior-scheduler/migrations"), pluginName: "scheduler", diff --git a/packages/junior/tests/integration/api/conversations/list.test.ts b/packages/junior/tests/integration/api/conversations/list.test.ts index 611f9d7af..d0c5ec0fa 100644 --- a/packages/junior/tests/integration/api/conversations/list.test.ts +++ b/packages/junior/tests/integration/api/conversations/list.test.ts @@ -11,7 +11,7 @@ describe("conversation list API", () => { const fixture = createConfiguredJuniorSqlFixture(); const store = createSqlStore(fixture.sql); try { - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await store.recordActivity({ actor: { email: "other@example.com", diff --git a/packages/junior/tests/integration/api/conversations/stats.test.ts b/packages/junior/tests/integration/api/conversations/stats.test.ts index 30880c5e1..f63339739 100644 --- a/packages/junior/tests/integration/api/conversations/stats.test.ts +++ b/packages/junior/tests/integration/api/conversations/stats.test.ts @@ -15,7 +15,7 @@ describe("conversation stats API", () => { const fixture = createConfiguredJuniorSqlFixture(); const store = createSqlStore(fixture.sql); try { - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await store.recordActivity({ conversationId: "slack:C1:recent", channelName: "proj-alpha", @@ -179,7 +179,7 @@ describe("conversation stats API", () => { const fixture = createConfiguredJuniorSqlFixture(); try { - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); const now = new Date("2026-06-15T11:00:00.000Z"); await fixture.sql .db() diff --git a/packages/junior/tests/integration/api/locations.test.ts b/packages/junior/tests/integration/api/locations.test.ts index 9420114df..c17dd0ae9 100644 --- a/packages/junior/tests/integration/api/locations.test.ts +++ b/packages/junior/tests/integration/api/locations.test.ts @@ -19,7 +19,7 @@ describe("locations API", () => { const store = createSqlStore(fixture.sql); try { - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); const nowMs = Date.parse("2026-06-15T11:00:00.000Z"); await store.recordActivity({ conversationId: "slack:C1:seed", diff --git a/packages/junior/tests/integration/api/people/fixture.ts b/packages/junior/tests/integration/api/people/fixture.ts index c35dc0ee1..99356d1ad 100644 --- a/packages/junior/tests/integration/api/people/fixture.ts +++ b/packages/junior/tests/integration/api/people/fixture.ts @@ -6,7 +6,7 @@ import type { LocalJuniorSqlFixture } from "../../../fixtures/sql"; /** Seed representative verified and untrusted people rows for people API tests. */ export async function seedPeople(fixture: LocalJuniorSqlFixture) { const store = createSqlStore(fixture.sql); - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await store.recordActivity({ conversationId: "slack:C1:123", diff --git a/packages/junior/tests/integration/api/people/profile.test.ts b/packages/junior/tests/integration/api/people/profile.test.ts index 99e172041..e259bcf56 100644 --- a/packages/junior/tests/integration/api/people/profile.test.ts +++ b/packages/junior/tests/integration/api/people/profile.test.ts @@ -120,7 +120,7 @@ describe("people profile API", () => { const store = createSqlStore(fixture.sql); try { - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); const nowMs = Date.parse("2026-06-15T11:00:00.000Z"); await store.recordActivity({ conversationId: "slack:C1:seed", diff --git a/packages/junior/tests/integration/conversation-sql.test.ts b/packages/junior/tests/integration/conversation-sql.test.ts index 511e60f70..889d40dfe 100644 --- a/packages/junior/tests/integration/conversation-sql.test.ts +++ b/packages/junior/tests/integration/conversation-sql.test.ts @@ -20,7 +20,7 @@ describe("conversation SQL local mode", () => { const fixture = await createLocalJuniorSqlFixture(); try { - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); const rows = await fixture.sql.query<{ column_name: string; @@ -132,7 +132,7 @@ INSERT INTO drizzle.__drizzle_migrations (hash, created_at) VALUES ('host-migration', 9999999999999) `); - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); const [host] = await fixture.sql.query<{ count: number }>( "SELECT count(*)::integer AS count FROM drizzle.__drizzle_migrations", @@ -156,7 +156,7 @@ VALUES ('host-migration', 9999999999999) }); try { - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await fixture.sql.execute(` CREATE TABLE junior_schema_migrations ( id TEXT PRIMARY KEY, @@ -191,7 +191,10 @@ ALTER TABLE junior_conversations second.query("SELECT 1"), ]); - await Promise.all([migrateSchema(fixture.sql), migrateSchema(second)]); + await Promise.all([ + migrateSchema(fixture.sql, { mode: "schema-bootstrap" }), + migrateSchema(second, { mode: "schema-bootstrap" }), + ]); const [journal] = await fixture.sql.query<{ count: number }>( "SELECT count(*)::integer AS count FROM drizzle.__drizzle_junior_core", ); @@ -207,8 +210,8 @@ ALTER TABLE junior_conversations const fixture = await createLocalJuniorSqlFixture(); try { - await migrateSchema(fixture.sql); - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); const conversation = buildJuniorSqlConversation({ conversationId: "slack:C123:1718123456.000000", @@ -291,7 +294,7 @@ WHERE conversation_id = $1 const fixture = await createLocalJuniorSqlFixture(); try { - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await fixture.sql.execute(` CREATE TABLE junior_schema_migrations ( id TEXT PRIMARY KEY, @@ -322,7 +325,7 @@ ALTER TABLE junior_conversations DROP COLUMN metric_run_id `); - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); const metricColumns = await fixture.sql.query<{ column_name: string }>(` SELECT column_name @@ -357,7 +360,7 @@ ORDER BY column_name const fixture = await createLocalJuniorSqlFixture(); try { - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); await fixture.sql.execute(` CREATE TABLE junior_schema_migrations ( id TEXT PRIMARY KEY, @@ -383,7 +386,7 @@ VALUES "ALTER TABLE junior_conversations DROP COLUMN archived_at", ); - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); const [migrationRows] = await fixture.sql.query<{ count: number }>( "SELECT count(*)::integer AS count FROM drizzle.__drizzle_junior_core", @@ -433,9 +436,9 @@ INSERT INTO junior_schema_migrations (id, checksum) VALUES ('0001_conversation_core', 'legacy-checksum-1') `); - await expect(migrateSchema(fixture.sql)).rejects.toThrow( - "Cannot adopt partial legacy core migration state", - ); + await expect( + migrateSchema(fixture.sql, { mode: "schema-bootstrap" }), + ).rejects.toThrow("Cannot adopt partial legacy core migration state"); } finally { await fixture.close(); } @@ -445,7 +448,7 @@ VALUES ('0001_conversation_core', 'legacy-checksum-1') const fixture = await createLocalJuniorSqlFixture(); try { - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); const store = createSqlStore(fixture.sql); await recordAgentTurnSessionSummary({ diff --git a/packages/junior/tests/integration/slack-conversation-search.test.ts b/packages/junior/tests/integration/slack-conversation-search.test.ts index 61a8f50bf..67add69bf 100644 --- a/packages/junior/tests/integration/slack-conversation-search.test.ts +++ b/packages/junior/tests/integration/slack-conversation-search.test.ts @@ -25,7 +25,7 @@ describe("searchConversationHistory", () => { const fixture = await createLocalJuniorSqlFixture(); try { - await migrateSchema(fixture.sql); + await migrateSchema(fixture.sql, { mode: "schema-bootstrap" }); const conversations = createSqlStore(fixture.sql); const messages = createSqlConversationMessageStore(fixture.sql); const search = createSqlConversationSearchStore(fixture.sql); diff --git a/packages/junior/tests/integration/slack-schedule-tools.test.ts b/packages/junior/tests/integration/slack-schedule-tools.test.ts index d68de51f1..93a4b62eb 100644 --- a/packages/junior/tests/integration/slack-schedule-tools.test.ts +++ b/packages/junior/tests/integration/slack-schedule-tools.test.ts @@ -17,7 +17,7 @@ import { type SchedulerDb, type SchedulerToolContext, } from "@sentry/junior-scheduler"; -import { migratePluginSchemas } from "@/chat/plugins/migrations"; +import { bootstrapPluginSchemas } from "@/chat/plugins/migrations"; import * as dbModule from "@/chat/db"; import { getPluginTools, setPlugins } from "@/chat/plugins/agent-hooks"; import { disconnectStateAdapter } from "@/chat/state/adapter"; @@ -41,7 +41,7 @@ function schedulerMigrationsDir(): string { async function useSchedulerSqlPlugin() { const fixture = await createLocalJuniorSqlFixture(); - await migratePluginSchemas(fixture.sql, [ + await bootstrapPluginSchemas(fixture.sql, [ { dir: schedulerMigrationsDir(), pluginName: "scheduler", From 4ca01d1dd845a1d9dd9aa2de7fde67b9a2dd2948 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Thu, 16 Jul 2026 15:42:23 -0700 Subject: [PATCH 8/8] fix(migrations): Preserve host state adapter identity Pass the real core state adapter into TypeScript migrations so Redis-native index and lock detection continues to work. Add multiline import guard coverage and remove the stale generated temp-path comment from the history migration. Co-Authored-By: GPT-5.6 Codex --- packages/junior-migrations/src/journal.ts | 4 +-- .../junior-migrations/tests/journal.test.ts | 12 +++++++ .../0008_conversation_history_to_sql.ts | 1 - .../src/chat/conversations/sql/migrations.ts | 3 +- .../junior/src/chat/migrations/state-v1.ts | 32 ------------------- .../tests/component/cli/upgrade-cli.test.ts | 30 +++++++++++++++++ 6 files changed, 45 insertions(+), 37 deletions(-) delete mode 100644 packages/junior/src/chat/migrations/state-v1.ts diff --git a/packages/junior-migrations/src/journal.ts b/packages/junior-migrations/src/journal.ts index 56ab5c969..ca1b92847 100644 --- a/packages/junior-migrations/src/journal.ts +++ b/packages/junior-migrations/src/journal.ts @@ -86,7 +86,7 @@ function validateTypeScriptSource(tag: string, source: string): void { } }; const imports = source.matchAll( - /^\s*import\s+([^;]+?)\s+from\s+["']([^"']+)["'];?/gm, + /^\s*import\s+([\s\S]*?)\s+from\s+["']([^"']+)["'];?/gm, ); for (const match of imports) { const clause = match[1]?.trim(); @@ -97,7 +97,7 @@ function validateTypeScriptSource(tag: string, source: string): void { validateRuntimeSpecifier(specifier); } const exports = source.matchAll( - /^\s*export\s+([^;]+?)\s+from\s+["']([^"']+)["'];?/gm, + /^\s*export\s+([\s\S]*?)\s+from\s+["']([^"']+)["'];?/gm, ); for (const match of exports) { const clause = match[1]?.trim(); diff --git a/packages/junior-migrations/tests/journal.test.ts b/packages/junior-migrations/tests/journal.test.ts index a25336a8b..38a2c9875 100644 --- a/packages/junior-migrations/tests/journal.test.ts +++ b/packages/junior-migrations/tests/journal.test.ts @@ -62,6 +62,18 @@ describe("resolveMigrations", () => { ); }); + it("rejects multiline imports of Junior runtime modules", async () => { + const folder = await migrationFolder(["0000_multiline"]); + await writeFile( + join(folder, "0000_multiline.ts"), + 'import {\n getChatConfig,\n getDb,\n} from "@sentry/junior/internal";\nexport default { apiVersion: 1, async up() {} };\n', + ); + + await expect(resolveMigrations(folder)).rejects.toThrow( + "cannot import application runtime code", + ); + }); + it("allows versioned Junior migration helpers", async () => { const folder = await migrationFolder(["0000_helpers"]); await writeFile( diff --git a/packages/junior/migrations/0008_conversation_history_to_sql.ts b/packages/junior/migrations/0008_conversation_history_to_sql.ts index 256c58ced..0f1828640 100644 --- a/packages/junior/migrations/0008_conversation_history_to_sql.ts +++ b/packages/junior/migrations/0008_conversation_history_to_sql.ts @@ -1034,7 +1034,6 @@ async function importConversationFromLegacy(conversationId, deps) { return { imported }; } -// ../../../../../../private/tmp/0008-entry.ts import { createMigrationSqlConversationMessageStore, createMigrationStateConversationStore, diff --git a/packages/junior/src/chat/conversations/sql/migrations.ts b/packages/junior/src/chat/conversations/sql/migrations.ts index 448bc6e35..aa8637dcc 100644 --- a/packages/junior/src/chat/conversations/sql/migrations.ts +++ b/packages/junior/src/chat/conversations/sql/migrations.ts @@ -10,7 +10,6 @@ import { } from "@sentry/junior-migrations"; import type { StateAdapter } from "chat"; import type { RedisStateAdapter } from "@chat-adapter/state-redis"; -import { createMigrationStateV1 } from "@/chat/migrations/state-v1"; import type { JuniorSqlMigrationExecutor } from "@/db/db"; import { juniorSqlSchema as schema } from "@/db/schema"; @@ -155,7 +154,7 @@ export async function migrateSchema( }, } : {}), - state: createMigrationStateV1(options.stateAdapter), + state: options.stateAdapter, }), loadTypeScript: options.loadTypeScript, mode: "all", diff --git a/packages/junior/src/chat/migrations/state-v1.ts b/packages/junior/src/chat/migrations/state-v1.ts deleted file mode 100644 index dd93a9ab3..000000000 --- a/packages/junior/src/chat/migrations/state-v1.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { MigrationStateV1 } from "@sentry/junior-migrations"; -import type { StateAdapter } from "chat"; - -/** Project the host state adapter onto the permanent v1 migration capability. */ -export function createMigrationStateV1( - 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: string) => - (await stateAdapter.get(key)) ?? undefined, - getList: async (key: string) => 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), - }; -} diff --git a/packages/junior/tests/component/cli/upgrade-cli.test.ts b/packages/junior/tests/component/cli/upgrade-cli.test.ts index 9b1619989..64f69e347 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 type { MigrationContextV1 } from "@sentry/junior-migrations"; import { createJiti } from "jiti"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { getChatConfig } from "@/chat/config"; @@ -379,6 +380,35 @@ export const plugins = { } }, 15_000); + it("passes the host state adapter through without wrapping its identity", async () => { + const stateAdapter = getStateAdapter(); + await stateAdapter.connect(); + const fixture = await createLocalJuniorSqlFixture(); + const observedStates: unknown[] = []; + + try { + await migrateSchema(fixture.sql, { + loadTypeScript: async () => ({ + default: { + apiVersion: 1, + async up(context: MigrationContextV1) { + observedStates.push(context.state); + }, + }, + }), + mode: "all", + stateAdapter, + }); + + expect(observedStates).toHaveLength(5); + expect(observedStates.every((state) => state === stateAdapter)).toBe( + true, + ); + } finally { + await fixture.close(); + } + }); + it("migrates legacy conversation work before SQL conversation backfill", async () => { const stateAdapter = getStateAdapter(); await stateAdapter.connect();