svamp-cli 0.2.176 → 0.2.177
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/bin/skills/loop/SKILL.md +1 -1
- package/dist/{agentCommands-kchQlyIS.mjs → agentCommands-DWvXxX8p.mjs} +5 -5
- package/dist/{auth-oH2upjHc.mjs → auth-Dxka4cE6.mjs} +1 -1
- package/dist/cli.mjs +60 -64
- package/dist/{commands-CbrJ16x7.mjs → commands--dKRqMv8.mjs} +5 -5
- package/dist/{commands-DGkX7Kcr.mjs → commands-B4u6CUWH.mjs} +1 -1
- package/dist/{commands-USG2Z4XP.mjs → commands-Bf-TovMw.mjs} +1 -1
- package/dist/{commands-DHbv5qMP.mjs → commands-BwBxmMxJ.mjs} +1 -1
- package/dist/{commands-Cu6nPOsD.mjs → commands-CwdYZQ-a.mjs} +1 -1
- package/dist/{commands-9LT5p45A.mjs → commands-DVKFo1pt.mjs} +2 -2
- package/dist/{commands-CXtkDntj.mjs → commands-iXMwyExn.mjs} +2 -2
- package/dist/{fleet-BcS8xNkk.mjs → fleet-plROHfB8.mjs} +1 -1
- package/dist/{frpc-Bm7BrLan.mjs → frpc-yc5-4EUe.mjs} +1 -1
- package/dist/{headlessCli-B4CVNI7S.mjs → headlessCli-D7vLrl7K.mjs} +2 -2
- package/dist/index.mjs +1 -1
- package/dist/{package-DndVS0ln.mjs → package-Do-iBoUv.mjs} +2 -2
- package/dist/{rpc-DVNDMfl8.mjs → rpc-D1gr74eF.mjs} +1 -1
- package/dist/{rpc-BbaS0cLh.mjs → rpc-DUZqKPK4.mjs} +1 -1
- package/dist/{run-BmpyfykC.mjs → run-21-bMHCc.mjs} +19 -403
- package/dist/{run-Cr1yt_tm.mjs → run-C__yViCA.mjs} +1 -1
- package/dist/{scheduler-BusuolPD.mjs → scheduler-DweevMw_.mjs} +1 -1
- package/dist/{serveCommands-Crb8ENkv.mjs → serveCommands-BZmiAjxi.mjs} +5 -5
- package/dist/{serveManager-DQByExEO.mjs → serveManager-AbWv-Akt.mjs} +2 -2
- package/dist/{sideband-B4L2zR2U.mjs → sideband-CzzHtakf.mjs} +1 -1
- package/package.json +2 -2
- package/bin/skills/loop/bin/routine-cli.mjs +0 -121
- package/bin/skills/loop/bin/routine-runner.mjs +0 -125
- package/bin/skills/loop/bin/routine-store.mjs +0 -49
- package/bin/skills/loop/routines.process.yaml +0 -20
- package/bin/skills/loop/test/test-routine-engine.mjs +0 -122
- package/dist/commands-DBi7fxGX.mjs +0 -223
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "svamp-cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.177",
|
|
4
4
|
"description": "Svamp CLI — AI workspace daemon on Hypha Cloud",
|
|
5
5
|
"author": "Amun AI AB",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"scripts": {
|
|
21
21
|
"build": "rm -rf dist bin/skills && mkdir -p bin/skills && cp -r ../../skills/artifact bin/skills/artifact && cp -r ../../skills/loop bin/skills/loop && cp -r ../../skills/crew bin/skills/crew && tsc --noEmit && pkgroll",
|
|
22
22
|
"typecheck": "tsc --noEmit",
|
|
23
|
-
"test": "npx tsx test/test-context-window.mjs && npx tsx test/test-ratelimit-retry.mjs && npx tsx test/test-instance-config.mjs && npx tsx test/test-authorize.mjs && npx tsx test/test-normalize-allowed-user.mjs && npx tsx test/test-share-url.mjs && npx tsx test/test-update-sharing-normalization.mjs && npx tsx test/test-sharing-notify-sync.mjs && npx tsx test/test-staged-homes-sweep.mjs && npx tsx test/test-session-helpers.mjs && npx tsx test/test-cli-routing.mjs && npx tsx test/test-security-context.mjs && npx tsx test/test-isolation-decision.mjs && npx tsx test/test-loop-activation.mjs && npx tsx test/test-message-helpers.mjs && npx tsx test/test-agent-config.mjs && npx tsx test/test-wrap-command.mjs && npx tsx test/test-credential-staging.mjs && npx tsx test/test-claude-auth.mjs && npx tsx test/test-output-formatters.mjs && npx tsx test/test-inbox-guard.mjs && npx tsx test/test-auto-topic.mjs && npx tsx test/test-project-info.mjs && npx tsx test/test-agent-types.mjs && npx tsx test/test-transport.mjs && npx tsx test/test-session-update-handlers.mjs && npx tsx test/test-session-scanner.mjs && npx tsx test/test-hypha-client.mjs && npx tsx test/test-hook-settings.mjs && npx tsx test/test-session-service-logic.mjs && npx tsx test/test-daemon-persistence.mjs && npx tsx test/test-detect-isolation.mjs && npx tsx test/test-machine-service-logic.mjs && npx tsx test/test-interactive-helpers.mjs && npx tsx test/test-codex-backend.mjs && npx tsx test/test-acp-backend.mjs && npx tsx test/test-acp-bridge.mjs && npx tsx test/test-hook-server.mjs && npx tsx test/test-session-commands.mjs && npx tsx test/test-interactive-console.mjs && npx tsx test/test-session-messages.mjs && npx tsx test/test-session-send-query.mjs && npx tsx test/test-skills.mjs && npx tsx test/test-agent-grouping.mjs && npx tsx test/test-machine-list-directory.mjs && npx tsx test/test-service-commands.mjs && npx tsx test/test-supervisor.mjs && npx tsx test/test-supervisor-lock.mjs && node test/test-supervisor-restart.mjs && npx tsx test/test-clear-detection.mjs && npx tsx test/test-session-consolidation.mjs && npx tsx test/test-inbox.mjs && npx tsx test/test-inbox-cross-machine.mjs && npx tsx test/test-issue-store.mjs && npx tsx test/test-issue-close-gate.mjs && npx tsx test/test-issue-rpc.mjs && npx tsx test/test-workflow-store.mjs && npx tsx test/test-workflow-rpc.mjs && npx tsx test/test-workflow-scheduler.mjs && npx tsx test/test-serve-link-subdomain.mjs && npx tsx test/test-short-id.mjs && npx tsx test/test-transcript-edit.mjs && npx tsx test/test-edit-history.mjs && npx tsx test/test-friendly-name.mjs && npx tsx test/test-session-rpc-dispatch.mjs && npx tsx test/test-sandbox-cli.mjs && npx tsx test/test-serve-manager.mjs && npx tsx test/test-serve-stability.mjs && npx tsx test/test-frpc-e2e.mjs --unit-only && npx tsx test/test-frpc-status.mjs && node test/pinnedClaudeCode.test.mjs && node test/fleet.test.mjs && npx tsx test/test-
|
|
23
|
+
"test": "npx tsx test/test-context-window.mjs && npx tsx test/test-ratelimit-retry.mjs && npx tsx test/test-instance-config.mjs && npx tsx test/test-authorize.mjs && npx tsx test/test-normalize-allowed-user.mjs && npx tsx test/test-share-url.mjs && npx tsx test/test-update-sharing-normalization.mjs && npx tsx test/test-sharing-notify-sync.mjs && npx tsx test/test-staged-homes-sweep.mjs && npx tsx test/test-session-helpers.mjs && npx tsx test/test-cli-routing.mjs && npx tsx test/test-security-context.mjs && npx tsx test/test-isolation-decision.mjs && npx tsx test/test-loop-activation.mjs && npx tsx test/test-message-helpers.mjs && npx tsx test/test-agent-config.mjs && npx tsx test/test-wrap-command.mjs && npx tsx test/test-credential-staging.mjs && npx tsx test/test-claude-auth.mjs && npx tsx test/test-output-formatters.mjs && npx tsx test/test-inbox-guard.mjs && npx tsx test/test-auto-topic.mjs && npx tsx test/test-project-info.mjs && npx tsx test/test-agent-types.mjs && npx tsx test/test-transport.mjs && npx tsx test/test-session-update-handlers.mjs && npx tsx test/test-session-scanner.mjs && npx tsx test/test-hypha-client.mjs && npx tsx test/test-hook-settings.mjs && npx tsx test/test-session-service-logic.mjs && npx tsx test/test-daemon-persistence.mjs && npx tsx test/test-detect-isolation.mjs && npx tsx test/test-machine-service-logic.mjs && npx tsx test/test-interactive-helpers.mjs && npx tsx test/test-codex-backend.mjs && npx tsx test/test-acp-backend.mjs && npx tsx test/test-acp-bridge.mjs && npx tsx test/test-hook-server.mjs && npx tsx test/test-session-commands.mjs && npx tsx test/test-interactive-console.mjs && npx tsx test/test-session-messages.mjs && npx tsx test/test-session-send-query.mjs && npx tsx test/test-skills.mjs && npx tsx test/test-agent-grouping.mjs && npx tsx test/test-machine-list-directory.mjs && npx tsx test/test-service-commands.mjs && npx tsx test/test-supervisor.mjs && npx tsx test/test-supervisor-lock.mjs && node test/test-supervisor-restart.mjs && npx tsx test/test-clear-detection.mjs && npx tsx test/test-session-consolidation.mjs && npx tsx test/test-inbox.mjs && npx tsx test/test-inbox-cross-machine.mjs && npx tsx test/test-issue-store.mjs && npx tsx test/test-issue-close-gate.mjs && npx tsx test/test-issue-rpc.mjs && npx tsx test/test-workflow-store.mjs && npx tsx test/test-workflow-rpc.mjs && npx tsx test/test-workflow-scheduler.mjs && npx tsx test/test-serve-link-subdomain.mjs && npx tsx test/test-short-id.mjs && npx tsx test/test-transcript-edit.mjs && npx tsx test/test-edit-history.mjs && npx tsx test/test-friendly-name.mjs && npx tsx test/test-session-rpc-dispatch.mjs && npx tsx test/test-sandbox-cli.mjs && npx tsx test/test-serve-manager.mjs && npx tsx test/test-serve-stability.mjs && npx tsx test/test-frpc-e2e.mjs --unit-only && npx tsx test/test-frpc-status.mjs && node test/pinnedClaudeCode.test.mjs && node test/fleet.test.mjs && npx tsx test/test-session-file.mjs && npx tsx test/test-channel-rpc.mjs && npx tsx test/test-wise-agent.mjs && npx tsx test/test-channel-agent.mjs && npx tsx test/test-channels-service.mjs && npx tsx test/test-channel-async-reply.mjs && npx tsx test/test-channel-binding.mjs && npx tsx test/test-channel-identity.mjs && npx tsx test/test-shared-session-identity.mjs && npx tsx test/test-wise-agent-auth.mjs && npx tsx test/test-channel-http.mjs && npx tsx test/test-wise-voice.mjs && npx tsx test/test-wise-headless.mjs && npx tsx test/test-wise-machine.mjs && npx tsx test/test-crew-merge.mjs && npx tsx test/test-crew-verdict-routing.mjs",
|
|
24
24
|
"test:hypha": "node --no-warnings test/test-hypha-service.mjs",
|
|
25
25
|
"dev": "tsx src/cli.ts",
|
|
26
26
|
"dev:daemon": "tsx src/cli.ts daemon start-sync",
|
|
@@ -1,121 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// routine-cli.mjs — manage + run routines.
|
|
3
|
-
// add/list/remove/enable/disable/run-now — CRUD + manual fire
|
|
4
|
-
// serve [--port N] — scheduler tick + webhook HTTP server
|
|
5
|
-
//
|
|
6
|
-
// Delivery uses the svamp CLI: a fired routine becomes a message (or loop
|
|
7
|
-
// kickoff) into the bound session via `svamp session send`.
|
|
8
|
-
import { execFileSync } from 'node:child_process';
|
|
9
|
-
import { createServer } from 'node:http';
|
|
10
|
-
import { dirname, join, resolve } from 'node:path';
|
|
11
|
-
import { fileURLToPath } from 'node:url';
|
|
12
|
-
import { RoutineStore } from './routine-store.mjs';
|
|
13
|
-
import { RoutineRunner } from './routine-runner.mjs';
|
|
14
|
-
|
|
15
|
-
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
16
|
-
const LOOP_INIT = join(HERE, 'loop-init.mjs');
|
|
17
|
-
|
|
18
|
-
function parseArgs(argv) {
|
|
19
|
-
const a = { _: [] };
|
|
20
|
-
for (let i = 0; i < argv.length; i++) {
|
|
21
|
-
const t = argv[i];
|
|
22
|
-
if (t.startsWith('--')) a[t.slice(2)] = (argv[i + 1] && !argv[i + 1].startsWith('--')) ? argv[++i] : true;
|
|
23
|
-
else a._.push(t);
|
|
24
|
-
}
|
|
25
|
-
return a;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
// ---- real delivery via svamp CLI ---------------------------------------
|
|
29
|
-
async function cliDeliver({ routine, resolved }) {
|
|
30
|
-
const sid = routine.session_id;
|
|
31
|
-
if (resolved.kind === 'message') {
|
|
32
|
-
execFileSync('svamp', ['session', 'send', sid, resolved.text], { stdio: 'pipe' });
|
|
33
|
-
return;
|
|
34
|
-
}
|
|
35
|
-
// loop action: set up the loop in the project dir, then kick the session.
|
|
36
|
-
const dir = routine.action.loop?.dir || routine.dir;
|
|
37
|
-
if (dir) {
|
|
38
|
-
const initArgs = [LOOP_INIT, resolve(dir), '--task', resolved.task || routine.action.loop?.task || '(task)'];
|
|
39
|
-
if (routine.action.loop?.oracle) initArgs.push('--oracle', routine.action.loop.oracle);
|
|
40
|
-
initArgs.push('--evaluator', routine.action.loop?.evaluator || 'off');
|
|
41
|
-
execFileSync(process.execPath, initArgs, { stdio: 'pipe' });
|
|
42
|
-
}
|
|
43
|
-
execFileSync('svamp', ['session', 'send', sid,
|
|
44
|
-
`Begin the loop (LOOP MODE). Task: ${resolved.task}. Iterate until the gate lets you stop.`], { stdio: 'pipe' });
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function buildRoutineFromArgs(a) {
|
|
48
|
-
const r = { session_id: a.session, name: a.name || 'routine', overlap: a.overlap || 'queue' };
|
|
49
|
-
if (a.schedule) r.trigger = { type: 'schedule', cron: a.schedule, missed: a.missed || 'skip', tz: a.tz };
|
|
50
|
-
else if (a.webhook || a.api) r.trigger = { type: a.api ? 'api' : 'webhook', methods: a.get ? ['GET'] : ['GET', 'POST'], public: !!a.public };
|
|
51
|
-
else r.trigger = { type: 'manual' };
|
|
52
|
-
if (a.loop) r.action = { kind: 'loop', task_template: a.task || '', loop: { dir: a.dir, oracle: a.oracle, evaluator: a.evaluator || 'off' } };
|
|
53
|
-
else r.action = { kind: 'message', template: a.message || a.task || 'run' };
|
|
54
|
-
return r;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
async function main() {
|
|
58
|
-
const a = parseArgs(process.argv.slice(2));
|
|
59
|
-
const cmd = a._[0];
|
|
60
|
-
const store = new RoutineStore();
|
|
61
|
-
const runner = new RoutineRunner({ store, deliver: cliDeliver, log: console.error });
|
|
62
|
-
|
|
63
|
-
switch (cmd) {
|
|
64
|
-
case 'add': {
|
|
65
|
-
if (!a.session) { console.error('--session <id> required'); process.exit(1); }
|
|
66
|
-
const r = store.save(buildRoutineFromArgs(a));
|
|
67
|
-
console.log(`✅ added routine ${r.id} (${r.name}) trigger=${r.trigger.type} action=${r.action.kind}`);
|
|
68
|
-
if (r.trigger.key) console.log(` webhook key: ${r.trigger.key}`);
|
|
69
|
-
break;
|
|
70
|
-
}
|
|
71
|
-
case 'list': {
|
|
72
|
-
const rs = store.list();
|
|
73
|
-
if (a.json) { console.log(JSON.stringify(rs, null, 2)); break; }
|
|
74
|
-
if (!rs.length) { console.log('(no routines)'); break; }
|
|
75
|
-
for (const r of rs) console.log(`${r.enabled ? '●' : '○'} ${r.id} ${r.name} [${r.trigger.type}${r.trigger.cron ? ' ' + r.trigger.cron : ''}] -> ${r.action.kind} session=${r.session_id}`);
|
|
76
|
-
break;
|
|
77
|
-
}
|
|
78
|
-
case 'remove': ok(store.remove(a._[1]), `removed ${a._[1]}`); break;
|
|
79
|
-
case 'enable': store.setEnabled(a._[1], true); console.log(`enabled ${a._[1]}`); break;
|
|
80
|
-
case 'disable': store.setEnabled(a._[1], false); console.log(`disabled ${a._[1]}`); break;
|
|
81
|
-
case 'run-now': console.log(JSON.stringify(await runner.runNow(a._[1], {}), null, 2)); break;
|
|
82
|
-
case 'serve': await serve(runner, store, Number(a.port) || 8722); break;
|
|
83
|
-
default:
|
|
84
|
-
console.log(`usage: routine-cli <add|list|remove|enable|disable|run-now|serve>
|
|
85
|
-
add --session <id> --name <n> [--schedule "*/5 * * * *" [--missed catchup]] [--webhook|--api [--get] [--public]] (--message "..." | --loop --dir <path> --task "..." [--oracle "cmd"])
|
|
86
|
-
serve [--port 8722]`);
|
|
87
|
-
}
|
|
88
|
-
function ok(c, m) { console.log(c ? '✅ ' + m : '✗ ' + m); }
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
async function serve(runner, store, port) {
|
|
92
|
-
// catch up missed schedules, then tick every 20s (per-minute dedupe inside).
|
|
93
|
-
await runner.catchUp(new Date(Date.now() - 24 * 3600 * 1000), new Date());
|
|
94
|
-
const timer = setInterval(() => { runner.tick(new Date()).catch((e) => console.error('tick error', e)); }, 20000);
|
|
95
|
-
|
|
96
|
-
const server = createServer((req, res) => {
|
|
97
|
-
const u = new URL(req.url, `http://localhost:${port}`);
|
|
98
|
-
if (u.pathname === '/' || u.pathname === '/health') { res.writeHead(200).end('routines ok'); return; }
|
|
99
|
-
const m = u.pathname.match(/^\/routine\/([\w.-]+)$/);
|
|
100
|
-
if (!m) { res.writeHead(404).end('not found'); return; }
|
|
101
|
-
const id = m[1];
|
|
102
|
-
const key = u.searchParams.get('key');
|
|
103
|
-
const query = Object.fromEntries(u.searchParams.entries());
|
|
104
|
-
let chunks = '';
|
|
105
|
-
req.on('data', (c) => { chunks += c; if (chunks.length > 1e6) req.destroy(); });
|
|
106
|
-
req.on('end', async () => {
|
|
107
|
-
let body = {}; try { body = chunks ? JSON.parse(chunks) : {}; } catch {}
|
|
108
|
-
const result = await runner.webhook(id, { key, method: req.method, body, query });
|
|
109
|
-
res.writeHead(result.status || 200, { 'content-type': 'application/json' }).end(JSON.stringify(result));
|
|
110
|
-
});
|
|
111
|
-
});
|
|
112
|
-
server.listen(port, () => {
|
|
113
|
-
console.log(`🪝 routine server on http://localhost:${port}`);
|
|
114
|
-
console.log(` webhook URL pattern: http://localhost:${port}/routine/<id>?key=<key>`);
|
|
115
|
-
console.log(` expose publicly: svamp service expose routines --port ${port}`);
|
|
116
|
-
console.log(` scheduler: ticking every 20s`);
|
|
117
|
-
});
|
|
118
|
-
process.on('SIGINT', () => { clearInterval(timer); server.close(); process.exit(0); });
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
main().catch((e) => { console.error(e); process.exit(1); });
|
|
@@ -1,125 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// routine-runner.mjs — fires routines (schedule tick / webhook / manual),
|
|
3
|
-
// applies overlap + missed-run policies, and delivers the resolved action
|
|
4
|
-
// (message | loop) via an injectable `deliver` fn. Pure logic; the actual
|
|
5
|
-
// CLI delivery + timer + HTTP server live in routine-cli.mjs.
|
|
6
|
-
import { cronMatches, renderTemplate, nextFire, inZone } from './routine-core.mjs';
|
|
7
|
-
|
|
8
|
-
const minuteKey = (d) => `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}-${d.getHours()}-${d.getMinutes()}`;
|
|
9
|
-
|
|
10
|
-
export class RoutineRunner {
|
|
11
|
-
/** @param {{store, deliver:(ctx)=>Promise<any>, now?:()=>Date, onReplace?:(id)=>void, log?:Function}} o */
|
|
12
|
-
constructor({ store, deliver, now = () => new Date(), onReplace, log = () => {} }) {
|
|
13
|
-
this.store = store; this.deliver = deliver; this.now = now; this.onReplace = onReplace; this.log = log;
|
|
14
|
-
this.active = new Set(); // routine ids with an in-flight loop run
|
|
15
|
-
this._firedMinute = new Map(); // id -> minuteKey (in-memory dedupe within a process)
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
resolveAction(routine, payload = {}) {
|
|
19
|
-
const ctx = { ...payload, now: this.now().toISOString(), routine: { id: routine.id, name: routine.name } };
|
|
20
|
-
const a = routine.action;
|
|
21
|
-
if (a.kind === 'message') return { kind: 'message', text: renderTemplate(a.template, ctx) };
|
|
22
|
-
const task = renderTemplate(a.task_template || a.loop?.task || '', ctx);
|
|
23
|
-
return { kind: 'loop', task, loop: a.loop };
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
markDone(id) { this.active.delete(id); }
|
|
27
|
-
|
|
28
|
-
/** Deliveries counted today via a dedicated counter (NOT derived from the
|
|
29
|
-
* capped last_runs audit, which would undercount past 20 runs/day). */
|
|
30
|
-
_deliveredToday(routine) {
|
|
31
|
-
const today = this.now().toDateString();
|
|
32
|
-
return routine.daily && routine.daily.date === today ? routine.daily.n : 0;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** Core fire path with overlap + daily-cap policy. via = 'schedule'|'webhook'|'api'|'manual'.
|
|
36
|
-
* NOTE: `active` guards genuinely CONCURRENT in-flight deliveries only — the runner
|
|
37
|
-
* cannot observe a spawned loop's full duration (delivery is fire-and-forget), so it
|
|
38
|
-
* is cleared once delivery returns. True loop-duration overlap needs a loop-state
|
|
39
|
-
* signal (future). `replace` calls onReplace (best-effort) then proceeds. */
|
|
40
|
-
async fire(routine, payload = {}, via = 'manual') {
|
|
41
|
-
if (!routine.enabled) return { skipped: 'disabled' };
|
|
42
|
-
// Defense-in-depth (also enforced at validate time): never run a loop action
|
|
43
|
-
// from a public/keyless webhook (unauthenticated task injection).
|
|
44
|
-
if (routine.action?.kind === 'loop' && (routine.trigger?.type === 'webhook' || routine.trigger?.type === 'api') && routine.trigger?.public)
|
|
45
|
-
return { skipped: 'forbidden (public webhook + loop)' };
|
|
46
|
-
const today = this.now().toDateString();
|
|
47
|
-
// Reserve a daily slot SYNCHRONOUSLY (before any await) so concurrent fires
|
|
48
|
-
// serialize on the JS event loop and cannot all read n=0 and over-deliver.
|
|
49
|
-
const r0 = this.store.get(routine.id) || routine;
|
|
50
|
-
const usedToday = (r0.daily && r0.daily.date === today) ? r0.daily.n : 0;
|
|
51
|
-
if (routine.daily_cap && usedToday >= routine.daily_cap) {
|
|
52
|
-
this.store.recordRun(routine.id, { via, delivered: routine.action.kind, outcome: 'skipped (daily cap)' });
|
|
53
|
-
return { skipped: 'daily_cap' };
|
|
54
|
-
}
|
|
55
|
-
if (this.active.has(routine.id)) {
|
|
56
|
-
if (routine.overlap === 'skip') { this.store.recordRun(routine.id, { via, delivered: routine.action.kind, outcome: 'skipped (busy)' }); return { skipped: 'busy' }; }
|
|
57
|
-
if (routine.overlap === 'replace') { try { this.onReplace?.(routine.id); } catch {} }
|
|
58
|
-
// 'queue'/'replace' -> proceed
|
|
59
|
-
}
|
|
60
|
-
this.active.add(routine.id);
|
|
61
|
-
if (routine.daily_cap) { r0.daily = { date: today, n: usedToday + 1 }; this.store.save(r0); } // reserve
|
|
62
|
-
const resolved = this.resolveAction(routine, payload);
|
|
63
|
-
let outcome = 'delivered';
|
|
64
|
-
try {
|
|
65
|
-
await this.deliver({ routine, action: routine.action, resolved, payload, via });
|
|
66
|
-
} catch (e) {
|
|
67
|
-
outcome = 'error: ' + (e?.message || e);
|
|
68
|
-
if (routine.daily_cap) { const rb = this.store.get(routine.id); if (rb?.daily?.date === today && rb.daily.n > 0) { rb.daily.n -= 1; this.store.save(rb); } } // roll back reservation
|
|
69
|
-
} finally { this.active.delete(routine.id); }
|
|
70
|
-
const r = this.store.get(routine.id) || routine;
|
|
71
|
-
r.last_fired_at = this.now().toISOString();
|
|
72
|
-
r.last_runs = [{ firedAt: r.last_fired_at, via, delivered: resolved.kind, outcome }, ...(r.last_runs || [])].slice(0, 20);
|
|
73
|
-
this.store.save(r);
|
|
74
|
-
return { fired: true, via, resolved, outcome };
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/** Schedule tick — fire any matching schedule routine once per minute. */
|
|
78
|
-
async tick(date = this.now()) {
|
|
79
|
-
const results = [];
|
|
80
|
-
for (const r of this.store.list()) {
|
|
81
|
-
if (!r.enabled || r.trigger?.type !== 'schedule') continue;
|
|
82
|
-
if (!cronMatches(r.trigger.cron, inZone(date, r.trigger.tz))) continue;
|
|
83
|
-
const mk = minuteKey(date);
|
|
84
|
-
if (this._firedMinute.get(r.id) === mk) continue; // already fired this minute
|
|
85
|
-
this._firedMinute.set(r.id, mk);
|
|
86
|
-
results.push({ id: r.id, ...(await this.fire(r, { tick: date.toISOString() }, 'schedule')) });
|
|
87
|
-
}
|
|
88
|
-
return results;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/** Webhook/api fire: validate key + method, then fire with payload. */
|
|
92
|
-
async webhook(id, { key, method = 'POST', body = {}, query = {} } = {}) {
|
|
93
|
-
const r = this.store.get(id);
|
|
94
|
-
if (!r) return { status: 404, error: 'routine not found' };
|
|
95
|
-
if (r.trigger?.type !== 'webhook' && r.trigger?.type !== 'api') return { status: 400, error: 'not a webhook routine' };
|
|
96
|
-
if (!r.enabled) return { status: 409, error: 'routine disabled' };
|
|
97
|
-
if (!r.trigger.public && r.trigger.key && key !== r.trigger.key) return { status: 401, error: 'bad key' };
|
|
98
|
-
const methods = r.trigger.methods || ['GET', 'POST'];
|
|
99
|
-
if (!methods.includes(method)) return { status: 405, error: 'method not allowed' };
|
|
100
|
-
const res = await this.fire(r, { body, query }, r.trigger.type);
|
|
101
|
-
return { status: 200, ...res };
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
async runNow(id, payload = {}) {
|
|
105
|
-
const r = this.store.get(id);
|
|
106
|
-
if (!r) return { error: 'not found' };
|
|
107
|
-
return this.fire(r, payload, 'manual');
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/** On startup, fire schedule routines whose time was missed while down (catchup). */
|
|
111
|
-
async catchUp(sinceDate, nowDate = this.now()) {
|
|
112
|
-
const results = [];
|
|
113
|
-
for (const r of this.store.list()) {
|
|
114
|
-
if (!r.enabled || r.trigger?.type !== 'schedule' || r.trigger.missed !== 'catchup') continue;
|
|
115
|
-
const deadlineMs = (r.trigger.deadline_sec || 3600) * 1000;
|
|
116
|
-
const since = new Date(Math.max(sinceDate.getTime(), nowDate.getTime() - deadlineMs));
|
|
117
|
-
const due = nextFire(r.trigger.cron, since, r.trigger.tz); // first scheduled time after `since` (in the routine's tz)
|
|
118
|
-
if (due && due <= nowDate) {
|
|
119
|
-
const last = r.last_fired_at ? new Date(r.last_fired_at) : new Date(0);
|
|
120
|
-
if (last < due) results.push({ id: r.id, ...(await this.fire(r, { catchup: due.toISOString() }, 'schedule')) });
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
return results;
|
|
124
|
-
}
|
|
125
|
-
}
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// routine-store.mjs — persistence + CRUD for routine specs.
|
|
3
|
-
// Specs live as JSON files under a routines dir (default ~/.svamp/routines/),
|
|
4
|
-
// one file per routine, atomic writes, restored on restart.
|
|
5
|
-
import { mkdirSync, writeFileSync, renameSync, readdirSync, readFileSync, rmSync, existsSync } from 'node:fs';
|
|
6
|
-
import { homedir } from 'node:os';
|
|
7
|
-
import { join } from 'node:path';
|
|
8
|
-
import { randomBytes } from 'node:crypto';
|
|
9
|
-
import { validateRoutine } from './routine-core.mjs';
|
|
10
|
-
|
|
11
|
-
export function defaultRoutinesDir() {
|
|
12
|
-
return process.env.SVAMP_ROUTINES_DIR || join(homedir(), '.svamp', 'routines');
|
|
13
|
-
}
|
|
14
|
-
const genId = () => 'rt_' + randomBytes(5).toString('hex');
|
|
15
|
-
const genKey = () => randomBytes(18).toString('base64url');
|
|
16
|
-
|
|
17
|
-
export class RoutineStore {
|
|
18
|
-
constructor(dir = defaultRoutinesDir()) { this.dir = dir; mkdirSync(dir, { recursive: true }); }
|
|
19
|
-
_path(id) { return join(this.dir, `${id}.json`); }
|
|
20
|
-
|
|
21
|
-
list() {
|
|
22
|
-
return readdirSync(this.dir).filter((f) => f.endsWith('.json')).map((f) => {
|
|
23
|
-
try { return JSON.parse(readFileSync(join(this.dir, f), 'utf8')); } catch { return null; }
|
|
24
|
-
}).filter(Boolean);
|
|
25
|
-
}
|
|
26
|
-
get(id) { try { return JSON.parse(readFileSync(this._path(id), 'utf8')); } catch { return null; } }
|
|
27
|
-
|
|
28
|
-
/** Create/update. Assigns id, key (for webhook/api), defaults; validates. */
|
|
29
|
-
save(routine) {
|
|
30
|
-
const r = { overlap: 'queue', enabled: true, last_runs: [], ...routine };
|
|
31
|
-
if (!r.id) r.id = genId();
|
|
32
|
-
if ((r.trigger?.type === 'webhook' || r.trigger?.type === 'api') && !r.trigger.key) r.trigger.key = genKey();
|
|
33
|
-
const errs = validateRoutine(r);
|
|
34
|
-
if (errs.length) throw new Error('invalid routine: ' + errs.join('; '));
|
|
35
|
-
const tmp = this._path(r.id) + '.tmp';
|
|
36
|
-
writeFileSync(tmp, JSON.stringify(r, null, 2));
|
|
37
|
-
renameSync(tmp, this._path(r.id));
|
|
38
|
-
return r;
|
|
39
|
-
}
|
|
40
|
-
remove(id) { const p = this._path(id); if (existsSync(p)) { rmSync(p); return true; } return false; }
|
|
41
|
-
setEnabled(id, enabled) { const r = this.get(id); if (!r) return null; r.enabled = enabled; return this.save(r); }
|
|
42
|
-
|
|
43
|
-
/** Append a capped run-history entry. */
|
|
44
|
-
recordRun(id, entry) {
|
|
45
|
-
const r = this.get(id); if (!r) return null;
|
|
46
|
-
r.last_runs = [{ firedAt: new Date().toISOString(), ...entry }, ...(r.last_runs || [])].slice(0, 20);
|
|
47
|
-
return this.save(r);
|
|
48
|
-
}
|
|
49
|
-
}
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
# Supervised routine server — run the scheduler + webhook dispatcher persistently.
|
|
2
|
-
# svamp process apply skills/loop/routines.process.yaml
|
|
3
|
-
# svamp service expose routines --port 8722 # public capability URLs
|
|
4
|
-
# Adjust the absolute path to wherever the loop skill is installed.
|
|
5
|
-
id: routines
|
|
6
|
-
name: routines
|
|
7
|
-
command: node
|
|
8
|
-
args:
|
|
9
|
-
- "/Users/weio/workspace/hypha-cloud/skills/loop/bin/routine-cli.mjs"
|
|
10
|
-
- "serve"
|
|
11
|
-
- "--port"
|
|
12
|
-
- "8722"
|
|
13
|
-
workdir: /Users/weio/workspace/hypha-cloud
|
|
14
|
-
env:
|
|
15
|
-
SVAMP_ROUTINES_DIR: "/Users/weio/.svamp/routines"
|
|
16
|
-
keepAlive: true
|
|
17
|
-
probe:
|
|
18
|
-
type: http
|
|
19
|
-
port: 8722
|
|
20
|
-
path: /health
|
|
@@ -1,122 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// Deterministic tests for routine-store + routine-runner (mock delivery, fake clock).
|
|
3
|
-
import { mkdtempSync, rmSync } from 'node:fs';
|
|
4
|
-
import { tmpdir } from 'node:os';
|
|
5
|
-
import { join } from 'node:path';
|
|
6
|
-
import { RoutineStore } from '../bin/routine-store.mjs';
|
|
7
|
-
import { RoutineRunner } from '../bin/routine-runner.mjs';
|
|
8
|
-
|
|
9
|
-
let pass = 0, fail = 0;
|
|
10
|
-
function ok(c, m) { if (c) { pass++; console.log(` ✓ ${m}`); } else { fail++; console.log(` ✗ ${m}`); } }
|
|
11
|
-
const D = (y, mo, d, h, mi) => new Date(y, mo - 1, d, h, mi, 0, 0);
|
|
12
|
-
|
|
13
|
-
const dirs = [];
|
|
14
|
-
function freshStore() { const dir = mkdtempSync(join(tmpdir(), 'routines-')); dirs.push(dir); return new RoutineStore(dir); }
|
|
15
|
-
|
|
16
|
-
try {
|
|
17
|
-
console.log('store');
|
|
18
|
-
{ const s = freshStore();
|
|
19
|
-
const r = s.save({ session_id: 's1', name: 'hook', trigger: { type: 'webhook' }, action: { kind: 'message', template: 'hi ${body.x}' } });
|
|
20
|
-
ok(r.id?.startsWith('rt_'), 'save assigns id');
|
|
21
|
-
ok(typeof r.trigger.key === 'string' && r.trigger.key.length > 10, 'webhook routine gets a capability key');
|
|
22
|
-
ok(s.get(r.id).name === 'hook', 'get round-trips');
|
|
23
|
-
ok(s.list().length === 1, 'list returns saved routine');
|
|
24
|
-
s.setEnabled(r.id, false); ok(s.get(r.id).enabled === false, 'setEnabled persists');
|
|
25
|
-
ok(s.remove(r.id) === true && s.list().length === 0, 'remove deletes'); }
|
|
26
|
-
|
|
27
|
-
console.log('resolveAction (templating)');
|
|
28
|
-
{ const s = freshStore();
|
|
29
|
-
const runner = new RoutineRunner({ store: s, deliver: async () => {}, now: () => D(2026, 6, 10, 9, 0) });
|
|
30
|
-
const msg = s.save({ session_id: 's', name: 'm', trigger: { type: 'manual' }, action: { kind: 'message', template: 'PR ${body.pr}' } });
|
|
31
|
-
ok(runner.resolveAction(msg, { body: { pr: 7 } }).text === 'PR 7', 'message action templated');
|
|
32
|
-
const lp = s.save({ session_id: 's', name: 'l', trigger: { type: 'webhook' }, action: { kind: 'loop', task_template: 'fix ${body.issue}' } });
|
|
33
|
-
const r = runner.resolveAction(lp, { body: { issue: 'X' } });
|
|
34
|
-
ok(r.kind === 'loop' && r.task === 'fix X', 'loop action task templated'); }
|
|
35
|
-
|
|
36
|
-
console.log('schedule tick (fire once per minute)');
|
|
37
|
-
{ const s = freshStore();
|
|
38
|
-
let clock = D(2026, 6, 10, 9, 5);
|
|
39
|
-
const calls = [];
|
|
40
|
-
const runner = new RoutineRunner({ store: s, deliver: async (c) => calls.push(c), now: () => clock });
|
|
41
|
-
s.save({ session_id: 's', name: 'poll', trigger: { type: 'schedule', cron: '*/5 * * * *' }, action: { kind: 'message', template: 'tick' } });
|
|
42
|
-
await runner.tick(D(2026, 6, 10, 9, 3)); ok(calls.length === 0, 'no fire at non-matching minute 9:03');
|
|
43
|
-
await runner.tick(D(2026, 6, 10, 9, 5)); ok(calls.length === 1, 'fires at matching minute 9:05');
|
|
44
|
-
await runner.tick(D(2026, 6, 10, 9, 5)); ok(calls.length === 1, 'does not double-fire same minute');
|
|
45
|
-
await runner.tick(D(2026, 6, 10, 9, 10)); ok(calls.length === 2, 'fires again next matching minute');
|
|
46
|
-
ok(s.list()[0].last_runs.length === 2, 'run history recorded'); }
|
|
47
|
-
|
|
48
|
-
console.log('overlap policy (concurrent in-flight guard)');
|
|
49
|
-
{ const s = freshStore();
|
|
50
|
-
const calls = []; let release; const gate = new Promise((r) => { release = r; });
|
|
51
|
-
const runner = new RoutineRunner({ store: s, deliver: async () => { calls.push(1); await gate; } });
|
|
52
|
-
const r = s.save({ session_id: 's', name: 'l', overlap: 'skip', trigger: { type: 'manual' }, action: { kind: 'loop', task_template: 'go' } });
|
|
53
|
-
const p1 = runner.fire(s.get(r.id), {}, 'manual'); // enters deliver, holds active
|
|
54
|
-
await new Promise((res) => setTimeout(res, 10));
|
|
55
|
-
const res2 = await runner.fire(s.get(r.id), {}, 'manual'); // concurrent -> skipped
|
|
56
|
-
ok(res2.skipped === 'busy' && calls.length === 1, 'overlap=skip skips a concurrent fire');
|
|
57
|
-
release(); await p1; // first delivery completes -> active cleared
|
|
58
|
-
const res3 = await runner.fire(s.get(r.id), {}, 'manual');
|
|
59
|
-
ok(res3.fired && calls.length === 2, 'fires again once the in-flight delivery completes (no permanent skip)'); }
|
|
60
|
-
|
|
61
|
-
console.log('webhook dispatch + key/method checks');
|
|
62
|
-
{ const s = freshStore();
|
|
63
|
-
const calls = [];
|
|
64
|
-
const runner = new RoutineRunner({ store: s, deliver: async (c) => calls.push(c) });
|
|
65
|
-
const r = s.save({ session_id: 's', name: 'wh', trigger: { type: 'webhook', methods: ['POST'] }, action: { kind: 'message', template: 'got ${body.v}' } });
|
|
66
|
-
const key = s.get(r.id).trigger.key;
|
|
67
|
-
ok((await runner.webhook(r.id, { key: 'wrong', method: 'POST' })).status === 401, 'bad key -> 401');
|
|
68
|
-
ok((await runner.webhook(r.id, { key, method: 'GET' })).status === 405, 'disallowed method -> 405');
|
|
69
|
-
const good = await runner.webhook(r.id, { key, method: 'POST', body: { v: 42 } });
|
|
70
|
-
ok(good.status === 200 && good.resolved.text === 'got 42', 'good webhook delivers templated message');
|
|
71
|
-
s.setEnabled(r.id, false);
|
|
72
|
-
ok((await runner.webhook(r.id, { key, method: 'POST' })).status === 409, 'disabled -> 409'); }
|
|
73
|
-
|
|
74
|
-
console.log('catch-up missed schedule');
|
|
75
|
-
{ const s = freshStore();
|
|
76
|
-
const calls = [];
|
|
77
|
-
const now = D(2026, 6, 10, 9, 30);
|
|
78
|
-
const runner = new RoutineRunner({ store: s, deliver: async (c) => calls.push(c), now: () => now });
|
|
79
|
-
s.save({ session_id: 's', name: 'daily', trigger: { type: 'schedule', cron: '0 9 * * *', missed: 'catchup', deadline_sec: 7200 }, action: { kind: 'message', template: 'good morning' } });
|
|
80
|
-
await runner.catchUp(D(2026, 6, 10, 8, 0), now); ok(calls.length === 1, 'catch-up fires the missed 09:00 run');
|
|
81
|
-
await runner.catchUp(D(2026, 6, 10, 8, 0), now); ok(calls.length === 1, 'catch-up does not re-fire once recorded'); }
|
|
82
|
-
|
|
83
|
-
console.log('daily cap');
|
|
84
|
-
{ const s = freshStore(); const calls = []; const now = D(2026, 6, 10, 9, 0);
|
|
85
|
-
const runner = new RoutineRunner({ store: s, deliver: async (c) => calls.push(c), now: () => now });
|
|
86
|
-
const r = s.save({ session_id: 's', name: 'capped', daily_cap: 2, trigger: { type: 'manual' }, action: { kind: 'message', template: 'x' } });
|
|
87
|
-
await runner.fire(s.get(r.id), {}, 'manual');
|
|
88
|
-
await runner.fire(s.get(r.id), {}, 'manual');
|
|
89
|
-
const third = await runner.fire(s.get(r.id), {}, 'manual');
|
|
90
|
-
ok(calls.length === 2, 'daily_cap=2 delivers exactly twice');
|
|
91
|
-
ok(third.skipped === 'daily_cap', 'third fire skipped by daily cap'); }
|
|
92
|
-
|
|
93
|
-
console.log('fire-time public+loop guard (#8)');
|
|
94
|
-
{ const s = freshStore(); const calls = [];
|
|
95
|
-
const runner = new RoutineRunner({ store: s, deliver: async () => calls.push(1) });
|
|
96
|
-
// hand-built (bypasses save validation, simulating a legacy/pre-guard stored routine)
|
|
97
|
-
const res = await runner.fire({ id: 'x', enabled: true, trigger: { type: 'webhook', public: true }, action: { kind: 'loop', task_template: 't' } }, {}, 'webhook');
|
|
98
|
-
ok(res.skipped?.includes('forbidden') && calls.length === 0, 'public webhook + loop action refused at fire time'); }
|
|
99
|
-
|
|
100
|
-
console.log('daily_cap under concurrency (#9 reserve-before-await)');
|
|
101
|
-
{ const s = freshStore(); const calls = []; let release; const gate = new Promise((r) => { release = r; });
|
|
102
|
-
const runner = new RoutineRunner({ store: s, deliver: async () => { calls.push(1); await gate; } });
|
|
103
|
-
const r = s.save({ session_id: 's', name: 'cap', daily_cap: 2, trigger: { type: 'manual' }, action: { kind: 'message', template: 'x' } });
|
|
104
|
-
const fires = Promise.all([0, 1, 2, 3, 4].map(() => runner.fire(s.get(r.id), {}, 'manual')));
|
|
105
|
-
await new Promise((res) => setTimeout(res, 20));
|
|
106
|
-
release(); await fires;
|
|
107
|
-
ok(calls.length === 2, `5 concurrent fires respect daily_cap=2 (delivered ${calls.length})`); }
|
|
108
|
-
|
|
109
|
-
console.log('catch-up honors timezone (#10)');
|
|
110
|
-
{ const s = freshStore();
|
|
111
|
-
const now = new Date('2026-06-10T13:30:00Z'); // 09:30 EDT
|
|
112
|
-
const calls = [];
|
|
113
|
-
const runner = new RoutineRunner({ store: s, deliver: async () => calls.push(1), now: () => now });
|
|
114
|
-
s.save({ session_id: 's', name: 'tzd', trigger: { type: 'schedule', cron: '0 9 * * *', tz: 'America/New_York', missed: 'catchup', deadline_sec: 7200 }, action: { kind: 'message', template: 'gm' } });
|
|
115
|
-
await runner.catchUp(new Date('2026-06-10T12:00:00Z'), now);
|
|
116
|
-
ok(calls.length === 1, 'catch-up fires the missed 09:00-America/New_York run (tz-aware)'); }
|
|
117
|
-
|
|
118
|
-
console.log(`\n${fail === 0 ? '✅' : '❌'} ${pass} passed, ${fail} failed`);
|
|
119
|
-
process.exit(fail === 0 ? 0 : 1);
|
|
120
|
-
} finally {
|
|
121
|
-
for (const d of dirs) { try { rmSync(d, { recursive: true, force: true }); } catch {} }
|
|
122
|
-
}
|