@chrysb/alphaclaw 0.8.0 → 0.8.1-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/lib/public/js/app.js +100 -83
  2. package/lib/public/js/components/agents-tab/agent-pairing-section.js +47 -12
  3. package/lib/public/js/components/channels.js +14 -17
  4. package/lib/public/js/components/envars.js +42 -6
  5. package/lib/public/js/components/features.js +6 -12
  6. package/lib/public/js/components/general/use-general-tab.js +10 -5
  7. package/lib/public/js/components/google/use-gmail-watch.js +22 -18
  8. package/lib/public/js/components/google/use-google-accounts.js +23 -23
  9. package/lib/public/js/components/models-tab/use-models.js +20 -4
  10. package/lib/public/js/components/nodes-tab/connected-nodes/user-connected-nodes.js +2 -2
  11. package/lib/public/js/components/nodes-tab/use-nodes-tab.js +13 -9
  12. package/lib/public/js/components/routes/webhooks-route.js +1 -1
  13. package/lib/public/js/components/webhooks/create-webhook-modal/index.js +176 -0
  14. package/lib/public/js/components/webhooks/helpers.js +106 -0
  15. package/lib/public/js/components/webhooks/index.js +148 -0
  16. package/lib/public/js/components/webhooks/request-history/index.js +241 -0
  17. package/lib/public/js/components/webhooks/request-history/use-request-history.js +167 -0
  18. package/lib/public/js/components/webhooks/webhook-detail/index.js +374 -0
  19. package/lib/public/js/components/webhooks/webhook-detail/use-webhook-detail.js +261 -0
  20. package/lib/public/js/components/webhooks/webhook-list/index.js +96 -0
  21. package/lib/public/js/components/webhooks/webhook-list/use-webhook-list.js +30 -0
  22. package/lib/public/js/hooks/use-app-shell-controller.js +59 -6
  23. package/lib/public/js/hooks/use-cached-fetch.js +63 -0
  24. package/lib/public/js/hooks/usePolling.js +45 -7
  25. package/lib/public/js/lib/api-cache.js +88 -0
  26. package/lib/public/js/lib/api.js +64 -1
  27. package/lib/server/db/webhooks/index.js +144 -0
  28. package/lib/server/db/webhooks/schema.js +13 -0
  29. package/lib/server/init/register-server-routes.js +21 -0
  30. package/lib/server/oauth-callback-middleware.js +34 -0
  31. package/lib/server/routes/proxy.js +2 -0
  32. package/lib/server/routes/system.js +50 -2
  33. package/lib/server/routes/webhooks.js +126 -18
  34. package/lib/server/webhook-middleware.js +6 -1
  35. package/lib/server.js +12 -0
  36. package/package.json +1 -1
  37. package/lib/public/js/components/webhooks.js +0 -1259
