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
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { Fragment, useCallback, useEffect, useState } from "react";
|
|
4
4
|
import { RefreshCw } from "lucide-react";
|
|
5
|
+
import { khotanFetch, ApiErrorState } from "./api-state";
|
|
5
6
|
import { Badge } from "@/components/ui/badge";
|
|
6
7
|
import { Button } from "@/components/ui/button";
|
|
7
8
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
@@ -359,7 +360,7 @@ export function KhotanRunsTable({ pageSize = 10 }: { pageSize?: number } = {}) {
|
|
|
359
360
|
const [data, setData] = useState<PageResponse<RunLogItem> | null>(null);
|
|
360
361
|
const [offset, setOffset] = useState(0);
|
|
361
362
|
const [loading, setLoading] = useState(true);
|
|
362
|
-
const [error, setError] = useState<
|
|
363
|
+
const [error, setError] = useState<unknown>(null);
|
|
363
364
|
const [refreshKey, setRefreshKey] = useState(0);
|
|
364
365
|
const [expandedRunId, setExpandedRunId] = useState<string | null>(null);
|
|
365
366
|
const [streamingEnabled, setStreamingEnabled] = useState(false);
|
|
@@ -378,20 +379,16 @@ export function KhotanRunsTable({ pageSize = 10 }: { pageSize?: number } = {}) {
|
|
|
378
379
|
setLoading(true);
|
|
379
380
|
setError(null);
|
|
380
381
|
try {
|
|
381
|
-
const
|
|
382
|
+
const json = await khotanFetch<PageResponse<RunLogItem>>(
|
|
382
383
|
`/api/khotan/runs?limit=${String(pageSize)}&offset=${String(offset)}`,
|
|
383
384
|
);
|
|
384
|
-
if (!res.ok) {
|
|
385
|
-
throw new Error("Failed to load runs");
|
|
386
|
-
}
|
|
387
|
-
const json = (await res.json()) as PageResponse<RunLogItem>;
|
|
388
385
|
if (!cancelled) {
|
|
389
386
|
setData(json);
|
|
390
387
|
setLastUpdatedAt(new Date().toISOString());
|
|
391
388
|
}
|
|
392
389
|
} catch (err) {
|
|
393
390
|
if (!cancelled) {
|
|
394
|
-
setError(err
|
|
391
|
+
setError(err);
|
|
395
392
|
}
|
|
396
393
|
} finally {
|
|
397
394
|
if (!cancelled) {
|
|
@@ -454,138 +451,144 @@ export function KhotanRunsTable({ pageSize = 10 }: { pageSize?: number } = {}) {
|
|
|
454
451
|
</CardHeader>
|
|
455
452
|
<CardContent className="space-y-4">
|
|
456
453
|
{error ? (
|
|
457
|
-
<
|
|
458
|
-
{error}
|
|
459
|
-
|
|
454
|
+
<ApiErrorState
|
|
455
|
+
error={error}
|
|
456
|
+
onRetry={() => setRefreshKey((v) => v + 1)}
|
|
457
|
+
compact
|
|
458
|
+
/>
|
|
460
459
|
) : null}
|
|
461
460
|
|
|
462
|
-
|
|
463
|
-
<
|
|
464
|
-
<
|
|
465
|
-
<TableHead>Started</TableHead>
|
|
466
|
-
<TableHead>Status</TableHead>
|
|
467
|
-
<TableHead>Source</TableHead>
|
|
468
|
-
<TableHead>Plug</TableHead>
|
|
469
|
-
<TableHead>Run Type</TableHead>
|
|
470
|
-
<TableHead>Counts</TableHead>
|
|
471
|
-
<TableHead>Workflow</TableHead>
|
|
472
|
-
<TableHead />
|
|
473
|
-
</TableRow>
|
|
474
|
-
</TableHeader>
|
|
475
|
-
<TableBody>
|
|
476
|
-
{loading ? (
|
|
461
|
+
{error ? null : (
|
|
462
|
+
<Table>
|
|
463
|
+
<TableHeader>
|
|
477
464
|
<TableRow>
|
|
478
|
-
<
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
>
|
|
482
|
-
|
|
483
|
-
</
|
|
465
|
+
<TableHead>Started</TableHead>
|
|
466
|
+
<TableHead>Status</TableHead>
|
|
467
|
+
<TableHead>Source</TableHead>
|
|
468
|
+
<TableHead>Plug</TableHead>
|
|
469
|
+
<TableHead>Run Type</TableHead>
|
|
470
|
+
<TableHead>Counts</TableHead>
|
|
471
|
+
<TableHead>Workflow</TableHead>
|
|
472
|
+
<TableHead />
|
|
484
473
|
</TableRow>
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
{statusLabel[item.status]}
|
|
500
|
-
</Badge>
|
|
501
|
-
{item.error ? (
|
|
502
|
-
<div
|
|
503
|
-
className="mt-1 max-w-56 truncate text-xs text-destructive"
|
|
504
|
-
title={item.error}
|
|
505
|
-
>
|
|
506
|
-
{item.error}
|
|
507
|
-
</div>
|
|
508
|
-
) : null}
|
|
509
|
-
</TableCell>
|
|
510
|
-
<TableCell className="font-medium">
|
|
511
|
-
{formatSource(item)}
|
|
512
|
-
</TableCell>
|
|
513
|
-
<TableCell className="text-muted-foreground">
|
|
514
|
-
{item.plugName ?? "-"}
|
|
515
|
-
</TableCell>
|
|
516
|
-
<TableCell className="font-mono text-xs">
|
|
517
|
-
{item.runType}
|
|
518
|
-
</TableCell>
|
|
519
|
-
<TableCell className="max-w-64 text-xs text-muted-foreground">
|
|
520
|
-
{formatCounts(item)}
|
|
521
|
-
</TableCell>
|
|
522
|
-
<TableCell className="font-mono text-xs text-muted-foreground">
|
|
523
|
-
{item.workflowRunId ?? "-"}
|
|
524
|
-
</TableCell>
|
|
525
|
-
<TableCell className="text-right">
|
|
526
|
-
<Button
|
|
527
|
-
variant="outline"
|
|
528
|
-
size="sm"
|
|
529
|
-
onClick={() =>
|
|
530
|
-
setExpandedRunId((current) =>
|
|
531
|
-
current === item.id ? null : item.id,
|
|
532
|
-
)
|
|
533
|
-
}
|
|
534
|
-
>
|
|
535
|
-
{expandedRunId === item.id ? "Hide" : "Details"}
|
|
536
|
-
</Button>
|
|
537
|
-
</TableCell>
|
|
538
|
-
</TableRow>
|
|
539
|
-
{expandedRunId === item.id ? (
|
|
474
|
+
</TableHeader>
|
|
475
|
+
<TableBody>
|
|
476
|
+
{loading ? (
|
|
477
|
+
<TableRow>
|
|
478
|
+
<TableCell
|
|
479
|
+
colSpan={8}
|
|
480
|
+
className="text-sm text-muted-foreground"
|
|
481
|
+
>
|
|
482
|
+
Loading runs...
|
|
483
|
+
</TableCell>
|
|
484
|
+
</TableRow>
|
|
485
|
+
) : data?.items.length ? (
|
|
486
|
+
data.items.map((item) => (
|
|
487
|
+
<Fragment key={item.id}>
|
|
540
488
|
<TableRow>
|
|
541
|
-
<TableCell
|
|
542
|
-
<
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
489
|
+
<TableCell className="text-sm text-muted-foreground">
|
|
490
|
+
<div>{formatDateTime(item.startedAt)}</div>
|
|
491
|
+
<div className="text-xs">
|
|
492
|
+
{item.completedAt
|
|
493
|
+
? `completed ${formatDateTime(item.completedAt)}`
|
|
494
|
+
: "in progress"}
|
|
495
|
+
</div>
|
|
496
|
+
</TableCell>
|
|
497
|
+
<TableCell>
|
|
498
|
+
<Badge variant={statusVariant[item.status]}>
|
|
499
|
+
{statusLabel[item.status]}
|
|
500
|
+
</Badge>
|
|
501
|
+
{item.error ? (
|
|
502
|
+
<div
|
|
503
|
+
className="mt-1 max-w-56 truncate text-xs text-destructive"
|
|
504
|
+
title={item.error}
|
|
505
|
+
>
|
|
506
|
+
{item.error}
|
|
507
|
+
</div>
|
|
508
|
+
) : null}
|
|
509
|
+
</TableCell>
|
|
510
|
+
<TableCell className="font-medium">
|
|
511
|
+
{formatSource(item)}
|
|
512
|
+
</TableCell>
|
|
513
|
+
<TableCell className="text-muted-foreground">
|
|
514
|
+
{item.plugName ?? "-"}
|
|
515
|
+
</TableCell>
|
|
516
|
+
<TableCell className="font-mono text-xs">
|
|
517
|
+
{item.runType}
|
|
518
|
+
</TableCell>
|
|
519
|
+
<TableCell className="max-w-64 text-xs text-muted-foreground">
|
|
520
|
+
{formatCounts(item)}
|
|
521
|
+
</TableCell>
|
|
522
|
+
<TableCell className="font-mono text-xs text-muted-foreground">
|
|
523
|
+
{item.workflowRunId ?? "-"}
|
|
524
|
+
</TableCell>
|
|
525
|
+
<TableCell className="text-right">
|
|
526
|
+
<Button
|
|
527
|
+
variant="outline"
|
|
528
|
+
size="sm"
|
|
529
|
+
onClick={() =>
|
|
530
|
+
setExpandedRunId((current) =>
|
|
531
|
+
current === item.id ? null : item.id,
|
|
532
|
+
)
|
|
533
|
+
}
|
|
534
|
+
>
|
|
535
|
+
{expandedRunId === item.id ? "Hide" : "Details"}
|
|
536
|
+
</Button>
|
|
548
537
|
</TableCell>
|
|
549
538
|
</TableRow>
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
539
|
+
{expandedRunId === item.id ? (
|
|
540
|
+
<TableRow>
|
|
541
|
+
<TableCell colSpan={8}>
|
|
542
|
+
<RunDetails
|
|
543
|
+
run={item}
|
|
544
|
+
streamingEnabled={streamingEnabled}
|
|
545
|
+
onChanged={() => setRefreshKey((v) => v + 1)}
|
|
546
|
+
onStreamInbound={pulseLiveIndicator}
|
|
547
|
+
/>
|
|
548
|
+
</TableCell>
|
|
549
|
+
</TableRow>
|
|
550
|
+
) : null}
|
|
551
|
+
</Fragment>
|
|
552
|
+
))
|
|
553
|
+
) : (
|
|
554
|
+
<TableRow>
|
|
555
|
+
<TableCell
|
|
556
|
+
colSpan={8}
|
|
557
|
+
className="text-sm text-muted-foreground"
|
|
558
|
+
>
|
|
559
|
+
No runs recorded yet.
|
|
560
|
+
</TableCell>
|
|
561
|
+
</TableRow>
|
|
562
|
+
)}
|
|
563
|
+
</TableBody>
|
|
564
|
+
</Table>
|
|
565
|
+
)}
|
|
565
566
|
|
|
566
|
-
|
|
567
|
-
<
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
<
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
567
|
+
{error ? null : (
|
|
568
|
+
<div className="flex items-center justify-between gap-3">
|
|
569
|
+
<p className="text-sm text-muted-foreground">
|
|
570
|
+
Page {Math.floor(offset / pageSize) + 1}
|
|
571
|
+
</p>
|
|
572
|
+
<div className="flex items-center gap-2">
|
|
573
|
+
<Button
|
|
574
|
+
variant="outline"
|
|
575
|
+
size="sm"
|
|
576
|
+
disabled={offset === 0 || loading}
|
|
577
|
+
onClick={() => setOffset(Math.max(offset - pageSize, 0))}
|
|
578
|
+
>
|
|
579
|
+
Previous
|
|
580
|
+
</Button>
|
|
581
|
+
<Button
|
|
582
|
+
variant="outline"
|
|
583
|
+
size="sm"
|
|
584
|
+
disabled={!data?.page.hasMore || loading}
|
|
585
|
+
onClick={() => setOffset(offset + pageSize)}
|
|
586
|
+
>
|
|
587
|
+
Next
|
|
588
|
+
</Button>
|
|
589
|
+
</div>
|
|
587
590
|
</div>
|
|
588
|
-
|
|
591
|
+
)}
|
|
589
592
|
</CardContent>
|
|
590
593
|
</Card>
|
|
591
594
|
);
|
|
@@ -20,7 +20,7 @@ The Hub scaffolds three components to `src/components/khotan/`:
|
|
|
20
20
|
|
|
21
21
|
| File | Purpose |
|
|
22
22
|
|------|---------|
|
|
23
|
-
| `hub.tsx` | Main `<KhotanHub />` — plug cards, flow table, enable/disable toggles |
|
|
23
|
+
| `hub.tsx` | Main `<KhotanHub />` — plug cards, flow table, enable/disable toggles, per-flow "Run now" trigger |
|
|
24
24
|
| `var-panel.tsx` | Variables panel for configuring plug vars |
|
|
25
25
|
| `wire-panel.tsx` | Webhook subscription management (connect/disconnect) |
|
|
26
26
|
|
|
@@ -44,6 +44,7 @@ Or use `npx khotan add config-page-1` to scaffold a `/config` page automatically
|
|
|
44
44
|
|
|
45
45
|
- Lists all registered plugs with status badges (connected/error/idle)
|
|
46
46
|
- Click a plug to see its flows with enable/disable toggles
|
|
47
|
+
- "Run now" button on each flow row triggers a tracked run via `POST /api/khotan/flows/:id/runs` (uses the browser session, so it passes your `authorize` hook)
|
|
47
48
|
- VarPanel: configure plug variables (stored encrypted via `KHOTAN_SECRET`)
|
|
48
49
|
- WirePanel: manage webhook subscriptions (requires wires configured on plug)
|
|
49
50
|
- Debug button on each plug card (visible when `KHOTAN_DEBUG=1`)
|
|
@@ -90,9 +90,48 @@ The schema command auto-detects your Drizzle schema directory, updates `drizzle.
|
|
|
90
90
|
|----------|----------|---------|
|
|
91
91
|
| `DATABASE_URL` | Yes | Postgres connection (used by Drizzle) |
|
|
92
92
|
| `KHOTAN_SECRET` | For variables | AES-256-GCM key for encrypting plug vars |
|
|
93
|
-
| `KHOTAN_DEBUG` | For debugging | Enables `/debug/*` routes and the `plug` CLI (`probe` alias) |
|
|
93
|
+
| `KHOTAN_DEBUG` | For debugging | Enables `/debug/*` routes and the `plug` CLI (`probe` alias). Automatically disabled when `NODE_ENV=production` |
|
|
94
94
|
| `KHOTAN_WEBHOOK_URL` | For webhooks | Public URL for wire callbacks |
|
|
95
|
-
| `CRON_SECRET` | For production cron | Protects the built-in `/api/khotan/cron` dispatcher route |
|
|
95
|
+
| `CRON_SECRET` | For production cron | Protects the built-in `/api/khotan/cron` dispatcher route. The route fails closed in production when this is unset |
|
|
96
|
+
|
|
97
|
+
## Securing the Management API
|
|
98
|
+
|
|
99
|
+
The management API (`/api/khotan/*`) and the Hub dashboard expose plug
|
|
100
|
+
credentials and operational controls. **They are public unless you gate them.**
|
|
101
|
+
|
|
102
|
+
Pass an `authorize` hook to `khotan({ ... })`. It receives the raw `Request` and
|
|
103
|
+
returns `true` to allow the request or `false` to reject it with `401`. It
|
|
104
|
+
composes directly with session libraries like better-auth:
|
|
105
|
+
|
|
106
|
+
```typescript
|
|
107
|
+
import { khotan, drizzleAdapter } from "khotan-data/factory";
|
|
108
|
+
import { auth } from "@/lib/auth";
|
|
109
|
+
import { db } from "@/db";
|
|
110
|
+
|
|
111
|
+
const khotanData = khotan({
|
|
112
|
+
adapter: drizzleAdapter(db),
|
|
113
|
+
authorize: async (request) => {
|
|
114
|
+
const session = await auth.api.getSession({ headers: request.headers });
|
|
115
|
+
return Boolean(session?.user); // or: session?.user?.role === "admin"
|
|
116
|
+
},
|
|
117
|
+
plugs: [/* ... */],
|
|
118
|
+
});
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Notes:
|
|
122
|
+
- `authorize` is **not** a replacement for `KHOTAN_SECRET` — that key only
|
|
123
|
+
encrypts credentials at rest, it does not authenticate requests. Conversely,
|
|
124
|
+
`KHOTAN_SECRET` is **not** an HTTP credential: do not send it as a Bearer
|
|
125
|
+
token. Management routes are gated solely by `authorize` (plus the dev-only
|
|
126
|
+
CLI HMAC token). A rejected request returns `401` with `code:
|
|
127
|
+
authorize_rejected` and a `hint` explaining how to authenticate.
|
|
128
|
+
- Inbound webhooks (`POST /webhook/:plug`, verified per-plug via `onVerify`),
|
|
129
|
+
the cron dispatcher (`CRON_SECRET`), and debug routes (`KHOTAN_DEBUG`,
|
|
130
|
+
non-production only) are exempt from `authorize` automatically.
|
|
131
|
+
- Also protect the Hub dashboard page (e.g. `/config`) with your app's
|
|
132
|
+
middleware — `authorize` only guards the API, not your React pages.
|
|
133
|
+
- Without `authorize`, khotan logs a startup warning. Always configure it
|
|
134
|
+
before deploying.
|
|
96
135
|
|
|
97
136
|
## Next.js Config
|
|
98
137
|
|
|
@@ -104,6 +143,76 @@ const nextConfig = {
|
|
|
104
143
|
};
|
|
105
144
|
```
|
|
106
145
|
|
|
146
|
+
## Workflow Runtime & Middleware/Proxy
|
|
147
|
+
|
|
148
|
+
Inflows, outflows, relays, catch, and pass run on **Vercel Workflow**, which
|
|
149
|
+
communicates over `/.well-known/workflow/*`. If your app has a `middleware.ts`
|
|
150
|
+
(or `proxy.ts`) whose `matcher` captures these paths, durable runs **silently
|
|
151
|
+
fail** — steps never get invoked and runs hang.
|
|
152
|
+
|
|
153
|
+
`npx khotan init` detects a middleware/proxy file and warns when it may
|
|
154
|
+
intercept these paths. Exclude them from the matcher:
|
|
155
|
+
|
|
156
|
+
```typescript
|
|
157
|
+
// middleware.ts
|
|
158
|
+
export const config = {
|
|
159
|
+
matcher: ["/((?!_next|.well-known/workflow).*)"],
|
|
160
|
+
};
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
If you do auth or rewrites manually (not via `matcher`), short-circuit early:
|
|
164
|
+
|
|
165
|
+
```typescript
|
|
166
|
+
export function middleware(request: NextRequest) {
|
|
167
|
+
if (request.nextUrl.pathname.startsWith("/.well-known/workflow")) {
|
|
168
|
+
return NextResponse.next();
|
|
169
|
+
}
|
|
170
|
+
// ...your logic
|
|
171
|
+
}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Vercel Workflow also requires AI Gateway OIDC — run `vercel link` and
|
|
175
|
+
`vercel env pull` so `VERCEL_OIDC_TOKEN` is available locally.
|
|
176
|
+
|
|
177
|
+
## Triggering Flows
|
|
178
|
+
|
|
179
|
+
Start a flow through khotan (never call the workflow function directly) so run
|
|
180
|
+
tracking and Workflow IDs are recorded. The API is `khotanData.flow(name).start()`:
|
|
181
|
+
|
|
182
|
+
```typescript
|
|
183
|
+
import khotanData from "@/lib/khotan/khotan";
|
|
184
|
+
|
|
185
|
+
await khotanData.flow("products-inflow", { plugName: "shopify" }).start({
|
|
186
|
+
runType: "delta", // or "full"
|
|
187
|
+
});
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
`plugName` is only needed to disambiguate when the same flow name is registered
|
|
191
|
+
under multiple plugs. There is no `khotanData.api.*` or `flow().run()` surface —
|
|
192
|
+
`flow(name).start(options)` is the single entry point for manual and scheduled
|
|
193
|
+
runs alike. The cron dispatcher (`/api/khotan/cron`) calls this same path.
|
|
194
|
+
|
|
195
|
+
### Triggering over HTTP (scripts / external services)
|
|
196
|
+
|
|
197
|
+
There is **no** `POST /flows/:name/run` route. The HTTP trigger is:
|
|
198
|
+
|
|
199
|
+
```
|
|
200
|
+
POST /api/khotan/flows/{flowId}/runs body: { "runType": "delta" }
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
This is a **management route**, so it goes through your `authorize` hook. Common
|
|
204
|
+
gotcha: `KHOTAN_SECRET` is an encryption key, **not** an HTTP credential — sending
|
|
205
|
+
`Authorization: Bearer <KHOTAN_SECRET>` returns `401` with `code: authorize_rejected`.
|
|
206
|
+
To trigger from outside the app, authenticate with a credential your `authorize`
|
|
207
|
+
hook accepts (a session cookie, or your own token you validate inside `authorize`).
|
|
208
|
+
|
|
209
|
+
Prefer triggering server-side with `khotanData.flow(name).start()` whenever you
|
|
210
|
+
can — it needs no HTTP round-trip or auth.
|
|
211
|
+
|
|
212
|
+
The `npx khotan flows trigger <name>` CLI works in **dev** without any of this: it
|
|
213
|
+
signs a short-lived HMAC token from `KHOTAN_SECRET` (the `KhotanCLI` auth scheme,
|
|
214
|
+
disabled when `NODE_ENV=production`). The raw secret never leaves your machine.
|
|
215
|
+
|
|
107
216
|
## Verify Setup
|
|
108
217
|
|
|
109
218
|
```bash
|
|
@@ -159,3 +268,5 @@ This keeps sync logic grounded in real API payloads before you write pagination,
|
|
|
159
268
|
- **"Cannot find module khotan-data"**: Add to `serverExternalPackages` in next.config.ts
|
|
160
269
|
- **Migration fails**: Ensure `DATABASE_URL` is set and Postgres is reachable
|
|
161
270
|
- **Init won't overwrite**: By design — delete the file manually if you need to re-scaffold
|
|
271
|
+
- **Flow/workflow runs hang or never start**: Check your `middleware.ts`/`proxy.ts` matcher excludes `/.well-known/workflow/*` (see "Workflow Runtime & Middleware/Proxy")
|
|
272
|
+
- **Step "is not a function" / fails to resolve at runtime**: Declare `"use step"` functions at module top level and pass `ctx` as an argument — never nest them inside the `"use workflow"` function (closures over workflow scope cannot be hoisted)
|
|
@@ -110,19 +110,28 @@ npx khotan add catch --yes
|
|
|
110
110
|
|
|
111
111
|
Process webhook events durably via Vercel Workflow:
|
|
112
112
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const processInvoice = catchEvent(async (ctx) => {
|
|
117
|
-
"use workflow";
|
|
113
|
+
Declare `"use step"` functions at module top level and pass `ctx` (serializable
|
|
114
|
+
data) as an argument. Nesting steps inside the `"use workflow"` function fails at
|
|
115
|
+
runtime — closures over workflow scope cannot be hoisted.
|
|
118
116
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
117
|
+
```typescript
|
|
118
|
+
import { catchEvent, type CatchContext } from "./webhooks/catch";
|
|
119
|
+
import { db } from "@/db";
|
|
120
|
+
import { invoices } from "@/db/schema";
|
|
121
|
+
|
|
122
|
+
// Step: top-level, full Node.js access, retried on failure.
|
|
123
|
+
async function persistInvoice(ctx: CatchContext) {
|
|
124
|
+
"use step";
|
|
125
|
+
await db.insert(invoices).values(ctx.event);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const processInvoice = catchEvent({
|
|
129
|
+
name: "stripe-invoices",
|
|
130
|
+
events: ["invoice.paid"],
|
|
131
|
+
workflow: async (ctx) => {
|
|
132
|
+
"use workflow";
|
|
133
|
+
await persistInvoice(ctx);
|
|
134
|
+
},
|
|
126
135
|
});
|
|
127
136
|
```
|
|
128
137
|
|
|
@@ -140,22 +149,35 @@ npx khotan add pass --yes
|
|
|
140
149
|
|
|
141
150
|
Forward webhook events to another service:
|
|
142
151
|
|
|
152
|
+
The context exposes `ctx.event`, `ctx.eventType`, and `ctx.destVars` (the
|
|
153
|
+
decrypted credentials for the destination plug). There is no `ctx.destPlug` —
|
|
154
|
+
construct the destination plug from `destVars` inside a top-level step.
|
|
155
|
+
|
|
143
156
|
```typescript
|
|
144
|
-
import { pass } from "./webhooks/pass";
|
|
157
|
+
import { pass, type PassContext } from "./webhooks/pass";
|
|
158
|
+
import { plug } from "@/lib/khotan/plugs/plug";
|
|
159
|
+
|
|
160
|
+
// Step: top-level. Build the destination plug from ctx.destVars.
|
|
161
|
+
async function forwardToSlackStep(ctx: PassContext) {
|
|
162
|
+
"use step";
|
|
163
|
+
const slack = plug({
|
|
164
|
+
name: "slack",
|
|
165
|
+
baseUrl: "https://slack.com/api",
|
|
166
|
+
authType: "bearer",
|
|
167
|
+
auth: { bearer: { token: ctx.destVars["botToken"] ?? "" } },
|
|
168
|
+
});
|
|
169
|
+
await slack.post("/chat.postMessage", {
|
|
170
|
+
body: { channel: ctx.destVars["channelId"], text: `New event: ${ctx.eventType}` },
|
|
171
|
+
});
|
|
172
|
+
}
|
|
145
173
|
|
|
146
174
|
const forwardToSlack = pass({
|
|
147
|
-
|
|
175
|
+
name: "stripe-to-slack",
|
|
176
|
+
to: "slack", // Destination plug name (must be registered)
|
|
177
|
+
events: ["invoice.paid"],
|
|
148
178
|
workflow: async (ctx) => {
|
|
149
179
|
"use workflow";
|
|
150
|
-
|
|
151
|
-
// ctx.destVars — destination plug variables
|
|
152
|
-
async function forward() {
|
|
153
|
-
"use step";
|
|
154
|
-
await ctx.destPlug.post("/messages", {
|
|
155
|
-
body: { text: `New event: ${ctx.event.type}` },
|
|
156
|
-
});
|
|
157
|
-
}
|
|
158
|
-
await forward();
|
|
180
|
+
await forwardToSlackStep(ctx);
|
|
159
181
|
},
|
|
160
182
|
});
|
|
161
183
|
```
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
type NodeProps,
|
|
19
19
|
} from "@xyflow/react";
|
|
20
20
|
import "@xyflow/react/dist/style.css";
|
|
21
|
+
import { khotanFetch, ApiErrorState } from "./api-state";
|
|
21
22
|
import { Badge } from "@/components/ui/badge";
|
|
22
23
|
import {
|
|
23
24
|
Card,
|
|
@@ -1056,8 +1057,9 @@ function TopologyCanvasInner() {
|
|
|
1056
1057
|
const [nodes, setNodes, onNodesChange] = useNodesState<TopologyNodeData>([]);
|
|
1057
1058
|
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
|
|
1058
1059
|
const [loading, setLoading] = useState(true);
|
|
1059
|
-
const [error, setError] = useState<
|
|
1060
|
+
const [error, setError] = useState<unknown>(null);
|
|
1060
1061
|
const [lastUpdatedAt, setLastUpdatedAt] = useState<string | null>(null);
|
|
1062
|
+
const [refreshKey, setRefreshKey] = useState(0);
|
|
1061
1063
|
|
|
1062
1064
|
useEffect(() => {
|
|
1063
1065
|
let cancelled = false;
|
|
@@ -1068,30 +1070,22 @@ function TopologyCanvasInner() {
|
|
|
1068
1070
|
setError(null);
|
|
1069
1071
|
}
|
|
1070
1072
|
|
|
1071
|
-
const [
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1073
|
+
const [plugs, flows, runsRaw] = await Promise.all([
|
|
1074
|
+
khotanFetch<PlugRecord[]>("/api/khotan/plugs"),
|
|
1075
|
+
khotanFetch<FlowRecord[]>("/api/khotan/flows"),
|
|
1076
|
+
khotanFetch<unknown>("/api/khotan/runs?limit=100"),
|
|
1075
1077
|
]);
|
|
1076
1078
|
|
|
1077
|
-
|
|
1078
|
-
throw new Error("Failed to load topology data from /api/khotan");
|
|
1079
|
-
}
|
|
1080
|
-
|
|
1081
|
-
const plugs = (await plugsRes.json()) as PlugRecord[];
|
|
1082
|
-
const flows = (await flowsRes.json()) as FlowRecord[];
|
|
1083
|
-
const runs = normalizeRuns(await runsRes.json());
|
|
1079
|
+
const runs = normalizeRuns(runsRaw);
|
|
1084
1080
|
|
|
1085
1081
|
const webhookGroups = await Promise.all(
|
|
1086
1082
|
plugs.map(async (plug) => {
|
|
1087
1083
|
try {
|
|
1088
|
-
const
|
|
1084
|
+
const handlers = await khotanFetch<
|
|
1085
|
+
Array<Omit<WebhookHandlerRecord, "plugName">>
|
|
1086
|
+
>(
|
|
1089
1087
|
`/api/khotan/webhook-handlers/${encodeURIComponent(plug.name)}`,
|
|
1090
1088
|
);
|
|
1091
|
-
if (!res.ok) return [] as WebhookHandlerRecord[];
|
|
1092
|
-
const handlers = (await res.json()) as Array<
|
|
1093
|
-
Omit<WebhookHandlerRecord, "plugName">
|
|
1094
|
-
>;
|
|
1095
1089
|
return handlers.map((handler) => ({
|
|
1096
1090
|
...handler,
|
|
1097
1091
|
plugName: plug.name,
|
|
@@ -1113,11 +1107,7 @@ function TopologyCanvasInner() {
|
|
|
1113
1107
|
setLastUpdatedAt(new Date().toISOString());
|
|
1114
1108
|
} catch (loadError) {
|
|
1115
1109
|
if (!cancelled) {
|
|
1116
|
-
setError(
|
|
1117
|
-
loadError instanceof Error
|
|
1118
|
-
? loadError.message
|
|
1119
|
-
: "Unknown topology load failure",
|
|
1120
|
-
);
|
|
1110
|
+
setError(loadError);
|
|
1121
1111
|
}
|
|
1122
1112
|
} finally {
|
|
1123
1113
|
if (!cancelled) {
|
|
@@ -1135,7 +1125,7 @@ function TopologyCanvasInner() {
|
|
|
1135
1125
|
cancelled = true;
|
|
1136
1126
|
window.clearInterval(interval);
|
|
1137
1127
|
};
|
|
1138
|
-
}, []);
|
|
1128
|
+
}, [refreshKey]);
|
|
1139
1129
|
|
|
1140
1130
|
const model = useMemo(() => {
|
|
1141
1131
|
return snapshot ? buildTopologyModel(snapshot) : null;
|
|
@@ -1180,19 +1170,18 @@ function TopologyCanvasInner() {
|
|
|
1180
1170
|
|
|
1181
1171
|
if (error) {
|
|
1182
1172
|
return (
|
|
1183
|
-
<Card className="border-
|
|
1173
|
+
<Card className="border-white/70 bg-white/80 shadow-xl backdrop-blur">
|
|
1184
1174
|
<CardHeader>
|
|
1185
1175
|
<CardTitle>Topology Canvas</CardTitle>
|
|
1186
1176
|
<CardDescription>
|
|
1187
1177
|
Could not load the graph from the local Khotan API.
|
|
1188
1178
|
</CardDescription>
|
|
1189
1179
|
</CardHeader>
|
|
1190
|
-
<CardContent
|
|
1191
|
-
<
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
</p>
|
|
1180
|
+
<CardContent>
|
|
1181
|
+
<ApiErrorState
|
|
1182
|
+
error={error}
|
|
1183
|
+
onRetry={() => setRefreshKey((v) => v + 1)}
|
|
1184
|
+
/>
|
|
1196
1185
|
</CardContent>
|
|
1197
1186
|
</Card>
|
|
1198
1187
|
);
|