Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .agents/skills/autoreview/CLAUDE.md

This file was deleted.

38 changes: 17 additions & 21 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,43 +13,37 @@ For fixes, describe the user-visible symptom and trigger:
fix: task list fails to load when user has no environments
Avoid implementation details such as:
fix: add null check to task query
-->

<details>
<summary>Additional instructions</summary>

**MUST:** Keep **Allow edits from maintainers** enabled for this PR so maintainers
can help update the branch when needed.

</details>
-->

## What Problem This Solves

<!--
Describe the concrete user, product, or operational problem.
For fixes, begin with:
"Fixes an issue where users <do X> would <experience Y> when <condition>."
or:
"Resolves a problem where..."

Name the affected UI surface or workflow. Do not describe the code-level cause here.
Use one short, plain-language sentence. For fixes, prefer:
"Fixes: <what goes wrong> when <trigger or condition>."
For other changes, describe the need without inventing a bug.
Name the affected workflow, not the code-level cause.
-->

## Why This Change Was Made
## User Impact

<!--
In one or two sentences, explain the complete shipped solution, key design
decisions, and relevant boundaries or non-goals. Include implementation detail
only when it helps reviewers understand user-visible behavior or risk.
Avoid file-by-file narration.
"User impact: <what users, operators, or developers can now do or expect>."
Lead with the concrete outcome in plain language, usually one sentence.
For internal-only changes, say there is no user-visible change; do not invent a benefit.
Keep important risks, breaking changes, migrations, and required user actions visible here.
-->

## User Impact
## Why This Change Was Made

<!--
State what users, operators, or developers can now do or expect. Lead with the
concrete benefit and use user-facing language. If there is no user-visible
impact, say so plainly.
Briefly explain how the change addresses the problem without repeating the impact.
Keep the body short. Leave file lists, internal acronyms, and root-cause walkthroughs
in the diff or optional <details>; include technical detail only when it explains
behavior or a material tradeoff. Do not hide risks or required actions in <details>.
-->

## Evidence
Expand All @@ -62,4 +56,6 @@ changes when it clarifies the result.

Reviewers will inspect the code, tests, and CI. Use this section to make the
validation easy to understand, not to restate the diff.
Summarize what was checked and the result; note meaningful gaps. Link long output
or put it in optional <details>, keeping the useful evidence summary visible.
-->
4 changes: 2 additions & 2 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,12 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7

- name: Initialize CodeQL
uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4
uses: github/codeql-action/init@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4
with:
languages: javascript-typescript
build-mode: none

- name: Analyze
uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4
uses: github/codeql-action/analyze@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4
with:
category: "/language:javascript-typescript"
2 changes: 1 addition & 1 deletion .github/workflows/npm-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ jobs:
fetch-depth: 0

- name: Setup pnpm
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
with:
run_install: false

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,4 +58,4 @@ jobs:

- name: Deploy Pages
id: deployment
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5
uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ and security boundaries.
- Security policy: [`SECURITY.md`](SECURITY.md)

Read the nearest scoped `AGENTS.md` before changing files below this directory.
`CLAUDE.md` is a compatibility symlink to this file; edit `AGENTS.md` only.
Edit `AGENTS.md` directly; do not add `CLAUDE.md` aliases.

## Architecture Boundaries

Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ All notable changes to Krillswitch are documented in this file.

## [Unreleased]

### Fixed

- Abort webhook deliveries after two seconds, record timeout status, and keep unattempted entries queued without delaying other subscribers. Thanks @SebTardif.
- Abort CLI API requests and response-body reads after a new 30-second default deadline, report timeout errors clearly, and support `KRILLSWITCH_TIMEOUT_MS` overrides (`0` restores unlimited waits). Thanks @SebTardif.

### Added

- Published `@openclaw/krillswitch-core` and
Expand All @@ -15,6 +20,11 @@ All notable changes to Krillswitch are documented in this file.

- Refresh workspace dependencies and GitHub Actions pins, including pnpm 11.24, Carapace 0.6.2, and Cloudflare's renamed Vitest plugin while retaining the two-day dependency release cooldown.

### Security

- Update Sharp to 0.35.4 and require the patched version in dependency overrides to address libheif advisory GHSA-rgj7-g3m4-5g8c.
- Require patched Undici 7.29.1 and 8.10.2 in tooling dependencies so exact transitive pins cannot block security updates; retain the two-day release cooldown.

## [v0.5.0] - 2026-07-30

### Added
Expand Down
1 change: 0 additions & 1 deletion CLAUDE.md

This file was deleted.

