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)

OP OpenVibers committed 19 days ago · 2184625c10 · 5 files, +88 −12

Changes

ddiff --git a/migrations/0011_agent_quiet_hours.sql b/migrations/0011_agent_quiet_hours.sql
nnew file mode 100644
iindex 0000000..64a8b16
---- /dev/null
++++ b/migrations/0011_agent_quiet_hours.sql
@@@ -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));
ddiff --git a/server/agents/personal.js b/server/agents/personal.js
iindex 8dc01aa..18e954a 100644
---- a/server/agents/personal.js
++++ b/server/agents/personal.js
@@@ -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 };
ddiff --git a/server/http/agent-pages.js b/server/http/agent-pages.js
iindex 714e810..0fa311b 100644
---- a/server/http/agent-pages.js
++++ b/server/http/agent-pages.js
@@@ -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) => {
ddiff --git a/test/agents-pages.test.js b/test/agents-pages.test.js
iindex 203a76d..e65930f 100644
---- a/test/agents-pages.test.js
++++ b/test/agents-pages.test.js
@@@ -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();
})();
ddiff --git a/test/agents-schedule.test.js b/test/agents-schedule.test.js
iindex ad939f9..ee5d209 100644
---- a/test/agents-schedule.test.js
++++ b/test/agents-schedule.test.js
@@@ -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();
})();