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,164 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
/**
|
|
5
|
+
* Collision-resistant lock name from a cron ID.
|
|
6
|
+
* Sanitizes non-alphanumeric chars and appends a short hash suffix
|
|
7
|
+
* to prevent collisions when different IDs sanitize to the same string
|
|
8
|
+
* (e.g. "a/b" vs "a_b").
|
|
9
|
+
*/
|
|
10
|
+
export function safeCronId(id) {
|
|
11
|
+
const sanitized = id.replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
12
|
+
const hash = crypto.createHash('sha256').update(id).digest('hex').slice(0, 8);
|
|
13
|
+
return `${sanitized}.${hash}`;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Read the Linux process start time from /proc/{pid}/stat (field 22, jiffies since boot).
|
|
17
|
+
* Returns null on non-Linux systems or on any read failure.
|
|
18
|
+
*/
|
|
19
|
+
export async function getProcessStartTime(pid) {
|
|
20
|
+
try {
|
|
21
|
+
const stat = await fs.readFile(`/proc/${pid}/stat`, 'utf-8');
|
|
22
|
+
// Fields are space-separated but field 2 (comm) may contain spaces and is wrapped in parens.
|
|
23
|
+
// Find the closing paren, then split the rest.
|
|
24
|
+
const closeParenIdx = stat.lastIndexOf(')');
|
|
25
|
+
if (closeParenIdx === -1)
|
|
26
|
+
return null;
|
|
27
|
+
const fields = stat.slice(closeParenIdx + 2).split(' ');
|
|
28
|
+
// After the closing paren: field 3=state(idx 0), ..., field 22=starttime(idx 19)
|
|
29
|
+
const startTime = Number(fields[19]);
|
|
30
|
+
return Number.isFinite(startTime) ? startTime : null;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** Grace period: lock dirs younger than this with missing meta.json are treated as initializing. */
|
|
37
|
+
const GRACE_PERIOD_MS = 2000;
|
|
38
|
+
function isPidAlive(pid) {
|
|
39
|
+
try {
|
|
40
|
+
process.kill(pid, 0);
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
const code = err.code;
|
|
45
|
+
if (code === 'EPERM')
|
|
46
|
+
return true; // process exists, no permission to signal
|
|
47
|
+
return false; // ESRCH = dead
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function generateToken() {
|
|
51
|
+
return crypto.randomBytes(16).toString('hex');
|
|
52
|
+
}
|
|
53
|
+
async function readMeta(lockPath) {
|
|
54
|
+
try {
|
|
55
|
+
const raw = await fs.readFile(path.join(lockPath, 'meta.json'), 'utf-8');
|
|
56
|
+
const parsed = JSON.parse(raw);
|
|
57
|
+
if (typeof parsed?.pid !== 'number' || typeof parsed?.token !== 'string')
|
|
58
|
+
return null;
|
|
59
|
+
return parsed;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
async function writeMeta(lockPath, meta) {
|
|
66
|
+
const metaPath = path.join(lockPath, 'meta.json');
|
|
67
|
+
const tmpPath = `${metaPath}.tmp.${process.pid}`;
|
|
68
|
+
await fs.writeFile(tmpPath, JSON.stringify(meta) + '\n', 'utf-8');
|
|
69
|
+
await fs.rename(tmpPath, metaPath);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Acquire a file-based lock for a cron job.
|
|
73
|
+
* Returns a token on success. Throws if the lock is held or initializing.
|
|
74
|
+
*/
|
|
75
|
+
export async function acquireCronLock(lockDir, cronId) {
|
|
76
|
+
const lockName = safeCronId(cronId);
|
|
77
|
+
const lockPath = path.join(lockDir, lockName + '.lock');
|
|
78
|
+
const token = generateToken();
|
|
79
|
+
const startTime = await getProcessStartTime(process.pid);
|
|
80
|
+
const meta = {
|
|
81
|
+
pid: process.pid,
|
|
82
|
+
token,
|
|
83
|
+
acquiredAt: new Date().toISOString(),
|
|
84
|
+
...(startTime != null ? { startTime } : {}),
|
|
85
|
+
};
|
|
86
|
+
// Attempt 1: try atomic mkdir.
|
|
87
|
+
try {
|
|
88
|
+
await fs.mkdir(lockPath);
|
|
89
|
+
await writeMeta(lockPath, meta);
|
|
90
|
+
return token;
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
if (err.code !== 'EEXIST')
|
|
94
|
+
throw err;
|
|
95
|
+
}
|
|
96
|
+
// Lock directory exists — check if it's stale.
|
|
97
|
+
const existingMeta = await readMeta(lockPath);
|
|
98
|
+
if (!existingMeta) {
|
|
99
|
+
// meta.json missing or corrupt — check grace period.
|
|
100
|
+
let dirAge = Infinity;
|
|
101
|
+
try {
|
|
102
|
+
const stat = await fs.stat(lockPath);
|
|
103
|
+
dirAge = Date.now() - stat.mtimeMs;
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// Can't stat — treat as old.
|
|
107
|
+
}
|
|
108
|
+
if (dirAge < GRACE_PERIOD_MS) {
|
|
109
|
+
throw new Error(`Lock initializing for "${cronId}" (dir age: ${Math.round(dirAge)}ms)`);
|
|
110
|
+
}
|
|
111
|
+
// Old enough with no valid meta — treat as corrupt/orphaned.
|
|
112
|
+
await fs.rm(lockPath, { recursive: true, force: true });
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
// Valid meta — check PID liveness + startTime.
|
|
116
|
+
const alive = isPidAlive(existingMeta.pid);
|
|
117
|
+
if (alive) {
|
|
118
|
+
// PID is alive — check startTime to detect PID reuse.
|
|
119
|
+
const existingStartTime = await getProcessStartTime(existingMeta.pid);
|
|
120
|
+
const metaHasStartTime = existingMeta.startTime != null;
|
|
121
|
+
const procHasStartTime = existingStartTime != null;
|
|
122
|
+
if (metaHasStartTime && procHasStartTime && existingMeta.startTime !== existingStartTime) {
|
|
123
|
+
// PID was reused — stale lock.
|
|
124
|
+
await fs.rm(lockPath, { recursive: true, force: true });
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
// PID alive and startTime matches (or unavailable on one/both sides) — lock is held.
|
|
128
|
+
throw new Error(`Lock held by PID ${existingMeta.pid} for "${cronId}"`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
// PID is dead — stale lock.
|
|
133
|
+
await fs.rm(lockPath, { recursive: true, force: true });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
// Attempt 2: retry mkdir after removing stale lock.
|
|
137
|
+
try {
|
|
138
|
+
await fs.mkdir(lockPath);
|
|
139
|
+
await writeMeta(lockPath, meta);
|
|
140
|
+
return token;
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
if (err.code === 'EEXIST') {
|
|
144
|
+
throw new Error(`Lock contention for "${cronId}" (lost race on retry)`);
|
|
145
|
+
}
|
|
146
|
+
throw err;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Release a file-based lock for a cron job.
|
|
151
|
+
* Only removes the lock if the token matches (prevents one process from
|
|
152
|
+
* releasing another's lock). Silent on mismatch or missing lock.
|
|
153
|
+
*/
|
|
154
|
+
export async function releaseCronLock(lockDir, cronId, token) {
|
|
155
|
+
const lockName = safeCronId(cronId);
|
|
156
|
+
const lockPath = path.join(lockDir, lockName + '.lock');
|
|
157
|
+
const meta = await readMeta(lockPath);
|
|
158
|
+
if (!meta)
|
|
159
|
+
return; // Lock dir missing or meta unreadable — nothing to do.
|
|
160
|
+
if (meta.token === token) {
|
|
161
|
+
await fs.rm(lockPath, { recursive: true, force: true });
|
|
162
|
+
}
|
|
163
|
+
// Token mismatch — another process owns it; leave it alone.
|
|
164
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import { safeCronId, acquireCronLock, releaseCronLock, getProcessStartTime } from './job-lock.js';
|
|
6
|
+
let tmpDir;
|
|
7
|
+
beforeEach(async () => {
|
|
8
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'job-lock-test-'));
|
|
9
|
+
});
|
|
10
|
+
afterEach(async () => {
|
|
11
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
12
|
+
});
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// safeCronId
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
describe('safeCronId', () => {
|
|
17
|
+
it('produces distinct names for IDs that sanitize identically', () => {
|
|
18
|
+
// "a/b" and "a_b" both sanitize to "a_b" but hashes differ.
|
|
19
|
+
const a = safeCronId('a/b');
|
|
20
|
+
const b = safeCronId('a_b');
|
|
21
|
+
expect(a).not.toBe(b);
|
|
22
|
+
});
|
|
23
|
+
it('preserves safe characters', () => {
|
|
24
|
+
const result = safeCronId('cron-abc_123.test');
|
|
25
|
+
expect(result).toMatch(/^cron-abc_123\.test\.[0-9a-f]{8}$/);
|
|
26
|
+
});
|
|
27
|
+
it('replaces unsafe characters', () => {
|
|
28
|
+
const result = safeCronId('my cron/job!@#');
|
|
29
|
+
expect(result).not.toContain(' ');
|
|
30
|
+
expect(result).not.toContain('/');
|
|
31
|
+
expect(result).not.toContain('!');
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// getProcessStartTime
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
describe('getProcessStartTime', () => {
|
|
38
|
+
it('returns a number for the current process (Linux) or null (non-Linux)', async () => {
|
|
39
|
+
const result = await getProcessStartTime(process.pid);
|
|
40
|
+
if (process.platform === 'linux') {
|
|
41
|
+
expect(typeof result).toBe('number');
|
|
42
|
+
expect(result).toBeGreaterThan(0);
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
expect(result).toBeNull();
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
it('returns null for a non-existent PID', async () => {
|
|
49
|
+
const result = await getProcessStartTime(999999999);
|
|
50
|
+
expect(result).toBeNull();
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// acquireCronLock
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
describe('acquireCronLock', () => {
|
|
57
|
+
it('acquires a fresh lock (directory + meta.json created)', async () => {
|
|
58
|
+
const token = await acquireCronLock(tmpDir, 'cron-test');
|
|
59
|
+
expect(typeof token).toBe('string');
|
|
60
|
+
expect(token.length).toBe(32); // 16 bytes hex
|
|
61
|
+
const lockPath = path.join(tmpDir, safeCronId('cron-test') + '.lock');
|
|
62
|
+
const stat = await fs.stat(lockPath);
|
|
63
|
+
expect(stat.isDirectory()).toBe(true);
|
|
64
|
+
const raw = await fs.readFile(path.join(lockPath, 'meta.json'), 'utf-8');
|
|
65
|
+
const meta = JSON.parse(raw);
|
|
66
|
+
expect(meta.pid).toBe(process.pid);
|
|
67
|
+
expect(meta.token).toBe(token);
|
|
68
|
+
expect(meta.acquiredAt).toBeDefined();
|
|
69
|
+
});
|
|
70
|
+
it('throws when PID alive and startTime matches (lock held)', async () => {
|
|
71
|
+
const token1 = await acquireCronLock(tmpDir, 'cron-held');
|
|
72
|
+
expect(token1).toBeDefined();
|
|
73
|
+
// Second acquire by same process — PID alive, startTime will match.
|
|
74
|
+
await expect(acquireCronLock(tmpDir, 'cron-held')).rejects.toThrow(/Lock held by PID/);
|
|
75
|
+
});
|
|
76
|
+
it('takes over when PID is dead (ESRCH)', async () => {
|
|
77
|
+
// Create a lock with a fake dead PID.
|
|
78
|
+
const lockPath = path.join(tmpDir, safeCronId('cron-dead') + '.lock');
|
|
79
|
+
await fs.mkdir(lockPath);
|
|
80
|
+
const fakeMeta = { pid: 999999999, token: 'old-token', acquiredAt: new Date().toISOString() };
|
|
81
|
+
await fs.writeFile(path.join(lockPath, 'meta.json'), JSON.stringify(fakeMeta));
|
|
82
|
+
const token = await acquireCronLock(tmpDir, 'cron-dead');
|
|
83
|
+
expect(typeof token).toBe('string');
|
|
84
|
+
// Verify the new lock has our PID.
|
|
85
|
+
const raw = await fs.readFile(path.join(lockPath, 'meta.json'), 'utf-8');
|
|
86
|
+
const meta = JSON.parse(raw);
|
|
87
|
+
expect(meta.pid).toBe(process.pid);
|
|
88
|
+
expect(meta.token).toBe(token);
|
|
89
|
+
});
|
|
90
|
+
it('takes over when PID alive but startTime mismatches (PID reuse)', async () => {
|
|
91
|
+
const lockPath = path.join(tmpDir, safeCronId('cron-reuse') + '.lock');
|
|
92
|
+
await fs.mkdir(lockPath);
|
|
93
|
+
// Use current PID (alive) but a bogus startTime that won't match.
|
|
94
|
+
const fakeMeta = {
|
|
95
|
+
pid: process.pid,
|
|
96
|
+
token: 'old-token',
|
|
97
|
+
acquiredAt: new Date().toISOString(),
|
|
98
|
+
startTime: 12345, // won't match real startTime
|
|
99
|
+
};
|
|
100
|
+
await fs.writeFile(path.join(lockPath, 'meta.json'), JSON.stringify(fakeMeta));
|
|
101
|
+
if (process.platform === 'linux') {
|
|
102
|
+
// On Linux, startTime will differ → stale takeover.
|
|
103
|
+
const token = await acquireCronLock(tmpDir, 'cron-reuse');
|
|
104
|
+
expect(typeof token).toBe('string');
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
// On non-Linux, getProcessStartTime returns null for both sides,
|
|
108
|
+
// and meta has a startTime while proc doesn't → they don't both have startTime,
|
|
109
|
+
// so it falls through to "lock held" (PID alive, can't confirm mismatch).
|
|
110
|
+
await expect(acquireCronLock(tmpDir, 'cron-reuse')).rejects.toThrow(/Lock held by PID/);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
it('blocks when lock dir exists, meta.json missing, dir < 2s old (grace period)', async () => {
|
|
114
|
+
const lockPath = path.join(tmpDir, safeCronId('cron-grace') + '.lock');
|
|
115
|
+
await fs.mkdir(lockPath);
|
|
116
|
+
// No meta.json, dir just created → grace period.
|
|
117
|
+
await expect(acquireCronLock(tmpDir, 'cron-grace')).rejects.toThrow(/Lock initializing/);
|
|
118
|
+
});
|
|
119
|
+
it('takes over when lock dir exists, meta.json missing, dir >= 2s old', async () => {
|
|
120
|
+
const lockPath = path.join(tmpDir, safeCronId('cron-old') + '.lock');
|
|
121
|
+
await fs.mkdir(lockPath);
|
|
122
|
+
// Backdate the directory mtime by 3 seconds.
|
|
123
|
+
const past = new Date(Date.now() - 3000);
|
|
124
|
+
await fs.utimes(lockPath, past, past);
|
|
125
|
+
const token = await acquireCronLock(tmpDir, 'cron-old');
|
|
126
|
+
expect(typeof token).toBe('string');
|
|
127
|
+
});
|
|
128
|
+
it('takes over when meta.json is corrupt (invalid JSON) and dir old', async () => {
|
|
129
|
+
const lockPath = path.join(tmpDir, safeCronId('cron-corrupt') + '.lock');
|
|
130
|
+
await fs.mkdir(lockPath);
|
|
131
|
+
await fs.writeFile(path.join(lockPath, 'meta.json'), 'not json!!!');
|
|
132
|
+
// Backdate directory.
|
|
133
|
+
const past = new Date(Date.now() - 3000);
|
|
134
|
+
await fs.utimes(lockPath, past, past);
|
|
135
|
+
const token = await acquireCronLock(tmpDir, 'cron-corrupt');
|
|
136
|
+
expect(typeof token).toBe('string');
|
|
137
|
+
});
|
|
138
|
+
it('simulates EEXIST contention then stale-takeover retry path', async () => {
|
|
139
|
+
// Pre-create a lock with a dead PID to ensure the EEXIST → stale → retry path.
|
|
140
|
+
const lockPath = path.join(tmpDir, safeCronId('cron-contention') + '.lock');
|
|
141
|
+
await fs.mkdir(lockPath);
|
|
142
|
+
const fakeMeta = { pid: 999999999, token: 'stale-token', acquiredAt: new Date().toISOString() };
|
|
143
|
+
await fs.writeFile(path.join(lockPath, 'meta.json'), JSON.stringify(fakeMeta));
|
|
144
|
+
// Should detect EEXIST, read meta, find dead PID, rm, retry, succeed.
|
|
145
|
+
const token = await acquireCronLock(tmpDir, 'cron-contention');
|
|
146
|
+
expect(typeof token).toBe('string');
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
// releaseCronLock
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
describe('releaseCronLock', () => {
|
|
153
|
+
it('removes lock when token matches', async () => {
|
|
154
|
+
const token = await acquireCronLock(tmpDir, 'cron-release');
|
|
155
|
+
const lockPath = path.join(tmpDir, safeCronId('cron-release') + '.lock');
|
|
156
|
+
// Lock exists.
|
|
157
|
+
await expect(fs.stat(lockPath)).resolves.toBeDefined();
|
|
158
|
+
await releaseCronLock(tmpDir, 'cron-release', token);
|
|
159
|
+
// Lock removed.
|
|
160
|
+
await expect(fs.stat(lockPath)).rejects.toThrow();
|
|
161
|
+
});
|
|
162
|
+
it('leaves lock intact when token does not match', async () => {
|
|
163
|
+
const token = await acquireCronLock(tmpDir, 'cron-mismatch');
|
|
164
|
+
const lockPath = path.join(tmpDir, safeCronId('cron-mismatch') + '.lock');
|
|
165
|
+
await releaseCronLock(tmpDir, 'cron-mismatch', 'wrong-token');
|
|
166
|
+
// Lock still exists.
|
|
167
|
+
const stat = await fs.stat(lockPath);
|
|
168
|
+
expect(stat.isDirectory()).toBe(true);
|
|
169
|
+
// And the original token is still in meta.json.
|
|
170
|
+
const raw = await fs.readFile(path.join(lockPath, 'meta.json'), 'utf-8');
|
|
171
|
+
const meta = JSON.parse(raw);
|
|
172
|
+
expect(meta.token).toBe(token);
|
|
173
|
+
});
|
|
174
|
+
it('is idempotent (no error if dir already missing)', async () => {
|
|
175
|
+
// No lock exists — should not throw.
|
|
176
|
+
await expect(releaseCronLock(tmpDir, 'cron-nonexistent', 'any-token')).resolves.toBeUndefined();
|
|
177
|
+
});
|
|
178
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { resolveModel } from '../runtime/model-tiers.js';
|
|
2
|
+
import { getDefaultTimezone } from './default-timezone.js';
|
|
3
|
+
function buildSystemPrompt() {
|
|
4
|
+
const defaultTz = getDefaultTimezone();
|
|
5
|
+
return `You are a cron definition parser. Extract a cron schedule from a natural-language task description.
|
|
6
|
+
|
|
7
|
+
Return ONLY valid JSON with these fields:
|
|
8
|
+
- schedule: 5-field cron expression (minute hour day-of-month month day-of-week)
|
|
9
|
+
- timezone: IANA timezone string (default "${defaultTz}" if not specified)
|
|
10
|
+
- channel: target Discord channel name (without #) or ID. If the user says "post to #general", channel is "general".
|
|
11
|
+
- prompt: the instruction text the bot should follow at each execution (rephrase as a direct instruction)
|
|
12
|
+
|
|
13
|
+
Rules:
|
|
14
|
+
- Use standard 5-field cron (no seconds). Examples: "0 7 * * 1-5" = weekdays at 7am, "*/5 * * * *" = every 5 minutes, "0 9 * * 1" = Mondays at 9am.
|
|
15
|
+
- Day-of-week: 0=Sunday, 1=Monday, ..., 6=Saturday. Range "1-5" = weekdays.
|
|
16
|
+
- If the user says "every minute", use "* * * * *".
|
|
17
|
+
- If no timezone is mentioned, default to "${defaultTz}".
|
|
18
|
+
- If no target channel is mentioned, set channel to "general".
|
|
19
|
+
- The prompt field should capture what the bot should do/say, not the scheduling part.
|
|
20
|
+
|
|
21
|
+
Return ONLY the JSON object, no markdown fences, no explanation.`;
|
|
22
|
+
}
|
|
23
|
+
export async function parseCronDefinition(text, runtime, opts) {
|
|
24
|
+
const prompt = `${buildSystemPrompt()}\n\nUser definition:\n${text}`;
|
|
25
|
+
let finalText = '';
|
|
26
|
+
let deltaText = '';
|
|
27
|
+
for await (const evt of runtime.invoke({
|
|
28
|
+
prompt,
|
|
29
|
+
model: resolveModel(opts?.model ?? 'fast', runtime.id),
|
|
30
|
+
cwd: opts?.cwd ?? process.cwd(),
|
|
31
|
+
timeoutMs: opts?.timeoutMs ?? 30_000,
|
|
32
|
+
tools: [],
|
|
33
|
+
})) {
|
|
34
|
+
if (evt.type === 'text_final') {
|
|
35
|
+
finalText = evt.text;
|
|
36
|
+
}
|
|
37
|
+
else if (evt.type === 'text_delta') {
|
|
38
|
+
deltaText += evt.text;
|
|
39
|
+
}
|
|
40
|
+
else if (evt.type === 'error') {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const output = finalText || deltaText;
|
|
45
|
+
// Strip markdown fences if present.
|
|
46
|
+
const cleaned = output.replace(/^```(?:json)?\s*/i, '').replace(/\s*```\s*$/, '').trim();
|
|
47
|
+
if (!cleaned)
|
|
48
|
+
return null;
|
|
49
|
+
try {
|
|
50
|
+
const parsed = JSON.parse(cleaned);
|
|
51
|
+
if (typeof parsed.schedule !== 'string' ||
|
|
52
|
+
typeof parsed.timezone !== 'string' ||
|
|
53
|
+
typeof parsed.channel !== 'string' ||
|
|
54
|
+
typeof parsed.prompt !== 'string') {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
triggerType: 'schedule',
|
|
59
|
+
schedule: parsed.schedule.trim(),
|
|
60
|
+
timezone: parsed.timezone.trim() || getDefaultTimezone(),
|
|
61
|
+
channel: parsed.channel.replace(/^#/, '').trim(),
|
|
62
|
+
prompt: parsed.prompt.trim(),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { parseCronDefinition } from './parser.js';
|
|
3
|
+
function makeMockRuntime(response) {
|
|
4
|
+
return {
|
|
5
|
+
id: 'claude_code',
|
|
6
|
+
capabilities: new Set(['streaming_text']),
|
|
7
|
+
async *invoke() {
|
|
8
|
+
yield { type: 'text_final', text: response };
|
|
9
|
+
yield { type: 'done' };
|
|
10
|
+
},
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
function makeMockRuntimeError() {
|
|
14
|
+
return {
|
|
15
|
+
id: 'claude_code',
|
|
16
|
+
capabilities: new Set(['streaming_text']),
|
|
17
|
+
async *invoke() {
|
|
18
|
+
yield { type: 'error', message: 'timeout' };
|
|
19
|
+
yield { type: 'done' };
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
describe('parseCronDefinition', () => {
|
|
24
|
+
it('parses a valid JSON response', async () => {
|
|
25
|
+
const runtime = makeMockRuntime(JSON.stringify({
|
|
26
|
+
schedule: '0 7 * * 1-5',
|
|
27
|
+
timezone: 'America/Los_Angeles',
|
|
28
|
+
channel: 'general',
|
|
29
|
+
prompt: 'Check the weather for Portland OR and post a brief summary.',
|
|
30
|
+
}));
|
|
31
|
+
const result = await parseCronDefinition('Every weekday at 7am Pacific, check the weather', runtime);
|
|
32
|
+
expect(result).toEqual({
|
|
33
|
+
triggerType: 'schedule',
|
|
34
|
+
schedule: '0 7 * * 1-5',
|
|
35
|
+
timezone: 'America/Los_Angeles',
|
|
36
|
+
channel: 'general',
|
|
37
|
+
prompt: 'Check the weather for Portland OR and post a brief summary.',
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
it('handles markdown-fenced JSON', async () => {
|
|
41
|
+
const json = JSON.stringify({
|
|
42
|
+
schedule: '* * * * *',
|
|
43
|
+
timezone: 'UTC',
|
|
44
|
+
channel: 'general',
|
|
45
|
+
prompt: 'Say hello.',
|
|
46
|
+
});
|
|
47
|
+
const runtime = makeMockRuntime('```json\n' + json + '\n```');
|
|
48
|
+
const result = await parseCronDefinition('Every minute, say hello to #general', runtime);
|
|
49
|
+
expect(result).toEqual({
|
|
50
|
+
triggerType: 'schedule',
|
|
51
|
+
schedule: '* * * * *',
|
|
52
|
+
timezone: 'UTC',
|
|
53
|
+
channel: 'general',
|
|
54
|
+
prompt: 'Say hello.',
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
it('strips # from channel name', async () => {
|
|
58
|
+
const runtime = makeMockRuntime(JSON.stringify({
|
|
59
|
+
schedule: '0 9 * * 1',
|
|
60
|
+
timezone: 'UTC',
|
|
61
|
+
channel: '#announcements',
|
|
62
|
+
prompt: 'Post weekly update.',
|
|
63
|
+
}));
|
|
64
|
+
const result = await parseCronDefinition('Every Monday at 9am, post to #announcements', runtime);
|
|
65
|
+
expect(result?.channel).toBe('announcements');
|
|
66
|
+
});
|
|
67
|
+
it('returns null on runtime error', async () => {
|
|
68
|
+
const runtime = makeMockRuntimeError();
|
|
69
|
+
const result = await parseCronDefinition('test', runtime);
|
|
70
|
+
expect(result).toBeNull();
|
|
71
|
+
});
|
|
72
|
+
it('returns null on empty output', async () => {
|
|
73
|
+
const runtime = makeMockRuntime('');
|
|
74
|
+
const result = await parseCronDefinition('test', runtime);
|
|
75
|
+
expect(result).toBeNull();
|
|
76
|
+
});
|
|
77
|
+
it('returns null on invalid JSON', async () => {
|
|
78
|
+
const runtime = makeMockRuntime('not json at all');
|
|
79
|
+
const result = await parseCronDefinition('test', runtime);
|
|
80
|
+
expect(result).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
it('returns null when required fields are missing', async () => {
|
|
83
|
+
const runtime = makeMockRuntime(JSON.stringify({
|
|
84
|
+
schedule: '0 7 * * *',
|
|
85
|
+
timezone: 'UTC',
|
|
86
|
+
}));
|
|
87
|
+
const result = await parseCronDefinition('test', runtime);
|
|
88
|
+
expect(result).toBeNull();
|
|
89
|
+
});
|
|
90
|
+
it('defaults timezone to UTC when empty and DEFAULT_TIMEZONE=UTC', async () => {
|
|
91
|
+
vi.stubEnv('DEFAULT_TIMEZONE', 'UTC');
|
|
92
|
+
const runtime = makeMockRuntime(JSON.stringify({
|
|
93
|
+
schedule: '0 7 * * *',
|
|
94
|
+
timezone: '',
|
|
95
|
+
channel: 'general',
|
|
96
|
+
prompt: 'Do something.',
|
|
97
|
+
}));
|
|
98
|
+
const result = await parseCronDefinition('test', runtime);
|
|
99
|
+
expect(result?.timezone).toBe('UTC');
|
|
100
|
+
vi.unstubAllEnvs();
|
|
101
|
+
});
|
|
102
|
+
it('defaults timezone to system timezone when empty and no env override', async () => {
|
|
103
|
+
vi.stubEnv('DEFAULT_TIMEZONE', '');
|
|
104
|
+
const systemTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
105
|
+
const runtime = makeMockRuntime(JSON.stringify({
|
|
106
|
+
schedule: '0 7 * * *',
|
|
107
|
+
timezone: '',
|
|
108
|
+
channel: 'general',
|
|
109
|
+
prompt: 'Do something.',
|
|
110
|
+
}));
|
|
111
|
+
const result = await parseCronDefinition('test', runtime);
|
|
112
|
+
expect(result?.timezone).toBe(systemTz);
|
|
113
|
+
vi.unstubAllEnvs();
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export class CronRunControl {
|
|
2
|
+
cancelers = new Map();
|
|
3
|
+
register(jobId, cancel) {
|
|
4
|
+
this.cancelers.set(jobId, cancel);
|
|
5
|
+
}
|
|
6
|
+
clear(jobId, cancel) {
|
|
7
|
+
const current = this.cancelers.get(jobId);
|
|
8
|
+
if (!current)
|
|
9
|
+
return;
|
|
10
|
+
if (!cancel || current === cancel) {
|
|
11
|
+
this.cancelers.delete(jobId);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
requestCancel(jobId) {
|
|
15
|
+
const cancel = this.cancelers.get(jobId);
|
|
16
|
+
if (!cancel)
|
|
17
|
+
return false;
|
|
18
|
+
cancel();
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
has(jobId) {
|
|
22
|
+
return this.cancelers.has(jobId);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { CronRunControl } from './run-control.js';
|
|
3
|
+
describe('CronRunControl', () => {
|
|
4
|
+
it('registers and cancels a running job', () => {
|
|
5
|
+
const control = new CronRunControl();
|
|
6
|
+
const cancel = vi.fn();
|
|
7
|
+
control.register('job-1', cancel);
|
|
8
|
+
expect(control.has('job-1')).toBe(true);
|
|
9
|
+
expect(control.requestCancel('job-1')).toBe(true);
|
|
10
|
+
expect(cancel).toHaveBeenCalledOnce();
|
|
11
|
+
});
|
|
12
|
+
it('clear with mismatched cancel function is ignored', () => {
|
|
13
|
+
const control = new CronRunControl();
|
|
14
|
+
const cancel = vi.fn();
|
|
15
|
+
control.register('job-1', cancel);
|
|
16
|
+
control.clear('job-1', () => { });
|
|
17
|
+
expect(control.has('job-1')).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
it('clear removes registered cancel function', () => {
|
|
20
|
+
const control = new CronRunControl();
|
|
21
|
+
const cancel = vi.fn();
|
|
22
|
+
control.register('job-1', cancel);
|
|
23
|
+
control.clear('job-1', cancel);
|
|
24
|
+
expect(control.has('job-1')).toBe(false);
|
|
25
|
+
expect(control.requestCancel('job-1')).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
});
|