@jmoyers/harness 0.1.10 → 0.1.20

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.
Files changed (239) hide show
  1. package/README.md +31 -35
  2. package/package.json +31 -11
  3. package/packages/harness-ai/src/anthropic-protocol.ts +68 -68
  4. package/packages/harness-ai/src/stream-text.ts +13 -91
  5. package/packages/harness-ui/src/frame-primitives.ts +158 -0
  6. package/packages/harness-ui/src/index.ts +18 -0
  7. package/packages/harness-ui/src/interaction/conversation-input-forwarder.ts +221 -0
  8. package/packages/harness-ui/src/interaction/conversation-selection-input.ts +213 -0
  9. package/packages/harness-ui/src/interaction/global-shortcut-input.ts +172 -0
  10. package/{src/ui → packages/harness-ui/src/interaction}/input-preflight.ts +10 -12
  11. package/{src/ui → packages/harness-ui/src/interaction}/input-token-router.ts +120 -69
  12. package/packages/harness-ui/src/interaction/input.ts +420 -0
  13. package/packages/harness-ui/src/interaction/left-nav-input.ts +166 -0
  14. package/{src/ui → packages/harness-ui/src/interaction}/main-pane-pointer-input.ts +91 -23
  15. package/{src/ui → packages/harness-ui/src/interaction}/pointer-routing-input.ts +112 -48
  16. package/packages/harness-ui/src/interaction/rail-pointer-input.ts +62 -0
  17. package/packages/harness-ui/src/interaction/repository-fold-input.ts +118 -0
  18. package/packages/harness-ui/src/kit.ts +476 -0
  19. package/packages/harness-ui/src/layout.ts +238 -0
  20. package/{src/ui/modals/manager.ts → packages/harness-ui/src/modal-manager.ts} +94 -64
  21. package/{src/ui → packages/harness-ui/src}/screen.ts +53 -26
  22. package/packages/harness-ui/src/surface.ts +252 -0
  23. package/packages/harness-ui/src/text-layout.ts +210 -0
  24. package/packages/nim-core/src/contracts.ts +239 -0
  25. package/packages/nim-core/src/event-store.ts +299 -0
  26. package/packages/nim-core/src/events.ts +53 -0
  27. package/packages/nim-core/src/index.ts +9 -0
  28. package/packages/nim-core/src/provider-router.ts +129 -0
  29. package/packages/nim-core/src/providers/anthropic-driver.ts +291 -0
  30. package/packages/nim-core/src/runtime-factory.ts +49 -0
  31. package/packages/nim-core/src/runtime.ts +1797 -0
  32. package/packages/nim-core/src/session-store.ts +516 -0
  33. package/packages/nim-core/src/telemetry.ts +48 -0
  34. package/packages/nim-test-tui/src/index.ts +150 -0
  35. package/packages/nim-ui-core/src/index.ts +1 -0
  36. package/packages/nim-ui-core/src/projection.ts +87 -0
  37. package/scripts/codex-live-mux-runtime.ts +2 -3721
  38. package/scripts/control-plane-daemon.ts +24 -2
  39. package/scripts/harness-bin.js +5 -0
  40. package/scripts/harness-commands.ts +300 -0
  41. package/scripts/harness-runtime.ts +82 -0
  42. package/scripts/harness.ts +33 -3007
  43. package/scripts/nim-tui-smoke.ts +748 -0
  44. package/src/cli/auth/runtime.ts +948 -0
  45. package/src/cli/default-gateway-pointer.ts +193 -0
  46. package/src/cli/gateway/runtime.ts +1872 -0
  47. package/src/cli/parsing/flags.ts +23 -0
  48. package/src/cli/parsing/session.ts +42 -0
  49. package/src/cli/runtime/context.ts +193 -0
  50. package/src/cli/runtime-app/application.ts +392 -0
  51. package/src/cli/runtime-infra/gateway-control.ts +729 -0
  52. package/{scripts/harness-inspector.ts → src/cli/workflows/inspector.ts} +14 -11
  53. package/src/cli/workflows/runtime.ts +965 -0
  54. package/src/clients/tui/left-rail-interactions.ts +519 -0
  55. package/src/clients/tui/main-pane-interactions.ts +509 -0
  56. package/src/clients/tui/modal-input-routing.ts +71 -0
  57. package/src/clients/tui/render-snapshot-adapter.ts +88 -0
  58. package/src/clients/web/synced-selectors.ts +132 -0
  59. package/src/codex/live-session.ts +82 -29
  60. package/src/config/config-core.ts +361 -10
  61. package/src/config/harness-paths.ts +4 -7
  62. package/src/config/harness-runtime-migration.ts +142 -19
  63. package/src/config/harness.config.template.jsonc +33 -0
  64. package/src/config/secrets-core.ts +92 -4
  65. package/src/control-plane/agent-realtime-api.ts +82 -427
  66. package/src/control-plane/prompt/thread-title-namer.ts +49 -23
  67. package/src/control-plane/session-summary.ts +10 -81
  68. package/src/control-plane/status/reducer-base.ts +12 -12
  69. package/src/control-plane/status/reducers/claude-status-reducer.ts +3 -3
  70. package/src/control-plane/status/reducers/codex-status-reducer.ts +4 -4
  71. package/src/control-plane/status/reducers/cursor-status-reducer.ts +3 -3
  72. package/src/control-plane/stream-client.ts +12 -2
  73. package/src/control-plane/stream-command-parser.ts +83 -143
  74. package/src/control-plane/stream-protocol.ts +53 -37
  75. package/src/control-plane/stream-server-background.ts +18 -2
  76. package/src/control-plane/stream-server-command.ts +376 -69
  77. package/src/control-plane/stream-server-session-runtime.ts +3 -2
  78. package/src/control-plane/stream-server.ts +943 -80
  79. package/src/control-plane/stream-session-runtime-types.ts +41 -0
  80. package/src/{mux/live-mux/control-plane-records.ts → core/contracts/records.ts} +24 -97
  81. package/src/core/state/observed-stream-cursor.ts +43 -0
  82. package/src/core/state/synced-observed-state.ts +273 -0
  83. package/src/core/store/harness-synced-store.ts +81 -0
  84. package/src/diff/budget.ts +136 -0
  85. package/src/diff/build.ts +289 -0
  86. package/src/diff/chunker.ts +146 -0
  87. package/src/diff/git-invoke.ts +315 -0
  88. package/src/diff/git-parse.ts +472 -0
  89. package/src/diff/hash.ts +70 -0
  90. package/src/diff/index.ts +24 -0
  91. package/src/diff/normalize.ts +134 -0
  92. package/src/diff/types.ts +178 -0
  93. package/src/diff-ui/args.ts +346 -0
  94. package/src/diff-ui/commands.ts +123 -0
  95. package/src/diff-ui/finder.ts +94 -0
  96. package/src/diff-ui/highlight.ts +127 -0
  97. package/src/diff-ui/index.ts +2 -0
  98. package/src/diff-ui/model.ts +141 -0
  99. package/src/diff-ui/pager.ts +412 -0
  100. package/src/diff-ui/render.ts +337 -0
  101. package/src/diff-ui/runtime.ts +379 -0
  102. package/src/diff-ui/state.ts +224 -0
  103. package/src/diff-ui/types.ts +236 -0
  104. package/src/domain/conversations.ts +11 -7
  105. package/src/domain/workspace.ts +76 -4
  106. package/src/mux/control-plane-op-queue.ts +93 -7
  107. package/src/mux/conversation-rail.ts +28 -71
  108. package/src/mux/dual-pane-core.ts +13 -13
  109. package/src/mux/harness-core-ui.ts +313 -42
  110. package/src/mux/input-shortcuts.ts +22 -112
  111. package/src/mux/keybinding-catalog.ts +340 -0
  112. package/src/mux/keybinding-registry.ts +103 -0
  113. package/src/mux/live-mux/command-menu-open-in.ts +280 -0
  114. package/src/mux/live-mux/command-menu.ts +167 -4
  115. package/src/mux/live-mux/conversation-state.ts +13 -0
  116. package/src/mux/live-mux/directory-resolution.ts +1 -1
  117. package/src/mux/live-mux/git-parsing.ts +16 -0
  118. package/src/mux/live-mux/git-snapshot.ts +33 -2
  119. package/src/mux/live-mux/global-shortcut-handlers.ts +6 -0
  120. package/src/mux/live-mux/home-pane-drop.ts +1 -1
  121. package/src/mux/live-mux/home-pane-pointer.ts +10 -0
  122. package/src/mux/live-mux/input-forwarding.ts +59 -2
  123. package/src/mux/live-mux/left-nav-activation.ts +124 -7
  124. package/src/mux/live-mux/left-nav.ts +35 -0
  125. package/src/mux/live-mux/link-click.ts +292 -0
  126. package/src/mux/live-mux/modal-command-menu-handler.ts +46 -9
  127. package/src/mux/live-mux/modal-conversation-handlers.ts +5 -1
  128. package/src/mux/live-mux/modal-input-reducers.ts +106 -8
  129. package/src/mux/live-mux/modal-overlays.ts +210 -31
  130. package/src/mux/live-mux/modal-pointer.ts +3 -7
  131. package/src/mux/live-mux/modal-prompt-handlers.ts +107 -1
  132. package/src/mux/live-mux/modal-release-notes-handler.ts +111 -0
  133. package/src/mux/live-mux/modal-task-editor-handler.ts +16 -11
  134. package/src/mux/live-mux/pointer-routing.ts +5 -2
  135. package/src/mux/live-mux/project-pane-pointer.ts +8 -0
  136. package/src/mux/live-mux/rail-layout.ts +33 -30
  137. package/src/mux/live-mux/release-notes.ts +383 -0
  138. package/src/mux/live-mux/render-trace-analysis.ts +52 -7
  139. package/src/mux/live-mux/repository-folding.ts +3 -0
  140. package/src/mux/live-mux/selection.ts +0 -4
  141. package/src/mux/live-mux/session-diagnostics-paths.ts +21 -0
  142. package/src/mux/project-pane-github-review.ts +271 -0
  143. package/src/mux/render-frame.ts +4 -0
  144. package/src/mux/runtime-app/codex-live-mux-runtime.ts +5191 -0
  145. package/src/mux/task-composer.ts +21 -14
  146. package/src/mux/task-focused-pane.ts +118 -117
  147. package/src/mux/task-screen-keybindings.ts +19 -82
  148. package/src/mux/workspace-rail-model.ts +270 -104
  149. package/src/mux/workspace-rail.ts +45 -22
  150. package/src/pty/session-broker.ts +1 -1
  151. package/{scripts → src/recording}/terminal-recording-gif-lib.ts +2 -2
  152. package/src/services/control-plane.ts +50 -32
  153. package/src/services/conversation-lifecycle.ts +118 -87
  154. package/src/services/conversation-startup-hydration.ts +20 -12
  155. package/src/services/directory-hydration.ts +21 -16
  156. package/src/services/event-persistence.ts +7 -0
  157. package/src/services/left-rail-pointer-handler.ts +329 -0
  158. package/src/services/mux-ui-state-persistence.ts +5 -1
  159. package/src/services/recording.ts +34 -26
  160. package/src/services/runtime-command-menu-agent-tools.ts +1 -1
  161. package/src/services/runtime-control-actions.ts +79 -61
  162. package/src/services/runtime-control-plane-ops.ts +122 -83
  163. package/src/services/runtime-conversation-actions.ts +40 -26
  164. package/src/services/runtime-conversation-activation.ts +82 -30
  165. package/src/services/runtime-conversation-starter.ts +80 -48
  166. package/src/services/runtime-conversation-title-edit.ts +91 -80
  167. package/src/services/runtime-envelope-handler.ts +107 -105
  168. package/src/services/runtime-git-state.ts +42 -29
  169. package/src/services/runtime-layout-resize.ts +3 -1
  170. package/src/services/runtime-left-rail-render.ts +99 -63
  171. package/src/services/runtime-nim-cli-session.ts +438 -0
  172. package/src/services/runtime-nim-session.ts +705 -0
  173. package/src/services/runtime-nim-tool-bridge.ts +141 -0
  174. package/src/services/runtime-observed-event-projection-pipeline.ts +45 -0
  175. package/src/services/runtime-process-wiring.ts +29 -36
  176. package/src/services/runtime-project-pane-github-review-cache.ts +164 -0
  177. package/src/services/runtime-render-flush.ts +63 -70
  178. package/src/services/runtime-render-lifecycle.ts +65 -64
  179. package/src/services/runtime-render-orchestrator.ts +55 -45
  180. package/src/services/runtime-render-pipeline.ts +106 -103
  181. package/src/services/runtime-render-state.ts +62 -49
  182. package/src/services/runtime-repository-actions.ts +97 -70
  183. package/src/services/runtime-right-pane-render.ts +80 -53
  184. package/src/services/runtime-shutdown.ts +38 -35
  185. package/src/services/runtime-stream-subscriptions.ts +35 -27
  186. package/src/services/runtime-task-composer-persistence.ts +71 -59
  187. package/src/services/runtime-task-composer-snapshot.ts +14 -0
  188. package/src/services/runtime-task-editor-actions.ts +46 -29
  189. package/src/services/runtime-task-pane-actions.ts +220 -134
  190. package/src/services/runtime-task-pane-shortcuts.ts +323 -123
  191. package/src/services/runtime-workspace-observed-effect-queue.ts +25 -0
  192. package/src/services/runtime-workspace-observed-events.ts +33 -184
  193. package/src/services/runtime-workspace-observed-transition-policy.ts +228 -0
  194. package/src/services/session-diagnostics-store.ts +217 -0
  195. package/src/services/startup-background-resume.ts +26 -21
  196. package/src/services/startup-orchestrator.ts +16 -13
  197. package/src/services/startup-paint-tracker.ts +29 -21
  198. package/src/services/startup-persisted-conversation-queue.ts +19 -13
  199. package/src/services/startup-settled-gate.ts +25 -15
  200. package/src/services/startup-shutdown.ts +18 -22
  201. package/src/services/startup-state-hydration.ts +44 -34
  202. package/src/services/startup-visibility.ts +12 -4
  203. package/src/services/task-pane-selection-actions.ts +89 -72
  204. package/src/services/task-planning-hydration.ts +24 -18
  205. package/src/services/task-planning-observed-events.ts +50 -52
  206. package/src/services/workspace-observed-events.ts +66 -63
  207. package/src/storage/storage-lifecycle-core.ts +438 -0
  208. package/src/store/control-plane-store-normalize.ts +33 -242
  209. package/src/store/control-plane-store-types.ts +1 -35
  210. package/src/store/control-plane-store.ts +396 -56
  211. package/src/store/event-store.ts +397 -3
  212. package/src/terminal/snapshot-oracle.ts +207 -94
  213. package/src/ui/mux-theme.ts +112 -8
  214. package/src/ui/panes/home-gridfire.ts +40 -31
  215. package/src/ui/panes/home.ts +10 -2
  216. package/src/ui/panes/nim.ts +315 -0
  217. package/src/mux/live-mux/actions-task.ts +0 -115
  218. package/src/mux/live-mux/left-rail-actions.ts +0 -118
  219. package/src/mux/live-mux/left-rail-conversation-click.ts +0 -82
  220. package/src/mux/live-mux/left-rail-pointer.ts +0 -74
  221. package/src/mux/live-mux/task-pane-shortcuts.ts +0 -206
  222. package/src/services/runtime-directory-actions.ts +0 -164
  223. package/src/services/runtime-input-pipeline.ts +0 -50
  224. package/src/services/runtime-input-router.ts +0 -189
  225. package/src/services/runtime-main-pane-input.ts +0 -230
  226. package/src/services/runtime-modal-input.ts +0 -119
  227. package/src/services/runtime-navigation-input.ts +0 -197
  228. package/src/services/runtime-rail-input.ts +0 -278
  229. package/src/services/runtime-task-pane.ts +0 -62
  230. package/src/services/runtime-workspace-actions.ts +0 -158
  231. package/src/ui/conversation-input-forwarder.ts +0 -114
  232. package/src/ui/conversation-selection-input.ts +0 -103
  233. package/src/ui/global-shortcut-input.ts +0 -89
  234. package/src/ui/input.ts +0 -238
  235. package/src/ui/kit.ts +0 -509
  236. package/src/ui/left-nav-input.ts +0 -80
  237. package/src/ui/left-rail-pointer-input.ts +0 -148
  238. package/src/ui/repository-fold-input.ts +0 -91
  239. package/src/ui/surface.ts +0 -224
@@ -11,9 +11,10 @@ import { randomUUID } from 'node:crypto';
11
11
  import { tmpdir } from 'node:os';
12
12
  import { delimiter, isAbsolute, join, resolve } from 'node:path';
13
13
  import { fileURLToPath } from 'node:url';
14
+ import { LinearClient } from '@linear/sdk';
14
15
  import { type CodexLiveEvent, type LiveSessionNotifyMode } from '../codex/live-session.ts';
15
16
  import type { PtyExit } from '../pty/pty_host.ts';
16
- import type { TerminalBufferTail, TerminalSnapshotFrame } from '../terminal/snapshot-oracle.ts';
17
+ import type { TerminalSnapshotFrame } from '../terminal/snapshot-oracle.ts';
17
18
  import {
18
19
  encodeStreamEnvelope,
19
20
  type StreamObservedEvent,
@@ -85,6 +86,11 @@ import {
85
86
  import { closeOwnedStateStore as closeOwnedStreamServerStateStore } from './stream-server-state-store.ts';
86
87
  import { SessionStatusEngine } from './status/session-status-engine.ts';
87
88
  import { SessionPromptEngine } from './prompt/session-prompt-engine.ts';
89
+ import {
90
+ StorageLifecycleCore,
91
+ type StorageLifecyclePolicy,
92
+ type StorageLifecycleTelemetryStore,
93
+ } from '../storage/storage-lifecycle-core.ts';
88
94
  import {
89
95
  appendThreadTitlePromptHistory,
90
96
  createAnthropicThreadTitleNamer,
@@ -98,32 +104,14 @@ import {
98
104
  eventIncludesTaskId as filterEventIncludesTaskId,
99
105
  matchesObservedFilter as matchesStreamObservedFilter,
100
106
  } from './stream-server-observed-filter.ts';
101
- import type { HarnessLifecycleHooksConfig } from '../config/config-core.ts';
107
+ import {
108
+ DEFAULT_HARNESS_CONFIG,
109
+ loadHarnessConfig,
110
+ type HarnessLifecycleHooksConfig,
111
+ } from '../config/config-core.ts';
102
112
  import { LifecycleHooksRuntime } from './lifecycle-hooks.ts';
103
113
  import { readGitDirectorySnapshot } from '../mux/live-mux/git-snapshot.ts';
104
-
105
- interface SessionDataEvent {
106
- cursor: number;
107
- chunk: Buffer;
108
- }
109
-
110
- interface SessionAttachHandlers {
111
- onData: (event: SessionDataEvent) => void;
112
- onExit: (exit: PtyExit) => void;
113
- }
114
-
115
- interface LiveSessionLike {
116
- attach(handlers: SessionAttachHandlers, sinceCursor?: number): string;
117
- detach(attachmentId: string): void;
118
- latestCursorValue(): number;
119
- processId(): number | null;
120
- write(data: string | Uint8Array): void;
121
- resize(cols: number, rows: number): void;
122
- snapshot(): TerminalSnapshotFrame;
123
- bufferTail?(tailLines?: number): TerminalBufferTail;
124
- close(): void;
125
- onEvent(listener: (event: CodexLiveEvent) => void): () => void;
126
- }
114
+ import type { LiveSessionLike, StartSessionRuntimeInput } from './stream-session-runtime-types.ts';
127
115
 
128
116
  export interface StartControlPlaneSessionInput {
129
117
  command?: string;
@@ -140,21 +128,6 @@ export interface StartControlPlaneSessionInput {
140
128
  terminalBackgroundHex?: string;
141
129
  }
142
130
 
143
- interface StartSessionRuntimeInput {
144
- readonly sessionId: string;
145
- readonly args: readonly string[];
146
- readonly initialCols: number;
147
- readonly initialRows: number;
148
- readonly env?: Record<string, string>;
149
- readonly cwd?: string;
150
- readonly tenantId?: string;
151
- readonly userId?: string;
152
- readonly workspaceId?: string;
153
- readonly worktreeId?: string;
154
- readonly terminalForegroundHex?: string;
155
- readonly terminalBackgroundHex?: string;
156
- }
157
-
158
131
  type StartControlPlaneSession = (input: StartControlPlaneSessionInput) => LiveSessionLike;
159
132
 
160
133
  interface CodexTelemetryServerConfig {
@@ -201,6 +174,11 @@ interface CritiqueConfig {
201
174
  readonly launch: CritiqueLaunchConfig;
202
175
  }
203
176
 
177
+ interface ClaudeLaunchConfig {
178
+ readonly defaultMode: 'yolo' | 'standard';
179
+ readonly directoryModes: Readonly<Record<string, 'yolo' | 'standard'>>;
180
+ }
181
+
204
182
  interface CursorLaunchConfig {
205
183
  readonly defaultMode: 'yolo' | 'standard';
206
184
  readonly directoryModes: Readonly<Record<string, 'yolo' | 'standard'>>;
@@ -230,6 +208,13 @@ interface GitHubIntegrationConfig {
230
208
  readonly viewerLogin: string | null;
231
209
  }
232
210
 
211
+ interface LinearIntegrationConfig {
212
+ readonly enabled: boolean;
213
+ readonly apiBaseUrl: string;
214
+ readonly tokenEnvVar: string;
215
+ readonly token: string | null;
216
+ }
217
+
233
218
  interface ThreadTitleConfig {
234
219
  readonly enabled: boolean;
235
220
  readonly apiKey: string | null;
@@ -248,6 +233,7 @@ interface GitHubRemotePullRequest {
248
233
  readonly baseBranch: string;
249
234
  readonly state: 'open' | 'closed';
250
235
  readonly isDraft: boolean;
236
+ readonly mergedAt: string | null;
251
237
  readonly updatedAt: string;
252
238
  readonly createdAt: string;
253
239
  readonly closedAt: string | null;
@@ -264,6 +250,48 @@ interface GitHubRemotePrJob {
264
250
  readonly completedAt: string | null;
265
251
  }
266
252
 
253
+ interface GitHubRemotePrReviewComment {
254
+ readonly commentId: string;
255
+ readonly authorLogin: string | null;
256
+ readonly body: string;
257
+ readonly url: string | null;
258
+ readonly createdAt: string;
259
+ readonly updatedAt: string;
260
+ }
261
+
262
+ interface GitHubRemotePrReviewThread {
263
+ readonly threadId: string;
264
+ readonly isResolved: boolean;
265
+ readonly isOutdated: boolean;
266
+ readonly resolvedByLogin: string | null;
267
+ readonly comments: readonly GitHubRemotePrReviewComment[];
268
+ }
269
+
270
+ interface GitHubProjectReviewCachePullRequest {
271
+ readonly number: number;
272
+ readonly title: string;
273
+ readonly url: string;
274
+ readonly authorLogin: string | null;
275
+ readonly headBranch: string;
276
+ readonly headSha: string;
277
+ readonly baseBranch: string;
278
+ readonly state: 'draft' | 'open' | 'merged' | 'closed';
279
+ readonly isDraft: boolean;
280
+ readonly mergedAt: string | null;
281
+ readonly closedAt: string | null;
282
+ readonly updatedAt: string;
283
+ readonly createdAt: string;
284
+ }
285
+
286
+ interface GitHubProjectReviewCacheEntry {
287
+ readonly repositoryId: string;
288
+ readonly branchName: string;
289
+ readonly pr: GitHubProjectReviewCachePullRequest | null;
290
+ readonly openThreads: readonly GitHubRemotePrReviewThread[];
291
+ readonly resolvedThreads: readonly GitHubRemotePrReviewThread[];
292
+ readonly fetchedAtMs: number;
293
+ }
294
+
267
295
  type GitDirectorySnapshot = Awaited<ReturnType<typeof readGitDirectorySnapshot>>;
268
296
  type GitDirectorySnapshotReader = (cwd: string) => Promise<GitDirectorySnapshot>;
269
297
  type GitHubTokenResolver = () => Promise<string | null>;
@@ -290,12 +318,14 @@ interface StartControlPlaneStreamServerOptions {
290
318
  codexTelemetry?: CodexTelemetryServerConfig;
291
319
  codexHistory?: CodexHistoryIngestConfig;
292
320
  codexLaunch?: CodexLaunchConfig;
321
+ claudeLaunch?: ClaudeLaunchConfig;
293
322
  critique?: CritiqueConfig;
294
323
  agentInstall?: Partial<Record<AgentToolType, AgentInstallCommandConfig>>;
295
324
  cursorLaunch?: CursorLaunchConfig;
296
325
  cursorHooks?: Partial<CursorHooksConfig>;
297
326
  gitStatus?: GitStatusMonitorConfig;
298
327
  github?: Partial<GitHubIntegrationConfig>;
328
+ linear?: Partial<LinearIntegrationConfig>;
299
329
  githubTokenResolver?: GitHubTokenResolver;
300
330
  githubExecFile?: GitHubExecFile;
301
331
  githubFetch?: typeof fetch;
@@ -303,6 +333,20 @@ interface StartControlPlaneStreamServerOptions {
303
333
  lifecycleHooks?: HarnessLifecycleHooksConfig;
304
334
  threadTitle?: Partial<ThreadTitleConfig>;
305
335
  threadTitleNamer?: ThreadTitleNamer;
336
+ storageLifecyclePolicy?: Partial<StorageLifecyclePolicy>;
337
+ storageLifecyclePolicyReload?: {
338
+ cwd: string;
339
+ filePath?: string;
340
+ env?: NodeJS.ProcessEnv;
341
+ pollMs?: number;
342
+ };
343
+ }
344
+
345
+ interface StorageLifecyclePolicyReloadConfig {
346
+ readonly cwd: string;
347
+ readonly filePath?: string;
348
+ readonly env?: NodeJS.ProcessEnv;
349
+ readonly pollMs: number;
306
350
  }
307
351
 
308
352
  interface ConnectionState {
@@ -427,6 +471,52 @@ interface OtlpEndpointTarget {
427
471
  readonly token: string;
428
472
  }
429
473
 
474
+ function asTelemetryLifecycleStore(value: unknown): StorageLifecycleTelemetryStore | null {
475
+ if (typeof value !== 'object' || value === null) {
476
+ return null;
477
+ }
478
+ const candidate = value as Record<string, unknown>;
479
+ const prune = candidate['pruneTelemetryOlderThan'];
480
+ const checkpoint = candidate['checkpointWal'];
481
+ const compact = candidate['compactFreelistPages'];
482
+ const copyForward = candidate['runOnlineCopyForwardCompactionStep'];
483
+ if (
484
+ typeof prune !== 'function' ||
485
+ typeof checkpoint !== 'function' ||
486
+ typeof compact !== 'function'
487
+ ) {
488
+ return null;
489
+ }
490
+ return {
491
+ pruneTelemetryOlderThan: (cutoffIngestedAt, limit) =>
492
+ (prune as (cutoffIngestedAt: string, limit: number) => number).call(
493
+ value,
494
+ cutoffIngestedAt,
495
+ limit,
496
+ ),
497
+ checkpointWal: (mode) => {
498
+ (checkpoint as (mode?: 'PASSIVE' | 'TRUNCATE') => void).call(value, mode);
499
+ },
500
+ compactFreelistPages: (maxPages) => {
501
+ (compact as (maxPages: number) => void).call(value, maxPages);
502
+ },
503
+ ...(typeof copyForward !== 'function'
504
+ ? {}
505
+ : {
506
+ runOnlineCopyForwardCompactionStep: (batchSize: number, finalizeTailRows: number) =>
507
+ (
508
+ copyForward as (
509
+ batchSize: number,
510
+ finalizeTailRows: number,
511
+ ) => {
512
+ readonly state: 'idle' | 'copying' | 'finalized';
513
+ readonly copiedRows: number;
514
+ }
515
+ ).call(value, batchSize, finalizeTailRows),
516
+ }),
517
+ };
518
+ }
519
+
430
520
  function isTelemetryRequestAbortError(error: unknown): boolean {
431
521
  const code = (error as NodeJS.ErrnoException).code;
432
522
  if (code === 'ECONNRESET' || code === 'ERR_STREAM_PREMATURE_CLOSE') {
@@ -441,6 +531,11 @@ const DEFAULT_SESSION_EXIT_TOMBSTONE_TTL_MS = 5 * 60 * 1000;
441
531
  const DEFAULT_MAX_STREAM_JOURNAL_ENTRIES = 10000;
442
532
  const DEFAULT_GIT_STATUS_POLL_MS = 1200;
443
533
  const DEFAULT_GITHUB_POLL_MS = 15_000;
534
+ const DEFAULT_STORAGE_LIFECYCLE_POLICY_RELOAD_POLL_MS = 5000;
535
+ const DEFAULT_GITHUB_PROJECT_REVIEW_PREWARM_INTERVAL_MS = 5 * 60 * 1000;
536
+ const DEFAULT_LINEAR_API_BASE_URL = 'https://api.linear.app/graphql';
537
+ const GITHUB_OAUTH_ACCESS_TOKEN_ENV_VAR = 'HARNESS_GITHUB_OAUTH_ACCESS_TOKEN';
538
+ const LINEAR_OAUTH_ACCESS_TOKEN_ENV_VAR = 'HARNESS_LINEAR_OAUTH_ACCESS_TOKEN';
444
539
  const HISTORY_POLL_JITTER_RATIO = 0.35;
445
540
  const SESSION_DIAGNOSTICS_BUCKET_MS = 10_000;
446
541
  const SESSION_DIAGNOSTICS_BUCKET_COUNT = 6;
@@ -597,6 +692,23 @@ function normalizeCodexHistoryConfig(
597
692
  };
598
693
  }
599
694
 
695
+ function normalizeStorageLifecyclePolicyReloadConfig(
696
+ input: StartControlPlaneStreamServerOptions['storageLifecyclePolicyReload'],
697
+ ): StorageLifecyclePolicyReloadConfig | null {
698
+ if (input === undefined) {
699
+ return null;
700
+ }
701
+ return {
702
+ cwd: input.cwd,
703
+ ...(input.filePath === undefined ? {} : { filePath: input.filePath }),
704
+ ...(input.env === undefined ? {} : { env: input.env }),
705
+ pollMs: Math.max(
706
+ 250,
707
+ Math.floor(input.pollMs ?? DEFAULT_STORAGE_LIFECYCLE_POLICY_RELOAD_POLL_MS),
708
+ ),
709
+ };
710
+ }
711
+
600
712
  function normalizeCodexLaunchConfig(input: CodexLaunchConfig | undefined): CodexLaunchConfig {
601
713
  return {
602
714
  defaultMode: input?.defaultMode ?? 'standard',
@@ -666,6 +778,13 @@ function normalizeAgentInstallConfig(
666
778
  };
667
779
  }
668
780
 
781
+ function normalizeClaudeLaunchConfig(input: ClaudeLaunchConfig | undefined): ClaudeLaunchConfig {
782
+ return {
783
+ defaultMode: input?.defaultMode ?? 'standard',
784
+ directoryModes: input?.directoryModes ?? {},
785
+ };
786
+ }
787
+
669
788
  function normalizeCursorLaunchConfig(input: CursorLaunchConfig | undefined): CursorLaunchConfig {
670
789
  return {
671
790
  defaultMode: input?.defaultMode ?? 'standard',
@@ -729,10 +848,15 @@ function normalizeGitHubIntegrationConfig(
729
848
  typeof tokenEnvVarRaw === 'string' && tokenEnvVarRaw.trim().length > 0
730
849
  ? tokenEnvVarRaw.trim()
731
850
  : 'GITHUB_TOKEN';
732
- const envToken = process.env[tokenEnvVar];
851
+ const manualEnvToken = process.env[tokenEnvVar];
852
+ const oauthEnvToken = process.env[GITHUB_OAUTH_ACCESS_TOKEN_ENV_VAR];
853
+ const envTokenRaw =
854
+ typeof manualEnvToken === 'string' && manualEnvToken.trim().length > 0
855
+ ? manualEnvToken
856
+ : oauthEnvToken;
733
857
  const tokenRaw =
734
858
  input?.token ??
735
- (typeof envToken === 'string' && envToken.trim().length > 0 ? envToken.trim() : null);
859
+ (typeof envTokenRaw === 'string' && envTokenRaw.trim().length > 0 ? envTokenRaw.trim() : null);
736
860
  const branchStrategyRaw = input?.branchStrategy;
737
861
  const branchStrategy =
738
862
  branchStrategyRaw === 'current-only' ||
@@ -772,6 +896,36 @@ function normalizeGitHubIntegrationConfig(
772
896
  };
773
897
  }
774
898
 
899
+ function normalizeLinearIntegrationConfig(
900
+ input: Partial<LinearIntegrationConfig> | undefined,
901
+ ): LinearIntegrationConfig {
902
+ const tokenEnvVarRaw = input?.tokenEnvVar;
903
+ const tokenEnvVar =
904
+ typeof tokenEnvVarRaw === 'string' && tokenEnvVarRaw.trim().length > 0
905
+ ? tokenEnvVarRaw.trim()
906
+ : 'LINEAR_API_KEY';
907
+ const manualEnvToken = process.env[tokenEnvVar];
908
+ const oauthEnvToken = process.env[LINEAR_OAUTH_ACCESS_TOKEN_ENV_VAR];
909
+ const envTokenRaw =
910
+ typeof manualEnvToken === 'string' && manualEnvToken.trim().length > 0
911
+ ? manualEnvToken
912
+ : oauthEnvToken;
913
+ const tokenRaw =
914
+ input?.token ??
915
+ (typeof envTokenRaw === 'string' && envTokenRaw.trim().length > 0 ? envTokenRaw.trim() : null);
916
+ const apiBaseUrlRaw = input?.apiBaseUrl;
917
+ const apiBaseUrl =
918
+ typeof apiBaseUrlRaw === 'string' && apiBaseUrlRaw.trim().length > 0
919
+ ? apiBaseUrlRaw.trim().replace(/\/+$/u, '')
920
+ : DEFAULT_LINEAR_API_BASE_URL;
921
+ return {
922
+ enabled: input?.enabled ?? false,
923
+ apiBaseUrl,
924
+ tokenEnvVar,
925
+ token: tokenRaw,
926
+ };
927
+ }
928
+
775
929
  function normalizeThreadTitleConfig(
776
930
  input: Partial<ThreadTitleConfig> | undefined,
777
931
  ): ThreadTitleConfig {
@@ -965,6 +1119,7 @@ const streamServerInternals = {
965
1119
  gitRepositorySnapshotEqual,
966
1120
  commandExists,
967
1121
  normalizeGitHubIntegrationConfig,
1122
+ normalizeLinearIntegrationConfig,
968
1123
  parseGitHubOwnerRepoFromRemote,
969
1124
  resolveTrackedBranchName,
970
1125
  summarizeGitHubCiRollup,
@@ -1092,12 +1247,14 @@ export class ControlPlaneStreamServer {
1092
1247
  private readonly codexTelemetry: CodexTelemetryServerConfig;
1093
1248
  private readonly codexHistory: CodexHistoryIngestConfig;
1094
1249
  private readonly codexLaunch: CodexLaunchConfig;
1250
+ private readonly claudeLaunch: ClaudeLaunchConfig;
1095
1251
  private readonly critique: CritiqueConfig;
1096
1252
  private readonly agentInstall: AgentInstallConfig;
1097
1253
  private readonly cursorLaunch: CursorLaunchConfig;
1098
1254
  private readonly cursorHooks: CursorHooksConfig;
1099
1255
  private readonly gitStatusMonitor: GitStatusMonitorConfig;
1100
1256
  private readonly github: GitHubIntegrationConfig;
1257
+ private readonly linear: LinearIntegrationConfig;
1101
1258
  private readonly githubTokenResolver: GitHubTokenResolver;
1102
1259
  private readonly githubExecFile: GitHubExecFile;
1103
1260
  private readonly githubFetch: typeof fetch;
@@ -1107,6 +1264,16 @@ export class ControlPlaneStreamServer {
1107
1264
  repo: string;
1108
1265
  headBranch: string;
1109
1266
  }): Promise<GitHubRemotePullRequest | null>;
1267
+ findPullRequestForBranch(input: {
1268
+ owner: string;
1269
+ repo: string;
1270
+ headBranch: string;
1271
+ }): Promise<GitHubRemotePullRequest | null>;
1272
+ listPullRequestReviewThreads(input: {
1273
+ owner: string;
1274
+ repo: string;
1275
+ pullNumber: number;
1276
+ }): Promise<readonly GitHubRemotePrReviewThread[]>;
1110
1277
  createPullRequest(input: {
1111
1278
  owner: string;
1112
1279
  repo: string;
@@ -1117,6 +1284,16 @@ export class ControlPlaneStreamServer {
1117
1284
  draft: boolean;
1118
1285
  }): Promise<GitHubRemotePullRequest>;
1119
1286
  };
1287
+ private readonly linearApi: {
1288
+ issueByIdentifier(input: { identifier: string }): Promise<{
1289
+ identifier: string;
1290
+ title: string;
1291
+ description: string | null;
1292
+ url: string | null;
1293
+ stateName: string | null;
1294
+ teamKey: string | null;
1295
+ } | null>;
1296
+ };
1120
1297
  private readonly readGitDirectorySnapshot: GitDirectorySnapshotReader;
1121
1298
  private readonly statusEngine = new SessionStatusEngine();
1122
1299
  private readonly promptEngine = new SessionPromptEngine();
@@ -1129,6 +1306,8 @@ export class ControlPlaneStreamServer {
1129
1306
  private telemetryAddress: AddressInfo | null = null;
1130
1307
  private readonly telemetryTokenToSessionId = new Map<string, string>();
1131
1308
  private readonly lifecycleHooks: LifecycleHooksRuntime;
1309
+ private readonly storageLifecycle: StorageLifecycleCore;
1310
+ private readonly storageLifecyclePolicyReload: StorageLifecyclePolicyReloadConfig | null;
1132
1311
  private historyPollTimer: NodeJS.Timeout | null = null;
1133
1312
  private historyPollInFlight = false;
1134
1313
  private historyIdleStreak = 0;
@@ -1142,9 +1321,18 @@ export class ControlPlaneStreamServer {
1142
1321
  private gitStatusPollInFlight = false;
1143
1322
  private githubPollInFlight = false;
1144
1323
  private githubPollPromise: Promise<void> | null = null;
1324
+ private storageLifecycleTimer: NodeJS.Timeout | null = null;
1325
+ private storageLifecyclePolicyReloadTimer: NodeJS.Timeout | null = null;
1326
+ private storageLifecyclePolicyLastKnownGood = DEFAULT_HARNESS_CONFIG;
1327
+ private storageLifecyclePolicyLastError: string | null = null;
1145
1328
  private readonly gitStatusRefreshInFlightDirectoryIds = new Set<string>();
1146
1329
  private readonly gitStatusByDirectoryId = new Map<string, DirectoryGitStatusCacheEntry>();
1147
1330
  private readonly gitStatusDirectoriesById = new Map<string, ControlPlaneDirectoryRecord>();
1331
+ private readonly githubProjectReviewCacheByKey = new Map<string, GitHubProjectReviewCacheEntry>();
1332
+ private readonly githubProjectReviewRefreshInFlightByKey = new Map<
1333
+ string,
1334
+ Promise<GitHubProjectReviewCacheEntry>
1335
+ >();
1148
1336
  private readonly connections = new Map<string, ConnectionState>();
1149
1337
  private readonly sessions = new Map<string, SessionState>();
1150
1338
  private readonly launchCommandBySessionId = new Map<string, string>();
@@ -1173,25 +1361,40 @@ export class ControlPlaneStreamServer {
1173
1361
  this.stateStore = options.stateStore;
1174
1362
  this.ownsStateStore = false;
1175
1363
  } else {
1176
- this.stateStore = new SqliteControlPlaneStore(options.stateStorePath ?? ':memory:');
1364
+ const busyTimeoutMs = options.storageLifecyclePolicy?.busyTimeoutMs;
1365
+ this.stateStore = new SqliteControlPlaneStore(
1366
+ options.stateStorePath ?? ':memory:',
1367
+ busyTimeoutMs === undefined
1368
+ ? undefined
1369
+ : {
1370
+ busyTimeoutMs,
1371
+ },
1372
+ );
1177
1373
  this.ownsStateStore = true;
1178
1374
  }
1179
1375
  this.codexTelemetry = normalizeCodexTelemetryConfig(options.codexTelemetry);
1180
1376
  this.codexHistory = normalizeCodexHistoryConfig(options.codexHistory);
1181
1377
  this.codexLaunch = normalizeCodexLaunchConfig(options.codexLaunch);
1378
+ this.claudeLaunch = normalizeClaudeLaunchConfig(options.claudeLaunch);
1182
1379
  this.critique = normalizeCritiqueConfig(options.critique);
1183
1380
  this.agentInstall = normalizeAgentInstallConfig(options.agentInstall);
1184
1381
  this.cursorLaunch = normalizeCursorLaunchConfig(options.cursorLaunch);
1185
1382
  this.cursorHooks = normalizeCursorHooksConfig(options.cursorHooks);
1186
1383
  this.gitStatusMonitor = normalizeGitStatusMonitorConfig(options.gitStatus);
1187
1384
  this.github = normalizeGitHubIntegrationConfig(options.github);
1385
+ this.linear = normalizeLinearIntegrationConfig(options.linear);
1188
1386
  this.githubExecFile = options.githubExecFile ?? execFile;
1189
1387
  this.githubTokenResolver =
1190
1388
  options.githubTokenResolver ?? (async () => await this.readGhAuthToken());
1191
1389
  this.githubFetch = options.githubFetch ?? fetch;
1192
1390
  this.githubApi = {
1193
- openPullRequestForBranch: async (input) => await this.openGitHubPullRequestForBranch(input),
1194
- createPullRequest: async (input) => await this.createGitHubPullRequest(input),
1391
+ openPullRequestForBranch: this.openGitHubPullRequestForBranch.bind(this),
1392
+ findPullRequestForBranch: this.findGitHubPullRequestForBranch.bind(this),
1393
+ listPullRequestReviewThreads: this.listGitHubPullRequestReviewThreads.bind(this),
1394
+ createPullRequest: this.createGitHubPullRequest.bind(this),
1395
+ };
1396
+ this.linearApi = {
1397
+ issueByIdentifier: this.fetchLinearIssueByIdentifier.bind(this),
1195
1398
  };
1196
1399
  this.readGitDirectorySnapshot =
1197
1400
  options.readGitDirectorySnapshot ??
@@ -1230,14 +1433,22 @@ export class ControlPlaneStreamServer {
1230
1433
  webhooks: [],
1231
1434
  },
1232
1435
  );
1233
- this.server = createServer((socket) => {
1234
- this.handleConnection(socket);
1235
- });
1436
+ this.server = createServer(this.handleConnection.bind(this));
1236
1437
  this.telemetryServer = this.codexTelemetry.enabled
1237
- ? createHttpServer((request, response) => {
1238
- this.handleTelemetryHttpRequest(request, response);
1239
- })
1438
+ ? createHttpServer(this.handleTelemetryHttpRequest.bind(this))
1240
1439
  : null;
1440
+ this.storageLifecycle = new StorageLifecycleCore({
1441
+ telemetryStore: asTelemetryLifecycleStore(this.stateStore),
1442
+ ...(options.storageLifecyclePolicy === undefined
1443
+ ? {}
1444
+ : {
1445
+ policy: options.storageLifecyclePolicy,
1446
+ }),
1447
+ writeStderr: (text) => process.stderr.write(text),
1448
+ });
1449
+ this.storageLifecyclePolicyReload = normalizeStorageLifecyclePolicyReloadConfig(
1450
+ options.storageLifecyclePolicyReload,
1451
+ );
1241
1452
  }
1242
1453
 
1243
1454
  async start(): Promise<void> {
@@ -1268,6 +1479,8 @@ export class ControlPlaneStreamServer {
1268
1479
  this.startHistoryPollingIfEnabled();
1269
1480
  this.startGitStatusPollingIfEnabled();
1270
1481
  this.startGitHubPollingIfEnabled();
1482
+ this.startStorageLifecyclePolling();
1483
+ this.startStorageLifecyclePolicyReloadPolling();
1271
1484
  }
1272
1485
 
1273
1486
  address(): AddressInfo {
@@ -1294,6 +1507,8 @@ export class ControlPlaneStreamServer {
1294
1507
  this.stopHistoryPolling();
1295
1508
  this.stopGitStatusPolling();
1296
1509
  this.stopGitHubPolling();
1510
+ this.stopStorageLifecyclePolling();
1511
+ this.stopStorageLifecyclePolicyReloadPolling();
1297
1512
  await this.waitForGitHubPollingToSettle();
1298
1513
 
1299
1514
  for (const sessionId of [...this.sessions.keys()]) {
@@ -1381,7 +1596,18 @@ export class ControlPlaneStreamServer {
1381
1596
  }
1382
1597
 
1383
1598
  private pollHistoryTimerTick(): void {
1384
- void this.pollHistoryFile();
1599
+ void this.pollHistoryFile().catch((error: unknown) => {
1600
+ if (this.markStateStoreClosedIfDetected(error)) {
1601
+ return;
1602
+ }
1603
+ if (this.shouldSkipStateStoreWork()) {
1604
+ return;
1605
+ }
1606
+ const message = error instanceof Error ? error.message : String(error);
1607
+ recordPerfEvent('control-plane.history.poll.failed', {
1608
+ error: message,
1609
+ });
1610
+ });
1385
1611
  }
1386
1612
 
1387
1613
  private stopHistoryPolling(): void {
@@ -1394,14 +1620,78 @@ export class ControlPlaneStreamServer {
1394
1620
  this.historyNextAllowedPollAtMs = 0;
1395
1621
  }
1396
1622
 
1623
+ updateStorageLifecyclePolicy(policy: Partial<StorageLifecyclePolicy>): void {
1624
+ this.storageLifecycle.updatePolicy(policy);
1625
+ }
1626
+
1627
+ private startStorageLifecyclePolling(): void {
1628
+ // Temporarily disabled while maintenance is moved out of interactive/server hot paths.
1629
+ }
1630
+
1631
+ private stopStorageLifecyclePolling(): void {
1632
+ if (this.storageLifecycleTimer === null) {
1633
+ return;
1634
+ }
1635
+ clearInterval(this.storageLifecycleTimer);
1636
+ this.storageLifecycleTimer = null;
1637
+ }
1638
+
1639
+ private startStorageLifecyclePolicyReloadPolling(): void {
1640
+ if (
1641
+ this.storageLifecyclePolicyReload === null ||
1642
+ this.storageLifecyclePolicyReloadTimer !== null
1643
+ ) {
1644
+ return;
1645
+ }
1646
+ const intervalMs = this.storageLifecyclePolicyReload.pollMs;
1647
+ this.storageLifecyclePolicyReloadTimer = setInterval(() => {
1648
+ this.reloadStorageLifecyclePolicyFromConfig();
1649
+ }, intervalMs);
1650
+ this.storageLifecyclePolicyReloadTimer.unref();
1651
+ }
1652
+
1653
+ private stopStorageLifecyclePolicyReloadPolling(): void {
1654
+ if (this.storageLifecyclePolicyReloadTimer === null) {
1655
+ return;
1656
+ }
1657
+ clearInterval(this.storageLifecyclePolicyReloadTimer);
1658
+ this.storageLifecyclePolicyReloadTimer = null;
1659
+ }
1660
+
1661
+ private reloadStorageLifecyclePolicyFromConfig(): void {
1662
+ if (this.storageLifecyclePolicyReload === null) {
1663
+ return;
1664
+ }
1665
+ const loaded = loadHarnessConfig({
1666
+ cwd: this.storageLifecyclePolicyReload.cwd,
1667
+ ...(this.storageLifecyclePolicyReload.filePath === undefined
1668
+ ? {}
1669
+ : { filePath: this.storageLifecyclePolicyReload.filePath }),
1670
+ ...(this.storageLifecyclePolicyReload.env === undefined
1671
+ ? {}
1672
+ : { env: this.storageLifecyclePolicyReload.env }),
1673
+ lastKnownGood: this.storageLifecyclePolicyLastKnownGood,
1674
+ });
1675
+ this.storageLifecyclePolicyLastKnownGood = loaded.config;
1676
+ if (loaded.fromLastKnownGood && loaded.error !== null) {
1677
+ if (loaded.error !== this.storageLifecyclePolicyLastError) {
1678
+ process.stderr.write(`[config] storage lifecycle policy reload skipped: ${loaded.error}\n`);
1679
+ this.storageLifecyclePolicyLastError = loaded.error;
1680
+ }
1681
+ return;
1682
+ }
1683
+ this.storageLifecyclePolicyLastError = null;
1684
+ this.updateStorageLifecyclePolicy(loaded.config.storage.lifecycle);
1685
+ }
1686
+
1397
1687
  private startGitStatusPollingIfEnabled(): void {
1398
1688
  if (!this.gitStatusMonitor.enabled || this.gitStatusPollTimer !== null) {
1399
1689
  return;
1400
1690
  }
1401
1691
  this.reloadGitStatusDirectoriesFromStore();
1402
- void this.pollGitStatus();
1692
+ this.triggerGitStatusPoll();
1403
1693
  this.gitStatusPollTimer = setInterval(() => {
1404
- void this.pollGitStatus();
1694
+ this.triggerGitStatusPoll();
1405
1695
  }, this.gitStatusMonitor.pollMs);
1406
1696
  this.gitStatusPollTimer.unref();
1407
1697
  }
@@ -1494,6 +1784,9 @@ export class ControlPlaneStreamServer {
1494
1784
 
1495
1785
  private triggerGitHubPoll(): void {
1496
1786
  void this.pollGitHub().catch((error: unknown) => {
1787
+ if (this.markStateStoreClosedIfDetected(error)) {
1788
+ return;
1789
+ }
1497
1790
  if (this.shouldIgnoreGitHubPollError(error)) {
1498
1791
  return;
1499
1792
  }
@@ -1504,10 +1797,41 @@ export class ControlPlaneStreamServer {
1504
1797
  });
1505
1798
  }
1506
1799
 
1800
+ private triggerGitStatusPoll(): void {
1801
+ void this.pollGitStatus().catch((error: unknown) => {
1802
+ if (this.markStateStoreClosedIfDetected(error)) {
1803
+ return;
1804
+ }
1805
+ if (this.shouldSkipStateStoreWork()) {
1806
+ return;
1807
+ }
1808
+ const message = error instanceof Error ? error.message : String(error);
1809
+ recordPerfEvent('control-plane.git-status.poll.failed', {
1810
+ error: message,
1811
+ });
1812
+ });
1813
+ }
1814
+
1507
1815
  private isStateStoreClosedError(error: unknown): boolean {
1508
1816
  const message = error instanceof Error ? error.message : String(error);
1509
1817
  const normalized = message.trim().toLowerCase();
1510
- return normalized.includes('database has closed') || normalized.includes('database is closed');
1818
+ return (
1819
+ normalized.includes('database has closed') ||
1820
+ normalized.includes('database is closed') ||
1821
+ normalized.includes('cannot use a closed database')
1822
+ );
1823
+ }
1824
+
1825
+ private markStateStoreClosedIfDetected(error: unknown): boolean {
1826
+ if (!this.isStateStoreClosedError(error)) {
1827
+ return false;
1828
+ }
1829
+ this.stateStoreClosed = true;
1830
+ this.stopGitHubPolling();
1831
+ this.stopGitStatusPolling();
1832
+ this.stopHistoryPolling();
1833
+ this.stopStorageLifecyclePolling();
1834
+ return true;
1511
1835
  }
1512
1836
 
1513
1837
  private shouldSkipStateStoreWork(): boolean {
@@ -1526,6 +1850,9 @@ export class ControlPlaneStreamServer {
1526
1850
  try {
1527
1851
  await pollPromise;
1528
1852
  } catch (error: unknown) {
1853
+ if (this.markStateStoreClosedIfDetected(error)) {
1854
+ return;
1855
+ }
1529
1856
  if (!this.shouldIgnoreGitHubPollError(error)) {
1530
1857
  const message = error instanceof Error ? error.message : String(error);
1531
1858
  recordPerfEvent('control-plane.github.poll.failed-on-close', {
@@ -1720,6 +2047,8 @@ export class ControlPlaneStreamServer {
1720
2047
  directoryPath: directory?.path ?? null,
1721
2048
  codexLaunchDefaultMode: this.codexLaunch.defaultMode,
1722
2049
  codexLaunchModeByDirectoryPath: this.codexLaunch.directoryModes,
2050
+ claudeLaunchDefaultMode: this.claudeLaunch.defaultMode,
2051
+ claudeLaunchModeByDirectoryPath: this.claudeLaunch.directoryModes,
1723
2052
  cursorLaunchDefaultMode: this.cursorLaunch.defaultMode,
1724
2053
  cursorLaunchModeByDirectoryPath: this.cursorLaunch.directoryModes,
1725
2054
  });
@@ -1912,6 +2241,39 @@ export class ControlPlaneStreamServer {
1912
2241
  }
1913
2242
 
1914
2243
  private handleTelemetryHttpRequest(request: IncomingMessage, response: ServerResponse): void {
2244
+ const requestWithEvents = request as unknown as {
2245
+ on?: (event: string, listener: (...args: unknown[]) => void) => void;
2246
+ once?: (event: string, listener: (...args: unknown[]) => void) => void;
2247
+ off?: (event: string, listener: (...args: unknown[]) => void) => void;
2248
+ };
2249
+ const responseWithEvents = response as unknown as {
2250
+ on?: (event: string, listener: (...args: unknown[]) => void) => void;
2251
+ once?: (event: string, listener: (...args: unknown[]) => void) => void;
2252
+ off?: (event: string, listener: (...args: unknown[]) => void) => void;
2253
+ };
2254
+ const onStreamError = (error: unknown): void => {
2255
+ if (isTelemetryRequestAbortError(error)) {
2256
+ return;
2257
+ }
2258
+ if (response.writableEnded) {
2259
+ return;
2260
+ }
2261
+ response.statusCode = 500;
2262
+ response.end();
2263
+ };
2264
+ const cleanupStreamErrorListeners = (): void => {
2265
+ requestWithEvents.off?.('error', onStreamError);
2266
+ responseWithEvents.off?.('error', onStreamError);
2267
+ requestWithEvents.off?.('close', cleanupStreamErrorListeners);
2268
+ responseWithEvents.off?.('close', cleanupStreamErrorListeners);
2269
+ responseWithEvents.off?.('finish', cleanupStreamErrorListeners);
2270
+ };
2271
+ requestWithEvents.on?.('error', onStreamError);
2272
+ responseWithEvents.on?.('error', onStreamError);
2273
+ requestWithEvents.once?.('close', cleanupStreamErrorListeners);
2274
+ responseWithEvents.once?.('close', cleanupStreamErrorListeners);
2275
+ responseWithEvents.once?.('finish', cleanupStreamErrorListeners);
2276
+
1915
2277
  void this.handleTelemetryHttpRequestAsync(request, response).catch((error: unknown) => {
1916
2278
  if (isTelemetryRequestAbortError(error)) {
1917
2279
  return;
@@ -2054,6 +2416,7 @@ export class ControlPlaneStreamServer {
2054
2416
  observedAt: event.observedAt,
2055
2417
  payload: event.payload,
2056
2418
  });
2419
+ const persistedPayload = this.storageLifecycle.prepareTelemetryPayload(event.payload);
2057
2420
 
2058
2421
  const inserted = this.stateStore.appendTelemetry({
2059
2422
  source: event.source,
@@ -2063,7 +2426,7 @@ export class ControlPlaneStreamServer {
2063
2426
  severity: event.severity,
2064
2427
  summary: event.summary,
2065
2428
  observedAt: event.observedAt,
2066
- payload: event.payload,
2429
+ payload: persistedPayload,
2067
2430
  fingerprint,
2068
2431
  });
2069
2432
  if (resolvedSessionId !== null) {
@@ -2205,9 +2568,15 @@ export class ControlPlaneStreamServer {
2205
2568
  }
2206
2569
 
2207
2570
  private async pollHistoryFile(): Promise<void> {
2208
- await pollStreamServerHistoryFile(
2209
- this as unknown as Parameters<typeof pollStreamServerHistoryFile>[0],
2210
- );
2571
+ try {
2572
+ await pollStreamServerHistoryFile(
2573
+ this as unknown as Parameters<typeof pollStreamServerHistoryFile>[0],
2574
+ );
2575
+ } catch (error: unknown) {
2576
+ if (!this.markStateStoreClosedIfDetected(error)) {
2577
+ throw error;
2578
+ }
2579
+ }
2211
2580
  }
2212
2581
 
2213
2582
  private async pollHistoryFileUnsafe(): Promise<boolean> {
@@ -2217,9 +2586,15 @@ export class ControlPlaneStreamServer {
2217
2586
  }
2218
2587
 
2219
2588
  private async pollGitStatus(): Promise<void> {
2220
- await pollStreamServerGitStatus(
2221
- this as unknown as Parameters<typeof pollStreamServerGitStatus>[0],
2222
- );
2589
+ try {
2590
+ await pollStreamServerGitStatus(
2591
+ this as unknown as Parameters<typeof pollStreamServerGitStatus>[0],
2592
+ );
2593
+ } catch (error: unknown) {
2594
+ if (!this.markStateStoreClosedIfDetected(error)) {
2595
+ throw error;
2596
+ }
2597
+ }
2223
2598
  }
2224
2599
 
2225
2600
  private async refreshGitStatusForDirectory(
@@ -2314,6 +2689,10 @@ export class ControlPlaneStreamServer {
2314
2689
  this.githubPollPromise = pollPromise;
2315
2690
  try {
2316
2691
  await pollPromise;
2692
+ } catch (error: unknown) {
2693
+ if (!this.markStateStoreClosedIfDetected(error)) {
2694
+ throw error;
2695
+ }
2317
2696
  } finally {
2318
2697
  if (this.githubPollPromise === pollPromise) {
2319
2698
  this.githubPollPromise = null;
@@ -2322,6 +2701,200 @@ export class ControlPlaneStreamServer {
2322
2701
  }
2323
2702
  }
2324
2703
 
2704
+ private githubProjectReviewCacheKey(input: { repositoryId: string; branchName: string }): string {
2705
+ return `${input.repositoryId}:${input.branchName}`;
2706
+ }
2707
+
2708
+ private getGitHubProjectReviewCache(input: { repositoryId: string; branchName: string }): {
2709
+ repositoryId: string;
2710
+ branchName: string;
2711
+ pr: {
2712
+ number: number;
2713
+ title: string;
2714
+ url: string;
2715
+ authorLogin: string | null;
2716
+ headBranch: string;
2717
+ headSha: string;
2718
+ baseBranch: string;
2719
+ state: 'draft' | 'open' | 'merged' | 'closed';
2720
+ isDraft: boolean;
2721
+ mergedAt: string | null;
2722
+ closedAt: string | null;
2723
+ updatedAt: string;
2724
+ createdAt: string;
2725
+ } | null;
2726
+ openThreads: readonly {
2727
+ threadId: string;
2728
+ isResolved: boolean;
2729
+ isOutdated: boolean;
2730
+ resolvedByLogin: string | null;
2731
+ comments: readonly {
2732
+ commentId: string;
2733
+ authorLogin: string | null;
2734
+ body: string;
2735
+ url: string | null;
2736
+ createdAt: string;
2737
+ updatedAt: string;
2738
+ }[];
2739
+ }[];
2740
+ resolvedThreads: readonly {
2741
+ threadId: string;
2742
+ isResolved: boolean;
2743
+ isOutdated: boolean;
2744
+ resolvedByLogin: string | null;
2745
+ comments: readonly {
2746
+ commentId: string;
2747
+ authorLogin: string | null;
2748
+ body: string;
2749
+ url: string | null;
2750
+ createdAt: string;
2751
+ updatedAt: string;
2752
+ }[];
2753
+ }[];
2754
+ fetchedAtMs: number;
2755
+ } | null {
2756
+ const key = this.githubProjectReviewCacheKey(input);
2757
+ const cached = this.githubProjectReviewCacheByKey.get(key);
2758
+ return cached ?? null;
2759
+ }
2760
+
2761
+ private async refreshGitHubProjectReviewCache(input: {
2762
+ repositoryId: string;
2763
+ owner: string;
2764
+ repo: string;
2765
+ branchName: string;
2766
+ forceRefresh?: boolean;
2767
+ remotePr?: GitHubRemotePullRequest | null;
2768
+ }): Promise<{
2769
+ repositoryId: string;
2770
+ branchName: string;
2771
+ pr: {
2772
+ number: number;
2773
+ title: string;
2774
+ url: string;
2775
+ authorLogin: string | null;
2776
+ headBranch: string;
2777
+ headSha: string;
2778
+ baseBranch: string;
2779
+ state: 'draft' | 'open' | 'merged' | 'closed';
2780
+ isDraft: boolean;
2781
+ mergedAt: string | null;
2782
+ closedAt: string | null;
2783
+ updatedAt: string;
2784
+ createdAt: string;
2785
+ } | null;
2786
+ openThreads: readonly {
2787
+ threadId: string;
2788
+ isResolved: boolean;
2789
+ isOutdated: boolean;
2790
+ resolvedByLogin: string | null;
2791
+ comments: readonly {
2792
+ commentId: string;
2793
+ authorLogin: string | null;
2794
+ body: string;
2795
+ url: string | null;
2796
+ createdAt: string;
2797
+ updatedAt: string;
2798
+ }[];
2799
+ }[];
2800
+ resolvedThreads: readonly {
2801
+ threadId: string;
2802
+ isResolved: boolean;
2803
+ isOutdated: boolean;
2804
+ resolvedByLogin: string | null;
2805
+ comments: readonly {
2806
+ commentId: string;
2807
+ authorLogin: string | null;
2808
+ body: string;
2809
+ url: string | null;
2810
+ createdAt: string;
2811
+ updatedAt: string;
2812
+ }[];
2813
+ }[];
2814
+ fetchedAtMs: number;
2815
+ }> {
2816
+ const key = this.githubProjectReviewCacheKey(input);
2817
+ const forceRefresh = input.forceRefresh === true;
2818
+ const cached = this.githubProjectReviewCacheByKey.get(key);
2819
+ if (
2820
+ !forceRefresh &&
2821
+ cached !== undefined &&
2822
+ Date.now() - cached.fetchedAtMs < DEFAULT_GITHUB_PROJECT_REVIEW_PREWARM_INTERVAL_MS
2823
+ ) {
2824
+ return cached;
2825
+ }
2826
+ const inFlight = this.githubProjectReviewRefreshInFlightByKey.get(key);
2827
+ if (inFlight !== undefined) {
2828
+ return await inFlight;
2829
+ }
2830
+ const refreshPromise: Promise<GitHubProjectReviewCacheEntry> = (async () => {
2831
+ const remotePr =
2832
+ input.remotePr !== undefined
2833
+ ? input.remotePr
2834
+ : await this.openGitHubPullRequestForBranch({
2835
+ owner: input.owner,
2836
+ repo: input.repo,
2837
+ headBranch: input.branchName,
2838
+ });
2839
+ if (remotePr === null) {
2840
+ const next: GitHubProjectReviewCacheEntry = {
2841
+ repositoryId: input.repositoryId,
2842
+ branchName: input.branchName,
2843
+ pr: null,
2844
+ openThreads: [],
2845
+ resolvedThreads: [],
2846
+ fetchedAtMs: Date.now(),
2847
+ };
2848
+ this.githubProjectReviewCacheByKey.set(key, next);
2849
+ return next;
2850
+ }
2851
+ const reviewThreads = await this.listGitHubPullRequestReviewThreads({
2852
+ owner: input.owner,
2853
+ repo: input.repo,
2854
+ pullNumber: remotePr.number,
2855
+ });
2856
+ const next: GitHubProjectReviewCacheEntry = {
2857
+ repositoryId: input.repositoryId,
2858
+ branchName: input.branchName,
2859
+ pr: {
2860
+ number: remotePr.number,
2861
+ title: remotePr.title,
2862
+ url: remotePr.url,
2863
+ authorLogin: remotePr.authorLogin,
2864
+ headBranch: remotePr.headBranch,
2865
+ headSha: remotePr.headSha,
2866
+ baseBranch: remotePr.baseBranch,
2867
+ state:
2868
+ remotePr.state === 'open' && remotePr.isDraft
2869
+ ? 'draft'
2870
+ : remotePr.state === 'open'
2871
+ ? 'open'
2872
+ : remotePr.mergedAt !== null
2873
+ ? 'merged'
2874
+ : 'closed',
2875
+ isDraft: remotePr.isDraft,
2876
+ mergedAt: remotePr.mergedAt,
2877
+ closedAt: remotePr.closedAt,
2878
+ updatedAt: remotePr.updatedAt,
2879
+ createdAt: remotePr.createdAt,
2880
+ },
2881
+ openThreads: reviewThreads.filter((thread) => !thread.isResolved),
2882
+ resolvedThreads: reviewThreads.filter((thread) => thread.isResolved),
2883
+ fetchedAtMs: Date.now(),
2884
+ };
2885
+ this.githubProjectReviewCacheByKey.set(key, next);
2886
+ return next;
2887
+ })();
2888
+ this.githubProjectReviewRefreshInFlightByKey.set(key, refreshPromise);
2889
+ try {
2890
+ return await refreshPromise;
2891
+ } finally {
2892
+ if (this.githubProjectReviewRefreshInFlightByKey.get(key) === refreshPromise) {
2893
+ this.githubProjectReviewRefreshInFlightByKey.delete(key);
2894
+ }
2895
+ }
2896
+ }
2897
+
2325
2898
  private async syncGitHubBranch(input: {
2326
2899
  directory: ControlPlaneDirectoryRecord;
2327
2900
  repository: ControlPlaneRepositoryRecord;
@@ -2344,6 +2917,20 @@ export class ControlPlaneStreamServer {
2344
2917
  return;
2345
2918
  }
2346
2919
  if (remotePr === null) {
2920
+ this.githubProjectReviewCacheByKey.set(
2921
+ this.githubProjectReviewCacheKey({
2922
+ repositoryId: input.repository.repositoryId,
2923
+ branchName: input.branchName,
2924
+ }),
2925
+ {
2926
+ repositoryId: input.repository.repositoryId,
2927
+ branchName: input.branchName,
2928
+ pr: null,
2929
+ openThreads: [],
2930
+ resolvedThreads: [],
2931
+ fetchedAtMs: Date.now(),
2932
+ },
2933
+ );
2347
2934
  const staleOpen = this.stateStore.listGitHubPullRequests({
2348
2935
  repositoryId: input.repository.repositoryId,
2349
2936
  headBranch: input.branchName,
@@ -2424,6 +3011,13 @@ export class ControlPlaneStreamServer {
2424
3011
  isDraft: remotePr.isDraft,
2425
3012
  observedAt: remotePr.updatedAt || now,
2426
3013
  });
3014
+ void this.refreshGitHubProjectReviewCache({
3015
+ repositoryId: input.repository.repositoryId,
3016
+ owner: input.owner,
3017
+ repo: input.repo,
3018
+ branchName: input.branchName,
3019
+ remotePr,
3020
+ }).catch(() => {});
2427
3021
  const jobs = await this.listGitHubPrJobsForCommit({
2428
3022
  owner: input.owner,
2429
3023
  repo: input.repo,
@@ -2491,6 +3085,9 @@ export class ControlPlaneStreamServer {
2491
3085
  lastErrorAt: null,
2492
3086
  });
2493
3087
  } catch (error: unknown) {
3088
+ if (this.markStateStoreClosedIfDetected(error)) {
3089
+ return;
3090
+ }
2494
3091
  if (this.shouldIgnoreGitHubPollError(error)) {
2495
3092
  return;
2496
3093
  }
@@ -2510,6 +3107,9 @@ export class ControlPlaneStreamServer {
2510
3107
  lastErrorAt: now,
2511
3108
  });
2512
3109
  } catch (syncStateError: unknown) {
3110
+ if (this.markStateStoreClosedIfDetected(syncStateError)) {
3111
+ return;
3112
+ }
2513
3113
  if (!this.shouldIgnoreGitHubPollError(syncStateError)) {
2514
3114
  throw syncStateError;
2515
3115
  }
@@ -2527,8 +3127,8 @@ export class ControlPlaneStreamServer {
2527
3127
  if (token === null) {
2528
3128
  const hint =
2529
3129
  this.githubTokenResolutionError === null
2530
- ? 'set GITHUB_TOKEN or run gh auth login'
2531
- : `${this.githubTokenResolutionError}; set GITHUB_TOKEN or run gh auth login`;
3130
+ ? `set ${this.github.tokenEnvVar} or ${GITHUB_OAUTH_ACCESS_TOKEN_ENV_VAR} or run gh auth login`
3131
+ : `${this.githubTokenResolutionError}; set ${this.github.tokenEnvVar} or ${GITHUB_OAUTH_ACCESS_TOKEN_ENV_VAR} or run gh auth login`;
2532
3132
  throw new Error(`github token not configured: ${hint}`);
2533
3133
  }
2534
3134
  const response = await this.githubFetch(`${this.github.apiBaseUrl}${path}`, {
@@ -2549,6 +3149,82 @@ export class ControlPlaneStreamServer {
2549
3149
  return await response.json();
2550
3150
  }
2551
3151
 
3152
+ private async fetchLinearIssueByIdentifier(input: { identifier: string }): Promise<{
3153
+ identifier: string;
3154
+ title: string;
3155
+ description: string | null;
3156
+ url: string | null;
3157
+ stateName: string | null;
3158
+ teamKey: string | null;
3159
+ } | null> {
3160
+ const token = this.linear.token;
3161
+ if (token === null || token.trim().length === 0) {
3162
+ throw new Error(
3163
+ `linear token not configured: set ${this.linear.tokenEnvVar} or ${LINEAR_OAUTH_ACCESS_TOKEN_ENV_VAR}`,
3164
+ );
3165
+ }
3166
+ const client = new LinearClient({
3167
+ apiKey: token,
3168
+ apiUrl: this.linear.apiBaseUrl,
3169
+ });
3170
+ const response = await client.client.rawRequest<
3171
+ {
3172
+ issues?: {
3173
+ nodes?: Array<{
3174
+ identifier?: string;
3175
+ title?: string;
3176
+ description?: string | null;
3177
+ url?: string | null;
3178
+ state?: {
3179
+ name?: string | null;
3180
+ } | null;
3181
+ team?: {
3182
+ key?: string | null;
3183
+ } | null;
3184
+ }>;
3185
+ };
3186
+ },
3187
+ { identifier: string }
3188
+ >(
3189
+ `
3190
+ query HarnessLinearIssueImport($identifier: String!) {
3191
+ issues(filter: { identifier: { eq: $identifier } }, first: 1) {
3192
+ nodes {
3193
+ identifier
3194
+ title
3195
+ description
3196
+ url
3197
+ state {
3198
+ name
3199
+ }
3200
+ team {
3201
+ key
3202
+ }
3203
+ }
3204
+ }
3205
+ }
3206
+ `,
3207
+ {
3208
+ identifier: input.identifier,
3209
+ },
3210
+ );
3211
+ const issue = response.data?.issues?.nodes?.[0];
3212
+ if (issue === undefined) {
3213
+ return null;
3214
+ }
3215
+ if (typeof issue.identifier !== 'string' || typeof issue.title !== 'string') {
3216
+ throw new Error('linear issue response malformed');
3217
+ }
3218
+ return {
3219
+ identifier: issue.identifier,
3220
+ title: issue.title,
3221
+ description: typeof issue.description === 'string' ? issue.description : null,
3222
+ url: typeof issue.url === 'string' ? issue.url : null,
3223
+ stateName: typeof issue.state?.name === 'string' ? issue.state.name : null,
3224
+ teamKey: typeof issue.team?.key === 'string' ? issue.team.key : null,
3225
+ };
3226
+ }
3227
+
2552
3228
  private parseGitHubPullRequest(value: unknown): GitHubRemotePullRequest | null {
2553
3229
  if (typeof value !== 'object' || value === null || Array.isArray(value)) {
2554
3230
  return null;
@@ -2565,6 +3241,8 @@ export class ControlPlaneStreamServer {
2565
3241
  const updatedAt = record['updated_at'];
2566
3242
  const createdAt = record['created_at'];
2567
3243
  const closedAt = record['closed_at'];
3244
+ const mergedAtRaw = record['merged_at'];
3245
+ const mergedAt = mergedAtRaw === undefined ? null : mergedAtRaw;
2568
3246
  if (
2569
3247
  typeof number !== 'number' ||
2570
3248
  typeof title !== 'string' ||
@@ -2574,6 +3252,7 @@ export class ControlPlaneStreamServer {
2574
3252
  typeof updatedAt !== 'string' ||
2575
3253
  typeof createdAt !== 'string' ||
2576
3254
  (closedAt !== null && typeof closedAt !== 'string') ||
3255
+ (mergedAt !== null && typeof mergedAt !== 'string') ||
2577
3256
  typeof head !== 'object' ||
2578
3257
  head === null ||
2579
3258
  Array.isArray(head) ||
@@ -2608,6 +3287,7 @@ export class ControlPlaneStreamServer {
2608
3287
  baseBranch: baseRef,
2609
3288
  state,
2610
3289
  isDraft: draft,
3290
+ mergedAt: mergedAt as string | null,
2611
3291
  updatedAt,
2612
3292
  createdAt,
2613
3293
  closedAt: closedAt as string | null,
@@ -2628,6 +3308,204 @@ export class ControlPlaneStreamServer {
2628
3308
  return this.parseGitHubPullRequest(payload[0]);
2629
3309
  }
2630
3310
 
3311
+ private async findGitHubPullRequestForBranch(input: {
3312
+ owner: string;
3313
+ repo: string;
3314
+ headBranch: string;
3315
+ }): Promise<GitHubRemotePullRequest | null> {
3316
+ const head = encodeURIComponent(`${input.owner}:${input.headBranch}`);
3317
+ const path = `/repos/${encodeURIComponent(input.owner)}/${encodeURIComponent(input.repo)}/pulls?state=all&head=${head}&per_page=10&sort=updated&direction=desc`;
3318
+ const payload = await this.githubJsonRequest(path);
3319
+ if (!Array.isArray(payload) || payload.length === 0) {
3320
+ return null;
3321
+ }
3322
+ for (const value of payload) {
3323
+ const parsed = this.parseGitHubPullRequest(value);
3324
+ if (parsed !== null) {
3325
+ return parsed;
3326
+ }
3327
+ }
3328
+ return null;
3329
+ }
3330
+
3331
+ private async listGitHubPullRequestReviewThreads(input: {
3332
+ owner: string;
3333
+ repo: string;
3334
+ pullNumber: number;
3335
+ }): Promise<readonly GitHubRemotePrReviewThread[]> {
3336
+ const payload = await this.githubJsonRequest('/graphql', {
3337
+ method: 'POST',
3338
+ headers: {
3339
+ 'Content-Type': 'application/json',
3340
+ },
3341
+ body: JSON.stringify({
3342
+ query: `
3343
+ query HarnessPullRequestReviewThreads($owner: String!, $repo: String!, $number: Int!) {
3344
+ repository(owner: $owner, name: $repo) {
3345
+ pullRequest(number: $number) {
3346
+ reviewThreads(first: 100) {
3347
+ nodes {
3348
+ id
3349
+ isResolved
3350
+ isOutdated
3351
+ resolvedBy {
3352
+ login
3353
+ }
3354
+ comments(first: 100) {
3355
+ nodes {
3356
+ id
3357
+ body
3358
+ bodyText
3359
+ url
3360
+ createdAt
3361
+ updatedAt
3362
+ author {
3363
+ login
3364
+ }
3365
+ }
3366
+ }
3367
+ }
3368
+ }
3369
+ }
3370
+ }
3371
+ }
3372
+ `,
3373
+ variables: {
3374
+ owner: input.owner,
3375
+ repo: input.repo,
3376
+ number: input.pullNumber,
3377
+ },
3378
+ }),
3379
+ });
3380
+ if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) {
3381
+ throw new Error('github graphql review threads response malformed');
3382
+ }
3383
+ const payloadRecord = payload as Record<string, unknown>;
3384
+ const errors = payloadRecord['errors'];
3385
+ if (Array.isArray(errors) && errors.length > 0) {
3386
+ const firstError = errors[0];
3387
+ if (typeof firstError === 'object' && firstError !== null && !Array.isArray(firstError)) {
3388
+ const message = (firstError as Record<string, unknown>)['message'];
3389
+ if (typeof message === 'string' && message.trim().length > 0) {
3390
+ throw new Error(`github graphql review threads failed: ${message}`);
3391
+ }
3392
+ }
3393
+ throw new Error('github graphql review threads failed');
3394
+ }
3395
+ const data =
3396
+ typeof payloadRecord['data'] === 'object' &&
3397
+ payloadRecord['data'] !== null &&
3398
+ !Array.isArray(payloadRecord['data'])
3399
+ ? (payloadRecord['data'] as Record<string, unknown>)
3400
+ : null;
3401
+ const repository =
3402
+ data !== null &&
3403
+ typeof data['repository'] === 'object' &&
3404
+ data['repository'] !== null &&
3405
+ !Array.isArray(data['repository'])
3406
+ ? (data['repository'] as Record<string, unknown>)
3407
+ : null;
3408
+ const pullRequest =
3409
+ repository !== null &&
3410
+ typeof repository['pullRequest'] === 'object' &&
3411
+ repository['pullRequest'] !== null &&
3412
+ !Array.isArray(repository['pullRequest'])
3413
+ ? (repository['pullRequest'] as Record<string, unknown>)
3414
+ : null;
3415
+ const reviewThreads =
3416
+ pullRequest !== null &&
3417
+ typeof pullRequest['reviewThreads'] === 'object' &&
3418
+ pullRequest['reviewThreads'] !== null &&
3419
+ !Array.isArray(pullRequest['reviewThreads'])
3420
+ ? (pullRequest['reviewThreads'] as Record<string, unknown>)
3421
+ : null;
3422
+ const threadNodes = reviewThreads?.['nodes'];
3423
+ if (!Array.isArray(threadNodes)) {
3424
+ return [];
3425
+ }
3426
+ const threads: GitHubRemotePrReviewThread[] = [];
3427
+ for (const threadRaw of threadNodes) {
3428
+ if (typeof threadRaw !== 'object' || threadRaw === null || Array.isArray(threadRaw)) {
3429
+ continue;
3430
+ }
3431
+ const thread = threadRaw as Record<string, unknown>;
3432
+ const threadId = thread['id'];
3433
+ const isResolved = thread['isResolved'];
3434
+ const isOutdated = thread['isOutdated'];
3435
+ const resolvedBy =
3436
+ typeof thread['resolvedBy'] === 'object' &&
3437
+ thread['resolvedBy'] !== null &&
3438
+ !Array.isArray(thread['resolvedBy'])
3439
+ ? (thread['resolvedBy'] as Record<string, unknown>)
3440
+ : null;
3441
+ const resolvedByLogin =
3442
+ resolvedBy !== null && typeof resolvedBy['login'] === 'string' ? resolvedBy['login'] : null;
3443
+ const commentsRecord =
3444
+ typeof thread['comments'] === 'object' &&
3445
+ thread['comments'] !== null &&
3446
+ !Array.isArray(thread['comments'])
3447
+ ? (thread['comments'] as Record<string, unknown>)
3448
+ : null;
3449
+ const commentsNodes = commentsRecord?.['nodes'];
3450
+ if (
3451
+ typeof threadId !== 'string' ||
3452
+ typeof isResolved !== 'boolean' ||
3453
+ typeof isOutdated !== 'boolean' ||
3454
+ !Array.isArray(commentsNodes)
3455
+ ) {
3456
+ continue;
3457
+ }
3458
+ const comments: GitHubRemotePrReviewComment[] = [];
3459
+ for (const commentRaw of commentsNodes) {
3460
+ if (typeof commentRaw !== 'object' || commentRaw === null || Array.isArray(commentRaw)) {
3461
+ continue;
3462
+ }
3463
+ const comment = commentRaw as Record<string, unknown>;
3464
+ const commentId = comment['id'];
3465
+ const bodyText = comment['bodyText'];
3466
+ const body = comment['body'];
3467
+ const url = comment['url'];
3468
+ const createdAt = comment['createdAt'];
3469
+ const updatedAt = comment['updatedAt'];
3470
+ const author =
3471
+ typeof comment['author'] === 'object' &&
3472
+ comment['author'] !== null &&
3473
+ !Array.isArray(comment['author'])
3474
+ ? (comment['author'] as Record<string, unknown>)
3475
+ : null;
3476
+ const authorLogin =
3477
+ author !== null && typeof author['login'] === 'string' ? author['login'] : null;
3478
+ const normalizedBody =
3479
+ typeof bodyText === 'string' ? bodyText : typeof body === 'string' ? body : null;
3480
+ if (
3481
+ typeof commentId !== 'string' ||
3482
+ normalizedBody === null ||
3483
+ (url !== null && url !== undefined && typeof url !== 'string') ||
3484
+ typeof createdAt !== 'string' ||
3485
+ typeof updatedAt !== 'string'
3486
+ ) {
3487
+ continue;
3488
+ }
3489
+ comments.push({
3490
+ commentId,
3491
+ authorLogin,
3492
+ body: normalizedBody,
3493
+ url: typeof url === 'string' ? url : null,
3494
+ createdAt,
3495
+ updatedAt,
3496
+ });
3497
+ }
3498
+ threads.push({
3499
+ threadId,
3500
+ isResolved,
3501
+ isOutdated,
3502
+ resolvedByLogin,
3503
+ comments,
3504
+ });
3505
+ }
3506
+ return threads;
3507
+ }
3508
+
2631
3509
  private async createGitHubPullRequest(input: {
2632
3510
  owner: string;
2633
3511
  repo: string;
@@ -3318,7 +4196,7 @@ export class ControlPlaneStreamServer {
3318
4196
  scopeKind: task.scopeKind,
3319
4197
  projectId: task.projectId,
3320
4198
  title: task.title,
3321
- description: task.description,
4199
+ body: task.body,
3322
4200
  status: task.status,
3323
4201
  orderIndex: task.orderIndex,
3324
4202
  claimedByControllerId: task.claimedByControllerId,
@@ -3327,21 +4205,6 @@ export class ControlPlaneStreamServer {
3327
4205
  baseBranch: task.baseBranch,
3328
4206
  claimedAt: task.claimedAt,
3329
4207
  completedAt: task.completedAt,
3330
- linear: {
3331
- issueId: task.linear.issueId,
3332
- identifier: task.linear.identifier,
3333
- url: task.linear.url,
3334
- teamId: task.linear.teamId,
3335
- projectId: task.linear.projectId,
3336
- projectMilestoneId: task.linear.projectMilestoneId,
3337
- cycleId: task.linear.cycleId,
3338
- stateId: task.linear.stateId,
3339
- assigneeId: task.linear.assigneeId,
3340
- priority: task.linear.priority,
3341
- estimate: task.linear.estimate,
3342
- dueDate: task.linear.dueDate,
3343
- labelIds: [...task.linear.labelIds],
3344
- },
3345
4208
  createdAt: task.createdAt,
3346
4209
  updatedAt: task.updatedAt,
3347
4210
  };