@jmoyers/harness 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/LICENSE +21 -0
- package/README.md +145 -0
- package/native/ptyd/Cargo.lock +16 -0
- package/native/ptyd/Cargo.toml +7 -0
- package/native/ptyd/src/main.rs +257 -0
- package/package.json +90 -0
- package/scripts/build-ptyd.sh +73 -0
- package/scripts/control-plane-daemon.ts +277 -0
- package/scripts/cursor-hook-relay.ts +82 -0
- package/scripts/harness-animate.ts +469 -0
- package/scripts/harness-bin.js +77 -0
- package/scripts/harness-core.ts +1 -0
- package/scripts/harness-inspector.ts +439 -0
- package/scripts/harness.ts +2493 -0
- package/src/adapters/agent-session-state.ts +390 -0
- package/src/cli/gateway-record.ts +173 -0
- package/src/codex/live-session.ts +872 -0
- package/src/config/config-core.ts +1359 -0
- package/src/config/secrets-core.ts +170 -0
- package/src/control-plane/agent-realtime-api.ts +2441 -0
- package/src/control-plane/codex-session-stream.ts +392 -0
- package/src/control-plane/codex-telemetry.ts +1325 -0
- package/src/control-plane/lifecycle-hooks.ts +706 -0
- package/src/control-plane/session-summary.ts +380 -0
- package/src/control-plane/status/agent-status-reducer.ts +21 -0
- package/src/control-plane/status/reducer-base.ts +170 -0
- package/src/control-plane/status/reducers/claude-status-reducer.ts +37 -0
- package/src/control-plane/status/reducers/codex-status-reducer.ts +48 -0
- package/src/control-plane/status/reducers/critique-status-reducer.ts +15 -0
- package/src/control-plane/status/reducers/cursor-status-reducer.ts +37 -0
- package/src/control-plane/status/reducers/terminal-status-reducer.ts +15 -0
- package/src/control-plane/status/session-status-engine.ts +76 -0
- package/src/control-plane/stream-client.ts +396 -0
- package/src/control-plane/stream-command-parser.ts +1673 -0
- package/src/control-plane/stream-protocol.ts +1808 -0
- package/src/control-plane/stream-server-background.ts +486 -0
- package/src/control-plane/stream-server-command.ts +2557 -0
- package/src/control-plane/stream-server-connection.ts +234 -0
- package/src/control-plane/stream-server-observed-filter.ts +112 -0
- package/src/control-plane/stream-server-session-runtime.ts +566 -0
- package/src/control-plane/stream-server-state-store.ts +15 -0
- package/src/control-plane/stream-server.ts +3192 -0
- package/src/cursor/managed-hooks.ts +282 -0
- package/src/domain/conversations.ts +414 -0
- package/src/domain/directories.ts +78 -0
- package/src/domain/repositories.ts +123 -0
- package/src/domain/tasks.ts +148 -0
- package/src/domain/workspace.ts +156 -0
- package/src/events/normalized-events.ts +124 -0
- package/src/mux/ansi-integrity.ts +103 -0
- package/src/mux/control-plane-op-queue.ts +212 -0
- package/src/mux/conversation-rail.ts +339 -0
- package/src/mux/double-click.ts +78 -0
- package/src/mux/dual-pane-core.ts +435 -0
- package/src/mux/harness-core-ui.ts +817 -0
- package/src/mux/input-shortcuts.ts +667 -0
- package/src/mux/live-mux/actions-conversation.ts +344 -0
- package/src/mux/live-mux/actions-repository.ts +246 -0
- package/src/mux/live-mux/actions-task.ts +115 -0
- package/src/mux/live-mux/args.ts +142 -0
- package/src/mux/live-mux/command-menu.ts +298 -0
- package/src/mux/live-mux/control-plane-records.ts +546 -0
- package/src/mux/live-mux/conversation-state.ts +188 -0
- package/src/mux/live-mux/directory-resolution.ts +34 -0
- package/src/mux/live-mux/event-mapping.ts +96 -0
- package/src/mux/live-mux/gateway-profiler.ts +152 -0
- package/src/mux/live-mux/gateway-render-trace.ts +177 -0
- package/src/mux/live-mux/gateway-status-timeline.ts +166 -0
- package/src/mux/live-mux/git-parsing.ts +131 -0
- package/src/mux/live-mux/git-snapshot.ts +263 -0
- package/src/mux/live-mux/git-state.ts +136 -0
- package/src/mux/live-mux/global-shortcut-handlers.ts +143 -0
- package/src/mux/live-mux/home-pane-actions.ts +58 -0
- package/src/mux/live-mux/home-pane-drop.ts +44 -0
- package/src/mux/live-mux/home-pane-entity-click.ts +96 -0
- package/src/mux/live-mux/home-pane-pointer.ts +96 -0
- package/src/mux/live-mux/input-forwarding.ts +112 -0
- package/src/mux/live-mux/layout.ts +30 -0
- package/src/mux/live-mux/left-nav-activation.ts +103 -0
- package/src/mux/live-mux/left-nav.ts +85 -0
- package/src/mux/live-mux/left-rail-actions.ts +118 -0
- package/src/mux/live-mux/left-rail-conversation-click.ts +82 -0
- package/src/mux/live-mux/left-rail-pointer.ts +74 -0
- package/src/mux/live-mux/modal-command-menu-handler.ts +101 -0
- package/src/mux/live-mux/modal-conversation-handlers.ts +217 -0
- package/src/mux/live-mux/modal-input-reducers.ts +94 -0
- package/src/mux/live-mux/modal-overlays.ts +287 -0
- package/src/mux/live-mux/modal-pointer.ts +70 -0
- package/src/mux/live-mux/modal-prompt-handlers.ts +187 -0
- package/src/mux/live-mux/modal-task-editor-handler.ts +156 -0
- package/src/mux/live-mux/observed-stream.ts +87 -0
- package/src/mux/live-mux/palette-parsing.ts +128 -0
- package/src/mux/live-mux/pointer-routing.ts +108 -0
- package/src/mux/live-mux/process-usage.ts +53 -0
- package/src/mux/live-mux/project-pane-pointer.ts +44 -0
- package/src/mux/live-mux/rail-layout.ts +244 -0
- package/src/mux/live-mux/render-trace-analysis.ts +213 -0
- package/src/mux/live-mux/render-trace-state.ts +84 -0
- package/src/mux/live-mux/repository-folding.ts +207 -0
- package/src/mux/live-mux/runtime-shutdown.ts +51 -0
- package/src/mux/live-mux/selection.ts +411 -0
- package/src/mux/live-mux/startup-utils.ts +187 -0
- package/src/mux/live-mux/status-timeline-state.ts +82 -0
- package/src/mux/live-mux/task-pane-shortcuts.ts +206 -0
- package/src/mux/live-mux/terminal-palette.ts +79 -0
- package/src/mux/new-thread-prompt.ts +165 -0
- package/src/mux/project-tree.ts +295 -0
- package/src/mux/render-frame.ts +113 -0
- package/src/mux/runtime-wiring.ts +185 -0
- package/src/mux/selector-index.ts +160 -0
- package/src/mux/startup-sequencer.ts +238 -0
- package/src/mux/task-composer.ts +289 -0
- package/src/mux/task-focused-pane.ts +417 -0
- package/src/mux/task-screen-keybindings.ts +539 -0
- package/src/mux/terminal-input-modes.ts +35 -0
- package/src/mux/workspace-path.ts +55 -0
- package/src/mux/workspace-rail-model.ts +701 -0
- package/src/mux/workspace-rail.ts +247 -0
- package/src/perf/perf-core.ts +307 -0
- package/src/pty/pty_host.ts +217 -0
- package/src/pty/session-broker.ts +158 -0
- package/src/recording/terminal-recording.ts +383 -0
- package/src/services/control-plane.ts +567 -0
- package/src/services/conversation-lifecycle.ts +176 -0
- package/src/services/conversation-startup-hydration.ts +47 -0
- package/src/services/directory-hydration.ts +49 -0
- package/src/services/event-persistence.ts +104 -0
- package/src/services/mux-ui-state-persistence.ts +82 -0
- package/src/services/output-load-sampler.ts +231 -0
- package/src/services/process-usage-refresh.ts +88 -0
- package/src/services/recording.ts +75 -0
- package/src/services/render-trace-recorder.ts +177 -0
- package/src/services/runtime-control-actions.ts +123 -0
- package/src/services/runtime-control-plane-ops.ts +131 -0
- package/src/services/runtime-conversation-actions.ts +113 -0
- package/src/services/runtime-conversation-activation.ts +78 -0
- package/src/services/runtime-conversation-starter.ts +171 -0
- package/src/services/runtime-conversation-title-edit.ts +149 -0
- package/src/services/runtime-directory-actions.ts +164 -0
- package/src/services/runtime-envelope-handler.ts +198 -0
- package/src/services/runtime-git-state.ts +92 -0
- package/src/services/runtime-input-pipeline.ts +50 -0
- package/src/services/runtime-input-router.ts +202 -0
- package/src/services/runtime-layout-resize.ts +236 -0
- package/src/services/runtime-left-rail-render.ts +159 -0
- package/src/services/runtime-main-pane-input.ts +230 -0
- package/src/services/runtime-modal-input.ts +119 -0
- package/src/services/runtime-navigation-input.ts +207 -0
- package/src/services/runtime-process-wiring.ts +68 -0
- package/src/services/runtime-rail-input.ts +287 -0
- package/src/services/runtime-render-flush.ts +146 -0
- package/src/services/runtime-render-lifecycle.ts +104 -0
- package/src/services/runtime-render-orchestrator.ts +108 -0
- package/src/services/runtime-render-pipeline.ts +167 -0
- package/src/services/runtime-render-state.ts +72 -0
- package/src/services/runtime-repository-actions.ts +197 -0
- package/src/services/runtime-right-pane-render.ts +132 -0
- package/src/services/runtime-shutdown.ts +79 -0
- package/src/services/runtime-stream-subscriptions.ts +56 -0
- package/src/services/runtime-task-composer-persistence.ts +139 -0
- package/src/services/runtime-task-editor-actions.ts +83 -0
- package/src/services/runtime-task-pane-actions.ts +198 -0
- package/src/services/runtime-task-pane-shortcuts.ts +189 -0
- package/src/services/runtime-task-pane.ts +62 -0
- package/src/services/runtime-workspace-actions.ts +153 -0
- package/src/services/runtime-workspace-observed-events.ts +190 -0
- package/src/services/session-projection-instrumentation.ts +190 -0
- package/src/services/startup-background-probe.ts +91 -0
- package/src/services/startup-background-resume.ts +65 -0
- package/src/services/startup-orchestrator.ts +166 -0
- package/src/services/startup-output-tracker.ts +54 -0
- package/src/services/startup-paint-tracker.ts +115 -0
- package/src/services/startup-persisted-conversation-queue.ts +45 -0
- package/src/services/startup-settled-gate.ts +67 -0
- package/src/services/startup-shutdown.ts +53 -0
- package/src/services/startup-span-tracker.ts +77 -0
- package/src/services/startup-state-hydration.ts +94 -0
- package/src/services/startup-visibility.ts +35 -0
- package/src/services/status-timeline-recorder.ts +144 -0
- package/src/services/task-pane-selection-actions.ts +153 -0
- package/src/services/task-planning-hydration.ts +58 -0
- package/src/services/task-planning-observed-events.ts +89 -0
- package/src/services/workspace-observed-events.ts +113 -0
- package/src/store/control-plane-store-normalize.ts +760 -0
- package/src/store/control-plane-store-types.ts +224 -0
- package/src/store/control-plane-store.ts +2951 -0
- package/src/store/event-store.ts +253 -0
- package/src/store/sqlite.ts +81 -0
- package/src/terminal/compat-matrix.ts +345 -0
- package/src/terminal/differential-checkpoints.ts +132 -0
- package/src/terminal/parity-suite.ts +441 -0
- package/src/terminal/snapshot-oracle.ts +1840 -0
- package/src/ui/conversation-input-forwarder.ts +114 -0
- package/src/ui/conversation-selection-input.ts +103 -0
- package/src/ui/debug-footer-notice.ts +39 -0
- package/src/ui/global-shortcut-input.ts +126 -0
- package/src/ui/input-preflight.ts +68 -0
- package/src/ui/input-token-router.ts +312 -0
- package/src/ui/input.ts +238 -0
- package/src/ui/kit.ts +509 -0
- package/src/ui/left-nav-input.ts +80 -0
- package/src/ui/left-rail-pointer-input.ts +148 -0
- package/src/ui/main-pane-pointer-input.ts +150 -0
- package/src/ui/modals/manager.ts +192 -0
- package/src/ui/mux-theme.ts +529 -0
- package/src/ui/panes/conversation.ts +19 -0
- package/src/ui/panes/home-gridfire.ts +302 -0
- package/src/ui/panes/home.ts +109 -0
- package/src/ui/panes/left-rail.ts +12 -0
- package/src/ui/panes/project.ts +44 -0
- package/src/ui/pointer-routing-input.ts +158 -0
- package/src/ui/repository-fold-input.ts +91 -0
- package/src/ui/screen.ts +210 -0
- package/src/ui/surface.ts +224 -0
|
@@ -0,0 +1,3192 @@
|
|
|
1
|
+
import { createServer, type AddressInfo, type Server, type Socket } from 'node:net';
|
|
2
|
+
import {
|
|
3
|
+
createServer as createHttpServer,
|
|
4
|
+
type IncomingMessage,
|
|
5
|
+
type Server as HttpServer,
|
|
6
|
+
type ServerResponse,
|
|
7
|
+
} from 'node:http';
|
|
8
|
+
import { execFile } from 'node:child_process';
|
|
9
|
+
import { randomUUID } from 'node:crypto';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
11
|
+
import { join, resolve } from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import { type CodexLiveEvent, type LiveSessionNotifyMode } from '../codex/live-session.ts';
|
|
14
|
+
import type { PtyExit } from '../pty/pty_host.ts';
|
|
15
|
+
import type { TerminalBufferTail, TerminalSnapshotFrame } from '../terminal/snapshot-oracle.ts';
|
|
16
|
+
import {
|
|
17
|
+
encodeStreamEnvelope,
|
|
18
|
+
type StreamObservedEvent,
|
|
19
|
+
type StreamSessionKeyEventRecord,
|
|
20
|
+
type StreamSessionController,
|
|
21
|
+
type StreamSessionListSort,
|
|
22
|
+
type StreamSessionRuntimeStatus,
|
|
23
|
+
type StreamSessionStatusModel,
|
|
24
|
+
type StreamClientEnvelope,
|
|
25
|
+
type StreamCommand,
|
|
26
|
+
type StreamServerEnvelope,
|
|
27
|
+
type StreamSignal,
|
|
28
|
+
} from './stream-protocol.ts';
|
|
29
|
+
import {
|
|
30
|
+
SqliteControlPlaneStore,
|
|
31
|
+
type ControlPlaneConversationRecord,
|
|
32
|
+
type ControlPlaneDirectoryRecord,
|
|
33
|
+
type ControlPlaneRepositoryRecord,
|
|
34
|
+
type ControlPlaneTaskRecord,
|
|
35
|
+
type ControlPlaneTelemetrySummary,
|
|
36
|
+
} from '../store/control-plane-store.ts';
|
|
37
|
+
import {
|
|
38
|
+
buildAgentSessionStartArgs,
|
|
39
|
+
codexResumeSessionIdFromAdapterState,
|
|
40
|
+
normalizeAdapterState,
|
|
41
|
+
} from '../adapters/agent-session-state.ts';
|
|
42
|
+
import {
|
|
43
|
+
buildCursorHookRelayEnvironment,
|
|
44
|
+
buildCursorManagedHookRelayCommand,
|
|
45
|
+
ensureManagedCursorHooksInstalled,
|
|
46
|
+
} from '../cursor/managed-hooks.ts';
|
|
47
|
+
import { recordPerfEvent } from '../perf/perf-core.ts';
|
|
48
|
+
import {
|
|
49
|
+
buildCodexTelemetryConfigArgs,
|
|
50
|
+
parseOtlpLifecycleLogEvents,
|
|
51
|
+
parseOtlpLifecycleMetricEvents,
|
|
52
|
+
parseOtlpLifecycleTraceEvents,
|
|
53
|
+
parseOtlpLogEvents,
|
|
54
|
+
parseOtlpMetricEvents,
|
|
55
|
+
parseOtlpTraceEvents,
|
|
56
|
+
telemetryFingerprint,
|
|
57
|
+
type ParsedCodexTelemetryEvent,
|
|
58
|
+
} from './codex-telemetry.ts';
|
|
59
|
+
import { executeStreamServerCommand } from './stream-server-command.ts';
|
|
60
|
+
import {
|
|
61
|
+
handleAuth as handleConnectionAuth,
|
|
62
|
+
handleClientEnvelope as handleConnectionClientEnvelope,
|
|
63
|
+
handleCommand as handleConnectionCommand,
|
|
64
|
+
handleConnection as handleServerConnection,
|
|
65
|
+
handleSocketData as handleConnectionSocketData,
|
|
66
|
+
} from './stream-server-connection.ts';
|
|
67
|
+
import {
|
|
68
|
+
pollGitStatus as pollStreamServerGitStatus,
|
|
69
|
+
pollHistoryFile as pollStreamServerHistoryFile,
|
|
70
|
+
pollHistoryFileUnsafe as pollStreamServerHistoryFileUnsafe,
|
|
71
|
+
refreshGitStatusForDirectory as refreshStreamServerGitStatusForDirectory,
|
|
72
|
+
} from './stream-server-background.ts';
|
|
73
|
+
import {
|
|
74
|
+
applySessionKeyEvent as applyRuntimeSessionKeyEvent,
|
|
75
|
+
handleInput as handleRuntimeInput,
|
|
76
|
+
handleResize as handleRuntimeResize,
|
|
77
|
+
handleSessionEvent as handleRuntimeSessionEvent,
|
|
78
|
+
handleSignal as handleRuntimeSignal,
|
|
79
|
+
persistConversationRuntime as persistRuntimeConversationState,
|
|
80
|
+
publishStatusObservedEvent as publishRuntimeStatusObservedEvent,
|
|
81
|
+
setSessionStatus as setRuntimeSessionStatus,
|
|
82
|
+
} from './stream-server-session-runtime.ts';
|
|
83
|
+
import { closeOwnedStateStore as closeOwnedStreamServerStateStore } from './stream-server-state-store.ts';
|
|
84
|
+
import { SessionStatusEngine } from './status/session-status-engine.ts';
|
|
85
|
+
import {
|
|
86
|
+
eventIncludesRepositoryId as filterEventIncludesRepositoryId,
|
|
87
|
+
eventIncludesTaskId as filterEventIncludesTaskId,
|
|
88
|
+
matchesObservedFilter as matchesStreamObservedFilter,
|
|
89
|
+
} from './stream-server-observed-filter.ts';
|
|
90
|
+
import type { HarnessLifecycleHooksConfig } from '../config/config-core.ts';
|
|
91
|
+
import { LifecycleHooksRuntime } from './lifecycle-hooks.ts';
|
|
92
|
+
import { readGitDirectorySnapshot } from '../mux/live-mux/git-snapshot.ts';
|
|
93
|
+
|
|
94
|
+
interface SessionDataEvent {
|
|
95
|
+
cursor: number;
|
|
96
|
+
chunk: Buffer;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
interface SessionAttachHandlers {
|
|
100
|
+
onData: (event: SessionDataEvent) => void;
|
|
101
|
+
onExit: (exit: PtyExit) => void;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
interface LiveSessionLike {
|
|
105
|
+
attach(handlers: SessionAttachHandlers, sinceCursor?: number): string;
|
|
106
|
+
detach(attachmentId: string): void;
|
|
107
|
+
latestCursorValue(): number;
|
|
108
|
+
processId(): number | null;
|
|
109
|
+
write(data: string | Uint8Array): void;
|
|
110
|
+
resize(cols: number, rows: number): void;
|
|
111
|
+
snapshot(): TerminalSnapshotFrame;
|
|
112
|
+
bufferTail?(tailLines?: number): TerminalBufferTail;
|
|
113
|
+
close(): void;
|
|
114
|
+
onEvent(listener: (event: CodexLiveEvent) => void): () => void;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface StartControlPlaneSessionInput {
|
|
118
|
+
command?: string;
|
|
119
|
+
baseArgs?: string[];
|
|
120
|
+
args: string[];
|
|
121
|
+
env?: Record<string, string>;
|
|
122
|
+
cwd?: string;
|
|
123
|
+
useNotifyHook?: boolean;
|
|
124
|
+
notifyMode?: LiveSessionNotifyMode;
|
|
125
|
+
notifyFilePath?: string;
|
|
126
|
+
initialCols: number;
|
|
127
|
+
initialRows: number;
|
|
128
|
+
terminalForegroundHex?: string;
|
|
129
|
+
terminalBackgroundHex?: string;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
interface StartSessionRuntimeInput {
|
|
133
|
+
readonly sessionId: string;
|
|
134
|
+
readonly args: readonly string[];
|
|
135
|
+
readonly initialCols: number;
|
|
136
|
+
readonly initialRows: number;
|
|
137
|
+
readonly env?: Record<string, string>;
|
|
138
|
+
readonly cwd?: string;
|
|
139
|
+
readonly tenantId?: string;
|
|
140
|
+
readonly userId?: string;
|
|
141
|
+
readonly workspaceId?: string;
|
|
142
|
+
readonly worktreeId?: string;
|
|
143
|
+
readonly terminalForegroundHex?: string;
|
|
144
|
+
readonly terminalBackgroundHex?: string;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
type StartControlPlaneSession = (input: StartControlPlaneSessionInput) => LiveSessionLike;
|
|
148
|
+
|
|
149
|
+
interface CodexTelemetryServerConfig {
|
|
150
|
+
readonly enabled: boolean;
|
|
151
|
+
readonly host: string;
|
|
152
|
+
readonly port: number;
|
|
153
|
+
readonly logUserPrompt: boolean;
|
|
154
|
+
readonly captureLogs: boolean;
|
|
155
|
+
readonly captureMetrics: boolean;
|
|
156
|
+
readonly captureTraces: boolean;
|
|
157
|
+
readonly captureVerboseEvents?: boolean;
|
|
158
|
+
readonly ingestMode?: 'lifecycle-fast' | 'full';
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
interface CodexHistoryIngestConfig {
|
|
162
|
+
readonly enabled: boolean;
|
|
163
|
+
readonly filePath: string;
|
|
164
|
+
readonly pollMs: number;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
interface CodexLaunchConfig {
|
|
168
|
+
readonly defaultMode: 'yolo' | 'standard';
|
|
169
|
+
readonly directoryModes: Readonly<Record<string, 'yolo' | 'standard'>>;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
interface CritiqueLaunchConfig {
|
|
173
|
+
readonly defaultArgs: readonly string[];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
interface CritiqueInstallConfig {
|
|
177
|
+
readonly autoInstall: boolean;
|
|
178
|
+
readonly package: string;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
interface CritiqueConfig {
|
|
182
|
+
readonly launch: CritiqueLaunchConfig;
|
|
183
|
+
readonly install: CritiqueInstallConfig;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
interface CursorLaunchConfig {
|
|
187
|
+
readonly defaultMode: 'yolo' | 'standard';
|
|
188
|
+
readonly directoryModes: Readonly<Record<string, 'yolo' | 'standard'>>;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
interface CursorHooksConfig {
|
|
192
|
+
readonly managed: boolean;
|
|
193
|
+
readonly hooksFilePath: string | null;
|
|
194
|
+
readonly relayScriptPath: string;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
interface GitStatusMonitorConfig {
|
|
198
|
+
readonly enabled: boolean;
|
|
199
|
+
readonly pollMs: number;
|
|
200
|
+
readonly maxConcurrency: number;
|
|
201
|
+
readonly minDirectoryRefreshMs: number;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
interface GitHubIntegrationConfig {
|
|
205
|
+
readonly enabled: boolean;
|
|
206
|
+
readonly apiBaseUrl: string;
|
|
207
|
+
readonly tokenEnvVar: string;
|
|
208
|
+
readonly token: string | null;
|
|
209
|
+
readonly pollMs: number;
|
|
210
|
+
readonly maxConcurrency: number;
|
|
211
|
+
readonly branchStrategy: 'pinned-then-current' | 'current-only' | 'pinned-only';
|
|
212
|
+
readonly viewerLogin: string | null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
interface GitHubRemotePullRequest {
|
|
216
|
+
readonly number: number;
|
|
217
|
+
readonly title: string;
|
|
218
|
+
readonly url: string;
|
|
219
|
+
readonly authorLogin: string | null;
|
|
220
|
+
readonly headBranch: string;
|
|
221
|
+
readonly headSha: string;
|
|
222
|
+
readonly baseBranch: string;
|
|
223
|
+
readonly state: 'open' | 'closed';
|
|
224
|
+
readonly isDraft: boolean;
|
|
225
|
+
readonly updatedAt: string;
|
|
226
|
+
readonly createdAt: string;
|
|
227
|
+
readonly closedAt: string | null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
interface GitHubRemotePrJob {
|
|
231
|
+
readonly provider: 'check-run' | 'status-context';
|
|
232
|
+
readonly externalId: string;
|
|
233
|
+
readonly name: string;
|
|
234
|
+
readonly status: string;
|
|
235
|
+
readonly conclusion: string | null;
|
|
236
|
+
readonly url: string | null;
|
|
237
|
+
readonly startedAt: string | null;
|
|
238
|
+
readonly completedAt: string | null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
type GitDirectorySnapshot = Awaited<ReturnType<typeof readGitDirectorySnapshot>>;
|
|
242
|
+
type GitDirectorySnapshotReader = (cwd: string) => Promise<GitDirectorySnapshot>;
|
|
243
|
+
type GitHubTokenResolver = () => Promise<string | null>;
|
|
244
|
+
type GitHubExecFile = (
|
|
245
|
+
file: string,
|
|
246
|
+
args: readonly string[],
|
|
247
|
+
options: {
|
|
248
|
+
readonly timeout: number;
|
|
249
|
+
readonly windowsHide: boolean;
|
|
250
|
+
},
|
|
251
|
+
callback: (error: Error | null, stdout: string, stderr: string) => void,
|
|
252
|
+
) => void;
|
|
253
|
+
|
|
254
|
+
interface StartControlPlaneStreamServerOptions {
|
|
255
|
+
host?: string;
|
|
256
|
+
port?: number;
|
|
257
|
+
startSession?: StartControlPlaneSession;
|
|
258
|
+
authToken?: string;
|
|
259
|
+
maxConnectionBufferedBytes?: number;
|
|
260
|
+
sessionExitTombstoneTtlMs?: number;
|
|
261
|
+
maxStreamJournalEntries?: number;
|
|
262
|
+
stateStorePath?: string;
|
|
263
|
+
stateStore?: SqliteControlPlaneStore;
|
|
264
|
+
codexTelemetry?: CodexTelemetryServerConfig;
|
|
265
|
+
codexHistory?: CodexHistoryIngestConfig;
|
|
266
|
+
codexLaunch?: CodexLaunchConfig;
|
|
267
|
+
critique?: CritiqueConfig;
|
|
268
|
+
cursorLaunch?: CursorLaunchConfig;
|
|
269
|
+
cursorHooks?: Partial<CursorHooksConfig>;
|
|
270
|
+
gitStatus?: GitStatusMonitorConfig;
|
|
271
|
+
github?: Partial<GitHubIntegrationConfig>;
|
|
272
|
+
githubTokenResolver?: GitHubTokenResolver;
|
|
273
|
+
githubExecFile?: GitHubExecFile;
|
|
274
|
+
githubFetch?: typeof fetch;
|
|
275
|
+
readGitDirectorySnapshot?: GitDirectorySnapshotReader;
|
|
276
|
+
lifecycleHooks?: HarnessLifecycleHooksConfig;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
interface ConnectionState {
|
|
280
|
+
id: string;
|
|
281
|
+
socket: Socket;
|
|
282
|
+
remainder: string;
|
|
283
|
+
authenticated: boolean;
|
|
284
|
+
attachedSessionIds: Set<string>;
|
|
285
|
+
eventSessionIds: Set<string>;
|
|
286
|
+
streamSubscriptionIds: Set<string>;
|
|
287
|
+
queuedPayloads: QueuedPayload[];
|
|
288
|
+
queuedPayloadBytes: number;
|
|
289
|
+
writeBlocked: boolean;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
interface QueuedPayload {
|
|
293
|
+
payload: string;
|
|
294
|
+
bytes: number;
|
|
295
|
+
diagnosticSessionId: string | null;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
interface SessionRollingCounter {
|
|
299
|
+
buckets: [number, number, number, number, number, number];
|
|
300
|
+
currentBucketStartMs: number;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
interface SessionDiagnostics {
|
|
304
|
+
telemetryIngestedTotal: number;
|
|
305
|
+
telemetryRetainedTotal: number;
|
|
306
|
+
telemetryDroppedTotal: number;
|
|
307
|
+
telemetryIngestRate: SessionRollingCounter;
|
|
308
|
+
fanoutEventsEnqueuedTotal: number;
|
|
309
|
+
fanoutBytesEnqueuedTotal: number;
|
|
310
|
+
fanoutBackpressureSignalsTotal: number;
|
|
311
|
+
fanoutBackpressureDisconnectsTotal: number;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
interface SessionState {
|
|
315
|
+
id: string;
|
|
316
|
+
directoryId: string | null;
|
|
317
|
+
agentType: string;
|
|
318
|
+
adapterState: Record<string, unknown>;
|
|
319
|
+
tenantId: string;
|
|
320
|
+
userId: string;
|
|
321
|
+
workspaceId: string;
|
|
322
|
+
worktreeId: string;
|
|
323
|
+
session: LiveSessionLike | null;
|
|
324
|
+
eventSubscriberConnectionIds: Set<string>;
|
|
325
|
+
attachmentByConnectionId: Map<string, string>;
|
|
326
|
+
unsubscribe: (() => void) | null;
|
|
327
|
+
status: StreamSessionRuntimeStatus;
|
|
328
|
+
statusModel: StreamSessionStatusModel | null;
|
|
329
|
+
attentionReason: string | null;
|
|
330
|
+
lastEventAt: string | null;
|
|
331
|
+
lastExit: PtyExit | null;
|
|
332
|
+
lastSnapshot: Record<string, unknown> | null;
|
|
333
|
+
startedAt: string;
|
|
334
|
+
exitedAt: string | null;
|
|
335
|
+
tombstoneTimer: NodeJS.Timeout | null;
|
|
336
|
+
lastObservedOutputCursor: number;
|
|
337
|
+
latestTelemetry: ControlPlaneTelemetrySummary | null;
|
|
338
|
+
controller: SessionControllerState | null;
|
|
339
|
+
diagnostics: SessionDiagnostics;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
interface SessionControllerState extends StreamSessionController {
|
|
343
|
+
connectionId: string;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
interface StreamSubscriptionFilter {
|
|
347
|
+
tenantId?: string;
|
|
348
|
+
userId?: string;
|
|
349
|
+
workspaceId?: string;
|
|
350
|
+
repositoryId?: string;
|
|
351
|
+
taskId?: string;
|
|
352
|
+
directoryId?: string;
|
|
353
|
+
conversationId?: string;
|
|
354
|
+
includeOutput: boolean;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
interface StreamSubscriptionState {
|
|
358
|
+
id: string;
|
|
359
|
+
connectionId: string;
|
|
360
|
+
filter: StreamSubscriptionFilter;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
interface StreamObservedScope {
|
|
364
|
+
tenantId: string;
|
|
365
|
+
userId: string;
|
|
366
|
+
workspaceId: string;
|
|
367
|
+
directoryId: string | null;
|
|
368
|
+
conversationId: string | null;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
interface StreamJournalEntry {
|
|
372
|
+
cursor: number;
|
|
373
|
+
scope: StreamObservedScope;
|
|
374
|
+
event: StreamObservedEvent;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
interface DirectoryGitStatusCacheEntry {
|
|
378
|
+
readonly summary: GitDirectorySnapshot['summary'];
|
|
379
|
+
readonly repositorySnapshot: GitDirectorySnapshot['repository'];
|
|
380
|
+
readonly repositoryId: string | null;
|
|
381
|
+
readonly lastRefreshedAtMs: number;
|
|
382
|
+
readonly lastRefreshDurationMs: number;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
interface OtlpEndpointTarget {
|
|
386
|
+
readonly kind: 'logs' | 'metrics' | 'traces';
|
|
387
|
+
readonly token: string;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function isTelemetryRequestAbortError(error: unknown): boolean {
|
|
391
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
392
|
+
if (code === 'ECONNRESET' || code === 'ERR_STREAM_PREMATURE_CLOSE') {
|
|
393
|
+
return true;
|
|
394
|
+
}
|
|
395
|
+
const message = error instanceof Error ? error.message.toLowerCase() : '';
|
|
396
|
+
return message.includes('aborted');
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const DEFAULT_MAX_CONNECTION_BUFFERED_BYTES = 4 * 1024 * 1024;
|
|
400
|
+
const DEFAULT_SESSION_EXIT_TOMBSTONE_TTL_MS = 5 * 60 * 1000;
|
|
401
|
+
const DEFAULT_MAX_STREAM_JOURNAL_ENTRIES = 10000;
|
|
402
|
+
const DEFAULT_GIT_STATUS_POLL_MS = 1200;
|
|
403
|
+
const DEFAULT_GITHUB_POLL_MS = 15_000;
|
|
404
|
+
const HISTORY_POLL_JITTER_RATIO = 0.35;
|
|
405
|
+
const SESSION_DIAGNOSTICS_BUCKET_MS = 10_000;
|
|
406
|
+
const SESSION_DIAGNOSTICS_BUCKET_COUNT = 6;
|
|
407
|
+
const DEFAULT_BOOTSTRAP_SESSION_COLS = 80;
|
|
408
|
+
const DEFAULT_BOOTSTRAP_SESSION_ROWS = 24;
|
|
409
|
+
const DEFAULT_TENANT_ID = 'tenant-local';
|
|
410
|
+
const DEFAULT_USER_ID = 'user-local';
|
|
411
|
+
const DEFAULT_WORKSPACE_ID = 'workspace-local';
|
|
412
|
+
const DEFAULT_WORKTREE_ID = 'worktree-local';
|
|
413
|
+
const DEFAULT_CLAUDE_HOOK_RELAY_SCRIPT_PATH = fileURLToPath(
|
|
414
|
+
new URL('../../scripts/codex-notify-relay.ts', import.meta.url),
|
|
415
|
+
);
|
|
416
|
+
const DEFAULT_CRITIQUE_DEFAULT_ARGS = ['--watch'] as const;
|
|
417
|
+
const DEFAULT_CRITIQUE_PACKAGE = 'critique@latest';
|
|
418
|
+
const DEFAULT_CURSOR_HOOK_RELAY_SCRIPT_PATH = fileURLToPath(
|
|
419
|
+
new URL('../../scripts/cursor-hook-relay.ts', import.meta.url),
|
|
420
|
+
);
|
|
421
|
+
const LIFECYCLE_TELEMETRY_EVENT_NAMES = new Set([
|
|
422
|
+
'codex.user_prompt',
|
|
423
|
+
'codex.turn.e2e_duration_ms',
|
|
424
|
+
'codex.conversation_starts',
|
|
425
|
+
]);
|
|
426
|
+
|
|
427
|
+
function shellEscape(value: string): string {
|
|
428
|
+
if (value.length === 0) {
|
|
429
|
+
return "''";
|
|
430
|
+
}
|
|
431
|
+
return `'${value.replaceAll("'", "'\"'\"'")}'`;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function compareIsoDesc(left: string | null, right: string | null): number {
|
|
435
|
+
if (left === right) {
|
|
436
|
+
return 0;
|
|
437
|
+
}
|
|
438
|
+
if (left === null) {
|
|
439
|
+
return 1;
|
|
440
|
+
}
|
|
441
|
+
if (right === null) {
|
|
442
|
+
return -1;
|
|
443
|
+
}
|
|
444
|
+
return right.localeCompare(left);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function createSessionRollingCounter(nowMs = Date.now()): SessionRollingCounter {
|
|
448
|
+
const roundedStartMs =
|
|
449
|
+
Math.floor(nowMs / SESSION_DIAGNOSTICS_BUCKET_MS) * SESSION_DIAGNOSTICS_BUCKET_MS;
|
|
450
|
+
return {
|
|
451
|
+
buckets: [0, 0, 0, 0, 0, 0],
|
|
452
|
+
currentBucketStartMs: roundedStartMs,
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function advanceSessionRollingCounter(counter: SessionRollingCounter, nowMs: number): void {
|
|
457
|
+
const roundedNowMs =
|
|
458
|
+
Math.floor(nowMs / SESSION_DIAGNOSTICS_BUCKET_MS) * SESSION_DIAGNOSTICS_BUCKET_MS;
|
|
459
|
+
const elapsedBuckets = Math.floor(
|
|
460
|
+
(roundedNowMs - counter.currentBucketStartMs) / SESSION_DIAGNOSTICS_BUCKET_MS,
|
|
461
|
+
);
|
|
462
|
+
if (elapsedBuckets <= 0) {
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
if (elapsedBuckets >= SESSION_DIAGNOSTICS_BUCKET_COUNT) {
|
|
466
|
+
counter.buckets = [0, 0, 0, 0, 0, 0];
|
|
467
|
+
counter.currentBucketStartMs = roundedNowMs;
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
for (let idx = SESSION_DIAGNOSTICS_BUCKET_COUNT - 1; idx >= 0; idx -= 1) {
|
|
471
|
+
const fromIndex = idx - elapsedBuckets;
|
|
472
|
+
counter.buckets[idx] = fromIndex >= 0 ? (counter.buckets[fromIndex] ?? 0) : 0;
|
|
473
|
+
}
|
|
474
|
+
counter.currentBucketStartMs = roundedNowMs;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function incrementSessionRollingCounter(counter: SessionRollingCounter, nowMs: number): void {
|
|
478
|
+
advanceSessionRollingCounter(counter, nowMs);
|
|
479
|
+
counter.buckets[0] += 1;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function sessionRollingCounterTotal(counter: SessionRollingCounter, nowMs: number): number {
|
|
483
|
+
advanceSessionRollingCounter(counter, nowMs);
|
|
484
|
+
return counter.buckets.reduce((total, value) => total + value, 0);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function createSessionDiagnostics(nowMs = Date.now()): SessionDiagnostics {
|
|
488
|
+
return {
|
|
489
|
+
telemetryIngestedTotal: 0,
|
|
490
|
+
telemetryRetainedTotal: 0,
|
|
491
|
+
telemetryDroppedTotal: 0,
|
|
492
|
+
telemetryIngestRate: createSessionRollingCounter(nowMs),
|
|
493
|
+
fanoutEventsEnqueuedTotal: 0,
|
|
494
|
+
fanoutBytesEnqueuedTotal: 0,
|
|
495
|
+
fanoutBackpressureSignalsTotal: 0,
|
|
496
|
+
fanoutBackpressureDisconnectsTotal: 0,
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function sessionPriority(status: StreamSessionRuntimeStatus): number {
|
|
501
|
+
if (status === 'needs-input') {
|
|
502
|
+
return 0;
|
|
503
|
+
}
|
|
504
|
+
if (status === 'running') {
|
|
505
|
+
return 1;
|
|
506
|
+
}
|
|
507
|
+
if (status === 'completed') {
|
|
508
|
+
return 2;
|
|
509
|
+
}
|
|
510
|
+
return 3;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function normalizeCodexTelemetryConfig(
|
|
514
|
+
input: CodexTelemetryServerConfig | undefined,
|
|
515
|
+
): CodexTelemetryServerConfig {
|
|
516
|
+
return {
|
|
517
|
+
enabled: input?.enabled ?? false,
|
|
518
|
+
host: input?.host ?? '127.0.0.1',
|
|
519
|
+
port: input?.port ?? 0,
|
|
520
|
+
logUserPrompt: input?.logUserPrompt ?? true,
|
|
521
|
+
captureLogs: input?.captureLogs ?? true,
|
|
522
|
+
captureMetrics: input?.captureMetrics ?? true,
|
|
523
|
+
captureTraces: input?.captureTraces ?? true,
|
|
524
|
+
captureVerboseEvents: input?.captureVerboseEvents ?? false,
|
|
525
|
+
ingestMode: input?.ingestMode ?? 'lifecycle-fast',
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function isLifecycleTelemetryEventName(eventName: string | null): boolean {
|
|
530
|
+
const normalized = eventName?.trim().toLowerCase() ?? '';
|
|
531
|
+
if (normalized.length === 0) {
|
|
532
|
+
return false;
|
|
533
|
+
}
|
|
534
|
+
return LIFECYCLE_TELEMETRY_EVENT_NAMES.has(normalized);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function normalizeCodexHistoryConfig(
|
|
538
|
+
input: CodexHistoryIngestConfig | undefined,
|
|
539
|
+
): CodexHistoryIngestConfig {
|
|
540
|
+
return {
|
|
541
|
+
enabled: input?.enabled ?? false,
|
|
542
|
+
filePath: input?.filePath ?? '~/.codex/history.jsonl',
|
|
543
|
+
pollMs: Math.max(25, input?.pollMs ?? 500),
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function normalizeCodexLaunchConfig(input: CodexLaunchConfig | undefined): CodexLaunchConfig {
|
|
548
|
+
return {
|
|
549
|
+
defaultMode: input?.defaultMode ?? 'standard',
|
|
550
|
+
directoryModes: input?.directoryModes ?? {},
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function normalizeCritiqueConfig(input: CritiqueConfig | undefined): CritiqueConfig {
|
|
555
|
+
const normalizedDefaultArgs = input?.launch.defaultArgs
|
|
556
|
+
?.flatMap((value) => (typeof value === 'string' ? [value.trim()] : []))
|
|
557
|
+
.filter((value) => value.length > 0);
|
|
558
|
+
const defaultArgs =
|
|
559
|
+
normalizedDefaultArgs === undefined || normalizedDefaultArgs.length === 0
|
|
560
|
+
? [...DEFAULT_CRITIQUE_DEFAULT_ARGS]
|
|
561
|
+
: normalizedDefaultArgs;
|
|
562
|
+
const packageNameRaw = input?.install.package;
|
|
563
|
+
const packageName =
|
|
564
|
+
typeof packageNameRaw === 'string' && packageNameRaw.trim().length > 0
|
|
565
|
+
? packageNameRaw.trim()
|
|
566
|
+
: DEFAULT_CRITIQUE_PACKAGE;
|
|
567
|
+
return {
|
|
568
|
+
launch: {
|
|
569
|
+
defaultArgs,
|
|
570
|
+
},
|
|
571
|
+
install: {
|
|
572
|
+
autoInstall: input?.install.autoInstall ?? true,
|
|
573
|
+
package: packageName,
|
|
574
|
+
},
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function normalizeCursorLaunchConfig(input: CursorLaunchConfig | undefined): CursorLaunchConfig {
|
|
579
|
+
return {
|
|
580
|
+
defaultMode: input?.defaultMode ?? 'standard',
|
|
581
|
+
directoryModes: input?.directoryModes ?? {},
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function normalizeCursorHooksConfig(
|
|
586
|
+
input: Partial<CursorHooksConfig> | undefined,
|
|
587
|
+
): CursorHooksConfig {
|
|
588
|
+
const relayScriptPath = input?.relayScriptPath;
|
|
589
|
+
const normalizedRelayScriptPath =
|
|
590
|
+
typeof relayScriptPath === 'string' && relayScriptPath.trim().length > 0
|
|
591
|
+
? resolve(relayScriptPath)
|
|
592
|
+
: resolve(DEFAULT_CURSOR_HOOK_RELAY_SCRIPT_PATH);
|
|
593
|
+
const hooksFilePath =
|
|
594
|
+
typeof input?.hooksFilePath === 'string' && input.hooksFilePath.trim().length > 0
|
|
595
|
+
? resolve(input.hooksFilePath)
|
|
596
|
+
: null;
|
|
597
|
+
return {
|
|
598
|
+
managed: input?.managed ?? true,
|
|
599
|
+
hooksFilePath,
|
|
600
|
+
relayScriptPath: normalizedRelayScriptPath,
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function jitterDelayMs(baseMs: number): number {
|
|
605
|
+
const clampedBaseMs = Math.max(25, Math.floor(baseMs));
|
|
606
|
+
const jitterWindowMs = Math.max(1, Math.floor(clampedBaseMs * HISTORY_POLL_JITTER_RATIO));
|
|
607
|
+
const jitterOffsetMs = Math.floor(Math.random() * (2 * jitterWindowMs + 1) - jitterWindowMs);
|
|
608
|
+
return Math.max(25, clampedBaseMs + jitterOffsetMs);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function normalizeGitStatusMonitorConfig(
|
|
612
|
+
input: GitStatusMonitorConfig | undefined,
|
|
613
|
+
): GitStatusMonitorConfig {
|
|
614
|
+
const pollMs = Math.max(100, input?.pollMs ?? DEFAULT_GIT_STATUS_POLL_MS);
|
|
615
|
+
const rawMaxConcurrency = input?.maxConcurrency;
|
|
616
|
+
const maxConcurrency =
|
|
617
|
+
typeof rawMaxConcurrency === 'number' && Number.isFinite(rawMaxConcurrency)
|
|
618
|
+
? Math.max(1, Math.floor(rawMaxConcurrency))
|
|
619
|
+
: 1;
|
|
620
|
+
const rawMinDirectoryRefreshMs = input?.minDirectoryRefreshMs;
|
|
621
|
+
const minDirectoryRefreshMs =
|
|
622
|
+
typeof rawMinDirectoryRefreshMs === 'number' && Number.isFinite(rawMinDirectoryRefreshMs)
|
|
623
|
+
? Math.max(pollMs, Math.floor(rawMinDirectoryRefreshMs))
|
|
624
|
+
: Math.max(pollMs, 30_000);
|
|
625
|
+
return {
|
|
626
|
+
enabled: input?.enabled ?? false,
|
|
627
|
+
pollMs,
|
|
628
|
+
maxConcurrency,
|
|
629
|
+
minDirectoryRefreshMs,
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function normalizeGitHubIntegrationConfig(
|
|
634
|
+
input: Partial<GitHubIntegrationConfig> | undefined,
|
|
635
|
+
): GitHubIntegrationConfig {
|
|
636
|
+
const tokenEnvVarRaw = input?.tokenEnvVar;
|
|
637
|
+
const tokenEnvVar =
|
|
638
|
+
typeof tokenEnvVarRaw === 'string' && tokenEnvVarRaw.trim().length > 0
|
|
639
|
+
? tokenEnvVarRaw.trim()
|
|
640
|
+
: 'GITHUB_TOKEN';
|
|
641
|
+
const envToken = process.env[tokenEnvVar];
|
|
642
|
+
const tokenRaw =
|
|
643
|
+
input?.token ??
|
|
644
|
+
(typeof envToken === 'string' && envToken.trim().length > 0 ? envToken.trim() : null);
|
|
645
|
+
const branchStrategyRaw = input?.branchStrategy;
|
|
646
|
+
const branchStrategy =
|
|
647
|
+
branchStrategyRaw === 'current-only' ||
|
|
648
|
+
branchStrategyRaw === 'pinned-only' ||
|
|
649
|
+
branchStrategyRaw === 'pinned-then-current'
|
|
650
|
+
? branchStrategyRaw
|
|
651
|
+
: 'pinned-then-current';
|
|
652
|
+
const viewerLoginRaw = input?.viewerLogin;
|
|
653
|
+
const viewerLogin =
|
|
654
|
+
typeof viewerLoginRaw === 'string' && viewerLoginRaw.trim().length > 0
|
|
655
|
+
? viewerLoginRaw.trim()
|
|
656
|
+
: null;
|
|
657
|
+
const pollMsRaw = input?.pollMs;
|
|
658
|
+
const pollMs =
|
|
659
|
+
typeof pollMsRaw === 'number' && Number.isFinite(pollMsRaw)
|
|
660
|
+
? Math.max(1000, Math.floor(pollMsRaw))
|
|
661
|
+
: DEFAULT_GITHUB_POLL_MS;
|
|
662
|
+
const maxConcurrencyRaw = input?.maxConcurrency;
|
|
663
|
+
const maxConcurrency =
|
|
664
|
+
typeof maxConcurrencyRaw === 'number' && Number.isFinite(maxConcurrencyRaw)
|
|
665
|
+
? Math.max(1, Math.floor(maxConcurrencyRaw))
|
|
666
|
+
: 1;
|
|
667
|
+
const apiBaseUrlRaw = input?.apiBaseUrl;
|
|
668
|
+
const apiBaseUrl =
|
|
669
|
+
typeof apiBaseUrlRaw === 'string' && apiBaseUrlRaw.trim().length > 0
|
|
670
|
+
? apiBaseUrlRaw.trim().replace(/\/+$/u, '')
|
|
671
|
+
: 'https://api.github.com';
|
|
672
|
+
return {
|
|
673
|
+
enabled: input?.enabled ?? false,
|
|
674
|
+
apiBaseUrl,
|
|
675
|
+
tokenEnvVar,
|
|
676
|
+
token: tokenRaw,
|
|
677
|
+
pollMs,
|
|
678
|
+
maxConcurrency,
|
|
679
|
+
branchStrategy,
|
|
680
|
+
viewerLogin,
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function parseGitHubOwnerRepoFromRemote(remoteUrl: string): { owner: string; repo: string } | null {
|
|
685
|
+
const trimmed = remoteUrl.trim();
|
|
686
|
+
if (trimmed.length === 0) {
|
|
687
|
+
return null;
|
|
688
|
+
}
|
|
689
|
+
const httpsMatch = /^https:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/iu.exec(trimmed);
|
|
690
|
+
if (httpsMatch !== null) {
|
|
691
|
+
return {
|
|
692
|
+
owner: httpsMatch[1] as string,
|
|
693
|
+
repo: httpsMatch[2] as string,
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
const sshMatch = /^git@github\.com:([^/]+)\/([^/]+?)(?:\.git)?$/iu.exec(trimmed);
|
|
697
|
+
if (sshMatch !== null) {
|
|
698
|
+
return {
|
|
699
|
+
owner: sshMatch[1] as string,
|
|
700
|
+
repo: sshMatch[2] as string,
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
return null;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function resolveTrackedBranchName(input: {
|
|
707
|
+
strategy: GitHubIntegrationConfig['branchStrategy'];
|
|
708
|
+
pinnedBranch: string | null;
|
|
709
|
+
currentBranch: string | null;
|
|
710
|
+
}): string | null {
|
|
711
|
+
if (input.strategy === 'pinned-only') {
|
|
712
|
+
return input.pinnedBranch;
|
|
713
|
+
}
|
|
714
|
+
if (input.strategy === 'current-only') {
|
|
715
|
+
return input.currentBranch;
|
|
716
|
+
}
|
|
717
|
+
return input.pinnedBranch ?? input.currentBranch;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function summarizeGitHubCiRollup(
|
|
721
|
+
jobs: readonly GitHubRemotePrJob[],
|
|
722
|
+
): 'pending' | 'success' | 'failure' | 'cancelled' | 'neutral' | 'none' {
|
|
723
|
+
if (jobs.length === 0) {
|
|
724
|
+
return 'none';
|
|
725
|
+
}
|
|
726
|
+
let hasPending = false;
|
|
727
|
+
let hasFailure = false;
|
|
728
|
+
let hasCancelled = false;
|
|
729
|
+
let hasSuccess = false;
|
|
730
|
+
for (const job of jobs) {
|
|
731
|
+
const status = job.status.toLowerCase();
|
|
732
|
+
const conclusion = job.conclusion?.toLowerCase() ?? null;
|
|
733
|
+
if (status !== 'completed') {
|
|
734
|
+
hasPending = true;
|
|
735
|
+
continue;
|
|
736
|
+
}
|
|
737
|
+
if (
|
|
738
|
+
conclusion === 'failure' ||
|
|
739
|
+
conclusion === 'timed_out' ||
|
|
740
|
+
conclusion === 'action_required'
|
|
741
|
+
) {
|
|
742
|
+
hasFailure = true;
|
|
743
|
+
continue;
|
|
744
|
+
}
|
|
745
|
+
if (conclusion === 'cancelled') {
|
|
746
|
+
hasCancelled = true;
|
|
747
|
+
continue;
|
|
748
|
+
}
|
|
749
|
+
if (conclusion === 'success') {
|
|
750
|
+
hasSuccess = true;
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
if (hasFailure) {
|
|
754
|
+
return 'failure';
|
|
755
|
+
}
|
|
756
|
+
if (hasPending) {
|
|
757
|
+
return 'pending';
|
|
758
|
+
}
|
|
759
|
+
if (hasCancelled) {
|
|
760
|
+
return 'cancelled';
|
|
761
|
+
}
|
|
762
|
+
if (hasSuccess) {
|
|
763
|
+
return 'success';
|
|
764
|
+
}
|
|
765
|
+
return 'neutral';
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
async function runWithConcurrencyLimit<T>(
|
|
769
|
+
values: readonly T[],
|
|
770
|
+
concurrency: number,
|
|
771
|
+
worker: (value: T) => Promise<void>,
|
|
772
|
+
): Promise<void> {
|
|
773
|
+
if (values.length === 0) {
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
const workerCount = Math.min(values.length, Math.max(1, Math.floor(concurrency)));
|
|
777
|
+
let index = 0;
|
|
778
|
+
const runners: Promise<void>[] = [];
|
|
779
|
+
for (let workerIndex = 0; workerIndex < workerCount; workerIndex += 1) {
|
|
780
|
+
runners.push(
|
|
781
|
+
(async () => {
|
|
782
|
+
while (true) {
|
|
783
|
+
const nextIndex = index;
|
|
784
|
+
index += 1;
|
|
785
|
+
if (nextIndex >= values.length) {
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
const value = values[nextIndex];
|
|
789
|
+
if (value === undefined) {
|
|
790
|
+
continue;
|
|
791
|
+
}
|
|
792
|
+
await worker(value);
|
|
793
|
+
}
|
|
794
|
+
})(),
|
|
795
|
+
);
|
|
796
|
+
}
|
|
797
|
+
await Promise.all(runners);
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
function parseOtlpEndpoint(urlPath: string): OtlpEndpointTarget | null {
|
|
801
|
+
const [pathPart = ''] = urlPath.trim().split('?');
|
|
802
|
+
const match = /^\/v1\/(logs|metrics|traces)\/([^/]+)$/u.exec(pathPart);
|
|
803
|
+
if (match === null) {
|
|
804
|
+
return null;
|
|
805
|
+
}
|
|
806
|
+
let decodedToken = '';
|
|
807
|
+
try {
|
|
808
|
+
decodedToken = decodeURIComponent(match[2] as string);
|
|
809
|
+
} catch {
|
|
810
|
+
return null;
|
|
811
|
+
}
|
|
812
|
+
return {
|
|
813
|
+
kind: match[1] as 'logs' | 'metrics' | 'traces',
|
|
814
|
+
token: decodedToken,
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function gitSummaryEqual(
|
|
819
|
+
left: GitDirectorySnapshot['summary'],
|
|
820
|
+
right: GitDirectorySnapshot['summary'],
|
|
821
|
+
): boolean {
|
|
822
|
+
return (
|
|
823
|
+
left.branch === right.branch &&
|
|
824
|
+
left.changedFiles === right.changedFiles &&
|
|
825
|
+
left.additions === right.additions &&
|
|
826
|
+
left.deletions === right.deletions
|
|
827
|
+
);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
function gitRepositorySnapshotEqual(
|
|
831
|
+
left: GitDirectorySnapshot['repository'],
|
|
832
|
+
right: GitDirectorySnapshot['repository'],
|
|
833
|
+
): boolean {
|
|
834
|
+
return (
|
|
835
|
+
left.normalizedRemoteUrl === right.normalizedRemoteUrl &&
|
|
836
|
+
left.commitCount === right.commitCount &&
|
|
837
|
+
left.lastCommitAt === right.lastCommitAt &&
|
|
838
|
+
left.shortCommitHash === right.shortCommitHash &&
|
|
839
|
+
left.inferredName === right.inferredName &&
|
|
840
|
+
left.defaultBranch === right.defaultBranch
|
|
841
|
+
);
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
const streamServerInternals = {
|
|
845
|
+
runWithConcurrencyLimit,
|
|
846
|
+
gitSummaryEqual,
|
|
847
|
+
gitRepositorySnapshotEqual,
|
|
848
|
+
normalizeGitHubIntegrationConfig,
|
|
849
|
+
parseGitHubOwnerRepoFromRemote,
|
|
850
|
+
resolveTrackedBranchName,
|
|
851
|
+
summarizeGitHubCiRollup,
|
|
852
|
+
};
|
|
853
|
+
export const streamServerTestInternals = streamServerInternals;
|
|
854
|
+
|
|
855
|
+
function toPublicSessionController(
|
|
856
|
+
controller: SessionControllerState | null | undefined,
|
|
857
|
+
): StreamSessionController | null {
|
|
858
|
+
if (controller === null || controller === undefined) {
|
|
859
|
+
return null;
|
|
860
|
+
}
|
|
861
|
+
return {
|
|
862
|
+
controllerId: controller.controllerId,
|
|
863
|
+
controllerType: controller.controllerType,
|
|
864
|
+
controllerLabel: controller.controllerLabel,
|
|
865
|
+
claimedAt: controller.claimedAt,
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
function controllerDisplayName(controller: SessionControllerState): string {
|
|
870
|
+
const label = controller.controllerLabel?.trim() ?? '';
|
|
871
|
+
if (label.length > 0) {
|
|
872
|
+
return label;
|
|
873
|
+
}
|
|
874
|
+
return `${controller.controllerType}:${controller.controllerId}`;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function shellQuoteToken(token: string): string {
|
|
878
|
+
if (token.length === 0) {
|
|
879
|
+
return "''";
|
|
880
|
+
}
|
|
881
|
+
if (/^[A-Za-z0-9_./:@%+=,-]+$/u.test(token)) {
|
|
882
|
+
return token;
|
|
883
|
+
}
|
|
884
|
+
return `'${token.replaceAll("'", "'\"'\"'")}'`;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
function formatLaunchCommand(command: string, args: readonly string[]): string {
|
|
888
|
+
const tokens = [command, ...args].map(shellQuoteToken);
|
|
889
|
+
return tokens.join(' ');
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
export function resolveTerminalCommandForEnvironment(
|
|
893
|
+
env: NodeJS.ProcessEnv,
|
|
894
|
+
platform: NodeJS.Platform,
|
|
895
|
+
): string {
|
|
896
|
+
const shellCommand = env.SHELL?.trim();
|
|
897
|
+
if (shellCommand !== undefined && shellCommand.length > 0) {
|
|
898
|
+
return shellCommand;
|
|
899
|
+
}
|
|
900
|
+
const windowsCommand = env.ComSpec?.trim();
|
|
901
|
+
if (windowsCommand !== undefined && windowsCommand.length > 0) {
|
|
902
|
+
return windowsCommand;
|
|
903
|
+
}
|
|
904
|
+
return platform === 'win32' ? 'cmd.exe' : 'sh';
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
export class ControlPlaneStreamServer {
|
|
908
|
+
private readonly host: string;
|
|
909
|
+
private readonly port: number;
|
|
910
|
+
private readonly authToken: string | null;
|
|
911
|
+
private readonly maxConnectionBufferedBytes: number;
|
|
912
|
+
private readonly sessionExitTombstoneTtlMs: number;
|
|
913
|
+
private readonly maxStreamJournalEntries: number;
|
|
914
|
+
private readonly startSession: StartControlPlaneSession;
|
|
915
|
+
private readonly stateStore: SqliteControlPlaneStore;
|
|
916
|
+
private readonly ownsStateStore: boolean;
|
|
917
|
+
private readonly codexTelemetry: CodexTelemetryServerConfig;
|
|
918
|
+
private readonly codexHistory: CodexHistoryIngestConfig;
|
|
919
|
+
private readonly codexLaunch: CodexLaunchConfig;
|
|
920
|
+
private readonly critique: CritiqueConfig;
|
|
921
|
+
private readonly cursorLaunch: CursorLaunchConfig;
|
|
922
|
+
private readonly cursorHooks: CursorHooksConfig;
|
|
923
|
+
private readonly gitStatusMonitor: GitStatusMonitorConfig;
|
|
924
|
+
private readonly github: GitHubIntegrationConfig;
|
|
925
|
+
private readonly githubTokenResolver: GitHubTokenResolver;
|
|
926
|
+
private readonly githubExecFile: GitHubExecFile;
|
|
927
|
+
private readonly githubFetch: typeof fetch;
|
|
928
|
+
private readonly githubApi: {
|
|
929
|
+
openPullRequestForBranch(input: {
|
|
930
|
+
owner: string;
|
|
931
|
+
repo: string;
|
|
932
|
+
headBranch: string;
|
|
933
|
+
}): Promise<GitHubRemotePullRequest | null>;
|
|
934
|
+
createPullRequest(input: {
|
|
935
|
+
owner: string;
|
|
936
|
+
repo: string;
|
|
937
|
+
title: string;
|
|
938
|
+
body: string;
|
|
939
|
+
head: string;
|
|
940
|
+
base: string;
|
|
941
|
+
draft: boolean;
|
|
942
|
+
}): Promise<GitHubRemotePullRequest>;
|
|
943
|
+
};
|
|
944
|
+
private readonly readGitDirectorySnapshot: GitDirectorySnapshotReader;
|
|
945
|
+
private readonly statusEngine = new SessionStatusEngine();
|
|
946
|
+
private readonly server: Server;
|
|
947
|
+
private readonly telemetryServer: HttpServer | null;
|
|
948
|
+
private telemetryAddress: AddressInfo | null = null;
|
|
949
|
+
private readonly telemetryTokenToSessionId = new Map<string, string>();
|
|
950
|
+
private readonly lifecycleHooks: LifecycleHooksRuntime;
|
|
951
|
+
private historyPollTimer: NodeJS.Timeout | null = null;
|
|
952
|
+
private historyPollInFlight = false;
|
|
953
|
+
private historyIdleStreak = 0;
|
|
954
|
+
private historyNextAllowedPollAtMs = 0;
|
|
955
|
+
private historyOffset = 0;
|
|
956
|
+
private historyRemainder = '';
|
|
957
|
+
private gitStatusPollTimer: NodeJS.Timeout | null = null;
|
|
958
|
+
private githubPollTimer: NodeJS.Timeout | null = null;
|
|
959
|
+
private githubTokenResolveInFlight: Promise<string | null> | null = null;
|
|
960
|
+
private githubTokenResolutionError: string | null = null;
|
|
961
|
+
private gitStatusPollInFlight = false;
|
|
962
|
+
private githubPollInFlight = false;
|
|
963
|
+
private readonly gitStatusRefreshInFlightDirectoryIds = new Set<string>();
|
|
964
|
+
private readonly gitStatusByDirectoryId = new Map<string, DirectoryGitStatusCacheEntry>();
|
|
965
|
+
private readonly gitStatusDirectoriesById = new Map<string, ControlPlaneDirectoryRecord>();
|
|
966
|
+
private readonly connections = new Map<string, ConnectionState>();
|
|
967
|
+
private readonly sessions = new Map<string, SessionState>();
|
|
968
|
+
private readonly launchCommandBySessionId = new Map<string, string>();
|
|
969
|
+
private readonly streamSubscriptions = new Map<string, StreamSubscriptionState>();
|
|
970
|
+
private readonly streamJournal: StreamJournalEntry[] = [];
|
|
971
|
+
private streamCursor = 0;
|
|
972
|
+
private listening = false;
|
|
973
|
+
private stateStoreClosed = false;
|
|
974
|
+
|
|
975
|
+
constructor(options: StartControlPlaneStreamServerOptions = {}) {
|
|
976
|
+
this.host = options.host ?? '127.0.0.1';
|
|
977
|
+
this.port = options.port ?? 0;
|
|
978
|
+
this.authToken = options.authToken ?? null;
|
|
979
|
+
this.maxConnectionBufferedBytes =
|
|
980
|
+
options.maxConnectionBufferedBytes ?? DEFAULT_MAX_CONNECTION_BUFFERED_BYTES;
|
|
981
|
+
this.sessionExitTombstoneTtlMs =
|
|
982
|
+
options.sessionExitTombstoneTtlMs ?? DEFAULT_SESSION_EXIT_TOMBSTONE_TTL_MS;
|
|
983
|
+
this.maxStreamJournalEntries =
|
|
984
|
+
options.maxStreamJournalEntries ?? DEFAULT_MAX_STREAM_JOURNAL_ENTRIES;
|
|
985
|
+
if (options.startSession === undefined) {
|
|
986
|
+
throw new Error('startSession is required');
|
|
987
|
+
}
|
|
988
|
+
this.startSession = options.startSession;
|
|
989
|
+
if (options.stateStore !== undefined) {
|
|
990
|
+
this.stateStore = options.stateStore;
|
|
991
|
+
this.ownsStateStore = false;
|
|
992
|
+
} else {
|
|
993
|
+
this.stateStore = new SqliteControlPlaneStore(options.stateStorePath ?? ':memory:');
|
|
994
|
+
this.ownsStateStore = true;
|
|
995
|
+
}
|
|
996
|
+
this.codexTelemetry = normalizeCodexTelemetryConfig(options.codexTelemetry);
|
|
997
|
+
this.codexHistory = normalizeCodexHistoryConfig(options.codexHistory);
|
|
998
|
+
this.codexLaunch = normalizeCodexLaunchConfig(options.codexLaunch);
|
|
999
|
+
this.critique = normalizeCritiqueConfig(options.critique);
|
|
1000
|
+
this.cursorLaunch = normalizeCursorLaunchConfig(options.cursorLaunch);
|
|
1001
|
+
this.cursorHooks = normalizeCursorHooksConfig(options.cursorHooks);
|
|
1002
|
+
this.gitStatusMonitor = normalizeGitStatusMonitorConfig(options.gitStatus);
|
|
1003
|
+
this.github = normalizeGitHubIntegrationConfig(options.github);
|
|
1004
|
+
this.githubExecFile = options.githubExecFile ?? execFile;
|
|
1005
|
+
this.githubTokenResolver =
|
|
1006
|
+
options.githubTokenResolver ?? (async () => await this.readGhAuthToken());
|
|
1007
|
+
this.githubFetch = options.githubFetch ?? fetch;
|
|
1008
|
+
this.githubApi = {
|
|
1009
|
+
openPullRequestForBranch: async (input) => await this.openGitHubPullRequestForBranch(input),
|
|
1010
|
+
createPullRequest: async (input) => await this.createGitHubPullRequest(input),
|
|
1011
|
+
};
|
|
1012
|
+
this.readGitDirectorySnapshot =
|
|
1013
|
+
options.readGitDirectorySnapshot ??
|
|
1014
|
+
(async (cwd: string) =>
|
|
1015
|
+
await readGitDirectorySnapshot(cwd, undefined, {
|
|
1016
|
+
includeCommitCount: false,
|
|
1017
|
+
}));
|
|
1018
|
+
this.lifecycleHooks = new LifecycleHooksRuntime(
|
|
1019
|
+
options.lifecycleHooks ?? {
|
|
1020
|
+
enabled: false,
|
|
1021
|
+
providers: {
|
|
1022
|
+
codex: true,
|
|
1023
|
+
claude: true,
|
|
1024
|
+
cursor: true,
|
|
1025
|
+
controlPlane: true,
|
|
1026
|
+
},
|
|
1027
|
+
peonPing: {
|
|
1028
|
+
enabled: false,
|
|
1029
|
+
baseUrl: 'http://127.0.0.1:19998',
|
|
1030
|
+
timeoutMs: 1200,
|
|
1031
|
+
eventCategoryMap: {},
|
|
1032
|
+
},
|
|
1033
|
+
webhooks: [],
|
|
1034
|
+
},
|
|
1035
|
+
);
|
|
1036
|
+
this.server = createServer((socket) => {
|
|
1037
|
+
this.handleConnection(socket);
|
|
1038
|
+
});
|
|
1039
|
+
this.telemetryServer = this.codexTelemetry.enabled
|
|
1040
|
+
? createHttpServer((request, response) => {
|
|
1041
|
+
this.handleTelemetryHttpRequest(request, response);
|
|
1042
|
+
})
|
|
1043
|
+
: null;
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
async start(): Promise<void> {
|
|
1047
|
+
if (this.listening) {
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
await new Promise<void>((resolve, reject) => {
|
|
1052
|
+
const onError = (error: Error): void => {
|
|
1053
|
+
this.server.off('listening', onListening);
|
|
1054
|
+
reject(error);
|
|
1055
|
+
};
|
|
1056
|
+
const onListening = (): void => {
|
|
1057
|
+
this.server.off('error', onError);
|
|
1058
|
+
this.listening = true;
|
|
1059
|
+
resolve();
|
|
1060
|
+
};
|
|
1061
|
+
|
|
1062
|
+
this.server.once('error', onError);
|
|
1063
|
+
this.server.once('listening', onListening);
|
|
1064
|
+
this.server.listen(this.port, this.host);
|
|
1065
|
+
});
|
|
1066
|
+
|
|
1067
|
+
if (this.telemetryServer !== null) {
|
|
1068
|
+
await this.startTelemetryServer();
|
|
1069
|
+
}
|
|
1070
|
+
this.autoStartPersistedConversationsOnStartup();
|
|
1071
|
+
this.startHistoryPollingIfEnabled();
|
|
1072
|
+
this.startGitStatusPollingIfEnabled();
|
|
1073
|
+
this.startGitHubPollingIfEnabled();
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
address(): AddressInfo {
|
|
1077
|
+
const value = this.server.address();
|
|
1078
|
+
if (value === null || typeof value === 'string') {
|
|
1079
|
+
throw new Error('control-plane server is not listening on tcp');
|
|
1080
|
+
}
|
|
1081
|
+
return value;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
telemetryAddressInfo(): AddressInfo | null {
|
|
1085
|
+
if (this.telemetryAddress === null) {
|
|
1086
|
+
return null;
|
|
1087
|
+
}
|
|
1088
|
+
return {
|
|
1089
|
+
address: this.telemetryAddress.address,
|
|
1090
|
+
family: this.telemetryAddress.family,
|
|
1091
|
+
port: this.telemetryAddress.port,
|
|
1092
|
+
};
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
async close(): Promise<void> {
|
|
1096
|
+
this.stopHistoryPolling();
|
|
1097
|
+
this.stopGitStatusPolling();
|
|
1098
|
+
this.stopGitHubPolling();
|
|
1099
|
+
|
|
1100
|
+
for (const sessionId of [...this.sessions.keys()]) {
|
|
1101
|
+
this.destroySession(sessionId, true);
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
for (const connection of this.connections.values()) {
|
|
1105
|
+
connection.socket.destroy();
|
|
1106
|
+
}
|
|
1107
|
+
this.connections.clear();
|
|
1108
|
+
this.streamSubscriptions.clear();
|
|
1109
|
+
this.streamJournal.length = 0;
|
|
1110
|
+
|
|
1111
|
+
if (!this.listening) {
|
|
1112
|
+
await this.closeTelemetryServerIfOpen();
|
|
1113
|
+
this.closeOwnedStateStore();
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
await new Promise<void>((resolve) => {
|
|
1118
|
+
this.server.close(() => {
|
|
1119
|
+
this.listening = false;
|
|
1120
|
+
resolve();
|
|
1121
|
+
});
|
|
1122
|
+
});
|
|
1123
|
+
await this.closeTelemetryServerIfOpen();
|
|
1124
|
+
await this.lifecycleHooks.close();
|
|
1125
|
+
this.closeOwnedStateStore();
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
private closeOwnedStateStore(): void {
|
|
1129
|
+
closeOwnedStreamServerStateStore(
|
|
1130
|
+
this as unknown as Parameters<typeof closeOwnedStreamServerStateStore>[0],
|
|
1131
|
+
);
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
private async startTelemetryServer(): Promise<void> {
|
|
1135
|
+
if (this.telemetryServer === null || this.telemetryAddress !== null) {
|
|
1136
|
+
return;
|
|
1137
|
+
}
|
|
1138
|
+
await new Promise<void>((resolveStart, rejectStart) => {
|
|
1139
|
+
const onError = (error: Error): void => {
|
|
1140
|
+
this.telemetryServer?.off('listening', onListening);
|
|
1141
|
+
rejectStart(error);
|
|
1142
|
+
};
|
|
1143
|
+
const onListening = (): void => {
|
|
1144
|
+
this.telemetryServer?.off('error', onError);
|
|
1145
|
+
const telemetryServer = this.telemetryServer;
|
|
1146
|
+
this.telemetryAddress = telemetryServer!.address() as AddressInfo;
|
|
1147
|
+
resolveStart();
|
|
1148
|
+
};
|
|
1149
|
+
this.telemetryServer?.once('error', onError);
|
|
1150
|
+
this.telemetryServer?.once('listening', onListening);
|
|
1151
|
+
this.telemetryServer?.listen(this.codexTelemetry.port, this.codexTelemetry.host);
|
|
1152
|
+
});
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
private async closeTelemetryServerIfOpen(): Promise<void> {
|
|
1156
|
+
if (this.telemetryServer === null) {
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
const telemetryServer = this.telemetryServer;
|
|
1160
|
+
await new Promise<void>((resolveClose) => {
|
|
1161
|
+
if (!telemetryServer.listening) {
|
|
1162
|
+
this.telemetryAddress = null;
|
|
1163
|
+
resolveClose();
|
|
1164
|
+
return;
|
|
1165
|
+
}
|
|
1166
|
+
telemetryServer.close(() => {
|
|
1167
|
+
this.telemetryAddress = null;
|
|
1168
|
+
resolveClose();
|
|
1169
|
+
});
|
|
1170
|
+
});
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
private startHistoryPollingIfEnabled(): void {
|
|
1174
|
+
if (!this.codexHistory.enabled || this.historyPollTimer !== null) {
|
|
1175
|
+
return;
|
|
1176
|
+
}
|
|
1177
|
+
this.historyIdleStreak = 0;
|
|
1178
|
+
this.historyNextAllowedPollAtMs = Date.now() + jitterDelayMs(this.codexHistory.pollMs);
|
|
1179
|
+
const pollTickMs = Math.max(250, Math.floor(this.codexHistory.pollMs / 4));
|
|
1180
|
+
this.historyPollTimer = setInterval(this.pollHistoryTimerTick.bind(this), pollTickMs);
|
|
1181
|
+
this.historyPollTimer.unref();
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
private pollHistoryTimerTick(): void {
|
|
1185
|
+
void this.pollHistoryFile();
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
private stopHistoryPolling(): void {
|
|
1189
|
+
if (this.historyPollTimer === null) {
|
|
1190
|
+
return;
|
|
1191
|
+
}
|
|
1192
|
+
clearInterval(this.historyPollTimer);
|
|
1193
|
+
this.historyPollTimer = null;
|
|
1194
|
+
this.historyIdleStreak = 0;
|
|
1195
|
+
this.historyNextAllowedPollAtMs = 0;
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
private startGitStatusPollingIfEnabled(): void {
|
|
1199
|
+
if (!this.gitStatusMonitor.enabled || this.gitStatusPollTimer !== null) {
|
|
1200
|
+
return;
|
|
1201
|
+
}
|
|
1202
|
+
this.reloadGitStatusDirectoriesFromStore();
|
|
1203
|
+
void this.pollGitStatus();
|
|
1204
|
+
this.gitStatusPollTimer = setInterval(() => {
|
|
1205
|
+
void this.pollGitStatus();
|
|
1206
|
+
}, this.gitStatusMonitor.pollMs);
|
|
1207
|
+
this.gitStatusPollTimer.unref();
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
private stopGitStatusPolling(): void {
|
|
1211
|
+
if (this.gitStatusPollTimer === null) {
|
|
1212
|
+
return;
|
|
1213
|
+
}
|
|
1214
|
+
clearInterval(this.gitStatusPollTimer);
|
|
1215
|
+
this.gitStatusPollTimer = null;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
private async readGhAuthToken(): Promise<string | null> {
|
|
1219
|
+
return await new Promise((resolveToken) => {
|
|
1220
|
+
this.githubExecFile(
|
|
1221
|
+
'gh',
|
|
1222
|
+
['auth', 'token'],
|
|
1223
|
+
{
|
|
1224
|
+
timeout: 2000,
|
|
1225
|
+
windowsHide: true,
|
|
1226
|
+
},
|
|
1227
|
+
(error, stdout) => {
|
|
1228
|
+
if (error !== null) {
|
|
1229
|
+
resolveToken(null);
|
|
1230
|
+
return;
|
|
1231
|
+
}
|
|
1232
|
+
const token = stdout.trim();
|
|
1233
|
+
resolveToken(token.length > 0 ? token : null);
|
|
1234
|
+
},
|
|
1235
|
+
);
|
|
1236
|
+
});
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
private async resolveGitHubTokenIfNeeded(): Promise<string | null> {
|
|
1240
|
+
if (this.github.token !== null) {
|
|
1241
|
+
return this.github.token;
|
|
1242
|
+
}
|
|
1243
|
+
if (this.githubTokenResolveInFlight !== null) {
|
|
1244
|
+
return await this.githubTokenResolveInFlight;
|
|
1245
|
+
}
|
|
1246
|
+
this.githubTokenResolveInFlight = (async () => {
|
|
1247
|
+
try {
|
|
1248
|
+
const resolved = await this.githubTokenResolver();
|
|
1249
|
+
if (resolved === null) {
|
|
1250
|
+
this.githubTokenResolutionError = 'missing token and gh auth token unavailable';
|
|
1251
|
+
recordPerfEvent('control-plane.github.token.unavailable', {
|
|
1252
|
+
tokenEnvVar: this.github.tokenEnvVar,
|
|
1253
|
+
fallback: 'gh auth token',
|
|
1254
|
+
});
|
|
1255
|
+
return null;
|
|
1256
|
+
}
|
|
1257
|
+
this.githubTokenResolutionError = null;
|
|
1258
|
+
(this.github as { token: string | null }).token = resolved;
|
|
1259
|
+
recordPerfEvent('control-plane.github.token.resolved', {
|
|
1260
|
+
source: 'gh auth token',
|
|
1261
|
+
});
|
|
1262
|
+
return resolved;
|
|
1263
|
+
} catch (error: unknown) {
|
|
1264
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1265
|
+
this.githubTokenResolutionError = message;
|
|
1266
|
+
recordPerfEvent('control-plane.github.token.resolve-failed', {
|
|
1267
|
+
error: message,
|
|
1268
|
+
});
|
|
1269
|
+
return null;
|
|
1270
|
+
} finally {
|
|
1271
|
+
this.githubTokenResolveInFlight = null;
|
|
1272
|
+
}
|
|
1273
|
+
})();
|
|
1274
|
+
return await this.githubTokenResolveInFlight;
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
private startGitHubPollingIfEnabled(): void {
|
|
1278
|
+
if (!this.github.enabled || this.githubPollTimer !== null) {
|
|
1279
|
+
return;
|
|
1280
|
+
}
|
|
1281
|
+
void this.pollGitHub();
|
|
1282
|
+
this.githubPollTimer = setInterval(() => {
|
|
1283
|
+
void this.pollGitHub();
|
|
1284
|
+
}, this.github.pollMs);
|
|
1285
|
+
this.githubPollTimer.unref();
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
private stopGitHubPolling(): void {
|
|
1289
|
+
if (this.githubPollTimer === null) {
|
|
1290
|
+
return;
|
|
1291
|
+
}
|
|
1292
|
+
clearInterval(this.githubPollTimer);
|
|
1293
|
+
this.githubPollTimer = null;
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
private reloadGitStatusDirectoriesFromStore(): void {
|
|
1297
|
+
const directories = this.stateStore.listDirectories({
|
|
1298
|
+
includeArchived: false,
|
|
1299
|
+
limit: 1000,
|
|
1300
|
+
});
|
|
1301
|
+
this.gitStatusDirectoriesById.clear();
|
|
1302
|
+
for (const directory of directories) {
|
|
1303
|
+
this.gitStatusDirectoriesById.set(directory.directoryId, directory);
|
|
1304
|
+
}
|
|
1305
|
+
for (const directoryId of this.gitStatusByDirectoryId.keys()) {
|
|
1306
|
+
if (!this.gitStatusDirectoriesById.has(directoryId)) {
|
|
1307
|
+
this.gitStatusByDirectoryId.delete(directoryId);
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
private codexLaunchArgsForSession(sessionId: string, agentType: string): readonly string[] {
|
|
1313
|
+
if (agentType !== 'codex') {
|
|
1314
|
+
return [];
|
|
1315
|
+
}
|
|
1316
|
+
const endpointBaseUrl = this.telemetryEndpointBaseUrl();
|
|
1317
|
+
if (endpointBaseUrl === null) {
|
|
1318
|
+
if (!this.codexHistory.enabled) {
|
|
1319
|
+
return [];
|
|
1320
|
+
}
|
|
1321
|
+
return ['-c', 'history.persistence="save-all"'];
|
|
1322
|
+
}
|
|
1323
|
+
const token = randomUUID();
|
|
1324
|
+
this.telemetryTokenToSessionId.set(token, sessionId);
|
|
1325
|
+
return buildCodexTelemetryConfigArgs({
|
|
1326
|
+
endpointBaseUrl,
|
|
1327
|
+
token,
|
|
1328
|
+
logUserPrompt: this.codexTelemetry.logUserPrompt,
|
|
1329
|
+
captureLogs: this.codexTelemetry.captureLogs,
|
|
1330
|
+
captureMetrics: this.codexTelemetry.captureMetrics,
|
|
1331
|
+
captureTraces: this.codexTelemetry.captureTraces,
|
|
1332
|
+
historyPersistence: this.codexHistory.enabled ? 'save-all' : 'none',
|
|
1333
|
+
});
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
private claudeHookLaunchConfigForSession(
|
|
1337
|
+
sessionId: string,
|
|
1338
|
+
agentType: string,
|
|
1339
|
+
): {
|
|
1340
|
+
readonly args: readonly string[];
|
|
1341
|
+
readonly notifyFilePath: string;
|
|
1342
|
+
} | null {
|
|
1343
|
+
if (agentType !== 'claude') {
|
|
1344
|
+
return null;
|
|
1345
|
+
}
|
|
1346
|
+
const notifyFilePath = join(
|
|
1347
|
+
tmpdir(),
|
|
1348
|
+
`harness-claude-hook-${process.pid}-${sessionId}-${randomUUID()}.jsonl`,
|
|
1349
|
+
);
|
|
1350
|
+
const relayScriptPath = resolve(DEFAULT_CLAUDE_HOOK_RELAY_SCRIPT_PATH);
|
|
1351
|
+
const hookCommand = `/usr/bin/env ${shellEscape(process.execPath)} ${shellEscape(relayScriptPath)} ${shellEscape(notifyFilePath)}`;
|
|
1352
|
+
const hook = {
|
|
1353
|
+
type: 'command',
|
|
1354
|
+
command: hookCommand,
|
|
1355
|
+
};
|
|
1356
|
+
const settings = {
|
|
1357
|
+
hooks: {
|
|
1358
|
+
UserPromptSubmit: [{ hooks: [hook] }],
|
|
1359
|
+
PreToolUse: [{ hooks: [hook] }],
|
|
1360
|
+
Stop: [{ hooks: [hook] }],
|
|
1361
|
+
Notification: [{ hooks: [hook] }],
|
|
1362
|
+
},
|
|
1363
|
+
};
|
|
1364
|
+
return {
|
|
1365
|
+
args: ['--settings', JSON.stringify(settings)],
|
|
1366
|
+
notifyFilePath,
|
|
1367
|
+
};
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
private cursorHookLaunchConfigForSession(
|
|
1371
|
+
sessionId: string,
|
|
1372
|
+
agentType: string,
|
|
1373
|
+
): {
|
|
1374
|
+
readonly notifyFilePath: string;
|
|
1375
|
+
readonly env: Readonly<Record<string, string>>;
|
|
1376
|
+
} | null {
|
|
1377
|
+
if (agentType !== 'cursor') {
|
|
1378
|
+
return null;
|
|
1379
|
+
}
|
|
1380
|
+
if (this.cursorHooks.managed) {
|
|
1381
|
+
const relayCommand = buildCursorManagedHookRelayCommand(this.cursorHooks.relayScriptPath);
|
|
1382
|
+
const installResult = ensureManagedCursorHooksInstalled({
|
|
1383
|
+
relayCommand,
|
|
1384
|
+
...(this.cursorHooks.hooksFilePath === null
|
|
1385
|
+
? {}
|
|
1386
|
+
: { hooksFilePath: this.cursorHooks.hooksFilePath }),
|
|
1387
|
+
});
|
|
1388
|
+
recordPerfEvent('control-plane.cursor-hooks.managed.ensure', {
|
|
1389
|
+
filePath: installResult.filePath,
|
|
1390
|
+
changed: installResult.changed,
|
|
1391
|
+
removedCount: installResult.removedCount,
|
|
1392
|
+
addedCount: installResult.addedCount,
|
|
1393
|
+
});
|
|
1394
|
+
}
|
|
1395
|
+
const notifyFilePath = join(
|
|
1396
|
+
tmpdir(),
|
|
1397
|
+
`harness-cursor-hook-${process.pid}-${sessionId}-${randomUUID()}.jsonl`,
|
|
1398
|
+
);
|
|
1399
|
+
return {
|
|
1400
|
+
notifyFilePath,
|
|
1401
|
+
env: buildCursorHookRelayEnvironment(sessionId, notifyFilePath),
|
|
1402
|
+
};
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
private resolveTerminalCommand(): string {
|
|
1406
|
+
return resolveTerminalCommandForEnvironment(process.env, process.platform);
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
private launchProfileForAgent(agentType: string): {
|
|
1410
|
+
readonly command?: string;
|
|
1411
|
+
readonly baseArgs?: readonly string[];
|
|
1412
|
+
} {
|
|
1413
|
+
if (agentType === 'claude') {
|
|
1414
|
+
return {
|
|
1415
|
+
command: 'claude',
|
|
1416
|
+
baseArgs: [],
|
|
1417
|
+
};
|
|
1418
|
+
}
|
|
1419
|
+
if (agentType === 'cursor') {
|
|
1420
|
+
return {
|
|
1421
|
+
command: 'cursor-agent',
|
|
1422
|
+
baseArgs: [],
|
|
1423
|
+
};
|
|
1424
|
+
}
|
|
1425
|
+
if (agentType === 'critique') {
|
|
1426
|
+
return {
|
|
1427
|
+
command: 'critique',
|
|
1428
|
+
baseArgs: [],
|
|
1429
|
+
};
|
|
1430
|
+
}
|
|
1431
|
+
if (agentType !== 'terminal') {
|
|
1432
|
+
return {};
|
|
1433
|
+
}
|
|
1434
|
+
return {
|
|
1435
|
+
command: this.resolveTerminalCommand(),
|
|
1436
|
+
baseArgs: [],
|
|
1437
|
+
};
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
private autoStartPersistedConversationsOnStartup(): void {
|
|
1441
|
+
const conversations = this.stateStore.listConversations();
|
|
1442
|
+
let started = 0;
|
|
1443
|
+
let failed = 0;
|
|
1444
|
+
for (const conversation of conversations) {
|
|
1445
|
+
const adapterState = normalizeAdapterState(conversation.adapterState);
|
|
1446
|
+
const directory = this.stateStore.getDirectory(conversation.directoryId);
|
|
1447
|
+
const baseArgs =
|
|
1448
|
+
conversation.agentType === 'critique' ? this.critique.launch.defaultArgs : [];
|
|
1449
|
+
const startArgs = buildAgentSessionStartArgs(conversation.agentType, baseArgs, adapterState, {
|
|
1450
|
+
directoryPath: directory?.path ?? null,
|
|
1451
|
+
codexLaunchDefaultMode: this.codexLaunch.defaultMode,
|
|
1452
|
+
codexLaunchModeByDirectoryPath: this.codexLaunch.directoryModes,
|
|
1453
|
+
cursorLaunchDefaultMode: this.cursorLaunch.defaultMode,
|
|
1454
|
+
cursorLaunchModeByDirectoryPath: this.cursorLaunch.directoryModes,
|
|
1455
|
+
});
|
|
1456
|
+
try {
|
|
1457
|
+
const bootstrapInput: StartSessionRuntimeInput = {
|
|
1458
|
+
sessionId: conversation.conversationId,
|
|
1459
|
+
args: startArgs,
|
|
1460
|
+
initialCols: DEFAULT_BOOTSTRAP_SESSION_COLS,
|
|
1461
|
+
initialRows: DEFAULT_BOOTSTRAP_SESSION_ROWS,
|
|
1462
|
+
tenantId: conversation.tenantId,
|
|
1463
|
+
userId: conversation.userId,
|
|
1464
|
+
workspaceId: conversation.workspaceId,
|
|
1465
|
+
worktreeId: DEFAULT_WORKTREE_ID,
|
|
1466
|
+
...(directory?.path !== undefined ? { cwd: directory.path } : {}),
|
|
1467
|
+
};
|
|
1468
|
+
this.startSessionRuntime(bootstrapInput);
|
|
1469
|
+
started += 1;
|
|
1470
|
+
} catch {
|
|
1471
|
+
failed += 1;
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
recordPerfEvent('control-plane.startup.sessions-auto-start', {
|
|
1475
|
+
conversations: conversations.length,
|
|
1476
|
+
started,
|
|
1477
|
+
failed,
|
|
1478
|
+
});
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
private startSessionRuntime(command: StartSessionRuntimeInput): void {
|
|
1482
|
+
const existing = this.sessions.get(command.sessionId);
|
|
1483
|
+
if (existing !== undefined) {
|
|
1484
|
+
if (existing.status === 'exited' && existing.session === null) {
|
|
1485
|
+
this.destroySession(command.sessionId, false);
|
|
1486
|
+
} else {
|
|
1487
|
+
throw new Error(`session already exists: ${command.sessionId}`);
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
const persistedConversation = this.stateStore.getConversation(command.sessionId);
|
|
1492
|
+
const agentType = persistedConversation?.agentType ?? 'codex';
|
|
1493
|
+
const baseSessionArgs =
|
|
1494
|
+
agentType === 'critique' && command.args.length === 0
|
|
1495
|
+
? [...this.critique.launch.defaultArgs]
|
|
1496
|
+
: [...command.args];
|
|
1497
|
+
const codexLaunchArgs = this.codexLaunchArgsForSession(command.sessionId, agentType);
|
|
1498
|
+
const claudeHookLaunchConfig = this.claudeHookLaunchConfigForSession(
|
|
1499
|
+
command.sessionId,
|
|
1500
|
+
agentType,
|
|
1501
|
+
);
|
|
1502
|
+
const cursorHookLaunchConfig = this.cursorHookLaunchConfigForSession(
|
|
1503
|
+
command.sessionId,
|
|
1504
|
+
agentType,
|
|
1505
|
+
);
|
|
1506
|
+
const launchProfile = this.launchProfileForAgent(agentType);
|
|
1507
|
+
let launchCommandName = launchProfile.command ?? 'codex';
|
|
1508
|
+
let launchArgs = [
|
|
1509
|
+
...codexLaunchArgs,
|
|
1510
|
+
...(claudeHookLaunchConfig?.args ?? []),
|
|
1511
|
+
...baseSessionArgs,
|
|
1512
|
+
];
|
|
1513
|
+
if (agentType === 'critique' && this.critique.install.autoInstall) {
|
|
1514
|
+
launchCommandName = 'bunx';
|
|
1515
|
+
launchArgs = [this.critique.install.package, ...launchArgs];
|
|
1516
|
+
}
|
|
1517
|
+
const launchCommand = formatLaunchCommand(launchCommandName, launchArgs);
|
|
1518
|
+
const startInput: StartControlPlaneSessionInput = {
|
|
1519
|
+
args: launchArgs,
|
|
1520
|
+
initialCols: command.initialCols,
|
|
1521
|
+
initialRows: command.initialRows,
|
|
1522
|
+
};
|
|
1523
|
+
if (agentType === 'codex' || agentType === 'claude') {
|
|
1524
|
+
startInput.useNotifyHook = true;
|
|
1525
|
+
startInput.notifyMode = (
|
|
1526
|
+
agentType === 'claude' ? 'external' : 'codex'
|
|
1527
|
+
) as LiveSessionNotifyMode;
|
|
1528
|
+
}
|
|
1529
|
+
if (agentType === 'cursor') {
|
|
1530
|
+
startInput.useNotifyHook = true;
|
|
1531
|
+
startInput.notifyMode = 'external';
|
|
1532
|
+
}
|
|
1533
|
+
if (claudeHookLaunchConfig !== null) {
|
|
1534
|
+
startInput.notifyFilePath = claudeHookLaunchConfig.notifyFilePath;
|
|
1535
|
+
}
|
|
1536
|
+
if (cursorHookLaunchConfig !== null) {
|
|
1537
|
+
const mergedEnv: Record<string, string> = {};
|
|
1538
|
+
const baseEnv = command.env ?? process.env;
|
|
1539
|
+
for (const [key, value] of Object.entries(baseEnv)) {
|
|
1540
|
+
if (typeof value !== 'string') {
|
|
1541
|
+
continue;
|
|
1542
|
+
}
|
|
1543
|
+
mergedEnv[key] = value;
|
|
1544
|
+
}
|
|
1545
|
+
startInput.notifyFilePath = cursorHookLaunchConfig.notifyFilePath;
|
|
1546
|
+
startInput.env = {
|
|
1547
|
+
...mergedEnv,
|
|
1548
|
+
...cursorHookLaunchConfig.env,
|
|
1549
|
+
};
|
|
1550
|
+
}
|
|
1551
|
+
if (launchProfile.command !== undefined || launchCommandName !== 'codex') {
|
|
1552
|
+
startInput.command = launchCommandName;
|
|
1553
|
+
}
|
|
1554
|
+
if (launchProfile.baseArgs !== undefined) {
|
|
1555
|
+
startInput.baseArgs = [...launchProfile.baseArgs];
|
|
1556
|
+
}
|
|
1557
|
+
if (command.env !== undefined && cursorHookLaunchConfig === null) {
|
|
1558
|
+
startInput.env = command.env;
|
|
1559
|
+
}
|
|
1560
|
+
if (command.cwd !== undefined) {
|
|
1561
|
+
startInput.cwd = command.cwd;
|
|
1562
|
+
}
|
|
1563
|
+
if (command.terminalForegroundHex !== undefined) {
|
|
1564
|
+
startInput.terminalForegroundHex = command.terminalForegroundHex;
|
|
1565
|
+
}
|
|
1566
|
+
if (command.terminalBackgroundHex !== undefined) {
|
|
1567
|
+
startInput.terminalBackgroundHex = command.terminalBackgroundHex;
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
const session = this.startSession(startInput);
|
|
1571
|
+
this.launchCommandBySessionId.set(command.sessionId, launchCommand);
|
|
1572
|
+
|
|
1573
|
+
const unsubscribe = session.onEvent((event) => {
|
|
1574
|
+
this.handleSessionEvent(command.sessionId, event);
|
|
1575
|
+
});
|
|
1576
|
+
|
|
1577
|
+
const persistedRuntimeStatus = persistedConversation?.runtimeStatus;
|
|
1578
|
+
const persistedRuntimeLastEventAt = persistedConversation?.runtimeLastEventAt ?? null;
|
|
1579
|
+
const latestTelemetry = this.stateStore.latestTelemetrySummary(command.sessionId);
|
|
1580
|
+
const startupObservedAt =
|
|
1581
|
+
persistedRuntimeLastEventAt ?? latestTelemetry?.observedAt ?? new Date().toISOString();
|
|
1582
|
+
const initialStatus: StreamSessionRuntimeStatus =
|
|
1583
|
+
persistedRuntimeStatus === undefined ||
|
|
1584
|
+
persistedRuntimeStatus === 'running' ||
|
|
1585
|
+
persistedRuntimeStatus === 'exited' ||
|
|
1586
|
+
(persistedRuntimeStatus === 'completed' && persistedRuntimeLastEventAt === null)
|
|
1587
|
+
? 'running'
|
|
1588
|
+
: persistedRuntimeStatus;
|
|
1589
|
+
const initialAttentionReason =
|
|
1590
|
+
initialStatus === 'needs-input'
|
|
1591
|
+
? (persistedConversation?.runtimeAttentionReason ?? null)
|
|
1592
|
+
: null;
|
|
1593
|
+
const initialStatusModel = this.statusEngine.project({
|
|
1594
|
+
agentType,
|
|
1595
|
+
runtimeStatus: initialStatus,
|
|
1596
|
+
attentionReason: initialAttentionReason,
|
|
1597
|
+
telemetry: latestTelemetry,
|
|
1598
|
+
observedAt: startupObservedAt,
|
|
1599
|
+
previous: persistedConversation?.runtimeStatusModel ?? null,
|
|
1600
|
+
});
|
|
1601
|
+
this.sessions.set(command.sessionId, {
|
|
1602
|
+
id: command.sessionId,
|
|
1603
|
+
directoryId: persistedConversation?.directoryId ?? null,
|
|
1604
|
+
agentType,
|
|
1605
|
+
adapterState: normalizeAdapterState(persistedConversation?.adapterState ?? {}),
|
|
1606
|
+
tenantId: persistedConversation?.tenantId ?? command.tenantId ?? DEFAULT_TENANT_ID,
|
|
1607
|
+
userId: persistedConversation?.userId ?? command.userId ?? DEFAULT_USER_ID,
|
|
1608
|
+
workspaceId:
|
|
1609
|
+
persistedConversation?.workspaceId ?? command.workspaceId ?? DEFAULT_WORKSPACE_ID,
|
|
1610
|
+
worktreeId: command.worktreeId ?? DEFAULT_WORKTREE_ID,
|
|
1611
|
+
session,
|
|
1612
|
+
eventSubscriberConnectionIds: new Set<string>(),
|
|
1613
|
+
attachmentByConnectionId: new Map<string, string>(),
|
|
1614
|
+
unsubscribe,
|
|
1615
|
+
status: initialStatus,
|
|
1616
|
+
statusModel: initialStatusModel,
|
|
1617
|
+
attentionReason: initialAttentionReason,
|
|
1618
|
+
lastEventAt: persistedConversation?.runtimeLastEventAt ?? null,
|
|
1619
|
+
lastExit: persistedConversation?.runtimeLastExit ?? null,
|
|
1620
|
+
lastSnapshot: null,
|
|
1621
|
+
startedAt: new Date().toISOString(),
|
|
1622
|
+
exitedAt: null,
|
|
1623
|
+
tombstoneTimer: null,
|
|
1624
|
+
lastObservedOutputCursor: session.latestCursorValue(),
|
|
1625
|
+
latestTelemetry,
|
|
1626
|
+
controller: null,
|
|
1627
|
+
diagnostics: createSessionDiagnostics(),
|
|
1628
|
+
});
|
|
1629
|
+
|
|
1630
|
+
const state = this.sessions.get(command.sessionId);
|
|
1631
|
+
if (state !== undefined) {
|
|
1632
|
+
this.persistConversationRuntime(state);
|
|
1633
|
+
this.publishStatusObservedEvent(state);
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
private telemetryEndpointBaseUrl(): string | null {
|
|
1638
|
+
if (this.telemetryAddress === null) {
|
|
1639
|
+
return null;
|
|
1640
|
+
}
|
|
1641
|
+
const host =
|
|
1642
|
+
this.telemetryAddress.family === 'IPv6'
|
|
1643
|
+
? `[${this.telemetryAddress.address}]`
|
|
1644
|
+
: this.telemetryAddress.address;
|
|
1645
|
+
return `http://${host}:${String(this.telemetryAddress.port)}`;
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
private handleTelemetryHttpRequest(request: IncomingMessage, response: ServerResponse): void {
|
|
1649
|
+
void this.handleTelemetryHttpRequestAsync(request, response).catch((error: unknown) => {
|
|
1650
|
+
if (isTelemetryRequestAbortError(error)) {
|
|
1651
|
+
return;
|
|
1652
|
+
}
|
|
1653
|
+
if (response.writableEnded) {
|
|
1654
|
+
return;
|
|
1655
|
+
}
|
|
1656
|
+
response.statusCode = 500;
|
|
1657
|
+
response.end();
|
|
1658
|
+
});
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
private async handleTelemetryHttpRequestAsync(
|
|
1662
|
+
request: IncomingMessage,
|
|
1663
|
+
response: ServerResponse,
|
|
1664
|
+
): Promise<void> {
|
|
1665
|
+
if (request.method !== 'POST') {
|
|
1666
|
+
response.statusCode = 405;
|
|
1667
|
+
response.end();
|
|
1668
|
+
return;
|
|
1669
|
+
}
|
|
1670
|
+
const target = parseOtlpEndpoint(request.url ?? '');
|
|
1671
|
+
if (target === null) {
|
|
1672
|
+
response.statusCode = 404;
|
|
1673
|
+
response.end();
|
|
1674
|
+
return;
|
|
1675
|
+
}
|
|
1676
|
+
const sessionId = this.telemetryTokenToSessionId.get(target.token);
|
|
1677
|
+
if (sessionId === undefined) {
|
|
1678
|
+
response.statusCode = 404;
|
|
1679
|
+
response.end();
|
|
1680
|
+
return;
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
const bodyText = await this.readHttpBody(request);
|
|
1684
|
+
|
|
1685
|
+
let payload: unknown;
|
|
1686
|
+
try {
|
|
1687
|
+
payload = JSON.parse(bodyText);
|
|
1688
|
+
} catch {
|
|
1689
|
+
response.statusCode = 400;
|
|
1690
|
+
response.end();
|
|
1691
|
+
return;
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
this.ingestOtlpPayload(target.kind, sessionId, payload);
|
|
1695
|
+
response.statusCode = 200;
|
|
1696
|
+
response.setHeader('content-type', 'application/json');
|
|
1697
|
+
response.end('{"partialSuccess":{}}');
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
private async readHttpBody(request: IncomingMessage): Promise<string> {
|
|
1701
|
+
const chunks: Uint8Array[] = [];
|
|
1702
|
+
for await (const rawChunk of request) {
|
|
1703
|
+
const chunk = rawChunk as Uint8Array;
|
|
1704
|
+
chunks.push(chunk);
|
|
1705
|
+
}
|
|
1706
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
private ingestOtlpPayload(
|
|
1710
|
+
kind: 'logs' | 'metrics' | 'traces',
|
|
1711
|
+
sessionId: string,
|
|
1712
|
+
payload: unknown,
|
|
1713
|
+
): void {
|
|
1714
|
+
const now = new Date().toISOString();
|
|
1715
|
+
const useLifecycleFastPath =
|
|
1716
|
+
this.codexTelemetry.captureVerboseEvents !== true &&
|
|
1717
|
+
this.codexTelemetry.ingestMode === 'lifecycle-fast';
|
|
1718
|
+
const parsed =
|
|
1719
|
+
kind === 'logs'
|
|
1720
|
+
? useLifecycleFastPath
|
|
1721
|
+
? parseOtlpLifecycleLogEvents(payload, now)
|
|
1722
|
+
: parseOtlpLogEvents(payload, now)
|
|
1723
|
+
: kind === 'metrics'
|
|
1724
|
+
? useLifecycleFastPath
|
|
1725
|
+
? parseOtlpLifecycleMetricEvents(payload, now)
|
|
1726
|
+
: parseOtlpMetricEvents(payload, now)
|
|
1727
|
+
: useLifecycleFastPath
|
|
1728
|
+
? parseOtlpLifecycleTraceEvents(payload, now)
|
|
1729
|
+
: parseOtlpTraceEvents(payload, now);
|
|
1730
|
+
if (parsed.length === 0) {
|
|
1731
|
+
const sourceByKind: Record<
|
|
1732
|
+
'logs' | 'metrics' | 'traces',
|
|
1733
|
+
'otlp-log' | 'otlp-metric' | 'otlp-trace'
|
|
1734
|
+
> = {
|
|
1735
|
+
logs: 'otlp-log',
|
|
1736
|
+
metrics: 'otlp-metric',
|
|
1737
|
+
traces: 'otlp-trace',
|
|
1738
|
+
};
|
|
1739
|
+
const source = sourceByKind[kind];
|
|
1740
|
+
this.ingestParsedTelemetryEvent(sessionId, {
|
|
1741
|
+
source,
|
|
1742
|
+
observedAt: now,
|
|
1743
|
+
eventName: null,
|
|
1744
|
+
severity: null,
|
|
1745
|
+
summary: `${source} batch`,
|
|
1746
|
+
providerThreadId: null,
|
|
1747
|
+
statusHint: null,
|
|
1748
|
+
payload: {
|
|
1749
|
+
batch: payload,
|
|
1750
|
+
},
|
|
1751
|
+
});
|
|
1752
|
+
return;
|
|
1753
|
+
}
|
|
1754
|
+
for (const entry of parsed) {
|
|
1755
|
+
this.ingestParsedTelemetryEvent(sessionId, entry);
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
private ingestParsedTelemetryEvent(
|
|
1760
|
+
fallbackSessionId: string | null,
|
|
1761
|
+
event: ParsedCodexTelemetryEvent,
|
|
1762
|
+
): void {
|
|
1763
|
+
const resolvedSessionId =
|
|
1764
|
+
fallbackSessionId ??
|
|
1765
|
+
(event.providerThreadId === null
|
|
1766
|
+
? null
|
|
1767
|
+
: this.resolveSessionIdByThreadId(event.providerThreadId));
|
|
1768
|
+
const captureVerboseEvents = this.codexTelemetry.captureVerboseEvents === true;
|
|
1769
|
+
const shouldRetainHighSignalEvent =
|
|
1770
|
+
isLifecycleTelemetryEventName(event.eventName) || event.statusHint !== null;
|
|
1771
|
+
if (!captureVerboseEvents && !shouldRetainHighSignalEvent) {
|
|
1772
|
+
if (resolvedSessionId !== null) {
|
|
1773
|
+
this.noteTelemetryIngest(resolvedSessionId, 'dropped', event.observedAt);
|
|
1774
|
+
}
|
|
1775
|
+
if (resolvedSessionId !== null && event.providerThreadId !== null) {
|
|
1776
|
+
const sessionState = this.sessions.get(resolvedSessionId);
|
|
1777
|
+
if (sessionState !== undefined) {
|
|
1778
|
+
this.updateSessionThreadId(sessionState, event.providerThreadId, event.observedAt);
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
return;
|
|
1782
|
+
}
|
|
1783
|
+
const fingerprint = telemetryFingerprint({
|
|
1784
|
+
source: event.source,
|
|
1785
|
+
sessionId: resolvedSessionId,
|
|
1786
|
+
providerThreadId: event.providerThreadId,
|
|
1787
|
+
eventName: event.eventName,
|
|
1788
|
+
observedAt: event.observedAt,
|
|
1789
|
+
payload: event.payload,
|
|
1790
|
+
});
|
|
1791
|
+
|
|
1792
|
+
const inserted = this.stateStore.appendTelemetry({
|
|
1793
|
+
source: event.source,
|
|
1794
|
+
sessionId: resolvedSessionId,
|
|
1795
|
+
providerThreadId: event.providerThreadId,
|
|
1796
|
+
eventName: event.eventName,
|
|
1797
|
+
severity: event.severity,
|
|
1798
|
+
summary: event.summary,
|
|
1799
|
+
observedAt: event.observedAt,
|
|
1800
|
+
payload: event.payload,
|
|
1801
|
+
fingerprint,
|
|
1802
|
+
});
|
|
1803
|
+
if (resolvedSessionId !== null) {
|
|
1804
|
+
this.noteTelemetryIngest(
|
|
1805
|
+
resolvedSessionId,
|
|
1806
|
+
inserted ? 'retained' : 'dropped',
|
|
1807
|
+
event.observedAt,
|
|
1808
|
+
);
|
|
1809
|
+
}
|
|
1810
|
+
if (!inserted || resolvedSessionId === null) {
|
|
1811
|
+
return;
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
const keyEvent: StreamSessionKeyEventRecord = {
|
|
1815
|
+
source: event.source,
|
|
1816
|
+
eventName: event.eventName,
|
|
1817
|
+
severity: event.severity,
|
|
1818
|
+
summary: event.summary,
|
|
1819
|
+
observedAt: event.observedAt,
|
|
1820
|
+
statusHint: event.statusHint,
|
|
1821
|
+
};
|
|
1822
|
+
const sessionState = this.sessions.get(resolvedSessionId);
|
|
1823
|
+
let publishedThroughRuntime = false;
|
|
1824
|
+
if (sessionState !== undefined) {
|
|
1825
|
+
if (event.providerThreadId !== null) {
|
|
1826
|
+
this.updateSessionThreadId(sessionState, event.providerThreadId, event.observedAt);
|
|
1827
|
+
}
|
|
1828
|
+
const shouldApplyStatusHint =
|
|
1829
|
+
keyEvent.statusHint !== null &&
|
|
1830
|
+
event.source !== 'history' &&
|
|
1831
|
+
sessionState.status !== 'exited' &&
|
|
1832
|
+
sessionState.session !== null;
|
|
1833
|
+
applyRuntimeSessionKeyEvent(
|
|
1834
|
+
this as unknown as Parameters<typeof applyRuntimeSessionKeyEvent>[0],
|
|
1835
|
+
sessionState,
|
|
1836
|
+
keyEvent,
|
|
1837
|
+
{
|
|
1838
|
+
applyStatusHint: shouldApplyStatusHint,
|
|
1839
|
+
},
|
|
1840
|
+
);
|
|
1841
|
+
publishedThroughRuntime = true;
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
const observedScope = publishedThroughRuntime
|
|
1845
|
+
? null
|
|
1846
|
+
: this.observedScopeForSessionId(resolvedSessionId);
|
|
1847
|
+
if (observedScope !== null) {
|
|
1848
|
+
this.publishObservedEvent(observedScope, {
|
|
1849
|
+
type: 'session-key-event',
|
|
1850
|
+
sessionId: resolvedSessionId,
|
|
1851
|
+
keyEvent,
|
|
1852
|
+
ts: new Date().toISOString(),
|
|
1853
|
+
directoryId: observedScope.directoryId,
|
|
1854
|
+
conversationId: observedScope.conversationId,
|
|
1855
|
+
});
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
private observedScopeForSessionId(sessionId: string): StreamObservedScope | null {
|
|
1860
|
+
const liveState = this.sessions.get(sessionId);
|
|
1861
|
+
if (liveState !== undefined) {
|
|
1862
|
+
return this.sessionScope(liveState);
|
|
1863
|
+
}
|
|
1864
|
+
const persisted = this.stateStore.getConversation(sessionId);
|
|
1865
|
+
if (persisted === null || persisted.archivedAt !== null) {
|
|
1866
|
+
return null;
|
|
1867
|
+
}
|
|
1868
|
+
return {
|
|
1869
|
+
tenantId: persisted.tenantId,
|
|
1870
|
+
userId: persisted.userId,
|
|
1871
|
+
workspaceId: persisted.workspaceId,
|
|
1872
|
+
directoryId: persisted.directoryId,
|
|
1873
|
+
conversationId: persisted.conversationId,
|
|
1874
|
+
};
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
private updateSessionThreadId(state: SessionState, threadId: string, observedAt: string): void {
|
|
1878
|
+
if (state.agentType !== 'codex') {
|
|
1879
|
+
return;
|
|
1880
|
+
}
|
|
1881
|
+
const currentThreadId = codexResumeSessionIdFromAdapterState(state.adapterState);
|
|
1882
|
+
if (currentThreadId === threadId) {
|
|
1883
|
+
return;
|
|
1884
|
+
}
|
|
1885
|
+
const currentCodex =
|
|
1886
|
+
typeof state.adapterState['codex'] === 'object' &&
|
|
1887
|
+
state.adapterState['codex'] !== null &&
|
|
1888
|
+
!Array.isArray(state.adapterState['codex'])
|
|
1889
|
+
? (state.adapterState['codex'] as Record<string, unknown>)
|
|
1890
|
+
: {};
|
|
1891
|
+
state.adapterState = {
|
|
1892
|
+
...state.adapterState,
|
|
1893
|
+
codex: {
|
|
1894
|
+
...currentCodex,
|
|
1895
|
+
resumeSessionId: threadId,
|
|
1896
|
+
lastObservedAt: observedAt,
|
|
1897
|
+
},
|
|
1898
|
+
};
|
|
1899
|
+
this.stateStore.updateConversationAdapterState(state.id, state.adapterState);
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
private resolveSessionIdByThreadId(threadId: string): string | null {
|
|
1903
|
+
const normalized = threadId.trim();
|
|
1904
|
+
if (normalized.length === 0) {
|
|
1905
|
+
return null;
|
|
1906
|
+
}
|
|
1907
|
+
for (const state of this.sessions.values()) {
|
|
1908
|
+
const stateThreadId = codexResumeSessionIdFromAdapterState(state.adapterState);
|
|
1909
|
+
if (stateThreadId === normalized) {
|
|
1910
|
+
return state.id;
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
return this.stateStore.findConversationIdByCodexThreadId(normalized);
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
private async pollHistoryFile(): Promise<void> {
|
|
1917
|
+
await pollStreamServerHistoryFile(
|
|
1918
|
+
this as unknown as Parameters<typeof pollStreamServerHistoryFile>[0],
|
|
1919
|
+
);
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
private async pollHistoryFileUnsafe(): Promise<boolean> {
|
|
1923
|
+
return await pollStreamServerHistoryFileUnsafe(
|
|
1924
|
+
this as unknown as Parameters<typeof pollStreamServerHistoryFileUnsafe>[0],
|
|
1925
|
+
);
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
private async pollGitStatus(): Promise<void> {
|
|
1929
|
+
await pollStreamServerGitStatus(
|
|
1930
|
+
this as unknown as Parameters<typeof pollStreamServerGitStatus>[0],
|
|
1931
|
+
);
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
private async refreshGitStatusForDirectory(
|
|
1935
|
+
directory: ControlPlaneDirectoryRecord,
|
|
1936
|
+
options: {
|
|
1937
|
+
readonly forcePublish?: boolean;
|
|
1938
|
+
} = {},
|
|
1939
|
+
): Promise<void> {
|
|
1940
|
+
await refreshStreamServerGitStatusForDirectory(
|
|
1941
|
+
this as unknown as Parameters<typeof refreshStreamServerGitStatusForDirectory>[0],
|
|
1942
|
+
directory,
|
|
1943
|
+
options,
|
|
1944
|
+
);
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
private async pollGitHub(): Promise<void> {
|
|
1948
|
+
if (!this.github.enabled || this.githubPollInFlight) {
|
|
1949
|
+
return;
|
|
1950
|
+
}
|
|
1951
|
+
this.githubPollInFlight = true;
|
|
1952
|
+
try {
|
|
1953
|
+
const directories = this.stateStore.listDirectories({
|
|
1954
|
+
includeArchived: false,
|
|
1955
|
+
limit: 1000,
|
|
1956
|
+
});
|
|
1957
|
+
const targetsByKey = new Map<
|
|
1958
|
+
string,
|
|
1959
|
+
{
|
|
1960
|
+
directory: ControlPlaneDirectoryRecord;
|
|
1961
|
+
repository: ControlPlaneRepositoryRecord;
|
|
1962
|
+
owner: string;
|
|
1963
|
+
repo: string;
|
|
1964
|
+
branchName: string;
|
|
1965
|
+
}
|
|
1966
|
+
>();
|
|
1967
|
+
for (const directory of directories) {
|
|
1968
|
+
const gitStatus = this.gitStatusByDirectoryId.get(directory.directoryId);
|
|
1969
|
+
const repositoryId = gitStatus?.repositoryId ?? null;
|
|
1970
|
+
if (repositoryId === null) {
|
|
1971
|
+
continue;
|
|
1972
|
+
}
|
|
1973
|
+
const repository = this.stateStore.getRepository(repositoryId);
|
|
1974
|
+
if (repository === null || repository.archivedAt !== null) {
|
|
1975
|
+
continue;
|
|
1976
|
+
}
|
|
1977
|
+
const ownerRepo = parseGitHubOwnerRepoFromRemote(repository.remoteUrl);
|
|
1978
|
+
if (ownerRepo === null) {
|
|
1979
|
+
continue;
|
|
1980
|
+
}
|
|
1981
|
+
const settings = this.stateStore.getProjectSettings(directory.directoryId);
|
|
1982
|
+
const branchName = resolveTrackedBranchName({
|
|
1983
|
+
strategy: this.github.branchStrategy,
|
|
1984
|
+
pinnedBranch: settings.pinnedBranch,
|
|
1985
|
+
currentBranch: gitStatus?.summary.branch ?? null,
|
|
1986
|
+
});
|
|
1987
|
+
if (branchName === null) {
|
|
1988
|
+
continue;
|
|
1989
|
+
}
|
|
1990
|
+
const key = `${repository.repositoryId}:${branchName}`;
|
|
1991
|
+
if (targetsByKey.has(key)) {
|
|
1992
|
+
continue;
|
|
1993
|
+
}
|
|
1994
|
+
targetsByKey.set(key, {
|
|
1995
|
+
directory,
|
|
1996
|
+
repository,
|
|
1997
|
+
owner: ownerRepo.owner,
|
|
1998
|
+
repo: ownerRepo.repo,
|
|
1999
|
+
branchName,
|
|
2000
|
+
});
|
|
2001
|
+
}
|
|
2002
|
+
if (targetsByKey.size === 0) {
|
|
2003
|
+
return;
|
|
2004
|
+
}
|
|
2005
|
+
const token = this.github.token ?? (await this.resolveGitHubTokenIfNeeded());
|
|
2006
|
+
if (token === null) {
|
|
2007
|
+
return;
|
|
2008
|
+
}
|
|
2009
|
+
await runWithConcurrencyLimit(
|
|
2010
|
+
[...targetsByKey.values()],
|
|
2011
|
+
this.github.maxConcurrency,
|
|
2012
|
+
async (target) => {
|
|
2013
|
+
await this.syncGitHubBranch(target);
|
|
2014
|
+
},
|
|
2015
|
+
);
|
|
2016
|
+
} finally {
|
|
2017
|
+
this.githubPollInFlight = false;
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
private async syncGitHubBranch(input: {
|
|
2022
|
+
directory: ControlPlaneDirectoryRecord;
|
|
2023
|
+
repository: ControlPlaneRepositoryRecord;
|
|
2024
|
+
owner: string;
|
|
2025
|
+
repo: string;
|
|
2026
|
+
branchName: string;
|
|
2027
|
+
}): Promise<void> {
|
|
2028
|
+
const now = new Date().toISOString();
|
|
2029
|
+
const syncStateId = `github-sync:${input.repository.repositoryId}:${input.directory.directoryId}:${input.branchName}`;
|
|
2030
|
+
try {
|
|
2031
|
+
const remotePr = await this.openGitHubPullRequestForBranch({
|
|
2032
|
+
owner: input.owner,
|
|
2033
|
+
repo: input.repo,
|
|
2034
|
+
headBranch: input.branchName,
|
|
2035
|
+
});
|
|
2036
|
+
if (remotePr === null) {
|
|
2037
|
+
const staleOpen = this.stateStore.listGitHubPullRequests({
|
|
2038
|
+
repositoryId: input.repository.repositoryId,
|
|
2039
|
+
headBranch: input.branchName,
|
|
2040
|
+
state: 'open',
|
|
2041
|
+
});
|
|
2042
|
+
for (const existing of staleOpen) {
|
|
2043
|
+
const closed = this.stateStore.upsertGitHubPullRequest({
|
|
2044
|
+
prRecordId: existing.prRecordId,
|
|
2045
|
+
tenantId: existing.tenantId,
|
|
2046
|
+
userId: existing.userId,
|
|
2047
|
+
workspaceId: existing.workspaceId,
|
|
2048
|
+
repositoryId: existing.repositoryId,
|
|
2049
|
+
directoryId: existing.directoryId,
|
|
2050
|
+
owner: existing.owner,
|
|
2051
|
+
repo: existing.repo,
|
|
2052
|
+
number: existing.number,
|
|
2053
|
+
title: existing.title,
|
|
2054
|
+
url: existing.url,
|
|
2055
|
+
authorLogin: existing.authorLogin,
|
|
2056
|
+
headBranch: existing.headBranch,
|
|
2057
|
+
headSha: existing.headSha,
|
|
2058
|
+
baseBranch: existing.baseBranch,
|
|
2059
|
+
state: 'closed',
|
|
2060
|
+
isDraft: existing.isDraft,
|
|
2061
|
+
ciRollup: existing.ciRollup,
|
|
2062
|
+
closedAt: now,
|
|
2063
|
+
observedAt: now,
|
|
2064
|
+
});
|
|
2065
|
+
this.publishObservedEvent(
|
|
2066
|
+
{
|
|
2067
|
+
tenantId: closed.tenantId,
|
|
2068
|
+
userId: closed.userId,
|
|
2069
|
+
workspaceId: closed.workspaceId,
|
|
2070
|
+
directoryId: input.directory.directoryId,
|
|
2071
|
+
conversationId: null,
|
|
2072
|
+
},
|
|
2073
|
+
{
|
|
2074
|
+
type: 'github-pr-closed',
|
|
2075
|
+
prRecordId: closed.prRecordId,
|
|
2076
|
+
repositoryId: closed.repositoryId,
|
|
2077
|
+
ts: now,
|
|
2078
|
+
},
|
|
2079
|
+
);
|
|
2080
|
+
}
|
|
2081
|
+
this.stateStore.upsertGitHubSyncState({
|
|
2082
|
+
stateId: syncStateId,
|
|
2083
|
+
tenantId: input.directory.tenantId,
|
|
2084
|
+
userId: input.directory.userId,
|
|
2085
|
+
workspaceId: input.directory.workspaceId,
|
|
2086
|
+
repositoryId: input.repository.repositoryId,
|
|
2087
|
+
directoryId: input.directory.directoryId,
|
|
2088
|
+
branchName: input.branchName,
|
|
2089
|
+
lastSyncAt: now,
|
|
2090
|
+
lastSuccessAt: now,
|
|
2091
|
+
lastError: null,
|
|
2092
|
+
lastErrorAt: null,
|
|
2093
|
+
});
|
|
2094
|
+
return;
|
|
2095
|
+
}
|
|
2096
|
+
|
|
2097
|
+
const storedPr = this.stateStore.upsertGitHubPullRequest({
|
|
2098
|
+
prRecordId: `github-pr-${randomUUID()}`,
|
|
2099
|
+
tenantId: input.directory.tenantId,
|
|
2100
|
+
userId: input.directory.userId,
|
|
2101
|
+
workspaceId: input.directory.workspaceId,
|
|
2102
|
+
repositoryId: input.repository.repositoryId,
|
|
2103
|
+
directoryId: input.directory.directoryId,
|
|
2104
|
+
owner: input.owner,
|
|
2105
|
+
repo: input.repo,
|
|
2106
|
+
number: remotePr.number,
|
|
2107
|
+
title: remotePr.title,
|
|
2108
|
+
url: remotePr.url,
|
|
2109
|
+
authorLogin: remotePr.authorLogin,
|
|
2110
|
+
headBranch: remotePr.headBranch,
|
|
2111
|
+
headSha: remotePr.headSha,
|
|
2112
|
+
baseBranch: remotePr.baseBranch,
|
|
2113
|
+
state: remotePr.state,
|
|
2114
|
+
isDraft: remotePr.isDraft,
|
|
2115
|
+
observedAt: remotePr.updatedAt || now,
|
|
2116
|
+
});
|
|
2117
|
+
const jobs = await this.listGitHubPrJobsForCommit({
|
|
2118
|
+
owner: input.owner,
|
|
2119
|
+
repo: input.repo,
|
|
2120
|
+
headSha: storedPr.headSha,
|
|
2121
|
+
});
|
|
2122
|
+
const rollup = summarizeGitHubCiRollup(jobs);
|
|
2123
|
+
const updatedPr =
|
|
2124
|
+
this.stateStore.updateGitHubPullRequestCiRollup(
|
|
2125
|
+
storedPr.prRecordId,
|
|
2126
|
+
rollup,
|
|
2127
|
+
remotePr.updatedAt || now,
|
|
2128
|
+
) ?? storedPr;
|
|
2129
|
+
const storedJobs = this.stateStore.replaceGitHubPrJobs({
|
|
2130
|
+
tenantId: updatedPr.tenantId,
|
|
2131
|
+
userId: updatedPr.userId,
|
|
2132
|
+
workspaceId: updatedPr.workspaceId,
|
|
2133
|
+
repositoryId: updatedPr.repositoryId,
|
|
2134
|
+
prRecordId: updatedPr.prRecordId,
|
|
2135
|
+
observedAt: remotePr.updatedAt || now,
|
|
2136
|
+
jobs: jobs.map((job) => ({
|
|
2137
|
+
jobRecordId: `github-job-${randomUUID()}`,
|
|
2138
|
+
provider: job.provider,
|
|
2139
|
+
externalId: job.externalId,
|
|
2140
|
+
name: job.name,
|
|
2141
|
+
status: job.status,
|
|
2142
|
+
conclusion: job.conclusion,
|
|
2143
|
+
url: job.url,
|
|
2144
|
+
startedAt: job.startedAt,
|
|
2145
|
+
completedAt: job.completedAt,
|
|
2146
|
+
})),
|
|
2147
|
+
});
|
|
2148
|
+
const observedScope = {
|
|
2149
|
+
tenantId: updatedPr.tenantId,
|
|
2150
|
+
userId: updatedPr.userId,
|
|
2151
|
+
workspaceId: updatedPr.workspaceId,
|
|
2152
|
+
directoryId: updatedPr.directoryId,
|
|
2153
|
+
conversationId: null,
|
|
2154
|
+
};
|
|
2155
|
+
this.publishObservedEvent(observedScope, {
|
|
2156
|
+
type: 'github-pr-upserted',
|
|
2157
|
+
pr: updatedPr as unknown as Record<string, unknown>,
|
|
2158
|
+
});
|
|
2159
|
+
this.publishObservedEvent(observedScope, {
|
|
2160
|
+
type: 'github-pr-jobs-updated',
|
|
2161
|
+
prRecordId: updatedPr.prRecordId,
|
|
2162
|
+
repositoryId: updatedPr.repositoryId,
|
|
2163
|
+
ciRollup: rollup,
|
|
2164
|
+
jobs: storedJobs as unknown as Record<string, unknown>[],
|
|
2165
|
+
ts: now,
|
|
2166
|
+
});
|
|
2167
|
+
this.stateStore.upsertGitHubSyncState({
|
|
2168
|
+
stateId: syncStateId,
|
|
2169
|
+
tenantId: input.directory.tenantId,
|
|
2170
|
+
userId: input.directory.userId,
|
|
2171
|
+
workspaceId: input.directory.workspaceId,
|
|
2172
|
+
repositoryId: input.repository.repositoryId,
|
|
2173
|
+
directoryId: input.directory.directoryId,
|
|
2174
|
+
branchName: input.branchName,
|
|
2175
|
+
lastSyncAt: now,
|
|
2176
|
+
lastSuccessAt: now,
|
|
2177
|
+
lastError: null,
|
|
2178
|
+
lastErrorAt: null,
|
|
2179
|
+
});
|
|
2180
|
+
} catch (error: unknown) {
|
|
2181
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2182
|
+
this.stateStore.upsertGitHubSyncState({
|
|
2183
|
+
stateId: syncStateId,
|
|
2184
|
+
tenantId: input.directory.tenantId,
|
|
2185
|
+
userId: input.directory.userId,
|
|
2186
|
+
workspaceId: input.directory.workspaceId,
|
|
2187
|
+
repositoryId: input.repository.repositoryId,
|
|
2188
|
+
directoryId: input.directory.directoryId,
|
|
2189
|
+
branchName: input.branchName,
|
|
2190
|
+
lastSyncAt: now,
|
|
2191
|
+
lastSuccessAt: null,
|
|
2192
|
+
lastError: message,
|
|
2193
|
+
lastErrorAt: now,
|
|
2194
|
+
});
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
private async githubJsonRequest(
|
|
2199
|
+
path: string,
|
|
2200
|
+
init: Omit<RequestInit, 'headers'> & {
|
|
2201
|
+
headers?: Record<string, string>;
|
|
2202
|
+
} = {},
|
|
2203
|
+
): Promise<unknown> {
|
|
2204
|
+
const token = this.github.token ?? (await this.resolveGitHubTokenIfNeeded());
|
|
2205
|
+
if (token === null) {
|
|
2206
|
+
const hint =
|
|
2207
|
+
this.githubTokenResolutionError === null
|
|
2208
|
+
? 'set GITHUB_TOKEN or run gh auth login'
|
|
2209
|
+
: `${this.githubTokenResolutionError}; set GITHUB_TOKEN or run gh auth login`;
|
|
2210
|
+
throw new Error(`github token not configured: ${hint}`);
|
|
2211
|
+
}
|
|
2212
|
+
const response = await this.githubFetch(`${this.github.apiBaseUrl}${path}`, {
|
|
2213
|
+
...init,
|
|
2214
|
+
headers: {
|
|
2215
|
+
Accept: 'application/vnd.github+json',
|
|
2216
|
+
Authorization: `Bearer ${token}`,
|
|
2217
|
+
'User-Agent': 'harness-control-plane',
|
|
2218
|
+
...init.headers,
|
|
2219
|
+
},
|
|
2220
|
+
});
|
|
2221
|
+
if (!response.ok) {
|
|
2222
|
+
const message = await response.text();
|
|
2223
|
+
throw new Error(
|
|
2224
|
+
`github api request failed (${response.status}): ${message || response.statusText}`,
|
|
2225
|
+
);
|
|
2226
|
+
}
|
|
2227
|
+
return await response.json();
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2230
|
+
private parseGitHubPullRequest(value: unknown): GitHubRemotePullRequest | null {
|
|
2231
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
2232
|
+
return null;
|
|
2233
|
+
}
|
|
2234
|
+
const record = value as Record<string, unknown>;
|
|
2235
|
+
const number = record['number'];
|
|
2236
|
+
const title = record['title'];
|
|
2237
|
+
const htmlUrl = record['html_url'];
|
|
2238
|
+
const state = record['state'];
|
|
2239
|
+
const draft = record['draft'];
|
|
2240
|
+
const head = record['head'];
|
|
2241
|
+
const base = record['base'];
|
|
2242
|
+
const user = record['user'];
|
|
2243
|
+
const updatedAt = record['updated_at'];
|
|
2244
|
+
const createdAt = record['created_at'];
|
|
2245
|
+
const closedAt = record['closed_at'];
|
|
2246
|
+
if (
|
|
2247
|
+
typeof number !== 'number' ||
|
|
2248
|
+
typeof title !== 'string' ||
|
|
2249
|
+
typeof htmlUrl !== 'string' ||
|
|
2250
|
+
(state !== 'open' && state !== 'closed') ||
|
|
2251
|
+
typeof draft !== 'boolean' ||
|
|
2252
|
+
typeof updatedAt !== 'string' ||
|
|
2253
|
+
typeof createdAt !== 'string' ||
|
|
2254
|
+
(closedAt !== null && typeof closedAt !== 'string') ||
|
|
2255
|
+
typeof head !== 'object' ||
|
|
2256
|
+
head === null ||
|
|
2257
|
+
Array.isArray(head) ||
|
|
2258
|
+
typeof base !== 'object' ||
|
|
2259
|
+
base === null ||
|
|
2260
|
+
Array.isArray(base)
|
|
2261
|
+
) {
|
|
2262
|
+
return null;
|
|
2263
|
+
}
|
|
2264
|
+
const headRecord = head as Record<string, unknown>;
|
|
2265
|
+
const baseRecord = base as Record<string, unknown>;
|
|
2266
|
+
const headRef = headRecord['ref'];
|
|
2267
|
+
const headSha = headRecord['sha'];
|
|
2268
|
+
const baseRef = baseRecord['ref'];
|
|
2269
|
+
if (typeof headRef !== 'string' || typeof headSha !== 'string' || typeof baseRef !== 'string') {
|
|
2270
|
+
return null;
|
|
2271
|
+
}
|
|
2272
|
+
let authorLogin: string | null = null;
|
|
2273
|
+
if (typeof user === 'object' && user !== null && !Array.isArray(user)) {
|
|
2274
|
+
const login = (user as Record<string, unknown>)['login'];
|
|
2275
|
+
if (typeof login === 'string') {
|
|
2276
|
+
authorLogin = login;
|
|
2277
|
+
}
|
|
2278
|
+
}
|
|
2279
|
+
return {
|
|
2280
|
+
number,
|
|
2281
|
+
title,
|
|
2282
|
+
url: htmlUrl,
|
|
2283
|
+
authorLogin,
|
|
2284
|
+
headBranch: headRef,
|
|
2285
|
+
headSha,
|
|
2286
|
+
baseBranch: baseRef,
|
|
2287
|
+
state,
|
|
2288
|
+
isDraft: draft,
|
|
2289
|
+
updatedAt,
|
|
2290
|
+
createdAt,
|
|
2291
|
+
closedAt: closedAt as string | null,
|
|
2292
|
+
};
|
|
2293
|
+
}
|
|
2294
|
+
|
|
2295
|
+
private async openGitHubPullRequestForBranch(input: {
|
|
2296
|
+
owner: string;
|
|
2297
|
+
repo: string;
|
|
2298
|
+
headBranch: string;
|
|
2299
|
+
}): Promise<GitHubRemotePullRequest | null> {
|
|
2300
|
+
const head = encodeURIComponent(`${input.owner}:${input.headBranch}`);
|
|
2301
|
+
const path = `/repos/${encodeURIComponent(input.owner)}/${encodeURIComponent(input.repo)}/pulls?state=open&head=${head}&per_page=1`;
|
|
2302
|
+
const payload = await this.githubJsonRequest(path);
|
|
2303
|
+
if (!Array.isArray(payload) || payload.length === 0) {
|
|
2304
|
+
return null;
|
|
2305
|
+
}
|
|
2306
|
+
return this.parseGitHubPullRequest(payload[0]);
|
|
2307
|
+
}
|
|
2308
|
+
|
|
2309
|
+
private async createGitHubPullRequest(input: {
|
|
2310
|
+
owner: string;
|
|
2311
|
+
repo: string;
|
|
2312
|
+
title: string;
|
|
2313
|
+
body: string;
|
|
2314
|
+
head: string;
|
|
2315
|
+
base: string;
|
|
2316
|
+
draft: boolean;
|
|
2317
|
+
}): Promise<GitHubRemotePullRequest> {
|
|
2318
|
+
const path = `/repos/${encodeURIComponent(input.owner)}/${encodeURIComponent(input.repo)}/pulls`;
|
|
2319
|
+
const payload = await this.githubJsonRequest(path, {
|
|
2320
|
+
method: 'POST',
|
|
2321
|
+
headers: {
|
|
2322
|
+
'Content-Type': 'application/json',
|
|
2323
|
+
},
|
|
2324
|
+
body: JSON.stringify({
|
|
2325
|
+
title: input.title,
|
|
2326
|
+
body: input.body,
|
|
2327
|
+
head: input.head,
|
|
2328
|
+
base: input.base,
|
|
2329
|
+
draft: input.draft,
|
|
2330
|
+
}),
|
|
2331
|
+
});
|
|
2332
|
+
const parsed = this.parseGitHubPullRequest(payload);
|
|
2333
|
+
if (parsed === null) {
|
|
2334
|
+
throw new Error('github create pr returned malformed response');
|
|
2335
|
+
}
|
|
2336
|
+
return parsed;
|
|
2337
|
+
}
|
|
2338
|
+
|
|
2339
|
+
private async listGitHubPrJobsForCommit(input: {
|
|
2340
|
+
owner: string;
|
|
2341
|
+
repo: string;
|
|
2342
|
+
headSha: string;
|
|
2343
|
+
}): Promise<readonly GitHubRemotePrJob[]> {
|
|
2344
|
+
const owner = encodeURIComponent(input.owner);
|
|
2345
|
+
const repo = encodeURIComponent(input.repo);
|
|
2346
|
+
const sha = encodeURIComponent(input.headSha);
|
|
2347
|
+
const checkRunsPayload = await this.githubJsonRequest(
|
|
2348
|
+
`/repos/${owner}/${repo}/commits/${sha}/check-runs?per_page=100`,
|
|
2349
|
+
);
|
|
2350
|
+
const checkRuns: GitHubRemotePrJob[] = [];
|
|
2351
|
+
if (
|
|
2352
|
+
typeof checkRunsPayload === 'object' &&
|
|
2353
|
+
checkRunsPayload !== null &&
|
|
2354
|
+
!Array.isArray(checkRunsPayload)
|
|
2355
|
+
) {
|
|
2356
|
+
const runsRaw = (checkRunsPayload as Record<string, unknown>)['check_runs'];
|
|
2357
|
+
if (Array.isArray(runsRaw)) {
|
|
2358
|
+
for (const runRaw of runsRaw) {
|
|
2359
|
+
if (typeof runRaw !== 'object' || runRaw === null || Array.isArray(runRaw)) {
|
|
2360
|
+
continue;
|
|
2361
|
+
}
|
|
2362
|
+
const run = runRaw as Record<string, unknown>;
|
|
2363
|
+
const id = run['id'];
|
|
2364
|
+
const name = run['name'];
|
|
2365
|
+
const status = run['status'];
|
|
2366
|
+
const conclusion = run['conclusion'];
|
|
2367
|
+
const htmlUrl = run['html_url'];
|
|
2368
|
+
const startedAt = run['started_at'];
|
|
2369
|
+
const completedAt = run['completed_at'];
|
|
2370
|
+
if (
|
|
2371
|
+
typeof id !== 'number' ||
|
|
2372
|
+
typeof name !== 'string' ||
|
|
2373
|
+
typeof status !== 'string' ||
|
|
2374
|
+
(conclusion !== null && typeof conclusion !== 'string')
|
|
2375
|
+
) {
|
|
2376
|
+
continue;
|
|
2377
|
+
}
|
|
2378
|
+
checkRuns.push({
|
|
2379
|
+
provider: 'check-run',
|
|
2380
|
+
externalId: String(id),
|
|
2381
|
+
name,
|
|
2382
|
+
status,
|
|
2383
|
+
conclusion: conclusion as string | null,
|
|
2384
|
+
url: typeof htmlUrl === 'string' ? htmlUrl : null,
|
|
2385
|
+
startedAt: typeof startedAt === 'string' ? startedAt : null,
|
|
2386
|
+
completedAt: typeof completedAt === 'string' ? completedAt : null,
|
|
2387
|
+
});
|
|
2388
|
+
}
|
|
2389
|
+
}
|
|
2390
|
+
}
|
|
2391
|
+
const statusPayload = await this.githubJsonRequest(
|
|
2392
|
+
`/repos/${owner}/${repo}/commits/${sha}/status`,
|
|
2393
|
+
);
|
|
2394
|
+
const statusJobs: GitHubRemotePrJob[] = [];
|
|
2395
|
+
if (
|
|
2396
|
+
typeof statusPayload === 'object' &&
|
|
2397
|
+
statusPayload !== null &&
|
|
2398
|
+
!Array.isArray(statusPayload)
|
|
2399
|
+
) {
|
|
2400
|
+
const contextsRaw = (statusPayload as Record<string, unknown>)['statuses'];
|
|
2401
|
+
if (Array.isArray(contextsRaw)) {
|
|
2402
|
+
for (const statusRaw of contextsRaw) {
|
|
2403
|
+
if (typeof statusRaw !== 'object' || statusRaw === null || Array.isArray(statusRaw)) {
|
|
2404
|
+
continue;
|
|
2405
|
+
}
|
|
2406
|
+
const context = statusRaw as Record<string, unknown>;
|
|
2407
|
+
const id = context['id'];
|
|
2408
|
+
const name = context['context'];
|
|
2409
|
+
const state = context['state'];
|
|
2410
|
+
const targetUrl = context['target_url'];
|
|
2411
|
+
const createdAt = context['created_at'];
|
|
2412
|
+
const updatedAt = context['updated_at'];
|
|
2413
|
+
if (typeof id !== 'number' || typeof name !== 'string' || typeof state !== 'string') {
|
|
2414
|
+
continue;
|
|
2415
|
+
}
|
|
2416
|
+
statusJobs.push({
|
|
2417
|
+
provider: 'status-context',
|
|
2418
|
+
externalId: String(id),
|
|
2419
|
+
name,
|
|
2420
|
+
status: state === 'pending' ? 'in_progress' : 'completed',
|
|
2421
|
+
conclusion: state === 'pending' ? null : state,
|
|
2422
|
+
url: typeof targetUrl === 'string' ? targetUrl : null,
|
|
2423
|
+
startedAt: typeof createdAt === 'string' ? createdAt : null,
|
|
2424
|
+
completedAt: typeof updatedAt === 'string' ? updatedAt : null,
|
|
2425
|
+
});
|
|
2426
|
+
}
|
|
2427
|
+
}
|
|
2428
|
+
}
|
|
2429
|
+
return [...checkRuns, ...statusJobs];
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
private handleConnection(socket: Socket): void {
|
|
2433
|
+
handleServerConnection(this as unknown as Parameters<typeof handleServerConnection>[0], socket);
|
|
2434
|
+
}
|
|
2435
|
+
|
|
2436
|
+
private handleSocketData(connection: ConnectionState, chunk: Buffer): void {
|
|
2437
|
+
handleConnectionSocketData(
|
|
2438
|
+
this as unknown as Parameters<typeof handleConnectionSocketData>[0],
|
|
2439
|
+
connection,
|
|
2440
|
+
chunk,
|
|
2441
|
+
);
|
|
2442
|
+
}
|
|
2443
|
+
|
|
2444
|
+
private handleClientEnvelope(connection: ConnectionState, envelope: StreamClientEnvelope): void {
|
|
2445
|
+
handleConnectionClientEnvelope(
|
|
2446
|
+
this as unknown as Parameters<typeof handleConnectionClientEnvelope>[0],
|
|
2447
|
+
connection,
|
|
2448
|
+
envelope,
|
|
2449
|
+
);
|
|
2450
|
+
}
|
|
2451
|
+
|
|
2452
|
+
private handleAuth(connection: ConnectionState, token: string): void {
|
|
2453
|
+
handleConnectionAuth(
|
|
2454
|
+
this as unknown as Parameters<typeof handleConnectionAuth>[0],
|
|
2455
|
+
connection,
|
|
2456
|
+
token,
|
|
2457
|
+
);
|
|
2458
|
+
}
|
|
2459
|
+
|
|
2460
|
+
private handleCommand(
|
|
2461
|
+
connection: ConnectionState,
|
|
2462
|
+
commandId: string,
|
|
2463
|
+
command: StreamCommand,
|
|
2464
|
+
): void {
|
|
2465
|
+
handleConnectionCommand(
|
|
2466
|
+
this as unknown as Parameters<typeof handleConnectionCommand>[0],
|
|
2467
|
+
connection,
|
|
2468
|
+
commandId,
|
|
2469
|
+
command,
|
|
2470
|
+
);
|
|
2471
|
+
}
|
|
2472
|
+
|
|
2473
|
+
private executeCommand(
|
|
2474
|
+
connection: ConnectionState,
|
|
2475
|
+
command: StreamCommand,
|
|
2476
|
+
): Promise<Record<string, unknown>> {
|
|
2477
|
+
return executeStreamServerCommand(
|
|
2478
|
+
this as unknown as Parameters<typeof executeStreamServerCommand>[0],
|
|
2479
|
+
connection,
|
|
2480
|
+
command,
|
|
2481
|
+
);
|
|
2482
|
+
}
|
|
2483
|
+
|
|
2484
|
+
private handleInput(connectionId: string, sessionId: string, dataBase64: string): void {
|
|
2485
|
+
handleRuntimeInput(
|
|
2486
|
+
this as unknown as Parameters<typeof handleRuntimeInput>[0],
|
|
2487
|
+
connectionId,
|
|
2488
|
+
sessionId,
|
|
2489
|
+
dataBase64,
|
|
2490
|
+
);
|
|
2491
|
+
}
|
|
2492
|
+
|
|
2493
|
+
private handleResize(connectionId: string, sessionId: string, cols: number, rows: number): void {
|
|
2494
|
+
handleRuntimeResize(
|
|
2495
|
+
this as unknown as Parameters<typeof handleRuntimeResize>[0],
|
|
2496
|
+
connectionId,
|
|
2497
|
+
sessionId,
|
|
2498
|
+
cols,
|
|
2499
|
+
rows,
|
|
2500
|
+
);
|
|
2501
|
+
}
|
|
2502
|
+
|
|
2503
|
+
private handleSignal(connectionId: string, sessionId: string, signal: StreamSignal): void {
|
|
2504
|
+
handleRuntimeSignal(
|
|
2505
|
+
this as unknown as Parameters<typeof handleRuntimeSignal>[0],
|
|
2506
|
+
connectionId,
|
|
2507
|
+
sessionId,
|
|
2508
|
+
signal,
|
|
2509
|
+
);
|
|
2510
|
+
}
|
|
2511
|
+
|
|
2512
|
+
private handleSessionEvent(sessionId: string, event: CodexLiveEvent): void {
|
|
2513
|
+
handleRuntimeSessionEvent(
|
|
2514
|
+
this as unknown as Parameters<typeof handleRuntimeSessionEvent>[0],
|
|
2515
|
+
sessionId,
|
|
2516
|
+
event,
|
|
2517
|
+
);
|
|
2518
|
+
}
|
|
2519
|
+
|
|
2520
|
+
private publishSessionKeyObservedEvent(
|
|
2521
|
+
state: SessionState,
|
|
2522
|
+
keyEvent: StreamSessionKeyEventRecord,
|
|
2523
|
+
): void {
|
|
2524
|
+
this.publishObservedEvent(this.sessionScope(state), {
|
|
2525
|
+
type: 'session-key-event',
|
|
2526
|
+
sessionId: state.id,
|
|
2527
|
+
keyEvent: {
|
|
2528
|
+
source: keyEvent.source,
|
|
2529
|
+
eventName: keyEvent.eventName,
|
|
2530
|
+
severity: keyEvent.severity,
|
|
2531
|
+
summary: keyEvent.summary,
|
|
2532
|
+
observedAt: keyEvent.observedAt,
|
|
2533
|
+
statusHint: keyEvent.statusHint,
|
|
2534
|
+
},
|
|
2535
|
+
ts: new Date().toISOString(),
|
|
2536
|
+
directoryId: state.directoryId,
|
|
2537
|
+
conversationId: state.id,
|
|
2538
|
+
});
|
|
2539
|
+
}
|
|
2540
|
+
|
|
2541
|
+
private setSessionStatus(
|
|
2542
|
+
state: SessionState,
|
|
2543
|
+
status: StreamSessionRuntimeStatus,
|
|
2544
|
+
attentionReason: string | null,
|
|
2545
|
+
lastEventAt: string | null,
|
|
2546
|
+
): void {
|
|
2547
|
+
setRuntimeSessionStatus(
|
|
2548
|
+
this as unknown as Parameters<typeof setRuntimeSessionStatus>[0],
|
|
2549
|
+
state,
|
|
2550
|
+
status,
|
|
2551
|
+
attentionReason,
|
|
2552
|
+
lastEventAt,
|
|
2553
|
+
);
|
|
2554
|
+
}
|
|
2555
|
+
|
|
2556
|
+
private refreshSessionStatusModel(state: SessionState, observedAt: string): void {
|
|
2557
|
+
state.statusModel = this.statusEngine.project({
|
|
2558
|
+
agentType: state.agentType,
|
|
2559
|
+
runtimeStatus: state.status,
|
|
2560
|
+
attentionReason: state.attentionReason,
|
|
2561
|
+
telemetry: state.latestTelemetry,
|
|
2562
|
+
observedAt,
|
|
2563
|
+
previous: state.statusModel,
|
|
2564
|
+
});
|
|
2565
|
+
}
|
|
2566
|
+
|
|
2567
|
+
private persistConversationRuntime(state: SessionState): void {
|
|
2568
|
+
persistRuntimeConversationState(
|
|
2569
|
+
this as unknown as Parameters<typeof persistRuntimeConversationState>[0],
|
|
2570
|
+
state,
|
|
2571
|
+
);
|
|
2572
|
+
}
|
|
2573
|
+
|
|
2574
|
+
private publishStatusObservedEvent(state: SessionState): void {
|
|
2575
|
+
publishRuntimeStatusObservedEvent(
|
|
2576
|
+
this as unknown as Parameters<typeof publishRuntimeStatusObservedEvent>[0],
|
|
2577
|
+
state,
|
|
2578
|
+
);
|
|
2579
|
+
}
|
|
2580
|
+
|
|
2581
|
+
private publishSessionControlObservedEvent(
|
|
2582
|
+
state: SessionState,
|
|
2583
|
+
action: 'claimed' | 'released' | 'taken-over',
|
|
2584
|
+
controller: StreamSessionController | null,
|
|
2585
|
+
previousController: StreamSessionController | null,
|
|
2586
|
+
reason: string | null,
|
|
2587
|
+
): void {
|
|
2588
|
+
this.publishObservedEvent(this.sessionScope(state), {
|
|
2589
|
+
type: 'session-control',
|
|
2590
|
+
sessionId: state.id,
|
|
2591
|
+
action,
|
|
2592
|
+
controller,
|
|
2593
|
+
previousController,
|
|
2594
|
+
reason,
|
|
2595
|
+
ts: new Date().toISOString(),
|
|
2596
|
+
directoryId: state.directoryId,
|
|
2597
|
+
conversationId: state.id,
|
|
2598
|
+
});
|
|
2599
|
+
}
|
|
2600
|
+
|
|
2601
|
+
private sessionScope(state: SessionState): StreamObservedScope {
|
|
2602
|
+
return {
|
|
2603
|
+
tenantId: state.tenantId,
|
|
2604
|
+
userId: state.userId,
|
|
2605
|
+
workspaceId: state.workspaceId,
|
|
2606
|
+
directoryId: state.directoryId,
|
|
2607
|
+
conversationId: state.id,
|
|
2608
|
+
};
|
|
2609
|
+
}
|
|
2610
|
+
|
|
2611
|
+
private eventIncludesRepositoryId(event: StreamObservedEvent, repositoryId: string): boolean {
|
|
2612
|
+
return filterEventIncludesRepositoryId(event, repositoryId);
|
|
2613
|
+
}
|
|
2614
|
+
|
|
2615
|
+
private eventIncludesTaskId(event: StreamObservedEvent, taskId: string): boolean {
|
|
2616
|
+
return filterEventIncludesTaskId(event, taskId);
|
|
2617
|
+
}
|
|
2618
|
+
|
|
2619
|
+
private matchesObservedFilter(
|
|
2620
|
+
scope: StreamObservedScope,
|
|
2621
|
+
event: StreamObservedEvent,
|
|
2622
|
+
filter: StreamSubscriptionFilter,
|
|
2623
|
+
): boolean {
|
|
2624
|
+
return matchesStreamObservedFilter(
|
|
2625
|
+
this as unknown as Parameters<typeof matchesStreamObservedFilter>[0],
|
|
2626
|
+
scope,
|
|
2627
|
+
event,
|
|
2628
|
+
filter,
|
|
2629
|
+
);
|
|
2630
|
+
}
|
|
2631
|
+
|
|
2632
|
+
private publishObservedEvent(scope: StreamObservedScope, event: StreamObservedEvent): void {
|
|
2633
|
+
this.streamCursor += 1;
|
|
2634
|
+
const entry: StreamJournalEntry = {
|
|
2635
|
+
cursor: this.streamCursor,
|
|
2636
|
+
scope,
|
|
2637
|
+
event,
|
|
2638
|
+
};
|
|
2639
|
+
const diagnosticSessionId = this.diagnosticSessionIdForObservedEvent(scope, event);
|
|
2640
|
+
this.streamJournal.push(entry);
|
|
2641
|
+
if (this.streamJournal.length > this.maxStreamJournalEntries) {
|
|
2642
|
+
this.streamJournal.shift();
|
|
2643
|
+
}
|
|
2644
|
+
|
|
2645
|
+
for (const subscription of this.streamSubscriptions.values()) {
|
|
2646
|
+
if (!this.matchesObservedFilter(scope, event, subscription.filter)) {
|
|
2647
|
+
continue;
|
|
2648
|
+
}
|
|
2649
|
+
this.sendToConnection(
|
|
2650
|
+
subscription.connectionId,
|
|
2651
|
+
{
|
|
2652
|
+
kind: 'stream.event',
|
|
2653
|
+
subscriptionId: subscription.id,
|
|
2654
|
+
cursor: entry.cursor,
|
|
2655
|
+
event: entry.event,
|
|
2656
|
+
},
|
|
2657
|
+
diagnosticSessionId,
|
|
2658
|
+
);
|
|
2659
|
+
}
|
|
2660
|
+
this.lifecycleHooks.publish(scope, event, entry.cursor);
|
|
2661
|
+
}
|
|
2662
|
+
|
|
2663
|
+
private directoryRecord(directory: ControlPlaneDirectoryRecord): Record<string, unknown> {
|
|
2664
|
+
return {
|
|
2665
|
+
directoryId: directory.directoryId,
|
|
2666
|
+
tenantId: directory.tenantId,
|
|
2667
|
+
userId: directory.userId,
|
|
2668
|
+
workspaceId: directory.workspaceId,
|
|
2669
|
+
path: directory.path,
|
|
2670
|
+
createdAt: directory.createdAt,
|
|
2671
|
+
archivedAt: directory.archivedAt,
|
|
2672
|
+
};
|
|
2673
|
+
}
|
|
2674
|
+
|
|
2675
|
+
private conversationRecord(
|
|
2676
|
+
conversation: ControlPlaneConversationRecord,
|
|
2677
|
+
): Record<string, unknown> {
|
|
2678
|
+
return {
|
|
2679
|
+
conversationId: conversation.conversationId,
|
|
2680
|
+
directoryId: conversation.directoryId,
|
|
2681
|
+
tenantId: conversation.tenantId,
|
|
2682
|
+
userId: conversation.userId,
|
|
2683
|
+
workspaceId: conversation.workspaceId,
|
|
2684
|
+
title: conversation.title,
|
|
2685
|
+
agentType: conversation.agentType,
|
|
2686
|
+
createdAt: conversation.createdAt,
|
|
2687
|
+
archivedAt: conversation.archivedAt,
|
|
2688
|
+
runtimeStatus: conversation.runtimeStatus,
|
|
2689
|
+
runtimeStatusModel: conversation.runtimeStatusModel,
|
|
2690
|
+
runtimeLive: conversation.runtimeLive,
|
|
2691
|
+
runtimeAttentionReason: conversation.runtimeAttentionReason,
|
|
2692
|
+
runtimeProcessId: conversation.runtimeProcessId,
|
|
2693
|
+
runtimeLastEventAt: conversation.runtimeLastEventAt,
|
|
2694
|
+
runtimeLastExit: conversation.runtimeLastExit,
|
|
2695
|
+
adapterState: conversation.adapterState,
|
|
2696
|
+
};
|
|
2697
|
+
}
|
|
2698
|
+
|
|
2699
|
+
private repositoryRecord(repository: ControlPlaneRepositoryRecord): Record<string, unknown> {
|
|
2700
|
+
return {
|
|
2701
|
+
repositoryId: repository.repositoryId,
|
|
2702
|
+
tenantId: repository.tenantId,
|
|
2703
|
+
userId: repository.userId,
|
|
2704
|
+
workspaceId: repository.workspaceId,
|
|
2705
|
+
name: repository.name,
|
|
2706
|
+
remoteUrl: repository.remoteUrl,
|
|
2707
|
+
defaultBranch: repository.defaultBranch,
|
|
2708
|
+
metadata: repository.metadata,
|
|
2709
|
+
createdAt: repository.createdAt,
|
|
2710
|
+
archivedAt: repository.archivedAt,
|
|
2711
|
+
};
|
|
2712
|
+
}
|
|
2713
|
+
|
|
2714
|
+
private taskRecord(task: ControlPlaneTaskRecord): Record<string, unknown> {
|
|
2715
|
+
return {
|
|
2716
|
+
taskId: task.taskId,
|
|
2717
|
+
tenantId: task.tenantId,
|
|
2718
|
+
userId: task.userId,
|
|
2719
|
+
workspaceId: task.workspaceId,
|
|
2720
|
+
repositoryId: task.repositoryId,
|
|
2721
|
+
scopeKind: task.scopeKind,
|
|
2722
|
+
projectId: task.projectId,
|
|
2723
|
+
title: task.title,
|
|
2724
|
+
description: task.description,
|
|
2725
|
+
status: task.status,
|
|
2726
|
+
orderIndex: task.orderIndex,
|
|
2727
|
+
claimedByControllerId: task.claimedByControllerId,
|
|
2728
|
+
claimedByDirectoryId: task.claimedByDirectoryId,
|
|
2729
|
+
branchName: task.branchName,
|
|
2730
|
+
baseBranch: task.baseBranch,
|
|
2731
|
+
claimedAt: task.claimedAt,
|
|
2732
|
+
completedAt: task.completedAt,
|
|
2733
|
+
linear: {
|
|
2734
|
+
issueId: task.linear.issueId,
|
|
2735
|
+
identifier: task.linear.identifier,
|
|
2736
|
+
url: task.linear.url,
|
|
2737
|
+
teamId: task.linear.teamId,
|
|
2738
|
+
projectId: task.linear.projectId,
|
|
2739
|
+
projectMilestoneId: task.linear.projectMilestoneId,
|
|
2740
|
+
cycleId: task.linear.cycleId,
|
|
2741
|
+
stateId: task.linear.stateId,
|
|
2742
|
+
assigneeId: task.linear.assigneeId,
|
|
2743
|
+
priority: task.linear.priority,
|
|
2744
|
+
estimate: task.linear.estimate,
|
|
2745
|
+
dueDate: task.linear.dueDate,
|
|
2746
|
+
labelIds: [...task.linear.labelIds],
|
|
2747
|
+
},
|
|
2748
|
+
createdAt: task.createdAt,
|
|
2749
|
+
updatedAt: task.updatedAt,
|
|
2750
|
+
};
|
|
2751
|
+
}
|
|
2752
|
+
|
|
2753
|
+
private toPublicSessionController(
|
|
2754
|
+
controller: SessionControllerState | null | undefined,
|
|
2755
|
+
): StreamSessionController | null {
|
|
2756
|
+
return toPublicSessionController(controller);
|
|
2757
|
+
}
|
|
2758
|
+
|
|
2759
|
+
private controllerDisplayName(controller: SessionControllerState): string {
|
|
2760
|
+
return controllerDisplayName(controller);
|
|
2761
|
+
}
|
|
2762
|
+
|
|
2763
|
+
private requireSession(sessionId: string): SessionState {
|
|
2764
|
+
const state = this.sessions.get(sessionId);
|
|
2765
|
+
if (state === undefined) {
|
|
2766
|
+
throw new Error(`session not found: ${sessionId}`);
|
|
2767
|
+
}
|
|
2768
|
+
return state;
|
|
2769
|
+
}
|
|
2770
|
+
|
|
2771
|
+
private requireLiveSession(sessionId: string): SessionState & { session: LiveSessionLike } {
|
|
2772
|
+
const state = this.requireSession(sessionId);
|
|
2773
|
+
if (state.session === null) {
|
|
2774
|
+
throw new Error(`session is not live: ${sessionId}`);
|
|
2775
|
+
}
|
|
2776
|
+
return state as SessionState & { session: LiveSessionLike };
|
|
2777
|
+
}
|
|
2778
|
+
|
|
2779
|
+
private connectionCanMutateSession(connectionId: string, state: SessionState): boolean {
|
|
2780
|
+
return state.controller === null || state.controller.connectionId === connectionId;
|
|
2781
|
+
}
|
|
2782
|
+
|
|
2783
|
+
private assertConnectionCanMutateSession(connectionId: string, state: SessionState): void {
|
|
2784
|
+
if (this.connectionCanMutateSession(connectionId, state)) {
|
|
2785
|
+
return;
|
|
2786
|
+
}
|
|
2787
|
+
const controller = state.controller;
|
|
2788
|
+
if (controller === null) {
|
|
2789
|
+
return;
|
|
2790
|
+
}
|
|
2791
|
+
throw new Error(`session is claimed by ${controllerDisplayName(controller)}`);
|
|
2792
|
+
}
|
|
2793
|
+
|
|
2794
|
+
private detachConnectionFromSession(connectionId: string, sessionId: string): void {
|
|
2795
|
+
const state = this.sessions.get(sessionId);
|
|
2796
|
+
if (state === undefined) {
|
|
2797
|
+
return;
|
|
2798
|
+
}
|
|
2799
|
+
|
|
2800
|
+
const attachmentId = state.attachmentByConnectionId.get(connectionId);
|
|
2801
|
+
if (attachmentId === undefined) {
|
|
2802
|
+
return;
|
|
2803
|
+
}
|
|
2804
|
+
if (state.session === null) {
|
|
2805
|
+
state.attachmentByConnectionId.delete(connectionId);
|
|
2806
|
+
return;
|
|
2807
|
+
}
|
|
2808
|
+
|
|
2809
|
+
state.session.detach(attachmentId);
|
|
2810
|
+
state.attachmentByConnectionId.delete(connectionId);
|
|
2811
|
+
}
|
|
2812
|
+
|
|
2813
|
+
private cleanupConnection(connectionId: string): void {
|
|
2814
|
+
const connection = this.connections.get(connectionId);
|
|
2815
|
+
if (connection === undefined) {
|
|
2816
|
+
return;
|
|
2817
|
+
}
|
|
2818
|
+
|
|
2819
|
+
for (const sessionId of connection.attachedSessionIds) {
|
|
2820
|
+
this.detachConnectionFromSession(connectionId, sessionId);
|
|
2821
|
+
}
|
|
2822
|
+
|
|
2823
|
+
for (const sessionId of connection.eventSessionIds) {
|
|
2824
|
+
const state = this.sessions.get(sessionId);
|
|
2825
|
+
state?.eventSubscriberConnectionIds.delete(connectionId);
|
|
2826
|
+
}
|
|
2827
|
+
|
|
2828
|
+
for (const subscriptionId of connection.streamSubscriptionIds) {
|
|
2829
|
+
this.streamSubscriptions.delete(subscriptionId);
|
|
2830
|
+
}
|
|
2831
|
+
|
|
2832
|
+
for (const state of this.sessions.values()) {
|
|
2833
|
+
if (state.controller?.connectionId !== connectionId) {
|
|
2834
|
+
continue;
|
|
2835
|
+
}
|
|
2836
|
+
const previousController = state.controller;
|
|
2837
|
+
state.controller = null;
|
|
2838
|
+
this.publishSessionControlObservedEvent(
|
|
2839
|
+
state,
|
|
2840
|
+
'released',
|
|
2841
|
+
null,
|
|
2842
|
+
toPublicSessionController(previousController),
|
|
2843
|
+
'controller-disconnected',
|
|
2844
|
+
);
|
|
2845
|
+
this.publishStatusObservedEvent(state);
|
|
2846
|
+
}
|
|
2847
|
+
|
|
2848
|
+
this.connections.delete(connectionId);
|
|
2849
|
+
recordPerfEvent('control-plane.server.connection.closed', {
|
|
2850
|
+
role: 'server',
|
|
2851
|
+
connectionId,
|
|
2852
|
+
});
|
|
2853
|
+
}
|
|
2854
|
+
|
|
2855
|
+
private deactivateSession(sessionId: string, closeSession: boolean): void {
|
|
2856
|
+
const state = this.sessions.get(sessionId);
|
|
2857
|
+
if (state === undefined || state.session === null) {
|
|
2858
|
+
return;
|
|
2859
|
+
}
|
|
2860
|
+
|
|
2861
|
+
const liveSession = state.session;
|
|
2862
|
+
state.session = null;
|
|
2863
|
+
|
|
2864
|
+
if (state.unsubscribe !== null) {
|
|
2865
|
+
state.unsubscribe();
|
|
2866
|
+
state.unsubscribe = null;
|
|
2867
|
+
}
|
|
2868
|
+
|
|
2869
|
+
state.lastSnapshot = this.snapshotRecordFromFrame(liveSession.snapshot());
|
|
2870
|
+
|
|
2871
|
+
if (closeSession) {
|
|
2872
|
+
liveSession.close();
|
|
2873
|
+
}
|
|
2874
|
+
|
|
2875
|
+
for (const [connectionId, attachmentId] of state.attachmentByConnectionId.entries()) {
|
|
2876
|
+
liveSession.detach(attachmentId);
|
|
2877
|
+
const connection = this.connections.get(connectionId);
|
|
2878
|
+
if (connection !== undefined) {
|
|
2879
|
+
connection.attachedSessionIds.delete(sessionId);
|
|
2880
|
+
}
|
|
2881
|
+
}
|
|
2882
|
+
state.attachmentByConnectionId.clear();
|
|
2883
|
+
|
|
2884
|
+
for (const connectionId of state.eventSubscriberConnectionIds) {
|
|
2885
|
+
const connection = this.connections.get(connectionId);
|
|
2886
|
+
if (connection !== undefined) {
|
|
2887
|
+
connection.eventSessionIds.delete(sessionId);
|
|
2888
|
+
}
|
|
2889
|
+
}
|
|
2890
|
+
state.eventSubscriberConnectionIds.clear();
|
|
2891
|
+
|
|
2892
|
+
this.persistConversationRuntime(state);
|
|
2893
|
+
this.publishStatusObservedEvent(state);
|
|
2894
|
+
|
|
2895
|
+
if (state.status === 'exited') {
|
|
2896
|
+
this.scheduleTombstoneRemoval(state.id);
|
|
2897
|
+
}
|
|
2898
|
+
}
|
|
2899
|
+
|
|
2900
|
+
private scheduleTombstoneRemoval(sessionId: string): void {
|
|
2901
|
+
const state = this.sessions.get(sessionId);
|
|
2902
|
+
if (state === undefined || state.status !== 'exited') {
|
|
2903
|
+
return;
|
|
2904
|
+
}
|
|
2905
|
+
|
|
2906
|
+
if (state.tombstoneTimer !== null) {
|
|
2907
|
+
clearTimeout(state.tombstoneTimer);
|
|
2908
|
+
state.tombstoneTimer = null;
|
|
2909
|
+
}
|
|
2910
|
+
|
|
2911
|
+
if (this.sessionExitTombstoneTtlMs <= 0) {
|
|
2912
|
+
this.destroySession(sessionId, false);
|
|
2913
|
+
return;
|
|
2914
|
+
}
|
|
2915
|
+
|
|
2916
|
+
state.tombstoneTimer = setTimeout(() => {
|
|
2917
|
+
const current = this.sessions.get(sessionId);
|
|
2918
|
+
if (current === undefined || current.status !== 'exited') {
|
|
2919
|
+
return;
|
|
2920
|
+
}
|
|
2921
|
+
this.destroySession(sessionId, false);
|
|
2922
|
+
}, this.sessionExitTombstoneTtlMs);
|
|
2923
|
+
state.tombstoneTimer.unref();
|
|
2924
|
+
}
|
|
2925
|
+
|
|
2926
|
+
private destroySession(sessionId: string, closeSession: boolean): void {
|
|
2927
|
+
const state = this.sessions.get(sessionId);
|
|
2928
|
+
if (state === undefined) {
|
|
2929
|
+
return;
|
|
2930
|
+
}
|
|
2931
|
+
|
|
2932
|
+
if (state.tombstoneTimer !== null) {
|
|
2933
|
+
clearTimeout(state.tombstoneTimer);
|
|
2934
|
+
state.tombstoneTimer = null;
|
|
2935
|
+
}
|
|
2936
|
+
|
|
2937
|
+
if (state.session !== null) {
|
|
2938
|
+
this.deactivateSession(sessionId, closeSession);
|
|
2939
|
+
}
|
|
2940
|
+
|
|
2941
|
+
this.sessions.delete(sessionId);
|
|
2942
|
+
this.launchCommandBySessionId.delete(sessionId);
|
|
2943
|
+
for (const [token, mappedSessionId] of this.telemetryTokenToSessionId.entries()) {
|
|
2944
|
+
if (mappedSessionId === sessionId) {
|
|
2945
|
+
this.telemetryTokenToSessionId.delete(token);
|
|
2946
|
+
}
|
|
2947
|
+
}
|
|
2948
|
+
}
|
|
2949
|
+
|
|
2950
|
+
private sortSessionSummaries(
|
|
2951
|
+
sessions: readonly SessionState[],
|
|
2952
|
+
sort: StreamSessionListSort,
|
|
2953
|
+
): readonly Record<string, unknown>[] {
|
|
2954
|
+
const sorted = [...sessions];
|
|
2955
|
+
sorted.sort((left, right) => {
|
|
2956
|
+
if (sort === 'started-asc') {
|
|
2957
|
+
const byStartedAsc = left.startedAt.localeCompare(right.startedAt);
|
|
2958
|
+
if (byStartedAsc !== 0) {
|
|
2959
|
+
return byStartedAsc;
|
|
2960
|
+
}
|
|
2961
|
+
return left.id.localeCompare(right.id);
|
|
2962
|
+
}
|
|
2963
|
+
|
|
2964
|
+
if (sort === 'started-desc') {
|
|
2965
|
+
const byStartedDesc = right.startedAt.localeCompare(left.startedAt);
|
|
2966
|
+
if (byStartedDesc !== 0) {
|
|
2967
|
+
return byStartedDesc;
|
|
2968
|
+
}
|
|
2969
|
+
return left.id.localeCompare(right.id);
|
|
2970
|
+
}
|
|
2971
|
+
|
|
2972
|
+
const byPriority = sessionPriority(left.status) - sessionPriority(right.status);
|
|
2973
|
+
if (byPriority !== 0) {
|
|
2974
|
+
return byPriority;
|
|
2975
|
+
}
|
|
2976
|
+
const byLastEvent = compareIsoDesc(left.lastEventAt, right.lastEventAt);
|
|
2977
|
+
if (byLastEvent !== 0) {
|
|
2978
|
+
return byLastEvent;
|
|
2979
|
+
}
|
|
2980
|
+
const byStartedDesc = right.startedAt.localeCompare(left.startedAt);
|
|
2981
|
+
if (byStartedDesc !== 0) {
|
|
2982
|
+
return byStartedDesc;
|
|
2983
|
+
}
|
|
2984
|
+
return left.id.localeCompare(right.id);
|
|
2985
|
+
});
|
|
2986
|
+
|
|
2987
|
+
return sorted.map((state) => this.sessionSummaryRecord(state));
|
|
2988
|
+
}
|
|
2989
|
+
|
|
2990
|
+
private sessionDiagnosticsRecord(state: SessionState): Record<string, unknown> {
|
|
2991
|
+
const nowMs = Date.now();
|
|
2992
|
+
const telemetryEventsLast60s = sessionRollingCounterTotal(
|
|
2993
|
+
state.diagnostics.telemetryIngestRate,
|
|
2994
|
+
nowMs,
|
|
2995
|
+
);
|
|
2996
|
+
return {
|
|
2997
|
+
telemetryIngestedTotal: state.diagnostics.telemetryIngestedTotal,
|
|
2998
|
+
telemetryRetainedTotal: state.diagnostics.telemetryRetainedTotal,
|
|
2999
|
+
telemetryDroppedTotal: state.diagnostics.telemetryDroppedTotal,
|
|
3000
|
+
telemetryEventsLast60s,
|
|
3001
|
+
telemetryIngestQps1m: Number((telemetryEventsLast60s / 60).toFixed(3)),
|
|
3002
|
+
fanoutEventsEnqueuedTotal: state.diagnostics.fanoutEventsEnqueuedTotal,
|
|
3003
|
+
fanoutBytesEnqueuedTotal: state.diagnostics.fanoutBytesEnqueuedTotal,
|
|
3004
|
+
fanoutBackpressureSignalsTotal: state.diagnostics.fanoutBackpressureSignalsTotal,
|
|
3005
|
+
fanoutBackpressureDisconnectsTotal: state.diagnostics.fanoutBackpressureDisconnectsTotal,
|
|
3006
|
+
};
|
|
3007
|
+
}
|
|
3008
|
+
|
|
3009
|
+
private noteTelemetryIngest(
|
|
3010
|
+
sessionId: string,
|
|
3011
|
+
outcome: 'ingested-only' | 'retained' | 'dropped',
|
|
3012
|
+
observedAt: string,
|
|
3013
|
+
): void {
|
|
3014
|
+
const state = this.sessions.get(sessionId);
|
|
3015
|
+
if (state === undefined) {
|
|
3016
|
+
return;
|
|
3017
|
+
}
|
|
3018
|
+
state.diagnostics.telemetryIngestedTotal += 1;
|
|
3019
|
+
incrementSessionRollingCounter(
|
|
3020
|
+
state.diagnostics.telemetryIngestRate,
|
|
3021
|
+
Date.parse(observedAt) || Date.now(),
|
|
3022
|
+
);
|
|
3023
|
+
if (outcome === 'retained') {
|
|
3024
|
+
state.diagnostics.telemetryRetainedTotal += 1;
|
|
3025
|
+
return;
|
|
3026
|
+
}
|
|
3027
|
+
if (outcome === 'dropped') {
|
|
3028
|
+
state.diagnostics.telemetryDroppedTotal += 1;
|
|
3029
|
+
}
|
|
3030
|
+
}
|
|
3031
|
+
|
|
3032
|
+
private noteSessionFanoutEnqueue(sessionId: string | null, bytes: number): void {
|
|
3033
|
+
if (sessionId === null) {
|
|
3034
|
+
return;
|
|
3035
|
+
}
|
|
3036
|
+
const state = this.sessions.get(sessionId);
|
|
3037
|
+
if (state === undefined) {
|
|
3038
|
+
return;
|
|
3039
|
+
}
|
|
3040
|
+
state.diagnostics.fanoutEventsEnqueuedTotal += 1;
|
|
3041
|
+
state.diagnostics.fanoutBytesEnqueuedTotal += Math.max(0, bytes);
|
|
3042
|
+
}
|
|
3043
|
+
|
|
3044
|
+
private noteSessionFanoutBackpressure(sessionId: string | null): void {
|
|
3045
|
+
if (sessionId === null) {
|
|
3046
|
+
return;
|
|
3047
|
+
}
|
|
3048
|
+
const state = this.sessions.get(sessionId);
|
|
3049
|
+
if (state === undefined) {
|
|
3050
|
+
return;
|
|
3051
|
+
}
|
|
3052
|
+
state.diagnostics.fanoutBackpressureSignalsTotal += 1;
|
|
3053
|
+
}
|
|
3054
|
+
|
|
3055
|
+
private noteSessionFanoutDisconnect(sessionId: string | null): void {
|
|
3056
|
+
if (sessionId === null) {
|
|
3057
|
+
return;
|
|
3058
|
+
}
|
|
3059
|
+
const state = this.sessions.get(sessionId);
|
|
3060
|
+
if (state === undefined) {
|
|
3061
|
+
return;
|
|
3062
|
+
}
|
|
3063
|
+
state.diagnostics.fanoutBackpressureDisconnectsTotal += 1;
|
|
3064
|
+
}
|
|
3065
|
+
|
|
3066
|
+
private diagnosticSessionIdForObservedEvent(
|
|
3067
|
+
scope: StreamObservedScope,
|
|
3068
|
+
event: StreamObservedEvent,
|
|
3069
|
+
): string | null {
|
|
3070
|
+
if (event.type === 'session-status') {
|
|
3071
|
+
return event.sessionId;
|
|
3072
|
+
}
|
|
3073
|
+
if (event.type === 'session-event') {
|
|
3074
|
+
return event.sessionId;
|
|
3075
|
+
}
|
|
3076
|
+
if (event.type === 'session-key-event') {
|
|
3077
|
+
return event.sessionId;
|
|
3078
|
+
}
|
|
3079
|
+
if (event.type === 'session-control') {
|
|
3080
|
+
return event.sessionId;
|
|
3081
|
+
}
|
|
3082
|
+
if (event.type === 'session-output') {
|
|
3083
|
+
return event.sessionId;
|
|
3084
|
+
}
|
|
3085
|
+
return scope.conversationId;
|
|
3086
|
+
}
|
|
3087
|
+
|
|
3088
|
+
private sessionSummaryRecord(state: SessionState): Record<string, unknown> {
|
|
3089
|
+
return {
|
|
3090
|
+
sessionId: state.id,
|
|
3091
|
+
directoryId: state.directoryId,
|
|
3092
|
+
tenantId: state.tenantId,
|
|
3093
|
+
userId: state.userId,
|
|
3094
|
+
workspaceId: state.workspaceId,
|
|
3095
|
+
worktreeId: state.worktreeId,
|
|
3096
|
+
status: state.status,
|
|
3097
|
+
attentionReason: state.attentionReason,
|
|
3098
|
+
statusModel: state.statusModel,
|
|
3099
|
+
latestCursor: state.session?.latestCursorValue() ?? null,
|
|
3100
|
+
processId: state.session?.processId() ?? null,
|
|
3101
|
+
attachedClients: state.attachmentByConnectionId.size,
|
|
3102
|
+
eventSubscribers: state.eventSubscriberConnectionIds.size,
|
|
3103
|
+
startedAt: state.startedAt,
|
|
3104
|
+
lastEventAt: state.lastEventAt,
|
|
3105
|
+
lastExit: state.lastExit,
|
|
3106
|
+
exitedAt: state.exitedAt,
|
|
3107
|
+
live: state.session !== null,
|
|
3108
|
+
launchCommand: this.launchCommandBySessionId.get(state.id) ?? null,
|
|
3109
|
+
telemetry: state.latestTelemetry,
|
|
3110
|
+
controller: toPublicSessionController(state.controller),
|
|
3111
|
+
diagnostics: this.sessionDiagnosticsRecord(state),
|
|
3112
|
+
};
|
|
3113
|
+
}
|
|
3114
|
+
|
|
3115
|
+
private snapshotRecordFromFrame(frame: TerminalSnapshotFrame): Record<string, unknown> {
|
|
3116
|
+
return {
|
|
3117
|
+
rows: frame.rows,
|
|
3118
|
+
cols: frame.cols,
|
|
3119
|
+
activeScreen: frame.activeScreen,
|
|
3120
|
+
modes: frame.modes,
|
|
3121
|
+
cursor: frame.cursor,
|
|
3122
|
+
viewport: frame.viewport,
|
|
3123
|
+
lines: frame.lines,
|
|
3124
|
+
frameHash: frame.frameHash,
|
|
3125
|
+
};
|
|
3126
|
+
}
|
|
3127
|
+
|
|
3128
|
+
private sendToConnection(
|
|
3129
|
+
connectionId: string,
|
|
3130
|
+
envelope: StreamServerEnvelope,
|
|
3131
|
+
diagnosticSessionId: string | null = null,
|
|
3132
|
+
): void {
|
|
3133
|
+
const connection = this.connections.get(connectionId);
|
|
3134
|
+
if (connection === undefined) {
|
|
3135
|
+
return;
|
|
3136
|
+
}
|
|
3137
|
+
|
|
3138
|
+
const payload = encodeStreamEnvelope(envelope);
|
|
3139
|
+
const payloadBytes = Buffer.byteLength(payload);
|
|
3140
|
+
connection.queuedPayloads.push({
|
|
3141
|
+
payload,
|
|
3142
|
+
bytes: payloadBytes,
|
|
3143
|
+
diagnosticSessionId,
|
|
3144
|
+
});
|
|
3145
|
+
connection.queuedPayloadBytes += payloadBytes;
|
|
3146
|
+
this.noteSessionFanoutEnqueue(diagnosticSessionId, payloadBytes);
|
|
3147
|
+
|
|
3148
|
+
if (this.connectionBufferedBytes(connection) > this.maxConnectionBufferedBytes) {
|
|
3149
|
+
this.noteSessionFanoutDisconnect(diagnosticSessionId);
|
|
3150
|
+
connection.socket.destroy(new Error('connection output buffer exceeded configured maximum'));
|
|
3151
|
+
return;
|
|
3152
|
+
}
|
|
3153
|
+
|
|
3154
|
+
this.flushConnectionWrites(connectionId);
|
|
3155
|
+
}
|
|
3156
|
+
|
|
3157
|
+
private flushConnectionWrites(connectionId: string): void {
|
|
3158
|
+
const connection = this.connections.get(connectionId);
|
|
3159
|
+
if (connection === undefined || connection.writeBlocked) {
|
|
3160
|
+
return;
|
|
3161
|
+
}
|
|
3162
|
+
|
|
3163
|
+
while (connection.queuedPayloads.length > 0) {
|
|
3164
|
+
const queued = connection.queuedPayloads.shift()!;
|
|
3165
|
+
connection.queuedPayloadBytes -= queued.bytes;
|
|
3166
|
+
const writeResult = connection.socket.write(queued.payload);
|
|
3167
|
+
if (!writeResult) {
|
|
3168
|
+
connection.writeBlocked = true;
|
|
3169
|
+
this.noteSessionFanoutBackpressure(queued.diagnosticSessionId);
|
|
3170
|
+
break;
|
|
3171
|
+
}
|
|
3172
|
+
}
|
|
3173
|
+
|
|
3174
|
+
if (this.connectionBufferedBytes(connection) > this.maxConnectionBufferedBytes) {
|
|
3175
|
+
const diagnosticSessionId = connection.queuedPayloads[0]?.diagnosticSessionId ?? null;
|
|
3176
|
+
this.noteSessionFanoutDisconnect(diagnosticSessionId);
|
|
3177
|
+
connection.socket.destroy(new Error('connection output buffer exceeded configured maximum'));
|
|
3178
|
+
}
|
|
3179
|
+
}
|
|
3180
|
+
|
|
3181
|
+
private connectionBufferedBytes(connection: ConnectionState): number {
|
|
3182
|
+
return connection.queuedPayloadBytes + connection.socket.writableLength;
|
|
3183
|
+
}
|
|
3184
|
+
}
|
|
3185
|
+
|
|
3186
|
+
export async function startControlPlaneStreamServer(
|
|
3187
|
+
options: StartControlPlaneStreamServerOptions,
|
|
3188
|
+
): Promise<ControlPlaneStreamServer> {
|
|
3189
|
+
const server = new ControlPlaneStreamServer(options);
|
|
3190
|
+
await server.start();
|
|
3191
|
+
return server;
|
|
3192
|
+
}
|