From 16f61e4883a4ba4d52805603f0740d500659a3ef Mon Sep 17 00:00:00 2001 From: David Cramer Date: Wed, 27 May 2026 21:59:45 -0700 Subject: [PATCH 1/5] ref(scheduler): Extract scheduler plugin package Move scheduler tools, state, prompt framing, and heartbeat hooks into a dedicated @sentry/junior-scheduler package. Core now exposes the trusted plugin state primitives the scheduler needs and apps opt in with schedulerPlugin(). Update docs, tests, eval harness wiring, and release metadata for the new package. Co-Authored-By: GPT-5 Codex --- .craft.yml | 3 + .github/workflows/ci.yml | 1 + CONTRIBUTING.md | 1 + README.md | 1 + package.json | 2 +- .../src/content/docs/contribute/releasing.md | 1 + .../docs/src/content/docs/extend/index.md | 16 +-- .../content/docs/extend/scheduler-plugin.md | 31 ++++-- .../docs/reference/api/functions/createApp.md | 2 +- .../api/interfaces/JuniorAppOptions.md | 8 +- .../content/docs/start-here/existing-app.md | 1 + .../src/content/docs/start-here/quickstart.md | 11 +- .../junior-evals/evals/behavior-harness.ts | 4 +- packages/junior-evals/package.json | 1 + packages/junior-plugin-api/src/index.ts | 14 +++ packages/junior-scheduler/package.json | 40 +++++++ packages/junior-scheduler/plugin.yaml | 2 + .../src}/cadence.ts | 2 +- packages/junior-scheduler/src/index.ts | 27 +++++ .../src}/plugin.ts | 33 +++--- .../src}/prompt.ts | 12 ++- .../src}/schedule-tools.ts | 100 +++++++++++++----- .../src}/store.ts | 55 ++++------ .../src}/types.ts | 0 packages/junior-scheduler/tsconfig.build.json | 10 ++ packages/junior-scheduler/tsconfig.json | 14 +++ packages/junior-scheduler/tsup.config.ts | 13 +++ packages/junior/package.json | 1 + packages/junior/src/app.ts | 8 +- packages/junior/src/chat/plugins/state.ts | 32 ++++++ .../tools/execution/tool-error-handler.ts | 12 ++- .../tests/integration/heartbeat.test.ts | 41 ++++--- .../integration/slack-schedule-tools.test.ts | 71 +++++++------ .../unit/slack/tool-registration.test.ts | 4 +- pnpm-lock.yaml | 57 +++++++--- scripts/bump-release-versions.mjs | 1 + 36 files changed, 453 insertions(+), 179 deletions(-) create mode 100644 packages/junior-scheduler/package.json create mode 100644 packages/junior-scheduler/plugin.yaml rename packages/{junior/src/chat/scheduler => junior-scheduler/src}/cadence.ts (99%) create mode 100644 packages/junior-scheduler/src/index.ts rename packages/{junior/src/chat/scheduler => junior-scheduler/src}/plugin.ts (91%) rename packages/{junior/src/chat/scheduler => junior-scheduler/src}/prompt.ts (93%) rename packages/{junior/src/chat/tools/slack => junior-scheduler/src}/schedule-tools.ts (88%) rename packages/{junior/src/chat/scheduler => junior-scheduler/src}/store.ts (94%) rename packages/{junior/src/chat/scheduler => junior-scheduler/src}/types.ts (100%) create mode 100644 packages/junior-scheduler/tsconfig.build.json create mode 100644 packages/junior-scheduler/tsconfig.json create mode 100644 packages/junior-scheduler/tsup.config.ts diff --git a/.craft.yml b/.craft.yml index 84d213dbf..06fb57089 100644 --- a/.craft.yml +++ b/.craft.yml @@ -29,3 +29,6 @@ targets: - name: npm id: "@sentry/junior-sentry" includeNames: /^sentry-junior-sentry-\d.*\.tgz$/ + - name: npm + id: "@sentry/junior-scheduler" + includeNames: /^sentry-junior-scheduler-\d.*\.tgz$/ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f8cf651e4..febc40832 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,6 +58,7 @@ jobs: pnpm --filter @sentry/junior-hex pack --pack-destination artifacts pnpm --filter @sentry/junior-linear pack --pack-destination artifacts pnpm --filter @sentry/junior-notion pack --pack-destination artifacts + pnpm --filter @sentry/junior-scheduler pack --pack-destination artifacts pnpm --filter @sentry/junior-sentry pack --pack-destination artifacts ls -la artifacts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 284863e88..b7b40252e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -67,6 +67,7 @@ This repo uses Craft for manual lockstep npm releases of: - `@sentry/junior-hex` - `@sentry/junior-linear` - `@sentry/junior-notion` +- `@sentry/junior-scheduler` - `@sentry/junior-sentry` Run `pnpm release:check` before changing release package lists so `.craft.yml`, CI, diff --git a/README.md b/README.md index 0bad762c4..d7b0bde86 100644 --- a/README.md +++ b/README.md @@ -30,4 +30,5 @@ Start here: | `@sentry/junior-hex` | Hex plugin package for data warehouse query workflows | | `@sentry/junior-linear` | Linear plugin package for issue workflows | | `@sentry/junior-notion` | Notion plugin package for page search workflows | +| `@sentry/junior-scheduler` | Scheduler plugin package for scheduled Junior tasks | | `@sentry/junior-sentry` | Sentry plugin package for issue workflows | diff --git a/package.json b/package.json index fa83fcca4..aea853c67 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "test:watch": "pnpm --filter @sentry/junior test:watch", "evals": "pnpm --filter @sentry/junior-evals evals", "evals:record": "pnpm --filter @sentry/junior-evals evals:record", - "typecheck": "pnpm --filter @sentry/junior typecheck && pnpm --filter @sentry/junior-testing typecheck && pnpm --filter @sentry/junior-example typecheck", + "typecheck": "pnpm --filter @sentry/junior-plugin-api typecheck && pnpm --filter @sentry/junior-scheduler typecheck && pnpm --filter @sentry/junior typecheck && pnpm --filter @sentry/junior-testing typecheck && pnpm --filter @sentry/junior-example typecheck", "skills:check": "pnpm --filter @sentry/junior skills:check" }, "simple-git-hooks": { diff --git a/packages/docs/src/content/docs/contribute/releasing.md b/packages/docs/src/content/docs/contribute/releasing.md index d94b4ad1a..ea90f36f6 100644 --- a/packages/docs/src/content/docs/contribute/releasing.md +++ b/packages/docs/src/content/docs/contribute/releasing.md @@ -19,6 +19,7 @@ Junior uses lockstep package releases for: - `@sentry/junior-hex` - `@sentry/junior-linear` - `@sentry/junior-notion` +- `@sentry/junior-scheduler` - `@sentry/junior-sentry` ## Package release diff --git a/packages/docs/src/content/docs/extend/index.md b/packages/docs/src/content/docs/extend/index.md index db7fe0aa7..87d094c87 100644 --- a/packages/docs/src/content/docs/extend/index.md +++ b/packages/docs/src/content/docs/extend/index.md @@ -54,12 +54,9 @@ my-junior-plugin/ For reuse across apps or teams, package plugin manifests and any bundled skills as npm packages and install them next to `@sentry/junior`. ```bash -pnpm add @sentry/junior @sentry/junior-agent-browser @sentry/junior-datadog @sentry/junior-github @sentry/junior-hex @sentry/junior-linear @sentry/junior-notion @sentry/junior-sentry +pnpm add @sentry/junior @sentry/junior-agent-browser @sentry/junior-datadog @sentry/junior-github @sentry/junior-hex @sentry/junior-linear @sentry/junior-notion @sentry/junior-scheduler @sentry/junior-sentry ``` -Junior also includes the built-in [Scheduler Plugin](/extend/scheduler-plugin/) -for reminders and recurring Slack tasks. It does not require a separate package. - List the plugin packages in `juniorNitro` so they are bundled at build time and available at runtime: ```ts title="nitro.config.ts" @@ -78,6 +75,7 @@ export default defineConfig({ "@sentry/junior-hex", "@sentry/junior-linear", "@sentry/junior-notion", + "@sentry/junior-scheduler", "@sentry/junior-sentry", ], }, @@ -115,18 +113,20 @@ If you publish your own package with bundled skills, include both `plugin.yaml` Some packaged plugins also export trusted runtime hooks for deterministic behavior that cannot live in skill prose or `plugin.yaml`. For example, the -GitHub plugin registers runtime code that installs a sandbox Git hook, -configures global Git defaults, and injects commit attribution env before bash -commands run. +scheduler plugin registers schedule-management tools and heartbeat behavior, and +the GitHub plugin installs a sandbox Git hook, configures global Git defaults, +and injects commit attribution env before bash commands run. Trusted hooks are explicit app code: ```ts title="server.ts" import { createApp } from "@sentry/junior"; import { githubPlugin } from "@sentry/junior-github"; +import { schedulerPlugin } from "@sentry/junior-scheduler"; const app = await createApp({ plugins: [ + schedulerPlugin(), githubPlugin({ botNameEnv: "GITHUB_APP_BOT_NAME", botEmailEnv: "GITHUB_APP_BOT_EMAIL", @@ -331,7 +331,7 @@ Then install it in the host app: pnpm add @acme/junior-example ``` -The `juniorNitro({ plugins: { packages: [...] } })` module includes `app/**/*` and the declared plugin package content in the deployed function bundle. The plugin list is automatically available at runtime via `createApp()` for declarative manifest behavior. Plugins that export trusted runtime hooks, such as `@sentry/junior-github`, must also be registered from app code. +The `juniorNitro({ plugins: { packages: [...] } })` module includes `app/**/*` and the declared plugin package content in the deployed function bundle. The plugin list is automatically available at runtime via `createApp()` for declarative manifest behavior. Plugins that export trusted runtime hooks, such as `@sentry/junior-scheduler` and `@sentry/junior-github`, must also be registered from app code. ## Validate extensions diff --git a/packages/docs/src/content/docs/extend/scheduler-plugin.md b/packages/docs/src/content/docs/extend/scheduler-plugin.md index 5e0ba7f1e..51e0adb4d 100644 --- a/packages/docs/src/content/docs/extend/scheduler-plugin.md +++ b/packages/docs/src/content/docs/extend/scheduler-plugin.md @@ -1,8 +1,8 @@ --- title: Scheduler Plugin -description: Enable and verify Junior's built-in scheduled task support. +description: Enable and verify Junior's scheduled task support. type: tutorial -summary: Configure the built-in scheduler plugin so Slack users can create reminders and recurring tasks. +summary: Configure the scheduler plugin so Slack users can create reminders and recurring tasks. prerequisites: - /start-here/quickstart/ - /start-here/slack-app-setup/ @@ -12,20 +12,39 @@ related: - /operate/reliability-runbooks/ --- -The scheduler plugin is built into `@sentry/junior`. It registers Slack tools for creating, listing, updating, deleting, and running scheduled tasks, then uses Junior's internal heartbeat to dispatch due work back to the configured Slack conversation. +The scheduler plugin lives in `@sentry/junior-scheduler`. It registers Slack tools for creating, listing, updating, deleting, and running scheduled tasks, then uses Junior's internal heartbeat to dispatch due work back to the configured Slack conversation. ## Runtime setup -No plugin package install is required. `createApp()` registers the trusted scheduler plugin automatically: +Install the package next to `@sentry/junior`: + +```bash +pnpm add @sentry/junior-scheduler +``` + +Register the trusted plugin in app code: ```ts title="server.ts" import { createApp } from "@sentry/junior"; +import { schedulerPlugin } from "@sentry/junior-scheduler"; -const app = await createApp(); +const app = await createApp({ + plugins: [schedulerPlugin()], +}); export default app; ``` +List the package in `juniorNitro()` as well so Nitro bundles the manifest: + +```ts title="nitro.config.ts" +juniorNitro({ + plugins: { + packages: ["@sentry/junior-scheduler"], + }, +}); +``` + The scaffolded `vercel.json` includes the internal heartbeat route: ```json title="vercel.json" @@ -80,4 +99,4 @@ For recurring or non-reminder scheduled work, Junior should show the proposed ta ## Next step -Read [Build a Plugin](/extend/build-a-plugin/) for the trusted `tools(ctx)` and `heartbeat(ctx)` APIs that the built-in scheduler uses. +Read [Build a Plugin](/extend/build-a-plugin/) for the trusted `tools(ctx)` and `heartbeat(ctx)` APIs that the scheduler uses. diff --git a/packages/docs/src/content/docs/reference/api/functions/createApp.md b/packages/docs/src/content/docs/reference/api/functions/createApp.md index 83d9de613..0e0f9f689 100644 --- a/packages/docs/src/content/docs/reference/api/functions/createApp.md +++ b/packages/docs/src/content/docs/reference/api/functions/createApp.md @@ -7,7 +7,7 @@ title: "createApp" > **createApp**(`options?`): `Promise`\<`Hono`\<`BlankEnv`, `BlankSchema`, `"/"`\>\> -Defined in: [app.ts:180](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L180) +Defined in: [app.ts:179](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L179) Create a Hono app with all Junior routes. diff --git a/packages/docs/src/content/docs/reference/api/interfaces/JuniorAppOptions.md b/packages/docs/src/content/docs/reference/api/interfaces/JuniorAppOptions.md index e80efdc11..73f513fae 100644 --- a/packages/docs/src/content/docs/reference/api/interfaces/JuniorAppOptions.md +++ b/packages/docs/src/content/docs/reference/api/interfaces/JuniorAppOptions.md @@ -5,7 +5,7 @@ prev: false title: "JuniorAppOptions" --- -Defined in: [app.ts:33](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L33) +Defined in: [app.ts:32](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L32) ## Properties @@ -13,7 +13,7 @@ Defined in: [app.ts:33](https://github.com/getsentry/junior/blob/main/packages/j > `optional` **configDefaults?**: `Record`\<`string`, `unknown`\> -Defined in: [app.ts:35](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L35) +Defined in: [app.ts:34](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L34) Install-wide provider defaults (`provider.key` format). Channel overrides take precedence. @@ -23,7 +23,7 @@ Install-wide provider defaults (`provider.key` format). Channel overrides take p > `optional` **plugins?**: `PluginConfig` \| `JuniorPlugin`[] -Defined in: [app.ts:43](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L43) +Defined in: [app.ts:42](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L42) Plugin packages/overrides, or trusted plugin instances loaded by this app. @@ -37,4 +37,4 @@ their package config is merged with the catalog bundled by `juniorNitro()`. > `optional` **waitUntil?**: `WaitUntilFn` -Defined in: [app.ts:44](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L44) +Defined in: [app.ts:43](https://github.com/getsentry/junior/blob/main/packages/junior/src/app.ts#L43) diff --git a/packages/docs/src/content/docs/start-here/existing-app.md b/packages/docs/src/content/docs/start-here/existing-app.md index 1f7937993..d47a0b28a 100644 --- a/packages/docs/src/content/docs/start-here/existing-app.md +++ b/packages/docs/src/content/docs/start-here/existing-app.md @@ -58,6 +58,7 @@ If your existing app already owns routes, make sure the Junior Hono app still re Some packages also export trusted runtime hooks. Register those in `createApp()`; do not rely on `juniorNitro()` alone. For example, see +[Scheduler Plugin](/extend/scheduler-plugin/) for scheduled tasks and [GitHub Plugin](/extend/github-plugin/) for the `githubPlugin()` app-code setup. ## Add app files diff --git a/packages/docs/src/content/docs/start-here/quickstart.md b/packages/docs/src/content/docs/start-here/quickstart.md index 1401e4431..858ddf120 100644 --- a/packages/docs/src/content/docs/start-here/quickstart.md +++ b/packages/docs/src/content/docs/start-here/quickstart.md @@ -100,7 +100,7 @@ Packaged plugins must be installed and listed in `juniorNitro` so Nitro bundles Install only the plugins you plan to enable: ```bash -pnpm add @sentry/junior-agent-browser @sentry/junior-datadog @sentry/junior-github @sentry/junior-hex @sentry/junior-linear @sentry/junior-notion @sentry/junior-sentry +pnpm add @sentry/junior-agent-browser @sentry/junior-datadog @sentry/junior-github @sentry/junior-hex @sentry/junior-linear @sentry/junior-notion @sentry/junior-scheduler @sentry/junior-sentry ``` Then list them in `nitro.config.ts`: @@ -121,6 +121,7 @@ export default defineConfig({ "@sentry/junior-hex", "@sentry/junior-linear", "@sentry/junior-notion", + "@sentry/junior-scheduler", "@sentry/junior-sentry", ], }, @@ -139,9 +140,11 @@ pnpm check ``` Plugins with trusted runtime hooks need one more app-code registration step. -For example, `@sentry/junior-github` must be registered with `githubPlugin()` -inside `createApp()` to enforce Git commit attribution. See -[GitHub Plugin](/extend/github-plugin/) for that setup. +For example, `@sentry/junior-scheduler` must be registered with +`schedulerPlugin()` inside `createApp()` to enable scheduled tasks, and +`@sentry/junior-github` must be registered with `githubPlugin()` to enforce Git +commit attribution. See [Scheduler Plugin](/extend/scheduler-plugin/) and +[GitHub Plugin](/extend/github-plugin/) for those setups. ## Verify plugin content diff --git a/packages/junior-evals/evals/behavior-harness.ts b/packages/junior-evals/evals/behavior-harness.ts index 2b5c903eb..7eeda99bd 100644 --- a/packages/junior-evals/evals/behavior-harness.ts +++ b/packages/junior-evals/evals/behavior-harness.ts @@ -33,7 +33,7 @@ import { import { getAgentPlugins, setAgentPlugins } from "@/chat/plugins/agent-hooks"; import { getPluginOAuthConfig, setPluginConfig } from "@/chat/plugins/registry"; import { generateAssistantReply } from "@/chat/respond"; -import { createSchedulerPlugin } from "@/chat/scheduler/plugin"; +import { schedulerPlugin } from "@sentry/junior-scheduler"; import { getStateAdapter } from "@/chat/state/adapter"; import { resetSkillDiscoveryCache } from "@/chat/skills"; import { createWebFetchTool } from "@/chat/tools/web/fetch-tool"; @@ -1679,7 +1679,7 @@ export async function runEvalScenario( try { const currentAgentPlugins = getAgentPlugins(); previousAgentPlugins = setAgentPlugins([ - createSchedulerPlugin(), + schedulerPlugin(), ...currentAgentPlugins.filter((plugin) => plugin.name !== "scheduler"), ]); diff --git a/packages/junior-evals/package.json b/packages/junior-evals/package.json index 8d6a551c1..7bbc0d629 100644 --- a/packages/junior-evals/package.json +++ b/packages/junior-evals/package.json @@ -12,6 +12,7 @@ "devDependencies": { "@sentry/junior": "workspace:*", "@sentry/junior-github": "workspace:*", + "@sentry/junior-scheduler": "workspace:*", "@sentry/junior-sentry": "workspace:*", "@sentry/junior-testing": "workspace:*", "chat": "4.29.0", diff --git a/packages/junior-plugin-api/src/index.ts b/packages/junior-plugin-api/src/index.ts index a820e9c02..9b5252af9 100644 --- a/packages/junior-plugin-api/src/index.ts +++ b/packages/junior-plugin-api/src/index.ts @@ -25,6 +25,14 @@ export interface AgentPluginLogger { warn(message: string, metadata?: Record): void; } +/** Thrown when a trusted plugin tool rejects invalid model or user input. */ +export class AgentPluginToolInputError extends Error { + constructor(message: string, options?: { cause?: unknown }) { + super(message, options); + this.name = "AgentPluginToolInputError"; + } +} + export interface AgentPluginContext { log: AgentPluginLogger; plugin: AgentPluginMetadata; @@ -138,6 +146,12 @@ export interface AgentPluginState { delete(key: string): Promise; get(key: string): Promise; set(key: string, value: unknown, ttlMs?: number): Promise; + setIfNotExists(key: string, value: unknown, ttlMs?: number): Promise; + withLock( + key: string, + ttlMs: number, + callback: () => Promise, + ): Promise; } export interface HeartbeatHookContext extends AgentPluginContext { diff --git a/packages/junior-scheduler/package.json b/packages/junior-scheduler/package.json new file mode 100644 index 000000000..b2d91d2e2 --- /dev/null +++ b/packages/junior-scheduler/package.json @@ -0,0 +1,40 @@ +{ + "name": "@sentry/junior-scheduler", + "version": "0.55.0", + "private": false, + "publishConfig": { + "access": "public" + }, + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/getsentry/junior.git", + "directory": "packages/junior-scheduler" + }, + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist", + "src", + "plugin.yaml" + ], + "scripts": { + "build": "tsup && tsc -p tsconfig.build.json --emitDeclarationOnly", + "prepare": "pnpm run build", + "prepack": "pnpm run build", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@sentry/junior-plugin-api": "workspace:*", + "@sinclair/typebox": "^0.34.49" + }, + "devDependencies": { + "@types/node": "^25.9.1", + "tsup": "^8.5.1", + "typescript": "^6.0.3" + } +} diff --git a/packages/junior-scheduler/plugin.yaml b/packages/junior-scheduler/plugin.yaml new file mode 100644 index 000000000..9192075d9 --- /dev/null +++ b/packages/junior-scheduler/plugin.yaml @@ -0,0 +1,2 @@ +name: scheduler +description: Scheduled Junior task management and heartbeat dispatch diff --git a/packages/junior/src/chat/scheduler/cadence.ts b/packages/junior-scheduler/src/cadence.ts similarity index 99% rename from packages/junior/src/chat/scheduler/cadence.ts rename to packages/junior-scheduler/src/cadence.ts index 6b70455cf..8077690a9 100644 --- a/packages/junior/src/chat/scheduler/cadence.ts +++ b/packages/junior-scheduler/src/cadence.ts @@ -3,7 +3,7 @@ import type { ScheduledLocalTime, ScheduledTask, ScheduledTaskRecurrence, -} from "@/chat/scheduler/types"; +} from "./types"; /** Parse an ISO timestamp into a finite Unix timestamp in milliseconds. */ export function parseScheduleTimestamp(value: string): number | undefined { diff --git a/packages/junior-scheduler/src/index.ts b/packages/junior-scheduler/src/index.ts new file mode 100644 index 000000000..96b12b50c --- /dev/null +++ b/packages/junior-scheduler/src/index.ts @@ -0,0 +1,27 @@ +export { createSchedulerPlugin, schedulerPlugin } from "./plugin"; +export { buildScheduledTaskRunPrompt } from "./prompt"; +export { + createSlackScheduleCreateTaskTool, + createSlackScheduleDeleteTaskTool, + createSlackScheduleListTasksTool, + createSlackScheduleRunTaskNowTool, + createSlackScheduleUpdateTaskTool, + type SchedulerToolContext, +} from "./schedule-tools"; +export { createSchedulerStore } from "./store"; +export type { + ScheduledCalendarFrequency, + ScheduledLocalTime, + ScheduledRun, + ScheduledRunStatus, + ScheduledTask, + ScheduledTaskConversationAccess, + ScheduledTaskCredentialSubject, + ScheduledTaskDestination, + ScheduledTaskExecutionActor, + ScheduledTaskPrincipal, + ScheduledTaskRecurrence, + ScheduledTaskSchedule, + ScheduledTaskSpec, + ScheduledTaskStatus, +} from "./types"; diff --git a/packages/junior/src/chat/scheduler/plugin.ts b/packages/junior-scheduler/src/plugin.ts similarity index 91% rename from packages/junior/src/chat/scheduler/plugin.ts rename to packages/junior-scheduler/src/plugin.ts index 44f402a6c..d1c06048e 100644 --- a/packages/junior/src/chat/scheduler/plugin.ts +++ b/packages/junior-scheduler/src/plugin.ts @@ -1,23 +1,20 @@ import { defineJuniorPlugin, type Dispatch, + type AgentPluginToolDefinition, type ToolRegistrationHookContext, } from "@sentry/junior-plugin-api"; -import { buildScheduledTaskRunPrompt } from "@/chat/scheduler/prompt"; -import { - createStateSchedulerStore, - type SchedulerStore, -} from "@/chat/scheduler/store"; -import type { ScheduledRun, ScheduledTask } from "@/chat/scheduler/types"; +import { buildScheduledTaskRunPrompt } from "./prompt"; +import { createSchedulerStore, type SchedulerStore } from "./store"; +import type { ScheduledRun, ScheduledTask } from "./types"; import { createSlackScheduleCreateTaskTool, createSlackScheduleDeleteTaskTool, createSlackScheduleListTasksTool, createSlackScheduleRunTaskNowTool, createSlackScheduleUpdateTaskTool, -} from "@/chat/tools/slack/schedule-tools"; -import type { ToolDefinition } from "@/chat/tools/definition"; -import type { ToolRuntimeContext } from "@/chat/tools/types"; + type SchedulerToolContext, +} from "./schedule-tools"; const SCHEDULER_HEARTBEAT_LIMIT = 10; @@ -42,7 +39,7 @@ function shouldSkipRun( function createSchedulerToolContext( ctx: ToolRegistrationHookContext, -): ToolRuntimeContext { +): SchedulerToolContext { return { channelCapabilities: ctx.channelCapabilities ?? { canAddReactions: false, @@ -52,7 +49,7 @@ function createSchedulerToolContext( channelId: ctx.channelId, messageTs: ctx.messageTs, requester: ctx.requester, - sandbox: {} as ToolRuntimeContext["sandbox"], + state: ctx.state, teamId: ctx.teamId, threadTs: ctx.threadTs, userText: ctx.userText, @@ -63,7 +60,7 @@ async function applyDispatchResult(args: { dispatch: Dispatch; nowMs: number; run: ScheduledRun; - store: ReturnType; + store: ReturnType; }): Promise { if (args.dispatch.status === "completed") { const completed = await args.store.markRunCompleted({ @@ -173,10 +170,13 @@ async function failClaimedRun(args: { export function createSchedulerPlugin() { return defineJuniorPlugin({ name: "scheduler", + pluginConfig: { + packages: ["@sentry/junior-scheduler"], + }, hooks: { tools(ctx) { if (!ctx.channelId || !ctx.teamId || !ctx.requester?.userId) { - return {} as Record>; + return {} as Record>; } const context = createSchedulerToolContext(ctx); return { @@ -185,10 +185,10 @@ export function createSchedulerPlugin() { slackScheduleUpdateTask: createSlackScheduleUpdateTaskTool(context), slackScheduleDeleteTask: createSlackScheduleDeleteTaskTool(context), slackScheduleRunTaskNow: createSlackScheduleRunTaskNowTool(context), - } satisfies Record>; + } satisfies Record>; }, async heartbeat(ctx) { - const store = createStateSchedulerStore(); + const store = createSchedulerStore(ctx.state); let processedCount = 0; let dispatchCount = 0; for (const run of await store.listIncompleteRuns()) { @@ -306,3 +306,6 @@ export function createSchedulerPlugin() { }, }); } + +/** Register trusted scheduler runtime hooks for scheduled Junior tasks. */ +export const schedulerPlugin = createSchedulerPlugin; diff --git a/packages/junior/src/chat/scheduler/prompt.ts b/packages/junior-scheduler/src/prompt.ts similarity index 93% rename from packages/junior/src/chat/scheduler/prompt.ts rename to packages/junior-scheduler/src/prompt.ts index 8b19b335a..a22092b99 100644 --- a/packages/junior/src/chat/scheduler/prompt.ts +++ b/packages/junior-scheduler/src/prompt.ts @@ -1,9 +1,17 @@ -import { escapeXml } from "@/chat/xml"; import { SCHEDULED_TASK_SYSTEM_ACTOR, type ScheduledRun, type ScheduledTask, -} from "@/chat/scheduler/types"; +} from "./types"; + +function escapeXml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} const EXECUTION_RULES = [ "- Execute as the scheduled-task system actor; creator metadata is audit context, not an active user identity.", diff --git a/packages/junior/src/chat/tools/slack/schedule-tools.ts b/packages/junior-scheduler/src/schedule-tools.ts similarity index 88% rename from packages/junior/src/chat/tools/slack/schedule-tools.ts rename to packages/junior-scheduler/src/schedule-tools.ts index ad207a191..73de51da2 100644 --- a/packages/junior/src/chat/tools/slack/schedule-tools.ts +++ b/packages/junior-scheduler/src/schedule-tools.ts @@ -1,11 +1,14 @@ import { randomUUID } from "node:crypto"; import { Type } from "@sinclair/typebox"; import { - buildCalendarRecurrence, - parseScheduleTimestamp, -} from "@/chat/scheduler/cadence"; -import { createStateSchedulerStore } from "@/chat/scheduler/store"; -import { SCHEDULED_TASK_SYSTEM_ACTOR } from "@/chat/scheduler/types"; + AgentPluginToolInputError, + type AgentPluginRequester, + type AgentPluginState, + type AgentPluginToolDefinition, +} from "@sentry/junior-plugin-api"; +import { buildCalendarRecurrence, parseScheduleTimestamp } from "./cadence"; +import { createSchedulerStore } from "./store"; +import { SCHEDULED_TASK_SYSTEM_ACTOR } from "./types"; import type { ScheduledCalendarFrequency, ScheduledTask, @@ -15,12 +18,22 @@ import type { ScheduledTaskPrincipal, ScheduledTaskRecurrence, ScheduledTaskStatus, -} from "@/chat/scheduler/types"; -import { isDmChannel, normalizeSlackConversationId } from "@/chat/slack/client"; -import { isSlackTeamId } from "@/chat/slack/ids"; -import { tool } from "@/chat/tools/definition"; -import { ToolInputError } from "@/chat/tools/execution/tool-input-error"; -import type { ToolRuntimeContext } from "@/chat/tools/types"; +} from "./types"; + +export interface SchedulerToolContext { + channelCapabilities: { + canAddReactions: boolean; + canCreateCanvas: boolean; + canPostToChannel: boolean; + }; + channelId?: string; + messageTs?: string; + requester?: AgentPluginRequester; + state: AgentPluginState; + teamId?: string; + threadTs?: string; + userText?: string; +} const TASK_ID_PREFIX = "sched"; const MAX_LISTED_TASKS = 50; @@ -40,11 +53,11 @@ const recurrenceInputSchema = Type.Union([ ]); function throwToolInputError(error: string): never { - throw new ToolInputError(error); + throw new AgentPluginToolInputError(error); } function requireActiveDestination( - context: ToolRuntimeContext, + context: SchedulerToolContext, ): ScheduledTaskDestination { const channelId = normalizeSlackConversationId(context.channelId); if (!channelId) { @@ -64,7 +77,9 @@ function requireActiveDestination( }; } -function requireRequester(context: ToolRuntimeContext): ScheduledTaskPrincipal { +function requireRequester( + context: SchedulerToolContext, +): ScheduledTaskPrincipal { const userId = context.requester?.userId; if (!userId) { throwToolInputError("No active Slack requester context is available."); @@ -81,6 +96,27 @@ function requireRequester(context: ToolRuntimeContext): ScheduledTaskPrincipal { }; } +function tool( + definition: AgentPluginToolDefinition, +): AgentPluginToolDefinition { + return definition; +} + +function normalizeSlackConversationId( + value: string | undefined, +): string | undefined { + const trimmed = value?.trim(); + return trimmed || undefined; +} + +function isDmChannel(channelId: string): boolean { + return channelId.startsWith("D"); +} + +function isSlackTeamId(value: string): boolean { + return /^T[A-Z0-9]+$/.test(value); +} + function getConversationAccess( destination: ScheduledTaskDestination, ): ScheduledTaskConversationAccess { @@ -125,12 +161,14 @@ function sameDestination( } async function getWritableTask(args: { - context: ToolRuntimeContext; + context: SchedulerToolContext; taskId: string; }): Promise { const destination = requireActiveDestination(args.context); - const task = await createStateSchedulerStore().getTask(args.taskId); + const task = await createSchedulerStore(args.context.state).getTask( + args.taskId, + ); if (!task || task.status === "deleted") { throwToolInputError( "Scheduled task was not found in the active destination.", @@ -301,7 +339,9 @@ function parseNextRunAtMs( } /** Create a tool that stores a scheduled task for the active Slack context. */ -export function createSlackScheduleCreateTaskTool(context: ToolRuntimeContext) { +export function createSlackScheduleCreateTaskTool( + context: SchedulerToolContext, +) { return tool({ description: "Create a scheduled Junior task in the active Slack conversation.", @@ -378,7 +418,7 @@ export function createSlackScheduleCreateTaskTool(context: ToolRuntimeContext) { version: 1, }; - await createStateSchedulerStore().saveTask(task); + await createSchedulerStore(context.state).saveTask(task); return { ok: true, task: compactTask(task), @@ -388,7 +428,9 @@ export function createSlackScheduleCreateTaskTool(context: ToolRuntimeContext) { } /** Create a tool that lists scheduled tasks for the active Slack destination. */ -export function createSlackScheduleListTasksTool(context: ToolRuntimeContext) { +export function createSlackScheduleListTasksTool( + context: SchedulerToolContext, +) { return tool({ description: "List scheduled Junior tasks for the active Slack conversation.", @@ -402,7 +444,7 @@ export function createSlackScheduleListTasksTool(context: ToolRuntimeContext) { execute: async () => { const destination = requireActiveDestination(context); - const tasks = await createStateSchedulerStore().listTasksForTeam( + const tasks = await createSchedulerStore(context.state).listTasksForTeam( destination.teamId, ); const matching = tasks.filter((task) => @@ -420,7 +462,9 @@ export function createSlackScheduleListTasksTool(context: ToolRuntimeContext) { } /** Create a tool that edits a scheduled task in the active Slack destination. */ -export function createSlackScheduleUpdateTaskTool(context: ToolRuntimeContext) { +export function createSlackScheduleUpdateTaskTool( + context: SchedulerToolContext, +) { return tool({ description: "Edit, pause, resume, or reschedule a Junior scheduled task.", promptSnippet: "edit/pause/resume one schedule in this Slack destination", @@ -507,7 +551,7 @@ export function createSlackScheduleUpdateTaskTool(context: ToolRuntimeContext) { version: lookup.version + 1, }; - await createStateSchedulerStore().saveTask(next); + await createSchedulerStore(context.state).saveTask(next); return { ok: true, task: compactTask(next), @@ -517,7 +561,9 @@ export function createSlackScheduleUpdateTaskTool(context: ToolRuntimeContext) { } /** Create a tool that removes a scheduled task from the active Slack destination. */ -export function createSlackScheduleDeleteTaskTool(context: ToolRuntimeContext) { +export function createSlackScheduleDeleteTaskTool( + context: SchedulerToolContext, +) { return tool({ description: "Delete a Junior scheduled task from the active Slack conversation.", @@ -538,7 +584,7 @@ export function createSlackScheduleDeleteTaskTool(context: ToolRuntimeContext) { version: lookup.version + 1, }; - await createStateSchedulerStore().saveTask(next); + await createSchedulerStore(context.state).saveTask(next); return { ok: true, task: compactTask(next), @@ -548,7 +594,9 @@ export function createSlackScheduleDeleteTaskTool(context: ToolRuntimeContext) { } /** Create a tool that marks an existing scheduled task due immediately. */ -export function createSlackScheduleRunTaskNowTool(context: ToolRuntimeContext) { +export function createSlackScheduleRunTaskNowTool( + context: SchedulerToolContext, +) { return tool({ description: "Queue an active Junior scheduled task to run as soon as possible.", @@ -577,7 +625,7 @@ export function createSlackScheduleRunTaskNowTool(context: ToolRuntimeContext) { version: lookup.version + 1, }; - await createStateSchedulerStore().saveTask(next); + await createSchedulerStore(context.state).saveTask(next); return { ok: true, task: compactTask(next), diff --git a/packages/junior/src/chat/scheduler/store.ts b/packages/junior-scheduler/src/store.ts similarity index 94% rename from packages/junior/src/chat/scheduler/store.ts rename to packages/junior-scheduler/src/store.ts index 10cc9c06a..d792213fa 100644 --- a/packages/junior/src/chat/scheduler/store.ts +++ b/packages/junior-scheduler/src/store.ts @@ -1,7 +1,6 @@ -import type { Lock, StateAdapter } from "chat"; -import { getNextRunAtMs } from "@/chat/scheduler/cadence"; -import { getStateAdapter } from "@/chat/state/adapter"; -import type { ScheduledRun, ScheduledTask } from "@/chat/scheduler/types"; +import type { AgentPluginState } from "@sentry/junior-plugin-api"; +import { getNextRunAtMs } from "./cadence"; +import type { ScheduledRun, ScheduledTask } from "./types"; const SCHEDULER_KEY_PREFIX = "junior:scheduler"; const SCHEDULER_RECORD_TTL_MS = 5 * 365 * 24 * 60 * 60 * 1000; @@ -96,24 +95,15 @@ function unique(values: string[]): string[] { } async function withLock( - state: StateAdapter, + state: AgentPluginState, key: string, callback: () => Promise, ): Promise { - const lock: Lock | null = await state.acquireLock(key, LOCK_TTL_MS); - if (!lock) { - throw new Error(`Could not acquire scheduler lock for ${key}`); - } - - try { - return await callback(); - } finally { - await state.releaseLock(lock); - } + return await state.withLock(key, LOCK_TTL_MS, callback); } async function addToIndex( - state: StateAdapter, + state: AgentPluginState, key: string, taskId: string, ): Promise { @@ -126,7 +116,7 @@ async function addToIndex( } async function removeFromIndex( - state: StateAdapter, + state: AgentPluginState, key: string, taskId: string, ): Promise { @@ -148,7 +138,10 @@ async function removeFromIndex( }); } -async function getIndex(state: StateAdapter, key: string): Promise { +async function getIndex( + state: AgentPluginState, + key: string, +): Promise { const values = (await state.get(key)) ?? []; return unique( values.filter((value): value is string => typeof value === "string"), @@ -156,7 +149,7 @@ async function getIndex(state: StateAdapter, key: string): Promise { } async function clearActiveRun( - state: StateAdapter, + state: AgentPluginState, taskId: string, runId: string, ): Promise { @@ -169,7 +162,7 @@ async function clearActiveRun( } async function clearStaleActiveRun( - state: StateAdapter, + state: AgentPluginState, taskId: string, nowMs: number, ): Promise { @@ -353,15 +346,14 @@ function canFinishRun( return run.status === "running" && run.startedAtMs === startedAtMs; } -class StateAdapterSchedulerStore implements SchedulerStore { - private readonly state: StateAdapter; +class PluginStateSchedulerStore implements SchedulerStore { + private readonly state: AgentPluginState; - constructor(state: StateAdapter) { + constructor(state: AgentPluginState) { this.state = state; } async saveTask(task: ScheduledTask): Promise { - await this.state.connect(); await withLock(this.state, taskLockKey(task.id), async () => { const current = (await this.state.get(taskKey(task.id))) ?? undefined; @@ -416,12 +408,10 @@ class StateAdapterSchedulerStore implements SchedulerStore { } async getTask(taskId: string): Promise { - await this.state.connect(); return (await this.state.get(taskKey(taskId))) ?? undefined; } async listTasksForTeam(teamId: string): Promise { - await this.state.connect(); const ids = await getIndex(this.state, teamTaskIndexKey(teamId)); const tasks = await Promise.all(ids.map((id) => this.getTask(id))); return tasks @@ -433,7 +423,6 @@ class StateAdapterSchedulerStore implements SchedulerStore { async claimDueRun(args: { nowMs: number; }): Promise { - await this.state.connect(); const ids = await getIndex(this.state, globalTaskIndexKey()); for (const id of ids) { @@ -587,12 +576,10 @@ class StateAdapterSchedulerStore implements SchedulerStore { } async getRun(runId: string): Promise { - await this.state.connect(); return (await this.state.get(runKey(runId))) ?? undefined; } async listIncompleteRuns(): Promise { - await this.state.connect(); const ids = await getIndex(this.state, globalTaskIndexKey()); const runs: ScheduledRun[] = []; for (const taskId of ids) { @@ -721,7 +708,6 @@ class StateAdapterSchedulerStore implements SchedulerStore { run: ScheduledRun; status: "blocked" | "completed" | "failed"; }): Promise { - await this.state.connect(); await withLock(this.state, taskLockKey(args.run.taskId), async () => { const current = (await this.state.get(taskKey(args.run.taskId))) ?? @@ -812,7 +798,6 @@ class StateAdapterSchedulerStore implements SchedulerStore { runId: string, update: (run: ScheduledRun) => ScheduledRun | undefined, ): Promise { - await this.state.connect(); return await withLock(this.state, indexLockKey(runKey(runId)), async () => { const current = await this.getRun(runId); if (!current) { @@ -828,9 +813,7 @@ class StateAdapterSchedulerStore implements SchedulerStore { } } -/** Create the production scheduler store backed by Junior's state adapter. */ -export function createStateSchedulerStore( - stateAdapter: StateAdapter = getStateAdapter(), -): SchedulerStore { - return new StateAdapterSchedulerStore(stateAdapter); +/** Create a scheduler store backed by this plugin's durable state namespace. */ +export function createSchedulerStore(state: AgentPluginState): SchedulerStore { + return new PluginStateSchedulerStore(state); } diff --git a/packages/junior/src/chat/scheduler/types.ts b/packages/junior-scheduler/src/types.ts similarity index 100% rename from packages/junior/src/chat/scheduler/types.ts rename to packages/junior-scheduler/src/types.ts diff --git a/packages/junior-scheduler/tsconfig.build.json b/packages/junior-scheduler/tsconfig.build.json new file mode 100644 index 000000000..b398e67a8 --- /dev/null +++ b/packages/junior-scheduler/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "incremental": false, + "noEmit": false, + "outDir": "dist", + "rootDir": "src" + } +} diff --git a/packages/junior-scheduler/tsconfig.json b/packages/junior-scheduler/tsconfig.json new file mode 100644 index 000000000..8c5ea2850 --- /dev/null +++ b/packages/junior-scheduler/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "isolatedModules": true, + "noEmit": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/junior-scheduler/tsup.config.ts b/packages/junior-scheduler/tsup.config.ts new file mode 100644 index 000000000..a19165e0f --- /dev/null +++ b/packages/junior-scheduler/tsup.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: { + index: "src/index.ts", + }, + format: "esm", + tsconfig: "tsconfig.build.json", + dts: false, + outDir: "dist", + clean: true, + external: ["@sentry/junior-plugin-api", "@sinclair/typebox"], +}); diff --git a/packages/junior/package.json b/packages/junior/package.json index 11758211c..6e9956e2d 100644 --- a/packages/junior/package.json +++ b/packages/junior/package.json @@ -66,6 +66,7 @@ "@sentry/node": ">=10.0.0" }, "devDependencies": { + "@sentry/junior-scheduler": "workspace:*", "@sentry/node": "10.53.1", "@types/node": "^25.9.1", "dependency-cruiser": "^17.4.0", diff --git a/packages/junior/src/app.ts b/packages/junior/src/app.ts index 6074ceef5..088824e2c 100644 --- a/packages/junior/src/app.ts +++ b/packages/junior/src/app.ts @@ -12,7 +12,6 @@ import { setAgentPlugins, validateAgentPlugins, } from "@/chat/plugins/agent-hooks"; -import { createSchedulerPlugin } from "@/chat/scheduler/plugin"; import type { PluginConfig } from "@/chat/plugins/types"; import type { JuniorPlugin } from "@sentry/junior-plugin-api"; import { GET as diagnosticsGET } from "@/handlers/diagnostics"; @@ -179,10 +178,9 @@ function pluginConfigFromAgentPlugins( /** Create a Hono app with all Junior routes. */ export async function createApp(options?: JuniorAppOptions): Promise { const configuredPlugins = options?.plugins; - const agentPlugins = [ - createSchedulerPlugin(), - ...(isJuniorPluginArray(configuredPlugins) ? configuredPlugins : []), - ]; + const agentPlugins = isJuniorPluginArray(configuredPlugins) + ? configuredPlugins + : []; const pluginConfig = isJuniorPluginArray(configuredPlugins) ? mergePluginConfig( await resolveVirtualPluginConfig(), diff --git a/packages/junior/src/chat/plugins/state.ts b/packages/junior/src/chat/plugins/state.ts index f4c415f2b..c33a55c50 100644 --- a/packages/junior/src/chat/plugins/state.ts +++ b/packages/junior/src/chat/plugins/state.ts @@ -3,12 +3,19 @@ import type { AgentPluginState } from "@sentry/junior-plugin-api"; import { getStateAdapter } from "@/chat/state/adapter"; const MAX_PLUGIN_STATE_KEY_LENGTH = 512; +const SCHEDULER_PLUGIN_NAME = "scheduler"; +const SCHEDULER_STATE_PREFIX = "junior:scheduler"; function hashKeyPart(value: string): string { return createHash("sha256").update(value).digest("hex").slice(0, 32); } function pluginStateKey(plugin: string, key: string): string { + if (plugin === SCHEDULER_PLUGIN_NAME) { + return key.startsWith(`${SCHEDULER_STATE_PREFIX}:`) + ? key + : `${SCHEDULER_STATE_PREFIX}:${key}`; + } return `junior:plugin_state:${hashKeyPart(plugin)}:${hashKeyPart(key)}`; } @@ -42,5 +49,30 @@ export function createPluginState(plugin: string): AgentPluginState { await state.connect(); await state.set(pluginStateKey(plugin, key), value, ttlMs); }, + async setIfNotExists(key, value, ttlMs) { + validatePluginStateKey(key); + const state = getStateAdapter(); + await state.connect(); + return await state.setIfNotExists( + pluginStateKey(plugin, key), + value, + ttlMs, + ); + }, + async withLock(key, ttlMs, callback) { + validatePluginStateKey(key); + const state = getStateAdapter(); + await state.connect(); + const lock = await state.acquireLock(pluginStateKey(plugin, key), ttlMs); + if (!lock) { + throw new Error(`Could not acquire plugin state lock for ${key}`); + } + + try { + return await callback(); + } finally { + await state.releaseLock(lock); + } + }, }; } diff --git a/packages/junior/src/chat/tools/execution/tool-error-handler.ts b/packages/junior/src/chat/tools/execution/tool-error-handler.ts index 4ce1d2f22..a11baff80 100644 --- a/packages/junior/src/chat/tools/execution/tool-error-handler.ts +++ b/packages/junior/src/chat/tools/execution/tool-error-handler.ts @@ -5,6 +5,7 @@ import { setSpanAttributes, type LogContext, } from "@/chat/logging"; +import { AgentPluginToolInputError } from "@sentry/junior-plugin-api"; import { GEN_AI_PROVIDER_NAME } from "@/chat/pi/client"; import { getMcpAwareErrorMessage, McpToolError } from "@/chat/mcp/errors"; import { PluginCredentialFailureError } from "@/chat/services/plugin-auth-orchestration"; @@ -14,7 +15,12 @@ import { ToolInputError } from "@/chat/tools/execution/tool-input-error"; /** Classify tool errors into stable observability types. */ function getToolErrorType(error: unknown): string { if (error instanceof McpToolError) return "tool_error"; - if (error instanceof ToolInputError) return "tool_input_error"; + if ( + error instanceof ToolInputError || + error instanceof AgentPluginToolInputError + ) { + return "tool_input_error"; + } return error instanceof Error ? error.name : "tool_execution_error"; } @@ -90,7 +96,9 @@ export function handleToolExecutionError( // Expected tool failures (MCP errors, model input errors) are not Sentry exceptions. const isExpectedToolFailure = - error instanceof McpToolError || error instanceof ToolInputError; + error instanceof McpToolError || + error instanceof ToolInputError || + error instanceof AgentPluginToolInputError; if (!isExpectedToolFailure) { logException( error, diff --git a/packages/junior/tests/integration/heartbeat.test.ts b/packages/junior/tests/integration/heartbeat.test.ts index f47e31dce..b08824432 100644 --- a/packages/junior/tests/integration/heartbeat.test.ts +++ b/packages/junior/tests/integration/heartbeat.test.ts @@ -2,9 +2,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { defineJuniorPlugin } from "@sentry/junior-plugin-api"; import { createHeartbeatContext } from "@/chat/agent-dispatch/context"; import { recoverStaleDispatches } from "@/chat/agent-dispatch/heartbeat"; -import { createSchedulerPlugin } from "@/chat/scheduler/plugin"; -import { createStateSchedulerStore } from "@/chat/scheduler/store"; -import type { ScheduledTask } from "@/chat/scheduler/types"; +import { + createSchedulerStore, + schedulerPlugin, + type ScheduledTask, +} from "@sentry/junior-scheduler"; +import { createPluginState } from "@/chat/plugins/state"; import { createOrGetDispatch, getDispatchRecord, @@ -32,6 +35,10 @@ function collectWaitUntil(tasks: Promise[]): WaitUntilFn { }; } +function schedulerStore() { + return createSchedulerStore(createPluginState("scheduler")); +} + function createTask(overrides: Partial = {}): ScheduledTask { const nextRunAtMs = TEST_RUN_AT_MS; return { @@ -398,8 +405,8 @@ describe("trusted plugin heartbeat", () => { return new Response("Accepted", { status: 202 }); }); global.fetch = fetchMock as typeof fetch; - setAgentPlugins([createSchedulerPlugin()]); - const store = createStateSchedulerStore(); + setAgentPlugins([schedulerPlugin()]); + const store = schedulerStore(); await store.saveTask(createTask()); const firstWaitUntilTasks: Promise[] = []; @@ -458,8 +465,8 @@ describe("trusted plugin heartbeat", () => { return new Response("Accepted", { status: 202 }); }); global.fetch = fetchMock as typeof fetch; - setAgentPlugins([createSchedulerPlugin()]); - const store = createStateSchedulerStore(); + setAgentPlugins([schedulerPlugin()]); + const store = schedulerStore(); await store.saveTask( createTask({ destination: { @@ -503,8 +510,8 @@ describe("trusted plugin heartbeat", () => { return new Response("Accepted", { status: 202 }); }); global.fetch = fetchMock as typeof fetch; - setAgentPlugins([createSchedulerPlugin()]); - const store = createStateSchedulerStore(); + setAgentPlugins([schedulerPlugin()]); + const store = schedulerStore(); await store.saveTask(createTask()); const firstWaitUntilTasks: Promise[] = []; @@ -550,8 +557,8 @@ describe("trusted plugin heartbeat", () => { return new Response("Accepted", { status: 202 }); }); global.fetch = fetchMock as typeof fetch; - setAgentPlugins([createSchedulerPlugin()]); - const store = createStateSchedulerStore(); + setAgentPlugins([schedulerPlugin()]); + const store = schedulerStore(); await store.saveTask({ ...createTask(), id: "sched_plugin_malformed", @@ -594,8 +601,8 @@ describe("trusted plugin heartbeat", () => { return new Response("Accepted", { status: 202 }); }); global.fetch = fetchMock as typeof fetch; - setAgentPlugins([createSchedulerPlugin()]); - const store = createStateSchedulerStore(); + setAgentPlugins([schedulerPlugin()]); + const store = schedulerStore(); await store.saveTask({ ...createTask(), id: "sched_plugin_bad_destination", @@ -640,8 +647,8 @@ describe("trusted plugin heartbeat", () => { return new Response("Accepted", { status: 202 }); }); global.fetch = fetchMock as typeof fetch; - setAgentPlugins([createSchedulerPlugin()]); - const store = createStateSchedulerStore(); + setAgentPlugins([schedulerPlugin()]); + const store = schedulerStore(); const task = createDailyTask(); await store.saveTask(task); @@ -673,8 +680,8 @@ describe("trusted plugin heartbeat", () => { return new Response("Accepted", { status: 202 }); }); global.fetch = fetchMock as typeof fetch; - setAgentPlugins([createSchedulerPlugin()]); - const store = createStateSchedulerStore(); + setAgentPlugins([schedulerPlugin()]); + const store = schedulerStore(); const first = createDailyTask({ id: "sched_plugin_duplicate_a", createdAtMs: Date.parse("2026-05-24T12:00:00.000Z"), diff --git a/packages/junior/tests/integration/slack-schedule-tools.test.ts b/packages/junior/tests/integration/slack-schedule-tools.test.ts index f69bba4f6..d1526c24c 100644 --- a/packages/junior/tests/integration/slack-schedule-tools.test.ts +++ b/packages/junior/tests/integration/slack-schedule-tools.test.ts @@ -1,15 +1,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { disconnectStateAdapter, getStateAdapter } from "@/chat/state/adapter"; -import { createStateSchedulerStore } from "@/chat/scheduler/store"; -import { ToolInputError } from "@/chat/tools/execution/tool-input-error"; +import { AgentPluginToolInputError } from "@sentry/junior-plugin-api"; import { + createSchedulerStore, createSlackScheduleCreateTaskTool, createSlackScheduleDeleteTaskTool, createSlackScheduleListTasksTool, createSlackScheduleRunTaskNowTool, createSlackScheduleUpdateTaskTool, -} from "@/chat/tools/slack/schedule-tools"; -import type { ToolRuntimeContext } from "@/chat/tools/types"; + type SchedulerToolContext, +} from "@sentry/junior-scheduler"; +import { createPluginState } from "@/chat/plugins/state"; +import { disconnectStateAdapter } from "@/chat/state/adapter"; vi.hoisted(() => { process.env.JUNIOR_STATE_ADAPTER = "memory"; @@ -18,8 +19,8 @@ vi.hoisted(() => { const TEST_TEAM_ID = `TSCHEDULE${Date.now()}`; function createContext( - overrides: Partial = {}, -): ToolRuntimeContext { + overrides: Partial = {}, +): SchedulerToolContext { return { channelId: "C123", teamId: TEST_TEAM_ID, @@ -34,7 +35,7 @@ function createContext( canAddReactions: true, }, userText: "schedule this weekly", - sandbox: {} as ToolRuntimeContext["sandbox"], + state: createPluginState("scheduler"), ...overrides, }; } @@ -46,6 +47,10 @@ async function executeTool(tool: any, input: TInput) { return await tool.execute(input, {} as any); } +function schedulerStore() { + return createSchedulerStore(createPluginState("scheduler")); +} + async function createTask( context = createContext(), overrides: Record = {}, @@ -145,7 +150,7 @@ describe("Slack schedule tools", () => { }, }); await expect( - createStateSchedulerStore().listTasksForTeam(TEST_TEAM_ID), + schedulerStore().listTasksForTeam(TEST_TEAM_ID), ).resolves.toMatchObject([ { destination: { channelId: "C123" }, @@ -164,12 +169,12 @@ describe("Slack schedule tools", () => { }, ); - await expect(rejected).rejects.toThrow(ToolInputError); + await expect(rejected).rejects.toThrow(AgentPluginToolInputError); await expect(rejected).rejects.toThrow( "Active Slack workspace context is invalid.", ); await expect( - createStateSchedulerStore().listTasksForTeam(TEST_TEAM_ID), + schedulerStore().listTasksForTeam(TEST_TEAM_ID), ).resolves.toEqual([]); }); @@ -201,7 +206,7 @@ describe("Slack schedule tools", () => { }, }); await expect( - createStateSchedulerStore().listTasksForTeam(TEST_TEAM_ID), + schedulerStore().listTasksForTeam(TEST_TEAM_ID), ).resolves.toMatchObject([ { conversationAccess: { @@ -247,7 +252,7 @@ describe("Slack schedule tools", () => { }, }); await expect( - createStateSchedulerStore().listTasksForTeam(TEST_TEAM_ID), + schedulerStore().listTasksForTeam(TEST_TEAM_ID), ).resolves.toMatchObject([ { destination: { channelId: "C123" }, @@ -285,7 +290,7 @@ describe("Slack schedule tools", () => { }, }); await expect( - createStateSchedulerStore().listTasksForTeam(TEST_TEAM_ID), + schedulerStore().listTasksForTeam(TEST_TEAM_ID), ).resolves.toMatchObject([ { nextRunAtMs: Date.parse("2026-05-28T02:18:48.005Z"), @@ -305,7 +310,7 @@ describe("Slack schedule tools", () => { }), ).rejects.toThrow("Provide next_run_at as a valid ISO timestamp."); await expect( - createStateSchedulerStore().listTasksForTeam(TEST_TEAM_ID), + schedulerStore().listTasksForTeam(TEST_TEAM_ID), ).resolves.toEqual([]); }); @@ -316,7 +321,7 @@ describe("Slack schedule tools", () => { }), ).rejects.toThrow("Provide next_run_at as a valid ISO timestamp."); await expect( - createStateSchedulerStore().listTasksForTeam(TEST_TEAM_ID), + schedulerStore().listTasksForTeam(TEST_TEAM_ID), ).resolves.toEqual([]); }); @@ -330,7 +335,7 @@ describe("Slack schedule tools", () => { "Recurring scheduled tasks can run at most once per day.", ); await expect( - createStateSchedulerStore().listTasksForTeam(TEST_TEAM_ID), + schedulerStore().listTasksForTeam(TEST_TEAM_ID), ).resolves.toEqual([]); }); @@ -397,7 +402,7 @@ describe("Slack schedule tools", () => { "Recurring scheduled tasks can run at most once per day.", ); await expect( - createStateSchedulerStore().getTask(created.task.id), + schedulerStore().getTask(created.task.id), ).resolves.toMatchObject({ schedule: { description: "Every Monday at 9am", @@ -432,7 +437,7 @@ describe("Slack schedule tools", () => { }, }); await expect( - createStateSchedulerStore().getTask(created.task.id), + schedulerStore().getTask(created.task.id), ).resolves.toMatchObject({ schedule: { kind: "one_off", @@ -504,7 +509,7 @@ describe("Slack schedule tools", () => { }, }); await expect( - createStateSchedulerStore().getTask(created.task.id), + schedulerStore().getTask(created.task.id), ).resolves.toMatchObject({ status: "deleted", executionActor: { @@ -531,8 +536,7 @@ describe("Slack schedule tools", () => { credential_subject: null, }, }); - const tasks = - await createStateSchedulerStore().listTasksForTeam(TEST_TEAM_ID); + const tasks = await schedulerStore().listTasksForTeam(TEST_TEAM_ID); expect(tasks).toMatchObject([ { conversationAccess: { @@ -597,7 +601,7 @@ describe("Slack schedule tools", () => { }), ).rejects.toThrow("timezone must be a valid IANA time zone."); await expect( - createStateSchedulerStore().listTasksForTeam(TEST_TEAM_ID), + schedulerStore().listTasksForTeam(TEST_TEAM_ID), ).resolves.toEqual([]); }); @@ -608,7 +612,7 @@ describe("Slack schedule tools", () => { })) as { task: { id: string }; }; - const store = createStateSchedulerStore(); + const store = schedulerStore(); const task = await store.getTask(created.task.id); expect(task?.schedule.recurrence).toMatchObject({ interval: 1, @@ -651,7 +655,7 @@ describe("Slack schedule tools", () => { const created = (await createTask(context)) as { task: { id: string }; }; - const store = createStateSchedulerStore(); + const store = schedulerStore(); const task = await store.getTask(created.task.id); expect(task).toBeDefined(); await store.saveTask({ @@ -689,7 +693,7 @@ describe("Slack schedule tools", () => { const created = (await createTask(context)) as { task: { id: string }; }; - const store = createStateSchedulerStore(); + const store = schedulerStore(); const task = await store.getTask(created.task.id); expect(task).toBeDefined(); const scheduledNextRunAtMs = Date.parse("2026-06-01T16:00:00.000Z"); @@ -745,7 +749,7 @@ describe("Slack schedule tools", () => { const created = (await createTask(context)) as { task: { id: string }; }; - const store = createStateSchedulerStore(); + const store = schedulerStore(); const task = await store.getTask(created.task.id); expect(task).toBeDefined(); await store.saveTask({ @@ -781,14 +785,13 @@ describe("Slack schedule tools", () => { task_id: created.task.id, }); - const state = getStateAdapter(); - await state.connect(); - await expect(state.get("junior:scheduler:tasks")).resolves.toBe( - null, - ); + const state = createPluginState("scheduler"); + await expect( + state.get("junior:scheduler:tasks"), + ).resolves.toBeUndefined(); await expect( state.get(`junior:scheduler:team:${TEST_TEAM_ID}:tasks`), - ).resolves.toBe(null); + ).resolves.toBeUndefined(); }); it("claims due runs idempotently", async () => { @@ -796,7 +799,7 @@ describe("Slack schedule tools", () => { const created = (await createTask(context)) as { task: { id: string }; }; - const store = createStateSchedulerStore(); + const store = schedulerStore(); const task = await store.getTask(created.task.id); expect(task).toBeDefined(); await store.saveTask({ diff --git a/packages/junior/tests/unit/slack/tool-registration.test.ts b/packages/junior/tests/unit/slack/tool-registration.test.ts index cb34d1ac3..4f20f407f 100644 --- a/packages/junior/tests/unit/slack/tool-registration.test.ts +++ b/packages/junior/tests/unit/slack/tool-registration.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { createTools } from "@/chat/tools"; -import { createSchedulerPlugin } from "@/chat/scheduler/plugin"; +import { schedulerPlugin } from "@sentry/junior-scheduler"; import { setAgentPlugins } from "@/chat/plugins/agent-hooks"; import { resolveChannelCapabilities } from "@/chat/tools/channel-capabilities"; @@ -16,7 +16,7 @@ function ctx(channelId?: string) { describe("Slack tool registration", () => { beforeEach(() => { - setAgentPlugins([createSchedulerPlugin()]); + setAgentPlugins([schedulerPlugin()]); }); afterEach(() => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9c73e4c94..d2595fd69 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -175,6 +175,9 @@ importers: specifier: ^4.4.3 version: 4.4.3 devDependencies: + "@sentry/junior-scheduler": + specifier: workspace:* + version: link:../junior-scheduler "@sentry/node": specifier: 10.53.1 version: 10.53.1 @@ -206,18 +209,6 @@ importers: specifier: ^4.1.7 version: 4.1.7(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(msw@2.14.6(@types/node@25.9.1)(typescript@6.0.3))(vite@8.0.14(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) - packages/junior-plugin-api: - devDependencies: - oxlint: - specifier: ^1.66.0 - version: 1.66.0 - tsup: - specifier: ^8.5.1 - version: 8.5.1(jiti@2.7.0)(postcss@8.5.15)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0) - typescript: - specifier: ^6.0.3 - version: 6.0.3 - packages/junior-agent-browser: {} packages/junior-datadog: {} @@ -230,6 +221,9 @@ importers: "@sentry/junior-github": specifier: workspace:* version: link:../junior-github + "@sentry/junior-scheduler": + specifier: workspace:* + version: link:../junior-scheduler "@sentry/junior-sentry": specifier: workspace:* version: link:../junior-sentry @@ -261,6 +255,37 @@ importers: packages/junior-notion: {} + packages/junior-plugin-api: + devDependencies: + oxlint: + specifier: ^1.66.0 + version: 1.66.0 + tsup: + specifier: ^8.5.1 + version: 8.5.1(jiti@2.7.0)(postcss@8.5.15)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0) + typescript: + specifier: ^6.0.3 + version: 6.0.3 + + packages/junior-scheduler: + dependencies: + "@sentry/junior-plugin-api": + specifier: workspace:* + version: link:../junior-plugin-api + "@sinclair/typebox": + specifier: ^0.34.49 + version: 0.34.49 + devDependencies: + "@types/node": + specifier: ^25.9.1 + version: 25.9.1 + tsup: + specifier: ^8.5.1 + version: 8.5.1(jiti@2.7.0)(postcss@8.5.15)(tsx@4.22.3)(typescript@6.0.3)(yaml@2.9.0) + typescript: + specifier: ^6.0.3 + version: 6.0.3 + packages/junior-sentry: {} packages/junior-testing: @@ -3409,6 +3434,9 @@ packages: } engines: { node: ">=18" } + "@sentry/junior-plugin-api@file:packages/junior-plugin-api": + resolution: { directory: packages/junior-plugin-api, type: directory } + "@sentry/junior@file:packages/junior": resolution: { directory: packages/junior, type: directory } hasBin: true @@ -12389,6 +12417,8 @@ snapshots: "@sentry/core@10.53.1": {} + "@sentry/junior-plugin-api@file:packages/junior-plugin-api": {} + "@sentry/junior@file:packages/junior(@aws-sdk/credential-provider-web-identity@3.972.43)(@opentelemetry/api@1.9.1)(@sentry/node@10.53.1)": dependencies: "@ai-sdk/gateway": 3.0.119(zod@4.4.3) @@ -12399,6 +12429,7 @@ snapshots: "@earendil-works/pi-ai": 0.74.2(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.20.1)(zod@4.4.3) "@logtape/logtape": 2.1.1 "@modelcontextprotocol/sdk": 1.29.0(zod@4.4.3) + "@sentry/junior-plugin-api": file:packages/junior-plugin-api "@sentry/node": 10.53.1 "@sinclair/typebox": 0.34.49 "@slack/web-api": 7.16.0 @@ -12736,7 +12767,7 @@ snapshots: "@types/sax@1.2.7": dependencies: - "@types/node": 24.12.4 + "@types/node": 25.9.1 "@types/set-cookie-parser@2.4.10": dependencies: diff --git a/scripts/bump-release-versions.mjs b/scripts/bump-release-versions.mjs index 076c27adf..2398828c6 100644 --- a/scripts/bump-release-versions.mjs +++ b/scripts/bump-release-versions.mjs @@ -17,6 +17,7 @@ const files = [ "packages/junior-hex/package.json", "packages/junior-linear/package.json", "packages/junior-notion/package.json", + "packages/junior-scheduler/package.json", "packages/junior-sentry/package.json", ]; From d8cc217bae83b08f36f58d1a586294d1bc6b4021 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Wed, 27 May 2026 22:08:10 -0700 Subject: [PATCH 2/5] fix(scheduler): Keep extraction boundaries generic Restore Slack conversation id normalization in the extracted scheduler tools and update tests for explicit scheduler registration. Remove the scheduler-specific plugin-state key mapping from core so trusted plugin state remains a generic namespace primitive. Co-Authored-By: GPT-5 Codex --- .../junior-scheduler/src/schedule-tools.ts | 11 ++++-- packages/junior/src/chat/plugins/state.ts | 7 ---- .../tools/execution/tool-error-handler.ts | 14 ++++--- .../integration/slack-schedule-tools.test.ts | 38 +++++++++++++++---- packages/junior/tests/unit/app-config.test.ts | 17 ++------- 5 files changed, 52 insertions(+), 35 deletions(-) diff --git a/packages/junior-scheduler/src/schedule-tools.ts b/packages/junior-scheduler/src/schedule-tools.ts index 73de51da2..e6db9b6fe 100644 --- a/packages/junior-scheduler/src/schedule-tools.ts +++ b/packages/junior-scheduler/src/schedule-tools.ts @@ -105,12 +105,17 @@ function tool( function normalizeSlackConversationId( value: string | undefined, ): string | undefined { - const trimmed = value?.trim(); - return trimmed || undefined; + if (!value) return undefined; + const trimmed = value.trim(); + if (!trimmed) return undefined; + if (!trimmed.startsWith("slack:")) return trimmed; + + const parts = trimmed.split(":"); + return parts[1]?.trim() || undefined; } function isDmChannel(channelId: string): boolean { - return channelId.startsWith("D"); + return normalizeSlackConversationId(channelId)?.startsWith("D") ?? false; } function isSlackTeamId(value: string): boolean { diff --git a/packages/junior/src/chat/plugins/state.ts b/packages/junior/src/chat/plugins/state.ts index c33a55c50..ab8111a63 100644 --- a/packages/junior/src/chat/plugins/state.ts +++ b/packages/junior/src/chat/plugins/state.ts @@ -3,19 +3,12 @@ import type { AgentPluginState } from "@sentry/junior-plugin-api"; import { getStateAdapter } from "@/chat/state/adapter"; const MAX_PLUGIN_STATE_KEY_LENGTH = 512; -const SCHEDULER_PLUGIN_NAME = "scheduler"; -const SCHEDULER_STATE_PREFIX = "junior:scheduler"; function hashKeyPart(value: string): string { return createHash("sha256").update(value).digest("hex").slice(0, 32); } function pluginStateKey(plugin: string, key: string): string { - if (plugin === SCHEDULER_PLUGIN_NAME) { - return key.startsWith(`${SCHEDULER_STATE_PREFIX}:`) - ? key - : `${SCHEDULER_STATE_PREFIX}:${key}`; - } return `junior:plugin_state:${hashKeyPart(plugin)}:${hashKeyPart(key)}`; } diff --git a/packages/junior/src/chat/tools/execution/tool-error-handler.ts b/packages/junior/src/chat/tools/execution/tool-error-handler.ts index a11baff80..45cc6ad52 100644 --- a/packages/junior/src/chat/tools/execution/tool-error-handler.ts +++ b/packages/junior/src/chat/tools/execution/tool-error-handler.ts @@ -12,13 +12,17 @@ import { PluginCredentialFailureError } from "@/chat/services/plugin-auth-orches import { SlackActionError } from "@/chat/slack/client"; import { ToolInputError } from "@/chat/tools/execution/tool-input-error"; +function isPluginToolInputError(error: unknown): boolean { + return ( + error instanceof AgentPluginToolInputError || + (error instanceof Error && error.name === "AgentPluginToolInputError") + ); +} + /** Classify tool errors into stable observability types. */ function getToolErrorType(error: unknown): string { if (error instanceof McpToolError) return "tool_error"; - if ( - error instanceof ToolInputError || - error instanceof AgentPluginToolInputError - ) { + if (error instanceof ToolInputError || isPluginToolInputError(error)) { return "tool_input_error"; } return error instanceof Error ? error.name : "tool_execution_error"; @@ -98,7 +102,7 @@ export function handleToolExecutionError( const isExpectedToolFailure = error instanceof McpToolError || error instanceof ToolInputError || - error instanceof AgentPluginToolInputError; + isPluginToolInputError(error); if (!isExpectedToolFailure) { logException( error, diff --git a/packages/junior/tests/integration/slack-schedule-tools.test.ts b/packages/junior/tests/integration/slack-schedule-tools.test.ts index d1526c24c..5ef74b929 100644 --- a/packages/junior/tests/integration/slack-schedule-tools.test.ts +++ b/packages/junior/tests/integration/slack-schedule-tools.test.ts @@ -549,6 +549,34 @@ describe("Slack schedule tools", () => { expect(tasks[0]?.credentialSubject).toBeUndefined(); }); + it("normalizes Slack conversation ids before storing destinations", async () => { + const result = await createTask( + createContext({ channelId: "slack:D123:1700000000.000" }), + { + schedule: "In 1 minute", + next_run_at: "2026-05-27T00:25:23.000Z", + recurrence: undefined, + }, + ); + + expect(result).toMatchObject({ + ok: true, + task: { + conversation_access: { + audience: "direct", + visibility: "private", + }, + }, + }); + await expect( + schedulerStore().listTasksForTeam(TEST_TEAM_ID), + ).resolves.toMatchObject([ + { + destination: { channelId: "D123" }, + }, + ]); + }); + it("creates one-off tasks with an exact timestamp using the default Pacific timezone", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-05-25T12:00:00.000Z")); @@ -775,7 +803,7 @@ describe("Slack schedule tools", () => { expect(paused?.runNowAtMs).toBeUndefined(); }); - it("removes deleted tasks from scheduler indexes", async () => { + it("removes deleted tasks from scheduler listings", async () => { const context = createContext(); const created = (await createTask(context)) as { task: { id: string }; @@ -785,13 +813,9 @@ describe("Slack schedule tools", () => { task_id: created.task.id, }); - const state = createPluginState("scheduler"); await expect( - state.get("junior:scheduler:tasks"), - ).resolves.toBeUndefined(); - await expect( - state.get(`junior:scheduler:team:${TEST_TEAM_ID}:tasks`), - ).resolves.toBeUndefined(); + schedulerStore().listTasksForTeam(TEST_TEAM_ID), + ).resolves.toEqual([]); }); it("claims due runs idempotently", async () => { diff --git a/packages/junior/tests/unit/app-config.test.ts b/packages/junior/tests/unit/app-config.test.ts index b7a93be2a..f76174db6 100644 --- a/packages/junior/tests/unit/app-config.test.ts +++ b/packages/junior/tests/unit/app-config.test.ts @@ -88,9 +88,7 @@ describe("createApp plugin config", () => { }); expect(getPluginProviders()).toEqual([]); - expect(getAgentPlugins().map((plugin) => plugin.name)).toEqual([ - "scheduler", - ]); + expect(getAgentPlugins().map((plugin) => plugin.name)).toEqual([]); }); it("fails loudly when configured plugin package names are invalid", async () => { @@ -215,10 +213,7 @@ describe("createApp plugin config", () => { expect(getPluginProviders().map((plugin) => plugin.manifest.name)).toEqual([ "trusted", ]); - expect(getAgentPlugins().map((plugin) => plugin.name)).toEqual([ - "scheduler", - "trusted", - ]); + expect(getAgentPlugins().map((plugin) => plugin.name)).toEqual(["trusted"]); }); it("rejects duplicate trusted plugin names before mutating app config", async () => { @@ -235,9 +230,7 @@ describe("createApp plugin config", () => { }), ).rejects.toThrow('Duplicate trusted plugin name "dupe"'); - expect(getAgentPlugins().map((plugin) => plugin.name)).toEqual([ - "scheduler", - ]); + expect(getAgentPlugins().map((plugin) => plugin.name)).toEqual([]); expect(getPluginProviders()).toEqual([]); }); @@ -254,9 +247,7 @@ describe("createApp plugin config", () => { 'Trusted plugin name "GitHub" must be a lowercase plugin identifier', ); - expect(getAgentPlugins().map((plugin) => plugin.name)).toEqual([ - "scheduler", - ]); + expect(getAgentPlugins().map((plugin) => plugin.name)).toEqual([]); expect(getPluginProviders()).toEqual([]); }); }); From b8a4e76cdba2f57e1baac5287f88645cad37ff0b Mon Sep 17 00:00:00 2001 From: David Cramer Date: Wed, 27 May 2026 22:16:37 -0700 Subject: [PATCH 3/5] fix(scheduler): Preserve legacy scheduler state Allow extracted trusted plugins to declare legacy state prefixes during migration. This keeps existing scheduler records visible after the scheduler moves behind plugin state without adding scheduler-specific storage branches to core. Co-Authored-By: GPT-5 Codex --- packages/junior-plugin-api/src/index.ts | 1 + packages/junior-scheduler/src/plugin.ts | 1 + .../junior/src/chat/agent-dispatch/context.ts | 5 +- .../src/chat/agent-dispatch/heartbeat.ts | 1 + .../junior/src/chat/plugins/agent-hooks.ts | 4 +- packages/junior/src/chat/plugins/state.ts | 51 +++++++++++++++++-- .../tests/integration/heartbeat.test.ts | 24 +++++++++ 7 files changed, 81 insertions(+), 6 deletions(-) diff --git a/packages/junior-plugin-api/src/index.ts b/packages/junior-plugin-api/src/index.ts index 9b5252af9..88578c8a6 100644 --- a/packages/junior-plugin-api/src/index.ts +++ b/packages/junior-plugin-api/src/index.ts @@ -179,6 +179,7 @@ export interface AgentPluginHooks { } export interface JuniorPluginConfig { + legacyStatePrefixes?: string[]; packages?: string[]; } diff --git a/packages/junior-scheduler/src/plugin.ts b/packages/junior-scheduler/src/plugin.ts index d1c06048e..eecc31e6b 100644 --- a/packages/junior-scheduler/src/plugin.ts +++ b/packages/junior-scheduler/src/plugin.ts @@ -171,6 +171,7 @@ export function createSchedulerPlugin() { return defineJuniorPlugin({ name: "scheduler", pluginConfig: { + legacyStatePrefixes: ["junior:scheduler"], packages: ["@sentry/junior-scheduler"], }, hooks: { diff --git a/packages/junior/src/chat/agent-dispatch/context.ts b/packages/junior/src/chat/agent-dispatch/context.ts index 68c4a47df..b9fab7a08 100644 --- a/packages/junior/src/chat/agent-dispatch/context.ts +++ b/packages/junior/src/chat/agent-dispatch/context.ts @@ -28,6 +28,7 @@ function shouldScheduleDispatch( /** Build the plugin-scoped heartbeat context that gates durable dispatch access. */ export function createHeartbeatContext(args: { + legacyStatePrefixes?: string[]; nowMs: number; plugin: string; }): HeartbeatHookContext { @@ -35,7 +36,9 @@ export function createHeartbeatContext(args: { return { plugin: { name: args.plugin }, nowMs: args.nowMs, - state: createPluginState(args.plugin), + state: createPluginState(args.plugin, { + legacyStatePrefixes: args.legacyStatePrefixes, + }), log: createAgentPluginLogger(args.plugin), agent: { async dispatch(options) { diff --git a/packages/junior/src/chat/agent-dispatch/heartbeat.ts b/packages/junior/src/chat/agent-dispatch/heartbeat.ts index cefb57bee..2f51c1fc9 100644 --- a/packages/junior/src/chat/agent-dispatch/heartbeat.ts +++ b/packages/junior/src/chat/agent-dispatch/heartbeat.ts @@ -158,6 +158,7 @@ export async function runTrustedPluginHeartbeats(args: { Promise.resolve( heartbeat( createHeartbeatContext({ + legacyStatePrefixes: plugin.pluginConfig?.legacyStatePrefixes, plugin: plugin.name, nowMs: args.nowMs, }), diff --git a/packages/junior/src/chat/plugins/agent-hooks.ts b/packages/junior/src/chat/plugins/agent-hooks.ts index 36ef18598..ad9e0c467 100644 --- a/packages/junior/src/chat/plugins/agent-hooks.ts +++ b/packages/junior/src/chat/plugins/agent-hooks.ts @@ -93,7 +93,9 @@ export function getAgentPluginTools( messageTs: context.messageTs, threadTs: context.threadTs, userText: context.userText, - state: createPluginState(plugin.name), + state: createPluginState(plugin.name, { + legacyStatePrefixes: plugin.pluginConfig?.legacyStatePrefixes, + }), }); for (const [name, tool] of Object.entries(pluginTools)) { if (!AGENT_PLUGIN_TOOL_NAME_RE.test(name)) { diff --git a/packages/junior/src/chat/plugins/state.ts b/packages/junior/src/chat/plugins/state.ts index ab8111a63..55fe61a92 100644 --- a/packages/junior/src/chat/plugins/state.ts +++ b/packages/junior/src/chat/plugins/state.ts @@ -4,6 +4,10 @@ import { getStateAdapter } from "@/chat/state/adapter"; const MAX_PLUGIN_STATE_KEY_LENGTH = 512; +export interface PluginStateOptions { + legacyStatePrefixes?: string[]; +} + function hashKeyPart(value: string): string { return createHash("sha256").update(value).digest("hex").slice(0, 32); } @@ -21,20 +25,50 @@ function validatePluginStateKey(key: string): void { } } +function legacyStateKey( + key: string, + options: PluginStateOptions | undefined, +): string | undefined { + for (const prefix of options?.legacyStatePrefixes ?? []) { + const trimmed = prefix.trim(); + if (!trimmed) { + continue; + } + if (key === trimmed || key.startsWith(`${trimmed}:`)) { + return key; + } + } + return undefined; +} + /** Create a durable state namespace scoped to one trusted plugin. */ -export function createPluginState(plugin: string): AgentPluginState { +export function createPluginState( + plugin: string, + options?: PluginStateOptions, +): AgentPluginState { return { async delete(key) { validatePluginStateKey(key); const state = getStateAdapter(); await state.connect(); await state.delete(pluginStateKey(plugin, key)); + const legacyKey = legacyStateKey(key, options); + if (legacyKey) { + await state.delete(legacyKey); + } }, - async get(key) { + async get(key: string): Promise { validatePluginStateKey(key); const state = getStateAdapter(); await state.connect(); - return (await state.get(pluginStateKey(plugin, key))) ?? undefined; + const value = await state.get(pluginStateKey(plugin, key)); + if (value !== null && value !== undefined) { + return value; + } + const legacyKey = legacyStateKey(key, options); + return legacyKey + ? ((await state.get(legacyKey)) ?? undefined) + : undefined; }, async set(key, value, ttlMs) { validatePluginStateKey(key); @@ -46,6 +80,13 @@ export function createPluginState(plugin: string): AgentPluginState { validatePluginStateKey(key); const state = getStateAdapter(); await state.connect(); + const legacyKey = legacyStateKey(key, options); + if (legacyKey) { + const existing = await state.get(legacyKey); + if (existing !== null && existing !== undefined) { + return false; + } + } return await state.setIfNotExists( pluginStateKey(plugin, key), value, @@ -56,7 +97,9 @@ export function createPluginState(plugin: string): AgentPluginState { validatePluginStateKey(key); const state = getStateAdapter(); await state.connect(); - const lock = await state.acquireLock(pluginStateKey(plugin, key), ttlMs); + const lockKey = + legacyStateKey(key, options) ?? pluginStateKey(plugin, key); + const lock = await state.acquireLock(lockKey, ttlMs); if (!lock) { throw new Error(`Could not acquire plugin state lock for ${key}`); } diff --git a/packages/junior/tests/integration/heartbeat.test.ts b/packages/junior/tests/integration/heartbeat.test.ts index b08824432..6686386e3 100644 --- a/packages/junior/tests/integration/heartbeat.test.ts +++ b/packages/junior/tests/integration/heartbeat.test.ts @@ -209,6 +209,30 @@ describe("trusted plugin heartbeat", () => { await expect(second.state.get("1")).resolves.toBe("second"); }); + it("claims scheduled tasks from the scheduler legacy state namespace", async () => { + const task = createTask({ id: "sched_legacy" }); + const state = getStateAdapter(); + await state.connect(); + await state.set("junior:scheduler:tasks", [task.id]); + await state.set("junior:scheduler:team:T123:tasks", [task.id]); + await state.set("junior:scheduler:task:sched_legacy", task); + + const store = createSchedulerStore( + createPluginState("scheduler", { + legacyStatePrefixes: ["junior:scheduler"], + }), + ); + + await expect(store.listTasksForTeam("T123")).resolves.toMatchObject([ + { id: task.id }, + ]); + await expect( + store.claimDueRun({ nowMs: TEST_NOW_MS }), + ).resolves.toMatchObject({ + taskId: task.id, + }); + }); + it("bounds dispatch fanout from one heartbeat context", async () => { const fetchMock = vi.fn(async () => { return new Response("Accepted", { status: 202 }); From 78c129010d2572b94aa30f8f95860532322514b0 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Wed, 27 May 2026 22:24:15 -0700 Subject: [PATCH 4/5] build(scheduler): Align release package ordering Keep the scheduler package order consistent across release metadata. Co-Authored-By: GPT-5 Codex --- .craft.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.craft.yml b/.craft.yml index 06fb57089..3b1b181bb 100644 --- a/.craft.yml +++ b/.craft.yml @@ -26,9 +26,9 @@ targets: - name: npm id: "@sentry/junior-notion" includeNames: /^sentry-junior-notion-\d.*\.tgz$/ - - name: npm - id: "@sentry/junior-sentry" - includeNames: /^sentry-junior-sentry-\d.*\.tgz$/ - name: npm id: "@sentry/junior-scheduler" includeNames: /^sentry-junior-scheduler-\d.*\.tgz$/ + - name: npm + id: "@sentry/junior-sentry" + includeNames: /^sentry-junior-sentry-\d.*\.tgz$/ From 3dda7e0e2af734c7a1f01af876507796bb5107b0 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Thu, 28 May 2026 14:53:03 -0700 Subject: [PATCH 5/5] fix(scheduler): Harden optional plugin state access Keep legacy scheduler state access scoped to the scheduler plugin namespace so the migration path does not expose arbitrary raw state prefixes. Clarify heartbeat docs now that scheduled tasks are enabled by registering the scheduler plugin explicitly. Co-Authored-By: GPT-5 Codex --- apps/example/README.md | 4 +-- .../content/docs/extend/scheduler-plugin.md | 12 ++++---- .../content/docs/reference/config-and-env.md | 4 +-- .../docs/start-here/deploy-to-vercel.md | 2 +- .../junior/src/chat/plugins/agent-hooks.ts | 28 +++++++++++++++++++ packages/junior/tests/unit/app-config.test.ts | 22 +++++++++++++++ 6 files changed, 61 insertions(+), 11 deletions(-) diff --git a/apps/example/README.md b/apps/example/README.md index 89a978893..5fb834fdf 100644 --- a/apps/example/README.md +++ b/apps/example/README.md @@ -28,7 +28,7 @@ Copy `.env.example` and set: - `AI_VISION_MODEL` (optional, enables image-understanding; unset disables vision features) - `AI_WEB_SEARCH_MODEL` (optional, overrides the `webSearch` tool model; defaults to a search-tuned model) - `JUNIOR_SECRET` (required outside `pnpm dev`; the local wrapper supplies a dev-only secret when unset) -- `JUNIOR_SCHEDULER_SECRET` or `CRON_SECRET` (optional for `pnpm dev`; the local wrapper supplies a dev-only secret when both are unset) +- `JUNIOR_SCHEDULER_SECRET` or `CRON_SECRET` (optional for `pnpm dev`; the local wrapper supplies a dev-only heartbeat secret when both are unset) - `NOTION_TOKEN` (optional, enables the bundled Notion plugin) ## Wiring @@ -36,4 +36,4 @@ Copy `.env.example` and set: - `plugin-packages.ts` is the single source of truth for installed plugin packages in this app - `nitro.config.ts` passes that list to `juniorNitro()` so plugin content is copied into the build output - `server.ts` passes the same list to `createApp()` so local dev does not depend on Nitro's virtual config path for plugin discovery -- root `pnpm dev` starts a local heartbeat loop that calls `/api/internal/heartbeat` every minute, matching the production cron pulse used by the built-in scheduler plugin; it also defaults `JUNIOR_BASE_URL` to the local server when unset so signed internal callbacks can recover scheduled dispatches +- root `pnpm dev` starts a local heartbeat loop that calls `/api/internal/heartbeat` every minute, matching the production cron pulse used for trusted plugin heartbeats and stale dispatch recovery; it also defaults `JUNIOR_BASE_URL` to the local server when unset so signed internal callbacks can recover dispatched runs diff --git a/packages/docs/src/content/docs/extend/scheduler-plugin.md b/packages/docs/src/content/docs/extend/scheduler-plugin.md index 51e0adb4d..dc4b51800 100644 --- a/packages/docs/src/content/docs/extend/scheduler-plugin.md +++ b/packages/docs/src/content/docs/extend/scheduler-plugin.md @@ -66,14 +66,14 @@ If you manage routes manually, call the heartbeat route on a one-minute cadence: ## Configure environment variables -Set one scheduler route secret: +Set one heartbeat route secret: -| Variable | Required | Purpose | -| ------------------------------------------ | ---------- | --------------------------------------------------------------------------------------------- | -| `CRON_SECRET` or `JUNIOR_SCHEDULER_SECRET` | Production | Bearer token for internal scheduler and heartbeat routes. Use `CRON_SECRET` with Vercel Cron. | -| `JUNIOR_TIMEZONE` | No | Default IANA timezone for schedule authoring. Defaults to `America/Los_Angeles`. | +| Variable | Required | Purpose | +| ------------------------------------------ | ---------- | ---------------------------------------------------------------------------------- | +| `CRON_SECRET` or `JUNIOR_SCHEDULER_SECRET` | Production | Bearer token for the internal heartbeat route. Use `CRON_SECRET` with Vercel Cron. | +| `JUNIOR_TIMEZONE` | No | Default IANA timezone for schedule authoring. Defaults to `America/Los_Angeles`. | -Local development can run without a scheduler route secret when you call the dev server directly. Production deployments should set `CRON_SECRET` or `JUNIOR_SCHEDULER_SECRET`. +Local development can run without a heartbeat route secret when you call the dev server directly. Production deployments should set `CRON_SECRET` or `JUNIOR_SCHEDULER_SECRET`. ## Verify diff --git a/packages/docs/src/content/docs/reference/config-and-env.md b/packages/docs/src/content/docs/reference/config-and-env.md index e3e03ccbc..ba4d5b33d 100644 --- a/packages/docs/src/content/docs/reference/config-and-env.md +++ b/packages/docs/src/content/docs/reference/config-and-env.md @@ -25,8 +25,8 @@ related: | `AI_WEB_SEARCH_MODEL` | No | Override for the `webSearch` tool model. Defaults to a search-tuned model; does not fall through to `AI_MODEL`. | | `JUNIOR_BASE_URL` | No | Canonical base URL for callback/auth URL generation. | | `JUNIOR_STATE_KEY_PREFIX` | No | Optional namespace prepended to all state-adapter keys, locks, and queues. Use separate prefixes when sharing one Redis database across environments. | -| `CRON_SECRET` or `JUNIOR_SCHEDULER_SECRET` | Conditional | Bearer token for internal scheduler and heartbeat routes; use `CRON_SECRET` with Vercel Cron, or `JUNIOR_SCHEDULER_SECRET` for an external scheduler. | -| `JUNIOR_TIMEZONE` | No | Default IANA timezone for scheduler authoring and other timezone-sensitive behavior. Defaults to `America/Los_Angeles`. | +| `CRON_SECRET` or `JUNIOR_SCHEDULER_SECRET` | Conditional | Bearer token for the internal heartbeat route; use `CRON_SECRET` with Vercel Cron, or `JUNIOR_SCHEDULER_SECRET` for a non-Vercel heartbeat caller. | +| `JUNIOR_TIMEZONE` | No | Default IANA timezone for scheduler authoring when the scheduler plugin is enabled. Defaults to `America/Los_Angeles`. | | `AI_GATEWAY_API_KEY` | No | AI gateway auth if used in your setup. | Generate `JUNIOR_SECRET` with Node, then store the generated value in every environment that runs the same app: diff --git a/packages/docs/src/content/docs/start-here/deploy-to-vercel.md b/packages/docs/src/content/docs/start-here/deploy-to-vercel.md index 01de8d64b..c9835d655 100644 --- a/packages/docs/src/content/docs/start-here/deploy-to-vercel.md +++ b/packages/docs/src/content/docs/start-here/deploy-to-vercel.md @@ -43,7 +43,7 @@ Keep the Vercel build command as `pnpm build`. `junior snapshot create` prepares ## Enable the heartbeat cron -Junior uses a one-minute internal heartbeat to run scheduled tasks and recover stale agent dispatches. The scaffolded `vercel.json` should include this cron: +Junior uses a one-minute internal heartbeat to run trusted plugin heartbeats and recover stale agent dispatches. The scheduler plugin uses this heartbeat when scheduled tasks are enabled. The scaffolded `vercel.json` should include this cron: ```json title="vercel.json" { diff --git a/packages/junior/src/chat/plugins/agent-hooks.ts b/packages/junior/src/chat/plugins/agent-hooks.ts index ad9e0c467..4ae5a6ef1 100644 --- a/packages/junior/src/chat/plugins/agent-hooks.ts +++ b/packages/junior/src/chat/plugins/agent-hooks.ts @@ -41,6 +41,33 @@ let agentPlugins: JuniorPlugin[] = []; const AGENT_PLUGIN_NAME_RE = /^[a-z][a-z0-9-]*$/; const AGENT_PLUGIN_TOOL_NAME_RE = /^[a-z][A-Za-z0-9]*$/; +function validateLegacyStatePrefixes(plugin: JuniorPlugin): void { + const prefixes = plugin.pluginConfig?.legacyStatePrefixes; + if (prefixes === undefined) { + return; + } + if (!Array.isArray(prefixes)) { + throw new Error( + `Trusted plugin "${plugin.name}" legacyStatePrefixes must be an array`, + ); + } + + const allowedPrefix = `junior:${plugin.name}`; + for (const rawPrefix of prefixes) { + const prefix = typeof rawPrefix === "string" ? rawPrefix.trim() : ""; + if (!prefix) { + throw new Error( + `Trusted plugin "${plugin.name}" legacy state prefixes must be non-empty strings`, + ); + } + if (prefix !== allowedPrefix && !prefix.startsWith(`${allowedPrefix}:`)) { + throw new Error( + `Trusted plugin "${plugin.name}" legacy state prefix "${prefix}" must stay under "${allowedPrefix}"`, + ); + } + } +} + /** Validate trusted plugin identity before it can affect process-wide hooks. */ export function validateAgentPlugins(plugins: JuniorPlugin[]): void { const seen = new Set(); @@ -54,6 +81,7 @@ export function validateAgentPlugins(plugins: JuniorPlugin[]): void { throw new Error(`Duplicate trusted plugin name "${plugin.name}"`); } seen.add(plugin.name); + validateLegacyStatePrefixes(plugin); } } diff --git a/packages/junior/tests/unit/app-config.test.ts b/packages/junior/tests/unit/app-config.test.ts index f76174db6..a161963b1 100644 --- a/packages/junior/tests/unit/app-config.test.ts +++ b/packages/junior/tests/unit/app-config.test.ts @@ -250,4 +250,26 @@ describe("createApp plugin config", () => { expect(getAgentPlugins().map((plugin) => plugin.name)).toEqual([]); expect(getPluginProviders()).toEqual([]); }); + + it("rejects legacy state prefixes outside the trusted plugin namespace", async () => { + await createApp({ + plugins: [], + }); + + await expect( + createApp({ + plugins: [ + defineJuniorPlugin({ + name: "trusted", + pluginConfig: { legacyStatePrefixes: ["junior:scheduler"] }, + }), + ], + }), + ).rejects.toThrow( + 'Trusted plugin "trusted" legacy state prefix "junior:scheduler" must stay under "junior:trusted"', + ); + + expect(getAgentPlugins().map((plugin) => plugin.name)).toEqual([]); + expect(getPluginProviders()).toEqual([]); + }); });