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,36 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { TaskStore } from './store.js';
|
|
3
|
+
import { findTaskByThreadId } from './thread-cache.js';
|
|
4
|
+
function makeStore(externalRefs) {
|
|
5
|
+
const store = new TaskStore({ prefix: 'ws' });
|
|
6
|
+
for (const externalRef of externalRefs) {
|
|
7
|
+
const b = store.create({ title: 'Test' });
|
|
8
|
+
store.update(b.id, { externalRef });
|
|
9
|
+
}
|
|
10
|
+
return store;
|
|
11
|
+
}
|
|
12
|
+
describe('findTaskByThreadId', () => {
|
|
13
|
+
it('returns task when external_ref matches as discord:<threadId>', () => {
|
|
14
|
+
const store = makeStore([
|
|
15
|
+
'discord:111222333444555666',
|
|
16
|
+
'discord:999888777666555444',
|
|
17
|
+
]);
|
|
18
|
+
const result = findTaskByThreadId('111222333444555666', store);
|
|
19
|
+
expect(result?.id).toBe('ws-001');
|
|
20
|
+
});
|
|
21
|
+
it('returns task when external_ref is raw numeric ID', () => {
|
|
22
|
+
const store = makeStore(['111222333444555666']);
|
|
23
|
+
const result = findTaskByThreadId('111222333444555666', store);
|
|
24
|
+
expect(result?.id).toBe('ws-001');
|
|
25
|
+
});
|
|
26
|
+
it('returns null when no match', () => {
|
|
27
|
+
const store = makeStore(['discord:999888777666555444']);
|
|
28
|
+
const result = findTaskByThreadId('111222333444555666', store);
|
|
29
|
+
expect(result).toBeNull();
|
|
30
|
+
});
|
|
31
|
+
it('returns null when store is empty', () => {
|
|
32
|
+
const store = new TaskStore();
|
|
33
|
+
const result = findTaskByThreadId('111222333444555666', store);
|
|
34
|
+
expect(result).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { findTaskByThreadId } from './thread-cache.js';
|
|
2
|
+
import { buildAppliedTagsWithStatus, buildThreadName } from './thread-helpers.js';
|
|
3
|
+
function isBotOwned(thread) {
|
|
4
|
+
const botUserId = thread.client?.user?.id ?? '';
|
|
5
|
+
return botUserId !== '' && thread.ownerId === botUserId;
|
|
6
|
+
}
|
|
7
|
+
async function rejectManualThread(thread, log) {
|
|
8
|
+
log?.info({ threadId: thread.id, name: thread.name, ownerId: thread.ownerId }, 'tasks:forum rejected manual thread');
|
|
9
|
+
try {
|
|
10
|
+
await thread.send('Tasks must be created using bot commands or the `bd` CLI, not by manually creating forum threads.\n\n'
|
|
11
|
+
+ 'Ask the bot to create a task for you, or run `bd create` from the terminal.\n\n'
|
|
12
|
+
+ 'This thread will be archived.');
|
|
13
|
+
}
|
|
14
|
+
catch { /* ignore */ }
|
|
15
|
+
try {
|
|
16
|
+
await thread.setArchived(true);
|
|
17
|
+
}
|
|
18
|
+
catch { /* ignore */ }
|
|
19
|
+
}
|
|
20
|
+
async function reArchiveTaskThread(thread, store, tagMap, log) {
|
|
21
|
+
let task;
|
|
22
|
+
try {
|
|
23
|
+
task = findTaskByThreadId(thread.id, store);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
if (!task)
|
|
29
|
+
return false;
|
|
30
|
+
log?.info({ threadId: thread.id, taskId: task.id }, 'tasks:forum re-archiving known task thread');
|
|
31
|
+
try {
|
|
32
|
+
const current = thread.appliedTags ?? [];
|
|
33
|
+
const updated = buildAppliedTagsWithStatus(current, task.status, tagMap);
|
|
34
|
+
const name = buildThreadName(task.id, task.title, task.status);
|
|
35
|
+
await thread.edit({ name, appliedTags: updated });
|
|
36
|
+
}
|
|
37
|
+
catch { /* ignore — proceed to archive */ }
|
|
38
|
+
try {
|
|
39
|
+
await thread.setArchived(true);
|
|
40
|
+
}
|
|
41
|
+
catch { /* ignore */ }
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
export function initTasksForumGuard(opts) {
|
|
45
|
+
const { client, forumId, log, store, tagMap } = opts;
|
|
46
|
+
client.on('threadCreate', async (thread) => {
|
|
47
|
+
try {
|
|
48
|
+
if (thread.parentId !== forumId)
|
|
49
|
+
return;
|
|
50
|
+
if (isBotOwned(thread))
|
|
51
|
+
return;
|
|
52
|
+
if (store && tagMap) {
|
|
53
|
+
if (await reArchiveTaskThread(thread, store, tagMap, log))
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
await rejectManualThread(thread, log);
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
log?.error({ err, threadId: thread.id }, 'tasks:forum threadCreate guard failed');
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
client.on('threadUpdate', async (_oldThread, newThread) => {
|
|
63
|
+
try {
|
|
64
|
+
if (newThread.parentId !== forumId)
|
|
65
|
+
return;
|
|
66
|
+
// Only act on unarchive transitions.
|
|
67
|
+
if (newThread.archived)
|
|
68
|
+
return;
|
|
69
|
+
if (isBotOwned(newThread))
|
|
70
|
+
return;
|
|
71
|
+
if (store && tagMap) {
|
|
72
|
+
if (await reArchiveTaskThread(newThread, store, tagMap, log))
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
await rejectManualThread(newThread, log);
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
log?.error({ err, threadId: newThread.id }, 'tasks:forum threadUpdate guard failed');
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { TaskStore } from './store.js';
|
|
3
|
+
import { initTasksForumGuard } from './forum-guard.js';
|
|
4
|
+
vi.mock('./thread-cache.js', () => ({
|
|
5
|
+
findTaskByThreadId: vi.fn(),
|
|
6
|
+
}));
|
|
7
|
+
vi.mock('./thread-helpers.js', () => ({
|
|
8
|
+
buildAppliedTagsWithStatus: vi.fn(() => ['tag-closed']),
|
|
9
|
+
buildThreadName: vi.fn(() => '✅ [001] My Task'),
|
|
10
|
+
}));
|
|
11
|
+
import { findTaskByThreadId } from './thread-cache.js';
|
|
12
|
+
function makeClient(botUserId = 'bot-user-1') {
|
|
13
|
+
const listeners = {};
|
|
14
|
+
return {
|
|
15
|
+
on: vi.fn((event, cb) => {
|
|
16
|
+
(listeners[event] ??= []).push(cb);
|
|
17
|
+
}),
|
|
18
|
+
user: { id: botUserId },
|
|
19
|
+
_listeners: listeners,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
function makeThread(overrides) {
|
|
23
|
+
return {
|
|
24
|
+
id: 'thread-1',
|
|
25
|
+
name: 'Task 1',
|
|
26
|
+
parentId: 'tasks-forum-1',
|
|
27
|
+
ownerId: 'bot-user-1',
|
|
28
|
+
appliedTags: [],
|
|
29
|
+
send: vi.fn().mockResolvedValue(undefined),
|
|
30
|
+
setArchived: vi.fn().mockResolvedValue(undefined),
|
|
31
|
+
setName: vi.fn().mockResolvedValue(undefined),
|
|
32
|
+
edit: vi.fn().mockResolvedValue(undefined),
|
|
33
|
+
client: { user: { id: 'bot-user-1' } },
|
|
34
|
+
...overrides,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
const MOCK_TASK = { id: 'ws-001', title: 'My Task', status: 'closed' };
|
|
38
|
+
describe('initTasksForumGuard', () => {
|
|
39
|
+
function setup(botUserId = 'bot-user-1') {
|
|
40
|
+
const client = makeClient(botUserId);
|
|
41
|
+
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
|
42
|
+
initTasksForumGuard({ client: client, forumId: 'tasks-forum-1', log });
|
|
43
|
+
const listeners = client._listeners['threadCreate'] ?? [];
|
|
44
|
+
expect(listeners.length).toBeGreaterThan(0);
|
|
45
|
+
return { listener: listeners[0], log };
|
|
46
|
+
}
|
|
47
|
+
it('rejects manually-created threads with guidance and archives', async () => {
|
|
48
|
+
const { listener } = setup();
|
|
49
|
+
const thread = makeThread({ ownerId: 'some-user' });
|
|
50
|
+
await listener(thread);
|
|
51
|
+
expect(thread.send).toHaveBeenCalledWith(expect.stringContaining('bd create'));
|
|
52
|
+
expect(thread.setArchived).toHaveBeenCalledWith(true);
|
|
53
|
+
});
|
|
54
|
+
it('allows bot-created threads through without sending or archiving', async () => {
|
|
55
|
+
const { listener } = setup();
|
|
56
|
+
const thread = makeThread({ ownerId: 'bot-user-1' });
|
|
57
|
+
await listener(thread);
|
|
58
|
+
expect(thread.send).not.toHaveBeenCalled();
|
|
59
|
+
expect(thread.setArchived).not.toHaveBeenCalled();
|
|
60
|
+
});
|
|
61
|
+
it('ignores threads from other forums', async () => {
|
|
62
|
+
const { listener } = setup();
|
|
63
|
+
const thread = makeThread({ parentId: 'other-forum', ownerId: 'some-user' });
|
|
64
|
+
await listener(thread);
|
|
65
|
+
expect(thread.send).not.toHaveBeenCalled();
|
|
66
|
+
expect(thread.setArchived).not.toHaveBeenCalled();
|
|
67
|
+
});
|
|
68
|
+
it('handles send failure without preventing archive attempt', async () => {
|
|
69
|
+
const { listener } = setup();
|
|
70
|
+
const thread = makeThread({ ownerId: 'some-user' });
|
|
71
|
+
thread.send.mockRejectedValue(new Error('Missing Access'));
|
|
72
|
+
await listener(thread);
|
|
73
|
+
expect(thread.setArchived).toHaveBeenCalledWith(true);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
describe('initTasksForumGuard threadUpdate', () => {
|
|
77
|
+
function setup(botUserId = 'bot-user-1') {
|
|
78
|
+
const client = makeClient(botUserId);
|
|
79
|
+
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
|
80
|
+
initTasksForumGuard({ client: client, forumId: 'tasks-forum-1', log });
|
|
81
|
+
const listeners = client._listeners['threadUpdate'] ?? [];
|
|
82
|
+
expect(listeners.length).toBeGreaterThan(0);
|
|
83
|
+
return { listener: listeners[0], log };
|
|
84
|
+
}
|
|
85
|
+
it('rejects unarchived manual thread', async () => {
|
|
86
|
+
const { listener } = setup();
|
|
87
|
+
const oldThread = makeThread({ ownerId: 'some-user', archived: true });
|
|
88
|
+
const newThread = makeThread({ ownerId: 'some-user', archived: false });
|
|
89
|
+
await listener(oldThread, newThread);
|
|
90
|
+
expect(newThread.send).toHaveBeenCalledWith(expect.stringContaining('bd create'));
|
|
91
|
+
expect(newThread.setArchived).toHaveBeenCalledWith(true);
|
|
92
|
+
});
|
|
93
|
+
it('allows bot-owned unarchived thread through', async () => {
|
|
94
|
+
const { listener } = setup();
|
|
95
|
+
const oldThread = makeThread({ archived: true });
|
|
96
|
+
const newThread = makeThread({ archived: false });
|
|
97
|
+
await listener(oldThread, newThread);
|
|
98
|
+
expect(newThread.send).not.toHaveBeenCalled();
|
|
99
|
+
expect(newThread.setArchived).not.toHaveBeenCalled();
|
|
100
|
+
});
|
|
101
|
+
it('ignores archive transitions (thread being archived)', async () => {
|
|
102
|
+
const { listener } = setup();
|
|
103
|
+
const oldThread = makeThread({ ownerId: 'some-user', archived: false });
|
|
104
|
+
const newThread = makeThread({ ownerId: 'some-user', archived: true });
|
|
105
|
+
await listener(oldThread, newThread);
|
|
106
|
+
expect(newThread.send).not.toHaveBeenCalled();
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
describe('initTasksForumGuard task-aware re-archive (threadCreate)', () => {
|
|
110
|
+
function setup() {
|
|
111
|
+
const client = makeClient();
|
|
112
|
+
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
|
113
|
+
initTasksForumGuard({
|
|
114
|
+
client: client,
|
|
115
|
+
forumId: 'tasks-forum-1',
|
|
116
|
+
log,
|
|
117
|
+
store: new TaskStore(),
|
|
118
|
+
tagMap: { closed: 'tag-closed' },
|
|
119
|
+
});
|
|
120
|
+
const listener = (client._listeners['threadCreate'] ?? [])[0];
|
|
121
|
+
return { listener, log };
|
|
122
|
+
}
|
|
123
|
+
it('re-archives known task thread without sending rejection message', async () => {
|
|
124
|
+
vi.mocked(findTaskByThreadId).mockReturnValue(MOCK_TASK);
|
|
125
|
+
const { listener } = setup();
|
|
126
|
+
const thread = makeThread({ ownerId: 'some-user' });
|
|
127
|
+
await listener(thread);
|
|
128
|
+
expect(thread.send).not.toHaveBeenCalled();
|
|
129
|
+
expect(thread.edit).toHaveBeenCalledWith({ name: '✅ [001] My Task', appliedTags: ['tag-closed'] });
|
|
130
|
+
expect(thread.setName).not.toHaveBeenCalled();
|
|
131
|
+
expect(thread.setArchived).toHaveBeenCalledWith(true);
|
|
132
|
+
});
|
|
133
|
+
it('falls through to rejection when task lookup returns null', async () => {
|
|
134
|
+
vi.mocked(findTaskByThreadId).mockReturnValue(null);
|
|
135
|
+
const { listener } = setup();
|
|
136
|
+
const thread = makeThread({ ownerId: 'some-user' });
|
|
137
|
+
await listener(thread);
|
|
138
|
+
expect(thread.send).toHaveBeenCalledWith(expect.stringContaining('bd create'));
|
|
139
|
+
expect(thread.setArchived).toHaveBeenCalledWith(true);
|
|
140
|
+
});
|
|
141
|
+
it('falls through to rejection when task lookup throws', async () => {
|
|
142
|
+
vi.mocked(findTaskByThreadId).mockImplementation(() => { throw new Error('fs error'); });
|
|
143
|
+
const { listener } = setup();
|
|
144
|
+
const thread = makeThread({ ownerId: 'some-user' });
|
|
145
|
+
await listener(thread);
|
|
146
|
+
expect(thread.send).toHaveBeenCalledWith(expect.stringContaining('bd create'));
|
|
147
|
+
expect(thread.setArchived).toHaveBeenCalledWith(true);
|
|
148
|
+
});
|
|
149
|
+
it('still archives even when edit throws', async () => {
|
|
150
|
+
vi.mocked(findTaskByThreadId).mockReturnValue(MOCK_TASK);
|
|
151
|
+
const { listener } = setup();
|
|
152
|
+
const thread = makeThread({ ownerId: 'some-user' });
|
|
153
|
+
thread.edit.mockRejectedValue(new Error('edit failed'));
|
|
154
|
+
await listener(thread);
|
|
155
|
+
expect(thread.setArchived).toHaveBeenCalledWith(true);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
describe('initTasksForumGuard task-aware re-archive (threadUpdate)', () => {
|
|
159
|
+
function setup() {
|
|
160
|
+
const client = makeClient();
|
|
161
|
+
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
|
162
|
+
initTasksForumGuard({
|
|
163
|
+
client: client,
|
|
164
|
+
forumId: 'tasks-forum-1',
|
|
165
|
+
log,
|
|
166
|
+
store: new TaskStore(),
|
|
167
|
+
tagMap: { closed: 'tag-closed' },
|
|
168
|
+
});
|
|
169
|
+
const listener = (client._listeners['threadUpdate'] ?? [])[0];
|
|
170
|
+
return { listener, log };
|
|
171
|
+
}
|
|
172
|
+
it('re-archives known task thread on unarchive without rejection message', async () => {
|
|
173
|
+
vi.mocked(findTaskByThreadId).mockReturnValue(MOCK_TASK);
|
|
174
|
+
const { listener } = setup();
|
|
175
|
+
const oldThread = makeThread({ ownerId: 'some-user', archived: true });
|
|
176
|
+
const newThread = makeThread({ ownerId: 'some-user', archived: false });
|
|
177
|
+
await listener(oldThread, newThread);
|
|
178
|
+
expect(newThread.send).not.toHaveBeenCalled();
|
|
179
|
+
expect(newThread.edit).toHaveBeenCalledWith({ name: '✅ [001] My Task', appliedTags: ['tag-closed'] });
|
|
180
|
+
expect(newThread.setName).not.toHaveBeenCalled();
|
|
181
|
+
expect(newThread.setArchived).toHaveBeenCalledWith(true);
|
|
182
|
+
});
|
|
183
|
+
it('falls through to rejection when task not found on threadUpdate', async () => {
|
|
184
|
+
vi.mocked(findTaskByThreadId).mockReturnValue(null);
|
|
185
|
+
const { listener } = setup();
|
|
186
|
+
const oldThread = makeThread({ ownerId: 'some-user', archived: true });
|
|
187
|
+
const newThread = makeThread({ ownerId: 'some-user', archived: false });
|
|
188
|
+
await listener(oldThread, newThread);
|
|
189
|
+
expect(newThread.send).toHaveBeenCalledWith(expect.stringContaining('bd create'));
|
|
190
|
+
expect(newThread.setArchived).toHaveBeenCalledWith(true);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { createTaskService } from './service.js';
|
|
2
|
+
import { ensureTaskSyncCoordinator, wireTaskStoreSyncTriggers } from './task-sync.js';
|
|
3
|
+
import { TASK_SYNC_TRIGGER_EVENTS } from './sync-contract.js';
|
|
4
|
+
import { loadTagMap } from './tag-map.js';
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Core initialization (no Discord client — context only)
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
/**
|
|
9
|
+
* Build a TaskContext if prerequisites are met, or return undefined with
|
|
10
|
+
* appropriate log warnings. This covers the "pre-bot" phase — before the
|
|
11
|
+
* Discord client is available. Forum guard and sync trigger subscriptions are wired
|
|
12
|
+
* separately after the bot connects.
|
|
13
|
+
*/
|
|
14
|
+
export async function initializeTasksContext(opts) {
|
|
15
|
+
if (!opts.enabled) {
|
|
16
|
+
return { taskCtx: undefined };
|
|
17
|
+
}
|
|
18
|
+
const effectiveForum = opts.systemTasksForumId || opts.tasksForum || '';
|
|
19
|
+
if (!effectiveForum) {
|
|
20
|
+
opts.log.warn('tasks: no forum resolved — set DISCORD_GUILD_ID or DISCOCLAW_TASKS_FORUM ' +
|
|
21
|
+
'(set DISCOCLAW_TASKS_ENABLED=0 to suppress)');
|
|
22
|
+
return { taskCtx: undefined };
|
|
23
|
+
}
|
|
24
|
+
const tagMapPath = opts.tasksTagMapPath || '';
|
|
25
|
+
const tagMap = await loadTagMap(tagMapPath);
|
|
26
|
+
const tasksSidebar = opts.tasksSidebar ?? false;
|
|
27
|
+
const tasksMentionUser = opts.tasksMentionUser;
|
|
28
|
+
const sidebarMentionUserId = tasksSidebar ? tasksMentionUser : undefined;
|
|
29
|
+
if (tasksSidebar && !tasksMentionUser) {
|
|
30
|
+
opts.log.warn('tasks:sidebar enabled but DISCOCLAW_TASKS_MENTION_USER not set; sidebar mentions will be inactive');
|
|
31
|
+
}
|
|
32
|
+
let store = opts.store;
|
|
33
|
+
if (!store) {
|
|
34
|
+
const { TaskStore } = await import('./store.js');
|
|
35
|
+
store = new TaskStore();
|
|
36
|
+
}
|
|
37
|
+
const taskCtx = {
|
|
38
|
+
tasksCwd: opts.tasksCwd || process.cwd(),
|
|
39
|
+
forumId: effectiveForum,
|
|
40
|
+
tagMap,
|
|
41
|
+
tagMapPath,
|
|
42
|
+
store,
|
|
43
|
+
taskService: createTaskService(store),
|
|
44
|
+
runtime: opts.runtime,
|
|
45
|
+
resolveModel: opts.resolveModel,
|
|
46
|
+
autoTag: opts.tasksAutoTag ?? true,
|
|
47
|
+
autoTagModel: opts.tasksAutoTagModel ?? 'fast',
|
|
48
|
+
mentionUserId: tasksMentionUser,
|
|
49
|
+
sidebarMentionUserId,
|
|
50
|
+
statusPoster: opts.statusPoster,
|
|
51
|
+
hasInFlightForChannel: opts.hasInFlightForChannel,
|
|
52
|
+
metrics: opts.metrics,
|
|
53
|
+
log: opts.log,
|
|
54
|
+
syncFailureRetryEnabled: opts.tasksSyncFailureRetryEnabled,
|
|
55
|
+
syncFailureRetryDelayMs: opts.tasksSyncFailureRetryDelayMs,
|
|
56
|
+
syncDeferredRetryDelayMs: opts.tasksSyncDeferredRetryDelayMs,
|
|
57
|
+
syncRunOptions: opts.syncRunOptions,
|
|
58
|
+
};
|
|
59
|
+
return { taskCtx };
|
|
60
|
+
}
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Post-connect wiring (store event subscriptions + startup sync)
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
export async function wireTaskSync(taskCtx, runCtx) {
|
|
65
|
+
const log = taskCtx.log;
|
|
66
|
+
if (!log) {
|
|
67
|
+
throw new Error('wireTaskSync requires taskCtx.log');
|
|
68
|
+
}
|
|
69
|
+
const syncCoordinator = await ensureTaskSyncCoordinator(taskCtx, runCtx);
|
|
70
|
+
// Startup sync: fire-and-forget to avoid blocking cron init
|
|
71
|
+
syncCoordinator.sync().catch((err) => {
|
|
72
|
+
log.warn({ err }, 'tasks:startup-sync failed');
|
|
73
|
+
});
|
|
74
|
+
const wiring = wireTaskStoreSyncTriggers(taskCtx, syncCoordinator, log);
|
|
75
|
+
log.info({ tasksCwd: taskCtx.tasksCwd, triggerEvents: TASK_SYNC_TRIGGER_EVENTS }, 'tasks:store-event sync triggers started');
|
|
76
|
+
return wiring;
|
|
77
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// Module mocks
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
vi.mock('./tag-map.js', () => ({
|
|
6
|
+
loadTagMap: vi.fn().mockResolvedValue({ bug: '111', feature: '222' }),
|
|
7
|
+
}));
|
|
8
|
+
vi.mock('./sync-coordinator.js', () => ({
|
|
9
|
+
TaskSyncCoordinator: vi.fn().mockImplementation(() => ({
|
|
10
|
+
sync: vi.fn().mockResolvedValue(undefined),
|
|
11
|
+
})),
|
|
12
|
+
}));
|
|
13
|
+
import { TaskSyncCoordinator } from './sync-coordinator.js';
|
|
14
|
+
import { initializeTasksContext, wireTaskSync } from './initialize.js';
|
|
15
|
+
import { TaskStore } from './store.js';
|
|
16
|
+
import { withDirectTaskLifecycle } from './task-lifecycle.js';
|
|
17
|
+
function fakeLog() {
|
|
18
|
+
return {
|
|
19
|
+
info: vi.fn(),
|
|
20
|
+
warn: vi.fn(),
|
|
21
|
+
error: vi.fn(),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function baseOpts(overrides = {}) {
|
|
25
|
+
return {
|
|
26
|
+
enabled: true,
|
|
27
|
+
tasksCwd: '/tmp/tasks',
|
|
28
|
+
tasksForum: 'forum-123',
|
|
29
|
+
tasksTagMapPath: '/tmp/tag-map.json',
|
|
30
|
+
tasksSidebar: false,
|
|
31
|
+
tasksAutoTag: true,
|
|
32
|
+
tasksAutoTagModel: 'haiku',
|
|
33
|
+
runtime: {},
|
|
34
|
+
resolveModel: (model) => model,
|
|
35
|
+
log: fakeLog(),
|
|
36
|
+
...overrides,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
describe('initializeTasksContext', () => {
|
|
40
|
+
it('returns undefined with no warnings when disabled', async () => {
|
|
41
|
+
const log = fakeLog();
|
|
42
|
+
const result = await initializeTasksContext(baseOpts({ enabled: false, log }));
|
|
43
|
+
expect(result.taskCtx).toBeUndefined();
|
|
44
|
+
expect(log.warn).not.toHaveBeenCalled();
|
|
45
|
+
});
|
|
46
|
+
it('returns undefined and warns when no forum resolved', async () => {
|
|
47
|
+
const log = fakeLog();
|
|
48
|
+
const result = await initializeTasksContext(baseOpts({
|
|
49
|
+
tasksForum: '',
|
|
50
|
+
systemTasksForumId: undefined,
|
|
51
|
+
log,
|
|
52
|
+
}));
|
|
53
|
+
expect(result.taskCtx).toBeUndefined();
|
|
54
|
+
expect(log.warn).toHaveBeenCalledWith(expect.stringContaining('no forum resolved'));
|
|
55
|
+
});
|
|
56
|
+
it('returns TaskContext when all prerequisites met', async () => {
|
|
57
|
+
const log = fakeLog();
|
|
58
|
+
const result = await initializeTasksContext(baseOpts({ log }));
|
|
59
|
+
expect(result.taskCtx).toBeDefined();
|
|
60
|
+
expect(result.taskCtx.forumId).toBe('forum-123');
|
|
61
|
+
expect(result.taskCtx.autoTag).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
it('resolves forum from systemTasksForumId when tasksForum is empty', async () => {
|
|
64
|
+
const result = await initializeTasksContext(baseOpts({
|
|
65
|
+
tasksForum: '',
|
|
66
|
+
systemTasksForumId: 'system-forum-456',
|
|
67
|
+
}));
|
|
68
|
+
expect(result.taskCtx).toBeDefined();
|
|
69
|
+
expect(result.taskCtx.forumId).toBe('system-forum-456');
|
|
70
|
+
});
|
|
71
|
+
it('sets sidebarMentionUserId when sidebar enabled with mention user', async () => {
|
|
72
|
+
const log = fakeLog();
|
|
73
|
+
const result = await initializeTasksContext(baseOpts({
|
|
74
|
+
tasksSidebar: true,
|
|
75
|
+
tasksMentionUser: 'user-789',
|
|
76
|
+
log,
|
|
77
|
+
}));
|
|
78
|
+
expect(result.taskCtx).toBeDefined();
|
|
79
|
+
expect(result.taskCtx.sidebarMentionUserId).toBe('user-789');
|
|
80
|
+
expect(log.warn).not.toHaveBeenCalled();
|
|
81
|
+
});
|
|
82
|
+
it('warns when sidebar enabled but mention user not set', async () => {
|
|
83
|
+
const log = fakeLog();
|
|
84
|
+
const result = await initializeTasksContext(baseOpts({
|
|
85
|
+
tasksSidebar: true,
|
|
86
|
+
tasksMentionUser: undefined,
|
|
87
|
+
log,
|
|
88
|
+
}));
|
|
89
|
+
expect(result.taskCtx).toBeDefined();
|
|
90
|
+
expect(result.taskCtx.sidebarMentionUserId).toBeUndefined();
|
|
91
|
+
expect(log.warn).toHaveBeenCalledWith(expect.stringContaining('sidebar mentions will be inactive'));
|
|
92
|
+
});
|
|
93
|
+
it('does not set sidebarMentionUserId when sidebar disabled', async () => {
|
|
94
|
+
const log = fakeLog();
|
|
95
|
+
const result = await initializeTasksContext(baseOpts({
|
|
96
|
+
tasksSidebar: false,
|
|
97
|
+
tasksMentionUser: 'user-789',
|
|
98
|
+
log,
|
|
99
|
+
}));
|
|
100
|
+
expect(result.taskCtx).toBeDefined();
|
|
101
|
+
expect(result.taskCtx.sidebarMentionUserId).toBeUndefined();
|
|
102
|
+
expect(log.warn).not.toHaveBeenCalled();
|
|
103
|
+
});
|
|
104
|
+
it('propagates tagMapPath to TaskContext', async () => {
|
|
105
|
+
const result = await initializeTasksContext(baseOpts({
|
|
106
|
+
tasksTagMapPath: '/my/custom/tag-map.json',
|
|
107
|
+
}));
|
|
108
|
+
expect(result.taskCtx).toBeDefined();
|
|
109
|
+
expect(result.taskCtx.tagMapPath).toBe('/my/custom/tag-map.json');
|
|
110
|
+
});
|
|
111
|
+
it('stores sync run options on TaskContext', async () => {
|
|
112
|
+
const result = await initializeTasksContext(baseOpts({
|
|
113
|
+
syncRunOptions: { skipPhase5: true },
|
|
114
|
+
}));
|
|
115
|
+
expect(result.taskCtx).toBeDefined();
|
|
116
|
+
expect(result.taskCtx.syncRunOptions).toEqual({ skipPhase5: true });
|
|
117
|
+
});
|
|
118
|
+
it('uses provided store instead of creating a new one', async () => {
|
|
119
|
+
const store = new TaskStore();
|
|
120
|
+
const result = await initializeTasksContext(baseOpts({ store }));
|
|
121
|
+
expect(result.taskCtx).toBeDefined();
|
|
122
|
+
expect(result.taskCtx.store).toBe(store);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
describe('wireTaskSync', () => {
|
|
126
|
+
it('wires coordinator and store event listeners', async () => {
|
|
127
|
+
const log = fakeLog();
|
|
128
|
+
const store = new TaskStore();
|
|
129
|
+
const taskCtx = {
|
|
130
|
+
tasksCwd: '/tmp/tasks',
|
|
131
|
+
forumId: 'forum-123',
|
|
132
|
+
tagMap: { bug: '111' },
|
|
133
|
+
sidebarMentionUserId: 'user-1',
|
|
134
|
+
store,
|
|
135
|
+
syncFailureRetryEnabled: false,
|
|
136
|
+
syncFailureRetryDelayMs: 12_000,
|
|
137
|
+
syncDeferredRetryDelayMs: 18_000,
|
|
138
|
+
log,
|
|
139
|
+
};
|
|
140
|
+
const client = {};
|
|
141
|
+
const guild = {};
|
|
142
|
+
const result = await wireTaskSync(taskCtx, { client, guild });
|
|
143
|
+
expect(TaskSyncCoordinator).toHaveBeenCalledWith(expect.objectContaining({
|
|
144
|
+
client,
|
|
145
|
+
guild,
|
|
146
|
+
forumId: 'forum-123',
|
|
147
|
+
mentionUserId: 'user-1',
|
|
148
|
+
enableFailureRetry: false,
|
|
149
|
+
failureRetryDelayMs: 12_000,
|
|
150
|
+
deferredRetryDelayMs: 18_000,
|
|
151
|
+
}));
|
|
152
|
+
// The coordinator's sync() should have been called (fire-and-forget startup sync).
|
|
153
|
+
const coordinatorInstance = vi.mocked(TaskSyncCoordinator).mock.results[0]?.value;
|
|
154
|
+
expect(coordinatorInstance.sync).toHaveBeenCalled();
|
|
155
|
+
expect(taskCtx.syncCoordinator).toBeDefined();
|
|
156
|
+
expect(result).toHaveProperty('stop');
|
|
157
|
+
expect(log.info).toHaveBeenCalledWith(expect.objectContaining({ tasksCwd: '/tmp/tasks' }), 'tasks:store-event sync triggers started');
|
|
158
|
+
});
|
|
159
|
+
it('store events trigger coordinator sync', async () => {
|
|
160
|
+
const log = fakeLog();
|
|
161
|
+
const store = new TaskStore({ prefix: 'test' });
|
|
162
|
+
const taskCtx = {
|
|
163
|
+
tasksCwd: '/tmp/tasks',
|
|
164
|
+
forumId: 'forum-123',
|
|
165
|
+
tagMap: { bug: '111' },
|
|
166
|
+
tagMapPath: '/tmp/tag-map.json',
|
|
167
|
+
store,
|
|
168
|
+
log,
|
|
169
|
+
};
|
|
170
|
+
vi.mocked(TaskSyncCoordinator).mockClear();
|
|
171
|
+
await wireTaskSync(taskCtx, { client: {}, guild: {} });
|
|
172
|
+
const coordinatorInstance = vi.mocked(TaskSyncCoordinator).mock.results[0]?.value;
|
|
173
|
+
// 'created' is intentionally NOT wired — taskCreate handles thread creation directly.
|
|
174
|
+
const callsBeforeCreate = coordinatorInstance.sync.mock.calls.length;
|
|
175
|
+
const task = store.create({ title: 'Test task' });
|
|
176
|
+
expect(coordinatorInstance.sync.mock.calls.length).toBe(callsBeforeCreate);
|
|
177
|
+
// 'updated' IS wired — should trigger sync.
|
|
178
|
+
const callsBeforeUpdate = coordinatorInstance.sync.mock.calls.length;
|
|
179
|
+
store.update(task.id, { title: 'Updated task' });
|
|
180
|
+
expect(coordinatorInstance.sync.mock.calls.length).toBeGreaterThan(callsBeforeUpdate);
|
|
181
|
+
});
|
|
182
|
+
it('does not trigger coordinator sync while direct task lifecycle ownership is active', async () => {
|
|
183
|
+
const log = fakeLog();
|
|
184
|
+
const store = new TaskStore({ prefix: 'test' });
|
|
185
|
+
const taskCtx = {
|
|
186
|
+
tasksCwd: '/tmp/tasks',
|
|
187
|
+
forumId: 'forum-123',
|
|
188
|
+
tagMap: { bug: '111' },
|
|
189
|
+
tagMapPath: '/tmp/tag-map.json',
|
|
190
|
+
store,
|
|
191
|
+
log,
|
|
192
|
+
};
|
|
193
|
+
vi.mocked(TaskSyncCoordinator).mockClear();
|
|
194
|
+
await wireTaskSync(taskCtx, { client: {}, guild: {} });
|
|
195
|
+
const coordinatorInstance = vi.mocked(TaskSyncCoordinator).mock.results[0]?.value;
|
|
196
|
+
const task = store.create({ title: 'Owned lifecycle task' });
|
|
197
|
+
const callsBeforeUpdate = coordinatorInstance.sync.mock.calls.length;
|
|
198
|
+
await withDirectTaskLifecycle(task.id, async () => {
|
|
199
|
+
store.update(task.id, { title: 'Updated while owned' });
|
|
200
|
+
});
|
|
201
|
+
expect(coordinatorInstance.sync.mock.calls.length).toBe(callsBeforeUpdate);
|
|
202
|
+
});
|
|
203
|
+
it('stop() removes store event listeners', async () => {
|
|
204
|
+
const log = fakeLog();
|
|
205
|
+
const store = new TaskStore({ prefix: 'test' });
|
|
206
|
+
const taskCtx = {
|
|
207
|
+
tasksCwd: '/tmp/tasks',
|
|
208
|
+
forumId: 'forum-123',
|
|
209
|
+
tagMap: { bug: '111' },
|
|
210
|
+
tagMapPath: '/tmp/tag-map.json',
|
|
211
|
+
store,
|
|
212
|
+
log,
|
|
213
|
+
};
|
|
214
|
+
vi.mocked(TaskSyncCoordinator).mockClear();
|
|
215
|
+
const result = await wireTaskSync(taskCtx, { client: {}, guild: {} });
|
|
216
|
+
result.stop();
|
|
217
|
+
const coordinatorInstance = vi.mocked(TaskSyncCoordinator).mock.results[0]?.value;
|
|
218
|
+
const callsAfterStop = coordinatorInstance.sync.mock.calls.length;
|
|
219
|
+
// After stop(), store mutations should NOT trigger additional syncs
|
|
220
|
+
const task = store.create({ title: 'Another task' });
|
|
221
|
+
store.update(task.id, { title: 'Modified' });
|
|
222
|
+
expect(coordinatorInstance.sync.mock.calls.length).toBe(callsAfterStop);
|
|
223
|
+
});
|
|
224
|
+
it('propagates tagMapPath to CoordinatorOptions', async () => {
|
|
225
|
+
const log = fakeLog();
|
|
226
|
+
const tagMap = { bug: '111' };
|
|
227
|
+
const store = new TaskStore();
|
|
228
|
+
const taskCtx = {
|
|
229
|
+
tasksCwd: '/tmp/tasks',
|
|
230
|
+
forumId: 'forum-123',
|
|
231
|
+
tagMap,
|
|
232
|
+
tagMapPath: '/config/tag-map.json',
|
|
233
|
+
store,
|
|
234
|
+
log,
|
|
235
|
+
};
|
|
236
|
+
await wireTaskSync(taskCtx, { client: {}, guild: {} });
|
|
237
|
+
expect(TaskSyncCoordinator).toHaveBeenCalledWith(expect.objectContaining({
|
|
238
|
+
tagMapPath: '/config/tag-map.json',
|
|
239
|
+
tagMap,
|
|
240
|
+
}));
|
|
241
|
+
});
|
|
242
|
+
it('uses TaskContext sync retry configuration in CoordinatorOptions', async () => {
|
|
243
|
+
const log = fakeLog();
|
|
244
|
+
const store = new TaskStore();
|
|
245
|
+
const taskCtx = {
|
|
246
|
+
tasksCwd: '/tmp/tasks',
|
|
247
|
+
forumId: 'forum-123',
|
|
248
|
+
tagMap: { bug: '111' },
|
|
249
|
+
syncFailureRetryEnabled: false,
|
|
250
|
+
syncFailureRetryDelayMs: 12_000,
|
|
251
|
+
syncDeferredRetryDelayMs: 18_000,
|
|
252
|
+
store,
|
|
253
|
+
log,
|
|
254
|
+
};
|
|
255
|
+
vi.mocked(TaskSyncCoordinator).mockClear();
|
|
256
|
+
await wireTaskSync(taskCtx, { client: {}, guild: {} });
|
|
257
|
+
expect(TaskSyncCoordinator).toHaveBeenCalledWith(expect.objectContaining({
|
|
258
|
+
enableFailureRetry: false,
|
|
259
|
+
failureRetryDelayMs: 12_000,
|
|
260
|
+
deferredRetryDelayMs: 18_000,
|
|
261
|
+
}));
|
|
262
|
+
});
|
|
263
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|