@dobby.ai/dobby 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.
Files changed (174) hide show
  1. package/.env.example +9 -0
  2. package/AGENTS.md +267 -0
  3. package/README.md +382 -0
  4. package/ROADMAP.md +34 -0
  5. package/config/cron.example.json +9 -0
  6. package/config/gateway.example.json +128 -0
  7. package/config/models.custom.example.json +27 -0
  8. package/dist/src/agent/event-forwarder.js +341 -0
  9. package/dist/src/agent/tests/event-forwarder.test.js +113 -0
  10. package/dist/src/cli/commands/config.js +243 -0
  11. package/dist/src/cli/commands/configure.js +61 -0
  12. package/dist/src/cli/commands/cron.js +288 -0
  13. package/dist/src/cli/commands/doctor.js +189 -0
  14. package/dist/src/cli/commands/extension.js +151 -0
  15. package/dist/src/cli/commands/init.js +286 -0
  16. package/dist/src/cli/commands/start.js +177 -0
  17. package/dist/src/cli/commands/topology.js +254 -0
  18. package/dist/src/cli/index.js +8 -0
  19. package/dist/src/cli/program.js +386 -0
  20. package/dist/src/cli/shared/config-io.js +223 -0
  21. package/dist/src/cli/shared/config-mutators.js +345 -0
  22. package/dist/src/cli/shared/config-path.js +207 -0
  23. package/dist/src/cli/shared/config-schema.js +159 -0
  24. package/dist/src/cli/shared/config-types.js +1 -0
  25. package/dist/src/cli/shared/configure-sections.js +429 -0
  26. package/dist/src/cli/shared/discord-config.js +12 -0
  27. package/dist/src/cli/shared/init-catalog.js +115 -0
  28. package/dist/src/cli/shared/init-models-file.js +65 -0
  29. package/dist/src/cli/shared/presets.js +86 -0
  30. package/dist/src/cli/shared/runtime.js +29 -0
  31. package/dist/src/cli/shared/schema-prompts.js +325 -0
  32. package/dist/src/cli/tests/config-command.test.js +42 -0
  33. package/dist/src/cli/tests/config-io.test.js +64 -0
  34. package/dist/src/cli/tests/config-mutators.test.js +47 -0
  35. package/dist/src/cli/tests/config-path.test.js +21 -0
  36. package/dist/src/cli/tests/discord-config.test.js +23 -0
  37. package/dist/src/cli/tests/doctor.test.js +107 -0
  38. package/dist/src/cli/tests/init-catalog.test.js +87 -0
  39. package/dist/src/cli/tests/presets.test.js +41 -0
  40. package/dist/src/cli/tests/program-options.test.js +92 -0
  41. package/dist/src/cli/tests/routing-config.test.js +199 -0
  42. package/dist/src/cli/tests/routing-legacy.test.js +191 -0
  43. package/dist/src/core/control-command.js +12 -0
  44. package/dist/src/core/dedup-store.js +92 -0
  45. package/dist/src/core/gateway.js +432 -0
  46. package/dist/src/core/routing.js +306 -0
  47. package/dist/src/core/runtime-registry.js +119 -0
  48. package/dist/src/core/tests/control-command.test.js +17 -0
  49. package/dist/src/core/tests/gateway-update-strategy.test.js +167 -0
  50. package/dist/src/core/tests/runtime-registry.test.js +116 -0
  51. package/dist/src/core/tests/typing-controller.test.js +103 -0
  52. package/dist/src/core/types.js +1 -0
  53. package/dist/src/core/typing-controller.js +88 -0
  54. package/dist/src/cron/config.js +114 -0
  55. package/dist/src/cron/schedule.js +49 -0
  56. package/dist/src/cron/service.js +196 -0
  57. package/dist/src/cron/store.js +142 -0
  58. package/dist/src/cron/types.js +1 -0
  59. package/dist/src/extension/loader.js +97 -0
  60. package/dist/src/extension/manager.js +269 -0
  61. package/dist/src/extension/manifest.js +21 -0
  62. package/dist/src/extension/registry.js +137 -0
  63. package/dist/src/main.js +6 -0
  64. package/dist/src/sandbox/executor.js +1 -0
  65. package/dist/src/sandbox/host-executor.js +111 -0
  66. package/docs/BOXLITE_SANDBOX_FEASIBILITY.md +175 -0
  67. package/docs/CRON_SCHEDULER_DESIGN.md +374 -0
  68. package/docs/DOCKER_SANDBOX_vs_BOXLITE.md +77 -0
  69. package/docs/EXTENSION_SYSTEM_ARCHITECTURE.md +119 -0
  70. package/docs/MVP.md +135 -0
  71. package/docs/RUNBOOK.md +242 -0
  72. package/docs/TEAMWORK_HANDOFF_DESIGN.md +440 -0
  73. package/package.json +43 -0
  74. package/plugins/connector-discord/dobby.manifest.json +18 -0
  75. package/plugins/connector-discord/index.js +1 -0
  76. package/plugins/connector-discord/package-lock.json +360 -0
  77. package/plugins/connector-discord/package.json +38 -0
  78. package/plugins/connector-discord/src/connector.ts +350 -0
  79. package/plugins/connector-discord/src/contribution.ts +21 -0
  80. package/plugins/connector-discord/src/mapper.ts +102 -0
  81. package/plugins/connector-discord/tsconfig.json +19 -0
  82. package/plugins/connector-feishu/dobby.manifest.json +18 -0
  83. package/plugins/connector-feishu/index.js +1 -0
  84. package/plugins/connector-feishu/package-lock.json +618 -0
  85. package/plugins/connector-feishu/package.json +38 -0
  86. package/plugins/connector-feishu/src/connector.ts +343 -0
  87. package/plugins/connector-feishu/src/contribution.ts +26 -0
  88. package/plugins/connector-feishu/src/mapper.ts +401 -0
  89. package/plugins/connector-feishu/tsconfig.json +19 -0
  90. package/plugins/plugin-sdk/index.d.ts +261 -0
  91. package/plugins/plugin-sdk/index.js +1 -0
  92. package/plugins/plugin-sdk/package-lock.json +12 -0
  93. package/plugins/plugin-sdk/package.json +22 -0
  94. package/plugins/provider-claude/dobby.manifest.json +17 -0
  95. package/plugins/provider-claude/index.js +1 -0
  96. package/plugins/provider-claude/package-lock.json +3398 -0
  97. package/plugins/provider-claude/package.json +39 -0
  98. package/plugins/provider-claude/src/contribution.ts +1018 -0
  99. package/plugins/provider-claude/tsconfig.json +19 -0
  100. package/plugins/provider-claude-cli/dobby.manifest.json +17 -0
  101. package/plugins/provider-claude-cli/index.js +1 -0
  102. package/plugins/provider-claude-cli/package-lock.json +2898 -0
  103. package/plugins/provider-claude-cli/package.json +38 -0
  104. package/plugins/provider-claude-cli/src/contribution.ts +1673 -0
  105. package/plugins/provider-claude-cli/tsconfig.json +19 -0
  106. package/plugins/provider-pi/dobby.manifest.json +17 -0
  107. package/plugins/provider-pi/index.js +1 -0
  108. package/plugins/provider-pi/package-lock.json +3877 -0
  109. package/plugins/provider-pi/package.json +40 -0
  110. package/plugins/provider-pi/src/contribution.ts +476 -0
  111. package/plugins/provider-pi/tsconfig.json +19 -0
  112. package/plugins/sandbox-core/boxlite.js +1 -0
  113. package/plugins/sandbox-core/dobby.manifest.json +17 -0
  114. package/plugins/sandbox-core/docker.js +1 -0
  115. package/plugins/sandbox-core/package-lock.json +136 -0
  116. package/plugins/sandbox-core/package.json +39 -0
  117. package/plugins/sandbox-core/src/boxlite-context.ts +2 -0
  118. package/plugins/sandbox-core/src/boxlite-contribution.ts +53 -0
  119. package/plugins/sandbox-core/src/boxlite-executor.ts +911 -0
  120. package/plugins/sandbox-core/src/docker-contribution.ts +43 -0
  121. package/plugins/sandbox-core/src/docker-executor.ts +217 -0
  122. package/plugins/sandbox-core/tsconfig.json +19 -0
  123. package/scripts/local-extensions.mjs +168 -0
  124. package/src/agent/event-forwarder.ts +414 -0
  125. package/src/cli/commands/config.ts +328 -0
  126. package/src/cli/commands/configure.ts +92 -0
  127. package/src/cli/commands/cron.ts +410 -0
  128. package/src/cli/commands/doctor.ts +230 -0
  129. package/src/cli/commands/extension.ts +205 -0
  130. package/src/cli/commands/init.ts +396 -0
  131. package/src/cli/commands/start.ts +223 -0
  132. package/src/cli/commands/topology.ts +383 -0
  133. package/src/cli/index.ts +9 -0
  134. package/src/cli/program.ts +465 -0
  135. package/src/cli/shared/config-io.ts +277 -0
  136. package/src/cli/shared/config-mutators.ts +440 -0
  137. package/src/cli/shared/config-schema.ts +228 -0
  138. package/src/cli/shared/config-types.ts +121 -0
  139. package/src/cli/shared/configure-sections.ts +551 -0
  140. package/src/cli/shared/discord-config.ts +14 -0
  141. package/src/cli/shared/init-catalog.ts +189 -0
  142. package/src/cli/shared/init-models-file.ts +77 -0
  143. package/src/cli/shared/runtime.ts +33 -0
  144. package/src/cli/shared/schema-prompts.ts +414 -0
  145. package/src/cli/tests/config-command.test.ts +56 -0
  146. package/src/cli/tests/config-io.test.ts +92 -0
  147. package/src/cli/tests/config-mutators.test.ts +59 -0
  148. package/src/cli/tests/doctor.test.ts +120 -0
  149. package/src/cli/tests/init-catalog.test.ts +96 -0
  150. package/src/cli/tests/program-options.test.ts +113 -0
  151. package/src/cli/tests/routing-config.test.ts +209 -0
  152. package/src/core/control-command.ts +12 -0
  153. package/src/core/dedup-store.ts +103 -0
  154. package/src/core/gateway.ts +607 -0
  155. package/src/core/routing.ts +379 -0
  156. package/src/core/runtime-registry.ts +141 -0
  157. package/src/core/tests/control-command.test.ts +20 -0
  158. package/src/core/tests/runtime-registry.test.ts +140 -0
  159. package/src/core/tests/typing-controller.test.ts +129 -0
  160. package/src/core/types.ts +318 -0
  161. package/src/core/typing-controller.ts +119 -0
  162. package/src/cron/config.ts +154 -0
  163. package/src/cron/schedule.ts +61 -0
  164. package/src/cron/service.ts +249 -0
  165. package/src/cron/store.ts +155 -0
  166. package/src/cron/types.ts +60 -0
  167. package/src/extension/loader.ts +145 -0
  168. package/src/extension/manager.ts +355 -0
  169. package/src/extension/manifest.ts +26 -0
  170. package/src/extension/registry.ts +229 -0
  171. package/src/main.ts +8 -0
  172. package/src/sandbox/executor.ts +44 -0
  173. package/src/sandbox/host-executor.ts +118 -0
  174. package/tsconfig.json +18 -0
