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,499 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { executeCronAction, CRON_ACTION_TYPES } from './actions-crons.js';
|
|
3
|
+
function makeMockRuntime(output) {
|
|
4
|
+
return {
|
|
5
|
+
id: 'other',
|
|
6
|
+
capabilities: new Set(),
|
|
7
|
+
async *invoke() {
|
|
8
|
+
yield { type: 'text_final', text: output };
|
|
9
|
+
},
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
function mockLog() {
|
|
13
|
+
return { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
|
14
|
+
}
|
|
15
|
+
function makeRecord(overrides) {
|
|
16
|
+
return {
|
|
17
|
+
cronId: 'cron-test0001',
|
|
18
|
+
threadId: 'thread-1',
|
|
19
|
+
runCount: 5,
|
|
20
|
+
lastRunAt: '2025-01-15T10:00:00Z',
|
|
21
|
+
lastRunStatus: 'success',
|
|
22
|
+
cadence: 'daily',
|
|
23
|
+
purposeTags: ['monitoring'],
|
|
24
|
+
disabled: false,
|
|
25
|
+
model: 'haiku',
|
|
26
|
+
...overrides,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function makeStatsStore(records) {
|
|
30
|
+
const store = {};
|
|
31
|
+
for (const r of records)
|
|
32
|
+
store[r.cronId] = r;
|
|
33
|
+
return {
|
|
34
|
+
getStore: () => ({ version: 1, updatedAt: Date.now(), jobs: store }),
|
|
35
|
+
getRecord: (id) => store[id],
|
|
36
|
+
getRecordByThreadId: (tid) => Object.values(store).find((r) => r.threadId === tid),
|
|
37
|
+
upsertRecord: vi.fn(async (cronId, threadId, updates) => {
|
|
38
|
+
const existing = store[cronId] ?? makeRecord({ cronId, threadId });
|
|
39
|
+
if (updates)
|
|
40
|
+
Object.assign(existing, updates);
|
|
41
|
+
store[cronId] = existing;
|
|
42
|
+
return existing;
|
|
43
|
+
}),
|
|
44
|
+
recordRun: vi.fn(async () => { }),
|
|
45
|
+
removeRecord: vi.fn(async (cronId) => { delete store[cronId]; return true; }),
|
|
46
|
+
removeByThreadId: vi.fn(async () => true),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function makeScheduler(jobs) {
|
|
50
|
+
const jobMap = new Map(jobs.map((j) => [j.id, { id: j.id, cronId: j.cronId, threadId: j.threadId, guildId: 'guild-1', name: j.name, def: { schedule: j.schedule, timezone: 'UTC', channel: 'general', prompt: 'Test' }, cron: null, running: false }]));
|
|
51
|
+
return {
|
|
52
|
+
register: vi.fn((...args) => {
|
|
53
|
+
const newJob = { id: args[0], cronId: args[5] ?? '', threadId: args[1], guildId: args[2], name: args[3], def: args[4], cron: null, running: false };
|
|
54
|
+
jobMap.set(args[0], newJob);
|
|
55
|
+
return newJob;
|
|
56
|
+
}),
|
|
57
|
+
unregister: vi.fn((id) => {
|
|
58
|
+
const existed = jobMap.has(id);
|
|
59
|
+
jobMap.delete(id);
|
|
60
|
+
return existed;
|
|
61
|
+
}),
|
|
62
|
+
disable: vi.fn((id) => jobMap.has(id)),
|
|
63
|
+
enable: vi.fn((id) => jobMap.has(id)),
|
|
64
|
+
getJob: (id) => jobMap.get(id),
|
|
65
|
+
listJobs: () => Array.from(jobMap.values()).map((j) => ({ id: j.id, name: j.name, schedule: j.def.schedule, timezone: j.def.timezone, nextRun: null })),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
function makeActionCtx() {
|
|
69
|
+
return {
|
|
70
|
+
guild: { id: 'guild-1' },
|
|
71
|
+
client: {},
|
|
72
|
+
channelId: 'ch-1',
|
|
73
|
+
messageId: 'msg-1',
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
function makeCronCtx(overrides) {
|
|
77
|
+
const forumThread = { id: 'new-thread', isThread: () => true, send: vi.fn(), fetchStarterMessage: vi.fn() };
|
|
78
|
+
const forum = {
|
|
79
|
+
id: 'forum-1',
|
|
80
|
+
type: 15, // ChannelType.GuildForum
|
|
81
|
+
threads: {
|
|
82
|
+
create: vi.fn(async () => forumThread),
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
const client = {
|
|
86
|
+
channels: {
|
|
87
|
+
cache: {
|
|
88
|
+
get: vi.fn((id) => {
|
|
89
|
+
if (id === 'forum-1')
|
|
90
|
+
return forum;
|
|
91
|
+
if (id === 'thread-1')
|
|
92
|
+
return { id: 'thread-1', isThread: () => true, send: vi.fn(), fetchStarterMessage: vi.fn(), setArchived: vi.fn() };
|
|
93
|
+
return undefined;
|
|
94
|
+
}),
|
|
95
|
+
},
|
|
96
|
+
fetch: vi.fn(async (id) => id === 'forum-1' ? forum : null),
|
|
97
|
+
},
|
|
98
|
+
user: { id: 'bot-user' },
|
|
99
|
+
};
|
|
100
|
+
return {
|
|
101
|
+
scheduler: makeScheduler([{ id: 'thread-1', threadId: 'thread-1', cronId: 'cron-test0001', name: 'Test Job', schedule: '0 7 * * *' }]),
|
|
102
|
+
client: client,
|
|
103
|
+
forumId: 'forum-1',
|
|
104
|
+
tagMapPath: '/tmp/tags.json',
|
|
105
|
+
tagMap: { monitoring: 'tag-1', daily: 'tag-2' },
|
|
106
|
+
statsStore: makeStatsStore([makeRecord()]),
|
|
107
|
+
runtime: makeMockRuntime('monitoring'),
|
|
108
|
+
autoTag: false,
|
|
109
|
+
autoTagModel: 'haiku',
|
|
110
|
+
cwd: '/tmp',
|
|
111
|
+
allowUserIds: new Set(['user-1']),
|
|
112
|
+
log: mockLog(),
|
|
113
|
+
pendingThreadIds: new Set(),
|
|
114
|
+
...overrides,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
// Mock reloadCronTagMapInPlace (best-effort reload in actions)
|
|
118
|
+
vi.mock('../cron/tag-map.js', () => ({
|
|
119
|
+
reloadCronTagMapInPlace: vi.fn(async () => 2),
|
|
120
|
+
}));
|
|
121
|
+
// Mock ensureStatusMessage
|
|
122
|
+
vi.mock('../cron/discord-sync.js', async (importOriginal) => {
|
|
123
|
+
const actual = await importOriginal();
|
|
124
|
+
return {
|
|
125
|
+
...actual,
|
|
126
|
+
ensureStatusMessage: vi.fn(async () => 'msg-1'),
|
|
127
|
+
};
|
|
128
|
+
});
|
|
129
|
+
describe('CRON_ACTION_TYPES', () => {
|
|
130
|
+
it('includes all cron action types', () => {
|
|
131
|
+
expect(CRON_ACTION_TYPES.has('cronCreate')).toBe(true);
|
|
132
|
+
expect(CRON_ACTION_TYPES.has('cronUpdate')).toBe(true);
|
|
133
|
+
expect(CRON_ACTION_TYPES.has('cronList')).toBe(true);
|
|
134
|
+
expect(CRON_ACTION_TYPES.has('cronShow')).toBe(true);
|
|
135
|
+
expect(CRON_ACTION_TYPES.has('cronPause')).toBe(true);
|
|
136
|
+
expect(CRON_ACTION_TYPES.has('cronResume')).toBe(true);
|
|
137
|
+
expect(CRON_ACTION_TYPES.has('cronDelete')).toBe(true);
|
|
138
|
+
expect(CRON_ACTION_TYPES.has('cronTrigger')).toBe(true);
|
|
139
|
+
expect(CRON_ACTION_TYPES.has('cronSync')).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
describe('executeCronAction', () => {
|
|
143
|
+
it('cronList returns registered jobs', async () => {
|
|
144
|
+
const cronCtx = makeCronCtx();
|
|
145
|
+
const result = await executeCronAction({ type: 'cronList' }, makeActionCtx(), cronCtx);
|
|
146
|
+
expect(result.ok).toBe(true);
|
|
147
|
+
if (result.ok) {
|
|
148
|
+
expect(result.summary).toContain('Test Job');
|
|
149
|
+
expect(result.summary).toContain('cron-test0001');
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
it('cronList returns empty message when no jobs', async () => {
|
|
153
|
+
const cronCtx = makeCronCtx({ scheduler: makeScheduler([]) });
|
|
154
|
+
const result = await executeCronAction({ type: 'cronList' }, makeActionCtx(), cronCtx);
|
|
155
|
+
expect(result.ok).toBe(true);
|
|
156
|
+
if (result.ok)
|
|
157
|
+
expect(result.summary).toContain('No cron jobs');
|
|
158
|
+
});
|
|
159
|
+
it('cronShow returns details for known cronId', async () => {
|
|
160
|
+
const cronCtx = makeCronCtx();
|
|
161
|
+
const result = await executeCronAction({ type: 'cronShow', cronId: 'cron-test0001' }, makeActionCtx(), cronCtx);
|
|
162
|
+
expect(result.ok).toBe(true);
|
|
163
|
+
if (result.ok) {
|
|
164
|
+
expect(result.summary).toContain('cron-test0001');
|
|
165
|
+
expect(result.summary).toContain('haiku');
|
|
166
|
+
expect(result.summary).toContain('monitoring');
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
it('cronShow returns error for unknown cronId', async () => {
|
|
170
|
+
const cronCtx = makeCronCtx();
|
|
171
|
+
const result = await executeCronAction({ type: 'cronShow', cronId: 'cron-nope' }, makeActionCtx(), cronCtx);
|
|
172
|
+
expect(result.ok).toBe(false);
|
|
173
|
+
});
|
|
174
|
+
it('cronPause disables the job', async () => {
|
|
175
|
+
const cronCtx = makeCronCtx();
|
|
176
|
+
const result = await executeCronAction({ type: 'cronPause', cronId: 'cron-test0001' }, makeActionCtx(), cronCtx);
|
|
177
|
+
expect(result.ok).toBe(true);
|
|
178
|
+
expect(cronCtx.scheduler.disable).toHaveBeenCalledWith('thread-1');
|
|
179
|
+
});
|
|
180
|
+
it('cronPause returns error when scheduler job is missing', async () => {
|
|
181
|
+
const cronCtx = makeCronCtx({ scheduler: makeScheduler([]) });
|
|
182
|
+
const result = await executeCronAction({ type: 'cronPause', cronId: 'cron-test0001' }, makeActionCtx(), cronCtx);
|
|
183
|
+
expect(result.ok).toBe(false);
|
|
184
|
+
if (!result.ok)
|
|
185
|
+
expect(result.error).toContain('not registered in scheduler');
|
|
186
|
+
});
|
|
187
|
+
it('cronResume enables the job', async () => {
|
|
188
|
+
const cronCtx = makeCronCtx();
|
|
189
|
+
const result = await executeCronAction({ type: 'cronResume', cronId: 'cron-test0001' }, makeActionCtx(), cronCtx);
|
|
190
|
+
expect(result.ok).toBe(true);
|
|
191
|
+
expect(cronCtx.scheduler.enable).toHaveBeenCalledWith('thread-1');
|
|
192
|
+
});
|
|
193
|
+
it('cronResume returns error when scheduler job is missing', async () => {
|
|
194
|
+
const cronCtx = makeCronCtx({ scheduler: makeScheduler([]) });
|
|
195
|
+
const result = await executeCronAction({ type: 'cronResume', cronId: 'cron-test0001' }, makeActionCtx(), cronCtx);
|
|
196
|
+
expect(result.ok).toBe(false);
|
|
197
|
+
if (!result.ok)
|
|
198
|
+
expect(result.error).toContain('not registered in scheduler');
|
|
199
|
+
});
|
|
200
|
+
it('cronDelete unregisters and archives', async () => {
|
|
201
|
+
const cronCtx = makeCronCtx();
|
|
202
|
+
const result = await executeCronAction({ type: 'cronDelete', cronId: 'cron-test0001' }, makeActionCtx(), cronCtx);
|
|
203
|
+
expect(result.ok).toBe(true);
|
|
204
|
+
expect(cronCtx.scheduler.unregister).toHaveBeenCalledWith('thread-1');
|
|
205
|
+
expect(cronCtx.statsStore.removeRecord).toHaveBeenCalledWith('cron-test0001');
|
|
206
|
+
});
|
|
207
|
+
it('cronDelete warns when archive fails', async () => {
|
|
208
|
+
const cronCtx = makeCronCtx();
|
|
209
|
+
// Override the cached thread to have a failing setArchived
|
|
210
|
+
cronCtx.client.channels.cache.get.mockImplementation((id) => {
|
|
211
|
+
if (id === 'thread-1')
|
|
212
|
+
return { id: 'thread-1', isThread: () => true, send: vi.fn(), setArchived: vi.fn().mockRejectedValue(new Error('Missing Permissions')) };
|
|
213
|
+
return undefined;
|
|
214
|
+
});
|
|
215
|
+
const result = await executeCronAction({ type: 'cronDelete', cronId: 'cron-test0001' }, makeActionCtx(), cronCtx);
|
|
216
|
+
expect(result.ok).toBe(true);
|
|
217
|
+
if (result.ok) {
|
|
218
|
+
expect(result.summary).toContain('could not be archived');
|
|
219
|
+
}
|
|
220
|
+
expect(cronCtx.scheduler.unregister).toHaveBeenCalledWith('thread-1');
|
|
221
|
+
expect(cronCtx.log?.warn).toHaveBeenCalled();
|
|
222
|
+
});
|
|
223
|
+
it('cronList shows running emoji when job is running', async () => {
|
|
224
|
+
const cronCtx = makeCronCtx();
|
|
225
|
+
const job = cronCtx.scheduler.getJob('thread-1');
|
|
226
|
+
job.running = true;
|
|
227
|
+
const result = await executeCronAction({ type: 'cronList' }, makeActionCtx(), cronCtx);
|
|
228
|
+
expect(result.ok).toBe(true);
|
|
229
|
+
if (result.ok) {
|
|
230
|
+
expect(result.summary).toContain('\uD83D\uDD04');
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
it('cronShow shows Runtime line when job is running', async () => {
|
|
234
|
+
const cronCtx = makeCronCtx();
|
|
235
|
+
const job = cronCtx.scheduler.getJob('thread-1');
|
|
236
|
+
job.running = true;
|
|
237
|
+
const result = await executeCronAction({ type: 'cronShow', cronId: 'cron-test0001' }, makeActionCtx(), cronCtx);
|
|
238
|
+
expect(result.ok).toBe(true);
|
|
239
|
+
if (result.ok) {
|
|
240
|
+
expect(result.summary).toContain('Runtime:');
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
it('cronShow does not show Runtime line when job is not running', async () => {
|
|
244
|
+
const cronCtx = makeCronCtx();
|
|
245
|
+
const result = await executeCronAction({ type: 'cronShow', cronId: 'cron-test0001' }, makeActionCtx(), cronCtx);
|
|
246
|
+
expect(result.ok).toBe(true);
|
|
247
|
+
if (result.ok) {
|
|
248
|
+
expect(result.summary).not.toContain('Runtime:');
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
it('cronCreate calls forumCountSync.requestUpdate', async () => {
|
|
252
|
+
const mockSync = { requestUpdate: vi.fn(), stop: vi.fn() };
|
|
253
|
+
const cronCtx = makeCronCtx({ forumCountSync: mockSync });
|
|
254
|
+
const result = await executeCronAction({ type: 'cronCreate', name: 'New Cron', schedule: '0 7 * * *', channel: 'general', prompt: 'Do something' }, makeActionCtx(), cronCtx);
|
|
255
|
+
expect(result.ok).toBe(true);
|
|
256
|
+
expect(mockSync.requestUpdate).toHaveBeenCalled();
|
|
257
|
+
});
|
|
258
|
+
it('cronDelete calls forumCountSync.requestUpdate', async () => {
|
|
259
|
+
const mockSync = { requestUpdate: vi.fn(), stop: vi.fn() };
|
|
260
|
+
const cronCtx = makeCronCtx({ forumCountSync: mockSync });
|
|
261
|
+
const result = await executeCronAction({ type: 'cronDelete', cronId: 'cron-test0001' }, makeActionCtx(), cronCtx);
|
|
262
|
+
expect(result.ok).toBe(true);
|
|
263
|
+
expect(mockSync.requestUpdate).toHaveBeenCalled();
|
|
264
|
+
});
|
|
265
|
+
it('cronSync calls forumCountSync.requestUpdate', async () => {
|
|
266
|
+
const mockSync = { requestUpdate: vi.fn(), stop: vi.fn() };
|
|
267
|
+
const cronCtx = makeCronCtx({ forumCountSync: mockSync });
|
|
268
|
+
const result = await executeCronAction({ type: 'cronSync' }, makeActionCtx(), cronCtx);
|
|
269
|
+
expect(result.ok).toBe(true);
|
|
270
|
+
expect(mockSync.requestUpdate).toHaveBeenCalled();
|
|
271
|
+
});
|
|
272
|
+
it('cronCreate validates required fields', async () => {
|
|
273
|
+
const cronCtx = makeCronCtx();
|
|
274
|
+
const result = await executeCronAction({ type: 'cronCreate', name: '', schedule: '', channel: '', prompt: '' }, makeActionCtx(), cronCtx);
|
|
275
|
+
expect(result.ok).toBe(false);
|
|
276
|
+
});
|
|
277
|
+
it('cronCreate creates thread and registers job', async () => {
|
|
278
|
+
const cronCtx = makeCronCtx();
|
|
279
|
+
const result = await executeCronAction({ type: 'cronCreate', name: 'New Cron', schedule: '0 7 * * *', channel: 'general', prompt: 'Do something' }, makeActionCtx(), cronCtx);
|
|
280
|
+
expect(result.ok).toBe(true);
|
|
281
|
+
if (result.ok)
|
|
282
|
+
expect(result.summary).toContain('New Cron');
|
|
283
|
+
});
|
|
284
|
+
it('cronCreate rejects invalid schedule before creating a thread', async () => {
|
|
285
|
+
const invoke = vi.fn(async function* () {
|
|
286
|
+
yield { type: 'text_final', text: 'monitoring' };
|
|
287
|
+
});
|
|
288
|
+
const runtime = {
|
|
289
|
+
id: 'other',
|
|
290
|
+
capabilities: new Set(),
|
|
291
|
+
invoke,
|
|
292
|
+
};
|
|
293
|
+
const cronCtx = makeCronCtx({ runtime });
|
|
294
|
+
const result = await executeCronAction({ type: 'cronCreate', name: 'Bad Cron', schedule: 'not-a-cron', channel: 'general', prompt: 'Do something' }, makeActionCtx(), cronCtx);
|
|
295
|
+
expect(result.ok).toBe(false);
|
|
296
|
+
if (!result.ok)
|
|
297
|
+
expect(result.error).toContain('Invalid cron definition');
|
|
298
|
+
expect(cronCtx.scheduler.register).not.toHaveBeenCalled();
|
|
299
|
+
expect(invoke).not.toHaveBeenCalled();
|
|
300
|
+
const forum = cronCtx.client.channels.cache.get('forum-1');
|
|
301
|
+
expect(forum.threads.create).not.toHaveBeenCalled();
|
|
302
|
+
});
|
|
303
|
+
it('cronCreate reports timezone validation errors as definition errors', async () => {
|
|
304
|
+
const cronCtx = makeCronCtx();
|
|
305
|
+
const result = await executeCronAction({
|
|
306
|
+
type: 'cronCreate',
|
|
307
|
+
name: 'TZ Fail',
|
|
308
|
+
schedule: '0 7 * * *',
|
|
309
|
+
timezone: 'Not/A_Real_Timezone',
|
|
310
|
+
channel: 'general',
|
|
311
|
+
prompt: 'Do something',
|
|
312
|
+
}, makeActionCtx(), cronCtx);
|
|
313
|
+
expect(result.ok).toBe(false);
|
|
314
|
+
if (!result.ok)
|
|
315
|
+
expect(result.error).toContain('Invalid cron definition');
|
|
316
|
+
});
|
|
317
|
+
it('cronUpdate returns error for unknown cronId', async () => {
|
|
318
|
+
const cronCtx = makeCronCtx();
|
|
319
|
+
const result = await executeCronAction({ type: 'cronUpdate', cronId: 'cron-nope' }, makeActionCtx(), cronCtx);
|
|
320
|
+
expect(result.ok).toBe(false);
|
|
321
|
+
});
|
|
322
|
+
it('cronUpdate with model sets override', async () => {
|
|
323
|
+
const cronCtx = makeCronCtx();
|
|
324
|
+
const result = await executeCronAction({ type: 'cronUpdate', cronId: 'cron-test0001', model: 'opus' }, makeActionCtx(), cronCtx);
|
|
325
|
+
expect(result.ok).toBe(true);
|
|
326
|
+
expect(cronCtx.statsStore.upsertRecord).toHaveBeenCalledWith('cron-test0001', 'thread-1', expect.objectContaining({ modelOverride: 'opus' }));
|
|
327
|
+
});
|
|
328
|
+
it('cronUpdate rejects invalid schedule before thread edits or scheduler mutation', async () => {
|
|
329
|
+
const cronCtx = makeCronCtx();
|
|
330
|
+
const thread = cronCtx.client.channels.cache.get('thread-1');
|
|
331
|
+
const starter = { author: { id: 'bot-user' }, edit: vi.fn() };
|
|
332
|
+
thread.fetchStarterMessage = vi.fn(async () => starter);
|
|
333
|
+
const result = await executeCronAction({ type: 'cronUpdate', cronId: 'cron-test0001', schedule: 'bad-schedule' }, makeActionCtx(), cronCtx);
|
|
334
|
+
expect(result.ok).toBe(false);
|
|
335
|
+
if (!result.ok)
|
|
336
|
+
expect(result.error).toContain('Invalid cron definition');
|
|
337
|
+
expect(starter.edit).not.toHaveBeenCalled();
|
|
338
|
+
expect(cronCtx.scheduler.register).not.toHaveBeenCalled();
|
|
339
|
+
});
|
|
340
|
+
it('cronCreate without timezone uses getDefaultTimezone', async () => {
|
|
341
|
+
vi.stubEnv('DEFAULT_TIMEZONE', 'America/Chicago');
|
|
342
|
+
const cronCtx = makeCronCtx();
|
|
343
|
+
const result = await executeCronAction({ type: 'cronCreate', name: 'TZ Test', schedule: '0 12 * * *', channel: 'general', prompt: 'Test timezone' }, makeActionCtx(), cronCtx);
|
|
344
|
+
expect(result.ok).toBe(true);
|
|
345
|
+
// The scheduler.register call should receive a def with America/Chicago timezone.
|
|
346
|
+
expect(cronCtx.scheduler.register).toHaveBeenCalledWith(expect.any(String), expect.any(String), expect.any(String), 'TZ Test', expect.objectContaining({ timezone: 'America/Chicago' }), expect.any(String));
|
|
347
|
+
vi.unstubAllEnvs();
|
|
348
|
+
});
|
|
349
|
+
it('cronCreate does not set modelOverride', async () => {
|
|
350
|
+
const cronCtx = makeCronCtx();
|
|
351
|
+
await executeCronAction({ type: 'cronCreate', name: 'New Cron', schedule: '0 7 * * *', channel: 'general', prompt: 'Do something', model: 'opus' }, makeActionCtx(), cronCtx);
|
|
352
|
+
// Should set model but NOT modelOverride.
|
|
353
|
+
expect(cronCtx.statsStore.upsertRecord).toHaveBeenCalledWith(expect.any(String), expect.any(String), expect.not.objectContaining({ modelOverride: expect.anything() }));
|
|
354
|
+
});
|
|
355
|
+
it('cronTrigger returns ok for known job', async () => {
|
|
356
|
+
// Mock the dynamic import of executeCronJob.
|
|
357
|
+
vi.mock('../cron/executor.js', () => ({
|
|
358
|
+
executeCronJob: vi.fn(async () => { }),
|
|
359
|
+
}));
|
|
360
|
+
const cronCtx = makeCronCtx();
|
|
361
|
+
const result = await executeCronAction({ type: 'cronTrigger', cronId: 'cron-test0001' }, makeActionCtx(), cronCtx);
|
|
362
|
+
expect(result.ok).toBe(true);
|
|
363
|
+
if (result.ok)
|
|
364
|
+
expect(result.summary).toContain('triggered');
|
|
365
|
+
});
|
|
366
|
+
it('cronTrigger returns error for unknown cronId', async () => {
|
|
367
|
+
const cronCtx = makeCronCtx();
|
|
368
|
+
const result = await executeCronAction({ type: 'cronTrigger', cronId: 'cron-nope' }, makeActionCtx(), cronCtx);
|
|
369
|
+
expect(result.ok).toBe(false);
|
|
370
|
+
});
|
|
371
|
+
it('cronSync returns sync results', async () => {
|
|
372
|
+
// Mock the dynamic import of runCronSync.
|
|
373
|
+
vi.mock('../cron/cron-sync.js', () => ({
|
|
374
|
+
runCronSync: vi.fn(async () => ({ tagsApplied: 1, namesUpdated: 0, statusMessagesUpdated: 2, orphansDetected: 0 })),
|
|
375
|
+
}));
|
|
376
|
+
const cronCtx = makeCronCtx();
|
|
377
|
+
const result = await executeCronAction({ type: 'cronSync' }, makeActionCtx(), cronCtx);
|
|
378
|
+
expect(result.ok).toBe(true);
|
|
379
|
+
if (result.ok) {
|
|
380
|
+
expect(result.summary).toContain('1 tags');
|
|
381
|
+
expect(result.summary).toContain('2 status msgs');
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
it('cronCreate returns error when thread creation fails', async () => {
|
|
385
|
+
const forum = {
|
|
386
|
+
id: 'forum-1',
|
|
387
|
+
type: 15,
|
|
388
|
+
threads: {
|
|
389
|
+
create: vi.fn(async () => { throw new Error('Missing Permissions'); }),
|
|
390
|
+
},
|
|
391
|
+
};
|
|
392
|
+
const client = {
|
|
393
|
+
channels: {
|
|
394
|
+
cache: { get: vi.fn((id) => id === 'forum-1' ? forum : undefined) },
|
|
395
|
+
fetch: vi.fn(async (id) => id === 'forum-1' ? forum : null),
|
|
396
|
+
},
|
|
397
|
+
user: { id: 'bot-user' },
|
|
398
|
+
};
|
|
399
|
+
const cronCtx = makeCronCtx({ client: client });
|
|
400
|
+
const result = await executeCronAction({ type: 'cronCreate', name: 'Fail Cron', schedule: '0 7 * * *', channel: 'general', prompt: 'Test' }, makeActionCtx(), cronCtx);
|
|
401
|
+
expect(result.ok).toBe(false);
|
|
402
|
+
if (!result.ok)
|
|
403
|
+
expect(result.error).toContain('Missing Permissions');
|
|
404
|
+
});
|
|
405
|
+
it('cronTrigger force is rejected in Discord actions', async () => {
|
|
406
|
+
const cronCtx = makeCronCtx();
|
|
407
|
+
const result = await executeCronAction({ type: 'cronTrigger', cronId: 'cron-test0001', force: true }, makeActionCtx(), cronCtx);
|
|
408
|
+
expect(result.ok).toBe(false);
|
|
409
|
+
if (!result.ok)
|
|
410
|
+
expect(result.error).toContain('force is disabled');
|
|
411
|
+
});
|
|
412
|
+
it('cronPause requests cancellation when a run is active', async () => {
|
|
413
|
+
const runControl = { requestCancel: vi.fn(() => true) };
|
|
414
|
+
const cronCtx = makeCronCtx({ executorCtx: { runControl } });
|
|
415
|
+
const result = await executeCronAction({ type: 'cronPause', cronId: 'cron-test0001' }, makeActionCtx(), cronCtx);
|
|
416
|
+
expect(result.ok).toBe(true);
|
|
417
|
+
expect(runControl.requestCancel).toHaveBeenCalledWith('thread-1');
|
|
418
|
+
if (result.ok)
|
|
419
|
+
expect(result.summary).toContain('cancel requested');
|
|
420
|
+
});
|
|
421
|
+
it('cronDelete requests cancellation when a run is active', async () => {
|
|
422
|
+
const runControl = { requestCancel: vi.fn(() => true) };
|
|
423
|
+
const cronCtx = makeCronCtx({ executorCtx: { runControl } });
|
|
424
|
+
const result = await executeCronAction({ type: 'cronDelete', cronId: 'cron-test0001' }, makeActionCtx(), cronCtx);
|
|
425
|
+
expect(result.ok).toBe(true);
|
|
426
|
+
expect(runControl.requestCancel).toHaveBeenCalledWith('thread-1');
|
|
427
|
+
if (result.ok)
|
|
428
|
+
expect(result.summary).toContain('cancel requested');
|
|
429
|
+
});
|
|
430
|
+
it('cronSync uses coordinator when present and returns result summary', async () => {
|
|
431
|
+
const coordinator = {
|
|
432
|
+
sync: vi.fn(async () => ({ tagsApplied: 2, namesUpdated: 1, statusMessagesUpdated: 3, orphansDetected: 0 })),
|
|
433
|
+
};
|
|
434
|
+
const cronCtx = makeCronCtx({ syncCoordinator: coordinator });
|
|
435
|
+
const result = await executeCronAction({ type: 'cronSync' }, makeActionCtx(), cronCtx);
|
|
436
|
+
expect(result.ok).toBe(true);
|
|
437
|
+
if (result.ok) {
|
|
438
|
+
expect(result.summary).toContain('2 tags');
|
|
439
|
+
expect(result.summary).toContain('1 names');
|
|
440
|
+
expect(result.summary).toContain('3 status msgs');
|
|
441
|
+
}
|
|
442
|
+
expect(coordinator.sync).toHaveBeenCalled();
|
|
443
|
+
});
|
|
444
|
+
it('cronSync coalesced case returns "already running" message', async () => {
|
|
445
|
+
const coordinator = { sync: vi.fn(async () => null) };
|
|
446
|
+
const cronCtx = makeCronCtx({ syncCoordinator: coordinator });
|
|
447
|
+
const result = await executeCronAction({ type: 'cronSync' }, makeActionCtx(), cronCtx);
|
|
448
|
+
expect(result.ok).toBe(true);
|
|
449
|
+
if (result.ok)
|
|
450
|
+
expect(result.summary).toContain('coalesced');
|
|
451
|
+
});
|
|
452
|
+
it('cronSync fallback when coordinator absent', async () => {
|
|
453
|
+
vi.mock('../cron/cron-sync.js', () => ({
|
|
454
|
+
runCronSync: vi.fn(async () => ({ tagsApplied: 1, namesUpdated: 0, statusMessagesUpdated: 2, orphansDetected: 0 })),
|
|
455
|
+
}));
|
|
456
|
+
const cronCtx = makeCronCtx();
|
|
457
|
+
// Ensure no coordinator
|
|
458
|
+
delete cronCtx.syncCoordinator;
|
|
459
|
+
const result = await executeCronAction({ type: 'cronSync' }, makeActionCtx(), cronCtx);
|
|
460
|
+
expect(result.ok).toBe(true);
|
|
461
|
+
if (result.ok) {
|
|
462
|
+
expect(result.summary).toContain('1 tags');
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
it('cronTagMapReload success with coordinator queues sync', async () => {
|
|
466
|
+
const { reloadCronTagMapInPlace } = await import('../cron/tag-map.js');
|
|
467
|
+
vi.mocked(reloadCronTagMapInPlace).mockResolvedValue(3);
|
|
468
|
+
const coordinator = { sync: vi.fn(async () => null) };
|
|
469
|
+
const cronCtx = makeCronCtx({ syncCoordinator: coordinator });
|
|
470
|
+
const result = await executeCronAction({ type: 'cronTagMapReload' }, makeActionCtx(), cronCtx);
|
|
471
|
+
expect(result.ok).toBe(true);
|
|
472
|
+
if (result.ok) {
|
|
473
|
+
expect(result.summary).toContain('sync queued');
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
it('cronTagMapReload success without coordinator', async () => {
|
|
477
|
+
const { reloadCronTagMapInPlace } = await import('../cron/tag-map.js');
|
|
478
|
+
vi.mocked(reloadCronTagMapInPlace).mockResolvedValue(2);
|
|
479
|
+
const cronCtx = makeCronCtx();
|
|
480
|
+
delete cronCtx.syncCoordinator;
|
|
481
|
+
const result = await executeCronAction({ type: 'cronTagMapReload' }, makeActionCtx(), cronCtx);
|
|
482
|
+
expect(result.ok).toBe(true);
|
|
483
|
+
if (result.ok) {
|
|
484
|
+
expect(result.summary).toContain('no sync coordinator configured');
|
|
485
|
+
}
|
|
486
|
+
});
|
|
487
|
+
it('cronTagMapReload failure returns error', async () => {
|
|
488
|
+
const { reloadCronTagMapInPlace } = await import('../cron/tag-map.js');
|
|
489
|
+
vi.mocked(reloadCronTagMapInPlace).mockRejectedValue(new Error('bad json'));
|
|
490
|
+
const cronCtx = makeCronCtx();
|
|
491
|
+
const result = await executeCronAction({ type: 'cronTagMapReload' }, makeActionCtx(), cronCtx);
|
|
492
|
+
expect(result.ok).toBe(false);
|
|
493
|
+
if (!result.ok)
|
|
494
|
+
expect(result.error).toContain('bad json');
|
|
495
|
+
});
|
|
496
|
+
it('CRON_ACTION_TYPES includes cronTagMapReload', () => {
|
|
497
|
+
expect(CRON_ACTION_TYPES.has('cronTagMapReload')).toBe(true);
|
|
498
|
+
});
|
|
499
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { fmtTime } from './action-utils.js';
|
|
2
|
+
const DEFER_TYPES = ['defer'];
|
|
3
|
+
export const DEFER_ACTION_TYPES = new Set(DEFER_TYPES);
|
|
4
|
+
export async function executeDeferAction(action, ctx) {
|
|
5
|
+
const scheduler = ctx.deferScheduler;
|
|
6
|
+
if (!scheduler) {
|
|
7
|
+
return { ok: false, error: 'Deferred actions are not configured for this bot' };
|
|
8
|
+
}
|
|
9
|
+
const channel = action.channel?.trim();
|
|
10
|
+
if (!channel) {
|
|
11
|
+
return { ok: false, error: 'Deferred actions require a target channel' };
|
|
12
|
+
}
|
|
13
|
+
const prompt = action.prompt?.trim();
|
|
14
|
+
if (!prompt) {
|
|
15
|
+
return { ok: false, error: 'Deferred actions require a prompt to re-run' };
|
|
16
|
+
}
|
|
17
|
+
const delaySeconds = action.delaySeconds;
|
|
18
|
+
if (!Number.isFinite(delaySeconds)) {
|
|
19
|
+
return { ok: false, error: 'delaySeconds must be a valid number' };
|
|
20
|
+
}
|
|
21
|
+
const normalizedAction = {
|
|
22
|
+
...action,
|
|
23
|
+
channel,
|
|
24
|
+
prompt,
|
|
25
|
+
delaySeconds,
|
|
26
|
+
};
|
|
27
|
+
const result = scheduler.schedule({ action: normalizedAction, context: ctx });
|
|
28
|
+
if (!result.ok) {
|
|
29
|
+
return { ok: false, error: buildDeferRejection(channel, result.error) };
|
|
30
|
+
}
|
|
31
|
+
const delayLabel = formatDuration(result.delaySeconds);
|
|
32
|
+
const when = fmtTime(result.runsAt);
|
|
33
|
+
return {
|
|
34
|
+
ok: true,
|
|
35
|
+
summary: `Deferred follow-up scheduled for ${channel} in ${delayLabel} (runs at ${when})`,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function formatDuration(seconds) {
|
|
39
|
+
const parts = [];
|
|
40
|
+
let remaining = seconds;
|
|
41
|
+
const hours = Math.floor(remaining / 3600);
|
|
42
|
+
if (hours > 0) {
|
|
43
|
+
parts.push(`${hours}h`);
|
|
44
|
+
remaining -= hours * 3600;
|
|
45
|
+
}
|
|
46
|
+
const minutes = Math.floor(remaining / 60);
|
|
47
|
+
if (minutes > 0) {
|
|
48
|
+
parts.push(`${minutes}m`);
|
|
49
|
+
remaining -= minutes * 60;
|
|
50
|
+
}
|
|
51
|
+
const secs = Math.floor(remaining);
|
|
52
|
+
if (secs > 0 || parts.length === 0) {
|
|
53
|
+
parts.push(`${secs}s`);
|
|
54
|
+
}
|
|
55
|
+
return parts.join(' ');
|
|
56
|
+
}
|
|
57
|
+
function buildDeferRejection(channel, reason) {
|
|
58
|
+
const target = channel || 'requested channel';
|
|
59
|
+
return `Deferred follow-up for ${target} rejected: ${reason}`;
|
|
60
|
+
}
|