@runravel/ravel 0.1.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 +34 -0
- package/LICENSE +202 -0
- package/README.md +68 -0
- package/bin/ravel.mjs +6 -0
- package/examples/acme/agent.md +14 -0
- package/examples/acme/growth/agent.md +19 -0
- package/examples/acme/growth/copywriter/agent.md +13 -0
- package/examples/acme/growth/copywriter/tools.json +11 -0
- package/examples/acme/growth/researcher/agent.md +11 -0
- package/examples/acme/processes/prospect-outreach.process.md +24 -0
- package/examples/harbor/agent.md +31 -0
- package/examples/harbor/processes/new-client-quote.process.md +31 -0
- package/examples/harbor/processes/resolve-ticket-batch.process.md +33 -0
- package/examples/harbor/sales/agent.md +29 -0
- package/examples/harbor/sales/sdr/agent.md +24 -0
- package/examples/harbor/sales/solutions/agent.md +29 -0
- package/examples/harbor/sales/solutions/tools.json +16 -0
- package/examples/harbor/support/agent.md +31 -0
- package/examples/harbor/support/kb-writer/agent.md +25 -0
- package/examples/harbor/support/kb-writer/tools.json +10 -0
- package/examples/harbor/support/qa-reviewer/agent.md +23 -0
- package/examples/harbor/support/tools.json +16 -0
- package/examples/harbor/support/triage/agent.md +24 -0
- package/examples/harbor/support/triage/tools.json +10 -0
- package/examples/plugin-demo/agent.md +15 -0
- package/examples/plugin-demo/processes/jot.process.md +21 -0
- package/examples/plugin-demo/scribe/agent.md +15 -0
- package/examples/plugin-demo/scribe/plugin.ts +53 -0
- package/examples/plugin-demo/scribe/tools.json +9 -0
- package/package.json +65 -0
- package/src/cli/main.ts +428 -0
- package/src/control-plane/registry.ts +294 -0
- package/src/control-plane/watcher.ts +132 -0
- package/src/domain/ids.ts +17 -0
- package/src/domain/pricing.ts +51 -0
- package/src/domain/types.ts +168 -0
- package/src/index.ts +35 -0
- package/src/memory/genericTools.ts +276 -0
- package/src/memory/kv.ts +52 -0
- package/src/memory/store.ts +76 -0
- package/src/messaging/bus.ts +143 -0
- package/src/messaging/inbox.ts +119 -0
- package/src/orchestrator/orchestrator.ts +270 -0
- package/src/orchestrator/planner.ts +218 -0
- package/src/platform/app.ts +287 -0
- package/src/plugins/loader.ts +86 -0
- package/src/plugins/server.ts +41 -0
- package/src/plugins/types.ts +96 -0
- package/src/runtime/agent.ts +488 -0
- package/src/runtime/engine.ts +84 -0
- package/src/runtime/fakeEngine.ts +75 -0
- package/src/runtime/lifecycle.ts +85 -0
- package/src/runtime/officeActions.ts +58 -0
- package/src/runtime/officeTools.ts +42 -0
- package/src/runtime/sdkEngine.ts +213 -0
- package/src/schemas/agent.ts +47 -0
- package/src/schemas/common.ts +52 -0
- package/src/schemas/frontmatter.ts +36 -0
- package/src/schemas/process.ts +55 -0
- package/src/schemas/tools.ts +83 -0
- package/src/secrets/store.ts +131 -0
- package/src/service/scheduler.ts +377 -0
- package/src/service/server.ts +554 -0
- package/src/trust/approval.ts +180 -0
- package/src/trust/audit.ts +195 -0
- package/src/trust/budget.ts +64 -0
- package/src/trust/emittingAudit.ts +32 -0
- package/src/trust/executor.ts +73 -0
- package/src/trust/killswitch.ts +73 -0
- package/src/trust/observability.ts +97 -0
- package/src/trust/proposals.ts +114 -0
- package/ui/dist/assets/index-C6CxDaPS.js +44 -0
- package/ui/dist/assets/index-CD-lhs0Z.css +1 -0
- package/ui/dist/index.html +13 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Parse `.env` content into a map: `KEY=VALUE` per line, `#` comments and blank
|
|
6
|
+
* lines skipped, surrounding single/double quotes stripped. Shared by the CLI's
|
|
7
|
+
* global loader and the per-node `SecretStore`.
|
|
8
|
+
*/
|
|
9
|
+
export function parseDotEnv(content: string): Record<string, string> {
|
|
10
|
+
const out: Record<string, string> = {};
|
|
11
|
+
for (const raw of content.split(/\r?\n/)) {
|
|
12
|
+
const line = raw.trim();
|
|
13
|
+
if (!line || line.startsWith("#")) continue;
|
|
14
|
+
const eq = line.indexOf("=");
|
|
15
|
+
if (eq === -1) continue;
|
|
16
|
+
const key = line.slice(0, eq).trim();
|
|
17
|
+
if (!key) continue;
|
|
18
|
+
let value = line.slice(eq + 1).trim();
|
|
19
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
20
|
+
value = value.slice(1, -1);
|
|
21
|
+
}
|
|
22
|
+
out[key] = value;
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A valid env var name (used to gate UI writes). */
|
|
28
|
+
export const ENV_KEY_RE = /^[A-Z_][A-Z0-9_]*$/;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Per-node credential resolver. Secrets live in a `.env` file co-located with each
|
|
32
|
+
* agent folder; an agent's effective environment is the merge of every `.env` from
|
|
33
|
+
* its own folder up to the org root, **most-specific (deepest) wins**. This scopes
|
|
34
|
+
* credentials per agent: an agent only ever sees its own dir→root chain, so a
|
|
35
|
+
* sibling agent's `.env` (e.g. a write key) is unreachable.
|
|
36
|
+
*
|
|
37
|
+
* Returns only file-sourced vars; callers fall back to `process.env` for anything
|
|
38
|
+
* not set per-node (so global keys like ANTHROPIC_API_KEY keep working).
|
|
39
|
+
*/
|
|
40
|
+
export class SecretStore {
|
|
41
|
+
private readonly root: string;
|
|
42
|
+
|
|
43
|
+
constructor(orgRoot: string) {
|
|
44
|
+
this.root = path.resolve(orgRoot);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The chain of `.env` paths from the org root down to (and including) nodeDir. */
|
|
48
|
+
private chain(nodeDir: string): string[] {
|
|
49
|
+
const abs = path.resolve(nodeDir);
|
|
50
|
+
// Stay within the org root; ignore anything outside it.
|
|
51
|
+
if (abs !== this.root && !abs.startsWith(this.root + path.sep)) return [];
|
|
52
|
+
const dirs: string[] = [];
|
|
53
|
+
let cur = abs;
|
|
54
|
+
while (true) {
|
|
55
|
+
dirs.unshift(cur); // root-first so deeper dirs overwrite shallower ones
|
|
56
|
+
if (cur === this.root) break;
|
|
57
|
+
cur = path.dirname(cur);
|
|
58
|
+
}
|
|
59
|
+
return dirs.map((d) => path.join(d, ".env"));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Merge `.env` files from org root → nodeDir (deepest wins). File-sourced only. */
|
|
63
|
+
async resolve(nodeDir: string): Promise<Record<string, string>> {
|
|
64
|
+
const merged: Record<string, string> = {};
|
|
65
|
+
for (const file of this.chain(nodeDir)) {
|
|
66
|
+
let content: string;
|
|
67
|
+
try {
|
|
68
|
+
content = await fs.readFile(file, "utf8");
|
|
69
|
+
} catch {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
Object.assign(merged, parseDotEnv(content));
|
|
73
|
+
}
|
|
74
|
+
return merged;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Key NAMES set directly on this node's own `.env` (for the masked UI). */
|
|
78
|
+
async listKeys(nodeDir: string): Promise<string[]> {
|
|
79
|
+
const file = this.envPath(nodeDir);
|
|
80
|
+
if (!file) return [];
|
|
81
|
+
try {
|
|
82
|
+
return Object.keys(parseDotEnv(await fs.readFile(file, "utf8")));
|
|
83
|
+
} catch {
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Set (or replace) a key in this node's own `.env`. */
|
|
89
|
+
async setKey(nodeDir: string, key: string, value: string): Promise<void> {
|
|
90
|
+
if (!ENV_KEY_RE.test(key)) throw new Error(`invalid env key "${key}"`);
|
|
91
|
+
const file = this.requireEnvPath(nodeDir);
|
|
92
|
+
const current = await this.readOwn(file);
|
|
93
|
+
current[key] = value;
|
|
94
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
95
|
+
await fs.writeFile(file, serialize(current), "utf8");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Remove a key from this node's own `.env`. */
|
|
99
|
+
async deleteKey(nodeDir: string, key: string): Promise<void> {
|
|
100
|
+
const file = this.requireEnvPath(nodeDir);
|
|
101
|
+
const current = await this.readOwn(file);
|
|
102
|
+
delete current[key];
|
|
103
|
+
await fs.writeFile(file, serialize(current), "utf8");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private async readOwn(file: string): Promise<Record<string, string>> {
|
|
107
|
+
try {
|
|
108
|
+
return parseDotEnv(await fs.readFile(file, "utf8"));
|
|
109
|
+
} catch {
|
|
110
|
+
return {};
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The node's own `.env` path, or null if nodeDir escapes the org root. */
|
|
115
|
+
private envPath(nodeDir: string): string | null {
|
|
116
|
+
const abs = path.resolve(nodeDir);
|
|
117
|
+
if (abs !== this.root && !abs.startsWith(this.root + path.sep)) return null;
|
|
118
|
+
return path.join(abs, ".env");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private requireEnvPath(nodeDir: string): string {
|
|
122
|
+
const file = this.envPath(nodeDir);
|
|
123
|
+
if (!file) throw new Error("node directory escapes the org root");
|
|
124
|
+
return file;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function serialize(env: Record<string, string>): string {
|
|
129
|
+
const lines = Object.entries(env).map(([k, v]) => `${k}=${v}`);
|
|
130
|
+
return lines.length ? lines.join("\n") + "\n" : "";
|
|
131
|
+
}
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import type { MemoryStore } from "../memory/store.js";
|
|
3
|
+
import type { AuditEvent } from "../trust/audit.js";
|
|
4
|
+
|
|
5
|
+
/** Operator-configurable auto-run policy for one process. */
|
|
6
|
+
export interface ProcessSchedule {
|
|
7
|
+
/** Auto-run on/off. */
|
|
8
|
+
enabled: boolean;
|
|
9
|
+
/**
|
|
10
|
+
* "adaptive" — the orchestrator paces itself between [min,max] (self-pacing).
|
|
11
|
+
* "cron" — fire on a fixed 5-field cron expression (e.g. "0 9 * * *" = daily 09:00).
|
|
12
|
+
*/
|
|
13
|
+
mode: "adaptive" | "cron";
|
|
14
|
+
/** adaptive: never fire more often than this (floor on the interval). */
|
|
15
|
+
minMinutes: number;
|
|
16
|
+
/** adaptive: never wait longer than this (ceiling; fallback when the orch gives no hint). */
|
|
17
|
+
maxMinutes: number;
|
|
18
|
+
/** cron: standard 5-field expression (minute hour day-of-month month day-of-week), local time. */
|
|
19
|
+
cron?: string;
|
|
20
|
+
/** Optional hard safety rail: pause auto-runs once rolling-24h spend for this process hits it. */
|
|
21
|
+
maxUsdPerDay?: number;
|
|
22
|
+
}
|
|
23
|
+
export interface SchedulerConfig {
|
|
24
|
+
processes: Record<string, ProcessSchedule>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Live per-process state the UI surfaces (not persisted — rebuilt on start). */
|
|
28
|
+
interface RunState {
|
|
29
|
+
nextRunAt: number; // epoch ms; 0 = due now
|
|
30
|
+
running: boolean;
|
|
31
|
+
lastRunAt?: number;
|
|
32
|
+
lastRunId?: string;
|
|
33
|
+
lastIntervalMin?: number;
|
|
34
|
+
lastReason?: string;
|
|
35
|
+
pausedForBudget?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface SchedulerDeps {
|
|
39
|
+
/** Fire a process run (same path the HTTP route uses); returns the runId. */
|
|
40
|
+
launch: (name: string) => Promise<string> | string;
|
|
41
|
+
/** True if a run of this process is currently in flight (single-flight guard). */
|
|
42
|
+
isLive: (name: string) => boolean;
|
|
43
|
+
/** Team-scope manager id for a process (where the orch writes its pacing hint), or null if unknown. */
|
|
44
|
+
ownerOf: (name: string) => string | null;
|
|
45
|
+
memory: MemoryStore;
|
|
46
|
+
/** Durable audit — used to sum rolling-24h spend per process for the budget rail. */
|
|
47
|
+
events: { all: () => readonly AuditEvent[] };
|
|
48
|
+
configPath: string;
|
|
49
|
+
/** Structured progress line (defaults to no-op). */
|
|
50
|
+
log?: (line: string) => void;
|
|
51
|
+
/** Injectable clock for tests. */
|
|
52
|
+
now?: () => number;
|
|
53
|
+
/** Tick cadence in ms (default 20s). */
|
|
54
|
+
tickMs?: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const MIN_FLOOR = 1;
|
|
58
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A self-pacing scheduler: fires enabled processes on an adaptive interval. The
|
|
62
|
+
* orchestrator decides *how long to wait* (it writes `next_run_minutes` to team
|
|
63
|
+
* memory based on data/needs — caught up → long, backlog → short); the scheduler
|
|
64
|
+
* enforces the operator's [min,max] clamp, single-flight, and an optional daily
|
|
65
|
+
* spend ceiling. Judgment in the LLM, safety in code.
|
|
66
|
+
*/
|
|
67
|
+
export class Scheduler {
|
|
68
|
+
private config: SchedulerConfig = { processes: {} };
|
|
69
|
+
private readonly state = new Map<string, RunState>();
|
|
70
|
+
private timer: NodeJS.Timeout | null = null;
|
|
71
|
+
private ticking = false;
|
|
72
|
+
private readonly now: () => number;
|
|
73
|
+
private readonly tickMs: number;
|
|
74
|
+
private readonly log: (line: string) => void;
|
|
75
|
+
|
|
76
|
+
constructor(private readonly deps: SchedulerDeps) {
|
|
77
|
+
this.now = deps.now ?? (() => Date.now());
|
|
78
|
+
this.tickMs = deps.tickMs ?? 20_000;
|
|
79
|
+
this.log = deps.log ?? (() => {});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async start(): Promise<void> {
|
|
83
|
+
await this.loadConfig();
|
|
84
|
+
if (this.timer) return;
|
|
85
|
+
this.timer = setInterval(() => void this.tick(), this.tickMs);
|
|
86
|
+
// Don't keep the process alive just for the scheduler.
|
|
87
|
+
this.timer.unref?.();
|
|
88
|
+
void this.tick();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
stop(): void {
|
|
92
|
+
if (this.timer) clearInterval(this.timer);
|
|
93
|
+
this.timer = null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// --- config ----------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
async loadConfig(): Promise<SchedulerConfig> {
|
|
99
|
+
try {
|
|
100
|
+
const raw = await fs.readFile(this.deps.configPath, "utf8");
|
|
101
|
+
const parsed = JSON.parse(raw) as SchedulerConfig;
|
|
102
|
+
this.config = { processes: parsed.processes ?? {} };
|
|
103
|
+
} catch {
|
|
104
|
+
this.config = { processes: {} };
|
|
105
|
+
}
|
|
106
|
+
this.reconcileState();
|
|
107
|
+
return this.config;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Public snapshot for the API: config merged with live state. */
|
|
111
|
+
snapshot(): {
|
|
112
|
+
processes: Array<ProcessSchedule & { name: string } & Partial<RunState> & { spentTodayUsd: number }>;
|
|
113
|
+
} {
|
|
114
|
+
return {
|
|
115
|
+
processes: Object.entries(this.config.processes).map(([name, cfg]) => {
|
|
116
|
+
const st = this.state.get(name);
|
|
117
|
+
return {
|
|
118
|
+
name,
|
|
119
|
+
...cfg,
|
|
120
|
+
spentTodayUsd: round4(this.spentTodayUsd(name)),
|
|
121
|
+
...(st ? { nextRunAt: st.nextRunAt, running: st.running, lastRunAt: st.lastRunAt, lastRunId: st.lastRunId, lastIntervalMin: st.lastIntervalMin, lastReason: st.lastReason, pausedForBudget: st.pausedForBudget } : {}),
|
|
122
|
+
};
|
|
123
|
+
}),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Replace one process's policy (validated) and persist. Throws on an invalid cron. */
|
|
128
|
+
async setProcess(name: string, patch: Partial<ProcessSchedule>): Promise<SchedulerConfig> {
|
|
129
|
+
const prev = this.config.processes[name] ?? { enabled: false, mode: "adaptive" as const, minMinutes: 15, maxMinutes: 360 };
|
|
130
|
+
const mode = patch.mode ?? prev.mode ?? "adaptive";
|
|
131
|
+
const cron = patch.cron ?? prev.cron;
|
|
132
|
+
if (mode === "cron" && !isValidCron(cron)) {
|
|
133
|
+
throw new Error(`invalid cron "${cron ?? ""}" — expected 5 fields (minute hour day-of-month month day-of-week)`);
|
|
134
|
+
}
|
|
135
|
+
const next: ProcessSchedule = {
|
|
136
|
+
enabled: patch.enabled ?? prev.enabled,
|
|
137
|
+
mode,
|
|
138
|
+
minMinutes: clampInt(patch.minMinutes ?? prev.minMinutes, MIN_FLOOR, 100_000),
|
|
139
|
+
maxMinutes: clampInt(patch.maxMinutes ?? prev.maxMinutes, MIN_FLOOR, 100_000),
|
|
140
|
+
...(mode === "cron" && cron ? { cron: cron.trim() } : {}),
|
|
141
|
+
...(patch.maxUsdPerDay ?? prev.maxUsdPerDay ? { maxUsdPerDay: Math.max(0, patch.maxUsdPerDay ?? prev.maxUsdPerDay ?? 0) } : {}),
|
|
142
|
+
};
|
|
143
|
+
if (next.maxMinutes < next.minMinutes) next.maxMinutes = next.minMinutes;
|
|
144
|
+
this.config.processes[name] = next;
|
|
145
|
+
await this.saveConfig();
|
|
146
|
+
// Recompute this entry's next-run time under the (possibly new) policy.
|
|
147
|
+
const st = this.state.get(name) ?? { nextRunAt: this.now(), running: false };
|
|
148
|
+
st.nextRunAt = this.dueTime(next);
|
|
149
|
+
this.state.set(name, st);
|
|
150
|
+
this.reconcileState();
|
|
151
|
+
return this.config;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Remove a process from the schedule entirely. */
|
|
155
|
+
async removeProcess(name: string): Promise<SchedulerConfig> {
|
|
156
|
+
delete this.config.processes[name];
|
|
157
|
+
this.state.delete(name);
|
|
158
|
+
await this.saveConfig();
|
|
159
|
+
return this.config;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private async saveConfig(): Promise<void> {
|
|
163
|
+
await fs.writeFile(this.deps.configPath, JSON.stringify(this.config, null, 2), "utf8");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Ensure every configured process has state; a newly-enabled one is due per its mode. */
|
|
167
|
+
private reconcileState(): void {
|
|
168
|
+
for (const [name, cfg] of Object.entries(this.config.processes)) {
|
|
169
|
+
if (!cfg.mode) cfg.mode = "adaptive"; // tolerate legacy configs written before modes.
|
|
170
|
+
if (!this.state.has(name)) this.state.set(name, { nextRunAt: this.dueTime(cfg), running: false });
|
|
171
|
+
}
|
|
172
|
+
for (const name of [...this.state.keys()]) {
|
|
173
|
+
if (!this.config.processes[name]) this.state.delete(name);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The next time this process should fire from now: adaptive → now (due); cron → next match. */
|
|
178
|
+
private dueTime(cfg: ProcessSchedule): number {
|
|
179
|
+
if (cfg.mode === "cron" && cfg.cron) return nextCronTime(cfg.cron, this.now()) ?? this.now() + cfg.maxMinutes * 60_000;
|
|
180
|
+
return this.now();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// --- the loop --------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
/** One scheduling pass (public for deterministic testing; also driven by the timer). */
|
|
186
|
+
async tick(): Promise<void> {
|
|
187
|
+
if (this.ticking) return;
|
|
188
|
+
this.ticking = true;
|
|
189
|
+
try {
|
|
190
|
+
for (const [name, cfg] of Object.entries(this.config.processes)) {
|
|
191
|
+
if (!cfg.enabled) continue;
|
|
192
|
+
const st = this.state.get(name);
|
|
193
|
+
if (!st) continue;
|
|
194
|
+
|
|
195
|
+
const live = this.deps.isLive(name);
|
|
196
|
+
if (live) {
|
|
197
|
+
st.running = true;
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
// The run we launched has just settled → adapt the next interval now.
|
|
201
|
+
if (st.running) {
|
|
202
|
+
st.running = false;
|
|
203
|
+
await this.scheduleNext(name, cfg, st);
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (this.now() < st.nextRunAt) continue;
|
|
207
|
+
|
|
208
|
+
// Safety rail: pause (don't fire) if the daily spend ceiling is reached.
|
|
209
|
+
if (cfg.maxUsdPerDay !== undefined && this.spentTodayUsd(name) >= cfg.maxUsdPerDay) {
|
|
210
|
+
st.pausedForBudget = true;
|
|
211
|
+
// adaptive: re-check after a min interval (spend rolls off); cron: skip to next slot.
|
|
212
|
+
st.nextRunAt = cfg.mode === "cron" && cfg.cron
|
|
213
|
+
? nextCronTime(cfg.cron, this.now()) ?? this.now() + 60 * 60_000
|
|
214
|
+
: this.now() + cfg.minMinutes * 60_000;
|
|
215
|
+
this.log(`· scheduler: "${name}" paused — 24h spend ≥ $${cfg.maxUsdPerDay}`);
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
st.pausedForBudget = false;
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
const runId = await this.deps.launch(name);
|
|
222
|
+
st.running = true;
|
|
223
|
+
st.lastRunAt = this.now();
|
|
224
|
+
st.lastRunId = runId;
|
|
225
|
+
this.log(`· scheduler: launched "${name}" (${runId})`);
|
|
226
|
+
} catch (err) {
|
|
227
|
+
// Launch failed — back off a min interval and try again.
|
|
228
|
+
st.nextRunAt = this.now() + cfg.minMinutes * 60_000;
|
|
229
|
+
this.log(`· scheduler: launch of "${name}" failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
} finally {
|
|
233
|
+
this.ticking = false;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** After a run settles, set the next run time — cron: next match; adaptive: orch hint, clamped. */
|
|
238
|
+
private async scheduleNext(name: string, cfg: ProcessSchedule, st: RunState): Promise<void> {
|
|
239
|
+
if (cfg.mode === "cron" && cfg.cron) {
|
|
240
|
+
st.nextRunAt = nextCronTime(cfg.cron, this.now()) ?? this.now() + 60 * 60_000;
|
|
241
|
+
st.lastReason = `cron ${cfg.cron}`;
|
|
242
|
+
this.log(`· scheduler: "${name}" next per cron ${cfg.cron}`);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const owner = this.deps.ownerOf(name);
|
|
246
|
+
let minutes = cfg.maxMinutes; // safe fallback: if the orch left no hint, back off.
|
|
247
|
+
let reason = "no pacing hint — backing off to max";
|
|
248
|
+
if (owner) {
|
|
249
|
+
const raw = await this.deps.memory.get({ kind: "team", managerNodeId: owner }, "next_run_minutes").catch(() => null);
|
|
250
|
+
const parsed = raw !== null ? Number.parseInt(String(raw).trim(), 10) : NaN;
|
|
251
|
+
if (Number.isFinite(parsed)) {
|
|
252
|
+
minutes = parsed;
|
|
253
|
+
const why = await this.deps.memory.get({ kind: "team", managerNodeId: owner }, "next_run_reason").catch(() => null);
|
|
254
|
+
reason = why ? String(why).slice(0, 200) : "orch pacing hint";
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
const clamped = clampInt(minutes, cfg.minMinutes, cfg.maxMinutes);
|
|
258
|
+
st.lastIntervalMin = clamped;
|
|
259
|
+
st.lastReason = `${reason}${clamped !== minutes ? ` (asked ${minutes}, clamped to ${clamped})` : ""}`;
|
|
260
|
+
st.nextRunAt = this.now() + clamped * 60_000;
|
|
261
|
+
this.log(`· scheduler: "${name}" next in ${clamped}m — ${st.lastReason}`);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Rolling-24h USD spent by a process (join started→finished by runId). */
|
|
265
|
+
private spentTodayUsd(name: string): number {
|
|
266
|
+
const since = this.now() - DAY_MS;
|
|
267
|
+
const proc = new Map<string, string>(); // runId → process name
|
|
268
|
+
let total = 0;
|
|
269
|
+
for (const e of this.deps.events.all()) {
|
|
270
|
+
if (e.type === "process.started" && e.runId) proc.set(e.runId, String(e.data["process"] ?? ""));
|
|
271
|
+
else if (e.type === "process.finished" && e.runId && proc.get(e.runId) === name) {
|
|
272
|
+
if (new Date(e.at).getTime() < since) continue;
|
|
273
|
+
const usage = e.data["usage"] as { usd?: number } | undefined;
|
|
274
|
+
total += usage?.usd ?? 0;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return total;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function clampInt(n: number, lo: number, hi: number): number {
|
|
282
|
+
const v = Math.round(Number.isFinite(n) ? n : hi);
|
|
283
|
+
return Math.max(lo, Math.min(hi, v));
|
|
284
|
+
}
|
|
285
|
+
function round4(n: number): number {
|
|
286
|
+
return Math.round(n * 10000) / 10000;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// --- cron (standard 5-field, local time) -------------------------------------
|
|
290
|
+
|
|
291
|
+
interface CronFields {
|
|
292
|
+
minute: Set<number>;
|
|
293
|
+
hour: Set<number>;
|
|
294
|
+
dom: Set<number>;
|
|
295
|
+
month: Set<number>;
|
|
296
|
+
dow: Set<number>;
|
|
297
|
+
domRestricted: boolean;
|
|
298
|
+
dowRestricted: boolean;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Parse one cron field — "*", "5", "1-5", step "*"+"/15", list "1,3,5", "0-23/2" — into a value set.
|
|
302
|
+
function parseCronField(field: string, lo: number, hi: number): Set<number> {
|
|
303
|
+
const out = new Set<number>();
|
|
304
|
+
for (const part of field.split(",")) {
|
|
305
|
+
const [rangePart, stepPart] = part.split("/");
|
|
306
|
+
const step = stepPart ? Number.parseInt(stepPart, 10) : 1;
|
|
307
|
+
if (!Number.isInteger(step) || step < 1) throw new Error(`bad step in "${field}"`);
|
|
308
|
+
let start = lo;
|
|
309
|
+
let end = hi;
|
|
310
|
+
if (rangePart && rangePart !== "*") {
|
|
311
|
+
const bounds = rangePart.split("-").map((n) => Number.parseInt(n, 10));
|
|
312
|
+
if (bounds.some((n) => !Number.isInteger(n))) throw new Error(`bad range in "${field}"`);
|
|
313
|
+
start = bounds[0]!;
|
|
314
|
+
end = bounds.length > 1 ? bounds[1]! : bounds[0]!;
|
|
315
|
+
}
|
|
316
|
+
if (start < lo || end > hi || start > end) throw new Error(`out-of-range field "${field}"`);
|
|
317
|
+
for (let v = start; v <= end; v += step) out.add(v);
|
|
318
|
+
}
|
|
319
|
+
return out;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function parseCron(expr: string): CronFields {
|
|
323
|
+
const parts = expr.trim().split(/\s+/);
|
|
324
|
+
if (parts.length !== 5) throw new Error("cron must have exactly 5 fields");
|
|
325
|
+
const [min, hr, dom, mon, dow] = parts as [string, string, string, string, string];
|
|
326
|
+
return {
|
|
327
|
+
minute: parseCronField(min, 0, 59),
|
|
328
|
+
hour: parseCronField(hr, 0, 23),
|
|
329
|
+
dom: parseCronField(dom, 1, 31),
|
|
330
|
+
month: parseCronField(mon, 1, 12),
|
|
331
|
+
dow: parseCronField(dow.replace(/7/g, "0"), 0, 6), // allow 7 as Sunday
|
|
332
|
+
domRestricted: dom !== "*",
|
|
333
|
+
dowRestricted: dow !== "*",
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export function isValidCron(expr: string | undefined): boolean {
|
|
338
|
+
if (!expr) return false;
|
|
339
|
+
try {
|
|
340
|
+
parseCron(expr);
|
|
341
|
+
return true;
|
|
342
|
+
} catch {
|
|
343
|
+
return false;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function cronMatches(f: CronFields, d: Date): boolean {
|
|
348
|
+
if (!f.minute.has(d.getMinutes())) return false;
|
|
349
|
+
if (!f.hour.has(d.getHours())) return false;
|
|
350
|
+
if (!f.month.has(d.getMonth() + 1)) return false;
|
|
351
|
+
// Standard cron: if BOTH day-of-month and day-of-week are restricted, either matching fires.
|
|
352
|
+
const domOk = f.dom.has(d.getDate());
|
|
353
|
+
const dowOk = f.dow.has(d.getDay());
|
|
354
|
+
if (f.domRestricted && f.dowRestricted) return domOk || dowOk;
|
|
355
|
+
if (f.domRestricted) return domOk;
|
|
356
|
+
if (f.dowRestricted) return dowOk;
|
|
357
|
+
return true;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** The next epoch-ms strictly after `afterMs` that matches the cron (local time), or undefined within ~366d. */
|
|
361
|
+
export function nextCronTime(expr: string, afterMs: number): number | undefined {
|
|
362
|
+
let f: CronFields;
|
|
363
|
+
try {
|
|
364
|
+
f = parseCron(expr);
|
|
365
|
+
} catch {
|
|
366
|
+
return undefined;
|
|
367
|
+
}
|
|
368
|
+
const d = new Date(afterMs);
|
|
369
|
+
d.setSeconds(0, 0);
|
|
370
|
+
d.setMinutes(d.getMinutes() + 1); // strictly after
|
|
371
|
+
const horizon = 366 * 24 * 60;
|
|
372
|
+
for (let i = 0; i < horizon; i++) {
|
|
373
|
+
if (cronMatches(f, d)) return d.getTime();
|
|
374
|
+
d.setMinutes(d.getMinutes() + 1);
|
|
375
|
+
}
|
|
376
|
+
return undefined;
|
|
377
|
+
}
|