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,149 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { Client, GatewayIntentBits } from 'discord.js';
|
|
3
|
+
import { bdAddLabel, bdShow, bdUpdate } from './bd-cli.js';
|
|
4
|
+
import { findExistingThreadForBead, createBeadThread, ensureUnarchived, getThreadIdFromBead, resolveBeadsForum, updateBeadThreadName, updateBeadThreadTags, closeBeadThread, isThreadArchived } from './discord-sync.js';
|
|
5
|
+
import { loadTagMap } from './discord-sync.js';
|
|
6
|
+
function env(name) {
|
|
7
|
+
const v = (process.env[name] ?? '').trim();
|
|
8
|
+
if (!v)
|
|
9
|
+
throw new Error(`${name} is required`);
|
|
10
|
+
return v;
|
|
11
|
+
}
|
|
12
|
+
function envOpt(name) {
|
|
13
|
+
const v = (process.env[name] ?? '').trim();
|
|
14
|
+
return v || undefined;
|
|
15
|
+
}
|
|
16
|
+
function parseTagsCsv(csv) {
|
|
17
|
+
if (!csv)
|
|
18
|
+
return [];
|
|
19
|
+
return csv.split(',').map((t) => t.trim()).filter(Boolean);
|
|
20
|
+
}
|
|
21
|
+
function truncate(s, max) {
|
|
22
|
+
if (s.length <= max)
|
|
23
|
+
return s;
|
|
24
|
+
return s.slice(0, Math.max(0, max - 1)) + '\u2026';
|
|
25
|
+
}
|
|
26
|
+
function hasNoThread(bead, extraLabels) {
|
|
27
|
+
const labels = new Set([...(bead.labels ?? []), ...extraLabels]);
|
|
28
|
+
return labels.has('no-thread');
|
|
29
|
+
}
|
|
30
|
+
async function run() {
|
|
31
|
+
const sub = process.argv[2] ?? '';
|
|
32
|
+
const beadId = process.argv[3] ?? '';
|
|
33
|
+
if (!sub || !beadId) {
|
|
34
|
+
throw new Error('Usage: bead-hooks-cli <on-create|on-update|on-status-change|on-close> <bead-id> [--tags a,b]');
|
|
35
|
+
}
|
|
36
|
+
const args = process.argv.slice(4);
|
|
37
|
+
let tagsCsv;
|
|
38
|
+
for (let i = 0; i < args.length; i++) {
|
|
39
|
+
if (args[i] === '--tags') {
|
|
40
|
+
tagsCsv = args[i + 1];
|
|
41
|
+
i++;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const discordToken = env('DISCORD_TOKEN');
|
|
45
|
+
const guildId = env('DISCORD_GUILD_ID');
|
|
46
|
+
const forumId = env('DISCOCLAW_BEADS_FORUM');
|
|
47
|
+
const beadsCwd = envOpt('DISCOCLAW_BEADS_CWD') ?? process.cwd();
|
|
48
|
+
const dataDir = envOpt('DISCOCLAW_DATA_DIR');
|
|
49
|
+
const tagMapPath = envOpt('DISCOCLAW_BEADS_TAG_MAP')
|
|
50
|
+
?? (dataDir ? path.join(dataDir, 'beads', 'tag-map.json') : undefined);
|
|
51
|
+
const mentionUserId = envOpt('DISCOCLAW_BEADS_MENTION_USER');
|
|
52
|
+
const extraLabels = parseTagsCsv(tagsCsv);
|
|
53
|
+
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
|
|
54
|
+
await client.login(discordToken);
|
|
55
|
+
await new Promise((resolve) => client.once('ready', () => resolve()));
|
|
56
|
+
try {
|
|
57
|
+
const guild = await client.guilds.fetch(guildId);
|
|
58
|
+
const forum = await resolveBeadsForum(guild, forumId);
|
|
59
|
+
if (!forum)
|
|
60
|
+
throw new Error(`Beads forum not found: ${forumId}`);
|
|
61
|
+
const tagMap = tagMapPath ? await loadTagMap(tagMapPath) : {};
|
|
62
|
+
const bead = await bdShow(beadId, beadsCwd);
|
|
63
|
+
if (!bead)
|
|
64
|
+
throw new Error(`Bead not found: ${beadId}`);
|
|
65
|
+
if (sub === 'on-create') {
|
|
66
|
+
if (hasNoThread(bead, extraLabels))
|
|
67
|
+
return;
|
|
68
|
+
const existingRef = getThreadIdFromBead(bead);
|
|
69
|
+
if (existingRef)
|
|
70
|
+
return;
|
|
71
|
+
const deduped = await findExistingThreadForBead(forum, bead.id);
|
|
72
|
+
if (deduped) {
|
|
73
|
+
await bdUpdate(bead.id, { externalRef: `discord:${deduped}` }, beadsCwd);
|
|
74
|
+
// Backfill tag labels if provided.
|
|
75
|
+
for (const t of extraLabels) {
|
|
76
|
+
try {
|
|
77
|
+
await bdAddLabel(bead.id, `tag:${t}`, beadsCwd);
|
|
78
|
+
}
|
|
79
|
+
catch { }
|
|
80
|
+
}
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const beadForThread = { ...bead, labels: [...new Set([...(bead.labels ?? []), ...extraLabels])] };
|
|
84
|
+
const threadId = await createBeadThread(forum, beadForThread, tagMap, mentionUserId);
|
|
85
|
+
try {
|
|
86
|
+
await bdUpdate(bead.id, { externalRef: `discord:${threadId}` }, beadsCwd);
|
|
87
|
+
}
|
|
88
|
+
catch { }
|
|
89
|
+
for (const t of extraLabels) {
|
|
90
|
+
try {
|
|
91
|
+
await bdAddLabel(bead.id, `tag:${t}`, beadsCwd);
|
|
92
|
+
}
|
|
93
|
+
catch { }
|
|
94
|
+
}
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const threadId = getThreadIdFromBead(bead);
|
|
98
|
+
if (!threadId)
|
|
99
|
+
return;
|
|
100
|
+
if (sub === 'on-status-change') {
|
|
101
|
+
await ensureUnarchived(client, threadId);
|
|
102
|
+
await updateBeadThreadName(client, threadId, bead);
|
|
103
|
+
await updateBeadThreadTags(client, threadId, bead, tagMap);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (sub === 'on-update') {
|
|
107
|
+
await ensureUnarchived(client, threadId);
|
|
108
|
+
await updateBeadThreadName(client, threadId, bead);
|
|
109
|
+
const title = bead.title || 'Untitled';
|
|
110
|
+
const status = bead.status || 'open';
|
|
111
|
+
const priority = `P${bead.priority ?? 2}`;
|
|
112
|
+
const desc = truncate(bead.description ?? 'No description', 1800);
|
|
113
|
+
const content = `**Update**\n` +
|
|
114
|
+
`**Priority:** ${priority}\n` +
|
|
115
|
+
`**Status:** ${status}\n` +
|
|
116
|
+
`**Title:** ${title}\n\n` +
|
|
117
|
+
`${desc}`;
|
|
118
|
+
// Avoid accidental mentions from bead content.
|
|
119
|
+
try {
|
|
120
|
+
const thread = await client.channels.fetch(threadId);
|
|
121
|
+
if (thread && thread.isThread()) {
|
|
122
|
+
try {
|
|
123
|
+
await thread.send({ content: truncate(content, 2000), allowedMentions: { parse: [], users: [] } });
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
// ignore
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
// ignore fetch failures
|
|
132
|
+
}
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (sub === 'on-close') {
|
|
136
|
+
// Lightweight check: skip if thread is already archived to avoid
|
|
137
|
+
// duplicate close messages from concurrent close operations.
|
|
138
|
+
if (await isThreadArchived(client, threadId))
|
|
139
|
+
return;
|
|
140
|
+
await closeBeadThread(client, threadId, bead, tagMap);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
throw new Error(`Unknown subcommand: ${sub}`);
|
|
144
|
+
}
|
|
145
|
+
finally {
|
|
146
|
+
client.destroy();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
await run();
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { parseArgInt, runSyncWithStore, runTaskSyncCliMain, runTaskSyncWithStore, } from '../tasks/task-sync-cli.js';
|
|
2
|
+
if (import.meta.url === new URL(process.argv[1] ?? '', 'file:').href) {
|
|
3
|
+
const { runTaskSyncCliMain } = await import('../tasks/task-sync-cli.js');
|
|
4
|
+
await runTaskSyncCliMain();
|
|
5
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { parseArgInt, runSyncWithStore } from './bead-sync-cli.js';
|
|
3
|
+
import { parseArgInt as parseTaskArgInt, runSyncWithStore as runTaskSyncWithStoreCompat, } from '../tasks/task-sync-cli.js';
|
|
4
|
+
vi.mock('../tasks/task-sync-engine.js', () => {
|
|
5
|
+
const runTaskSync = vi.fn().mockResolvedValue({ created: 0, updated: 0, closed: 0 });
|
|
6
|
+
return { runTaskSync };
|
|
7
|
+
});
|
|
8
|
+
// bead-sync-cli.ts uses an import.meta.url guard so that main() does NOT run
|
|
9
|
+
// when the module is imported (only when invoked as a script). This lets us
|
|
10
|
+
// import and test the exported helpers without triggering Discord connections
|
|
11
|
+
// or env-var validation.
|
|
12
|
+
describe('parseArgInt', () => {
|
|
13
|
+
it('keeps compatibility exports aligned to canonical task sync CLI module', () => {
|
|
14
|
+
expect(parseArgInt).toBe(parseTaskArgInt);
|
|
15
|
+
expect(runSyncWithStore).toBe(runTaskSyncWithStoreCompat);
|
|
16
|
+
});
|
|
17
|
+
it('returns undefined when the flag is not in args', () => {
|
|
18
|
+
expect(parseArgInt(['--foo', '1'], '--bar')).toBeUndefined();
|
|
19
|
+
});
|
|
20
|
+
it('returns undefined for an empty args array', () => {
|
|
21
|
+
expect(parseArgInt([], '--throttle-ms')).toBeUndefined();
|
|
22
|
+
});
|
|
23
|
+
it('parses a positive integer', () => {
|
|
24
|
+
expect(parseArgInt(['--throttle-ms', '500'], '--throttle-ms')).toBe(500);
|
|
25
|
+
});
|
|
26
|
+
it('parses zero', () => {
|
|
27
|
+
expect(parseArgInt(['--throttle-ms', '0'], '--throttle-ms')).toBe(0);
|
|
28
|
+
});
|
|
29
|
+
it('parses a negative integer', () => {
|
|
30
|
+
expect(parseArgInt(['--limit', '-1'], '--limit')).toBe(-1);
|
|
31
|
+
});
|
|
32
|
+
it('parses a decimal value', () => {
|
|
33
|
+
expect(parseArgInt(['--throttle-ms', '2.5'], '--throttle-ms')).toBe(2.5);
|
|
34
|
+
});
|
|
35
|
+
it('finds the flag among mixed args', () => {
|
|
36
|
+
expect(parseArgInt(['--archived-limit', '200', '--throttle-ms', '100'], '--throttle-ms')).toBe(100);
|
|
37
|
+
});
|
|
38
|
+
it('returns the value for the first occurrence when the flag appears more than once', () => {
|
|
39
|
+
expect(parseArgInt(['--limit', '10', '--limit', '20'], '--limit')).toBe(10);
|
|
40
|
+
});
|
|
41
|
+
it('throws when the flag is present but no value follows', () => {
|
|
42
|
+
expect(() => parseArgInt(['--throttle-ms'], '--throttle-ms')).toThrow('--throttle-ms requires a value');
|
|
43
|
+
});
|
|
44
|
+
it('throws when the value is a non-numeric string', () => {
|
|
45
|
+
expect(() => parseArgInt(['--limit', 'abc'], '--limit')).toThrow('--limit must be a number');
|
|
46
|
+
});
|
|
47
|
+
it('throws when the value is "NaN"', () => {
|
|
48
|
+
expect(() => parseArgInt(['--limit', 'NaN'], '--limit')).toThrow('must be a number');
|
|
49
|
+
});
|
|
50
|
+
it('throws when the value is "Infinity"', () => {
|
|
51
|
+
expect(() => parseArgInt(['--limit', 'Infinity'], '--limit')).toThrow('must be a number');
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
describe('runSyncWithStore', () => {
|
|
55
|
+
it('passes store through to runTaskSync', async () => {
|
|
56
|
+
const { runTaskSync } = await import('../tasks/task-sync-engine.js');
|
|
57
|
+
const { TaskStore } = await import('../tasks/store.js');
|
|
58
|
+
const store = new TaskStore();
|
|
59
|
+
const fakeClient = {};
|
|
60
|
+
const fakeGuild = {};
|
|
61
|
+
await runSyncWithStore({
|
|
62
|
+
client: fakeClient,
|
|
63
|
+
guild: fakeGuild,
|
|
64
|
+
forumId: 'forum-123',
|
|
65
|
+
tagMap: {},
|
|
66
|
+
store,
|
|
67
|
+
throttleMs: 100,
|
|
68
|
+
archivedDedupeLimit: 50,
|
|
69
|
+
});
|
|
70
|
+
expect(runTaskSync).toHaveBeenCalledWith(expect.objectContaining({ store }));
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
vi.mock('../tasks/task-sync-engine.js', () => {
|
|
3
|
+
const runTaskSync = vi.fn(async () => ({
|
|
4
|
+
threadsCreated: 0,
|
|
5
|
+
emojisUpdated: 0,
|
|
6
|
+
starterMessagesUpdated: 0,
|
|
7
|
+
threadsArchived: 0,
|
|
8
|
+
statusesUpdated: 0,
|
|
9
|
+
tagsUpdated: 0,
|
|
10
|
+
warnings: 0,
|
|
11
|
+
closesDeferred: 0,
|
|
12
|
+
}));
|
|
13
|
+
return { runTaskSync };
|
|
14
|
+
});
|
|
15
|
+
vi.mock('../tasks/thread-cache.js', () => ({
|
|
16
|
+
taskThreadCache: { invalidate: vi.fn() },
|
|
17
|
+
}));
|
|
18
|
+
vi.mock('../tasks/discord-sync.js', () => ({
|
|
19
|
+
reloadTagMapInPlace: vi.fn(async () => 2),
|
|
20
|
+
}));
|
|
21
|
+
import { BeadSyncCoordinator } from './bead-sync-coordinator.js';
|
|
22
|
+
import { reloadTagMapInPlace } from '../tasks/discord-sync.js';
|
|
23
|
+
function makeOpts() {
|
|
24
|
+
return {
|
|
25
|
+
client: {},
|
|
26
|
+
guild: {},
|
|
27
|
+
forumId: 'forum-1',
|
|
28
|
+
tagMap: {},
|
|
29
|
+
store: {},
|
|
30
|
+
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
describe('BeadSyncCoordinator', () => {
|
|
34
|
+
beforeEach(() => vi.clearAllMocks());
|
|
35
|
+
it('calls runTaskSync and returns result', async () => {
|
|
36
|
+
const { runTaskSync } = await import('../tasks/task-sync-engine.js');
|
|
37
|
+
const coord = new BeadSyncCoordinator(makeOpts());
|
|
38
|
+
const result = await coord.sync();
|
|
39
|
+
expect(runTaskSync).toHaveBeenCalledOnce();
|
|
40
|
+
expect(result).toEqual(expect.objectContaining({ threadsCreated: 0 }));
|
|
41
|
+
});
|
|
42
|
+
it('invalidates cache after sync', async () => {
|
|
43
|
+
const { taskThreadCache } = await import('../tasks/thread-cache.js');
|
|
44
|
+
const coord = new BeadSyncCoordinator(makeOpts());
|
|
45
|
+
await coord.sync();
|
|
46
|
+
expect(taskThreadCache.invalidate).toHaveBeenCalledOnce();
|
|
47
|
+
});
|
|
48
|
+
it('passes statusPoster through to runTaskSync', async () => {
|
|
49
|
+
const { runTaskSync } = await import('../tasks/task-sync-engine.js');
|
|
50
|
+
const statusPoster = { taskSyncComplete: vi.fn() };
|
|
51
|
+
const coord = new BeadSyncCoordinator(makeOpts());
|
|
52
|
+
await coord.sync(statusPoster);
|
|
53
|
+
expect(runTaskSync).toHaveBeenCalledWith(expect.objectContaining({ statusPoster }));
|
|
54
|
+
});
|
|
55
|
+
it('omits statusPoster when not provided', async () => {
|
|
56
|
+
const { runTaskSync } = await import('../tasks/task-sync-engine.js');
|
|
57
|
+
const coord = new BeadSyncCoordinator(makeOpts());
|
|
58
|
+
await coord.sync();
|
|
59
|
+
expect(runTaskSync).toHaveBeenCalledWith(expect.objectContaining({ statusPoster: undefined }));
|
|
60
|
+
});
|
|
61
|
+
it('returns null for concurrent call and triggers follow-up', async () => {
|
|
62
|
+
const { runTaskSync } = await import('../tasks/task-sync-engine.js');
|
|
63
|
+
// Make the first sync take a while
|
|
64
|
+
let resolveFirst;
|
|
65
|
+
const firstPromise = new Promise((r) => { resolveFirst = r; });
|
|
66
|
+
runTaskSync.mockImplementationOnce(async () => {
|
|
67
|
+
await firstPromise;
|
|
68
|
+
return { threadsCreated: 1, emojisUpdated: 0, starterMessagesUpdated: 0, threadsArchived: 0, statusesUpdated: 0, tagsUpdated: 0, warnings: 0 };
|
|
69
|
+
});
|
|
70
|
+
const coord = new BeadSyncCoordinator(makeOpts());
|
|
71
|
+
// Start first sync (will block)
|
|
72
|
+
const first = coord.sync();
|
|
73
|
+
// Second call while first is running should return null
|
|
74
|
+
const second = await coord.sync();
|
|
75
|
+
expect(second).toBeNull();
|
|
76
|
+
// Complete the first sync
|
|
77
|
+
resolveFirst();
|
|
78
|
+
const firstResult = await first;
|
|
79
|
+
expect(firstResult).toEqual(expect.objectContaining({ threadsCreated: 1 }));
|
|
80
|
+
// Wait a tick for the fire-and-forget follow-up to start
|
|
81
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
82
|
+
// runTaskSync should have been called at least twice (first + follow-up)
|
|
83
|
+
expect(runTaskSync.mock.calls.length).toBeGreaterThanOrEqual(2);
|
|
84
|
+
});
|
|
85
|
+
it('propagates runTaskSync errors and remains usable', async () => {
|
|
86
|
+
const { runTaskSync } = await import('../tasks/task-sync-engine.js');
|
|
87
|
+
const { taskThreadCache } = await import('../tasks/thread-cache.js');
|
|
88
|
+
runTaskSync.mockRejectedValueOnce(new Error('Discord API down'));
|
|
89
|
+
const coord = new BeadSyncCoordinator(makeOpts());
|
|
90
|
+
// First call should throw
|
|
91
|
+
await expect(coord.sync()).rejects.toThrow('Discord API down');
|
|
92
|
+
// Cache should not be invalidated on failure
|
|
93
|
+
expect(taskThreadCache.invalidate).not.toHaveBeenCalled();
|
|
94
|
+
// Coordinator should still be usable for subsequent calls
|
|
95
|
+
const result = await coord.sync();
|
|
96
|
+
expect(result).toEqual(expect.objectContaining({ threadsCreated: 0 }));
|
|
97
|
+
expect(taskThreadCache.invalidate).toHaveBeenCalledOnce();
|
|
98
|
+
});
|
|
99
|
+
it('follow-up uses the coalesced caller statusPoster, not the running one', async () => {
|
|
100
|
+
const { runTaskSync } = await import('../tasks/task-sync-engine.js');
|
|
101
|
+
let resolveFirst;
|
|
102
|
+
const firstPromise = new Promise((r) => { resolveFirst = r; });
|
|
103
|
+
runTaskSync.mockImplementationOnce(async () => {
|
|
104
|
+
await firstPromise;
|
|
105
|
+
return { threadsCreated: 0, emojisUpdated: 0, starterMessagesUpdated: 0, threadsArchived: 0, statusesUpdated: 0, warnings: 0 };
|
|
106
|
+
});
|
|
107
|
+
const coord = new BeadSyncCoordinator(makeOpts());
|
|
108
|
+
const statusPoster = { taskSyncComplete: vi.fn() };
|
|
109
|
+
// Watcher triggers sync without statusPoster
|
|
110
|
+
const first = coord.sync();
|
|
111
|
+
// User action triggers sync with statusPoster — coalesced
|
|
112
|
+
const second = await coord.sync(statusPoster);
|
|
113
|
+
expect(second).toBeNull();
|
|
114
|
+
// Complete the first sync
|
|
115
|
+
resolveFirst();
|
|
116
|
+
await first;
|
|
117
|
+
// Wait for fire-and-forget follow-up
|
|
118
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
119
|
+
// The follow-up (second call to runTaskSync) should have the user's statusPoster
|
|
120
|
+
const followUpCall = runTaskSync.mock.calls[1];
|
|
121
|
+
expect(followUpCall[0].statusPoster).toBe(statusPoster);
|
|
122
|
+
});
|
|
123
|
+
it('logs warning when follow-up sync fails', async () => {
|
|
124
|
+
const { runTaskSync } = await import('../tasks/task-sync-engine.js');
|
|
125
|
+
let resolveFirst;
|
|
126
|
+
const firstPromise = new Promise((r) => { resolveFirst = r; });
|
|
127
|
+
runTaskSync
|
|
128
|
+
.mockImplementationOnce(async () => {
|
|
129
|
+
await firstPromise;
|
|
130
|
+
return { threadsCreated: 0, emojisUpdated: 0, starterMessagesUpdated: 0, threadsArchived: 0, statusesUpdated: 0, warnings: 0 };
|
|
131
|
+
})
|
|
132
|
+
.mockRejectedValueOnce(new Error('follow-up boom'));
|
|
133
|
+
const opts = makeOpts();
|
|
134
|
+
const coord = new BeadSyncCoordinator(opts);
|
|
135
|
+
const first = coord.sync();
|
|
136
|
+
await coord.sync(); // coalesce
|
|
137
|
+
resolveFirst();
|
|
138
|
+
await first;
|
|
139
|
+
// Wait for follow-up to fail and log
|
|
140
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
141
|
+
expect(opts.log.warn).toHaveBeenCalledWith(expect.objectContaining({ err: expect.any(Error) }), 'tasks:coordinator follow-up sync failed');
|
|
142
|
+
});
|
|
143
|
+
it('reloads tag map before runTaskSync when tagMapPath is set', async () => {
|
|
144
|
+
const { runTaskSync } = await import('../tasks/task-sync-engine.js');
|
|
145
|
+
reloadTagMapInPlace.mockClear();
|
|
146
|
+
const opts = makeOpts();
|
|
147
|
+
opts.tagMapPath = '/tmp/tag-map.json';
|
|
148
|
+
opts.tagMap = { bug: '111' };
|
|
149
|
+
const coord = new BeadSyncCoordinator(opts);
|
|
150
|
+
await coord.sync();
|
|
151
|
+
expect(reloadTagMapInPlace).toHaveBeenCalledWith('/tmp/tag-map.json', opts.tagMap);
|
|
152
|
+
// reloadTagMapInPlace called before runTaskSync
|
|
153
|
+
const reloadOrder = reloadTagMapInPlace.mock.invocationCallOrder[0];
|
|
154
|
+
const syncOrder = runTaskSync.mock.invocationCallOrder[0];
|
|
155
|
+
expect(reloadOrder).toBeLessThan(syncOrder);
|
|
156
|
+
});
|
|
157
|
+
it('preserves existing map and continues sync when tag-map reload fails', async () => {
|
|
158
|
+
const { runTaskSync } = await import('../tasks/task-sync-engine.js');
|
|
159
|
+
reloadTagMapInPlace.mockRejectedValueOnce(new Error('bad json'));
|
|
160
|
+
const opts = makeOpts();
|
|
161
|
+
opts.tagMapPath = '/tmp/tag-map.json';
|
|
162
|
+
opts.tagMap = { bug: '111' };
|
|
163
|
+
const coord = new BeadSyncCoordinator(opts);
|
|
164
|
+
const result = await coord.sync();
|
|
165
|
+
// Sync still runs despite reload failure
|
|
166
|
+
expect(result).toEqual(expect.objectContaining({ threadsCreated: 0 }));
|
|
167
|
+
expect(runTaskSync).toHaveBeenCalled();
|
|
168
|
+
expect(opts.log.warn).toHaveBeenCalledWith(expect.objectContaining({ err: expect.any(Error), tagMapPath: '/tmp/tag-map.json' }), 'tasks:tag-map reload failed; using cached map');
|
|
169
|
+
});
|
|
170
|
+
it('does not attempt reload when tagMapPath is not set', async () => {
|
|
171
|
+
reloadTagMapInPlace.mockClear();
|
|
172
|
+
const opts = makeOpts();
|
|
173
|
+
// No tagMapPath set
|
|
174
|
+
const coord = new BeadSyncCoordinator(opts);
|
|
175
|
+
await coord.sync();
|
|
176
|
+
expect(reloadTagMapInPlace).not.toHaveBeenCalled();
|
|
177
|
+
});
|
|
178
|
+
it('passes a tagMap snapshot to runTaskSync', async () => {
|
|
179
|
+
const { runTaskSync } = await import('../tasks/task-sync-engine.js');
|
|
180
|
+
reloadTagMapInPlace.mockClear();
|
|
181
|
+
const tagMap = { bug: '111' };
|
|
182
|
+
const opts = makeOpts();
|
|
183
|
+
opts.tagMapPath = '/tmp/tag-map.json';
|
|
184
|
+
opts.tagMap = tagMap;
|
|
185
|
+
const coord = new BeadSyncCoordinator(opts);
|
|
186
|
+
await coord.sync();
|
|
187
|
+
// runTaskSync should receive a snapshot (different object reference)
|
|
188
|
+
const passedOpts = runTaskSync.mock.calls[0][0];
|
|
189
|
+
expect(passedOpts.tagMap).toEqual(tagMap);
|
|
190
|
+
expect(passedOpts.tagMap).not.toBe(tagMap);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
describe('BeadSyncCoordinator deferred-close retry', () => {
|
|
194
|
+
beforeEach(() => {
|
|
195
|
+
vi.clearAllMocks();
|
|
196
|
+
vi.useFakeTimers();
|
|
197
|
+
});
|
|
198
|
+
afterEach(() => {
|
|
199
|
+
vi.useRealTimers();
|
|
200
|
+
});
|
|
201
|
+
it('does not schedule retry when closesDeferred is 0', async () => {
|
|
202
|
+
const { runTaskSync } = await import('../tasks/task-sync-engine.js');
|
|
203
|
+
const coord = new BeadSyncCoordinator(makeOpts());
|
|
204
|
+
await coord.sync();
|
|
205
|
+
expect(runTaskSync).toHaveBeenCalledOnce();
|
|
206
|
+
await vi.advanceTimersByTimeAsync(35_000);
|
|
207
|
+
// No retry — only the original call.
|
|
208
|
+
expect(runTaskSync.mock.calls.length).toBe(1);
|
|
209
|
+
});
|
|
210
|
+
it('schedules a retry sync after 30s when closesDeferred > 0', async () => {
|
|
211
|
+
const { runTaskSync } = await import('../tasks/task-sync-engine.js');
|
|
212
|
+
runTaskSync.mockResolvedValueOnce({
|
|
213
|
+
threadsCreated: 0, emojisUpdated: 0, starterMessagesUpdated: 0,
|
|
214
|
+
threadsArchived: 0, statusesUpdated: 0, tagsUpdated: 0, warnings: 0,
|
|
215
|
+
closesDeferred: 1,
|
|
216
|
+
});
|
|
217
|
+
const coord = new BeadSyncCoordinator(makeOpts());
|
|
218
|
+
await coord.sync();
|
|
219
|
+
expect(runTaskSync).toHaveBeenCalledOnce();
|
|
220
|
+
await vi.advanceTimersByTimeAsync(30_000);
|
|
221
|
+
// Retry should have fired.
|
|
222
|
+
expect(runTaskSync.mock.calls.length).toBe(2);
|
|
223
|
+
});
|
|
224
|
+
it('deferred-close retry failure is logged', async () => {
|
|
225
|
+
const { runTaskSync } = await import('../tasks/task-sync-engine.js');
|
|
226
|
+
runTaskSync
|
|
227
|
+
.mockResolvedValueOnce({
|
|
228
|
+
threadsCreated: 0, emojisUpdated: 0, starterMessagesUpdated: 0,
|
|
229
|
+
threadsArchived: 0, statusesUpdated: 0, tagsUpdated: 0, warnings: 0,
|
|
230
|
+
closesDeferred: 1,
|
|
231
|
+
})
|
|
232
|
+
.mockRejectedValueOnce(new Error('retry boom'));
|
|
233
|
+
const opts = makeOpts();
|
|
234
|
+
const coord = new BeadSyncCoordinator(opts);
|
|
235
|
+
await coord.sync();
|
|
236
|
+
await vi.advanceTimersByTimeAsync(30_000);
|
|
237
|
+
expect(opts.log.warn).toHaveBeenCalledWith(expect.objectContaining({ err: expect.any(Error) }), 'tasks:coordinator deferred-close retry failed');
|
|
238
|
+
});
|
|
239
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { EventEmitter } from 'node:events';
|
|
3
|
+
import { startBeadSyncWatcher } from './bead-sync-watcher.js';
|
|
4
|
+
import { startTaskSyncWatcher } from '../tasks/sync-watcher.js';
|
|
5
|
+
function makeCoordinator() {
|
|
6
|
+
return {
|
|
7
|
+
sync: vi.fn(async () => ({
|
|
8
|
+
threadsCreated: 0,
|
|
9
|
+
emojisUpdated: 0,
|
|
10
|
+
starterMessagesUpdated: 0,
|
|
11
|
+
threadsArchived: 0,
|
|
12
|
+
statusesUpdated: 0,
|
|
13
|
+
tagsUpdated: 0,
|
|
14
|
+
warnings: 0,
|
|
15
|
+
})),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
function makeStore() {
|
|
19
|
+
return new EventEmitter();
|
|
20
|
+
}
|
|
21
|
+
describe('startBeadSyncWatcher', () => {
|
|
22
|
+
it('keeps compatibility export aligned to canonical task sync watcher module', () => {
|
|
23
|
+
expect(startBeadSyncWatcher).toBe(startTaskSyncWatcher);
|
|
24
|
+
});
|
|
25
|
+
it('triggers sync on store created event', async () => {
|
|
26
|
+
const coordinator = makeCoordinator();
|
|
27
|
+
const store = makeStore();
|
|
28
|
+
const handle = startBeadSyncWatcher({ coordinator, store });
|
|
29
|
+
store.emit('created', {});
|
|
30
|
+
await Promise.resolve();
|
|
31
|
+
expect(coordinator.sync).toHaveBeenCalledOnce();
|
|
32
|
+
expect(coordinator.sync).toHaveBeenCalledWith();
|
|
33
|
+
handle.stop();
|
|
34
|
+
});
|
|
35
|
+
it('triggers sync on store updated event', async () => {
|
|
36
|
+
const coordinator = makeCoordinator();
|
|
37
|
+
const store = makeStore();
|
|
38
|
+
const handle = startBeadSyncWatcher({ coordinator, store });
|
|
39
|
+
store.emit('updated', {}, {});
|
|
40
|
+
await Promise.resolve();
|
|
41
|
+
expect(coordinator.sync).toHaveBeenCalledOnce();
|
|
42
|
+
handle.stop();
|
|
43
|
+
});
|
|
44
|
+
it('triggers sync on store closed event', async () => {
|
|
45
|
+
const coordinator = makeCoordinator();
|
|
46
|
+
const store = makeStore();
|
|
47
|
+
const handle = startBeadSyncWatcher({ coordinator, store });
|
|
48
|
+
store.emit('closed', {});
|
|
49
|
+
await Promise.resolve();
|
|
50
|
+
expect(coordinator.sync).toHaveBeenCalledOnce();
|
|
51
|
+
handle.stop();
|
|
52
|
+
});
|
|
53
|
+
it('triggers sync on store labeled event', async () => {
|
|
54
|
+
const coordinator = makeCoordinator();
|
|
55
|
+
const store = makeStore();
|
|
56
|
+
const handle = startBeadSyncWatcher({ coordinator, store });
|
|
57
|
+
store.emit('labeled', {}, 'some-label');
|
|
58
|
+
await Promise.resolve();
|
|
59
|
+
expect(coordinator.sync).toHaveBeenCalledOnce();
|
|
60
|
+
handle.stop();
|
|
61
|
+
});
|
|
62
|
+
it('no sync fires after stop()', async () => {
|
|
63
|
+
const coordinator = makeCoordinator();
|
|
64
|
+
const store = makeStore();
|
|
65
|
+
const handle = startBeadSyncWatcher({ coordinator, store });
|
|
66
|
+
handle.stop();
|
|
67
|
+
store.emit('created', {});
|
|
68
|
+
await Promise.resolve();
|
|
69
|
+
expect(coordinator.sync).not.toHaveBeenCalled();
|
|
70
|
+
});
|
|
71
|
+
it('multiple events each trigger a sync call (coordinator coalesces)', async () => {
|
|
72
|
+
const coordinator = makeCoordinator();
|
|
73
|
+
const store = makeStore();
|
|
74
|
+
const handle = startBeadSyncWatcher({ coordinator, store });
|
|
75
|
+
store.emit('created', {});
|
|
76
|
+
store.emit('updated', {}, {});
|
|
77
|
+
store.emit('closed', {});
|
|
78
|
+
await Promise.resolve();
|
|
79
|
+
// Each event fires a sync; the coordinator's concurrency guard coalesces them.
|
|
80
|
+
expect(coordinator.sync).toHaveBeenCalledTimes(3);
|
|
81
|
+
handle.stop();
|
|
82
|
+
});
|
|
83
|
+
it('sync failure is caught and logged without throwing', async () => {
|
|
84
|
+
const coordinator = {
|
|
85
|
+
sync: vi.fn().mockRejectedValue(new Error('network error')),
|
|
86
|
+
};
|
|
87
|
+
const store = makeStore();
|
|
88
|
+
const log = { warn: vi.fn(), info: vi.fn(), error: vi.fn() };
|
|
89
|
+
const handle = startBeadSyncWatcher({ coordinator, store, log });
|
|
90
|
+
store.emit('created', {});
|
|
91
|
+
// Flush the microtask queue so the catch handler runs.
|
|
92
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
93
|
+
expect(log.warn).toHaveBeenCalledWith(expect.objectContaining({ err: expect.any(Error) }), 'tasks:watcher sync failed');
|
|
94
|
+
handle.stop();
|
|
95
|
+
});
|
|
96
|
+
});
|