discoclaw 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/.context/README.md +42 -0
- package/.context/architecture.md +58 -0
- package/.context/bot-setup.md +24 -0
- package/.context/dev.md +230 -0
- package/.context/discord.md +144 -0
- package/.context/memory.md +257 -0
- package/.context/ops.md +59 -0
- package/.context/pa-safety.md +47 -0
- package/.context/pa.md +118 -0
- package/.context/project.md +43 -0
- package/.context/runtime.md +253 -0
- package/.context/tasks.md +71 -0
- package/.context/tools.md +75 -0
- package/.env.example +88 -0
- package/.env.example.full +378 -0
- package/LICENSE +21 -0
- package/README.md +220 -0
- package/dist/beads/auto-tag.js +2 -0
- package/dist/beads/auto-tag.test.js +62 -0
- package/dist/beads/bd-cli.js +9 -0
- package/dist/beads/bd-cli.test.js +495 -0
- package/dist/beads/bead-hooks-cli.js +149 -0
- package/dist/beads/bead-sync-cli.js +5 -0
- package/dist/beads/bead-sync-cli.test.js +72 -0
- package/dist/beads/bead-sync-coordinator.js +4 -0
- package/dist/beads/bead-sync-coordinator.test.js +239 -0
- package/dist/beads/bead-sync-watcher.js +2 -0
- package/dist/beads/bead-sync-watcher.test.js +96 -0
- package/dist/beads/bead-sync.js +7 -0
- package/dist/beads/bead-sync.test.js +876 -0
- package/dist/beads/bead-thread-cache.js +8 -0
- package/dist/beads/bead-thread-cache.test.js +91 -0
- package/dist/beads/discord-sync.js +18 -0
- package/dist/beads/discord-sync.test.js +782 -0
- package/dist/beads/find-bead-by-thread.test.js +36 -0
- package/dist/beads/forum-guard.js +2 -0
- package/dist/beads/forum-guard.test.js +204 -0
- package/dist/beads/initialize.js +3 -0
- package/dist/beads/initialize.test.js +304 -0
- package/dist/beads/types.js +10 -0
- package/dist/cli/daemon-installer.js +225 -0
- package/dist/cli/daemon-installer.test.js +289 -0
- package/dist/cli/index.js +42 -0
- package/dist/cli/init-wizard.js +374 -0
- package/dist/cli/init-wizard.test.js +191 -0
- package/dist/config.js +385 -0
- package/dist/config.test.js +589 -0
- package/dist/cron/auto-tag.js +100 -0
- package/dist/cron/auto-tag.test.js +91 -0
- package/dist/cron/cadence.js +74 -0
- package/dist/cron/cadence.test.js +53 -0
- package/dist/cron/cron-sync-coordinator.js +66 -0
- package/dist/cron/cron-sync-coordinator.test.js +118 -0
- package/dist/cron/cron-sync.js +165 -0
- package/dist/cron/cron-sync.test.js +228 -0
- package/dist/cron/cron-tag-map-watcher.js +128 -0
- package/dist/cron/cron-tag-map-watcher.test.js +155 -0
- package/dist/cron/default-timezone.js +23 -0
- package/dist/cron/default-timezone.test.js +30 -0
- package/dist/cron/discord-sync.js +205 -0
- package/dist/cron/discord-sync.test.js +353 -0
- package/dist/cron/executor.js +303 -0
- package/dist/cron/executor.test.js +614 -0
- package/dist/cron/forum-sync.js +347 -0
- package/dist/cron/forum-sync.test.js +539 -0
- package/dist/cron/job-lock.js +164 -0
- package/dist/cron/job-lock.test.js +178 -0
- package/dist/cron/parser.js +68 -0
- package/dist/cron/parser.test.js +115 -0
- package/dist/cron/run-control.js +24 -0
- package/dist/cron/run-control.test.js +27 -0
- package/dist/cron/run-stats.js +265 -0
- package/dist/cron/run-stats.test.js +160 -0
- package/dist/cron/scheduler.js +97 -0
- package/dist/cron/scheduler.test.js +112 -0
- package/dist/cron/tag-map.js +47 -0
- package/dist/cron/tag-map.test.js +64 -0
- package/dist/cron/types.js +1 -0
- package/dist/discoclaw-plan-format.test.js +137 -0
- package/dist/discoclaw-recipe-format.test.js +137 -0
- package/dist/discord/abort-registry.js +70 -0
- package/dist/discord/action-categories.js +36 -0
- package/dist/discord/action-types.js +1 -0
- package/dist/discord/action-utils.js +58 -0
- package/dist/discord/action-utils.test.js +58 -0
- package/dist/discord/actions-beads.js +1 -0
- package/dist/discord/actions-beads.test.js +372 -0
- package/dist/discord/actions-bot-profile.js +107 -0
- package/dist/discord/actions-bot-profile.test.js +138 -0
- package/dist/discord/actions-channels.js +427 -0
- package/dist/discord/actions-channels.test.js +697 -0
- package/dist/discord/actions-config.js +173 -0
- package/dist/discord/actions-config.test.js +322 -0
- package/dist/discord/actions-crons.js +586 -0
- package/dist/discord/actions-crons.test.js +499 -0
- package/dist/discord/actions-defer.js +60 -0
- package/dist/discord/actions-defer.test.js +134 -0
- package/dist/discord/actions-forge.js +134 -0
- package/dist/discord/actions-forge.test.js +206 -0
- package/dist/discord/actions-guild.js +301 -0
- package/dist/discord/actions-guild.test.js +386 -0
- package/dist/discord/actions-memory.js +106 -0
- package/dist/discord/actions-memory.test.js +248 -0
- package/dist/discord/actions-messaging.js +401 -0
- package/dist/discord/actions-messaging.test.js +738 -0
- package/dist/discord/actions-moderation.js +65 -0
- package/dist/discord/actions-moderation.test.js +88 -0
- package/dist/discord/actions-plan.js +445 -0
- package/dist/discord/actions-plan.test.js +610 -0
- package/dist/discord/actions-poll.js +38 -0
- package/dist/discord/actions-poll.test.js +93 -0
- package/dist/discord/actions-tasks.js +3 -0
- package/dist/discord/actions-tasks.test.js +418 -0
- package/dist/discord/actions.js +600 -0
- package/dist/discord/actions.test.js +522 -0
- package/dist/discord/allowed-mentions.js +3 -0
- package/dist/discord/allowed-mentions.test.js +17 -0
- package/dist/discord/allowlist.js +29 -0
- package/dist/discord/allowlist.test.js +24 -0
- package/dist/discord/audit-handler.js +191 -0
- package/dist/discord/audit-handler.test.js +361 -0
- package/dist/discord/bot.js +141 -0
- package/dist/discord/channel-context.js +181 -0
- package/dist/discord/defer-scheduler.js +45 -0
- package/dist/discord/destructive-confirmation.js +128 -0
- package/dist/discord/destructive-confirmation.test.js +49 -0
- package/dist/discord/discord-plan-auto-implement.test.js +18 -0
- package/dist/discord/durable-memory.js +145 -0
- package/dist/discord/durable-memory.test.js +281 -0
- package/dist/discord/durable-write-queue.js +4 -0
- package/dist/discord/file-download.js +308 -0
- package/dist/discord/file-download.test.js +303 -0
- package/dist/discord/forge-audit-verdict.js +140 -0
- package/dist/discord/forge-auto-implement.js +80 -0
- package/dist/discord/forge-auto-implement.test.js +110 -0
- package/dist/discord/forge-commands.js +698 -0
- package/dist/discord/forge-commands.test.js +1606 -0
- package/dist/discord/forge-plan-registry.js +68 -0
- package/dist/discord/forge-plan-registry.test.js +127 -0
- package/dist/discord/forum-count-sync.js +130 -0
- package/dist/discord/forum-count-sync.test.js +200 -0
- package/dist/discord/health-command.js +98 -0
- package/dist/discord/health-command.test.js +195 -0
- package/dist/discord/help-command.js +22 -0
- package/dist/discord/help-command.test.js +49 -0
- package/dist/discord/image-download.js +201 -0
- package/dist/discord/image-download.test.js +499 -0
- package/dist/discord/inflight-replies.js +228 -0
- package/dist/discord/inflight-replies.test.js +295 -0
- package/dist/discord/json-extract.js +110 -0
- package/dist/discord/keyed-queue.js +22 -0
- package/dist/discord/memory-commands.js +85 -0
- package/dist/discord/memory-commands.test.js +159 -0
- package/dist/discord/memory-timing.integration.test.js +159 -0
- package/dist/discord/message-coordinator.js +2347 -0
- package/dist/discord/message-coordinator.onboarding.test.js +183 -0
- package/dist/discord/message-coordinator.plan-run.test.js +264 -0
- package/dist/discord/message-history.js +53 -0
- package/dist/discord/message-history.test.js +95 -0
- package/dist/discord/models-command.js +59 -0
- package/dist/discord/models-command.test.js +150 -0
- package/dist/discord/nickname.test.js +76 -0
- package/dist/discord/onboarding-completion.js +55 -0
- package/dist/discord/onboarding-completion.test.js +176 -0
- package/dist/discord/output-common.js +178 -0
- package/dist/discord/output-common.test.js +198 -0
- package/dist/discord/output-utils.js +156 -0
- package/dist/discord/parse-identity-name.test.js +129 -0
- package/dist/discord/plan-commands.js +612 -0
- package/dist/discord/plan-commands.test.js +1622 -0
- package/dist/discord/plan-manager.js +1491 -0
- package/dist/discord/plan-manager.test.js +2380 -0
- package/dist/discord/plan-parser.js +110 -0
- package/dist/discord/plan-parser.test.js +63 -0
- package/dist/discord/plan-run-phase-start.js +20 -0
- package/dist/discord/plan-run-phase-start.test.js +29 -0
- package/dist/discord/platform-message.js +45 -0
- package/dist/discord/platform-message.test.js +110 -0
- package/dist/discord/prompt-common.js +240 -0
- package/dist/discord/prompt-common.test.js +423 -0
- package/dist/discord/reaction-handler.js +691 -0
- package/dist/discord/reaction-handler.test.js +1574 -0
- package/dist/discord/reaction-prompts.js +118 -0
- package/dist/discord/reaction-prompts.test.js +253 -0
- package/dist/discord/reply-reference.js +66 -0
- package/dist/discord/reply-reference.test.js +125 -0
- package/dist/discord/restart-command.js +143 -0
- package/dist/discord/restart-command.test.js +196 -0
- package/dist/discord/runtime-utils.js +43 -0
- package/dist/discord/runtime-utils.test.js +112 -0
- package/dist/discord/session-key.js +7 -0
- package/dist/discord/session-key.test.js +13 -0
- package/dist/discord/shortterm-memory.js +166 -0
- package/dist/discord/shortterm-memory.test.js +345 -0
- package/dist/discord/shutdown-context.js +122 -0
- package/dist/discord/shutdown-context.test.js +279 -0
- package/dist/discord/startup-profile.test.js +214 -0
- package/dist/discord/status-channel.js +190 -0
- package/dist/discord/status-channel.test.js +282 -0
- package/dist/discord/status-command.js +206 -0
- package/dist/discord/status-command.test.js +341 -0
- package/dist/discord/streaming-progress.js +107 -0
- package/dist/discord/streaming-progress.test.js +93 -0
- package/dist/discord/summarizer.js +89 -0
- package/dist/discord/summarizer.test.js +245 -0
- package/dist/discord/system-bootstrap.js +396 -0
- package/dist/discord/system-bootstrap.test.js +724 -0
- package/dist/discord/thread-context.js +169 -0
- package/dist/discord/thread-context.test.js +386 -0
- package/dist/discord/tool-aware-queue.js +116 -0
- package/dist/discord/tool-aware-queue.test.js +180 -0
- package/dist/discord/update-command.js +127 -0
- package/dist/discord/update-command.test.js +275 -0
- package/dist/discord/user-errors.js +40 -0
- package/dist/discord/user-errors.test.js +31 -0
- package/dist/discord/user-turn-to-durable.js +111 -0
- package/dist/discord/user-turn-to-durable.test.js +273 -0
- package/dist/discord-followup.test.js +677 -0
- package/dist/discord.channel-context.test.js +95 -0
- package/dist/discord.fail-closed.test.js +199 -0
- package/dist/discord.health-command.integration.test.js +140 -0
- package/dist/discord.js +190 -0
- package/dist/discord.prompt-context.test.js +1431 -0
- package/dist/discord.render.test.js +621 -0
- package/dist/discord.status-wiring.test.js +187 -0
- package/dist/engine/claudeCli.js +137 -0
- package/dist/engine/types.js +1 -0
- package/dist/group-queue.js +25 -0
- package/dist/health/credential-check.js +175 -0
- package/dist/health/credential-check.test.js +401 -0
- package/dist/health/startup-healing.js +139 -0
- package/dist/health/startup-healing.test.js +298 -0
- package/dist/identity.js +36 -0
- package/dist/index.js +1378 -0
- package/dist/logging/logger-like.js +1 -0
- package/dist/observability/memory-sampler.js +51 -0
- package/dist/observability/memory-sampler.test.js +93 -0
- package/dist/observability/metrics.js +88 -0
- package/dist/observability/metrics.test.js +42 -0
- package/dist/onboarding/onboarding-flow.js +246 -0
- package/dist/onboarding/onboarding-flow.test.js +238 -0
- package/dist/onboarding/onboarding-writer.js +102 -0
- package/dist/onboarding/onboarding-writer.test.js +143 -0
- package/dist/pidlock.js +187 -0
- package/dist/pidlock.test.js +128 -0
- package/dist/pipeline/engine.js +206 -0
- package/dist/pipeline/engine.test.js +771 -0
- package/dist/root-policy.js +21 -0
- package/dist/root-policy.test.js +55 -0
- package/dist/runtime/claude-code-cli.js +35 -0
- package/dist/runtime/claude-code-cli.test.js +1199 -0
- package/dist/runtime/cli-adapter.js +584 -0
- package/dist/runtime/cli-output-parsers.js +108 -0
- package/dist/runtime/cli-shared.js +96 -0
- package/dist/runtime/cli-shared.test.js +104 -0
- package/dist/runtime/cli-strategy.js +6 -0
- package/dist/runtime/codex-cli.js +16 -0
- package/dist/runtime/codex-cli.test.js +862 -0
- package/dist/runtime/concurrency-limit.js +80 -0
- package/dist/runtime/concurrency-limit.test.js +137 -0
- package/dist/runtime/gemini-cli.js +16 -0
- package/dist/runtime/gemini-cli.test.js +413 -0
- package/dist/runtime/long-running-process.js +415 -0
- package/dist/runtime/long-running-process.test.js +318 -0
- package/dist/runtime/model-smoke-helpers.js +160 -0
- package/dist/runtime/model-smoke.test.js +194 -0
- package/dist/runtime/model-tiers.js +33 -0
- package/dist/runtime/model-tiers.test.js +65 -0
- package/dist/runtime/openai-auth.js +151 -0
- package/dist/runtime/openai-auth.test.js +361 -0
- package/dist/runtime/openai-compat.js +178 -0
- package/dist/runtime/openai-compat.test.js +449 -0
- package/dist/runtime/process-pool.js +93 -0
- package/dist/runtime/process-pool.test.js +148 -0
- package/dist/runtime/registry.js +15 -0
- package/dist/runtime/registry.test.js +47 -0
- package/dist/runtime/session-scanner.js +186 -0
- package/dist/runtime/session-scanner.test.js +257 -0
- package/dist/runtime/strategies/claude-strategy.js +193 -0
- package/dist/runtime/strategies/codex-strategy.js +161 -0
- package/dist/runtime/strategies/gemini-strategy.js +64 -0
- package/dist/runtime/strategies/template-strategy.js +85 -0
- package/dist/runtime/tool-capabilities.js +27 -0
- package/dist/runtime/tool-capabilities.test.js +24 -0
- package/dist/runtime/tool-labels.js +48 -0
- package/dist/runtime/types.js +2 -0
- package/dist/sessionManager.js +47 -0
- package/dist/sessions.js +18 -0
- package/dist/tasks/architecture-contract.js +33 -0
- package/dist/tasks/architecture-contract.test.js +90 -0
- package/dist/tasks/auto-tag.js +50 -0
- package/dist/tasks/auto-tag.test.js +64 -0
- package/dist/tasks/bd-cli.js +164 -0
- package/dist/tasks/bd-cli.test.js +359 -0
- package/dist/tasks/bead-sync.js +1 -0
- package/dist/tasks/context-summary.js +27 -0
- package/dist/tasks/discord-sync.js +3 -0
- package/dist/tasks/discord-sync.test.js +685 -0
- package/dist/tasks/discord-types.js +4 -0
- package/dist/tasks/find-task-by-thread.test.js +36 -0
- package/dist/tasks/forum-guard.js +81 -0
- package/dist/tasks/forum-guard.test.js +192 -0
- package/dist/tasks/initialize.js +77 -0
- package/dist/tasks/initialize.test.js +263 -0
- package/dist/tasks/logger-types.js +1 -0
- package/dist/tasks/metrics-types.js +3 -0
- package/dist/tasks/migrate.js +33 -0
- package/dist/tasks/migrate.test.js +156 -0
- package/dist/tasks/path-defaults.js +67 -0
- package/dist/tasks/path-defaults.test.js +73 -0
- package/dist/tasks/runtime-types.js +1 -0
- package/dist/tasks/service.js +33 -0
- package/dist/tasks/service.test.js +51 -0
- package/dist/tasks/store.js +238 -0
- package/dist/tasks/store.test.js +417 -0
- package/dist/tasks/sync-context.js +1 -0
- package/dist/tasks/sync-contract.js +24 -0
- package/dist/tasks/sync-contract.test.js +25 -0
- package/dist/tasks/sync-coordinator-metrics.js +41 -0
- package/dist/tasks/sync-coordinator-retries.js +71 -0
- package/dist/tasks/sync-coordinator.js +96 -0
- package/dist/tasks/sync-coordinator.test.js +501 -0
- package/dist/tasks/sync-types.js +1 -0
- package/dist/tasks/sync-watcher.js +27 -0
- package/dist/tasks/sync-watcher.test.js +92 -0
- package/dist/tasks/tag-map.js +36 -0
- package/dist/tasks/tag-map.test.js +54 -0
- package/dist/tasks/task-action-contract.js +16 -0
- package/dist/tasks/task-action-contract.test.js +16 -0
- package/dist/tasks/task-action-executor.js +18 -0
- package/dist/tasks/task-action-executor.test.js +420 -0
- package/dist/tasks/task-action-mutation-helpers.js +17 -0
- package/dist/tasks/task-action-mutations.js +151 -0
- package/dist/tasks/task-action-prompt.js +62 -0
- package/dist/tasks/task-action-read-ops.js +73 -0
- package/dist/tasks/task-action-runner-types.js +1 -0
- package/dist/tasks/task-action-thread-sync.js +82 -0
- package/dist/tasks/task-actions.js +3 -0
- package/dist/tasks/task-cli.js +227 -0
- package/dist/tasks/task-context.js +1 -0
- package/dist/tasks/task-lifecycle.js +46 -0
- package/dist/tasks/task-lifecycle.test.js +35 -0
- package/dist/tasks/task-sync-apply-plan.js +95 -0
- package/dist/tasks/task-sync-apply-types.js +12 -0
- package/dist/tasks/task-sync-apply.js +319 -0
- package/dist/tasks/task-sync-cli.js +89 -0
- package/dist/tasks/task-sync-cli.test.js +70 -0
- package/dist/tasks/task-sync-engine.js +88 -0
- package/dist/tasks/task-sync-engine.test.js +934 -0
- package/dist/tasks/task-sync-phase-apply.js +171 -0
- package/dist/tasks/task-sync-pipeline.js +2 -0
- package/dist/tasks/task-sync-pipeline.test.js +265 -0
- package/dist/tasks/task-sync-reconcile-plan.js +182 -0
- package/dist/tasks/task-sync-reconcile.js +144 -0
- package/dist/tasks/task-sync.js +56 -0
- package/dist/tasks/task-sync.test.js +86 -0
- package/dist/tasks/thread-cache.js +42 -0
- package/dist/tasks/thread-cache.test.js +89 -0
- package/dist/tasks/thread-contracts.test.js +711 -0
- package/dist/tasks/thread-forum-ops.js +68 -0
- package/dist/tasks/thread-helpers.js +86 -0
- package/dist/tasks/thread-helpers.test.js +33 -0
- package/dist/tasks/thread-lifecycle-ops.js +144 -0
- package/dist/tasks/thread-ops-shared.js +21 -0
- package/dist/tasks/thread-ops.js +2 -0
- package/dist/tasks/types.js +20 -0
- package/dist/tasks/types.test.js +60 -0
- package/dist/test-setup.js +11 -0
- package/dist/test-setup.test.js +42 -0
- package/dist/transport/types.js +1 -0
- package/dist/validate.js +41 -0
- package/dist/validate.test.js +94 -0
- package/dist/version.js +15 -0
- package/dist/version.test.js +31 -0
- package/dist/webhook/server.js +199 -0
- package/dist/webhook/server.test.js +460 -0
- package/dist/workspace-bootstrap.js +135 -0
- package/dist/workspace-bootstrap.test.js +514 -0
- package/dist/workspace-permissions.js +134 -0
- package/dist/workspace-permissions.test.js +181 -0
- package/package.json +74 -0
- package/scripts/cron/cron-tag-map.json +9 -0
- package/scripts/tasks/tag-map.json +10 -0
- package/systemd/discoclaw.service +19 -0
- package/templates/recipes/integration.discoclaw-recipe.md +171 -0
- package/templates/workspace/AGENTS.md +217 -0
- package/templates/workspace/BOOTSTRAP.md +1 -0
- package/templates/workspace/HEARTBEAT.md +10 -0
- package/templates/workspace/IDENTITY.md +16 -0
- package/templates/workspace/MEMORY.md +24 -0
- package/templates/workspace/SOUL.md +52 -0
- package/templates/workspace/TOOLS.md +304 -0
- package/templates/workspace/USER.md +37 -0
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Types
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
export const CADENCE_TAGS = ['yearly', 'frequent', 'hourly', 'daily', 'weekly', 'monthly'];
|
|
8
|
+
export const CURRENT_VERSION = 3;
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Stable Cron ID generation
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
export function generateCronId() {
|
|
13
|
+
return `cron-${crypto.randomBytes(4).toString('hex')}`;
|
|
14
|
+
}
|
|
15
|
+
export function parseCronIdFromContent(content) {
|
|
16
|
+
const match = content.match(/\[cronId:(cron-[a-f0-9]+)\]/);
|
|
17
|
+
return match ? match[1] : null;
|
|
18
|
+
}
|
|
19
|
+
class WriteMutex {
|
|
20
|
+
queue = [];
|
|
21
|
+
running = false;
|
|
22
|
+
async run(fn) {
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
this.queue.push({ fn, resolve, reject });
|
|
25
|
+
if (!this.running)
|
|
26
|
+
void this.drain();
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
async drain() {
|
|
30
|
+
this.running = true;
|
|
31
|
+
while (this.queue.length > 0) {
|
|
32
|
+
const entry = this.queue.shift();
|
|
33
|
+
try {
|
|
34
|
+
await entry.fn();
|
|
35
|
+
entry.resolve();
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
entry.reject(err);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
this.running = false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Store implementation
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
export function emptyStore() {
|
|
48
|
+
return { version: CURRENT_VERSION, updatedAt: Date.now(), jobs: {} };
|
|
49
|
+
}
|
|
50
|
+
function emptyRecord(cronId, threadId) {
|
|
51
|
+
return {
|
|
52
|
+
cronId,
|
|
53
|
+
threadId,
|
|
54
|
+
runCount: 0,
|
|
55
|
+
lastRunAt: null,
|
|
56
|
+
lastRunStatus: null,
|
|
57
|
+
cadence: null,
|
|
58
|
+
purposeTags: [],
|
|
59
|
+
disabled: false,
|
|
60
|
+
model: null,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
export class CronRunStats {
|
|
64
|
+
store;
|
|
65
|
+
filePath;
|
|
66
|
+
mutex = new WriteMutex();
|
|
67
|
+
// Secondary index: threadId → cronId for O(1) lookups.
|
|
68
|
+
threadIndex = new Map();
|
|
69
|
+
// Secondary index: statusMessageId → cronId for O(1) recovery lookups.
|
|
70
|
+
statusMessageIndex = new Map();
|
|
71
|
+
// Secondary index: webhookSourceId → cronId for O(1) webhook routing.
|
|
72
|
+
sourceIndex = new Map();
|
|
73
|
+
constructor(store, filePath) {
|
|
74
|
+
this.store = store;
|
|
75
|
+
this.filePath = filePath;
|
|
76
|
+
this.rebuildThreadIndex();
|
|
77
|
+
}
|
|
78
|
+
rebuildThreadIndex() {
|
|
79
|
+
this.threadIndex.clear();
|
|
80
|
+
this.statusMessageIndex.clear();
|
|
81
|
+
this.sourceIndex.clear();
|
|
82
|
+
for (const rec of Object.values(this.store.jobs)) {
|
|
83
|
+
this.threadIndex.set(rec.threadId, rec.cronId);
|
|
84
|
+
if (rec.statusMessageId) {
|
|
85
|
+
this.statusMessageIndex.set(rec.statusMessageId, rec.cronId);
|
|
86
|
+
}
|
|
87
|
+
if (rec.webhookSourceId) {
|
|
88
|
+
this.sourceIndex.set(rec.webhookSourceId, rec.cronId);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
getStore() {
|
|
93
|
+
return this.store;
|
|
94
|
+
}
|
|
95
|
+
getRecord(cronId) {
|
|
96
|
+
return this.store.jobs[cronId];
|
|
97
|
+
}
|
|
98
|
+
getRecordByThreadId(threadId) {
|
|
99
|
+
const cronId = this.threadIndex.get(threadId);
|
|
100
|
+
return cronId ? this.store.jobs[cronId] : undefined;
|
|
101
|
+
}
|
|
102
|
+
getRecordByStatusMessageId(statusMessageId) {
|
|
103
|
+
const cronId = this.statusMessageIndex.get(statusMessageId);
|
|
104
|
+
return cronId ? this.store.jobs[cronId] : undefined;
|
|
105
|
+
}
|
|
106
|
+
getRecordBySourceId(sourceId) {
|
|
107
|
+
const cronId = this.sourceIndex.get(sourceId);
|
|
108
|
+
return cronId ? this.store.jobs[cronId] : undefined;
|
|
109
|
+
}
|
|
110
|
+
async upsertRecord(cronId, threadId, updates) {
|
|
111
|
+
let record;
|
|
112
|
+
await this.mutex.run(async () => {
|
|
113
|
+
// Enforce sourceId uniqueness before mutating state.
|
|
114
|
+
const incomingSourceId = updates?.webhookSourceId;
|
|
115
|
+
if (incomingSourceId !== undefined) {
|
|
116
|
+
const claimant = this.sourceIndex.get(incomingSourceId);
|
|
117
|
+
if (claimant && claimant !== cronId) {
|
|
118
|
+
throw new Error(`webhookSourceId "${incomingSourceId}" is already claimed by cronId "${claimant}"`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const existing = this.store.jobs[cronId];
|
|
122
|
+
if (existing) {
|
|
123
|
+
const prevStatusMessageId = existing.statusMessageId;
|
|
124
|
+
const prevSourceId = existing.webhookSourceId;
|
|
125
|
+
// If threadId changed, remove old index entry.
|
|
126
|
+
if (existing.threadId !== threadId) {
|
|
127
|
+
this.threadIndex.delete(existing.threadId);
|
|
128
|
+
}
|
|
129
|
+
if (updates)
|
|
130
|
+
Object.assign(existing, updates);
|
|
131
|
+
existing.threadId = threadId;
|
|
132
|
+
if (prevStatusMessageId && prevStatusMessageId !== existing.statusMessageId) {
|
|
133
|
+
this.statusMessageIndex.delete(prevStatusMessageId);
|
|
134
|
+
}
|
|
135
|
+
if (prevSourceId && prevSourceId !== existing.webhookSourceId) {
|
|
136
|
+
this.sourceIndex.delete(prevSourceId);
|
|
137
|
+
}
|
|
138
|
+
record = existing;
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
record = { ...emptyRecord(cronId, threadId), ...updates };
|
|
142
|
+
this.store.jobs[cronId] = record;
|
|
143
|
+
}
|
|
144
|
+
this.threadIndex.set(threadId, cronId);
|
|
145
|
+
if (record.statusMessageId) {
|
|
146
|
+
this.statusMessageIndex.set(record.statusMessageId, cronId);
|
|
147
|
+
}
|
|
148
|
+
if (record.webhookSourceId) {
|
|
149
|
+
this.sourceIndex.set(record.webhookSourceId, cronId);
|
|
150
|
+
}
|
|
151
|
+
this.store.updatedAt = Date.now();
|
|
152
|
+
await this.flush();
|
|
153
|
+
});
|
|
154
|
+
return record;
|
|
155
|
+
}
|
|
156
|
+
async recordRun(cronId, status, errorMessage) {
|
|
157
|
+
await this.mutex.run(async () => {
|
|
158
|
+
const rec = this.store.jobs[cronId];
|
|
159
|
+
if (!rec)
|
|
160
|
+
return;
|
|
161
|
+
rec.runCount++;
|
|
162
|
+
rec.lastRunAt = new Date().toISOString();
|
|
163
|
+
rec.lastRunStatus = status;
|
|
164
|
+
if (status === 'error' && errorMessage) {
|
|
165
|
+
rec.lastErrorMessage = errorMessage.slice(0, 200);
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
delete rec.lastErrorMessage;
|
|
169
|
+
}
|
|
170
|
+
this.store.updatedAt = Date.now();
|
|
171
|
+
await this.flush();
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
async removeRecord(cronId) {
|
|
175
|
+
let removed = false;
|
|
176
|
+
await this.mutex.run(async () => {
|
|
177
|
+
const rec = this.store.jobs[cronId];
|
|
178
|
+
if (rec) {
|
|
179
|
+
this.threadIndex.delete(rec.threadId);
|
|
180
|
+
if (rec.statusMessageId)
|
|
181
|
+
this.statusMessageIndex.delete(rec.statusMessageId);
|
|
182
|
+
if (rec.webhookSourceId)
|
|
183
|
+
this.sourceIndex.delete(rec.webhookSourceId);
|
|
184
|
+
delete this.store.jobs[cronId];
|
|
185
|
+
this.store.updatedAt = Date.now();
|
|
186
|
+
removed = true;
|
|
187
|
+
await this.flush();
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
return removed;
|
|
191
|
+
}
|
|
192
|
+
async removeByThreadId(threadId) {
|
|
193
|
+
let removed = false;
|
|
194
|
+
await this.mutex.run(async () => {
|
|
195
|
+
for (const [cronId, rec] of Object.entries(this.store.jobs)) {
|
|
196
|
+
if (rec.threadId === threadId) {
|
|
197
|
+
this.threadIndex.delete(threadId);
|
|
198
|
+
if (rec.statusMessageId)
|
|
199
|
+
this.statusMessageIndex.delete(rec.statusMessageId);
|
|
200
|
+
if (rec.webhookSourceId)
|
|
201
|
+
this.sourceIndex.delete(rec.webhookSourceId);
|
|
202
|
+
delete this.store.jobs[cronId];
|
|
203
|
+
removed = true;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (removed) {
|
|
207
|
+
this.store.updatedAt = Date.now();
|
|
208
|
+
await this.flush();
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
return removed;
|
|
212
|
+
}
|
|
213
|
+
async flush() {
|
|
214
|
+
const dir = path.dirname(this.filePath);
|
|
215
|
+
await fs.mkdir(dir, { recursive: true });
|
|
216
|
+
const tmp = `${this.filePath}.tmp.${process.pid}`;
|
|
217
|
+
await fs.writeFile(tmp, JSON.stringify(this.store, null, 2) + '\n', 'utf8');
|
|
218
|
+
await fs.rename(tmp, this.filePath);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
// Load / create
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
export async function loadRunStats(filePath) {
|
|
225
|
+
let store;
|
|
226
|
+
try {
|
|
227
|
+
const raw = await fs.readFile(filePath, 'utf8');
|
|
228
|
+
const parsed = JSON.parse(raw);
|
|
229
|
+
if (parsed &&
|
|
230
|
+
typeof parsed === 'object' &&
|
|
231
|
+
'version' in parsed &&
|
|
232
|
+
'jobs' in parsed &&
|
|
233
|
+
typeof parsed.jobs === 'object') {
|
|
234
|
+
store = parsed;
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
store = emptyStore();
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
store = emptyStore();
|
|
242
|
+
}
|
|
243
|
+
// Sequential version-migration guards — see docs/data-migration.md for the convention.
|
|
244
|
+
// Each block handles exactly one step; transformations are additive only.
|
|
245
|
+
// Migrate v1 → v2: backfill triggerType on existing records (additive, no data loss).
|
|
246
|
+
if (store.version === 1) {
|
|
247
|
+
for (const rec of Object.values(store.jobs)) {
|
|
248
|
+
if (!rec.triggerType)
|
|
249
|
+
rec.triggerType = 'schedule';
|
|
250
|
+
}
|
|
251
|
+
store.version = 2;
|
|
252
|
+
}
|
|
253
|
+
// Migrate v2 → v3: ensure triggerType is set on any records that lack it
|
|
254
|
+
// (records created via upsertRecord without an explicit triggerType update).
|
|
255
|
+
if (store.version === 2) {
|
|
256
|
+
for (const rec of Object.values(store.jobs)) {
|
|
257
|
+
if (!rec.triggerType)
|
|
258
|
+
rec.triggerType = 'schedule';
|
|
259
|
+
}
|
|
260
|
+
store.version = 3;
|
|
261
|
+
}
|
|
262
|
+
// Add future migration blocks here:
|
|
263
|
+
// if (store.version === 3) { /* transform fields */; store.version = 4; }
|
|
264
|
+
return new CronRunStats(store, filePath);
|
|
265
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import { loadRunStats, emptyStore, generateCronId, parseCronIdFromContent, } from './run-stats.js';
|
|
6
|
+
let tmpDir;
|
|
7
|
+
let statsPath;
|
|
8
|
+
beforeEach(async () => {
|
|
9
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cron-stats-'));
|
|
10
|
+
statsPath = path.join(tmpDir, 'cron-run-stats.json');
|
|
11
|
+
});
|
|
12
|
+
afterEach(async () => {
|
|
13
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
14
|
+
});
|
|
15
|
+
describe('generateCronId', () => {
|
|
16
|
+
it('produces cron-prefixed hex IDs', () => {
|
|
17
|
+
const id = generateCronId();
|
|
18
|
+
expect(id).toMatch(/^cron-[a-f0-9]{8}$/);
|
|
19
|
+
});
|
|
20
|
+
it('generates unique IDs', () => {
|
|
21
|
+
const ids = new Set(Array.from({ length: 100 }, () => generateCronId()));
|
|
22
|
+
expect(ids.size).toBe(100);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
describe('parseCronIdFromContent', () => {
|
|
26
|
+
it('extracts cronId from status message content', () => {
|
|
27
|
+
const content = '📊 **Cron Status** [cronId:cron-a1b2c3d4]\n**Last run:** ...';
|
|
28
|
+
expect(parseCronIdFromContent(content)).toBe('cron-a1b2c3d4');
|
|
29
|
+
});
|
|
30
|
+
it('returns null when no cronId token present', () => {
|
|
31
|
+
expect(parseCronIdFromContent('Just some text')).toBeNull();
|
|
32
|
+
});
|
|
33
|
+
it('returns null for empty content', () => {
|
|
34
|
+
expect(parseCronIdFromContent('')).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
describe('CronRunStats', () => {
|
|
38
|
+
it('creates empty store on missing file', async () => {
|
|
39
|
+
const stats = await loadRunStats(statsPath);
|
|
40
|
+
const store = stats.getStore();
|
|
41
|
+
expect(store.version).toBe(3);
|
|
42
|
+
expect(Object.keys(store.jobs)).toHaveLength(0);
|
|
43
|
+
});
|
|
44
|
+
it('upserts and retrieves records by cronId', async () => {
|
|
45
|
+
const stats = await loadRunStats(statsPath);
|
|
46
|
+
const rec = await stats.upsertRecord('cron-test1', 'thread-1');
|
|
47
|
+
expect(rec.cronId).toBe('cron-test1');
|
|
48
|
+
expect(rec.threadId).toBe('thread-1');
|
|
49
|
+
expect(rec.runCount).toBe(0);
|
|
50
|
+
const fetched = stats.getRecord('cron-test1');
|
|
51
|
+
expect(fetched).toBeDefined();
|
|
52
|
+
expect(fetched.threadId).toBe('thread-1');
|
|
53
|
+
});
|
|
54
|
+
it('upserts with partial updates', async () => {
|
|
55
|
+
const stats = await loadRunStats(statsPath);
|
|
56
|
+
await stats.upsertRecord('cron-test2', 'thread-2');
|
|
57
|
+
const updated = await stats.upsertRecord('cron-test2', 'thread-2', { cadence: 'daily', model: 'haiku' });
|
|
58
|
+
expect(updated.cadence).toBe('daily');
|
|
59
|
+
expect(updated.model).toBe('haiku');
|
|
60
|
+
});
|
|
61
|
+
it('retrieves records by threadId', async () => {
|
|
62
|
+
const stats = await loadRunStats(statsPath);
|
|
63
|
+
await stats.upsertRecord('cron-a', 'thread-100');
|
|
64
|
+
const rec = stats.getRecordByThreadId('thread-100');
|
|
65
|
+
expect(rec).toBeDefined();
|
|
66
|
+
expect(rec.cronId).toBe('cron-a');
|
|
67
|
+
});
|
|
68
|
+
it('retrieves records by statusMessageId', async () => {
|
|
69
|
+
const stats = await loadRunStats(statsPath);
|
|
70
|
+
await stats.upsertRecord('cron-b', 'thread-200', { statusMessageId: 'status-1' });
|
|
71
|
+
const rec = stats.getRecordByStatusMessageId('status-1');
|
|
72
|
+
expect(rec).toBeDefined();
|
|
73
|
+
expect(rec.cronId).toBe('cron-b');
|
|
74
|
+
});
|
|
75
|
+
it('returns undefined for unknown cronId', async () => {
|
|
76
|
+
const stats = await loadRunStats(statsPath);
|
|
77
|
+
expect(stats.getRecord('nonexistent')).toBeUndefined();
|
|
78
|
+
});
|
|
79
|
+
it('returns undefined for unknown threadId', async () => {
|
|
80
|
+
const stats = await loadRunStats(statsPath);
|
|
81
|
+
expect(stats.getRecordByThreadId('nonexistent')).toBeUndefined();
|
|
82
|
+
});
|
|
83
|
+
it('returns undefined for unknown statusMessageId', async () => {
|
|
84
|
+
const stats = await loadRunStats(statsPath);
|
|
85
|
+
expect(stats.getRecordByStatusMessageId('missing')).toBeUndefined();
|
|
86
|
+
});
|
|
87
|
+
it('records successful runs', async () => {
|
|
88
|
+
const stats = await loadRunStats(statsPath);
|
|
89
|
+
await stats.upsertRecord('cron-r1', 'thread-r1');
|
|
90
|
+
await stats.recordRun('cron-r1', 'success');
|
|
91
|
+
const rec = stats.getRecord('cron-r1');
|
|
92
|
+
expect(rec.runCount).toBe(1);
|
|
93
|
+
expect(rec.lastRunStatus).toBe('success');
|
|
94
|
+
expect(rec.lastRunAt).toBeTruthy();
|
|
95
|
+
expect(rec.lastErrorMessage).toBeUndefined();
|
|
96
|
+
});
|
|
97
|
+
it('records error runs with capped message', async () => {
|
|
98
|
+
const stats = await loadRunStats(statsPath);
|
|
99
|
+
await stats.upsertRecord('cron-r2', 'thread-r2');
|
|
100
|
+
const longMsg = 'x'.repeat(300);
|
|
101
|
+
await stats.recordRun('cron-r2', 'error', longMsg);
|
|
102
|
+
const rec = stats.getRecord('cron-r2');
|
|
103
|
+
expect(rec.runCount).toBe(1);
|
|
104
|
+
expect(rec.lastRunStatus).toBe('error');
|
|
105
|
+
expect(rec.lastErrorMessage).toHaveLength(200);
|
|
106
|
+
});
|
|
107
|
+
it('increments runCount across multiple runs', async () => {
|
|
108
|
+
const stats = await loadRunStats(statsPath);
|
|
109
|
+
await stats.upsertRecord('cron-r3', 'thread-r3');
|
|
110
|
+
await stats.recordRun('cron-r3', 'success');
|
|
111
|
+
await stats.recordRun('cron-r3', 'success');
|
|
112
|
+
await stats.recordRun('cron-r3', 'error', 'oops');
|
|
113
|
+
const rec = stats.getRecord('cron-r3');
|
|
114
|
+
expect(rec.runCount).toBe(3);
|
|
115
|
+
expect(rec.lastRunStatus).toBe('error');
|
|
116
|
+
});
|
|
117
|
+
it('removes record by cronId', async () => {
|
|
118
|
+
const stats = await loadRunStats(statsPath);
|
|
119
|
+
await stats.upsertRecord('cron-del', 'thread-del');
|
|
120
|
+
const removed = await stats.removeRecord('cron-del');
|
|
121
|
+
expect(removed).toBe(true);
|
|
122
|
+
expect(stats.getRecord('cron-del')).toBeUndefined();
|
|
123
|
+
});
|
|
124
|
+
it('returns false when removing nonexistent cronId', async () => {
|
|
125
|
+
const stats = await loadRunStats(statsPath);
|
|
126
|
+
const removed = await stats.removeRecord('nope');
|
|
127
|
+
expect(removed).toBe(false);
|
|
128
|
+
});
|
|
129
|
+
it('removes record by threadId', async () => {
|
|
130
|
+
const stats = await loadRunStats(statsPath);
|
|
131
|
+
await stats.upsertRecord('cron-dt', 'thread-dt');
|
|
132
|
+
const removed = await stats.removeByThreadId('thread-dt');
|
|
133
|
+
expect(removed).toBe(true);
|
|
134
|
+
expect(stats.getRecordByThreadId('thread-dt')).toBeUndefined();
|
|
135
|
+
});
|
|
136
|
+
it('persists to disk and survives reload', async () => {
|
|
137
|
+
const stats = await loadRunStats(statsPath);
|
|
138
|
+
await stats.upsertRecord('cron-persist', 'thread-p', { cadence: 'weekly', purposeTags: ['monitoring'] });
|
|
139
|
+
await stats.recordRun('cron-persist', 'success');
|
|
140
|
+
const stats2 = await loadRunStats(statsPath);
|
|
141
|
+
const rec = stats2.getRecord('cron-persist');
|
|
142
|
+
expect(rec).toBeDefined();
|
|
143
|
+
expect(rec.cadence).toBe('weekly');
|
|
144
|
+
expect(rec.runCount).toBe(1);
|
|
145
|
+
expect(rec.purposeTags).toEqual(['monitoring']);
|
|
146
|
+
});
|
|
147
|
+
it('no-ops recordRun for unknown cronId', async () => {
|
|
148
|
+
const stats = await loadRunStats(statsPath);
|
|
149
|
+
await stats.recordRun('nonexistent', 'success');
|
|
150
|
+
// Should not throw
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
describe('emptyStore', () => {
|
|
154
|
+
it('returns valid initial structure', () => {
|
|
155
|
+
const store = emptyStore();
|
|
156
|
+
expect(store.version).toBe(3);
|
|
157
|
+
expect(store.updatedAt).toBeGreaterThan(0);
|
|
158
|
+
expect(Object.keys(store.jobs)).toHaveLength(0);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { Cron } from 'croner';
|
|
2
|
+
export class CronScheduler {
|
|
3
|
+
jobs = new Map();
|
|
4
|
+
handler;
|
|
5
|
+
log;
|
|
6
|
+
constructor(handler, log) {
|
|
7
|
+
this.handler = handler;
|
|
8
|
+
this.log = log;
|
|
9
|
+
}
|
|
10
|
+
register(id, threadId, guildId, name, def, cronId) {
|
|
11
|
+
const existing = this.jobs.get(id);
|
|
12
|
+
const isScheduled = def.triggerType === 'schedule';
|
|
13
|
+
// Create the job shell first; create the cron timer only for schedule-type automations.
|
|
14
|
+
const job = { id, cronId: cronId ?? existing?.cronId ?? '', threadId, guildId, name, def, cron: null, running: false };
|
|
15
|
+
if (isScheduled) {
|
|
16
|
+
// Construct timer first so invalid schedules don't clobber an existing job.
|
|
17
|
+
// schedule is always defined when triggerType === 'schedule'.
|
|
18
|
+
const cron = new Cron(def.schedule, { timezone: def.timezone }, () => {
|
|
19
|
+
// Fire-and-forget: errors handled inside the handler.
|
|
20
|
+
void this.handler(job);
|
|
21
|
+
});
|
|
22
|
+
job.cron = cron;
|
|
23
|
+
}
|
|
24
|
+
if (existing) {
|
|
25
|
+
existing.cron?.stop();
|
|
26
|
+
existing.cron = null;
|
|
27
|
+
this.jobs.delete(id);
|
|
28
|
+
}
|
|
29
|
+
this.jobs.set(id, job);
|
|
30
|
+
this.log?.info({ jobId: id, triggerType: def.triggerType ?? 'schedule', schedule: def.schedule, timezone: def.timezone }, 'cron:registered');
|
|
31
|
+
return job;
|
|
32
|
+
}
|
|
33
|
+
unregister(id) {
|
|
34
|
+
const job = this.jobs.get(id);
|
|
35
|
+
if (!job)
|
|
36
|
+
return false;
|
|
37
|
+
job.cron?.stop();
|
|
38
|
+
job.cron = null;
|
|
39
|
+
this.jobs.delete(id);
|
|
40
|
+
this.log?.info({ jobId: id }, 'cron:unregistered');
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
disable(id) {
|
|
44
|
+
const job = this.jobs.get(id);
|
|
45
|
+
if (!job)
|
|
46
|
+
return false;
|
|
47
|
+
job.cron?.stop();
|
|
48
|
+
job.cron = null;
|
|
49
|
+
this.log?.info({ jobId: id }, 'cron:disabled');
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
enable(id) {
|
|
53
|
+
const job = this.jobs.get(id);
|
|
54
|
+
if (!job)
|
|
55
|
+
return false;
|
|
56
|
+
const isScheduled = job.def.triggerType === 'schedule';
|
|
57
|
+
job.cron?.stop();
|
|
58
|
+
if (isScheduled) {
|
|
59
|
+
// Recreate the cron instance to (re)start scheduling.
|
|
60
|
+
// schedule is always defined when triggerType === 'schedule'.
|
|
61
|
+
job.cron = new Cron(job.def.schedule, { timezone: job.def.timezone }, () => {
|
|
62
|
+
void this.handler(job);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
job.cron = null;
|
|
67
|
+
}
|
|
68
|
+
this.log?.info({ jobId: id }, 'cron:enabled');
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
reload(id, newDef) {
|
|
72
|
+
const existing = this.jobs.get(id);
|
|
73
|
+
if (!existing)
|
|
74
|
+
return null;
|
|
75
|
+
return this.register(id, existing.threadId, existing.guildId, existing.name, newDef);
|
|
76
|
+
}
|
|
77
|
+
getJob(id) {
|
|
78
|
+
return this.jobs.get(id);
|
|
79
|
+
}
|
|
80
|
+
listJobs() {
|
|
81
|
+
return Array.from(this.jobs.values()).map((job) => ({
|
|
82
|
+
id: job.id,
|
|
83
|
+
name: job.name,
|
|
84
|
+
schedule: job.def.schedule,
|
|
85
|
+
timezone: job.def.timezone,
|
|
86
|
+
nextRun: job.cron?.nextRun() ?? null,
|
|
87
|
+
}));
|
|
88
|
+
}
|
|
89
|
+
stopAll() {
|
|
90
|
+
for (const job of this.jobs.values()) {
|
|
91
|
+
job.cron?.stop();
|
|
92
|
+
job.cron = null;
|
|
93
|
+
}
|
|
94
|
+
this.jobs.clear();
|
|
95
|
+
this.log?.info('cron:stopAll');
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { describe, expect, it, vi, afterEach } from 'vitest';
|
|
2
|
+
import { CronScheduler } from './scheduler.js';
|
|
3
|
+
function makeDef(overrides) {
|
|
4
|
+
return {
|
|
5
|
+
triggerType: 'schedule',
|
|
6
|
+
schedule: '0 7 * * 1-5',
|
|
7
|
+
timezone: 'UTC',
|
|
8
|
+
channel: 'general',
|
|
9
|
+
prompt: 'Say hello.',
|
|
10
|
+
...overrides,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
function mockLog() {
|
|
14
|
+
return { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
|
15
|
+
}
|
|
16
|
+
describe('CronScheduler', () => {
|
|
17
|
+
let scheduler;
|
|
18
|
+
const handler = vi.fn();
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
scheduler?.stopAll();
|
|
21
|
+
handler.mockReset();
|
|
22
|
+
});
|
|
23
|
+
it('registers and lists a job', () => {
|
|
24
|
+
scheduler = new CronScheduler(handler, mockLog());
|
|
25
|
+
const def = makeDef();
|
|
26
|
+
scheduler.register('t1', 't1', 'g1', 'Test Job', def);
|
|
27
|
+
const jobs = scheduler.listJobs();
|
|
28
|
+
expect(jobs).toHaveLength(1);
|
|
29
|
+
expect(jobs[0].id).toBe('t1');
|
|
30
|
+
expect(jobs[0].name).toBe('Test Job');
|
|
31
|
+
expect(jobs[0].schedule).toBe('0 7 * * 1-5');
|
|
32
|
+
expect(jobs[0].nextRun).toBeInstanceOf(Date);
|
|
33
|
+
});
|
|
34
|
+
it('unregisters a job', () => {
|
|
35
|
+
scheduler = new CronScheduler(handler, mockLog());
|
|
36
|
+
scheduler.register('t1', 't1', 'g1', 'Job', makeDef());
|
|
37
|
+
expect(scheduler.listJobs()).toHaveLength(1);
|
|
38
|
+
const removed = scheduler.unregister('t1');
|
|
39
|
+
expect(removed).toBe(true);
|
|
40
|
+
expect(scheduler.listJobs()).toHaveLength(0);
|
|
41
|
+
});
|
|
42
|
+
it('unregister returns false for unknown id', () => {
|
|
43
|
+
scheduler = new CronScheduler(handler);
|
|
44
|
+
expect(scheduler.unregister('nope')).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
it('disable stops the cron without removing it', () => {
|
|
47
|
+
scheduler = new CronScheduler(handler, mockLog());
|
|
48
|
+
scheduler.register('t1', 't1', 'g1', 'Job', makeDef());
|
|
49
|
+
const disabled = scheduler.disable('t1');
|
|
50
|
+
expect(disabled).toBe(true);
|
|
51
|
+
// Job still listed.
|
|
52
|
+
const jobs = scheduler.listJobs();
|
|
53
|
+
expect(jobs).toHaveLength(1);
|
|
54
|
+
expect(jobs[0].nextRun).toBeNull();
|
|
55
|
+
});
|
|
56
|
+
it('enable re-starts a disabled job', () => {
|
|
57
|
+
scheduler = new CronScheduler(handler, mockLog());
|
|
58
|
+
scheduler.register('t1', 't1', 'g1', 'Job', makeDef());
|
|
59
|
+
scheduler.disable('t1');
|
|
60
|
+
const enabled = scheduler.enable('t1');
|
|
61
|
+
expect(enabled).toBe(true);
|
|
62
|
+
// Next run should be populated again.
|
|
63
|
+
const jobs = scheduler.listJobs();
|
|
64
|
+
expect(jobs[0].nextRun).toBeInstanceOf(Date);
|
|
65
|
+
});
|
|
66
|
+
it('reload replaces the definition', () => {
|
|
67
|
+
scheduler = new CronScheduler(handler, mockLog());
|
|
68
|
+
scheduler.register('t1', 't1', 'g1', 'Job', makeDef({ schedule: '0 7 * * *' }));
|
|
69
|
+
const newDef = makeDef({ schedule: '0 9 * * 1-5' });
|
|
70
|
+
scheduler.reload('t1', newDef);
|
|
71
|
+
const jobs = scheduler.listJobs();
|
|
72
|
+
expect(jobs[0].schedule).toBe('0 9 * * 1-5');
|
|
73
|
+
});
|
|
74
|
+
it('reload returns null for unknown id', () => {
|
|
75
|
+
scheduler = new CronScheduler(handler);
|
|
76
|
+
expect(scheduler.reload('nope', makeDef())).toBeNull();
|
|
77
|
+
});
|
|
78
|
+
it('register replaces existing job with same id', () => {
|
|
79
|
+
scheduler = new CronScheduler(handler, mockLog());
|
|
80
|
+
scheduler.register('t1', 't1', 'g1', 'Job A', makeDef());
|
|
81
|
+
scheduler.register('t1', 't1', 'g1', 'Job B', makeDef({ schedule: '0 12 * * *' }));
|
|
82
|
+
const jobs = scheduler.listJobs();
|
|
83
|
+
expect(jobs).toHaveLength(1);
|
|
84
|
+
expect(jobs[0].name).toBe('Job B');
|
|
85
|
+
});
|
|
86
|
+
it('stopAll clears everything', () => {
|
|
87
|
+
scheduler = new CronScheduler(handler, mockLog());
|
|
88
|
+
scheduler.register('t1', 't1', 'g1', 'A', makeDef());
|
|
89
|
+
scheduler.register('t2', 't2', 'g1', 'B', makeDef());
|
|
90
|
+
scheduler.stopAll();
|
|
91
|
+
expect(scheduler.listJobs()).toHaveLength(0);
|
|
92
|
+
});
|
|
93
|
+
it('getJob returns the job by id', () => {
|
|
94
|
+
scheduler = new CronScheduler(handler);
|
|
95
|
+
scheduler.register('t1', 't1', 'g1', 'Job', makeDef());
|
|
96
|
+
const job = scheduler.getJob('t1');
|
|
97
|
+
expect(job).toBeDefined();
|
|
98
|
+
expect(job?.name).toBe('Job');
|
|
99
|
+
});
|
|
100
|
+
it('fires handler on cron tick', async () => {
|
|
101
|
+
scheduler = new CronScheduler(handler);
|
|
102
|
+
// Use a schedule that fires every second — croner doesn't support seconds in 5-field,
|
|
103
|
+
// but we can test by directly calling the handler via the cron callback approach.
|
|
104
|
+
// Instead, let's directly test by registering with a very frequent schedule and waiting.
|
|
105
|
+
// For unit tests, we'll verify registration wiring is correct via the handler mock.
|
|
106
|
+
const def = makeDef({ schedule: '* * * * *' }); // every minute — too slow for unit test
|
|
107
|
+
const job = scheduler.register('t1', 't1', 'g1', 'Job', def);
|
|
108
|
+
// Verify the job was created and handler is wired (we can't easily wait for a minute).
|
|
109
|
+
expect(job.cron).not.toBeNull();
|
|
110
|
+
expect(job.id).toBe('t1');
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
/**
|
|
3
|
+
* Strict tag-map loader for the cron subsystem.
|
|
4
|
+
* Unlike the permissive task runtime tag-map loader, this throws on any failure
|
|
5
|
+
* (read error, invalid JSON, wrong shape) so callers can handle it explicitly.
|
|
6
|
+
*/
|
|
7
|
+
export async function loadCronTagMapStrict(tagMapPath) {
|
|
8
|
+
const raw = await fs.readFile(tagMapPath, 'utf8');
|
|
9
|
+
const parsed = JSON.parse(raw);
|
|
10
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
11
|
+
throw new Error(`tag-map.json must be a JSON object, got ${Array.isArray(parsed) ? 'array' : typeof parsed}`);
|
|
12
|
+
}
|
|
13
|
+
const map = {};
|
|
14
|
+
for (const [key, val] of Object.entries(parsed)) {
|
|
15
|
+
if (typeof val !== 'string') {
|
|
16
|
+
throw new Error(`tag-map.json value for "${key}" must be a string, got ${typeof val}`);
|
|
17
|
+
}
|
|
18
|
+
map[key] = val;
|
|
19
|
+
}
|
|
20
|
+
return map;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Reload tag-map.json and mutate the existing TagMap in-place.
|
|
24
|
+
* Same validate-then-mutate pattern as the task runtime loader:
|
|
25
|
+
* only mutates after full validation, throws on any failure so callers
|
|
26
|
+
* can catch and preserve the existing cached map.
|
|
27
|
+
* Returns the new tag count.
|
|
28
|
+
*/
|
|
29
|
+
export async function reloadCronTagMapInPlace(tagMapPath, tagMap) {
|
|
30
|
+
const raw = await fs.readFile(tagMapPath, 'utf8');
|
|
31
|
+
const parsed = JSON.parse(raw);
|
|
32
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
33
|
+
throw new Error(`tag-map.json must be a JSON object, got ${Array.isArray(parsed) ? 'array' : typeof parsed}`);
|
|
34
|
+
}
|
|
35
|
+
const newMap = {};
|
|
36
|
+
for (const [key, val] of Object.entries(parsed)) {
|
|
37
|
+
if (typeof val !== 'string') {
|
|
38
|
+
throw new Error(`tag-map.json value for "${key}" must be a string, got ${typeof val}`);
|
|
39
|
+
}
|
|
40
|
+
newMap[key] = val;
|
|
41
|
+
}
|
|
42
|
+
// Only mutate after full validation
|
|
43
|
+
for (const key of Object.keys(tagMap))
|
|
44
|
+
delete tagMap[key];
|
|
45
|
+
Object.assign(tagMap, newMap);
|
|
46
|
+
return Object.keys(tagMap).length;
|
|
47
|
+
}
|