diff --git a/.craft.yml b/.craft.yml index 84d213dbf..3b1b181bb 100644 --- a/.craft.yml +++ b/.craft.yml @@ -26,6 +26,9 @@ targets: - name: npm id: "@sentry/junior-notion" includeNames: /^sentry-junior-notion-\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$/ 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/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/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..dc4b51800 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" @@ -47,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 @@ -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/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/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..88578c8a6 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 { @@ -165,6 +179,7 @@ export interface AgentPluginHooks { } export interface JuniorPluginConfig { + legacyStatePrefixes?: string[]; packages?: string[]; } 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 90% rename from packages/junior/src/chat/scheduler/plugin.ts rename to packages/junior-scheduler/src/plugin.ts index 44f402a6c..eecc31e6b 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,14 @@ async function failClaimedRun(args: { export function createSchedulerPlugin() { return defineJuniorPlugin({ name: "scheduler", + pluginConfig: { + legacyStatePrefixes: ["junior:scheduler"], + 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 +186,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 +307,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 87% rename from packages/junior/src/chat/tools/slack/schedule-tools.ts rename to packages/junior-scheduler/src/schedule-tools.ts index ad207a191..e6db9b6fe 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,32 @@ function requireRequester(context: ToolRuntimeContext): ScheduledTaskPrincipal { }; } +function tool( + definition: AgentPluginToolDefinition, +): AgentPluginToolDefinition { + return definition; +} + +function normalizeSlackConversationId( + value: string | undefined, +): string | 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 normalizeSlackConversationId(channelId)?.startsWith("D") ?? false; +} + +function isSlackTeamId(value: string): boolean { + return /^T[A-Z0-9]+$/.test(value); +} + function getConversationAccess( destination: ScheduledTaskDestination, ): ScheduledTaskConversationAccess { @@ -125,12 +166,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 +344,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 +423,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 +433,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 +449,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 +467,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 +556,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 +566,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 +589,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 +599,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 +630,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/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..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); } } @@ -93,7 +121,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 f4c415f2b..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); @@ -42,5 +76,39 @@ 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(); + 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, + ttlMs, + ); + }, + async withLock(key, ttlMs, callback) { + validatePluginStateKey(key); + const state = getStateAdapter(); + await state.connect(); + 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}`); + } + + 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..45cc6ad52 100644 --- a/packages/junior/src/chat/tools/execution/tool-error-handler.ts +++ b/packages/junior/src/chat/tools/execution/tool-error-handler.ts @@ -5,16 +5,26 @@ 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"; 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) return "tool_input_error"; + if (error instanceof ToolInputError || isPluginToolInputError(error)) { + return "tool_input_error"; + } return error instanceof Error ? error.name : "tool_execution_error"; } @@ -90,7 +100,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 || + isPluginToolInputError(error); if (!isExpectedToolFailure) { logException( error, diff --git a/packages/junior/tests/integration/heartbeat.test.ts b/packages/junior/tests/integration/heartbeat.test.ts index f47e31dce..6686386e3 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 { @@ -202,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 }); @@ -398,8 +429,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 +489,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 +534,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 +581,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 +625,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 +671,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 +704,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..5ef74b929 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: { @@ -545,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")); @@ -597,7 +629,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 +640,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 +683,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 +721,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 +777,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({ @@ -771,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 }; @@ -781,14 +813,9 @@ 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, - ); await expect( - state.get(`junior:scheduler:team:${TEST_TEAM_ID}:tasks`), - ).resolves.toBe(null); + schedulerStore().listTasksForTeam(TEST_TEAM_ID), + ).resolves.toEqual([]); }); it("claims due runs idempotently", async () => { @@ -796,7 +823,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/app-config.test.ts b/packages/junior/tests/unit/app-config.test.ts index b7a93be2a..a161963b1 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,29 @@ 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([]); + }); + + 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([]); }); }); 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", ];