4runr-os 2.10.72 → 2.10.73
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/apps/gateway/package-lock.json +110 -110
- package/apps/gateway/scripts/verify-sentinel-persist.mjs +81 -81
- package/apps/gateway/src/__tests__/integration/sentinel.test.ts +79 -0
- package/apps/gateway/src/__tests__/sentinel-config-persist-http.test.ts +93 -93
- package/apps/gateway/src/__tests__/sentinel-config-store.test.ts +133 -133
- package/apps/gateway/src/security/sentinel-config-store.ts +208 -208
- package/dist/tui-handlers.js +57 -67
- package/dist/tui-handlers.js.map +1 -1
- package/dist/watchdog.d.ts +5 -0
- package/dist/watchdog.d.ts.map +1 -1
- package/dist/watchdog.js +82 -75
- package/dist/watchdog.js.map +1 -1
- package/mk3-tui/src/main.rs +7 -0
- package/package.json +2 -2
- package/scripts/os-tools-smoke.cjs +1 -1
|
@@ -1,208 +1,208 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Persist operator Sentinel limits to disk (survives Gateway restart).
|
|
3
|
-
* File: {4Runr data dir}/config/sentinel-limits.json
|
|
4
|
-
*
|
|
5
|
-
* Boot order: env defaults (packages/sentinel) → overlay saved file when present.
|
|
6
|
-
* Explicit env vars for a field are not overwritten by the saved file.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import * as fs from 'fs';
|
|
10
|
-
import * as path from 'path';
|
|
11
|
-
import { get4RunrConfigDir } from '@4runr/shared';
|
|
12
|
-
import { Sentinel, type SentinelConfig } from '@4runr/sentinel';
|
|
13
|
-
|
|
14
|
-
const FILE_NAME = 'sentinel-limits.json';
|
|
15
|
-
|
|
16
|
-
const ENV_FIELD_KEYS: Array<{
|
|
17
|
-
field: keyof SentinelConfig;
|
|
18
|
-
envKeys: string[];
|
|
19
|
-
}> = [
|
|
20
|
-
{ field: 'enabled', envKeys: ['SENTINEL_ENABLED'] },
|
|
21
|
-
{ field: 'runMaxDurationMs', envKeys: ['RUN_MAX_DURATION_MS'] },
|
|
22
|
-
{ field: 'runMaxTokens', envKeys: ['RUN_MAX_TOKENS'] },
|
|
23
|
-
{ field: 'runIdleMs', envKeys: ['SENTINEL_IDLE_MS', 'RUN_IDLE_MS'] },
|
|
24
|
-
{ field: 'loopWindow', envKeys: ['SENTINEL_LOOP_WINDOW'] },
|
|
25
|
-
{ field: 'loopMax', envKeys: ['SENTINEL_LOOP_MAX'] },
|
|
26
|
-
{ field: 'runMaxCost', envKeys: ['RUN_MAX_COST', 'SENTINEL_MAX_COST'] },
|
|
27
|
-
];
|
|
28
|
-
|
|
29
|
-
export function getSentinelLimitsFilePath(): string {
|
|
30
|
-
return path.join(get4RunrConfigDir(), FILE_NAME);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function envDefinesField(field: keyof SentinelConfig): boolean {
|
|
34
|
-
const entry = ENV_FIELD_KEYS.find((e) => e.field === field);
|
|
35
|
-
if (!entry) return false;
|
|
36
|
-
return entry.envKeys.some((k) => {
|
|
37
|
-
const v = process.env[k];
|
|
38
|
-
return v !== undefined && v.trim() !== '';
|
|
39
|
-
});
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function parseSavedPayload(raw: unknown): SentinelConfig | null {
|
|
43
|
-
if (!raw || typeof raw !== 'object') return null;
|
|
44
|
-
const o = raw as Record<string, unknown>;
|
|
45
|
-
const num = (key: string): number | undefined => {
|
|
46
|
-
const v = o[key];
|
|
47
|
-
if (typeof v === 'number' && Number.isFinite(v)) return v;
|
|
48
|
-
return undefined;
|
|
49
|
-
};
|
|
50
|
-
const enabled = o['enabled'];
|
|
51
|
-
if (typeof enabled !== 'boolean') return null;
|
|
52
|
-
const runMaxDurationMs = num('runMaxDurationMs');
|
|
53
|
-
const runMaxTokens = num('runMaxTokens');
|
|
54
|
-
const runIdleMs = num('runIdleMs');
|
|
55
|
-
const loopWindow = num('loopWindow');
|
|
56
|
-
const loopMax = num('loopMax');
|
|
57
|
-
if (
|
|
58
|
-
runMaxDurationMs === undefined ||
|
|
59
|
-
runMaxTokens === undefined ||
|
|
60
|
-
runIdleMs === undefined ||
|
|
61
|
-
loopWindow === undefined ||
|
|
62
|
-
loopMax === undefined
|
|
63
|
-
) {
|
|
64
|
-
return null;
|
|
65
|
-
}
|
|
66
|
-
const runMaxCost = num('runMaxCost');
|
|
67
|
-
return {
|
|
68
|
-
enabled,
|
|
69
|
-
runMaxDurationMs,
|
|
70
|
-
runMaxTokens,
|
|
71
|
-
runIdleMs,
|
|
72
|
-
loopWindow,
|
|
73
|
-
loopMax,
|
|
74
|
-
...(runMaxCost !== undefined ? { runMaxCost } : {}),
|
|
75
|
-
};
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export function loadSentinelLimitsFromDisk(): SentinelConfig | null {
|
|
79
|
-
const filePath = getSentinelLimitsFilePath();
|
|
80
|
-
try {
|
|
81
|
-
if (!fs.existsSync(filePath)) return null;
|
|
82
|
-
const text = fs.readFileSync(filePath, 'utf8');
|
|
83
|
-
const parsed = JSON.parse(text) as unknown;
|
|
84
|
-
const inner =
|
|
85
|
-
parsed && typeof parsed === 'object' && 'config' in (parsed as object)
|
|
86
|
-
? (parsed as { config: unknown }).config
|
|
87
|
-
: parsed;
|
|
88
|
-
return parseSavedPayload(inner);
|
|
89
|
-
} catch {
|
|
90
|
-
return null;
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
export function saveSentinelLimitsToDisk(config: SentinelConfig): void {
|
|
95
|
-
const filePath = getSentinelLimitsFilePath();
|
|
96
|
-
const payload = {
|
|
97
|
-
version: 1,
|
|
98
|
-
updatedAt: new Date().toISOString(),
|
|
99
|
-
config: {
|
|
100
|
-
enabled: config.enabled,
|
|
101
|
-
runMaxDurationMs: config.runMaxDurationMs,
|
|
102
|
-
runMaxTokens: config.runMaxTokens,
|
|
103
|
-
runIdleMs: config.runIdleMs,
|
|
104
|
-
loopWindow: config.loopWindow,
|
|
105
|
-
loopMax: config.loopMax,
|
|
106
|
-
runMaxCost: config.runMaxCost ?? 1.0,
|
|
107
|
-
},
|
|
108
|
-
};
|
|
109
|
-
fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), { mode: 0o600 });
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/** Merge saved limits from disk into the live Sentinel singleton (env wins per field). */
|
|
113
|
-
export function mergePersistedSentinelConfig(sentinel: Sentinel): boolean {
|
|
114
|
-
const saved = loadSentinelLimitsFromDisk();
|
|
115
|
-
if (!saved) return false;
|
|
116
|
-
|
|
117
|
-
const current = sentinel.getConfig();
|
|
118
|
-
const merged: SentinelConfig = { ...current };
|
|
119
|
-
|
|
120
|
-
for (const { field } of ENV_FIELD_KEYS) {
|
|
121
|
-
if (envDefinesField(field)) continue;
|
|
122
|
-
(merged as any)[field] = saved[field];
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
try {
|
|
126
|
-
sentinel.updateConfig(merged);
|
|
127
|
-
return true;
|
|
128
|
-
} catch {
|
|
129
|
-
return false;
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* Apply saved limits on Gateway boot. Env-defined fields keep env values.
|
|
135
|
-
*/
|
|
136
|
-
export function applyPersistedSentinelConfig(sentinel: Sentinel, logger?: {
|
|
137
|
-
info: (msg: string, meta?: Record<string, unknown>) => void;
|
|
138
|
-
warn: (msg: string, meta?: Record<string, unknown>) => void;
|
|
139
|
-
}): boolean {
|
|
140
|
-
const filePath = getSentinelLimitsFilePath();
|
|
141
|
-
const saved = loadSentinelLimitsFromDisk();
|
|
142
|
-
|
|
143
|
-
if (!saved) {
|
|
144
|
-
logger?.info('No Sentinel limits file found on disk (will use env defaults)', {
|
|
145
|
-
expectedPath: filePath,
|
|
146
|
-
});
|
|
147
|
-
return false;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// Check which env vars are set and will override the disk values
|
|
151
|
-
const envOverrides: string[] = [];
|
|
152
|
-
const diskApplied: string[] = [];
|
|
153
|
-
|
|
154
|
-
for (const { field, envKeys } of ENV_FIELD_KEYS) {
|
|
155
|
-
if (envDefinesField(field)) {
|
|
156
|
-
const activeEnv = envKeys.find(k => process.env[k] !== undefined && process.env[k]?.trim() !== '');
|
|
157
|
-
if (activeEnv) {
|
|
158
|
-
envOverrides.push(`${field} (via ${activeEnv}=${process.env[activeEnv]})`);
|
|
159
|
-
}
|
|
160
|
-
} else {
|
|
161
|
-
diskApplied.push(`${field}=${saved[field]}`);
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
const ok = mergePersistedSentinelConfig(sentinel);
|
|
166
|
-
|
|
167
|
-
if (ok) {
|
|
168
|
-
const finalConfig = sentinel.getConfig();
|
|
169
|
-
logger?.info('✅ Sentinel limits loaded from disk and merged', {
|
|
170
|
-
diskPath: filePath,
|
|
171
|
-
diskValues: saved,
|
|
172
|
-
envOverrides: envOverrides.length > 0 ? envOverrides : 'none',
|
|
173
|
-
diskAppliedFields: diskApplied.length > 0 ? diskApplied : 'none (all env-overridden)',
|
|
174
|
-
finalActiveConfig: finalConfig,
|
|
175
|
-
});
|
|
176
|
-
} else {
|
|
177
|
-
logger?.warn('Failed to apply Sentinel limits from disk', {
|
|
178
|
-
diskPath: filePath,
|
|
179
|
-
savedConfig: saved,
|
|
180
|
-
});
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
return ok;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/** Call before returning policy config to clients so long-lived Gateway picks up disk saves. */
|
|
187
|
-
export function hydrateSentinelConfigFromDisk(sentinel: Sentinel): void {
|
|
188
|
-
mergePersistedSentinelConfig(sentinel);
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
export function persistSentinelConfigAfterApply(
|
|
192
|
-
sentinel: Sentinel,
|
|
193
|
-
config: SentinelConfig,
|
|
194
|
-
logger?: {
|
|
195
|
-
info: (msg: string, meta?: Record<string, unknown>) => void;
|
|
196
|
-
}
|
|
197
|
-
): string {
|
|
198
|
-
const filePath = getSentinelLimitsFilePath();
|
|
199
|
-
saveSentinelLimitsToDisk(config);
|
|
200
|
-
|
|
201
|
-
logger?.info('✅ Sentinel limits saved to disk', {
|
|
202
|
-
path: filePath,
|
|
203
|
-
config: config,
|
|
204
|
-
note: 'Survives Gateway restart. Env vars in docker-compose override individual fields when set.',
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
return filePath;
|
|
208
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Persist operator Sentinel limits to disk (survives Gateway restart).
|
|
3
|
+
* File: {4Runr data dir}/config/sentinel-limits.json
|
|
4
|
+
*
|
|
5
|
+
* Boot order: env defaults (packages/sentinel) → overlay saved file when present.
|
|
6
|
+
* Explicit env vars for a field are not overwritten by the saved file.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as fs from 'fs';
|
|
10
|
+
import * as path from 'path';
|
|
11
|
+
import { get4RunrConfigDir } from '@4runr/shared';
|
|
12
|
+
import { Sentinel, type SentinelConfig } from '@4runr/sentinel';
|
|
13
|
+
|
|
14
|
+
const FILE_NAME = 'sentinel-limits.json';
|
|
15
|
+
|
|
16
|
+
const ENV_FIELD_KEYS: Array<{
|
|
17
|
+
field: keyof SentinelConfig;
|
|
18
|
+
envKeys: string[];
|
|
19
|
+
}> = [
|
|
20
|
+
{ field: 'enabled', envKeys: ['SENTINEL_ENABLED'] },
|
|
21
|
+
{ field: 'runMaxDurationMs', envKeys: ['RUN_MAX_DURATION_MS'] },
|
|
22
|
+
{ field: 'runMaxTokens', envKeys: ['RUN_MAX_TOKENS'] },
|
|
23
|
+
{ field: 'runIdleMs', envKeys: ['SENTINEL_IDLE_MS', 'RUN_IDLE_MS'] },
|
|
24
|
+
{ field: 'loopWindow', envKeys: ['SENTINEL_LOOP_WINDOW'] },
|
|
25
|
+
{ field: 'loopMax', envKeys: ['SENTINEL_LOOP_MAX'] },
|
|
26
|
+
{ field: 'runMaxCost', envKeys: ['RUN_MAX_COST', 'SENTINEL_MAX_COST'] },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
export function getSentinelLimitsFilePath(): string {
|
|
30
|
+
return path.join(get4RunrConfigDir(), FILE_NAME);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function envDefinesField(field: keyof SentinelConfig): boolean {
|
|
34
|
+
const entry = ENV_FIELD_KEYS.find((e) => e.field === field);
|
|
35
|
+
if (!entry) return false;
|
|
36
|
+
return entry.envKeys.some((k) => {
|
|
37
|
+
const v = process.env[k];
|
|
38
|
+
return v !== undefined && v.trim() !== '';
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function parseSavedPayload(raw: unknown): SentinelConfig | null {
|
|
43
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
44
|
+
const o = raw as Record<string, unknown>;
|
|
45
|
+
const num = (key: string): number | undefined => {
|
|
46
|
+
const v = o[key];
|
|
47
|
+
if (typeof v === 'number' && Number.isFinite(v)) return v;
|
|
48
|
+
return undefined;
|
|
49
|
+
};
|
|
50
|
+
const enabled = o['enabled'];
|
|
51
|
+
if (typeof enabled !== 'boolean') return null;
|
|
52
|
+
const runMaxDurationMs = num('runMaxDurationMs');
|
|
53
|
+
const runMaxTokens = num('runMaxTokens');
|
|
54
|
+
const runIdleMs = num('runIdleMs');
|
|
55
|
+
const loopWindow = num('loopWindow');
|
|
56
|
+
const loopMax = num('loopMax');
|
|
57
|
+
if (
|
|
58
|
+
runMaxDurationMs === undefined ||
|
|
59
|
+
runMaxTokens === undefined ||
|
|
60
|
+
runIdleMs === undefined ||
|
|
61
|
+
loopWindow === undefined ||
|
|
62
|
+
loopMax === undefined
|
|
63
|
+
) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
const runMaxCost = num('runMaxCost');
|
|
67
|
+
return {
|
|
68
|
+
enabled,
|
|
69
|
+
runMaxDurationMs,
|
|
70
|
+
runMaxTokens,
|
|
71
|
+
runIdleMs,
|
|
72
|
+
loopWindow,
|
|
73
|
+
loopMax,
|
|
74
|
+
...(runMaxCost !== undefined ? { runMaxCost } : {}),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function loadSentinelLimitsFromDisk(): SentinelConfig | null {
|
|
79
|
+
const filePath = getSentinelLimitsFilePath();
|
|
80
|
+
try {
|
|
81
|
+
if (!fs.existsSync(filePath)) return null;
|
|
82
|
+
const text = fs.readFileSync(filePath, 'utf8');
|
|
83
|
+
const parsed = JSON.parse(text) as unknown;
|
|
84
|
+
const inner =
|
|
85
|
+
parsed && typeof parsed === 'object' && 'config' in (parsed as object)
|
|
86
|
+
? (parsed as { config: unknown }).config
|
|
87
|
+
: parsed;
|
|
88
|
+
return parseSavedPayload(inner);
|
|
89
|
+
} catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function saveSentinelLimitsToDisk(config: SentinelConfig): void {
|
|
95
|
+
const filePath = getSentinelLimitsFilePath();
|
|
96
|
+
const payload = {
|
|
97
|
+
version: 1,
|
|
98
|
+
updatedAt: new Date().toISOString(),
|
|
99
|
+
config: {
|
|
100
|
+
enabled: config.enabled,
|
|
101
|
+
runMaxDurationMs: config.runMaxDurationMs,
|
|
102
|
+
runMaxTokens: config.runMaxTokens,
|
|
103
|
+
runIdleMs: config.runIdleMs,
|
|
104
|
+
loopWindow: config.loopWindow,
|
|
105
|
+
loopMax: config.loopMax,
|
|
106
|
+
runMaxCost: config.runMaxCost ?? 1.0,
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), { mode: 0o600 });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Merge saved limits from disk into the live Sentinel singleton (env wins per field). */
|
|
113
|
+
export function mergePersistedSentinelConfig(sentinel: Sentinel): boolean {
|
|
114
|
+
const saved = loadSentinelLimitsFromDisk();
|
|
115
|
+
if (!saved) return false;
|
|
116
|
+
|
|
117
|
+
const current = sentinel.getConfig();
|
|
118
|
+
const merged: SentinelConfig = { ...current };
|
|
119
|
+
|
|
120
|
+
for (const { field } of ENV_FIELD_KEYS) {
|
|
121
|
+
if (envDefinesField(field)) continue;
|
|
122
|
+
(merged as any)[field] = saved[field];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
sentinel.updateConfig(merged);
|
|
127
|
+
return true;
|
|
128
|
+
} catch {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Apply saved limits on Gateway boot. Env-defined fields keep env values.
|
|
135
|
+
*/
|
|
136
|
+
export function applyPersistedSentinelConfig(sentinel: Sentinel, logger?: {
|
|
137
|
+
info: (msg: string, meta?: Record<string, unknown>) => void;
|
|
138
|
+
warn: (msg: string, meta?: Record<string, unknown>) => void;
|
|
139
|
+
}): boolean {
|
|
140
|
+
const filePath = getSentinelLimitsFilePath();
|
|
141
|
+
const saved = loadSentinelLimitsFromDisk();
|
|
142
|
+
|
|
143
|
+
if (!saved) {
|
|
144
|
+
logger?.info('No Sentinel limits file found on disk (will use env defaults)', {
|
|
145
|
+
expectedPath: filePath,
|
|
146
|
+
});
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Check which env vars are set and will override the disk values
|
|
151
|
+
const envOverrides: string[] = [];
|
|
152
|
+
const diskApplied: string[] = [];
|
|
153
|
+
|
|
154
|
+
for (const { field, envKeys } of ENV_FIELD_KEYS) {
|
|
155
|
+
if (envDefinesField(field)) {
|
|
156
|
+
const activeEnv = envKeys.find(k => process.env[k] !== undefined && process.env[k]?.trim() !== '');
|
|
157
|
+
if (activeEnv) {
|
|
158
|
+
envOverrides.push(`${field} (via ${activeEnv}=${process.env[activeEnv]})`);
|
|
159
|
+
}
|
|
160
|
+
} else {
|
|
161
|
+
diskApplied.push(`${field}=${saved[field]}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const ok = mergePersistedSentinelConfig(sentinel);
|
|
166
|
+
|
|
167
|
+
if (ok) {
|
|
168
|
+
const finalConfig = sentinel.getConfig();
|
|
169
|
+
logger?.info('✅ Sentinel limits loaded from disk and merged', {
|
|
170
|
+
diskPath: filePath,
|
|
171
|
+
diskValues: saved,
|
|
172
|
+
envOverrides: envOverrides.length > 0 ? envOverrides : 'none',
|
|
173
|
+
diskAppliedFields: diskApplied.length > 0 ? diskApplied : 'none (all env-overridden)',
|
|
174
|
+
finalActiveConfig: finalConfig,
|
|
175
|
+
});
|
|
176
|
+
} else {
|
|
177
|
+
logger?.warn('Failed to apply Sentinel limits from disk', {
|
|
178
|
+
diskPath: filePath,
|
|
179
|
+
savedConfig: saved,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return ok;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Call before returning policy config to clients so long-lived Gateway picks up disk saves. */
|
|
187
|
+
export function hydrateSentinelConfigFromDisk(sentinel: Sentinel): void {
|
|
188
|
+
mergePersistedSentinelConfig(sentinel);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function persistSentinelConfigAfterApply(
|
|
192
|
+
sentinel: Sentinel,
|
|
193
|
+
config: SentinelConfig,
|
|
194
|
+
logger?: {
|
|
195
|
+
info: (msg: string, meta?: Record<string, unknown>) => void;
|
|
196
|
+
}
|
|
197
|
+
): string {
|
|
198
|
+
const filePath = getSentinelLimitsFilePath();
|
|
199
|
+
saveSentinelLimitsToDisk(config);
|
|
200
|
+
|
|
201
|
+
logger?.info('✅ Sentinel limits saved to disk', {
|
|
202
|
+
path: filePath,
|
|
203
|
+
config: config,
|
|
204
|
+
note: 'Survives Gateway restart. Env vars in docker-compose override individual fields when set.',
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
return filePath;
|
|
208
|
+
}
|
package/dist/tui-handlers.js
CHANGED
|
@@ -1571,22 +1571,32 @@ async function handleGatewayConnect(ctx) {
|
|
|
1571
1571
|
level: 'success',
|
|
1572
1572
|
message: `Gateway already listening on port ${port}.`,
|
|
1573
1573
|
});
|
|
1574
|
-
//
|
|
1574
|
+
// Ensure Postgres + Redis are up before trusting /ready (disconnect stops containers).
|
|
1575
|
+
if (bundleCheck.exists && bundleCheck.path) {
|
|
1576
|
+
activityLog.push({
|
|
1577
|
+
timestamp: getCurrentTime(),
|
|
1578
|
+
level: 'info',
|
|
1579
|
+
message: 'Ensuring Postgres + Redis Docker stack is running...',
|
|
1580
|
+
});
|
|
1581
|
+
const redisUrl = await tryTuiAutostartDockerComposeStack(bundleCheck.path, activityLog, getCurrentTime);
|
|
1582
|
+
if (redisUrl) {
|
|
1583
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1575
1586
|
activityLog.push({
|
|
1576
1587
|
timestamp: getCurrentTime(),
|
|
1577
1588
|
level: 'info',
|
|
1578
1589
|
message: 'Verifying dependencies (DB, Redis)...',
|
|
1579
1590
|
});
|
|
1580
1591
|
try {
|
|
1581
|
-
const verify = await client.verify({ readyTimeoutMs:
|
|
1592
|
+
const verify = await client.verify({ readyTimeoutMs: 12000 });
|
|
1582
1593
|
if (!verify.ok || !verify.ready?.ready) {
|
|
1583
1594
|
const errMsg = verify.error || (verify.ready ? 'Dependencies not ready' : 'Ready check failed');
|
|
1584
1595
|
activityLog.push({
|
|
1585
1596
|
timestamp: getCurrentTime(),
|
|
1586
1597
|
level: 'error',
|
|
1587
|
-
message: `Gateway is alive but dependencies are
|
|
1598
|
+
message: `Gateway is alive but dependencies are not ready: ${errMsg}`,
|
|
1588
1599
|
});
|
|
1589
|
-
// Show which dependencies are down
|
|
1590
1600
|
if (verify.readyChecks) {
|
|
1591
1601
|
for (const check of verify.readyChecks) {
|
|
1592
1602
|
if (check.status === 'down') {
|
|
@@ -1599,74 +1609,29 @@ async function handleGatewayConnect(ctx) {
|
|
|
1599
1609
|
}
|
|
1600
1610
|
}
|
|
1601
1611
|
}
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
}
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
await new Promise((r) => setTimeout(r, 3000));
|
|
1612
|
-
// Re-verify
|
|
1613
|
-
const verifyRetry = await client.verify({ readyTimeoutMs: 8000 });
|
|
1614
|
-
if (verifyRetry.ok && verifyRetry.ready?.ready) {
|
|
1615
|
-
activityLog.push({
|
|
1616
|
-
timestamp: getCurrentTime(),
|
|
1617
|
-
level: 'success',
|
|
1618
|
-
message: 'Docker containers restarted — Gateway is now ready!',
|
|
1619
|
-
});
|
|
1620
|
-
}
|
|
1621
|
-
else {
|
|
1622
|
-
activityLog.push({
|
|
1623
|
-
timestamp: getCurrentTime(),
|
|
1624
|
-
level: 'error',
|
|
1625
|
-
message: 'Docker restart did not fix the issue. Check containers manually: docker ps',
|
|
1626
|
-
});
|
|
1627
|
-
ctx.server.sendResponse(ctx.ws, ctx.id, {
|
|
1628
|
-
success: false,
|
|
1629
|
-
error: `Gateway dependencies (DB/Redis) are down. Docker restart failed. Run: docker compose -f "${path.join(bundleCheck.path, 'docker-compose.local.yml')}" up -d`,
|
|
1630
|
-
data: { activityLog, bundleStatus: bundleCheck, portStatus: { inUse: true, process: processInfo } },
|
|
1631
|
-
});
|
|
1632
|
-
return;
|
|
1633
|
-
}
|
|
1634
|
-
}
|
|
1635
|
-
else {
|
|
1636
|
-
activityLog.push({
|
|
1637
|
-
timestamp: getCurrentTime(),
|
|
1638
|
-
level: 'error',
|
|
1639
|
-
message: 'Could not restart Docker stack. Start manually: docker compose up -d',
|
|
1640
|
-
});
|
|
1641
|
-
ctx.server.sendResponse(ctx.ws, ctx.id, {
|
|
1642
|
-
success: false,
|
|
1643
|
-
error: `Gateway is alive but DB/Redis are down. Start Docker manually: docker compose -f "${path.join(bundleCheck.path, 'docker-compose.local.yml')}" up -d`,
|
|
1644
|
-
data: { activityLog, bundleStatus: bundleCheck, portStatus: { inUse: true, process: processInfo } },
|
|
1645
|
-
});
|
|
1646
|
-
return;
|
|
1647
|
-
}
|
|
1648
|
-
}
|
|
1649
|
-
else {
|
|
1650
|
-
activityLog.push({
|
|
1651
|
-
timestamp: getCurrentTime(),
|
|
1652
|
-
level: 'error',
|
|
1653
|
-
message: 'Gateway dependencies down but cannot auto-restart (no bundle path).',
|
|
1654
|
-
});
|
|
1655
|
-
ctx.server.sendResponse(ctx.ws, ctx.id, {
|
|
1656
|
-
success: false,
|
|
1657
|
-
error: `Gateway is alive but DB/Redis are down. Start Docker manually or restart the Gateway.`,
|
|
1658
|
-
data: { activityLog, bundleStatus: bundleCheck, portStatus: { inUse: true, process: processInfo } },
|
|
1659
|
-
});
|
|
1660
|
-
return;
|
|
1661
|
-
}
|
|
1612
|
+
const composeHint = bundleCheck.path
|
|
1613
|
+
? `docker compose -p 4runr -f "${path.join(bundleCheck.path, 'docker-compose.local.yml')}" up -d`
|
|
1614
|
+
: 'docker start 4runr-postgres 4runr-redis';
|
|
1615
|
+
ctx.server.sendResponse(ctx.ws, ctx.id, {
|
|
1616
|
+
success: false,
|
|
1617
|
+
error: `Gateway dependencies (DB/Redis) are not ready. Run: ${composeHint}`,
|
|
1618
|
+
data: { activityLog, bundleStatus: bundleCheck, portStatus: { inUse: true, process: processInfo } },
|
|
1619
|
+
});
|
|
1620
|
+
return;
|
|
1662
1621
|
}
|
|
1663
1622
|
}
|
|
1664
1623
|
catch (readyErr) {
|
|
1665
1624
|
activityLog.push({
|
|
1666
1625
|
timestamp: getCurrentTime(),
|
|
1667
|
-
level: '
|
|
1668
|
-
message: `/ready check failed: ${errorMessage(readyErr)}
|
|
1626
|
+
level: 'error',
|
|
1627
|
+
message: `/ready check failed: ${errorMessage(readyErr)}`,
|
|
1628
|
+
});
|
|
1629
|
+
ctx.server.sendResponse(ctx.ws, ctx.id, {
|
|
1630
|
+
success: false,
|
|
1631
|
+
error: `Could not verify Gateway dependencies: ${errorMessage(readyErr)}`,
|
|
1632
|
+
data: { activityLog, bundleStatus: bundleCheck, portStatus: { inUse: true, process: processInfo } },
|
|
1669
1633
|
});
|
|
1634
|
+
return;
|
|
1670
1635
|
}
|
|
1671
1636
|
activityLog.push({
|
|
1672
1637
|
timestamp: getCurrentTime(),
|
|
@@ -2025,12 +1990,37 @@ async function handleGatewayConnect(ctx) {
|
|
|
2025
1990
|
});
|
|
2026
1991
|
}
|
|
2027
1992
|
}
|
|
1993
|
+
function isLocalGatewayHost(url) {
|
|
1994
|
+
const trimmed = url.trim();
|
|
1995
|
+
if (!trimmed)
|
|
1996
|
+
return false;
|
|
1997
|
+
try {
|
|
1998
|
+
const host = new URL(trimmed).hostname.toLowerCase();
|
|
1999
|
+
return host === 'localhost' || host === '127.0.0.1' || host === '0.0.0.0' || host === '::1';
|
|
2000
|
+
}
|
|
2001
|
+
catch {
|
|
2002
|
+
return false;
|
|
2003
|
+
}
|
|
2004
|
+
}
|
|
2028
2005
|
async function handleGatewayDisconnect(ctx) {
|
|
2006
|
+
const url = process.env.GATEWAY_URL || '';
|
|
2007
|
+
const wasLocalhost = isLocalGatewayHost(url);
|
|
2029
2008
|
tuiActiveGatewayClient = null;
|
|
2030
2009
|
delete process.env.GATEWAY_URL;
|
|
2010
|
+
let message;
|
|
2011
|
+
if (wasLocalhost) {
|
|
2012
|
+
const { shutdownLocalGatewayStack } = await import('./watchdog.js');
|
|
2013
|
+
shutdownLocalGatewayStack();
|
|
2014
|
+
message =
|
|
2015
|
+
'Disconnected — local Gateway stopped and Postgres/Redis containers stopped (data volumes kept).';
|
|
2016
|
+
}
|
|
2031
2017
|
ctx.server.sendResponse(ctx.ws, ctx.id, {
|
|
2032
2018
|
success: true,
|
|
2033
|
-
data: {
|
|
2019
|
+
data: {
|
|
2020
|
+
connected: false,
|
|
2021
|
+
localShutdown: wasLocalhost,
|
|
2022
|
+
message,
|
|
2023
|
+
},
|
|
2034
2024
|
});
|
|
2035
2025
|
}
|
|
2036
2026
|
async function handleGatewayStatus(ctx) {
|