This commit is contained in:
seb
2026-10-17 02:59:37 +02:00
parent 59395f95d8
commit a194cf6b8b
12 changed files with 730 additions and 0 deletions

View File

@@ -183,6 +183,11 @@ import modelvfsRead from './store/modelvfs/read.js';
import modelvfsList from './store/modelvfs/list.js';
import modelvfsDelete from './store/modelvfs/delete.js';
import modelvfsReset from './store/modelvfs/reset.js';
import subagentscheduleCreate from './store/subagentschedule/create.js';
import subagentscheduleGetById from './store/subagentschedule/getById.js';
import subagentscheduleList from './store/subagentschedule/list.js';
import subagentscheduleUpdate from './store/subagentschedule/update.js';
import subagentscheduleDelete from './store/subagentschedule/delete.js';
import accountCreate from './store/account/create.js';
import accountList from './store/account/list.js';
import accountDelete from './store/account/delete.js';
@@ -584,6 +589,14 @@ export default class LibDashStore {
add: modelmessageAdd(provider),
};
this.subagentschedule = {
create: subagentscheduleCreate(provider),
getById: subagentscheduleGetById(provider),
list: subagentscheduleList(provider),
update: subagentscheduleUpdate(provider),
delete: subagentscheduleDelete(provider),
};
this.labeltemplate = {
create: labeltemplateCreate(provider),
getById: labeltemplateGetById(provider),

View File

@@ -0,0 +1,88 @@
export const SUBAGENTSCHEDULE_COLUMNS = `
id,
company_id,
[desc],
prompt,
skills,
lastagentnote,
max_itr,
cron,
created_at,
lastrun_at
`;
export const LIST_BY_COMPANY = `
SELECT
${SUBAGENTSCHEDULE_COLUMNS}
FROM subagentschedule
WHERE company_id = @companyId
ORDER BY id ASC
`;
export const GET_BY_COMPANY_AND_ID = `
SELECT
${SUBAGENTSCHEDULE_COLUMNS}
FROM subagentschedule
WHERE id = @id AND company_id = @companyId
`;
export const CREATE = `
INSERT INTO subagentschedule (
company_id,
[desc],
prompt,
skills,
lastagentnote,
max_itr,
cron
)
OUTPUT
INSERTED.id,
INSERTED.company_id,
INSERTED.[desc],
INSERTED.prompt,
INSERTED.skills,
INSERTED.lastagentnote,
INSERTED.max_itr,
INSERTED.cron,
INSERTED.created_at,
INSERTED.lastrun_at
VALUES (
@companyId,
@desc,
@prompt,
@skills,
@lastagentnote,
@maxItr,
@cron
)
`;
export const UPDATE = `
UPDATE subagentschedule
SET
[desc] = @desc,
prompt = @prompt,
skills = @skills,
lastagentnote = @lastagentnote,
max_itr = @maxItr,
cron = @cron,
lastrun_at = @lastrunAt
OUTPUT
INSERTED.id,
INSERTED.company_id,
INSERTED.[desc],
INSERTED.prompt,
INSERTED.skills,
INSERTED.lastagentnote,
INSERTED.max_itr,
INSERTED.cron,
INSERTED.created_at,
INSERTED.lastrun_at
WHERE id = @id AND company_id = @companyId
`;
export const DELETE_BY_COMPANY_AND_ID = `
DELETE FROM subagentschedule
WHERE id = @id AND company_id = @companyId
`;

View File

@@ -0,0 +1,12 @@
import * as source from '../../mssql/queries/subagentschedule.js';
import { translateQueries } from './translate.js';
const queries = translateQueries(source, 'subagentschedule');
export const {
CREATE,
DELETE_BY_COMPANY_AND_ID,
GET_BY_COMPANY_AND_ID,
LIST_BY_COMPANY,
SUBAGENTSCHEDULE_COLUMNS,
UPDATE,
} = queries;

View File

@@ -0,0 +1,118 @@
import { CREATE as MSSQL_CREATE } from '../../providers/mssql/queries/subagentschedule.js';
import { CREATE as SQLITE_CREATE } from '../../providers/sqlite3/queries/subagentschedule.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
import { isValidCron } from './cron.js';
function shape(row) {
return {
id: row.id,
company_id: row.company_id ?? row.companyId,
desc: row.desc ?? null,
prompt: row.prompt,
skills: row.skills ?? null,
lastagentnote: row.lastagentnote ?? null,
max_itr: row.max_itr,
cron: row.cron,
created_at: row.created_at,
lastrun_at: row.lastrun_at ?? null,
};
}
function optionalString(value) {
if (value == null || value === '') return null;
const s = String(value).trim();
return s || null;
}
export default function create(provider) {
const access = resolveSelectedCompanyAccess(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const createSql = isSqlite ? SQLITE_CREATE : MSSQL_CREATE;
/**
* Creates a new subagent schedule for the user's selected company.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {object} input - Schedule input.
* @param {string} input.prompt - The task prompt for the subagent.
* @param {string} input.cron - Standard 5-field cron timetable string.
* @param {string} [input.desc] - Optional schedule description.
* @param {string|Array<string>} [input.skills] - Optional skill names or JSON string.
* @param {string} [input.lastagentnote] - Optional initial agent note.
* @param {number} [input.max_itr=15] - Optional max iterations per run.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{id: number, company_id: number, desc: string|null, prompt: string, skills: string|null, lastagentnote: string|null, max_itr: number, cron: string, created_at: string, lastrun_at: string|null}>}
* @throws {Error} If prompt is missing, cron is invalid, or max_itr is invalid.
*
* @example
* const schedule = await store.subagentschedule.create(googleAuthId, {
* desc: 'Nightly inventory audit',
* prompt: 'Check stock discrepancies across all warehouses and summarize.',
* cron: '0 2 * * *',
* skills: ['stock', 'reporting'],
* max_itr: 20,
* });
*/
return async (googleAuthId, input = {}, tx) => {
const prompt = typeof input.prompt === 'string' ? input.prompt.trim() : '';
if (!prompt) {
throw new Error('Prompt required');
}
const cron = typeof input.cron === 'string' ? input.cron.trim() : '';
if (!cron) {
throw new Error('Cron expression required');
}
if (!isValidCron(cron)) {
throw new Error('Invalid cron expression');
}
let maxItr = 15;
if (input.max_itr !== undefined && input.max_itr !== null) {
const parsed = Number(input.max_itr);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new Error('Invalid max iterations: must be a positive integer');
}
maxItr = parsed;
}
let skills = null;
if (Array.isArray(input.skills)) {
skills = JSON.stringify(input.skills);
} else if (input.skills != null) {
skills = optionalString(input.skills);
}
const desc = optionalString(input.desc);
const lastagentnote = optionalString(input.lastagentnote);
const runner = makeRunner(provider, tx);
const { companyId } = await access(googleAuthId, runner);
const result = await runner.query(createSql, {
companyId,
desc,
prompt,
skills,
lastagentnote,
maxItr,
cron,
});
const row = result.rows[0] ?? {
id: result.insertId,
company_id: companyId,
desc,
prompt,
skills,
lastagentnote,
max_itr: maxItr,
cron,
created_at: new Date().toISOString(),
lastrun_at: null,
};
return shape(row);
};
}

View File

@@ -0,0 +1,99 @@
const CRON_NICKNAMES = new Set([
'@yearly',
'@annually',
'@monthly',
'@weekly',
'@daily',
'@midnight',
'@hourly',
]);
const MONTH_NAMES = {
jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6,
jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12,
};
const DAY_NAMES = {
sun: 0, mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6,
};
const FIELD_RANGES = [
{ name: 'minute', min: 0, max: 59 },
{ name: 'hour', min: 0, max: 23 },
{ name: 'day of month', min: 1, max: 31 },
{ name: 'month', min: 1, max: 12, names: MONTH_NAMES },
{ name: 'day of week', min: 0, max: 7, names: DAY_NAMES },
];
function parseCronValue(val, min, max, names) {
if (names && typeof val === 'string') {
const lower = val.toLowerCase();
if (lower in names) return names[lower];
}
if (!/^\d+$/.test(val)) return null;
const num = parseInt(val, 10);
if (num < min || num > max) return null;
return num;
}
function validateField(part, min, max, names) {
if (!part) return false;
const items = part.split(',');
if (items.length === 0) return false;
for (const item of items) {
if (!item) return false;
const slashParts = item.split('/');
if (slashParts.length > 2) return false;
if (slashParts.length === 2) {
const step = slashParts[1];
if (!/^\d+$/.test(step)) return false;
const stepNum = parseInt(step, 10);
if (stepNum <= 0 || stepNum > max) return false;
}
const base = slashParts[0];
if (base === '*' || base === '?') {
continue;
}
const dashParts = base.split('-');
if (dashParts.length > 2) return false;
if (dashParts.length === 2) {
const start = parseCronValue(dashParts[0], min, max, names);
const end = parseCronValue(dashParts[1], min, max, names);
if (start === null || end === null || start > end) return false;
continue;
}
const val = parseCronValue(base, min, max, names);
if (val === null) return false;
}
return true;
}
/**
* Validates a standard 5-part cron timetable string or standard nickname.
*
* @param {string} cron - The cron expression to validate.
* @returns {boolean} True if the cron expression is syntactically valid.
*/
export function isValidCron(cron) {
if (typeof cron !== 'string') return false;
const trimmed = cron.trim();
if (!trimmed) return false;
if (CRON_NICKNAMES.has(trimmed.toLowerCase())) return true;
const parts = trimmed.split(/\s+/);
if (parts.length !== 5) return false;
for (let i = 0; i < 5; i++) {
const part = parts[i];
const { min, max, names } = FIELD_RANGES[i];
if (!validateField(part, min, max, names)) {
return false;
}
}
return true;
}

View File

@@ -0,0 +1,34 @@
import { DELETE_BY_COMPANY_AND_ID as MSSQL_DELETE } from '../../providers/mssql/queries/subagentschedule.js';
import { DELETE_BY_COMPANY_AND_ID as SQLITE_DELETE } from '../../providers/sqlite3/queries/subagentschedule.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
export default function remove(provider) {
const access = resolveSelectedCompanyAccess(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const deleteSql = isSqlite ? SQLITE_DELETE : MSSQL_DELETE;
/**
* Deletes a subagent schedule by id for the user's selected company.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} id - The schedule ID to delete.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<void>}
* @throws {Error} If schedule not found.
*/
return async (googleAuthId, id, tx) => {
const numericId = Number(id);
if (!Number.isInteger(numericId) || numericId <= 0) {
throw new Error('Subagent schedule not found');
}
const runner = makeRunner(provider, tx);
const { companyId } = await access(googleAuthId, runner);
const result = await runner.query(deleteSql, { id: numericId, companyId });
if (result.rowCount === 0) {
throw new Error('Subagent schedule not found');
}
};
}

View File

@@ -0,0 +1,47 @@
import { GET_BY_COMPANY_AND_ID as MSSQL_GET_BY_ID } from '../../providers/mssql/queries/subagentschedule.js';
import { GET_BY_COMPANY_AND_ID as SQLITE_GET_BY_ID } from '../../providers/sqlite3/queries/subagentschedule.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
function shape(row) {
return {
id: row.id,
company_id: row.company_id ?? row.companyId,
desc: row.desc ?? null,
prompt: row.prompt,
skills: row.skills ?? null,
lastagentnote: row.lastagentnote ?? null,
max_itr: row.max_itr,
cron: row.cron,
created_at: row.created_at,
lastrun_at: row.lastrun_at ?? null,
};
}
export default function getById(provider) {
const access = resolveSelectedCompanyAccess(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const getSql = isSqlite ? SQLITE_GET_BY_ID : MSSQL_GET_BY_ID;
/**
* Retrieves a single subagent schedule by id for the user's selected company.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} id - The schedule ID.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{id: number, company_id: number, desc: string|null, prompt: string, skills: string|null, lastagentnote: string|null, max_itr: number, cron: string, created_at: string, lastrun_at: string|null}|null>}
*/
return async (googleAuthId, id, tx) => {
const numericId = Number(id);
if (!Number.isInteger(numericId) || numericId <= 0) {
return null;
}
const runner = makeRunner(provider, tx);
const { companyId } = await access(googleAuthId, runner);
const result = await runner.query(getSql, { id: numericId, companyId });
const row = result.rows[0];
return row ? shape(row) : null;
};
}

View File

@@ -0,0 +1,40 @@
import { LIST_BY_COMPANY as MSSQL_LIST } from '../../providers/mssql/queries/subagentschedule.js';
import { LIST_BY_COMPANY as SQLITE_LIST } from '../../providers/sqlite3/queries/subagentschedule.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
function shape(row) {
return {
id: row.id,
company_id: row.company_id ?? row.companyId,
desc: row.desc ?? null,
prompt: row.prompt,
skills: row.skills ?? null,
lastagentnote: row.lastagentnote ?? null,
max_itr: row.max_itr,
cron: row.cron,
created_at: row.created_at,
lastrun_at: row.lastrun_at ?? null,
};
}
export default function list(provider) {
const access = resolveSelectedCompanyAccess(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const listSql = isSqlite ? SQLITE_LIST : MSSQL_LIST;
/**
* Lists all subagent schedules for the user's selected company.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<Array<{id: number, company_id: number, desc: string|null, prompt: string, skills: string|null, lastagentnote: string|null, max_itr: number, cron: string, created_at: string, lastrun_at: string|null}>>}
*/
return async (googleAuthId, tx) => {
const runner = makeRunner(provider, tx);
const { companyId } = await access(googleAuthId, runner);
const result = await runner.query(listSql, { companyId });
return result.rows.map(shape);
};
}

View File

@@ -0,0 +1,142 @@
import {
GET_BY_COMPANY_AND_ID as MSSQL_GET_BY_ID,
UPDATE as MSSQL_UPDATE,
} from '../../providers/mssql/queries/subagentschedule.js';
import {
GET_BY_COMPANY_AND_ID as SQLITE_GET_BY_ID,
UPDATE as SQLITE_UPDATE,
} from '../../providers/sqlite3/queries/subagentschedule.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js';
import { isValidCron } from './cron.js';
function shape(row) {
return {
id: row.id,
company_id: row.company_id ?? row.companyId,
desc: row.desc ?? null,
prompt: row.prompt,
skills: row.skills ?? null,
lastagentnote: row.lastagentnote ?? null,
max_itr: row.max_itr,
cron: row.cron,
created_at: row.created_at,
lastrun_at: row.lastrun_at ?? null,
};
}
function optionalString(value) {
if (value == null || value === '') return null;
const s = String(value).trim();
return s || null;
}
export default function update(provider) {
const access = resolveSelectedCompanyAccess(provider);
const isSqlite = provider?.dialect === 'sqlite3';
const getSql = isSqlite ? SQLITE_GET_BY_ID : MSSQL_GET_BY_ID;
const updateSql = isSqlite ? SQLITE_UPDATE : MSSQL_UPDATE;
/**
* Updates an existing subagent schedule for the user's selected company.
*
* @param {number} googleAuthId - The id of the Google auth user.
* @param {number} id - The schedule ID to update.
* @param {object} input - Update payload.
* @param {string} [input.prompt] - Optional updated task prompt.
* @param {string} [input.cron] - Optional updated cron expression.
* @param {string|null} [input.desc] - Optional updated description.
* @param {string|Array<string>|null} [input.skills] - Optional updated skills.
* @param {string|null} [input.lastagentnote] - Optional updated agent note.
* @param {number} [input.max_itr] - Optional updated max iterations.
* @param {string|null} [input.lastrun_at] - Optional updated last run timestamp.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{id: number, company_id: number, desc: string|null, prompt: string, skills: string|null, lastagentnote: string|null, max_itr: number, cron: string, created_at: string, lastrun_at: string|null}>}
* @throws {Error} If schedule not found, prompt is empty, cron is invalid, or max_itr is invalid.
*/
return async (googleAuthId, id, input = {}, tx) => {
const numericId = Number(id);
if (!Number.isInteger(numericId) || numericId <= 0) {
throw new Error('Subagent schedule not found');
}
const runner = makeRunner(provider, tx);
const { companyId } = await access(googleAuthId, runner);
const existing = await runner.query(getSql, { id: numericId, companyId });
const current = existing.rows[0];
if (!current) {
throw new Error('Subagent schedule not found');
}
let prompt = current.prompt;
if (input.prompt !== undefined) {
const cleaned = typeof input.prompt === 'string' ? input.prompt.trim() : '';
if (!cleaned) {
throw new Error('Prompt required');
}
prompt = cleaned;
}
let cron = current.cron;
if (input.cron !== undefined) {
const cleaned = typeof input.cron === 'string' ? input.cron.trim() : '';
if (!cleaned) {
throw new Error('Cron expression required');
}
if (!isValidCron(cleaned)) {
throw new Error('Invalid cron expression');
}
cron = cleaned;
}
let maxItr = current.max_itr;
if (input.max_itr !== undefined && input.max_itr !== null) {
const parsed = Number(input.max_itr);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new Error('Invalid max iterations: must be a positive integer');
}
maxItr = parsed;
}
let skills = current.skills;
if (input.skills !== undefined) {
if (Array.isArray(input.skills)) {
skills = JSON.stringify(input.skills);
} else {
skills = optionalString(input.skills);
}
}
const desc = input.desc !== undefined ? optionalString(input.desc) : (current.desc ?? null);
const lastagentnote = input.lastagentnote !== undefined ? optionalString(input.lastagentnote) : (current.lastagentnote ?? null);
const lastrunAt = input.lastrun_at !== undefined ? optionalString(input.lastrun_at) : (current.lastrun_at ?? null);
const result = await runner.query(updateSql, {
id: numericId,
companyId,
desc,
prompt,
skills,
lastagentnote,
maxItr,
cron,
lastrunAt,
});
const row = result.rows[0] ?? {
id: numericId,
company_id: companyId,
desc,
prompt,
skills,
lastagentnote,
max_itr: maxItr,
cron,
created_at: current.created_at,
lastrun_at: lastrunAt,
};
return shape(row);
};
}