Add incoming webhooks, fix stale service worker and push re-registration

- Webhooks for automations: per-job secret, Gitea/GitHub HMAC signatures,
  payload appended to the instructions; settings on the automations page
- Service worker gets a new file name per build (proxies that cache *.js
  served a stale worker, which broke updates and Android installation);
  /sw.js is a kill switch that removes old workers
- Push: devices re-register their subscription with the server on start and
  before a test; the test reports why a push service rejected a message

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-09 00:38:04 +01:00
1 parent 6dffcb501a
commit 10d4b64289
16 files changed
+267 -18

No files matched your search

+40
View File
@@ -15,6 +15,7 @@ interface JobInfo {
last_status?: string | null;
next_run?: number | null;
error?: string;
webhook?: boolean;
spec?: { name: string; schedule: string; agent: string; model?: string | null; permission_mode: string; timeout_minutes: number; prompt: string };
}
@@ -26,6 +27,7 @@ export function Jobs({ onMenu }: { onMenu: () => void }) {
const [jobs, setJobs] = useState<JobInfo[]>();
const [path, setPath] = useState("");
const [busy, setBusy] = useState<string>();
const [hook, setHook] = useState<{ id: string; url: string; secret: string }>();
const reload = () =>
get<JobInfo[]>("/jobs")
@@ -108,6 +110,44 @@ export function Jobs({ onMenu }: { onMenu: () => void }) {
<pre>{j.spec.prompt}</pre>
</details>
)}
<div className="job-hook">
<span>
<Icon name="external" /> {t("jobs.webhook")}: {j.webhook ? t("jobs.on") : t("jobs.off")}
</span>
<button
className="small ghost"
onClick={() =>
act(j.id, async () => {
const r = await post<{ url?: string; secret?: string }>(`/jobs/${j.id}/webhook`, { enable: !j.webhook });
if (r.url && r.secret) setHook({ id: j.id, url: r.url, secret: r.secret });
})
}
>
{j.webhook ? t("jobs.webhookOff") : t("jobs.webhookOn")}
</button>
{j.webhook && (
<button
className="small ghost"
onClick={() =>
act(j.id, async () => {
const r = await post<{ url: string; secret: string }>(`/jobs/${j.id}/webhook`, { enable: true });
setHook({ id: j.id, url: r.url, secret: r.secret });
})
}
>
{t("jobs.webhookRenew")}
</button>
)}
</div>
{hook?.id === j.id && (
<div className="notice ok">
<div>{t("jobs.webhookShown")}</div>
<div className="mono" style={{ marginTop: 6, wordBreak: "break-all" }}>URL: {hook.url}</div>
<div className="mono" style={{ wordBreak: "break-all" }}>Secret: {hook.secret}</div>
<div className="hint" style={{ marginTop: 6 }}>{t("jobs.webhookHelp")}</div>
<pre className="job-example">{`curl -X POST ${hook.url} \\\n -H "X-CodeCraft-Secret: ${hook.secret}" \\\n -H "Content-Type: application/json" -d '{"event":"test"}'`}</pre>
</div>
)}
<div className="row" style={{ gap: 8, marginTop: 10 }}>
<button className="small" disabled={busy === j.id || !!j.error} onClick={() => act(j.id, async () => {
const r = await post<{ session_id: string }>(`/jobs/${j.id}/run`, {});
+9 -4
View File
@@ -6,7 +6,7 @@ import { getLang, languages, setLang, useT, type Key } from "../i18n";
import { navigate } from "../router";
import { useStore } from "../store";
import { canPrompt, isInstalled, onInstallChange, platformHint, promptInstall } from "../install";
import { currentSubscription, disablePush, enablePush, pushSupported, testPush } from "../push";
import { currentSubscription, disablePush, enablePush, pushSupported, syncPush, testPush } from "../push";
import { applyTheme, getTheme, getThemeMode, onThemeChange, THEME_MODES, THEMES, type Theme, type ThemeMode } from "../theme";
import { setReasoningMode, useReasoningMode, type ReasoningMode } from "../prefs";
import { ModelSelect } from "./ModelSelect";
@@ -149,7 +149,11 @@ function PushControl() {
if (!pushSupported()) return setState("unsupported");
if (Notification.permission === "denied") return setState("denied");
currentSubscription()
.then((s) => setState(s ? "on" : "off"))
.then(async (s) => {
setState(s ? "on" : "off");
// A subscription the server lost (or never got) is registered again.
if (s) await syncPush().catch(() => undefined);
})
.catch(() => setState("off"));
}, []);
const run = (f: () => Promise<unknown>) => async () => {
@@ -174,8 +178,9 @@ function PushControl() {
className="small"
disabled={busy}
onClick={run(async () => {
const n = await testPush();
toast(n > 0 ? t("settings.pushTestSent") : t("settings.pushTestNone"));
const r = await testPush();
if (r.sent > 0) toast(t("settings.pushTestSent"), "success");
else toast(r.errors?.length ? `${t("settings.pushTestFailed")}: ${r.errors.join("; ")}` : t("settings.pushTestNone"));
})}
>
{t("settings.pushTest")}
+7
View File
@@ -411,6 +411,13 @@ export const de: Messages = {
"install.hint.firefox": "Firefox kann keine Web-Apps installieren – öffne CodeCraft in Chrome (Android) oder nutze „Zum Startbildschirm hinzufügen“.",
"install.hint.ios": "Safari: Teilen-Knopf → „Zum Home-Bildschirm“. Push-Benachrichtigungen funktionieren nur in der installierten App.",
"install.hint.desktop": "Chrome/Edge: Installations-Symbol in der Adressleiste oder Menü → „CodeCraft installieren“.",
"settings.pushTestFailed": "Der Push-Dienst hat die Benachrichtigung abgelehnt",
"jobs.webhook": "Webhook",
"jobs.webhookOn": "Webhook aktivieren",
"jobs.webhookOff": "Webhook deaktivieren",
"jobs.webhookRenew": "Neues Secret",
"jobs.webhookShown": "Secret jetzt kopieren – es wird nur einmal angezeigt:",
"jobs.webhookHelp": "Beliebiges JSON per POST an die URL schicken; es wird an den Auftrag angehängt. Anmeldung per Header X-CodeCraft-Secret (oder ?secret=…), oder das Secret in Gitea/GitHub als Webhook-Secret eintragen (Signatur X-Gitea-Signature / X-Hub-Signature-256).",
"new.title": "Neue Session",
"new.directory": "Projektverzeichnis",
"new.up": "Hoch",
+7
View File
@@ -410,6 +410,13 @@ export const en = {
"install.hint.firefox": "Firefox can't install web apps – open CodeCraft in Chrome (Android) or use “Add to home screen”.",
"install.hint.ios": "Safari: Share button → “Add to Home Screen”. Push notifications work only in the installed app.",
"install.hint.desktop": "Chrome/Edge: install icon in the address bar or menu → “Install CodeCraft”.",
"settings.pushTestFailed": "The push service rejected the notification",
"jobs.webhook": "Webhook",
"jobs.webhookOn": "Enable webhook",
"jobs.webhookOff": "Disable webhook",
"jobs.webhookRenew": "New secret",
"jobs.webhookShown": "Copy the secret now – it is shown only once:",
"jobs.webhookHelp": "POST any JSON to the URL; it is appended to the instructions. Authenticate with the header X-CodeCraft-Secret (or ?secret=…), or enter the secret as webhook secret in Gitea/GitHub (signature X-Gitea-Signature / X-Hub-Signature-256).",
"new.title": "New session",
"new.directory": "Project directory",
"new.up": "Up",
+2
View File
@@ -32,6 +32,8 @@ export async function startPrefsSync() {
} catch {
/* offline or old server */
}
// Push subscription of this device: make sure the server has it.
void import("./push").then((m) => m.syncPush()).catch(() => undefined);
onThemeChange(push);
onLangChange(push);
}
+13 -3
View File
@@ -47,7 +47,17 @@ export async function disablePush(): Promise<void> {
localStorage.removeItem("codecraft.push");
}
export async function testPush(): Promise<number> {
const r = await post<{ sent: number }>("/push/test", {});
return r.sent;
/** Re-register this device's existing subscription with the server (idempotent). */
export async function syncPush(): Promise<boolean> {
const sub = await currentSubscription().catch(() => null);
if (!sub) return false;
await post("/push/subscribe", sub.toJSON());
localStorage.setItem("codecraft.push", "1");
return true;
}
export async function testPush(): Promise<{ sent: number; errors: string[] }> {
// Make sure the server knows this device before testing.
await syncPush();
return post<{ sent: number; errors: string[] }>("/push/test", {});
}
+10
View File
@@ -3086,3 +3086,13 @@ button.danger-solid {
flex-direction: column;
gap: 6px;
}
.job-hook {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-top: 10px;
font-size: 13px;
color: var(--fg2);
}