@@ -0,0 +1,96 @@
1
+ import { h } from "https://esm.sh/preact";
2
+ import htm from "https://esm.sh/htm";
3
+ import { Badge } from "../../badge.js";
4
+ import { formatLastReceived, healthClassName } from "../helpers.js";
5
+ import { useWebhookList } from "./use-webhook-list.js";
6
+
7
+ const html = htm.bind(h);
8
+
9
+ export const WebhookList = ({
10
+ onSelectHook = () => {},
11
+ }) => {
12
+ const { state, actions } = useWebhookList({ onSelectHook });
13
+
14
+ const { webhooks, isListLoading } = state;
15
+
16
+ return html`
17
+ <div class="bg-surface border border-border rounded-xl p-4 space-y-4">
18
+ ${isListLoading
19
+ ? html`<p class="text-xs text-gray-500">Loading webhooks...</p>`
20
+ : null}
21
+ ${!isListLoading && webhooks.length === 0
22
+ ? html`<p class="text-sm text-gray-500">
23
+ No webhooks configured yet. Create one to get started.
24
+ </p>`
25
+ : null}
26
+ ${webhooks.length > 0
27
+ ? html`
28
+ <div class="overflow-auto">
29
+ <table class="w-full text-sm">
30
+ <thead>
31
+ <tr class="text-left text-xs text-gray-500 border-b border-border">
32
+ <th class="pb-2 pr-3">Path</th>
33
+ <th class="pb-2 pr-3">Last received</th>
34
+ <th class="pb-2 pr-3">Errors</th>
35
+ <th class="pb-2 pr-3">Health</th>
36
+ <th class="pb-2 pr-3">Type</th>
37
+ </tr>
38
+ </thead>
39
+ <tbody>
40
+ <tr aria-hidden="true">
41
+ <td class="h-2 p-0" colspan="5"></td>
42
+ </tr>
43
+ ${webhooks.map(
44
+ (item) => html`
45
+ <tr
46
+ class="group cursor-pointer"
47
+ onclick=${() => actions.handleSelectHook(item.name)}
48
+ >
49
+ <td
50
+ class="px-3 py-2.5 group-hover:bg-white/5 first:rounded-l-lg transition-colors"
51
+ >
52
+ <code>${item.path || `/hooks/${item.name}`}</code>
53
+ </td>
54
+ <td
55
+ class="px-3 py-2.5 text-xs text-gray-400 group-hover:bg-white/5 transition-colors"
56
+ >
57
+ ${formatLastReceived(item.lastReceived)}
58
+ </td>
59
+ <td
60
+ class="px-3 py-2.5 text-xs group-hover:bg-white/5 transition-colors"
61
+ >
62
+ ${item.errorCount || 0}
63
+ </td>
64
+ <td
65
+ class="px-3 py-2.5 group-hover:bg-white/5 last:rounded-r-lg transition-colors"
66
+ >
67
+ <span
68
+ class="inline-block w-2.5 h-2.5 rounded-full ${healthClassName(
69
+ item.health,
70
+ )}"
71
+ title=${item.health}
72
+ />
73
+ </td>
74
+ <td
75
+ class="px-3 py-2.5 text-xs text-gray-400 group-hover:bg-white/5 transition-colors"
76
+ >
77
+ ${item.managed
78
+ ? html`<span
79
+ class="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] bg-cyan-500/10 text-cyan-200"
80
+ >Managed</span
81
+ >`
82
+ : item.oauthCallbackEnabled
83
+ ? html`<${Badge} tone="neutral">OAuth</${Badge}>`
84
+ : html`<${Badge} tone="neutral">Custom</${Badge}>`}
85
+ </td>
86
+ </tr>
87
+ `,
88
+ )}
89
+ </tbody>
90
+ </table>
91
+ </div>
92
+ `
93
+ : null}
94
+ </div>
95
+ `;
96
+ };
@@ -0,0 +1,30 @@
1
+ import { useCallback } from "https://esm.sh/preact/hooks";
2
+ import { usePolling } from "../../../hooks/usePolling.js";
3
+ import { fetchWebhooks } from "../../../lib/api.js";
4
+
5
+ export const useWebhookList = ({
6
+ onSelectHook = () => {},
7
+ }) => {
8
+ const listPoll = usePolling(fetchWebhooks, 15000);
9
+
10
+ const webhooks = listPoll.data?.webhooks || [];
11
+ const isListLoading = !listPoll.data && !listPoll.error;
12
+
13
+ const handleSelectHook = useCallback(
14
+ (name) => {
15
+ onSelectHook(name);
16
+ },
17
+ [onSelectHook],
18
+ );
19
+
20
+ return {
21
+ state: {
22
+ webhooks,
23
+ isListLoading,
24
+ },
25
+ actions: {
26
+ refreshList: listPoll.refresh,
27
+ handleSelectHook,
28
+ },
29
+ };
30
+ };
@@ -10,6 +10,7 @@ import {
10
10
  fetchWatchdogStatus,
11
11
  fetchDoctorStatus,
12
12
  updateOpenclaw,
13
+ subscribeStatusEvents,
13
14
  } from "../lib/api.js";
14
15
  import { shouldRequireRestartForBrowsePath } from "../lib/browse-restart-policy.js";
15
16
  import { usePolling } from "./usePolling.js";
