station-kit 1.0.9 → 2.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/.next/standalone/packages/station-kit/.next/BUILD_ID +1 -1
- package/.next/standalone/packages/station-kit/.next/app-build-manifest.json +57 -36
- package/.next/standalone/packages/station-kit/.next/app-path-routes-manifest.json +13 -10
- package/.next/standalone/packages/station-kit/.next/build-manifest.json +2 -2
- package/.next/standalone/packages/station-kit/.next/prerender-manifest.json +72 -24
- package/.next/standalone/packages/station-kit/.next/routes-manifest.json +20 -0
- 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 +8 -8
- package/.next/standalone/packages/station-kit/.next/server/app/beacons/[name]/page.js +2 -0
- package/.next/standalone/packages/station-kit/.next/server/app/beacons/[name]/page.js.nft.json +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/beacons/[name]/page_client-reference-manifest.js +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/beacons/page.js +2 -0
- package/.next/standalone/packages/station-kit/.next/server/app/beacons/page.js.nft.json +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/beacons/page_client-reference-manifest.js +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/beacons.html +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/beacons.meta +7 -0
- package/.next/standalone/packages/station-kit/.next/server/app/beacons.rsc +25 -0
- 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_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/dyn/[name]/v/[n]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/new/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/new.html +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/new.rsc +9 -9
- 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 +9 -9
- package/.next/standalone/packages/station-kit/.next/server/app/environment/page.js +2 -0
- package/.next/standalone/packages/station-kit/.next/server/app/environment/page.js.nft.json +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/environment/page_client-reference-manifest.js +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/environment.html +1 -0
- package/.next/standalone/packages/station-kit/.next/server/app/environment.meta +7 -0
- package/.next/standalone/packages/station-kit/.next/server/app/environment.rsc +25 -0
- package/.next/standalone/packages/station-kit/.next/server/app/index.html +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/index.rsc +9 -9
- 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_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/playground/expression.html +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/playground/expression.rsc +9 -9
- 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_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/schedules/new/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/schedules/new.html +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/schedules/new.rsc +9 -9
- package/.next/standalone/packages/station-kit/.next/server/app/schedules/page_client-reference-manifest.js +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/schedules.html +1 -1
- package/.next/standalone/packages/station-kit/.next/server/app/schedules.rsc +9 -9
- 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 +9 -9
- 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_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 +9 -9
- package/.next/standalone/packages/station-kit/.next/server/app-paths-manifest.json +13 -10
- package/.next/standalone/packages/station-kit/.next/server/chunks/102.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-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/chunks/285-583f23ee7827983f.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/561-05a7ef9669e3921a.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/beacons/[name]/page-eb1f82847734e145.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/beacons/page-64e296346197e44b.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/dyn/[name]/v/[n]/page-15edf07ab26241c9.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/page-b23a03097c8f75c4.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/environment/page-111242bec067fdee.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/layout-e9ac56fcaf7a5ea7.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/page-ef78692958c2fc01.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/playground/expression/page-29294a705dd8f471.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/runs/[id]/page-287bbb8f6dec2e23.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/schedules/page-c869218da203e3b4.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/settings/page-05cf43b6ae6b3187.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/signals/[name]/page-4027803be00c695a.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/signals/page-8e076fc5d0188d72.js +1 -0
- package/.next/standalone/packages/station-kit/.next/static/css/ddb1646dc25ed100.css +1 -0
- package/.next/standalone/packages/station-kit/.next/static/vTGEzSqxhR_E57YAFn9DC/_buildManifest.js +1 -0
- package/.next/standalone/packages/station-kit/package.json +6 -4
- package/dist/config/schema.d.ts +31 -3
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +4 -0
- package/dist/config/schema.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/server/auth/keys.d.ts +58 -6
- package/dist/server/auth/keys.d.ts.map +1 -1
- package/dist/server/auth/keys.js +179 -14
- package/dist/server/auth/keys.js.map +1 -1
- package/dist/server/auth/session.d.ts.map +1 -1
- package/dist/server/auth/session.js +34 -11
- package/dist/server/auth/session.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 +169 -24
- 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/metadata.d.ts +2 -0
- package/dist/server/metadata.d.ts.map +1 -1
- package/dist/server/metadata.js.map +1 -1
- package/dist/server/middleware/rate-limit.d.ts.map +1 -1
- package/dist/server/middleware/rate-limit.js +22 -1
- package/dist/server/middleware/rate-limit.js.map +1 -1
- package/dist/server/routes/beacons.d.ts +10 -0
- package/dist/server/routes/beacons.d.ts.map +1 -0
- package/dist/server/routes/beacons.js +98 -0
- package/dist/server/routes/beacons.js.map +1 -0
- package/dist/server/routes/broadcasts.d.ts.map +1 -1
- package/dist/server/routes/broadcasts.js +9 -3
- package/dist/server/routes/broadcasts.js.map +1 -1
- package/dist/server/routes/runs.d.ts.map +1 -1
- package/dist/server/routes/runs.js +20 -60
- package/dist/server/routes/runs.js.map +1 -1
- package/dist/server/routes/signals.d.ts.map +1 -1
- package/dist/server/routes/signals.js +17 -13
- package/dist/server/routes/signals.js.map +1 -1
- package/dist/server/routes/v1/env.d.ts +10 -0
- package/dist/server/routes/v1/env.d.ts.map +1 -0
- package/dist/server/routes/v1/env.js +132 -0
- package/dist/server/routes/v1/env.js.map +1 -0
- package/dist/server/routes/v1/events.js +2 -2
- package/dist/server/routes/v1/events.js.map +1 -1
- package/dist/server/routes/v1/runs.d.ts.map +1 -1
- package/dist/server/routes/v1/runs.js +11 -32
- package/dist/server/routes/v1/runs.js.map +1 -1
- package/dist/server/sse.d.ts +2 -1
- package/dist/server/sse.d.ts.map +1 -1
- package/dist/server/sse.js +5 -1
- package/dist/server/sse.js.map +1 -1
- package/dist/server/subscriber.d.ts +56 -0
- package/dist/server/subscriber.d.ts.map +1 -1
- package/dist/server/subscriber.js +94 -0
- package/dist/server/subscriber.js.map +1 -1
- package/dist/server/ws.d.ts +8 -2
- package/dist/server/ws.d.ts.map +1 -1
- package/dist/server/ws.js +28 -3
- package/dist/server/ws.js.map +1 -1
- package/package.json +13 -11
- package/src/app/beacons/[name]/beacon-detail.tsx +253 -0
- package/src/app/beacons/[name]/page.tsx +6 -0
- package/src/app/beacons/page.tsx +98 -0
- package/src/app/components/shell.tsx +24 -0
- package/src/app/components/status-badge.tsx +13 -1
- package/src/app/environment/page.tsx +415 -0
- package/src/app/globals.css +48 -0
- package/src/app/hooks/use-api.ts +80 -0
- package/src/config/schema.ts +35 -3
- package/src/index.ts +12 -0
- package/src/server/auth/keys.ts +215 -15
- package/src/server/auth/session.ts +38 -11
- package/src/server/index.ts +192 -23
- package/src/server/log-store.ts +196 -45
- package/src/server/metadata.ts +2 -0
- package/src/server/middleware/rate-limit.ts +20 -1
- package/src/server/routes/beacons.ts +112 -0
- package/src/server/routes/broadcasts.ts +9 -3
- package/src/server/routes/runs.ts +21 -68
- package/src/server/routes/signals.ts +17 -13
- package/src/server/routes/v1/env.ts +144 -0
- package/src/server/routes/v1/events.ts +2 -2
- package/src/server/routes/v1/runs.ts +12 -36
- package/src/server/sse.ts +7 -2
- package/src/server/subscriber.ts +111 -0
- package/src/server/ws.ts +38 -4
- package/.next/standalone/packages/station-kit/.next/static/chunks/285-ff198f0a909c4fdd.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/561-33d912169940283e.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/dyn/[name]/v/[n]/page-5eac0507f49a00ec.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/page-dee500ccc01f0821.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/layout-e14e14f3e5b0b8a9.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/page-aac41ef7a470daab.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/playground/expression/page-dc9d91f3f50f4716.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/runs/[id]/page-9e4c4f751a4bea72.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/schedules/page-738d98dc0b63166e.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/settings/page-fc5654b31f57ac21.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/signals/[name]/page-4b1c09a539a1ebcd.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/chunks/app/signals/page-d2f2403dfede87cc.js +0 -1
- package/.next/standalone/packages/station-kit/.next/static/css/0be0e65a5f561f37.css +0 -1
- package/.next/standalone/packages/station-kit/.next/static/demLiQWDy62JuUkBw-ILG/_buildManifest.js +0 -1
- /package/.next/standalone/packages/station-kit/.next/static/{demLiQWDy62JuUkBw-ILG → vTGEzSqxhR_E57YAFn9DC}/_ssgManifest.js +0 -0
package/src/server/index.ts
CHANGED
|
@@ -1,30 +1,36 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
2
|
import { createMiddleware } from "hono/factory";
|
|
3
|
+
import { bodyLimit } from "hono/body-limit";
|
|
3
4
|
import { serve } from "@hono/node-server";
|
|
4
5
|
import { resolve } from "node:path";
|
|
5
|
-
import { existsSync } from "node:fs";
|
|
6
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { randomBytes } from "node:crypto";
|
|
6
8
|
import type { Server } from "node:http";
|
|
7
9
|
import { SignalRunner, MemoryAdapter, parseInterval } from "station-signal";
|
|
8
10
|
import { BroadcastRunner, BroadcastMemoryAdapter } from "station-broadcast";
|
|
11
|
+
import { BeaconRunner, BeaconMemoryAdapter } from "station-beacon";
|
|
9
12
|
import type { SignalQueueAdapter } from "station-signal";
|
|
10
13
|
import type { BroadcastQueueAdapter } from "station-broadcast";
|
|
14
|
+
import type { BeaconStateAdapter } from "station-beacon";
|
|
11
15
|
import {
|
|
12
16
|
ScheduleReconciler,
|
|
13
17
|
type Schedule,
|
|
14
18
|
type ScheduleAdapter,
|
|
15
19
|
} from "station-schedules";
|
|
20
|
+
import { EnvStore, FileEnvStorage } from "station-env";
|
|
16
21
|
import type { StationConfig } from "../config/schema.js";
|
|
17
22
|
import { ensureStationDir } from "../station-dir.js";
|
|
18
23
|
import { WebSocketHub } from "./ws.js";
|
|
19
24
|
import { SSEHub } from "./sse.js";
|
|
20
25
|
import { LogBuffer } from "./log-buffer.js";
|
|
21
|
-
import { LogStore } from "./log-store.js";
|
|
22
|
-
import { StationSignalSubscriber, StationBroadcastSubscriber } from "./subscriber.js";
|
|
26
|
+
import { LogStore, FileLogStorage } from "./log-store.js";
|
|
27
|
+
import { StationSignalSubscriber, StationBroadcastSubscriber, StationBeaconSubscriber } from "./subscriber.js";
|
|
23
28
|
import { healthRoutes } from "./routes/health.js";
|
|
24
29
|
import { signalRoutes } from "./routes/signals.js";
|
|
25
30
|
import { runRoutes } from "./routes/runs.js";
|
|
26
31
|
import { broadcastRoutes } from "./routes/broadcasts.js";
|
|
27
|
-
import {
|
|
32
|
+
import { beaconRoutes } from "./routes/beacons.js";
|
|
33
|
+
import { KeyStore, FileKeyStorage } from "./auth/keys.js";
|
|
28
34
|
import { verifySessionToken, verifyCredentials, createSessionToken, type SessionConfig } from "./auth/session.js";
|
|
29
35
|
import { authResolver } from "./middleware/auth.js";
|
|
30
36
|
import { requireScope } from "./middleware/scope-guard.js";
|
|
@@ -39,10 +45,12 @@ import { v1AuthRoutes } from "./routes/v1/auth.js";
|
|
|
39
45
|
import { v1EventRoutes } from "./routes/v1/events.js";
|
|
40
46
|
import { v1DefinitionRoutes, v1DefinitionReadRoutes } from "./routes/v1/definitions.js";
|
|
41
47
|
import { v1ScheduleRoutes, v1ScheduleReadRoutes } from "./routes/v1/schedules.js";
|
|
48
|
+
import { v1EnvRoutes, v1EnvReadRoutes } from "./routes/v1/env.js";
|
|
42
49
|
import { v1ExpressionRoutes } from "./routes/v1/expressions.js";
|
|
43
50
|
|
|
44
51
|
export {
|
|
45
52
|
KeyStore,
|
|
53
|
+
FileKeyStorage,
|
|
46
54
|
SqliteKeyStorage,
|
|
47
55
|
MemoryKeyStorage,
|
|
48
56
|
} from "./auth/keys.js";
|
|
@@ -50,8 +58,19 @@ export type {
|
|
|
50
58
|
ApiKey,
|
|
51
59
|
ApiKeyPublic,
|
|
52
60
|
ApiKeyStorageAdapter,
|
|
61
|
+
FileKeyStorageOptions,
|
|
53
62
|
SqliteKeyStorageOptions,
|
|
54
63
|
} from "./auth/keys.js";
|
|
64
|
+
export {
|
|
65
|
+
LogStore,
|
|
66
|
+
FileLogStorage,
|
|
67
|
+
MemoryLogStorage,
|
|
68
|
+
} from "./log-store.js";
|
|
69
|
+
export type {
|
|
70
|
+
LogStorageAdapter,
|
|
71
|
+
FileLogStorageOptions,
|
|
72
|
+
} from "./log-store.js";
|
|
73
|
+
export type { LogEntry } from "./log-buffer.js";
|
|
55
74
|
|
|
56
75
|
export interface StationInstance {
|
|
57
76
|
start(): Promise<void>;
|
|
@@ -66,13 +85,29 @@ export async function createStation(config: StationConfig, cwd: string, nextPort
|
|
|
66
85
|
const signalAdapter: SignalQueueAdapter = config.adapter ?? new MemoryAdapter();
|
|
67
86
|
const broadcastAdapter: BroadcastQueueAdapter | undefined =
|
|
68
87
|
config.broadcastAdapter ?? (config.broadcastsDir ? new BroadcastMemoryAdapter() : undefined);
|
|
88
|
+
const beaconAdapter: BeaconStateAdapter | undefined =
|
|
89
|
+
config.beaconAdapter ?? (config.beaconsDir ? new BeaconMemoryAdapter() : undefined);
|
|
69
90
|
|
|
70
91
|
const { dataDir } = ensureStationDir(cwd, config.stationDir);
|
|
71
92
|
|
|
93
|
+
warnIfLegacySqliteFiles(dataDir);
|
|
94
|
+
|
|
72
95
|
const wsHub = new WebSocketHub();
|
|
73
96
|
const sseHub = new SSEHub();
|
|
74
97
|
const logBuffer = new LogBuffer();
|
|
75
|
-
const logStore = new LogStore(
|
|
98
|
+
const logStore = new LogStore(
|
|
99
|
+
config.logStorage ?? new FileLogStorage({
|
|
100
|
+
filePath: resolve(dataDir, "station-logs.jsonl"),
|
|
101
|
+
onError: (err) => console.error("[station] log write failed:", err),
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
// Runtime env store: defaults to a JSON file so `.env()` requirements and
|
|
106
|
+
// dashboard-defined variables work out of the box. Pass a durable adapter
|
|
107
|
+
// via `envStorage` for multi-process deployments.
|
|
108
|
+
const envStore = new EnvStore(
|
|
109
|
+
config.envStorage ?? new FileEnvStorage({ filePath: resolve(dataDir, "station-env.json") }),
|
|
110
|
+
);
|
|
76
111
|
|
|
77
112
|
// Auth: create KeyStore and SessionConfig if auth is configured
|
|
78
113
|
let keyStore: KeyStore | undefined;
|
|
@@ -80,8 +115,8 @@ export async function createStation(config: StationConfig, cwd: string, nextPort
|
|
|
80
115
|
|
|
81
116
|
if (config.auth) {
|
|
82
117
|
const storage = config.auth.keyStorage
|
|
83
|
-
?? new
|
|
84
|
-
keyStore = new KeyStore(storage);
|
|
118
|
+
?? new FileKeyStorage({ filePath: resolve(dataDir, "station-keys.json") });
|
|
119
|
+
keyStore = new KeyStore(storage, { pepper: resolveKeyPepper(dataDir) });
|
|
85
120
|
sessionConfig = {
|
|
86
121
|
username: config.auth.username,
|
|
87
122
|
password: config.auth.password,
|
|
@@ -102,17 +137,26 @@ export async function createStation(config: StationConfig, cwd: string, nextPort
|
|
|
102
137
|
? resolve(cwd, "broadcasts")
|
|
103
138
|
: undefined;
|
|
104
139
|
|
|
140
|
+
const beaconsDir = config.beaconsDir
|
|
141
|
+
? resolve(cwd, config.beaconsDir)
|
|
142
|
+
: existsSync(resolve(cwd, "beacons"))
|
|
143
|
+
? resolve(cwd, "beacons")
|
|
144
|
+
: undefined;
|
|
145
|
+
|
|
105
146
|
// Create subscribers (always — they collect metadata)
|
|
106
147
|
const stationSignalSub = new StationSignalSubscriber(wsHub, logBuffer, logStore);
|
|
107
148
|
const stationBroadcastSub = new StationBroadcastSubscriber(wsHub);
|
|
149
|
+
const stationBeaconSub = new StationBeaconSubscriber(wsHub, logBuffer, logStore);
|
|
108
150
|
|
|
109
151
|
// Wire SSE hub into subscribers so events reach both WS and SSE clients
|
|
110
152
|
stationSignalSub.setSSEHub(sseHub);
|
|
111
153
|
stationBroadcastSub.setSSEHub(sseHub);
|
|
154
|
+
stationBeaconSub.setSSEHub(sseHub);
|
|
112
155
|
|
|
113
156
|
// Create runners if enabled
|
|
114
157
|
let signalRunner: SignalRunner | undefined;
|
|
115
158
|
let broadcastRunner: BroadcastRunner | undefined;
|
|
159
|
+
let beaconRunner: BeaconRunner | undefined;
|
|
116
160
|
const scheduleAdapter: ScheduleAdapter | undefined = config.scheduleAdapter;
|
|
117
161
|
|
|
118
162
|
if (config.runRunners) {
|
|
@@ -139,6 +183,7 @@ export async function createStation(config: StationConfig, cwd: string, nextPort
|
|
|
139
183
|
retryBackoffMs: config.runner.retryBackoffMs,
|
|
140
184
|
subscribers: [stationSignalSub],
|
|
141
185
|
scheduleReconciler: signalScheduleReconciler,
|
|
186
|
+
envProvider: envStore,
|
|
142
187
|
});
|
|
143
188
|
|
|
144
189
|
if (broadcastsDir || broadcastAdapter) {
|
|
@@ -163,11 +208,30 @@ export async function createStation(config: StationConfig, cwd: string, nextPort
|
|
|
163
208
|
scheduleReconciler: broadcastScheduleReconciler,
|
|
164
209
|
});
|
|
165
210
|
}
|
|
211
|
+
|
|
212
|
+
if (beaconsDir || beaconAdapter) {
|
|
213
|
+
beaconRunner = new BeaconRunner({
|
|
214
|
+
beaconsDir,
|
|
215
|
+
adapter: beaconAdapter ?? new BeaconMemoryAdapter(),
|
|
216
|
+
signalRunner, // beacons can trigger signals into the shared queue
|
|
217
|
+
subscribers: [stationBeaconSub],
|
|
218
|
+
envProvider: envStore,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
166
221
|
}
|
|
167
222
|
|
|
168
223
|
// Build Hono app
|
|
169
224
|
const app = new Hono();
|
|
170
225
|
|
|
226
|
+
// Bound request bodies so oversized payloads can't be parsed/stored.
|
|
227
|
+
app.use("/api/*", bodyLimit({
|
|
228
|
+
maxSize: 5 * 1024 * 1024,
|
|
229
|
+
onError: (c) => c.json({ error: "payload_too_large", message: "Request body too large." }, 413),
|
|
230
|
+
}));
|
|
231
|
+
|
|
232
|
+
// Brute-force protection for the dashboard login (the v1 login is limited below).
|
|
233
|
+
app.use("/api/auth/login", rateLimiter({ windowMs: 60_000, max: 10 }));
|
|
234
|
+
|
|
171
235
|
// ── Dashboard auth routes (always accessible) ──────────────────────
|
|
172
236
|
app.get("/api/auth/check", async (c) => {
|
|
173
237
|
if (!sessionConfig) {
|
|
@@ -229,15 +293,18 @@ export async function createStation(config: StationConfig, cwd: string, nextPort
|
|
|
229
293
|
app.route("/api", signalRoutes({ signalRunner, signalAdapter, signalSubscriber: stationSignalSub }));
|
|
230
294
|
app.route("/api", runRoutes({ signalRunner, signalAdapter, logBuffer, logStore, signalSubscriber: stationSignalSub }));
|
|
231
295
|
app.route("/api", broadcastRoutes({ broadcastRunner, broadcastAdapter, broadcastSubscriber: stationBroadcastSub, logBuffer, logStore }));
|
|
296
|
+
app.route("/api", beaconRoutes({ beaconRunner, beaconAdapter, logBuffer, logStore }));
|
|
232
297
|
|
|
233
298
|
// ── v1 API routes (authenticated) ──────────────────────────────────
|
|
234
299
|
|
|
235
300
|
// Public v1 routes (no auth required)
|
|
236
301
|
app.route("/api/v1", v1HealthRoutes({ signalAdapter, broadcastAdapter }));
|
|
237
302
|
|
|
238
|
-
// Auth routes: public but rate-limited to prevent brute force
|
|
303
|
+
// Auth routes: public but rate-limited to prevent brute force. The limiter
|
|
304
|
+
// is scoped to /auth/* — a "/*" limiter here would run for every /api/v1
|
|
305
|
+
// route mounted after this app and throttle the whole API to 10 req/min.
|
|
239
306
|
const authApp = new Hono();
|
|
240
|
-
authApp.use("/*", rateLimiter({ windowMs: 60_000, max: 10 }));
|
|
307
|
+
authApp.use("/auth/*", rateLimiter({ windowMs: 60_000, max: 10 }));
|
|
241
308
|
authApp.route("/", v1AuthRoutes({ sessionConfig }));
|
|
242
309
|
app.route("/api/v1", authApp);
|
|
243
310
|
|
|
@@ -245,9 +312,22 @@ export async function createStation(config: StationConfig, cwd: string, nextPort
|
|
|
245
312
|
const v1 = new Hono();
|
|
246
313
|
v1.use("/*", authResolver({ keyStore, sessionConfig }));
|
|
247
314
|
|
|
315
|
+
// Hono runs a sub-app's `use("/*")` middleware for routes that sibling
|
|
316
|
+
// sub-apps mount LATER under the same prefix, so per-group scope guards
|
|
317
|
+
// stack instead of isolating (a trigger-only key would be rejected by the
|
|
318
|
+
// read group's guard before reaching /trigger). Attach the guard to each
|
|
319
|
+
// route individually instead.
|
|
320
|
+
const guarded = (scope: string, group: Hono): Hono => {
|
|
321
|
+
const out = new Hono();
|
|
322
|
+
const guard = requireScope(scope);
|
|
323
|
+
for (const r of group.routes) {
|
|
324
|
+
out.on(r.method, r.path, guard, r.handler);
|
|
325
|
+
}
|
|
326
|
+
return out;
|
|
327
|
+
};
|
|
328
|
+
|
|
248
329
|
// Read-scope routes
|
|
249
330
|
const readRoutes = new Hono();
|
|
250
|
-
readRoutes.use("/*", requireScope("read"));
|
|
251
331
|
readRoutes.route("/", v1SignalRoutes({ signalRunner, signalSubscriber: stationSignalSub }));
|
|
252
332
|
readRoutes.route("/", v1RunRoutes({ signalRunner, signalAdapter, logBuffer, logStore }));
|
|
253
333
|
readRoutes.route("/", v1BroadcastRoutes({ broadcastRunner, broadcastAdapter, broadcastSubscriber: stationBroadcastSub }));
|
|
@@ -255,23 +335,21 @@ export async function createStation(config: StationConfig, cwd: string, nextPort
|
|
|
255
335
|
readRoutes.route("/", v1ExpressionRoutes());
|
|
256
336
|
// Schedule GET + preview are read-scoped; mutating routes are mounted under admin below.
|
|
257
337
|
readRoutes.route("/", v1ScheduleReadRoutes({ scheduleAdapter }));
|
|
338
|
+
// Env GET is read-scoped (secret values redacted); mutations are admin-only below.
|
|
339
|
+
readRoutes.route("/", v1EnvReadRoutes({ envStore }));
|
|
258
340
|
readRoutes.route("/", v1DefinitionReadRoutes({
|
|
259
341
|
broadcastRunner,
|
|
260
342
|
broadcastAdapter,
|
|
261
343
|
signalRunner,
|
|
262
344
|
signalSubscriber: stationSignalSub,
|
|
263
345
|
}));
|
|
264
|
-
v1.route("/", readRoutes);
|
|
346
|
+
v1.route("/", guarded("read", readRoutes));
|
|
265
347
|
|
|
266
348
|
// Trigger-scope routes
|
|
267
|
-
|
|
268
|
-
triggerRoutes.use("/*", requireScope("trigger"));
|
|
269
|
-
triggerRoutes.route("/", v1TriggerRoutes({ signalRunner, signalAdapter, broadcastRunner, broadcastAdapter, signalSubscriber: stationSignalSub }));
|
|
270
|
-
v1.route("/", triggerRoutes);
|
|
349
|
+
v1.route("/", guarded("trigger", v1TriggerRoutes({ signalRunner, signalAdapter, broadcastRunner, broadcastAdapter, signalSubscriber: stationSignalSub })));
|
|
271
350
|
|
|
272
351
|
// Cancel-scope routes — only the cancel endpoints
|
|
273
352
|
const cancelRoutes = new Hono();
|
|
274
|
-
cancelRoutes.use("/*", requireScope("cancel"));
|
|
275
353
|
cancelRoutes.post("/runs/:id/cancel", async (c) => {
|
|
276
354
|
const id = c.req.param("id");
|
|
277
355
|
if (!signalRunner) {
|
|
@@ -294,11 +372,10 @@ export async function createStation(config: StationConfig, cwd: string, nextPort
|
|
|
294
372
|
}
|
|
295
373
|
return c.json({ data: { cancelled: true } });
|
|
296
374
|
});
|
|
297
|
-
v1.route("/", cancelRoutes);
|
|
375
|
+
v1.route("/", guarded("cancel", cancelRoutes));
|
|
298
376
|
|
|
299
377
|
// Admin-scope routes — destructive / mutating endpoints
|
|
300
378
|
const adminRoutes = new Hono();
|
|
301
|
-
adminRoutes.use("/*", requireScope("admin"));
|
|
302
379
|
adminRoutes.route("/", v1KeyRoutes({ keyStore }));
|
|
303
380
|
adminRoutes.route("/", v1DefinitionRoutes({
|
|
304
381
|
broadcastRunner,
|
|
@@ -307,7 +384,8 @@ export async function createStation(config: StationConfig, cwd: string, nextPort
|
|
|
307
384
|
signalSubscriber: stationSignalSub,
|
|
308
385
|
}));
|
|
309
386
|
adminRoutes.route("/", v1ScheduleRoutes({ scheduleAdapter }));
|
|
310
|
-
|
|
387
|
+
adminRoutes.route("/", v1EnvRoutes({ envStore }));
|
|
388
|
+
v1.route("/", guarded("admin", adminRoutes));
|
|
311
389
|
|
|
312
390
|
app.route("/api/v1", v1);
|
|
313
391
|
|
|
@@ -365,6 +443,14 @@ export async function createStation(config: StationConfig, cwd: string, nextPort
|
|
|
365
443
|
keyStore,
|
|
366
444
|
dataDir,
|
|
367
445
|
async start() {
|
|
446
|
+
if (!config.auth && !isLoopbackHost(config.host)) {
|
|
447
|
+
console.warn(
|
|
448
|
+
`[station] WARNING: no auth configured while binding to ${config.host} — ` +
|
|
449
|
+
"anyone who can reach this port can view logs and trigger, cancel, or rerun jobs. " +
|
|
450
|
+
"Set auth (STATION_AUTH_USERNAME/STATION_AUTH_PASSWORD) or bind to 127.0.0.1.",
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
|
|
368
454
|
// Start runners (non-blocking — they have internal poll loops)
|
|
369
455
|
if (config.runRunners) {
|
|
370
456
|
if (signalRunner) {
|
|
@@ -377,6 +463,11 @@ export async function createStation(config: StationConfig, cwd: string, nextPort
|
|
|
377
463
|
console.error("[station] Broadcast runner error:", err);
|
|
378
464
|
});
|
|
379
465
|
}
|
|
466
|
+
if (beaconRunner) {
|
|
467
|
+
beaconRunner.start().catch((err: unknown) => {
|
|
468
|
+
console.error("[station] Beacon runner error:", err);
|
|
469
|
+
});
|
|
470
|
+
}
|
|
380
471
|
}
|
|
381
472
|
|
|
382
473
|
// Start Hono server
|
|
@@ -387,12 +478,31 @@ export async function createStation(config: StationConfig, cwd: string, nextPort
|
|
|
387
478
|
},
|
|
388
479
|
) as unknown as Server;
|
|
389
480
|
|
|
390
|
-
// Attach WebSocket to the HTTP server
|
|
391
|
-
|
|
481
|
+
// Attach WebSocket to the HTTP server. The upgrade path never passes
|
|
482
|
+
// through Hono middleware, so it enforces auth itself: session cookie
|
|
483
|
+
// or a read-scoped API key, mirroring the SSE endpoint.
|
|
484
|
+
wsHub.attach(httpServer, async (req) => {
|
|
485
|
+
if (!sessionConfig && !keyStore) return true;
|
|
486
|
+
const cookie = req.headers.cookie;
|
|
487
|
+
if (cookie && sessionConfig) {
|
|
488
|
+
const match = cookie.match(/station_session=([^;]+)/);
|
|
489
|
+
if (match && verifySessionToken(match[1], sessionConfig)) return true;
|
|
490
|
+
}
|
|
491
|
+
const authHeader = req.headers.authorization;
|
|
492
|
+
if (authHeader?.startsWith("Bearer ") && keyStore) {
|
|
493
|
+
const key = await keyStore.verify(authHeader.slice(7));
|
|
494
|
+
if (key?.scopes.includes("read")) return true;
|
|
495
|
+
}
|
|
496
|
+
return false;
|
|
497
|
+
});
|
|
392
498
|
},
|
|
393
499
|
|
|
394
500
|
async stop() {
|
|
395
|
-
// Stop
|
|
501
|
+
// Stop beacons first — they are producers that may trigger signals.
|
|
502
|
+
if (beaconRunner) {
|
|
503
|
+
await beaconRunner.stop({ graceful: true, timeoutMs: 5000 });
|
|
504
|
+
}
|
|
505
|
+
// Stop broadcast runner next — it queries the DB during graceful shutdown
|
|
396
506
|
if (broadcastRunner) {
|
|
397
507
|
await broadcastRunner.stop({ graceful: true, timeoutMs: 5000 });
|
|
398
508
|
}
|
|
@@ -401,11 +511,70 @@ export async function createStation(config: StationConfig, cwd: string, nextPort
|
|
|
401
511
|
}
|
|
402
512
|
wsHub.close();
|
|
403
513
|
sseHub.close();
|
|
404
|
-
logStore.close();
|
|
514
|
+
await logStore.close();
|
|
405
515
|
await keyStore?.close();
|
|
516
|
+
await envStore.close();
|
|
406
517
|
if (httpServer) {
|
|
407
518
|
httpServer.close();
|
|
408
519
|
}
|
|
409
520
|
},
|
|
410
521
|
};
|
|
411
522
|
}
|
|
523
|
+
|
|
524
|
+
// Existing deployments that ran older Station versions persisted keys
|
|
525
|
+
// to `station-keys.db` (SQLite) and run logs to `station-logs.db`. The
|
|
526
|
+
// new defaults are `station-keys.json` and `station-logs.jsonl`; the
|
|
527
|
+
// legacy files are NOT auto-migrated. Emit a one-time warning so an
|
|
528
|
+
// upgrade doesn't silently appear to wipe a user's API keys.
|
|
529
|
+
function isLoopbackHost(host: string): boolean {
|
|
530
|
+
return host === "127.0.0.1" || host === "localhost" || host === "::1" || host === "[::1]";
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Resolve the API-key pepper (a server-side secret mixed into stored key
|
|
535
|
+
* hashes). Priority: STATION_KEY_PEPPER env var, else a persisted per-install
|
|
536
|
+
* secret at `<dataDir>/station-key-pepper` (generated with 0600 on first run).
|
|
537
|
+
* Persisting it means existing peppered keys keep verifying across restarts.
|
|
538
|
+
*/
|
|
539
|
+
function resolveKeyPepper(dataDir: string): string {
|
|
540
|
+
const fromEnv = process.env.STATION_KEY_PEPPER;
|
|
541
|
+
if (fromEnv) return fromEnv;
|
|
542
|
+
|
|
543
|
+
const pepperPath = resolve(dataDir, "station-key-pepper");
|
|
544
|
+
try {
|
|
545
|
+
if (existsSync(pepperPath)) {
|
|
546
|
+
const existing = readFileSync(pepperPath, "utf8").trim();
|
|
547
|
+
if (existing) return existing;
|
|
548
|
+
}
|
|
549
|
+
} catch {
|
|
550
|
+
// Unreadable — fall through and regenerate.
|
|
551
|
+
}
|
|
552
|
+
const pepper = randomBytes(32).toString("hex");
|
|
553
|
+
try {
|
|
554
|
+
writeFileSync(pepperPath, pepper, { mode: 0o600 });
|
|
555
|
+
} catch (err) {
|
|
556
|
+
// If we can't persist it, warn: keys created this run won't verify after
|
|
557
|
+
// a restart (a new pepper would be generated). Better than failing boot.
|
|
558
|
+
console.warn(`[station] Could not persist key pepper to ${pepperPath}:`, err);
|
|
559
|
+
}
|
|
560
|
+
return pepper;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function warnIfLegacySqliteFiles(dataDir: string): void {
|
|
564
|
+
const legacy: { file: string; replacement: string }[] = [
|
|
565
|
+
{ file: "station-keys.db", replacement: "station-keys.json" },
|
|
566
|
+
{ file: "station-logs.db", replacement: "station-logs.jsonl" },
|
|
567
|
+
];
|
|
568
|
+
for (const { file, replacement } of legacy) {
|
|
569
|
+
const legacyPath = resolve(dataDir, file);
|
|
570
|
+
if (!existsSync(legacyPath)) continue;
|
|
571
|
+
const replacementPath = resolve(dataDir, replacement);
|
|
572
|
+
if (existsSync(replacementPath)) continue;
|
|
573
|
+
console.warn(
|
|
574
|
+
`[station] Legacy ${file} detected at ${legacyPath} but no ${replacement} found. ` +
|
|
575
|
+
`Station no longer reads SQLite-backed defaults; data in ${file} will not be loaded. ` +
|
|
576
|
+
`If you need the contents, export them with the better-sqlite3 CLI before upgrading. ` +
|
|
577
|
+
`To suppress this warning, delete or rename ${file}.`,
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
}
|
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
|
}
|
package/src/server/metadata.ts
CHANGED