diff --git a/client/src/components/cos/JobCard.jsx b/client/src/components/cos/JobCard.jsx index 6f6b2652bf..f7cbeb598e 100644 --- a/client/src/components/cos/JobCard.jsx +++ b/client/src/components/cos/JobCard.jsx @@ -3,7 +3,7 @@ import { Play, Trash2, ChevronDown, ChevronUp, Clock, ToggleLeft, ToggleRight, E import toast from '../ui/Toast'; import * as api from '../../services/api'; import { timeAgo, timeUntil, formatDateNumeric } from '../../utils/formatters'; -import { DEFAULT_CRON, describeCron, describeRecurrence, parseCronToRecurrence, buildCronFromRecurrence, JOB_INTERVAL_OPTIONS as INTERVAL_OPTIONS } from '../../utils/cronHelpers'; +import { DEFAULT_CRON, describeCron, describeRecurrence, parseCronToRecurrence, buildCronFromRecurrence, JOB_INTERVAL_OPTIONS as INTERVAL_OPTIONS, ON_DEMAND_INTERVAL } from '../../utils/cronHelpers'; import CronSchedulePicker from '../CronSchedulePicker'; import AgentJobProviderFields, { hasRunnableAgentProvider } from './AgentJobProviderFields'; import { AGENT_OPTIONS, agentOptionButtonClass } from './constants'; @@ -46,6 +46,12 @@ export const JOB_TYPE_OPTIONS = [ // AI runner. export const isAgentJobType = (type) => type !== 'shell' && type !== 'script'; +// Interval-mode job with the no-recurrence cadence: never due, never scheduled, +// runnable only from the Run-now button. Cron mode overrides it — a job switched +// to Cron keeps whatever cadence it last had. Mirrors isOnDemandJob on the server. +export const isOnDemandSchedule = (job) => + !job?.cronExpression && !job?.cronSchedule && job?.interval === ON_DEMAND_INTERVAL; + const BRIEFING_CONFIG_OPTIONS = [ { key: 'dailyJoke', label: 'Daily Joke', desc: 'Include a short joke to start the day' }, { key: 'dailyQuote', label: 'Daily Quote', desc: 'Include an inspirational quote related to focus areas' }, @@ -127,25 +133,35 @@ export function ScheduleFields({ data, onChange, timezone }) { /> ) : ( -
+
- onChange('scheduledTime', e.target.value || null)} - className="px-3 py-2 bg-port-bg border border-port-border rounded-lg text-white text-sm" - title="Run at specific time (leave empty for any time)" - aria-label="Run at a specific time (leave empty for any time)" - /> + {data.interval === ON_DEMAND_INTERVAL ? ( + Runs only when you press Run now. + ) : ( + onChange('scheduledTime', e.target.value || null)} + className="px-3 py-2 bg-port-bg border border-port-border rounded-lg text-white text-sm" + title="Run at specific time (leave empty for any time)" + aria-label="Run at a specific time (leave empty for any time)" + /> + )}
)}
@@ -156,6 +172,8 @@ function formatNextDue(job) { // Cron jobs: show human-readable schedule (server computes exact next fire time) if (job.cronSchedule) return describeRecurrence(job.cronSchedule); if (job.cronExpression) return describeCron(job.cronExpression); + // No recurrence — rendering lastRun + a null intervalMs would print 'Invalid Date'. + if (isOnDemandSchedule(job)) return 'On demand'; const { lastRun, intervalMs, scheduledTime } = job; if (!lastRun) return scheduledTime ? `at ${scheduledTime}` : 'Immediately'; @@ -325,7 +343,7 @@ export default function JobCard({ onUpdate(); }; - const isDue = job.enabled && (job.cronSchedule + const isDue = job.enabled && !isOnDemandSchedule(job) && (job.cronSchedule ? (!job.lastRun || Boolean(job.nextRunAt && Date.now() >= new Date(job.nextRunAt).getTime())) : (!job.lastRun || (Date.now() - new Date(job.lastRun).getTime() >= job.intervalMs))); diff --git a/client/src/components/cos/JobCard.test.jsx b/client/src/components/cos/JobCard.test.jsx index 8ec9ebf457..8cf158775f 100644 --- a/client/src/components/cos/JobCard.test.jsx +++ b/client/src/components/cos/JobCard.test.jsx @@ -69,3 +69,57 @@ describe('JobCard machine output', () => { expect(pre.className).toContain('break-all'); }); }); + +// #6375 — the on-demand cadence. The card must not render an arithmetic-derived +// date for a job with no interval, and the edit form must not offer a +// time-of-day the job can never honor. +describe('JobCard on-demand cadence', () => { + const ON_DEMAND_JOB = { + id: 'job-manual', + name: 'Manual only', + description: '', + type: 'agent', + category: 'custom', + interval: 'on-demand', + intervalMs: null, + priority: 'MEDIUM', + autonomyLevel: 'manager', + enabled: true, + lastRun: '2026-01-01T00:00:00.000Z', + runCount: 3 + }; + + it('renders "On demand" as the next run instead of a date derived from a null interval', () => { + renderCard(ON_DEMAND_JOB); + + expect(screen.getByText('Next: On demand')).toBeTruthy(); + expect(screen.queryByText(/Invalid Date/)).toBeNull(); + expect(screen.queryByText(/NaN/)).toBeNull(); + }); + + it('never shows the Due badge, however long ago the job last ran', () => { + renderCard({ ...ON_DEMAND_JOB, lastRun: null }); + + // A never-run recurring job is due immediately; an on-demand one never is. + expect(screen.queryByText('Due')).toBeNull(); + }); + + it('labels the cadence from the shared option list', () => { + renderCard(ON_DEMAND_JOB); + expect(screen.getByText('On Demand')).toBeTruthy(); + }); + + it('offers the cadence and hides the time input once it is selected', () => { + renderCard({ ...ON_DEMAND_JOB, interval: 'daily', intervalMs: 86400000, scheduledTime: '09:00' }); + fireEvent.click(screen.getByRole('button', { name: 'Edit' })); + + const select = screen.getByLabelText('Interval'); + expect(screen.getByLabelText('Run at a specific time (leave empty for any time)')).toBeTruthy(); + + fireEvent.change(select, { target: { value: 'on-demand' } }); + + expect(select.value).toBe('on-demand'); + expect(screen.queryByLabelText('Run at a specific time (leave empty for any time)')).toBeNull(); + expect(screen.getByText('Runs only when you press Run now.')).toBeTruthy(); + }); +}); diff --git a/client/src/utils/README.md b/client/src/utils/README.md index 712679479a..219f40b89f 100644 --- a/client/src/utils/README.md +++ b/client/src/utils/README.md @@ -23,7 +23,7 @@ grep -i "what you want to do" client/src/utils/README.md | Module | Purpose | |---|---| | `formatters` | Date/time/duration/byte/word formatters (`clamp`, `formatBytes`, `formatDownloadGb` (decimal-GB model download size, "~29 GB"), `formatCompactCount`, `formatCompactCountOrDash` (same, but an ABSENT count renders "—" rather than "0"), `timeAgo`, `formatAgeDays` (age in whole DAYS — "412 days ago" — where `timeAgo` would collapse to "1y ago"; model-download lists), `localDateKey` (browser-local `YYYY-MM-DD`), `shiftISODate` (DST-safe calendar-day shifts), `formatTimecode`, `formatDurationMs`, `formatDateShort`, `formatContextTokens` (suffix-less context length, "4K"), `throughputLabel` (a measured model's speed as one label — tok/s where the runtime reported token counts, `~` prefixed when frame-counted, else chars/s; never both), `parseTimeoutMs`, `formatCooldown`, `recommendedRamGb`, `nameFromImageFilename`, `formatUsd` — one USD renderer (`signed` puts the minus outside the `$`; `trimWhole` drops `.00` on a typed round price) — `formatWeight` / `formatPercent` — round unit-converted floats (`170.35000000000002` → `170.4 lbs`) so raw binary precision never reaches a tile — `middleTruncate` — clip a long string from the MIDDLE so its distinguishing tail survives, where CSS `line-clamp`/`text-overflow` always eats the end — …) plus timeout-input bounds and `getAppName`. Do not re-define formatters inside components. **Never write `new Date(x).toLocaleDateString()` inline** — pick the helper for the shape you want: `formatDateNumeric` ("3/5/2026", compact cells), `formatDateShort` ("Mar 5, 2026"), `formatDate` ("March 5, 2026"), `formatDateFull` ("Saturday, March 5, 2026"), `formatWeekdayDate` ("Monday, Mar 5", `{ weekday, year }`), `formatMonthDay` ("Mar 5"), `formatMonthYear` ("March 2026"), `formatWeekdayShort` ("Mon"), `formatWeekdayTime` ("Mon, 7:00 AM"), `formatTimeOfDay` ("1:30 PM"), `formatTimeOfDaySeconds` ("1:30:45 PM", log/queue rows), `formatClockTime` ("02:30:45 PM"; `{ seconds, hour12, timeZone }`), `formatDateTime`. `formatHourOfDay` renders a bare hour-of-day number (0-23) as a 12-hour label in one of four styles (`long` "3 PM", `compact` "3PM", `tiny` "3p", `lower` "3pm") — the canonical home for what five components each hand-rolled, so the same 3pm no longer renders four ways across screens. Date display helpers anchor a bare `YYYY-MM-DD` at LOCAL midnight (a naive `new Date('2026-03-05')` is UTC midnight and renders as the previous day west of Greenwich) and take a fallback instead of rendering the literal "Invalid Date". | -| `cronHelpers` | Cron preset list, friendly cron parsing/building, anchored recurrence parsing/building/description, `isCronExpression` detection, `describeCron` human-readable rendering, `cronFromIntervalMs` numeric-interval → expression conversion (mirrors the server helper), and `JOB_INTERVAL_OPTIONS` — the interval-mode cadences for autonomous jobs, mirroring the server's `INTERVAL_OPTIONS`. Import it rather than re-declaring the list in a scheduling component. | +| `cronHelpers` | Cron preset list, friendly cron parsing/building, anchored recurrence parsing/building/description, `isCronExpression` detection, `describeCron` human-readable rendering, `cronFromIntervalMs` numeric-interval → expression conversion (mirrors the server helper), and `JOB_INTERVAL_OPTIONS` / `ON_DEMAND_INTERVAL` — the interval-mode cadences for autonomous jobs, mirroring the server's `INTERVAL_OPTIONS` (`on-demand` = stays enabled and manually runnable, but arms no timer). Import it rather than re-declaring the list in a scheduling component. | | `markdownText` | `markdownToPlainText(md)` — flatten markdown source to one plain-text string for a clamped preview: strips heading/list/blockquote/fence markers, unwraps emphasis, inline code and links, images → `[alt]`, collapses blank-line runs. Use whenever a card previews arbitrary agent-authored markdown — `line-clamp-N` does not clamp a subtree of block elements, and foreign `##` headings would otherwise join the page's heading outline. Underscore emphasis is word-boundary gated and `__…__` needs interior whitespace, so stack frames and user-agent strings (`10_15_7`, `__init__`) are never silently rewritten. `dropsMarkupWhenFlattened(md)` — did the flatten lose actual markup, as opposed to only normalizing whitespace? Gates a "Show more" disclosure so a short-but-lossy body stays reachable without putting a toggle on every body that merely lost a trailing newline. | | `timeWindow` | Time-of-day window math (`isInTimeWindow`, `timeStringToMinutes`) and morning-layout auto-switch helpers (`pickActiveLayoutId`, `recordManualLayoutPick`). | | `timezone` | Timezone day-key helpers (`dayKeyInTimezone`, `todayKeyInTimezone`) — browser mirror of the server's `todayInTimezone`, so date-scoped POST surfaces derive "today" in the user's configured timezone and agree with the server (#2681). | diff --git a/client/src/utils/cronHelpers.js b/client/src/utils/cronHelpers.js index 1acee43a01..b69106a63c 100644 --- a/client/src/utils/cronHelpers.js +++ b/client/src/utils/cronHelpers.js @@ -279,11 +279,15 @@ export function describeCron(expr) { } // Interval-mode cadences for autonomous jobs — the client mirror of -// `INTERVAL_OPTIONS` in `server/services/autonomousJobs/constants.js`. Values +// `INTERVAL_OPTIONS` in `server/lib/autonomousJobIntervals.js`. Values // must stay in lockstep with `resolveIntervalMs` there, since the server // recomputes `intervalMs` from whichever value a picker submits. Lives here // rather than in a component so a second job-scheduling surface doesn't fork -// its own copy of the list. +// its own copy of the list. `on-demand` is the no-recurrence cadence: the job +// stays enabled and manually runnable, but no timer is ever armed for it. +// `server/lib/autonomousJobIntervals.mirror.test.js` fails when the two lists drift. +export const ON_DEMAND_INTERVAL = 'on-demand'; + export const JOB_INTERVAL_OPTIONS = [ { value: 'hourly', label: 'Every Hour' }, { value: 'every-2-hours', label: 'Every 2 Hours' }, @@ -292,5 +296,6 @@ export const JOB_INTERVAL_OPTIONS = [ { value: 'daily', label: 'Daily' }, { value: 'weekly', label: 'Weekly' }, { value: 'biweekly', label: 'Every 2 Weeks' }, - { value: 'monthly', label: 'Monthly' } + { value: 'monthly', label: 'Monthly' }, + { value: ON_DEMAND_INTERVAL, label: 'On Demand' } ]; diff --git a/server/lib/README.md b/server/lib/README.md index 522c70be0e..a93554f8c8 100644 --- a/server/lib/README.md +++ b/server/lib/README.md @@ -401,6 +401,7 @@ pm` default, `NPM_CONFIG_PREFIX`, nvm/Volta) installed `codex` successfully and |---|---| | `appIdentity.js` | The baseline managed-app id (`PORTOS_APP_ID`) — importable without pulling in the `services/apps.js` graph (pm2, scheduler, settings) behind it. Re-exported by `apps.js`. | | `appResolver.js` | Fuzzy-match a spoken/typed phrase to a managed app (`{ id, name }`). Tiered exact → prefix → substring, used by voice tools that target a specific app. | +| `autonomousJobIntervals.js` | Autonomous-job cadence vocabulary (pure). `INTERVAL_OPTIONS` (picker rows, `ms: null` for a cadence with no recurrence), `ON_DEMAND_INTERVAL`, `JOB_INTERVAL_VALUES` (every legal stored cadence, incl. `custom`), `resolveIntervalMs(interval, customMs)` → ms or `null` (the no-interval sentinel — never falls through to `DAY`), and `isOnDemandJob(job)` for the three due/next-fire implementations. Lives here so `cosValidation.js` can validate the cadence without a lib → services import; `services/autonomousJobs/constants.js` re-exports it. Separate vocabulary from the scheduled-CoS-task `INTERVAL_TYPES`. | | `capabilityMap.js` | Pure row builders for Setup & Capabilities, including strict network/provider first-run readiness and optional-integration health rollups; fed by `routes/capabilities.js`. | | `chiptuneRender.js` | Deterministic chiptune score → mono PCM → 16-bit WAV buffer (pure Node, no audio deps); `renderScoreToPcm` / `pcmToWavBuffer` / `renderScoreToWav`. | | `chiptuneScore.js` | Chiptune score contract (#2911): `chiptuneScoreSchema` (Zod) + `sanitizeChiptuneScore`, pitch math (`pitchToMidi`/`midiToFreq`), and `buildScoreEvents` — the pattern/order → absolute-time flatten mirrored by `client/src/lib/chiptunePlayback.js`. | diff --git a/server/lib/autonomousJobIntervals.js b/server/lib/autonomousJobIntervals.js new file mode 100644 index 0000000000..b772bc17c3 --- /dev/null +++ b/server/lib/autonomousJobIntervals.js @@ -0,0 +1,94 @@ +/** + * Autonomous-job cadence vocabulary (pure). + * + * The interval-mode vocabulary for CoS autonomous jobs — the picker rows, the + * cadence → milliseconds resolver, and the "this job has no clock" predicate. + * Lives in lib rather than `services/autonomousJobs/constants.js` so the Zod + * boundary (`lib/cosValidation.js`) can validate against the same list without + * a lib → services import; the services constants module re-exports it, so + * existing deep imports keep working. + * + * NOTE: this is a SEPARATE vocabulary from the scheduled-CoS-task + * `INTERVAL_TYPES = { ON_DEMAND, CRON }` in `services/taskScheduleConstants.js`. + * Nothing converts between the two. + */ + +// Time units are declared locally rather than imported from `fileUtils.js`. +// This module is pulled in by `cosValidation.js` — i.e. by every route suite — +// and dozens of those suites mock fileUtils with an exhaustive factory, so an +// import here would make a missing `DAY`/`HOUR` on their mock a hard failure. +// `autonomousJobIntervals.test.js` asserts these still equal fileUtils'. +const HOUR = 60 * 60 * 1000; +const DAY = 24 * HOUR; + +export const WEEK = 7 * DAY; + +/** + * Cadence for a job that never fires on a clock — it runs only when a human (or + * another feature) triggers it via `POST /api/cos/jobs/:id/trigger`. Distinct + * from `enabled: false`, which also makes a job un-runnable. + */ +export const ON_DEMAND_INTERVAL = 'on-demand'; + +/** + * Available interval options for UI pickers. `ms: null` marks a cadence with no + * recurrence. Mirrored (value/label only) by `JOB_INTERVAL_OPTIONS` in + * `client/src/utils/cronHelpers.js`; the mirror describe in + * `autonomousJobIntervals.test.js` fails when the two drift. + */ +export const INTERVAL_OPTIONS = [ + { value: 'hourly', label: 'Every Hour', ms: HOUR }, + { value: 'every-2-hours', label: 'Every 2 Hours', ms: 2 * HOUR }, + { value: 'every-4-hours', label: 'Every 4 Hours', ms: 4 * HOUR }, + { value: 'every-8-hours', label: 'Every 8 Hours', ms: 8 * HOUR }, + { value: 'daily', label: 'Daily', ms: DAY }, + { value: 'weekly', label: 'Weekly', ms: WEEK }, + { value: 'biweekly', label: 'Every 2 Weeks', ms: 2 * WEEK }, + { value: 'monthly', label: 'Monthly', ms: 30 * DAY }, + { value: ON_DEMAND_INTERVAL, label: 'On Demand', ms: null } +]; + +/** + * Every cadence string a persisted job may carry. `custom` is deliberately not + * an INTERVAL_OPTIONS row (it has no fixed duration to label — the caller + * supplies `intervalMs`), but it is a legal stored value. + */ +export const JOB_INTERVAL_VALUES = [...INTERVAL_OPTIONS.map(o => o.value), 'custom']; + +const INTERVAL_MS_BY_VALUE = new Map(INTERVAL_OPTIONS.map(o => [o.value, o.ms])); + +/** + * Resolve a cadence string to milliseconds. + * + * Returns `null` — an explicit no-interval sentinel, never `DAY` and never + * `NaN` — for the on-demand cadence and for any value outside the vocabulary. + * The old `default: return DAY` fall-through silently rescheduled a typo'd + * cadence as a daily job; every caller now handles the sentinel instead. + * + * @param {string} interval + * @param {number} [customMs] duration for the `custom` cadence + * @returns {number|null} milliseconds, or null when the job has no recurrence + */ +export function resolveIntervalMs(interval, customMs) { + if (interval === 'custom') return customMs || DAY; + if (INTERVAL_MS_BY_VALUE.has(interval)) return INTERVAL_MS_BY_VALUE.get(interval); + if (interval !== ON_DEMAND_INTERVAL) { + console.warn(`⚠️ Unknown autonomous-job cadence '${interval}' — treating it as on-demand (no schedule)`); + } + return null; +} + +/** + * True when a job has nothing to fire on — the on-demand cadence, or a stored + * job whose cadence resolved to the no-interval sentinel. Cron-mode jobs are + * never on-demand: their schedule lives in `cronExpression`/`cronSchedule`, and + * a job switched to cron mode keeps whatever `interval` it last had. + * + * @param {Object} job + * @returns {boolean} + */ +export function isOnDemandJob(job) { + if (job?.cronExpression || job?.cronSchedule) return false; + if (job?.interval === ON_DEMAND_INTERVAL) return true; + return !Number.isFinite(job?.intervalMs); +} diff --git a/server/lib/autonomousJobIntervals.test.js b/server/lib/autonomousJobIntervals.test.js new file mode 100644 index 0000000000..a4217461ac --- /dev/null +++ b/server/lib/autonomousJobIntervals.test.js @@ -0,0 +1,110 @@ +/** + * Autonomous-job cadence vocabulary (#6375). + * + * Two contracts that a higher-level test can't pin cheaply: + * - `resolveIntervalMs` must return an explicit no-interval sentinel rather + * than falling through to `DAY`. The fall-through was silent, so only a + * direct assertion catches its return. + * - the client mirror in `client/src/utils/cronHelpers.js` is hand-maintained. + * A row added on one side only would leave a cadence the server accepts + * invisible in the picker (or a picker row the Zod enum rejects), so the + * mirror is compared by reading the client source rather than importing it + * (a server test that imports a client module breaks CI dependency-wise). + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { extractDeclaration } from './mirrorParity.js'; + +// Declared here rather than imported from fileUtils: this suite's whole point is +// that autonomousJobIntervals.js stays import-free, and pulling fileUtils in +// through the test would put its closure back into the suite's import budget. +const HOUR = 60 * 60 * 1000; +const DAY = 24 * HOUR; +import { + INTERVAL_OPTIONS, + JOB_INTERVAL_VALUES, + ON_DEMAND_INTERVAL, + isOnDemandJob, + resolveIntervalMs +} from './autonomousJobIntervals.js'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const CLIENT_PATH = resolve(HERE, '../../client/src/utils/cronHelpers.js'); + +describe('resolveIntervalMs', () => { + it('returns the no-interval sentinel for the on-demand cadence — not DAY, not NaN', () => { + const resolved = resolveIntervalMs(ON_DEMAND_INTERVAL); + expect(resolved).toBeNull(); + expect(resolved).not.toBe(DAY); + expect(Number.isNaN(resolved)).toBe(false); + }); + + it('no longer falls through to DAY for a cadence outside the vocabulary', () => { + // The `default: return DAY` this replaced turned a typo'd cadence into a + // daily job that nobody asked for. + expect(resolveIntervalMs('dailyy')).toBeNull(); + expect(resolveIntervalMs(undefined)).toBeNull(); + }); + + it('resolves every recurring option to its declared duration', () => { + for (const opt of INTERVAL_OPTIONS) { + expect(resolveIntervalMs(opt.value), opt.value).toBe(opt.ms); + } + }); + + it('custom uses the caller-supplied duration and falls back to a day', () => { + expect(resolveIntervalMs('custom', 90_000)).toBe(90_000); + expect(resolveIntervalMs('custom')).toBe(DAY); + }); + + it('JOB_INTERVAL_VALUES covers every option plus custom', () => { + expect(JOB_INTERVAL_VALUES).toEqual([...INTERVAL_OPTIONS.map(o => o.value), 'custom']); + }); +}); + +describe('isOnDemandJob', () => { + it('is true for the cadence and for a job left with no resolvable interval', () => { + expect(isOnDemandJob({ interval: ON_DEMAND_INTERVAL, intervalMs: null })).toBe(true); + expect(isOnDemandJob({ interval: 'daily', intervalMs: null })).toBe(true); + }); + + it('is false for a recurring job', () => { + expect(isOnDemandJob({ interval: 'daily', intervalMs: DAY })).toBe(false); + }); + + it('is false for a cron-mode job that kept a stale on-demand cadence', () => { + // Switching a job to Cron does not rewrite `interval`, so the cron fields + // have to win or the job would silently stop firing. + expect(isOnDemandJob({ interval: ON_DEMAND_INTERVAL, intervalMs: null, cronExpression: '0 4 * * *' })).toBe(false); + expect(isOnDemandJob({ interval: ON_DEMAND_INTERVAL, intervalMs: null, cronSchedule: { kind: 'DAILY' } })).toBe(false); + }); +}); + +describe('client JOB_INTERVAL_OPTIONS mirror', () => { + const clientSrc = readFileSync(CLIENT_PATH, 'utf8'); + const clientDecl = extractDeclaration(clientSrc, 'JOB_INTERVAL_OPTIONS'); + + // Parses `{ value: X, label: 'Y' }` rows — X is a quoted literal for most + // rows and the ON_DEMAND_INTERVAL identifier for the on-demand one. + const ROW_RE = /\{\s*value:\s*(?:'([^']+)'|([A-Z_][A-Z0-9_]*))\s*,\s*label:\s*'([^']+)'\s*\}/g; + const clientRows = [...(clientDecl ?? '').matchAll(ROW_RE)].map(m => ({ + value: m[1] ?? (m[2] === 'ON_DEMAND_INTERVAL' ? ON_DEMAND_INTERVAL : m[2]), + label: m[3] + })); + + it('finds the client declaration and parses every row', () => { + expect(clientDecl, 'client cronHelpers.js is missing JOB_INTERVAL_OPTIONS').not.toBeNull(); + expect(clientRows.length).toBe(INTERVAL_OPTIONS.length); + }); + + it('carries the same values and labels, in the same order, as the server list', () => { + expect(clientRows).toEqual(INTERVAL_OPTIONS.map(({ value, label }) => ({ value, label }))); + }); + + it('declares the on-demand cadence with the same string the server validates', () => { + expect(clientSrc).toContain(`export const ON_DEMAND_INTERVAL = '${ON_DEMAND_INTERVAL}';`); + }); +}); diff --git a/server/lib/cosValidation.js b/server/lib/cosValidation.js index fd679ce2a7..cd8fea2f51 100644 --- a/server/lib/cosValidation.js +++ b/server/lib/cosValidation.js @@ -19,6 +19,7 @@ import { PUBLIC_REVIEW_EXECUTION_PROFILES } from './agentExecutionProfiles.js'; import { ORCHESTRATION_MODES, ORCHESTRATION_ROLES } from './orchestrationProfile.js'; import { AGENT_RUN_EVENT_KINDS, RUN_EVENT_READ_LIMITS } from './agentRunEvents.js'; import { recurrenceRuleSchema } from './recurrenceValidation.js'; +import { JOB_INTERVAL_VALUES } from './autonomousJobIntervals.js'; import { TASK_DATA_INPUT_DEFINITIONS, TASK_DATA_INPUT_IDS } from './taskDataInputCatalog.js'; import { EFFORT_SELECTABLE_REVIEWERS, @@ -409,7 +410,13 @@ export const createCosJobSchema = z.object({ description: z.string().optional(), category: z.string().optional(), type: z.enum(['agent', 'shell', 'script']).optional(), - interval: z.string().optional(), + // The autonomous-job cadence vocabulary. Previously a bare z.string(), so a + // typo'd cadence reached disk and silently rescheduled the job daily via + // resolveIntervalMs's old `default: DAY` fall-through. Note this is NOT the + // scheduled-CoS-task INTERVAL_TYPES vocabulary — nothing converts between them. + interval: z.enum(JOB_INTERVAL_VALUES).optional(), + // Only meaningful for the `custom` cadence; never required or back-filled for + // the on-demand one, which resolves to a null intervalMs by design. intervalMs: z.number().positive().int().optional(), // Null actively clears a pinned time/cron mode on update. The jobs UI has // always emitted null for the inactive mode; accepting it here lets updateJob diff --git a/server/lib/cosValidation.test.js b/server/lib/cosValidation.test.js index abb9f5d904..fac461157c 100644 --- a/server/lib/cosValidation.test.js +++ b/server/lib/cosValidation.test.js @@ -8,6 +8,7 @@ import { taskTemplateSettingsSchema, } from './cosValidation.js'; import { EFFORT_LEVELS } from './providerModels.js'; +import { JOB_INTERVAL_VALUES, ON_DEMAND_INTERVAL } from './autonomousJobIntervals.js'; describe('cosValidation effort field', () => { it('accepts every EFFORT_LEVELS value on create and rejects unknown values', () => { @@ -280,3 +281,28 @@ describe('createCosTaskSchema isInvestigation (#6043)', () => { expect(parsed).not.toHaveProperty('investigationFingerprint'); }); }); + +describe('cosValidation job cadence (#6375)', () => { + it('accepts every legal cadence on create', () => { + for (const interval of JOB_INTERVAL_VALUES) { + expect(createCosJobSchema.safeParse({ name: 'j', interval }).success, interval).toBe(true); + } + }); + + it('rejects a cadence outside the vocabulary on create and update', () => { + // Before this enum, `interval` was a bare z.string(): a typo reached disk + // and resolveIntervalMs's `default: DAY` silently rescheduled it daily. + expect(createCosJobSchema.safeParse({ name: 'j', interval: 'dailyy' }).success).toBe(false); + expect(updateCosJobSchema.safeParse({ interval: 'dailyy' }).success).toBe(false); + }); + + it('accepts the on-demand cadence without an intervalMs', () => { + const parsed = createCosJobSchema.parse({ name: 'j', interval: ON_DEMAND_INTERVAL }); + expect(parsed.interval).toBe(ON_DEMAND_INTERVAL); + expect(parsed.intervalMs).toBeUndefined(); + }); + + it('still leaves the cadence optional so an unrelated PATCH does not have to send one', () => { + expect(updateCosJobSchema.safeParse({ enabled: false }).success).toBe(true); + }); +}); diff --git a/server/lib/importScoping.test.js b/server/lib/importScoping.test.js index 90126a632f..122450712e 100644 --- a/server/lib/importScoping.test.js +++ b/server/lib/importScoping.test.js @@ -192,6 +192,12 @@ describe('deferred imports stay deferred (#6156)', () => { // #6368 adds `lib/callerModePolicy.js`, another zero-dependency leaf reached by // the routing boundary and the lib barrel (~92 instantiations). Same tolerated // shape; it fits inside the allowance above. +// +// #6375 adds `lib/autonomousJobIntervals.js` — the autonomous-job cadence +// vocabulary `cosValidation.js` validates against, so ~194 suites reach it. It is +// a zero-import leaf (its time units are declared locally precisely so it drags +// nothing); the alternative is re-declaring the cadence list at the Zod boundary, +// which is the drift that issue exists to close. Fits inside the allowance above. const MAX_STATIC_INSTANTIATIONS = 91400; const SKIP_DIRS = new Set(['node_modules', 'coverage', 'dist', 'data']); diff --git a/server/lib/index.js b/server/lib/index.js index 2b7e6a486b..a38e9792ba 100644 --- a/server/lib/index.js +++ b/server/lib/index.js @@ -364,6 +364,7 @@ export * from './songCraftRef.js'; // === Domain utilities === export * from './appIdentity.js'; export * from './appResolver.js'; +export * from './autonomousJobIntervals.js'; export * from './capabilityMap.js'; export * from './chiptuneRender.js'; export * from './chiptuneScore.js'; diff --git a/server/services/autonomousJobs.test.js b/server/services/autonomousJobs.test.js index f81cf1ef68..546496e2c9 100644 --- a/server/services/autonomousJobs.test.js +++ b/server/services/autonomousJobs.test.js @@ -53,6 +53,8 @@ vi.mock('./autobiography.js', () => ({ })) // Import after mocks +import { computeNextJobRun } from './autonomousJobs/scheduler.js' +import { ON_DEMAND_INTERVAL, resolveIntervalMs } from '../lib/autonomousJobIntervals.js' import { agentDataCleanup, getAllJobs, @@ -318,6 +320,79 @@ describe('autonomousJobs', () => { }) }) + // #6375 — the on-demand cadence. Its whole contract is negative (never fires + // on a clock) and it is enforced in three separate places, so each is pinned. + describe('on-demand cadence (#6375)', () => { + const onDemandJob = (overrides = {}) => ({ + ...mockJobsData.jobs[0], + id: 'job-on-demand', + interval: ON_DEMAND_INTERVAL, + intervalMs: null, + enabled: true, + ...overrides + }) + + it('resolveIntervalMs returns the no-interval sentinel, not DAY and not NaN', () => { + const resolved = resolveIntervalMs(ON_DEMAND_INTERVAL) + expect(resolved).toBeNull() + expect(resolved).not.toBe(24 * 60 * 60 * 1000) + expect(Number.isNaN(resolved)).toBe(false) + }) + + it('does not fall through to DAY for a cadence outside the vocabulary', () => { + // The old `default: return DAY` silently rescheduled a typo'd cadence daily. + expect(resolveIntervalMs('dailyy')).toBeNull() + }) + + it('is never due — not when never run, and not after a week of elapsed time', async () => { + // loadJobs merges the shipped default jobs in, so assert on this job only. + readJSONFile.mockResolvedValue({ ...mockJobsData, jobs: [onDemandJob()] }) + expect((await getDueJobs()).find(j => j.id === 'job-on-demand')).toBeUndefined() + + const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString() + readJSONFile.mockResolvedValue({ ...mockJobsData, jobs: [onDemandJob({ lastRun: weekAgo })] }) + expect((await getDueJobs()).find(j => j.id === 'job-on-demand')).toBeUndefined() + }) + + it('computeNextJobRun returns null rather than an Invalid Date', () => { + const next = computeNextJobRun(onDemandJob({ lastRun: '2025-01-01T00:00:00.000Z' }), 'UTC') + expect(next).toBeNull() + // The regression this closes: lastRun + null === lastRun, a finite past + // timestamp that reads as permanently overdue. + expect(next).not.toBe(Date.parse('2025-01-01T00:00:00.000Z')) + }) + + it('createJob stores the sentinel and drops a pinned time-of-day', async () => { + const job = await createJob({ + name: 'Manual only', + interval: ON_DEMAND_INTERVAL, + scheduledTime: '09:00', + promptTemplate: 'Only when asked' + }) + + expect(job.interval).toBe(ON_DEMAND_INTERVAL) + expect(job.intervalMs).toBeNull() + expect(job.scheduledTime).toBeNull() + }) + + it('updateJob switching an existing recurring job to on-demand clears its interval', async () => { + const updated = await updateJob('job-test-1', { interval: ON_DEMAND_INTERVAL }) + + expect(updated.interval).toBe(ON_DEMAND_INTERVAL) + expect(updated.intervalMs).toBeNull() + }) + + it('every recurring cadence still round-trips to a finite interval', async () => { + for (const opt of INTERVAL_OPTIONS.filter(o => o.value !== ON_DEMAND_INTERVAL)) { + const job = await createJob({ name: opt.value, interval: opt.value, promptTemplate: 'x' }) + expect(job.interval, opt.value).toBe(opt.value) + expect(job.intervalMs, opt.value).toBe(opt.ms) + } + const custom = await createJob({ name: 'custom', interval: 'custom', intervalMs: 90_000, promptTemplate: 'x' }) + expect(custom.intervalMs).toBe(90_000) + }) + }) + describe('INTERVAL_OPTIONS', () => { it('has expected options', () => { expect(INTERVAL_OPTIONS).toContainEqual({ diff --git a/server/services/autonomousJobs/constants.js b/server/services/autonomousJobs/constants.js index 41ea85d345..97162d8f3a 100644 --- a/server/services/autonomousJobs/constants.js +++ b/server/services/autonomousJobs/constants.js @@ -18,45 +18,22 @@ export const JOBS_SKILLS_DIR = PATHS.promptSkillsJobs // Serializes writes to autonomous-jobs.json so two write paths can't clobber each other. export const withLock = createMutex() -export const WEEK = 7 * DAY +// Cadence vocabulary + resolver live in lib so lib/cosValidation.js can validate +// against the same list without importing a service. Re-exported here because +// the submodules (and autonomousJobs.js) have always imported them from this +// leaf module. +export { + WEEK, + ON_DEMAND_INTERVAL, + INTERVAL_OPTIONS, + JOB_INTERVAL_VALUES, + resolveIntervalMs, + isOnDemandJob +} from '../../lib/autonomousJobIntervals.js' // Re-export the time units used across submodules so callers import from one place. export { DAY, HOUR } -/** - * Resolve interval string to milliseconds. Pure (depends only on the time - * units above), so it lives in this leaf module — keeping it out of scheduler.js - * avoids the store→scheduler and crud→scheduler import cycles. - */ -export function resolveIntervalMs(interval, customMs) { - switch (interval) { - case 'hourly': return HOUR - case 'every-2-hours': return 2 * HOUR - case 'every-4-hours': return 4 * HOUR - case 'every-8-hours': return 8 * HOUR - case 'daily': return DAY - case 'weekly': return WEEK - case 'biweekly': return 2 * WEEK - case 'monthly': return 30 * DAY - case 'custom': return customMs || DAY - default: return DAY - } -} - -/** - * Available interval options for UI - */ -export const INTERVAL_OPTIONS = [ - { value: 'hourly', label: 'Every Hour', ms: HOUR }, - { value: 'every-2-hours', label: 'Every 2 Hours', ms: 2 * HOUR }, - { value: 'every-4-hours', label: 'Every 4 Hours', ms: 4 * HOUR }, - { value: 'every-8-hours', label: 'Every 8 Hours', ms: 8 * HOUR }, - { value: 'daily', label: 'Daily', ms: DAY }, - { value: 'weekly', label: 'Weekly', ms: WEEK }, - { value: 'biweekly', label: 'Every 2 Weeks', ms: 2 * WEEK }, - { value: 'monthly', label: 'Monthly', ms: 30 * DAY } -] - // Fields that are code contracts — always overwrite on restart so runtime // stays consistent with the shipped implementation. export const JOB_STRUCTURAL_FIELDS = ['type', 'scriptHandler', 'appId'] diff --git a/server/services/autonomousJobs/crud.js b/server/services/autonomousJobs/crud.js index eaf0283103..6ca8846ad5 100644 --- a/server/services/autonomousJobs/crud.js +++ b/server/services/autonomousJobs/crud.js @@ -69,6 +69,12 @@ async function createJob(jobData) { const jobType = jobData.type || 'agent' + // resolveIntervalMs returns null — the no-interval sentinel — for the + // on-demand cadence, so the schedulers arm nothing and a time-of-day has no + // recurrence to align to. + const interval = jobData.interval || 'weekly' + const intervalMs = resolveIntervalMs(interval, jobData.intervalMs) + // Strip agent-specific triggerAction values from shell jobs const agentOnlyActions = ['spawn-agent', 'create-task'] const triggerAction = (jobType === 'shell' && agentOnlyActions.includes(jobData.triggerAction)) @@ -91,9 +97,9 @@ async function createJob(jobData) { cronExpression: jobData.cronExpression || null, cronSchedule: jobData.cronSchedule || null, type: jobType, - interval: jobData.interval || 'weekly', - intervalMs: resolveIntervalMs(jobData.interval || 'weekly', jobData.intervalMs), - scheduledTime: jobData.scheduledTime || null, + interval, + intervalMs, + scheduledTime: intervalMs == null ? null : (jobData.scheduledTime || null), weekdaysOnly: jobData.weekdaysOnly || false, enabled: jobData.enabled !== undefined ? jobData.enabled : false, priority: jobData.priority || 'MEDIUM', @@ -167,6 +173,9 @@ async function updateJob(jobId, updates) { // Recalculate intervalMs if interval changed if (updates.interval) { job.intervalMs = resolveIntervalMs(updates.interval, updates.intervalMs) + // Switching to a no-recurrence cadence (on-demand) leaves no schedule for + // a pinned time-of-day to align to. + if (job.intervalMs == null) job.scheduledTime = null } // Validate shell jobs have a valid command after all fields are applied diff --git a/server/services/autonomousJobs/scheduler.js b/server/services/autonomousJobs/scheduler.js index 29e6dcf0e4..4b5de3663f 100644 --- a/server/services/autonomousJobs/scheduler.js +++ b/server/services/autonomousJobs/scheduler.js @@ -9,7 +9,7 @@ import { getLocalParts, nextLocalTime } from '../../lib/timezone.js' import { getUserTimezone } from '../userTimezone.js' import { parseCronToNextRun, parseRecurrenceToNextRun } from '../eventScheduler.js' -import { DAY } from './constants.js' +import { DAY, isOnDemandJob } from './constants.js' import { loadJobs } from './store.js' // Reads enabled jobs straight from the store. Scheduler intentionally does NOT @@ -56,6 +56,11 @@ async function getDueJobs() { continue } + // On-demand jobs have no clock — they run only via POST /jobs/:id/trigger. + // Without this guard the interval comparison below reads a null intervalMs + // as 0 and reports the job due on every sweep. + if (isOnDemandJob(job)) continue + // Interval-mode jobs const lastRun = job.lastRun ? new Date(job.lastRun).getTime() : 0 const timeSinceLastRun = now - lastRun @@ -114,6 +119,10 @@ export function computeNextJobRun(job, timezone) { return next?.getTime() ?? null } + // No recurrence to project forward from — the caller renders 'On demand' + // rather than an arithmetic-on-null date. + if (isOnDemandJob(job)) return null + const lastRun = job.lastRun ? new Date(job.lastRun).getTime() : 0 let nextDue = lastRun + job.intervalMs diff --git a/server/services/autonomousJobs/store.js b/server/services/autonomousJobs/store.js index 7fbeee72d7..a141f86559 100644 --- a/server/services/autonomousJobs/store.js +++ b/server/services/autonomousJobs/store.js @@ -13,7 +13,7 @@ import { join } from 'path' import { existsSync } from 'fs' import { ensureDir, PATHS, readJSONFile, atomicWrite, tryReadFile } from '../../lib/fileUtils.js' import { validateCommand } from '../../lib/commandSecurity.js' -import { DATA_DIR, JOBS_FILE, JOBS_SKILLS_DIR, resolveIntervalMs } from './constants.js' +import { DATA_DIR, JOBS_FILE, JOBS_SKILLS_DIR, JOB_INTERVAL_VALUES, resolveIntervalMs } from './constants.js' import { createDefaultJobsData, mergeWithDefaults } from './defaults.js' let initPromise = null @@ -133,7 +133,7 @@ async function migrateScriptsState(jobsData) { const existingIds = new Set(jobsData.jobs.map(j => j.id)) // Map legacy schedule values to valid interval values - const VALID_INTERVALS = new Set(['hourly', 'every-2-hours', 'every-4-hours', 'every-8-hours', 'daily', 'weekly', 'biweekly', 'monthly', 'custom']) + const VALID_INTERVALS = new Set(JOB_INTERVAL_VALUES) const LEGACY_SCHEDULE_MAP = { 'every-5-min': 'hourly', 'every-10-min': 'hourly', @@ -146,7 +146,9 @@ async function migrateScriptsState(jobsData) { 'twice-daily': 'daily' } const mapLegacySchedule = (schedule, scriptName) => { - if (!schedule || schedule === 'on-demand' || schedule === 'startup') return 'daily' + // 'startup' has no cadence equivalent; 'on-demand' now maps to the real + // on-demand cadence via VALID_INTERVALS below instead of a disabled daily job. + if (!schedule || schedule === 'startup') return 'daily' if (VALID_INTERVALS.has(schedule)) return schedule if (LEGACY_SCHEDULE_MAP[schedule]) { console.log(`📦 Mapped legacy schedule '${schedule}' for '${scriptName}' to '${LEGACY_SCHEDULE_MAP[schedule]}'`) @@ -167,7 +169,10 @@ async function migrateScriptsState(jobsData) { if (script.cronExpression) { console.warn(`⚠️ Legacy cron expression '${script.cronExpression}' for script '${script.name}' not supported by job scheduler, using interval '${mappedInterval}' instead`) } - const isOnDemandOrStartup = script.schedule === 'on-demand' || script.schedule === 'startup' + // A startup script has no cadence to carry over, so it lands disabled. An + // on-demand one keeps its own enabled flag — the on-demand cadence already + // guarantees no timer, and staying enabled keeps it manually runnable. + const isStartupScript = script.schedule === 'startup' // Validate command against allowlist — disable jobs with invalid commands let commandValid = true @@ -188,7 +193,7 @@ async function migrateScriptsState(jobsData) { command: commandValid ? script.command : null, interval: mappedInterval, intervalMs: resolveIntervalMs(mappedInterval), - enabled: commandValid ? (isOnDemandOrStartup ? false : (script.enabled || false)) : false, + enabled: commandValid ? (isStartupScript ? false : (script.enabled || false)) : false, priority: script.triggerPriority || 'MEDIUM', triggerAction: 'log-only', lastRun: script.lastRun || null, diff --git a/server/services/cosJobScheduler.js b/server/services/cosJobScheduler.js index a334f8f0f4..5ed8475f64 100644 --- a/server/services/cosJobScheduler.js +++ b/server/services/cosJobScheduler.js @@ -27,6 +27,7 @@ import { getUserTimezone } from './userTimezone.js'; import { formatDuration } from '../lib/fileUtils.js'; import { loadState, isDaemonRunning, canQueueImprovementTasks } from './cosState.js'; import { getDomainMode } from '../lib/domainAutonomy.js'; +import { isOnDemandJob } from '../lib/autonomousJobIntervals.js'; import { remainingActionBudget } from '../lib/domainBudgets.js'; import { getDomainBudgetStatus, recordDomainUsage } from './domainUsage.js'; import { cosEvents, emitLog } from './cosEvents.js'; @@ -45,9 +46,11 @@ import { * 1. Cron mode: job.cronExpression defines the full schedule * 2. Interval mode: job.intervalMs + optional job.scheduledTime (HH:MM in user timezone) * + * 3. On-demand: no cadence at all — returns null so no timer is armed. + * * @param {Object} job - The job object * @param {string} timezone - IANA timezone string for interpreting scheduledTime/cron - * @returns {number} Timestamp (ms) of next fire time + * @returns {number|null} Timestamp (ms) of next fire time, or null when the job never fires on a clock */ function computeNextJobFireTime(job, timezone) { // Convert scheduledTime (HH:MM) + interval to a cron expression so parseCronToNextRun @@ -65,6 +68,12 @@ function computeNextJobFireTime(job, timezone) { } return next.getTime(); } + // On-demand jobs never fire on a clock. Checked after the explicit cron + // fields (a job switched to cron mode keeps whatever cadence it last had) but + // before the synthesized daily/weekday cron below, which a weekdaysOnly + // on-demand job would otherwise fall into. + if (!cronExpr && isOnDemandJob(job)) return null; + const isDailyCronCandidate = job.interval === 'daily' || job.weekdaysOnly; if (!cronExpr && job.scheduledTime && isDailyCronCandidate) { const match = String(job.scheduledTime).match(/^([01]\d|2[0-3]):([0-5]\d)$/); @@ -125,6 +134,12 @@ export async function registerSingleJobSchedule(jobId) { const timezone = await getUserTimezone(); const nextFire = computeNextJobFireTime(job, timezone); + // An on-demand job stays enabled (so POST /jobs/:id/trigger still runs it) + // but arms no timer. Cancel any timer left over from a previous cadence. + if (nextFire == null) { + cancelEvent(`job:${jobId}`); + return; + } const delayMs = Math.max(nextFire - Date.now(), 1000); scheduleEvent({ diff --git a/server/services/cosJobScheduler.test.js b/server/services/cosJobScheduler.test.js index b821bcd215..4ef0498d3b 100644 --- a/server/services/cosJobScheduler.test.js +++ b/server/services/cosJobScheduler.test.js @@ -234,4 +234,63 @@ describe('registerSingleJobSchedule', () => { expect(cancelEvent).toHaveBeenCalledWith('job:job-disabled'); expect(scheduleEvent).not.toHaveBeenCalled(); }); + + // #6375 — this is the scheduler that actually arms the timer. An on-demand + // job stays enabled (so POST /jobs/:id/trigger still runs it) but must never + // be given a fire time; before the cadence existed, a null intervalMs reached + // `lastRun + intervalMs` and produced a bogus delay instead. + it('arms no timer for an enabled on-demand job, and cancels any stale one', async () => { + scheduleEvent.mockClear(); + cancelEvent.mockClear(); + getJob.mockResolvedValueOnce({ + id: 'job-on-demand', + name: 'Manual only', + enabled: true, + interval: 'on-demand', + intervalMs: null, + }); + + await registerSingleJobSchedule('job-on-demand'); + + expect(scheduleEvent).not.toHaveBeenCalled(); + expect(cancelEvent).toHaveBeenCalledWith('job:job-on-demand'); + }); + + it('a weekdaysOnly on-demand job with a stale scheduledTime still arms no timer', async () => { + // The synthesized daily/weekday cron path keys on weekdaysOnly, so it would + // otherwise capture an on-demand job that carried those fields over. + scheduleEvent.mockClear(); + cancelEvent.mockClear(); + getJob.mockResolvedValueOnce({ + id: 'job-on-demand-weekday', + name: 'Manual only', + enabled: true, + interval: 'on-demand', + intervalMs: null, + weekdaysOnly: true, + scheduledTime: '04:30', + }); + + await registerSingleJobSchedule('job-on-demand-weekday'); + + expect(scheduleEvent).not.toHaveBeenCalled(); + }); + + it('still arms a timer for a recurring job', async () => { + scheduleEvent.mockClear(); + getJob.mockResolvedValueOnce({ + id: 'job-daily', + name: 'Daily', + enabled: true, + interval: 'daily', + intervalMs: 86_400_000, + }); + + await registerSingleJobSchedule('job-daily'); + + expect(scheduleEvent).toHaveBeenCalledWith( + expect.objectContaining({ id: 'job:job-daily', type: 'once' }) + ); + expect(scheduleEvent.mock.calls[0][0].delayMs).toBeGreaterThan(0); + }); });