khotan-data 0.1.1 → 0.3.0
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.
- package/README.md +60 -19
- package/dist/cli.js +183 -46
- package/dist/factory.cjs +86 -9
- package/dist/factory.cjs.map +1 -1
- package/dist/factory.d.cts +46 -1
- package/dist/factory.d.ts +46 -1
- package/dist/factory.js +86 -10
- package/dist/factory.js.map +1 -1
- package/dist/templates/api-state.tsx +249 -0
- package/dist/templates/catch.example.ts +25 -17
- package/dist/templates/catch.ts +20 -15
- package/dist/templates/debug-index-page.tsx +56 -36
- package/dist/templates/hub.tsx +105 -36
- package/dist/templates/inflow.example.ts +46 -38
- package/dist/templates/inflow.ts +37 -31
- package/dist/templates/khotan-config.ts +28 -0
- package/dist/templates/mapping-browser.tsx +56 -44
- package/dist/templates/outflow.example.ts +39 -31
- package/dist/templates/outflow.ts +28 -23
- package/dist/templates/pass.example.ts +38 -30
- package/dist/templates/pass.ts +29 -24
- package/dist/templates/plug-debugger.tsx +15 -7
- package/dist/templates/relay.example.ts +52 -44
- package/dist/templates/relay.ts +38 -33
- package/dist/templates/runs-table.tsx +133 -130
- package/dist/templates/skill-dashboard.md +2 -1
- package/dist/templates/skill-setup.md +113 -2
- package/dist/templates/skill-webhook.md +45 -23
- package/dist/templates/topology-canvas.tsx +19 -30
- package/dist/templates/var-panel.tsx +33 -10
- package/dist/templates/webhook-events-table.tsx +105 -102
- package/dist/templates/wire-panel.tsx +30 -8
- package/package.json +1 -1
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
// ============================================================================
|
|
6
|
+
// Khotan API helpers — typed fetch + graceful error/empty UI states
|
|
7
|
+
// Generated by khotan CLI · https://github.com/khotan-data
|
|
8
|
+
//
|
|
9
|
+
// This file is yours. Every khotan dashboard component imports `khotanFetch`
|
|
10
|
+
// and `<ApiErrorState>` from here so that 401 (auth), 403, 404, 5xx, and
|
|
11
|
+
// network failures render a simple, consistent UI instead of a blank screen
|
|
12
|
+
// or a thrown error. Restyle it however you like.
|
|
13
|
+
// ============================================================================
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Error thrown by {@link khotanFetch} when a request fails. Carries the HTTP
|
|
17
|
+
* status so the UI can branch on it. `status === 0` means a network-level
|
|
18
|
+
* failure (server unreachable) rather than an HTTP error response.
|
|
19
|
+
*/
|
|
20
|
+
export class KhotanApiError extends Error {
|
|
21
|
+
readonly status: number;
|
|
22
|
+
readonly statusText: string;
|
|
23
|
+
readonly body: unknown;
|
|
24
|
+
|
|
25
|
+
constructor(status: number, statusText: string, body?: unknown) {
|
|
26
|
+
super(
|
|
27
|
+
status === 0
|
|
28
|
+
? "Could not reach the server"
|
|
29
|
+
: `Request failed: ${String(status)} ${statusText}`,
|
|
30
|
+
);
|
|
31
|
+
this.name = "KhotanApiError";
|
|
32
|
+
this.status = status;
|
|
33
|
+
this.statusText = statusText;
|
|
34
|
+
this.body = body;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function isKhotanApiError(error: unknown): error is KhotanApiError {
|
|
39
|
+
return error instanceof KhotanApiError;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* `fetch` wrapper that throws a typed {@link KhotanApiError} on any non-2xx
|
|
44
|
+
* response (or network failure) and returns parsed JSON otherwise. Use it for
|
|
45
|
+
* every call to the khotan API so error handling stays consistent.
|
|
46
|
+
*/
|
|
47
|
+
export async function khotanFetch<T = unknown>(
|
|
48
|
+
input: string,
|
|
49
|
+
init?: RequestInit,
|
|
50
|
+
): Promise<T> {
|
|
51
|
+
let res: Response;
|
|
52
|
+
try {
|
|
53
|
+
res = await fetch(input, init);
|
|
54
|
+
} catch (err) {
|
|
55
|
+
throw new KhotanApiError(
|
|
56
|
+
0,
|
|
57
|
+
"Network Error",
|
|
58
|
+
err instanceof Error ? err.message : undefined,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!res.ok) {
|
|
63
|
+
let body: unknown;
|
|
64
|
+
try {
|
|
65
|
+
body = await res.clone().json();
|
|
66
|
+
} catch {
|
|
67
|
+
try {
|
|
68
|
+
body = await res.text();
|
|
69
|
+
} catch {
|
|
70
|
+
body = undefined;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
throw new KhotanApiError(res.status, res.statusText, body);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (res.status === 204) return undefined as T;
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
return (await res.json()) as T;
|
|
80
|
+
} catch {
|
|
81
|
+
return undefined as T;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface ErrorCopy {
|
|
86
|
+
/** HTTP status code, or null for non-HTTP failures. */
|
|
87
|
+
code: number | null;
|
|
88
|
+
title: string;
|
|
89
|
+
message: string;
|
|
90
|
+
/** Auth errors (401/403) show a lock icon instead of the alert icon. */
|
|
91
|
+
isAuth: boolean;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function describeError(error: unknown): ErrorCopy {
|
|
95
|
+
const status = isKhotanApiError(error) ? error.status : null;
|
|
96
|
+
|
|
97
|
+
if (status === 401 || status === 403) {
|
|
98
|
+
return {
|
|
99
|
+
code: status,
|
|
100
|
+
title: "Access denied",
|
|
101
|
+
message: "You don't have permission to view this.",
|
|
102
|
+
isAuth: true,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
if (status === 404) {
|
|
106
|
+
return {
|
|
107
|
+
code: 404,
|
|
108
|
+
title: "Not found",
|
|
109
|
+
message: "This resource doesn't exist.",
|
|
110
|
+
isAuth: false,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
if (status === 429) {
|
|
114
|
+
return {
|
|
115
|
+
code: 429,
|
|
116
|
+
title: "Too many requests",
|
|
117
|
+
message: "Slow down and try again in a moment.",
|
|
118
|
+
isAuth: false,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
if (status !== null && status >= 500) {
|
|
122
|
+
return {
|
|
123
|
+
code: status,
|
|
124
|
+
title: "Server error",
|
|
125
|
+
message: "Something went wrong on the server.",
|
|
126
|
+
isAuth: false,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
if (status !== null && status >= 400) {
|
|
130
|
+
return {
|
|
131
|
+
code: status,
|
|
132
|
+
title: "Request error",
|
|
133
|
+
message: "The request couldn't be completed.",
|
|
134
|
+
isAuth: false,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
code: null,
|
|
139
|
+
title: "Connection error",
|
|
140
|
+
message: "Couldn't reach the server.",
|
|
141
|
+
isAuth: false,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function LockIcon({ className = "" }: { className?: string }): ReactNode {
|
|
146
|
+
return (
|
|
147
|
+
<svg
|
|
148
|
+
viewBox="0 0 24 24"
|
|
149
|
+
fill="none"
|
|
150
|
+
stroke="currentColor"
|
|
151
|
+
strokeWidth={1.5}
|
|
152
|
+
strokeLinecap="round"
|
|
153
|
+
strokeLinejoin="round"
|
|
154
|
+
aria-hidden="true"
|
|
155
|
+
className={className}
|
|
156
|
+
>
|
|
157
|
+
<rect x="3.5" y="10.5" width="17" height="10" rx="2" />
|
|
158
|
+
<path d="M7.5 10.5V7a4.5 4.5 0 0 1 9 0v3.5" />
|
|
159
|
+
<circle cx="12" cy="15.5" r="1.25" />
|
|
160
|
+
</svg>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function AlertIcon({ className = "" }: { className?: string }): ReactNode {
|
|
165
|
+
return (
|
|
166
|
+
<svg
|
|
167
|
+
viewBox="0 0 24 24"
|
|
168
|
+
fill="none"
|
|
169
|
+
stroke="currentColor"
|
|
170
|
+
strokeWidth={1.5}
|
|
171
|
+
strokeLinecap="round"
|
|
172
|
+
strokeLinejoin="round"
|
|
173
|
+
aria-hidden="true"
|
|
174
|
+
className={className}
|
|
175
|
+
>
|
|
176
|
+
<circle cx="12" cy="12" r="9" />
|
|
177
|
+
<path d="M12 8v5" />
|
|
178
|
+
<path d="M12 16.5h.01" />
|
|
179
|
+
</svg>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export interface ApiErrorStateProps {
|
|
184
|
+
error: unknown;
|
|
185
|
+
/** Called when the user clicks Retry. Omit to hide the Retry button. */
|
|
186
|
+
onRetry?: () => void;
|
|
187
|
+
/** Render a smaller inline banner (for nested panels) instead of a full card. */
|
|
188
|
+
compact?: boolean;
|
|
189
|
+
className?: string;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Simple UI for a failed khotan API request — shows an icon, the HTTP status,
|
|
194
|
+
* and a short message, plus an optional Retry button. Intentionally minimal.
|
|
195
|
+
*/
|
|
196
|
+
export function ApiErrorState({
|
|
197
|
+
error,
|
|
198
|
+
onRetry,
|
|
199
|
+
compact = false,
|
|
200
|
+
className = "",
|
|
201
|
+
}: ApiErrorStateProps): ReactNode {
|
|
202
|
+
const { code, title, message, isAuth } = describeError(error);
|
|
203
|
+
const Icon = isAuth ? LockIcon : AlertIcon;
|
|
204
|
+
|
|
205
|
+
if (compact) {
|
|
206
|
+
return (
|
|
207
|
+
<div
|
|
208
|
+
role="alert"
|
|
209
|
+
className={`flex flex-wrap items-center justify-between gap-2 rounded-md border border-border bg-muted/30 px-3 py-2 ${className}`}
|
|
210
|
+
>
|
|
211
|
+
<span className="flex items-center gap-2 text-xs font-medium text-foreground">
|
|
212
|
+
<Icon className="h-4 w-4 text-muted-foreground" />
|
|
213
|
+
{code ? `${String(code)} — ${title}` : title}
|
|
214
|
+
</span>
|
|
215
|
+
{onRetry && (
|
|
216
|
+
<button
|
|
217
|
+
type="button"
|
|
218
|
+
onClick={onRetry}
|
|
219
|
+
className="text-xs text-muted-foreground underline-offset-2 hover:underline"
|
|
220
|
+
>
|
|
221
|
+
Retry
|
|
222
|
+
</button>
|
|
223
|
+
)}
|
|
224
|
+
</div>
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return (
|
|
229
|
+
<div
|
|
230
|
+
role="alert"
|
|
231
|
+
className={`flex flex-col items-center justify-center rounded-lg border border-border bg-card px-6 py-12 text-center ${className}`}
|
|
232
|
+
>
|
|
233
|
+
<Icon className="h-8 w-8 text-muted-foreground" />
|
|
234
|
+
<p className="mt-3 text-sm font-medium text-foreground">
|
|
235
|
+
{code ? `${String(code)} — ${title}` : title}
|
|
236
|
+
</p>
|
|
237
|
+
<p className="mt-1 text-sm text-muted-foreground">{message}</p>
|
|
238
|
+
{onRetry && (
|
|
239
|
+
<button
|
|
240
|
+
type="button"
|
|
241
|
+
onClick={onRetry}
|
|
242
|
+
className="mt-4 text-xs text-muted-foreground underline-offset-2 hover:underline"
|
|
243
|
+
>
|
|
244
|
+
Retry
|
|
245
|
+
</button>
|
|
246
|
+
)}
|
|
247
|
+
</div>
|
|
248
|
+
);
|
|
249
|
+
}
|
|
@@ -4,29 +4,37 @@
|
|
|
4
4
|
//
|
|
5
5
|
// Copy this file, rename it for your webhook source/event, and register the
|
|
6
6
|
// exported catch handler in {outputDir}/khotan.ts.
|
|
7
|
+
//
|
|
8
|
+
// IMPORTANT — Workflow step structure:
|
|
9
|
+
// Declare "use step" functions at module top level and pass them only
|
|
10
|
+
// serializable values (the `ctx` object is plain data and is safe to pass).
|
|
11
|
+
// Do NOT nest step functions inside the "use workflow" function — the Workflow
|
|
12
|
+
// compiler cannot hoist closures that capture workflow scope, and they fail at
|
|
13
|
+
// runtime in the sandbox. Keep the workflow body limited to orchestration.
|
|
7
14
|
// ============================================================================
|
|
8
15
|
|
|
9
16
|
import { catchEvent, type CatchContext } from "./catch";
|
|
10
17
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
khotanRunId: ctx.khotanRunId,
|
|
19
|
-
});
|
|
18
|
+
// Step: full Node.js access, retried independently. Receives serializable ctx.
|
|
19
|
+
async function persistEvent(ctx: CatchContext) {
|
|
20
|
+
"use step";
|
|
21
|
+
console.log("Handling webhook event", {
|
|
22
|
+
eventType: ctx.eventType,
|
|
23
|
+
khotanRunId: ctx.khotanRunId,
|
|
24
|
+
});
|
|
20
25
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
// Khotan already records webhook deliveries. Add app-specific side effects
|
|
27
|
+
// here, such as updating a local table or enqueueing downstream work.
|
|
28
|
+
console.log("Webhook payload", {
|
|
29
|
+
event: ctx.event,
|
|
30
|
+
headers: ctx.headers,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
28
33
|
|
|
29
|
-
|
|
34
|
+
// Workflow: orchestration only. Calls top-level steps with serializable args.
|
|
35
|
+
async function stripeInvoiceCatchWorkflow(ctx: CatchContext) {
|
|
36
|
+
"use workflow";
|
|
37
|
+
await persistEvent(ctx);
|
|
30
38
|
}
|
|
31
39
|
|
|
32
40
|
export const stripeInvoiceCatch = catchEvent({
|
package/dist/templates/catch.ts
CHANGED
|
@@ -81,26 +81,31 @@ export function catchEvent(config: CatchConfig): CatchRegistration {
|
|
|
81
81
|
// them to khotan_runs. Use your catch workflow for app-specific side effects
|
|
82
82
|
// and optionally khotanCache(ctx, "name") for dedupe or cursor state.
|
|
83
83
|
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
84
|
+
// Declare "use step" functions at MODULE TOP LEVEL and pass them serializable
|
|
85
|
+
// values only (`ctx` is plain data). Do NOT nest steps inside the "use workflow"
|
|
86
|
+
// function — closures over workflow scope cannot be hoisted and fail at runtime.
|
|
86
87
|
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
88
|
+
// // Step: top-level, full Node.js access, retried independently.
|
|
89
|
+
// async function notifyOps(ctx: CatchContext) {
|
|
90
|
+
// "use step";
|
|
91
|
+
// const cache = khotanCache(ctx, "pollinate-webhook-markers");
|
|
92
|
+
// const eventId = String(ctx.event["id"] ?? "");
|
|
93
|
+
// if (eventId && (await cache.get<boolean>(eventId))) return;
|
|
92
94
|
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
95
|
+
// console.log("Handled webhook", {
|
|
96
|
+
// eventType: ctx.eventType,
|
|
97
|
+
// khotanRunId: ctx.khotanRunId,
|
|
98
|
+
// });
|
|
97
99
|
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
// }
|
|
100
|
+
// if (eventId) {
|
|
101
|
+
// await cache.set(eventId, true);
|
|
101
102
|
// }
|
|
103
|
+
// }
|
|
102
104
|
//
|
|
103
|
-
//
|
|
105
|
+
// // Workflow: orchestration only.
|
|
106
|
+
// async function pollinateCatchWorkflow(ctx: CatchContext) {
|
|
107
|
+
// "use workflow";
|
|
108
|
+
// await notifyOps(ctx);
|
|
104
109
|
// }
|
|
105
110
|
//
|
|
106
111
|
// export const pollinateCatch = catchEvent({
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useEffect, useState } from "react";
|
|
3
|
+
import { useCallback, useEffect, useState } from "react";
|
|
4
4
|
import Link from "next/link";
|
|
5
|
+
import { khotanFetch, ApiErrorState } from "@/components/khotan/api-state";
|
|
5
6
|
|
|
6
7
|
// ============================================================================
|
|
7
8
|
// Debug Index — Lists all registered plugs for debugging
|
|
@@ -23,20 +24,28 @@ export default function DebugIndexPage() {
|
|
|
23
24
|
const [plugs, setPlugs] = useState<Plug[]>([]);
|
|
24
25
|
const [loading, setLoading] = useState(true);
|
|
25
26
|
const [debugEnabled, setDebugEnabled] = useState<boolean | null>(null);
|
|
27
|
+
const [error, setError] = useState<unknown>(null);
|
|
26
28
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
setPlugs(
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
|
|
29
|
+
const load = useCallback(async () => {
|
|
30
|
+
setLoading(true);
|
|
31
|
+
setError(null);
|
|
32
|
+
try {
|
|
33
|
+
const enabled = await fetch("/api/khotan/debug").then((r) => r.ok);
|
|
34
|
+
setDebugEnabled(enabled);
|
|
35
|
+
if (enabled) {
|
|
36
|
+
setPlugs(await khotanFetch<Plug[]>("/api/khotan/plugs"));
|
|
37
|
+
}
|
|
38
|
+
} catch (err) {
|
|
39
|
+
setError(err);
|
|
40
|
+
} finally {
|
|
41
|
+
setLoading(false);
|
|
42
|
+
}
|
|
38
43
|
}, []);
|
|
39
44
|
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
void load();
|
|
47
|
+
}, [load]);
|
|
48
|
+
|
|
40
49
|
if (loading) {
|
|
41
50
|
return (
|
|
42
51
|
<main className="container mx-auto max-w-3xl px-4 py-10">
|
|
@@ -71,31 +80,42 @@ export default function DebugIndexPage() {
|
|
|
71
80
|
</a>
|
|
72
81
|
</div>
|
|
73
82
|
<h1 className="text-2xl font-bold tracking-tight mb-6">Debug</h1>
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
83
|
+
{error ? (
|
|
84
|
+
<ApiErrorState error={error} onRetry={() => void load()} />
|
|
85
|
+
) : (
|
|
86
|
+
<>
|
|
87
|
+
<p className="text-muted-foreground mb-6">
|
|
88
|
+
Select a plug to test requests through its real code path.
|
|
89
|
+
</p>
|
|
90
|
+
<div className="grid gap-3">
|
|
91
|
+
{plugs.map((plug) => (
|
|
92
|
+
<Link
|
|
93
|
+
key={plug.id}
|
|
94
|
+
href={`/debug/${plug.name}`}
|
|
95
|
+
className="flex items-center justify-between rounded-lg border border-border p-4 transition-colors hover:border-foreground/30 hover:bg-muted/50"
|
|
96
|
+
>
|
|
97
|
+
<div>
|
|
98
|
+
<p className="font-medium">{plug.name}</p>
|
|
99
|
+
<p className="text-xs text-muted-foreground truncate">
|
|
100
|
+
{plug.baseUrl}
|
|
101
|
+
</p>
|
|
102
|
+
</div>
|
|
103
|
+
<div className="flex items-center gap-2">
|
|
104
|
+
<span className="text-xs text-muted-foreground">
|
|
105
|
+
{plug.authType}
|
|
106
|
+
</span>
|
|
107
|
+
<span className="text-muted-foreground">→</span>
|
|
108
|
+
</div>
|
|
109
|
+
</Link>
|
|
110
|
+
))}
|
|
111
|
+
{plugs.length === 0 && (
|
|
112
|
+
<p className="text-sm text-muted-foreground">
|
|
113
|
+
No plugs registered yet.
|
|
88
114
|
</p>
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
</span>
|
|
94
|
-
<span className="text-muted-foreground">→</span>
|
|
95
|
-
</div>
|
|
96
|
-
</Link>
|
|
97
|
-
))}
|
|
98
|
-
</div>
|
|
115
|
+
)}
|
|
116
|
+
</div>
|
|
117
|
+
</>
|
|
118
|
+
)}
|
|
99
119
|
</main>
|
|
100
120
|
);
|
|
101
121
|
}
|