heyhank 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -0
- package/bin/cli.ts +168 -0
- package/bin/ctl.ts +528 -0
- package/bin/generate-token.ts +28 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/AgentsPage-BPhirnCe.js +7 -0
- package/dist/assets/AssistantPage-DJ-cMQfb.js +1 -0
- package/dist/assets/CronManager-DDbz-yiT.js +1 -0
- package/dist/assets/HelpPage-DMfkzERp.js +1 -0
- package/dist/assets/IntegrationsPage-CrOitCmJ.js +1 -0
- package/dist/assets/MediaPage-CE5rdvkC.js +1 -0
- package/dist/assets/PlatformDashboard-Do6F0O2p.js +1 -0
- package/dist/assets/Playground-Fc5cdc5p.js +109 -0
- package/dist/assets/ProcessPanel-CslEiZkI.js +2 -0
- package/dist/assets/PromptsPage-D2EhsdNO.js +4 -0
- package/dist/assets/RunsPage-C5BZF5Rx.js +1 -0
- package/dist/assets/SandboxManager-a1AVI5q2.js +8 -0
- package/dist/assets/SettingsPage-DirhjQrJ.js +51 -0
- package/dist/assets/SocialMediaPage-DBuM28vD.js +1 -0
- package/dist/assets/TailscalePage-CHiFhZXF.js +1 -0
- package/dist/assets/TelephonyPage-x0VV0fOo.js +1 -0
- package/dist/assets/TerminalPage-Drwyrnfd.js +1 -0
- package/dist/assets/gemini-audio-t-TSU-To.js +17 -0
- package/dist/assets/gemini-live-client-C7rqAW7G.js +166 -0
- package/dist/assets/index-C8M_PUmX.css +32 -0
- package/dist/assets/index-CEqZnThB.js +204 -0
- package/dist/assets/sw-register-LSSpj6RU.js +1 -0
- package/dist/assets/time-ago-B6r_l9u1.js +1 -0
- package/dist/assets/workbox-window.prod.es5-BIl4cyR9.js +2 -0
- package/dist/favicon-32-original.png +0 -0
- package/dist/favicon-32.png +0 -0
- package/dist/favicon.ico +0 -0
- package/dist/favicon.svg +8 -0
- package/dist/fonts/MesloLGSNerdFontMono-Bold.woff2 +0 -0
- package/dist/fonts/MesloLGSNerdFontMono-Regular.woff2 +0 -0
- package/dist/heyhank-mascot-poster.png +0 -0
- package/dist/heyhank-mascot.mp4 +0 -0
- package/dist/heyhank-mascot.webm +0 -0
- package/dist/icon-192-original.png +0 -0
- package/dist/icon-192.png +0 -0
- package/dist/icon-512-original.png +0 -0
- package/dist/icon-512.png +0 -0
- package/dist/index.html +21 -0
- package/dist/logo-192.png +0 -0
- package/dist/logo-512.png +0 -0
- package/dist/logo-codex.svg +14 -0
- package/dist/logo-docker.svg +4 -0
- package/dist/logo-original.png +0 -0
- package/dist/logo.png +0 -0
- package/dist/logo.svg +14 -0
- package/dist/manifest.json +24 -0
- package/dist/push-sw.js +34 -0
- package/dist/sw.js +1 -0
- package/dist/workbox-d2a0910a.js +1 -0
- package/package.json +109 -0
- package/server/agent-cron-migrator.ts +85 -0
- package/server/agent-executor.ts +357 -0
- package/server/agent-store.ts +185 -0
- package/server/agent-timeout.ts +107 -0
- package/server/agent-types.ts +122 -0
- package/server/ai-validation-settings.ts +37 -0
- package/server/ai-validator.ts +181 -0
- package/server/anthropic-provider-migration.ts +48 -0
- package/server/assistant-store.ts +272 -0
- package/server/auth-manager.ts +150 -0
- package/server/auto-approve.ts +153 -0
- package/server/auto-namer.ts +36 -0
- package/server/backend-adapter.ts +54 -0
- package/server/cache-headers.ts +61 -0
- package/server/calendar-service.ts +434 -0
- package/server/claude-adapter.ts +889 -0
- package/server/claude-container-auth.ts +30 -0
- package/server/claude-session-discovery.ts +157 -0
- package/server/claude-session-history.ts +410 -0
- package/server/cli-launcher.ts +1303 -0
- package/server/codex-adapter.ts +3027 -0
- package/server/codex-container-auth.ts +24 -0
- package/server/codex-home.ts +27 -0
- package/server/codex-ws-proxy.cjs +226 -0
- package/server/commands-discovery.ts +81 -0
- package/server/constants.ts +7 -0
- package/server/container-manager.ts +1053 -0
- package/server/cost-tracker.ts +222 -0
- package/server/cron-scheduler.ts +243 -0
- package/server/cron-store.ts +148 -0
- package/server/cron-types.ts +63 -0
- package/server/email-service.ts +354 -0
- package/server/env-manager.ts +161 -0
- package/server/event-bus-types.ts +75 -0
- package/server/event-bus.ts +124 -0
- package/server/execution-store.ts +170 -0
- package/server/federation/node-connection.ts +190 -0
- package/server/federation/node-manager.ts +366 -0
- package/server/federation/node-store.ts +86 -0
- package/server/federation/node-types.ts +121 -0
- package/server/fs-utils.ts +15 -0
- package/server/git-utils.ts +421 -0
- package/server/github-pr.ts +379 -0
- package/server/google-media.ts +342 -0
- package/server/image-pull-manager.ts +279 -0
- package/server/index.ts +491 -0
- package/server/internal-ai.ts +237 -0
- package/server/kill-switch.ts +99 -0
- package/server/llm-providers.ts +342 -0
- package/server/logger.ts +259 -0
- package/server/mcp-registry.ts +401 -0
- package/server/message-bus.ts +271 -0
- package/server/message-delivery.ts +128 -0
- package/server/metrics-collector.ts +350 -0
- package/server/metrics-types.ts +108 -0
- package/server/middleware/managed-auth.ts +195 -0
- package/server/novnc-proxy.ts +99 -0
- package/server/path-resolver.ts +186 -0
- package/server/paths.ts +13 -0
- package/server/pr-poller.ts +162 -0
- package/server/prompt-manager.ts +211 -0
- package/server/protocol/claude-upstream/README.md +19 -0
- package/server/protocol/claude-upstream/sdk.d.ts.txt +1943 -0
- package/server/protocol/codex-upstream/ClientNotification.ts.txt +5 -0
- package/server/protocol/codex-upstream/ClientRequest.ts.txt +60 -0
- package/server/protocol/codex-upstream/README.md +18 -0
- package/server/protocol/codex-upstream/ServerNotification.ts.txt +41 -0
- package/server/protocol/codex-upstream/ServerRequest.ts.txt +16 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallParams.ts.txt +6 -0
- package/server/protocol/codex-upstream/v2/DynamicToolCallResponse.ts.txt +6 -0
- package/server/protocol-monitor.ts +50 -0
- package/server/provider-manager.ts +111 -0
- package/server/provider-registry.ts +393 -0
- package/server/push-notifications.ts +221 -0
- package/server/recorder.ts +374 -0
- package/server/recording-hub/compat-validator.ts +284 -0
- package/server/recording-hub/diagnostics.ts +299 -0
- package/server/recording-hub/hub-config.ts +19 -0
- package/server/recording-hub/hub-routes.ts +236 -0
- package/server/recording-hub/hub-store.ts +265 -0
- package/server/recording-hub/replay-adapter.ts +207 -0
- package/server/relay-client.ts +320 -0
- package/server/reminder-scheduler.ts +38 -0
- package/server/replay.ts +78 -0
- package/server/routes/agent-routes.ts +264 -0
- package/server/routes/assistant-routes.ts +90 -0
- package/server/routes/cron-routes.ts +103 -0
- package/server/routes/env-routes.ts +95 -0
- package/server/routes/federation-routes.ts +76 -0
- package/server/routes/fs-routes.ts +622 -0
- package/server/routes/git-routes.ts +97 -0
- package/server/routes/llm-routes.ts +166 -0
- package/server/routes/media-routes.ts +135 -0
- package/server/routes/metrics-routes.ts +13 -0
- package/server/routes/platform-routes.ts +1379 -0
- package/server/routes/prompt-routes.ts +67 -0
- package/server/routes/provider-routes.ts +109 -0
- package/server/routes/sandbox-routes.ts +127 -0
- package/server/routes/settings-routes.ts +285 -0
- package/server/routes/skills-routes.ts +100 -0
- package/server/routes/socialmedia-routes.ts +208 -0
- package/server/routes/system-routes.ts +228 -0
- package/server/routes/tailscale-routes.ts +22 -0
- package/server/routes/telephony-routes.ts +259 -0
- package/server/routes.ts +1379 -0
- package/server/sandbox-manager.ts +168 -0
- package/server/service.ts +718 -0
- package/server/session-creation-service.ts +457 -0
- package/server/session-git-info.ts +104 -0
- package/server/session-names.ts +67 -0
- package/server/session-orchestrator.ts +824 -0
- package/server/session-state-machine.ts +207 -0
- package/server/session-store.ts +146 -0
- package/server/session-types.ts +511 -0
- package/server/settings-manager.ts +149 -0
- package/server/shared-context.ts +157 -0
- package/server/socialmedia/adapter.ts +15 -0
- package/server/socialmedia/adapters/ayrshare-adapter.ts +169 -0
- package/server/socialmedia/adapters/buffer-adapter.ts +299 -0
- package/server/socialmedia/adapters/postiz-adapter.ts +298 -0
- package/server/socialmedia/manager.ts +227 -0
- package/server/socialmedia/store.ts +98 -0
- package/server/socialmedia/types.ts +89 -0
- package/server/tailscale-manager.ts +451 -0
- package/server/telephony/audio-bridge.ts +331 -0
- package/server/telephony/call-manager.ts +457 -0
- package/server/telephony/call-types.ts +108 -0
- package/server/telephony/telephony-store.ts +119 -0
- package/server/terminal-manager.ts +240 -0
- package/server/update-checker.ts +192 -0
- package/server/usage-limits.ts +225 -0
- package/server/web-push.d.ts +51 -0
- package/server/worktree-tracker.ts +84 -0
- package/server/ws-auth.ts +41 -0
- package/server/ws-bridge-browser-ingest.ts +72 -0
- package/server/ws-bridge-browser.ts +112 -0
- package/server/ws-bridge-cli-ingest.ts +81 -0
- package/server/ws-bridge-codex.ts +266 -0
- package/server/ws-bridge-controls.ts +20 -0
- package/server/ws-bridge-persist.ts +66 -0
- package/server/ws-bridge-publish.ts +79 -0
- package/server/ws-bridge-replay.ts +61 -0
- package/server/ws-bridge-types.ts +121 -0
- package/server/ws-bridge.ts +1240 -0
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Disconnection diagnostics for recorded sessions.
|
|
3
|
+
*
|
|
4
|
+
* Analyzes recording entries for connection lifecycle events and data gaps
|
|
5
|
+
* to identify disconnection patterns and potential causes.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Recording } from "../replay.js";
|
|
9
|
+
import type { RecordingEntry } from "../recorder.js";
|
|
10
|
+
|
|
11
|
+
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
export interface TimelineEntry {
|
|
14
|
+
ts: number;
|
|
15
|
+
event: string;
|
|
16
|
+
channel: "cli" | "browser";
|
|
17
|
+
detail?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface DisconnectionEvent {
|
|
21
|
+
ts: number;
|
|
22
|
+
channel: "cli" | "browser";
|
|
23
|
+
closeCode?: number;
|
|
24
|
+
closeReason?: string;
|
|
25
|
+
reconnectedAt?: number;
|
|
26
|
+
gapMs: number;
|
|
27
|
+
messagesLostEstimate: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface DisconnectionReport {
|
|
31
|
+
sessionId: string;
|
|
32
|
+
backendType: string;
|
|
33
|
+
totalDuration: number;
|
|
34
|
+
totalDisconnections: number;
|
|
35
|
+
disconnections: DisconnectionEvent[];
|
|
36
|
+
patterns: string[];
|
|
37
|
+
dataGaps: DataGap[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface DataGap {
|
|
41
|
+
startTs: number;
|
|
42
|
+
endTs: number;
|
|
43
|
+
gapMs: number;
|
|
44
|
+
channel: "cli" | "browser";
|
|
45
|
+
messagesBefore: number;
|
|
46
|
+
messagesAfter: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
/** Gaps longer than this in CLI messages suggest a disconnection. */
|
|
52
|
+
const CLI_GAP_THRESHOLD_MS = 30_000;
|
|
53
|
+
/** Minimum number of disconnections to detect a pattern. */
|
|
54
|
+
const PATTERN_MIN_COUNT = 3;
|
|
55
|
+
/** Tolerance for regular interval detection (±20%). */
|
|
56
|
+
const INTERVAL_TOLERANCE = 0.2;
|
|
57
|
+
|
|
58
|
+
// ─── Analysis ────────────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Analyze a recording for disconnection patterns.
|
|
62
|
+
*
|
|
63
|
+
* Works with both legacy recordings (data messages only) and enhanced
|
|
64
|
+
* recordings that include connection lifecycle events.
|
|
65
|
+
*/
|
|
66
|
+
export function analyzeDisconnections(recording: Recording): DisconnectionReport {
|
|
67
|
+
const entries = recording.entries;
|
|
68
|
+
const header = recording.header;
|
|
69
|
+
|
|
70
|
+
// Build timeline from both lifecycle events and data gap analysis
|
|
71
|
+
const timeline = buildTimeline(recording);
|
|
72
|
+
const disconnections = detectDisconnections(entries, timeline);
|
|
73
|
+
const dataGaps = detectDataGaps(entries);
|
|
74
|
+
const patterns = detectPatterns(disconnections, dataGaps);
|
|
75
|
+
|
|
76
|
+
const firstTs = entries[0]?.ts ?? header.started_at;
|
|
77
|
+
const lastTs = entries[entries.length - 1]?.ts ?? firstTs;
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
sessionId: header.session_id,
|
|
81
|
+
backendType: header.backend_type,
|
|
82
|
+
totalDuration: lastTs - firstTs,
|
|
83
|
+
totalDisconnections: disconnections.length,
|
|
84
|
+
disconnections,
|
|
85
|
+
patterns,
|
|
86
|
+
dataGaps,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Build a timeline of connection events from a recording.
|
|
92
|
+
*
|
|
93
|
+
* Extracts both explicit lifecycle events (ws_open, ws_close, etc.) from
|
|
94
|
+
* enhanced recordings and infers events from data gaps in legacy recordings.
|
|
95
|
+
*/
|
|
96
|
+
export function buildTimeline(recording: Recording): TimelineEntry[] {
|
|
97
|
+
const timeline: TimelineEntry[] = [];
|
|
98
|
+
|
|
99
|
+
for (const entry of recording.entries) {
|
|
100
|
+
// Enhanced recordings have explicit lifecycle events
|
|
101
|
+
const enhanced = entry as RecordingEntry & { event?: string; meta?: Record<string, unknown> };
|
|
102
|
+
if (enhanced.event) {
|
|
103
|
+
timeline.push({
|
|
104
|
+
ts: entry.ts,
|
|
105
|
+
event: enhanced.event,
|
|
106
|
+
channel: entry.ch,
|
|
107
|
+
detail: enhanced.meta ? JSON.stringify(enhanced.meta) : undefined,
|
|
108
|
+
});
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// For data messages, track key protocol events
|
|
113
|
+
if (entry.dir === "out" && entry.ch === "browser") {
|
|
114
|
+
try {
|
|
115
|
+
const msg = JSON.parse(entry.raw);
|
|
116
|
+
if (msg.type === "cli_connected") {
|
|
117
|
+
timeline.push({ ts: entry.ts, event: "cli_connected", channel: "cli" });
|
|
118
|
+
} else if (msg.type === "cli_disconnected") {
|
|
119
|
+
timeline.push({ ts: entry.ts, event: "cli_disconnected", channel: "cli" });
|
|
120
|
+
} else if (msg.type === "session_init") {
|
|
121
|
+
timeline.push({ ts: entry.ts, event: "session_init", channel: "cli" });
|
|
122
|
+
}
|
|
123
|
+
} catch {
|
|
124
|
+
// Skip unparseable
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return timeline.sort((a, b) => a.ts - b.ts);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ─── Internal helpers ────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
function detectDisconnections(
|
|
135
|
+
entries: RecordingEntry[],
|
|
136
|
+
timeline: TimelineEntry[],
|
|
137
|
+
): DisconnectionEvent[] {
|
|
138
|
+
const disconnections: DisconnectionEvent[] = [];
|
|
139
|
+
|
|
140
|
+
// From explicit timeline events
|
|
141
|
+
for (let i = 0; i < timeline.length; i++) {
|
|
142
|
+
const event = timeline[i];
|
|
143
|
+
if (event.event === "ws_close" || event.event === "cli_disconnected") {
|
|
144
|
+
// Find next reconnect/connect event on same channel
|
|
145
|
+
let reconnectedAt: number | undefined;
|
|
146
|
+
for (let j = i + 1; j < timeline.length; j++) {
|
|
147
|
+
if (
|
|
148
|
+
timeline[j].channel === event.channel &&
|
|
149
|
+
(timeline[j].event === "ws_open" || timeline[j].event === "cli_connected" || timeline[j].event === "reconnect_success")
|
|
150
|
+
) {
|
|
151
|
+
reconnectedAt = timeline[j].ts;
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Estimate messages lost during the gap
|
|
157
|
+
let messagesLost = 0;
|
|
158
|
+
if (reconnectedAt) {
|
|
159
|
+
// Count messages that arrived on the other channel during the gap
|
|
160
|
+
messagesLost = entries.filter(
|
|
161
|
+
(e) =>
|
|
162
|
+
e.ts > event.ts &&
|
|
163
|
+
e.ts < reconnectedAt! &&
|
|
164
|
+
e.ch !== event.channel,
|
|
165
|
+
).length;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
let meta: Record<string, unknown> | undefined;
|
|
169
|
+
try {
|
|
170
|
+
meta = event.detail ? JSON.parse(event.detail) : undefined;
|
|
171
|
+
} catch {
|
|
172
|
+
// ignore
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
disconnections.push({
|
|
176
|
+
ts: event.ts,
|
|
177
|
+
channel: event.channel,
|
|
178
|
+
closeCode: meta?.code as number | undefined,
|
|
179
|
+
closeReason: meta?.reason as string | undefined,
|
|
180
|
+
reconnectedAt,
|
|
181
|
+
gapMs: reconnectedAt ? reconnectedAt - event.ts : 0,
|
|
182
|
+
messagesLostEstimate: messagesLost,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Deduplicate: ws_close and cli_disconnected for the same outage.
|
|
188
|
+
// Only dedup if the second disconnect happens before the first one reconnected.
|
|
189
|
+
const deduped: DisconnectionEvent[] = [];
|
|
190
|
+
for (const d of disconnections) {
|
|
191
|
+
const isDuplicate = deduped.some(
|
|
192
|
+
(existing) =>
|
|
193
|
+
existing.channel === d.channel &&
|
|
194
|
+
// Only dedup if this disconnect happened before the previous one reconnected
|
|
195
|
+
// (i.e. same outage, not a new one after recovery)
|
|
196
|
+
(!existing.reconnectedAt || d.ts < existing.reconnectedAt),
|
|
197
|
+
);
|
|
198
|
+
if (!isDuplicate) deduped.push(d);
|
|
199
|
+
}
|
|
200
|
+
return deduped;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function detectDataGaps(entries: RecordingEntry[]): DataGap[] {
|
|
204
|
+
const gaps: DataGap[] = [];
|
|
205
|
+
|
|
206
|
+
// Group entries by channel
|
|
207
|
+
const cliEntries = entries.filter((e) => e.ch === "cli" && e.dir === "in" && !e.event);
|
|
208
|
+
const browserEntries = entries.filter((e) => e.ch === "browser" && e.dir === "in" && !e.event);
|
|
209
|
+
|
|
210
|
+
for (const [channel, channelEntries] of [
|
|
211
|
+
["cli", cliEntries],
|
|
212
|
+
["browser", browserEntries],
|
|
213
|
+
] as const) {
|
|
214
|
+
for (let i = 1; i < channelEntries.length; i++) {
|
|
215
|
+
const gapMs = channelEntries[i].ts - channelEntries[i - 1].ts;
|
|
216
|
+
if (gapMs > CLI_GAP_THRESHOLD_MS) {
|
|
217
|
+
gaps.push({
|
|
218
|
+
startTs: channelEntries[i - 1].ts,
|
|
219
|
+
endTs: channelEntries[i].ts,
|
|
220
|
+
gapMs,
|
|
221
|
+
channel,
|
|
222
|
+
messagesBefore: i,
|
|
223
|
+
messagesAfter: channelEntries.length - i,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return gaps.sort((a, b) => a.startTs - b.startTs);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function detectPatterns(
|
|
233
|
+
disconnections: DisconnectionEvent[],
|
|
234
|
+
dataGaps: DataGap[],
|
|
235
|
+
): string[] {
|
|
236
|
+
const patterns: string[] = [];
|
|
237
|
+
|
|
238
|
+
// Pattern: Keep-alive failure (regular interval disconnections)
|
|
239
|
+
if (disconnections.length >= PATTERN_MIN_COUNT) {
|
|
240
|
+
const cliDisconnections = disconnections.filter((d) => d.channel === "cli");
|
|
241
|
+
if (cliDisconnections.length >= PATTERN_MIN_COUNT) {
|
|
242
|
+
const intervals = [];
|
|
243
|
+
for (let i = 1; i < cliDisconnections.length; i++) {
|
|
244
|
+
intervals.push(cliDisconnections[i].ts - cliDisconnections[i - 1].ts);
|
|
245
|
+
}
|
|
246
|
+
const avgInterval = intervals.reduce((a, b) => a + b, 0) / intervals.length;
|
|
247
|
+
const allClose = intervals.every(
|
|
248
|
+
(iv) => Math.abs(iv - avgInterval) / avgInterval < INTERVAL_TOLERANCE,
|
|
249
|
+
);
|
|
250
|
+
if (allClose) {
|
|
251
|
+
patterns.push(
|
|
252
|
+
`Regular CLI disconnections every ~${Math.round(avgInterval / 1000)}s — possible keep-alive or timeout issue`,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Pattern: Rapid reconnect cycling
|
|
259
|
+
const rapidReconnects = disconnections.filter(
|
|
260
|
+
(d) => d.reconnectedAt && d.gapMs < 5000,
|
|
261
|
+
);
|
|
262
|
+
if (rapidReconnects.length >= PATTERN_MIN_COUNT) {
|
|
263
|
+
patterns.push(
|
|
264
|
+
`${rapidReconnects.length} rapid reconnections (< 5s gap) — possible flapping connection`,
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Pattern: Large data gaps without explicit disconnect events
|
|
269
|
+
const unexplainedGaps = dataGaps.filter((g) => {
|
|
270
|
+
// Check if any disconnection event falls within this gap
|
|
271
|
+
return !disconnections.some(
|
|
272
|
+
(d) => d.ts >= g.startTs && d.ts <= g.endTs,
|
|
273
|
+
);
|
|
274
|
+
});
|
|
275
|
+
if (unexplainedGaps.length > 0) {
|
|
276
|
+
patterns.push(
|
|
277
|
+
`${unexplainedGaps.length} data gap(s) without recorded disconnect events — possible silent connection drops`,
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Pattern: Asymmetric disconnection (CLI drops but browser stays)
|
|
282
|
+
const cliOnly = disconnections.filter((d) => d.channel === "cli");
|
|
283
|
+
const browserOnly = disconnections.filter((d) => d.channel === "browser");
|
|
284
|
+
if (cliOnly.length > 0 && browserOnly.length === 0) {
|
|
285
|
+
patterns.push(
|
|
286
|
+
`All ${cliOnly.length} disconnection(s) are CLI-side — browser connections are stable. Check CLI process health.`,
|
|
287
|
+
);
|
|
288
|
+
} else if (browserOnly.length > 0 && cliOnly.length === 0) {
|
|
289
|
+
patterns.push(
|
|
290
|
+
`All ${browserOnly.length} disconnection(s) are browser-side — CLI connection is stable. Check network/proxy.`,
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (patterns.length === 0 && disconnections.length === 0 && dataGaps.length === 0) {
|
|
295
|
+
patterns.push("No disconnection issues detected in this recording.");
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return patterns;
|
|
299
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Feature gate for the Recording Hub.
|
|
3
|
+
*
|
|
4
|
+
* The hub is disabled by default. Enable with HEYHANK_RECORDING_HUB=1 (or COMPANION_RECORDING_HUB=1).
|
|
5
|
+
* When disabled, hub routes are not registered and hub storage is not initialized.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const DEFAULT_MAX_UPLOAD_MB = 50;
|
|
9
|
+
|
|
10
|
+
export function isRecordingHubEnabled(): boolean {
|
|
11
|
+
const env = process.env.HEYHANK_RECORDING_HUB || process.env.COMPANION_RECORDING_HUB;
|
|
12
|
+
return env === "1" || env === "true";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function getMaxUploadBytes(): number {
|
|
16
|
+
const parsed = Number(process.env.HEYHANK_HUB_MAX_UPLOAD_MB || process.env.COMPANION_HUB_MAX_UPLOAD_MB);
|
|
17
|
+
const mb = Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_MAX_UPLOAD_MB;
|
|
18
|
+
return mb * 1024 * 1024;
|
|
19
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* REST API routes for the Recording Hub.
|
|
3
|
+
*
|
|
4
|
+
* All routes are under /api/hub/ and only registered when HEYHANK_RECORDING_HUB=1.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Hono } from "hono";
|
|
8
|
+
import { join, resolve, sep } from "node:path";
|
|
9
|
+
import { existsSync } from "node:fs";
|
|
10
|
+
import { HubStore } from "./hub-store.js";
|
|
11
|
+
import type { ReplayAdapter } from "./replay-adapter.js";
|
|
12
|
+
import type { WsBridge } from "../ws-bridge.js";
|
|
13
|
+
|
|
14
|
+
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
interface HubRoutesOptions {
|
|
17
|
+
wsBridge: WsBridge;
|
|
18
|
+
recordingsDir: string; // Auto-recording directory for import-local
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ─── Route Registration ─────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
export function registerHubRoutes(api: Hono, options: HubRoutesOptions): void {
|
|
24
|
+
const store = new HubStore();
|
|
25
|
+
const replayAdapters = new Map<string, ReplayAdapter>();
|
|
26
|
+
|
|
27
|
+
// ── Recording CRUD ────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
api.get("/hub/recordings", (c) => {
|
|
30
|
+
return c.json(store.list());
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
api.get("/hub/recordings/:id", (c) => {
|
|
34
|
+
const meta = store.get(c.req.param("id"));
|
|
35
|
+
if (!meta) return c.json({ error: "Recording not found" }, 404);
|
|
36
|
+
return c.json(meta);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
api.get("/hub/recordings/:id/summary", (c) => {
|
|
40
|
+
const summary = store.getSummary(c.req.param("id"));
|
|
41
|
+
if (!summary) return c.json({ error: "Recording not found" }, 404);
|
|
42
|
+
return c.json(summary);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
api.delete("/hub/recordings/:id", (c) => {
|
|
46
|
+
const deleted = store.delete(c.req.param("id"));
|
|
47
|
+
if (!deleted) return c.json({ error: "Recording not found" }, 404);
|
|
48
|
+
return c.json({ ok: true });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// ── Upload (raw JSONL content in body) ────────────────────────────────
|
|
52
|
+
|
|
53
|
+
api.post("/hub/recordings/upload", async (c) => {
|
|
54
|
+
try {
|
|
55
|
+
const contentType = c.req.header("content-type") || "";
|
|
56
|
+
|
|
57
|
+
let content: string;
|
|
58
|
+
let originalFilename: string | undefined;
|
|
59
|
+
|
|
60
|
+
if (contentType.includes("multipart/form-data")) {
|
|
61
|
+
const formData = await c.req.formData();
|
|
62
|
+
const file = formData.get("file");
|
|
63
|
+
if (!file || !(file instanceof File)) {
|
|
64
|
+
return c.json({ error: "Missing 'file' field in multipart form" }, 400);
|
|
65
|
+
}
|
|
66
|
+
content = await file.text();
|
|
67
|
+
originalFilename = file.name;
|
|
68
|
+
} else {
|
|
69
|
+
// Plain text body
|
|
70
|
+
content = await c.req.text();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const meta = store.importContent(content, originalFilename);
|
|
74
|
+
return c.json(meta, 201);
|
|
75
|
+
} catch (e: unknown) {
|
|
76
|
+
return c.json({ error: e instanceof Error ? e.message : String(e) }, 400);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// ── Import from local auto-recordings ─────────────────────────────────
|
|
81
|
+
|
|
82
|
+
api.post("/hub/recordings/import-local", async (c) => {
|
|
83
|
+
try {
|
|
84
|
+
const body = await c.req.json().catch(() => ({} as { filename?: string }));
|
|
85
|
+
if (!body.filename) {
|
|
86
|
+
return c.json({ error: "Missing 'filename' field" }, 400);
|
|
87
|
+
}
|
|
88
|
+
const sourcePath = join(options.recordingsDir, body.filename);
|
|
89
|
+
const resolvedSource = resolve(sourcePath);
|
|
90
|
+
const resolvedBase = resolve(options.recordingsDir);
|
|
91
|
+
if (!resolvedSource.startsWith(resolvedBase + sep) && resolvedSource !== resolvedBase) {
|
|
92
|
+
return c.json({ error: "Invalid filename" }, 400);
|
|
93
|
+
}
|
|
94
|
+
if (!existsSync(sourcePath)) {
|
|
95
|
+
return c.json({ error: "Recording file not found in auto-recordings directory" }, 404);
|
|
96
|
+
}
|
|
97
|
+
const meta = store.importLocal(sourcePath);
|
|
98
|
+
return c.json(meta, 201);
|
|
99
|
+
} catch (e: unknown) {
|
|
100
|
+
return c.json({ error: e instanceof Error ? e.message : String(e) }, 400);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// ── Tags ──────────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
api.put("/hub/recordings/:id/tags", async (c) => {
|
|
107
|
+
const body = await c.req.json().catch(() => ({} as { tags?: string[] }));
|
|
108
|
+
if (!Array.isArray(body.tags)) {
|
|
109
|
+
return c.json({ error: "Missing 'tags' array" }, 400);
|
|
110
|
+
}
|
|
111
|
+
const meta = store.updateTags(c.req.param("id"), body.tags);
|
|
112
|
+
if (!meta) return c.json({ error: "Recording not found" }, 404);
|
|
113
|
+
return c.json(meta);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// ── Replay Sessions ───────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
api.post("/hub/replay", async (c) => {
|
|
119
|
+
try {
|
|
120
|
+
const body = await c.req.json().catch(() => ({} as { recordingId?: string; speed?: number }));
|
|
121
|
+
if (!body.recordingId) {
|
|
122
|
+
return c.json({ error: "Missing 'recordingId'" }, 400);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (body.speed !== undefined && (typeof body.speed !== "number" || body.speed <= 0)) {
|
|
126
|
+
return c.json({ error: "Invalid 'speed' value" }, 400);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const recording = store.loadRecording(body.recordingId);
|
|
130
|
+
if (!recording) {
|
|
131
|
+
return c.json({ error: "Recording not found" }, 404);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Lazy import to avoid circular dependency at module load time
|
|
135
|
+
const { ReplayAdapter } = await import("./replay-adapter.js");
|
|
136
|
+
|
|
137
|
+
const replaySessionId = `replay-${Date.now().toString(36)}`;
|
|
138
|
+
const speed = body.speed ?? 1;
|
|
139
|
+
const adapter = new ReplayAdapter(recording, speed);
|
|
140
|
+
|
|
141
|
+
options.wsBridge.attachBackendAdapter(
|
|
142
|
+
replaySessionId,
|
|
143
|
+
adapter,
|
|
144
|
+
recording.header.backend_type,
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
replayAdapters.set(replaySessionId, adapter);
|
|
148
|
+
|
|
149
|
+
// Clean up when replay finishes
|
|
150
|
+
adapter.onDisconnect(() => {
|
|
151
|
+
replayAdapters.delete(replaySessionId);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// Start playback
|
|
155
|
+
adapter.play();
|
|
156
|
+
|
|
157
|
+
return c.json({
|
|
158
|
+
sessionId: replaySessionId,
|
|
159
|
+
backendType: recording.header.backend_type,
|
|
160
|
+
speed,
|
|
161
|
+
entryCount: recording.entries.length,
|
|
162
|
+
}, 201);
|
|
163
|
+
} catch (e: unknown) {
|
|
164
|
+
return c.json({ error: e instanceof Error ? e.message : String(e) }, 400);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
api.post("/hub/replay/:sessionId/pause", (c) => {
|
|
169
|
+
const adapter = replayAdapters.get(c.req.param("sessionId"));
|
|
170
|
+
if (!adapter) return c.json({ error: "Replay session not found" }, 404);
|
|
171
|
+
adapter.pause();
|
|
172
|
+
return c.json({ ok: true, paused: true });
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
api.post("/hub/replay/:sessionId/resume", (c) => {
|
|
176
|
+
const adapter = replayAdapters.get(c.req.param("sessionId"));
|
|
177
|
+
if (!adapter) return c.json({ error: "Replay session not found" }, 404);
|
|
178
|
+
adapter.play();
|
|
179
|
+
return c.json({ ok: true, paused: false });
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
api.post("/hub/replay/:sessionId/speed", async (c) => {
|
|
183
|
+
const adapter = replayAdapters.get(c.req.param("sessionId"));
|
|
184
|
+
if (!adapter) return c.json({ error: "Replay session not found" }, 404);
|
|
185
|
+
const body = await c.req.json().catch(() => ({} as { speed?: number }));
|
|
186
|
+
if (typeof body.speed !== "number" || body.speed <= 0) {
|
|
187
|
+
return c.json({ error: "Invalid 'speed' value" }, 400);
|
|
188
|
+
}
|
|
189
|
+
adapter.setSpeed(body.speed);
|
|
190
|
+
return c.json({ ok: true, speed: body.speed });
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
api.get("/hub/replay/:sessionId/progress", (c) => {
|
|
194
|
+
const adapter = replayAdapters.get(c.req.param("sessionId"));
|
|
195
|
+
if (!adapter) return c.json({ error: "Replay session not found" }, 404);
|
|
196
|
+
return c.json(adapter.getProgress());
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// ── Compatibility Validation ──────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
api.post("/hub/recordings/:id/validate", async (c) => {
|
|
202
|
+
try {
|
|
203
|
+
const recording = store.loadRecording(c.req.param("id"));
|
|
204
|
+
if (!recording) return c.json({ error: "Recording not found" }, 404);
|
|
205
|
+
const { validateRecording } = await import("./compat-validator.js");
|
|
206
|
+
const result = validateRecording(recording);
|
|
207
|
+
return c.json(result);
|
|
208
|
+
} catch (e: unknown) {
|
|
209
|
+
return c.json({ error: e instanceof Error ? e.message : String(e) }, 500);
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// ── Disconnection Diagnostics ─────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
api.get("/hub/recordings/:id/diagnostics", async (c) => {
|
|
216
|
+
try {
|
|
217
|
+
const recording = store.loadRecording(c.req.param("id"));
|
|
218
|
+
if (!recording) return c.json({ error: "Recording not found" }, 404);
|
|
219
|
+
const { analyzeDisconnections } = await import("./diagnostics.js");
|
|
220
|
+
return c.json(analyzeDisconnections(recording));
|
|
221
|
+
} catch (e: unknown) {
|
|
222
|
+
return c.json({ error: e instanceof Error ? e.message : String(e) }, 500);
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
api.get("/hub/recordings/:id/timeline", async (c) => {
|
|
227
|
+
try {
|
|
228
|
+
const recording = store.loadRecording(c.req.param("id"));
|
|
229
|
+
if (!recording) return c.json({ error: "Recording not found" }, 404);
|
|
230
|
+
const { buildTimeline } = await import("./diagnostics.js");
|
|
231
|
+
return c.json(buildTimeline(recording));
|
|
232
|
+
} catch (e: unknown) {
|
|
233
|
+
return c.json({ error: e instanceof Error ? e.message : String(e) }, 500);
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
}
|