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,228 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { ChannelType } from 'discord.js';
|
|
3
|
+
import { runCronSync } from './cron-sync.js';
|
|
4
|
+
function makeMockRuntime(output) {
|
|
5
|
+
return {
|
|
6
|
+
id: 'other',
|
|
7
|
+
capabilities: new Set(),
|
|
8
|
+
async *invoke() {
|
|
9
|
+
yield { type: 'text_final', text: output };
|
|
10
|
+
},
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
function mockLog() {
|
|
14
|
+
return { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
|
15
|
+
}
|
|
16
|
+
function makeRecord(overrides) {
|
|
17
|
+
return {
|
|
18
|
+
cronId: 'cron-test1',
|
|
19
|
+
threadId: 'thread-1',
|
|
20
|
+
runCount: 0,
|
|
21
|
+
lastRunAt: null,
|
|
22
|
+
lastRunStatus: null,
|
|
23
|
+
cadence: null,
|
|
24
|
+
purposeTags: [],
|
|
25
|
+
disabled: false,
|
|
26
|
+
model: null,
|
|
27
|
+
...overrides,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function makeStatsStore(records) {
|
|
31
|
+
const store = {};
|
|
32
|
+
for (const r of records)
|
|
33
|
+
store[r.cronId] = r;
|
|
34
|
+
return {
|
|
35
|
+
getStore: () => ({ version: 1, updatedAt: Date.now(), jobs: store }),
|
|
36
|
+
getRecord: (id) => store[id],
|
|
37
|
+
getRecordByThreadId: (tid) => Object.values(store).find((r) => r.threadId === tid),
|
|
38
|
+
upsertRecord: vi.fn(async (cronId, threadId, updates) => {
|
|
39
|
+
const existing = store[cronId] ?? makeRecord({ cronId, threadId });
|
|
40
|
+
if (updates)
|
|
41
|
+
Object.assign(existing, updates);
|
|
42
|
+
store[cronId] = existing;
|
|
43
|
+
return existing;
|
|
44
|
+
}),
|
|
45
|
+
recordRun: vi.fn(async () => { }),
|
|
46
|
+
removeRecord: vi.fn(async () => true),
|
|
47
|
+
removeByThreadId: vi.fn(async () => true),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function makeScheduler(jobs) {
|
|
51
|
+
return {
|
|
52
|
+
listJobs: () => jobs.map((j) => ({ id: j.id, name: j.name, schedule: j.schedule, timezone: 'UTC', nextRun: null })),
|
|
53
|
+
getJob: (id) => {
|
|
54
|
+
const j = jobs.find((jj) => jj.id === id);
|
|
55
|
+
if (!j)
|
|
56
|
+
return undefined;
|
|
57
|
+
return { id: j.id, cronId: j.cronId, threadId: j.threadId, guildId: 'g1', name: j.name, def: { schedule: j.schedule, timezone: 'UTC', channel: 'general', prompt: j.prompt }, cron: null, running: false };
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function makeForum(threads) {
|
|
62
|
+
const threadMap = new Map(threads.map((t) => [t.id, { ...t, appliedTags: t.appliedTags ?? [], edit: vi.fn(), setName: vi.fn() }]));
|
|
63
|
+
return {
|
|
64
|
+
id: 'forum-1',
|
|
65
|
+
type: ChannelType.GuildForum,
|
|
66
|
+
threads: {
|
|
67
|
+
fetchActive: vi.fn(async () => ({ threads: threadMap })),
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function makeClient(forum) {
|
|
72
|
+
return {
|
|
73
|
+
channels: {
|
|
74
|
+
cache: { get: (id) => id === forum.id ? forum : undefined },
|
|
75
|
+
fetch: vi.fn(async (id) => id === forum.id ? forum : null),
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
const defaultTagMap = {
|
|
80
|
+
monitoring: 'tag-1',
|
|
81
|
+
cleanup: 'tag-2',
|
|
82
|
+
daily: 'tag-3',
|
|
83
|
+
weekly: 'tag-4',
|
|
84
|
+
};
|
|
85
|
+
// Mock ensureStatusMessage (from the cron discord-sync)
|
|
86
|
+
vi.mock('./discord-sync.js', async (importOriginal) => {
|
|
87
|
+
const actual = await importOriginal();
|
|
88
|
+
return {
|
|
89
|
+
...actual,
|
|
90
|
+
ensureStatusMessage: vi.fn(async () => 'msg-1'),
|
|
91
|
+
};
|
|
92
|
+
});
|
|
93
|
+
describe('runCronSync', () => {
|
|
94
|
+
it('returns zeros when forum not found', async () => {
|
|
95
|
+
const client = { channels: { cache: { get: () => undefined }, fetch: vi.fn(async () => null) } };
|
|
96
|
+
const result = await runCronSync({
|
|
97
|
+
client: client,
|
|
98
|
+
forumId: 'missing',
|
|
99
|
+
scheduler: makeScheduler([]),
|
|
100
|
+
statsStore: makeStatsStore([]),
|
|
101
|
+
runtime: makeMockRuntime('monitoring'),
|
|
102
|
+
tagMap: { ...defaultTagMap },
|
|
103
|
+
autoTag: true,
|
|
104
|
+
autoTagModel: 'haiku',
|
|
105
|
+
cwd: '/tmp',
|
|
106
|
+
log: mockLog(),
|
|
107
|
+
throttleMs: 0,
|
|
108
|
+
});
|
|
109
|
+
expect(result.tagsApplied).toBe(0);
|
|
110
|
+
expect(result.namesUpdated).toBe(0);
|
|
111
|
+
});
|
|
112
|
+
it('phase 1: applies tags to threads missing them', async () => {
|
|
113
|
+
const forum = makeForum([{ id: 'thread-1', name: 'Test Job', parentId: 'forum-1' }]);
|
|
114
|
+
const client = makeClient(forum);
|
|
115
|
+
const record = makeRecord({ cronId: 'cron-1', threadId: 'thread-1' });
|
|
116
|
+
const scheduler = makeScheduler([{ id: 'thread-1', threadId: 'thread-1', cronId: 'cron-1', name: 'Test Job', schedule: '0 7 * * *', prompt: 'Monitor health' }]);
|
|
117
|
+
const result = await runCronSync({
|
|
118
|
+
client: client,
|
|
119
|
+
forumId: 'forum-1',
|
|
120
|
+
scheduler,
|
|
121
|
+
statsStore: makeStatsStore([record]),
|
|
122
|
+
runtime: makeMockRuntime('monitoring'),
|
|
123
|
+
tagMap: { ...defaultTagMap },
|
|
124
|
+
autoTag: true,
|
|
125
|
+
autoTagModel: 'haiku',
|
|
126
|
+
cwd: '/tmp',
|
|
127
|
+
log: mockLog(),
|
|
128
|
+
throttleMs: 0,
|
|
129
|
+
});
|
|
130
|
+
expect(result.tagsApplied).toBe(1);
|
|
131
|
+
});
|
|
132
|
+
it('phase 1: reconciles applied tags when tag-map IDs change', async () => {
|
|
133
|
+
const forum = makeForum([{ id: 'thread-1', name: 'Test Job', parentId: 'forum-1', appliedTags: ['old-tag', 'daily-old'] }]);
|
|
134
|
+
const client = makeClient(forum);
|
|
135
|
+
const record = makeRecord({
|
|
136
|
+
cronId: 'cron-1',
|
|
137
|
+
threadId: 'thread-1',
|
|
138
|
+
cadence: 'daily',
|
|
139
|
+
purposeTags: ['monitoring'],
|
|
140
|
+
model: 'fast',
|
|
141
|
+
});
|
|
142
|
+
const scheduler = makeScheduler([{ id: 'thread-1', threadId: 'thread-1', cronId: 'cron-1', name: 'Test Job', schedule: '0 7 * * *', prompt: 'Monitor health' }]);
|
|
143
|
+
const result = await runCronSync({
|
|
144
|
+
client: client,
|
|
145
|
+
forumId: 'forum-1',
|
|
146
|
+
scheduler,
|
|
147
|
+
statsStore: makeStatsStore([record]),
|
|
148
|
+
runtime: makeMockRuntime('monitoring'),
|
|
149
|
+
tagMap: { ...defaultTagMap },
|
|
150
|
+
autoTag: true,
|
|
151
|
+
autoTagModel: 'haiku',
|
|
152
|
+
cwd: '/tmp',
|
|
153
|
+
log: mockLog(),
|
|
154
|
+
throttleMs: 0,
|
|
155
|
+
});
|
|
156
|
+
expect(result.tagsApplied).toBe(1);
|
|
157
|
+
const thread = (await forum.threads.fetchActive()).threads.get('thread-1');
|
|
158
|
+
expect(thread.edit).toHaveBeenCalledWith({ appliedTags: ['tag-1', 'tag-3'] });
|
|
159
|
+
});
|
|
160
|
+
it('phase 2: updates thread names with cadence emoji', async () => {
|
|
161
|
+
const forum = makeForum([{ id: 'thread-2', name: 'Old Name', parentId: 'forum-1' }]);
|
|
162
|
+
const client = makeClient(forum);
|
|
163
|
+
const record = makeRecord({ cronId: 'cron-2', threadId: 'thread-2', cadence: 'daily', purposeTags: ['monitoring'], model: 'haiku' });
|
|
164
|
+
const scheduler = makeScheduler([{ id: 'thread-2', threadId: 'thread-2', cronId: 'cron-2', name: 'Daily Check', schedule: '0 7 * * *', prompt: 'Check things' }]);
|
|
165
|
+
const result = await runCronSync({
|
|
166
|
+
client: client,
|
|
167
|
+
forumId: 'forum-1',
|
|
168
|
+
scheduler,
|
|
169
|
+
statsStore: makeStatsStore([record]),
|
|
170
|
+
runtime: makeMockRuntime('monitoring'),
|
|
171
|
+
tagMap: { ...defaultTagMap },
|
|
172
|
+
autoTag: false,
|
|
173
|
+
autoTagModel: 'haiku',
|
|
174
|
+
cwd: '/tmp',
|
|
175
|
+
log: mockLog(),
|
|
176
|
+
throttleMs: 0,
|
|
177
|
+
});
|
|
178
|
+
expect(result.namesUpdated).toBe(1);
|
|
179
|
+
});
|
|
180
|
+
it('continues with metadata/status phases when fetchActive fails', async () => {
|
|
181
|
+
const forum = makeForum([]);
|
|
182
|
+
forum.threads.fetchActive.mockRejectedValueOnce(new Error('Discord API failure'));
|
|
183
|
+
const client = makeClient(forum);
|
|
184
|
+
const log = mockLog();
|
|
185
|
+
const record = makeRecord({ cronId: 'cron-3', threadId: 'thread-3', cadence: 'daily', model: 'haiku' });
|
|
186
|
+
const scheduler = makeScheduler([
|
|
187
|
+
{ id: 'thread-3', threadId: 'thread-3', cronId: 'cron-3', name: 'Daily Check', schedule: '0 7 * * *', prompt: 'Check things' },
|
|
188
|
+
]);
|
|
189
|
+
const result = await runCronSync({
|
|
190
|
+
client: client,
|
|
191
|
+
forumId: 'forum-1',
|
|
192
|
+
scheduler,
|
|
193
|
+
statsStore: makeStatsStore([record]),
|
|
194
|
+
runtime: makeMockRuntime('monitoring'),
|
|
195
|
+
tagMap: { ...defaultTagMap },
|
|
196
|
+
autoTag: false,
|
|
197
|
+
autoTagModel: 'haiku',
|
|
198
|
+
cwd: '/tmp',
|
|
199
|
+
log,
|
|
200
|
+
throttleMs: 0,
|
|
201
|
+
});
|
|
202
|
+
expect(result.tagsApplied).toBe(0);
|
|
203
|
+
expect(result.namesUpdated).toBe(0);
|
|
204
|
+
expect(result.statusMessagesUpdated).toBe(1);
|
|
205
|
+
expect(result.orphansDetected).toBe(0);
|
|
206
|
+
expect(log.warn).toHaveBeenCalledWith(expect.objectContaining({ err: expect.any(Error), forumId: 'forum-1' }), expect.stringContaining('failed to fetch active threads'));
|
|
207
|
+
});
|
|
208
|
+
it('phase 4: detects orphan threads', async () => {
|
|
209
|
+
const forum = makeForum([{ id: 'thread-orphan', name: 'Orphan', parentId: 'forum-1' }]);
|
|
210
|
+
const client = makeClient(forum);
|
|
211
|
+
const log = mockLog();
|
|
212
|
+
const result = await runCronSync({
|
|
213
|
+
client: client,
|
|
214
|
+
forumId: 'forum-1',
|
|
215
|
+
scheduler: makeScheduler([]),
|
|
216
|
+
statsStore: makeStatsStore([]),
|
|
217
|
+
runtime: makeMockRuntime(''),
|
|
218
|
+
tagMap: { ...defaultTagMap },
|
|
219
|
+
autoTag: false,
|
|
220
|
+
autoTagModel: 'haiku',
|
|
221
|
+
cwd: '/tmp',
|
|
222
|
+
log,
|
|
223
|
+
throttleMs: 0,
|
|
224
|
+
});
|
|
225
|
+
expect(result.orphansDetected).toBe(1);
|
|
226
|
+
expect(log.warn).toHaveBeenCalledWith(expect.objectContaining({ threadId: 'thread-orphan' }), expect.stringContaining('orphan'));
|
|
227
|
+
});
|
|
228
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import fsp from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
const DEFAULT_DEBOUNCE_MS = 2000;
|
|
5
|
+
const DEFAULT_POLL_FALLBACK_MS = 30_000;
|
|
6
|
+
const DIR_POLL_MS = 30_000;
|
|
7
|
+
/**
|
|
8
|
+
* Watch tag-map.json for changes and trigger coordinator.sync() on change.
|
|
9
|
+
* Mirrors the tag-map watching portion of TaskSyncWatcher but triggers a
|
|
10
|
+
* full coordinator sync (reload + runCronSync) instead of just a reload.
|
|
11
|
+
*
|
|
12
|
+
* Uses fs.watch on the parent directory (atomic-write safe) with a stat-based
|
|
13
|
+
* polling fallback for platforms where fs.watch is unreliable.
|
|
14
|
+
* If the parent directory doesn't exist yet, polls until it appears.
|
|
15
|
+
*/
|
|
16
|
+
export function startCronTagMapWatcher(opts) {
|
|
17
|
+
const { coordinator, tagMapPath, log } = opts;
|
|
18
|
+
const debounceMs = opts.debounceMs ?? DEFAULT_DEBOUNCE_MS;
|
|
19
|
+
const pollFallbackMs = opts.pollFallbackMs ?? DEFAULT_POLL_FALLBACK_MS;
|
|
20
|
+
const tagMapDir = path.dirname(tagMapPath);
|
|
21
|
+
const tagMapBase = path.basename(tagMapPath);
|
|
22
|
+
let stopped = false;
|
|
23
|
+
let watcher = null;
|
|
24
|
+
let debounceTimer = null;
|
|
25
|
+
let pollTimer = null;
|
|
26
|
+
let dirPollTimer = null;
|
|
27
|
+
let lastMtimeMs = 0;
|
|
28
|
+
let mtimeSeeded = false;
|
|
29
|
+
function clearDebounce() {
|
|
30
|
+
if (debounceTimer) {
|
|
31
|
+
clearTimeout(debounceTimer);
|
|
32
|
+
debounceTimer = null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function scheduleDebouncedSync() {
|
|
36
|
+
clearDebounce();
|
|
37
|
+
debounceTimer = setTimeout(() => {
|
|
38
|
+
debounceTimer = null;
|
|
39
|
+
if (stopped)
|
|
40
|
+
return;
|
|
41
|
+
coordinator.sync().catch((err) => {
|
|
42
|
+
log?.warn({ err }, 'cron:tag-map-watcher sync failed');
|
|
43
|
+
});
|
|
44
|
+
}, debounceMs);
|
|
45
|
+
}
|
|
46
|
+
function startWatching() {
|
|
47
|
+
// Primary: fs.watch on parent directory, filtered by basename
|
|
48
|
+
try {
|
|
49
|
+
watcher = fs.watch(tagMapDir, (_eventType, filename) => {
|
|
50
|
+
if (stopped)
|
|
51
|
+
return;
|
|
52
|
+
if (filename === tagMapBase) {
|
|
53
|
+
scheduleDebouncedSync();
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
watcher.on('error', (err) => {
|
|
57
|
+
log?.warn({ err }, 'cron:tag-map-watcher fs.watch error; polling fallback continues');
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// fs.watch not available — polling alone.
|
|
62
|
+
}
|
|
63
|
+
// Seed initial mtime before starting poll to avoid spurious first-poll trigger
|
|
64
|
+
fsp.stat(tagMapPath).then((s) => {
|
|
65
|
+
lastMtimeMs = s.mtimeMs;
|
|
66
|
+
}).catch(() => { }).finally(() => { mtimeSeeded = true; });
|
|
67
|
+
// Polling fallback: check stat.mtimeMs
|
|
68
|
+
pollTimer = setInterval(async () => {
|
|
69
|
+
if (stopped || !mtimeSeeded)
|
|
70
|
+
return;
|
|
71
|
+
try {
|
|
72
|
+
const s = await fsp.stat(tagMapPath);
|
|
73
|
+
if (s.mtimeMs > lastMtimeMs) {
|
|
74
|
+
lastMtimeMs = s.mtimeMs;
|
|
75
|
+
scheduleDebouncedSync();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// stat failed — ignore.
|
|
80
|
+
}
|
|
81
|
+
}, pollFallbackMs);
|
|
82
|
+
}
|
|
83
|
+
// If parent directory exists, start watching immediately.
|
|
84
|
+
// Otherwise, poll until it appears.
|
|
85
|
+
fsp.access(tagMapDir).then(() => {
|
|
86
|
+
if (!stopped)
|
|
87
|
+
startWatching();
|
|
88
|
+
}).catch(() => {
|
|
89
|
+
// Directory doesn't exist yet — poll for it.
|
|
90
|
+
dirPollTimer = setInterval(async () => {
|
|
91
|
+
if (stopped)
|
|
92
|
+
return;
|
|
93
|
+
try {
|
|
94
|
+
await fsp.access(tagMapDir);
|
|
95
|
+
// Directory appeared — start watching and stop polling.
|
|
96
|
+
if (dirPollTimer) {
|
|
97
|
+
clearInterval(dirPollTimer);
|
|
98
|
+
dirPollTimer = null;
|
|
99
|
+
}
|
|
100
|
+
if (!stopped)
|
|
101
|
+
startWatching();
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
// Still doesn't exist — keep polling.
|
|
105
|
+
}
|
|
106
|
+
}, DIR_POLL_MS);
|
|
107
|
+
});
|
|
108
|
+
return {
|
|
109
|
+
stop() {
|
|
110
|
+
if (stopped)
|
|
111
|
+
return;
|
|
112
|
+
stopped = true;
|
|
113
|
+
clearDebounce();
|
|
114
|
+
if (watcher) {
|
|
115
|
+
watcher.close();
|
|
116
|
+
watcher = null;
|
|
117
|
+
}
|
|
118
|
+
if (pollTimer) {
|
|
119
|
+
clearInterval(pollTimer);
|
|
120
|
+
pollTimer = null;
|
|
121
|
+
}
|
|
122
|
+
if (dirPollTimer) {
|
|
123
|
+
clearInterval(dirPollTimer);
|
|
124
|
+
dirPollTimer = null;
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import fsp from 'node:fs/promises';
|
|
4
|
+
import { startCronTagMapWatcher } from './cron-tag-map-watcher.js';
|
|
5
|
+
vi.mock('node:fs');
|
|
6
|
+
vi.mock('node:fs/promises');
|
|
7
|
+
const mockAccess = vi.mocked(fsp.access);
|
|
8
|
+
const mockStat = vi.mocked(fsp.stat);
|
|
9
|
+
const mockWatch = vi.mocked(fs.watch);
|
|
10
|
+
function mockLog() {
|
|
11
|
+
return { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
|
12
|
+
}
|
|
13
|
+
function makeCoordinator() {
|
|
14
|
+
return {
|
|
15
|
+
sync: vi.fn(async () => ({ tagsApplied: 0, namesUpdated: 0, statusMessagesUpdated: 0, orphansDetected: 0 })),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
describe('startCronTagMapWatcher', () => {
|
|
19
|
+
let watchCallback = null;
|
|
20
|
+
let watcherObj;
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
vi.resetAllMocks();
|
|
23
|
+
vi.useFakeTimers();
|
|
24
|
+
watchCallback = null;
|
|
25
|
+
watcherObj = { close: vi.fn(), on: vi.fn() };
|
|
26
|
+
mockWatch.mockImplementation((_path, callback) => {
|
|
27
|
+
watchCallback = callback;
|
|
28
|
+
return watcherObj;
|
|
29
|
+
});
|
|
30
|
+
// Default: directory exists, file has mtime
|
|
31
|
+
mockAccess.mockResolvedValue(undefined);
|
|
32
|
+
mockStat.mockResolvedValue({ mtimeMs: 1000 });
|
|
33
|
+
});
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
vi.useRealTimers();
|
|
36
|
+
});
|
|
37
|
+
it('change event triggers debounced coordinator.sync()', async () => {
|
|
38
|
+
const coordinator = makeCoordinator();
|
|
39
|
+
const handle = startCronTagMapWatcher({
|
|
40
|
+
coordinator,
|
|
41
|
+
tagMapPath: '/data/tag-map.json',
|
|
42
|
+
log: mockLog(),
|
|
43
|
+
debounceMs: 100,
|
|
44
|
+
});
|
|
45
|
+
// Let access/stat promises resolve
|
|
46
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
47
|
+
// Simulate file change
|
|
48
|
+
watchCallback?.('change', 'tag-map.json');
|
|
49
|
+
expect(coordinator.sync).not.toHaveBeenCalled();
|
|
50
|
+
// Advance past debounce
|
|
51
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
52
|
+
expect(coordinator.sync).toHaveBeenCalledTimes(1);
|
|
53
|
+
handle.stop();
|
|
54
|
+
});
|
|
55
|
+
it('multiple rapid events debounce to single sync', async () => {
|
|
56
|
+
const coordinator = makeCoordinator();
|
|
57
|
+
const handle = startCronTagMapWatcher({
|
|
58
|
+
coordinator,
|
|
59
|
+
tagMapPath: '/data/tag-map.json',
|
|
60
|
+
log: mockLog(),
|
|
61
|
+
debounceMs: 200,
|
|
62
|
+
});
|
|
63
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
64
|
+
watchCallback?.('change', 'tag-map.json');
|
|
65
|
+
watchCallback?.('change', 'tag-map.json');
|
|
66
|
+
watchCallback?.('change', 'tag-map.json');
|
|
67
|
+
await vi.advanceTimersByTimeAsync(200);
|
|
68
|
+
expect(coordinator.sync).toHaveBeenCalledTimes(1);
|
|
69
|
+
handle.stop();
|
|
70
|
+
});
|
|
71
|
+
it('ignores changes to unrelated files', async () => {
|
|
72
|
+
const coordinator = makeCoordinator();
|
|
73
|
+
const handle = startCronTagMapWatcher({
|
|
74
|
+
coordinator,
|
|
75
|
+
tagMapPath: '/data/tag-map.json',
|
|
76
|
+
log: mockLog(),
|
|
77
|
+
debounceMs: 100,
|
|
78
|
+
});
|
|
79
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
80
|
+
watchCallback?.('change', 'other-file.json');
|
|
81
|
+
await vi.advanceTimersByTimeAsync(200);
|
|
82
|
+
expect(coordinator.sync).not.toHaveBeenCalled();
|
|
83
|
+
handle.stop();
|
|
84
|
+
});
|
|
85
|
+
it('polling fallback detects mtime change', async () => {
|
|
86
|
+
// No fs.watch available
|
|
87
|
+
mockWatch.mockImplementation(() => { throw new Error('not available'); });
|
|
88
|
+
const coordinator = makeCoordinator();
|
|
89
|
+
const handle = startCronTagMapWatcher({
|
|
90
|
+
coordinator,
|
|
91
|
+
tagMapPath: '/data/tag-map.json',
|
|
92
|
+
log: mockLog(),
|
|
93
|
+
debounceMs: 100,
|
|
94
|
+
pollFallbackMs: 500,
|
|
95
|
+
});
|
|
96
|
+
// Let access resolve + seed mtime
|
|
97
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
98
|
+
// Simulate mtime change on next poll
|
|
99
|
+
mockStat.mockResolvedValue({ mtimeMs: 2000 });
|
|
100
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
101
|
+
// Advance past debounce
|
|
102
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
103
|
+
expect(coordinator.sync).toHaveBeenCalledTimes(1);
|
|
104
|
+
handle.stop();
|
|
105
|
+
});
|
|
106
|
+
it('dir-missing startup polls until dir appears', async () => {
|
|
107
|
+
// Directory missing initially
|
|
108
|
+
mockAccess.mockRejectedValueOnce(new Error('ENOENT'));
|
|
109
|
+
const coordinator = makeCoordinator();
|
|
110
|
+
const handle = startCronTagMapWatcher({
|
|
111
|
+
coordinator,
|
|
112
|
+
tagMapPath: '/data/tag-map.json',
|
|
113
|
+
log: mockLog(),
|
|
114
|
+
debounceMs: 100,
|
|
115
|
+
});
|
|
116
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
117
|
+
// Directory appears on next dir-poll cycle
|
|
118
|
+
mockAccess.mockResolvedValue(undefined);
|
|
119
|
+
await vi.advanceTimersByTimeAsync(30_000);
|
|
120
|
+
// Now fs.watch should be set up and a change should work
|
|
121
|
+
if (watchCallback) {
|
|
122
|
+
watchCallback('change', 'tag-map.json');
|
|
123
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
124
|
+
expect(coordinator.sync).toHaveBeenCalled();
|
|
125
|
+
}
|
|
126
|
+
handle.stop();
|
|
127
|
+
});
|
|
128
|
+
it('stop() prevents further syncs', async () => {
|
|
129
|
+
const coordinator = makeCoordinator();
|
|
130
|
+
const handle = startCronTagMapWatcher({
|
|
131
|
+
coordinator,
|
|
132
|
+
tagMapPath: '/data/tag-map.json',
|
|
133
|
+
log: mockLog(),
|
|
134
|
+
debounceMs: 100,
|
|
135
|
+
});
|
|
136
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
137
|
+
handle.stop();
|
|
138
|
+
watchCallback?.('change', 'tag-map.json');
|
|
139
|
+
await vi.advanceTimersByTimeAsync(200);
|
|
140
|
+
expect(coordinator.sync).not.toHaveBeenCalled();
|
|
141
|
+
});
|
|
142
|
+
it('stop() is idempotent', async () => {
|
|
143
|
+
const coordinator = makeCoordinator();
|
|
144
|
+
const handle = startCronTagMapWatcher({
|
|
145
|
+
coordinator,
|
|
146
|
+
tagMapPath: '/data/tag-map.json',
|
|
147
|
+
log: mockLog(),
|
|
148
|
+
debounceMs: 100,
|
|
149
|
+
});
|
|
150
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
151
|
+
handle.stop();
|
|
152
|
+
handle.stop(); // second call should not throw
|
|
153
|
+
expect(watcherObj.close).toHaveBeenCalledTimes(1);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns the default timezone using a fallback chain:
|
|
3
|
+
* 1. process.env.DEFAULT_TIMEZONE (if set and valid)
|
|
4
|
+
* 2. Intl.DateTimeFormat().resolvedOptions().timeZone (system timezone)
|
|
5
|
+
*
|
|
6
|
+
* Invalid DEFAULT_TIMEZONE values are logged and skipped.
|
|
7
|
+
*/
|
|
8
|
+
export function getDefaultTimezone() {
|
|
9
|
+
const envTz = process.env.DEFAULT_TIMEZONE;
|
|
10
|
+
if (envTz) {
|
|
11
|
+
try {
|
|
12
|
+
// Validate by attempting to construct a formatter with this timezone.
|
|
13
|
+
Intl.DateTimeFormat(undefined, { timeZone: envTz });
|
|
14
|
+
return envTz;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
// Invalid IANA timezone in env var — fall through to system detection.
|
|
18
|
+
// Log to stderr so the operator sees the misconfiguration.
|
|
19
|
+
console.error(`[cron] DEFAULT_TIMEZONE="${envTz}" is not a valid IANA timezone; falling back to system timezone.`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
23
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, expect, it, vi, afterEach } from 'vitest';
|
|
2
|
+
import { getDefaultTimezone } from './default-timezone.js';
|
|
3
|
+
describe('getDefaultTimezone', () => {
|
|
4
|
+
afterEach(() => {
|
|
5
|
+
vi.unstubAllEnvs();
|
|
6
|
+
});
|
|
7
|
+
it('returns DEFAULT_TIMEZONE when set to a valid IANA timezone', () => {
|
|
8
|
+
vi.stubEnv('DEFAULT_TIMEZONE', 'America/New_York');
|
|
9
|
+
expect(getDefaultTimezone()).toBe('America/New_York');
|
|
10
|
+
});
|
|
11
|
+
it('falls back to system timezone when env var is not set', () => {
|
|
12
|
+
vi.stubEnv('DEFAULT_TIMEZONE', '');
|
|
13
|
+
const systemTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
14
|
+
expect(getDefaultTimezone()).toBe(systemTz);
|
|
15
|
+
});
|
|
16
|
+
it('falls back to system timezone and logs error for invalid DEFAULT_TIMEZONE', () => {
|
|
17
|
+
vi.stubEnv('DEFAULT_TIMEZONE', 'NotATimezone');
|
|
18
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
19
|
+
const systemTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
20
|
+
expect(getDefaultTimezone()).toBe(systemTz);
|
|
21
|
+
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('NotATimezone'));
|
|
22
|
+
consoleSpy.mockRestore();
|
|
23
|
+
});
|
|
24
|
+
it('always returns a non-empty string', () => {
|
|
25
|
+
vi.stubEnv('DEFAULT_TIMEZONE', '');
|
|
26
|
+
const result = getDefaultTimezone();
|
|
27
|
+
expect(typeof result).toBe('string');
|
|
28
|
+
expect(result.length).toBeGreaterThan(0);
|
|
29
|
+
});
|
|
30
|
+
});
|