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,144 @@
|
|
|
1
|
+
import { planTaskReconcileFromThreadSources, } from './task-sync-pipeline.js';
|
|
2
|
+
import { closeTaskThread, isTaskThreadAlreadyClosed } from './thread-ops.js';
|
|
3
|
+
import { extractShortIdFromThreadName, getThreadIdFromTask, shortTaskId, } from './thread-helpers.js';
|
|
4
|
+
function sleep(ms) {
|
|
5
|
+
const n = ms ?? 0;
|
|
6
|
+
if (n <= 0)
|
|
7
|
+
return Promise.resolve();
|
|
8
|
+
return new Promise((r) => setTimeout(r, n));
|
|
9
|
+
}
|
|
10
|
+
async function applyReconcileOrphanThread(ctx, operation, state) {
|
|
11
|
+
state.orphanThreadsFound++;
|
|
12
|
+
ctx.log?.info({ threadId: operation.thread.id, threadName: operation.thread.name, shortId: operation.shortId }, 'task-sync:phase5 orphan thread detected');
|
|
13
|
+
}
|
|
14
|
+
async function applyReconcileCollision(ctx, operation) {
|
|
15
|
+
ctx.log?.info({ threadId: operation.thread.id, shortId: operation.shortId, count: operation.collisionCount }, 'task-sync:phase5 short-id collision, skipping');
|
|
16
|
+
}
|
|
17
|
+
async function applyReconcileSkipMismatch(ctx, operation) {
|
|
18
|
+
ctx.log?.info({ taskId: operation.task?.id, threadId: operation.thread.id, existingThreadId: operation.existingThreadId }, 'task-sync:phase5 external_ref points to different thread, skipping');
|
|
19
|
+
}
|
|
20
|
+
async function applyReconcileArchiveActiveClosed(ctx, operation, state) {
|
|
21
|
+
const task = operation.task;
|
|
22
|
+
if (!task)
|
|
23
|
+
return;
|
|
24
|
+
if (ctx.hasInFlightForChannel(operation.thread.id)) {
|
|
25
|
+
ctx.counters.closesDeferred++;
|
|
26
|
+
ctx.log?.info({ taskId: task.id, threadId: operation.thread.id }, 'task-sync:phase5 close deferred (in-flight reply active)');
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (!operation.existingThreadId) {
|
|
30
|
+
try {
|
|
31
|
+
ctx.taskService.update(task.id, { externalRef: `discord:${operation.thread.id}` });
|
|
32
|
+
ctx.log?.info({ taskId: task.id, threadId: operation.thread.id }, 'task-sync:phase5 external_ref backfilled');
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
ctx.log?.warn({ err, taskId: task.id, threadId: operation.thread.id }, 'task-sync:phase5 external_ref backfill failed');
|
|
36
|
+
ctx.counters.warnings++;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
await closeTaskThread(ctx.client, operation.thread.id, task, ctx.tagMap, ctx.log);
|
|
41
|
+
state.threadsReconciled++;
|
|
42
|
+
ctx.log?.info({ taskId: task.id, threadId: operation.thread.id }, 'task-sync:phase5 reconciled (archived)');
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
ctx.log?.warn({ err, taskId: task.id, threadId: operation.thread.id }, 'task-sync:phase5 archive failed');
|
|
46
|
+
ctx.counters.warnings++;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
async function applyReconcileArchivedClosed(ctx, operation, state) {
|
|
50
|
+
const task = operation.task;
|
|
51
|
+
if (!task)
|
|
52
|
+
return;
|
|
53
|
+
try {
|
|
54
|
+
const alreadyClosed = await isTaskThreadAlreadyClosed(ctx.client, operation.thread.id, task, ctx.tagMap);
|
|
55
|
+
if (!alreadyClosed) {
|
|
56
|
+
if (ctx.hasInFlightForChannel(operation.thread.id)) {
|
|
57
|
+
ctx.counters.closesDeferred++;
|
|
58
|
+
ctx.log?.info({ taskId: task.id, threadId: operation.thread.id }, 'task-sync:phase5 close deferred (in-flight reply active)');
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
ctx.log?.info({ taskId: task.id, threadId: operation.thread.id }, 'task-sync:phase5 archived thread is stale, unarchiving to reconcile');
|
|
62
|
+
await closeTaskThread(ctx.client, operation.thread.id, task, ctx.tagMap, ctx.log);
|
|
63
|
+
state.threadsReconciled++;
|
|
64
|
+
ctx.log?.info({ taskId: task.id, threadId: operation.thread.id }, 'task-sync:phase5 reconciled (re-archived)');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
ctx.log?.warn({ err, taskId: task.id, threadId: operation.thread.id }, 'task-sync:phase5 archived reconcile failed');
|
|
70
|
+
ctx.counters.warnings++;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const RECONCILE_EXECUTORS = {
|
|
74
|
+
orphan: applyReconcileOrphanThread,
|
|
75
|
+
collision: applyReconcileCollision,
|
|
76
|
+
skip_external_ref_mismatch: applyReconcileSkipMismatch,
|
|
77
|
+
archive_active_closed: applyReconcileArchiveActiveClosed,
|
|
78
|
+
reconcile_archived_closed: applyReconcileArchivedClosed,
|
|
79
|
+
};
|
|
80
|
+
async function fetchPhase5ThreadSources(ctx) {
|
|
81
|
+
let activeThreads;
|
|
82
|
+
try {
|
|
83
|
+
const fetchedActive = await ctx.forum.threads.fetchActive();
|
|
84
|
+
activeThreads = fetchedActive.threads;
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
ctx.log?.warn({ err }, 'task-sync:phase5 failed to fetch active threads');
|
|
88
|
+
ctx.counters.warnings++;
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
let archivedThreads = new Map();
|
|
92
|
+
try {
|
|
93
|
+
const fetchedArchived = await ctx.forum.threads.fetchArchived();
|
|
94
|
+
archivedThreads = new Map(fetchedArchived.threads);
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
ctx.log?.warn({ err }, 'task-sync:phase5 failed to fetch archived threads');
|
|
98
|
+
ctx.counters.warnings++;
|
|
99
|
+
}
|
|
100
|
+
return { activeThreads, archivedThreads };
|
|
101
|
+
}
|
|
102
|
+
async function planPhase5ReconcileOperations(ctx, allTasks) {
|
|
103
|
+
const threadSources = await fetchPhase5ThreadSources(ctx);
|
|
104
|
+
if (!threadSources)
|
|
105
|
+
return null;
|
|
106
|
+
return planTaskReconcileFromThreadSources({
|
|
107
|
+
tasks: allTasks,
|
|
108
|
+
archivedThreads: threadSources.archivedThreads.values(),
|
|
109
|
+
activeThreads: threadSources.activeThreads.values(),
|
|
110
|
+
shortIdOfTaskId: shortTaskId,
|
|
111
|
+
shortIdFromThreadName: extractShortIdFromThreadName,
|
|
112
|
+
threadIdFromTask: getThreadIdFromTask,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
async function applyPhase5ReconcileOperations(ctx, operations, state) {
|
|
116
|
+
for (const operation of operations) {
|
|
117
|
+
await RECONCILE_EXECUTORS[operation.action](ctx, operation, state);
|
|
118
|
+
await sleep(ctx.throttleMs);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
async function applyPhase5ReconcileThreads(ctx, allTasks) {
|
|
122
|
+
const reconcileState = {
|
|
123
|
+
threadsReconciled: 0,
|
|
124
|
+
orphanThreadsFound: 0,
|
|
125
|
+
};
|
|
126
|
+
const plannedReconcileOps = await planPhase5ReconcileOperations(ctx, allTasks);
|
|
127
|
+
if (!plannedReconcileOps) {
|
|
128
|
+
return {
|
|
129
|
+
threadsReconciled: reconcileState.threadsReconciled,
|
|
130
|
+
orphanThreadsFound: reconcileState.orphanThreadsFound,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
await applyPhase5ReconcileOperations(ctx, plannedReconcileOps, reconcileState);
|
|
134
|
+
return {
|
|
135
|
+
threadsReconciled: reconcileState.threadsReconciled,
|
|
136
|
+
orphanThreadsFound: reconcileState.orphanThreadsFound,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
export async function runTaskSyncReconcilePhase(ctx, allTasks, opts) {
|
|
140
|
+
if (opts?.skipPhase5) {
|
|
141
|
+
return { threadsReconciled: 0, orphanThreadsFound: 0 };
|
|
142
|
+
}
|
|
143
|
+
return applyPhase5ReconcileThreads(ctx, allTasks);
|
|
144
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { TaskSyncCoordinator } from './sync-coordinator.js';
|
|
2
|
+
import { TASK_SYNC_TRIGGER_EVENTS } from './sync-contract.js';
|
|
3
|
+
import { isDirectTaskLifecycleActive } from './task-lifecycle.js';
|
|
4
|
+
export async function ensureTaskSyncCoordinator(taskCtx, runCtx) {
|
|
5
|
+
if (taskCtx.syncCoordinator)
|
|
6
|
+
return taskCtx.syncCoordinator;
|
|
7
|
+
const syncCoordinator = new TaskSyncCoordinator({
|
|
8
|
+
client: runCtx.client,
|
|
9
|
+
guild: runCtx.guild,
|
|
10
|
+
forumId: taskCtx.forumId,
|
|
11
|
+
tagMap: taskCtx.tagMap,
|
|
12
|
+
tagMapPath: taskCtx.tagMapPath,
|
|
13
|
+
store: taskCtx.store,
|
|
14
|
+
taskService: taskCtx.taskService,
|
|
15
|
+
log: taskCtx.log,
|
|
16
|
+
mentionUserId: taskCtx.sidebarMentionUserId,
|
|
17
|
+
forumCountSync: taskCtx.forumCountSync,
|
|
18
|
+
hasInFlightForChannel: taskCtx.hasInFlightForChannel,
|
|
19
|
+
metrics: taskCtx.metrics,
|
|
20
|
+
...(taskCtx.syncRunOptions ?? {}),
|
|
21
|
+
enableFailureRetry: taskCtx.syncFailureRetryEnabled,
|
|
22
|
+
failureRetryDelayMs: taskCtx.syncFailureRetryDelayMs,
|
|
23
|
+
deferredRetryDelayMs: taskCtx.syncDeferredRetryDelayMs,
|
|
24
|
+
});
|
|
25
|
+
taskCtx.syncCoordinator = syncCoordinator;
|
|
26
|
+
return syncCoordinator;
|
|
27
|
+
}
|
|
28
|
+
export async function runTaskSync(taskCtx, runCtx, statusPoster) {
|
|
29
|
+
const syncCoordinator = await ensureTaskSyncCoordinator(taskCtx, runCtx);
|
|
30
|
+
return syncCoordinator.sync(statusPoster);
|
|
31
|
+
}
|
|
32
|
+
export function wireTaskStoreSyncTriggers(taskCtx, syncCoordinator, log) {
|
|
33
|
+
const triggerSync = (eventName, taskId) => {
|
|
34
|
+
syncCoordinator.sync().catch((err) => {
|
|
35
|
+
log.warn({ err, eventName, taskId }, 'tasks:store-event sync failed');
|
|
36
|
+
});
|
|
37
|
+
};
|
|
38
|
+
const subscriptions = TASK_SYNC_TRIGGER_EVENTS.map((eventName) => {
|
|
39
|
+
const handler = (task) => {
|
|
40
|
+
const taskId = task?.id;
|
|
41
|
+
if (taskId && isDirectTaskLifecycleActive(taskId)) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
triggerSync(eventName, taskId);
|
|
45
|
+
};
|
|
46
|
+
taskCtx.store.on(eventName, handler);
|
|
47
|
+
return { eventName, handler };
|
|
48
|
+
});
|
|
49
|
+
return {
|
|
50
|
+
stop() {
|
|
51
|
+
for (const sub of subscriptions) {
|
|
52
|
+
taskCtx.store.off(sub.eventName, sub.handler);
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { TaskStore } from './store.js';
|
|
3
|
+
import { ensureTaskSyncCoordinator, runTaskSync, wireTaskStoreSyncTriggers, } from './task-sync.js';
|
|
4
|
+
import { withDirectTaskLifecycle } from './task-lifecycle.js';
|
|
5
|
+
vi.mock('./sync-coordinator.js', () => ({
|
|
6
|
+
TaskSyncCoordinator: vi.fn().mockImplementation(() => ({
|
|
7
|
+
sync: vi.fn(async () => ({
|
|
8
|
+
threadsCreated: 0,
|
|
9
|
+
emojisUpdated: 0,
|
|
10
|
+
starterMessagesUpdated: 0,
|
|
11
|
+
threadsArchived: 0,
|
|
12
|
+
statusesUpdated: 0,
|
|
13
|
+
tagsUpdated: 0,
|
|
14
|
+
warnings: 0,
|
|
15
|
+
})),
|
|
16
|
+
})),
|
|
17
|
+
}));
|
|
18
|
+
function makeTaskCtx() {
|
|
19
|
+
return {
|
|
20
|
+
forumId: 'forum-1',
|
|
21
|
+
tagMap: { feature: 'tag-1' },
|
|
22
|
+
tagMapPath: '/tmp/tag-map.json',
|
|
23
|
+
store: new TaskStore({ prefix: 'ws' }),
|
|
24
|
+
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
25
|
+
sidebarMentionUserId: 'user-123',
|
|
26
|
+
forumCountSync: { requestUpdate: vi.fn(), stop: vi.fn() },
|
|
27
|
+
syncFailureRetryEnabled: false,
|
|
28
|
+
syncFailureRetryDelayMs: 12_000,
|
|
29
|
+
syncDeferredRetryDelayMs: 18_000,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
function makeRunCtx() {
|
|
33
|
+
return { client: {}, guild: {} };
|
|
34
|
+
}
|
|
35
|
+
describe('task-sync coordinator helpers', () => {
|
|
36
|
+
it('creates and reuses coordinator on task context', async () => {
|
|
37
|
+
const { TaskSyncCoordinator } = await import('./sync-coordinator.js');
|
|
38
|
+
const taskCtx = makeTaskCtx();
|
|
39
|
+
taskCtx.syncRunOptions = { skipPhase5: true };
|
|
40
|
+
const first = await ensureTaskSyncCoordinator(taskCtx, makeRunCtx());
|
|
41
|
+
const second = await ensureTaskSyncCoordinator(taskCtx, makeRunCtx());
|
|
42
|
+
expect(first).toBe(second);
|
|
43
|
+
expect(TaskSyncCoordinator).toHaveBeenCalledOnce();
|
|
44
|
+
expect(TaskSyncCoordinator).toHaveBeenCalledWith(expect.objectContaining({
|
|
45
|
+
client: expect.anything(),
|
|
46
|
+
guild: expect.anything(),
|
|
47
|
+
forumId: 'forum-1',
|
|
48
|
+
tagMapPath: '/tmp/tag-map.json',
|
|
49
|
+
mentionUserId: 'user-123',
|
|
50
|
+
skipPhase5: true,
|
|
51
|
+
enableFailureRetry: false,
|
|
52
|
+
failureRetryDelayMs: 12_000,
|
|
53
|
+
deferredRetryDelayMs: 18_000,
|
|
54
|
+
}));
|
|
55
|
+
});
|
|
56
|
+
it('runTaskSync forwards statusPoster to coordinator sync', async () => {
|
|
57
|
+
const taskCtx = makeTaskCtx();
|
|
58
|
+
const statusPoster = { taskSyncComplete: vi.fn() };
|
|
59
|
+
const result = await runTaskSync(taskCtx, makeRunCtx(), statusPoster);
|
|
60
|
+
expect(result).toEqual(expect.objectContaining({ threadsCreated: 0 }));
|
|
61
|
+
expect(taskCtx.syncCoordinator.sync).toHaveBeenCalledWith(statusPoster);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
describe('wireTaskStoreSyncTriggers', () => {
|
|
65
|
+
it('wires only trigger events and skips direct-lifecycle-owned updates', async () => {
|
|
66
|
+
const taskCtx = makeTaskCtx();
|
|
67
|
+
const syncCoordinator = { sync: vi.fn(async () => null) };
|
|
68
|
+
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
|
69
|
+
const wired = wireTaskStoreSyncTriggers(taskCtx, syncCoordinator, log);
|
|
70
|
+
const task = taskCtx.store.create({ title: 'Test task' });
|
|
71
|
+
// created does not trigger sync
|
|
72
|
+
const callsAfterCreate = syncCoordinator.sync.mock.calls.length;
|
|
73
|
+
expect(callsAfterCreate).toBe(0);
|
|
74
|
+
// updated does trigger sync
|
|
75
|
+
taskCtx.store.update(task.id, { title: 'Updated' });
|
|
76
|
+
expect(syncCoordinator.sync.mock.calls.length).toBeGreaterThan(callsAfterCreate);
|
|
77
|
+
const callsBeforeOwnedUpdate = syncCoordinator.sync.mock.calls.length;
|
|
78
|
+
await withDirectTaskLifecycle(task.id, async () => {
|
|
79
|
+
taskCtx.store.update(task.id, { title: 'Owned update' });
|
|
80
|
+
});
|
|
81
|
+
expect(syncCoordinator.sync.mock.calls.length).toBe(callsBeforeOwnedUpdate);
|
|
82
|
+
wired.stop();
|
|
83
|
+
taskCtx.store.update(task.id, { title: 'Post-stop update' });
|
|
84
|
+
expect(syncCoordinator.sync.mock.calls.length).toBe(callsBeforeOwnedUpdate);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { getThreadIdFromTask } from './thread-helpers.js';
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// Thread → task lookup
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
/** Find a task by its Discord thread ID (matches via external_ref). */
|
|
6
|
+
export function findTaskByThreadId(threadId, store) {
|
|
7
|
+
const tasks = store.list({ status: 'all' });
|
|
8
|
+
return tasks.find((task) => getThreadIdFromTask(task) === threadId) ?? null;
|
|
9
|
+
}
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// In-memory TTL cache: Discord thread ID → task data
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
const DEFAULT_TTL_MS = 120_000; // 2 minutes
|
|
14
|
+
export class TaskThreadCache {
|
|
15
|
+
cache = new Map();
|
|
16
|
+
ttlMs;
|
|
17
|
+
constructor(ttlMs = DEFAULT_TTL_MS) {
|
|
18
|
+
this.ttlMs = ttlMs;
|
|
19
|
+
}
|
|
20
|
+
/** Get task for a thread ID (cached or fresh). Returns null if no task matches. */
|
|
21
|
+
async get(threadId, store) {
|
|
22
|
+
const entry = this.cache.get(threadId);
|
|
23
|
+
if (entry && Date.now() - entry.fetchedAt < this.ttlMs) {
|
|
24
|
+
return entry.task;
|
|
25
|
+
}
|
|
26
|
+
const tasks = store.list({ status: 'all' });
|
|
27
|
+
const task = tasks.find((item) => getThreadIdFromTask(item) === threadId) ?? null;
|
|
28
|
+
this.cache.set(threadId, { task, fetchedAt: Date.now() });
|
|
29
|
+
return task;
|
|
30
|
+
}
|
|
31
|
+
/** Invalidate one entry or all entries. */
|
|
32
|
+
invalidate(threadId) {
|
|
33
|
+
if (threadId) {
|
|
34
|
+
this.cache.delete(threadId);
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
this.cache.clear();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** Module-level singleton used by the bot process. */
|
|
42
|
+
export const taskThreadCache = new TaskThreadCache();
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { TaskStore } from './store.js';
|
|
3
|
+
import { TaskThreadCache, taskThreadCache, findTaskByThreadId, } from './thread-cache.js';
|
|
4
|
+
function makeStore(tasks) {
|
|
5
|
+
const store = new TaskStore({ prefix: 'ws' });
|
|
6
|
+
for (const { externalRef, title } of tasks) {
|
|
7
|
+
const b = store.create({ title: title ?? 'Test' });
|
|
8
|
+
store.update(b.id, { externalRef });
|
|
9
|
+
}
|
|
10
|
+
return store;
|
|
11
|
+
}
|
|
12
|
+
describe('TaskThreadCache', () => {
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
vi.clearAllMocks();
|
|
15
|
+
});
|
|
16
|
+
it('returns cached task within TTL', async () => {
|
|
17
|
+
const cache = new TaskThreadCache(60_000);
|
|
18
|
+
const store = makeStore([{ externalRef: 'discord:thread-1' }]);
|
|
19
|
+
const listSpy = vi.spyOn(store, 'list');
|
|
20
|
+
const first = await cache.get('thread-1', store);
|
|
21
|
+
expect(first).not.toBeNull();
|
|
22
|
+
expect(listSpy).toHaveBeenCalledTimes(1);
|
|
23
|
+
// Second call should use cache, not call store.list again.
|
|
24
|
+
const second = await cache.get('thread-1', store);
|
|
25
|
+
expect(second?.id).toBe(first?.id);
|
|
26
|
+
expect(listSpy).toHaveBeenCalledTimes(1);
|
|
27
|
+
});
|
|
28
|
+
it('refetches after TTL expires', async () => {
|
|
29
|
+
const cache = new TaskThreadCache(0); // 0ms TTL = always expired
|
|
30
|
+
const store = makeStore([{ externalRef: 'discord:thread-1' }]);
|
|
31
|
+
const listSpy = vi.spyOn(store, 'list');
|
|
32
|
+
const first = await cache.get('thread-1', store);
|
|
33
|
+
expect(first).not.toBeNull();
|
|
34
|
+
const second = await cache.get('thread-1', store);
|
|
35
|
+
expect(second?.id).toBe(first?.id);
|
|
36
|
+
expect(listSpy).toHaveBeenCalledTimes(2);
|
|
37
|
+
});
|
|
38
|
+
it('invalidate() clears all entries', async () => {
|
|
39
|
+
const cache = new TaskThreadCache(60_000);
|
|
40
|
+
const store = makeStore([
|
|
41
|
+
{ externalRef: 'discord:thread-1' },
|
|
42
|
+
{ externalRef: 'discord:thread-2' },
|
|
43
|
+
]);
|
|
44
|
+
const listSpy = vi.spyOn(store, 'list');
|
|
45
|
+
await cache.get('thread-1', store);
|
|
46
|
+
await cache.get('thread-2', store);
|
|
47
|
+
expect(listSpy).toHaveBeenCalledTimes(2);
|
|
48
|
+
cache.invalidate();
|
|
49
|
+
await cache.get('thread-1', store);
|
|
50
|
+
expect(listSpy).toHaveBeenCalledTimes(3);
|
|
51
|
+
});
|
|
52
|
+
it('invalidate(threadId) clears single entry', async () => {
|
|
53
|
+
const cache = new TaskThreadCache(60_000);
|
|
54
|
+
const store = makeStore([
|
|
55
|
+
{ externalRef: 'discord:thread-1' },
|
|
56
|
+
{ externalRef: 'discord:thread-2' },
|
|
57
|
+
]);
|
|
58
|
+
const listSpy = vi.spyOn(store, 'list');
|
|
59
|
+
await cache.get('thread-1', store);
|
|
60
|
+
await cache.get('thread-2', store);
|
|
61
|
+
expect(listSpy).toHaveBeenCalledTimes(2);
|
|
62
|
+
cache.invalidate('thread-1');
|
|
63
|
+
// thread-1 should refetch, thread-2 should still be cached.
|
|
64
|
+
await cache.get('thread-1', store);
|
|
65
|
+
await cache.get('thread-2', store);
|
|
66
|
+
expect(listSpy).toHaveBeenCalledTimes(3);
|
|
67
|
+
});
|
|
68
|
+
it('returns null when no task matches', async () => {
|
|
69
|
+
const cache = new TaskThreadCache(60_000);
|
|
70
|
+
const store = new TaskStore();
|
|
71
|
+
const result = await cache.get('thread-1', store);
|
|
72
|
+
expect(result).toBeNull();
|
|
73
|
+
});
|
|
74
|
+
it('caches null results (negative cache)', async () => {
|
|
75
|
+
const cache = new TaskThreadCache(60_000);
|
|
76
|
+
const store = new TaskStore();
|
|
77
|
+
const listSpy = vi.spyOn(store, 'list');
|
|
78
|
+
const first = await cache.get('thread-1', store);
|
|
79
|
+
expect(first).toBeNull();
|
|
80
|
+
const second = await cache.get('thread-1', store);
|
|
81
|
+
expect(second).toBeNull();
|
|
82
|
+
// Only one store.list call — the null was cached.
|
|
83
|
+
expect(listSpy).toHaveBeenCalledTimes(1);
|
|
84
|
+
});
|
|
85
|
+
it('exposes module-level singleton and lookup export', () => {
|
|
86
|
+
expect(taskThreadCache).toBeInstanceOf(TaskThreadCache);
|
|
87
|
+
expect(typeof findTaskByThreadId).toBe('function');
|
|
88
|
+
});
|
|
89
|
+
});
|