18 changes: 9 additions & 9 deletions apps/admin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,20 @@
"@openclaw/carapace": "github:openclaw/carapace#v0.6.2",
"@radix-ui/react-alert-dialog": "^1.1.23",
"@radix-ui/react-select": "^2.3.7",
"@tanstack/react-query": "^5.102.4",
"@tanstack/react-query": "^5.104.0",
"clsx": "^2.1.1",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-router": "^8.3.0",
"tailwind-merge": "^3.6.0"
"react": "^19.3.0",
"react-dom": "^19.3.0",
"react-router": "^8.4.0",
"tailwind-merge": "^3.7.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.5",
"@vitejs/plugin-react": "^6.1.0",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.1",
"tailwindcss": "^4.3.3",
"typescript": "~7.0.2",
"vite": "^8.2.2"
"vite": "^8.3.1"
}
}
20 changes: 10 additions & 10 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,23 +14,23 @@
"test": "vitest run",
"cf-typegen": "wrangler types",
"typecheck": "wrangler types && tsc --noEmit",
"db:generate-auth": "pnpm dlx auth@1.6.30 generate --config auth-schema.config.ts --output src/db/authSchema.ts --yes && pnpm --workspace-root exec biome check --write apps/api/src/db/authSchema.ts",
"db:generate-auth": "pnpm dlx auth@1.6.33 generate --config auth-schema.config.ts --output src/db/authSchema.ts --yes && pnpm --workspace-root exec biome check --write apps/api/src/db/authSchema.ts",
"ensure-dev-vars": "node -e \"const fs=require('node:fs');if(!fs.existsSync('.dev.vars'))fs.copyFileSync('.dev.vars.example','.dev.vars')\""
},
"dependencies": {
"@openclaw/krillswitch-core": "workspace:*",
"better-auth": "1.6.30",
"drizzle-orm": "^0.45.2",
"hono": "4.13.5",
"jose": "^6.2.10",
"zod": "^4.4.3"
"better-auth": "1.6.33",
"drizzle-orm": "^0.45.3",
"hono": "4.13.9",
"jose": "^6.2.12",
"zod": "^4.6.5"
},
"devDependencies": {
"@cloudflare/vitest-plugin": "^1.1.0",
"@cloudflare/workers-types": "^5.20260826.1",
"drizzle-kit": "^0.31.10",
"@cloudflare/vitest-plugin": "^1.3.0",
"@cloudflare/workers-types": "^5.20260928.1",
"drizzle-kit": "^0.31.11",
"typescript": "~7.0.2",
"vitest": "^4.1.11",
"wrangler": "^4.126.0"
"wrangler": "^4.142.0"
}
}
36 changes: 29 additions & 7 deletions apps/api/src/admin/webhooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { changeLog, webhooks } from "../db/schema";
import { type Actor, changeLogInsert } from "./changeLog";

const DRAIN_BATCH = 10;
/** Bound each subscriber POST so a stalled URL cannot pin waitUntil. */
export const WEBHOOK_DRAIN_FETCH_TIMEOUT_MS = 2_000;

