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,876 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { runBeadSync } from './bead-sync.js';
|
|
3
|
+
import { runTaskSync as runTaskModuleSync } from '../tasks/task-sync-engine.js';
|
|
4
|
+
import { withDirectTaskLifecycle } from '../tasks/task-lifecycle.js';
|
|
5
|
+
vi.mock('../discord/inflight-replies.js', () => ({
|
|
6
|
+
hasInFlightForChannel: vi.fn(() => false),
|
|
7
|
+
}));
|
|
8
|
+
var discordSyncMock;
|
|
9
|
+
function makeDiscordSyncMock() {
|
|
10
|
+
if (!discordSyncMock) {
|
|
11
|
+
const resolveTasksForum = vi.fn(async () => ({ threads: { fetchActive: vi.fn(async () => ({ threads: new Map() })), fetchArchived: vi.fn(async () => ({ threads: new Map() })) } }));
|
|
12
|
+
const createTaskThread = vi.fn(async () => 'thread-new');
|
|
13
|
+
const closeTaskThread = vi.fn(async () => { });
|
|
14
|
+
const isThreadArchived = vi.fn(async () => false);
|
|
15
|
+
const isTaskThreadAlreadyClosed = vi.fn(async () => false);
|
|
16
|
+
const updateTaskThreadName = vi.fn(async () => true);
|
|
17
|
+
const updateTaskStarterMessage = vi.fn(async () => true);
|
|
18
|
+
const updateTaskThreadTags = vi.fn(async () => false);
|
|
19
|
+
const getThreadIdFromTask = vi.fn((task) => {
|
|
20
|
+
const ref = (task.external_ref ?? '').trim();
|
|
21
|
+
if (!ref)
|
|
22
|
+
return null;
|
|
23
|
+
if (ref.startsWith('discord:'))
|
|
24
|
+
return ref.slice('discord:'.length);
|
|
25
|
+
if (/^\\d+$/.test(ref))
|
|
26
|
+
return ref;
|
|
27
|
+
return null;
|
|
28
|
+
});
|
|
29
|
+
const ensureUnarchived = vi.fn(async () => { });
|
|
30
|
+
const findExistingThreadForTask = vi.fn(async () => null);
|
|
31
|
+
const extractShortIdFromThreadName = vi.fn((name) => {
|
|
32
|
+
const m = name.match(/\[(\d+)\]/);
|
|
33
|
+
return m ? m[1] : null;
|
|
34
|
+
});
|
|
35
|
+
const shortTaskId = vi.fn((id) => {
|
|
36
|
+
const idx = id.indexOf('-');
|
|
37
|
+
return idx >= 0 ? id.slice(idx + 1) : id;
|
|
38
|
+
});
|
|
39
|
+
discordSyncMock = {
|
|
40
|
+
resolveTasksForum,
|
|
41
|
+
createTaskThread,
|
|
42
|
+
closeTaskThread,
|
|
43
|
+
isThreadArchived,
|
|
44
|
+
isTaskThreadAlreadyClosed,
|
|
45
|
+
updateTaskThreadName,
|
|
46
|
+
updateTaskStarterMessage,
|
|
47
|
+
updateTaskThreadTags,
|
|
48
|
+
getThreadIdFromTask,
|
|
49
|
+
ensureUnarchived,
|
|
50
|
+
findExistingThreadForTask,
|
|
51
|
+
extractShortIdFromThreadName,
|
|
52
|
+
shortTaskId,
|
|
53
|
+
resolveBeadsForum: resolveTasksForum,
|
|
54
|
+
createBeadThread: createTaskThread,
|
|
55
|
+
closeBeadThread: closeTaskThread,
|
|
56
|
+
isBeadThreadAlreadyClosed: isTaskThreadAlreadyClosed,
|
|
57
|
+
updateBeadThreadName: updateTaskThreadName,
|
|
58
|
+
updateBeadStarterMessage: updateTaskStarterMessage,
|
|
59
|
+
updateBeadThreadTags: updateTaskThreadTags,
|
|
60
|
+
getThreadIdFromBead: getThreadIdFromTask,
|
|
61
|
+
findExistingThreadForBead: findExistingThreadForTask,
|
|
62
|
+
shortBeadId: shortTaskId,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
return discordSyncMock;
|
|
66
|
+
}
|
|
67
|
+
vi.mock('./discord-sync.js', makeDiscordSyncMock);
|
|
68
|
+
vi.mock('../tasks/discord-sync.js', makeDiscordSyncMock);
|
|
69
|
+
function makeStore(tasks = []) {
|
|
70
|
+
const byId = new Map(tasks.map((task) => [task.id, { ...task }]));
|
|
71
|
+
return {
|
|
72
|
+
list: vi.fn(() => [...byId.values()]),
|
|
73
|
+
get: vi.fn((id) => byId.get(id)),
|
|
74
|
+
update: vi.fn((id, params) => {
|
|
75
|
+
const existing = byId.get(id);
|
|
76
|
+
if (!existing)
|
|
77
|
+
return;
|
|
78
|
+
const updated = {
|
|
79
|
+
...existing,
|
|
80
|
+
...(params.status !== undefined ? { status: params.status } : {}),
|
|
81
|
+
...(params.externalRef !== undefined ? { external_ref: params.externalRef } : {}),
|
|
82
|
+
};
|
|
83
|
+
byId.set(id, updated);
|
|
84
|
+
return updated;
|
|
85
|
+
}),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
function makeClient() {
|
|
89
|
+
return { channels: { cache: { get: () => undefined } } };
|
|
90
|
+
}
|
|
91
|
+
function makeGuild() {
|
|
92
|
+
return {};
|
|
93
|
+
}
|
|
94
|
+
describe('runBeadSync', () => {
|
|
95
|
+
it('keeps compatibility export aligned to canonical task sync module', () => {
|
|
96
|
+
expect(runBeadSync).toBe(runTaskModuleSync);
|
|
97
|
+
});
|
|
98
|
+
beforeEach(() => vi.clearAllMocks());
|
|
99
|
+
it('skips no-thread beads in phase 1', async () => {
|
|
100
|
+
const { createBeadThread } = await import('./discord-sync.js');
|
|
101
|
+
const store = makeStore([
|
|
102
|
+
{ id: 'ws-001', title: 'A', status: 'open', labels: ['no-thread'], external_ref: '' },
|
|
103
|
+
]);
|
|
104
|
+
const result = await runBeadSync({
|
|
105
|
+
client: makeClient(),
|
|
106
|
+
guild: makeGuild(),
|
|
107
|
+
forumId: 'forum',
|
|
108
|
+
tagMap: {},
|
|
109
|
+
store,
|
|
110
|
+
throttleMs: 0,
|
|
111
|
+
});
|
|
112
|
+
expect(result.threadsCreated).toBe(0);
|
|
113
|
+
expect(createBeadThread).not.toHaveBeenCalled();
|
|
114
|
+
});
|
|
115
|
+
it('dedupes by backfilling external_ref when a matching thread exists', async () => {
|
|
116
|
+
const { createBeadThread, findExistingThreadForBead } = await import('./discord-sync.js');
|
|
117
|
+
const store = makeStore([
|
|
118
|
+
{ id: 'ws-002', title: 'B', status: 'open', labels: [], external_ref: '' },
|
|
119
|
+
]);
|
|
120
|
+
findExistingThreadForBead.mockResolvedValueOnce('thread-existing');
|
|
121
|
+
const result = await runBeadSync({
|
|
122
|
+
client: makeClient(),
|
|
123
|
+
guild: makeGuild(),
|
|
124
|
+
forumId: 'forum',
|
|
125
|
+
tagMap: {},
|
|
126
|
+
store,
|
|
127
|
+
throttleMs: 0,
|
|
128
|
+
});
|
|
129
|
+
expect(result.threadsCreated).toBe(0);
|
|
130
|
+
expect(createBeadThread).not.toHaveBeenCalled();
|
|
131
|
+
expect(store.update).toHaveBeenCalledWith('ws-002', { externalRef: 'discord:thread-existing' });
|
|
132
|
+
});
|
|
133
|
+
it('re-checks latest phase 1 task state after lock wait and skips create when already linked', async () => {
|
|
134
|
+
const { createBeadThread } = await import('./discord-sync.js');
|
|
135
|
+
const store = makeStore([
|
|
136
|
+
{ id: 'ws-014', title: 'N', status: 'open', labels: [], external_ref: '' },
|
|
137
|
+
]);
|
|
138
|
+
let applyUpdate;
|
|
139
|
+
const updateGate = new Promise((resolve) => {
|
|
140
|
+
applyUpdate = resolve;
|
|
141
|
+
});
|
|
142
|
+
let releaseOwner;
|
|
143
|
+
const ownerGate = new Promise((resolve) => {
|
|
144
|
+
releaseOwner = resolve;
|
|
145
|
+
});
|
|
146
|
+
const owner = withDirectTaskLifecycle('ws-014', async () => {
|
|
147
|
+
await updateGate;
|
|
148
|
+
store.update('ws-014', { externalRef: 'discord:thread-linked' });
|
|
149
|
+
await ownerGate;
|
|
150
|
+
});
|
|
151
|
+
const syncRun = runBeadSync({
|
|
152
|
+
client: makeClient(),
|
|
153
|
+
guild: makeGuild(),
|
|
154
|
+
forumId: 'forum',
|
|
155
|
+
tagMap: {},
|
|
156
|
+
store,
|
|
157
|
+
throttleMs: 0,
|
|
158
|
+
});
|
|
159
|
+
await Promise.resolve();
|
|
160
|
+
applyUpdate();
|
|
161
|
+
await Promise.resolve();
|
|
162
|
+
releaseOwner();
|
|
163
|
+
const result = await syncRun;
|
|
164
|
+
await owner;
|
|
165
|
+
expect(result.threadsCreated).toBe(0);
|
|
166
|
+
expect(createBeadThread).not.toHaveBeenCalled();
|
|
167
|
+
});
|
|
168
|
+
it('fixes open+blocked-label to blocked in phase 2', async () => {
|
|
169
|
+
const store = makeStore([
|
|
170
|
+
{ id: 'ws-003', title: 'C', status: 'open', labels: ['blocked-waiting-on'], external_ref: 'discord:1' },
|
|
171
|
+
]);
|
|
172
|
+
const result = await runBeadSync({
|
|
173
|
+
client: makeClient(),
|
|
174
|
+
guild: makeGuild(),
|
|
175
|
+
forumId: 'forum',
|
|
176
|
+
tagMap: {},
|
|
177
|
+
store,
|
|
178
|
+
throttleMs: 0,
|
|
179
|
+
});
|
|
180
|
+
expect(result.statusesUpdated).toBe(1);
|
|
181
|
+
expect(store.update).toHaveBeenCalledWith('ws-003', { status: 'blocked' });
|
|
182
|
+
});
|
|
183
|
+
it('phase 3 skips beads whose thread is already archived', async () => {
|
|
184
|
+
const { isThreadArchived, ensureUnarchived, updateBeadThreadName } = await import('./discord-sync.js');
|
|
185
|
+
const store = makeStore([
|
|
186
|
+
{ id: 'ws-030', title: 'Archived active', status: 'in_progress', labels: [], external_ref: 'discord:300' },
|
|
187
|
+
]);
|
|
188
|
+
isThreadArchived.mockResolvedValueOnce(true);
|
|
189
|
+
const result = await runBeadSync({
|
|
190
|
+
client: makeClient(),
|
|
191
|
+
guild: makeGuild(),
|
|
192
|
+
forumId: 'forum',
|
|
193
|
+
tagMap: {},
|
|
194
|
+
store,
|
|
195
|
+
throttleMs: 0,
|
|
196
|
+
});
|
|
197
|
+
expect(isThreadArchived).toHaveBeenCalledWith(expect.anything(), '300');
|
|
198
|
+
expect(ensureUnarchived).not.toHaveBeenCalled();
|
|
199
|
+
expect(updateBeadThreadName).not.toHaveBeenCalled();
|
|
200
|
+
expect(result.emojisUpdated).toBe(0);
|
|
201
|
+
});
|
|
202
|
+
it('phase 3 processes non-archived beads through the guard', async () => {
|
|
203
|
+
const { isThreadArchived, ensureUnarchived, updateBeadThreadName } = await import('./discord-sync.js');
|
|
204
|
+
const store = makeStore([
|
|
205
|
+
{ id: 'ws-031', title: 'Active bead', status: 'open', labels: [], external_ref: 'discord:301' },
|
|
206
|
+
]);
|
|
207
|
+
isThreadArchived.mockResolvedValueOnce(false);
|
|
208
|
+
updateBeadThreadName.mockResolvedValueOnce(true);
|
|
209
|
+
const result = await runBeadSync({
|
|
210
|
+
client: makeClient(),
|
|
211
|
+
guild: makeGuild(),
|
|
212
|
+
forumId: 'forum',
|
|
213
|
+
tagMap: {},
|
|
214
|
+
store,
|
|
215
|
+
throttleMs: 0,
|
|
216
|
+
});
|
|
217
|
+
expect(isThreadArchived).toHaveBeenCalledWith(expect.anything(), '301');
|
|
218
|
+
expect(ensureUnarchived).toHaveBeenCalledWith(expect.anything(), '301');
|
|
219
|
+
expect(updateBeadThreadName).toHaveBeenCalled();
|
|
220
|
+
expect(result.emojisUpdated).toBe(1);
|
|
221
|
+
});
|
|
222
|
+
it('renames threads for active beads in phase 3 and counts changes', async () => {
|
|
223
|
+
const { ensureUnarchived, updateBeadThreadName } = await import('./discord-sync.js');
|
|
224
|
+
const store = makeStore([
|
|
225
|
+
{ id: 'ws-004', title: 'D', status: 'in_progress', labels: [], external_ref: 'discord:123' },
|
|
226
|
+
]);
|
|
227
|
+
updateBeadThreadName.mockResolvedValueOnce(true);
|
|
228
|
+
const result = await runBeadSync({
|
|
229
|
+
client: makeClient(),
|
|
230
|
+
guild: makeGuild(),
|
|
231
|
+
forumId: 'forum',
|
|
232
|
+
tagMap: {},
|
|
233
|
+
store,
|
|
234
|
+
throttleMs: 0,
|
|
235
|
+
});
|
|
236
|
+
expect(ensureUnarchived).toHaveBeenCalledWith(expect.anything(), '123');
|
|
237
|
+
expect(updateBeadThreadName).toHaveBeenCalled();
|
|
238
|
+
expect(result.emojisUpdated).toBe(1);
|
|
239
|
+
});
|
|
240
|
+
it('calls updateBeadStarterMessage for active beads with threads in phase 3', async () => {
|
|
241
|
+
const { updateBeadStarterMessage } = await import('./discord-sync.js');
|
|
242
|
+
const store = makeStore([
|
|
243
|
+
{ id: 'ws-010', title: 'J', status: 'in_progress', labels: [], external_ref: 'discord:456' },
|
|
244
|
+
]);
|
|
245
|
+
updateBeadStarterMessage.mockResolvedValueOnce(true);
|
|
246
|
+
const result = await runBeadSync({
|
|
247
|
+
client: makeClient(),
|
|
248
|
+
guild: makeGuild(),
|
|
249
|
+
forumId: 'forum',
|
|
250
|
+
tagMap: {},
|
|
251
|
+
store,
|
|
252
|
+
throttleMs: 0,
|
|
253
|
+
});
|
|
254
|
+
expect(updateBeadStarterMessage).toHaveBeenCalledWith(expect.anything(), '456', expect.objectContaining({ id: 'ws-010' }), undefined);
|
|
255
|
+
expect(result.starterMessagesUpdated).toBe(1);
|
|
256
|
+
});
|
|
257
|
+
it('passes mentionUserId through to updateBeadStarterMessage in phase 3', async () => {
|
|
258
|
+
const { updateBeadStarterMessage } = await import('./discord-sync.js');
|
|
259
|
+
const store = makeStore([
|
|
260
|
+
{ id: 'ws-012', title: 'L', status: 'in_progress', labels: [], external_ref: 'discord:456' },
|
|
261
|
+
]);
|
|
262
|
+
updateBeadStarterMessage.mockResolvedValueOnce(true);
|
|
263
|
+
await runBeadSync({
|
|
264
|
+
client: makeClient(),
|
|
265
|
+
guild: makeGuild(),
|
|
266
|
+
forumId: 'forum',
|
|
267
|
+
tagMap: {},
|
|
268
|
+
store,
|
|
269
|
+
throttleMs: 0,
|
|
270
|
+
mentionUserId: '999',
|
|
271
|
+
});
|
|
272
|
+
expect(updateBeadStarterMessage).toHaveBeenCalledWith(expect.anything(), '456', expect.objectContaining({ id: 'ws-012' }), '999');
|
|
273
|
+
});
|
|
274
|
+
it('passes mentionUserId through to createBeadThread in phase 1', async () => {
|
|
275
|
+
const { createBeadThread } = await import('./discord-sync.js');
|
|
276
|
+
const store = makeStore([
|
|
277
|
+
{ id: 'ws-013', title: 'M', status: 'open', labels: [], external_ref: '' },
|
|
278
|
+
]);
|
|
279
|
+
await runBeadSync({
|
|
280
|
+
client: makeClient(),
|
|
281
|
+
guild: makeGuild(),
|
|
282
|
+
forumId: 'forum',
|
|
283
|
+
tagMap: {},
|
|
284
|
+
store,
|
|
285
|
+
throttleMs: 0,
|
|
286
|
+
mentionUserId: '999',
|
|
287
|
+
});
|
|
288
|
+
expect(createBeadThread).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ id: 'ws-013' }), {}, '999');
|
|
289
|
+
});
|
|
290
|
+
it('starterMessagesUpdated stays 0 when updateBeadStarterMessage returns false', async () => {
|
|
291
|
+
const { updateBeadStarterMessage } = await import('./discord-sync.js');
|
|
292
|
+
const store = makeStore([
|
|
293
|
+
{ id: 'ws-011', title: 'K', status: 'open', labels: [], external_ref: 'discord:789' },
|
|
294
|
+
]);
|
|
295
|
+
updateBeadStarterMessage.mockResolvedValueOnce(false);
|
|
296
|
+
const result = await runBeadSync({
|
|
297
|
+
client: makeClient(),
|
|
298
|
+
guild: makeGuild(),
|
|
299
|
+
forumId: 'forum',
|
|
300
|
+
tagMap: {},
|
|
301
|
+
store,
|
|
302
|
+
throttleMs: 0,
|
|
303
|
+
});
|
|
304
|
+
expect(result.starterMessagesUpdated).toBe(0);
|
|
305
|
+
});
|
|
306
|
+
it('archives threads for closed beads in phase 4', async () => {
|
|
307
|
+
const { closeBeadThread } = await import('./discord-sync.js');
|
|
308
|
+
const store = makeStore([
|
|
309
|
+
{ id: 'ws-005', title: 'E', status: 'closed', labels: [], external_ref: 'discord:999' },
|
|
310
|
+
]);
|
|
311
|
+
const result = await runBeadSync({
|
|
312
|
+
client: makeClient(),
|
|
313
|
+
guild: makeGuild(),
|
|
314
|
+
forumId: 'forum',
|
|
315
|
+
tagMap: {},
|
|
316
|
+
store,
|
|
317
|
+
throttleMs: 0,
|
|
318
|
+
});
|
|
319
|
+
expect(closeBeadThread).toHaveBeenCalled();
|
|
320
|
+
expect(result.threadsArchived).toBe(1);
|
|
321
|
+
});
|
|
322
|
+
it('skips fully-closed bead threads in phase 4', async () => {
|
|
323
|
+
const { closeBeadThread, isBeadThreadAlreadyClosed } = await import('./discord-sync.js');
|
|
324
|
+
const store = makeStore([
|
|
325
|
+
{ id: 'ws-006', title: 'F', status: 'closed', labels: [], external_ref: 'discord:888' },
|
|
326
|
+
]);
|
|
327
|
+
isBeadThreadAlreadyClosed.mockResolvedValueOnce(true);
|
|
328
|
+
const result = await runBeadSync({
|
|
329
|
+
client: makeClient(),
|
|
330
|
+
guild: makeGuild(),
|
|
331
|
+
forumId: 'forum',
|
|
332
|
+
tagMap: {},
|
|
333
|
+
store,
|
|
334
|
+
throttleMs: 0,
|
|
335
|
+
});
|
|
336
|
+
expect(isBeadThreadAlreadyClosed).toHaveBeenCalledWith(expect.anything(), '888', expect.objectContaining({ id: 'ws-006' }), {});
|
|
337
|
+
expect(closeBeadThread).not.toHaveBeenCalled();
|
|
338
|
+
expect(result.threadsArchived).toBe(0);
|
|
339
|
+
});
|
|
340
|
+
it('phase 4 uses isBeadThreadAlreadyClosed for full state check', async () => {
|
|
341
|
+
const { isBeadThreadAlreadyClosed, closeBeadThread } = await import('./discord-sync.js');
|
|
342
|
+
const store = makeStore([
|
|
343
|
+
{ id: 'ws-040', title: 'Closed bead', status: 'closed', labels: [], external_ref: 'discord:400' },
|
|
344
|
+
]);
|
|
345
|
+
isBeadThreadAlreadyClosed.mockResolvedValueOnce(false);
|
|
346
|
+
await runBeadSync({
|
|
347
|
+
client: makeClient(),
|
|
348
|
+
guild: makeGuild(),
|
|
349
|
+
forumId: 'forum',
|
|
350
|
+
tagMap: {},
|
|
351
|
+
store,
|
|
352
|
+
throttleMs: 0,
|
|
353
|
+
});
|
|
354
|
+
expect(isBeadThreadAlreadyClosed).toHaveBeenCalledWith(expect.anything(), '400', expect.objectContaining({ id: 'ws-040' }), {});
|
|
355
|
+
expect(closeBeadThread).toHaveBeenCalled();
|
|
356
|
+
});
|
|
357
|
+
it('phase 4 recovers archived thread with wrong name/tags', async () => {
|
|
358
|
+
const { isBeadThreadAlreadyClosed, closeBeadThread } = await import('./discord-sync.js');
|
|
359
|
+
const store = makeStore([
|
|
360
|
+
{ id: 'ws-050', title: 'Stale name', status: 'closed', labels: [], external_ref: 'discord:500' },
|
|
361
|
+
]);
|
|
362
|
+
// Thread is archived but has wrong name — isBeadThreadAlreadyClosed returns false
|
|
363
|
+
isBeadThreadAlreadyClosed.mockResolvedValueOnce(false);
|
|
364
|
+
const result = await runBeadSync({
|
|
365
|
+
client: makeClient(),
|
|
366
|
+
guild: makeGuild(),
|
|
367
|
+
forumId: 'forum',
|
|
368
|
+
tagMap: {},
|
|
369
|
+
store,
|
|
370
|
+
throttleMs: 0,
|
|
371
|
+
});
|
|
372
|
+
expect(isBeadThreadAlreadyClosed).toHaveBeenCalledWith(expect.anything(), '500', expect.objectContaining({ id: 'ws-050' }), {});
|
|
373
|
+
expect(closeBeadThread).toHaveBeenCalled();
|
|
374
|
+
expect(result.threadsArchived).toBe(1);
|
|
375
|
+
});
|
|
376
|
+
it('calls statusPoster.taskSyncComplete with the result when provided', async () => {
|
|
377
|
+
const store = makeStore([]);
|
|
378
|
+
const statusPoster = { taskSyncComplete: vi.fn(async () => { }) };
|
|
379
|
+
const result = await runBeadSync({
|
|
380
|
+
client: makeClient(),
|
|
381
|
+
guild: makeGuild(),
|
|
382
|
+
forumId: 'forum',
|
|
383
|
+
tagMap: {},
|
|
384
|
+
store,
|
|
385
|
+
throttleMs: 0,
|
|
386
|
+
statusPoster,
|
|
387
|
+
});
|
|
388
|
+
expect(statusPoster.taskSyncComplete).toHaveBeenCalledOnce();
|
|
389
|
+
expect(statusPoster.taskSyncComplete).toHaveBeenCalledWith(result);
|
|
390
|
+
});
|
|
391
|
+
it('works fine without statusPoster', async () => {
|
|
392
|
+
const store = makeStore([]);
|
|
393
|
+
const result = await runBeadSync({
|
|
394
|
+
client: makeClient(),
|
|
395
|
+
guild: makeGuild(),
|
|
396
|
+
forumId: 'forum',
|
|
397
|
+
tagMap: {},
|
|
398
|
+
store,
|
|
399
|
+
throttleMs: 0,
|
|
400
|
+
});
|
|
401
|
+
expect(result.warnings).toBe(0);
|
|
402
|
+
});
|
|
403
|
+
it('tagsUpdated counter increments when updateBeadThreadTags returns true', async () => {
|
|
404
|
+
const { updateBeadThreadTags } = await import('./discord-sync.js');
|
|
405
|
+
const store = makeStore([
|
|
406
|
+
{ id: 'ws-020', title: 'T', status: 'open', labels: [], external_ref: 'discord:777' },
|
|
407
|
+
]);
|
|
408
|
+
updateBeadThreadTags.mockResolvedValueOnce(true);
|
|
409
|
+
const result = await runBeadSync({
|
|
410
|
+
client: makeClient(),
|
|
411
|
+
guild: makeGuild(),
|
|
412
|
+
forumId: 'forum',
|
|
413
|
+
tagMap: { open: 's1' },
|
|
414
|
+
store,
|
|
415
|
+
throttleMs: 0,
|
|
416
|
+
});
|
|
417
|
+
expect(updateBeadThreadTags).toHaveBeenCalledWith(expect.anything(), '777', expect.objectContaining({ id: 'ws-020' }), { open: 's1' });
|
|
418
|
+
expect(result.tagsUpdated).toBe(1);
|
|
419
|
+
});
|
|
420
|
+
it('warnings increment when updateBeadThreadTags throws', async () => {
|
|
421
|
+
const { updateBeadThreadTags } = await import('./discord-sync.js');
|
|
422
|
+
const store = makeStore([
|
|
423
|
+
{ id: 'ws-021', title: 'U', status: 'open', labels: [], external_ref: 'discord:888' },
|
|
424
|
+
]);
|
|
425
|
+
updateBeadThreadTags.mockRejectedValueOnce(new Error('Discord API failure'));
|
|
426
|
+
const result = await runBeadSync({
|
|
427
|
+
client: makeClient(),
|
|
428
|
+
guild: makeGuild(),
|
|
429
|
+
forumId: 'forum',
|
|
430
|
+
tagMap: {},
|
|
431
|
+
store,
|
|
432
|
+
throttleMs: 0,
|
|
433
|
+
});
|
|
434
|
+
expect(result.warnings).toBeGreaterThanOrEqual(1);
|
|
435
|
+
});
|
|
436
|
+
it('increments warnings counter on phase failures', async () => {
|
|
437
|
+
const { updateBeadThreadName } = await import('./discord-sync.js');
|
|
438
|
+
const store = makeStore([
|
|
439
|
+
{ id: 'ws-008', title: 'H', status: 'in_progress', labels: [], external_ref: 'discord:555' },
|
|
440
|
+
]);
|
|
441
|
+
updateBeadThreadName.mockRejectedValueOnce(new Error('Discord API failure'));
|
|
442
|
+
const result = await runBeadSync({
|
|
443
|
+
client: makeClient(),
|
|
444
|
+
guild: makeGuild(),
|
|
445
|
+
forumId: 'forum',
|
|
446
|
+
tagMap: {},
|
|
447
|
+
store,
|
|
448
|
+
throttleMs: 0,
|
|
449
|
+
});
|
|
450
|
+
expect(result.warnings).toBe(1);
|
|
451
|
+
});
|
|
452
|
+
it('warnings counter increments when forum is not found', async () => {
|
|
453
|
+
const { resolveBeadsForum } = await import('./discord-sync.js');
|
|
454
|
+
resolveBeadsForum.mockResolvedValueOnce(null);
|
|
455
|
+
const result = await runBeadSync({
|
|
456
|
+
client: makeClient(),
|
|
457
|
+
guild: makeGuild(),
|
|
458
|
+
forumId: 'forum',
|
|
459
|
+
tagMap: {},
|
|
460
|
+
store: makeStore([]),
|
|
461
|
+
throttleMs: 0,
|
|
462
|
+
});
|
|
463
|
+
expect(result.warnings).toBe(1);
|
|
464
|
+
});
|
|
465
|
+
it('accepts skipPhase5 option without error and skips phase 5', async () => {
|
|
466
|
+
const store = makeStore([
|
|
467
|
+
{ id: 'ws-001', title: 'A', status: 'closed', labels: [], external_ref: '' },
|
|
468
|
+
]);
|
|
469
|
+
const result = await runBeadSync({
|
|
470
|
+
client: makeClient(),
|
|
471
|
+
guild: makeGuild(),
|
|
472
|
+
forumId: 'forum',
|
|
473
|
+
tagMap: {},
|
|
474
|
+
store,
|
|
475
|
+
throttleMs: 0,
|
|
476
|
+
skipPhase5: true,
|
|
477
|
+
});
|
|
478
|
+
expect(result.threadsReconciled).toBe(0);
|
|
479
|
+
expect(result.orphanThreadsFound).toBe(0);
|
|
480
|
+
});
|
|
481
|
+
it('phase 5 archives non-archived thread for closed bead and backfills external_ref', async () => {
|
|
482
|
+
const { resolveBeadsForum, closeBeadThread } = await import('./discord-sync.js');
|
|
483
|
+
const store = makeStore([
|
|
484
|
+
{ id: 'ws-001', title: 'Closed bead', status: 'closed', labels: [], external_ref: '' },
|
|
485
|
+
]);
|
|
486
|
+
const mockForum = {
|
|
487
|
+
threads: {
|
|
488
|
+
create: vi.fn(async () => ({ id: 'thread-new' })),
|
|
489
|
+
fetchActive: vi.fn(async () => ({
|
|
490
|
+
threads: new Map([
|
|
491
|
+
['thread-100', { id: 'thread-100', name: '\u{1F7E2} [001] Closed bead', archived: false }],
|
|
492
|
+
]),
|
|
493
|
+
})),
|
|
494
|
+
fetchArchived: vi.fn(async () => ({ threads: new Map() })),
|
|
495
|
+
},
|
|
496
|
+
};
|
|
497
|
+
resolveBeadsForum.mockResolvedValueOnce(mockForum);
|
|
498
|
+
const result = await runBeadSync({
|
|
499
|
+
client: makeClient(),
|
|
500
|
+
guild: makeGuild(),
|
|
501
|
+
forumId: 'forum',
|
|
502
|
+
tagMap: {},
|
|
503
|
+
store,
|
|
504
|
+
throttleMs: 0,
|
|
505
|
+
});
|
|
506
|
+
expect(result.threadsReconciled).toBe(1);
|
|
507
|
+
expect(store.update).toHaveBeenCalledWith('ws-001', { externalRef: 'discord:thread-100' });
|
|
508
|
+
expect(closeBeadThread).toHaveBeenCalledWith(expect.anything(), 'thread-100', expect.objectContaining({ id: 'ws-001' }), {}, undefined);
|
|
509
|
+
});
|
|
510
|
+
it('phase 5 detects orphan threads with no matching bead', async () => {
|
|
511
|
+
const { resolveBeadsForum } = await import('./discord-sync.js');
|
|
512
|
+
const store = makeStore([]);
|
|
513
|
+
const mockForum = {
|
|
514
|
+
threads: {
|
|
515
|
+
create: vi.fn(async () => ({ id: 'thread-new' })),
|
|
516
|
+
fetchActive: vi.fn(async () => ({
|
|
517
|
+
threads: new Map([
|
|
518
|
+
['thread-200', { id: 'thread-200', name: '\u{1F7E2} [999] Unknown bead', archived: false }],
|
|
519
|
+
]),
|
|
520
|
+
})),
|
|
521
|
+
fetchArchived: vi.fn(async () => ({ threads: new Map() })),
|
|
522
|
+
},
|
|
523
|
+
};
|
|
524
|
+
resolveBeadsForum.mockResolvedValueOnce(mockForum);
|
|
525
|
+
const result = await runBeadSync({
|
|
526
|
+
client: makeClient(),
|
|
527
|
+
guild: makeGuild(),
|
|
528
|
+
forumId: 'forum',
|
|
529
|
+
tagMap: {},
|
|
530
|
+
store,
|
|
531
|
+
throttleMs: 0,
|
|
532
|
+
});
|
|
533
|
+
expect(result.orphanThreadsFound).toBe(1);
|
|
534
|
+
expect(result.threadsReconciled).toBe(0);
|
|
535
|
+
});
|
|
536
|
+
it('phase 5 skips threads with short-id collision (multiple beads)', async () => {
|
|
537
|
+
const { resolveBeadsForum, closeBeadThread } = await import('./discord-sync.js');
|
|
538
|
+
const store = makeStore([
|
|
539
|
+
{ id: 'ws-001', title: 'First', status: 'closed', labels: [], external_ref: '' },
|
|
540
|
+
{ id: 'other-001', title: 'Second', status: 'open', labels: [], external_ref: '' },
|
|
541
|
+
]);
|
|
542
|
+
const mockForum = {
|
|
543
|
+
threads: {
|
|
544
|
+
create: vi.fn(async () => ({ id: 'thread-new' })),
|
|
545
|
+
fetchActive: vi.fn(async () => ({
|
|
546
|
+
threads: new Map([
|
|
547
|
+
['thread-300', { id: 'thread-300', name: '\u{1F7E2} [001] First', archived: false }],
|
|
548
|
+
]),
|
|
549
|
+
})),
|
|
550
|
+
fetchArchived: vi.fn(async () => ({ threads: new Map() })),
|
|
551
|
+
},
|
|
552
|
+
};
|
|
553
|
+
resolveBeadsForum.mockResolvedValueOnce(mockForum);
|
|
554
|
+
const result = await runBeadSync({
|
|
555
|
+
client: makeClient(),
|
|
556
|
+
guild: makeGuild(),
|
|
557
|
+
forumId: 'forum',
|
|
558
|
+
tagMap: {},
|
|
559
|
+
store,
|
|
560
|
+
throttleMs: 0,
|
|
561
|
+
});
|
|
562
|
+
// Collision: two beads with short ID "001" — should skip, not archive or count as orphan
|
|
563
|
+
expect(result.threadsReconciled).toBe(0);
|
|
564
|
+
expect(result.orphanThreadsFound).toBe(0);
|
|
565
|
+
// closeBeadThread should not be called from phase 5 (may be called from phase 4)
|
|
566
|
+
});
|
|
567
|
+
it('phase 5 skips thread when bead external_ref points to a different thread', async () => {
|
|
568
|
+
const { resolveBeadsForum, closeBeadThread, isBeadThreadAlreadyClosed } = await import('./discord-sync.js');
|
|
569
|
+
const store = makeStore([
|
|
570
|
+
{ id: 'ws-001', title: 'Closed bead', status: 'closed', labels: [], external_ref: 'discord:thread-OTHER' },
|
|
571
|
+
]);
|
|
572
|
+
// Phase 4 will try to archive thread-OTHER — let it skip via already-closed check.
|
|
573
|
+
isBeadThreadAlreadyClosed.mockResolvedValueOnce(true);
|
|
574
|
+
const mockForum = {
|
|
575
|
+
threads: {
|
|
576
|
+
create: vi.fn(async () => ({ id: 'thread-new' })),
|
|
577
|
+
fetchActive: vi.fn(async () => ({
|
|
578
|
+
threads: new Map([
|
|
579
|
+
['thread-100', { id: 'thread-100', name: '\u{1F7E2} [001] Closed bead', archived: false }],
|
|
580
|
+
]),
|
|
581
|
+
})),
|
|
582
|
+
fetchArchived: vi.fn(async () => ({ threads: new Map() })),
|
|
583
|
+
},
|
|
584
|
+
};
|
|
585
|
+
resolveBeadsForum.mockResolvedValueOnce(mockForum);
|
|
586
|
+
const result = await runBeadSync({
|
|
587
|
+
client: makeClient(),
|
|
588
|
+
guild: makeGuild(),
|
|
589
|
+
forumId: 'forum',
|
|
590
|
+
tagMap: {},
|
|
591
|
+
store,
|
|
592
|
+
throttleMs: 0,
|
|
593
|
+
});
|
|
594
|
+
// Thread should be skipped by Phase 5 — external_ref points elsewhere.
|
|
595
|
+
expect(result.threadsReconciled).toBe(0);
|
|
596
|
+
// closeBeadThread should not have been called for thread-100 (Phase 5 skipped it).
|
|
597
|
+
expect(closeBeadThread).not.toHaveBeenCalledWith(expect.anything(), 'thread-100', expect.anything(), expect.anything(), expect.anything());
|
|
598
|
+
});
|
|
599
|
+
it('phase 5 archives thread when bead external_ref matches this thread', async () => {
|
|
600
|
+
const { resolveBeadsForum, closeBeadThread } = await import('./discord-sync.js');
|
|
601
|
+
const store = makeStore([
|
|
602
|
+
{ id: 'ws-001', title: 'Closed bead', status: 'closed', labels: [], external_ref: 'discord:thread-100' },
|
|
603
|
+
]);
|
|
604
|
+
const mockForum = {
|
|
605
|
+
threads: {
|
|
606
|
+
create: vi.fn(async () => ({ id: 'thread-new' })),
|
|
607
|
+
fetchActive: vi.fn(async () => ({
|
|
608
|
+
threads: new Map([
|
|
609
|
+
['thread-100', { id: 'thread-100', name: '\u{1F7E2} [001] Closed bead', archived: false }],
|
|
610
|
+
]),
|
|
611
|
+
})),
|
|
612
|
+
fetchArchived: vi.fn(async () => ({ threads: new Map() })),
|
|
613
|
+
},
|
|
614
|
+
};
|
|
615
|
+
resolveBeadsForum.mockResolvedValueOnce(mockForum);
|
|
616
|
+
const result = await runBeadSync({
|
|
617
|
+
client: makeClient(),
|
|
618
|
+
guild: makeGuild(),
|
|
619
|
+
forumId: 'forum',
|
|
620
|
+
tagMap: {},
|
|
621
|
+
store,
|
|
622
|
+
throttleMs: 0,
|
|
623
|
+
});
|
|
624
|
+
expect(result.threadsReconciled).toBe(1);
|
|
625
|
+
// No backfill needed — external_ref already set.
|
|
626
|
+
expect(store.update).not.toHaveBeenCalledWith('ws-001', { externalRef: expect.anything() });
|
|
627
|
+
expect(closeBeadThread).toHaveBeenCalledWith(expect.anything(), 'thread-100', expect.objectContaining({ id: 'ws-001' }), {}, undefined);
|
|
628
|
+
});
|
|
629
|
+
it('phase 5 still archives thread when external_ref backfill fails', async () => {
|
|
630
|
+
const { resolveBeadsForum, closeBeadThread } = await import('./discord-sync.js');
|
|
631
|
+
const store = makeStore([
|
|
632
|
+
{ id: 'ws-001', title: 'Closed bead', status: 'closed', labels: [], external_ref: '' },
|
|
633
|
+
]);
|
|
634
|
+
store.update.mockImplementationOnce(() => { throw new Error('store failure'); });
|
|
635
|
+
const mockForum = {
|
|
636
|
+
threads: {
|
|
637
|
+
create: vi.fn(async () => ({ id: 'thread-new' })),
|
|
638
|
+
fetchActive: vi.fn(async () => ({
|
|
639
|
+
threads: new Map([
|
|
640
|
+
['thread-100', { id: 'thread-100', name: '\u{1F7E2} [001] Closed bead', archived: false }],
|
|
641
|
+
]),
|
|
642
|
+
})),
|
|
643
|
+
fetchArchived: vi.fn(async () => ({ threads: new Map() })),
|
|
644
|
+
},
|
|
645
|
+
};
|
|
646
|
+
resolveBeadsForum.mockResolvedValueOnce(mockForum);
|
|
647
|
+
const result = await runBeadSync({
|
|
648
|
+
client: makeClient(),
|
|
649
|
+
guild: makeGuild(),
|
|
650
|
+
forumId: 'forum',
|
|
651
|
+
tagMap: {},
|
|
652
|
+
store,
|
|
653
|
+
throttleMs: 0,
|
|
654
|
+
});
|
|
655
|
+
// Backfill failed but archive should still proceed.
|
|
656
|
+
expect(result.warnings).toBeGreaterThanOrEqual(1);
|
|
657
|
+
expect(result.threadsReconciled).toBe(1);
|
|
658
|
+
expect(closeBeadThread).toHaveBeenCalledWith(expect.anything(), 'thread-100', expect.objectContaining({ id: 'ws-001' }), {}, undefined);
|
|
659
|
+
});
|
|
660
|
+
it('phase 5 skips already-archived thread for closed bead when fully reconciled', async () => {
|
|
661
|
+
const { resolveBeadsForum, closeBeadThread, isBeadThreadAlreadyClosed } = await import('./discord-sync.js');
|
|
662
|
+
const store = makeStore([
|
|
663
|
+
{ id: 'ws-001', title: 'Closed bead', status: 'closed', labels: [], external_ref: 'discord:thread-100' },
|
|
664
|
+
]);
|
|
665
|
+
// Phase 4 checks isBeadThreadAlreadyClosed → true (skip).
|
|
666
|
+
// Phase 5 also checks isBeadThreadAlreadyClosed for the archived thread → true (skip).
|
|
667
|
+
isBeadThreadAlreadyClosed.mockResolvedValueOnce(true).mockResolvedValueOnce(true);
|
|
668
|
+
const mockForum = {
|
|
669
|
+
threads: {
|
|
670
|
+
create: vi.fn(async () => ({ id: 'thread-new' })),
|
|
671
|
+
fetchActive: vi.fn(async () => ({ threads: new Map() })),
|
|
672
|
+
fetchArchived: vi.fn(async () => ({
|
|
673
|
+
threads: new Map([
|
|
674
|
+
['thread-100', { id: 'thread-100', name: '\u2705 [001] Closed bead', archived: true }],
|
|
675
|
+
]),
|
|
676
|
+
})),
|
|
677
|
+
},
|
|
678
|
+
};
|
|
679
|
+
resolveBeadsForum.mockResolvedValueOnce(mockForum);
|
|
680
|
+
const result = await runBeadSync({
|
|
681
|
+
client: makeClient(),
|
|
682
|
+
guild: makeGuild(),
|
|
683
|
+
forumId: 'forum',
|
|
684
|
+
tagMap: {},
|
|
685
|
+
store,
|
|
686
|
+
throttleMs: 0,
|
|
687
|
+
});
|
|
688
|
+
// Thread is already fully reconciled — no work from Phase 5.
|
|
689
|
+
expect(result.threadsReconciled).toBe(0);
|
|
690
|
+
// closeBeadThread should not be called (Phase 4 skipped, Phase 5 skipped via isBeadThreadAlreadyClosed).
|
|
691
|
+
expect(closeBeadThread).not.toHaveBeenCalled();
|
|
692
|
+
});
|
|
693
|
+
it('phase 5 reconciles stale archived thread for closed bead via unarchive→edit→re-archive', async () => {
|
|
694
|
+
const { resolveBeadsForum, closeBeadThread, isBeadThreadAlreadyClosed } = await import('./discord-sync.js');
|
|
695
|
+
const store = makeStore([
|
|
696
|
+
{ id: 'ws-001', title: 'Closed bead', status: 'closed', labels: [], external_ref: 'discord:thread-100' },
|
|
697
|
+
]);
|
|
698
|
+
// Phase 4 checks isBeadThreadAlreadyClosed → true (skip Phase 4 archive).
|
|
699
|
+
// Phase 5 checks isBeadThreadAlreadyClosed → false (thread is stale, needs reconcile).
|
|
700
|
+
isBeadThreadAlreadyClosed.mockResolvedValueOnce(true).mockResolvedValueOnce(false);
|
|
701
|
+
const mockForum = {
|
|
702
|
+
threads: {
|
|
703
|
+
create: vi.fn(async () => ({ id: 'thread-new' })),
|
|
704
|
+
fetchActive: vi.fn(async () => ({ threads: new Map() })),
|
|
705
|
+
fetchArchived: vi.fn(async () => ({
|
|
706
|
+
threads: new Map([
|
|
707
|
+
['thread-100', { id: 'thread-100', name: '\u{1F7E0} [001] Old stale name', archived: true }],
|
|
708
|
+
]),
|
|
709
|
+
})),
|
|
710
|
+
},
|
|
711
|
+
};
|
|
712
|
+
resolveBeadsForum.mockResolvedValueOnce(mockForum);
|
|
713
|
+
const result = await runBeadSync({
|
|
714
|
+
client: makeClient(),
|
|
715
|
+
guild: makeGuild(),
|
|
716
|
+
forumId: 'forum',
|
|
717
|
+
tagMap: {},
|
|
718
|
+
store,
|
|
719
|
+
throttleMs: 0,
|
|
720
|
+
});
|
|
721
|
+
// Phase 5 should have reconciled the stale archived thread.
|
|
722
|
+
expect(result.threadsReconciled).toBe(1);
|
|
723
|
+
expect(isBeadThreadAlreadyClosed).toHaveBeenCalledWith(expect.anything(), 'thread-100', expect.objectContaining({ id: 'ws-001' }), {});
|
|
724
|
+
expect(closeBeadThread).toHaveBeenCalledWith(expect.anything(), 'thread-100', expect.objectContaining({ id: 'ws-001' }), {}, undefined);
|
|
725
|
+
});
|
|
726
|
+
it('phase 5 no-ops gracefully when forum has 0 threads', async () => {
|
|
727
|
+
const { resolveBeadsForum } = await import('./discord-sync.js');
|
|
728
|
+
const store = makeStore([
|
|
729
|
+
{ id: 'ws-001', title: 'Some bead', status: 'open', labels: [], external_ref: '' },
|
|
730
|
+
]);
|
|
731
|
+
const mockForum = {
|
|
732
|
+
threads: {
|
|
733
|
+
create: vi.fn(async () => ({ id: 'thread-new' })),
|
|
734
|
+
fetchActive: vi.fn(async () => ({ threads: new Map() })),
|
|
735
|
+
fetchArchived: vi.fn(async () => ({ threads: new Map() })),
|
|
736
|
+
},
|
|
737
|
+
};
|
|
738
|
+
resolveBeadsForum.mockResolvedValueOnce(mockForum);
|
|
739
|
+
const result = await runBeadSync({
|
|
740
|
+
client: makeClient(),
|
|
741
|
+
guild: makeGuild(),
|
|
742
|
+
forumId: 'forum',
|
|
743
|
+
tagMap: {},
|
|
744
|
+
store,
|
|
745
|
+
throttleMs: 0,
|
|
746
|
+
});
|
|
747
|
+
expect(result.threadsReconciled).toBe(0);
|
|
748
|
+
expect(result.orphanThreadsFound).toBe(0);
|
|
749
|
+
});
|
|
750
|
+
it('phase 5 handles fetchActive API error gracefully', async () => {
|
|
751
|
+
const { resolveBeadsForum } = await import('./discord-sync.js');
|
|
752
|
+
const store = makeStore([]);
|
|
753
|
+
const mockForum = {
|
|
754
|
+
threads: {
|
|
755
|
+
create: vi.fn(async () => ({ id: 'thread-new' })),
|
|
756
|
+
fetchActive: vi.fn(async () => { throw new Error('Discord API failure'); }),
|
|
757
|
+
fetchArchived: vi.fn(async () => ({ threads: new Map() })),
|
|
758
|
+
},
|
|
759
|
+
};
|
|
760
|
+
resolveBeadsForum.mockResolvedValueOnce(mockForum);
|
|
761
|
+
const result = await runBeadSync({
|
|
762
|
+
client: makeClient(),
|
|
763
|
+
guild: makeGuild(),
|
|
764
|
+
forumId: 'forum',
|
|
765
|
+
tagMap: {},
|
|
766
|
+
store,
|
|
767
|
+
throttleMs: 0,
|
|
768
|
+
});
|
|
769
|
+
expect(result.warnings).toBeGreaterThanOrEqual(1);
|
|
770
|
+
expect(result.threadsReconciled).toBe(0);
|
|
771
|
+
expect(result.orphanThreadsFound).toBe(0);
|
|
772
|
+
});
|
|
773
|
+
it('calls statusPoster.taskSyncComplete in forum-not-found early return', async () => {
|
|
774
|
+
const { resolveBeadsForum } = await import('./discord-sync.js');
|
|
775
|
+
resolveBeadsForum.mockResolvedValueOnce(null);
|
|
776
|
+
const statusPoster = { taskSyncComplete: vi.fn(async () => { }) };
|
|
777
|
+
const result = await runBeadSync({
|
|
778
|
+
client: makeClient(),
|
|
779
|
+
guild: makeGuild(),
|
|
780
|
+
forumId: 'forum',
|
|
781
|
+
tagMap: {},
|
|
782
|
+
store: makeStore([]),
|
|
783
|
+
throttleMs: 0,
|
|
784
|
+
statusPoster,
|
|
785
|
+
});
|
|
786
|
+
expect(statusPoster.taskSyncComplete).toHaveBeenCalledOnce();
|
|
787
|
+
expect(statusPoster.taskSyncComplete).toHaveBeenCalledWith(result);
|
|
788
|
+
expect(result.warnings).toBe(1);
|
|
789
|
+
});
|
|
790
|
+
it('phase 4 defers close when in-flight reply is active for that thread', async () => {
|
|
791
|
+
const { closeBeadThread } = await import('./discord-sync.js');
|
|
792
|
+
const { hasInFlightForChannel } = await import('../discord/inflight-replies.js');
|
|
793
|
+
const store = makeStore([
|
|
794
|
+
{ id: 'ws-005', title: 'E', status: 'closed', labels: [], external_ref: 'discord:999' },
|
|
795
|
+
]);
|
|
796
|
+
hasInFlightForChannel.mockReturnValueOnce(true);
|
|
797
|
+
const result = await runBeadSync({
|
|
798
|
+
client: makeClient(),
|
|
799
|
+
guild: makeGuild(),
|
|
800
|
+
forumId: 'forum',
|
|
801
|
+
tagMap: {},
|
|
802
|
+
store,
|
|
803
|
+
throttleMs: 0,
|
|
804
|
+
});
|
|
805
|
+
expect(closeBeadThread).not.toHaveBeenCalled();
|
|
806
|
+
expect(result.threadsArchived).toBe(0);
|
|
807
|
+
expect(result.closesDeferred).toBe(1);
|
|
808
|
+
});
|
|
809
|
+
it('phase 5 defers close when in-flight reply is active for non-archived thread', async () => {
|
|
810
|
+
const { resolveBeadsForum, closeBeadThread } = await import('./discord-sync.js');
|
|
811
|
+
const { hasInFlightForChannel } = await import('../discord/inflight-replies.js');
|
|
812
|
+
const store = makeStore([
|
|
813
|
+
{ id: 'ws-001', title: 'Closed bead', status: 'closed', labels: [], external_ref: '' },
|
|
814
|
+
]);
|
|
815
|
+
// Phase 4 sees no thread (no external_ref), so hasInFlightForChannel is not called there.
|
|
816
|
+
// Phase 5 finds the thread and checks in-flight.
|
|
817
|
+
hasInFlightForChannel.mockReturnValue(true);
|
|
818
|
+
const mockForum = {
|
|
819
|
+
threads: {
|
|
820
|
+
create: vi.fn(async () => ({ id: 'thread-new' })),
|
|
821
|
+
fetchActive: vi.fn(async () => ({
|
|
822
|
+
threads: new Map([
|
|
823
|
+
['thread-100', { id: 'thread-100', name: '\u{1F7E2} [001] Closed bead', archived: false }],
|
|
824
|
+
]),
|
|
825
|
+
})),
|
|
826
|
+
fetchArchived: vi.fn(async () => ({ threads: new Map() })),
|
|
827
|
+
},
|
|
828
|
+
};
|
|
829
|
+
resolveBeadsForum.mockResolvedValueOnce(mockForum);
|
|
830
|
+
const result = await runBeadSync({
|
|
831
|
+
client: makeClient(),
|
|
832
|
+
guild: makeGuild(),
|
|
833
|
+
forumId: 'forum',
|
|
834
|
+
tagMap: {},
|
|
835
|
+
store,
|
|
836
|
+
throttleMs: 0,
|
|
837
|
+
});
|
|
838
|
+
expect(closeBeadThread).not.toHaveBeenCalled();
|
|
839
|
+
expect(result.threadsReconciled).toBe(0);
|
|
840
|
+
expect(result.closesDeferred).toBeGreaterThanOrEqual(1);
|
|
841
|
+
hasInFlightForChannel.mockReturnValue(false);
|
|
842
|
+
});
|
|
843
|
+
it('phase 5 defers close when in-flight reply is active for archived stale thread', async () => {
|
|
844
|
+
const { resolveBeadsForum, closeBeadThread, isBeadThreadAlreadyClosed } = await import('./discord-sync.js');
|
|
845
|
+
const { hasInFlightForChannel } = await import('../discord/inflight-replies.js');
|
|
846
|
+
const store = makeStore([
|
|
847
|
+
{ id: 'ws-001', title: 'Closed bead', status: 'closed', labels: [], external_ref: 'discord:thread-100' },
|
|
848
|
+
]);
|
|
849
|
+
// Phase 4: already closed → skip (no hasInFlightForChannel call). Phase 5: stale → in-flight → defer.
|
|
850
|
+
isBeadThreadAlreadyClosed.mockResolvedValueOnce(true).mockResolvedValueOnce(false);
|
|
851
|
+
hasInFlightForChannel.mockReturnValueOnce(true);
|
|
852
|
+
const mockForum = {
|
|
853
|
+
threads: {
|
|
854
|
+
create: vi.fn(async () => ({ id: 'thread-new' })),
|
|
855
|
+
fetchActive: vi.fn(async () => ({ threads: new Map() })),
|
|
856
|
+
fetchArchived: vi.fn(async () => ({
|
|
857
|
+
threads: new Map([
|
|
858
|
+
['thread-100', { id: 'thread-100', name: '\u{1F7E0} [001] Old stale name', archived: true }],
|
|
859
|
+
]),
|
|
860
|
+
})),
|
|
861
|
+
},
|
|
862
|
+
};
|
|
863
|
+
resolveBeadsForum.mockResolvedValueOnce(mockForum);
|
|
864
|
+
const result = await runBeadSync({
|
|
865
|
+
client: makeClient(),
|
|
866
|
+
guild: makeGuild(),
|
|
867
|
+
forumId: 'forum',
|
|
868
|
+
tagMap: {},
|
|
869
|
+
store,
|
|
870
|
+
throttleMs: 0,
|
|
871
|
+
});
|
|
872
|
+
expect(closeBeadThread).not.toHaveBeenCalled();
|
|
873
|
+
expect(result.threadsReconciled).toBe(0);
|
|
874
|
+
expect(result.closesDeferred).toBe(1);
|
|
875
|
+
});
|
|
876
|
+
});
|