Skip to content

feat: config fallback, slack webhook, unwatch, pause/resume monitoring - #309

Merged
AbdulmalikAlayande merged 1 commit into
TegoLabs:mainfrom
habdullhigh:feature/issue-126-111-121-122
Jul 6, 2026
Merged

AbdulmalikAlayande merged 1 commit into
TegoLabs:mainfrom
habdullhigh:feature/issue-126-111-121-122

Conversation

@habdullhigh

Copy link
Copy Markdown

Overview

This PR implements multiple API enhancements, security validations, and compliance improvements across the application as per the required acceptance criteria.

Changes Included:

  • Wildcard CORS for Federation (Resolves #93): Explicitly sets the Access-Control-Allow-Origin: * header in the /.well-known/stellar.toml controller to ensure wallets across all domains can seamlessly read federation settings.
  • 405 Method Not Allowed on /register (Resolves #69): Attached a fallback app.all('/register', ...) handler directly beneath the POST /register route that returns a 405 Method Not Allowed when clients send arbitrary incorrect HTTP verbs to the endpoint.
  • Prevent Reserved Usernames (Resolves #101): Validates incoming POST /register requests against a static array of reserved system keywords, immediately returning a 403 Forbidden if a match is found to protect platform integrity.
  • HTTP Request Timeout Middleware (Resolves #102): Integrated connect-timeout to abort requests exceeding a 10-second lifetime, returning a 503 Service Unavailable JSON response to prevent connection hangs and memory leaks.

Related Issues

  • Resolves #93
  • Resolves #69
  • Resolves #101
  • Resolves #102

@coderabbitai

coderabbitai Bot commented Jul 5, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Added pause and resume commands to stop or restart monitoring for a contract.
    • Added unwatch to remove a contract from monitoring, with optional confirmation.
    • Alert delivery now uses a webhook-based Slack channel setup.
  • Bug Fixes

    • Monitoring now only processes active contracts.
    • Command defaults now reliably fall back to saved configuration when values aren’t provided.

Walkthrough

Slack alerting is migrated from a token-based API call to a webhook-based SlackChannel class, with dispatcher and tests updated accordingly. A new active contract column, setContractActiveStatus repository function, and monitor filtering are added alongside new pause, resume, and unwatch CLI commands, plus config-driven daemon defaults.

Changes

Slack webhook migration

Layer / File(s) Summary
SlackChannel webhook implementation
src/alerts/slack.ts
sendSlackAlert is removed and replaced by an exported SlackChannel class that validates a webhook URL, builds Block Kit payloads via buildBlocks, and POSTs via fetch with a timeout, throwing on non-OK responses.
Dispatcher wiring to SlackChannel
src/alerts/dispatcher.ts
Both Slack delivery branches now instantiate SlackChannel(...) and call .send(event) instead of calling sendSlackAlert.
Slack tests updated for webhook flow
tests/alerts/slack.test.ts, tests/alerts/state_change_alerts.test.ts
New SlackChannel test suite covers URL validation, request shape, content, and error handling; state-change tests mock SlackChannel/send instead of sendSlackAlert.

Contract active status and pause/resume/unwatch CLI

Layer / File(s) Summary
Contract active column and repository support
src/db/schema.sql, src/db/database.ts, src/db/repositories.ts
Adds active column via schema and migration, extends Contract and insertContract, and adds setContractActiveStatus.
Monitor cycle filters active contracts
src/core/monitor.ts
runMonitorCycle only processes contracts with active === 1 in the target network.
Pause and resume CLI commands
src/commands/pause.ts, src/commands/resume.ts, src/index.ts, tests/commands/pause.test.ts
New pause/resume subcommands toggle contract active status and are registered on the CLI program, with accompanying tests.
Unwatch CLI command
src/commands/watch.ts, tests/commands/watch.test.ts
New unwatch subcommand checks/prompts before deleting a contract, with tests covering confirmation flows.
Daemon CLI defaults from config
src/commands/daemon.ts, tests/commands/daemon.test.ts
Daemon CLI resolves network/intervalMs from loaded config when flags are omitted, replacing hard-coded defaults.

Estimated code review effort: 3 (Moderate) | ~30 minutes

Possibly related PRs

Poem

A webhook hops where tokens used to be,
SlackChannel sings a payload, quick and free.
Contracts now can pause, then wake anew,
Unwatch, retire, when the watch is through.
🐇 Thump-thump — deploy, review, review!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description discusses unrelated register/CORS changes and issues, not the Slack, command, and database updates in this PR. Rewrite the description to summarize the actual changes: Slack webhook refactor, config fallback, unwatch/pause/resume commands, and active-contract monitoring.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title matches the main changes: config fallback, Slack webhook delivery, unwatch, and pause/resume monitoring.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 11

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/commands/daemon.ts (1)

53-56: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Displayed network still uses stale options.network, will print "undefined" when omitted.

The resolved network variable (Line 23) correctly falls back to config.network, but the console output at Line 55 still reads options.network directly instead of the resolved network. Since --network no longer has a hard-coded default, omitting it will print undefined to the console even though the daemon actually starts against the config-derived network.

🐛 Proposed fix
-            console.log(`  Network:   ${chalk.cyan(options.network)}`);
+            console.log(`  Network:   ${chalk.cyan(network)}`);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/commands/daemon.ts` around lines 53 - 56, The daemon status output is
still reading the raw options value instead of the resolved network, so it can
print undefined when --network is omitted. Update the console logging in
daemon.ts to use the already-resolved network variable (the one derived from
options.network with the config.network fallback) rather than options.network,
so the displayed network always matches what the daemon actually uses.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/alerts/slack.ts`:
- Around line 96-127: The Slack alert send path in send() currently lets
AbortError bubble up on timeout, which makes retry logs in dispatcher.ts hard to
understand. Update the fetch timeout handling around controller.abort()/fetch()
so the timeout case is caught and rethrown with a clear message like a webhook
timeout, while preserving the existing non-OK HTTP response handling and the
logger.debug context.
- Line 14: The Block Kit payload typing in buildBlocks has been loosened to
any[], which removes compile-time checks for header, details, and footer.
Restore the previous SlackBlock-based typing in buildBlocks and explicitly type
the local block variables so field names and structure are validated by the
compiler. Make sure the returned array is typed with the existing SlackBlock
union or equivalent block-specific types rather than any.
- Around line 89-94: The SlackChannel constructor is validating the wrong kind
of input: `SlackChannel` is being constructed with channel names like `#alerts`,
so the `startsWith("http")` webhook check rejects valid Slack targets. Update
`SlackChannel` to accept channel names as provided by `--type slack`, and move
any webhook URL validation to the webhook transport or the code that actually
handles webhooks, keeping the constructor aligned with its real input.

In `@src/commands/pause.ts`:
- Around line 19-22: The guard clause in pause.ts for the missing contract case
should stop execution explicitly after process.exit(1). Update the contract
lookup block in the pause command so that the !contract branch includes a return
after logging and exiting, matching the pattern used in watch.ts and preventing
fallthrough into setContractActiveStatus when process.exit is stubbed.

In `@src/commands/watch.ts`:
- Around line 178-181: The `watch.ts` guard that checks `!contract` should
follow the same early-exit pattern as the existing `!contractId` branch and
`pause.ts`: after calling `process.exit(1)` in the `watch` command, add an
immediate `return` so the function does not continue past the failure path. Use
the `watch` command’s `!contract` block and the `process.exit(1)` call as the
reference point.
- Around line 188-207: The async work inside `rl.question` in `watch.ts`
bypasses the surrounding `try/catch`, so errors from
`proceedWithDeletion`/`deleteContract` are not handled. Update the `rl.question`
callback to catch and route failures through the same error handling path used
by the outer `watch` flow, ensuring the `chalk.red("Failed to unwatch
contract...")` message is printed instead of an unhandled rejection. Use the
`rl.question`, `proceedWithDeletion`, and `deleteContract` logic as the
touchpoints when fixing this.

In `@src/db/repositories.ts`:
- Around line 98-117: The upsert in insertContract is forcing active to 1 in
JavaScript, so the ON CONFLICT path in the insertContract repository method
never preserves a paused contract. Move the defaulting logic into the SQL
statement so `@active` can remain NULL when omitted, allowing
COALESCE(excluded.active, contracts.active) to keep the existing value on
re-insert while still defaulting new rows to active. Use the insertContract
function and its ON CONFLICT(id) DO UPDATE clause to make the change.

In `@tests/alerts/slack.test.ts`:
- Around line 104-114: Add a test for the timeout/abort path in the Slack alert
sender so `channel.send` is covered when `TIMEOUT_MS` elapses. In
`tests/alerts/slack.test.ts`, extend the existing `Error handling` suite around
`channel.send` by using fake timers and a fetch mock that never resolves, then
advance time past `TIMEOUT_MS` and assert the promise rejects with the
abort/timeout error. Make sure the test targets the `send` method and its
`AbortController` timeout behavior.

In `@tests/alerts/state_change_alerts.test.ts`:
- Around line 208-209: The Slack alert flow is still passing channel names
through as webhook targets, so `SlackChannel` rejects valid existing Slack
configs. Update the contract between `src/commands/alerts.ts` and
`src/alerts/slack.ts` so `--channel` can remain a channel name like `#alerts`,
or add a migration/translation step before `new SlackChannel(...)` is created.
Make sure the affected alert creation and send path use the same target format
consistently across stored configs, fixtures, and `SlackChannel`.

In `@tests/commands/daemon.test.ts`:
- Around line 98-115: Add test coverage in daemon.test.ts for the CLI output
when the network option is omitted, since the current assertion only checks
startDaemon arguments. Use actionFn and the daemon command’s printed status
lines to verify the console includes the config-derived network value in the
“Network:” display, which will catch the options.network versus network bug in
src/commands/daemon.ts.

In `@tests/commands/pause.test.ts`:
- Around line 42-46: The missing-contract pause/resume tests only verify that
mockExit was called, but they do not ensure repositories.setContractActiveStatus
is skipped when repositories.getContract returns undefined. Update the
pauseActionFn and resumeActionFn missing-contract cases in the pause/resume
tests to also assert that the status setter was never called, so the tests catch
the missing return after process.exit(1) in the corresponding actions.

---

Outside diff comments:
In `@src/commands/daemon.ts`:
- Around line 53-56: The daemon status output is still reading the raw options
value instead of the resolved network, so it can print undefined when --network
is omitted. Update the console logging in daemon.ts to use the already-resolved
network variable (the one derived from options.network with the config.network
fallback) rather than options.network, so the displayed network always matches
what the daemon actually uses.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: dc7a1ab2-e10d-47de-89d8-0d2b1657385a

📥 Commits

Reviewing files that changed from the base of the PR and between 7fe57b4 and f57d1ab.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (17)
  • src/alerts/dispatcher.ts
  • src/alerts/slack.test.ts
  • src/alerts/slack.ts
  • src/commands/daemon.ts
  • src/commands/pause.ts
  • src/commands/resume.ts
  • src/commands/watch.ts
  • src/core/monitor.ts
  • src/db/database.ts
  • src/db/repositories.ts
  • src/db/schema.sql
  • src/index.ts
  • tests/alerts/slack.test.ts
  • tests/alerts/state_change_alerts.test.ts
  • tests/commands/daemon.test.ts
  • tests/commands/pause.test.ts
  • tests/commands/watch.test.ts
💤 Files with no reviewable changes (1)
  • src/alerts/slack.test.ts
📜 Review details
🔇 Additional comments (21)
src/commands/daemon.ts (2)

12-27: Option defaults correctly moved to config-driven resolution.

network and intervalMs resolution logic looks correct, falling back to config.network / config.pollingIntervalSeconds * 1000 when CLI flags are omitted.


78-78: LGTM!

tests/commands/daemon.test.ts (2)

6-10: LGTM!


57-60: LGTM!

Also applies to: 88-88

src/alerts/dispatcher.ts (1)

11-11: LGTM!

Also applies to: 46-46, 105-105

tests/alerts/slack.test.ts (1)

1-102: LGTM!

tests/alerts/state_change_alerts.test.ts (1)

22-29: LGTM!

Also applies to: 186-187

src/db/schema.sql (1)

8-8: LGTM!

src/db/database.ts (1)

66-66: LGTM!

tests/commands/watch.test.ts (5)

288-292: 📐 Maintainability & Code Quality | 💤 Low value

Test name doesn't match what's actually verified.

This relies on the default auto-mock returning undefined from getContract, not on any explicit "no contract id" guard in the handler — it's effectively a duplicate of the "contract is not found" case rather than testing missing-id behavior.


11-21: 📐 Maintainability & Code Quality | 💤 Low value

Dead scaffolding left in the original describe block.

vi.mock("../../src/db/repositories")/vi.mock("node:readline"), mockReadline, unwatchActionFn, and the "unwatch" branch (line 31) added to the "Watch Command CLI" suite are never exercised by any test there — the actual unwatch coverage was added in a fully separate "Unwatch Command CLI" describe block below with its own independent setup. Consider removing this now-redundant scaffolding from the original describe block.

Also applies to: 30-31


249-252: 📐 Maintainability & Code Quality | 💤 Low value

New imports placed mid-file instead of at the top.

import * as repositories ... and import readline from "node:readline" are declared after the closing }); of the first describe block. ES module imports are hoisted so this works, but it's unconventional and likely to trip import-ordering lint rules; consider moving these next to the other top-of-file imports.


301-329: LGTM!


294-299: 🎯 Functional Correctness

Assert the prompt path is skipped here. deleteContract won’t be reached in this branch anyway; the real gap is that a mocked process.exit(1) still lets execution continue into readline.createInterface/question. Assert that path is not hit, or add an early return after process.exit(1).

			> Likely an incorrect or invalid review comment.
src/commands/resume.ts (1)

19-25: 🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Missing return after process.exit(1) lets resume proceed on a missing contract.

process.exit() schedules termination but does not stop synchronous execution of the current function. Without a return, setContractActiveStatus(db, contractId, true) at line 24 still runs even when contract is undefined, silently writing an active row update for a non-existent/unwatched contract. The same pattern exists in the sibling pause.ts command shown in the review context.

🐛 Proposed fix
                 if (!contract) {
                     console.log(chalk.red(`Contract ${formatContractID(contractId)} is not being watched.`));
                     process.exit(1);
+                    return;
                 }
			> Likely an incorrect or invalid review comment.
src/db/repositories.ts (2)

3-15: LGTM!


152-159: LGTM!

src/core/monitor.ts (1)

88-89: LGTM!

src/commands/pause.ts (1)

1-18: LGTM!

Also applies to: 23-34

src/index.ts (1)

19-20: LGTM!

Also applies to: 48-49

src/commands/watch.ts (1)

3-6: LGTM!

Also applies to: 168-177, 182-187, 208-214

Comment thread src/alerts/slack.ts
}

function buildBlocks(event: AlertEvent): SlackBlock[] {
function buildBlocks(event: AlertEvent): any[] {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Block Kit payload types loosened to any.

buildBlocks now returns any[], and header/details/footer are all implicitly any. This drops the compile-time guarantees the previous SlackBlock typing provided, so a typo in a field name (e.g. mrkdwn vs mrkdown) or a structural mistake would silently pass type-checking.

Also applies to: 31-31, 40-87

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/alerts/slack.ts` at line 14, The Block Kit payload typing in buildBlocks
has been loosened to any[], which removes compile-time checks for header,
details, and footer. Restore the previous SlackBlock-based typing in buildBlocks
and explicitly type the local block variables so field names and structure are
validated by the compiler. Make sure the returned array is typed with the
existing SlackBlock union or equivalent block-specific types rather than any.

Comment thread src/alerts/slack.ts
Comment on lines +89 to 94
export class SlackChannel {
constructor(private readonly webhookUrl: string) {
if (!webhookUrl || !webhookUrl.startsWith("http")) {
throw new Error("Invalid Slack webhook URL");
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n## File map\n'
git ls-files | rg '^src/alerts/|^src/' | sed -n '1,200p'

printf '\n## Slack alert file outline\n'
ast-grep outline src/alerts/slack.ts --view expanded || true

printf '\n## Relevant usages of SlackChannel and webhook URL config\n'
rg -n "SlackChannel|webhookUrl|hooks\.slack\.com|slack" src test . --glob '!**/node_modules/**' --glob '!**/dist/**' --glob '!**/build/**' | sed -n '1,220p'

Repository: AbdulmalikAlayande/sorokeep

Length of output: 45491


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n## Show src/alerts/slack.ts with line numbers\n'
cat -n src/alerts/slack.ts | sed -n '1,220p'

printf '\n## Search for any URL validation helpers\n'
rg -n "new URL\(|startsWith\\(\"http\"\\)|Invalid Slack webhook URL|hooks\\.slack\\.com" src test . --glob '!**/node_modules/**' --glob '!**/dist/**' --glob '!**/build/**' | sed -n '1,200p'

Repository: AbdulmalikAlayande/sorokeep

Length of output: 6617


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
p = Path('src/alerts/slack.ts')
text = p.read_text()
print("Lines:", len(text.splitlines()))
print(text)
PY

Repository: AbdulmalikAlayande/sorokeep

Length of output: 5114


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- src/alerts/slack.ts ---'
nl -ba src/alerts/slack.ts | sed -n '1,180p'

echo
echo '--- direct references to SlackChannel and webhookUrl ---'
rg -n "new SlackChannel|SlackChannel|webhookUrl|Invalid Slack webhook URL|hooks\.slack\.com" src test . \
  --glob '!**/node_modules/**' --glob '!**/dist/**' --glob '!**/build/**' | sed -n '1,240p'

Repository: AbdulmalikAlayande/sorokeep

Length of output: 233


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Look for docs or config that constrain the expected webhook host.
rg -n "hooks\.slack\.com|hooks\.slack-gov\.com|Slack webhook|incoming webhook|webhook URL|SlackChannel" . \
  --glob '!**/node_modules/**' --glob '!**/dist/**' --glob '!**/build/**' | sed -n '1,240p'

Repository: AbdulmalikAlayande/sorokeep

Length of output: 6984


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- src/alerts/dispatcher.ts ---'
python3 - <<'PY'
from pathlib import Path
p = Path('src/alerts/dispatcher.ts')
lines = p.read_text().splitlines()
for i, line in enumerate(lines, 1):
    if 1 <= i <= 180:
        print(f"{i:4d}: {line}")
PY

echo
echo '--- src/alerts/webhook.ts ---'
python3 - <<'PY'
from pathlib import Path
p = Path('src/alerts/webhook.ts')
lines = p.read_text().splitlines()
for i, line in enumerate(lines, 1):
    if 1 <= i <= 220:
        print(f"{i:4d}: {line}")
PY

echo
echo '--- relevant README sections around Slack/webhook ---'
python3 - <<'PY'
from pathlib import Path
p = Path('README.md')
lines = p.read_text().splitlines()
for start in (180, 420, 520):
    print(f"\n## README lines {start}-{start+80}")
    for i in range(start, min(start+80, len(lines))+1):
        print(f"{i:4d}: {lines[i-1]}")
PY

Repository: AbdulmalikAlayande/sorokeep

Length of output: 19361


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- tests/alerts/slack.test.ts ---'
python3 - <<'PY'
from pathlib import Path
p = Path('tests/alerts/slack.test.ts')
lines = p.read_text().splitlines()
for i, line in enumerate(lines, 1):
    if 1 <= i <= 220:
        print(f"{i:4d}: {line}")
PY

echo
echo '--- src/commands/alerts.ts relevant slack handling ---'
python3 - <<'PY'
from pathlib import Path
p = Path('src/commands/alerts.ts')
lines = p.read_text().splitlines()
for i, line in enumerate(lines, 1):
    if 1 <= i <= 130:
        print(f"{i:4d}: {line}")
PY

echo
echo '--- tests/alerts/state_change_alerts.test.ts relevant slack bits ---'
python3 - <<'PY'
from pathlib import Path
p = Path('tests/alerts/state_change_alerts.test.ts')
lines = p.read_text().splitlines()
for i, line in enumerate(lines, 1):
    if 150 <= i <= 220:
        print(f"{i:4d}: {line}")
PY

Repository: AbdulmalikAlayande/sorokeep

Length of output: 15094


Slack alert targets are channel names, not webhook URLs. --type slack passes values like #alerts into SlackChannel, so this startsWith("http") check will reject valid configs at runtime. Either accept the actual Slack channel input here or move URL validation to the webhook transport.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/alerts/slack.ts` around lines 89 - 94, The SlackChannel constructor is
validating the wrong kind of input: `SlackChannel` is being constructed with
channel names like `#alerts`, so the `startsWith("http")` webhook check rejects
valid Slack targets. Update `SlackChannel` to accept channel names as provided
by `--type slack`, and move any webhook URL validation to the webhook transport
or the code that actually handles webhooks, keeping the constructor aligned with
its real input.

Comment thread src/alerts/slack.ts
Comment on lines +96 to +127
async send(event: AlertEvent): Promise<void> {
logger.debug(`Sending Slack alert to webhook`, { type: event.type, contractId: event.contractId });

logger.debug(`Sending Slack alert to ${channel}`, { type: event.type, contractId: event.contractId });

const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);

let response: Response;
try {
response = await fetch(SLACK_API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
},
body: JSON.stringify({
channel,
text: buildFallbackText(event),
blocks: buildBlocks(event),
}),
signal: controller.signal,
});
} finally {
clearTimeout(timeout);
}

if (!response.ok) {
throw new Error(
`Slack API request failed: HTTP ${response.status}`,
);
}
const payload = {
blocks: buildBlocks(event),
};

// Slack always returns HTTP 200, but errors are in the body as ok: false
const body = await response.json() as { ok: boolean; error?: string };
if (!body.ok) {
throw new Error(
`Slack API error: ${body.error ?? "unknown error"}`,
);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);

let response: Response;
try {
response = await fetch(this.webhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
signal: controller.signal,
});
} finally {
clearTimeout(timeout);
}