@@ -0,0 +1,116 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { RuntimeRegistry } from "../runtime-registry.js";
4
+ function deferred() {
5
+ let resolve;
6
+ const promise = new Promise((res) => {
7
+ resolve = res;
8
+ });
9
+ return { promise, resolve };
10
+ }
11
+ function createLogger() {
12
+ return {
13
+ error() { },
14
+ warn() { },
15
+ info() { },
16
+ debug() { },
17
+ };
18
+ }
19
+ function createConversationRuntime(id, abortCalls, closeCalls) {
20
+ return {
21
+ key: id,
22
+ routeId: "route.main",
23
+ route: {
24
+ projectRoot: "/tmp/project",
25
+ tools: "full",
26
+ mentions: "required",
27
+ provider: "provider.main",
28
+ sandbox: "host.builtin",
29
+ },
30
+ providerId: "provider.main",
31
+ sandboxId: "host.builtin",
32
+ runtime: {
33
+ async prompt() { },
34
+ subscribe() {
35
+ return () => { };
36
+ },
37
+ async abort() {
38
+ abortCalls.push(id);
39
+ },
40
+ dispose() { },
41
+ },
42
+ async close() {
43
+ closeCalls.push(id);
44
+ },
45
+ };
46
+ }
47
+ test("cancel aborts the active run and drops queued turns", async () => {
48
+ const registry = new RuntimeRegistry(createLogger());
49
+ const abortCalls = [];
50
+ const closeCalls = [];
51
+ let runtimeCount = 0;
52
+ const createRuntime = async () => {
53
+ runtimeCount += 1;
54
+ return createConversationRuntime(`runtime-${runtimeCount}`, abortCalls, closeCalls);
55
+ };
56
+ const firstStarted = deferred();
57
+ const releaseFirst = deferred();
58
+ const started = [];
59
+ const firstTurn = registry.run("conversation", createRuntime, async (runtime) => {
60
+ started.push(runtime.key);
61
+ firstStarted.resolve(undefined);
62
+ await releaseFirst.promise;
63
+ });
64
+ await firstStarted.promise;
65
+ const secondTurn = registry.run("conversation", createRuntime, async (runtime) => {
66
+ started.push(runtime.key);
67
+ });
68
+ const thirdTurn = registry.run("conversation", createRuntime, async (runtime) => {
69
+ started.push(runtime.key);
70
+ });
71
+ assert.equal(await registry.cancel("conversation"), true);
72
+ assert.deepEqual(abortCalls, ["runtime-1"]);
73
+ releaseFirst.resolve(undefined);
74
+ await Promise.all([firstTurn, secondTurn, thirdTurn]);
75
+ assert.deepEqual(started, ["runtime-1"]);
76
+ assert.deepEqual(closeCalls, []);
77
+ await registry.run("conversation", createRuntime, async (runtime) => {
78
+ started.push(runtime.key);
79
+ });
80
+ assert.deepEqual(started, ["runtime-1", "runtime-1"]);
81
+ assert.equal(runtimeCount, 1);
82
+ });
83
+ test("reset closes the current runtime and recreates it on the next turn", async () => {
84
+ const registry = new RuntimeRegistry(createLogger());
85
+ const abortCalls = [];
86
+ const closeCalls = [];
87
+ let runtimeCount = 0;
88
+ const createRuntime = async () => {
89
+ runtimeCount += 1;
90
+ return createConversationRuntime(`runtime-${runtimeCount}`, abortCalls, closeCalls);
91
+ };
92
+ const firstStarted = deferred();
93
+ const releaseFirst = deferred();
94
+ const started = [];
95
+ const firstTurn = registry.run("conversation", createRuntime, async (runtime) => {
96
+ started.push(runtime.key);
97
+ firstStarted.resolve(undefined);
98
+ await releaseFirst.promise;
99
+ });
100
+ await firstStarted.promise;
101
+ const queuedTurn = registry.run("conversation", createRuntime, async (runtime) => {
102
+ started.push(runtime.key);
103
+ });
104
+ const resetPromise = registry.reset("conversation");
105
+ releaseFirst.resolve(undefined);
106
+ assert.equal(await resetPromise, true);
107
+ await Promise.all([firstTurn, queuedTurn]);
108
+ assert.deepEqual(abortCalls, ["runtime-1"]);
109
+ assert.deepEqual(closeCalls, ["runtime-1"]);
110
+ assert.deepEqual(started, ["runtime-1"]);
111
+ await registry.run("conversation", createRuntime, async (runtime) => {
112
+ started.push(runtime.key);
113
+ });
114
+ assert.deepEqual(started, ["runtime-1", "runtime-2"]);
115
+ assert.equal(runtimeCount, 2);
116
+ });
@@ -0,0 +1,103 @@
1
+ import assert from "node:assert/strict";
2
+ import { setTimeout as delay } from "node:timers/promises";
3
+ import test from "node:test";
4
+ import { createTypingKeepAliveController } from "../typing-controller.js";
5
+ function createLogger() {
6
+ return {
7
+ error() { },
8
+ warn() { },
9
+ info() { },
10
+ debug() { },
11
+ };
12
+ }
13
+ function createInbound() {
14
+ return {
15
+ connectorId: "discord.main",
16
+ platform: "discord",
17
+ accountId: "discord.main",
18
+ source: {
19
+ type: "channel",
20
+ id: "123",
21
+ },
22
+ chatId: "123",
23
+ messageId: "m-1",
24
+ userId: "u-1",
25
+ userName: "tester",
26
+ text: "hello",
27
+ attachments: [],
28
+ timestampMs: Date.now(),
29
+ raw: {},
30
+ isDirectMessage: false,
31
+ mentionedBot: true,
32
+ };
33
+ }
34
+ function createConnector(sentTyping) {
35
+ return {
36
+ id: "discord.main",
37
+ platform: "discord",
38
+ name: "discord",
39
+ capabilities: {
40
+ updateStrategy: "edit",
41
+ supportedSources: ["channel"],
42
+ supportsThread: true,
43
+ supportsTyping: true,
44
+ supportsFileUpload: true,
45
+ maxTextLength: 2000,
46
+ },
47
+ async start() { },
48
+ async send() {
49
+ return { messageId: "reply-1" };
50
+ },
51
+ async sendTyping() {
52
+ sentTyping.push(Date.now());
53
+ },
54
+ async stop() { },
55
+ };
56
+ }
57
+ test("typing is sent before the first visible output when response is slow", async () => {
58
+ const sentTyping = [];
59
+ const controller = createTypingKeepAliveController(createConnector(sentTyping), createInbound(), createLogger(), {
60
+ initialDelayMs: 0,
61
+ keepaliveIntervalMs: 200,
62
+ });
63
+ try {
64
+ await controller.prime();
65
+ assert.equal(sentTyping.length, 1);
66
+ }
67
+ finally {
68
+ controller.stop();
69
+ }
70
+ });
71
+ test("visible output before prime suppresses typing", async () => {
72
+ const sentTyping = [];
73
+ const controller = createTypingKeepAliveController(createConnector(sentTyping), createInbound(), createLogger(), {
74
+ initialDelayMs: 0,
75
+ keepaliveIntervalMs: 200,
76
+ });
77
+ try {
78
+ controller.markVisibleOutput();
79
+ await controller.prime();
80
+ assert.deepEqual(sentTyping, []);
81
+ }
82
+ finally {
83
+ controller.stop();
84
+ }
85
+ });
86
+ test("visible output stops further typing keepalive", async () => {
87
+ const sentTyping = [];
88
+ const controller = createTypingKeepAliveController(createConnector(sentTyping), createInbound(), createLogger(), {
89
+ initialDelayMs: 0,
90
+ keepaliveIntervalMs: 15,
91
+ });
92
+ try {
93
+ await controller.prime();
94
+ const beforeVisibleOutput = sentTyping.length;
95
+ assert.equal(beforeVisibleOutput > 0, true);
96
+ controller.markVisibleOutput();
97
+ await delay(30);
98
+ assert.equal(sentTyping.length, beforeVisibleOutput);
99
+ }
100
+ finally {
101
+ controller.stop();
102
+ }
103
+ });
@@ -0,0 +1 @@
1
+ export const BUILTIN_HOST_SANDBOX_ID = "host.builtin";
@@ -0,0 +1,88 @@
1
+ const DEFAULT_TYPING_INITIAL_DELAY_MS = 0;
2
+ const DEFAULT_TYPING_KEEPALIVE_INTERVAL_MS = 8_000;
3
+ export function createTypingKeepAliveController(connector, message, logger, options = {}) {
4
+ const sendTypingMethod = connector.sendTyping;
5
+ if (!connector.capabilities.supportsTyping || !sendTypingMethod) {
6
+ return {
7
+ prime: async () => { },
8
+ markVisibleOutput: () => { },
9
+ stop: () => { },
10
+ };
11
+ }
12
+ const typingTarget = {
13
+ platform: message.platform,
14
+ accountId: message.accountId,
15
+ chatId: message.chatId,
16
+ ...(message.threadId ? { threadId: message.threadId } : {}),
17
+ };
18
+ const initialDelayMs = options.initialDelayMs ?? DEFAULT_TYPING_INITIAL_DELAY_MS;
19
+ const keepaliveIntervalMs = options.keepaliveIntervalMs ?? DEFAULT_TYPING_KEEPALIVE_INTERVAL_MS;
20
+ let stopped = false;
21
+ let visibleOutputSent = false;
22
+ let inFlight = false;
23
+ let keepaliveTimer = null;
24
+ const clearKeepalive = () => {
25
+ if (!keepaliveTimer) {
26
+ return;
27
+ }
28
+ clearInterval(keepaliveTimer);
29
+ keepaliveTimer = null;
30
+ };
31
+ const sendTyping = async () => {
32
+ if (stopped || visibleOutputSent || inFlight) {
33
+ return;
34
+ }
35
+ inFlight = true;
36
+ try {
37
+ await sendTypingMethod.call(connector, typingTarget);
38
+ }
39
+ catch (error) {
40
+ logger.warn({
41
+ err: error,
42
+ connectorId: message.connectorId,
43
+ chatId: message.chatId,
44
+ threadId: message.threadId,
45
+ }, "Failed to send typing indicator");
46
+ }
47
+ finally {
48
+ inFlight = false;
49
+ }
50
+ };
51
+ const startKeepalive = () => {
52
+ if (keepaliveTimer || stopped || visibleOutputSent) {
53
+ return;
54
+ }
55
+ keepaliveTimer = setInterval(() => {
56
+ void sendTyping();
57
+ }, keepaliveIntervalMs);
58
+ };
59
+ return {
60
+ prime: async () => {
61
+ if (stopped || visibleOutputSent) {
62
+ return;
63
+ }
64
+ if (initialDelayMs > 0) {
65
+ await new Promise((resolve) => {
66
+ const timer = setTimeout(resolve, initialDelayMs);
67
+ if (stopped || visibleOutputSent) {
68
+ clearTimeout(timer);
69
+ resolve();
70
+ }
71
+ });
72
+ }
73
+ await sendTyping();
74
+ startKeepalive();
75
+ },
76
+ markVisibleOutput: () => {
77
+ if (visibleOutputSent) {
78
+ return;
79
+ }
80
+ visibleOutputSent = true;
81
+ clearKeepalive();
82
+ },
83
+ stop: () => {
84
+ stopped = true;
85
+ clearKeepalive();
86
+ },
87
+ };
88
+ }
@@ -0,0 +1,114 @@
1
+ import { dirname, isAbsolute, resolve } from "node:path";
2
+ import { access, mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { z } from "zod";
5
+ const rawCronConfigSchema = z.object({
6
+ enabled: z.boolean().default(true),
7
+ storeFile: z.string().min(1).optional(),
8
+ runLogFile: z.string().min(1).optional(),
9
+ pollIntervalMs: z.number().int().positive().default(10_000),
10
+ maxConcurrentRuns: z.number().int().positive().default(1),
11
+ runMissedOnStartup: z.boolean().default(true),
12
+ jobTimeoutMs: z.number().int().positive().default(10 * 60 * 1000),
13
+ });
14
+ function expandHome(value) {
15
+ if (value === "~") {
16
+ return homedir();
17
+ }
18
+ if (value.startsWith("~/") || value.startsWith("~\\")) {
19
+ return resolve(homedir(), value.slice(2));
20
+ }
21
+ return value;
22
+ }
23
+ function gatewayConfigBaseDir(gatewayConfigPath) {
24
+ const configDir = dirname(resolve(gatewayConfigPath));
25
+ return configDir.endsWith("/config") || configDir.endsWith("\\config")
26
+ ? resolve(configDir, "..")
27
+ : configDir;
28
+ }
29
+ function resolvePathFromBase(baseDir, value) {
30
+ const expanded = expandHome(value);
31
+ return isAbsolute(expanded) ? resolve(expanded) : resolve(baseDir, expanded);
32
+ }
33
+ async function fileExists(filePath) {
34
+ try {
35
+ await access(filePath);
36
+ return true;
37
+ }
38
+ catch {
39
+ return false;
40
+ }
41
+ }
42
+ function defaultCronConfigPayload() {
43
+ return {
44
+ enabled: true,
45
+ storeFile: "./data/state/cron-jobs.json",
46
+ runLogFile: "./data/state/cron-runs.jsonl",
47
+ pollIntervalMs: 10_000,
48
+ maxConcurrentRuns: 1,
49
+ runMissedOnStartup: true,
50
+ jobTimeoutMs: 10 * 60 * 1000,
51
+ };
52
+ }
53
+ async function ensureCronConfigFile(configPath) {
54
+ if (await fileExists(configPath)) {
55
+ return;
56
+ }
57
+ await mkdir(dirname(configPath), { recursive: true });
58
+ const payload = `${JSON.stringify(defaultCronConfigPayload(), null, 2)}\n`;
59
+ await writeFile(configPath, payload, "utf-8");
60
+ }
61
+ function resolveCronConfigPath(options) {
62
+ if (options.explicitCronConfigPath && options.explicitCronConfigPath.trim().length > 0) {
63
+ const rawPath = options.explicitCronConfigPath.trim();
64
+ const expanded = expandHome(rawPath);
65
+ const configPath = isAbsolute(expanded) ? resolve(expanded) : resolve(process.cwd(), expanded);
66
+ return { configPath, source: "explicit" };
67
+ }
68
+ const envPath = process.env.DOBBY_CRON_CONFIG_PATH?.trim();
69
+ if (envPath) {
70
+ const expanded = expandHome(envPath);
71
+ const configPath = isAbsolute(expanded) ? resolve(expanded) : resolve(process.cwd(), expanded);
72
+ return { configPath, source: "env" };
73
+ }
74
+ const candidate = resolve(dirname(resolve(options.gatewayConfigPath)), "cron.json");
75
+ if (process.env.DOBBY_CRON_CONFIG_PATH === undefined && options.explicitCronConfigPath === undefined) {
76
+ // no-op branch to make intent explicit for the resolution order
77
+ }
78
+ return {
79
+ configPath: candidate,
80
+ source: "gateway",
81
+ };
82
+ }
83
+ function normalizeCronConfig(gatewayConfigPath, gatewayConfig, raw) {
84
+ const baseDir = gatewayConfigBaseDir(gatewayConfigPath);
85
+ const defaultStoreFile = resolve(gatewayConfig.data.stateDir, "cron-jobs.json");
86
+ const defaultRunLogFile = resolve(gatewayConfig.data.stateDir, "cron-runs.jsonl");
87
+ return {
88
+ enabled: raw.enabled,
89
+ storeFile: raw.storeFile ? resolvePathFromBase(baseDir, raw.storeFile) : defaultStoreFile,
90
+ runLogFile: raw.runLogFile ? resolvePathFromBase(baseDir, raw.runLogFile) : defaultRunLogFile,
91
+ pollIntervalMs: raw.pollIntervalMs,
92
+ maxConcurrentRuns: raw.maxConcurrentRuns,
93
+ runMissedOnStartup: raw.runMissedOnStartup,
94
+ jobTimeoutMs: raw.jobTimeoutMs,
95
+ };
96
+ }
97
+ export async function loadCronConfig(options) {
98
+ const resolved = resolveCronConfigPath(options);
99
+ const gatewayFallbackPath = resolve(options.gatewayConfig.data.stateDir, "cron.config.json");
100
+ let configPath = resolved.configPath;
101
+ let source = resolved.source;
102
+ if (source === "gateway" && !(await fileExists(configPath))) {
103
+ configPath = gatewayFallbackPath;
104
+ source = "state";
105
+ }
106
+ await ensureCronConfigFile(configPath);
107
+ const raw = await readFile(configPath, "utf-8");
108
+ const parsed = rawCronConfigSchema.parse(JSON.parse(raw));
109
+ return {
110
+ configPath,
111
+ source,
112
+ config: normalizeCronConfig(options.gatewayConfigPath, options.gatewayConfig, parsed),
113
+ };
114
+ }
@@ -0,0 +1,49 @@
1
+ import { CronExpressionParser } from "cron-parser";
2
+ const BACKOFF_STEPS_MS = [30_000, 60_000, 5 * 60_000, 15 * 60_000];
3
+ function parseAtTimestamp(at) {
4
+ const timestamp = Date.parse(at);
5
+ if (!Number.isFinite(timestamp)) {
6
+ throw new Error(`Invalid at schedule timestamp '${at}'`);
7
+ }
8
+ return timestamp;
9
+ }
10
+ function nextCronAtMs(expr, currentDateMs, tz) {
11
+ const parsed = CronExpressionParser.parse(expr, {
12
+ currentDate: new Date(currentDateMs),
13
+ ...(tz ? { tz } : {}),
14
+ });
15
+ return parsed.next().toDate().getTime();
16
+ }
17
+ export function computeInitialNextRunAtMs(schedule, nowMs) {
18
+ if (schedule.kind === "at") {
19
+ return parseAtTimestamp(schedule.at);
20
+ }
21
+ if (schedule.kind === "every") {
22
+ return nowMs + schedule.everyMs;
23
+ }
24
+ return nextCronAtMs(schedule.expr, nowMs, schedule.tz);
25
+ }
26
+ export function computeNextRunAfterSuccessMs(schedule, nowMs) {
27
+ if (schedule.kind === "at") {
28
+ return undefined;
29
+ }
30
+ if (schedule.kind === "every") {
31
+ return nowMs + schedule.everyMs;
32
+ }
33
+ return nextCronAtMs(schedule.expr, nowMs, schedule.tz);
34
+ }
35
+ export function computeBackoffDelayMs(consecutiveErrors) {
36
+ const safeErrors = Number.isFinite(consecutiveErrors) && consecutiveErrors > 0 ? Math.floor(consecutiveErrors) : 1;
37
+ const index = Math.min(safeErrors - 1, BACKOFF_STEPS_MS.length - 1);
38
+ const fallback = BACKOFF_STEPS_MS[BACKOFF_STEPS_MS.length - 1];
39
+ return BACKOFF_STEPS_MS[index] ?? fallback;
40
+ }
41
+ export function describeSchedule(schedule) {
42
+ if (schedule.kind === "at") {
43
+ return `at ${schedule.at}`;
44
+ }
45
+ if (schedule.kind === "every") {
46
+ return `every ${schedule.everyMs}ms`;
47
+ }
48
+ return schedule.tz ? `cron '${schedule.expr}' (tz=${schedule.tz})` : `cron '${schedule.expr}'`;
49
+ }
@@ -0,0 +1,196 @@
1
+ import { computeBackoffDelayMs, computeInitialNextRunAtMs, computeNextRunAfterSuccessMs } from "./schedule.js";
2
+ export class CronService {
3
+ options;
4
+ timer = null;
5
+ activeRuns = new Map();
6
+ tickInFlight = false;
7
+ started = false;
8
+ stopping = false;
9
+ constructor(options) {
10
+ this.options = options;
11
+ }
12
+ async start() {
13
+ if (this.started) {
14
+ return;
15
+ }
16
+ await this.options.store.load();
17
+ await this.recoverOnStartup();
18
+ if (!this.options.config.enabled) {
19
+ this.options.logger.info("Cron scheduler is disabled by config");
20
+ this.started = true;
21
+ return;
22
+ }
23
+ const intervalMs = Math.min(this.options.config.pollIntervalMs, 60_000);
24
+ await this.tick();
25
+ this.timer = setInterval(() => {
26
+ void this.tick();
27
+ }, intervalMs);
28
+ this.started = true;
29
+ this.options.logger.info({
30
+ pollIntervalMs: intervalMs,
31
+ maxConcurrentRuns: this.options.config.maxConcurrentRuns,
32
+ }, "Cron scheduler started");
33
+ }
34
+ async stop() {
35
+ this.stopping = true;
36
+ if (this.timer) {
37
+ clearInterval(this.timer);
38
+ this.timer = null;
39
+ }
40
+ await Promise.allSettled(this.activeRuns.values());
41
+ this.activeRuns.clear();
42
+ this.started = false;
43
+ }
44
+ async triggerNow(jobId) {
45
+ await this.options.store.load();
46
+ const job = this.options.store.getJob(jobId);
47
+ if (!job) {
48
+ throw new Error(`Cron job '${jobId}' does not exist`);
49
+ }
50
+ await this.enqueueJob(job, Date.now());
51
+ const latestRun = [...this.activeRuns.values()].at(-1);
52
+ if (latestRun) {
53
+ await latestRun;
54
+ }
55
+ }
56
+ async recoverOnStartup() {
57
+ const now = Date.now();
58
+ for (const job of this.options.store.listJobs()) {
59
+ let changed = false;
60
+ const next = structuredClone(job);
61
+ if (next.state.runningAtMs !== undefined) {
62
+ next.state.runningAtMs = undefined;
63
+ next.state.lastStatus = "error";
64
+ next.state.lastError = "Recovered stale running state after restart";
65
+ changed = true;
66
+ }
67
+ if (next.state.nextRunAtMs === undefined) {
68
+ next.state.nextRunAtMs = computeInitialNextRunAtMs(next.schedule, now);
69
+ changed = true;
70
+ }
71
+ if (this.options.config.runMissedOnStartup
72
+ && next.enabled
73
+ && next.state.nextRunAtMs !== undefined
74
+ && next.state.nextRunAtMs <= now) {
75
+ next.state.nextRunAtMs = now;
76
+ changed = true;
77
+ }
78
+ if (changed) {
79
+ next.updatedAtMs = now;
80
+ await this.options.store.upsertJob(next);
81
+ }
82
+ }
83
+ }
84
+ async tick() {
85
+ if (this.stopping || !this.options.config.enabled) {
86
+ return;
87
+ }
88
+ if (this.tickInFlight) {
89
+ return;
90
+ }
91
+ this.tickInFlight = true;
92
+ try {
93
+ // Reload from disk on every tick so CLI mutations (cron run/update/pause/resume)
94
+ // made by a separate process become visible to the long-running scheduler.
95
+ await this.options.store.load();
96
+ const now = Date.now();
97
+ const dueJobs = this.options.store.listJobs().filter((job) => job.enabled
98
+ && job.state.runningAtMs === undefined
99
+ && job.state.nextRunAtMs !== undefined
100
+ && job.state.nextRunAtMs <= now);
101
+ for (const job of dueJobs) {
102
+ if (this.activeRuns.size >= this.options.config.maxConcurrentRuns) {
103
+ break;
104
+ }
105
+ await this.enqueueJob(job, now);
106
+ }
107
+ }
108
+ finally {
109
+ this.tickInFlight = false;
110
+ }
111
+ }
112
+ async enqueueJob(job, now) {
113
+ const scheduledAtMs = job.state.nextRunAtMs ?? now;
114
+ const runContext = {
115
+ runId: `${job.id}:${scheduledAtMs}`,
116
+ jobId: job.id,
117
+ scheduledAtMs,
118
+ };
119
+ await this.options.store.updateJob(job.id, (current) => ({
120
+ ...current,
121
+ updatedAtMs: now,
122
+ state: {
123
+ ...current.state,
124
+ runningAtMs: now,
125
+ },
126
+ }));
127
+ const runPromise = this.executeJobRun(runContext)
128
+ .catch((error) => {
129
+ this.options.logger.warn({ err: error, jobId: runContext.jobId, runId: runContext.runId }, "Cron run failed");
130
+ })
131
+ .finally(() => {
132
+ this.activeRuns.delete(runContext.runId);
133
+ });
134
+ this.activeRuns.set(runContext.runId, runPromise);
135
+ }
136
+ async executeJobRun(run) {
137
+ const startedAtMs = Date.now();
138
+ const job = this.options.store.getJob(run.jobId);
139
+ if (!job) {
140
+ return;
141
+ }
142
+ let status = "ok";
143
+ let errorMessage;
144
+ try {
145
+ await this.options.gateway.handleScheduled({
146
+ jobId: job.id,
147
+ runId: run.runId,
148
+ prompt: job.prompt,
149
+ connectorId: job.delivery.connectorId,
150
+ routeId: job.delivery.routeId,
151
+ channelId: job.delivery.channelId,
152
+ ...(job.delivery.threadId ? { threadId: job.delivery.threadId } : {}),
153
+ timeoutMs: this.options.config.jobTimeoutMs,
154
+ });
155
+ }
156
+ catch (error) {
157
+ status = "error";
158
+ errorMessage = error instanceof Error ? error.message : String(error);
159
+ }
160
+ const endedAtMs = Date.now();
161
+ await this.options.store.updateJob(job.id, (current) => {
162
+ const isSuccess = status === "ok";
163
+ const previousErrors = current.state.consecutiveErrors ?? 0;
164
+ const nextErrorCount = isSuccess ? 0 : previousErrors + 1;
165
+ const nextRunAtMs = isSuccess
166
+ ? computeNextRunAfterSuccessMs(current.schedule, endedAtMs)
167
+ : current.schedule.kind === "at"
168
+ ? undefined
169
+ : endedAtMs + computeBackoffDelayMs(nextErrorCount);
170
+ return {
171
+ ...current,
172
+ enabled: current.schedule.kind === "at" ? false : current.enabled,
173
+ updatedAtMs: endedAtMs,
174
+ state: {
175
+ ...current.state,
176
+ runningAtMs: undefined,
177
+ lastRunAtMs: endedAtMs,
178
+ lastStatus: isSuccess ? "ok" : "error",
179
+ lastError: isSuccess ? undefined : errorMessage,
180
+ consecutiveErrors: nextErrorCount,
181
+ nextRunAtMs,
182
+ },
183
+ };
184
+ });
185
+ const runRecord = {
186
+ runId: run.runId,
187
+ jobId: job.id,
188
+ jobName: job.name,
189
+ startedAtMs,
190
+ endedAtMs,
191
+ status,
192
+ ...(errorMessage ? { error: errorMessage } : {}),
193
+ };
194
+ await this.options.store.appendRunLog(runRecord);
195
+ }
196
+ }