openvibers/openvibe.actor Commits
Your agents: quiet hours (quiet_from to quiet_to, UTC, may cross midnight): no scheduled run starts inside them; an hourly run waits for their end, a daily or weekly time inside them is refused; Run now still runs (#114)
Changes
nnew file mode 100644@@@ -0,0 +1,7 @@++-- phase: expand++-- Quiet hours for personal agents (actor.agent-manage-request@1 quiet_from/quiet_to, contracts 0.214.0): HH:MM UTC, both++-- or neither; no scheduled run starts inside the window (it may cross midnight). A run the owner starts still runs.++ALTER TABLE actor_agents++ ADD COLUMN quiet_from text COLLATE "C" CHECK (quiet_from ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$'),++ ADD COLUMN quiet_to text COLLATE "C" CHECK (quiet_to ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$'),++ ADD CONSTRAINT actor_agents_quiet_pair CHECK ((quiet_from IS NULL) = (quiet_to IS NULL) AND (quiet_from IS NULL OR quiet_from <> quiet_to));@@@ -6,11 +6,32 @@ const { createTask } = require('../tasks/create');const SCHEDULES = ['off', 'hourly', 'daily', 'weekly'];const ID = /^agt_[0-9A-HJKMNP-TV-Z]{26}$/;const MEM_ID = /^mem_[0-9A-HJKMNP-TV-Z]{26}$/;++const HHMM = /^([01]\d|2[0-3]):[0-5]\d$/;++++// Quiet hours (quiet_from to quiet_to, HH:MM UTC; the window may cross midnight, like 22:00 to 07:00): no scheduled run++// starts inside them. An hourly run waits for the end; a daily or weekly time inside them is refused when it is set.++const minutes = (hhmm) => { const [h, m] = hhmm.split(':').map(Number); return h * 60 + m; };++function inQuiet(agent, at) {++ if (!agent.quiet_from || !agent.quiet_to) return false;++ const d = new Date(at);++ const m = d.getUTCHours() * 60 + d.getUTCMinutes();++ const from = minutes(agent.quiet_from), to = minutes(agent.quiet_to);++ return from < to ? m >= from && m < to : m >= from || m < to;++}++function quietEnd(agent, at) {++ const d = new Date(at);++ const end = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()) + minutes(agent.quiet_to) * 60_000);++ if (end <= d) end.setUTCDate(end.getUTCDate() + 1);++ return end;++}function nextRun(agent, after) {if (agent.schedule === 'off') return null;const start = new Date(after);-- if (agent.schedule === 'hourly') return new Date(start.getTime() + 3_600_000).toISOString();++ if (agent.schedule === 'hourly') {++ const next = new Date(start.getTime() + 3_600_000);++ return (inQuiet(agent, next) ? quietEnd(agent, next) : next).toISOString();++ }const [hour, minute] = agent.schedule_at.split(':').map(Number);const candidate = new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth(), start.getUTCDate(), hour, minute));if (agent.schedule === 'daily') {@@@ -23,7 +44,7 @@ function nextRun(agent, after) {}function validate(input, config, current = null) {-- const allowed = ['name', 'instructions', 'mode', 'budget_per_run_usd', 'daily_runs_max', 'schedule', 'schedule_at', 'schedule_dow', 'status'];++ const allowed = ['name', 'instructions', 'mode', 'budget_per_run_usd', 'daily_runs_max', 'schedule', 'schedule_at', 'schedule_dow', 'quiet_from', 'quiet_to', 'status'];if (!input || typeof input !== 'object' || Array.isArray(input) || Object.keys(input).some((k) => !allowed.includes(k))) return 'Unknown agent setting.';const a = { ...current, ...input };if (typeof a.name !== 'string' || a.name.trim().length < 1 || a.name.length > 60) return 'name must be 1–60 characters.';@@@ -32,8 +53,14 @@ function validate(input, config, current = null) {if (typeof a.budget_per_run_usd !== 'number' || !Number.isFinite(a.budget_per_run_usd) || a.budget_per_run_usd <= 0 || a.budget_per_run_usd > config.allowance.perTaskUsd) return 'budget_per_run_usd exceeds the per-task allowance or is invalid.';if (!Number.isInteger(a.daily_runs_max) || a.daily_runs_max < 1 || a.daily_runs_max > 24) return 'daily_runs_max must be 1–24.';if (!SCHEDULES.includes(a.schedule)) return 'schedule is invalid.';-- if (['daily', 'weekly'].includes(a.schedule) && !/^([01]\d|2[0-3]):[0-5]\d$/.test(a.schedule_at || '')) return 'schedule_at must be HH:MM UTC.';++ if (['daily', 'weekly'].includes(a.schedule) && !HHMM.test(a.schedule_at || '')) return 'schedule_at must be HH:MM UTC.';if (a.schedule === 'weekly' && (!Number.isInteger(a.schedule_dow) || a.schedule_dow < 0 || a.schedule_dow > 6)) return 'schedule_dow must be 0–6.';++ if ((a.quiet_from == null) !== (a.quiet_to == null)) return 'quiet_from and quiet_to go together: set both or neither.';++ if (a.quiet_from != null) {++ if (!HHMM.test(a.quiet_from) || !HHMM.test(a.quiet_to)) return 'quiet_from and quiet_to must be HH:MM UTC.';++ if (a.quiet_from === a.quiet_to) return 'Quiet hours must not start and end at the same time.';++ if (['daily', 'weekly'].includes(a.schedule) && inQuiet(a, `2000-01-01T${a.schedule_at}:00.000Z`)) return 'schedule_at falls inside the quiet hours.';++ }if (!['active', 'paused'].includes(a.status)) return 'status is invalid.';return null;}@@@ -43,7 +70,7 @@ const list = (s, owner) => s.db.many('SELECT * FROM actor_agents WHERE owner = $const memory = (s, owner, id) => s.db.many('SELECT * FROM actor_agent_memory WHERE owner = $1 AND agent_id = $2 ORDER BY created_order DESC', [owner, id]);async function create(s, config, owner, input) {-- const body = { mode: 'balanced', budget_per_run_usd: config.allowance.perTaskUsd, daily_runs_max: 1, schedule: 'off', schedule_at: null, schedule_dow: null, status: 'active', ...input };++ const body = { mode: 'balanced', budget_per_run_usd: config.allowance.perTaskUsd, daily_runs_max: 1, schedule: 'off', schedule_at: null, schedule_dow: null, quiet_from: null, quiet_to: null, status: 'active', ...input };const invalid = validate(body, config);if (invalid) return { error: invalid };return s.tx(async () => {@@@ -54,9 +81,9 @@ async function create(s, config, owner, input) {const count = Number(await s.db.value('SELECT count(*) FROM actor_agents WHERE owner = $1', [owner]));if (count >= config.agents.maxPerOwner) return { limit: true };const now = s.iso();-- const row = await s.db.maybe(`INSERT INTO actor_agents (id, owner, name, instructions, mode, budget_per_run_usd, daily_runs_max, schedule, schedule_at, schedule_dow, next_run_at, status, created_at, updated_at)-- VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$13) RETURNING *`,-- [s.newId('agt'), owner, body.name, body.instructions, body.mode, body.budget_per_run_usd, body.daily_runs_max, body.schedule, body.schedule_at, body.schedule_dow, nextRun(body, now), body.status, now]);++ const row = await s.db.maybe(`INSERT INTO actor_agents (id, owner, name, instructions, mode, budget_per_run_usd, daily_runs_max, schedule, schedule_at, schedule_dow, quiet_from, quiet_to, next_run_at, status, created_at, updated_at)++ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$15) RETURNING *`,++ [s.newId('agt'), owner, body.name, body.instructions, body.mode, body.budget_per_run_usd, body.daily_runs_max, body.schedule, body.schedule_at, body.schedule_dow, body.quiet_from, body.quiet_to, nextRun(body, now), body.status, now]);return { row };});}@@@ -67,7 +94,7 @@ async function update(s, config, owner, id, patch) {const invalid = validate(patch, config, row);if (invalid) return { error: invalid };const a = { ...row, ...patch };-- const at = ['schedule', 'schedule_at', 'schedule_dow'].some((k) => Object.hasOwn(patch, k)) ? nextRun(a, s.iso()) : row.next_run_at;++ const at = ['schedule', 'schedule_at', 'schedule_dow', 'quiet_from', 'quiet_to'].some((k) => Object.hasOwn(patch, k)) ? nextRun(a, s.iso()) : row.next_run_at;const cols = Object.keys(patch);const args = cols.map((k) => patch[k]);args.push(at, s.iso(), id, owner);@@@ -133,4 +160,4 @@ async function tick(ctx) {}}--module.exports = { ID, MEM_ID, SCHEDULES, get, list, memory, create, update, addMemory, prompt, run, tick, nextRun, validate };++module.exports = { ID, MEM_ID, SCHEDULES, get, list, memory, create, update, addMemory, prompt, run, tick, nextRun, inQuiet, validate };@@@ -56,15 +56,19 @@ function createAgentPages(ctx) {<label>Schedule <select name="schedule">${personal.SCHEDULES.map((x) => html`<option value="${x}"${(a.schedule || 'off') === x ? raw(' selected') : ''}>${SCHEDULE_LABELS[x]}</option>`)}</select></label><label>At (UTC) <input name="schedule_at" type="time" value="${a.schedule_at || ''}"><span class="pa-hint">For daily and weekly.</span></label><label>On <select name="schedule_dow"><option value=""${a.schedule_dow == null ? raw(' selected') : ''}>Any day</option>${DAYS.map((d, i) => html`<option value="${String(i)}"${a.schedule_dow === i ? raw(' selected') : ''}>${d}</option>`)}</select><span class="pa-hint">For weekly.</span></label>++<label>Quiet from (UTC) <input name="quiet_from" type="time" value="${a.quiet_from || ''}"><span class="pa-hint">No scheduled run starts in quiet hours; Run now still works.</span></label>++<label>Quiet until (UTC) <input name="quiet_to" type="time" value="${a.quiet_to || ''}"><span class="pa-hint">It may cross midnight, like 22:00 to 07:00.</span></label><label>Status <select name="status"><option value="active"${a.status !== 'paused' ? raw(' selected') : ''}>Active</option><option value="paused"${a.status === 'paused' ? raw(' selected') : ''}>Paused</option></select></label></div>`;}-- const when = (a) => a.schedule === 'off' ? 'Runs when you run it' : a.schedule === 'hourly' ? 'Every hour'-- : a.schedule === 'daily' ? `Every day at ${a.schedule_at} UTC` : `Every ${DAYS[a.schedule_dow]} at ${a.schedule_at} UTC`;++ const when = (a) => a.schedule === 'off' ? 'Runs when you run it' : (a.schedule === 'hourly' ? 'Every hour'++ : a.schedule === 'daily' ? `Every day at ${a.schedule_at} UTC` : `Every ${DAYS[a.schedule_dow]} at ${a.schedule_at} UTC`)++ + (a.quiet_from ? `, quiet ${a.quiet_from}–${a.quiet_to} UTC` : '');function fields(body) {return { name: body.name, instructions: body.instructions, mode: body.mode,budget_per_run_usd: Number(body.budget_per_run_usd), daily_runs_max: Number(body.daily_runs_max), schedule: body.schedule,-- schedule_at: body.schedule_at || null, schedule_dow: body.schedule_dow === '' ? null : Number(body.schedule_dow), status: body.status };++ schedule_at: body.schedule_at || null, schedule_dow: body.schedule_dow === '' ? null : Number(body.schedule_dow),++ quiet_from: body.quiet_from || null, quiet_to: body.quiet_to || null, status: body.status };}r.get('/me/agents', async (req, res) => {@@@ -48,6 +48,17 @@ const SAME = { 'sec-fetch-site': 'same-origin' };const filled = await t.get('/me/agents', { as: a, form: { ...base, instructions: 'Find jobs: a junior video editor, remote.' }, headers: SAME });assert.strictEqual(filled.status, 303);});++ await check('quiet hours from the form: saved, shown, and a time inside them refused', async () => {++ assert.match((await t.get('/me/agents', { as: a })).text, /name="quiet_from" type="time"/);++ const base = { name: 'Hourly', instructions: 'Check the page.', mode: 'balanced', budget_per_run_usd: '0.02', daily_runs_max: '3', schedule_dow: '', status: 'active' };++ const made = await t.get('/me/agents', { as: a, form: { ...base, schedule: 'hourly', schedule_at: '', quiet_from: '22:00', quiet_to: '07:00' }, headers: SAME });++ assert.strictEqual(made.status, 303);++ const own = await t.get(made.headers.get('location'), { as: a });++ assert.match(own.text, /Every hour, quiet 22:00–07:00 UTC/);++ const inside = await t.get('/me/agents', { as: a, form: { ...base, schedule: 'daily', schedule_at: '23:00', quiet_from: '22:00', quiet_to: '07:00' }, headers: SAME });++ assert.strictEqual(inside.status, 422);++ assert.match(inside.text, /inside the quiet hours/);++ });} finally { await t.close(); }done();})();@@@ -10,6 +10,26 @@ const personal = require('../server/agents/personal');assert.strictEqual(personal.nextRun({ schedule: 'daily', schedule_at: '09:00' }, at), '2026-10-12T09:00:00.000Z');assert.strictEqual(personal.nextRun({ schedule: 'weekly', schedule_at: '09:00', schedule_dow: 0 }, at), '2026-10-18T09:00:00.000Z');});++ await check('quiet hours: an hourly run waits for their end, across midnight too', async () => {++ const night = { schedule: 'hourly', quiet_from: '22:00', quiet_to: '07:00' };++ assert.strictEqual(personal.nextRun(night, '2026-10-11T20:30:00.000Z'), '2026-10-11T21:30:00.000Z');++ assert.strictEqual(personal.nextRun(night, '2026-10-11T21:30:00.000Z'), '2026-10-12T07:00:00.000Z');++ assert.strictEqual(personal.nextRun(night, '2026-10-12T02:15:00.000Z'), '2026-10-12T07:00:00.000Z');++ assert.strictEqual(personal.nextRun(night, '2026-10-12T07:00:00.000Z'), '2026-10-12T08:00:00.000Z');++ const lunch = { schedule: 'hourly', quiet_from: '13:00', quiet_to: '14:00' };++ assert.strictEqual(personal.nextRun(lunch, '2026-10-11T12:30:00.000Z'), '2026-10-11T14:00:00.000Z');++ assert.ok(personal.inQuiet(night, '2026-10-11T23:59:00.000Z') && !personal.inQuiet(night, '2026-10-11T07:00:00.000Z'));++ });++ await check('quiet hours: both or neither, HH:MM, not empty, and a daily time cannot sit inside them', async () => {++ const config = { allowance: { perTaskUsd: 0.05 } };++ const ok = { name: 'A', instructions: 'B', mode: 'balanced', budget_per_run_usd: 0.01, daily_runs_max: 1, schedule: 'daily', schedule_at: '09:00', schedule_dow: null, status: 'active' };++ assert.strictEqual(personal.validate({ ...ok, quiet_from: '22:00', quiet_to: '07:00' }, config), null);++ assert.match(personal.validate({ ...ok, quiet_from: '22:00' }, config), /set both or neither/);++ assert.match(personal.validate({ ...ok, quiet_from: '22:00', quiet_to: '7am' }, config), /HH:MM/);++ assert.match(personal.validate({ ...ok, quiet_from: '22:00', quiet_to: '22:00' }, config), /same time/);++ assert.match(personal.validate({ ...ok, schedule_at: '23:30', quiet_from: '22:00', quiet_to: '07:00' }, config), /inside the quiet hours/);++ assert.strictEqual(personal.validate({ ...ok, schedule: 'off', schedule_at: null, quiet_from: '22:00', quiet_to: '07:00' }, config), null);++ });let now = Date.parse('2026-10-11T12:30:00.000Z');const submitted = [];const t = await boot({ now: () => now, adapters: { run: {}, checkers: {} } });@@@ -34,6 +54,13 @@ const personal = require('../server/agents/personal');assert.match(after.last_skip_reason, /actor.agent.limit/);assert.ok(after.next_run_at > new Date(now).toISOString());});++ await check('a scheduled agent with quiet hours is next due when they end', async () => {++ const quiet = (await personal.create(t.ctx.s, t.config, owner, { ...base, name: 'Quiet', quiet_from: '13:00', quiet_to: '20:00' })).row;++ assert.strictEqual(quiet.quiet_from, '13:00');++ assert.strictEqual(quiet.next_run_at, '2026-10-11T20:00:00.000Z');++ const moved = (await personal.update(t.ctx.s, t.config, owner, quiet.id, { quiet_from: null, quiet_to: null })).row;++ assert.strictEqual(moved.next_run_at, new Date(now + 3_600_000).toISOString(), 'clearing them reschedules');++ });} finally { await t.close(); }done();})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
+-- phase: expand
+-- Quiet hours for personal agents (actor.agent-manage-request@1 quiet_from/quiet_to, contracts 0.214.0): HH:MM UTC, both
+-- or neither; no scheduled run starts inside the window (it may cross midnight). A run the owner starts still runs.
+ALTER TABLE actor_agents
+ ADD COLUMN quiet_from text COLLATE "C" CHECK (quiet_from ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$'),
+ ADD COLUMN quiet_to text COLLATE "C" CHECK (quiet_to ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$'),
+ ADD CONSTRAINT actor_agents_quiet_pair CHECK ((quiet_from IS NULL) = (quiet_to IS NULL) AND (quiet_from IS NULL OR quiet_from <> quiet_to));
@@ -6,11 +6,32 @@ const { createTask } = require('../tasks/create');
@@ -6,11 +6,32 @@ const { createTask } = require('../tasks/create');
const SCHEDULES = ['off', 'hourly', 'daily', 'weekly'];
const SCHEDULES = ['off', 'hourly', 'daily', 'weekly'];
const ID = /^agt_[0-9A-HJKMNP-TV-Z]{26}$/;
const ID = /^agt_[0-9A-HJKMNP-TV-Z]{26}$/;
const MEM_ID = /^mem_[0-9A-HJKMNP-TV-Z]{26}$/;
const MEM_ID = /^mem_[0-9A-HJKMNP-TV-Z]{26}$/;
+const HHMM = /^([01]\d|2[0-3]):[0-5]\d$/;
+
+// Quiet hours (quiet_from to quiet_to, HH:MM UTC; the window may cross midnight, like 22:00 to 07:00): no scheduled run
+// starts inside them. An hourly run waits for the end; a daily or weekly time inside them is refused when it is set.
+const minutes = (hhmm) => { const [h, m] = hhmm.split(':').map(Number); return h * 60 + m; };
+function inQuiet(agent, at) {
+ if (!agent.quiet_from || !agent.quiet_to) return false;
+ const d = new Date(at);
+ const m = d.getUTCHours() * 60 + d.getUTCMinutes();
+ const from = minutes(agent.quiet_from), to = minutes(agent.quiet_to);
+ return from < to ? m >= from && m < to : m >= from || m < to;
+}
+function quietEnd(agent, at) {
+ const d = new Date(at);
+ const end = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()) + minutes(agent.quiet_to) * 60_000);
+ if (end <= d) end.setUTCDate(end.getUTCDate() + 1);
+ return end;
+}
function nextRun(agent, after) {
function nextRun(agent, after) {
if (agent.schedule === 'off') return null;
if (agent.schedule === 'off') return null;
const start = new Date(after);
const start = new Date(after);
- if (agent.schedule === 'hourly') return new Date(start.getTime() + 3_600_000).toISOString();
+ if (agent.schedule === 'hourly') {
+ const next = new Date(start.getTime() + 3_600_000);
+ return (inQuiet(agent, next) ? quietEnd(agent, next) : next).toISOString();
+ }
const [hour, minute] = agent.schedule_at.split(':').map(Number);
const [hour, minute] = agent.schedule_at.split(':').map(Number);
const candidate = new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth(), start.getUTCDate(), hour, minute));
const candidate = new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth(), start.getUTCDate(), hour, minute));
if (agent.schedule === 'daily') {
if (agent.schedule === 'daily') {
@@ -23,7 +44,7 @@ function nextRun(agent, after) {
@@ -23,7 +44,7 @@ function nextRun(agent, after) {
}
}
function validate(input, config, current = null) {
function validate(input, config, current = null) {
- const allowed = ['name', 'instructions', 'mode', 'budget_per_run_usd', 'daily_runs_max', 'schedule', 'schedule_at', 'schedule_dow', 'status'];
+ const allowed = ['name', 'instructions', 'mode', 'budget_per_run_usd', 'daily_runs_max', 'schedule', 'schedule_at', 'schedule_dow', 'quiet_from', 'quiet_to', 'status'];
if (!input || typeof input !== 'object' || Array.isArray(input) || Object.keys(input).some((k) => !allowed.includes(k))) return 'Unknown agent setting.';
if (!input || typeof input !== 'object' || Array.isArray(input) || Object.keys(input).some((k) => !allowed.includes(k))) return 'Unknown agent setting.';
const a = { ...current, ...input };
const a = { ...current, ...input };
if (typeof a.name !== 'string' || a.name.trim().length < 1 || a.name.length > 60) return 'name must be 1–60 characters.';
if (typeof a.name !== 'string' || a.name.trim().length < 1 || a.name.length > 60) return 'name must be 1–60 characters.';
@@ -32,8 +53,14 @@ function validate(input, config, current = null) {
@@ -32,8 +53,14 @@ function validate(input, config, current = null) {
if (typeof a.budget_per_run_usd !== 'number' || !Number.isFinite(a.budget_per_run_usd) || a.budget_per_run_usd <= 0 || a.budget_per_run_usd > config.allowance.perTaskUsd) return 'budget_per_run_usd exceeds the per-task allowance or is invalid.';
if (typeof a.budget_per_run_usd !== 'number' || !Number.isFinite(a.budget_per_run_usd) || a.budget_per_run_usd <= 0 || a.budget_per_run_usd > config.allowance.perTaskUsd) return 'budget_per_run_usd exceeds the per-task allowance or is invalid.';
if (!Number.isInteger(a.daily_runs_max) || a.daily_runs_max < 1 || a.daily_runs_max > 24) return 'daily_runs_max must be 1–24.';
if (!Number.isInteger(a.daily_runs_max) || a.daily_runs_max < 1 || a.daily_runs_max > 24) return 'daily_runs_max must be 1–24.';
if (!SCHEDULES.includes(a.schedule)) return 'schedule is invalid.';
if (!SCHEDULES.includes(a.schedule)) return 'schedule is invalid.';
- if (['daily', 'weekly'].includes(a.schedule) && !/^([01]\d|2[0-3]):[0-5]\d$/.test(a.schedule_at || '')) return 'schedule_at must be HH:MM UTC.';
+ if (['daily', 'weekly'].includes(a.schedule) && !HHMM.test(a.schedule_at || '')) return 'schedule_at must be HH:MM UTC.';
if (a.schedule === 'weekly' && (!Number.isInteger(a.schedule_dow) || a.schedule_dow < 0 || a.schedule_dow > 6)) return 'schedule_dow must be 0–6.';
if (a.schedule === 'weekly' && (!Number.isInteger(a.schedule_dow) || a.schedule_dow < 0 || a.schedule_dow > 6)) return 'schedule_dow must be 0–6.';
+ if ((a.quiet_from == null) !== (a.quiet_to == null)) return 'quiet_from and quiet_to go together: set both or neither.';
+ if (a.quiet_from != null) {
+ if (!HHMM.test(a.quiet_from) || !HHMM.test(a.quiet_to)) return 'quiet_from and quiet_to must be HH:MM UTC.';
+ if (a.quiet_from === a.quiet_to) return 'Quiet hours must not start and end at the same time.';
+ if (['daily', 'weekly'].includes(a.schedule) && inQuiet(a, `2000-01-01T${a.schedule_at}:00.000Z`)) return 'schedule_at falls inside the quiet hours.';
+ }
if (!['active', 'paused'].includes(a.status)) return 'status is invalid.';
if (!['active', 'paused'].includes(a.status)) return 'status is invalid.';
return null;
return null;
}
}
@@ -43,7 +70,7 @@ const list = (s, owner) => s.db.many('SELECT * FROM actor_agents WHERE owner = $
@@ -43,7 +70,7 @@ const list = (s, owner) => s.db.many('SELECT * FROM actor_agents WHERE owner = $
const memory = (s, owner, id) => s.db.many('SELECT * FROM actor_agent_memory WHERE owner = $1 AND agent_id = $2 ORDER BY created_order DESC', [owner, id]);
const memory = (s, owner, id) => s.db.many('SELECT * FROM actor_agent_memory WHERE owner = $1 AND agent_id = $2 ORDER BY created_order DESC', [owner, id]);
async function create(s, config, owner, input) {
async function create(s, config, owner, input) {
- const body = { mode: 'balanced', budget_per_run_usd: config.allowance.perTaskUsd, daily_runs_max: 1, schedule: 'off', schedule_at: null, schedule_dow: null, status: 'active', ...input };
+ const body = { mode: 'balanced', budget_per_run_usd: config.allowance.perTaskUsd, daily_runs_max: 1, schedule: 'off', schedule_at: null, schedule_dow: null, quiet_from: null, quiet_to: null, status: 'active', ...input };
const invalid = validate(body, config);
const invalid = validate(body, config);
if (invalid) return { error: invalid };
if (invalid) return { error: invalid };
return s.tx(async () => {
return s.tx(async () => {
@@ -54,9 +81,9 @@ async function create(s, config, owner, input) {
@@ -54,9 +81,9 @@ async function create(s, config, owner, input) {
const count = Number(await s.db.value('SELECT count(*) FROM actor_agents WHERE owner = $1', [owner]));
const count = Number(await s.db.value('SELECT count(*) FROM actor_agents WHERE owner = $1', [owner]));
if (count >= config.agents.maxPerOwner) return { limit: true };
if (count >= config.agents.maxPerOwner) return { limit: true };
const now = s.iso();
const now = s.iso();
- const row = await s.db.maybe(`INSERT INTO actor_agents (id, owner, name, instructions, mode, budget_per_run_usd, daily_runs_max, schedule, schedule_at, schedule_dow, next_run_at, status, created_at, updated_at)
- VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$13) RETURNING *`,
- [s.newId('agt'), owner, body.name, body.instructions, body.mode, body.budget_per_run_usd, body.daily_runs_max, body.schedule, body.schedule_at, body.schedule_dow, nextRun(body, now), body.status, now]);
+ const row = await s.db.maybe(`INSERT INTO actor_agents (id, owner, name, instructions, mode, budget_per_run_usd, daily_runs_max, schedule, schedule_at, schedule_dow, quiet_from, quiet_to, next_run_at, status, created_at, updated_at)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$15) RETURNING *`,
+ [s.newId('agt'), owner, body.name, body.instructions, body.mode, body.budget_per_run_usd, body.daily_runs_max, body.schedule, body.schedule_at, body.schedule_dow, body.quiet_from, body.quiet_to, nextRun(body, now), body.status, now]);
return { row };
return { row };
});
});
}
}
@@ -67,7 +94,7 @@ async function update(s, config, owner, id, patch) {
@@ -67,7 +94,7 @@ async function update(s, config, owner, id, patch) {
const invalid = validate(patch, config, row);
const invalid = validate(patch, config, row);
if (invalid) return { error: invalid };
if (invalid) return { error: invalid };
const a = { ...row, ...patch };
const a = { ...row, ...patch };
- const at = ['schedule', 'schedule_at', 'schedule_dow'].some((k) => Object.hasOwn(patch, k)) ? nextRun(a, s.iso()) : row.next_run_at;
+ const at = ['schedule', 'schedule_at', 'schedule_dow', 'quiet_from', 'quiet_to'].some((k) => Object.hasOwn(patch, k)) ? nextRun(a, s.iso()) : row.next_run_at;
const cols = Object.keys(patch);
const cols = Object.keys(patch);
const args = cols.map((k) => patch[k]);
const args = cols.map((k) => patch[k]);
args.push(at, s.iso(), id, owner);
args.push(at, s.iso(), id, owner);
@@ -133,4 +160,4 @@ async function tick(ctx) {
@@ -133,4 +160,4 @@ async function tick(ctx) {
}
}
}
}
-module.exports = { ID, MEM_ID, SCHEDULES, get, list, memory, create, update, addMemory, prompt, run, tick, nextRun, validate };
+module.exports = { ID, MEM_ID, SCHEDULES, get, list, memory, create, update, addMemory, prompt, run, tick, nextRun, inQuiet, validate };
@@ -56,15 +56,19 @@ function createAgentPages(ctx) {
@@ -56,15 +56,19 @@ function createAgentPages(ctx) {
<label>Schedule <select name="schedule">${personal.SCHEDULES.map((x) => html`<option value="${x}"${(a.schedule || 'off') === x ? raw(' selected') : ''}>${SCHEDULE_LABELS[x]}</option>`)}</select></label>
<label>Schedule <select name="schedule">${personal.SCHEDULES.map((x) => html`<option value="${x}"${(a.schedule || 'off') === x ? raw(' selected') : ''}>${SCHEDULE_LABELS[x]}</option>`)}</select></label>
<label>At (UTC) <input name="schedule_at" type="time" value="${a.schedule_at || ''}"><span class="pa-hint">For daily and weekly.</span></label>
<label>At (UTC) <input name="schedule_at" type="time" value="${a.schedule_at || ''}"><span class="pa-hint">For daily and weekly.</span></label>
<label>On <select name="schedule_dow"><option value=""${a.schedule_dow == null ? raw(' selected') : ''}>Any day</option>${DAYS.map((d, i) => html`<option value="${String(i)}"${a.schedule_dow === i ? raw(' selected') : ''}>${d}</option>`)}</select><span class="pa-hint">For weekly.</span></label>
<label>On <select name="schedule_dow"><option value=""${a.schedule_dow == null ? raw(' selected') : ''}>Any day</option>${DAYS.map((d, i) => html`<option value="${String(i)}"${a.schedule_dow === i ? raw(' selected') : ''}>${d}</option>`)}</select><span class="pa-hint">For weekly.</span></label>
+<label>Quiet from (UTC) <input name="quiet_from" type="time" value="${a.quiet_from || ''}"><span class="pa-hint">No scheduled run starts in quiet hours; Run now still works.</span></label>
+<label>Quiet until (UTC) <input name="quiet_to" type="time" value="${a.quiet_to || ''}"><span class="pa-hint">It may cross midnight, like 22:00 to 07:00.</span></label>
<label>Status <select name="status"><option value="active"${a.status !== 'paused' ? raw(' selected') : ''}>Active</option><option value="paused"${a.status === 'paused' ? raw(' selected') : ''}>Paused</option></select></label>
<label>Status <select name="status"><option value="active"${a.status !== 'paused' ? raw(' selected') : ''}>Active</option><option value="paused"${a.status === 'paused' ? raw(' selected') : ''}>Paused</option></select></label>
</div>`;
</div>`;
}
}
- const when = (a) => a.schedule === 'off' ? 'Runs when you run it' : a.schedule === 'hourly' ? 'Every hour'
- : a.schedule === 'daily' ? `Every day at ${a.schedule_at} UTC` : `Every ${DAYS[a.schedule_dow]} at ${a.schedule_at} UTC`;
+ const when = (a) => a.schedule === 'off' ? 'Runs when you run it' : (a.schedule === 'hourly' ? 'Every hour'
+ : a.schedule === 'daily' ? `Every day at ${a.schedule_at} UTC` : `Every ${DAYS[a.schedule_dow]} at ${a.schedule_at} UTC`)
+ + (a.quiet_from ? `, quiet ${a.quiet_from}–${a.quiet_to} UTC` : '');
function fields(body) {
function fields(body) {
return { name: body.name, instructions: body.instructions, mode: body.mode,
return { name: body.name, instructions: body.instructions, mode: body.mode,
budget_per_run_usd: Number(body.budget_per_run_usd), daily_runs_max: Number(body.daily_runs_max), schedule: body.schedule,
budget_per_run_usd: Number(body.budget_per_run_usd), daily_runs_max: Number(body.daily_runs_max), schedule: body.schedule,
- schedule_at: body.schedule_at || null, schedule_dow: body.schedule_dow === '' ? null : Number(body.schedule_dow), status: body.status };
+ schedule_at: body.schedule_at || null, schedule_dow: body.schedule_dow === '' ? null : Number(body.schedule_dow),
+ quiet_from: body.quiet_from || null, quiet_to: body.quiet_to || null, status: body.status };
}
}
r.get('/me/agents', async (req, res) => {
r.get('/me/agents', async (req, res) => {
@@ -48,6 +48,17 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
@@ -48,6 +48,17 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
const filled = await t.get('/me/agents', { as: a, form: { ...base, instructions: 'Find jobs: a junior video editor, remote.' }, headers: SAME });
const filled = await t.get('/me/agents', { as: a, form: { ...base, instructions: 'Find jobs: a junior video editor, remote.' }, headers: SAME });
assert.strictEqual(filled.status, 303);
assert.strictEqual(filled.status, 303);
});
});
+ await check('quiet hours from the form: saved, shown, and a time inside them refused', async () => {
+ assert.match((await t.get('/me/agents', { as: a })).text, /name="quiet_from" type="time"/);
+ const base = { name: 'Hourly', instructions: 'Check the page.', mode: 'balanced', budget_per_run_usd: '0.02', daily_runs_max: '3', schedule_dow: '', status: 'active' };
+ const made = await t.get('/me/agents', { as: a, form: { ...base, schedule: 'hourly', schedule_at: '', quiet_from: '22:00', quiet_to: '07:00' }, headers: SAME });
+ assert.strictEqual(made.status, 303);
+ const own = await t.get(made.headers.get('location'), { as: a });
+ assert.match(own.text, /Every hour, quiet 22:00–07:00 UTC/);
+ const inside = await t.get('/me/agents', { as: a, form: { ...base, schedule: 'daily', schedule_at: '23:00', quiet_from: '22:00', quiet_to: '07:00' }, headers: SAME });
+ assert.strictEqual(inside.status, 422);
+ assert.match(inside.text, /inside the quiet hours/);
+ });
} finally { await t.close(); }
} finally { await t.close(); }
done();
done();
})();
})();
@@ -10,6 +10,26 @@ const personal = require('../server/agents/personal');
@@ -10,6 +10,26 @@ const personal = require('../server/agents/personal');
assert.strictEqual(personal.nextRun({ schedule: 'daily', schedule_at: '09:00' }, at), '2026-10-12T09:00:00.000Z');
assert.strictEqual(personal.nextRun({ schedule: 'daily', schedule_at: '09:00' }, at), '2026-10-12T09:00:00.000Z');
assert.strictEqual(personal.nextRun({ schedule: 'weekly', schedule_at: '09:00', schedule_dow: 0 }, at), '2026-10-18T09:00:00.000Z');
assert.strictEqual(personal.nextRun({ schedule: 'weekly', schedule_at: '09:00', schedule_dow: 0 }, at), '2026-10-18T09:00:00.000Z');
});
});
+ await check('quiet hours: an hourly run waits for their end, across midnight too', async () => {
+ const night = { schedule: 'hourly', quiet_from: '22:00', quiet_to: '07:00' };
+ assert.strictEqual(personal.nextRun(night, '2026-10-11T20:30:00.000Z'), '2026-10-11T21:30:00.000Z');
+ assert.strictEqual(personal.nextRun(night, '2026-10-11T21:30:00.000Z'), '2026-10-12T07:00:00.000Z');
+ assert.strictEqual(personal.nextRun(night, '2026-10-12T02:15:00.000Z'), '2026-10-12T07:00:00.000Z');
+ assert.strictEqual(personal.nextRun(night, '2026-10-12T07:00:00.000Z'), '2026-10-12T08:00:00.000Z');
+ const lunch = { schedule: 'hourly', quiet_from: '13:00', quiet_to: '14:00' };
+ assert.strictEqual(personal.nextRun(lunch, '2026-10-11T12:30:00.000Z'), '2026-10-11T14:00:00.000Z');
+ assert.ok(personal.inQuiet(night, '2026-10-11T23:59:00.000Z') && !personal.inQuiet(night, '2026-10-11T07:00:00.000Z'));
+ });
+ await check('quiet hours: both or neither, HH:MM, not empty, and a daily time cannot sit inside them', async () => {
+ const config = { allowance: { perTaskUsd: 0.05 } };
+ const ok = { name: 'A', instructions: 'B', mode: 'balanced', budget_per_run_usd: 0.01, daily_runs_max: 1, schedule: 'daily', schedule_at: '09:00', schedule_dow: null, status: 'active' };
+ assert.strictEqual(personal.validate({ ...ok, quiet_from: '22:00', quiet_to: '07:00' }, config), null);
+ assert.match(personal.validate({ ...ok, quiet_from: '22:00' }, config), /set both or neither/);
+ assert.match(personal.validate({ ...ok, quiet_from: '22:00', quiet_to: '7am' }, config), /HH:MM/);
+ assert.match(personal.validate({ ...ok, quiet_from: '22:00', quiet_to: '22:00' }, config), /same time/);
+ assert.match(personal.validate({ ...ok, schedule_at: '23:30', quiet_from: '22:00', quiet_to: '07:00' }, config), /inside the quiet hours/);
+ assert.strictEqual(personal.validate({ ...ok, schedule: 'off', schedule_at: null, quiet_from: '22:00', quiet_to: '07:00' }, config), null);
+ });
let now = Date.parse('2026-10-11T12:30:00.000Z');
let now = Date.parse('2026-10-11T12:30:00.000Z');
const submitted = [];
const submitted = [];
const t = await boot({ now: () => now, adapters: { run: {}, checkers: {} } });
const t = await boot({ now: () => now, adapters: { run: {}, checkers: {} } });
@@ -34,6 +54,13 @@ const personal = require('../server/agents/personal');
@@ -34,6 +54,13 @@ const personal = require('../server/agents/personal');
assert.match(after.last_skip_reason, /actor.agent.limit/);
assert.match(after.last_skip_reason, /actor.agent.limit/);
assert.ok(after.next_run_at > new Date(now).toISOString());
assert.ok(after.next_run_at > new Date(now).toISOString());
});
});
+ await check('a scheduled agent with quiet hours is next due when they end', async () => {
+ const quiet = (await personal.create(t.ctx.s, t.config, owner, { ...base, name: 'Quiet', quiet_from: '13:00', quiet_to: '20:00' })).row;
+ assert.strictEqual(quiet.quiet_from, '13:00');
+ assert.strictEqual(quiet.next_run_at, '2026-10-11T20:00:00.000Z');
+ const moved = (await personal.update(t.ctx.s, t.config, owner, quiet.id, { quiet_from: null, quiet_to: null })).row;
+ assert.strictEqual(moved.next_run_at, new Date(now + 3_600_000).toISOString(), 'clearing them reschedules');
+ });
} finally { await t.close(); }
} finally { await t.close(); }
done();
done();
})();
})();