@ggui-ai/mcp-server 0.1.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +48 -0
- package/dist/admin-blueprints-transport.d.ts +114 -0
- package/dist/admin-blueprints-transport.d.ts.map +1 -0
- package/dist/admin-blueprints-transport.js +118 -0
- package/dist/admin-oauth-providers-transport.d.ts +40 -0
- package/dist/admin-oauth-providers-transport.d.ts.map +1 -0
- package/dist/admin-oauth-providers-transport.js +263 -0
- package/dist/auth.d.ts +39 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +75 -0
- package/dist/build-mcp.d.ts +128 -0
- package/dist/build-mcp.d.ts.map +1 -0
- package/dist/build-mcp.js +113 -0
- package/dist/code-store-fs.d.ts +19 -0
- package/dist/code-store-fs.d.ts.map +1 -0
- package/dist/code-store-fs.js +98 -0
- package/dist/console-auth.d.ts +139 -0
- package/dist/console-auth.d.ts.map +1 -0
- package/dist/console-auth.js +102 -0
- package/dist/console-cache.d.ts +78 -0
- package/dist/console-cache.d.ts.map +1 -0
- package/dist/console-cache.js +105 -0
- package/dist/console-headers.d.ts +124 -0
- package/dist/console-headers.d.ts.map +1 -0
- package/dist/console-headers.js +49 -0
- package/dist/console-llm-trace.d.ts +66 -0
- package/dist/console-llm-trace.d.ts.map +1 -0
- package/dist/console-llm-trace.js +105 -0
- package/dist/console-payloads.d.ts +67 -0
- package/dist/console-payloads.d.ts.map +1 -0
- package/dist/console-payloads.js +105 -0
- package/dist/console-theme-routes.d.ts +111 -0
- package/dist/console-theme-routes.d.ts.map +1 -0
- package/dist/console-theme-routes.js +202 -0
- package/dist/console-timeline.d.ts +45 -0
- package/dist/console-timeline.d.ts.map +1 -0
- package/dist/console-timeline.js +169 -0
- package/dist/console-validator.d.ts +67 -0
- package/dist/console-validator.d.ts.map +1 -0
- package/dist/console-validator.js +105 -0
- package/dist/console-welcome.d.ts +7 -0
- package/dist/console-welcome.d.ts.map +1 -0
- package/dist/console-welcome.js +221 -0
- package/dist/csrf-middleware.d.ts +55 -0
- package/dist/csrf-middleware.d.ts.map +1 -0
- package/dist/csrf-middleware.js +138 -0
- package/dist/email-login.d.ts +174 -0
- package/dist/email-login.d.ts.map +1 -0
- package/dist/email-login.js +254 -0
- package/dist/email-resend.d.ts +29 -0
- package/dist/email-resend.d.ts.map +1 -0
- package/dist/email-resend.js +71 -0
- package/dist/email-sender-from-env.d.ts +34 -0
- package/dist/email-sender-from-env.d.ts.map +1 -0
- package/dist/email-sender-from-env.js +112 -0
- package/dist/email-smtp.d.ts +42 -0
- package/dist/email-smtp.d.ts.map +1 -0
- package/dist/email-smtp.js +81 -0
- package/dist/index.d.ts +102 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +122 -0
- package/dist/instructions-presets.d.ts +112 -0
- package/dist/instructions-presets.d.ts.map +1 -0
- package/dist/instructions-presets.js +195 -0
- package/dist/llm-backed-negotiator.d.ts +178 -0
- package/dist/llm-backed-negotiator.d.ts.map +1 -0
- package/dist/llm-backed-negotiator.js +579 -0
- package/dist/logger.d.ts +23 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +41 -0
- package/dist/mcp-apps-inbound.d.ts +86 -0
- package/dist/mcp-apps-inbound.d.ts.map +1 -0
- package/dist/mcp-apps-inbound.js +278 -0
- package/dist/mcp-apps-outbound.d.ts +448 -0
- package/dist/mcp-apps-outbound.d.ts.map +1 -0
- package/dist/mcp-apps-outbound.js +1163 -0
- package/dist/mcp-mounts.d.ts +239 -0
- package/dist/mcp-mounts.d.ts.map +1 -0
- package/dist/mcp-mounts.js +222 -0
- package/dist/oauth-login-types.d.ts +160 -0
- package/dist/oauth-login-types.d.ts.map +1 -0
- package/dist/oauth-login-types.js +9 -0
- package/dist/oauth-login.d.ts +77 -0
- package/dist/oauth-login.d.ts.map +1 -0
- package/dist/oauth-login.js +455 -0
- package/dist/oauth-providers/github.d.ts +17 -0
- package/dist/oauth-providers/github.d.ts.map +1 -0
- package/dist/oauth-providers/github.js +89 -0
- package/dist/oauth-providers/google.d.ts +18 -0
- package/dist/oauth-providers/google.d.ts.map +1 -0
- package/dist/oauth-providers/google.js +59 -0
- package/dist/oauth-providers-store.d.ts +32 -0
- package/dist/oauth-providers-store.d.ts.map +1 -0
- package/dist/oauth-providers-store.js +291 -0
- package/dist/oauth.d.ts +347 -0
- package/dist/oauth.d.ts.map +1 -0
- package/dist/oauth.js +686 -0
- package/dist/pairing-transport.d.ts +99 -0
- package/dist/pairing-transport.d.ts.map +1 -0
- package/dist/pairing-transport.js +223 -0
- package/dist/rate-limit-middleware.d.ts +36 -0
- package/dist/rate-limit-middleware.d.ts.map +1 -0
- package/dist/rate-limit-middleware.js +57 -0
- package/dist/render-gate.d.ts +87 -0
- package/dist/render-gate.d.ts.map +1 -0
- package/dist/render-gate.js +77 -0
- package/dist/render-rate-limit.d.ts +59 -0
- package/dist/render-rate-limit.d.ts.map +1 -0
- package/dist/render-rate-limit.js +73 -0
- package/dist/render-signing.d.ts +98 -0
- package/dist/render-signing.d.ts.map +1 -0
- package/dist/render-signing.js +113 -0
- package/dist/request-context.d.ts +113 -0
- package/dist/request-context.d.ts.map +1 -0
- package/dist/request-context.js +154 -0
- package/dist/reserved-validators.d.ts +22 -0
- package/dist/reserved-validators.d.ts.map +1 -0
- package/dist/reserved-validators.js +101 -0
- package/dist/schema-compat.d.ts +167 -0
- package/dist/schema-compat.d.ts.map +1 -0
- package/dist/schema-compat.js +187 -0
- package/dist/security-headers-middleware.d.ts +38 -0
- package/dist/security-headers-middleware.d.ts.map +1 -0
- package/dist/security-headers-middleware.js +30 -0
- package/dist/server.d.ts +2060 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +6338 -0
- package/dist/session-channel.d.ts +651 -0
- package/dist/session-channel.d.ts.map +1 -0
- package/dist/session-channel.js +1756 -0
- package/dist/storage.d.ts +89 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +171 -0
- package/dist/thread-transport.d.ts +118 -0
- package/dist/thread-transport.d.ts.map +1 -0
- package/dist/thread-transport.js +478 -0
- package/dist/user-session-auth.d.ts +167 -0
- package/dist/user-session-auth.d.ts.map +1 -0
- package/dist/user-session-auth.js +148 -0
- package/package.json +76 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Console theme picker routes — `GET /ggui/console/theme` +
|
|
3
|
+
* `POST /ggui/console/theme`.
|
|
4
|
+
*
|
|
5
|
+
* Shape:
|
|
6
|
+
*
|
|
7
|
+
* GET /ggui/console/theme
|
|
8
|
+
* → 200 {
|
|
9
|
+
* presets: ThemeEntry[], // from @ggui-ai/design#listThemes()
|
|
10
|
+
* current: ThemeConfig | null, // ggui.json#theme as parsed
|
|
11
|
+
* writerEnabled: boolean, // POST availability
|
|
12
|
+
* }
|
|
13
|
+
*
|
|
14
|
+
* POST /ggui/console/theme
|
|
15
|
+
* body: ThemeConfig | null // null = clear field, fall back to default
|
|
16
|
+
* → 200 { ok: true }
|
|
17
|
+
* → 400 { error: 'invalid_config', issue } — schema rejected
|
|
18
|
+
* → 501 { error: 'writer_not_configured' } — opts.themeWriter omitted
|
|
19
|
+
* → 500 { error: 'write_failed', message } — writer threw
|
|
20
|
+
*
|
|
21
|
+
* Authentication: piggy-backs on the same admin gate the LLM-keys
|
|
22
|
+
* routes use — operator-only since the value persists to ggui.json,
|
|
23
|
+
* which the OSS server treats as trusted manifest input. End-users
|
|
24
|
+
* who paired into a session don't get to mutate the project's
|
|
25
|
+
* theme. Multi-tenant deployments may relax this in a follow-up by
|
|
26
|
+
* scoping overrides per user (overrides become server state, not
|
|
27
|
+
* file state).
|
|
28
|
+
*/
|
|
29
|
+
import { ThemeConfigSchema, safeParseThemeDocument, } from '@ggui-ai/project-config';
|
|
30
|
+
import { applyDevtoolSecurityHeaders } from './console-headers.js';
|
|
31
|
+
/**
|
|
32
|
+
* Filename gate for `POST /ggui/console/theme/upload`. Operators upload
|
|
33
|
+
* arbitrary names; we constrain to a small alphabet that can't escape
|
|
34
|
+
* the project directory or shadow tooling files.
|
|
35
|
+
*/
|
|
36
|
+
const SAFE_FILENAME_RE = /^[a-zA-Z0-9._-]+\.json$/;
|
|
37
|
+
const FORBIDDEN_FILENAMES = new Set(['ggui.json', 'package.json']);
|
|
38
|
+
/**
|
|
39
|
+
* Mount `GET /ggui/console/theme` + `POST /ggui/console/theme` onto
|
|
40
|
+
* the express app. Returns nothing — the routes self-register.
|
|
41
|
+
*
|
|
42
|
+
* The current-config state is held in-process (mutable closure) so
|
|
43
|
+
* subsequent GETs reflect the latest POST without re-reading
|
|
44
|
+
* ggui.json. Writes are durable via the supplied `themeWriter`.
|
|
45
|
+
*/
|
|
46
|
+
export function mountDevtoolThemeRoutes(opts) {
|
|
47
|
+
const { app, themeWriter, themeFileUploader, requestHasAdminAuth, onConfigChange } = opts;
|
|
48
|
+
let currentConfig = opts.initialConfig;
|
|
49
|
+
// Single helper for the two POST paths so the cell update + the
|
|
50
|
+
// change-notifier fire in lockstep. Errors from the notifier are
|
|
51
|
+
// swallowed — the operator's save already landed on disk, so a
|
|
52
|
+
// downstream subscription glitch shouldn't surface as a UI failure.
|
|
53
|
+
const updateConfig = (next) => {
|
|
54
|
+
currentConfig = next;
|
|
55
|
+
if (onConfigChange !== undefined) {
|
|
56
|
+
try {
|
|
57
|
+
onConfigChange(next);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
/* observer-side failure is non-load-bearing */
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
const writerEnabled = themeWriter !== undefined;
|
|
65
|
+
const uploadEnabled = themeWriter !== undefined && themeFileUploader !== undefined;
|
|
66
|
+
// GET /ggui/console/theme — picker data
|
|
67
|
+
app.get('/ggui/console/theme', (req, res) => {
|
|
68
|
+
applyDevtoolSecurityHeaders(res);
|
|
69
|
+
if (!requestHasAdminAuth(req)) {
|
|
70
|
+
res.status(401).json({ error: 'admin_auth_required' });
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
// The list of registered presets is owned by `@ggui-ai/design`,
|
|
74
|
+
// which the console client imports directly. The server only
|
|
75
|
+
// returns the resolved-from-manifest selection + the writer's
|
|
76
|
+
// posture; the client renders the preset grid from listThemes()
|
|
77
|
+
// on its own.
|
|
78
|
+
res.status(200).json({
|
|
79
|
+
current: currentConfig,
|
|
80
|
+
writerEnabled,
|
|
81
|
+
uploadEnabled,
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
// POST /ggui/console/theme — persist a selection
|
|
85
|
+
app.post('/ggui/console/theme', async (req, res) => {
|
|
86
|
+
applyDevtoolSecurityHeaders(res);
|
|
87
|
+
if (!requestHasAdminAuth(req)) {
|
|
88
|
+
res.status(401).json({ error: 'admin_auth_required' });
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (themeWriter === undefined) {
|
|
92
|
+
res.status(501).json({
|
|
93
|
+
error: 'writer_not_configured',
|
|
94
|
+
message: 'Theme writes require the CLI to provide a themeWriter — ' +
|
|
95
|
+
'launch via `ggui serve` (writer is wired) instead of using ' +
|
|
96
|
+
'createGguiServer directly without the option.',
|
|
97
|
+
});
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
// body is `ThemeConfig | null`. Empty body / `null` clears.
|
|
101
|
+
const raw = (req.body ?? null);
|
|
102
|
+
let parsed;
|
|
103
|
+
if (raw === null) {
|
|
104
|
+
parsed = null;
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
const result = ThemeConfigSchema.safeParse(raw);
|
|
108
|
+
if (!result.success) {
|
|
109
|
+
res.status(400).json({
|
|
110
|
+
error: 'invalid_config',
|
|
111
|
+
issue: result.error.flatten(),
|
|
112
|
+
});
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
parsed = result.data;
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
await themeWriter(parsed);
|
|
119
|
+
updateConfig(parsed);
|
|
120
|
+
res.status(200).json({ ok: true, current: parsed });
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
124
|
+
res.status(500).json({ error: 'write_failed', message });
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
// POST /ggui/console/theme/upload — write a DTCG theme document
|
|
128
|
+
// alongside ggui.json and switch the manifest to point at it.
|
|
129
|
+
//
|
|
130
|
+
// Body shape: { filename: string, content: unknown, mode: 'light' | 'dark' }
|
|
131
|
+
//
|
|
132
|
+
// - filename is constrained by SAFE_FILENAME_RE — no path
|
|
133
|
+
// separators, no `ggui.json` / `package.json` collision.
|
|
134
|
+
// - content is validated via `safeParseThemeDocument` (plain DTCG
|
|
135
|
+
// v1) so a malformed paste is rejected before hitting disk.
|
|
136
|
+
// - On success we run `themeFileUploader(filename, content)`
|
|
137
|
+
// followed by `themeWriter({ file: './<filename>', mode })`,
|
|
138
|
+
// so a save lands the file AND updates the manifest atomically
|
|
139
|
+
// from the operator's POV. Partial-failure recovery is the
|
|
140
|
+
// CLI's responsibility — the seam contract says either both
|
|
141
|
+
// side-effects land or both throw.
|
|
142
|
+
app.post('/ggui/console/theme/upload', async (req, res) => {
|
|
143
|
+
applyDevtoolSecurityHeaders(res);
|
|
144
|
+
if (!requestHasAdminAuth(req)) {
|
|
145
|
+
res.status(401).json({ error: 'admin_auth_required' });
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (themeWriter === undefined || themeFileUploader === undefined) {
|
|
149
|
+
res.status(501).json({
|
|
150
|
+
error: 'upload_not_configured',
|
|
151
|
+
message: 'Theme uploads require both themeWriter and themeFileUploader — ' +
|
|
152
|
+
'launch via `ggui serve` (both are wired) instead of using ' +
|
|
153
|
+
'createGguiServer directly without the options.',
|
|
154
|
+
});
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const body = (req.body ?? {});
|
|
158
|
+
const filename = typeof body.filename === 'string' ? body.filename : '';
|
|
159
|
+
if (!SAFE_FILENAME_RE.test(filename)) {
|
|
160
|
+
res.status(400).json({
|
|
161
|
+
error: 'invalid_filename',
|
|
162
|
+
message: 'filename must match /^[a-zA-Z0-9._-]+\\.json$/ — no path ' +
|
|
163
|
+
'separators, ends with .json. Got: ' + JSON.stringify(filename),
|
|
164
|
+
});
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (FORBIDDEN_FILENAMES.has(filename)) {
|
|
168
|
+
res.status(400).json({
|
|
169
|
+
error: 'forbidden_filename',
|
|
170
|
+
message: `filename "${filename}" would shadow a reserved file.`,
|
|
171
|
+
});
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const mode = body.mode;
|
|
175
|
+
if (mode !== 'light' && mode !== 'dark') {
|
|
176
|
+
res.status(400).json({
|
|
177
|
+
error: 'invalid_mode',
|
|
178
|
+
message: 'mode must be "light" or "dark".',
|
|
179
|
+
});
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const docResult = safeParseThemeDocument(body.content);
|
|
183
|
+
if (!docResult.success) {
|
|
184
|
+
res.status(400).json({
|
|
185
|
+
error: 'invalid_content',
|
|
186
|
+
issue: docResult.error.flatten(),
|
|
187
|
+
});
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
await themeFileUploader(filename, docResult.data);
|
|
192
|
+
const nextConfig = { file: `./${filename}`, mode };
|
|
193
|
+
await themeWriter(nextConfig);
|
|
194
|
+
updateConfig(nextConfig);
|
|
195
|
+
res.status(200).json({ ok: true, current: nextConfig });
|
|
196
|
+
}
|
|
197
|
+
catch (err) {
|
|
198
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
199
|
+
res.status(500).json({ error: 'write_failed', message });
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Console-facing session-event timeline endpoints powering
|
|
3
|
+
* `/devtools/timeline` in the @ggui-ai/console SPA.
|
|
4
|
+
*
|
|
5
|
+
* Unlike `/devtools/llm-trace`, the timeline does NOT introduce a new
|
|
6
|
+
* sink — every event the operator wants to step through already
|
|
7
|
+
* exists in the {@link SessionStore} (inbound user
|
|
8
|
+
* actions + tool calls + UI mutations) and the
|
|
9
|
+
* {@link SessionStreamBuffer} (outbound stream cursor). This module is
|
|
10
|
+
* a thin read-only window over both.
|
|
11
|
+
*
|
|
12
|
+
* **Two surfaces, both admin-gated:**
|
|
13
|
+
*
|
|
14
|
+
* - `GET /ggui/console/timeline/sessions` — list of sessions visible
|
|
15
|
+
* to the timeline picker. All statuses (active / completed /
|
|
16
|
+
* expired) — operators frequently want to debug a session AFTER it
|
|
17
|
+
* terminated. Sorted most-recent-`lastActivityAt` first; defaults
|
|
18
|
+
* to 50 rows, clamped to [1, 200] via `?limit=`.
|
|
19
|
+
*
|
|
20
|
+
* - `GET /ggui/console/timeline/:sessionId/events` — the full event
|
|
21
|
+
* log for one session, oldest-first, drained from
|
|
22
|
+
* `sessionStore.observe(id, { tail: false })`. Also reports the
|
|
23
|
+
* outbound stream cursor (`streamBuffer.currentSeq`) so the
|
|
24
|
+
* operator can see live-channel progress without a separate fetch.
|
|
25
|
+
*
|
|
26
|
+
* Why REST-only (no SSE): replay is a snapshot. The operator picks a
|
|
27
|
+
* session and steps through what happened — they want a stable frozen
|
|
28
|
+
* view, not a live tail. SSE would force the scrubber to keep chasing
|
|
29
|
+
* a moving end-of-stream and complicate the UI without paying for it.
|
|
30
|
+
*
|
|
31
|
+
* Memory: bounded by whatever the underlying SessionStore retains.
|
|
32
|
+
* `InMemorySessionStore` keeps everything for the process lifetime —
|
|
33
|
+
* fine for OSS dev. A hosted closed runtime's persistent store is the
|
|
34
|
+
* durability surface.
|
|
35
|
+
*/
|
|
36
|
+
import type { Express } from 'express';
|
|
37
|
+
import type { SessionStore, SessionStreamBuffer } from '@ggui-ai/mcp-server-core';
|
|
38
|
+
/**
|
|
39
|
+
* Mount `/ggui/console/timeline/*` routes on `app`. Caller is
|
|
40
|
+
* responsible for admin-gating the path prefix beforehand — this
|
|
41
|
+
* function does not re-implement auth (matches the
|
|
42
|
+
* `mountConsoleLlmTraceRoutes` shape in console-llm-trace.ts).
|
|
43
|
+
*/
|
|
44
|
+
export declare function mountConsoleTimelineRoutes(app: Express, sessionStore: SessionStore | undefined, streamBuffer: SessionStreamBuffer | undefined): void;
|
|
45
|
+
//# sourceMappingURL=console-timeline.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"console-timeline.d.ts","sourceRoot":"","sources":["../src/console-timeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AACH,OAAO,KAAK,EAAqB,OAAO,EAAE,MAAM,SAAS,CAAC;AAC1D,OAAO,KAAK,EAEV,YAAY,EACZ,mBAAmB,EACpB,MAAM,0BAA0B,CAAC;AAkElC;;;;;GAKG;AACH,wBAAgB,0BAA0B,CACxC,GAAG,EAAE,OAAO,EACZ,YAAY,EAAE,YAAY,GAAG,SAAS,EACtC,YAAY,EAAE,mBAAmB,GAAG,SAAS,GAC5C,IAAI,CA8JN"}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { applyDevtoolSecurityHeaders } from './console-headers.js';
|
|
2
|
+
/**
|
|
3
|
+
* Drain {@link SessionStore.observe} into an array. The observe iterator
|
|
4
|
+
* with `{ tail: false }` resolves cleanly after replaying every stored
|
|
5
|
+
* event, so this is a plain `for await`. Bound by the store's per-
|
|
6
|
+
* session retention (in-memory: full history; hosted: store-defined).
|
|
7
|
+
*/
|
|
8
|
+
async function drainSessionEvents(sessionStore, sessionId) {
|
|
9
|
+
const out = [];
|
|
10
|
+
for await (const event of sessionStore.observe(sessionId, {
|
|
11
|
+
fromSeq: 1,
|
|
12
|
+
tail: false,
|
|
13
|
+
})) {
|
|
14
|
+
out.push(event);
|
|
15
|
+
}
|
|
16
|
+
return out;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Mount `/ggui/console/timeline/*` routes on `app`. Caller is
|
|
20
|
+
* responsible for admin-gating the path prefix beforehand — this
|
|
21
|
+
* function does not re-implement auth (matches the
|
|
22
|
+
* `mountConsoleLlmTraceRoutes` shape in console-llm-trace.ts).
|
|
23
|
+
*/
|
|
24
|
+
export function mountConsoleTimelineRoutes(app, sessionStore, streamBuffer) {
|
|
25
|
+
// GET /ggui/console/timeline/sessions?limit=<n>
|
|
26
|
+
app.get('/ggui/console/timeline/sessions', async (req, res) => {
|
|
27
|
+
applyDevtoolSecurityHeaders(res);
|
|
28
|
+
const limitRaw = req.query['limit'];
|
|
29
|
+
let limit = 50;
|
|
30
|
+
if (typeof limitRaw === 'string') {
|
|
31
|
+
const parsed = Number.parseInt(limitRaw, 10);
|
|
32
|
+
if (Number.isFinite(parsed) && parsed > 0) {
|
|
33
|
+
limit = Math.min(200, parsed);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
// Zero-config shape: no store wired (pure-MCP boot) → empty list.
|
|
37
|
+
if (!sessionStore) {
|
|
38
|
+
const body = { sessions: [], total: 0 };
|
|
39
|
+
res.json(body);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
// No status filter — operators want to debug completed +
|
|
44
|
+
// expired sessions, not just live ones. Limit is post-filter
|
|
45
|
+
// so the response stays bounded.
|
|
46
|
+
const sessions = await sessionStore.list({});
|
|
47
|
+
const summaries = [];
|
|
48
|
+
const now = Date.now();
|
|
49
|
+
for (const session of sessions) {
|
|
50
|
+
let streamSeq = 0;
|
|
51
|
+
if (streamBuffer) {
|
|
52
|
+
try {
|
|
53
|
+
streamSeq = await streamBuffer.currentSeq(session.id);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// Stream-cursor lookup is best-effort metadata; an
|
|
57
|
+
// adapter error must not blank the picker.
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// Compute status from session state (the store doesn't
|
|
61
|
+
// surface its private `closed` flag on the Session type).
|
|
62
|
+
// We mirror the InMemorySessionStore's `computeStatus` rule:
|
|
63
|
+
// expired = expiresAt <= now, otherwise active. The
|
|
64
|
+
// 'completed' state needs the closed flag to disambiguate;
|
|
65
|
+
// we report 'active' until eviction. Operators reading the
|
|
66
|
+
// detail pane get the authoritative status from the store.
|
|
67
|
+
const status = session.expiresAt <= now ? 'expired' : 'active';
|
|
68
|
+
summaries.push({
|
|
69
|
+
sessionId: session.id,
|
|
70
|
+
appId: session.appId,
|
|
71
|
+
stackSize: session.stack.length,
|
|
72
|
+
createdAt: session.createdAt,
|
|
73
|
+
lastActivityAt: session.lastActivityAt,
|
|
74
|
+
status,
|
|
75
|
+
streamSeq,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
// Most-recent activity first. Tiebreak on sessionId for
|
|
79
|
+
// stable ordering across reloads.
|
|
80
|
+
summaries.sort((a, b) => {
|
|
81
|
+
const byRecency = b.lastActivityAt - a.lastActivityAt;
|
|
82
|
+
if (byRecency !== 0)
|
|
83
|
+
return byRecency;
|
|
84
|
+
return a.sessionId.localeCompare(b.sessionId);
|
|
85
|
+
});
|
|
86
|
+
const trimmed = summaries.slice(0, limit);
|
|
87
|
+
const body = {
|
|
88
|
+
sessions: trimmed,
|
|
89
|
+
total: summaries.length,
|
|
90
|
+
};
|
|
91
|
+
res.json(body);
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
res.status(500).json({
|
|
95
|
+
error: 'timeline_sessions_list_failed',
|
|
96
|
+
message: err instanceof Error
|
|
97
|
+
? `Session store failed to list — ${err.message}`
|
|
98
|
+
: `Session store failed to list — ${String(err)}`,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
// GET /ggui/console/timeline/:sessionId/events
|
|
103
|
+
app.get('/ggui/console/timeline/:sessionId/events', async (req, res) => {
|
|
104
|
+
applyDevtoolSecurityHeaders(res);
|
|
105
|
+
const sessionId = req.params['sessionId'];
|
|
106
|
+
if (!sessionId || sessionId.length === 0) {
|
|
107
|
+
res.status(400).json({
|
|
108
|
+
error: 'invalid_session_id',
|
|
109
|
+
message: 'sessionId path parameter is required',
|
|
110
|
+
});
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
// Zero-config shape: no store wired → empty events.
|
|
114
|
+
if (!sessionStore) {
|
|
115
|
+
const body = {
|
|
116
|
+
sessionId,
|
|
117
|
+
events: [],
|
|
118
|
+
streamSeq: 0,
|
|
119
|
+
status: 'unknown',
|
|
120
|
+
};
|
|
121
|
+
res.json(body);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
const session = await sessionStore.get(sessionId);
|
|
126
|
+
if (!session) {
|
|
127
|
+
// 404 is the right status — but we still return a well-
|
|
128
|
+
// formed body so the SPA can render an "expired/dropped"
|
|
129
|
+
// notice without a special-case branch. Body matches the
|
|
130
|
+
// schema; status code disambiguates.
|
|
131
|
+
const body = {
|
|
132
|
+
sessionId,
|
|
133
|
+
events: [],
|
|
134
|
+
streamSeq: 0,
|
|
135
|
+
status: 'unknown',
|
|
136
|
+
};
|
|
137
|
+
res.status(404).json(body);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const events = await drainSessionEvents(sessionStore, sessionId);
|
|
141
|
+
let streamSeq = 0;
|
|
142
|
+
if (streamBuffer) {
|
|
143
|
+
try {
|
|
144
|
+
streamSeq = await streamBuffer.currentSeq(sessionId);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
// Best-effort. Inbound events stand on their own.
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const now = Date.now();
|
|
151
|
+
const status = session.expiresAt <= now ? 'expired' : 'active';
|
|
152
|
+
const body = {
|
|
153
|
+
sessionId,
|
|
154
|
+
events,
|
|
155
|
+
streamSeq,
|
|
156
|
+
status,
|
|
157
|
+
};
|
|
158
|
+
res.json(body);
|
|
159
|
+
}
|
|
160
|
+
catch (err) {
|
|
161
|
+
res.status(500).json({
|
|
162
|
+
error: 'timeline_events_drain_failed',
|
|
163
|
+
message: err instanceof Error
|
|
164
|
+
? `Session store failed to drain — ${err.message}`
|
|
165
|
+
: `Session store failed to drain — ${String(err)}`,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Console-facing validator-trace sink + REST/SSE endpoints powering
|
|
3
|
+
* `/devtools/validator` in the @ggui-ai/console SPA.
|
|
4
|
+
*
|
|
5
|
+
* This is the OSS-default sink the `ggui serve` process registers via
|
|
6
|
+
* {@link setValidatorTraceSink}. A hosted closed runtime may swap in a
|
|
7
|
+
* durable sink (e.g. Redis-backed) — the harness only knows about the
|
|
8
|
+
* {@link ValidatorTraceSink} contract.
|
|
9
|
+
*
|
|
10
|
+
* **Two surfaces, both admin-gated:**
|
|
11
|
+
* - `GET /ggui/console/validator/recent?limit=<n>` — JSON snapshot
|
|
12
|
+
* of the ring buffer's most recent N events, oldest-first within
|
|
13
|
+
* the page. Used for initial page load.
|
|
14
|
+
* - `GET /ggui/console/validator/stream` — SSE stream of new events
|
|
15
|
+
* as they fire. Heartbeat every 15s to keep proxies awake.
|
|
16
|
+
*
|
|
17
|
+
* **Memory bound.** Default capacity = 200 events. Each event carries
|
|
18
|
+
* the source-under-check (capped at ~16KB by truncateSourceForTrace)
|
|
19
|
+
* plus the full issues array — at ~20-30KB/event that's ~4-6MB peak.
|
|
20
|
+
* Operator can override via `createGguiServer({ validatorTrace: {
|
|
21
|
+
* capacity }})` if running on a small box.
|
|
22
|
+
*/
|
|
23
|
+
import type { Express } from 'express';
|
|
24
|
+
import type { ValidatorTraceEvent, ValidatorTraceSink } from '@ggui-ai/ui-gen/harness/validator-trace-sink';
|
|
25
|
+
/** SSE listener — receives one event per runCheck invocation. */
|
|
26
|
+
type SseListener = (event: ValidatorTraceEvent) => void;
|
|
27
|
+
/**
|
|
28
|
+
* In-memory ring buffer + listener fanout. Implements
|
|
29
|
+
* {@link ValidatorTraceSink} so it can be passed to
|
|
30
|
+
* {@link setValidatorTraceSink}.
|
|
31
|
+
*
|
|
32
|
+
* **Why a class, not a closure.** Tests + operators read state
|
|
33
|
+
* (`recent()`, listener count) — instance methods on a class beat a
|
|
34
|
+
* pile of getter functions captured in scope. The shape is also the
|
|
35
|
+
* extension point if a hosted closed runtime wants to subclass and
|
|
36
|
+
* pipe events to Redis / DDB / S3 in addition to the ring buffer.
|
|
37
|
+
*/
|
|
38
|
+
export declare class BoundedValidatorTraceSink implements ValidatorTraceSink {
|
|
39
|
+
private readonly capacity;
|
|
40
|
+
private readonly buffer;
|
|
41
|
+
private readonly listeners;
|
|
42
|
+
constructor(opts?: {
|
|
43
|
+
readonly capacity?: number;
|
|
44
|
+
});
|
|
45
|
+
emit(event: ValidatorTraceEvent): void;
|
|
46
|
+
/**
|
|
47
|
+
* Snapshot of the most-recent `limit` events, oldest-first within
|
|
48
|
+
* the returned slice (so the operator UI can append in chronological
|
|
49
|
+
* order without re-sorting).
|
|
50
|
+
*/
|
|
51
|
+
recent(limit: number): readonly ValidatorTraceEvent[];
|
|
52
|
+
/** Subscribe to live events. Returns an unsubscribe function. */
|
|
53
|
+
subscribe(listener: SseListener): () => void;
|
|
54
|
+
/** Listener count — for tests + the eventual `/devtools/info` view. */
|
|
55
|
+
listenerCount(): number;
|
|
56
|
+
/** Buffer size — for tests + future bound enforcement assertions. */
|
|
57
|
+
size(): number;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Mount the `/ggui/console/validator/recent` + `/.../stream` routes on
|
|
61
|
+
* `app`. Caller is responsible for installing the admin gate
|
|
62
|
+
* middleware on these paths beforehand — this function does not
|
|
63
|
+
* re-implement auth.
|
|
64
|
+
*/
|
|
65
|
+
export declare function mountConsoleValidatorRoutes(app: Express, sink: BoundedValidatorTraceSink): void;
|
|
66
|
+
export {};
|
|
67
|
+
//# sourceMappingURL=console-validator.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"console-validator.d.ts","sourceRoot":"","sources":["../src/console-validator.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,OAAO,KAAK,EAAqB,OAAO,EAAE,MAAM,SAAS,CAAC;AAC1D,OAAO,KAAK,EACV,mBAAmB,EACnB,kBAAkB,EACnB,MAAM,8CAA8C,CAAC;AAGtD,iEAAiE;AACjE,KAAK,WAAW,GAAG,CAAC,KAAK,EAAE,mBAAmB,KAAK,IAAI,CAAC;AAExD;;;;;;;;;;GAUG;AACH,qBAAa,yBAA0B,YAAW,kBAAkB;IAClE,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAClC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAA6B;IACpD,OAAO,CAAC,QAAQ,CAAC,SAAS,CAA0B;gBAExC,IAAI,CAAC,EAAE;QAAE,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;KAAE;IAUjD,IAAI,CAAC,KAAK,EAAE,mBAAmB,GAAG,IAAI;IActC;;;;OAIG;IACH,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,mBAAmB,EAAE;IAKrD,iEAAiE;IACjE,SAAS,CAAC,QAAQ,EAAE,WAAW,GAAG,MAAM,IAAI;IAO5C,uEAAuE;IACvE,aAAa,IAAI,MAAM;IAIvB,qEAAqE;IACrE,IAAI,IAAI,MAAM;CAGf;AAED;;;;;GAKG;AACH,wBAAgB,2BAA2B,CACzC,GAAG,EAAE,OAAO,EACZ,IAAI,EAAE,yBAAyB,GAC9B,IAAI,CA8CN"}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { applyDevtoolSecurityHeaders } from './console-headers.js';
|
|
2
|
+
/**
|
|
3
|
+
* In-memory ring buffer + listener fanout. Implements
|
|
4
|
+
* {@link ValidatorTraceSink} so it can be passed to
|
|
5
|
+
* {@link setValidatorTraceSink}.
|
|
6
|
+
*
|
|
7
|
+
* **Why a class, not a closure.** Tests + operators read state
|
|
8
|
+
* (`recent()`, listener count) — instance methods on a class beat a
|
|
9
|
+
* pile of getter functions captured in scope. The shape is also the
|
|
10
|
+
* extension point if a hosted closed runtime wants to subclass and
|
|
11
|
+
* pipe events to Redis / DDB / S3 in addition to the ring buffer.
|
|
12
|
+
*/
|
|
13
|
+
export class BoundedValidatorTraceSink {
|
|
14
|
+
capacity;
|
|
15
|
+
buffer = [];
|
|
16
|
+
listeners = new Set();
|
|
17
|
+
constructor(opts) {
|
|
18
|
+
const cap = opts?.capacity ?? 200;
|
|
19
|
+
if (!Number.isFinite(cap) || cap <= 0) {
|
|
20
|
+
throw new Error(`BoundedValidatorTraceSink: capacity must be a positive integer, got ${cap}`);
|
|
21
|
+
}
|
|
22
|
+
this.capacity = Math.floor(cap);
|
|
23
|
+
}
|
|
24
|
+
emit(event) {
|
|
25
|
+
this.buffer.push(event);
|
|
26
|
+
if (this.buffer.length > this.capacity) {
|
|
27
|
+
this.buffer.shift();
|
|
28
|
+
}
|
|
29
|
+
for (const listener of this.listeners) {
|
|
30
|
+
try {
|
|
31
|
+
listener(event);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// One bad listener must not block fan-out to others.
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Snapshot of the most-recent `limit` events, oldest-first within
|
|
40
|
+
* the returned slice (so the operator UI can append in chronological
|
|
41
|
+
* order without re-sorting).
|
|
42
|
+
*/
|
|
43
|
+
recent(limit) {
|
|
44
|
+
const n = Math.max(0, Math.min(limit, this.buffer.length));
|
|
45
|
+
return this.buffer.slice(-n);
|
|
46
|
+
}
|
|
47
|
+
/** Subscribe to live events. Returns an unsubscribe function. */
|
|
48
|
+
subscribe(listener) {
|
|
49
|
+
this.listeners.add(listener);
|
|
50
|
+
return () => {
|
|
51
|
+
this.listeners.delete(listener);
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/** Listener count — for tests + the eventual `/devtools/info` view. */
|
|
55
|
+
listenerCount() {
|
|
56
|
+
return this.listeners.size;
|
|
57
|
+
}
|
|
58
|
+
/** Buffer size — for tests + future bound enforcement assertions. */
|
|
59
|
+
size() {
|
|
60
|
+
return this.buffer.length;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Mount the `/ggui/console/validator/recent` + `/.../stream` routes on
|
|
65
|
+
* `app`. Caller is responsible for installing the admin gate
|
|
66
|
+
* middleware on these paths beforehand — this function does not
|
|
67
|
+
* re-implement auth.
|
|
68
|
+
*/
|
|
69
|
+
export function mountConsoleValidatorRoutes(app, sink) {
|
|
70
|
+
// GET /ggui/console/validator/recent?limit=<n> — JSON snapshot.
|
|
71
|
+
app.get('/ggui/console/validator/recent', (req, res) => {
|
|
72
|
+
applyDevtoolSecurityHeaders(res);
|
|
73
|
+
const limitRaw = req.query['limit'];
|
|
74
|
+
let limit = 100;
|
|
75
|
+
if (typeof limitRaw === 'string') {
|
|
76
|
+
const parsed = Number.parseInt(limitRaw, 10);
|
|
77
|
+
if (Number.isFinite(parsed) && parsed > 0) {
|
|
78
|
+
limit = Math.min(500, parsed);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
res.json({ events: sink.recent(limit) });
|
|
82
|
+
});
|
|
83
|
+
// GET /ggui/console/validator/stream — SSE live stream.
|
|
84
|
+
// Heartbeat comment every 15s so reverse proxies don't kill the
|
|
85
|
+
// connection on idle. Client cleanup unregisters the listener.
|
|
86
|
+
app.get('/ggui/console/validator/stream', (req, res) => {
|
|
87
|
+
applyDevtoolSecurityHeaders(res);
|
|
88
|
+
res.setHeader('content-type', 'text/event-stream');
|
|
89
|
+
res.setHeader('cache-control', 'no-store');
|
|
90
|
+
res.setHeader('connection', 'keep-alive');
|
|
91
|
+
// Flush headers immediately so the EventSource starts receiving.
|
|
92
|
+
res.flushHeaders?.();
|
|
93
|
+
const heartbeat = setInterval(() => {
|
|
94
|
+
// SSE comment frame — clients ignore but proxies see traffic.
|
|
95
|
+
res.write(': ping\n\n');
|
|
96
|
+
}, 15000);
|
|
97
|
+
const off = sink.subscribe((event) => {
|
|
98
|
+
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
99
|
+
});
|
|
100
|
+
req.on('close', () => {
|
|
101
|
+
clearInterval(heartbeat);
|
|
102
|
+
off();
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { OperatorConfig } from '@ggui-ai/project-config';
|
|
2
|
+
export interface WelcomePageInputs {
|
|
3
|
+
readonly operator?: OperatorConfig;
|
|
4
|
+
readonly appName?: string;
|
|
5
|
+
}
|
|
6
|
+
export declare const renderWelcomeHtml: (inputs: WelcomePageInputs, serverName: string) => string;
|
|
7
|
+
//# sourceMappingURL=console-welcome.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"console-welcome.d.ts","sourceRoot":"","sources":["../src/console-welcome.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,yBAAyB,CAAC;AAE9D,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,QAAQ,CAAC,EAAE,cAAc,CAAC;IACnC,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;CAC3B;AAoDD,eAAO,MAAM,iBAAiB,GAC5B,QAAQ,iBAAiB,EACzB,YAAY,MAAM,KACjB,MA6KF,CAAC"}
|