@agentic-surfaces/core 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/dist/cache.d.ts +11 -0
- package/dist/cache.js +30 -0
- package/dist/executor.d.ts +10 -0
- package/dist/executor.js +57 -0
- package/dist/handlers/agent.d.ts +2 -0
- package/dist/handlers/agent.js +16 -0
- package/dist/handlers/branch.d.ts +2 -0
- package/dist/handlers/branch.js +19 -0
- package/dist/handlers/http.d.ts +2 -0
- package/dist/handlers/http.js +47 -0
- package/dist/handlers/transform.d.ts +3 -0
- package/dist/handlers/transform.js +21 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +9 -0
- package/dist/observer.d.ts +7 -0
- package/dist/observer.js +6 -0
- package/dist/policy.d.ts +2 -0
- package/dist/policy.js +17 -0
- package/dist/project-config.d.ts +26 -0
- package/dist/project-config.js +53 -0
- package/dist/registry.d.ts +6 -0
- package/dist/registry.js +10 -0
- package/dist/scheduler.d.ts +25 -0
- package/dist/scheduler.js +72 -0
- package/dist/schema.d.ts +2 -0
- package/dist/schema.js +46 -0
- package/dist/secrets.d.ts +9 -0
- package/dist/secrets.js +18 -0
- package/dist/types.d.ts +68 -0
- package/dist/types.js +1 -0
- package/package.json +36 -0
package/dist/cache.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Cache } from "./types.js";
|
|
2
|
+
export declare class SqliteCache implements Cache {
|
|
3
|
+
private db;
|
|
4
|
+
constructor(path?: string);
|
|
5
|
+
getCursor(k: string): string | undefined;
|
|
6
|
+
setCursor(k: string, v: string): void;
|
|
7
|
+
isSeen(k: string): boolean;
|
|
8
|
+
markSeen(k: string): void;
|
|
9
|
+
wipe(): void;
|
|
10
|
+
close(): void;
|
|
11
|
+
}
|
package/dist/cache.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
export class SqliteCache {
|
|
3
|
+
db;
|
|
4
|
+
constructor(path = ":memory:") {
|
|
5
|
+
this.db = new Database(path);
|
|
6
|
+
this.db.exec(`
|
|
7
|
+
CREATE TABLE IF NOT EXISTS cursors (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
|
8
|
+
CREATE TABLE IF NOT EXISTS seen (key TEXT PRIMARY KEY);
|
|
9
|
+
`);
|
|
10
|
+
}
|
|
11
|
+
getCursor(k) {
|
|
12
|
+
const row = this.db.prepare("SELECT value FROM cursors WHERE key = ?").get(k);
|
|
13
|
+
return row?.value;
|
|
14
|
+
}
|
|
15
|
+
setCursor(k, v) {
|
|
16
|
+
this.db.prepare("INSERT INTO cursors(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run(k, v);
|
|
17
|
+
}
|
|
18
|
+
isSeen(k) {
|
|
19
|
+
return !!this.db.prepare("SELECT 1 FROM seen WHERE key = ?").get(k);
|
|
20
|
+
}
|
|
21
|
+
markSeen(k) {
|
|
22
|
+
this.db.prepare("INSERT OR IGNORE INTO seen(key) VALUES(?)").run(k);
|
|
23
|
+
}
|
|
24
|
+
wipe() {
|
|
25
|
+
this.db.exec("DELETE FROM cursors; DELETE FROM seen;");
|
|
26
|
+
}
|
|
27
|
+
close() {
|
|
28
|
+
this.db.close();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { HandlerRegistry } from "./registry.js";
|
|
2
|
+
import type { Services, Workflow, RunObserver } from "./types.js";
|
|
3
|
+
export declare function runWorkflow(opts: {
|
|
4
|
+
workflow: Workflow;
|
|
5
|
+
triggerNodeId: string;
|
|
6
|
+
registry: HandlerRegistry;
|
|
7
|
+
services: Services;
|
|
8
|
+
payload?: unknown;
|
|
9
|
+
observer?: RunObserver;
|
|
10
|
+
}): Promise<Map<string, unknown>>;
|
package/dist/executor.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { withRetry } from "./policy.js";
|
|
2
|
+
export async function runWorkflow(opts) {
|
|
3
|
+
const { workflow, triggerNodeId, registry, services, payload, observer } = opts;
|
|
4
|
+
const byId = new Map(workflow.nodes.map(n => [n.id, n]));
|
|
5
|
+
const ctx = {
|
|
6
|
+
workflow,
|
|
7
|
+
outputs: new Map(),
|
|
8
|
+
triggerNodeId,
|
|
9
|
+
triggerPayload: payload,
|
|
10
|
+
services,
|
|
11
|
+
};
|
|
12
|
+
const queue = [triggerNodeId];
|
|
13
|
+
const visited = new Set();
|
|
14
|
+
observer?.onRunStart?.(workflow.name);
|
|
15
|
+
while (queue.length > 0) {
|
|
16
|
+
const id = queue.shift();
|
|
17
|
+
if (visited.has(id))
|
|
18
|
+
continue;
|
|
19
|
+
visited.add(id);
|
|
20
|
+
const node = byId.get(id);
|
|
21
|
+
if (!node)
|
|
22
|
+
throw new Error(`workflow references missing node: ${id}`);
|
|
23
|
+
const handler = registry.get(node.type);
|
|
24
|
+
observer?.onNodeStart?.(id);
|
|
25
|
+
let result;
|
|
26
|
+
let nodeErrored = false;
|
|
27
|
+
try {
|
|
28
|
+
result = await withRetry(node, () => handler.execute(node, ctx));
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
nodeErrored = true;
|
|
32
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
33
|
+
services.logger?.error?.(`node ${node.id} failed`, { workflow: workflow.name, error: message });
|
|
34
|
+
observer?.onNodeFinish?.(id, "failed", { error: message });
|
|
35
|
+
if (node.onError === "continue") {
|
|
36
|
+
result = { output: { error: message } };
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
observer?.onRunFinish?.(workflow.name, "failed");
|
|
40
|
+
throw err;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
ctx.outputs.set(id, result.output);
|
|
44
|
+
if (!nodeErrored)
|
|
45
|
+
observer?.onNodeFinish?.(id, "success");
|
|
46
|
+
const branches = result.branches;
|
|
47
|
+
for (const edge of workflow.edges) {
|
|
48
|
+
if (edge.from !== id)
|
|
49
|
+
continue;
|
|
50
|
+
if (edge.when === undefined || (branches && branches.includes(edge.when))) {
|
|
51
|
+
queue.push(edge.to);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
observer?.onRunFinish?.(workflow.name, "success");
|
|
56
|
+
return ctx.outputs;
|
|
57
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export const agentHandler = {
|
|
2
|
+
type: "agent.run",
|
|
3
|
+
async execute(node, ctx) {
|
|
4
|
+
const cfg = node.config;
|
|
5
|
+
const defaults = ctx.services.agentDefaults ?? {};
|
|
6
|
+
const model = cfg.model ?? defaults.model;
|
|
7
|
+
const mcpServers = cfg.mcpServers ?? defaults.mcpServers;
|
|
8
|
+
const result = await ctx.services.agent.run({
|
|
9
|
+
prompt: cfg.prompt,
|
|
10
|
+
model,
|
|
11
|
+
mcpServers,
|
|
12
|
+
context: Object.fromEntries(ctx.outputs),
|
|
13
|
+
});
|
|
14
|
+
return { output: result };
|
|
15
|
+
},
|
|
16
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import jsonata from "jsonata";
|
|
2
|
+
import { buildJsonataScope } from "./transform.js";
|
|
3
|
+
function toBranches(value) {
|
|
4
|
+
if (typeof value === "boolean")
|
|
5
|
+
return [value ? "true" : "false"];
|
|
6
|
+
if (typeof value === "string")
|
|
7
|
+
return [value];
|
|
8
|
+
if (Array.isArray(value))
|
|
9
|
+
return value.map(String);
|
|
10
|
+
return [];
|
|
11
|
+
}
|
|
12
|
+
export const branchHandler = {
|
|
13
|
+
type: "task.branch",
|
|
14
|
+
async execute(node, ctx) {
|
|
15
|
+
const expr = String(node.config.expression ?? "");
|
|
16
|
+
const value = await jsonata(expr).evaluate(buildJsonataScope(ctx));
|
|
17
|
+
return { output: value, branches: toBranches(value) };
|
|
18
|
+
},
|
|
19
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import jsonata from "jsonata";
|
|
2
|
+
import { buildJsonataScope } from "./transform.js";
|
|
3
|
+
import { resolveSecrets } from "../secrets.js";
|
|
4
|
+
function resolveObj(obj) {
|
|
5
|
+
const out = {};
|
|
6
|
+
for (const [k, v] of Object.entries(obj ?? {}))
|
|
7
|
+
out[k] = resolveSecrets(v);
|
|
8
|
+
return out;
|
|
9
|
+
}
|
|
10
|
+
// NOTE: engine dry-run gates deterministic task.http writes only.
|
|
11
|
+
// Agent-initiated MCP writes are NOT gated here. Safe pattern: agents draft
|
|
12
|
+
// content via read-only MCP, then a task.http node posts (gated by dryRun).
|
|
13
|
+
export const httpHandler = {
|
|
14
|
+
type: "task.http",
|
|
15
|
+
async execute(node, ctx) {
|
|
16
|
+
const cfg = node.config;
|
|
17
|
+
const method = (cfg.method ?? "GET").toUpperCase();
|
|
18
|
+
const url = new URL(resolveSecrets(cfg.url));
|
|
19
|
+
for (const [k, v] of Object.entries(resolveObj(cfg.query)))
|
|
20
|
+
url.searchParams.set(k, v);
|
|
21
|
+
if (ctx.services.dryRun && method !== "GET" && method !== "HEAD") {
|
|
22
|
+
ctx.services.logger?.info?.(`DRY-RUN: would ${method} ${url}`);
|
|
23
|
+
return { output: { dryRun: true, method, url: url.toString() } };
|
|
24
|
+
}
|
|
25
|
+
const headers = resolveObj(cfg.headers);
|
|
26
|
+
let body;
|
|
27
|
+
if (cfg.bodyExpression) {
|
|
28
|
+
const data = await jsonata(cfg.bodyExpression).evaluate(buildJsonataScope(ctx));
|
|
29
|
+
body = JSON.stringify(data);
|
|
30
|
+
headers["content-type"] ??= "application/json";
|
|
31
|
+
}
|
|
32
|
+
const res = await ctx.services.fetch(url, {
|
|
33
|
+
method,
|
|
34
|
+
headers,
|
|
35
|
+
body,
|
|
36
|
+
});
|
|
37
|
+
if (!res.ok)
|
|
38
|
+
throw new Error(`http ${res.status} from ${url.pathname}`);
|
|
39
|
+
const text = await res.text();
|
|
40
|
+
try {
|
|
41
|
+
return { output: JSON.parse(text) };
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return { output: text };
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import jsonata from "jsonata";
|
|
2
|
+
export function buildJsonataScope(ctx) {
|
|
3
|
+
const scope = {};
|
|
4
|
+
for (const [id, out] of ctx.outputs)
|
|
5
|
+
scope[id] = out;
|
|
6
|
+
scope.trigger = ctx.triggerPayload;
|
|
7
|
+
return scope;
|
|
8
|
+
}
|
|
9
|
+
export const transformHandler = {
|
|
10
|
+
type: "task.transform",
|
|
11
|
+
async execute(node, ctx) {
|
|
12
|
+
const expr = String(node.config.expression ?? "");
|
|
13
|
+
const compiled = jsonata(expr);
|
|
14
|
+
let output = await compiled.evaluate(buildJsonataScope(ctx));
|
|
15
|
+
// jsonata may return a sequence object with array-like properties; convert to plain array/value
|
|
16
|
+
if (output && typeof output === 'object' && 'sequence' in output && Array.isArray(output)) {
|
|
17
|
+
output = Array.from(output);
|
|
18
|
+
}
|
|
19
|
+
return { output };
|
|
20
|
+
},
|
|
21
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare const version = "0.0.0";
|
|
2
|
+
export * from "./types.js";
|
|
3
|
+
export * from "./schema.js";
|
|
4
|
+
export * from "./executor.js";
|
|
5
|
+
export * from "./scheduler.js";
|
|
6
|
+
export * from "./cache.js";
|
|
7
|
+
export * from "./observer.js";
|
|
8
|
+
export * from "./secrets.js";
|
|
9
|
+
export * from "./project-config.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export const version = "0.0.0";
|
|
2
|
+
export * from "./types.js";
|
|
3
|
+
export * from "./schema.js";
|
|
4
|
+
export * from "./executor.js";
|
|
5
|
+
export * from "./scheduler.js";
|
|
6
|
+
export * from "./cache.js";
|
|
7
|
+
export * from "./observer.js";
|
|
8
|
+
export * from "./secrets.js";
|
|
9
|
+
export * from "./project-config.js";
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { RunObserver } from "./types.js";
|
|
2
|
+
export declare class ConsoleObserver implements RunObserver {
|
|
3
|
+
onRunStart(workflow: string): void;
|
|
4
|
+
onNodeStart(nodeId: string): void;
|
|
5
|
+
onNodeFinish(nodeId: string, status: "success" | "failed"): void;
|
|
6
|
+
onRunFinish(workflow: string, status: "success" | "failed"): void;
|
|
7
|
+
}
|
package/dist/observer.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export class ConsoleObserver {
|
|
2
|
+
onRunStart(workflow) { console.log(`▶ run ${workflow}`); }
|
|
3
|
+
onNodeStart(nodeId) { console.log(` · ${nodeId}…`); }
|
|
4
|
+
onNodeFinish(nodeId, status) { console.log(` ${status === "success" ? "✓" : "✗"} ${nodeId}`); }
|
|
5
|
+
onRunFinish(workflow, status) { console.log(`■ ${workflow}: ${status}`); }
|
|
6
|
+
}
|
package/dist/policy.d.ts
ADDED
package/dist/policy.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const defaultSleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
2
|
+
export async function withRetry(node, fn, sleep = defaultSleep) {
|
|
3
|
+
const maxAttempts = node.retry?.maxAttempts ?? 1;
|
|
4
|
+
const backoffMs = node.retry?.backoffMs ?? 0;
|
|
5
|
+
let lastErr;
|
|
6
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
7
|
+
try {
|
|
8
|
+
return await fn();
|
|
9
|
+
}
|
|
10
|
+
catch (err) {
|
|
11
|
+
lastErr = err;
|
|
12
|
+
if (attempt < maxAttempts && backoffMs > 0)
|
|
13
|
+
await sleep(backoffMs * attempt);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
throw lastErr;
|
|
17
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
declare const ProjectConfigSchema: z.ZodObject<{
|
|
3
|
+
dryRun: z.ZodDefault<z.ZodBoolean>;
|
|
4
|
+
agent: z.ZodDefault<z.ZodObject<{
|
|
5
|
+
runner: z.ZodDefault<z.ZodString>;
|
|
6
|
+
model: z.ZodDefault<z.ZodString>;
|
|
7
|
+
mcpServers: z.ZodOptional<z.ZodArray<z.ZodUnion<readonly [z.ZodObject<{
|
|
8
|
+
name: z.ZodString;
|
|
9
|
+
url: z.ZodString;
|
|
10
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
11
|
+
name: z.ZodString;
|
|
12
|
+
command: z.ZodString;
|
|
13
|
+
args: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
14
|
+
}, z.core.$strip>]>>>;
|
|
15
|
+
}, z.core.$strip>>;
|
|
16
|
+
workflows: z.ZodOptional<z.ZodString>;
|
|
17
|
+
}, z.core.$strip>;
|
|
18
|
+
export type ProjectConfig = z.infer<typeof ProjectConfigSchema>;
|
|
19
|
+
/**
|
|
20
|
+
* Load and validate `agentic-surfaces.config.yaml` from the given directory.
|
|
21
|
+
*
|
|
22
|
+
* Returns `undefined` if the file does not exist.
|
|
23
|
+
* Throws a descriptive error if the file exists but is invalid YAML or fails schema validation.
|
|
24
|
+
*/
|
|
25
|
+
export declare function loadProjectConfig(dir: string): ProjectConfig | undefined;
|
|
26
|
+
export {};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { parse as parseYaml } from "yaml";
|
|
5
|
+
// Mirror the McpServerRef interface from @agentic-surfaces/agent as a Zod schema.
|
|
6
|
+
// We do NOT import a zod schema from agent (that package has no zod dep).
|
|
7
|
+
const McpServerRefSchema = z.union([
|
|
8
|
+
z.object({ name: z.string(), url: z.string() }),
|
|
9
|
+
z.object({ name: z.string(), command: z.string(), args: z.array(z.string()).optional() }),
|
|
10
|
+
]);
|
|
11
|
+
const ProjectConfigSchema = z.object({
|
|
12
|
+
dryRun: z.boolean().default(false),
|
|
13
|
+
agent: z
|
|
14
|
+
.object({
|
|
15
|
+
runner: z.string().default("claude"),
|
|
16
|
+
model: z.string().default("claude-opus-4-8"),
|
|
17
|
+
mcpServers: z.array(McpServerRefSchema).optional(),
|
|
18
|
+
})
|
|
19
|
+
.default({ runner: "claude", model: "claude-opus-4-8" }),
|
|
20
|
+
workflows: z.string().optional(), // relative subdir; omitted = use dir itself
|
|
21
|
+
});
|
|
22
|
+
const CONFIG_FILENAME = "agentic-surfaces.config.yaml";
|
|
23
|
+
/**
|
|
24
|
+
* Load and validate `agentic-surfaces.config.yaml` from the given directory.
|
|
25
|
+
*
|
|
26
|
+
* Returns `undefined` if the file does not exist.
|
|
27
|
+
* Throws a descriptive error if the file exists but is invalid YAML or fails schema validation.
|
|
28
|
+
*/
|
|
29
|
+
export function loadProjectConfig(dir) {
|
|
30
|
+
const filePath = join(dir, CONFIG_FILENAME);
|
|
31
|
+
let raw;
|
|
32
|
+
try {
|
|
33
|
+
raw = readFileSync(filePath, "utf8");
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
if (err.code === "ENOENT")
|
|
37
|
+
return undefined;
|
|
38
|
+
throw new Error(`Failed to read ${CONFIG_FILENAME}: ${err instanceof Error ? err.message : String(err)}`);
|
|
39
|
+
}
|
|
40
|
+
let parsed;
|
|
41
|
+
try {
|
|
42
|
+
parsed = parseYaml(raw);
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
throw new Error(`Failed to parse ${CONFIG_FILENAME} as YAML: ${err instanceof Error ? err.message : String(err)}`);
|
|
46
|
+
}
|
|
47
|
+
const result = ProjectConfigSchema.safeParse(parsed);
|
|
48
|
+
if (!result.success) {
|
|
49
|
+
const issues = result.error.issues.map(i => ` ${i.path.join(".")}: ${i.message}`).join("\n");
|
|
50
|
+
throw new Error(`Invalid ${CONFIG_FILENAME}:\n${issues}`);
|
|
51
|
+
}
|
|
52
|
+
return result.data;
|
|
53
|
+
}
|
package/dist/registry.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { HandlerRegistry } from "./registry.js";
|
|
2
|
+
import type { RunObserver, Services, Workflow } from "./types.js";
|
|
3
|
+
export declare function defaultRegistry(): HandlerRegistry;
|
|
4
|
+
export declare function runWorkflowOnce(workflow: Workflow, opts: {
|
|
5
|
+
registry?: HandlerRegistry;
|
|
6
|
+
services: Services;
|
|
7
|
+
triggerNodeId?: string;
|
|
8
|
+
payload?: unknown;
|
|
9
|
+
observer?: RunObserver;
|
|
10
|
+
}): Promise<Map<string, unknown>>;
|
|
11
|
+
export declare class Scheduler {
|
|
12
|
+
private services;
|
|
13
|
+
private registry;
|
|
14
|
+
private observer;
|
|
15
|
+
private crons;
|
|
16
|
+
private active;
|
|
17
|
+
private pending;
|
|
18
|
+
private workflows;
|
|
19
|
+
constructor(services: Services, registry?: HandlerRegistry, observer?: RunObserver);
|
|
20
|
+
add(workflow: Workflow): void;
|
|
21
|
+
start(): void;
|
|
22
|
+
private enqueue;
|
|
23
|
+
private drain;
|
|
24
|
+
stop(): void;
|
|
25
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { Cron } from "croner";
|
|
2
|
+
import { HandlerRegistry } from "./registry.js";
|
|
3
|
+
import { runWorkflow } from "./executor.js";
|
|
4
|
+
import { transformHandler } from "./handlers/transform.js";
|
|
5
|
+
import { branchHandler } from "./handlers/branch.js";
|
|
6
|
+
import { httpHandler } from "./handlers/http.js";
|
|
7
|
+
import { agentHandler } from "./handlers/agent.js";
|
|
8
|
+
import { ConsoleObserver } from "./observer.js";
|
|
9
|
+
const passthrough = (type) => ({
|
|
10
|
+
type,
|
|
11
|
+
async execute(_n, ctx) {
|
|
12
|
+
return { output: ctx.triggerPayload ?? null };
|
|
13
|
+
},
|
|
14
|
+
});
|
|
15
|
+
export function defaultRegistry() {
|
|
16
|
+
const r = new HandlerRegistry();
|
|
17
|
+
r.register(transformHandler);
|
|
18
|
+
r.register(branchHandler);
|
|
19
|
+
r.register(httpHandler);
|
|
20
|
+
r.register(agentHandler);
|
|
21
|
+
r.register(passthrough("trigger.cron"));
|
|
22
|
+
r.register(passthrough("trigger.webhook"));
|
|
23
|
+
return r;
|
|
24
|
+
}
|
|
25
|
+
export async function runWorkflowOnce(workflow, opts) {
|
|
26
|
+
const registry = opts.registry ?? defaultRegistry();
|
|
27
|
+
const triggerNodeId = opts.triggerNodeId
|
|
28
|
+
?? workflow.nodes.find(n => n.type.startsWith("trigger."))?.id;
|
|
29
|
+
if (!triggerNodeId)
|
|
30
|
+
throw new Error(`workflow ${workflow.name} has no trigger node`);
|
|
31
|
+
return runWorkflow({ workflow, triggerNodeId, registry, services: opts.services, payload: opts.payload, observer: opts.observer });
|
|
32
|
+
}
|
|
33
|
+
export class Scheduler {
|
|
34
|
+
services;
|
|
35
|
+
registry;
|
|
36
|
+
observer;
|
|
37
|
+
crons = [];
|
|
38
|
+
active = 0;
|
|
39
|
+
pending = [];
|
|
40
|
+
workflows = [];
|
|
41
|
+
constructor(services, registry = defaultRegistry(), observer = new ConsoleObserver()) {
|
|
42
|
+
this.services = services;
|
|
43
|
+
this.registry = registry;
|
|
44
|
+
this.observer = observer;
|
|
45
|
+
}
|
|
46
|
+
add(workflow) { this.workflows.push(workflow); }
|
|
47
|
+
start() {
|
|
48
|
+
for (const wf of this.workflows) {
|
|
49
|
+
for (const node of wf.nodes) {
|
|
50
|
+
if (node.type !== "trigger.cron")
|
|
51
|
+
continue;
|
|
52
|
+
const pattern = String(node.config.cron);
|
|
53
|
+
this.crons.push(new Cron(pattern, () => this.enqueue(() => runWorkflowOnce(wf, { registry: this.registry, services: this.services, triggerNodeId: node.id, observer: this.observer })
|
|
54
|
+
.then(() => { }))));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
enqueue(run) {
|
|
59
|
+
this.pending.push(run);
|
|
60
|
+
this.drain();
|
|
61
|
+
}
|
|
62
|
+
drain() {
|
|
63
|
+
while (this.active < 4 && this.pending.length > 0) {
|
|
64
|
+
const run = this.pending.shift();
|
|
65
|
+
this.active++;
|
|
66
|
+
run().catch(e => this.services.logger.error("scheduled run failed", { error: String(e) }))
|
|
67
|
+
.finally(() => { this.active--; this.drain(); });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
stop() { for (const c of this.crons)
|
|
71
|
+
c.stop(); this.crons = []; }
|
|
72
|
+
}
|
package/dist/schema.d.ts
ADDED
package/dist/schema.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { parse as parseYaml } from "yaml";
|
|
3
|
+
const nodeType = z.enum([
|
|
4
|
+
"trigger.cron", "trigger.webhook",
|
|
5
|
+
"task.http", "task.transform", "task.branch",
|
|
6
|
+
"agent.run",
|
|
7
|
+
]);
|
|
8
|
+
const retry = z.object({ maxAttempts: z.number().int().min(1), backoffMs: z.number().int().min(0) });
|
|
9
|
+
const NODE_ID_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
10
|
+
const node = z.object({
|
|
11
|
+
id: z.string().min(1).superRefine((v, ctx) => {
|
|
12
|
+
if (!NODE_ID_RE.test(v)) {
|
|
13
|
+
ctx.addIssue({ code: "custom", message: `node id "${v}" is not a valid identifier (must match /^[A-Za-z_][A-Za-z0-9_]*$/)` });
|
|
14
|
+
}
|
|
15
|
+
}),
|
|
16
|
+
type: nodeType,
|
|
17
|
+
config: z.record(z.string(), z.unknown()).default({}),
|
|
18
|
+
retry: retry.optional(),
|
|
19
|
+
onError: z.enum(["fail", "continue"]).optional(),
|
|
20
|
+
});
|
|
21
|
+
const edge = z.object({ from: z.string().min(1), to: z.string().min(1), when: z.string().optional() });
|
|
22
|
+
const workflow = z.object({
|
|
23
|
+
name: z.string().min(1),
|
|
24
|
+
nodes: z.array(node).min(1),
|
|
25
|
+
edges: z.array(edge).default([]),
|
|
26
|
+
}).superRefine((wf, ctx) => {
|
|
27
|
+
const ids = new Set(wf.nodes.map(n => n.id));
|
|
28
|
+
for (const e of wf.edges) {
|
|
29
|
+
if (!ids.has(e.from))
|
|
30
|
+
ctx.addIssue({ code: "custom", message: `edge.from references missing node: ${e.from}` });
|
|
31
|
+
if (!ids.has(e.to))
|
|
32
|
+
ctx.addIssue({ code: "custom", message: `edge.to references missing node: ${e.to}` });
|
|
33
|
+
}
|
|
34
|
+
for (const n of wf.nodes) {
|
|
35
|
+
if (n.type === "trigger.cron") {
|
|
36
|
+
const cron = n.config.cron;
|
|
37
|
+
if (typeof cron !== "string" || cron.trim() === "") {
|
|
38
|
+
ctx.addIssue({ code: "custom", message: `node "${n.id}" (trigger.cron) requires a non-empty config.cron string` });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
export function loadWorkflow(yamlText) {
|
|
44
|
+
const raw = parseYaml(yamlText);
|
|
45
|
+
return workflow.parse(raw);
|
|
46
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface SecretProvider {
|
|
2
|
+
get(name: string): string | undefined;
|
|
3
|
+
}
|
|
4
|
+
export declare class EnvSecretProvider implements SecretProvider {
|
|
5
|
+
private env;
|
|
6
|
+
constructor(env?: NodeJS.ProcessEnv);
|
|
7
|
+
get(name: string): string | undefined;
|
|
8
|
+
}
|
|
9
|
+
export declare function resolveSecrets(input: string, provider?: SecretProvider): string;
|
package/dist/secrets.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export class EnvSecretProvider {
|
|
2
|
+
env;
|
|
3
|
+
constructor(env = process.env) {
|
|
4
|
+
this.env = env;
|
|
5
|
+
}
|
|
6
|
+
get(name) {
|
|
7
|
+
return this.env[name];
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
const defaultProvider = new EnvSecretProvider();
|
|
11
|
+
export function resolveSecrets(input, provider = defaultProvider) {
|
|
12
|
+
return input.replace(/\$\{([A-Z0-9_]+)\}/g, (_, name) => {
|
|
13
|
+
const v = provider.get(name);
|
|
14
|
+
if (v === undefined)
|
|
15
|
+
throw new Error(`missing required secret: ${name}`);
|
|
16
|
+
return v;
|
|
17
|
+
});
|
|
18
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { AgentRunner, McpServerRef } from "@agentic-surfaces/agent";
|
|
2
|
+
export type { AgentRunner, McpServerRef };
|
|
3
|
+
export type NodeType = "trigger.cron" | "trigger.webhook" | "task.http" | "task.transform" | "task.branch" | "agent.run";
|
|
4
|
+
export interface RetryPolicy {
|
|
5
|
+
maxAttempts: number;
|
|
6
|
+
backoffMs: number;
|
|
7
|
+
}
|
|
8
|
+
export interface WorkflowNode {
|
|
9
|
+
id: string;
|
|
10
|
+
type: NodeType;
|
|
11
|
+
config: Record<string, unknown>;
|
|
12
|
+
retry?: RetryPolicy;
|
|
13
|
+
onError?: "fail" | "continue";
|
|
14
|
+
}
|
|
15
|
+
export interface WorkflowEdge {
|
|
16
|
+
from: string;
|
|
17
|
+
to: string;
|
|
18
|
+
when?: string;
|
|
19
|
+
}
|
|
20
|
+
export interface Workflow {
|
|
21
|
+
name: string;
|
|
22
|
+
nodes: WorkflowNode[];
|
|
23
|
+
edges: WorkflowEdge[];
|
|
24
|
+
}
|
|
25
|
+
export interface Logger {
|
|
26
|
+
info(msg: string, meta?: unknown): void;
|
|
27
|
+
error(msg: string, meta?: unknown): void;
|
|
28
|
+
}
|
|
29
|
+
export interface Cache {
|
|
30
|
+
getCursor(k: string): string | undefined;
|
|
31
|
+
setCursor(k: string, v: string): void;
|
|
32
|
+
isSeen(k: string): boolean;
|
|
33
|
+
markSeen(k: string): void;
|
|
34
|
+
wipe(): void;
|
|
35
|
+
}
|
|
36
|
+
export interface Services {
|
|
37
|
+
agent: AgentRunner;
|
|
38
|
+
cache: Cache;
|
|
39
|
+
logger: Logger;
|
|
40
|
+
fetch: typeof fetch;
|
|
41
|
+
dryRun?: boolean;
|
|
42
|
+
agentDefaults?: {
|
|
43
|
+
model?: string;
|
|
44
|
+
runner?: string;
|
|
45
|
+
mcpServers?: McpServerRef[];
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
export interface RunContext {
|
|
49
|
+
workflow: Workflow;
|
|
50
|
+
outputs: Map<string, unknown>;
|
|
51
|
+
triggerNodeId: string;
|
|
52
|
+
triggerPayload?: unknown;
|
|
53
|
+
services: Services;
|
|
54
|
+
}
|
|
55
|
+
export interface NodeResult {
|
|
56
|
+
output: unknown;
|
|
57
|
+
branches?: string[];
|
|
58
|
+
}
|
|
59
|
+
export interface NodeHandler {
|
|
60
|
+
type: NodeType;
|
|
61
|
+
execute(node: WorkflowNode, ctx: RunContext): Promise<NodeResult>;
|
|
62
|
+
}
|
|
63
|
+
export interface RunObserver {
|
|
64
|
+
onRunStart?(workflow: string): void;
|
|
65
|
+
onNodeStart?(nodeId: string): void;
|
|
66
|
+
onNodeFinish?(nodeId: string, status: "success" | "failed", meta?: unknown): void;
|
|
67
|
+
onRunFinish?(workflow: string, status: "success" | "failed"): void;
|
|
68
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@agentic-surfaces/core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"main": "dist/index.js",
|
|
10
|
+
"types": "dist/index.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"default": "./dist/index.js"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist"
|
|
19
|
+
],
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"better-sqlite3": "^12.11.1",
|
|
22
|
+
"croner": "^10.0.1",
|
|
23
|
+
"jsonata": "^2.2.1",
|
|
24
|
+
"yaml": "^2.9.0",
|
|
25
|
+
"zod": "^4.4.3",
|
|
26
|
+
"@agentic-surfaces/agent": "0.1.0"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/better-sqlite3": "^7.6.13",
|
|
30
|
+
"msw": "^2.14.6"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build": "tsc -b",
|
|
34
|
+
"test": "vitest run --root ../.."
|
|
35
|
+
}
|
|
36
|
+
}
|