Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 30 additions & 12 deletions client/src/components/cos/JobCard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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' },
Expand Down Expand Up @@ -127,25 +133,35 @@ export function ScheduleFields({ data, onChange, timezone }) {
/>
</div>
) : (
<div className="flex gap-3">
<div className="flex flex-wrap items-center gap-3">
<select
aria-label="Interval"
value={data.interval}
onChange={e => onChange('interval', e.target.value)}
onChange={e => {
onChange('interval', e.target.value);
// The on-demand cadence has no recurrence for a time-of-day to
// align to; the server clears it too, so drop it here rather than
// saving a value the job will never honor.
if (e.target.value === ON_DEMAND_INTERVAL) onChange('scheduledTime', null);
}}
className="px-3 py-2 bg-port-bg border border-port-border rounded-lg text-white text-sm"
>
{INTERVAL_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
<input
type="time"
value={data.scheduledTime || ''}
onChange={e => 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 ? (
<span className="text-xs text-gray-500">Runs only when you press Run now.</span>
) : (
<input
type="time"
value={data.scheduledTime || ''}
onChange={e => 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)"
/>
)}
</div>
)}
</div>
Expand All @@ -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';
Expand Down Expand Up @@ -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)));

Expand Down
54 changes: 54 additions & 0 deletions client/src/components/cos/JobCard.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
2 changes: 1 addition & 1 deletion client/src/utils/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). |
Expand Down
11 changes: 8 additions & 3 deletions client/src/utils/cronHelpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand All @@ -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' }
];
1 change: 1 addition & 0 deletions server/lib/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`. |
Expand Down
94 changes: 94 additions & 0 deletions server/lib/autonomousJobIntervals.js
Original file line number Diff line number Diff line change
@@ -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);
}
Loading