station-kit 1.0.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/LICENSE +21 -0
- package/dist/cli-main.d.ts +2 -0
- package/dist/cli-main.d.ts.map +1 -0
- package/dist/cli-main.js +58 -0
- package/dist/cli-main.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +25 -0
- package/dist/cli.js.map +1 -0
- package/dist/config/loader.d.ts +3 -0
- package/dist/config/loader.d.ts.map +1 -0
- package/dist/config/loader.js +29 -0
- package/dist/config/loader.js.map +1 -0
- package/dist/config/schema.d.ts +36 -0
- package/dist/config/schema.d.ts.map +1 -0
- package/dist/config/schema.js +40 -0
- package/dist/config/schema.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/server/auth/keys.d.ts +28 -0
- package/dist/server/auth/keys.d.ts.map +1 -0
- package/dist/server/auth/keys.js +91 -0
- package/dist/server/auth/keys.js.map +1 -0
- package/dist/server/auth/session.d.ts +9 -0
- package/dist/server/auth/session.d.ts.map +1 -0
- package/dist/server/auth/session.js +42 -0
- package/dist/server/auth/session.js.map +1 -0
- package/dist/server/index.d.ts +7 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +253 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/log-buffer.d.ts +20 -0
- package/dist/server/log-buffer.d.ts.map +1 -0
- package/dist/server/log-buffer.js +33 -0
- package/dist/server/log-buffer.js.map +1 -0
- package/dist/server/log-store.d.ts +11 -0
- package/dist/server/log-store.d.ts.map +1 -0
- package/dist/server/log-store.js +40 -0
- package/dist/server/log-store.js.map +1 -0
- package/dist/server/metadata.d.ts +38 -0
- package/dist/server/metadata.d.ts.map +1 -0
- package/dist/server/metadata.js +130 -0
- package/dist/server/metadata.js.map +1 -0
- package/dist/server/middleware/auth.d.ts +12 -0
- package/dist/server/middleware/auth.d.ts.map +1 -0
- package/dist/server/middleware/auth.js +42 -0
- package/dist/server/middleware/auth.js.map +1 -0
- package/dist/server/middleware/rate-limit.d.ts +15 -0
- package/dist/server/middleware/rate-limit.d.ts.map +1 -0
- package/dist/server/middleware/rate-limit.js +36 -0
- package/dist/server/middleware/rate-limit.js.map +1 -0
- package/dist/server/middleware/scope-guard.d.ts +9 -0
- package/dist/server/middleware/scope-guard.d.ts.map +1 -0
- package/dist/server/middleware/scope-guard.js +17 -0
- package/dist/server/middleware/scope-guard.js.map +1 -0
- package/dist/server/routes/broadcasts.d.ts +12 -0
- package/dist/server/routes/broadcasts.d.ts.map +1 -0
- package/dist/server/routes/broadcasts.js +135 -0
- package/dist/server/routes/broadcasts.js.map +1 -0
- package/dist/server/routes/health.d.ts +9 -0
- package/dist/server/routes/health.d.ts.map +1 -0
- package/dist/server/routes/health.js +27 -0
- package/dist/server/routes/health.js.map +1 -0
- package/dist/server/routes/runs.d.ts +12 -0
- package/dist/server/routes/runs.d.ts.map +1 -0
- package/dist/server/routes/runs.js +122 -0
- package/dist/server/routes/runs.js.map +1 -0
- package/dist/server/routes/signals.d.ts +10 -0
- package/dist/server/routes/signals.d.ts.map +1 -0
- package/dist/server/routes/signals.js +120 -0
- package/dist/server/routes/signals.js.map +1 -0
- package/dist/server/routes/v1/auth.d.ts +7 -0
- package/dist/server/routes/v1/auth.d.ts.map +1 -0
- package/dist/server/routes/v1/auth.js +28 -0
- package/dist/server/routes/v1/auth.js.map +1 -0
- package/dist/server/routes/v1/broadcasts.d.ts +10 -0
- package/dist/server/routes/v1/broadcasts.d.ts.map +1 -0
- package/dist/server/routes/v1/broadcasts.js +68 -0
- package/dist/server/routes/v1/broadcasts.js.map +1 -0
- package/dist/server/routes/v1/events.d.ts +7 -0
- package/dist/server/routes/v1/events.d.ts.map +1 -0
- package/dist/server/routes/v1/events.js +57 -0
- package/dist/server/routes/v1/events.js.map +1 -0
- package/dist/server/routes/v1/health.d.ts +9 -0
- package/dist/server/routes/v1/health.d.ts.map +1 -0
- package/dist/server/routes/v1/health.js +31 -0
- package/dist/server/routes/v1/health.js.map +1 -0
- package/dist/server/routes/v1/keys.d.ts +7 -0
- package/dist/server/routes/v1/keys.d.ts.map +1 -0
- package/dist/server/routes/v1/keys.js +43 -0
- package/dist/server/routes/v1/keys.js.map +1 -0
- package/dist/server/routes/v1/runs.d.ts +12 -0
- package/dist/server/routes/v1/runs.d.ts.map +1 -0
- package/dist/server/routes/v1/runs.js +76 -0
- package/dist/server/routes/v1/runs.js.map +1 -0
- package/dist/server/routes/v1/signals.d.ts +9 -0
- package/dist/server/routes/v1/signals.d.ts.map +1 -0
- package/dist/server/routes/v1/signals.js +33 -0
- package/dist/server/routes/v1/signals.js.map +1 -0
- package/dist/server/routes/v1/trigger.d.ts +12 -0
- package/dist/server/routes/v1/trigger.d.ts.map +1 -0
- package/dist/server/routes/v1/trigger.js +73 -0
- package/dist/server/routes/v1/trigger.js.map +1 -0
- package/dist/server/sse.d.ts +19 -0
- package/dist/server/sse.d.ts.map +1 -0
- package/dist/server/sse.js +51 -0
- package/dist/server/sse.js.map +1 -0
- package/dist/server/subscriber.d.ts +128 -0
- package/dist/server/subscriber.d.ts.map +1 -0
- package/dist/server/subscriber.js +246 -0
- package/dist/server/subscriber.js.map +1 -0
- package/dist/server/ws.d.ts +15 -0
- package/dist/server/ws.d.ts.map +1 -0
- package/dist/server/ws.js +32 -0
- package/dist/server/ws.js.map +1 -0
- package/next-env.d.ts +6 -0
- package/next.config.ts +10 -0
- package/package.json +49 -0
- package/src/app/broadcasts/[id]/page.tsx +511 -0
- package/src/app/broadcasts/page.tsx +158 -0
- package/src/app/components/auth-provider.tsx +75 -0
- package/src/app/components/breadcrumb-provider.tsx +18 -0
- package/src/app/components/dag-view.tsx +380 -0
- package/src/app/components/empty-state.tsx +7 -0
- package/src/app/components/json-viewer.tsx +153 -0
- package/src/app/components/login-page.tsx +78 -0
- package/src/app/components/node-detail.tsx +158 -0
- package/src/app/components/pulse-dot.tsx +8 -0
- package/src/app/components/relative-time.tsx +34 -0
- package/src/app/components/run-table.tsx +96 -0
- package/src/app/components/schema-form.tsx +121 -0
- package/src/app/components/shell.tsx +203 -0
- package/src/app/components/status-badge.tsx +10 -0
- package/src/app/components/step-timeline.tsx +134 -0
- package/src/app/components/theme-provider.tsx +45 -0
- package/src/app/components/workflow-node-sidebar.tsx +68 -0
- package/src/app/globals.css +1523 -0
- package/src/app/hooks/use-api.ts +129 -0
- package/src/app/hooks/use-breadcrumb.ts +37 -0
- package/src/app/hooks/use-realtime.ts +68 -0
- package/src/app/hooks/use-station.tsx +34 -0
- package/src/app/layout.tsx +42 -0
- package/src/app/page.tsx +275 -0
- package/src/app/runs/[id]/page.tsx +277 -0
- package/src/app/signals/[name]/page.tsx +250 -0
- package/src/app/signals/page.tsx +99 -0
- package/src/cli-main.ts +70 -0
- package/src/cli.ts +27 -0
- package/src/config/loader.ts +33 -0
- package/src/config/schema.ts +80 -0
- package/src/index.ts +7 -0
- package/src/server/auth/keys.ts +112 -0
- package/src/server/auth/session.ts +48 -0
- package/src/server/index.ts +296 -0
- package/src/server/log-buffer.ts +43 -0
- package/src/server/log-store.ts +56 -0
- package/src/server/metadata.ts +180 -0
- package/src/server/middleware/auth.ts +50 -0
- package/src/server/middleware/rate-limit.ts +61 -0
- package/src/server/middleware/scope-guard.ts +20 -0
- package/src/server/routes/broadcasts.ts +160 -0
- package/src/server/routes/health.ts +37 -0
- package/src/server/routes/runs.ts +149 -0
- package/src/server/routes/signals.ts +153 -0
- package/src/server/routes/v1/auth.ts +47 -0
- package/src/server/routes/v1/broadcasts.ts +84 -0
- package/src/server/routes/v1/events.ts +71 -0
- package/src/server/routes/v1/health.ts +41 -0
- package/src/server/routes/v1/keys.ts +57 -0
- package/src/server/routes/v1/runs.ts +97 -0
- package/src/server/routes/v1/signals.ts +44 -0
- package/src/server/routes/v1/trigger.ts +111 -0
- package/src/server/sse.ts +70 -0
- package/src/server/subscriber.ts +288 -0
- package/src/server/ws.ts +44 -0
- package/station.config.example.ts +16 -0
- package/tsconfig.json +12 -0
- package/tsconfig.next.json +15 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { SignalRunner, SignalQueueAdapter } from "station-signal";
|
|
3
|
+
import type { LogBuffer } from "../log-buffer.js";
|
|
4
|
+
import type { LogStore } from "../log-store.js";
|
|
5
|
+
|
|
6
|
+
export interface RunDeps {
|
|
7
|
+
signalRunner?: SignalRunner;
|
|
8
|
+
signalAdapter: SignalQueueAdapter;
|
|
9
|
+
logBuffer: LogBuffer;
|
|
10
|
+
logStore?: LogStore;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function runRoutes(deps: RunDeps) {
|
|
14
|
+
const app = new Hono();
|
|
15
|
+
|
|
16
|
+
app.get("/runs", async (c) => {
|
|
17
|
+
const status = c.req.query("status");
|
|
18
|
+
const signalName = c.req.query("signalName");
|
|
19
|
+
|
|
20
|
+
// Gather runs from adapter
|
|
21
|
+
let runs: any[] = [];
|
|
22
|
+
|
|
23
|
+
if (signalName) {
|
|
24
|
+
runs = await deps.signalAdapter.listRuns(signalName);
|
|
25
|
+
} else {
|
|
26
|
+
// Get all runs by combining due + running + listing by known signals
|
|
27
|
+
const due = await deps.signalAdapter.getRunsDue();
|
|
28
|
+
const running = await deps.signalAdapter.getRunsRunning();
|
|
29
|
+
const seen = new Set<string>();
|
|
30
|
+
for (const r of [...due, ...running]) {
|
|
31
|
+
if (!seen.has(r.id)) {
|
|
32
|
+
seen.add(r.id);
|
|
33
|
+
runs.push(r);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Also get runs from known signals
|
|
38
|
+
if (deps.signalRunner) {
|
|
39
|
+
for (const { name } of deps.signalRunner.listRegistered()) {
|
|
40
|
+
const signalRuns = await deps.signalAdapter.listRuns(name);
|
|
41
|
+
for (const r of signalRuns) {
|
|
42
|
+
if (!seen.has(r.id)) {
|
|
43
|
+
seen.add(r.id);
|
|
44
|
+
runs.push(r);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (status) {
|
|
52
|
+
runs = runs.filter((r) => r.status === status);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Sort by createdAt descending
|
|
56
|
+
runs.sort((a, b) => {
|
|
57
|
+
const aTime = a.createdAt instanceof Date ? a.createdAt.getTime() : new Date(a.createdAt).getTime();
|
|
58
|
+
const bTime = b.createdAt instanceof Date ? b.createdAt.getTime() : new Date(b.createdAt).getTime();
|
|
59
|
+
return bTime - aTime;
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
return c.json({
|
|
63
|
+
data: runs.map(serializeRun),
|
|
64
|
+
meta: { total: runs.length },
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
app.get("/runs/stats", async (c) => {
|
|
69
|
+
const due = await deps.signalAdapter.getRunsDue();
|
|
70
|
+
const running = await deps.signalAdapter.getRunsRunning();
|
|
71
|
+
|
|
72
|
+
// Aggregate from known signals
|
|
73
|
+
let allRuns: any[] = [...due, ...running];
|
|
74
|
+
const seen = new Set(allRuns.map((r) => r.id));
|
|
75
|
+
|
|
76
|
+
if (deps.signalRunner) {
|
|
77
|
+
for (const { name } of deps.signalRunner.listRegistered()) {
|
|
78
|
+
const signalRuns = await deps.signalAdapter.listRuns(name);
|
|
79
|
+
for (const r of signalRuns) {
|
|
80
|
+
if (!seen.has(r.id)) {
|
|
81
|
+
seen.add(r.id);
|
|
82
|
+
allRuns.push(r);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const stats = { pending: 0, running: 0, completed: 0, failed: 0, cancelled: 0 };
|
|
89
|
+
for (const r of allRuns) {
|
|
90
|
+
if (r.status in stats) {
|
|
91
|
+
stats[r.status as keyof typeof stats]++;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return c.json({ data: stats });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
app.get("/runs/:id", async (c) => {
|
|
99
|
+
const id = c.req.param("id");
|
|
100
|
+
const run = await deps.signalAdapter.getRun(id);
|
|
101
|
+
if (!run) {
|
|
102
|
+
return c.json({ error: "not_found", message: "Run not found." }, 404);
|
|
103
|
+
}
|
|
104
|
+
return c.json({ data: serializeRun(run) });
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
app.get("/runs/:id/steps", async (c) => {
|
|
108
|
+
const id = c.req.param("id");
|
|
109
|
+
const steps = await deps.signalAdapter.getSteps(id);
|
|
110
|
+
return c.json({
|
|
111
|
+
data: steps.map((s) => ({
|
|
112
|
+
...s,
|
|
113
|
+
startedAt: s.startedAt?.toISOString?.() ?? s.startedAt,
|
|
114
|
+
completedAt: s.completedAt?.toISOString?.() ?? s.completedAt,
|
|
115
|
+
})),
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
app.get("/runs/:id/logs", async (c) => {
|
|
120
|
+
const id = c.req.param("id");
|
|
121
|
+
const logs = deps.logStore?.get(id) ?? deps.logBuffer.get(id);
|
|
122
|
+
return c.json({ data: logs });
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
app.post("/runs/:id/cancel", async (c) => {
|
|
126
|
+
const id = c.req.param("id");
|
|
127
|
+
if (!deps.signalRunner) {
|
|
128
|
+
return c.json({ error: "read_only", message: "Station is in read-only mode." }, 403);
|
|
129
|
+
}
|
|
130
|
+
const success = await deps.signalRunner.cancel(id);
|
|
131
|
+
if (!success) {
|
|
132
|
+
return c.json({ error: "cannot_cancel", message: "Run cannot be cancelled." }, 400);
|
|
133
|
+
}
|
|
134
|
+
return c.json({ data: { cancelled: true } });
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
return app;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function serializeRun(run: any): Record<string, unknown> {
|
|
141
|
+
return {
|
|
142
|
+
...run,
|
|
143
|
+
nextRunAt: run.nextRunAt?.toISOString?.() ?? run.nextRunAt,
|
|
144
|
+
lastRunAt: run.lastRunAt?.toISOString?.() ?? run.lastRunAt,
|
|
145
|
+
startedAt: run.startedAt?.toISOString?.() ?? run.startedAt,
|
|
146
|
+
completedAt: run.completedAt?.toISOString?.() ?? run.completedAt,
|
|
147
|
+
createdAt: run.createdAt?.toISOString?.() ?? run.createdAt,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { SignalRunner, SignalQueueAdapter } from "station-signal";
|
|
3
|
+
import type { StationSignalSubscriber } from "../subscriber.js";
|
|
4
|
+
|
|
5
|
+
export interface SignalDeps {
|
|
6
|
+
signalRunner?: SignalRunner;
|
|
7
|
+
signalAdapter: SignalQueueAdapter;
|
|
8
|
+
signalSubscriber?: StationSignalSubscriber;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function signalRoutes(deps: SignalDeps) {
|
|
12
|
+
const app = new Hono();
|
|
13
|
+
|
|
14
|
+
// GET /signals — list all signals with metadata
|
|
15
|
+
app.get("/signals", async (c) => {
|
|
16
|
+
// Prefer metadata from subscriber (includes schemas, config)
|
|
17
|
+
if (deps.signalSubscriber) {
|
|
18
|
+
const meta = deps.signalSubscriber.getAllSignalMeta();
|
|
19
|
+
if (meta.length > 0) {
|
|
20
|
+
return c.json({ data: meta });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Fallback to registry
|
|
25
|
+
if (!deps.signalRunner) {
|
|
26
|
+
return c.json({ data: [] });
|
|
27
|
+
}
|
|
28
|
+
const result = deps.signalRunner.listRegistered().map(({ name, filePath }) => ({ name, filePath }));
|
|
29
|
+
return c.json({ data: result });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// GET /signals/scheduled — recurring signals with next/last run info
|
|
33
|
+
app.get("/signals/scheduled", async (c) => {
|
|
34
|
+
const allMeta = deps.signalSubscriber?.getAllSignalMeta() ?? [];
|
|
35
|
+
const recurring = allMeta.filter((s) => s.interval);
|
|
36
|
+
|
|
37
|
+
const result: Array<{
|
|
38
|
+
name: string;
|
|
39
|
+
interval: string;
|
|
40
|
+
nextRunAt: string | null;
|
|
41
|
+
lastRunAt: string | null;
|
|
42
|
+
lastStatus: string | null;
|
|
43
|
+
}> = [];
|
|
44
|
+
|
|
45
|
+
for (const sig of recurring) {
|
|
46
|
+
const runs = await deps.signalAdapter.listRuns(sig.name);
|
|
47
|
+
const pendingRun = runs.find((r) => r.status === "pending" && r.kind === "recurring");
|
|
48
|
+
const lastRun = runs
|
|
49
|
+
.filter((r) => r.status !== "pending")
|
|
50
|
+
.sort((a, b) => {
|
|
51
|
+
const aT = a.completedAt ?? a.startedAt ?? a.createdAt;
|
|
52
|
+
const bT = b.completedAt ?? b.startedAt ?? b.createdAt;
|
|
53
|
+
const aMs = aT instanceof Date ? aT.getTime() : new Date(aT).getTime();
|
|
54
|
+
const bMs = bT instanceof Date ? bT.getTime() : new Date(bT).getTime();
|
|
55
|
+
return bMs - aMs;
|
|
56
|
+
})[0];
|
|
57
|
+
|
|
58
|
+
result.push({
|
|
59
|
+
name: sig.name,
|
|
60
|
+
interval: sig.interval!,
|
|
61
|
+
nextRunAt: pendingRun?.nextRunAt
|
|
62
|
+
? (pendingRun.nextRunAt instanceof Date ? pendingRun.nextRunAt.toISOString() : String(pendingRun.nextRunAt))
|
|
63
|
+
: null,
|
|
64
|
+
lastRunAt: lastRun?.completedAt
|
|
65
|
+
? (lastRun.completedAt instanceof Date ? lastRun.completedAt.toISOString() : String(lastRun.completedAt))
|
|
66
|
+
: lastRun?.startedAt
|
|
67
|
+
? (lastRun.startedAt instanceof Date ? lastRun.startedAt.toISOString() : String(lastRun.startedAt))
|
|
68
|
+
: null,
|
|
69
|
+
lastStatus: lastRun?.status ?? null,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return c.json({ data: result });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// GET /signals/:name — single signal metadata
|
|
77
|
+
app.get("/signals/:name", async (c) => {
|
|
78
|
+
const name = c.req.param("name");
|
|
79
|
+
|
|
80
|
+
if (deps.signalSubscriber) {
|
|
81
|
+
const meta = deps.signalSubscriber.getSignalMeta(name);
|
|
82
|
+
if (meta) {
|
|
83
|
+
return c.json({ data: meta });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Fallback: check registry
|
|
88
|
+
if (deps.signalRunner) {
|
|
89
|
+
const entry = deps.signalRunner.listRegistered().find((s) => s.name === name);
|
|
90
|
+
if (entry) {
|
|
91
|
+
return c.json({ data: { name, filePath: entry.filePath } });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return c.json({ error: "not_found", message: `Signal "${name}" not found.` }, 404);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// POST /signals/:name/trigger
|
|
99
|
+
app.post("/signals/:name/trigger", async (c) => {
|
|
100
|
+
const name = c.req.param("name");
|
|
101
|
+
if (!deps.signalRunner) {
|
|
102
|
+
return c.json({ error: "read_only", message: "Station is in read-only mode." }, 403);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!deps.signalRunner.hasSignal(name)) {
|
|
106
|
+
return c.json({ error: "not_found", message: `Signal "${name}" not found.` }, 404);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const body = await c.req.json().catch(() => ({}));
|
|
110
|
+
const input = body.input ?? {};
|
|
111
|
+
|
|
112
|
+
const id = deps.signalAdapter.generateId();
|
|
113
|
+
await deps.signalAdapter.addRun({
|
|
114
|
+
id,
|
|
115
|
+
signalName: name,
|
|
116
|
+
kind: "trigger",
|
|
117
|
+
input: JSON.stringify(input),
|
|
118
|
+
status: "pending",
|
|
119
|
+
attempts: 0,
|
|
120
|
+
maxAttempts: 3,
|
|
121
|
+
timeout: 5 * 60 * 1000,
|
|
122
|
+
createdAt: new Date(),
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
return c.json({ data: { id } });
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// GET /signals/:name/runs
|
|
129
|
+
app.get("/signals/:name/runs", async (c) => {
|
|
130
|
+
const name = c.req.param("name");
|
|
131
|
+
if (!deps.signalRunner) {
|
|
132
|
+
return c.json({ data: [], meta: { total: 0 } });
|
|
133
|
+
}
|
|
134
|
+
const runs = await deps.signalRunner.listRuns(name);
|
|
135
|
+
return c.json({
|
|
136
|
+
data: runs.map(serializeRun),
|
|
137
|
+
meta: { total: runs.length },
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
return app;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function serializeRun(run: any): Record<string, unknown> {
|
|
145
|
+
return {
|
|
146
|
+
...run,
|
|
147
|
+
nextRunAt: run.nextRunAt?.toISOString?.() ?? run.nextRunAt,
|
|
148
|
+
lastRunAt: run.lastRunAt?.toISOString?.() ?? run.lastRunAt,
|
|
149
|
+
startedAt: run.startedAt?.toISOString?.() ?? run.startedAt,
|
|
150
|
+
completedAt: run.completedAt?.toISOString?.() ?? run.completedAt,
|
|
151
|
+
createdAt: run.createdAt?.toISOString?.() ?? run.createdAt,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { verifyCredentials, createSessionToken, type SessionConfig } from "../../auth/session.js";
|
|
3
|
+
|
|
4
|
+
export interface V1AuthRouteDeps {
|
|
5
|
+
sessionConfig?: SessionConfig;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function v1AuthRoutes(deps: V1AuthRouteDeps) {
|
|
9
|
+
const app = new Hono();
|
|
10
|
+
|
|
11
|
+
app.post("/auth/login", async (c) => {
|
|
12
|
+
if (!deps.sessionConfig) {
|
|
13
|
+
return c.json({ error: "unavailable", message: "Auth not configured." }, 503);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const body = await c.req.json().catch(() => ({}));
|
|
17
|
+
const { username, password } = body;
|
|
18
|
+
|
|
19
|
+
if (!username || !password) {
|
|
20
|
+
return c.json({ error: "bad_request", message: "Missing username or password." }, 400);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (!verifyCredentials(username, password, deps.sessionConfig)) {
|
|
24
|
+
return c.json({ error: "unauthorized", message: "Invalid credentials." }, 401);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const token = createSessionToken(deps.sessionConfig);
|
|
28
|
+
const ttlSeconds = Math.floor(
|
|
29
|
+
(deps.sessionConfig.sessionTtlMs ?? 86_400_000) / 1000,
|
|
30
|
+
);
|
|
31
|
+
c.header(
|
|
32
|
+
"Set-Cookie",
|
|
33
|
+
`station_session=${token}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${ttlSeconds}`,
|
|
34
|
+
);
|
|
35
|
+
return c.json({ data: { ok: true } });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
app.post("/auth/logout", async (c) => {
|
|
39
|
+
c.header(
|
|
40
|
+
"Set-Cookie",
|
|
41
|
+
"station_session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0",
|
|
42
|
+
);
|
|
43
|
+
return c.json({ data: { ok: true } });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
return app;
|
|
47
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { BroadcastRunner, BroadcastQueueAdapter, BroadcastRun } from "station-broadcast";
|
|
3
|
+
import type { StationBroadcastSubscriber } from "../../subscriber.js";
|
|
4
|
+
|
|
5
|
+
export interface V1BroadcastDeps {
|
|
6
|
+
broadcastRunner?: BroadcastRunner;
|
|
7
|
+
broadcastAdapter?: BroadcastQueueAdapter;
|
|
8
|
+
broadcastSubscriber?: StationBroadcastSubscriber;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function v1BroadcastRoutes(deps: V1BroadcastDeps) {
|
|
12
|
+
const app = new Hono();
|
|
13
|
+
|
|
14
|
+
app.get("/broadcasts", async (c) => {
|
|
15
|
+
if (deps.broadcastSubscriber) {
|
|
16
|
+
const meta = deps.broadcastSubscriber.getAllBroadcastMeta();
|
|
17
|
+
if (meta.length > 0) return c.json({ data: meta });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (!deps.broadcastRunner) return c.json({ data: [] });
|
|
21
|
+
|
|
22
|
+
// BroadcastRunner.listRegistered() returns { name, nodeCount, failurePolicy, timeout?, interval? }
|
|
23
|
+
const result = deps.broadcastRunner.listRegistered();
|
|
24
|
+
return c.json({ data: result });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
app.get("/broadcasts/:name", async (c) => {
|
|
28
|
+
const name = c.req.param("name");
|
|
29
|
+
|
|
30
|
+
if (deps.broadcastSubscriber) {
|
|
31
|
+
const meta = deps.broadcastSubscriber.getBroadcastMeta(name);
|
|
32
|
+
if (meta) return c.json({ data: meta });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (deps.broadcastRunner) {
|
|
36
|
+
const entry = deps.broadcastRunner.listRegistered().find((b) => b.name === name);
|
|
37
|
+
if (entry) return c.json({ data: entry });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return c.json({ error: "not_found", message: `Broadcast "${name}" not found.` }, 404);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
app.get("/broadcast-runs/:id", async (c) => {
|
|
44
|
+
if (!deps.broadcastAdapter) {
|
|
45
|
+
return c.json({ error: "unavailable", message: "No broadcast adapter configured." }, 503);
|
|
46
|
+
}
|
|
47
|
+
const id = c.req.param("id");
|
|
48
|
+
const run = await deps.broadcastAdapter.getBroadcastRun(id);
|
|
49
|
+
if (!run) {
|
|
50
|
+
return c.json({ error: "not_found", message: "Broadcast run not found." }, 404);
|
|
51
|
+
}
|
|
52
|
+
return c.json({ data: serializeBroadcastRun(run) });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
app.get("/broadcast-runs/:id/nodes", async (c) => {
|
|
56
|
+
if (!deps.broadcastAdapter) {
|
|
57
|
+
return c.json({ error: "unavailable", message: "No broadcast adapter configured." }, 503);
|
|
58
|
+
}
|
|
59
|
+
const id = c.req.param("id");
|
|
60
|
+
const nodes = await deps.broadcastAdapter.getNodeRuns(id);
|
|
61
|
+
return c.json({
|
|
62
|
+
data: nodes.map((n) => ({
|
|
63
|
+
...n,
|
|
64
|
+
startedAt: n.startedAt?.toISOString?.() ?? n.startedAt,
|
|
65
|
+
completedAt: n.completedAt?.toISOString?.() ?? n.completedAt,
|
|
66
|
+
})),
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Cancel endpoint is not included here — it requires "cancel" scope
|
|
71
|
+
// and is mounted separately in the server wiring.
|
|
72
|
+
|
|
73
|
+
return app;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function serializeBroadcastRun(run: BroadcastRun): Record<string, unknown> {
|
|
77
|
+
return {
|
|
78
|
+
...run,
|
|
79
|
+
nextRunAt: run.nextRunAt?.toISOString?.() ?? run.nextRunAt,
|
|
80
|
+
startedAt: run.startedAt?.toISOString?.() ?? run.startedAt,
|
|
81
|
+
completedAt: run.completedAt?.toISOString?.() ?? run.completedAt,
|
|
82
|
+
createdAt: run.createdAt?.toISOString?.() ?? run.createdAt,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { streamSSE } from "hono/streaming";
|
|
3
|
+
import crypto from "node:crypto";
|
|
4
|
+
import type { SSEHub, SSEClient } from "../../sse.js";
|
|
5
|
+
import type { StationEvent } from "../../ws.js";
|
|
6
|
+
|
|
7
|
+
export interface V1EventDeps {
|
|
8
|
+
sseHub: SSEHub;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function v1EventRoutes(deps: V1EventDeps) {
|
|
12
|
+
const app = new Hono();
|
|
13
|
+
|
|
14
|
+
app.get("/events", (c) => {
|
|
15
|
+
const signalFilter = c.req.query("signals")
|
|
16
|
+
? new Set(c.req.query("signals")!.split(",").filter(Boolean))
|
|
17
|
+
: null;
|
|
18
|
+
const broadcastFilter = c.req.query("broadcasts")
|
|
19
|
+
? new Set(c.req.query("broadcasts")!.split(",").filter(Boolean))
|
|
20
|
+
: null;
|
|
21
|
+
const eventFilter = c.req.query("events")
|
|
22
|
+
? new Set(c.req.query("events")!.split(",").filter(Boolean))
|
|
23
|
+
: null;
|
|
24
|
+
|
|
25
|
+
return streamSSE(c, async (stream) => {
|
|
26
|
+
const clientId = crypto.randomUUID();
|
|
27
|
+
let eventCounter = 0;
|
|
28
|
+
|
|
29
|
+
const client: SSEClient = {
|
|
30
|
+
id: clientId,
|
|
31
|
+
signalFilter,
|
|
32
|
+
broadcastFilter,
|
|
33
|
+
eventFilter,
|
|
34
|
+
send(event: StationEvent) {
|
|
35
|
+
eventCounter++;
|
|
36
|
+
stream.writeSSE({
|
|
37
|
+
event: event.type,
|
|
38
|
+
data: JSON.stringify(event.data),
|
|
39
|
+
id: `evt_${eventCounter}`,
|
|
40
|
+
});
|
|
41
|
+
},
|
|
42
|
+
close() {
|
|
43
|
+
stream.close();
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
deps.sseHub.addClient(client);
|
|
48
|
+
|
|
49
|
+
// Keep connection alive with a periodic heartbeat comment
|
|
50
|
+
const heartbeat = setInterval(() => {
|
|
51
|
+
stream.writeSSE({ event: "heartbeat", data: "" });
|
|
52
|
+
}, 30_000);
|
|
53
|
+
|
|
54
|
+
// Clean up when client disconnects
|
|
55
|
+
stream.onAbort(() => {
|
|
56
|
+
clearInterval(heartbeat);
|
|
57
|
+
deps.sseHub.removeClient(clientId);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// Hold the connection open indefinitely until the client disconnects.
|
|
61
|
+
// The stream will be closed by onAbort or by the SSEHub.close() method.
|
|
62
|
+
await new Promise<void>((resolve) => {
|
|
63
|
+
stream.onAbort(() => {
|
|
64
|
+
resolve();
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
return app;
|
|
71
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { SignalQueueAdapter } from "station-signal";
|
|
3
|
+
import type { BroadcastQueueAdapter } from "station-broadcast";
|
|
4
|
+
|
|
5
|
+
export interface V1HealthDeps {
|
|
6
|
+
signalAdapter: SignalQueueAdapter;
|
|
7
|
+
broadcastAdapter?: BroadcastQueueAdapter;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function v1HealthRoutes(deps: V1HealthDeps) {
|
|
11
|
+
const app = new Hono();
|
|
12
|
+
|
|
13
|
+
app.get("/health", async (c) => {
|
|
14
|
+
let signalOk = false;
|
|
15
|
+
let broadcastOk = false;
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
signalOk = await deps.signalAdapter.ping();
|
|
19
|
+
} catch {
|
|
20
|
+
// ping failed — signalOk stays false
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (deps.broadcastAdapter) {
|
|
24
|
+
try {
|
|
25
|
+
broadcastOk = await deps.broadcastAdapter.ping();
|
|
26
|
+
} catch {
|
|
27
|
+
// ping failed — broadcastOk stays false
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return c.json({
|
|
32
|
+
data: {
|
|
33
|
+
ok: signalOk && (!deps.broadcastAdapter || broadcastOk),
|
|
34
|
+
signal: signalOk,
|
|
35
|
+
broadcast: deps.broadcastAdapter ? broadcastOk : null,
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
return app;
|
|
41
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { KeyStore } from "../../auth/keys.js";
|
|
3
|
+
|
|
4
|
+
export interface V1KeyDeps {
|
|
5
|
+
keyStore?: KeyStore;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function v1KeyRoutes(deps: V1KeyDeps) {
|
|
9
|
+
const app = new Hono();
|
|
10
|
+
|
|
11
|
+
app.post("/keys", async (c) => {
|
|
12
|
+
if (!deps.keyStore) {
|
|
13
|
+
return c.json({ error: "unavailable", message: "Auth not configured." }, 503);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const body = await c.req.json().catch(() => ({}));
|
|
17
|
+
const name = body.name || "Unnamed key";
|
|
18
|
+
const scopes = Array.isArray(body.scopes) ? body.scopes : ["trigger", "read"];
|
|
19
|
+
|
|
20
|
+
const { key, record } = deps.keyStore.create(name, scopes);
|
|
21
|
+
return c.json(
|
|
22
|
+
{
|
|
23
|
+
data: {
|
|
24
|
+
id: record.id,
|
|
25
|
+
name: record.name,
|
|
26
|
+
key, // Only returned at creation time
|
|
27
|
+
keyPrefix: record.keyPrefix,
|
|
28
|
+
scopes: record.scopes,
|
|
29
|
+
createdAt: record.createdAt,
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
201,
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
app.get("/keys", async (c) => {
|
|
37
|
+
if (!deps.keyStore) {
|
|
38
|
+
return c.json({ error: "unavailable", message: "Auth not configured." }, 503);
|
|
39
|
+
}
|
|
40
|
+
const keys = deps.keyStore.list();
|
|
41
|
+
return c.json({ data: keys });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
app.delete("/keys/:id", async (c) => {
|
|
45
|
+
if (!deps.keyStore) {
|
|
46
|
+
return c.json({ error: "unavailable", message: "Auth not configured." }, 503);
|
|
47
|
+
}
|
|
48
|
+
const id = c.req.param("id");
|
|
49
|
+
const success = deps.keyStore.revoke(id);
|
|
50
|
+
if (!success) {
|
|
51
|
+
return c.json({ error: "not_found", message: "Key not found." }, 404);
|
|
52
|
+
}
|
|
53
|
+
return c.json({ data: { revoked: true } });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
return app;
|
|
57
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { SignalRunner, SignalQueueAdapter, Run } from "station-signal";
|
|
3
|
+
import type { LogBuffer } from "../../log-buffer.js";
|
|
4
|
+
import type { LogStore } from "../../log-store.js";
|
|
5
|
+
|
|
6
|
+
export interface V1RunDeps {
|
|
7
|
+
signalRunner?: SignalRunner;
|
|
8
|
+
signalAdapter: SignalQueueAdapter;
|
|
9
|
+
logBuffer: LogBuffer;
|
|
10
|
+
logStore?: LogStore;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Cancel endpoint is not included here — it requires "cancel" scope
|
|
14
|
+
// and is mounted separately in the server wiring.
|
|
15
|
+
|
|
16
|
+
export function v1RunRoutes(deps: V1RunDeps) {
|
|
17
|
+
const app = new Hono();
|
|
18
|
+
|
|
19
|
+
app.get("/runs", async (c) => {
|
|
20
|
+
const status = c.req.query("status");
|
|
21
|
+
const signalName = c.req.query("signalName");
|
|
22
|
+
const limit = Math.min(parseInt(c.req.query("limit") ?? "50", 10) || 50, 200);
|
|
23
|
+
|
|
24
|
+
let runs: Run[] = [];
|
|
25
|
+
|
|
26
|
+
if (signalName) {
|
|
27
|
+
runs = await deps.signalAdapter.listRuns(signalName);
|
|
28
|
+
} else if (deps.signalRunner) {
|
|
29
|
+
const seen = new Set<string>();
|
|
30
|
+
for (const { name } of deps.signalRunner.listRegistered()) {
|
|
31
|
+
const signalRuns = await deps.signalAdapter.listRuns(name);
|
|
32
|
+
for (const r of signalRuns) {
|
|
33
|
+
if (!seen.has(r.id)) {
|
|
34
|
+
seen.add(r.id);
|
|
35
|
+
runs.push(r);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (status) {
|
|
42
|
+
runs = runs.filter((r) => r.status === status);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
runs.sort((a, b) => {
|
|
46
|
+
const aTime = a.createdAt instanceof Date
|
|
47
|
+
? a.createdAt.getTime()
|
|
48
|
+
: new Date(a.createdAt as unknown as string).getTime();
|
|
49
|
+
const bTime = b.createdAt instanceof Date
|
|
50
|
+
? b.createdAt.getTime()
|
|
51
|
+
: new Date(b.createdAt as unknown as string).getTime();
|
|
52
|
+
return bTime - aTime;
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
runs = runs.slice(0, limit);
|
|
56
|
+
|
|
57
|
+
return c.json({ data: runs.map(serializeRun), meta: { total: runs.length } });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
app.get("/runs/:id", async (c) => {
|
|
61
|
+
const id = c.req.param("id");
|
|
62
|
+
const run = await deps.signalAdapter.getRun(id);
|
|
63
|
+
if (!run) return c.json({ error: "not_found", message: "Run not found." }, 404);
|
|
64
|
+
return c.json({ data: serializeRun(run) });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
app.get("/runs/:id/steps", async (c) => {
|
|
68
|
+
const id = c.req.param("id");
|
|
69
|
+
const steps = await deps.signalAdapter.getSteps(id);
|
|
70
|
+
return c.json({
|
|
71
|
+
data: steps.map((s) => ({
|
|
72
|
+
...s,
|
|
73
|
+
startedAt: s.startedAt?.toISOString?.() ?? s.startedAt,
|
|
74
|
+
completedAt: s.completedAt?.toISOString?.() ?? s.completedAt,
|
|
75
|
+
})),
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
app.get("/runs/:id/logs", async (c) => {
|
|
80
|
+
const id = c.req.param("id");
|
|
81
|
+
const logs = deps.logStore?.get(id) ?? deps.logBuffer.get(id);
|
|
82
|
+
return c.json({ data: logs });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
return app;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function serializeRun(run: Run): Record<string, unknown> {
|
|
89
|
+
return {
|
|
90
|
+
...run,
|
|
91
|
+
nextRunAt: run.nextRunAt?.toISOString?.() ?? run.nextRunAt,
|
|
92
|
+
lastRunAt: run.lastRunAt?.toISOString?.() ?? run.lastRunAt,
|
|
93
|
+
startedAt: run.startedAt?.toISOString?.() ?? run.startedAt,
|
|
94
|
+
completedAt: run.completedAt?.toISOString?.() ?? run.completedAt,
|
|
95
|
+
createdAt: run.createdAt?.toISOString?.() ?? run.createdAt,
|
|
96
|
+
};
|
|
97
|
+
}
|