@@ -28,19 +29,28 @@ export const useAppShellController = ({ location = "" } = {}) => {
28
29
  const [gatewayRestartSignal, setGatewayRestartSignal] = useState(0);
29
30
  const [statusPollCadenceMs, setStatusPollCadenceMs] = useState(15000);
30
31
  const [openclawUpdateInProgress, setOpenclawUpdateInProgress] = useState(false);
32
+ const [statusStreamConnected, setStatusStreamConnected] = useState(false);
33
+ const [statusStreamStatus, setStatusStreamStatus] = useState(null);
34
+ const [statusStreamWatchdog, setStatusStreamWatchdog] = useState(null);
35
+ const [statusStreamDoctor, setStatusStreamDoctor] = useState(null);
31
36
 
32
37
  const sharedStatusPoll = usePolling(fetchStatus, statusPollCadenceMs, {
33
- enabled: onboarded === true,
38
+ enabled: onboarded === true && !statusStreamConnected,
39
+ cacheKey: "/api/status",
34
40
  });
35
41
  const sharedWatchdogPoll = usePolling(fetchWatchdogStatus, statusPollCadenceMs, {
36
- enabled: onboarded === true,
42
+ enabled: onboarded === true && !statusStreamConnected,
43
+ cacheKey: "/api/watchdog/status",
37
44
  });
38
45
  const sharedDoctorPoll = usePolling(fetchDoctorStatus, statusPollCadenceMs, {
39
- enabled: onboarded === true,
46
+ enabled: onboarded === true && !statusStreamConnected,
47
+ cacheKey: "/api/doctor/status",
40
48
  });
41
- const sharedStatus = sharedStatusPoll.data || null;
42
- const sharedWatchdogStatus = sharedWatchdogPoll.data?.status || null;
43
- const sharedDoctorStatus = sharedDoctorPoll.data?.status || null;
49
+ const sharedStatus = statusStreamStatus || sharedStatusPoll.data || null;
50
+ const sharedWatchdogStatus =
51
+ statusStreamWatchdog || sharedWatchdogPoll.data?.status || null;
52
+ const sharedDoctorStatus =
53
+ statusStreamDoctor || sharedDoctorPoll.data?.status || null;
44
54
  const isAnyRestartRequired = restartRequired || browseRestartRequired;
45
55
 
46
56
  const refreshSharedStatuses = useCallback(() => {
@@ -58,6 +68,49 @@ export const useAppShellController = ({ location = "" } = {}) => {
58
68
  .catch(() => {});
59
69
  }, []);
60
70
 
71
+ useEffect(() => {
72
+ if (onboarded !== true) return;
73
+ let disposed = false;
74
+ const startStream = () => {
75
+ if (disposed) return;
76
+ try {
77
+ return subscribeStatusEvents({
78
+ onOpen: () => {
79
+ if (disposed) return;
80
+ setStatusStreamConnected(true);
81
+ },
82
+ onMessage: (payload = {}) => {
83
+ if (disposed) return;
84
+ if (payload.status && typeof payload.status === "object") {
85
+ setStatusStreamStatus(payload.status);
86
+ }
87
+ if (payload.watchdogStatus && typeof payload.watchdogStatus === "object") {
88
+ setStatusStreamWatchdog(payload.watchdogStatus);
89
+ }
90
+ if (payload.doctorStatus && typeof payload.doctorStatus === "object") {
91
+ setStatusStreamDoctor(payload.doctorStatus);
92
+ }
93
+ },
94
+ onError: () => {
95
+ if (disposed) return;
96
+ setStatusStreamConnected(false);
97
+ },
98
+ });
99
+ } catch {
100
+ setStatusStreamConnected(false);
101
+ return null;
102
+ }
103
+ };
104
+ let cleanup = startStream();
105
+ return () => {
106
+ disposed = true;
107
+ setStatusStreamConnected(false);
108
+ if (typeof cleanup === "function") {
109
+ cleanup();
110
+ }
111
+ };
112
+ }, [onboarded]);
113
+
61
114
  useEffect(() => {
62
115
  if (!onboarded) return;
63
116
  let active = true;
@@ -0,0 +1,63 @@
1
+ import { useCallback, useEffect, useMemo, useState } from "https://esm.sh/preact/hooks";
2
+ import { cachedFetch, getCached } from "../lib/api-cache.js";
3
+
4
+ export const useCachedFetch = (
5
+ key,
6
+ fetcher,
7
+ {
8
+ enabled = true,
9
+ maxAgeMs = 15000,
10
+ staleWhileRevalidate = true,
11
+ } = {},
12
+ ) => {
13
+ const normalizedKey = useMemo(() => String(key || ""), [key]);
14
+ const initialCachedData = useMemo(() => getCached(normalizedKey), [normalizedKey]);
15
+ const [data, setData] = useState(initialCachedData);
16
+ const [loading, setLoading] = useState(initialCachedData === null);
17
+ const [error, setError] = useState(null);
18
+
19
+ useEffect(() => {
20
+ setData(getCached(normalizedKey));
21
+ }, [normalizedKey]);
22
+
23
+ const refresh = useCallback(
24
+ async ({ force = false } = {}) => {
25
+ if (!enabled) return getCached(normalizedKey);
26
+ if (getCached(normalizedKey) === null) {
27
+ setLoading(true);
28
+ }
29
+ try {
30
+ const next = await cachedFetch(normalizedKey, fetcher, {
31
+ maxAgeMs,
32
+ force,
33
+ staleWhileRevalidate,
34
+ onRevalidate: (revalidatedData) => {
35
+ setData(revalidatedData);
36
+ setError(null);
37
+ },
38
+ });
39
+ setData(next);
40
+ setError(null);
41
+ return next;
42
+ } catch (err) {
43
+ setError(err);
44
+ throw err;
45
+ } finally {
46
+ setLoading(false);
47
+ }
48
+ },
49
+ [enabled, fetcher, maxAgeMs, normalizedKey, staleWhileRevalidate],
50
+ );
51
+
52
+ useEffect(() => {
53
+ if (!enabled) return;
54
+ refresh().catch(() => {});
55
+ }, [enabled, refresh]);
56
+
57
+ return {
58
+ data,
59
+ error,
60
+ loading,
61
+ refresh,
62
+ };
63
+ };
@@ -1,7 +1,19 @@
1
- import { useState, useEffect, useCallback, useRef } from 'https://esm.sh/preact/hooks';
1
+ import { useState, useEffect, useCallback, useRef } from "https://esm.sh/preact/hooks";
2
+ import { getCached, setCached } from "../lib/api-cache.js";
2
3
 
3
- export const usePolling = (fetcher, interval, { enabled = true } = {}) => {
4
- const [data, setData] = useState(null);
4
+ export const usePolling = (
5
+ fetcher,
6
+ interval,
7
+ {
8
+ enabled = true,
9
+ pauseWhenHidden = true,
10
+ cacheKey = "",
11
+ } = {},
12
+ ) => {
13
+ const normalizedCacheKey = String(cacheKey || "");
14
+ const [data, setData] = useState(() =>
15
+ normalizedCacheKey ? getCached(normalizedCacheKey) : null,
16
+ );
5
17
  const [error, setError] = useState(null);
6
18
  const fetcherRef = useRef(fetcher);
7
19
  fetcherRef.current = fetcher;
@@ -9,6 +21,9 @@ export const usePolling = (fetcher, interval, { enabled = true } = {}) => {
9
21
  const refresh = useCallback(async () => {
10
22
  try {
11
23
  const result = await fetcherRef.current();
24
+ if (normalizedCacheKey) {
25
+ setCached(normalizedCacheKey, result);
26
+ }
12
27
  setData(result);
13
28
  setError(null);
14
29
  return result;
@@ -16,14 +31,37 @@ export const usePolling = (fetcher, interval, { enabled = true } = {}) => {
16
31
  setError(err);
17
32
  return null;
18
33
  }
19
- }, []);
34
+ }, [normalizedCacheKey]);
35
+
36
+ useEffect(() => {
37
+ if (!normalizedCacheKey) return;
38
+ const cached = getCached(normalizedCacheKey);
39
+ if (cached !== null) {
40
+ setData(cached);
41
+ }
42
+ }, [normalizedCacheKey]);
20
43
 
21
44
  useEffect(() => {
22
45
  if (!enabled) return;
46
+ if (pauseWhenHidden && typeof document !== "undefined" && document.hidden) {
47
+ return undefined;
48
+ }
23
49
  refresh();
24
- const id = setInterval(refresh, interval);
25
- return () => clearInterval(id);
26
- }, [enabled, interval, refresh]);
50
+ const intervalId = setInterval(refresh, interval);
51
+ return () => clearInterval(intervalId);
52
+ }, [enabled, interval, pauseWhenHidden, refresh]);
53
+
54
+ useEffect(() => {
55
+ if (!enabled || !pauseWhenHidden || typeof document === "undefined") return;
56
+ const handleVisibilityChange = () => {
57
+ if (!document.hidden) {
58
+ refresh();
59
+ }
60
+ };
61
+ document.addEventListener("visibilitychange", handleVisibilityChange);
62
+ return () =>
63
+ document.removeEventListener("visibilitychange", handleVisibilityChange);
64
+ }, [enabled, pauseWhenHidden, refresh]);
27
65
 
28
66
  return { data, error, refresh };
29
67
  };
@@ -0,0 +1,88 @@
1
+ const kApiCache = new Map();
2
+ const kInFlightByKey = new Map();
3
+
4
+ const nowMs = () => Date.now();
5
+
6
+ const isFresh = (entry, maxAgeMs) => {
7
+ if (!entry) return false;
8
+ return nowMs() - Number(entry.fetchedAt || 0) < Number(maxAgeMs || 0);
9
+ };
10
+
11
+ export const getCached = (key = "") => {
12
+ const normalizedKey = String(key || "");
13
+ if (!normalizedKey) return null;
14
+ return kApiCache.get(normalizedKey)?.data ?? null;
15
+ };
16
+
17
+ export const setCached = (key = "", data = null) => {
18
+ const normalizedKey = String(key || "");
19
+ if (!normalizedKey) return data;
20
+ kApiCache.set(normalizedKey, {
21
+ data,
22
+ fetchedAt: nowMs(),
23
+ });
24
+ return data;
25
+ };
26
+
27
+ export const invalidateCache = (key = "") => {
28
+ const normalizedKey = String(key || "");
29
+ if (!normalizedKey) return;
30
+ kApiCache.delete(normalizedKey);
31
+ kInFlightByKey.delete(normalizedKey);
32
+ };
33
+
34
+ export const cachedFetch = async (
35
+ key,
36
+ fetcher,
37
+ {
38
+ maxAgeMs = 15000,
39
+ force = false,
40
+ staleWhileRevalidate = true,
41
+ onRevalidate = null,
42
+ } = {},
43
+ ) => {
44
+ const normalizedKey = String(key || "");
45
+ if (!normalizedKey || typeof fetcher !== "function") {
46
+ return fetcher();
47
+ }
48
+
49
+ const entry = kApiCache.get(normalizedKey);
50
+ if (!force && isFresh(entry, maxAgeMs)) {
51
+ return entry.data;
52
+ }
53
+
54
+ if (!force && staleWhileRevalidate && entry) {
55
+ if (!kInFlightByKey.has(normalizedKey)) {
56
+ const backgroundPromise = Promise.resolve()
57
+ .then(() => fetcher())
58
+ .then((result) => {
59
+ setCached(normalizedKey, result);
60
+ if (typeof onRevalidate === "function") {
61
+ onRevalidate(result);
62
+ }
63
+ return result;
64
+ })
65
+ .finally(() => {
66
+ kInFlightByKey.delete(normalizedKey);
67
+ });
68
+ kInFlightByKey.set(normalizedKey, backgroundPromise);
69
+ }
70
+ return entry.data;
71
+ }
72
+
73
+ if (kInFlightByKey.has(normalizedKey)) {
74
+ return kInFlightByKey.get(normalizedKey);
75
+ }
76
+
77
+ const requestPromise = Promise.resolve()
78
+ .then(() => fetcher())
79
+ .then((result) => {
80
+ setCached(normalizedKey, result);
81
+ return result;
82
+ })
83
+ .finally(() => {
84
+ kInFlightByKey.delete(normalizedKey);
85
+ });
86
+ kInFlightByKey.set(normalizedKey, requestPromise);
87
+ return requestPromise;
88
+ };
@@ -31,6 +31,35 @@ export const authFetch = async (url, opts = {}) => {
31
31
  return res;
32
32
  };
33
33
 
34
+ export const subscribeStatusEvents = ({
35
+ onMessage = () => {},
36
+ onOpen = () => {},
37
+ onError = () => {},
38
+ } = {}) => {
39
+ if (typeof window?.EventSource !== "function") {
40
+ throw new Error("Server events are not supported in this browser");
41
+ }
42
+ const source = new window.EventSource("/api/events/status", {
43
+ withCredentials: true,
44
+ });
45
+ const handleStatus = (event) => {
46
+ let payload = {};
47
+ try {
48
+ payload = event?.data ? JSON.parse(event.data) : {};
49
+ } catch {}
50
+ onMessage(payload || {});
51
+ };
52
+ source.addEventListener("status", handleStatus);
53
+ source.onopen = () => onOpen();
54
+ source.onerror = (event) => onError(event);
55
+ return () => {
56
+ source.removeEventListener("status", handleStatus);
57
+ source.onopen = null;
58
+ source.onerror = null;
59
+ source.close();
60
+ };
61
+ };
62
+
34
63
  export async function fetchStatus() {
35
64
  const res = await authFetch("/api/status");
36
65
  return res.json();
@@ -1104,13 +1133,17 @@ export async function fetchWebhookDetail(name) {
1104
1133
  return parseJsonOrThrow(res, "Could not load webhook detail");
1105
1134
  }
1106
1135
 
1107
- export async function createWebhook(name, { destination = null } = {}) {
1136
+ export async function createWebhook(
1137
+ name,
1138
+ { destination = null, oauthCallback = false } = {},
1139
+ ) {
1108
1140
  const res = await authFetch("/api/webhooks", {
1109
1141
  method: "POST",
1110
1142
  headers: { "Content-Type": "application/json" },
1111
1143
  body: JSON.stringify({
1112
1144
  name,
1113
1145
  ...(destination ? { destination } : {}),
1146
+ oauthCallback: !!oauthCallback,
1114
1147
  }),
1115
1148
  });
1116
1149
  return parseJsonOrThrow(res, "Could not create webhook");
@@ -1125,6 +1158,36 @@ export async function deleteWebhook(name, { deleteTransformDir = false } = {}) {
1125
1158
  return parseJsonOrThrow(res, "Could not delete webhook");
1126
1159
  }
1127
1160
 
1161
+ export async function createWebhookOauthCallback(name) {
1162
+ const res = await authFetch(
1163
+ `/api/webhooks/${encodeURIComponent(name)}/oauth-callback`,
1164
+ {
1165
+ method: "POST",
1166
+ },
1167
+ );
1168
+ return parseJsonOrThrow(res, "Could not enable OAuth callback");
1169
+ }
1170
+
1171
+ export async function rotateWebhookOauthCallback(name) {
1172
+ const res = await authFetch(
1173
+ `/api/webhooks/${encodeURIComponent(name)}/oauth-callback/rotate`,
1174
+ {
1175
+ method: "POST",
1176
+ },
1177
+ );
1178
+ return parseJsonOrThrow(res, "Could not rotate OAuth callback");
1179
+ }
1180
+
1181
+ export async function deleteWebhookOauthCallback(name) {
1182
+ const res = await authFetch(
1183
+ `/api/webhooks/${encodeURIComponent(name)}/oauth-callback`,
1184
+ {
1185
+ method: "DELETE",
1186
+ },
1187
+ );
1188
+ return parseJsonOrThrow(res, "Could not delete OAuth callback");
1189
+ }
1190
+
1128
1191
  export async function fetchWebhookRequests(
1129
1192
  name,
1130
1193
  { limit = 50, offset = 0, status = "all" } = {},