station-kit 1.0.8 → 1.1.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/.next/standalone/package.json +3 -1
- package/.next/standalone/packages/station-kit/.next/BUILD_ID +1 -1
- package/.next/standalone/packages/station-kit/.next/app-build-manifest.json +75 -16
- package/.next/standalone/packages/station-kit/.next/app-path-routes-manifest.json +10 -3
- package/.next/standalone/packages/station-kit/.next/build-manifest.json +3 -3
- package/.next/standalone/packages/station-kit/.next/prerender-manifest.json +108 -12
- package/.next/standalone/packages/station-kit/.next/routes-manifest.json +49 -0
- package/.next/standalone/packages/station-kit/.next/server/app/_not-found/page.js +2 -2
- package/.next/standalone/packages/station-kit/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/_not-found.html +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/_not-found.rsc +7 -7
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/[id]/page.js +2 -2
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/[id]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/dyn/[name]/page.js +2 -0
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/dyn/[name]/page.js.nft.json +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/dyn/[name]/page_client-reference-manifest.js +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/dyn/[name]/v/[n]/page.js +2 -0
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/dyn/[name]/v/[n]/page.js.nft.json +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/dyn/[name]/v/[n]/page_client-reference-manifest.js +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/new/page.js +2 -0
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/new/page.js.nft.json +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/new/page_client-reference-manifest.js +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/new.html +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/new.meta +7 -0
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/new.rsc +25 -0
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/page.js +2 -2
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts.html +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts.rsc +8 -8
- package/.next/standalone/packages/station-kit/.next/server/app/index.html +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/index.rsc +8 -8
- package/.next/standalone/packages/station-kit/.next/server/app/page.js +2 -2
- package/.next/standalone/packages/station-kit/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/playground/expression/page.js +2 -0
- package/.next/standalone/packages/station-kit/.next/server/app/playground/expression/page.js.nft.json +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/playground/expression/page_client-reference-manifest.js +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/playground/expression.html +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/playground/expression.meta +7 -0
- package/.next/standalone/packages/station-kit/.next/server/app/playground/expression.rsc +25 -0
- package/.next/standalone/packages/station-kit/.next/server/app/runs/[id]/page.js +2 -2
- package/.next/standalone/packages/station-kit/.next/server/app/runs/[id]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/schedules/[id]/page.js +2 -0
- package/.next/standalone/packages/station-kit/.next/server/app/schedules/[id]/page.js.nft.json +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/schedules/[id]/page_client-reference-manifest.js +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/schedules/new/page.js +2 -0
- package/.next/standalone/packages/station-kit/.next/server/app/schedules/new/page.js.nft.json +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/schedules/new/page_client-reference-manifest.js +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/schedules/new.html +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/schedules/new.meta +7 -0
- package/.next/standalone/packages/station-kit/.next/server/app/schedules/new.rsc +25 -0
- package/.next/standalone/packages/station-kit/.next/server/app/schedules/page.js +2 -0
- package/.next/standalone/packages/station-kit/.next/server/app/schedules/page.js.nft.json +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/schedules/page_client-reference-manifest.js +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/schedules.html +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/schedules.meta +7 -0
- package/.next/standalone/packages/station-kit/.next/server/app/schedules.rsc +25 -0
- package/.next/standalone/packages/station-kit/.next/server/app/settings/page.js +2 -2
- package/.next/standalone/packages/station-kit/.next/server/app/settings/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/settings.html +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/settings.rsc +8 -8
- package/.next/standalone/packages/station-kit/.next/server/app/signals/[name]/page.js +2 -2
- package/.next/standalone/packages/station-kit/.next/server/app/signals/[name]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/signals/page.js +2 -2
- package/.next/standalone/packages/station-kit/.next/server/app/signals/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/signals.html +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/signals.rsc +8 -8
- package/.next/standalone/packages/station-kit/.next/server/app-paths-manifest.json +10 -3
- package/.next/standalone/packages/station-kit/.next/server/chunks/102.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/chunks/535.js +2 -0
- package/.next/standalone/packages/station-kit/.next/server/chunks/606.js +14 -14
- package/.next/standalone/packages/station-kit/.next/server/chunks/783.js +3 -3
- package/.next/standalone/packages/station-kit/.next/server/middleware-build-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/pages/404.html +1 -1
- package/.next/standalone/packages/station-kit/.next/server/pages/500.html +1 -1
- package/.next/standalone/packages/station-kit/.next/server/pages/_app.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/pages/_document.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/pages/_error.js +9 -9
- package/.next/standalone/packages/station-kit/.next/server/pages-manifest.json +1 -1
- package/.next/standalone/packages/station-kit/.next/server/server-reference-manifest.json +1 -1
- package/.next/standalone/packages/station-kit/.next/static/THKSkCipW_pj0F6DRXYEG/_buildManifest.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/145-9e370afd2e5aba39.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/285-ff198f0a909c4fdd.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/561-33d912169940283e.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/935-dff12960528de017.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/_not-found/{page-ce21b4ba9038a5a7.js → page-67ef312aee40cfeb.js} +1 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/[id]/page-fe2f5467a0c68fef.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/dyn/[name]/page-0d2505242014f51e.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/dyn/[name]/v/[n]/page-5eac0507f49a00ec.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/new/page-3d02707043d24dc7.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/page-dee500ccc01f0821.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/layout-e14e14f3e5b0b8a9.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/page-aac41ef7a470daab.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/playground/expression/page-dc9d91f3f50f4716.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/runs/[id]/page-9e4c4f751a4bea72.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/schedules/[id]/page-435f67be180b8e4f.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/schedules/new/page-f697c289c813496a.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/schedules/page-738d98dc0b63166e.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/settings/page-fc5654b31f57ac21.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/signals/[name]/page-4b1c09a539a1ebcd.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/signals/page-d2f2403dfede87cc.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/pages/_app-a3774a320f58a018.js +1 -0
- package/.next/standalone/packages/station-kit/package.json +7 -4
- package/dist/config/schema.d.ts +23 -0
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +2 -0
- package/dist/config/schema.js.map +1 -1
- package/dist/server/auth/keys.d.ts +91 -8
- package/dist/server/auth/keys.d.ts.map +1 -1
- package/dist/server/auth/keys.js +289 -54
- package/dist/server/auth/keys.js.map +1 -1
- package/dist/server/index.d.ts +5 -2
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +84 -9
- package/dist/server/index.js.map +1 -1
- package/dist/server/log-store.d.ts +102 -6
- package/dist/server/log-store.d.ts.map +1 -1
- package/dist/server/log-store.js +140 -32
- package/dist/server/log-store.js.map +1 -1
- package/dist/server/middleware/auth.js +1 -1
- package/dist/server/middleware/auth.js.map +1 -1
- package/dist/server/routes/broadcasts.d.ts.map +1 -1
- package/dist/server/routes/broadcasts.js +3 -1
- package/dist/server/routes/broadcasts.js.map +1 -1
- package/dist/server/routes/runs.js +1 -1
- package/dist/server/routes/runs.js.map +1 -1
- package/dist/server/routes/v1/definitions.d.ts +21 -0
- package/dist/server/routes/v1/definitions.d.ts.map +1 -0
- package/dist/server/routes/v1/definitions.js +139 -0
- package/dist/server/routes/v1/definitions.js.map +1 -0
- package/dist/server/routes/v1/expressions.d.ts +3 -0
- package/dist/server/routes/v1/expressions.d.ts.map +1 -0
- package/dist/server/routes/v1/expressions.js +56 -0
- package/dist/server/routes/v1/expressions.js.map +1 -0
- package/dist/server/routes/v1/keys.js +3 -3
- package/dist/server/routes/v1/keys.js.map +1 -1
- package/dist/server/routes/v1/runs.js +1 -1
- package/dist/server/routes/v1/runs.js.map +1 -1
- package/dist/server/routes/v1/schedules.d.ts +10 -0
- package/dist/server/routes/v1/schedules.d.ts.map +1 -0
- package/dist/server/routes/v1/schedules.js +169 -0
- package/dist/server/routes/v1/schedules.js.map +1 -0
- package/dist/server/routes/v1/trigger.d.ts.map +1 -1
- package/dist/server/routes/v1/trigger.js +21 -0
- package/dist/server/routes/v1/trigger.js.map +1 -1
- package/package.json +12 -9
- package/src/app/broadcasts/components/broadcast-builder.tsx +535 -0
- package/src/app/broadcasts/components/dag-editor.tsx +510 -0
- package/src/app/broadcasts/dyn/[name]/dynamic-detail.tsx +243 -0
- package/src/app/broadcasts/dyn/[name]/page.tsx +10 -0
- package/src/app/broadcasts/dyn/[name]/v/[n]/page.tsx +10 -0
- package/src/app/broadcasts/dyn/[name]/v/[n]/version-view.tsx +285 -0
- package/src/app/broadcasts/new/page.tsx +102 -0
- package/src/app/broadcasts/page.tsx +176 -91
- package/src/app/components/api-panel.tsx +151 -0
- package/src/app/components/shell.tsx +23 -0
- package/src/app/hooks/use-api.ts +117 -0
- package/src/app/playground/expression/page.tsx +245 -0
- package/src/app/schedules/[id]/page.tsx +10 -0
- package/src/app/schedules/[id]/schedule-editor.tsx +195 -0
- package/src/app/schedules/components/schedule-form.tsx +140 -0
- package/src/app/schedules/new/page.tsx +166 -0
- package/src/app/schedules/page.tsx +126 -0
- package/src/config/schema.ts +25 -0
- package/src/server/auth/keys.ts +348 -58
- package/src/server/index.ts +118 -11
- package/src/server/log-store.ts +196 -45
- package/src/server/middleware/auth.ts +1 -1
- package/src/server/routes/broadcasts.ts +3 -1
- package/src/server/routes/runs.ts +1 -1
- package/src/server/routes/v1/definitions.ts +164 -0
- package/src/server/routes/v1/expressions.ts +76 -0
- package/src/server/routes/v1/keys.ts +3 -3
- package/src/server/routes/v1/runs.ts +1 -1
- package/src/server/routes/v1/schedules.ts +176 -0
- package/src/server/routes/v1/trigger.ts +27 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/580-f007f4d4c050db4e.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/[id]/page-a0a20cccda13a0e9.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/page-937eb876f9087bc9.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/layout-68cd71116ba65cd8.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/page-70b0c0958c03459a.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/runs/[id]/page-01f8040619fe56c5.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/settings/page-beac11049f90da31.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/signals/[name]/page-931e6a38a4a53d25.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/signals/page-6a123a355d93fec5.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/pages/_app-0a7b2e66ecbe3f0a.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/xYd6dn0Ox68DaamIrH_pB/_buildManifest.js +0 -1
- /package/.next/standalone/packages/station-kit/.next/static/{xYd6dn0Ox68DaamIrH_pB → THKSkCipW_pj0F6DRXYEG}/_ssgManifest.js +0 -0
package/src/server/log-store.ts
CHANGED
|
@@ -1,56 +1,207 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { appendFile } from "node:fs/promises";
|
|
3
|
+
import { dirname } from "node:path";
|
|
2
4
|
import type { LogEntry } from "./log-buffer.js";
|
|
3
5
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
6
|
+
/**
|
|
7
|
+
* Pluggable storage backend for run logs. Implementations only persist
|
|
8
|
+
* and query records — bounded in-memory buffering for live UI streams
|
|
9
|
+
* lives in `LogBuffer`. May be sync or async; the LogStore wrapper
|
|
10
|
+
* normalizes both.
|
|
11
|
+
*
|
|
12
|
+
* Contract for implementers:
|
|
13
|
+
*
|
|
14
|
+
* - **`add(entry)`** is treated as fire-and-forget at the LogStore
|
|
15
|
+
* boundary. Signal runners never block on log writes. Adapters that
|
|
16
|
+
* need durability guarantees (queues, retries, batching) should
|
|
17
|
+
* implement that internally; thrown errors and rejected promises are
|
|
18
|
+
* caught and surfaced via the LogStore's `onError` hook (if set) but
|
|
19
|
+
* never rethrown to the caller.
|
|
20
|
+
* - **`get(runId)`** must return entries for that run in append order
|
|
21
|
+
* (oldest first). Routes that aggregate across runs may re-sort by
|
|
22
|
+
* timestamp, but per-run ordering is the adapter's responsibility.
|
|
23
|
+
* - **`close?()`** is called once on graceful shutdown. Use it to flush
|
|
24
|
+
* any in-flight buffers. It is NOT called on `SIGKILL` / OOM kill —
|
|
25
|
+
* adapters that must guarantee durability per write should not rely
|
|
26
|
+
* on it.
|
|
27
|
+
*
|
|
28
|
+
* Single-process semantics: the built-in `FileLogStorage` is safe for a
|
|
29
|
+
* single Node process. Running multiple processes against the same file
|
|
30
|
+
* path WILL produce interleaved bytes and lost entries — use a real
|
|
31
|
+
* database adapter (Postgres, MySQL, Redis, etc.) for multi-process or
|
|
32
|
+
* distributed deployments.
|
|
33
|
+
*/
|
|
34
|
+
export interface LogStorageAdapter {
|
|
35
|
+
add(entry: LogEntry): Promise<void> | void;
|
|
36
|
+
get(runId: string): Promise<LogEntry[]> | LogEntry[];
|
|
37
|
+
close?(): Promise<void> | void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ─── File-backed default ────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
export interface FileLogStorageOptions {
|
|
43
|
+
filePath: string;
|
|
44
|
+
/**
|
|
45
|
+
* Called when a background write to the underlying file fails. Use
|
|
46
|
+
* this to surface persistence problems (disk full, permission denied,
|
|
47
|
+
* etc.) to your monitoring system. If unset, write failures are
|
|
48
|
+
* silently dropped — acceptable for local dev, NOT for production.
|
|
49
|
+
*/
|
|
50
|
+
onError?: (err: unknown) => void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* File-backed log storage using append-only JSONL framing. Each line is
|
|
55
|
+
* a JSON-serialized `LogEntry`; existing entries are loaded into memory
|
|
56
|
+
* on construction; appends are serialized through an async write queue
|
|
57
|
+
* so concurrent writers can't interleave bytes within one process.
|
|
58
|
+
*
|
|
59
|
+
* No native dependencies — works on any Node 18+ install.
|
|
60
|
+
*
|
|
61
|
+
* **Production caveats** (in order of severity):
|
|
62
|
+
*
|
|
63
|
+
* 1. **Single-process only.** Two Node processes appending to the same
|
|
64
|
+
* file WILL interleave bytes once individual JSON lines exceed the
|
|
65
|
+
* OS pipe buffer (4 KB on Linux), corrupting the file.
|
|
66
|
+
* 2. **Best-effort durability.** Writes are queued and flushed via
|
|
67
|
+
* `fs.appendFile`; on `SIGKILL` / OOM kill, in-flight writes are lost.
|
|
68
|
+
* Set `onError` to surface fs failures.
|
|
69
|
+
* 3. **Unbounded memory on replay.** The whole file is loaded into a
|
|
70
|
+
* Map on startup. For high-volume deployments (gigabytes of logs)
|
|
71
|
+
* use a database-backed adapter instead.
|
|
72
|
+
*
|
|
73
|
+
* For multi-process, distributed, or high-durability deployments,
|
|
74
|
+
* implement `LogStorageAdapter` against Postgres / MySQL / Redis / S3.
|
|
75
|
+
*/
|
|
76
|
+
export class FileLogStorage implements LogStorageAdapter {
|
|
77
|
+
private path: string;
|
|
78
|
+
private byRunId = new Map<string, LogEntry[]>();
|
|
79
|
+
private writeQueue: Promise<void> = Promise.resolve();
|
|
80
|
+
private onError: (err: unknown) => void;
|
|
81
|
+
|
|
82
|
+
constructor(options: FileLogStorageOptions) {
|
|
83
|
+
// Backwards compat: callers used to pass `.db` paths for the sqlite store.
|
|
84
|
+
// Transparently swap to `.jsonl` so existing config files keep working.
|
|
85
|
+
this.path = options.filePath.endsWith(".db")
|
|
86
|
+
? options.filePath.replace(/\.db$/, ".jsonl")
|
|
87
|
+
: options.filePath;
|
|
88
|
+
this.onError = options.onError ?? (() => {});
|
|
89
|
+
mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 });
|
|
90
|
+
this.replay();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private replay(): void {
|
|
94
|
+
if (!existsSync(this.path)) return;
|
|
95
|
+
let content: string;
|
|
96
|
+
try {
|
|
97
|
+
content = readFileSync(this.path, "utf8");
|
|
98
|
+
} catch (err) {
|
|
99
|
+
this.onError(err);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const lines = content.split("\n");
|
|
103
|
+
for (const line of lines) {
|
|
104
|
+
if (!line) continue;
|
|
105
|
+
try {
|
|
106
|
+
const entry = JSON.parse(line) as LogEntry;
|
|
107
|
+
this.indexEntry(entry);
|
|
108
|
+
} catch {
|
|
109
|
+
// Skip malformed line; a partial write may have left a truncated tail.
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private indexEntry(entry: LogEntry): void {
|
|
115
|
+
let entries = this.byRunId.get(entry.runId);
|
|
116
|
+
if (!entries) {
|
|
117
|
+
entries = [];
|
|
118
|
+
this.byRunId.set(entry.runId, entries);
|
|
119
|
+
}
|
|
120
|
+
entries.push(entry);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
add(entry: LogEntry): void {
|
|
124
|
+
this.indexEntry(entry);
|
|
125
|
+
const line = JSON.stringify(entry) + "\n";
|
|
126
|
+
this.writeQueue = this.writeQueue.then(
|
|
127
|
+
() => appendFile(this.path, line, { mode: 0o600 }).catch((err) => {
|
|
128
|
+
this.onError(err);
|
|
129
|
+
}),
|
|
29
130
|
);
|
|
30
131
|
}
|
|
31
132
|
|
|
133
|
+
get(runId: string): LogEntry[] {
|
|
134
|
+
return this.byRunId.get(runId) ?? [];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async close(): Promise<void> {
|
|
138
|
+
await this.writeQueue;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ─── In-memory storage for tests / ephemeral deployments ────────────
|
|
143
|
+
|
|
144
|
+
export class MemoryLogStorage implements LogStorageAdapter {
|
|
145
|
+
private byRunId = new Map<string, LogEntry[]>();
|
|
146
|
+
|
|
32
147
|
add(entry: LogEntry): void {
|
|
33
|
-
this.
|
|
148
|
+
let entries = this.byRunId.get(entry.runId);
|
|
149
|
+
if (!entries) {
|
|
150
|
+
entries = [];
|
|
151
|
+
this.byRunId.set(entry.runId, entries);
|
|
152
|
+
}
|
|
153
|
+
entries.push(entry);
|
|
34
154
|
}
|
|
35
155
|
|
|
36
156
|
get(runId: string): LogEntry[] {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
157
|
+
return this.byRunId.get(runId) ?? [];
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ─── LogStore — thin wrapper that delegates to an adapter ───────────
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* LogStore is the consumer-facing handle that wraps a `LogStorageAdapter`.
|
|
165
|
+
* It exists so signal runners and route handlers can interact with a
|
|
166
|
+
* single concrete type, while the underlying persistence is swappable.
|
|
167
|
+
*
|
|
168
|
+
* `add` is fire-and-forget — adapter promises are caught at this boundary
|
|
169
|
+
* so a slow or failing log backend can never block (or crash) a signal
|
|
170
|
+
* runner. `get` always returns a Promise so callers can transparently
|
|
171
|
+
* support async backends (Postgres, Redis, etc.).
|
|
172
|
+
*/
|
|
173
|
+
export class LogStore {
|
|
174
|
+
private storage: LogStorageAdapter;
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Pass a `LogStorageAdapter` for any backend. The string overload is
|
|
178
|
+
* a shortcut for `new FileLogStorage({ filePath })` — useful for
|
|
179
|
+
* local dev and the default Station data directory.
|
|
180
|
+
*/
|
|
181
|
+
constructor(storageOrPath: LogStorageAdapter | string) {
|
|
182
|
+
if (typeof storageOrPath === "string") {
|
|
183
|
+
this.storage = new FileLogStorage({ filePath: storageOrPath });
|
|
184
|
+
} else {
|
|
185
|
+
this.storage = storageOrPath;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
add(entry: LogEntry): void {
|
|
190
|
+
try {
|
|
191
|
+
const result = this.storage.add(entry);
|
|
192
|
+
if (result && typeof (result as Promise<void>).catch === "function") {
|
|
193
|
+
(result as Promise<void>).catch(() => {});
|
|
194
|
+
}
|
|
195
|
+
} catch {
|
|
196
|
+
// Swallow sync throws; a broken log adapter must not crash signal runs.
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async get(runId: string): Promise<LogEntry[]> {
|
|
201
|
+
return await this.storage.get(runId);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async close(): Promise<void> {
|
|
205
|
+
if (this.storage.close) await this.storage.close();
|
|
55
206
|
}
|
|
56
207
|
}
|
|
@@ -15,7 +15,7 @@ export function authResolver(deps: AuthDeps) {
|
|
|
15
15
|
if (authHeader?.startsWith("Bearer ") && deps.keyStore) {
|
|
16
16
|
const token = authHeader.slice(7);
|
|
17
17
|
if (token.startsWith("sk_")) {
|
|
18
|
-
const key = deps.keyStore.verify(token);
|
|
18
|
+
const key = await deps.keyStore.verify(token);
|
|
19
19
|
if (key) {
|
|
20
20
|
c.set("authType", "api-key");
|
|
21
21
|
c.set("apiKeyId", key.id);
|
|
@@ -123,7 +123,9 @@ export function broadcastRoutes(deps: BroadcastDeps) {
|
|
|
123
123
|
const allLogs: Array<{ runId: string; signalName: string; level: string; message: string; timestamp: string; nodeName: string }> = [];
|
|
124
124
|
for (const nr of nodes) {
|
|
125
125
|
if (nr.signalRunId) {
|
|
126
|
-
const logs = deps.logStore
|
|
126
|
+
const logs = deps.logStore
|
|
127
|
+
? await deps.logStore.get(nr.signalRunId)
|
|
128
|
+
: deps.logBuffer?.get(nr.signalRunId) ?? [];
|
|
127
129
|
for (const log of logs) {
|
|
128
130
|
allLogs.push({ ...log, nodeName: nr.nodeName });
|
|
129
131
|
}
|
|
@@ -120,7 +120,7 @@ export function runRoutes(deps: RunDeps) {
|
|
|
120
120
|
|
|
121
121
|
app.get("/runs/:id/logs", async (c) => {
|
|
122
122
|
const id = c.req.param("id");
|
|
123
|
-
const logs = deps.logStore
|
|
123
|
+
const logs = deps.logStore ? await deps.logStore.get(id) : deps.logBuffer.get(id);
|
|
124
124
|
return c.json({ data: logs });
|
|
125
125
|
});
|
|
126
126
|
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type {
|
|
3
|
+
BroadcastRunner,
|
|
4
|
+
BroadcastQueueAdapter,
|
|
5
|
+
DynamicBroadcastSpec,
|
|
6
|
+
} from "station-broadcast";
|
|
7
|
+
import { validateDynamicSpec, type DynamicValidationContext } from "station-broadcast";
|
|
8
|
+
import type { SignalRunner } from "station-signal";
|
|
9
|
+
import type { SchemaField } from "station-expressions";
|
|
10
|
+
import type { StationSignalSubscriber } from "../../subscriber.js";
|
|
11
|
+
|
|
12
|
+
export interface V1DefinitionDeps {
|
|
13
|
+
broadcastRunner?: BroadcastRunner;
|
|
14
|
+
broadcastAdapter?: BroadcastQueueAdapter;
|
|
15
|
+
signalRunner?: SignalRunner;
|
|
16
|
+
signalSubscriber?: StationSignalSubscriber;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Read-scope routes for dynamic broadcast definitions: list, get, version
|
|
21
|
+
* history, get-by-version, plus the read-only `validate` endpoint.
|
|
22
|
+
*/
|
|
23
|
+
export function v1DefinitionReadRoutes(deps: V1DefinitionDeps) {
|
|
24
|
+
const app = new Hono();
|
|
25
|
+
|
|
26
|
+
app.get("/broadcast-definitions", async (c) => {
|
|
27
|
+
if (!deps.broadcastAdapter?.listDefinitions) return c.json({ data: [] });
|
|
28
|
+
const list = await deps.broadcastAdapter.listDefinitions();
|
|
29
|
+
return c.json({ data: list.map(serializeSpec) });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
app.get("/broadcast-definitions/:name", async (c) => {
|
|
33
|
+
if (!deps.broadcastAdapter?.getDefinition) return c.json({ error: "unavailable" }, 503);
|
|
34
|
+
const spec = await deps.broadcastAdapter.getDefinition(c.req.param("name"));
|
|
35
|
+
if (!spec) return c.json({ error: "not_found" }, 404);
|
|
36
|
+
return c.json({ data: serializeSpec(spec) });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
app.get("/broadcast-definitions/:name/versions", async (c) => {
|
|
40
|
+
if (!deps.broadcastAdapter?.listDefinitionVersions) return c.json({ data: [] });
|
|
41
|
+
const versions = await deps.broadcastAdapter.listDefinitionVersions(c.req.param("name"));
|
|
42
|
+
return c.json({ data: versions.map(serializeSpec) });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
app.get("/broadcast-definitions/:name/versions/:n", async (c) => {
|
|
46
|
+
if (!deps.broadcastAdapter?.getDefinition) return c.json({ error: "unavailable" }, 503);
|
|
47
|
+
const version = parseInt(c.req.param("n"), 10);
|
|
48
|
+
if (Number.isNaN(version)) {
|
|
49
|
+
return c.json({ error: "bad_request", message: "Version must be a number." }, 400);
|
|
50
|
+
}
|
|
51
|
+
const spec = await deps.broadcastAdapter.getDefinition(c.req.param("name"), version);
|
|
52
|
+
if (!spec) return c.json({ error: "not_found" }, 404);
|
|
53
|
+
return c.json({ data: serializeSpec(spec) });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
app.post("/broadcast-definitions/validate", async (c) => {
|
|
57
|
+
const body = await c.req.json().catch(() => ({}));
|
|
58
|
+
const spec = body as DynamicBroadcastSpec;
|
|
59
|
+
if (!spec || typeof spec !== "object" || typeof spec.name !== "string") {
|
|
60
|
+
return c.json({ error: "bad_request", message: "Spec is missing required fields." }, 400);
|
|
61
|
+
}
|
|
62
|
+
const ctx = buildValidationContext(deps);
|
|
63
|
+
const result = validateDynamicSpec(spec, ctx);
|
|
64
|
+
return c.json({ data: result });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
return app;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Admin-scope routes for mutating dynamic broadcast definitions. GETs are
|
|
72
|
+
* mounted under read scope in server/index.ts.
|
|
73
|
+
*/
|
|
74
|
+
export function v1DefinitionRoutes(deps: V1DefinitionDeps) {
|
|
75
|
+
const app = new Hono();
|
|
76
|
+
|
|
77
|
+
app.post("/broadcast-definitions", async (c) => {
|
|
78
|
+
if (!deps.broadcastAdapter?.saveDefinition) {
|
|
79
|
+
return c.json({ error: "unavailable", message: "Broadcast adapter does not support dynamic definitions." }, 503);
|
|
80
|
+
}
|
|
81
|
+
const body = await c.req.json().catch(() => ({}));
|
|
82
|
+
const incoming = body as DynamicBroadcastSpec;
|
|
83
|
+
if (!incoming?.name || !Array.isArray(incoming.nodes)) {
|
|
84
|
+
return c.json({ error: "bad_request", message: "Spec is missing required fields." }, 400);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const ctx = buildValidationContext(deps);
|
|
88
|
+
const validation = validateDynamicSpec(incoming, ctx);
|
|
89
|
+
if (!validation.ok) {
|
|
90
|
+
return c.json({ error: "validation_failed", data: validation }, 422);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const apiKeyId = c.get("apiKeyId" as never) as string | undefined;
|
|
94
|
+
const now = new Date();
|
|
95
|
+
const toSave: DynamicBroadcastSpec = {
|
|
96
|
+
...incoming,
|
|
97
|
+
version: 0,
|
|
98
|
+
createdAt: incoming.createdAt ?? now,
|
|
99
|
+
updatedAt: now,
|
|
100
|
+
createdBy: apiKeyId,
|
|
101
|
+
failurePolicy: incoming.failurePolicy ?? "fail-fast",
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const saved = await deps.broadcastAdapter.saveDefinition(toSave);
|
|
105
|
+
|
|
106
|
+
// Trigger an eager reconciliation so the new version is live immediately.
|
|
107
|
+
if (deps.broadcastRunner) {
|
|
108
|
+
void deps.broadcastRunner.reconcileDynamicDefinitions().catch(() => {});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return c.json({ data: serializeSpec(saved) }, 201);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// GET routes for definitions live in the read-scope mount in server/index.ts;
|
|
115
|
+
// this router only exposes mutating endpoints under admin scope.
|
|
116
|
+
|
|
117
|
+
app.delete("/broadcast-definitions/:name", async (c) => {
|
|
118
|
+
if (!deps.broadcastAdapter?.deleteDefinition) {
|
|
119
|
+
return c.json({ error: "unavailable" }, 503);
|
|
120
|
+
}
|
|
121
|
+
const success = await deps.broadcastAdapter.deleteDefinition(c.req.param("name"));
|
|
122
|
+
if (!success) return c.json({ error: "not_found" }, 404);
|
|
123
|
+
if (deps.broadcastRunner) {
|
|
124
|
+
void deps.broadcastRunner.reconcileDynamicDefinitions().catch(() => {});
|
|
125
|
+
}
|
|
126
|
+
return c.json({ data: { deleted: true } });
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
return app;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function serializeSpec(spec: DynamicBroadcastSpec): Record<string, unknown> {
|
|
133
|
+
return {
|
|
134
|
+
...spec,
|
|
135
|
+
createdAt: spec.createdAt?.toISOString?.() ?? spec.createdAt,
|
|
136
|
+
updatedAt: spec.updatedAt?.toISOString?.() ?? spec.updatedAt,
|
|
137
|
+
deletedAt: spec.deletedAt?.toISOString?.() ?? spec.deletedAt,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function buildValidationContext(deps: V1DefinitionDeps): DynamicValidationContext {
|
|
142
|
+
const signalSchemas = new Map<string, { inputSchema: SchemaField; outputSchema: SchemaField }>();
|
|
143
|
+
// Best-effort schema reflection: we don't know the precise SchemaField for
|
|
144
|
+
// each signal here without traversing Zod schemas. Use `any` for now — the
|
|
145
|
+
// structural checks (signal exists, deps exist, no cycles, expression
|
|
146
|
+
// wellformedness) still run. Zod input validation runs at trigger time.
|
|
147
|
+
const sigs = deps.signalRunner?.getAllSignals();
|
|
148
|
+
if (sigs) {
|
|
149
|
+
for (const name of sigs.keys()) {
|
|
150
|
+
signalSchemas.set(name, {
|
|
151
|
+
inputSchema: { type: "any" },
|
|
152
|
+
outputSchema: { type: "any" },
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
} else if (deps.signalSubscriber) {
|
|
156
|
+
for (const meta of deps.signalSubscriber.getAllSignalMeta()) {
|
|
157
|
+
signalSchemas.set(meta.name, {
|
|
158
|
+
inputSchema: { type: "any" },
|
|
159
|
+
outputSchema: { type: "any" },
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return { signalSchemas };
|
|
164
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import {
|
|
3
|
+
evaluate,
|
|
4
|
+
validate,
|
|
5
|
+
parse,
|
|
6
|
+
ExpressionEvalError,
|
|
7
|
+
ExpressionParseError,
|
|
8
|
+
type ExprNode,
|
|
9
|
+
type SchemaField,
|
|
10
|
+
} from "station-expressions";
|
|
11
|
+
|
|
12
|
+
export function v1ExpressionRoutes() {
|
|
13
|
+
const app = new Hono();
|
|
14
|
+
|
|
15
|
+
app.post("/expressions/evaluate", async (c) => {
|
|
16
|
+
const body = await c.req.json().catch(() => ({}));
|
|
17
|
+
const { node, context } = body as {
|
|
18
|
+
node?: ExprNode;
|
|
19
|
+
context?: { input?: unknown; upstream?: Record<string, unknown> };
|
|
20
|
+
};
|
|
21
|
+
if (!node) {
|
|
22
|
+
return c.json({ error: "bad_request", message: "Missing `node`." }, 400);
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
const result = evaluate(node, {
|
|
26
|
+
input: context?.input,
|
|
27
|
+
upstream: context?.upstream ?? {},
|
|
28
|
+
});
|
|
29
|
+
return c.json({ data: { value: result } });
|
|
30
|
+
} catch (err) {
|
|
31
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
32
|
+
const status = err instanceof ExpressionEvalError ? 400 : 500;
|
|
33
|
+
return c.json({ error: "evaluation_failed", message }, status);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
app.post("/expressions/validate", async (c) => {
|
|
38
|
+
const body = await c.req.json().catch(() => ({}));
|
|
39
|
+
const { node, schemaContext } = body as {
|
|
40
|
+
node?: ExprNode;
|
|
41
|
+
schemaContext?: {
|
|
42
|
+
inputSchema?: SchemaField;
|
|
43
|
+
upstreamSchemas?: Record<string, SchemaField>;
|
|
44
|
+
expectedSchema?: SchemaField;
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
if (!node) {
|
|
48
|
+
return c.json({ error: "bad_request", message: "Missing `node`." }, 400);
|
|
49
|
+
}
|
|
50
|
+
const result = validate(node, {
|
|
51
|
+
inputSchema: schemaContext?.inputSchema ?? { type: "any" },
|
|
52
|
+
upstreamSchemas: schemaContext?.upstreamSchemas ?? {},
|
|
53
|
+
expectedSchema: schemaContext?.expectedSchema,
|
|
54
|
+
});
|
|
55
|
+
return c.json({ data: result });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
app.post("/expressions/parse", async (c) => {
|
|
59
|
+
const body = await c.req.json().catch(() => ({}));
|
|
60
|
+
const { source } = body as { source?: string };
|
|
61
|
+
if (typeof source !== "string") {
|
|
62
|
+
return c.json({ error: "bad_request", message: "Missing `source` string." }, 400);
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
const node = parse(source);
|
|
66
|
+
return c.json({ data: { node } });
|
|
67
|
+
} catch (err) {
|
|
68
|
+
if (err instanceof ExpressionParseError) {
|
|
69
|
+
return c.json({ error: "parse_error", message: err.message, position: err.position }, 400);
|
|
70
|
+
}
|
|
71
|
+
return c.json({ error: "parse_error", message: err instanceof Error ? err.message : String(err) }, 400);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
return app;
|
|
76
|
+
}
|
|
@@ -17,7 +17,7 @@ export function v1KeyRoutes(deps: V1KeyDeps) {
|
|
|
17
17
|
const name = body.name || "Unnamed key";
|
|
18
18
|
const scopes = Array.isArray(body.scopes) ? body.scopes : ["trigger", "read"];
|
|
19
19
|
|
|
20
|
-
const { key, record } = deps.keyStore.create(name, scopes);
|
|
20
|
+
const { key, record } = await deps.keyStore.create(name, scopes);
|
|
21
21
|
return c.json(
|
|
22
22
|
{
|
|
23
23
|
data: {
|
|
@@ -37,7 +37,7 @@ export function v1KeyRoutes(deps: V1KeyDeps) {
|
|
|
37
37
|
if (!deps.keyStore) {
|
|
38
38
|
return c.json({ error: "unavailable", message: "Auth not configured." }, 503);
|
|
39
39
|
}
|
|
40
|
-
const keys = deps.keyStore.list();
|
|
40
|
+
const keys = await deps.keyStore.list();
|
|
41
41
|
return c.json({ data: keys });
|
|
42
42
|
});
|
|
43
43
|
|
|
@@ -46,7 +46,7 @@ export function v1KeyRoutes(deps: V1KeyDeps) {
|
|
|
46
46
|
return c.json({ error: "unavailable", message: "Auth not configured." }, 503);
|
|
47
47
|
}
|
|
48
48
|
const id = c.req.param("id");
|
|
49
|
-
const success = deps.keyStore.revoke(id);
|
|
49
|
+
const success = await deps.keyStore.revoke(id);
|
|
50
50
|
if (!success) {
|
|
51
51
|
return c.json({ error: "not_found", message: "Key not found." }, 404);
|
|
52
52
|
}
|
|
@@ -78,7 +78,7 @@ export function v1RunRoutes(deps: V1RunDeps) {
|
|
|
78
78
|
|
|
79
79
|
app.get("/runs/:id/logs", async (c) => {
|
|
80
80
|
const id = c.req.param("id");
|
|
81
|
-
const logs = deps.logStore
|
|
81
|
+
const logs = deps.logStore ? await deps.logStore.get(id) : deps.logBuffer.get(id);
|
|
82
82
|
return c.json({ data: logs });
|
|
83
83
|
});
|
|
84
84
|
|