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,724 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { ChannelType } from 'discord.js';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import { ensureSystemScaffold, ensureForumTags } from './system-bootstrap.js';
|
|
7
|
+
function makeMockGuild(channels) {
|
|
8
|
+
const cache = new Map();
|
|
9
|
+
for (const ch of channels) {
|
|
10
|
+
cache.set(ch.id, {
|
|
11
|
+
id: ch.id,
|
|
12
|
+
name: ch.name,
|
|
13
|
+
type: ch.type,
|
|
14
|
+
parentId: ch.parentId ?? null,
|
|
15
|
+
setParent: vi.fn(async function (pid) { this.parentId = pid; }),
|
|
16
|
+
edit: vi.fn(async function (opts) { if ('parent' in opts)
|
|
17
|
+
this.parentId = opts.parent; if ('name' in opts)
|
|
18
|
+
this.name = opts.name; }),
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
let seq = 0;
|
|
22
|
+
const create = vi.fn(async (opts) => {
|
|
23
|
+
const id = `new-${++seq}`;
|
|
24
|
+
const ch = {
|
|
25
|
+
id,
|
|
26
|
+
name: opts.name,
|
|
27
|
+
type: opts.type,
|
|
28
|
+
parentId: opts.parent ?? null,
|
|
29
|
+
availableTags: [],
|
|
30
|
+
setParent: vi.fn(async function (pid) { this.parentId = pid; }),
|
|
31
|
+
edit: vi.fn(async function (o) {
|
|
32
|
+
if ('parent' in o)
|
|
33
|
+
this.parentId = o.parent;
|
|
34
|
+
if ('name' in o)
|
|
35
|
+
this.name = o.name;
|
|
36
|
+
if ('availableTags' in o) {
|
|
37
|
+
this.availableTags = o.availableTags.map((t, i) => ({
|
|
38
|
+
...t,
|
|
39
|
+
id: t.id ?? `tag-${this.id}-${i}`,
|
|
40
|
+
name: t.name,
|
|
41
|
+
moderated: t.moderated ?? false,
|
|
42
|
+
emoji: t.emoji ?? null,
|
|
43
|
+
}));
|
|
44
|
+
}
|
|
45
|
+
}),
|
|
46
|
+
};
|
|
47
|
+
cache.set(id, ch);
|
|
48
|
+
return ch;
|
|
49
|
+
});
|
|
50
|
+
return {
|
|
51
|
+
id: 'guild-1',
|
|
52
|
+
channels: {
|
|
53
|
+
cache: {
|
|
54
|
+
find: (fn) => {
|
|
55
|
+
for (const ch of cache.values()) {
|
|
56
|
+
if (fn(ch))
|
|
57
|
+
return ch;
|
|
58
|
+
}
|
|
59
|
+
return undefined;
|
|
60
|
+
},
|
|
61
|
+
values: () => cache.values(),
|
|
62
|
+
get: (id) => cache.get(id),
|
|
63
|
+
},
|
|
64
|
+
create,
|
|
65
|
+
fetch: vi.fn(async (id) => cache.get(id) ?? null),
|
|
66
|
+
},
|
|
67
|
+
__cache: cache,
|
|
68
|
+
__create: create,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
describe('ensureSystemScaffold', () => {
|
|
72
|
+
it('creates System category, status text channel, and automations forum', async () => {
|
|
73
|
+
const guild = makeMockGuild([]);
|
|
74
|
+
const res = await ensureSystemScaffold({ guild, ensureTasks: false });
|
|
75
|
+
expect(res).not.toBeNull();
|
|
76
|
+
expect(res?.systemCategoryId).toBeTruthy();
|
|
77
|
+
expect(res?.statusChannelId).toBeTruthy();
|
|
78
|
+
expect(res?.cronsForumId).toBeTruthy();
|
|
79
|
+
expect(res?.tasksForumId).toBeUndefined();
|
|
80
|
+
// 3 creates: category + status + automations
|
|
81
|
+
expect(guild.__create).toHaveBeenCalledTimes(3);
|
|
82
|
+
});
|
|
83
|
+
it('moves existing channels/forums under System', async () => {
|
|
84
|
+
const guild = makeMockGuild([
|
|
85
|
+
{ id: 'cat-other', name: 'Other', type: ChannelType.GuildCategory },
|
|
86
|
+
{ id: 'status-1', name: 'status', type: ChannelType.GuildText, parentId: 'cat-other' },
|
|
87
|
+
{ id: 'crons-1', name: 'automations', type: ChannelType.GuildForum, parentId: null },
|
|
88
|
+
]);
|
|
89
|
+
const res = await ensureSystemScaffold({ guild, ensureTasks: false });
|
|
90
|
+
expect(res?.systemCategoryId).toBeTruthy();
|
|
91
|
+
expect(res?.statusChannelId).toBe('status-1');
|
|
92
|
+
expect(res?.cronsForumId).toBe('crons-1');
|
|
93
|
+
const statusCh = guild.__cache.get('status-1');
|
|
94
|
+
const cronsCh = guild.__cache.get('crons-1');
|
|
95
|
+
expect(statusCh.parentId).toBe(res?.systemCategoryId);
|
|
96
|
+
expect(cronsCh.parentId).toBe(res?.systemCategoryId);
|
|
97
|
+
});
|
|
98
|
+
it('creates beads forum only when ensureTasks is true', async () => {
|
|
99
|
+
const guild = makeMockGuild([]);
|
|
100
|
+
const res = await ensureSystemScaffold({ guild, ensureTasks: true });
|
|
101
|
+
expect(res?.tasksForumId).toBeTruthy();
|
|
102
|
+
// 4 creates: category + status + automations + beads
|
|
103
|
+
expect(guild.__create).toHaveBeenCalledTimes(4);
|
|
104
|
+
});
|
|
105
|
+
it('bootstraps bead status tags when tasksTagMapPath is provided', async () => {
|
|
106
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'bead-tags-'));
|
|
107
|
+
try {
|
|
108
|
+
const tagMapPath = path.join(tmpDir, 'tag-map.json');
|
|
109
|
+
await fs.writeFile(tagMapPath, '{"open": "", "in_progress": "", "blocked": "", "closed": ""}', 'utf8');
|
|
110
|
+
const guild = makeMockGuild([]);
|
|
111
|
+
const res = await ensureSystemScaffold({ guild, ensureTasks: true, tasksTagMapPath: tagMapPath });
|
|
112
|
+
expect(res?.tasksForumId).toBeTruthy();
|
|
113
|
+
// The beads forum should have had edit() called with availableTags.
|
|
114
|
+
const beadsForum = guild.__cache.get(res.tasksForumId);
|
|
115
|
+
expect(beadsForum).toBeDefined();
|
|
116
|
+
expect(beadsForum.edit).toHaveBeenCalledWith(expect.objectContaining({ availableTags: expect.any(Array) }));
|
|
117
|
+
// The tag map file should have been updated with IDs.
|
|
118
|
+
const updatedRaw = await fs.readFile(tagMapPath, 'utf8');
|
|
119
|
+
const updatedMap = JSON.parse(updatedRaw);
|
|
120
|
+
expect(updatedMap.open).toBeTruthy();
|
|
121
|
+
expect(updatedMap.in_progress).toBeTruthy();
|
|
122
|
+
expect(updatedMap.blocked).toBeTruthy();
|
|
123
|
+
expect(updatedMap.closed).toBeTruthy();
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
it('skips bead tag bootstrap when tasksTagMapPath is not provided', async () => {
|
|
130
|
+
const guild = makeMockGuild([]);
|
|
131
|
+
const res = await ensureSystemScaffold({ guild, ensureTasks: true });
|
|
132
|
+
expect(res?.tasksForumId).toBeTruthy();
|
|
133
|
+
// The beads forum edit should NOT have been called with availableTags
|
|
134
|
+
// (only the create call happens, no subsequent tag bootstrap).
|
|
135
|
+
const beadsForum = guild.__cache.get(res.tasksForumId);
|
|
136
|
+
// edit is called 0 times since there's no tag map path to bootstrap from.
|
|
137
|
+
expect(beadsForum.edit).not.toHaveBeenCalled();
|
|
138
|
+
});
|
|
139
|
+
it('finds renamed forum by existingId and does NOT create a duplicate', async () => {
|
|
140
|
+
const guild = makeMockGuild([
|
|
141
|
+
{ id: 'cat-sys', name: 'System', type: ChannelType.GuildCategory },
|
|
142
|
+
{ id: '1000000000000000002', name: 'beads-6', type: ChannelType.GuildForum, parentId: 'cat-sys' },
|
|
143
|
+
{ id: '1000000000000000001', name: 'automations ・ 1', type: ChannelType.GuildForum, parentId: 'cat-sys' },
|
|
144
|
+
{ id: 'status-1', name: 'status', type: ChannelType.GuildText, parentId: 'cat-sys' },
|
|
145
|
+
]);
|
|
146
|
+
const res = await ensureSystemScaffold({
|
|
147
|
+
guild,
|
|
148
|
+
ensureTasks: true,
|
|
149
|
+
existingCronsId: '1000000000000000001',
|
|
150
|
+
existingTasksId: '1000000000000000002',
|
|
151
|
+
});
|
|
152
|
+
expect(res).not.toBeNull();
|
|
153
|
+
expect(res?.cronsForumId).toBe('1000000000000000001');
|
|
154
|
+
expect(res?.tasksForumId).toBe('1000000000000000002');
|
|
155
|
+
// No new channels should have been created (only category existed already).
|
|
156
|
+
expect(guild.__create).not.toHaveBeenCalled();
|
|
157
|
+
});
|
|
158
|
+
it('returns no ID and does NOT create when existingId has wrong channel type (fail closed)', async () => {
|
|
159
|
+
const guild = makeMockGuild([
|
|
160
|
+
{ id: 'cat-sys', name: 'System', type: ChannelType.GuildCategory },
|
|
161
|
+
// crons ID points to a text channel, not a forum.
|
|
162
|
+
{ id: '1000000000000000001', name: 'automations', type: ChannelType.GuildText, parentId: 'cat-sys' },
|
|
163
|
+
{ id: 'status-1', name: 'status', type: ChannelType.GuildText, parentId: 'cat-sys' },
|
|
164
|
+
]);
|
|
165
|
+
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
|
166
|
+
const res = await ensureSystemScaffold({
|
|
167
|
+
guild,
|
|
168
|
+
ensureTasks: false,
|
|
169
|
+
existingCronsId: '1000000000000000001',
|
|
170
|
+
}, log);
|
|
171
|
+
expect(res).not.toBeNull();
|
|
172
|
+
// cronsForumId should be undefined because the type was wrong.
|
|
173
|
+
expect(res?.cronsForumId).toBeUndefined();
|
|
174
|
+
// Should have logged an error about wrong type.
|
|
175
|
+
expect(log.error).toHaveBeenCalledWith(expect.objectContaining({ existingId: '1000000000000000001' }), expect.stringContaining('wrong channel type'));
|
|
176
|
+
// Should NOT have created a new automations forum.
|
|
177
|
+
const createCalls = guild.__create.mock.calls;
|
|
178
|
+
const cronCreateCalls = createCalls.filter((c) => c[0]?.name === 'automations');
|
|
179
|
+
expect(cronCreateCalls).toHaveLength(0);
|
|
180
|
+
});
|
|
181
|
+
it('falls back to name lookup / creation when existingId is stale (not found)', async () => {
|
|
182
|
+
const guild = makeMockGuild([
|
|
183
|
+
{ id: 'cat-sys', name: 'System', type: ChannelType.GuildCategory },
|
|
184
|
+
{ id: 'status-1', name: 'status', type: ChannelType.GuildText, parentId: 'cat-sys' },
|
|
185
|
+
]);
|
|
186
|
+
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
|
187
|
+
const res = await ensureSystemScaffold({
|
|
188
|
+
guild,
|
|
189
|
+
ensureTasks: false,
|
|
190
|
+
existingCronsId: '9999999999999999999',
|
|
191
|
+
}, log);
|
|
192
|
+
expect(res).not.toBeNull();
|
|
193
|
+
// Should have warned about stale ID and fallen through to creation.
|
|
194
|
+
expect(log.warn).toHaveBeenCalledWith(expect.objectContaining({ existingId: '9999999999999999999' }), expect.stringContaining('not found in guild'));
|
|
195
|
+
// Should have created a new automations forum via fallback.
|
|
196
|
+
expect(res?.cronsForumId).toBeDefined();
|
|
197
|
+
const createCalls = guild.__create.mock.calls;
|
|
198
|
+
const cronCreateCalls = createCalls.filter((c) => c[0]?.name === 'automations');
|
|
199
|
+
expect(cronCreateCalls).toHaveLength(1);
|
|
200
|
+
});
|
|
201
|
+
it('finds channel by existingId via API fetch when not in cache', async () => {
|
|
202
|
+
// Guild starts with only System category and status in cache.
|
|
203
|
+
// The automations forum is NOT in cache but fetch() will return it.
|
|
204
|
+
const guild = makeMockGuild([
|
|
205
|
+
{ id: 'cat-sys', name: 'System', type: ChannelType.GuildCategory },
|
|
206
|
+
{ id: 'status-1', name: 'status', type: ChannelType.GuildText, parentId: 'cat-sys' },
|
|
207
|
+
]);
|
|
208
|
+
// Simulate: the channel exists on Discord but isn't in the local cache.
|
|
209
|
+
// Override fetch to return a channel object for the crons ID.
|
|
210
|
+
const cronsChannel = {
|
|
211
|
+
id: '1000000000000000001',
|
|
212
|
+
name: 'automations ・ 3',
|
|
213
|
+
type: ChannelType.GuildForum,
|
|
214
|
+
parentId: null,
|
|
215
|
+
setParent: vi.fn(async function (pid) { this.parentId = pid; }),
|
|
216
|
+
edit: vi.fn(async function (opts) { if ('parent' in opts)
|
|
217
|
+
this.parentId = opts.parent; }),
|
|
218
|
+
};
|
|
219
|
+
guild.channels.fetch = vi.fn(async (id) => {
|
|
220
|
+
if (id === '1000000000000000001')
|
|
221
|
+
return cronsChannel;
|
|
222
|
+
return null;
|
|
223
|
+
});
|
|
224
|
+
const res = await ensureSystemScaffold({
|
|
225
|
+
guild,
|
|
226
|
+
ensureTasks: false,
|
|
227
|
+
existingCronsId: '1000000000000000001',
|
|
228
|
+
});
|
|
229
|
+
expect(res).not.toBeNull();
|
|
230
|
+
expect(res?.cronsForumId).toBe('1000000000000000001');
|
|
231
|
+
// fetch should have been called with the ID.
|
|
232
|
+
expect(guild.channels.fetch).toHaveBeenCalledWith('1000000000000000001');
|
|
233
|
+
// Should NOT have created a new automations forum.
|
|
234
|
+
expect(guild.__create).not.toHaveBeenCalled();
|
|
235
|
+
});
|
|
236
|
+
it('findByNameAndType: exact match takes precedence over stripped match', async () => {
|
|
237
|
+
// Both "automations" and "automations ・ 1" exist — searching for "automations" should find the exact match.
|
|
238
|
+
const guild = makeMockGuild([
|
|
239
|
+
{ id: 'cat-sys', name: 'System', type: ChannelType.GuildCategory },
|
|
240
|
+
{ id: 'crons-exact', name: 'automations', type: ChannelType.GuildForum },
|
|
241
|
+
{ id: 'crons-suffixed', name: 'automations ・ 1', type: ChannelType.GuildForum },
|
|
242
|
+
{ id: 'status-1', name: 'status', type: ChannelType.GuildText, parentId: 'cat-sys' },
|
|
243
|
+
]);
|
|
244
|
+
const res = await ensureSystemScaffold({ guild, ensureTasks: false });
|
|
245
|
+
expect(res?.cronsForumId).toBe('crons-exact');
|
|
246
|
+
});
|
|
247
|
+
it('findByNameAndType: count-suffixed name matches search for base name', async () => {
|
|
248
|
+
// Only "automations ・ 1" exists (no exact "automations") — should match via stripped suffix.
|
|
249
|
+
const guild = makeMockGuild([
|
|
250
|
+
{ id: 'cat-sys', name: 'System', type: ChannelType.GuildCategory },
|
|
251
|
+
{ id: 'crons-suffixed', name: 'automations ・ 1', type: ChannelType.GuildForum },
|
|
252
|
+
{ id: 'status-1', name: 'status', type: ChannelType.GuildText, parentId: 'cat-sys' },
|
|
253
|
+
]);
|
|
254
|
+
const res = await ensureSystemScaffold({ guild, ensureTasks: false });
|
|
255
|
+
expect(res?.cronsForumId).toBe('crons-suffixed');
|
|
256
|
+
// Should NOT have created a new automations forum.
|
|
257
|
+
const createCalls = guild.__create.mock.calls;
|
|
258
|
+
const cronCreateCalls = createCalls.filter((c) => c[0]?.name === 'automations');
|
|
259
|
+
expect(cronCreateCalls).toHaveLength(0);
|
|
260
|
+
});
|
|
261
|
+
it('reconciles name drift when channel found by existingId has a stale name', async () => {
|
|
262
|
+
const guild = makeMockGuild([
|
|
263
|
+
{ id: 'cat-sys', name: 'System', type: ChannelType.GuildCategory },
|
|
264
|
+
{ id: '1000000000000000001', name: 'automations ・ 3', type: ChannelType.GuildForum, parentId: 'cat-sys' },
|
|
265
|
+
{ id: '1000000000000000002', name: 'beads-6', type: ChannelType.GuildForum, parentId: 'cat-sys' },
|
|
266
|
+
{ id: 'status-1', name: 'status', type: ChannelType.GuildText, parentId: 'cat-sys' },
|
|
267
|
+
]);
|
|
268
|
+
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
|
269
|
+
const res = await ensureSystemScaffold({
|
|
270
|
+
guild,
|
|
271
|
+
ensureTasks: true,
|
|
272
|
+
existingCronsId: '1000000000000000001',
|
|
273
|
+
existingTasksId: '1000000000000000002',
|
|
274
|
+
}, log);
|
|
275
|
+
expect(res).not.toBeNull();
|
|
276
|
+
expect(res?.cronsForumId).toBe('1000000000000000001');
|
|
277
|
+
expect(res?.tasksForumId).toBe('1000000000000000002');
|
|
278
|
+
const cronsCh = guild.__cache.get('1000000000000000001');
|
|
279
|
+
const beadsCh = guild.__cache.get('1000000000000000002');
|
|
280
|
+
// automations ・ 3 differs only by count suffix — should NOT be renamed.
|
|
281
|
+
expect(cronsCh.name).toBe('automations ・ 3');
|
|
282
|
+
expect(cronsCh.edit).not.toHaveBeenCalledWith(expect.objectContaining({ name: expect.anything() }));
|
|
283
|
+
expect(log.info).toHaveBeenCalledWith(expect.objectContaining({ name: 'automations', was: 'automations ・ 3' }), expect.stringContaining('skipping'));
|
|
284
|
+
// beads-6 is a genuine name deviation — should still be reconciled.
|
|
285
|
+
expect(beadsCh.name).toBe('tasks');
|
|
286
|
+
expect(log.info).toHaveBeenCalledWith(expect.objectContaining({ name: 'tasks', was: 'beads-6' }), expect.stringContaining('reconciled name'));
|
|
287
|
+
});
|
|
288
|
+
it('reconciles name drift when channel found by name-based lookup (stripped suffix)', async () => {
|
|
289
|
+
const guild = makeMockGuild([
|
|
290
|
+
{ id: 'cat-sys', name: 'System', type: ChannelType.GuildCategory },
|
|
291
|
+
{ id: 'crons-1', name: 'automations ・ 5', type: ChannelType.GuildForum, parentId: 'cat-sys' },
|
|
292
|
+
{ id: 'status-1', name: 'status', type: ChannelType.GuildText, parentId: 'cat-sys' },
|
|
293
|
+
]);
|
|
294
|
+
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
|
295
|
+
const res = await ensureSystemScaffold({ guild, ensureTasks: false }, log);
|
|
296
|
+
expect(res).not.toBeNull();
|
|
297
|
+
expect(res?.cronsForumId).toBe('crons-1');
|
|
298
|
+
// automations ・ 5 differs only by count suffix — should NOT be renamed.
|
|
299
|
+
const cronsCh = guild.__cache.get('crons-1');
|
|
300
|
+
expect(cronsCh.name).toBe('automations ・ 5');
|
|
301
|
+
expect(cronsCh.edit).not.toHaveBeenCalledWith(expect.objectContaining({ name: expect.anything() }));
|
|
302
|
+
expect(log.info).toHaveBeenCalledWith(expect.objectContaining({ name: 'automations', was: 'automations ・ 5' }), expect.stringContaining('skipping'));
|
|
303
|
+
});
|
|
304
|
+
it('does not reconcile name when it already matches canonical', async () => {
|
|
305
|
+
const guild = makeMockGuild([
|
|
306
|
+
{ id: 'cat-sys', name: 'System', type: ChannelType.GuildCategory },
|
|
307
|
+
{ id: '1000000000000000001', name: 'automations', type: ChannelType.GuildForum, parentId: 'cat-sys' },
|
|
308
|
+
{ id: 'status-1', name: 'status', type: ChannelType.GuildText, parentId: 'cat-sys' },
|
|
309
|
+
]);
|
|
310
|
+
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
|
311
|
+
await ensureSystemScaffold({
|
|
312
|
+
guild,
|
|
313
|
+
ensureTasks: false,
|
|
314
|
+
existingCronsId: '1000000000000000001',
|
|
315
|
+
}, log);
|
|
316
|
+
// edit should not have been called for name reconciliation.
|
|
317
|
+
const cronsCh = guild.__cache.get('1000000000000000001');
|
|
318
|
+
expect(cronsCh.edit).not.toHaveBeenCalledWith(expect.objectContaining({ name: expect.anything() }));
|
|
319
|
+
// Should not have logged name reconciliation.
|
|
320
|
+
const nameReconcileCalls = log.info.mock.calls.filter((c) => typeof c[1] === 'string' && c[1].includes('reconciled name'));
|
|
321
|
+
expect(nameReconcileCalls).toHaveLength(0);
|
|
322
|
+
});
|
|
323
|
+
it('skips name reconciliation when only difference is a count suffix (ID-based)', async () => {
|
|
324
|
+
const guild = makeMockGuild([
|
|
325
|
+
{ id: 'cat-sys', name: 'System', type: ChannelType.GuildCategory },
|
|
326
|
+
{ id: '1000000000000000001', name: 'automations', type: ChannelType.GuildForum, parentId: 'cat-sys' },
|
|
327
|
+
{ id: '1000000000000000002', name: 'tasks・4', type: ChannelType.GuildForum, parentId: 'cat-sys' },
|
|
328
|
+
{ id: 'status-1', name: 'status', type: ChannelType.GuildText, parentId: 'cat-sys' },
|
|
329
|
+
]);
|
|
330
|
+
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
|
331
|
+
const res = await ensureSystemScaffold({
|
|
332
|
+
guild,
|
|
333
|
+
ensureTasks: true,
|
|
334
|
+
existingCronsId: '1000000000000000001',
|
|
335
|
+
existingTasksId: '1000000000000000002',
|
|
336
|
+
}, log);
|
|
337
|
+
expect(res).not.toBeNull();
|
|
338
|
+
expect(res?.tasksForumId).toBe('1000000000000000002');
|
|
339
|
+
const tasksCh = guild.__cache.get('1000000000000000002');
|
|
340
|
+
// edit should NOT have been called with a name argument.
|
|
341
|
+
expect(tasksCh.edit).not.toHaveBeenCalledWith(expect.objectContaining({ name: expect.anything() }));
|
|
342
|
+
// Should have logged skip.
|
|
343
|
+
expect(log.info).toHaveBeenCalledWith(expect.objectContaining({ name: 'tasks', was: 'tasks・4' }), expect.stringContaining('skipping'));
|
|
344
|
+
});
|
|
345
|
+
it('skips name reconciliation when only difference is a count suffix (name-based)', async () => {
|
|
346
|
+
const guild = makeMockGuild([
|
|
347
|
+
{ id: 'cat-sys', name: 'System', type: ChannelType.GuildCategory },
|
|
348
|
+
{ id: 'crons-1', name: 'automations・5', type: ChannelType.GuildForum, parentId: 'cat-sys' },
|
|
349
|
+
{ id: 'status-1', name: 'status', type: ChannelType.GuildText, parentId: 'cat-sys' },
|
|
350
|
+
]);
|
|
351
|
+
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
|
352
|
+
const res = await ensureSystemScaffold({ guild, ensureTasks: false }, log);
|
|
353
|
+
expect(res).not.toBeNull();
|
|
354
|
+
expect(res?.cronsForumId).toBe('crons-1');
|
|
355
|
+
const cronsCh = guild.__cache.get('crons-1');
|
|
356
|
+
// edit should NOT have been called with a name argument.
|
|
357
|
+
expect(cronsCh.edit).not.toHaveBeenCalledWith(expect.objectContaining({ name: expect.anything() }));
|
|
358
|
+
// Should have logged skip.
|
|
359
|
+
expect(log.info).toHaveBeenCalledWith(expect.objectContaining({ name: 'automations', was: 'automations・5' }), expect.stringContaining('skipping'));
|
|
360
|
+
});
|
|
361
|
+
it('finds existing #agents forum by legacy name and renames to #automations', async () => {
|
|
362
|
+
const guild = makeMockGuild([
|
|
363
|
+
{ id: 'cat-sys', name: 'System', type: ChannelType.GuildCategory },
|
|
364
|
+
{ id: 'agents-1', name: 'agents', type: ChannelType.GuildForum, parentId: 'cat-sys' },
|
|
365
|
+
{ id: 'status-1', name: 'status', type: ChannelType.GuildText, parentId: 'cat-sys' },
|
|
366
|
+
]);
|
|
367
|
+
const res = await ensureSystemScaffold({ guild, ensureTasks: false });
|
|
368
|
+
expect(res).not.toBeNull();
|
|
369
|
+
expect(res?.cronsForumId).toBe('agents-1');
|
|
370
|
+
// No new forum should have been created.
|
|
371
|
+
expect(guild.__create).not.toHaveBeenCalled();
|
|
372
|
+
// The existing forum should have been renamed to 'automations'.
|
|
373
|
+
const agentsCh = guild.__cache.get('agents-1');
|
|
374
|
+
expect(agentsCh.edit).toHaveBeenCalledWith(expect.objectContaining({ name: 'automations' }));
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
describe('ensureForumTags', () => {
|
|
378
|
+
let tmpDir;
|
|
379
|
+
beforeEach(async () => {
|
|
380
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'forum-tags-'));
|
|
381
|
+
});
|
|
382
|
+
afterEach(async () => {
|
|
383
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
384
|
+
});
|
|
385
|
+
it('returns 0 when tag map file does not exist', async () => {
|
|
386
|
+
const guild = makeMockGuild([]);
|
|
387
|
+
const result = await ensureForumTags(guild, 'forum-1', path.join(tmpDir, 'nonexistent.json'));
|
|
388
|
+
expect(result).toBe(0);
|
|
389
|
+
});
|
|
390
|
+
it('returns 0 when forum channel does not exist', async () => {
|
|
391
|
+
const tagMapPath = path.join(tmpDir, 'tags.json');
|
|
392
|
+
await fs.writeFile(tagMapPath, '{"monitoring": "", "daily": ""}', 'utf8');
|
|
393
|
+
const guild = makeMockGuild([]);
|
|
394
|
+
const result = await ensureForumTags(guild, 'missing-forum', tagMapPath);
|
|
395
|
+
expect(result).toBe(0);
|
|
396
|
+
});
|
|
397
|
+
it('creates missing tags on forum and writes IDs back to file', async () => {
|
|
398
|
+
const tagMapPath = path.join(tmpDir, 'tags.json');
|
|
399
|
+
await fs.writeFile(tagMapPath, '{"monitoring": "", "daily": ""}', 'utf8');
|
|
400
|
+
// Create a mock forum with editable availableTags.
|
|
401
|
+
let forumTags = [];
|
|
402
|
+
const forum = {
|
|
403
|
+
id: 'forum-1',
|
|
404
|
+
name: 'agents',
|
|
405
|
+
type: ChannelType.GuildForum,
|
|
406
|
+
availableTags: forumTags,
|
|
407
|
+
edit: vi.fn(async (opts) => {
|
|
408
|
+
// Simulate Discord creating tags with IDs.
|
|
409
|
+
forumTags = opts.availableTags.map((t, i) => ({
|
|
410
|
+
...t,
|
|
411
|
+
id: t.id ?? `tag-new-${i}`,
|
|
412
|
+
name: t.name,
|
|
413
|
+
moderated: false,
|
|
414
|
+
emoji: null,
|
|
415
|
+
}));
|
|
416
|
+
forum.availableTags = forumTags;
|
|
417
|
+
}),
|
|
418
|
+
};
|
|
419
|
+
const cache = new Map([['forum-1', forum]]);
|
|
420
|
+
const guild = {
|
|
421
|
+
id: 'guild-1',
|
|
422
|
+
channels: {
|
|
423
|
+
cache: {
|
|
424
|
+
get: (id) => cache.get(id),
|
|
425
|
+
find: () => undefined,
|
|
426
|
+
values: () => cache.values(),
|
|
427
|
+
},
|
|
428
|
+
},
|
|
429
|
+
};
|
|
430
|
+
const result = await ensureForumTags(guild, 'forum-1', tagMapPath);
|
|
431
|
+
expect(result).toBe(2);
|
|
432
|
+
expect(forum.edit).toHaveBeenCalled();
|
|
433
|
+
// Verify the tag map file was updated with new IDs.
|
|
434
|
+
const updatedRaw = await fs.readFile(tagMapPath, 'utf8');
|
|
435
|
+
const updatedMap = JSON.parse(updatedRaw);
|
|
436
|
+
expect(updatedMap.monitoring).toBeTruthy();
|
|
437
|
+
expect(updatedMap.daily).toBeTruthy();
|
|
438
|
+
});
|
|
439
|
+
it('backfills existing tag IDs without creating duplicates', async () => {
|
|
440
|
+
const tagMapPath = path.join(tmpDir, 'tags.json');
|
|
441
|
+
await fs.writeFile(tagMapPath, '{"monitoring": "", "daily": "existing-tag-1"}', 'utf8');
|
|
442
|
+
const forum = {
|
|
443
|
+
id: 'forum-1',
|
|
444
|
+
name: 'agents',
|
|
445
|
+
type: ChannelType.GuildForum,
|
|
446
|
+
availableTags: [
|
|
447
|
+
{ id: 'existing-tag-1', name: 'daily', moderated: false, emoji: null },
|
|
448
|
+
{ id: 'existing-tag-2', name: 'monitoring', moderated: false, emoji: null },
|
|
449
|
+
],
|
|
450
|
+
edit: vi.fn(),
|
|
451
|
+
};
|
|
452
|
+
const cache = new Map([['forum-1', forum]]);
|
|
453
|
+
const guild = {
|
|
454
|
+
id: 'guild-1',
|
|
455
|
+
channels: {
|
|
456
|
+
cache: {
|
|
457
|
+
get: (id) => cache.get(id),
|
|
458
|
+
find: () => undefined,
|
|
459
|
+
values: () => cache.values(),
|
|
460
|
+
},
|
|
461
|
+
},
|
|
462
|
+
};
|
|
463
|
+
const result = await ensureForumTags(guild, 'forum-1', tagMapPath);
|
|
464
|
+
// monitoring already exists on the forum so it should be backfilled, not created.
|
|
465
|
+
expect(result).toBe(0);
|
|
466
|
+
expect(forum.edit).not.toHaveBeenCalled();
|
|
467
|
+
// The tag map should have the backfilled ID for monitoring.
|
|
468
|
+
const updatedRaw = await fs.readFile(tagMapPath, 'utf8');
|
|
469
|
+
const updatedMap = JSON.parse(updatedRaw);
|
|
470
|
+
expect(updatedMap.monitoring).toBe('existing-tag-2');
|
|
471
|
+
});
|
|
472
|
+
it('clears stale IDs that do not exist on the forum', async () => {
|
|
473
|
+
const tagMapPath = path.join(tmpDir, 'tags.json');
|
|
474
|
+
await fs.writeFile(tagMapPath, '{"open": "stale-id-999", "closed": ""}', 'utf8');
|
|
475
|
+
let forumTags = [];
|
|
476
|
+
const forum = {
|
|
477
|
+
id: 'forum-1',
|
|
478
|
+
name: 'beads',
|
|
479
|
+
type: ChannelType.GuildForum,
|
|
480
|
+
availableTags: forumTags,
|
|
481
|
+
edit: vi.fn(async (opts) => {
|
|
482
|
+
forumTags = opts.availableTags.map((t, i) => ({
|
|
483
|
+
...t,
|
|
484
|
+
id: t.id ?? `tag-new-${i}`,
|
|
485
|
+
name: t.name,
|
|
486
|
+
moderated: false,
|
|
487
|
+
emoji: null,
|
|
488
|
+
}));
|
|
489
|
+
forum.availableTags = forumTags;
|
|
490
|
+
}),
|
|
491
|
+
};
|
|
492
|
+
const cache = new Map([['forum-1', forum]]);
|
|
493
|
+
const guild = {
|
|
494
|
+
id: 'guild-1',
|
|
495
|
+
channels: {
|
|
496
|
+
cache: {
|
|
497
|
+
get: (id) => cache.get(id),
|
|
498
|
+
find: () => undefined,
|
|
499
|
+
values: () => cache.values(),
|
|
500
|
+
},
|
|
501
|
+
},
|
|
502
|
+
};
|
|
503
|
+
const logMock = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
|
504
|
+
const result = await ensureForumTags(guild, 'forum-1', tagMapPath, { log: logMock });
|
|
505
|
+
// Both tags should be created (stale ID was cleared).
|
|
506
|
+
expect(result).toBe(2);
|
|
507
|
+
const updatedRaw = await fs.readFile(tagMapPath, 'utf8');
|
|
508
|
+
const updatedMap = JSON.parse(updatedRaw);
|
|
509
|
+
expect(updatedMap.open).toBeTruthy();
|
|
510
|
+
expect(updatedMap.open).not.toBe('stale-id-999');
|
|
511
|
+
expect(updatedMap.closed).toBeTruthy();
|
|
512
|
+
});
|
|
513
|
+
it('clears swapped IDs that map to wrong tag name', async () => {
|
|
514
|
+
const tagMapPath = path.join(tmpDir, 'tags.json');
|
|
515
|
+
// "open" has the ID that actually belongs to "closed" on the forum.
|
|
516
|
+
await fs.writeFile(tagMapPath, '{"open": "tag-closed-id", "closed": "tag-open-id"}', 'utf8');
|
|
517
|
+
const forum = {
|
|
518
|
+
id: 'forum-1',
|
|
519
|
+
name: 'beads',
|
|
520
|
+
type: ChannelType.GuildForum,
|
|
521
|
+
availableTags: [
|
|
522
|
+
{ id: 'tag-open-id', name: 'open', moderated: false, emoji: null },
|
|
523
|
+
{ id: 'tag-closed-id', name: 'closed', moderated: false, emoji: null },
|
|
524
|
+
],
|
|
525
|
+
edit: vi.fn(),
|
|
526
|
+
};
|
|
527
|
+
const cache = new Map([['forum-1', forum]]);
|
|
528
|
+
const guild = {
|
|
529
|
+
id: 'guild-1',
|
|
530
|
+
channels: {
|
|
531
|
+
cache: {
|
|
532
|
+
get: (id) => cache.get(id),
|
|
533
|
+
find: () => undefined,
|
|
534
|
+
values: () => cache.values(),
|
|
535
|
+
},
|
|
536
|
+
},
|
|
537
|
+
};
|
|
538
|
+
const logMock = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
|
539
|
+
const result = await ensureForumTags(guild, 'forum-1', tagMapPath, { log: logMock });
|
|
540
|
+
// No new tags created — they already exist, IDs just needed backfill after clearing swapped ones.
|
|
541
|
+
expect(result).toBe(0);
|
|
542
|
+
const updatedRaw = await fs.readFile(tagMapPath, 'utf8');
|
|
543
|
+
const updatedMap = JSON.parse(updatedRaw);
|
|
544
|
+
// After clearing swapped IDs, they should be backfilled with the correct IDs.
|
|
545
|
+
expect(updatedMap.open).toBe('tag-open-id');
|
|
546
|
+
expect(updatedMap.closed).toBe('tag-closed-id');
|
|
547
|
+
});
|
|
548
|
+
it('preserves valid IDs without clearing them', async () => {
|
|
549
|
+
const tagMapPath = path.join(tmpDir, 'tags.json');
|
|
550
|
+
await fs.writeFile(tagMapPath, '{"open": "tag-open-id", "closed": "tag-closed-id"}', 'utf8');
|
|
551
|
+
const forum = {
|
|
552
|
+
id: 'forum-1',
|
|
553
|
+
name: 'beads',
|
|
554
|
+
type: ChannelType.GuildForum,
|
|
555
|
+
availableTags: [
|
|
556
|
+
{ id: 'tag-open-id', name: 'open', moderated: false, emoji: null },
|
|
557
|
+
{ id: 'tag-closed-id', name: 'closed', moderated: false, emoji: null },
|
|
558
|
+
],
|
|
559
|
+
edit: vi.fn(),
|
|
560
|
+
};
|
|
561
|
+
const cache = new Map([['forum-1', forum]]);
|
|
562
|
+
const guild = {
|
|
563
|
+
id: 'guild-1',
|
|
564
|
+
channels: {
|
|
565
|
+
cache: {
|
|
566
|
+
get: (id) => cache.get(id),
|
|
567
|
+
find: () => undefined,
|
|
568
|
+
values: () => cache.values(),
|
|
569
|
+
},
|
|
570
|
+
},
|
|
571
|
+
};
|
|
572
|
+
const result = await ensureForumTags(guild, 'forum-1', tagMapPath);
|
|
573
|
+
expect(result).toBe(0);
|
|
574
|
+
expect(forum.edit).not.toHaveBeenCalled();
|
|
575
|
+
const updatedRaw = await fs.readFile(tagMapPath, 'utf8');
|
|
576
|
+
const updatedMap = JSON.parse(updatedRaw);
|
|
577
|
+
expect(updatedMap.open).toBe('tag-open-id');
|
|
578
|
+
expect(updatedMap.closed).toBe('tag-closed-id');
|
|
579
|
+
});
|
|
580
|
+
it('prioritizes status tags over content tags when slots are limited', async () => {
|
|
581
|
+
// Forum already has 18 tags — only 2 slots left.
|
|
582
|
+
const existingForumTags = Array.from({ length: 18 }, (_, i) => ({
|
|
583
|
+
id: `existing-${i}`,
|
|
584
|
+
name: `tag-${i}`,
|
|
585
|
+
moderated: false,
|
|
586
|
+
emoji: null,
|
|
587
|
+
}));
|
|
588
|
+
const tagMapPath = path.join(tmpDir, 'tags.json');
|
|
589
|
+
// 4 status tags + 2 content tags = 6, but only 2 slots.
|
|
590
|
+
await fs.writeFile(tagMapPath, JSON.stringify({
|
|
591
|
+
feature: '', bug: '', open: '', in_progress: '', blocked: '', closed: '',
|
|
592
|
+
}), 'utf8');
|
|
593
|
+
let forumTags = [...existingForumTags];
|
|
594
|
+
const forum = {
|
|
595
|
+
id: 'forum-1',
|
|
596
|
+
name: 'beads',
|
|
597
|
+
type: ChannelType.GuildForum,
|
|
598
|
+
availableTags: forumTags,
|
|
599
|
+
edit: vi.fn(async (opts) => {
|
|
600
|
+
forumTags = opts.availableTags.map((t, i) => ({
|
|
601
|
+
...t,
|
|
602
|
+
id: t.id ?? `tag-new-${i}`,
|
|
603
|
+
name: t.name,
|
|
604
|
+
moderated: false,
|
|
605
|
+
emoji: null,
|
|
606
|
+
}));
|
|
607
|
+
forum.availableTags = forumTags;
|
|
608
|
+
}),
|
|
609
|
+
};
|
|
610
|
+
const cache = new Map([['forum-1', forum]]);
|
|
611
|
+
const guild = {
|
|
612
|
+
id: 'guild-1',
|
|
613
|
+
channels: {
|
|
614
|
+
cache: {
|
|
615
|
+
get: (id) => cache.get(id),
|
|
616
|
+
find: () => undefined,
|
|
617
|
+
values: () => cache.values(),
|
|
618
|
+
},
|
|
619
|
+
},
|
|
620
|
+
};
|
|
621
|
+
const result = await ensureForumTags(guild, 'forum-1', tagMapPath);
|
|
622
|
+
expect(result).toBe(2);
|
|
623
|
+
// The 2 created tags should be the highest-priority status tags (open, in_progress),
|
|
624
|
+
// not blocked/closed or content tags — verifying deterministic lifecycle-priority ordering.
|
|
625
|
+
const updatedRaw = await fs.readFile(tagMapPath, 'utf8');
|
|
626
|
+
const updatedMap = JSON.parse(updatedRaw);
|
|
627
|
+
// open and in_progress should have IDs (created first by lifecycle priority).
|
|
628
|
+
expect(updatedMap.open).toBeTruthy();
|
|
629
|
+
expect(updatedMap.in_progress).toBeTruthy();
|
|
630
|
+
// blocked and closed should NOT have IDs (not enough slots).
|
|
631
|
+
expect(updatedMap.blocked).toBe('');
|
|
632
|
+
expect(updatedMap.closed).toBe('');
|
|
633
|
+
// Content tags should NOT have IDs (not enough slots).
|
|
634
|
+
expect(updatedMap.feature).toBe('');
|
|
635
|
+
expect(updatedMap.bug).toBe('');
|
|
636
|
+
});
|
|
637
|
+
it('merges new keys from seed file via options.seedPath', async () => {
|
|
638
|
+
const tagMapPath = path.join(tmpDir, 'tags.json');
|
|
639
|
+
await fs.writeFile(tagMapPath, '{"open": "existing-id", "closed": ""}', 'utf8');
|
|
640
|
+
const seedPath = path.join(tmpDir, 'seed.json');
|
|
641
|
+
await fs.writeFile(seedPath, '{"open": "", "closed": "", "feature": "", "bug": ""}', 'utf8');
|
|
642
|
+
let forumTags = [
|
|
643
|
+
{ id: 'existing-id', name: 'open', moderated: false, emoji: null },
|
|
644
|
+
];
|
|
645
|
+
const forum = {
|
|
646
|
+
id: 'forum-1',
|
|
647
|
+
name: 'beads',
|
|
648
|
+
type: ChannelType.GuildForum,
|
|
649
|
+
availableTags: forumTags,
|
|
650
|
+
edit: vi.fn(async (opts) => {
|
|
651
|
+
forumTags = opts.availableTags.map((t, i) => ({
|
|
652
|
+
...t,
|
|
653
|
+
id: t.id ?? `tag-new-${i}`,
|
|
654
|
+
name: t.name,
|
|
655
|
+
moderated: false,
|
|
656
|
+
emoji: null,
|
|
657
|
+
}));
|
|
658
|
+
forum.availableTags = forumTags;
|
|
659
|
+
}),
|
|
660
|
+
};
|
|
661
|
+
const cache = new Map([['forum-1', forum]]);
|
|
662
|
+
const guild = {
|
|
663
|
+
id: 'guild-1',
|
|
664
|
+
channels: {
|
|
665
|
+
cache: {
|
|
666
|
+
get: (id) => cache.get(id),
|
|
667
|
+
find: () => undefined,
|
|
668
|
+
values: () => cache.values(),
|
|
669
|
+
},
|
|
670
|
+
},
|
|
671
|
+
};
|
|
672
|
+
const result = await ensureForumTags(guild, 'forum-1', tagMapPath, { seedPath });
|
|
673
|
+
// closed, feature, bug should be created (open already exists with valid ID).
|
|
674
|
+
expect(result).toBe(3);
|
|
675
|
+
const updatedRaw = await fs.readFile(tagMapPath, 'utf8');
|
|
676
|
+
const updatedMap = JSON.parse(updatedRaw);
|
|
677
|
+
// open should keep its existing ID.
|
|
678
|
+
expect(updatedMap.open).toBe('existing-id');
|
|
679
|
+
// New keys from seed should have been merged and created.
|
|
680
|
+
expect(updatedMap.feature).toBeTruthy();
|
|
681
|
+
expect(updatedMap.bug).toBeTruthy();
|
|
682
|
+
expect(updatedMap.closed).toBeTruthy();
|
|
683
|
+
});
|
|
684
|
+
it('does not overwrite existing keys when merging from seed', async () => {
|
|
685
|
+
const tagMapPath = path.join(tmpDir, 'tags.json');
|
|
686
|
+
await fs.writeFile(tagMapPath, '{"open": "my-id"}', 'utf8');
|
|
687
|
+
const seedPath = path.join(tmpDir, 'seed.json');
|
|
688
|
+
// Seed has open with empty ID — should NOT overwrite the existing "my-id".
|
|
689
|
+
await fs.writeFile(seedPath, '{"open": ""}', 'utf8');
|
|
690
|
+
const forum = {
|
|
691
|
+
id: 'forum-1',
|
|
692
|
+
name: 'beads',
|
|
693
|
+
type: ChannelType.GuildForum,
|
|
694
|
+
availableTags: [
|
|
695
|
+
{ id: 'my-id', name: 'open', moderated: false, emoji: null },
|
|
696
|
+
],
|
|
697
|
+
edit: vi.fn(),
|
|
698
|
+
};
|
|
699
|
+
const cache = new Map([['forum-1', forum]]);
|
|
700
|
+
const guild = {
|
|
701
|
+
id: 'guild-1',
|
|
702
|
+
channels: {
|
|
703
|
+
cache: {
|
|
704
|
+
get: (id) => cache.get(id),
|
|
705
|
+
find: () => undefined,
|
|
706
|
+
values: () => cache.values(),
|
|
707
|
+
},
|
|
708
|
+
},
|
|
709
|
+
};
|
|
710
|
+
const result = await ensureForumTags(guild, 'forum-1', tagMapPath, { seedPath });
|
|
711
|
+
expect(result).toBe(0);
|
|
712
|
+
const updatedRaw = await fs.readFile(tagMapPath, 'utf8');
|
|
713
|
+
const updatedMap = JSON.parse(updatedRaw);
|
|
714
|
+
expect(updatedMap.open).toBe('my-id');
|
|
715
|
+
});
|
|
716
|
+
it('accepts options bag for backward compatibility', async () => {
|
|
717
|
+
const tagMapPath = path.join(tmpDir, 'tags.json');
|
|
718
|
+
await fs.writeFile(tagMapPath, '{}', 'utf8');
|
|
719
|
+
const guild = makeMockGuild([]);
|
|
720
|
+
// Calling with no options (undefined) should still work.
|
|
721
|
+
const result = await ensureForumTags(guild, 'forum-1', tagMapPath);
|
|
722
|
+
expect(result).toBe(0);
|
|
723
|
+
});
|
|
724
|
+
});
|