export type WebhookRow = {
id: string;
Expand Down Expand Up @@ -116,11 +118,13 @@ export async function deleteWebhook(

/** POST change-log entries newer than each enabled webhook's cursor.
* Runs off the request path (waitUntil) after every admin mutation.
* Delivery is notify-only: the cursor advances even when the POST fails,
* and last_status shows the most recent outcome. */
* Delivery is notify-only: HTTP, network, and timeout failures all
* advance the cursor so a dead or slow subscriber cannot stall the
* queue or duplicate a post that already completed after cutoff. */
export async function drainWebhooks(
db: DrizzleD1Database,
fetcher: typeof fetch = fetch,
timeoutMs = WEBHOOK_DRAIN_FETCH_TIMEOUT_MS,
): Promise<void> {
const hooks = await db
.select()
Expand All @@ -129,7 +133,7 @@ export async function drainWebhooks(
.all();
for (const hook of hooks) {
try {
await drainOneWebhook(db, fetcher, hook);
await drainOneWebhook(db, fetcher, hook, timeoutMs);
} catch {
// Notification must never surface as a worker error; the cursor simply
// stays put and the next mutation retries the drain.
Expand All @@ -141,6 +145,7 @@ async function drainOneWebhook(
db: DrizzleD1Database,
fetcher: typeof fetch,
hook: typeof webhooks.$inferSelect,
timeoutMs: number,
): Promise<void> {
{
const entries = await db
Expand All @@ -166,24 +171,41 @@ async function drainOneWebhook(
return;
}
let status = "ok";
for (const { rowid: _rowid, ...entry } of entries) {
let cursor = hook.cursor;
for (const { rowid, ...entry } of entries) {
try {
const response = await fetcher(hook.url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "change", entry }),
signal: AbortSignal.timeout(timeoutMs),
});
if (!response.ok) {
status = `http ${response.status}`;
}
} catch {
cursor = rowid;
} catch (error) {
if (isFetchTimeout(error)) {
status = "timeout";
cursor = rowid;
break;
}
status = "unreachable";
cursor = rowid;
}
}
const lastRowid = entries[entries.length - 1]?.rowid ?? hook.cursor;
await db
.update(webhooks)
.set({ cursor: lastRowid, lastStatus: status, lastSentAt: new Date() })
.set({ cursor, lastStatus: status, lastSentAt: new Date() })
.where(eq(webhooks.id, hook.id));
}
}

function isFetchTimeout(error: unknown): boolean {
return (
typeof error === "object" &&
error !== null &&
"name" in error &&
(error.name === "TimeoutError" || error.name === "AbortError")
);
}
90 changes: 90 additions & 0 deletions apps/api/test/webhooks.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { SELF } from "cloudflare:test";
import { env } from "cloudflare:workers";
import { eq, inArray, sql } from "drizzle-orm";
import { drizzle } from "drizzle-orm/d1";
import { beforeAll, describe, expect, it } from "vitest";
import seedSql from "../seed/seed.sql?raw";
import { drainWebhooks } from "../src/admin/webhooks";
import { changeLog, webhooks } from "../src/db/schema";

const BASE = "http://localhost";

Expand Down Expand Up @@ -125,4 +127,92 @@ describe("drainWebhooks", () => {
headers: { cookie },
});
});

it("times out one delivery without dropping queued entries or blocking other hooks", async () => {
const db = drizzle(env.DB);
const tail = await db
.select({ rowid: sql<number>`coalesce(max(rowid), 0)` })
.from(changeLog)
.get();
const hookIds = [crypto.randomUUID(), crypto.randomUUID()];
const entryIds = [crypto.randomUUID(), crypto.randomUUID()];
await db.insert(webhooks).values(
hookIds.map((id, index) => ({
id,
name: `Timeout test ${index}`,
url:
index === 0
? "https://hang.example/hook"
: "https://healthy.example/hook",
cursor: tail?.rowid ?? 0,
createdAt: new Date(),
})),
);
await db.insert(changeLog).values(
entryIds.map((id) => ({
id,
actorUserId: "timeout-test",
actorName: "Timeout test",
action: "flag.update" as const,
target: "timeout-test",
createdAt: new Date(),
})),
);
try {
const attempted: { url: string; id: string }[] = [];
const hangingFetch = (async (
input: RequestInfo | URL,
init?: RequestInit,
) => {
const url = String(input);
attempted.push({ url, id: JSON.parse(String(init?.body)).entry.id });
if (url !== "https://hang.example/hook") return new Response("ok");
return new Promise<Response>((_resolve, reject) => {
const signal = init?.signal;
const abort = () => reject(signal?.reason);
if (signal?.aborted) abort();
else signal?.addEventListener("abort", abort, { once: true });
});
}) as typeof fetch;
await drainWebhooks(db, hangingFetch, 20);
expect(
attempted
.filter(({ url }) => url === "https://hang.example/hook")
.map(({ id }) => id),
).toEqual([entryIds[0]]);
expect(
attempted
.filter(({ url }) => url === "https://healthy.example/hook")
.map(({ id }) => id),
).toEqual(entryIds);
const timedOut = await db
.select()
.from(webhooks)
.where(eq(webhooks.id, hookIds[0] as string))
.get();
expect(timedOut?.lastStatus).toBe("timeout");

attempted.length = 0;
const recoveredFetch = (async (
input: RequestInfo | URL,
init?: RequestInit,
) => {
attempted.push({
url: String(input),
id: JSON.parse(String(init?.body)).entry.id,
});
return new Response("ok");
}) as typeof fetch;
await drainWebhooks(db, recoveredFetch, 20);
expect(attempted).toEqual([
{ url: "https://hang.example/hook", id: entryIds[1] },
]);
attempted.length = 0;
await drainWebhooks(db, recoveredFetch, 20);
expect(attempted).toEqual([]);
} finally {
await db.delete(webhooks).where(inArray(webhooks.id, hookIds));
await db.delete(changeLog).where(inArray(changeLog.id, entryIds));
}
}, 3_000);
});
Loading
Loading