@hybridaione/hybridclaw 0.2.2 → 0.2.6
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/.github/workflows/ci.yml +70 -0
- package/.husky/pre-commit +1 -0
- package/CHANGELOG.md +85 -0
- package/CONTRIBUTING.md +33 -0
- package/README.md +41 -16
- package/SECURITY.md +17 -0
- package/biome.json +35 -0
- package/config.example.json +71 -8
- package/container/package-lock.json +2 -2
- package/container/package.json +1 -1
- package/container/src/approval-policy.ts +1303 -0
- package/container/src/browser-tools.ts +431 -136
- package/container/src/extensions.ts +36 -12
- package/container/src/hybridai-client.ts +34 -13
- package/container/src/index.ts +451 -109
- package/container/src/ipc.ts +5 -3
- package/container/src/token-usage.ts +20 -10
- package/container/src/tools.ts +599 -225
- package/container/src/types.ts +32 -2
- package/container/src/web-fetch.ts +89 -32
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +10 -2
- package/dist/agent.js.map +1 -1
- package/dist/audit-cli.d.ts.map +1 -1
- package/dist/audit-cli.js +4 -2
- package/dist/audit-cli.js.map +1 -1
- package/dist/audit-events.d.ts.map +1 -1
- package/dist/audit-events.js +53 -3
- package/dist/audit-events.js.map +1 -1
- package/dist/audit-trail.d.ts.map +1 -1
- package/dist/audit-trail.js +17 -8
- package/dist/audit-trail.js.map +1 -1
- package/dist/channels/discord/attachments.d.ts.map +1 -1
- package/dist/channels/discord/attachments.js +14 -7
- package/dist/channels/discord/attachments.js.map +1 -1
- package/dist/channels/discord/debounce.d.ts +9 -0
- package/dist/channels/discord/debounce.d.ts.map +1 -0
- package/dist/channels/discord/debounce.js +20 -0
- package/dist/channels/discord/debounce.js.map +1 -0
- package/dist/channels/discord/delivery.d.ts +4 -1
- package/dist/channels/discord/delivery.d.ts.map +1 -1
- package/dist/channels/discord/delivery.js +19 -3
- package/dist/channels/discord/delivery.js.map +1 -1
- package/dist/channels/discord/human-delay.d.ts +16 -0
- package/dist/channels/discord/human-delay.d.ts.map +1 -0
- package/dist/channels/discord/human-delay.js +29 -0
- package/dist/channels/discord/human-delay.js.map +1 -0
- package/dist/channels/discord/inbound.d.ts +4 -0
- package/dist/channels/discord/inbound.d.ts.map +1 -1
- package/dist/channels/discord/inbound.js +45 -4
- package/dist/channels/discord/inbound.js.map +1 -1
- package/dist/channels/discord/mentions.d.ts.map +1 -1
- package/dist/channels/discord/mentions.js +16 -4
- package/dist/channels/discord/mentions.js.map +1 -1
- package/dist/channels/discord/presence.d.ts +33 -0
- package/dist/channels/discord/presence.d.ts.map +1 -0
- package/dist/channels/discord/presence.js +111 -0
- package/dist/channels/discord/presence.js.map +1 -0
- package/dist/channels/discord/rate-limiter.d.ts +14 -0
- package/dist/channels/discord/rate-limiter.d.ts.map +1 -0
- package/dist/channels/discord/rate-limiter.js +49 -0
- package/dist/channels/discord/rate-limiter.js.map +1 -0
- package/dist/channels/discord/reactions.d.ts +38 -0
- package/dist/channels/discord/reactions.d.ts.map +1 -0
- package/dist/channels/discord/reactions.js +151 -0
- package/dist/channels/discord/reactions.js.map +1 -0
- package/dist/channels/discord/runtime.d.ts +6 -3
- package/dist/channels/discord/runtime.d.ts.map +1 -1
- package/dist/channels/discord/runtime.js +621 -125
- package/dist/channels/discord/runtime.js.map +1 -1
- package/dist/channels/discord/stream.d.ts +4 -1
- package/dist/channels/discord/stream.d.ts.map +1 -1
- package/dist/channels/discord/stream.js +16 -8
- package/dist/channels/discord/stream.js.map +1 -1
- package/dist/channels/discord/tool-actions.d.ts.map +1 -1
- package/dist/channels/discord/tool-actions.js +24 -12
- package/dist/channels/discord/tool-actions.js.map +1 -1
- package/dist/channels/discord/typing.d.ts +15 -0
- package/dist/channels/discord/typing.d.ts.map +1 -0
- package/dist/channels/discord/typing.js +106 -0
- package/dist/channels/discord/typing.js.map +1 -0
- package/dist/chunk.d.ts.map +1 -1
- package/dist/chunk.js +4 -2
- package/dist/chunk.js.map +1 -1
- package/dist/cli.js +47 -22
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +19 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +103 -18
- package/dist/config.js.map +1 -1
- package/dist/container-runner.d.ts.map +1 -1
- package/dist/container-runner.js +58 -26
- package/dist/container-runner.js.map +1 -1
- package/dist/container-setup.d.ts.map +1 -1
- package/dist/container-setup.js +10 -9
- package/dist/container-setup.js.map +1 -1
- package/dist/conversation.d.ts +2 -2
- package/dist/conversation.d.ts.map +1 -1
- package/dist/conversation.js +1 -1
- package/dist/conversation.js.map +1 -1
- package/dist/db.d.ts +118 -2
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +1568 -50
- package/dist/db.js.map +1 -1
- package/dist/delegation-manager.d.ts.map +1 -1
- package/dist/delegation-manager.js +3 -2
- package/dist/delegation-manager.js.map +1 -1
- package/dist/gateway-client.d.ts +2 -2
- package/dist/gateway-client.d.ts.map +1 -1
- package/dist/gateway-client.js +10 -4
- package/dist/gateway-client.js.map +1 -1
- package/dist/gateway-service.d.ts +3 -3
- package/dist/gateway-service.d.ts.map +1 -1
- package/dist/gateway-service.js +563 -73
- package/dist/gateway-service.js.map +1 -1
- package/dist/gateway-types.d.ts +24 -0
- package/dist/gateway-types.d.ts.map +1 -1
- package/dist/gateway-types.js.map +1 -1
- package/dist/gateway.js +179 -24
- package/dist/gateway.js.map +1 -1
- package/dist/health.d.ts.map +1 -1
- package/dist/health.js +20 -10
- package/dist/health.js.map +1 -1
- package/dist/heartbeat.d.ts +4 -0
- package/dist/heartbeat.d.ts.map +1 -1
- package/dist/heartbeat.js +48 -20
- package/dist/heartbeat.js.map +1 -1
- package/dist/hybridai-bots.d.ts.map +1 -1
- package/dist/hybridai-bots.js +4 -2
- package/dist/hybridai-bots.js.map +1 -1
- package/dist/instruction-approval-audit.d.ts.map +1 -1
- package/dist/instruction-approval-audit.js.map +1 -1
- package/dist/instruction-integrity.d.ts.map +1 -1
- package/dist/instruction-integrity.js +8 -2
- package/dist/instruction-integrity.js.map +1 -1
- package/dist/ipc.d.ts.map +1 -1
- package/dist/ipc.js +6 -1
- package/dist/ipc.js.map +1 -1
- package/dist/logger.js.map +1 -1
- package/dist/memory-consolidation.d.ts +17 -0
- package/dist/memory-consolidation.d.ts.map +1 -0
- package/dist/memory-consolidation.js +25 -0
- package/dist/memory-consolidation.js.map +1 -0
- package/dist/memory-service.d.ts +200 -0
- package/dist/memory-service.d.ts.map +1 -0
- package/dist/memory-service.js +294 -0
- package/dist/memory-service.js.map +1 -0
- package/dist/mount-security.d.ts.map +1 -1
- package/dist/mount-security.js +31 -7
- package/dist/mount-security.js.map +1 -1
- package/dist/observability-ingest.d.ts.map +1 -1
- package/dist/observability-ingest.js +32 -11
- package/dist/observability-ingest.js.map +1 -1
- package/dist/onboarding.d.ts.map +1 -1
- package/dist/onboarding.js +32 -9
- package/dist/onboarding.js.map +1 -1
- package/dist/proactive-policy.d.ts.map +1 -1
- package/dist/proactive-policy.js +2 -1
- package/dist/proactive-policy.js.map +1 -1
- package/dist/prompt-hooks.d.ts.map +1 -1
- package/dist/prompt-hooks.js +9 -7
- package/dist/prompt-hooks.js.map +1 -1
- package/dist/runtime-config.d.ts +98 -1
- package/dist/runtime-config.d.ts.map +1 -1
- package/dist/runtime-config.js +477 -23
- package/dist/runtime-config.js.map +1 -1
- package/dist/scheduled-task-runner.d.ts +1 -0
- package/dist/scheduled-task-runner.d.ts.map +1 -1
- package/dist/scheduled-task-runner.js +29 -10
- package/dist/scheduled-task-runner.js.map +1 -1
- package/dist/scheduler.d.ts +43 -4
- package/dist/scheduler.d.ts.map +1 -1
- package/dist/scheduler.js +530 -56
- package/dist/scheduler.js.map +1 -1
- package/dist/session-export.d.ts +26 -0
- package/dist/session-export.d.ts.map +1 -0
- package/dist/session-export.js +149 -0
- package/dist/session-export.js.map +1 -0
- package/dist/session-maintenance.d.ts.map +1 -1
- package/dist/session-maintenance.js +75 -13
- package/dist/session-maintenance.js.map +1 -1
- package/dist/session-transcripts.d.ts.map +1 -1
- package/dist/session-transcripts.js.map +1 -1
- package/dist/side-effects.d.ts.map +1 -1
- package/dist/side-effects.js +14 -2
- package/dist/side-effects.js.map +1 -1
- package/dist/skills-guard.d.ts.map +1 -1
- package/dist/skills-guard.js +893 -130
- package/dist/skills-guard.js.map +1 -1
- package/dist/skills.d.ts +5 -0
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +29 -15
- package/dist/skills.js.map +1 -1
- package/dist/token-efficiency.d.ts.map +1 -1
- package/dist/token-efficiency.js.map +1 -1
- package/dist/tui.js +92 -11
- package/dist/tui.js.map +1 -1
- package/dist/types.d.ts +146 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +24 -1
- package/dist/types.js.map +1 -1
- package/dist/update.d.ts.map +1 -1
- package/dist/update.js +42 -14
- package/dist/update.js.map +1 -1
- package/dist/workspace.d.ts.map +1 -1
- package/dist/workspace.js +49 -9
- package/dist/workspace.js.map +1 -1
- package/docs/chat.html +9 -3
- package/docs/index.html +37 -13
- package/package.json +8 -2
- package/src/agent.ts +16 -3
- package/src/audit-cli.ts +44 -16
- package/src/audit-events.ts +69 -5
- package/src/audit-trail.ts +41 -15
- package/src/channels/discord/attachments.ts +81 -27
- package/src/channels/discord/debounce.ts +25 -0
- package/src/channels/discord/delivery.ts +57 -13
- package/src/channels/discord/human-delay.ts +48 -0
- package/src/channels/discord/inbound.ts +66 -7
- package/src/channels/discord/mentions.ts +42 -18
- package/src/channels/discord/presence.ts +148 -0
- package/src/channels/discord/rate-limiter.ts +58 -0
- package/src/channels/discord/reactions.ts +211 -0
- package/src/channels/discord/runtime.ts +1048 -182
- package/src/channels/discord/stream.ts +73 -27
- package/src/channels/discord/tool-actions.ts +78 -37
- package/src/channels/discord/typing.ts +140 -0
- package/src/chunk.ts +12 -4
- package/src/cli.ts +141 -56
- package/src/config.ts +192 -34
- package/src/container-runner.ts +132 -42
- package/src/container-setup.ts +57 -22
- package/src/conversation.ts +9 -7
- package/src/db.ts +2217 -84
- package/src/delegation-manager.ts +6 -2
- package/src/gateway-client.ts +41 -17
- package/src/gateway-service.ts +1019 -201
- package/src/gateway-types.ts +33 -0
- package/src/gateway.ts +321 -48
- package/src/health.ts +66 -26
- package/src/heartbeat.ts +84 -22
- package/src/hybridai-bots.ts +14 -5
- package/src/instruction-approval-audit.ts +4 -1
- package/src/instruction-integrity.ts +30 -9
- package/src/ipc.ts +23 -5
- package/src/logger.ts +4 -1
- package/src/memory-consolidation.ts +41 -0
- package/src/memory-service.ts +606 -0
- package/src/mount-security.ts +58 -13
- package/src/observability-ingest.ts +134 -35
- package/src/onboarding.ts +126 -35
- package/src/proactive-policy.ts +3 -1
- package/src/prompt-hooks.ts +40 -17
- package/src/runtime-config.ts +1114 -99
- package/src/scheduled-task-runner.ts +63 -11
- package/src/scheduler.ts +683 -60
- package/src/session-export.ts +196 -0
- package/src/session-maintenance.ts +125 -22
- package/src/session-transcripts.ts +12 -3
- package/src/side-effects.ts +28 -5
- package/src/skills-guard.ts +1067 -219
- package/src/skills.ts +163 -65
- package/src/token-efficiency.ts +31 -9
- package/src/tui.ts +166 -25
- package/src/types.ts +195 -2
- package/src/update.ts +79 -23
- package/src/workspace.ts +63 -11
- package/tests/approval-policy.test.ts +224 -0
- package/tests/discord.basic.test.ts +82 -2
- package/tests/discord.human-presence.test.ts +85 -0
- package/tests/gateway-service.media-routing.test.ts +8 -2
- package/tests/memory-service.test.ts +1114 -0
- package/tests/token-efficiency.basic.test.ts +8 -2
- package/vitest.e2e.config.ts +3 -1
- package/vitest.integration.config.ts +3 -1
- package/vitest.live.config.ts +3 -1
- package/vitest.unit.config.ts +9 -0
package/src/scheduler.ts
CHANGED
|
@@ -1,24 +1,83 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Scheduler — timer-based, arms for exact next-fire time.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* sets a single setTimeout for that moment. Re-arms after every tick
|
|
6
|
-
* and whenever a task is added/removed via rearmScheduler().
|
|
4
|
+
* Runs both legacy DB-backed tasks and config-backed scheduler.jobs.
|
|
7
5
|
*/
|
|
6
|
+
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
8
9
|
import { CronExpressionParser } from 'cron-parser';
|
|
9
10
|
|
|
10
|
-
import {
|
|
11
|
+
import { DATA_DIR, getConfigSnapshot } from './config.js';
|
|
12
|
+
import {
|
|
13
|
+
deleteTask,
|
|
14
|
+
getAllEnabledTasks,
|
|
15
|
+
markTaskFailure,
|
|
16
|
+
markTaskSuccess,
|
|
17
|
+
updateTaskLastRun,
|
|
18
|
+
} from './db.js';
|
|
11
19
|
import { logger } from './logger.js';
|
|
20
|
+
import type { RuntimeSchedulerJob } from './runtime-config.js';
|
|
21
|
+
import type { ScheduledTask } from './types.js';
|
|
12
22
|
|
|
13
23
|
const MAX_TIMER_DELAY_MS = 300_000; // 5 min safety net for clock drift
|
|
24
|
+
const MAX_CONSECUTIVE_FAILURES = 5;
|
|
25
|
+
const CONFIG_ONESHOT_RETRY_MS = 60_000;
|
|
26
|
+
const SCHEDULER_STATE_VERSION = 1;
|
|
27
|
+
const SCHEDULER_STATE_PATH = path.join(DATA_DIR, 'scheduler-jobs-state.json');
|
|
28
|
+
|
|
29
|
+
export interface SchedulerDispatchRequest {
|
|
30
|
+
source: 'db-task' | 'config-job';
|
|
31
|
+
taskId?: number;
|
|
32
|
+
jobId?: string;
|
|
33
|
+
sessionId: string;
|
|
34
|
+
channelId: string;
|
|
35
|
+
prompt: string;
|
|
36
|
+
actionKind: 'agent_turn' | 'system_event';
|
|
37
|
+
delivery:
|
|
38
|
+
| { kind: 'channel'; channelId: string }
|
|
39
|
+
| { kind: 'last-channel' }
|
|
40
|
+
| { kind: 'webhook'; webhookUrl: string };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type TaskRunner = (request: SchedulerDispatchRequest) => Promise<void>;
|
|
44
|
+
|
|
45
|
+
interface ConfigJobMeta {
|
|
46
|
+
lastRun: string | null;
|
|
47
|
+
lastStatus: 'success' | 'error' | null;
|
|
48
|
+
nextRunAt: string | null;
|
|
49
|
+
consecutiveErrors: number;
|
|
50
|
+
disabled: boolean;
|
|
51
|
+
oneShotCompleted: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ConfigJobRuntimeState {
|
|
55
|
+
lastRun: string | null;
|
|
56
|
+
lastStatus: 'success' | 'error' | null;
|
|
57
|
+
nextRunAt: string | null;
|
|
58
|
+
disabled: boolean;
|
|
59
|
+
consecutiveErrors: number;
|
|
60
|
+
}
|
|
14
61
|
|
|
15
|
-
|
|
62
|
+
export interface SchedulerStatusJob extends ConfigJobRuntimeState {
|
|
63
|
+
id: string;
|
|
64
|
+
name: string;
|
|
65
|
+
description: string | null;
|
|
66
|
+
enabled: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface SchedulerStateFile {
|
|
70
|
+
version: number;
|
|
71
|
+
updatedAt: string;
|
|
72
|
+
configJobs: Record<string, ConfigJobMeta>;
|
|
73
|
+
}
|
|
16
74
|
|
|
17
75
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
18
76
|
let taskRunner: TaskRunner | null = null;
|
|
19
77
|
let ticking = false;
|
|
78
|
+
const schedulerState: SchedulerStateFile = loadSchedulerState();
|
|
20
79
|
|
|
21
|
-
// --- Prompt framing
|
|
80
|
+
// --- Prompt framing ---
|
|
22
81
|
|
|
23
82
|
function formatFireTime(): string {
|
|
24
83
|
return new Date().toLocaleString('en-US', {
|
|
@@ -31,39 +90,260 @@ function formatFireTime(): string {
|
|
|
31
90
|
});
|
|
32
91
|
}
|
|
33
92
|
|
|
34
|
-
export function wrapCronPrompt(
|
|
93
|
+
export function wrapCronPrompt(jobLabel: string, message: string): string {
|
|
35
94
|
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
36
|
-
return `[cron
|
|
95
|
+
return `[cron:${jobLabel}] ${message}\nCurrent time: ${formatFireTime()} (${tz})\n\nReturn your response as plain text; it will be delivered automatically. If the task explicitly calls for messaging a specific external recipient, note who/where it should go instead of sending it yourself.`;
|
|
37
96
|
}
|
|
38
97
|
|
|
39
|
-
|
|
98
|
+
function defaultConfigJobMeta(): ConfigJobMeta {
|
|
99
|
+
return {
|
|
100
|
+
lastRun: null,
|
|
101
|
+
lastStatus: null,
|
|
102
|
+
nextRunAt: null,
|
|
103
|
+
consecutiveErrors: 0,
|
|
104
|
+
disabled: false,
|
|
105
|
+
oneShotCompleted: false,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
40
108
|
|
|
41
|
-
function
|
|
42
|
-
|
|
43
|
-
|
|
109
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
110
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
111
|
+
}
|
|
44
112
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
113
|
+
function normalizeConfigJobMeta(value: unknown): ConfigJobMeta {
|
|
114
|
+
if (!isRecord(value)) return defaultConfigJobMeta();
|
|
115
|
+
const lastRun =
|
|
116
|
+
typeof value.lastRun === 'string' && value.lastRun.trim()
|
|
117
|
+
? value.lastRun.trim()
|
|
118
|
+
: null;
|
|
119
|
+
const lastStatus =
|
|
120
|
+
value.lastStatus === 'success' || value.lastStatus === 'error'
|
|
121
|
+
? value.lastStatus
|
|
122
|
+
: null;
|
|
123
|
+
const nextRunAt =
|
|
124
|
+
typeof value.nextRunAt === 'string' && value.nextRunAt.trim()
|
|
125
|
+
? value.nextRunAt.trim()
|
|
126
|
+
: null;
|
|
127
|
+
const consecutiveErrors =
|
|
128
|
+
typeof value.consecutiveErrors === 'number' &&
|
|
129
|
+
Number.isFinite(value.consecutiveErrors)
|
|
130
|
+
? Math.max(0, Math.floor(value.consecutiveErrors))
|
|
131
|
+
: 0;
|
|
132
|
+
return {
|
|
133
|
+
lastRun,
|
|
134
|
+
lastStatus,
|
|
135
|
+
nextRunAt,
|
|
136
|
+
consecutiveErrors,
|
|
137
|
+
disabled: Boolean(value.disabled),
|
|
138
|
+
oneShotCompleted: Boolean(value.oneShotCompleted),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
53
141
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
142
|
+
function loadSchedulerState(): SchedulerStateFile {
|
|
143
|
+
try {
|
|
144
|
+
if (!fs.existsSync(SCHEDULER_STATE_PATH)) {
|
|
145
|
+
return {
|
|
146
|
+
version: SCHEDULER_STATE_VERSION,
|
|
147
|
+
updatedAt: new Date(0).toISOString(),
|
|
148
|
+
configJobs: {},
|
|
149
|
+
};
|
|
59
150
|
}
|
|
151
|
+
const raw = fs.readFileSync(SCHEDULER_STATE_PATH, 'utf-8');
|
|
152
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
153
|
+
if (!isRecord(parsed)) throw new Error('state file root must be object');
|
|
154
|
+
const rawJobs = isRecord(parsed.configJobs) ? parsed.configJobs : {};
|
|
155
|
+
const configJobs: Record<string, ConfigJobMeta> = {};
|
|
156
|
+
for (const [id, meta] of Object.entries(rawJobs)) {
|
|
157
|
+
const key = id.trim();
|
|
158
|
+
if (!key) continue;
|
|
159
|
+
configJobs[key] = normalizeConfigJobMeta(meta);
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
version: SCHEDULER_STATE_VERSION,
|
|
163
|
+
updatedAt:
|
|
164
|
+
typeof parsed.updatedAt === 'string' && parsed.updatedAt.trim()
|
|
165
|
+
? parsed.updatedAt
|
|
166
|
+
: new Date(0).toISOString(),
|
|
167
|
+
configJobs,
|
|
168
|
+
};
|
|
169
|
+
} catch (error) {
|
|
170
|
+
logger.warn(
|
|
171
|
+
{ error },
|
|
172
|
+
'Failed to load scheduler state file; starting with defaults',
|
|
173
|
+
);
|
|
174
|
+
return {
|
|
175
|
+
version: SCHEDULER_STATE_VERSION,
|
|
176
|
+
updatedAt: new Date(0).toISOString(),
|
|
177
|
+
configJobs: {},
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function persistSchedulerState(): void {
|
|
183
|
+
try {
|
|
184
|
+
fs.mkdirSync(path.dirname(SCHEDULER_STATE_PATH), { recursive: true });
|
|
185
|
+
schedulerState.updatedAt = new Date().toISOString();
|
|
186
|
+
const payload = `${JSON.stringify(schedulerState, null, 2)}\n`;
|
|
187
|
+
const tmpPath = `${SCHEDULER_STATE_PATH}.tmp-${process.pid}-${Date.now()}`;
|
|
188
|
+
fs.writeFileSync(tmpPath, payload, 'utf-8');
|
|
189
|
+
fs.renameSync(tmpPath, SCHEDULER_STATE_PATH);
|
|
190
|
+
} catch (error) {
|
|
191
|
+
logger.warn({ error }, 'Failed to persist scheduler state file');
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function getConfigJobMeta(jobId: string): ConfigJobMeta {
|
|
196
|
+
const existing = schedulerState.configJobs[jobId];
|
|
197
|
+
if (existing) return existing;
|
|
198
|
+
const created = defaultConfigJobMeta();
|
|
199
|
+
schedulerState.configJobs[jobId] = created;
|
|
200
|
+
return created;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function pruneConfigJobMeta(activeJobs: RuntimeSchedulerJob[]): void {
|
|
204
|
+
const activeIds = new Set(activeJobs.map((job) => job.id));
|
|
205
|
+
let changed = false;
|
|
206
|
+
for (const id of Object.keys(schedulerState.configJobs)) {
|
|
207
|
+
if (activeIds.has(id)) continue;
|
|
208
|
+
delete schedulerState.configJobs[id];
|
|
209
|
+
changed = true;
|
|
210
|
+
}
|
|
211
|
+
if (changed) persistSchedulerState();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function resolveConfigJobLabel(
|
|
215
|
+
job: Pick<RuntimeSchedulerJob, 'id' | 'name'>,
|
|
216
|
+
): string {
|
|
217
|
+
const candidate = typeof job.name === 'string' ? job.name.trim() : '';
|
|
218
|
+
return candidate || job.id;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function parseCronExpression(
|
|
222
|
+
expr: string,
|
|
223
|
+
tz: string | undefined,
|
|
224
|
+
): ReturnType<typeof CronExpressionParser.parse> {
|
|
225
|
+
const trimmedTz = tz?.trim();
|
|
226
|
+
if (trimmedTz) {
|
|
227
|
+
return CronExpressionParser.parse(expr, { tz: trimmedTz });
|
|
228
|
+
}
|
|
229
|
+
return CronExpressionParser.parse(expr);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function nextFireMsForDbTask(
|
|
233
|
+
task: ScheduledTask,
|
|
234
|
+
nowMs: number,
|
|
235
|
+
): number | null {
|
|
236
|
+
if (task.run_at) {
|
|
237
|
+
if (task.last_run) return null;
|
|
238
|
+
const ms = new Date(task.run_at).getTime();
|
|
239
|
+
return Number.isFinite(ms) ? ms : null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (task.every_ms) {
|
|
243
|
+
const lastRunMs = task.last_run ? new Date(task.last_run).getTime() : 0;
|
|
244
|
+
return lastRunMs > 0 ? lastRunMs + task.every_ms : nowMs;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (!task.cron_expr) return null;
|
|
248
|
+
|
|
249
|
+
try {
|
|
250
|
+
const ms = CronExpressionParser.parse(task.cron_expr)
|
|
251
|
+
.next()
|
|
252
|
+
.toDate()
|
|
253
|
+
.getTime();
|
|
254
|
+
return Number.isFinite(ms) ? ms : null;
|
|
255
|
+
} catch {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function toIsoTimestamp(ms: number | null): string | null {
|
|
261
|
+
if (ms == null || !Number.isFinite(ms)) return null;
|
|
262
|
+
return new Date(ms).toISOString();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function syncConfigJobNextRunAt(
|
|
266
|
+
job: RuntimeSchedulerJob,
|
|
267
|
+
nowMs: number,
|
|
268
|
+
): boolean {
|
|
269
|
+
const meta = getConfigJobMeta(job.id);
|
|
270
|
+
const nextRunAt = toIsoTimestamp(nextFireMsForConfigJob(job, nowMs));
|
|
271
|
+
if (meta.nextRunAt === nextRunAt) return false;
|
|
272
|
+
meta.nextRunAt = nextRunAt;
|
|
273
|
+
return true;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function syncConfigJobsNextRunAt(
|
|
277
|
+
jobs: RuntimeSchedulerJob[],
|
|
278
|
+
nowMs: number,
|
|
279
|
+
): boolean {
|
|
280
|
+
let changed = false;
|
|
281
|
+
for (const job of jobs) {
|
|
282
|
+
if (syncConfigJobNextRunAt(job, nowMs)) changed = true;
|
|
283
|
+
}
|
|
284
|
+
return changed;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function nextFireMsForConfigJob(
|
|
288
|
+
job: RuntimeSchedulerJob,
|
|
289
|
+
nowMs: number,
|
|
290
|
+
): number | null {
|
|
291
|
+
if (!job.enabled) return null;
|
|
292
|
+
const meta = getConfigJobMeta(job.id);
|
|
293
|
+
if (meta.disabled) return null;
|
|
294
|
+
|
|
295
|
+
if (job.schedule.kind === 'at') {
|
|
296
|
+
if (meta.oneShotCompleted) return null;
|
|
297
|
+
if (!job.schedule.at) return null;
|
|
298
|
+
const atMs = new Date(job.schedule.at).getTime();
|
|
299
|
+
if (!Number.isFinite(atMs)) return null;
|
|
300
|
+
const lastRunMs = meta.lastRun ? new Date(meta.lastRun).getTime() : 0;
|
|
301
|
+
if (atMs > nowMs) return atMs;
|
|
302
|
+
if (lastRunMs <= 0) return atMs;
|
|
303
|
+
return lastRunMs + CONFIG_ONESHOT_RETRY_MS;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (job.schedule.kind === 'every') {
|
|
307
|
+
if (!job.schedule.everyMs) return null;
|
|
308
|
+
const lastRunMs = meta.lastRun ? new Date(meta.lastRun).getTime() : 0;
|
|
309
|
+
return lastRunMs > 0 ? lastRunMs + job.schedule.everyMs : nowMs;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (!job.schedule.expr) return null;
|
|
313
|
+
try {
|
|
314
|
+
const ms = parseCronExpression(
|
|
315
|
+
job.schedule.expr,
|
|
316
|
+
job.schedule.tz || undefined,
|
|
317
|
+
)
|
|
318
|
+
.next()
|
|
319
|
+
.toDate()
|
|
320
|
+
.getTime();
|
|
321
|
+
return Number.isFinite(ms) ? ms : null;
|
|
322
|
+
} catch {
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function computeNextFireMs(nowMs = Date.now()): number | null {
|
|
328
|
+
const dbTasks = getAllEnabledTasks();
|
|
329
|
+
const cfgJobs = getConfigSnapshot().scheduler.jobs;
|
|
330
|
+
pruneConfigJobMeta(cfgJobs);
|
|
331
|
+
if (syncConfigJobsNextRunAt(cfgJobs, nowMs)) {
|
|
332
|
+
persistSchedulerState();
|
|
333
|
+
}
|
|
60
334
|
|
|
61
|
-
|
|
335
|
+
let earliest: number | null = null;
|
|
336
|
+
|
|
337
|
+
for (const task of dbTasks) {
|
|
338
|
+
const fireMs = nextFireMsForDbTask(task, nowMs);
|
|
339
|
+
if (fireMs === null) continue;
|
|
340
|
+
if (earliest === null || fireMs < earliest) earliest = fireMs;
|
|
341
|
+
}
|
|
62
342
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
343
|
+
for (const job of cfgJobs) {
|
|
344
|
+
const fireMs = nextFireMsForConfigJob(job, nowMs);
|
|
345
|
+
if (fireMs === null) continue;
|
|
346
|
+
if (earliest === null || fireMs < earliest) earliest = fireMs;
|
|
67
347
|
}
|
|
68
348
|
|
|
69
349
|
return earliest;
|
|
@@ -74,7 +354,7 @@ function arm(): void {
|
|
|
74
354
|
timer = null;
|
|
75
355
|
|
|
76
356
|
const nextFireMs = computeNextFireMs();
|
|
77
|
-
if (nextFireMs === null) return;
|
|
357
|
+
if (nextFireMs === null) return;
|
|
78
358
|
|
|
79
359
|
const delay = Math.max(nextFireMs - Date.now(), 0);
|
|
80
360
|
const clamped = Math.min(delay, MAX_TIMER_DELAY_MS);
|
|
@@ -87,64 +367,172 @@ function arm(): void {
|
|
|
87
367
|
timer = setTimeout(() => {
|
|
88
368
|
void tick().catch((err) => {
|
|
89
369
|
logger.error({ err }, 'Scheduler tick failed');
|
|
90
|
-
arm();
|
|
370
|
+
arm();
|
|
91
371
|
});
|
|
92
372
|
}, clamped);
|
|
93
373
|
}
|
|
94
374
|
|
|
375
|
+
async function dispatchDbTask(task: ScheduledTask): Promise<void> {
|
|
376
|
+
if (!taskRunner) return;
|
|
377
|
+
const prompt = wrapCronPrompt(`#${task.id}`, task.prompt);
|
|
378
|
+
await taskRunner({
|
|
379
|
+
source: 'db-task',
|
|
380
|
+
taskId: task.id,
|
|
381
|
+
sessionId: task.session_id,
|
|
382
|
+
channelId: task.channel_id,
|
|
383
|
+
prompt,
|
|
384
|
+
actionKind: 'agent_turn',
|
|
385
|
+
delivery: {
|
|
386
|
+
kind: 'channel',
|
|
387
|
+
channelId: task.channel_id,
|
|
388
|
+
},
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async function dispatchConfigJob(job: RuntimeSchedulerJob): Promise<void> {
|
|
393
|
+
if (!taskRunner) return;
|
|
394
|
+
const jobLabel = resolveConfigJobLabel(job);
|
|
395
|
+
const contextChannelId =
|
|
396
|
+
job.delivery.kind === 'channel' ? job.delivery.to : 'scheduler';
|
|
397
|
+
const prompt =
|
|
398
|
+
job.action.kind === 'agent_turn'
|
|
399
|
+
? wrapCronPrompt(jobLabel, job.action.message)
|
|
400
|
+
: job.action.message;
|
|
401
|
+
await taskRunner({
|
|
402
|
+
source: 'config-job',
|
|
403
|
+
jobId: job.id,
|
|
404
|
+
sessionId: `scheduler:${job.id}`,
|
|
405
|
+
channelId: contextChannelId,
|
|
406
|
+
prompt,
|
|
407
|
+
actionKind: job.action.kind,
|
|
408
|
+
delivery:
|
|
409
|
+
job.delivery.kind === 'channel'
|
|
410
|
+
? { kind: 'channel', channelId: job.delivery.to }
|
|
411
|
+
: job.delivery.kind === 'last-channel'
|
|
412
|
+
? { kind: 'last-channel' }
|
|
413
|
+
: { kind: 'webhook', webhookUrl: job.delivery.webhookUrl },
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function markConfigJobSuccess(
|
|
418
|
+
job: RuntimeSchedulerJob,
|
|
419
|
+
markOneShotDone = false,
|
|
420
|
+
): void {
|
|
421
|
+
const meta = getConfigJobMeta(job.id);
|
|
422
|
+
meta.lastStatus = 'success';
|
|
423
|
+
meta.consecutiveErrors = 0;
|
|
424
|
+
if (markOneShotDone) meta.oneShotCompleted = true;
|
|
425
|
+
syncConfigJobNextRunAt(job, Date.now());
|
|
426
|
+
persistSchedulerState();
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function markConfigJobFailure(job: RuntimeSchedulerJob): {
|
|
430
|
+
disabled: boolean;
|
|
431
|
+
consecutiveErrors: number;
|
|
432
|
+
} {
|
|
433
|
+
const meta = getConfigJobMeta(job.id);
|
|
434
|
+
meta.lastStatus = 'error';
|
|
435
|
+
meta.consecutiveErrors = Math.max(0, meta.consecutiveErrors) + 1;
|
|
436
|
+
if (meta.consecutiveErrors >= MAX_CONSECUTIVE_FAILURES) {
|
|
437
|
+
meta.disabled = true;
|
|
438
|
+
}
|
|
439
|
+
syncConfigJobNextRunAt(job, Date.now());
|
|
440
|
+
persistSchedulerState();
|
|
441
|
+
return {
|
|
442
|
+
disabled: meta.disabled,
|
|
443
|
+
consecutiveErrors: meta.consecutiveErrors,
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
|
|
95
447
|
async function tick(): Promise<void> {
|
|
96
448
|
if (ticking) {
|
|
97
|
-
arm();
|
|
449
|
+
arm();
|
|
98
450
|
return;
|
|
99
451
|
}
|
|
100
452
|
ticking = true;
|
|
101
453
|
|
|
102
454
|
try {
|
|
103
|
-
const
|
|
455
|
+
const dbTasks = getAllEnabledTasks();
|
|
456
|
+
const cfgJobs = getConfigSnapshot().scheduler.jobs;
|
|
457
|
+
pruneConfigJobMeta(cfgJobs);
|
|
458
|
+
|
|
104
459
|
const now = new Date();
|
|
460
|
+
const nowMs = now.getTime();
|
|
105
461
|
|
|
106
|
-
for (const task of
|
|
462
|
+
for (const task of dbTasks) {
|
|
107
463
|
try {
|
|
108
|
-
// --- One-shot task ---
|
|
109
464
|
if (task.run_at) {
|
|
110
465
|
const runAt = new Date(task.run_at);
|
|
111
|
-
if (runAt.getTime() <=
|
|
466
|
+
if (runAt.getTime() <= nowMs && !task.last_run) {
|
|
112
467
|
logger.info(
|
|
113
468
|
{ taskId: task.id, runAt: task.run_at, prompt: task.prompt },
|
|
114
469
|
'One-shot task firing',
|
|
115
470
|
);
|
|
116
|
-
updateTaskLastRun(task.id);
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
471
|
+
updateTaskLastRun(task.id);
|
|
472
|
+
dispatchDbTask(task)
|
|
473
|
+
.then(() => {
|
|
474
|
+
markTaskSuccess(task.id);
|
|
475
|
+
deleteTask(task.id);
|
|
476
|
+
})
|
|
120
477
|
.catch((err) => {
|
|
121
|
-
|
|
478
|
+
const failure = markTaskFailure(
|
|
479
|
+
task.id,
|
|
480
|
+
MAX_CONSECUTIVE_FAILURES,
|
|
481
|
+
);
|
|
482
|
+
logger.error(
|
|
483
|
+
{ taskId: task.id, err },
|
|
484
|
+
'One-shot task failed (task preserved)',
|
|
485
|
+
);
|
|
486
|
+
if (failure.disabled) {
|
|
487
|
+
logger.warn(
|
|
488
|
+
{
|
|
489
|
+
taskId: task.id,
|
|
490
|
+
consecutiveErrors: failure.consecutiveErrors,
|
|
491
|
+
},
|
|
492
|
+
'Scheduled task auto-disabled after repeated failures',
|
|
493
|
+
);
|
|
494
|
+
}
|
|
122
495
|
});
|
|
123
496
|
}
|
|
124
497
|
continue;
|
|
125
498
|
}
|
|
126
499
|
|
|
127
|
-
// --- Interval task ---
|
|
128
500
|
if (task.every_ms) {
|
|
129
|
-
const lastRunMs = task.last_run
|
|
130
|
-
|
|
131
|
-
|
|
501
|
+
const lastRunMs = task.last_run
|
|
502
|
+
? new Date(task.last_run).getTime()
|
|
503
|
+
: 0;
|
|
504
|
+
const dueAt = lastRunMs > 0 ? lastRunMs + task.every_ms : 0;
|
|
505
|
+
if (dueAt <= nowMs) {
|
|
132
506
|
logger.info(
|
|
133
507
|
{ taskId: task.id, everyMs: task.every_ms, prompt: task.prompt },
|
|
134
508
|
'Interval task firing',
|
|
135
509
|
);
|
|
136
510
|
updateTaskLastRun(task.id);
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
511
|
+
dispatchDbTask(task)
|
|
512
|
+
.then(() => {
|
|
513
|
+
markTaskSuccess(task.id);
|
|
514
|
+
})
|
|
515
|
+
.catch((err) => {
|
|
516
|
+
const failure = markTaskFailure(
|
|
517
|
+
task.id,
|
|
518
|
+
MAX_CONSECUTIVE_FAILURES,
|
|
519
|
+
);
|
|
520
|
+
logger.error({ taskId: task.id, err }, 'Interval task failed');
|
|
521
|
+
if (failure.disabled) {
|
|
522
|
+
logger.warn(
|
|
523
|
+
{
|
|
524
|
+
taskId: task.id,
|
|
525
|
+
consecutiveErrors: failure.consecutiveErrors,
|
|
526
|
+
},
|
|
527
|
+
'Scheduled task auto-disabled after repeated failures',
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
});
|
|
141
531
|
}
|
|
142
532
|
continue;
|
|
143
533
|
}
|
|
144
534
|
|
|
145
|
-
// --- Recurring cron task ---
|
|
146
535
|
if (!task.cron_expr) continue;
|
|
147
|
-
|
|
148
536
|
const cron = CronExpressionParser.parse(task.cron_expr);
|
|
149
537
|
const prev = cron.prev();
|
|
150
538
|
const lastRun = task.last_run ? new Date(task.last_run) : new Date(0);
|
|
@@ -155,21 +543,257 @@ async function tick(): Promise<void> {
|
|
|
155
543
|
'Cron task firing',
|
|
156
544
|
);
|
|
157
545
|
updateTaskLastRun(task.id);
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
546
|
+
dispatchDbTask(task)
|
|
547
|
+
.then(() => {
|
|
548
|
+
markTaskSuccess(task.id);
|
|
549
|
+
})
|
|
550
|
+
.catch((err) => {
|
|
551
|
+
const failure = markTaskFailure(
|
|
552
|
+
task.id,
|
|
553
|
+
MAX_CONSECUTIVE_FAILURES,
|
|
554
|
+
);
|
|
555
|
+
logger.error({ taskId: task.id, err }, 'Cron task failed');
|
|
556
|
+
if (failure.disabled) {
|
|
557
|
+
logger.warn(
|
|
558
|
+
{
|
|
559
|
+
taskId: task.id,
|
|
560
|
+
consecutiveErrors: failure.consecutiveErrors,
|
|
561
|
+
},
|
|
562
|
+
'Scheduled task auto-disabled after repeated failures',
|
|
563
|
+
);
|
|
564
|
+
}
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
} catch (err) {
|
|
568
|
+
logger.error(
|
|
569
|
+
{ taskId: task.id, cron: task.cron_expr, err },
|
|
570
|
+
'Scheduler error for DB task',
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
for (const job of cfgJobs) {
|
|
576
|
+
if (!job.enabled) continue;
|
|
577
|
+
const meta = getConfigJobMeta(job.id);
|
|
578
|
+
if (meta.disabled) continue;
|
|
579
|
+
const jobLabel = resolveConfigJobLabel(job);
|
|
580
|
+
|
|
581
|
+
try {
|
|
582
|
+
if (job.schedule.kind === 'at') {
|
|
583
|
+
if (meta.oneShotCompleted || !job.schedule.at) continue;
|
|
584
|
+
const runAtMs = new Date(job.schedule.at).getTime();
|
|
585
|
+
if (!Number.isFinite(runAtMs) || runAtMs > nowMs) continue;
|
|
586
|
+
const lastRunMs = meta.lastRun ? new Date(meta.lastRun).getTime() : 0;
|
|
587
|
+
if (lastRunMs > 0 && nowMs - lastRunMs < CONFIG_ONESHOT_RETRY_MS)
|
|
588
|
+
continue;
|
|
589
|
+
meta.lastRun = now.toISOString();
|
|
590
|
+
persistSchedulerState();
|
|
591
|
+
logger.info(
|
|
592
|
+
{ jobId: job.id, jobLabel, runAt: job.schedule.at },
|
|
593
|
+
'Config one-shot job firing',
|
|
594
|
+
);
|
|
595
|
+
dispatchConfigJob(job)
|
|
596
|
+
.then(() => {
|
|
597
|
+
markConfigJobSuccess(job, true);
|
|
598
|
+
})
|
|
599
|
+
.catch((err) => {
|
|
600
|
+
const failure = markConfigJobFailure(job);
|
|
601
|
+
logger.error(
|
|
602
|
+
{ jobId: job.id, jobLabel, err },
|
|
603
|
+
'Config one-shot job failed',
|
|
604
|
+
);
|
|
605
|
+
if (failure.disabled) {
|
|
606
|
+
logger.warn(
|
|
607
|
+
{
|
|
608
|
+
jobId: job.id,
|
|
609
|
+
jobLabel,
|
|
610
|
+
consecutiveErrors: failure.consecutiveErrors,
|
|
611
|
+
},
|
|
612
|
+
'Config scheduler job auto-disabled after repeated failures',
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
});
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
if (job.schedule.kind === 'every') {
|
|
620
|
+
const everyMs = job.schedule.everyMs;
|
|
621
|
+
if (!everyMs) continue;
|
|
622
|
+
const lastRunMs = meta.lastRun ? new Date(meta.lastRun).getTime() : 0;
|
|
623
|
+
const dueAt = lastRunMs > 0 ? lastRunMs + everyMs : 0;
|
|
624
|
+
if (dueAt > nowMs) continue;
|
|
625
|
+
meta.lastRun = now.toISOString();
|
|
626
|
+
persistSchedulerState();
|
|
627
|
+
logger.info(
|
|
628
|
+
{ jobId: job.id, jobLabel, everyMs },
|
|
629
|
+
'Config interval job firing',
|
|
630
|
+
);
|
|
631
|
+
dispatchConfigJob(job)
|
|
632
|
+
.then(() => {
|
|
633
|
+
markConfigJobSuccess(job, false);
|
|
634
|
+
})
|
|
635
|
+
.catch((err) => {
|
|
636
|
+
const failure = markConfigJobFailure(job);
|
|
637
|
+
logger.error(
|
|
638
|
+
{ jobId: job.id, jobLabel, err },
|
|
639
|
+
'Config interval job failed',
|
|
640
|
+
);
|
|
641
|
+
if (failure.disabled) {
|
|
642
|
+
logger.warn(
|
|
643
|
+
{
|
|
644
|
+
jobId: job.id,
|
|
645
|
+
jobLabel,
|
|
646
|
+
consecutiveErrors: failure.consecutiveErrors,
|
|
647
|
+
},
|
|
648
|
+
'Config scheduler job auto-disabled after repeated failures',
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
});
|
|
652
|
+
continue;
|
|
162
653
|
}
|
|
654
|
+
|
|
655
|
+
if (!job.schedule.expr) continue;
|
|
656
|
+
const cron = parseCronExpression(
|
|
657
|
+
job.schedule.expr,
|
|
658
|
+
job.schedule.tz || undefined,
|
|
659
|
+
);
|
|
660
|
+
const prev = cron.prev().toDate();
|
|
661
|
+
const lastRun = meta.lastRun ? new Date(meta.lastRun) : new Date(0);
|
|
662
|
+
if (prev <= lastRun) continue;
|
|
663
|
+
|
|
664
|
+
meta.lastRun = now.toISOString();
|
|
665
|
+
persistSchedulerState();
|
|
666
|
+
logger.info(
|
|
667
|
+
{
|
|
668
|
+
jobId: job.id,
|
|
669
|
+
jobLabel,
|
|
670
|
+
expr: job.schedule.expr,
|
|
671
|
+
tz: job.schedule.tz,
|
|
672
|
+
},
|
|
673
|
+
'Config cron job firing',
|
|
674
|
+
);
|
|
675
|
+
dispatchConfigJob(job)
|
|
676
|
+
.then(() => {
|
|
677
|
+
markConfigJobSuccess(job, false);
|
|
678
|
+
})
|
|
679
|
+
.catch((err) => {
|
|
680
|
+
const failure = markConfigJobFailure(job);
|
|
681
|
+
logger.error(
|
|
682
|
+
{ jobId: job.id, jobLabel, err },
|
|
683
|
+
'Config cron job failed',
|
|
684
|
+
);
|
|
685
|
+
if (failure.disabled) {
|
|
686
|
+
logger.warn(
|
|
687
|
+
{
|
|
688
|
+
jobId: job.id,
|
|
689
|
+
jobLabel,
|
|
690
|
+
consecutiveErrors: failure.consecutiveErrors,
|
|
691
|
+
},
|
|
692
|
+
'Config scheduler job auto-disabled after repeated failures',
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
});
|
|
163
696
|
} catch (err) {
|
|
164
|
-
logger.error(
|
|
697
|
+
logger.error(
|
|
698
|
+
{ jobId: job.id, jobLabel, err },
|
|
699
|
+
'Scheduler error for config job',
|
|
700
|
+
);
|
|
165
701
|
}
|
|
166
702
|
}
|
|
167
703
|
} finally {
|
|
168
704
|
ticking = false;
|
|
169
|
-
arm();
|
|
705
|
+
arm();
|
|
170
706
|
}
|
|
171
707
|
}
|
|
172
708
|
|
|
709
|
+
function toRuntimeState(meta: ConfigJobMeta): ConfigJobRuntimeState {
|
|
710
|
+
return {
|
|
711
|
+
lastRun: meta.lastRun,
|
|
712
|
+
lastStatus: meta.lastStatus,
|
|
713
|
+
nextRunAt: meta.nextRunAt,
|
|
714
|
+
disabled: meta.disabled,
|
|
715
|
+
consecutiveErrors: meta.consecutiveErrors,
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
export function getConfigJobState(jobId: string): ConfigJobRuntimeState | null {
|
|
720
|
+
const normalizedJobId = jobId.trim();
|
|
721
|
+
if (!normalizedJobId) return null;
|
|
722
|
+
const jobs = getConfigSnapshot().scheduler.jobs;
|
|
723
|
+
pruneConfigJobMeta(jobs);
|
|
724
|
+
const job = jobs.find((candidate) => candidate.id === normalizedJobId);
|
|
725
|
+
if (!job) return null;
|
|
726
|
+
if (syncConfigJobNextRunAt(job, Date.now())) {
|
|
727
|
+
persistSchedulerState();
|
|
728
|
+
}
|
|
729
|
+
return toRuntimeState(getConfigJobMeta(normalizedJobId));
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
export function getSchedulerStatus(): SchedulerStatusJob[] {
|
|
733
|
+
const jobs = getConfigSnapshot().scheduler.jobs;
|
|
734
|
+
pruneConfigJobMeta(jobs);
|
|
735
|
+
if (syncConfigJobsNextRunAt(jobs, Date.now())) {
|
|
736
|
+
persistSchedulerState();
|
|
737
|
+
}
|
|
738
|
+
return jobs.map((job) => {
|
|
739
|
+
const meta = getConfigJobMeta(job.id);
|
|
740
|
+
const description =
|
|
741
|
+
typeof job.description === 'string' && job.description.trim()
|
|
742
|
+
? job.description.trim()
|
|
743
|
+
: null;
|
|
744
|
+
return {
|
|
745
|
+
id: job.id,
|
|
746
|
+
name: resolveConfigJobLabel(job),
|
|
747
|
+
description,
|
|
748
|
+
enabled: job.enabled,
|
|
749
|
+
...toRuntimeState(meta),
|
|
750
|
+
};
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
export function pauseConfigJob(jobId: string): boolean {
|
|
755
|
+
const normalizedJobId = jobId.trim();
|
|
756
|
+
if (!normalizedJobId) return false;
|
|
757
|
+
const jobs = getConfigSnapshot().scheduler.jobs;
|
|
758
|
+
pruneConfigJobMeta(jobs);
|
|
759
|
+
const job = jobs.find((candidate) => candidate.id === normalizedJobId);
|
|
760
|
+
if (!job) return false;
|
|
761
|
+
|
|
762
|
+
const meta = getConfigJobMeta(normalizedJobId);
|
|
763
|
+
meta.disabled = true;
|
|
764
|
+
meta.nextRunAt = null;
|
|
765
|
+
persistSchedulerState();
|
|
766
|
+
rearmScheduler();
|
|
767
|
+
|
|
768
|
+
logger.info(
|
|
769
|
+
{ jobId: normalizedJobId, jobLabel: resolveConfigJobLabel(job) },
|
|
770
|
+
'Config scheduler job paused',
|
|
771
|
+
);
|
|
772
|
+
return true;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
export function resumeConfigJob(jobId: string): boolean {
|
|
776
|
+
const normalizedJobId = jobId.trim();
|
|
777
|
+
if (!normalizedJobId) return false;
|
|
778
|
+
const jobs = getConfigSnapshot().scheduler.jobs;
|
|
779
|
+
pruneConfigJobMeta(jobs);
|
|
780
|
+
const job = jobs.find((candidate) => candidate.id === normalizedJobId);
|
|
781
|
+
if (!job) return false;
|
|
782
|
+
|
|
783
|
+
const meta = getConfigJobMeta(normalizedJobId);
|
|
784
|
+
meta.disabled = false;
|
|
785
|
+
meta.consecutiveErrors = 0;
|
|
786
|
+
syncConfigJobNextRunAt(job, Date.now());
|
|
787
|
+
persistSchedulerState();
|
|
788
|
+
rearmScheduler();
|
|
789
|
+
|
|
790
|
+
logger.info(
|
|
791
|
+
{ jobId: normalizedJobId, jobLabel: resolveConfigJobLabel(job) },
|
|
792
|
+
'Config scheduler job resumed',
|
|
793
|
+
);
|
|
794
|
+
return true;
|
|
795
|
+
}
|
|
796
|
+
|
|
173
797
|
// --- Public API ---
|
|
174
798
|
|
|
175
799
|
export function startScheduler(runner: TaskRunner): void {
|
|
@@ -179,8 +803,7 @@ export function startScheduler(runner: TaskRunner): void {
|
|
|
179
803
|
}
|
|
180
804
|
|
|
181
805
|
/**
|
|
182
|
-
* Re-arm the scheduler timer.
|
|
183
|
-
* so newly scheduled work is picked up immediately.
|
|
806
|
+
* Re-arm the scheduler timer. Call after creating/deleting tasks or updating config scheduler jobs.
|
|
184
807
|
*/
|
|
185
808
|
export function rearmScheduler(): void {
|
|
186
809
|
if (taskRunner) arm();
|