svamp-cli 0.2.176 → 0.2.178

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.
Files changed (31) hide show
  1. package/bin/skills/loop/SKILL.md +1 -1
  2. package/dist/{agentCommands-kchQlyIS.mjs → agentCommands-BIn9btot.mjs} +5 -5
  3. package/dist/{auth-oH2upjHc.mjs → auth-DY_tcgLY.mjs} +1 -1
  4. package/dist/cli.mjs +60 -64
  5. package/dist/{commands-CbrJ16x7.mjs → commands-BTyp-k7w.mjs} +5 -5
  6. package/dist/{commands-Cu6nPOsD.mjs → commands-BfdkS7Iy.mjs} +10 -10
  7. package/dist/{commands-USG2Z4XP.mjs → commands-CdMEMD_c.mjs} +1 -1
  8. package/dist/{commands-CXtkDntj.mjs → commands-J9WOCzDG.mjs} +2 -2
  9. package/dist/{commands-DGkX7Kcr.mjs → commands-MjIkmrmC.mjs} +1 -1
  10. package/dist/{commands-DHbv5qMP.mjs → commands-WgUO8QeK.mjs} +1 -1
  11. package/dist/{commands-9LT5p45A.mjs → commands-kepmDOXD.mjs} +2 -2
  12. package/dist/{fleet-BcS8xNkk.mjs → fleet-CDllUpMj.mjs} +1 -1
  13. package/dist/{frpc-Bm7BrLan.mjs → frpc-D4x74mG6.mjs} +1 -1
  14. package/dist/{headlessCli-B4CVNI7S.mjs → headlessCli-CmBCGmNV.mjs} +2 -2
  15. package/dist/index.mjs +1 -1
  16. package/dist/{package-DndVS0ln.mjs → package-B-yM7SvY.mjs} +2 -2
  17. package/dist/{rpc-BbaS0cLh.mjs → rpc-2TJ6ipXZ.mjs} +7 -7
  18. package/dist/{rpc-DVNDMfl8.mjs → rpc-DBH2bo8s.mjs} +1 -1
  19. package/dist/{run-BmpyfykC.mjs → run-B-1WDymr.mjs} +31 -409
  20. package/dist/{run-Cr1yt_tm.mjs → run-BVNBqt1C.mjs} +1 -1
  21. package/dist/{scheduler-BusuolPD.mjs → scheduler-C3j8Tghm.mjs} +1 -1
  22. package/dist/{serveCommands-Crb8ENkv.mjs → serveCommands-BFWBxK8L.mjs} +5 -5
  23. package/dist/{serveManager-DQByExEO.mjs → serveManager-DXm1bdoU.mjs} +2 -2
  24. package/dist/{sideband-B4L2zR2U.mjs → sideband-Da1VG2iq.mjs} +1 -1
  25. package/package.json +2 -2
  26. package/bin/skills/loop/bin/routine-cli.mjs +0 -121
  27. package/bin/skills/loop/bin/routine-runner.mjs +0 -125
  28. package/bin/skills/loop/bin/routine-store.mjs +0 -49
  29. package/bin/skills/loop/routines.process.yaml +0 -20
  30. package/bin/skills/loop/test/test-routine-engine.mjs +0 -122
  31. package/dist/commands-DBi7fxGX.mjs +0 -223
@@ -54,7 +54,7 @@ async function handleServeCommand() {
54
54
  }
55
55
  }
56
56
  async function serveAdd(args, machineId) {
57
- const { connectAndGetMachine } = await import('./commands-DGkX7Kcr.mjs');
57
+ const { connectAndGetMachine } = await import('./commands-MjIkmrmC.mjs');
58
58
  const pos = positionalArgs(args);
59
59
  const name = pos[0];
60
60
  if (!name) {
@@ -93,7 +93,7 @@ async function serveAdd(args, machineId) {
93
93
  }
94
94
  }
95
95
  async function serveApply(args, machineId) {
96
- const { connectAndGetMachine } = await import('./commands-DGkX7Kcr.mjs');
96
+ const { connectAndGetMachine } = await import('./commands-MjIkmrmC.mjs');
97
97
  const fs = await import('fs');
98
98
  const yaml = await import('yaml');
99
99
  const file = positionalArgs(args)[0];
@@ -182,7 +182,7 @@ async function serveApply(args, machineId) {
182
182
  }
183
183
  }
184
184
  async function serveRemove(args, machineId) {
185
- const { connectAndGetMachine } = await import('./commands-DGkX7Kcr.mjs');
185
+ const { connectAndGetMachine } = await import('./commands-MjIkmrmC.mjs');
186
186
  const pos = positionalArgs(args);
187
187
  const name = pos[0];
188
188
  if (!name) {
@@ -202,7 +202,7 @@ async function serveRemove(args, machineId) {
202
202
  }
203
203
  }
204
204
  async function serveList(args, machineId) {
205
- const { connectAndGetMachine } = await import('./commands-DGkX7Kcr.mjs');
205
+ const { connectAndGetMachine } = await import('./commands-MjIkmrmC.mjs');
206
206
  const all = hasFlag(args, "--all", "-a");
207
207
  const json = hasFlag(args, "--json");
208
208
  const sessionId = getFlag(args, "--session");
@@ -235,7 +235,7 @@ async function serveList(args, machineId) {
235
235
  }
236
236
  }
237
237
  async function serveInfo(machineId) {
238
- const { connectAndGetMachine } = await import('./commands-DGkX7Kcr.mjs');
238
+ const { connectAndGetMachine } = await import('./commands-MjIkmrmC.mjs');
239
239
  const { machine, server } = await connectAndGetMachine(machineId);
240
240
  try {
241
241
  const info = await machine.serveInfo();
@@ -4,7 +4,7 @@ import * as fs from 'fs';
4
4
  import * as http from 'http';
5
5
  import * as net from 'net';
6
6
  import * as path from 'path';
7
- import { k as getHyphaServerUrl, S as ServeAuth, l as hasCookieToken } from './run-BmpyfykC.mjs';
7
+ import { k as getHyphaServerUrl, S as ServeAuth, l as hasCookieToken } from './run-B-1WDymr.mjs';
8
8
  import 'os';
9
9
  import 'fs/promises';
10
10
  import 'url';
@@ -733,7 +733,7 @@ class ServeManager {
733
733
  const mount = this.mounts.get(mountName);
734
734
  const subdomainOverride = mount?.access === "link" && mount.linkToken ? /* @__PURE__ */ new Map([[this.port, buildLinkSubdomain(subdomainSafe, mount.linkToken)]]) : void 0;
735
735
  try {
736
- const { FrpcTunnel } = await import('./frpc-Bm7BrLan.mjs');
736
+ const { FrpcTunnel } = await import('./frpc-D4x74mG6.mjs');
737
737
  let tunnel;
738
738
  tunnel = new FrpcTunnel({
739
739
  name: tunnelName,
@@ -1,4 +1,4 @@
1
- import { z as READ_ONLY_TOOLS, A as loadMachineContext, B as buildMachineInstructions, C as machineToolsForRole, D as buildMachineTools } from './run-BmpyfykC.mjs';
1
+ import { R as READ_ONLY_TOOLS, y as loadMachineContext, z as buildMachineInstructions, A as machineToolsForRole, B as buildMachineTools } from './run-B-1WDymr.mjs';
2
2
  import 'node:child_process';
3
3
  import 'os';
4
4
  import 'fs/promises';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svamp-cli",
3
- "version": "0.2.176",
3
+ "version": "0.2.178",
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-routine.mjs && npx tsx test/test-routine-rpc.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",
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
- }