@danieltmn/tamnora 0.13.1 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -0
- package/harness/packages/core/src/config/index.js +2 -0
- package/harness/packages/core/src/hooks/index.js +42 -0
- package/harness/packages/core/src/index.js +3 -0
- package/harness/packages/core/src/plugins/index.js +36 -0
- package/harness/packages/core/src/rules/index.js +9 -63
- package/harness/packages/core/src/schedule/cron.js +124 -0
- package/harness/packages/core/src/storage/sqlite.js +96 -1
- package/harness/packages/core/src/tools/glob.js +64 -2
- package/harness/packages/core/test/cron.test.js +46 -0
- package/harness/packages/core/test/hooks.test.js +29 -0
- package/harness/packages/core/test/plugins.test.js +25 -0
- package/harness/packages/server/public/app.js +193 -3
- package/harness/packages/server/public/index.html +76 -2
- package/harness/packages/server/public/landing/index.html +5 -3
- package/harness/packages/server/public/styles.css +17 -10
- package/harness/packages/server/src/lib/advisor.js +4 -0
- package/harness/packages/server/src/lib/agentRun.js +24 -1
- package/harness/packages/server/src/lib/channels/telegram.js +7 -1
- package/harness/packages/server/src/lib/checkpoints.js +106 -0
- package/harness/packages/server/src/lib/compact.js +4 -0
- package/harness/packages/server/src/lib/hooks.js +45 -0
- package/harness/packages/server/src/lib/plugins.js +76 -0
- package/harness/packages/server/src/lib/scheduler.js +190 -0
- package/harness/packages/server/src/routes/advisor.js +4 -2
- package/harness/packages/server/src/routes/chat.js +32 -7
- package/harness/packages/server/src/routes/schedules.js +124 -0
- package/harness/packages/server/src/routes/sessions.js +18 -2
- package/harness/packages/server/src/server.js +24 -0
- package/harness/packages/server/test/server.test.js +201 -0
- package/harness/tamnora.example.json +2 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,44 @@ Todos los cambios relevantes de este proyecto se documentan en este archivo.
|
|
|
5
5
|
El formato sigue [Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/)
|
|
6
6
|
y el proyecto usa [Versionado Semantico](https://semver.org/lang/es/).
|
|
7
7
|
|
|
8
|
+
## [0.15.0] - 2026-10-10
|
|
9
|
+
|
|
10
|
+
### Agregado
|
|
11
|
+
|
|
12
|
+
- **Fase 4 del roadmap del server (plataforma)**:
|
|
13
|
+
- **Hooks locales**: módulos `.mjs` en `.tamnora/hooks/*.mjs` (o `hooks[]` en
|
|
14
|
+
`tamnora.json`) que observan el ciclo de vida (`run.start/end`,
|
|
15
|
+
`tool.before/after`, `session.compacting/compacted`, `advice`). Aislados de
|
|
16
|
+
errores, con datos redactados, opt-in.
|
|
17
|
+
- **Plugins**: módulos `.mjs` en `.tamnora/plugins/*.mjs` (o `plugins[]`) que
|
|
18
|
+
agregan **tools propias** al agente (`plugin__<plugin>__<tool>`), sujetas a
|
|
19
|
+
permisos (default `ask`).
|
|
20
|
+
- **Asesor con proveedor/modelo propios**: `app.json -> advisor`
|
|
21
|
+
`provider`/`model`; si están vacíos usa los activos.
|
|
22
|
+
|
|
23
|
+
## [0.14.0] - 2026-10-10
|
|
24
|
+
|
|
25
|
+
### Agregado
|
|
26
|
+
|
|
27
|
+
- **Fase 3 del roadmap del server (automatización y seguridad)**:
|
|
28
|
+
- **Tareas programadas**: `every N m|h`, `hourly`, `daily HH:MM` y un subconjunto
|
|
29
|
+
de cron. El scheduler corre un tick cada 30 s y dispara tareas `agent` (turno
|
|
30
|
+
headless que reusa el historial y avisa por los canales) o `shell` (con la
|
|
31
|
+
allowlist de procesos). CRUD en `/api/schedules` y pestaña **Tareas**.
|
|
32
|
+
- **Deshacer (checkpoints/rewind)**: se guarda la versión previa de los archivos
|
|
33
|
+
que `write`/`edit` tocan y `POST /api/sessions/:id/rewind` restaura los
|
|
34
|
+
archivos y borra el turno del historial. Acción **deshacer cambios** en el menú
|
|
35
|
+
de cada mensaje del asistente.
|
|
36
|
+
|
|
37
|
+
### Cambios
|
|
38
|
+
|
|
39
|
+
- Consola: el botón de tema muestra el icono del tema al que se va a cambiar
|
|
40
|
+
(en claro, luna; en oscuro, sol). Igual en la landing.
|
|
41
|
+
- Consola: el fondo del chat pasa a un color sólido; la imagen queda solo en la
|
|
42
|
+
landing.
|
|
43
|
+
- Consola: los botones de los paneles (p. ej. cerrar/refrescar) ya no se recortan
|
|
44
|
+
al angostar: las pestañas se comprimen y hacen scroll.
|
|
45
|
+
|
|
8
46
|
## [0.13.1] - 2026-10-10
|
|
9
47
|
|
|
10
48
|
### Arreglado
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Hooks locales: modulos `.mjs` que observan el ciclo de vida del agente
|
|
2
|
+
// (cargados con `import()` dinamico, opt-in por presencia y aislados de errores).
|
|
3
|
+
// Un hook exporta por default un objeto `{ name?, events: { '<tipo>': fn } }` o
|
|
4
|
+
// una funcion (se llama para todos los eventos).
|
|
5
|
+
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { pathToFileURL } from 'node:url';
|
|
8
|
+
import { globFiles } from '../tools/glob.js';
|
|
9
|
+
|
|
10
|
+
export const DEFAULT_HOOK_PATTERNS = ['.tamnora/hooks/*.mjs'];
|
|
11
|
+
|
|
12
|
+
/** Normaliza el modulo de un hook a `{ id, events }` (o null si no es valido). */
|
|
13
|
+
export function normalizeHookModule(mod, id = '') {
|
|
14
|
+
const value = mod?.default ?? mod;
|
|
15
|
+
if (typeof value === 'function') return { id, events: { '*': value } };
|
|
16
|
+
if (value && typeof value === 'object' && value.events && typeof value.events === 'object') {
|
|
17
|
+
return { id: value.name || id, events: value.events };
|
|
18
|
+
}
|
|
19
|
+
if (value && typeof value === 'object' && typeof value.onEvent === 'function') {
|
|
20
|
+
return { id: value.name || id, events: { '*': value.onEvent } };
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Carga los hooks de `patterns` + `.tamnora/hooks/*.mjs` (relativos a `cwd`).
|
|
27
|
+
* Un modulo roto se ignora.
|
|
28
|
+
*/
|
|
29
|
+
export async function loadHooks(cwd, patterns = []) {
|
|
30
|
+
const list = [...new Set([...(patterns ?? []), ...DEFAULT_HOOK_PATTERNS])];
|
|
31
|
+
const hooks = [];
|
|
32
|
+
for (const rel of await globFiles(cwd, list)) {
|
|
33
|
+
try {
|
|
34
|
+
const mod = await import(pathToFileURL(path.join(cwd, rel)).href);
|
|
35
|
+
const hook = normalizeHookModule(mod, rel);
|
|
36
|
+
if (hook) hooks.push({ ...hook, path: rel });
|
|
37
|
+
} catch {
|
|
38
|
+
/* modulo roto: se ignora */
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return hooks;
|
|
42
|
+
}
|
|
@@ -123,6 +123,9 @@ export {
|
|
|
123
123
|
buildStaticRulesBlock,
|
|
124
124
|
buildPathRulesBlock,
|
|
125
125
|
} from './rules/index.js';
|
|
126
|
+
export { parseScheduleSpec, parseCronFields, nextRun, nextRunFor } from './schedule/cron.js';
|
|
127
|
+
export { loadHooks, normalizeHookModule, DEFAULT_HOOK_PATTERNS } from './hooks/index.js';
|
|
128
|
+
export { loadPlugins, normalizePlugin, DEFAULT_PLUGIN_PATTERNS } from './plugins/index.js';
|
|
126
129
|
export {
|
|
127
130
|
searchMcpRegistry,
|
|
128
131
|
getMcpRegistryServer,
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Plugins locales: modulos `.mjs` que exportan `{ name?, tools: [...] }` para
|
|
2
|
+
// agregar herramientas propias al agente. Se cargan con `import()` dinamico,
|
|
3
|
+
// opt-in por presencia. Cada tool: `{ name, description, parameters?, required?,
|
|
4
|
+
// handler(args, ctx) }`.
|
|
5
|
+
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { pathToFileURL } from 'node:url';
|
|
8
|
+
import { globFiles } from '../tools/glob.js';
|
|
9
|
+
|
|
10
|
+
export const DEFAULT_PLUGIN_PATTERNS = ['.tamnora/plugins/*.mjs'];
|
|
11
|
+
|
|
12
|
+
/** Normaliza el modulo de un plugin a `{ id, tools }` (o null). */
|
|
13
|
+
export function normalizePlugin(mod, id = '') {
|
|
14
|
+
const value = mod?.default ?? mod;
|
|
15
|
+
if (!value || typeof value !== 'object') return null;
|
|
16
|
+
const tools = Array.isArray(value.tools) ? value.tools : [];
|
|
17
|
+
const valid = tools.filter((tool) => tool && typeof tool.name === 'string' && typeof tool.handler === 'function');
|
|
18
|
+
if (!valid.length) return null;
|
|
19
|
+
return { id: value.name || id, tools: valid };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Carga los plugins de `patterns` + `.tamnora/plugins/*.mjs`. */
|
|
23
|
+
export async function loadPlugins(cwd, patterns = []) {
|
|
24
|
+
const list = [...new Set([...(patterns ?? []), ...DEFAULT_PLUGIN_PATTERNS])];
|
|
25
|
+
const plugins = [];
|
|
26
|
+
for (const rel of await globFiles(cwd, list)) {
|
|
27
|
+
try {
|
|
28
|
+
const mod = await import(pathToFileURL(path.join(cwd, rel)).href);
|
|
29
|
+
const plugin = normalizePlugin(mod, rel);
|
|
30
|
+
if (plugin) plugins.push({ ...plugin, path: rel });
|
|
31
|
+
} catch {
|
|
32
|
+
/* plugin roto: se ignora */
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return plugins;
|
|
36
|
+
}
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
// Las que no tienen `paths` son estáticas (van siempre al system prompt); las
|
|
3
3
|
// que sí, se inyectan en runtime cuando una tool toca un archivo que matchea.
|
|
4
4
|
|
|
5
|
-
import { readFile
|
|
5
|
+
import { readFile } from 'node:fs/promises';
|
|
6
6
|
import path from 'node:path';
|
|
7
|
-
import { globToRegExp } from '../tools/glob.js';
|
|
7
|
+
import { globToRegExp, globFiles } from '../tools/glob.js';
|
|
8
8
|
|
|
9
9
|
/** Parsea un archivo de reglas: frontmatter (name, paths/globs) + body. */
|
|
10
10
|
export function parseRuleMarkdown(text, fallbackId = '') {
|
|
@@ -41,74 +41,20 @@ function normalizePaths(paths) {
|
|
|
41
41
|
return [...new Set((paths ?? []).map((p) => String(p).replace(/\\/g, '/').replace(/^\.?\//, '').trim()).filter(Boolean))];
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
function hasGlob(pattern) {
|
|
45
|
-
return /[*?]/.test(pattern);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function fixedPrefix(pattern) {
|
|
49
|
-
if (!hasGlob(pattern)) return pattern;
|
|
50
|
-
const at = pattern.search(/[*?]/);
|
|
51
|
-
const slash = pattern.slice(0, at).lastIndexOf('/');
|
|
52
|
-
return slash >= 0 ? pattern.slice(0, slash) : '';
|
|
53
|
-
}
|
|
54
|
-
|
|
55
44
|
/** Carga y parsea los archivos de reglas que matchean `patterns` (rel a `root`). */
|
|
56
45
|
export async function loadRules(root, patterns = []) {
|
|
57
|
-
const seen = new Set();
|
|
58
46
|
const rules = [];
|
|
59
|
-
for (const
|
|
60
|
-
const normalized = String(pattern).replace(/\\/g, '/');
|
|
61
|
-
const candidates = await matchFiles(root, normalized);
|
|
62
|
-
for (const rel of candidates) {
|
|
63
|
-
if (seen.has(rel)) continue;
|
|
64
|
-
seen.add(rel);
|
|
65
|
-
try {
|
|
66
|
-
const text = await readFile(path.join(root, rel), 'utf8');
|
|
67
|
-
const parsed = parseRuleMarkdown(text, rel);
|
|
68
|
-
if (!parsed.body) continue;
|
|
69
|
-
rules.push({ id: rel, name: parsed.name, paths: parsed.paths, body: parsed.body });
|
|
70
|
-
} catch {
|
|
71
|
-
/* ignore unreadable rule */
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
return rules.sort((a, b) => a.id.localeCompare(b.id, 'es'));
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
async function matchFiles(root, pattern) {
|
|
79
|
-
if (!hasGlob(pattern)) {
|
|
47
|
+
for (const rel of await globFiles(root, patterns)) {
|
|
80
48
|
try {
|
|
81
|
-
|
|
49
|
+
const text = await readFile(path.join(root, rel), 'utf8');
|
|
50
|
+
const parsed = parseRuleMarkdown(text, rel);
|
|
51
|
+
if (!parsed.body) continue;
|
|
52
|
+
rules.push({ id: rel, name: parsed.name, paths: parsed.paths, body: parsed.body });
|
|
82
53
|
} catch {
|
|
83
|
-
|
|
54
|
+
/* ignore unreadable rule */
|
|
84
55
|
}
|
|
85
56
|
}
|
|
86
|
-
|
|
87
|
-
const base = prefix ? path.join(root, prefix) : root;
|
|
88
|
-
const files = [];
|
|
89
|
-
const walk = async (dir, depth) => {
|
|
90
|
-
let entries;
|
|
91
|
-
try {
|
|
92
|
-
entries = await readdir(dir, { withFileTypes: true });
|
|
93
|
-
} catch {
|
|
94
|
-
return;
|
|
95
|
-
}
|
|
96
|
-
for (const entry of entries) {
|
|
97
|
-
if (entry.name === '.git' || entry.name === 'node_modules') continue;
|
|
98
|
-
const full = path.join(dir, entry.name);
|
|
99
|
-
if (entry.isDirectory()) {
|
|
100
|
-
if (depth < 6 && entry.name !== '.git' && entry.name !== 'node_modules') {
|
|
101
|
-
await walk(full, depth + 1);
|
|
102
|
-
}
|
|
103
|
-
} else if (entry.isFile()) {
|
|
104
|
-
const rel = path.relative(root, full).replace(/\\/g, '/');
|
|
105
|
-
files.push(rel);
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
};
|
|
109
|
-
await walk(base, 0);
|
|
110
|
-
const re = globToRegExp(pattern);
|
|
111
|
-
return files.filter((rel) => re.test(rel));
|
|
57
|
+
return rules.sort((a, b) => a.id.localeCompare(b.id, 'es'));
|
|
112
58
|
}
|
|
113
59
|
|
|
114
60
|
/** Reglas sin `paths` (aplican siempre). */
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Especificaciones de programación (puro, sin dependencias): `every N m|h`,
|
|
2
|
+
// `hourly`, `daily HH:MM`, `HH:MM` y un subconjunto de cron (5 campos:
|
|
3
|
+
// minuto hora dia-mes mes dia-semana) con `*`, listas, rangos y pasos.
|
|
4
|
+
|
|
5
|
+
const MINUTE = 60_000;
|
|
6
|
+
|
|
7
|
+
function parseField(text, min, max) {
|
|
8
|
+
const out = new Set();
|
|
9
|
+
for (const part of String(text).split(',')) {
|
|
10
|
+
const token = part.trim();
|
|
11
|
+
if (!token) return null;
|
|
12
|
+
const [rangePart, stepPart] = token.split('/');
|
|
13
|
+
const step = stepPart === undefined ? 1 : Number(stepPart);
|
|
14
|
+
if (!Number.isInteger(step) || step <= 0) return null;
|
|
15
|
+
let start;
|
|
16
|
+
let end;
|
|
17
|
+
if (rangePart === '*') {
|
|
18
|
+
start = min;
|
|
19
|
+
end = max;
|
|
20
|
+
} else {
|
|
21
|
+
const range = rangePart.split('-');
|
|
22
|
+
start = Number(range[0]);
|
|
23
|
+
end = range.length > 1 ? Number(range[1]) : start;
|
|
24
|
+
if (!Number.isInteger(start) || !Number.isInteger(end)) return null;
|
|
25
|
+
}
|
|
26
|
+
if (start < min || end > max || start > end) return null;
|
|
27
|
+
for (let v = start; v <= end; v += step) out.add(v);
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Parsea los 5 campos de cron. Devuelve null si es inválido. */
|
|
33
|
+
export function parseCronFields(parts) {
|
|
34
|
+
const [minute, hour, dom, month, dow] = parts;
|
|
35
|
+
const fields = {
|
|
36
|
+
minute: parseField(minute, 0, 59),
|
|
37
|
+
hour: parseField(hour, 0, 23),
|
|
38
|
+
dom: parseField(dom, 1, 31),
|
|
39
|
+
month: parseField(month, 1, 12),
|
|
40
|
+
dow: parseField(dow, 0, 6),
|
|
41
|
+
};
|
|
42
|
+
if (Object.values(fields).some((set) => !set)) return null;
|
|
43
|
+
fields.domStar = dom.trim() === '*';
|
|
44
|
+
fields.dowStar = dow.trim() === '*';
|
|
45
|
+
return fields;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Normaliza una especificación a `{ type, ... }` o null:
|
|
50
|
+
* - `{ type: 'every', minutes }`
|
|
51
|
+
* - `{ type: 'daily', hour, minute }`
|
|
52
|
+
* - `{ type: 'cron', fields }`
|
|
53
|
+
*/
|
|
54
|
+
export function parseScheduleSpec(spec) {
|
|
55
|
+
const text = String(spec ?? '').trim().toLowerCase();
|
|
56
|
+
if (!text) return null;
|
|
57
|
+
|
|
58
|
+
const every = text.match(/^every\s+(\d+)\s*(m|min|mins|minute|minutes|h|hr|hour|hours)$/);
|
|
59
|
+
if (every) {
|
|
60
|
+
const n = Number(every[1]);
|
|
61
|
+
if (n <= 0) return null;
|
|
62
|
+
return { type: 'every', minutes: every[2].startsWith('h') ? n * 60 : n, spec: text };
|
|
63
|
+
}
|
|
64
|
+
if (/^(every\s+hour|hourly)$/.test(text)) return { type: 'every', minutes: 60, spec: text };
|
|
65
|
+
|
|
66
|
+
const time = text.match(/^(?:daily\s+)?(\d{1,2}):(\d{2})$/);
|
|
67
|
+
if (time) {
|
|
68
|
+
const hour = Number(time[1]);
|
|
69
|
+
const minute = Number(time[2]);
|
|
70
|
+
if (hour > 23 || minute > 59) return null;
|
|
71
|
+
return { type: 'daily', hour, minute, spec: text };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const parts = text.split(/\s+/);
|
|
75
|
+
if (parts.length === 5) {
|
|
76
|
+
const fields = parseCronFields(parts);
|
|
77
|
+
if (!fields) return null;
|
|
78
|
+
return { type: 'cron', fields, spec: text };
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function cronMatches(fields, date) {
|
|
84
|
+
if (!fields.minute.has(date.getMinutes())) return false;
|
|
85
|
+
if (!fields.hour.has(date.getHours())) return false;
|
|
86
|
+
if (!fields.month.has(date.getMonth() + 1)) return false;
|
|
87
|
+
const domMatch = fields.dom.has(date.getDate());
|
|
88
|
+
const dowMatch = fields.dow.has(date.getDay());
|
|
89
|
+
if (fields.domStar && fields.dowStar) return true;
|
|
90
|
+
if (fields.domStar) return dowMatch;
|
|
91
|
+
if (fields.dowStar) return domMatch;
|
|
92
|
+
return domMatch || dowMatch;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Próximo instante (ms) en que corre, estrictamente después de `from`. */
|
|
96
|
+
export function nextRun(parsed, from = Date.now()) {
|
|
97
|
+
if (!parsed) return null;
|
|
98
|
+
if (parsed.type === 'every') {
|
|
99
|
+
const ms = parsed.minutes * MINUTE;
|
|
100
|
+
return Math.floor(from / ms) * ms + ms;
|
|
101
|
+
}
|
|
102
|
+
if (parsed.type === 'daily') {
|
|
103
|
+
const next = new Date(from);
|
|
104
|
+
next.setSeconds(0, 0);
|
|
105
|
+
next.setHours(parsed.hour, parsed.minute, 0, 0);
|
|
106
|
+
if (next.getTime() <= from) next.setDate(next.getDate() + 1);
|
|
107
|
+
return next.getTime();
|
|
108
|
+
}
|
|
109
|
+
const limit = from + 366 * 24 * 60 * MINUTE;
|
|
110
|
+
const cursor = new Date(from);
|
|
111
|
+
cursor.setSeconds(0, 0);
|
|
112
|
+
cursor.setMinutes(cursor.getMinutes() + 1);
|
|
113
|
+
while (cursor.getTime() <= limit) {
|
|
114
|
+
if (cronMatches(parsed.fields, cursor)) return cursor.getTime();
|
|
115
|
+
cursor.setMinutes(cursor.getMinutes() + 1);
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** `parse + nextRun` en un paso. */
|
|
121
|
+
export function nextRunFor(spec, from = Date.now()) {
|
|
122
|
+
const parsed = parseScheduleSpec(spec);
|
|
123
|
+
return parsed ? nextRun(parsed, from) : null;
|
|
124
|
+
}
|
|
@@ -115,6 +115,21 @@ CREATE TABLE IF NOT EXISTS settings (
|
|
|
115
115
|
key TEXT PRIMARY KEY,
|
|
116
116
|
value TEXT
|
|
117
117
|
);
|
|
118
|
+
|
|
119
|
+
CREATE TABLE IF NOT EXISTS schedules (
|
|
120
|
+
id TEXT PRIMARY KEY,
|
|
121
|
+
name TEXT,
|
|
122
|
+
type TEXT,
|
|
123
|
+
spec TEXT,
|
|
124
|
+
payload TEXT,
|
|
125
|
+
cwd TEXT,
|
|
126
|
+
enabled INTEGER,
|
|
127
|
+
last_run INTEGER,
|
|
128
|
+
last_status TEXT,
|
|
129
|
+
next_run INTEGER,
|
|
130
|
+
created_at INTEGER,
|
|
131
|
+
updated_at INTEGER
|
|
132
|
+
);
|
|
118
133
|
`;
|
|
119
134
|
|
|
120
135
|
/** Open (creating if needed) a SQLite database with the harness schema. */
|
|
@@ -533,8 +548,71 @@ export class Store {
|
|
|
533
548
|
this.db.prepare(`DELETE FROM providers WHERE id = ?`).run(id);
|
|
534
549
|
}
|
|
535
550
|
|
|
536
|
-
// ---
|
|
551
|
+
// --- Schedules (tareas programadas) ------------------------------------
|
|
552
|
+
|
|
553
|
+
listSchedules() {
|
|
554
|
+
return this.db
|
|
555
|
+
.prepare(`SELECT * FROM schedules ORDER BY created_at ASC`)
|
|
556
|
+
.all()
|
|
557
|
+
.map(toSchedule);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
getSchedule(id) {
|
|
561
|
+
const row = this.db.prepare(`SELECT * FROM schedules WHERE id = ?`).get(id);
|
|
562
|
+
return row ? toSchedule(row) : null;
|
|
563
|
+
}
|
|
537
564
|
|
|
565
|
+
saveSchedule(schedule) {
|
|
566
|
+
const ts = now();
|
|
567
|
+
const existing = this.getSchedule(schedule.id);
|
|
568
|
+
this.db
|
|
569
|
+
.prepare(
|
|
570
|
+
`INSERT INTO schedules (id, name, type, spec, payload, cwd, enabled, last_run, last_status, next_run, created_at, updated_at)
|
|
571
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
572
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
573
|
+
name = excluded.name, type = excluded.type, spec = excluded.spec, payload = excluded.payload,
|
|
574
|
+
cwd = excluded.cwd, enabled = excluded.enabled, next_run = excluded.next_run, updated_at = excluded.updated_at`
|
|
575
|
+
)
|
|
576
|
+
.run(
|
|
577
|
+
schedule.id,
|
|
578
|
+
schedule.name ?? schedule.id,
|
|
579
|
+
schedule.type ?? 'agent',
|
|
580
|
+
schedule.spec ?? '',
|
|
581
|
+
json(schedule.payload ?? {}),
|
|
582
|
+
schedule.cwd ?? null,
|
|
583
|
+
schedule.enabled === false ? 0 : 1,
|
|
584
|
+
schedule.lastRun ?? existing?.lastRun ?? null,
|
|
585
|
+
schedule.lastStatus ?? existing?.lastStatus ?? null,
|
|
586
|
+
schedule.nextRun ?? null,
|
|
587
|
+
existing?.createdAt ?? ts,
|
|
588
|
+
ts
|
|
589
|
+
);
|
|
590
|
+
return this.getSchedule(schedule.id);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
deleteSchedule(id) {
|
|
594
|
+
this.db.prepare(`DELETE FROM schedules WHERE id = ?`).run(id);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
setScheduleEnabled(id, enabled) {
|
|
598
|
+
this.db.prepare(`UPDATE schedules SET enabled = ?, updated_at = ? WHERE id = ?`).run(enabled ? 1 : 0, now(), id);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
markScheduleRun(id, { status, nextRun } = {}) {
|
|
602
|
+
this.db
|
|
603
|
+
.prepare(`UPDATE schedules SET last_run = ?, last_status = ?, next_run = ?, updated_at = ? WHERE id = ?`)
|
|
604
|
+
.run(now(), status ?? null, nextRun ?? null, now(), id);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/** Tareas habilitadas cuyo `next_run` ya venció. */
|
|
608
|
+
dueSchedules(at = Date.now()) {
|
|
609
|
+
return this.db
|
|
610
|
+
.prepare(`SELECT * FROM schedules WHERE enabled = 1 AND next_run IS NOT NULL AND next_run <= ? ORDER BY next_run ASC`)
|
|
611
|
+
.all(at)
|
|
612
|
+
.map(toSchedule);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// --- Settings (key/value) ----------------------------------------------
|
|
538
616
|
getSetting(key, fallback = null) {
|
|
539
617
|
const row = this.db.prepare(`SELECT value FROM settings WHERE key = ?`).get(key);
|
|
540
618
|
return row ? parseJson(row.value) : fallback;
|
|
@@ -574,6 +652,23 @@ function toAgent(row) {
|
|
|
574
652
|
};
|
|
575
653
|
}
|
|
576
654
|
|
|
655
|
+
function toSchedule(row) {
|
|
656
|
+
return {
|
|
657
|
+
id: row.id,
|
|
658
|
+
name: row.name ?? row.id,
|
|
659
|
+
type: row.type ?? 'agent',
|
|
660
|
+
spec: row.spec ?? '',
|
|
661
|
+
payload: parseJson(row.payload) ?? {},
|
|
662
|
+
cwd: row.cwd ?? null,
|
|
663
|
+
enabled: row.enabled === 0 ? false : true,
|
|
664
|
+
lastRun: row.last_run ?? null,
|
|
665
|
+
lastStatus: row.last_status ?? null,
|
|
666
|
+
nextRun: row.next_run ?? null,
|
|
667
|
+
createdAt: row.created_at,
|
|
668
|
+
updatedAt: row.updated_at,
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
|
|
577
672
|
function toProvider(row) {
|
|
578
673
|
return {
|
|
579
674
|
id: row.id,
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import { readdir } from 'node:fs/promises';
|
|
1
|
+
import { readdir, stat } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
|
|
4
3
|
export const DEFAULT_IGNORES = new Set([
|
|
5
4
|
'node_modules',
|
|
6
5
|
'.git',
|
|
@@ -65,3 +64,66 @@ export async function* walkFiles(root, { ignores = DEFAULT_IGNORES } = {}) {
|
|
|
65
64
|
}
|
|
66
65
|
}
|
|
67
66
|
}
|
|
67
|
+
|
|
68
|
+
function hasGlob(pattern) {
|
|
69
|
+
return /[*?]/.test(pattern);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function fixedPrefix(pattern) {
|
|
73
|
+
if (!hasGlob(pattern)) return pattern;
|
|
74
|
+
const at = pattern.search(/[*?]/);
|
|
75
|
+
const slash = pattern.slice(0, at).lastIndexOf('/');
|
|
76
|
+
return slash >= 0 ? pattern.slice(0, slash) : '';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function matchPattern(root, pattern) {
|
|
80
|
+
if (!hasGlob(pattern)) {
|
|
81
|
+
try {
|
|
82
|
+
return (await stat(path.join(root, pattern))).isFile() ? [pattern] : [];
|
|
83
|
+
} catch {
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const prefix = fixedPrefix(pattern);
|
|
88
|
+
const base = prefix ? path.join(root, prefix) : root;
|
|
89
|
+
const files = [];
|
|
90
|
+
const walk = async (dir, depth) => {
|
|
91
|
+
let entries;
|
|
92
|
+
try {
|
|
93
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
94
|
+
} catch {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
for (const entry of entries) {
|
|
98
|
+
if (entry.name === '.git' || entry.name === 'node_modules') continue;
|
|
99
|
+
const full = path.join(dir, entry.name);
|
|
100
|
+
if (entry.isDirectory()) {
|
|
101
|
+
if (depth < 6) await walk(full, depth + 1);
|
|
102
|
+
} else if (entry.isFile()) {
|
|
103
|
+
files.push(path.relative(root, full).replace(/\\/g, '/'));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
await walk(base, 0);
|
|
108
|
+
const re = globToRegExp(pattern);
|
|
109
|
+
return files.filter((rel) => re.test(rel));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Resuelve una lista de patrones glob a archivos (relativos a `root`). A
|
|
114
|
+
* diferencia de `walkFiles`, entra en `.tamnora` (los patrones suelen apuntar
|
|
115
|
+
* ahi): solo saltea `.git` y `node_modules`.
|
|
116
|
+
*/
|
|
117
|
+
export async function globFiles(root, patterns = []) {
|
|
118
|
+
const seen = new Set();
|
|
119
|
+
const out = [];
|
|
120
|
+
for (const pattern of patterns) {
|
|
121
|
+
const rel = String(pattern).replace(/\\/g, '/');
|
|
122
|
+
for (const file of await matchPattern(root, rel)) {
|
|
123
|
+
if (seen.has(file)) continue;
|
|
124
|
+
seen.add(file);
|
|
125
|
+
out.push(file);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { parseScheduleSpec, nextRun, nextRunFor } from '../src/schedule/cron.js';
|
|
4
|
+
|
|
5
|
+
test('parseScheduleSpec accepts every / hourly / daily / time', () => {
|
|
6
|
+
assert.deepEqual(parseScheduleSpec('every 15 m'), { type: 'every', minutes: 15, spec: 'every 15 m' });
|
|
7
|
+
assert.equal(parseScheduleSpec('every 2 h').minutes, 120);
|
|
8
|
+
assert.equal(parseScheduleSpec('hourly').minutes, 60);
|
|
9
|
+
assert.deepEqual(parseScheduleSpec('daily 09:30'), { type: 'daily', hour: 9, minute: 30, spec: 'daily 09:30' });
|
|
10
|
+
assert.equal(parseScheduleSpec('08:05').type, 'daily');
|
|
11
|
+
assert.equal(parseScheduleSpec('bogus'), null);
|
|
12
|
+
assert.equal(parseScheduleSpec('every 0 m'), null);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('nextRun for every aligns to the interval', () => {
|
|
16
|
+
const from = new Date('2026-01-01T10:07:00').getTime();
|
|
17
|
+
const next = new Date(nextRun(parseScheduleSpec('every 15 m'), from));
|
|
18
|
+
assert.equal(next.getHours(), 10);
|
|
19
|
+
assert.equal(next.getMinutes(), 15);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('nextRun for daily picks today or tomorrow', () => {
|
|
23
|
+
const spec = parseScheduleSpec('daily 09:00');
|
|
24
|
+
const before = new Date('2026-01-01T08:00:00').getTime();
|
|
25
|
+
assert.equal(new Date(nextRun(spec, before)).getDate(), 1);
|
|
26
|
+
const after = new Date('2026-01-01T10:00:00').getTime();
|
|
27
|
+
assert.equal(new Date(nextRun(spec, after)).getDate(), 2);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('nextRun for a cron subset', () => {
|
|
31
|
+
const from = new Date('2026-01-01T10:00:00').getTime();
|
|
32
|
+
const next = new Date(nextRun(parseScheduleSpec('30 14 * * *'), from));
|
|
33
|
+
assert.equal(next.getHours(), 14);
|
|
34
|
+
assert.equal(next.getMinutes(), 30);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('nextRun for weekday cron skips to the right day', () => {
|
|
38
|
+
const from = new Date('2026-01-01T00:00:00').getTime(); // Thursday
|
|
39
|
+
const next = new Date(nextRun(parseScheduleSpec('0 6 * * 1'), from)); // Monday 06:00
|
|
40
|
+
assert.equal(next.getDay(), 1);
|
|
41
|
+
assert.equal(next.getHours(), 6);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('nextRunFor returns null for invalid spec', () => {
|
|
45
|
+
assert.equal(nextRunFor('nope'), null);
|
|
46
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { normalizeHookModule, loadHooks } from '../src/hooks/index.js';
|
|
7
|
+
|
|
8
|
+
test('normalizeHookModule acepta objeto events, funcion y onEvent', () => {
|
|
9
|
+
const byEvents = normalizeHookModule({ default: { name: 'a', events: { 'run.end': () => {} } } }, 'x');
|
|
10
|
+
assert.equal(byEvents.id, 'a');
|
|
11
|
+
assert.deepEqual(Object.keys(byEvents.events), ['run.end']);
|
|
12
|
+
assert.ok(normalizeHookModule({ default: () => {} }, 'f').events['*']);
|
|
13
|
+
assert.ok(normalizeHookModule({ default: { onEvent: () => {} } }, 'o').events['*']);
|
|
14
|
+
assert.equal(normalizeHookModule({ default: {} }, 'n'), null);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('loadHooks carga .tamnora/hooks/*.mjs e ignora lo roto', async () => {
|
|
18
|
+
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-hooks-'));
|
|
19
|
+
await mkdir(path.join(cwd, '.tamnora', 'hooks'), { recursive: true });
|
|
20
|
+
await writeFile(
|
|
21
|
+
path.join(cwd, '.tamnora', 'hooks', 'demo.mjs'),
|
|
22
|
+
"export default { name: 'demo', events: { 'run.end': () => {} } };\n"
|
|
23
|
+
);
|
|
24
|
+
await writeFile(path.join(cwd, '.tamnora', 'hooks', 'roto.mjs'), 'this is not valid javascript {{{\n');
|
|
25
|
+
const hooks = await loadHooks(cwd, []);
|
|
26
|
+
assert.equal(hooks.length, 1);
|
|
27
|
+
assert.equal(hooks[0].id, 'demo');
|
|
28
|
+
assert.deepEqual(Object.keys(hooks[0].events), ['run.end']);
|
|
29
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { normalizePlugin, loadPlugins } from '../src/plugins/index.js';
|
|
7
|
+
|
|
8
|
+
test('normalizePlugin filtra tools invalidas', () => {
|
|
9
|
+
const plugin = normalizePlugin({ default: { name: 'demo', tools: [{ name: 'x', handler: () => {} }, { name: 'bad' }] } }, 'z');
|
|
10
|
+
assert.equal(plugin.id, 'demo');
|
|
11
|
+
assert.equal(plugin.tools.length, 1);
|
|
12
|
+
assert.equal(normalizePlugin({ default: {} }, 'n'), null);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('loadPlugins carga .tamnora/plugins/*.mjs', async () => {
|
|
16
|
+
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-plugins-'));
|
|
17
|
+
await mkdir(path.join(cwd, '.tamnora', 'plugins'), { recursive: true });
|
|
18
|
+
await writeFile(
|
|
19
|
+
path.join(cwd, '.tamnora', 'plugins', 'demo.mjs'),
|
|
20
|
+
"export default { name: 'demo', tools: [{ name: 'saludar', handler: () => 'hi' }] };\n"
|
|
21
|
+
);
|
|
22
|
+
const plugins = await loadPlugins(cwd, []);
|
|
23
|
+
assert.equal(plugins.length, 1);
|
|
24
|
+
assert.equal(plugins[0].tools[0].name, 'saludar');
|
|
25
|
+
});
|