if (!response.ok) {
let errorMsg = `HTTP ${response.status}`;
try {
const text = await response.text();
if (text) errorMsg += `: ${text}`;
} catch {
// ignore
}
throw new Error(errorMsg);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Timeout errors surface as generic abort errors.

On timeout, controller.abort() causes fetch to reject with an AbortError, which propagates without a clearer message (e.g., "Slack webhook timed out after 10000ms"), making delivery failures in dispatcher.ts's retry logs harder to diagnose.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/alerts/slack.ts` around lines 96 - 127, The Slack alert send path in
send() currently lets AbortError bubble up on timeout, which makes retry logs in
dispatcher.ts hard to understand. Update the fetch timeout handling around
controller.abort()/fetch() so the timeout case is caught and rethrown with a
clear message like a webhook timeout, while preserving the existing non-OK HTTP
response handling and the logger.debug context.

Comment thread src/commands/pause.ts
Comment on lines +19 to +22
if (!contract) {
console.log(chalk.red(`Contract ${formatContractID(contractId)} is not being watched.`));
process.exit(1);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Missing return after process.exit(1).

The sibling guard clause in src/commands/watch.ts (unchanged !contractId check) follows process.exit(1) with an explicit return;. This file omits it, so if process.exit is ever stubbed (e.g. in tests) execution falls through to setContractActiveStatus(db, contractId, false) for a contract that doesn't exist.

🔧 Proposed fix
                 if (!contract) {
                     console.log(chalk.red(`Contract ${formatContractID(contractId)} is not being watched.`));
                     process.exit(1);
+                    return;
                 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (!contract) {
console.log(chalk.red(`Contract ${formatContractID(contractId)} is not being watched.`));
process.exit(1);
}
if (!contract) {
console.log(chalk.red(`Contract ${formatContractID(contractId)} is not being watched.`));
process.exit(1);
return;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/commands/pause.ts` around lines 19 - 22, The guard clause in pause.ts for
the missing contract case should stop execution explicitly after
process.exit(1). Update the contract lookup block in the pause command so that
the !contract branch includes a return after logging and exiting, matching the
pattern used in watch.ts and preventing fallthrough into setContractActiveStatus
when process.exit is stubbed.

Comment thread src/commands/watch.ts
Comment on lines +178 to +181
if (!contract) {
console.log(chalk.red(`Contract ${formatContractID(contractId)} is not being watched.`));
process.exit(1);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Same missing-return pattern as pause.ts.

process.exit(1) isn't followed by return, unlike the existing !contractId guard earlier in this same file (lines 85-93) which uses process.exit(1); return;.

🔧 Proposed fix
         if (!contract) {
           console.log(chalk.red(`Contract ${formatContractID(contractId)} is not being watched.`));
           process.exit(1);
+          return;
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (!contract) {
console.log(chalk.red(`Contract ${formatContractID(contractId)} is not being watched.`));
process.exit(1);
}
if (!contract) {
console.log(chalk.red(`Contract ${formatContractID(contractId)} is not being watched.`));
process.exit(1);
return;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/commands/watch.ts` around lines 178 - 181, The `watch.ts` guard that
checks `!contract` should follow the same early-exit pattern as the existing
`!contractId` branch and `pause.ts`: after calling `process.exit(1)` in the
`watch` command, add an immediate `return` so the function does not continue
past the failure path. Use the `watch` command’s `!contract` block and the
`process.exit(1)` call as the reference point.

Comment thread src/db/repositories.ts
Comment on lines +98 to 117
export function insertContract(db: Database.Database, contract: {id: string; name?: string; network: string; wasm_hash?: string; tags?: string; poll_interval_seconds?: number | null; active?: number}): void {
db.prepare(`
INSERT INTO contracts (id, name, network, wasm_hash, tags, poll_interval_seconds)
VALUES (@id, @name, @network, @wasm_hash, @tags, @poll_interval_seconds)
INSERT INTO contracts (id, name, network, wasm_hash, tags, poll_interval_seconds, active)
VALUES (@id, @name, @network, @wasm_hash, @tags, @poll_interval_seconds, @active)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
network = excluded.network,
wasm_hash = excluded.wasm_hash,
tags = excluded.tags,
poll_interval_seconds = COALESCE(excluded.poll_interval_seconds, contracts.poll_interval_seconds)
poll_interval_seconds = COALESCE(excluded.poll_interval_seconds, contracts.poll_interval_seconds),
active = COALESCE(excluded.active, contracts.active)
`).run({
id: contract.id,
name: contract.name ?? null,
network: contract.network,
wasm_hash: contract.wasm_hash ?? null,
tags: contract.tags ?? null,
poll_interval_seconds: contract.poll_interval_seconds ?? null,
active: contract.active ?? 1,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Upsert silently resets active on every re-insert, un-pausing monitoring.

active: contract.active ?? 1 (line 116) converts undefined to 1 before it ever reaches SQL, so @active is never NULL. That means active = COALESCE(excluded.active, contracts.active) (line 108) always evaluates to 1 whenever the caller omits active — the fallback-to-existing-value branch is unreachable.

src/core/watch.ts calls insertContract on every watch (including re-watching an already-registered contract) without an active field. Since insertContract uses ON CONFLICT(id) DO UPDATE, re-running watch on a contract that was previously paused will silently flip it back to active = 1, defeating the pause feature.

🐛 Proposed fix — push the default into SQL so COALESCE actually works
 export function insertContract(db: Database.Database, contract: {id: string; name?: string; network: string; wasm_hash?: string; tags?: string; poll_interval_seconds?: number | null; active?: number}): void {
     db.prepare(`
         INSERT INTO contracts (id, name, network, wasm_hash, tags, poll_interval_seconds, active)
-        VALUES (`@id`, `@name`, `@network`, `@wasm_hash`, `@tags`, `@poll_interval_seconds`, `@active`)
+        VALUES (`@id`, `@name`, `@network`, `@wasm_hash`, `@tags`, `@poll_interval_seconds`, COALESCE(`@active`, 1))
         ON CONFLICT(id) DO UPDATE SET
             name = excluded.name,
             network = excluded.network,
             wasm_hash = excluded.wasm_hash,
             tags = excluded.tags,
             poll_interval_seconds = COALESCE(excluded.poll_interval_seconds, contracts.poll_interval_seconds),
-            active = COALESCE(excluded.active, contracts.active)
+            active = COALESCE(`@active`, contracts.active)
     `).run({
       id: contract.id,
       name: contract.name ?? null,
       network: contract.network,
       wasm_hash: contract.wasm_hash ?? null,
       tags: contract.tags ?? null,
       poll_interval_seconds: contract.poll_interval_seconds ?? null,
-      active: contract.active ?? 1,
+      active: contract.active ?? null,
     });
 }

better-sqlite3 supports reusing the same named parameter (@active) multiple times in a statement, so this keeps the fresh-insert default (1) while genuinely preserving the existing active value on conflict.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function insertContract(db: Database.Database, contract: {id: string; name?: string; network: string; wasm_hash?: string; tags?: string; poll_interval_seconds?: number | null; active?: number}): void {
db.prepare(`
INSERT INTO contracts (id, name, network, wasm_hash, tags, poll_interval_seconds)
VALUES (@id, @name, @network, @wasm_hash, @tags, @poll_interval_seconds)
INSERT INTO contracts (id, name, network, wasm_hash, tags, poll_interval_seconds, active)
VALUES (@id, @name, @network, @wasm_hash, @tags, @poll_interval_seconds, @active)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
network = excluded.network,
wasm_hash = excluded.wasm_hash,
tags = excluded.tags,
poll_interval_seconds = COALESCE(excluded.poll_interval_seconds, contracts.poll_interval_seconds)
poll_interval_seconds = COALESCE(excluded.poll_interval_seconds, contracts.poll_interval_seconds),
active = COALESCE(excluded.active, contracts.active)
`).run({
id: contract.id,
name: contract.name ?? null,
network: contract.network,
wasm_hash: contract.wasm_hash ?? null,
tags: contract.tags ?? null,
poll_interval_seconds: contract.poll_interval_seconds ?? null,
active: contract.active ?? 1,
});
export function insertContract(db: Database.Database, contract: {id: string; name?: string; network: string; wasm_hash?: string; tags?: string; poll_interval_seconds?: number | null; active?: number}): void {
db.prepare(`
INSERT INTO contracts (id, name, network, wasm_hash, tags, poll_interval_seconds, active)
VALUES (`@id`, `@name`, `@network`, `@wasm_hash`, `@tags`, `@poll_interval_seconds`, COALESCE(`@active`, 1))
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
network = excluded.network,
wasm_hash = excluded.wasm_hash,
tags = excluded.tags,
poll_interval_seconds = COALESCE(excluded.poll_interval_seconds, contracts.poll_interval_seconds),
active = COALESCE(`@active`, contracts.active)
`).run({
id: contract.id,
name: contract.name ?? null,
network: contract.network,
wasm_hash: contract.wasm_hash ?? null,
tags: contract.tags ?? null,
poll_interval_seconds: contract.poll_interval_seconds ?? null,
active: contract.active ?? null,
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/db/repositories.ts` around lines 98 - 117, The upsert in insertContract
is forcing active to 1 in JavaScript, so the ON CONFLICT path in the
insertContract repository method never preserves a paused contract. Move the
defaulting logic into the SQL statement so `@active` can remain NULL when omitted,
allowing COALESCE(excluded.active, contracts.active) to keep the existing value
on re-insert while still defaulting new rows to active. Use the insertContract
function and its ON CONFLICT(id) DO UPDATE clause to make the change.

Comment on lines +104 to +114
describe("Error handling", () => {
it("throws when API returns non-2xx", async () => {
mockFetch.mockResolvedValue(makeSlackErrorResponse("invalid_payload"));
await expect(channel.send(makeAlertEvent())).rejects.toThrow("invalid_payload");
});

it("throws when fetch itself rejects (network error)", async () => {
mockFetch.mockRejectedValue(new Error("ECONNREFUSED"));
await expect(channel.send(makeAlertEvent())).rejects.toThrow("ECONNREFUSED");
});
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

No coverage for the timeout/abort path.

Consider a test using fake timers to assert send rejects once TIMEOUT_MS elapses, given AbortController-based timeout is a key reliability feature of this class.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/alerts/slack.test.ts` around lines 104 - 114, Add a test for the
timeout/abort path in the Slack alert sender so `channel.send` is covered when
`TIMEOUT_MS` elapses. In `tests/alerts/slack.test.ts`, extend the existing
`Error handling` suite around `channel.send` by using fake timers and a fetch
mock that never resolves, then advance time past `TIMEOUT_MS` and assert the
promise rejects with the abort/timeout error. Make sure the test targets the
`send` method and its `AbortController` timeout behavior.

Comment on lines +208 to +209
expect(SlackChannel).toHaveBeenCalledWith("#alerts");
expect(mockSlackSend).toHaveBeenCalledWith(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Look for any migration or validation of pre-existing slack channel_target values
rg -nP -C3 'channel_target' --type=ts
rg -nP -C3 'channelTarget' --type=ts
fd -e sql | xargs rg -n 'channel_target'

Repository: AbdulmalikAlayande/sorokeep

Length of output: 247


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the relevant files first
git ls-files | rg '^(tests/alerts/state_change_alerts\.test\.ts|src/alerts/slack\.ts|src/alerts/|migrations/|.*\.sql|.*schema.*)$'

# Show compact structure for the likely implementation file(s)
for f in src/alerts/slack.ts tests/alerts/state_change_alerts.test.ts; do
  if [ -f "$f" ]; then
    echo "===== $f ====="
    ast-grep outline "$f" --view expanded
  fi
done

# Search for any validation or migration logic around Slack targets
rg -n -C 3 'channel_target|channelTarget|Slack webhook URL|startsWith\("http"\)|startsWith\(' src tests migrations . -g '!node_modules' -g '!dist' -g '!build'

# Read the relevant source slices with line numbers
for f in src/alerts/slack.ts tests/alerts/state_change_alerts.test.ts; do
  if [ -f "$f" ]; then
    echo "===== $f (numbered) ====="
    wc -l "$f"
  fi
done

Repository: AbdulmalikAlayande/sorokeep

Length of output: 50384


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read the exact relevant slices
sed -n '180,230p' tests/alerts/state_change_alerts.test.ts
printf '\n===== src/alerts/slack.ts =====\n'
sed -n '80,115p' src/alerts/slack.ts
printf '\n===== src/alerts/dispatcher.ts =====\n'
sed -n '35,120p' src/alerts/dispatcher.ts

# Check whether this test file is the only one using "`#alerts`" for Slack delivery
rg -n '"`#alerts`"|"`#my-alerts`"|"`#ops`"|SlackChannel\(' tests src/alerts -g '!node_modules' -g '!dist' -g '!build'

Repository: AbdulmalikAlayande/sorokeep

Length of output: 7750


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '===== src/commands/alerts.ts (relevant slices) =====\n'
sed -n '1,220p' src/commands/alerts.ts

printf '\n===== tests/commands/alerts.test.ts (slack-related slices) =====\n'
sed -n '90,120p' tests/commands/alerts.test.ts
sed -n '195,220p' tests/commands/alerts.test.ts
sed -n '490,520p' tests/commands/alerts.test.ts
sed -n '550,570p' tests/commands/alerts.test.ts
sed -n '730,765p' tests/commands/alerts.test.ts

printf '\n===== docs / README mentions of slack target =====\n'
rg -n -C 2 'slack|webhook|channel_target|`#alerts`|`#ops`' README.md docs src tests -g '!node_modules' -g '!dist' -g '!build'

Repository: AbdulmalikAlayande/sorokeep

Length of output: 50386


Slack alerts still use channel names, not webhook URLs. In src/commands/alerts.ts, --channel is stored as-is for slack, and existing configs/fixtures use values like #alerts, so new SlackChannel(channelTarget) in src/alerts/slack.ts will reject real Slack configs. Align the delivery contract or add a migration for stored Slack targets.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/alerts/state_change_alerts.test.ts` around lines 208 - 209, The Slack
alert flow is still passing channel names through as webhook targets, so
`SlackChannel` rejects valid existing Slack configs. Update the contract between
`src/commands/alerts.ts` and `src/alerts/slack.ts` so `--channel` can remain a
channel name like `#alerts`, or add a migration/translation step before `new
SlackChannel(...)` is created. Make sure the affected alert creation and send
path use the same target format consistently across stored configs, fixtures,
and `SlackChannel`.

Comment on lines +98 to +115
});

it("falls back to config values when interval and network are not provided", async () => {
vi.spyOn(configLib, "loadConfig").mockReturnValue({
network: "futurenet",
pollingIntervalSeconds: 45
} as any);

await actionFn({ logFormat: "pretty" });

expect(daemonLib.startDaemon).toHaveBeenCalledWith(
expect.anything(),
"futurenet",
expect.objectContaining({
intervalMs: 45000
})
);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Missing coverage for the display-string bug (options.network undefined).

This test correctly verifies startDaemon receives the config-derived network/interval, but nothing asserts the console output when network is omitted. Adding an assertion on the printed "Network:" line would have caught the options.network vs network display bug flagged in src/commands/daemon.ts.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/commands/daemon.test.ts` around lines 98 - 115, Add test coverage in
daemon.test.ts for the CLI output when the network option is omitted, since the
current assertion only checks startDaemon arguments. Use actionFn and the daemon
command’s printed status lines to verify the console includes the config-derived
network value in the “Network:” display, which will catch the options.network
versus network bug in src/commands/daemon.ts.

Comment on lines +42 to +46
it("pause exits with 1 if contract is not found", async () => {
vi.mocked(repositories.getContract).mockReturnValue(undefined);
await pauseActionFn("CDEF1234");
expect(mockExit).toHaveBeenCalledWith(1);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Missing-contract tests don't assert the status setter was skipped.

These tests only check mockExit was called with 1, not that repositories.setContractActiveStatus was never invoked. Since process.exit() doesn't halt synchronous execution and pause.ts/resume.ts are missing a return after process.exit(1) (see companion source review), the status-toggle call actually still fires in this scenario — but these tests can't catch it.

✅ Suggested strengthened assertions
     it("pause exits with 1 if contract is not found", async () => {
         vi.mocked(repositories.getContract).mockReturnValue(undefined);
         await pauseActionFn("CDEF1234");
         expect(mockExit).toHaveBeenCalledWith(1);
+        expect(repositories.setContractActiveStatus).not.toHaveBeenCalled();
     });

Also applies to: 55-59

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/commands/pause.test.ts` around lines 42 - 46, The missing-contract
pause/resume tests only verify that mockExit was called, but they do not ensure
repositories.setContractActiveStatus is skipped when repositories.getContract
returns undefined. Update the pauseActionFn and resumeActionFn missing-contract
cases in the pause/resume tests to also assert that the status setter was never
called, so the tests catch the missing return after process.exit(1) in the
corresponding actions.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants