feat: scheduled execution, webhook triggers, and trigger schema for swamp serve#1069
feat: scheduled execution, webhook triggers, and trigger schema for swamp serve#1069
Conversation
There was a problem hiding this comment.
Code Review
Blocking Issues
-
Import boundary violation (
src/cli/commands/serve.ts:25-27):ScheduledExecutionServiceis imported directly from../../libswamp/workflows/scheduled_execution.ts. Per CLAUDE.md, CLI commands must import fromsrc/libswamp/mod.ts— never from internal module paths. The type is already re-exported fromsrc/libswamp/mod.ts, so this is a one-line fix:import { ScheduledExecutionService } from "../../libswamp/mod.ts";
-
Fire-and-forget promises (
src/serve/webhook.ts:~line 281,src/libswamp/workflows/scheduled_execution.ts:~line 218):this.processQueue()is called withoutawaitor error handling in bothWebhookService.handleRequest()andScheduledExecutionService.handleFire(). CLAUDE.md requires: "No fire-and-forget promises. Every promise must be awaited or explicitly handled." Since these are intentionally async (returning immediately to the caller), the minimum fix is to add.catch()error handlers so failures don't silently disappear:this.processQueue().catch((err) => { logger.error("Queue processing failed: {error}", { error: err instanceof Error ? err.message : String(err), }); });
-
SKILL.md schema mismatch (
.claude/skills/swamp-workflow/SKILL.md): The example YAML showsschedule: "0 3,12 * * *"as a top-level field, but the actual implementation usestrigger: { schedule: "0 3,12 * * *" }. This will mislead users into writing invalid workflow YAML. The example should be updated to match the schema:trigger: schedule: "0 3,12 * * *"
-
Unhandled async in signal handlers (
src/cli/commands/serve.ts:~line 285):shutdown()is nowasyncbut the signal listeners discard the returned promise (() => shutdown()). If shutdown fails (e.g. watcher.stop() throws), the error is silently lost. Add a.catch():Deno.addSignalListener("SIGINT", () => shutdown().catch((e) => logger.error("Shutdown error: {e}", { e })));
Suggestions
-
listEndpoints()exposes secrets:WebhookService.listEndpoints()returns the fullWebhookEndpointincluding thesecretfield. While the health endpoint only mapsrouteandworkflow, the method signature returns secrets to any caller. Consider a separate type without the secret for the public API, or map to{ route, workflowIdOrName }within the method. -
DDD: Good layering overall.
WorkflowScheduleras a domain service (pure timer lifecycle),ScheduledExecutionServiceas an application service in libswamp (orchestrating scheduler + watcher + execution), andWebhookServicein the serve layer are all well-placed per DDD principles. TheexecuteWorkflowWithLocksextraction into a shared code path is a clean refactor. -
Watcher test coverage is thin (
src/libswamp/workflows/watcher_test.ts): Only tests theworkflowsDirhelper function. Consider adding tests forscanExisting()behavior (e.g., filtering workflows without schedules) using a mock repo, similar to thescheduled_execution_test.tspattern.
There was a problem hiding this comment.
CLI UX Review
Blocking
None.
Suggestions
-
src/serve/webhook.ts:55-78—parseWebhookFlagthrows plainError, notUserError
When a user passes a malformed--webhookflag (e.g., missing the secret segment), the error propagates throughrenderErrorand prints a stack trace, whileUserErrorinstances print only the message. The error messages themselves are clear ("Invalid --webhook format: expected '::', got '...'"), but the stack trace is noise for a validation error. Considerthrow new UserError(...)here for consistency with the rest of the CLI. -
src/cli/commands/serve.ts:102-133—schedule_unregisteredevent silently dropped in log mode
The switch statement in the scheduled execution event handler coversschedule_registered,schedule_fired,schedule_skipped,schedule_completed, andschedule_failed— but notschedule_unregistered. In JSON mode the event is emitted correctly; in log mode it's silently dropped. Users who rely on log output won't see when a workflow's schedule is live-reloaded away. A one-linerlogger.info("Unregistered schedule for workflow {name}", ...)would close the gap. -
src/cli/commands/serve.ts:53-56— example uses single quotes, preventing env-var expansion
The webhook example isswamp serve --webhook '/hooks/github:my-workflow:$WEBHOOK_SECRET'. With single quotes in a shell,$WEBHOOK_SECRETis not expanded — users who copy this verbatim would pass a literal$WEBHOOK_SECRETas the secret. The example would be more accurate with double quotes:"swamp serve --webhook \"/hooks/github:my-workflow:$WEBHOOK_SECRET\"". -
src/cli/commands/serve.ts:211-217—onListenJSON output includesschedulingEnabledbut not webhook count
The startup JSON event hasschedulingEnabledbut doesn't surface whether webhooks are active. Since both features are configured at startup, it would be consistent to include"webhooksRegistered": Nor similar so scripts can verify their configuration was picked up.
Verdict
PASS — no blocking issues. The new --no-schedule and --webhook flags are well-named and documented, log/JSON output is present for all event kinds (aside from the silent schedule_unregistered gap), and the health endpoint enrichment is additive and backward-compatible.
There was a problem hiding this comment.
CLI UX Review
Blocking
None.
Suggestions
-
src/serve/webhook.ts:55-78—parseWebhookFlagthrows plainError, notUserError
When a user passes a malformed--webhookflag (e.g., missing the secret segment), the error propagates throughrenderErrorand prints a stack trace, whileUserErrorinstances print only the message. The error messages themselves are clear (Invalid --webhook format: expected '<route>:<workflow>:<secret>', got '...'), but the stack trace is noise for a validation error. Considerthrow new UserError(...)here for consistency with the rest of the CLI. -
src/cli/commands/serve.ts:102-133—schedule_unregisteredevent silently dropped in log mode
The switch statement coversschedule_registered,schedule_fired,schedule_skipped,schedule_completed, andschedule_failed— but notschedule_unregistered. In JSON mode the event is emitted correctly; in log mode it's silently dropped. Users relying on log output won't see when a workflow's schedule is live-reloaded away. -
src/cli/commands/serve.ts:53-56— webhook example uses single quotes, preventing env-var expansion
The example isswamp serve --webhook '/hooks/github:my-workflow:$WEBHOOK_SECRET'. With single quotes,$WEBHOOK_SECRETis not expanded by the shell — users who copy this verbatim pass a literal string. Double quotes would make it work as intended. -
src/cli/commands/serve.ts:211-217—onListenJSON output includesschedulingEnabledbut not webhook count
The startup JSON event surfaces scheduling state but not whether webhooks are active. Since both are configured at startup, including something likewebhooksRegistered: Nwould let scripts verify their configuration.
Verdict
PASS — no blocking issues. The new --no-schedule and --webhook flags are well-named and documented, log/JSON output is present for all major event kinds, and the health endpoint enrichment is additive and backward-compatible.
There was a problem hiding this comment.
Adversarial Review
Critical / High
-
Unawaited async
processQueue()— fire-and-forget promise —src/serve/webhook.ts:271andsrc/libswamp/workflows/scheduled_execution.ts:254Both
WebhookService.handleRequest()andScheduledExecutionService.handleFire()callthis.processQueue()withoutawait.processQueueis an async method that returns aPromise<void>. The comment on line 270 of webhook.ts even says "serialized, awaited — not fire-and-forget" but it is factually fire-and-forget.How it breaks: The project's CLAUDE.md explicitly states: "No fire-and-forget promises. Every promise must be awaited or explicitly handled — unhandled promises race with
Deno.exitand silently lose data." During graceful shutdown,stop()aborts controllers and clears the queue, but ifprocessQueueis mid-await onexecuteWorkflow, the shutdown path callsac.abort()on the HTTP server while the unawaitedprocessQueuepromise is still running. The workflow execution may not complete and its result/error is silently lost. In webhook.ts specifically, the response is returned to the caller whileprocessQueueruns detached — if the process exits (e.g., SIGTERM during CI), queued workflow runs vanish.Suggested fix: For the webhook case, the fire-and-forget is somewhat intentional (respond immediately, process async). Store the promise and drain it in
stop():private processingPromise: Promise<void> = Promise.resolve(); // In handleRequest: this.processingPromise = this.processQueue(); // In stop: await this.processingPromise;
For the scheduled execution case, same pattern — store and await in
stop(). -
Unawaited
shutdown()in signal listeners —src/cli/commands/serve.ts:292-293Deno.addSignalListener("SIGINT", () => shutdown()); Deno.addSignalListener("SIGTERM", () => shutdown());
shutdownis async but the returned promise is discarded. Ifshutdownthrows (e.g.,webhookService.stop()orscheduledExecution.stop()throws), it becomes an unhandled rejection. Additionally, there is no guard against double invocation — if SIGINT fires while a SIGTERM-triggered shutdown is in progress, both run concurrently throughwebhookService.stop()andscheduledExecution.stop(), which could produce confusing double-abort behavior.Suggested fix: Add a
shuttingDownguard and capture the promise:let shuttingDown = false; const shutdown = async () => { if (shuttingDown) return; shuttingDown = true; // ... };
-
Unawaited
watchLoop()promise —src/libswamp/workflows/watcher.ts:69-70start()callsthis.watchLoop()(async) without await and does not store the promise. IfwatchLoopthrows an error that isn'tBadResource, it becomes an unhandled promise rejection.Suggested fix: Store the promise on an instance field so
stop()can await it, or add a.catch()handler.
Medium
-
No request body size limit on webhook endpoint —
src/serve/webhook.ts:227req.arrayBuffer()reads the entire request body into memory with no size limit, before signature verification. An attacker who knows the webhook route (not secret — the route is a CLI flag, not a credential) can send a multi-gigabyte POST body to OOM the process. The route must start with/but is otherwise predictable.Suggested fix: Check
Content-Lengthheader and reject bodies over a reasonable limit (e.g., 10MB) before callingarrayBuffer(), or use a streaming reader with a byte counter. -
Unawaited
handleChange()from setTimeout callback —src/libswamp/workflows/watcher.ts:115The debounce
setTimeoutcallback callsthis.handleChange(path, kind)which is async. The returned promise is not awaited or caught. IfworkflowRepo.findByIdthrows an unexpected error not caught by the inner try/catch (e.g., the repo is disposed during shutdown), it's an unhandled rejection. The inner try/catch (line 148) catches errors from the happy path but thematchregex andfilename.splitbefore the try block could theoretically throw on exotic inputs (unlikely but unhandled). -
Unbounded run queue in both services —
src/serve/webhook.ts:254andsrc/libswamp/workflows/scheduled_execution.ts:253Both
runQueuearrays grow without bound. Under sustained webhook traffic or many rapidly-firing cron schedules, the queue grows indefinitely. No backpressure is applied — the webhook handler always returns 200/queued regardless of queue depth.Suggested fix: Add a max queue depth (e.g., 100) and return HTTP 503 when full for webhooks, or skip with a log for scheduled execution.
Low
-
Health endpoint exposes schedule details without authentication —
src/cli/commands/serve.ts:244-266The
/healthendpoint returns workflow IDs, cron expressions, next run times, and running state to any unauthenticated GET request. The serve command already warns about non-loopback binding (line 72-76), but even on loopback this leaks internal workflow topology to any local process. -
listEndpoints()returns endpoints including secrets in the type —src/serve/webhook.ts:199The return type is
ReadonlyArray<WebhookEndpoint>which includes thesecretfield. The health endpoint in serve.ts only usesrouteandworkflowIdOrName, so secrets don't leak externally, but future callers oflistEndpoints()would get secrets. Consider returning a type withoutsecret.
Verdict
FAIL — The unawaited promises in processQueue(), shutdown(), and watchLoop() directly violate the project's explicit "no fire-and-forget promises" rule in CLAUDE.md. While the serialization logic is correct under normal operation, during shutdown these orphaned promises race with Deno.exit and can silently lose in-flight workflow run results. Fix the promise handling, add the shutdown guard, and consider the body size limit.
Workflows can now declare an optional `schedule` field with a cron expression. When `swamp serve` starts, it scans all workflows and registers cron entries for any with schedules. A filesystem watcher monitors the workflows directory for live reload — adding, changing, or removing a schedule takes effect without restart. - Add `croner` dependency for cron expression parsing and scheduling - Add optional `schedule` field to WorkflowSchema and Workflow aggregate - Create WorkflowScheduler domain service (pure timer lifecycle) - Create WorkflowWatcher in libswamp (Deno.watchFs with debouncing) - Create ScheduledExecutionService in libswamp (orchestrates scheduler, watcher, and workflowRun with overlap prevention) - Integrate into `swamp serve` with `--no-schedule` flag and enhanced `/health` endpoint reporting scheduled workflows and next fire times - Update design/workflow.md and swamp-workflow skill Closes #1009 Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Extract executeWorkflowWithLocks into serve/deps.ts as the single code path for workflow execution with model lock acquisition. Both the WebSocket handler (connection.ts) and ScheduledExecutionService now call this shared function, eliminating the duplicated lock acquisition and preventing execution path divergence (same class of issue as #947). Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
When multiple workflows fire at the same cron tick, they now queue and execute one at a time instead of concurrently. Before scheduling, each workflow ran as a separate process via systemd timers — serializing preserves that behavior and avoids lock contention on shared models. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Add webhook support to swamp serve via --webhook flag that accepts
HTTP POST requests, verifies GitHub HMAC-SHA256 signatures, and
queues workflow runs through the shared executeWorkflowWithLocks path.
Refactor workflow schema from top-level `schedule:` to nested
`trigger: { schedule: }` to prepare for additional trigger types.
Closes #1041
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
- Store processQueue() promise in both WebhookService and ScheduledExecutionService; drain it in stop() instead of polling - Add shuttingDown guard to prevent double shutdown on rapid signals - Catch shutdown() promise rejection in signal listeners - Store watchLoop() promise with .catch() handler; await in stop() - Catch unawaited handleChange() in debounce setTimeout callback - Add 10MB request body size limit on webhook endpoints (413 on exceed) - Add max queue depth of 100 with HTTP 503 backpressure for webhooks - Return WebhookEndpointInfo (without secret) from listEndpoints() Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
df56233 to
a2ba5cc
Compare
There was a problem hiding this comment.
Code Review
Blocking Issues
-
Inverted dependency: libswamp imports from serve layer (
src/libswamp/workflows/scheduled_execution.ts:38)import { executeWorkflowWithLocks } from "../../serve/deps.ts";
libswampis the library layer — it must not depend onserve(a consumer/presentation layer). This creates a circular dependency:serve → libswamp → serve. TheexecuteWorkflowWithLocksfunction should either live in libswamp (since it orchestrates libswamp operations) orScheduledExecutionServiceshould accept it as an injected dependency. The cleanest fix is to moveexecuteWorkflowWithLocksinto libswamp (e.g.src/libswamp/workflows/execute.ts) and haveserve/deps.tsre-export it, or inject it via theScheduledExecutionDepsinterface. -
CLI command imports from internal libswamp path (
src/cli/commands/serve.ts:25-26)import { ScheduledExecutionService, } from "../../libswamp/workflows/scheduled_execution.ts";
Per CLAUDE.md: "CLI commands and presentation renderers must import libswamp types and functions from
src/libswamp/mod.ts— never from internal module paths." This should be:import { ScheduledExecutionService } from "../../libswamp/mod.ts";
The types are already re-exported from
src/libswamp/mod.ts, so this is just an import path fix. -
SKILL.md documents
schedule:as a top-level field but code usestrigger: { schedule: }(.claude/skills/swamp-workflow/SKILL.md)
The skill documentation shows:schedule: "0 3,12 * * *"
But the actual schema uses
trigger: { schedule: "0 3,12 * * *" }. Users following the skill docs will create invalid workflows. The SKILL.md example should match the implemented schema.
Suggestions
-
WorkflowWatcherandScheduledExecutionServiceshare nearly identical queue/processing patterns withWebhookService— theprocessQueue/processing/processingPromisepattern is duplicated. Consider extracting a smallSerializedRunnerhelper, though this is fine to defer. -
Content-Length check is easily bypassed (
src/serve/webhook.ts): TheContent-Lengthheader check at line ~2092 can be spoofed (smaller value, larger body). The second check afterreq.arrayBuffer()at line ~2111 catches this, but by then the body is already in memory. Consider using a streaming read with a byte counter to truly enforce the limit, or just drop the first Content-Length check since the second one is the real guard. -
verifySignaturecopies body into a new ArrayBuffer unnecessarily (src/serve/webhook.ts:~1965-1966):const bodyBuffer = new ArrayBuffer(body.byteLength); new Uint8Array(bodyBuffer).set(body);
crypto.subtle.signacceptsBufferSource, so you can passbodydirectly — no copy needed. -
The
WorkflowSchedulerre-exports from libswamp/mod.ts cross the domain boundary (src/libswamp/mod.ts):export { ... } from "../domain/workflows/workflow_scheduler.ts";
This is a domain type being re-exported from the application layer barrel, which is fine for the types/interfaces, but the class itself could be considered an internal concern of
ScheduledExecutionService. Not blocking since the pattern is already used elsewhere in the codebase.
There was a problem hiding this comment.
CLI UX Review
Blocking
-
Documentation shows wrong schema — both
.claude/skills/swamp-workflow/SKILL.mdanddesign/workflow.mdshow the old top-levelschedule:syntax in their YAML examples:name: anime-downloader schedule: "0 3,12 * * *" # ← this is WRONG
But the actual implementation in
src/domain/workflows/workflow.ts(and confirmed by the new tests) uses the nestedtrigger:schema:name: anime-downloader trigger: schedule: "0 3,12 * * *" # ← correct
Any user following either doc will write YAML that fails schema validation. Both examples need to be updated to use
trigger: { schedule: ... }. -
parseWebhookFlagthrows plainError, notUserError—src/serve/webhook.tsthrowsnew Error(...)for invalid--webhookflag values. When this propagates fromwebhookFlags.map(parseWebhookFlag)inserve.ts, the user sees a full stack trace instead of a clean error message. All other user-facing validation errors in the CLI useUserErrorfromsrc/domain/errors.tsto suppress the stack trace.parseWebhookFlagshould throwUserErrorfor the same clean UX.
Suggestions
-
The
/healthJSON response includesscheduling.schedules[].workflowIdbut omits the workflow name. Scripts polling the health endpoint would need a secondaryswamp workflow getcall to make the IDs human-readable. AddingworkflowNamealongsideworkflowIdwould make the health output self-contained. -
The
--webhookflag description says<route>:<workflow>:<secret>but the example uses$WEBHOOK_SECRETas the secret (a shell variable reference, not a literal value). A short clarifying note like "secret may be a literal value or shell-expanded variable" would prevent confusion.
Verdict
NEEDS CHANGES — documentation examples show the deprecated top-level schedule: field instead of the new trigger: { schedule: } schema, which will cause immediate user-facing failures.
There was a problem hiding this comment.
Adversarial Review
Critical / High
No critical or high severity findings.
Medium
-
processingPromiseoverwrite silently drops the real processing promise —src/serve/webhook.ts:327andsrc/libswamp/workflows/scheduled_execution.ts:254Both
WebhookServiceandScheduledExecutionServiceuse the same pattern:this.processingPromise = this.processQueue();where
processQueue()returns immediately (resolved promise) ifthis.processingis alreadytrue. When a second item is queued while the first is still processing,this.processingPromisegets overwritten with a resolvedPromise<void>, and the reference to the original in-flight processing promise is lost.stop()thenawaitsthis.processingPromise— which is the resolved one, not the still-running one. This meansstop()returns before the actual processing loop finishes.Breaking example: During
swamp serveshutdown,stop()returns instantly,ac.abort()fires,server.finishedresolves, andrepoContext.catalogStore?.close()runs whileprocessQueueis still mid-execution inexecuteWorkflowWithLocks, potentially accessing the closed catalog store.Suggested fix: Store the original promise and never overwrite it:
if (!this.processing) { this.processingPromise = this.processQueue(); }
Or guard the assignment: only assign
this.processingPromisewhen the returned promise is not already resolved (i.e. whenprocessQueueactually started processing). -
Content-Length check is advisory-only; full body is always read into memory —
src/serve/webhook.ts:240-258The early
Content-Lengthcheck (line 240-253) is ineffective against a malicious sender. An attacker can omit theContent-Lengthheader (defaulting toparseInt("0", 10) = 0, passing the check) or lie about it, then send a very large chunked body.req.arrayBuffer()on line 258 reads the entire body into memory before the second size check on line 260 can reject it.Breaking example:
POST /hooks/githubwith noContent-Lengthheader and a 500MB chunked body → server allocates 500MB before rejecting at line 260.Suggested fix: Use a streaming read with a byte budget instead of
req.arrayBuffer(), or use Deno's built-in request body size limits if available.
Low
-
Missing
schedule_unregisteredcase in serve.ts event handler —src/cli/commands/serve.ts:102-134The event handler switch in log mode handles
schedule_registered,schedule_fired,schedule_skipped,schedule_completed, andschedule_failed— but notschedule_unregistered. When a workflow's schedule is removed via live-reload, no log line is emitted in non-JSON mode (JSON mode correctly emits it via the catch-allJSON.stringify(event)on line 100). -
No execution timeout on webhook-triggered workflow runs —
src/serve/webhook.ts:364The
AbortControllercreated for each webhook workflow execution has no timeout. A webhook-triggered workflow that hangs will block the serial queue indefinitely, preventing all subsequent queued webhook runs from executing. Scheduled execution has the same issue (src/libswamp/workflows/scheduled_execution.ts:275). Consider wiringAbortSignal.timeout()or a configurable max duration. -
Debounce timer callbacks can race with
stop()—src/libswamp/workflows/watcher.ts:119-128If a debounce timer fires (invoking
handleChange) at the exact instantstop()is called,stop()clears the timers map but the already-firedhandleChangecallback is in-flight. It will callthis.workflowRepo.findById()on a potentially-closing repository. The catch block on line 121 handles this gracefully (logs a warning), so the impact is cosmetic.
Verdict
PASS — The code is well-structured with good separation of concerns, proper HMAC verification, overlap prevention, and serialized execution. The processingPromise overwrite (Medium #1) is the most notable issue but its practical impact is limited to a narrow shutdown race since the abort signals and queue clearing provide defense-in-depth. No blocking issues found.
- Fix processingPromise overwrite: guard assignment with `if (!this.processing)`
so stop() always awaits the real in-flight promise
- Use streaming body read with byte budget instead of req.arrayBuffer() to
enforce 10MB limit without full allocation on oversized requests
- Throw UserError instead of Error in parseWebhookFlag for clean CLI output
- Update design/workflow.md and swamp-workflow SKILL.md to document
trigger: { schedule: } instead of the old top-level schedule: field
- Add missing schedule_unregistered case to serve.ts log-mode event handler
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
There was a problem hiding this comment.
Code Review
Blocking Issues
-
Import boundary violation in
src/cli/commands/serve.ts:25-27: The CLI command importsScheduledExecutionServicedirectly from../../libswamp/workflows/scheduled_execution.tsinstead of from../../libswamp/mod.ts. Per CLAUDE.md: "CLI commands and presentation renderers must import libswamp types and functions fromsrc/libswamp/mod.ts— never from internal module paths." The type is already re-exported frommod.ts, so the fix is just changing the import path. -
Inverted layer dependency in
src/libswamp/workflows/scheduled_execution.ts:39: A libswamp module importsexecuteWorkflowWithLocksfrom../../serve/deps.ts. This creates a dependency from a lower-level library (libswamp) upward into the serve layer, which inverts the intended dependency direction (serve → libswamp, not libswamp → serve). TheScheduledExecutionServiceeither needs to live in thesrc/serve/layer (since it depends on serve infrastructure), orexecuteWorkflowWithLocksneeds to be passed in as a dependency (e.g., via theScheduledExecutionDepsinterface) so libswamp remains independent of the serve layer.
Suggestions
-
Consider extracting duplicate queue+serialize pattern: Both
ScheduledExecutionServiceandWebhookServiceimplement nearly identical run-queue + processing patterns (runQueue,processing,processingPromise,processQueue(),executeWorkflow()). A sharedSerializedExecutionQueuecould reduce this duplication — but not blocking since both implementations are correct as-is. -
Watcher test coverage is minimal (
src/libswamp/workflows/watcher_test.ts): The only test covers theworkflowsDirutility. TheWorkflowWatcherclass itself (debouncing, filename parsing, handling create/modify/remove events,scanExisting) has no unit tests. Consider at minimum testingscanExistingwith a mock repo and the filename-matching logic inhandleChange. -
DDD observation: Good separation of concerns —
WorkflowScheduleras a pure domain service with no I/O,ScheduledExecutionServiceas an application service orchestrating the domain. TheWorkflowaggregate properly encapsulates the trigger/schedule concept through a getter. The event-driven communication pattern via typed discriminated unions is clean.
There was a problem hiding this comment.
CLI UX Review
Blocking
None.
Suggestions
-
--webhookhelp text doesn't mention repeatable flag (src/cli/commands/serve.ts): The description says "Register a webhook endpoint: ::" but doesn't hint that the flag is repeatable ({ collect: true }). Consider: "Register a webhook endpoint (repeatable): ::". -
--no-scheduledescription doesn't signal it's disabling a default (src/cli/commands/serve.ts): The flag says "Disable scheduled workflow execution" butswamp serve --helpusers won't know scheduling is on by default. Consider: "Disable scheduled workflow execution (enabled by default)". -
webhook_queuedlog-mode message drops the route (src/cli/commands/serve.ts):webhook_receivedlogs route+workflow, butwebhook_queuedonly logs the workflow name. Minor inconsistency — including route makes it easier to correlate logs when multiple webhooks are configured. -
Secret appears in
ps aux: The--webhookflag embeds the secret inline. The example already uses$WEBHOOK_SECRETwhich is the right nudge, but a parenthetical in the description like "use env var for secret" would make this explicit.
Verdict
PASS — new --webhook and --no-schedule flags are well-documented, error messages are clear and actionable, JSON and log-mode events are consistent and complete, and the health endpoint additions are additive/backwards-compatible.
There was a problem hiding this comment.
Adversarial Review
Critical / High
None found.
Medium
-
src/serve/webhook.ts:281-295 — Body read before signature header check enables unauthenticated resource consumption.
The body is read (up to 10MB) before checking whether theX-Hub-Signature-256header is present. An attacker who discovers a webhook route can force the server to allocate up to 10MB per request with no authentication at all — just send large POST bodies without the signature header.// Current order: const signatureHeader = req.headers.get("x-hub-signature-256") ?? ""; const body = await readBodyWithLimit(req, MAX_WEBHOOK_BODY_BYTES); // ← 10MB read // ... if (!signatureHeader) { return 401; } // ← checked AFTER the 10MB allocationBreaking input:
curl -X POST http://host:9090/hooks/github -d @/dev/urandom --max-time 5(repeated in parallel).
Suggested fix: Move thesignatureHeaderempty-check beforereadBodyWithLimit:const signatureHeader = req.headers.get("x-hub-signature-256") ?? ""; if (!signatureHeader) { this.emit({ kind: "webhook_rejected", route: endpoint.route, reason: "Missing header" }); return Response.json({ error: "Missing X-Hub-Signature-256 header" }, { status: 401 }); } const body = await readBodyWithLimit(req, MAX_WEBHOOK_BODY_BYTES);
-
src/serve/webhook.ts:157 —
reader.cancel()not awaited beforereleaseLock()in finally block.
ReadableStreamDefaultReader.cancel()returns a Promise. Not awaiting it means the stream may not have finished cancellation whenreleaseLock()runs in thefinallyblock. While most runtimes handle this gracefully, the Streams spec says callingreleaseLock()on a reader with a pending read request throws aTypeError.
Breaking scenario: A very slow sender triggers the size limit; the pendingreader.read()insidecancel()hasn't resolved whenreleaseLock()fires.
Suggested fix:await reader.cancel(); -
CLI
--webhookflag passes secret as a command-line argument (src/cli/commands/serve.ts:49-52).
The secret is visible inps aux,/proc/*/cmdline, and shell history. The example suggests$WEBHOOK_SECRET(env var expansion by the shell), which mitigates somewhat, but is easy to misuse with a literal string. Not blocking since the example demonstrates env var usage, but worth documenting that literal secrets in the flag value are a security concern.
Low
-
src/serve/webhook.ts:123-124 — Early return on hex length mismatch before constant-time loop. The comment says "constant-time comparison" but the length check at line 123 short-circuits. In practice this is harmless since
expectedHexis always 64 characters (SHA-256), so a length mismatch only reveals that the attacker provided a malformed hex string — not a secret-dependent timing difference. -
src/cli/commands/serve.ts:301-314 — Signal listeners are never removed.
Deno.addSignalListeneradds listeners that hold closures overwebhookService,scheduledExecution, and other resources. These are never cleaned up afterserver.finishedresolves. Harmless in practice sinceserveruns for the lifetime of the process, but would leak if the command were ever called more than once in the same process. -
src/libswamp/workflows/watcher.ts:135 —
path.split("/").pop()doesn't handle Windows paths. If the repo ever runs on Windows, backslash separators would causefilenameto contain the full path. Not an issue for the stated target platform, but fragile.
Verdict
PASS — The code is well-structured with proper serialization, overlap prevention, abort propagation, and HMAC verification. The body-before-auth-check issue (Medium #1) is worth fixing but does not cause data loss or authentication bypass — it's a bounded resource consumption vector that requires knowledge of the route path. No critical or high severity findings.
- Fix CLI import: use libswamp/mod.ts barrel for ScheduledExecutionService - Fix layer inversion: inject executeWorkflow as a callback via ScheduledExecutionDeps instead of importing from serve/deps.ts - Move signature header check before body read to prevent unauthenticated resource consumption on webhook endpoints Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
There was a problem hiding this comment.
CLI UX Review
Blocking
None.
Suggestions
-
Log mode silent on
--no-schedule: When the user passes--no-schedule, log mode emits nothing to confirm scheduling is disabled. JSON mode includesschedulingEnabled: falsein thelisteningevent (good), but log mode users get no acknowledgment. A singlelogger.info("Scheduled execution disabled (--no-schedule)")in the disabled branch would make the flag's effect visible. -
JSON field name inconsistency in webhook events: The webhook registration output (emitted directly in
serve.ts) uses the keyworkflow:{"kind":"webhook_registered","route":"...","workflow":"my-workflow"}But all
WebhookEventvariants useworkflowName. Scripts parsing JSON output would need to handle two different keys depending on event kind. Consider usingworkflowNameconsistently, or at minimum document the difference. -
webhook_queuedlog message drops route context:webhook_receivedlogs"Webhook received on {route} for workflow {workflow}"butwebhook_queuedlogs only"Webhook queued workflow {workflow}"— the route disappears. Minor, but the route is useful context when multiple webhooks are configured.
Verdict
PASS — new flags are well-documented with clear help text and an example, error messages are actionable, and both log/JSON output modes are covered for all new event types. The suggestions above are cosmetic and do not block merge.
There was a problem hiding this comment.
Code Review
Blocking Issues
- Fire-and-forget promise in
readBodyWithLimit(src/serve/webhook.ts:157):reader.cancel()returns aPromisethat is neither awaited nor.catch()-handled. This violates the project's "No fire-and-forget promises" rule in CLAUDE.md. Should beawait reader.cancel();.
Suggestions
-
Thin watcher tests (
src/libswamp/workflows/watcher_test.ts): Only theworkflowsDirhelper is tested. Consider adding a test forscanExistingbehavior using a mockWorkflowRepository— similar to howscheduled_execution_test.tstestsScheduledExecutionServicewith mocks. Not blocking since the watcher is indirectly exercised through the scheduled execution service tests, but direct coverage would catch regressions in debounce/change-handling logic. -
Unnecessary buffer copy in
verifySignature(src/serve/webhook.ts:115-116): The bodyUint8ArrayfromreadBodyWithLimitalready owns its backingArrayBuffer, so the copy to a newArrayBufferis redundant. You can passbody.bufferdirectly (withbyteOffset/byteLengthif needed) or just passbody—crypto.subtle.signacceptsBufferSourcewhich includesUint8Array.
Notes
- DDD: Clean layering —
WorkflowScheduleras a pure domain service,ScheduledExecutionServiceas an application service in libswamp,WebhookServicein the serve layer. TheexecuteWorkflowWithLocksextraction as a shared code path is a good call. - Import boundary: All CLI imports correctly go through
src/libswamp/mod.ts. ✅ - Schema: The optional
triggerfield with backward compatibility (existing workflows withouttriggercontinue to work) is well-handled with tests. - Security: HMAC verification with constant-time comparison, body size limits, queue depth limits, and signature-before-body-read ordering are all solid.
There was a problem hiding this comment.
Adversarial Review
Critical / High
None found.
Medium
-
Unawaited
reader.cancel()promise —src/serve/webhook.ts:157
readBodyWithLimitcallsreader.cancel()without awaiting it, then returnsnull, triggering thefinallyblock'sreader.releaseLock(). The project convention (CLAUDE.md: "No fire-and-forget promises") is violated. While the Streams spec toleratesreleaseLock()after an unawaitedcancel(), the dangling promise could mask stream errors on slow/large uploads.
Suggested fix:await reader.cancel();beforereturn null;. -
No cross-service serialization for the same workflow —
src/serve/webhook.ts+src/libswamp/workflows/scheduled_execution.ts
ScheduledExecutionServiceandWebhookServiceeach serialize their own queues, but both can concurrently trigger the same workflow throughexecuteWorkflowWithLocks. If a scheduled trigger and a webhook fire at the same time for the same workflow, two lock-acquisition + execution attempts will race. The lock layer may handle this, but the overlap-prevention logic inScheduledExecutionService.handleFire(checkingthis.running) does not protect against a webhook-triggered run of the same workflow — or vice versa.
Impact: Possible lock contention or duplicate concurrent runs of the same workflow.
Suggested fix: Consider a shared "workflow is executing" guard or document this as an accepted limitation.
Low
-
Watcher remove events use filename as workflow name —
src/libswamp/workflows/watcher.ts:131
When a workflow file is deleted,handleChangepasses the raw filename (e.g.workflow-abc123.yaml) as theworkflowNametoonChange. TheScheduledExecutionServicefalls back to this viathis.workflowNames.get(workflowId) ?? workflowName, soschedule_unregisteredevents and log messages may show a filename instead of the human-readable workflow name. Cosmetic only — unregistration works correctly by ID. -
Watcher does not detect workflows directory creation after startup —
src/libswamp/workflows/watcher.ts:50-56
If theworkflows/directory does not exist whenstart()is called, the watcher logs and returns without setting up a watch. If the directory is created later, no schedules will be detected until the service is restarted. This matches the documented "no catch-up" behavior but could surprise users who create their first workflow while serve is already running. -
Unnecessary
ArrayBuffercopy inverifySignature—src/serve/webhook.ts:115-116
The body is copied fromUint8Arrayinto a freshArrayBufferbefore passing tocrypto.subtle.sign, but the API acceptsBufferSourcedirectly (whichUint8Arrayimplements). The extra allocation is harmless but wasteful for large payloads.
Verdict
PASS — The code is well-structured with clear separation of concerns. The extracted executeWorkflowWithLocks unifies the execution path cleanly. Webhook signature verification is correctly implemented with constant-time comparison. Queue processing is correctly serialized within each service using JS's single-threaded event loop guarantees. Shutdown logic properly drains queues and aborts in-flight runs. The medium findings are worth addressing but don't block the merge.
- Await reader.cancel() in readBodyWithLimit to satisfy no-fire-and-forget rule - Use body.buffer directly in verifySignature instead of copying to new ArrayBuffer Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
There was a problem hiding this comment.
CLI UX Review
Blocking
None.
Suggestions
-
Log-mode startup doesn't confirm
--no-schedule(src/cli/commands/serve.ts): In JSON mode, the server-ready event includesschedulingEnabled: falsewhen--no-scheduleis passed, so a monitoring script knows scheduling is off. In log mode the startup line is justWebSocket API server listening on {host}:{port}— no mention of scheduling being disabled. Consider adding a log-mode line likelogger.info("Scheduled execution disabled (--no-schedule)")when!enableSchedule, so operators have visible confirmation. -
webhook_queuedlog message drops the route (src/cli/commands/serve.ts): The other webhook log messages include the route (webhook_received,webhook_rejected), butwebhook_queuedonly logs the workflow name:"Webhook queued workflow {workflow}". Minor inconsistency — the route is useful context in multi-webhook setups. -
Single-quoted example may surprise shell users (
src/cli/commands/serve.ts): The exampleswamp serve --webhook '/hooks/github:my-workflow:$WEBHOOK_SECRET'uses single quotes, which prevents shell expansion of$WEBHOOK_SECRET— the literal string$WEBHOOK_SECRETis passed as the secret. Double-quoting is the idiomatic pattern here:"'/hooks/github:my-workflow:$WEBHOOK_SECRET'"or just--webhook "/hooks/github:my-workflow:$WEBHOOK_SECRET".
Verdict
PASS — no blocking issues. New flags are clearly documented, error messages are actionable, JSON mode covers all new events, and the health endpoint is updated consistently.
There was a problem hiding this comment.
Code Review
Well-structured PR with clean DDD layering. The architecture is solid:
- WorkflowScheduler (domain service) — pure scheduling logic, no I/O, testable
- ScheduledExecutionService (application service in libswamp) — orchestrates domain objects, emits typed events
- WorkflowWatcher (infrastructure) — filesystem observation with proper debouncing
- WebhookService (serve layer) — HTTP concerns correctly isolated from domain
- executeWorkflowWithLocks — good extraction of shared code path for WebSocket, scheduled, and webhook triggers
Blocking Issues
None.
Suggestions
-
watcher_test.tsis thin — it only tests theworkflowsDirpath helper. TheWorkflowWatcherclass has meaningful logic (debouncing, file change → schedule change propagation,scanExisting) that could benefit from direct unit tests with a mockWorkflowRepository. Not blocking since theScheduledExecutionServicetests coverscanExistingindirectly and the watcher is hard to unit test without filesystem setup. -
Queue depth unbounded for scheduled execution —
WebhookServicehas aMAX_QUEUE_DEPTH = 100with backpressure (returns 503 when full), butScheduledExecutionService.runQueuehas no cap. In practice this is unlikely to matter since overlap prevention limits growth, but a defensive cap would be consistent.
Security looks good: HMAC-SHA256 with constant-time comparison, body size limits before signature verification, signature checked before reading body, no secret leakage in health endpoint or logs.
All new files have license headers, named exports, no any types, tests co-located with source, and libswamp import boundary is respected (serve.ts imports ScheduledExecutionService from libswamp/mod.ts).
There was a problem hiding this comment.
Adversarial Review
Critical / High
None found.
Medium
-
src/serve/webhook.ts:120—body.bufferis incorrect for Uint8Array views in a public security function.verifySignatureis exported and acceptsbody: Uint8Array, but computes the HMAC overbody.buffer(the underlying ArrayBuffer), not the view itself. If a caller passes a subarray (e.g.,largerBuffer.subarray(10, 50)),body.bufferis the entire underlying ArrayBuffer, causing the HMAC to be computed over wrong data — the signature check silently produces an incorrect result.The comment on line 115–116 acknowledges the internal call site is safe (freshly-allocated buffer), but the function is a public export used through
src/serve/mod.ts. A future caller using a view would get silent verification failure or, worse, a false positive if an attacker controls the surrounding buffer.Breaking example:
const full = new Uint8Array([0, 0, ...payload, 0, 0]); const view = full.subarray(2, 2 + payload.length); await verifySignature(view, sig, secret); // HMAC computed over `full`, not `view`
Fix:
crypto.subtle.signacceptsBufferSource(which includesUint8Array). Just passbodydirectly:const signature = await crypto.subtle.sign("HMAC", key, body);
-
src/serve/connection.ts:217— WebSocket disconnect no longer breaks the workflow event iteration loop.Previously,
handleWorkflowRunusedfor await (const event of workflowRun(...))withif (socket.readyState !== WebSocket.OPEN) break;, which called the async generator'sreturn()and could propagate cancellation into the workflow pipeline. The refactored code now just skips sending in theonEventcallback, butexecuteWorkflowWithLockscontinues iterating the full event stream to completion.For long-running workflows, this means the server keeps processing (holding locks, consuming CPU) for a disconnected client that will never see the results. The
controller.abort()is only triggered by an explicit cancel message, not by socket closure.Breaking example: Client starts a 30-minute workflow, disconnects immediately. Old behavior: generator return propagates, iteration stops, locks release. New behavior: workflow runs to completion, burning resources for 30 minutes with no consumer.
Suggested fix: Check socket state in
onEventand callcontroller.abort()when the socket is no longer open, or set up a socket close listener that aborts the controller.
Low
-
src/serve/webhook.ts:127-128— Length check before constant-time comparison leaks length information. The earlyreturn falseonreceivedHex.length !== expectedHex.lengthexits before the constant-time XOR loop. Since both values are hex-encoded SHA-256 (always 64 chars), this is unexploitable in practice — a well-formedsha256=header always produces a 64-char hex string, and the expected value is always 64 chars. But it contradicts the stated intent of constant-time comparison. If the codebase later adds support for other hash algorithms with different lengths, this would become a timing oracle. -
src/libswamp/workflows/scheduled_execution.ts— Serialized queue means one slow workflow blocks all scheduled runs. TheprocessQueuepattern executes workflows one at a time. If a workflow takes 2 hours, all other scheduled workflows queued behind it miss their windows. This is documented as intentional ("serializing preserves systemd-timer behavior"), but worth noting that it means a misbehaving workflow can starve the entire schedule.
Verdict
PASS — The code is well-structured with good test coverage, proper resource cleanup, and sensible security defaults (HMAC verification, body size limits, queue backpressure). The body.buffer issue (Medium #1) is latent — not exploitable via current call sites — and the WebSocket behavior change (Medium #2) is a resource efficiency concern, not a correctness bug. Neither blocks merge, but both are worth addressing.
Summary
swamp servenow runs workflows on cron schedules defined in workflow YAML viatrigger: { schedule: "<cron>" }. Includes filesystem watcher for live-reload of schedule changes, overlap prevention, and serialized execution to avoid lock contention.--webhook '<route>:<workflow>:<secret>'flag registers POST endpoints that verify GitHub HMAC-SHA256 signatures and queue workflow runs. Designed for CI triggers (e.g. GitHub push webhook → run extension-test-suite).executeWorkflowWithLocks()as the single code path for WebSocket, scheduled, and webhook-triggered workflow runs.schedule:to nestedtrigger: { schedule: }to prepare for additional trigger types (webhook filters, event-based triggers).Test Plan
swamp servewith 3 scheduled workflows in swamp-media — all registered correctly with newtrigger:schemadeno check,deno lint,deno fmtall passCloses #1041
🤖 Generated with Claude Code