@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.
Files changed (214) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +145 -0
  3. package/native/ptyd/Cargo.lock +16 -0
  4. package/native/ptyd/Cargo.toml +7 -0
  5. package/native/ptyd/src/main.rs +257 -0
  6. package/package.json +90 -0
  7. package/scripts/build-ptyd.sh +73 -0
  8. package/scripts/control-plane-daemon.ts +277 -0
  9. package/scripts/cursor-hook-relay.ts +82 -0
  10. package/scripts/harness-animate.ts +469 -0
  11. package/scripts/harness-bin.js +77 -0
  12. package/scripts/harness-core.ts +1 -0
  13. package/scripts/harness-inspector.ts +439 -0
  14. package/scripts/harness.ts +2493 -0
  15. package/src/adapters/agent-session-state.ts +390 -0
  16. package/src/cli/gateway-record.ts +173 -0
  17. package/src/codex/live-session.ts +872 -0
  18. package/src/config/config-core.ts +1359 -0
  19. package/src/config/secrets-core.ts +170 -0
  20. package/src/control-plane/agent-realtime-api.ts +2441 -0
  21. package/src/control-plane/codex-session-stream.ts +392 -0
  22. package/src/control-plane/codex-telemetry.ts +1325 -0
  23. package/src/control-plane/lifecycle-hooks.ts +706 -0
  24. package/src/control-plane/session-summary.ts +380 -0
  25. package/src/control-plane/status/agent-status-reducer.ts +21 -0
  26. package/src/control-plane/status/reducer-base.ts +170 -0
  27. package/src/control-plane/status/reducers/claude-status-reducer.ts +37 -0
  28. package/src/control-plane/status/reducers/codex-status-reducer.ts +48 -0
  29. package/src/control-plane/status/reducers/critique-status-reducer.ts +15 -0
  30. package/src/control-plane/status/reducers/cursor-status-reducer.ts +37 -0
  31. package/src/control-plane/status/reducers/terminal-status-reducer.ts +15 -0
  32. package/src/control-plane/status/session-status-engine.ts +76 -0
  33. package/src/control-plane/stream-client.ts +396 -0
  34. package/src/control-plane/stream-command-parser.ts +1673 -0
  35. package/src/control-plane/stream-protocol.ts +1808 -0
  36. package/src/control-plane/stream-server-background.ts +486 -0
  37. package/src/control-plane/stream-server-command.ts +2557 -0
  38. package/src/control-plane/stream-server-connection.ts +234 -0
  39. package/src/control-plane/stream-server-observed-filter.ts +112 -0
  40. package/src/control-plane/stream-server-session-runtime.ts +566 -0
  41. package/src/control-plane/stream-server-state-store.ts +15 -0
  42. package/src/control-plane/stream-server.ts +3192 -0
  43. package/src/cursor/managed-hooks.ts +282 -0
  44. package/src/domain/conversations.ts +414 -0
  45. package/src/domain/directories.ts +78 -0
  46. package/src/domain/repositories.ts +123 -0
  47. package/src/domain/tasks.ts +148 -0
  48. package/src/domain/workspace.ts +156 -0
  49. package/src/events/normalized-events.ts +124 -0
  50. package/src/mux/ansi-integrity.ts +103 -0
  51. package/src/mux/control-plane-op-queue.ts +212 -0
  52. package/src/mux/conversation-rail.ts +339 -0
  53. package/src/mux/double-click.ts +78 -0
  54. package/src/mux/dual-pane-core.ts +435 -0
  55. package/src/mux/harness-core-ui.ts +817 -0
  56. package/src/mux/input-shortcuts.ts +667 -0
  57. package/src/mux/live-mux/actions-conversation.ts +344 -0
  58. package/src/mux/live-mux/actions-repository.ts +246 -0
  59. package/src/mux/live-mux/actions-task.ts +115 -0
  60. package/src/mux/live-mux/args.ts +142 -0
  61. package/src/mux/live-mux/command-menu.ts +298 -0
  62. package/src/mux/live-mux/control-plane-records.ts +546 -0
  63. package/src/mux/live-mux/conversation-state.ts +188 -0
  64. package/src/mux/live-mux/directory-resolution.ts +34 -0
  65. package/src/mux/live-mux/event-mapping.ts +96 -0
  66. package/src/mux/live-mux/gateway-profiler.ts +152 -0
  67. package/src/mux/live-mux/gateway-render-trace.ts +177 -0
  68. package/src/mux/live-mux/gateway-status-timeline.ts +166 -0
  69. package/src/mux/live-mux/git-parsing.ts +131 -0
  70. package/src/mux/live-mux/git-snapshot.ts +263 -0
  71. package/src/mux/live-mux/git-state.ts +136 -0
  72. package/src/mux/live-mux/global-shortcut-handlers.ts +143 -0
  73. package/src/mux/live-mux/home-pane-actions.ts +58 -0
  74. package/src/mux/live-mux/home-pane-drop.ts +44 -0
  75. package/src/mux/live-mux/home-pane-entity-click.ts +96 -0
  76. package/src/mux/live-mux/home-pane-pointer.ts +96 -0
  77. package/src/mux/live-mux/input-forwarding.ts +112 -0
  78. package/src/mux/live-mux/layout.ts +30 -0
  79. package/src/mux/live-mux/left-nav-activation.ts +103 -0
  80. package/src/mux/live-mux/left-nav.ts +85 -0
  81. package/src/mux/live-mux/left-rail-actions.ts +118 -0
  82. package/src/mux/live-mux/left-rail-conversation-click.ts +82 -0
  83. package/src/mux/live-mux/left-rail-pointer.ts +74 -0
  84. package/src/mux/live-mux/modal-command-menu-handler.ts +101 -0
  85. package/src/mux/live-mux/modal-conversation-handlers.ts +217 -0
  86. package/src/mux/live-mux/modal-input-reducers.ts +94 -0
  87. package/src/mux/live-mux/modal-overlays.ts +287 -0
  88. package/src/mux/live-mux/modal-pointer.ts +70 -0
  89. package/src/mux/live-mux/modal-prompt-handlers.ts +187 -0
  90. package/src/mux/live-mux/modal-task-editor-handler.ts +156 -0
  91. package/src/mux/live-mux/observed-stream.ts +87 -0
  92. package/src/mux/live-mux/palette-parsing.ts +128 -0
  93. package/src/mux/live-mux/pointer-routing.ts +108 -0
  94. package/src/mux/live-mux/process-usage.ts +53 -0
  95. package/src/mux/live-mux/project-pane-pointer.ts +44 -0
  96. package/src/mux/live-mux/rail-layout.ts +244 -0
  97. package/src/mux/live-mux/render-trace-analysis.ts +213 -0
  98. package/src/mux/live-mux/render-trace-state.ts +84 -0
  99. package/src/mux/live-mux/repository-folding.ts +207 -0
  100. package/src/mux/live-mux/runtime-shutdown.ts +51 -0
  101. package/src/mux/live-mux/selection.ts +411 -0
  102. package/src/mux/live-mux/startup-utils.ts +187 -0
  103. package/src/mux/live-mux/status-timeline-state.ts +82 -0
  104. package/src/mux/live-mux/task-pane-shortcuts.ts +206 -0
  105. package/src/mux/live-mux/terminal-palette.ts +79 -0
  106. package/src/mux/new-thread-prompt.ts +165 -0
  107. package/src/mux/project-tree.ts +295 -0
  108. package/src/mux/render-frame.ts +113 -0
  109. package/src/mux/runtime-wiring.ts +185 -0
  110. package/src/mux/selector-index.ts +160 -0
  111. package/src/mux/startup-sequencer.ts +238 -0
  112. package/src/mux/task-composer.ts +289 -0
  113. package/src/mux/task-focused-pane.ts +417 -0
  114. package/src/mux/task-screen-keybindings.ts +539 -0
  115. package/src/mux/terminal-input-modes.ts +35 -0
  116. package/src/mux/workspace-path.ts +55 -0
  117. package/src/mux/workspace-rail-model.ts +701 -0
  118. package/src/mux/workspace-rail.ts +247 -0
  119. package/src/perf/perf-core.ts +307 -0
  120. package/src/pty/pty_host.ts +217 -0
  121. package/src/pty/session-broker.ts +158 -0
  122. package/src/recording/terminal-recording.ts +383 -0
  123. package/src/services/control-plane.ts +567 -0
  124. package/src/services/conversation-lifecycle.ts +176 -0
  125. package/src/services/conversation-startup-hydration.ts +47 -0
  126. package/src/services/directory-hydration.ts +49 -0
  127. package/src/services/event-persistence.ts +104 -0
  128. package/src/services/mux-ui-state-persistence.ts +82 -0
  129. package/src/services/output-load-sampler.ts +231 -0
  130. package/src/services/process-usage-refresh.ts +88 -0
  131. package/src/services/recording.ts +75 -0
  132. package/src/services/render-trace-recorder.ts +177 -0
  133. package/src/services/runtime-control-actions.ts +123 -0
  134. package/src/services/runtime-control-plane-ops.ts +131 -0
  135. package/src/services/runtime-conversation-actions.ts +113 -0
  136. package/src/services/runtime-conversation-activation.ts +78 -0
  137. package/src/services/runtime-conversation-starter.ts +171 -0
  138. package/src/services/runtime-conversation-title-edit.ts +149 -0
  139. package/src/services/runtime-directory-actions.ts +164 -0
  140. package/src/services/runtime-envelope-handler.ts +198 -0
  141. package/src/services/runtime-git-state.ts +92 -0
  142. package/src/services/runtime-input-pipeline.ts +50 -0
  143. package/src/services/runtime-input-router.ts +202 -0
  144. package/src/services/runtime-layout-resize.ts +236 -0
  145. package/src/services/runtime-left-rail-render.ts +159 -0
  146. package/src/services/runtime-main-pane-input.ts +230 -0
  147. package/src/services/runtime-modal-input.ts +119 -0
  148. package/src/services/runtime-navigation-input.ts +207 -0
  149. package/src/services/runtime-process-wiring.ts +68 -0
  150. package/src/services/runtime-rail-input.ts +287 -0
  151. package/src/services/runtime-render-flush.ts +146 -0
  152. package/src/services/runtime-render-lifecycle.ts +104 -0
  153. package/src/services/runtime-render-orchestrator.ts +108 -0
  154. package/src/services/runtime-render-pipeline.ts +167 -0
  155. package/src/services/runtime-render-state.ts +72 -0
  156. package/src/services/runtime-repository-actions.ts +197 -0
  157. package/src/services/runtime-right-pane-render.ts +132 -0
  158. package/src/services/runtime-shutdown.ts +79 -0
  159. package/src/services/runtime-stream-subscriptions.ts +56 -0
  160. package/src/services/runtime-task-composer-persistence.ts +139 -0
  161. package/src/services/runtime-task-editor-actions.ts +83 -0
  162. package/src/services/runtime-task-pane-actions.ts +198 -0
  163. package/src/services/runtime-task-pane-shortcuts.ts +189 -0
  164. package/src/services/runtime-task-pane.ts +62 -0
  165. package/src/services/runtime-workspace-actions.ts +153 -0
  166. package/src/services/runtime-workspace-observed-events.ts +190 -0
  167. package/src/services/session-projection-instrumentation.ts +190 -0
  168. package/src/services/startup-background-probe.ts +91 -0
  169. package/src/services/startup-background-resume.ts +65 -0
  170. package/src/services/startup-orchestrator.ts +166 -0
  171. package/src/services/startup-output-tracker.ts +54 -0
  172. package/src/services/startup-paint-tracker.ts +115 -0
  173. package/src/services/startup-persisted-conversation-queue.ts +45 -0
  174. package/src/services/startup-settled-gate.ts +67 -0
  175. package/src/services/startup-shutdown.ts +53 -0
  176. package/src/services/startup-span-tracker.ts +77 -0
  177. package/src/services/startup-state-hydration.ts +94 -0
  178. package/src/services/startup-visibility.ts +35 -0
  179. package/src/services/status-timeline-recorder.ts +144 -0
  180. package/src/services/task-pane-selection-actions.ts +153 -0
  181. package/src/services/task-planning-hydration.ts +58 -0
  182. package/src/services/task-planning-observed-events.ts +89 -0
  183. package/src/services/workspace-observed-events.ts +113 -0
  184. package/src/store/control-plane-store-normalize.ts +760 -0
  185. package/src/store/control-plane-store-types.ts +224 -0
  186. package/src/store/control-plane-store.ts +2951 -0
  187. package/src/store/event-store.ts +253 -0
  188. package/src/store/sqlite.ts +81 -0
  189. package/src/terminal/compat-matrix.ts +345 -0
  190. package/src/terminal/differential-checkpoints.ts +132 -0
  191. package/src/terminal/parity-suite.ts +441 -0
  192. package/src/terminal/snapshot-oracle.ts +1840 -0
  193. package/src/ui/conversation-input-forwarder.ts +114 -0
  194. package/src/ui/conversation-selection-input.ts +103 -0
  195. package/src/ui/debug-footer-notice.ts +39 -0
  196. package/src/ui/global-shortcut-input.ts +126 -0
  197. package/src/ui/input-preflight.ts +68 -0
  198. package/src/ui/input-token-router.ts +312 -0
  199. package/src/ui/input.ts +238 -0
  200. package/src/ui/kit.ts +509 -0
  201. package/src/ui/left-nav-input.ts +80 -0
  202. package/src/ui/left-rail-pointer-input.ts +148 -0
  203. package/src/ui/main-pane-pointer-input.ts +150 -0
  204. package/src/ui/modals/manager.ts +192 -0
  205. package/src/ui/mux-theme.ts +529 -0
  206. package/src/ui/panes/conversation.ts +19 -0
  207. package/src/ui/panes/home-gridfire.ts +302 -0
  208. package/src/ui/panes/home.ts +109 -0
  209. package/src/ui/panes/left-rail.ts +12 -0
  210. package/src/ui/panes/project.ts +44 -0
  211. package/src/ui/pointer-routing-input.ts +158 -0
  212. package/src/ui/repository-fold-input.ts +91 -0
  213. package/src/ui/screen.ts +210 -0
  214. package/src/ui/surface.ts +224 -0
@@ -0,0 +1,2951 @@
1
+ import { DatabaseSync } from './sqlite.ts';
2
+ import { mkdirSync } from 'node:fs';
3
+ import { dirname } from 'node:path';
4
+ import { randomUUID } from 'node:crypto';
5
+ import {
6
+ applyTaskLinearInput,
7
+ normalizeGitHubPrJobRow,
8
+ normalizeGitHubPullRequestRow,
9
+ normalizeGitHubSyncStateRow,
10
+ normalizeAutomationPolicyRow,
11
+ asNumberOrNull,
12
+ asRecord,
13
+ normalizeProjectSettingsRow,
14
+ asString,
15
+ asStringOrNull,
16
+ defaultTaskLinearRecord,
17
+ normalizeNonEmptyLabel,
18
+ normalizeRepositoryRow,
19
+ normalizeStoredConversationRow,
20
+ normalizeStoredConversationRow as normalizeConversationRow,
21
+ normalizeStoredDirectoryRow,
22
+ normalizeStoredDirectoryRow as normalizeDirectoryRow,
23
+ normalizeTaskRow,
24
+ normalizeTelemetryRow,
25
+ normalizeTelemetrySource,
26
+ serializeTaskLinear,
27
+ sqliteStatementChanges,
28
+ uniqueValues,
29
+ } from './control-plane-store-normalize.ts';
30
+ import type {
31
+ ControlPlaneAutomationPolicyRecord,
32
+ ControlPlaneAutomationPolicyScope,
33
+ ControlPlaneConversationRecord,
34
+ ControlPlaneDirectoryRecord,
35
+ ControlPlaneGitHubCiRollup,
36
+ ControlPlaneGitHubPrJobRecord,
37
+ ControlPlaneGitHubPullRequestRecord,
38
+ ControlPlaneGitHubSyncStateRecord,
39
+ ControlPlaneProjectSettingsRecord,
40
+ ControlPlaneProjectTaskFocusMode,
41
+ ControlPlaneProjectThreadSpawnMode,
42
+ ControlPlaneRepositoryRecord,
43
+ ControlPlaneTaskLinearRecord,
44
+ ControlPlaneTaskRecord,
45
+ ControlPlaneTaskScopeKind,
46
+ ControlPlaneTaskStatus,
47
+ ControlPlaneTelemetryRecord,
48
+ ControlPlaneTelemetrySummary,
49
+ TaskLinearInput,
50
+ } from './control-plane-store-types.ts';
51
+ import type { PtyExit } from '../pty/pty_host.ts';
52
+ import type { CodexTelemetrySource } from '../control-plane/codex-telemetry.ts';
53
+ import type {
54
+ StreamSessionRuntimeStatus,
55
+ StreamSessionStatusModel,
56
+ } from '../control-plane/stream-protocol.ts';
57
+
58
+ const DEFAULT_RUNTIME_STATUS_MODEL_JSON = JSON.stringify({
59
+ runtimeStatus: 'running',
60
+ phase: 'starting',
61
+ glyph: '◔',
62
+ badge: 'RUN ',
63
+ detailText: 'starting',
64
+ attentionReason: null,
65
+ lastKnownWork: null,
66
+ lastKnownWorkAt: null,
67
+ phaseHint: null,
68
+ observedAt: new Date(0).toISOString(),
69
+ } satisfies StreamSessionStatusModel | null);
70
+
71
+ function statusModelEnabledForAgentType(agentType: string): boolean {
72
+ const normalized = agentType.trim().toLowerCase();
73
+ return normalized === 'codex' || normalized === 'claude' || normalized === 'cursor';
74
+ }
75
+
76
+ function initialRuntimeStatusModel(
77
+ agentType: string,
78
+ observedAt: string,
79
+ ): StreamSessionStatusModel | null {
80
+ if (!statusModelEnabledForAgentType(agentType)) {
81
+ return null;
82
+ }
83
+ return {
84
+ runtimeStatus: 'running',
85
+ phase: 'starting',
86
+ glyph: '◔',
87
+ badge: 'RUN ',
88
+ detailText: 'starting',
89
+ attentionReason: null,
90
+ lastKnownWork: null,
91
+ lastKnownWorkAt: null,
92
+ phaseHint: null,
93
+ observedAt,
94
+ };
95
+ }
96
+
97
+ export type {
98
+ ControlPlaneAutomationPolicyRecord,
99
+ ControlPlaneAutomationPolicyScope,
100
+ ControlPlaneConversationRecord,
101
+ ControlPlaneDirectoryRecord,
102
+ ControlPlaneGitHubCiRollup,
103
+ ControlPlaneGitHubPrJobRecord,
104
+ ControlPlaneGitHubPullRequestRecord,
105
+ ControlPlaneGitHubSyncStateRecord,
106
+ ControlPlaneProjectSettingsRecord,
107
+ ControlPlaneProjectTaskFocusMode,
108
+ ControlPlaneProjectThreadSpawnMode,
109
+ ControlPlaneRepositoryRecord,
110
+ ControlPlaneTaskLinearRecord,
111
+ ControlPlaneTaskRecord,
112
+ ControlPlaneTaskScopeKind,
113
+ ControlPlaneTelemetryRecord,
114
+ ControlPlaneTelemetrySummary,
115
+ } from './control-plane-store-types.ts';
116
+
117
+ export { normalizeStoredConversationRow, normalizeStoredDirectoryRow };
118
+
119
+ interface UpsertDirectoryInput {
120
+ directoryId: string;
121
+ tenantId: string;
122
+ userId: string;
123
+ workspaceId: string;
124
+ path: string;
125
+ }
126
+
127
+ interface CreateConversationInput {
128
+ conversationId: string;
129
+ directoryId: string;
130
+ title: string;
131
+ agentType: string;
132
+ adapterState?: Record<string, unknown>;
133
+ }
134
+
135
+ interface ListDirectoryQuery {
136
+ tenantId?: string;
137
+ userId?: string;
138
+ workspaceId?: string;
139
+ includeArchived?: boolean;
140
+ limit?: number;
141
+ }
142
+
143
+ interface ListConversationQuery {
144
+ directoryId?: string;
145
+ tenantId?: string;
146
+ userId?: string;
147
+ workspaceId?: string;
148
+ includeArchived?: boolean;
149
+ limit?: number;
150
+ }
151
+
152
+ interface ConversationRuntimeUpdate {
153
+ status: StreamSessionRuntimeStatus;
154
+ statusModel: StreamSessionStatusModel | null;
155
+ live: boolean;
156
+ attentionReason: string | null;
157
+ processId: number | null;
158
+ lastEventAt: string | null;
159
+ lastExit: PtyExit | null;
160
+ }
161
+
162
+ interface AppendTelemetryInput {
163
+ source: CodexTelemetrySource;
164
+ sessionId: string | null;
165
+ providerThreadId: string | null;
166
+ eventName: string | null;
167
+ severity: string | null;
168
+ summary: string | null;
169
+ observedAt: string;
170
+ payload: Record<string, unknown>;
171
+ fingerprint: string;
172
+ }
173
+
174
+ interface UpsertRepositoryInput {
175
+ repositoryId: string;
176
+ tenantId: string;
177
+ userId: string;
178
+ workspaceId: string;
179
+ name: string;
180
+ remoteUrl: string;
181
+ defaultBranch?: string;
182
+ metadata?: Record<string, unknown>;
183
+ }
184
+
185
+ interface UpdateRepositoryInput {
186
+ name?: string;
187
+ remoteUrl?: string;
188
+ defaultBranch?: string;
189
+ metadata?: Record<string, unknown>;
190
+ }
191
+
192
+ interface ListRepositoryQuery {
193
+ tenantId?: string;
194
+ userId?: string;
195
+ workspaceId?: string;
196
+ includeArchived?: boolean;
197
+ limit?: number;
198
+ }
199
+
200
+ interface CreateTaskInput {
201
+ taskId: string;
202
+ tenantId: string;
203
+ userId: string;
204
+ workspaceId: string;
205
+ repositoryId?: string;
206
+ projectId?: string;
207
+ title: string;
208
+ description?: string;
209
+ linear?: TaskLinearInput;
210
+ }
211
+
212
+ interface UpdateTaskInput {
213
+ title?: string;
214
+ description?: string;
215
+ repositoryId?: string | null;
216
+ projectId?: string | null;
217
+ linear?: TaskLinearInput | null;
218
+ }
219
+
220
+ interface ListTaskQuery {
221
+ tenantId?: string;
222
+ userId?: string;
223
+ workspaceId?: string;
224
+ repositoryId?: string;
225
+ projectId?: string;
226
+ scopeKind?: ControlPlaneTaskScopeKind;
227
+ status?: ControlPlaneTaskStatus;
228
+ limit?: number;
229
+ }
230
+
231
+ interface ClaimTaskInput {
232
+ taskId: string;
233
+ controllerId: string;
234
+ directoryId?: string;
235
+ branchName?: string;
236
+ baseBranch?: string;
237
+ }
238
+
239
+ interface ReorderTasksInput {
240
+ tenantId: string;
241
+ userId: string;
242
+ workspaceId: string;
243
+ orderedTaskIds: readonly string[];
244
+ }
245
+
246
+ interface UpdateProjectSettingsInput {
247
+ directoryId: string;
248
+ pinnedBranch?: string | null;
249
+ taskFocusMode?: ControlPlaneProjectTaskFocusMode;
250
+ threadSpawnMode?: ControlPlaneProjectThreadSpawnMode;
251
+ }
252
+
253
+ interface GetAutomationPolicyInput {
254
+ tenantId: string;
255
+ userId: string;
256
+ workspaceId: string;
257
+ scope: ControlPlaneAutomationPolicyScope;
258
+ scopeId?: string | null;
259
+ }
260
+
261
+ interface UpsertAutomationPolicyInput {
262
+ tenantId: string;
263
+ userId: string;
264
+ workspaceId: string;
265
+ scope: ControlPlaneAutomationPolicyScope;
266
+ scopeId?: string | null;
267
+ automationEnabled?: boolean;
268
+ frozen?: boolean;
269
+ }
270
+
271
+ interface UpsertGitHubPullRequestInput {
272
+ prRecordId: string;
273
+ tenantId: string;
274
+ userId: string;
275
+ workspaceId: string;
276
+ repositoryId: string;
277
+ directoryId?: string | null;
278
+ owner: string;
279
+ repo: string;
280
+ number: number;
281
+ title: string;
282
+ url: string;
283
+ authorLogin?: string | null;
284
+ headBranch: string;
285
+ headSha: string;
286
+ baseBranch: string;
287
+ state: 'open' | 'closed';
288
+ isDraft: boolean;
289
+ ciRollup?: ControlPlaneGitHubCiRollup;
290
+ closedAt?: string | null;
291
+ observedAt: string;
292
+ }
293
+
294
+ interface ListGitHubPullRequestQuery {
295
+ tenantId?: string;
296
+ userId?: string;
297
+ workspaceId?: string;
298
+ repositoryId?: string;
299
+ directoryId?: string;
300
+ headBranch?: string;
301
+ state?: 'open' | 'closed';
302
+ limit?: number;
303
+ }
304
+
305
+ interface ReplaceGitHubPrJobsInput {
306
+ tenantId: string;
307
+ userId: string;
308
+ workspaceId: string;
309
+ repositoryId: string;
310
+ prRecordId: string;
311
+ observedAt: string;
312
+ jobs: readonly {
313
+ jobRecordId: string;
314
+ provider: 'check-run' | 'status-context';
315
+ externalId: string;
316
+ name: string;
317
+ status: string;
318
+ conclusion?: string | null;
319
+ url?: string | null;
320
+ startedAt?: string | null;
321
+ completedAt?: string | null;
322
+ }[];
323
+ }
324
+
325
+ interface ListGitHubPrJobsQuery {
326
+ tenantId?: string;
327
+ userId?: string;
328
+ workspaceId?: string;
329
+ repositoryId?: string;
330
+ prRecordId?: string;
331
+ limit?: number;
332
+ }
333
+
334
+ interface UpsertGitHubSyncStateInput {
335
+ stateId: string;
336
+ tenantId: string;
337
+ userId: string;
338
+ workspaceId: string;
339
+ repositoryId: string;
340
+ directoryId?: string | null;
341
+ branchName: string;
342
+ lastSyncAt: string;
343
+ lastSuccessAt?: string | null;
344
+ lastError?: string | null;
345
+ lastErrorAt?: string | null;
346
+ }
347
+
348
+ interface ListGitHubSyncStateQuery {
349
+ tenantId?: string;
350
+ userId?: string;
351
+ workspaceId?: string;
352
+ repositoryId?: string;
353
+ directoryId?: string;
354
+ branchName?: string;
355
+ limit?: number;
356
+ }
357
+
358
+ export class SqliteControlPlaneStore {
359
+ private readonly db: DatabaseSync;
360
+
361
+ constructor(filePath = ':memory:') {
362
+ const resolvedPath = this.preparePath(filePath);
363
+ this.db = new DatabaseSync(resolvedPath);
364
+ this.configureConnection();
365
+ this.initializeSchema();
366
+ }
367
+
368
+ close(): void {
369
+ this.db.close();
370
+ }
371
+
372
+ upsertDirectory(input: UpsertDirectoryInput): ControlPlaneDirectoryRecord {
373
+ this.db.exec('BEGIN IMMEDIATE TRANSACTION');
374
+ try {
375
+ const existingById = this.getDirectory(input.directoryId);
376
+ if (existingById !== null) {
377
+ if (
378
+ existingById.tenantId !== input.tenantId ||
379
+ existingById.userId !== input.userId ||
380
+ existingById.workspaceId !== input.workspaceId
381
+ ) {
382
+ throw new Error(`directory scope mismatch: ${input.directoryId}`);
383
+ }
384
+ if (existingById.path !== input.path || existingById.archivedAt !== null) {
385
+ this.db
386
+ .prepare(
387
+ `
388
+ UPDATE directories
389
+ SET path = ?, archived_at = NULL
390
+ WHERE directory_id = ?
391
+ `,
392
+ )
393
+ .run(input.path, input.directoryId);
394
+ const updated = this.getDirectory(input.directoryId);
395
+ if (updated === null) {
396
+ throw new Error(`directory missing after update: ${input.directoryId}`);
397
+ }
398
+ this.db.exec('COMMIT');
399
+ return updated;
400
+ }
401
+ this.db.exec('COMMIT');
402
+ return existingById;
403
+ }
404
+
405
+ const existing = this.findDirectoryByScopePath(
406
+ input.tenantId,
407
+ input.userId,
408
+ input.workspaceId,
409
+ input.path,
410
+ );
411
+ if (existing !== null) {
412
+ if (existing.archivedAt !== null) {
413
+ this.db
414
+ .prepare(
415
+ `
416
+ UPDATE directories
417
+ SET archived_at = NULL
418
+ WHERE directory_id = ?
419
+ `,
420
+ )
421
+ .run(existing.directoryId);
422
+ const restored = this.getDirectory(existing.directoryId);
423
+ if (restored === null) {
424
+ throw new Error(`directory missing after restore: ${existing.directoryId}`);
425
+ }
426
+ this.db.exec('COMMIT');
427
+ return restored;
428
+ }
429
+ this.db.exec('COMMIT');
430
+ return existing;
431
+ }
432
+
433
+ const createdAt = new Date().toISOString();
434
+ this.db
435
+ .prepare(
436
+ `
437
+ INSERT INTO directories (
438
+ directory_id,
439
+ tenant_id,
440
+ user_id,
441
+ workspace_id,
442
+ path,
443
+ created_at,
444
+ archived_at
445
+ ) VALUES (?, ?, ?, ?, ?, ?, NULL)
446
+ `,
447
+ )
448
+ .run(
449
+ input.directoryId,
450
+ input.tenantId,
451
+ input.userId,
452
+ input.workspaceId,
453
+ input.path,
454
+ createdAt,
455
+ );
456
+ const inserted = this.getDirectory(input.directoryId);
457
+ if (inserted === null) {
458
+ throw new Error(`directory insert failed: ${input.directoryId}`);
459
+ }
460
+ this.db.exec('COMMIT');
461
+ return inserted;
462
+ } catch (error) {
463
+ this.db.exec('ROLLBACK');
464
+ throw error;
465
+ }
466
+ }
467
+
468
+ getDirectory(directoryId: string): ControlPlaneDirectoryRecord | null {
469
+ const row = this.db
470
+ .prepare(
471
+ `
472
+ SELECT
473
+ directory_id,
474
+ tenant_id,
475
+ user_id,
476
+ workspace_id,
477
+ path,
478
+ created_at,
479
+ archived_at
480
+ FROM directories
481
+ WHERE directory_id = ?
482
+ `,
483
+ )
484
+ .get(directoryId);
485
+ if (row === undefined) {
486
+ return null;
487
+ }
488
+ return normalizeDirectoryRow(row);
489
+ }
490
+
491
+ listDirectories(query: ListDirectoryQuery = {}): readonly ControlPlaneDirectoryRecord[] {
492
+ const clauses: string[] = [];
493
+ const args: Array<number | string> = [];
494
+ if (query.tenantId !== undefined) {
495
+ clauses.push('tenant_id = ?');
496
+ args.push(query.tenantId);
497
+ }
498
+ if (query.userId !== undefined) {
499
+ clauses.push('user_id = ?');
500
+ args.push(query.userId);
501
+ }
502
+ if (query.workspaceId !== undefined) {
503
+ clauses.push('workspace_id = ?');
504
+ args.push(query.workspaceId);
505
+ }
506
+ if (query.includeArchived !== true) {
507
+ clauses.push('archived_at IS NULL');
508
+ }
509
+ const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '';
510
+ const limit = query.limit ?? 1000;
511
+
512
+ const rows = this.db
513
+ .prepare(
514
+ `
515
+ SELECT
516
+ directory_id,
517
+ tenant_id,
518
+ user_id,
519
+ workspace_id,
520
+ path,
521
+ created_at,
522
+ archived_at
523
+ FROM directories
524
+ ${where}
525
+ ORDER BY created_at ASC, directory_id ASC
526
+ LIMIT ?
527
+ `,
528
+ )
529
+ .all(...args, limit);
530
+ return rows.map((row) => normalizeDirectoryRow(row));
531
+ }
532
+
533
+ archiveDirectory(directoryId: string): ControlPlaneDirectoryRecord {
534
+ this.db.exec('BEGIN IMMEDIATE TRANSACTION');
535
+ try {
536
+ const existing = this.getDirectory(directoryId);
537
+ if (existing === null) {
538
+ throw new Error(`directory not found: ${directoryId}`);
539
+ }
540
+ if (existing.archivedAt !== null) {
541
+ this.db.exec('COMMIT');
542
+ return existing;
543
+ }
544
+ const archivedAt = new Date().toISOString();
545
+ this.db
546
+ .prepare(
547
+ `
548
+ UPDATE directories
549
+ SET archived_at = ?
550
+ WHERE directory_id = ?
551
+ `,
552
+ )
553
+ .run(archivedAt, directoryId);
554
+ const archived = this.getDirectory(directoryId);
555
+ if (archived === null) {
556
+ throw new Error(`directory missing after archive: ${directoryId}`);
557
+ }
558
+ this.db.exec('COMMIT');
559
+ return archived;
560
+ } catch (error) {
561
+ this.db.exec('ROLLBACK');
562
+ throw error;
563
+ }
564
+ }
565
+
566
+ createConversation(input: CreateConversationInput): ControlPlaneConversationRecord {
567
+ this.db.exec('BEGIN IMMEDIATE TRANSACTION');
568
+ try {
569
+ const directory = this.getDirectory(input.directoryId);
570
+ if (directory === null || directory.archivedAt !== null) {
571
+ throw new Error(`directory not found: ${input.directoryId}`);
572
+ }
573
+ const existing = this.getConversation(input.conversationId);
574
+ if (existing !== null) {
575
+ throw new Error(`conversation already exists: ${input.conversationId}`);
576
+ }
577
+
578
+ const createdAt = new Date().toISOString();
579
+ const initialStatusModel = initialRuntimeStatusModel(input.agentType, createdAt);
580
+ this.db
581
+ .prepare(
582
+ `
583
+ INSERT INTO conversations (
584
+ conversation_id,
585
+ directory_id,
586
+ tenant_id,
587
+ user_id,
588
+ workspace_id,
589
+ title,
590
+ agent_type,
591
+ created_at,
592
+ archived_at,
593
+ runtime_status,
594
+ runtime_status_model_json,
595
+ runtime_live,
596
+ runtime_attention_reason,
597
+ runtime_process_id,
598
+ runtime_last_event_at,
599
+ runtime_last_exit_code,
600
+ runtime_last_exit_signal,
601
+ adapter_state_json
602
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, 'running', ?, 0, NULL, NULL, NULL, NULL, NULL, ?)
603
+ `,
604
+ )
605
+ .run(
606
+ input.conversationId,
607
+ input.directoryId,
608
+ directory.tenantId,
609
+ directory.userId,
610
+ directory.workspaceId,
611
+ input.title,
612
+ input.agentType,
613
+ createdAt,
614
+ JSON.stringify(initialStatusModel),
615
+ JSON.stringify(input.adapterState ?? {}),
616
+ );
617
+ const created = this.getConversation(input.conversationId);
618
+ if (created === null) {
619
+ throw new Error(`conversation insert failed: ${input.conversationId}`);
620
+ }
621
+ this.db.exec('COMMIT');
622
+ return created;
623
+ } catch (error) {
624
+ this.db.exec('ROLLBACK');
625
+ throw error;
626
+ }
627
+ }
628
+
629
+ getConversation(conversationId: string): ControlPlaneConversationRecord | null {
630
+ const row = this.db
631
+ .prepare(
632
+ `
633
+ SELECT
634
+ conversation_id,
635
+ directory_id,
636
+ tenant_id,
637
+ user_id,
638
+ workspace_id,
639
+ title,
640
+ agent_type,
641
+ created_at,
642
+ archived_at,
643
+ runtime_status,
644
+ runtime_status_model_json,
645
+ runtime_live,
646
+ runtime_attention_reason,
647
+ runtime_process_id,
648
+ runtime_last_event_at,
649
+ runtime_last_exit_code,
650
+ runtime_last_exit_signal,
651
+ adapter_state_json
652
+ FROM conversations
653
+ WHERE conversation_id = ?
654
+ `,
655
+ )
656
+ .get(conversationId);
657
+ if (row === undefined) {
658
+ return null;
659
+ }
660
+ return normalizeConversationRow(row);
661
+ }
662
+
663
+ listConversations(query: ListConversationQuery = {}): readonly ControlPlaneConversationRecord[] {
664
+ const clauses: string[] = [];
665
+ const args: Array<number | string> = [];
666
+ if (query.directoryId !== undefined) {
667
+ clauses.push('directory_id = ?');
668
+ args.push(query.directoryId);
669
+ }
670
+ if (query.tenantId !== undefined) {
671
+ clauses.push('tenant_id = ?');
672
+ args.push(query.tenantId);
673
+ }
674
+ if (query.userId !== undefined) {
675
+ clauses.push('user_id = ?');
676
+ args.push(query.userId);
677
+ }
678
+ if (query.workspaceId !== undefined) {
679
+ clauses.push('workspace_id = ?');
680
+ args.push(query.workspaceId);
681
+ }
682
+ if (query.includeArchived !== true) {
683
+ clauses.push('archived_at IS NULL');
684
+ }
685
+ const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '';
686
+ const limit = query.limit ?? 5000;
687
+
688
+ const rows = this.db
689
+ .prepare(
690
+ `
691
+ SELECT
692
+ conversation_id,
693
+ directory_id,
694
+ tenant_id,
695
+ user_id,
696
+ workspace_id,
697
+ title,
698
+ agent_type,
699
+ created_at,
700
+ archived_at,
701
+ runtime_status,
702
+ runtime_status_model_json,
703
+ runtime_live,
704
+ runtime_attention_reason,
705
+ runtime_process_id,
706
+ runtime_last_event_at,
707
+ runtime_last_exit_code,
708
+ runtime_last_exit_signal,
709
+ adapter_state_json
710
+ FROM conversations
711
+ ${where}
712
+ ORDER BY created_at ASC, conversation_id ASC
713
+ LIMIT ?
714
+ `,
715
+ )
716
+ .all(...args, limit);
717
+ return rows.map((row) => normalizeConversationRow(row));
718
+ }
719
+
720
+ archiveConversation(conversationId: string): ControlPlaneConversationRecord {
721
+ this.db.exec('BEGIN IMMEDIATE TRANSACTION');
722
+ try {
723
+ const existing = this.getConversation(conversationId);
724
+ if (existing === null) {
725
+ throw new Error(`conversation not found: ${conversationId}`);
726
+ }
727
+ const archivedAt = new Date().toISOString();
728
+ this.db
729
+ .prepare(
730
+ `
731
+ UPDATE conversations
732
+ SET archived_at = ?
733
+ WHERE conversation_id = ?
734
+ `,
735
+ )
736
+ .run(archivedAt, conversationId);
737
+ const archived = this.getConversation(conversationId);
738
+ if (archived === null) {
739
+ throw new Error(`conversation missing after archive: ${conversationId}`);
740
+ }
741
+ this.db.exec('COMMIT');
742
+ return archived;
743
+ } catch (error) {
744
+ this.db.exec('ROLLBACK');
745
+ throw error;
746
+ }
747
+ }
748
+
749
+ updateConversationTitle(
750
+ conversationId: string,
751
+ title: string,
752
+ ): ControlPlaneConversationRecord | null {
753
+ const existing = this.getConversation(conversationId);
754
+ if (existing === null) {
755
+ return null;
756
+ }
757
+ this.db
758
+ .prepare(
759
+ `
760
+ UPDATE conversations
761
+ SET title = ?
762
+ WHERE conversation_id = ?
763
+ `,
764
+ )
765
+ .run(title, conversationId);
766
+ return this.getConversation(conversationId);
767
+ }
768
+
769
+ deleteConversation(conversationId: string): boolean {
770
+ this.db.exec('BEGIN IMMEDIATE TRANSACTION');
771
+ try {
772
+ const existing = this.getConversation(conversationId);
773
+ if (existing === null) {
774
+ throw new Error(`conversation not found: ${conversationId}`);
775
+ }
776
+ this.db
777
+ .prepare(
778
+ `
779
+ DELETE FROM conversations
780
+ WHERE conversation_id = ?
781
+ `,
782
+ )
783
+ .run(conversationId);
784
+ this.db.exec('COMMIT');
785
+ return true;
786
+ } catch (error) {
787
+ this.db.exec('ROLLBACK');
788
+ throw error;
789
+ }
790
+ }
791
+
792
+ updateConversationAdapterState(
793
+ conversationId: string,
794
+ adapterState: Record<string, unknown>,
795
+ ): ControlPlaneConversationRecord | null {
796
+ const existing = this.getConversation(conversationId);
797
+ if (existing === null) {
798
+ return null;
799
+ }
800
+ this.db
801
+ .prepare(
802
+ `
803
+ UPDATE conversations
804
+ SET adapter_state_json = ?
805
+ WHERE conversation_id = ?
806
+ `,
807
+ )
808
+ .run(JSON.stringify(adapterState), conversationId);
809
+ return this.getConversation(conversationId);
810
+ }
811
+
812
+ updateConversationRuntime(
813
+ conversationId: string,
814
+ update: ConversationRuntimeUpdate,
815
+ ): ControlPlaneConversationRecord | null {
816
+ const existing = this.getConversation(conversationId);
817
+ if (existing === null) {
818
+ return null;
819
+ }
820
+ this.db
821
+ .prepare(
822
+ `
823
+ UPDATE conversations
824
+ SET
825
+ runtime_status = ?,
826
+ runtime_status_model_json = ?,
827
+ runtime_live = ?,
828
+ runtime_attention_reason = ?,
829
+ runtime_process_id = ?,
830
+ runtime_last_event_at = ?,
831
+ runtime_last_exit_code = ?,
832
+ runtime_last_exit_signal = ?
833
+ WHERE conversation_id = ?
834
+ `,
835
+ )
836
+ .run(
837
+ update.status,
838
+ JSON.stringify(update.statusModel),
839
+ update.live ? 1 : 0,
840
+ update.attentionReason,
841
+ update.processId,
842
+ update.lastEventAt,
843
+ update.lastExit?.code ?? null,
844
+ update.lastExit?.signal ?? null,
845
+ conversationId,
846
+ );
847
+ return this.getConversation(conversationId);
848
+ }
849
+
850
+ appendTelemetry(input: AppendTelemetryInput): boolean {
851
+ const ingestedAt = new Date().toISOString();
852
+ const result = this.db
853
+ .prepare(
854
+ `
855
+ INSERT INTO session_telemetry (
856
+ source,
857
+ session_id,
858
+ provider_thread_id,
859
+ event_name,
860
+ severity,
861
+ summary,
862
+ observed_at,
863
+ ingested_at,
864
+ payload_json,
865
+ fingerprint
866
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
867
+ ON CONFLICT(fingerprint) DO NOTHING
868
+ `,
869
+ )
870
+ .run(
871
+ input.source,
872
+ input.sessionId,
873
+ input.providerThreadId,
874
+ input.eventName,
875
+ input.severity,
876
+ input.summary,
877
+ input.observedAt,
878
+ ingestedAt,
879
+ JSON.stringify(input.payload),
880
+ input.fingerprint,
881
+ );
882
+ return sqliteStatementChanges(result) > 0;
883
+ }
884
+
885
+ latestTelemetrySummary(sessionId: string): ControlPlaneTelemetrySummary | null {
886
+ const row = this.db
887
+ .prepare(
888
+ `
889
+ SELECT
890
+ source,
891
+ event_name,
892
+ severity,
893
+ summary,
894
+ observed_at
895
+ FROM session_telemetry
896
+ WHERE session_id = ?
897
+ ORDER BY observed_at DESC, telemetry_id DESC
898
+ LIMIT 1
899
+ `,
900
+ )
901
+ .get(sessionId);
902
+ if (row === undefined) {
903
+ return null;
904
+ }
905
+ const asRow = asRecord(row);
906
+ return {
907
+ source: normalizeTelemetrySource(asRow.source),
908
+ eventName: asStringOrNull(asRow.event_name, 'event_name'),
909
+ severity: asStringOrNull(asRow.severity, 'severity'),
910
+ summary: asStringOrNull(asRow.summary, 'summary'),
911
+ observedAt: asString(asRow.observed_at, 'observed_at'),
912
+ };
913
+ }
914
+
915
+ listTelemetryForSession(sessionId: string, limit = 200): readonly ControlPlaneTelemetryRecord[] {
916
+ const rows = this.db
917
+ .prepare(
918
+ `
919
+ SELECT
920
+ telemetry_id,
921
+ source,
922
+ session_id,
923
+ provider_thread_id,
924
+ event_name,
925
+ severity,
926
+ summary,
927
+ observed_at,
928
+ ingested_at,
929
+ payload_json,
930
+ fingerprint
931
+ FROM session_telemetry
932
+ WHERE session_id = ?
933
+ ORDER BY observed_at DESC, telemetry_id DESC
934
+ LIMIT ?
935
+ `,
936
+ )
937
+ .all(sessionId, limit);
938
+ return rows.map((row) => normalizeTelemetryRow(row));
939
+ }
940
+
941
+ findConversationIdByCodexThreadId(threadId: string): string | null {
942
+ const normalized = threadId.trim();
943
+ if (normalized.length === 0) {
944
+ return null;
945
+ }
946
+
947
+ try {
948
+ const direct = this.db
949
+ .prepare(
950
+ `
951
+ SELECT conversation_id
952
+ FROM conversations
953
+ WHERE (
954
+ json_extract(adapter_state_json, '$.codex.resumeSessionId') = ?
955
+ OR json_extract(adapter_state_json, '$.codex.threadId') = ?
956
+ )
957
+ AND archived_at IS NULL
958
+ ORDER BY created_at DESC, conversation_id DESC
959
+ LIMIT 1
960
+ `,
961
+ )
962
+ .get(normalized, normalized);
963
+ if (direct !== undefined) {
964
+ const row = asRecord(direct);
965
+ return asString(row.conversation_id, 'conversation_id');
966
+ }
967
+ } catch {
968
+ // Best-effort index lookup; fallback scan handles environments without json_extract.
969
+ }
970
+
971
+ const rows = this.listConversations({
972
+ includeArchived: false,
973
+ limit: 10000,
974
+ });
975
+ for (let idx = rows.length - 1; idx >= 0; idx -= 1) {
976
+ const conversation = rows[idx]!;
977
+ const codex = conversation.adapterState['codex'];
978
+ if (typeof codex !== 'object' || codex === null || Array.isArray(codex)) {
979
+ continue;
980
+ }
981
+ const codexState = codex as Record<string, unknown>;
982
+ const resumeSessionId = codexState['resumeSessionId'];
983
+ if (typeof resumeSessionId === 'string' && resumeSessionId.trim() === normalized) {
984
+ return conversation.conversationId;
985
+ }
986
+ const legacyThreadId = codexState['threadId'];
987
+ if (typeof legacyThreadId === 'string' && legacyThreadId.trim() === normalized) {
988
+ return conversation.conversationId;
989
+ }
990
+ }
991
+ return null;
992
+ }
993
+
994
+ upsertRepository(input: UpsertRepositoryInput): ControlPlaneRepositoryRecord {
995
+ const normalizedName = normalizeNonEmptyLabel(input.name, 'name');
996
+ const normalizedRemoteUrl = normalizeNonEmptyLabel(input.remoteUrl, 'remoteUrl');
997
+ const normalizedDefaultBranch = normalizeNonEmptyLabel(
998
+ input.defaultBranch ?? 'main',
999
+ 'defaultBranch',
1000
+ );
1001
+ const metadata = input.metadata ?? {};
1002
+ this.db.exec('BEGIN IMMEDIATE TRANSACTION');
1003
+ try {
1004
+ const existingById = this.getRepository(input.repositoryId);
1005
+ if (existingById !== null) {
1006
+ this.assertScopeMatch(existingById, input, 'repository');
1007
+ if (
1008
+ existingById.name !== normalizedName ||
1009
+ existingById.remoteUrl !== normalizedRemoteUrl ||
1010
+ existingById.defaultBranch !== normalizedDefaultBranch ||
1011
+ JSON.stringify(existingById.metadata) !== JSON.stringify(metadata) ||
1012
+ existingById.archivedAt !== null
1013
+ ) {
1014
+ this.db
1015
+ .prepare(
1016
+ `
1017
+ UPDATE repositories
1018
+ SET
1019
+ name = ?,
1020
+ remote_url = ?,
1021
+ default_branch = ?,
1022
+ metadata_json = ?,
1023
+ archived_at = NULL
1024
+ WHERE repository_id = ?
1025
+ `,
1026
+ )
1027
+ .run(
1028
+ normalizedName,
1029
+ normalizedRemoteUrl,
1030
+ normalizedDefaultBranch,
1031
+ JSON.stringify(metadata),
1032
+ input.repositoryId,
1033
+ );
1034
+ const updated = this.getRepository(input.repositoryId);
1035
+ if (updated === null) {
1036
+ throw new Error(`repository missing after update: ${input.repositoryId}`);
1037
+ }
1038
+ this.db.exec('COMMIT');
1039
+ return updated;
1040
+ }
1041
+ this.db.exec('COMMIT');
1042
+ return existingById;
1043
+ }
1044
+
1045
+ const existingByScopeUrl = this.findRepositoryByScopeRemoteUrl(
1046
+ input.tenantId,
1047
+ input.userId,
1048
+ input.workspaceId,
1049
+ normalizedRemoteUrl,
1050
+ );
1051
+ if (existingByScopeUrl !== null) {
1052
+ if (
1053
+ existingByScopeUrl.name !== normalizedName ||
1054
+ existingByScopeUrl.defaultBranch !== normalizedDefaultBranch ||
1055
+ JSON.stringify(existingByScopeUrl.metadata) !== JSON.stringify(metadata) ||
1056
+ existingByScopeUrl.archivedAt !== null
1057
+ ) {
1058
+ this.db
1059
+ .prepare(
1060
+ `
1061
+ UPDATE repositories
1062
+ SET
1063
+ name = ?,
1064
+ default_branch = ?,
1065
+ metadata_json = ?,
1066
+ archived_at = NULL
1067
+ WHERE repository_id = ?
1068
+ `,
1069
+ )
1070
+ .run(
1071
+ normalizedName,
1072
+ normalizedDefaultBranch,
1073
+ JSON.stringify(metadata),
1074
+ existingByScopeUrl.repositoryId,
1075
+ );
1076
+ const restored = this.getRepository(existingByScopeUrl.repositoryId);
1077
+ if (restored === null) {
1078
+ throw new Error(`repository missing after restore: ${existingByScopeUrl.repositoryId}`);
1079
+ }
1080
+ this.db.exec('COMMIT');
1081
+ return restored;
1082
+ }
1083
+ this.db.exec('COMMIT');
1084
+ return existingByScopeUrl;
1085
+ }
1086
+
1087
+ const createdAt = new Date().toISOString();
1088
+ this.db
1089
+ .prepare(
1090
+ `
1091
+ INSERT INTO repositories (
1092
+ repository_id,
1093
+ tenant_id,
1094
+ user_id,
1095
+ workspace_id,
1096
+ name,
1097
+ remote_url,
1098
+ default_branch,
1099
+ metadata_json,
1100
+ created_at,
1101
+ archived_at
1102
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)
1103
+ `,
1104
+ )
1105
+ .run(
1106
+ input.repositoryId,
1107
+ input.tenantId,
1108
+ input.userId,
1109
+ input.workspaceId,
1110
+ normalizedName,
1111
+ normalizedRemoteUrl,
1112
+ normalizedDefaultBranch,
1113
+ JSON.stringify(metadata),
1114
+ createdAt,
1115
+ );
1116
+ const inserted = this.getRepository(input.repositoryId);
1117
+ if (inserted === null) {
1118
+ throw new Error(`repository insert failed: ${input.repositoryId}`);
1119
+ }
1120
+ this.db.exec('COMMIT');
1121
+ return inserted;
1122
+ } catch (error) {
1123
+ this.db.exec('ROLLBACK');
1124
+ throw error;
1125
+ }
1126
+ }
1127
+
1128
+ getRepository(repositoryId: string): ControlPlaneRepositoryRecord | null {
1129
+ const row = this.db
1130
+ .prepare(
1131
+ `
1132
+ SELECT
1133
+ repository_id,
1134
+ tenant_id,
1135
+ user_id,
1136
+ workspace_id,
1137
+ name,
1138
+ remote_url,
1139
+ default_branch,
1140
+ metadata_json,
1141
+ created_at,
1142
+ archived_at
1143
+ FROM repositories
1144
+ WHERE repository_id = ?
1145
+ `,
1146
+ )
1147
+ .get(repositoryId);
1148
+ if (row === undefined) {
1149
+ return null;
1150
+ }
1151
+ return normalizeRepositoryRow(row);
1152
+ }
1153
+
1154
+ listRepositories(query: ListRepositoryQuery = {}): readonly ControlPlaneRepositoryRecord[] {
1155
+ const clauses: string[] = [];
1156
+ const args: Array<number | string> = [];
1157
+ if (query.tenantId !== undefined) {
1158
+ clauses.push('tenant_id = ?');
1159
+ args.push(query.tenantId);
1160
+ }
1161
+ if (query.userId !== undefined) {
1162
+ clauses.push('user_id = ?');
1163
+ args.push(query.userId);
1164
+ }
1165
+ if (query.workspaceId !== undefined) {
1166
+ clauses.push('workspace_id = ?');
1167
+ args.push(query.workspaceId);
1168
+ }
1169
+ if (query.includeArchived !== true) {
1170
+ clauses.push('archived_at IS NULL');
1171
+ }
1172
+ const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '';
1173
+ const limit = query.limit ?? 1000;
1174
+ const rows = this.db
1175
+ .prepare(
1176
+ `
1177
+ SELECT
1178
+ repository_id,
1179
+ tenant_id,
1180
+ user_id,
1181
+ workspace_id,
1182
+ name,
1183
+ remote_url,
1184
+ default_branch,
1185
+ metadata_json,
1186
+ created_at,
1187
+ archived_at
1188
+ FROM repositories
1189
+ ${where}
1190
+ ORDER BY created_at ASC, repository_id ASC
1191
+ LIMIT ?
1192
+ `,
1193
+ )
1194
+ .all(...args, limit);
1195
+ return rows.map((row) => normalizeRepositoryRow(row));
1196
+ }
1197
+
1198
+ updateRepository(
1199
+ repositoryId: string,
1200
+ update: UpdateRepositoryInput,
1201
+ ): ControlPlaneRepositoryRecord | null {
1202
+ const existing = this.getRepository(repositoryId);
1203
+ if (existing === null) {
1204
+ return null;
1205
+ }
1206
+ const name =
1207
+ update.name === undefined ? existing.name : normalizeNonEmptyLabel(update.name, 'name');
1208
+ const remoteUrl =
1209
+ update.remoteUrl === undefined
1210
+ ? existing.remoteUrl
1211
+ : normalizeNonEmptyLabel(update.remoteUrl, 'remoteUrl');
1212
+ const defaultBranch =
1213
+ update.defaultBranch === undefined
1214
+ ? existing.defaultBranch
1215
+ : normalizeNonEmptyLabel(update.defaultBranch, 'defaultBranch');
1216
+ const metadata = update.metadata === undefined ? existing.metadata : update.metadata;
1217
+ this.db
1218
+ .prepare(
1219
+ `
1220
+ UPDATE repositories
1221
+ SET
1222
+ name = ?,
1223
+ remote_url = ?,
1224
+ default_branch = ?,
1225
+ metadata_json = ?
1226
+ WHERE repository_id = ?
1227
+ `,
1228
+ )
1229
+ .run(name, remoteUrl, defaultBranch, JSON.stringify(metadata), repositoryId);
1230
+ return this.getRepository(repositoryId);
1231
+ }
1232
+
1233
+ archiveRepository(repositoryId: string): ControlPlaneRepositoryRecord {
1234
+ this.db.exec('BEGIN IMMEDIATE TRANSACTION');
1235
+ try {
1236
+ const existing = this.getRepository(repositoryId);
1237
+ if (existing === null) {
1238
+ throw new Error(`repository not found: ${repositoryId}`);
1239
+ }
1240
+ if (existing.archivedAt !== null) {
1241
+ this.db.exec('COMMIT');
1242
+ return existing;
1243
+ }
1244
+ const archivedAt = new Date().toISOString();
1245
+ this.db
1246
+ .prepare(
1247
+ `
1248
+ UPDATE repositories
1249
+ SET archived_at = ?
1250
+ WHERE repository_id = ?
1251
+ `,
1252
+ )
1253
+ .run(archivedAt, repositoryId);
1254
+ const archived = this.getRepository(repositoryId);
1255
+ if (archived === null) {
1256
+ throw new Error(`repository missing after archive: ${repositoryId}`);
1257
+ }
1258
+ this.db.exec('COMMIT');
1259
+ return archived;
1260
+ } catch (error) {
1261
+ this.db.exec('ROLLBACK');
1262
+ throw error;
1263
+ }
1264
+ }
1265
+
1266
+ createTask(input: CreateTaskInput): ControlPlaneTaskRecord {
1267
+ const title = normalizeNonEmptyLabel(input.title, 'title');
1268
+ const description = input.description ?? '';
1269
+ const linear = applyTaskLinearInput(defaultTaskLinearRecord(), input.linear ?? {});
1270
+ this.db.exec('BEGIN IMMEDIATE TRANSACTION');
1271
+ try {
1272
+ const existing = this.getTask(input.taskId);
1273
+ if (existing !== null) {
1274
+ throw new Error(`task already exists: ${input.taskId}`);
1275
+ }
1276
+ const repositoryId = input.repositoryId ?? null;
1277
+ const projectId = input.projectId ?? null;
1278
+ if (repositoryId !== null) {
1279
+ const repository = this.getActiveRepository(repositoryId);
1280
+ this.assertScopeMatch(input, repository, 'task');
1281
+ }
1282
+ if (projectId !== null) {
1283
+ const directory = this.getActiveDirectory(projectId);
1284
+ this.assertScopeMatch(input, directory, 'task');
1285
+ }
1286
+ const scopeKind = this.deriveTaskScopeKind(repositoryId, projectId);
1287
+ const createdAt = new Date().toISOString();
1288
+ const orderIndex = this.nextTaskOrderIndex(input.tenantId, input.userId, input.workspaceId);
1289
+ this.db
1290
+ .prepare(
1291
+ `
1292
+ INSERT INTO tasks (
1293
+ task_id,
1294
+ tenant_id,
1295
+ user_id,
1296
+ workspace_id,
1297
+ repository_id,
1298
+ scope_kind,
1299
+ project_id,
1300
+ title,
1301
+ description,
1302
+ linear_json,
1303
+ status,
1304
+ order_index,
1305
+ claimed_by_controller_id,
1306
+ claimed_by_directory_id,
1307
+ branch_name,
1308
+ base_branch,
1309
+ claimed_at,
1310
+ completed_at,
1311
+ created_at,
1312
+ updated_at
1313
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'draft', ?, NULL, NULL, NULL, NULL, NULL, NULL, ?, ?)
1314
+ `,
1315
+ )
1316
+ .run(
1317
+ input.taskId,
1318
+ input.tenantId,
1319
+ input.userId,
1320
+ input.workspaceId,
1321
+ repositoryId,
1322
+ scopeKind,
1323
+ projectId,
1324
+ title,
1325
+ description,
1326
+ serializeTaskLinear(linear),
1327
+ orderIndex,
1328
+ createdAt,
1329
+ createdAt,
1330
+ );
1331
+ const inserted = this.getTask(input.taskId);
1332
+ if (inserted === null) {
1333
+ throw new Error(`task insert failed: ${input.taskId}`);
1334
+ }
1335
+ this.db.exec('COMMIT');
1336
+ return inserted;
1337
+ } catch (error) {
1338
+ this.db.exec('ROLLBACK');
1339
+ throw error;
1340
+ }
1341
+ }
1342
+
1343
+ getTask(taskId: string): ControlPlaneTaskRecord | null {
1344
+ const row = this.db
1345
+ .prepare(
1346
+ `
1347
+ SELECT
1348
+ task_id,
1349
+ tenant_id,
1350
+ user_id,
1351
+ workspace_id,
1352
+ repository_id,
1353
+ scope_kind,
1354
+ project_id,
1355
+ title,
1356
+ description,
1357
+ linear_json,
1358
+ status,
1359
+ order_index,
1360
+ claimed_by_controller_id,
1361
+ claimed_by_directory_id,
1362
+ branch_name,
1363
+ base_branch,
1364
+ claimed_at,
1365
+ completed_at,
1366
+ created_at,
1367
+ updated_at
1368
+ FROM tasks
1369
+ WHERE task_id = ?
1370
+ `,
1371
+ )
1372
+ .get(taskId);
1373
+ if (row === undefined) {
1374
+ return null;
1375
+ }
1376
+ return normalizeTaskRow(row);
1377
+ }
1378
+
1379
+ listTasks(query: ListTaskQuery = {}): readonly ControlPlaneTaskRecord[] {
1380
+ const clauses: string[] = [];
1381
+ const args: Array<number | string> = [];
1382
+ if (query.tenantId !== undefined) {
1383
+ clauses.push('tenant_id = ?');
1384
+ args.push(query.tenantId);
1385
+ }
1386
+ if (query.userId !== undefined) {
1387
+ clauses.push('user_id = ?');
1388
+ args.push(query.userId);
1389
+ }
1390
+ if (query.workspaceId !== undefined) {
1391
+ clauses.push('workspace_id = ?');
1392
+ args.push(query.workspaceId);
1393
+ }
1394
+ if (query.repositoryId !== undefined) {
1395
+ clauses.push('repository_id = ?');
1396
+ args.push(query.repositoryId);
1397
+ }
1398
+ if (query.projectId !== undefined) {
1399
+ clauses.push('project_id = ?');
1400
+ args.push(query.projectId);
1401
+ }
1402
+ if (query.scopeKind !== undefined) {
1403
+ clauses.push('scope_kind = ?');
1404
+ args.push(query.scopeKind);
1405
+ }
1406
+ if (query.status !== undefined) {
1407
+ clauses.push('status = ?');
1408
+ args.push(query.status);
1409
+ }
1410
+ const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '';
1411
+ const limit = query.limit ?? 5000;
1412
+ const rows = this.db
1413
+ .prepare(
1414
+ `
1415
+ SELECT
1416
+ task_id,
1417
+ tenant_id,
1418
+ user_id,
1419
+ workspace_id,
1420
+ repository_id,
1421
+ scope_kind,
1422
+ project_id,
1423
+ title,
1424
+ description,
1425
+ linear_json,
1426
+ status,
1427
+ order_index,
1428
+ claimed_by_controller_id,
1429
+ claimed_by_directory_id,
1430
+ branch_name,
1431
+ base_branch,
1432
+ claimed_at,
1433
+ completed_at,
1434
+ created_at,
1435
+ updated_at
1436
+ FROM tasks
1437
+ ${where}
1438
+ ORDER BY order_index ASC, created_at ASC, task_id ASC
1439
+ LIMIT ?
1440
+ `,
1441
+ )
1442
+ .all(...args, limit);
1443
+ return rows.map((row) => normalizeTaskRow(row));
1444
+ }
1445
+
1446
+ updateTask(taskId: string, update: UpdateTaskInput): ControlPlaneTaskRecord | null {
1447
+ const existing = this.getTask(taskId);
1448
+ if (existing === null) {
1449
+ return null;
1450
+ }
1451
+ const title =
1452
+ update.title === undefined ? existing.title : normalizeNonEmptyLabel(update.title, 'title');
1453
+ const description =
1454
+ update.description === undefined ? existing.description : update.description;
1455
+ const repositoryId =
1456
+ update.repositoryId === undefined ? existing.repositoryId : update.repositoryId;
1457
+ const projectId = update.projectId === undefined ? existing.projectId : update.projectId;
1458
+ const linear =
1459
+ update.linear === undefined
1460
+ ? existing.linear
1461
+ : update.linear === null
1462
+ ? defaultTaskLinearRecord()
1463
+ : applyTaskLinearInput(existing.linear, update.linear);
1464
+ if (repositoryId !== null) {
1465
+ const repository = this.getActiveRepository(repositoryId);
1466
+ this.assertScopeMatch(existing, repository, 'task');
1467
+ }
1468
+ if (projectId !== null) {
1469
+ const directory = this.getActiveDirectory(projectId);
1470
+ this.assertScopeMatch(existing, directory, 'task');
1471
+ }
1472
+ const scopeKind = this.deriveTaskScopeKind(repositoryId, projectId);
1473
+ const updatedAt = new Date().toISOString();
1474
+ this.db
1475
+ .prepare(
1476
+ `
1477
+ UPDATE tasks
1478
+ SET
1479
+ repository_id = ?,
1480
+ scope_kind = ?,
1481
+ project_id = ?,
1482
+ title = ?,
1483
+ description = ?,
1484
+ linear_json = ?,
1485
+ updated_at = ?
1486
+ WHERE task_id = ?
1487
+ `,
1488
+ )
1489
+ .run(
1490
+ repositoryId,
1491
+ scopeKind,
1492
+ projectId,
1493
+ title,
1494
+ description,
1495
+ serializeTaskLinear(linear),
1496
+ updatedAt,
1497
+ taskId,
1498
+ );
1499
+ return this.getTask(taskId);
1500
+ }
1501
+
1502
+ deleteTask(taskId: string): boolean {
1503
+ this.db.exec('BEGIN IMMEDIATE TRANSACTION');
1504
+ try {
1505
+ const existing = this.getTask(taskId);
1506
+ if (existing === null) {
1507
+ throw new Error(`task not found: ${taskId}`);
1508
+ }
1509
+ this.db
1510
+ .prepare(
1511
+ `
1512
+ DELETE FROM tasks
1513
+ WHERE task_id = ?
1514
+ `,
1515
+ )
1516
+ .run(taskId);
1517
+ this.db.exec('COMMIT');
1518
+ return true;
1519
+ } catch (error) {
1520
+ this.db.exec('ROLLBACK');
1521
+ throw error;
1522
+ }
1523
+ }
1524
+
1525
+ claimTask(input: ClaimTaskInput): ControlPlaneTaskRecord {
1526
+ const controllerId = normalizeNonEmptyLabel(input.controllerId, 'controllerId');
1527
+ this.db.exec('BEGIN IMMEDIATE TRANSACTION');
1528
+ try {
1529
+ const task = this.getTask(input.taskId);
1530
+ if (task === null) {
1531
+ throw new Error(`task not found: ${input.taskId}`);
1532
+ }
1533
+ if (task.status === 'completed') {
1534
+ throw new Error(`cannot claim completed task: ${input.taskId}`);
1535
+ }
1536
+ if (task.status === 'draft') {
1537
+ throw new Error(`cannot claim draft task: ${input.taskId}`);
1538
+ }
1539
+ if (
1540
+ task.status === 'in-progress' &&
1541
+ task.claimedByControllerId !== null &&
1542
+ task.claimedByControllerId !== controllerId
1543
+ ) {
1544
+ throw new Error(`task already claimed: ${input.taskId}`);
1545
+ }
1546
+ let claimedByDirectoryId: string | null = null;
1547
+ if (input.directoryId !== undefined) {
1548
+ const directory = this.getActiveDirectory(input.directoryId);
1549
+ this.assertScopeMatch(task, directory, 'task claim');
1550
+ claimedByDirectoryId = directory.directoryId;
1551
+ }
1552
+
1553
+ const claimedAt = new Date().toISOString();
1554
+ this.db
1555
+ .prepare(
1556
+ `
1557
+ UPDATE tasks
1558
+ SET
1559
+ status = 'in-progress',
1560
+ claimed_by_controller_id = ?,
1561
+ claimed_by_directory_id = ?,
1562
+ branch_name = ?,
1563
+ base_branch = ?,
1564
+ claimed_at = ?,
1565
+ completed_at = NULL,
1566
+ updated_at = ?
1567
+ WHERE task_id = ?
1568
+ `,
1569
+ )
1570
+ .run(
1571
+ controllerId,
1572
+ claimedByDirectoryId,
1573
+ input.branchName ?? null,
1574
+ input.baseBranch ?? null,
1575
+ claimedAt,
1576
+ claimedAt,
1577
+ input.taskId,
1578
+ );
1579
+ const claimed = this.getTask(input.taskId);
1580
+ if (claimed === null) {
1581
+ throw new Error(`task missing after claim: ${input.taskId}`);
1582
+ }
1583
+ this.db.exec('COMMIT');
1584
+ return claimed;
1585
+ } catch (error) {
1586
+ this.db.exec('ROLLBACK');
1587
+ throw error;
1588
+ }
1589
+ }
1590
+
1591
+ completeTask(taskId: string): ControlPlaneTaskRecord {
1592
+ this.db.exec('BEGIN IMMEDIATE TRANSACTION');
1593
+ try {
1594
+ const existing = this.getTask(taskId);
1595
+ if (existing === null) {
1596
+ throw new Error(`task not found: ${taskId}`);
1597
+ }
1598
+ if (existing.status === 'completed') {
1599
+ this.db.exec('COMMIT');
1600
+ return existing;
1601
+ }
1602
+ const completedAt = new Date().toISOString();
1603
+ this.db
1604
+ .prepare(
1605
+ `
1606
+ UPDATE tasks
1607
+ SET
1608
+ status = 'completed',
1609
+ completed_at = ?,
1610
+ updated_at = ?
1611
+ WHERE task_id = ?
1612
+ `,
1613
+ )
1614
+ .run(completedAt, completedAt, taskId);
1615
+ const completed = this.getTask(taskId);
1616
+ if (completed === null) {
1617
+ throw new Error(`task missing after complete: ${taskId}`);
1618
+ }
1619
+ this.db.exec('COMMIT');
1620
+ return completed;
1621
+ } catch (error) {
1622
+ this.db.exec('ROLLBACK');
1623
+ throw error;
1624
+ }
1625
+ }
1626
+
1627
+ readyTask(taskId: string): ControlPlaneTaskRecord {
1628
+ this.db.exec('BEGIN IMMEDIATE TRANSACTION');
1629
+ try {
1630
+ const existing = this.getTask(taskId);
1631
+ if (existing === null) {
1632
+ throw new Error(`task not found: ${taskId}`);
1633
+ }
1634
+ const updatedAt = new Date().toISOString();
1635
+ this.db
1636
+ .prepare(
1637
+ `
1638
+ UPDATE tasks
1639
+ SET
1640
+ status = 'ready',
1641
+ claimed_by_controller_id = NULL,
1642
+ claimed_by_directory_id = NULL,
1643
+ branch_name = NULL,
1644
+ base_branch = NULL,
1645
+ claimed_at = NULL,
1646
+ completed_at = NULL,
1647
+ updated_at = ?
1648
+ WHERE task_id = ?
1649
+ `,
1650
+ )
1651
+ .run(updatedAt, taskId);
1652
+ const ready = this.getTask(taskId);
1653
+ if (ready === null) {
1654
+ throw new Error(`task missing after ready: ${taskId}`);
1655
+ }
1656
+ this.db.exec('COMMIT');
1657
+ return ready;
1658
+ } catch (error) {
1659
+ this.db.exec('ROLLBACK');
1660
+ throw error;
1661
+ }
1662
+ }
1663
+
1664
+ draftTask(taskId: string): ControlPlaneTaskRecord {
1665
+ this.db.exec('BEGIN IMMEDIATE TRANSACTION');
1666
+ try {
1667
+ const existing = this.getTask(taskId);
1668
+ if (existing === null) {
1669
+ throw new Error(`task not found: ${taskId}`);
1670
+ }
1671
+ const updatedAt = new Date().toISOString();
1672
+ this.db
1673
+ .prepare(
1674
+ `
1675
+ UPDATE tasks
1676
+ SET
1677
+ status = 'draft',
1678
+ claimed_by_controller_id = NULL,
1679
+ claimed_by_directory_id = NULL,
1680
+ branch_name = NULL,
1681
+ base_branch = NULL,
1682
+ claimed_at = NULL,
1683
+ completed_at = NULL,
1684
+ updated_at = ?
1685
+ WHERE task_id = ?
1686
+ `,
1687
+ )
1688
+ .run(updatedAt, taskId);
1689
+ const drafted = this.getTask(taskId);
1690
+ if (drafted === null) {
1691
+ throw new Error(`task missing after draft: ${taskId}`);
1692
+ }
1693
+ this.db.exec('COMMIT');
1694
+ return drafted;
1695
+ } catch (error) {
1696
+ this.db.exec('ROLLBACK');
1697
+ throw error;
1698
+ }
1699
+ }
1700
+
1701
+ queueTask(taskId: string): ControlPlaneTaskRecord {
1702
+ return this.readyTask(taskId);
1703
+ }
1704
+
1705
+ reorderTasks(input: ReorderTasksInput): readonly ControlPlaneTaskRecord[] {
1706
+ this.db.exec('BEGIN IMMEDIATE TRANSACTION');
1707
+ try {
1708
+ const normalizedOrder = input.orderedTaskIds
1709
+ .map((taskId) => taskId.trim())
1710
+ .filter((taskId) => taskId.length > 0);
1711
+ if (uniqueValues(normalizedOrder).length !== normalizedOrder.length) {
1712
+ throw new Error('orderedTaskIds contains duplicate ids');
1713
+ }
1714
+ const existing = this.listTasks({
1715
+ tenantId: input.tenantId,
1716
+ userId: input.userId,
1717
+ workspaceId: input.workspaceId,
1718
+ limit: 10000,
1719
+ });
1720
+ const byId = new Map(existing.map((task) => [task.taskId, task] as const));
1721
+ for (const taskId of normalizedOrder) {
1722
+ if (!byId.has(taskId)) {
1723
+ throw new Error(`task not found in scope for reorder: ${taskId}`);
1724
+ }
1725
+ }
1726
+ const orderedSet = new Set(normalizedOrder);
1727
+ const finalOrder = [
1728
+ ...normalizedOrder,
1729
+ ...existing.map((task) => task.taskId).filter((taskId) => !orderedSet.has(taskId)),
1730
+ ];
1731
+ for (let idx = 0; idx < finalOrder.length; idx += 1) {
1732
+ const taskId = finalOrder[idx]!;
1733
+ this.db
1734
+ .prepare(
1735
+ `
1736
+ UPDATE tasks
1737
+ SET
1738
+ order_index = ?,
1739
+ updated_at = ?
1740
+ WHERE task_id = ?
1741
+ `,
1742
+ )
1743
+ .run(idx, new Date().toISOString(), taskId);
1744
+ }
1745
+ const reordered = this.listTasks({
1746
+ tenantId: input.tenantId,
1747
+ userId: input.userId,
1748
+ workspaceId: input.workspaceId,
1749
+ limit: 10000,
1750
+ });
1751
+ this.db.exec('COMMIT');
1752
+ return reordered;
1753
+ } catch (error) {
1754
+ this.db.exec('ROLLBACK');
1755
+ throw error;
1756
+ }
1757
+ }
1758
+
1759
+ getProjectSettings(directoryId: string): ControlPlaneProjectSettingsRecord {
1760
+ const directory = this.getActiveDirectory(directoryId);
1761
+ const row = this.db
1762
+ .prepare(
1763
+ `
1764
+ SELECT
1765
+ directory_id,
1766
+ tenant_id,
1767
+ user_id,
1768
+ workspace_id,
1769
+ pinned_branch,
1770
+ task_focus_mode,
1771
+ thread_spawn_mode,
1772
+ created_at,
1773
+ updated_at
1774
+ FROM project_settings
1775
+ WHERE directory_id = ?
1776
+ `,
1777
+ )
1778
+ .get(directoryId);
1779
+ if (row === undefined) {
1780
+ return {
1781
+ directoryId: directory.directoryId,
1782
+ tenantId: directory.tenantId,
1783
+ userId: directory.userId,
1784
+ workspaceId: directory.workspaceId,
1785
+ pinnedBranch: null,
1786
+ taskFocusMode: 'balanced',
1787
+ threadSpawnMode: 'new-thread',
1788
+ createdAt: directory.createdAt,
1789
+ updatedAt: directory.createdAt,
1790
+ };
1791
+ }
1792
+ return normalizeProjectSettingsRow(row);
1793
+ }
1794
+
1795
+ updateProjectSettings(input: UpdateProjectSettingsInput): ControlPlaneProjectSettingsRecord {
1796
+ this.db.exec('BEGIN IMMEDIATE TRANSACTION');
1797
+ try {
1798
+ const current = this.getProjectSettings(input.directoryId);
1799
+ const pinnedBranch =
1800
+ input.pinnedBranch === undefined
1801
+ ? current.pinnedBranch
1802
+ : input.pinnedBranch === null
1803
+ ? null
1804
+ : normalizeNonEmptyLabel(input.pinnedBranch, 'pinnedBranch');
1805
+ const taskFocusMode = input.taskFocusMode ?? current.taskFocusMode;
1806
+ const threadSpawnMode = input.threadSpawnMode ?? current.threadSpawnMode;
1807
+ const now = new Date().toISOString();
1808
+ this.db
1809
+ .prepare(
1810
+ `
1811
+ INSERT INTO project_settings (
1812
+ directory_id,
1813
+ tenant_id,
1814
+ user_id,
1815
+ workspace_id,
1816
+ pinned_branch,
1817
+ task_focus_mode,
1818
+ thread_spawn_mode,
1819
+ created_at,
1820
+ updated_at
1821
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
1822
+ ON CONFLICT(directory_id) DO UPDATE SET
1823
+ pinned_branch = excluded.pinned_branch,
1824
+ task_focus_mode = excluded.task_focus_mode,
1825
+ thread_spawn_mode = excluded.thread_spawn_mode,
1826
+ updated_at = excluded.updated_at
1827
+ `,
1828
+ )
1829
+ .run(
1830
+ current.directoryId,
1831
+ current.tenantId,
1832
+ current.userId,
1833
+ current.workspaceId,
1834
+ pinnedBranch,
1835
+ taskFocusMode,
1836
+ threadSpawnMode,
1837
+ current.createdAt,
1838
+ now,
1839
+ );
1840
+ const updated = this.getProjectSettings(input.directoryId);
1841
+ this.db.exec('COMMIT');
1842
+ return updated;
1843
+ } catch (error) {
1844
+ this.db.exec('ROLLBACK');
1845
+ throw error;
1846
+ }
1847
+ }
1848
+
1849
+ getAutomationPolicy(input: GetAutomationPolicyInput): ControlPlaneAutomationPolicyRecord | null {
1850
+ const normalized = this.normalizeAutomationScope(input.scope, input.scopeId ?? null);
1851
+ const row = this.db
1852
+ .prepare(
1853
+ `
1854
+ SELECT
1855
+ policy_id,
1856
+ tenant_id,
1857
+ user_id,
1858
+ workspace_id,
1859
+ scope_type,
1860
+ scope_id,
1861
+ automation_enabled,
1862
+ frozen,
1863
+ created_at,
1864
+ updated_at
1865
+ FROM automation_policies
1866
+ WHERE
1867
+ tenant_id = ? AND
1868
+ user_id = ? AND
1869
+ workspace_id = ? AND
1870
+ scope_type = ? AND
1871
+ scope_id = ?
1872
+ `,
1873
+ )
1874
+ .get(input.tenantId, input.userId, input.workspaceId, normalized.scope, normalized.scopeKey);
1875
+ if (row === undefined) {
1876
+ return null;
1877
+ }
1878
+ return normalizeAutomationPolicyRow(row);
1879
+ }
1880
+
1881
+ updateAutomationPolicy(input: UpsertAutomationPolicyInput): ControlPlaneAutomationPolicyRecord {
1882
+ this.db.exec('BEGIN IMMEDIATE TRANSACTION');
1883
+ try {
1884
+ const normalized = this.normalizeAutomationScope(input.scope, input.scopeId ?? null);
1885
+ if (normalized.scope === 'repository') {
1886
+ const repository = this.getActiveRepository(normalized.scopeId as string);
1887
+ this.assertScopeMatch(input, repository, 'automation policy');
1888
+ } else if (normalized.scope === 'project') {
1889
+ const directory = this.getActiveDirectory(normalized.scopeId as string);
1890
+ this.assertScopeMatch(input, directory, 'automation policy');
1891
+ }
1892
+ const existing = this.getAutomationPolicy({
1893
+ tenantId: input.tenantId,
1894
+ userId: input.userId,
1895
+ workspaceId: input.workspaceId,
1896
+ scope: normalized.scope,
1897
+ scopeId: normalized.scopeId,
1898
+ });
1899
+ const automationEnabled = input.automationEnabled ?? existing?.automationEnabled ?? true;
1900
+ const frozen = input.frozen ?? existing?.frozen ?? false;
1901
+ const now = new Date().toISOString();
1902
+ const policyId = existing?.policyId ?? `policy-${randomUUID()}`;
1903
+ this.db
1904
+ .prepare(
1905
+ `
1906
+ INSERT INTO automation_policies (
1907
+ policy_id,
1908
+ tenant_id,
1909
+ user_id,
1910
+ workspace_id,
1911
+ scope_type,
1912
+ scope_id,
1913
+ automation_enabled,
1914
+ frozen,
1915
+ created_at,
1916
+ updated_at
1917
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1918
+ ON CONFLICT(tenant_id, user_id, workspace_id, scope_type, scope_id) DO UPDATE SET
1919
+ automation_enabled = excluded.automation_enabled,
1920
+ frozen = excluded.frozen,
1921
+ updated_at = excluded.updated_at
1922
+ `,
1923
+ )
1924
+ .run(
1925
+ policyId,
1926
+ input.tenantId,
1927
+ input.userId,
1928
+ input.workspaceId,
1929
+ normalized.scope,
1930
+ normalized.scopeKey,
1931
+ automationEnabled ? 1 : 0,
1932
+ frozen ? 1 : 0,
1933
+ existing?.createdAt ?? now,
1934
+ now,
1935
+ );
1936
+ const updated = this.getAutomationPolicy({
1937
+ tenantId: input.tenantId,
1938
+ userId: input.userId,
1939
+ workspaceId: input.workspaceId,
1940
+ scope: normalized.scope,
1941
+ scopeId: normalized.scopeId,
1942
+ });
1943
+ if (updated === null) {
1944
+ throw new Error('automation policy missing after update');
1945
+ }
1946
+ this.db.exec('COMMIT');
1947
+ return updated;
1948
+ } catch (error) {
1949
+ this.db.exec('ROLLBACK');
1950
+ throw error;
1951
+ }
1952
+ }
1953
+
1954
+ upsertGitHubPullRequest(
1955
+ input: UpsertGitHubPullRequestInput,
1956
+ ): ControlPlaneGitHubPullRequestRecord {
1957
+ this.db.exec('BEGIN IMMEDIATE TRANSACTION');
1958
+ try {
1959
+ const repository = this.getActiveRepository(input.repositoryId);
1960
+ this.assertScopeMatch(input, repository, 'github pr');
1961
+ if (input.directoryId !== undefined && input.directoryId !== null) {
1962
+ const directory = this.getActiveDirectory(input.directoryId);
1963
+ this.assertScopeMatch(input, directory, 'github pr');
1964
+ }
1965
+ const existing = this.db
1966
+ .prepare(
1967
+ `
1968
+ SELECT pr_record_id
1969
+ FROM github_pull_requests
1970
+ WHERE repository_id = ? AND number = ?
1971
+ `,
1972
+ )
1973
+ .get(input.repositoryId, input.number) as { pr_record_id: string } | undefined;
1974
+ const now = new Date().toISOString();
1975
+ const closedAt =
1976
+ input.state === 'closed' ? (input.closedAt === undefined ? now : input.closedAt) : null;
1977
+ const ciRollup = input.ciRollup ?? 'none';
1978
+ if (existing === undefined) {
1979
+ this.db
1980
+ .prepare(
1981
+ `
1982
+ INSERT INTO github_pull_requests (
1983
+ pr_record_id,
1984
+ tenant_id,
1985
+ user_id,
1986
+ workspace_id,
1987
+ repository_id,
1988
+ directory_id,
1989
+ owner,
1990
+ repo,
1991
+ number,
1992
+ title,
1993
+ url,
1994
+ author_login,
1995
+ head_branch,
1996
+ head_sha,
1997
+ base_branch,
1998
+ state,
1999
+ is_draft,
2000
+ ci_rollup,
2001
+ created_at,
2002
+ updated_at,
2003
+ closed_at,
2004
+ observed_at
2005
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2006
+ `,
2007
+ )
2008
+ .run(
2009
+ input.prRecordId,
2010
+ input.tenantId,
2011
+ input.userId,
2012
+ input.workspaceId,
2013
+ input.repositoryId,
2014
+ input.directoryId ?? null,
2015
+ input.owner,
2016
+ input.repo,
2017
+ input.number,
2018
+ input.title,
2019
+ input.url,
2020
+ input.authorLogin ?? null,
2021
+ input.headBranch,
2022
+ input.headSha,
2023
+ input.baseBranch,
2024
+ input.state,
2025
+ input.isDraft ? 1 : 0,
2026
+ ciRollup,
2027
+ now,
2028
+ now,
2029
+ closedAt,
2030
+ input.observedAt,
2031
+ );
2032
+ } else {
2033
+ this.db
2034
+ .prepare(
2035
+ `
2036
+ UPDATE github_pull_requests
2037
+ SET
2038
+ directory_id = ?,
2039
+ title = ?,
2040
+ url = ?,
2041
+ author_login = ?,
2042
+ head_branch = ?,
2043
+ head_sha = ?,
2044
+ base_branch = ?,
2045
+ state = ?,
2046
+ is_draft = ?,
2047
+ ci_rollup = ?,
2048
+ updated_at = ?,
2049
+ closed_at = ?,
2050
+ observed_at = ?
2051
+ WHERE pr_record_id = ?
2052
+ `,
2053
+ )
2054
+ .run(
2055
+ input.directoryId ?? null,
2056
+ input.title,
2057
+ input.url,
2058
+ input.authorLogin ?? null,
2059
+ input.headBranch,
2060
+ input.headSha,
2061
+ input.baseBranch,
2062
+ input.state,
2063
+ input.isDraft ? 1 : 0,
2064
+ ciRollup,
2065
+ now,
2066
+ closedAt,
2067
+ input.observedAt,
2068
+ existing.pr_record_id,
2069
+ );
2070
+ }
2071
+ const recordId = existing?.pr_record_id ?? input.prRecordId;
2072
+ const updated = this.getGitHubPullRequest(recordId);
2073
+ if (updated === null) {
2074
+ throw new Error(`github pr missing after upsert: ${recordId}`);
2075
+ }
2076
+ this.db.exec('COMMIT');
2077
+ return updated;
2078
+ } catch (error) {
2079
+ this.db.exec('ROLLBACK');
2080
+ throw error;
2081
+ }
2082
+ }
2083
+
2084
+ getGitHubPullRequest(prRecordId: string): ControlPlaneGitHubPullRequestRecord | null {
2085
+ const row = this.db
2086
+ .prepare(
2087
+ `
2088
+ SELECT
2089
+ pr_record_id,
2090
+ tenant_id,
2091
+ user_id,
2092
+ workspace_id,
2093
+ repository_id,
2094
+ directory_id,
2095
+ owner,
2096
+ repo,
2097
+ number,
2098
+ title,
2099
+ url,
2100
+ author_login,
2101
+ head_branch,
2102
+ head_sha,
2103
+ base_branch,
2104
+ state,
2105
+ is_draft,
2106
+ ci_rollup,
2107
+ created_at,
2108
+ updated_at,
2109
+ closed_at,
2110
+ observed_at
2111
+ FROM github_pull_requests
2112
+ WHERE pr_record_id = ?
2113
+ `,
2114
+ )
2115
+ .get(prRecordId);
2116
+ if (row === undefined) {
2117
+ return null;
2118
+ }
2119
+ return normalizeGitHubPullRequestRow(row);
2120
+ }
2121
+
2122
+ listGitHubPullRequests(
2123
+ query: ListGitHubPullRequestQuery = {},
2124
+ ): ControlPlaneGitHubPullRequestRecord[] {
2125
+ const clauses: string[] = [];
2126
+ const args: unknown[] = [];
2127
+ if (query.tenantId !== undefined) {
2128
+ clauses.push('tenant_id = ?');
2129
+ args.push(query.tenantId);
2130
+ }
2131
+ if (query.userId !== undefined) {
2132
+ clauses.push('user_id = ?');
2133
+ args.push(query.userId);
2134
+ }
2135
+ if (query.workspaceId !== undefined) {
2136
+ clauses.push('workspace_id = ?');
2137
+ args.push(query.workspaceId);
2138
+ }
2139
+ if (query.repositoryId !== undefined) {
2140
+ clauses.push('repository_id = ?');
2141
+ args.push(query.repositoryId);
2142
+ }
2143
+ if (query.directoryId !== undefined) {
2144
+ clauses.push('directory_id = ?');
2145
+ args.push(query.directoryId);
2146
+ }
2147
+ if (query.headBranch !== undefined) {
2148
+ clauses.push('head_branch = ?');
2149
+ args.push(query.headBranch);
2150
+ }
2151
+ if (query.state !== undefined) {
2152
+ clauses.push('state = ?');
2153
+ args.push(query.state);
2154
+ }
2155
+ const whereClause = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '';
2156
+ const limitClause = query.limit === undefined ? '' : 'LIMIT ?';
2157
+ if (query.limit !== undefined) {
2158
+ args.push(query.limit);
2159
+ }
2160
+ const rows = this.db
2161
+ .prepare(
2162
+ `
2163
+ SELECT
2164
+ pr_record_id,
2165
+ tenant_id,
2166
+ user_id,
2167
+ workspace_id,
2168
+ repository_id,
2169
+ directory_id,
2170
+ owner,
2171
+ repo,
2172
+ number,
2173
+ title,
2174
+ url,
2175
+ author_login,
2176
+ head_branch,
2177
+ head_sha,
2178
+ base_branch,
2179
+ state,
2180
+ is_draft,
2181
+ ci_rollup,
2182
+ created_at,
2183
+ updated_at,
2184
+ closed_at,
2185
+ observed_at
2186
+ FROM github_pull_requests
2187
+ ${whereClause}
2188
+ ORDER BY updated_at DESC, number DESC
2189
+ ${limitClause}
2190
+ `,
2191
+ )
2192
+ .all(...args);
2193
+ return rows.map((row) => normalizeGitHubPullRequestRow(row));
2194
+ }
2195
+
2196
+ updateGitHubPullRequestCiRollup(
2197
+ prRecordId: string,
2198
+ ciRollup: ControlPlaneGitHubCiRollup,
2199
+ observedAt: string,
2200
+ ): ControlPlaneGitHubPullRequestRecord | null {
2201
+ const existing = this.getGitHubPullRequest(prRecordId);
2202
+ if (existing === null) {
2203
+ return null;
2204
+ }
2205
+ const now = new Date().toISOString();
2206
+ this.db
2207
+ .prepare(
2208
+ `
2209
+ UPDATE github_pull_requests
2210
+ SET ci_rollup = ?, observed_at = ?, updated_at = ?
2211
+ WHERE pr_record_id = ?
2212
+ `,
2213
+ )
2214
+ .run(ciRollup, observedAt, now, prRecordId);
2215
+ return this.getGitHubPullRequest(prRecordId);
2216
+ }
2217
+
2218
+ replaceGitHubPrJobs(input: ReplaceGitHubPrJobsInput): ControlPlaneGitHubPrJobRecord[] {
2219
+ this.db.exec('BEGIN IMMEDIATE TRANSACTION');
2220
+ try {
2221
+ const pr = this.getGitHubPullRequest(input.prRecordId);
2222
+ if (pr === null) {
2223
+ throw new Error(`github pr not found: ${input.prRecordId}`);
2224
+ }
2225
+ this.assertScopeMatch(input, pr, 'github pr jobs');
2226
+ if (pr.repositoryId !== input.repositoryId) {
2227
+ throw new Error('github pr jobs repository mismatch');
2228
+ }
2229
+ this.db
2230
+ .prepare(
2231
+ `
2232
+ DELETE FROM github_pr_jobs
2233
+ WHERE pr_record_id = ?
2234
+ `,
2235
+ )
2236
+ .run(input.prRecordId);
2237
+ const now = new Date().toISOString();
2238
+ const insert = this.db.prepare(
2239
+ `
2240
+ INSERT INTO github_pr_jobs (
2241
+ job_record_id,
2242
+ tenant_id,
2243
+ user_id,
2244
+ workspace_id,
2245
+ repository_id,
2246
+ pr_record_id,
2247
+ provider,
2248
+ external_id,
2249
+ name,
2250
+ status,
2251
+ conclusion,
2252
+ url,
2253
+ started_at,
2254
+ completed_at,
2255
+ observed_at,
2256
+ updated_at
2257
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2258
+ `,
2259
+ );
2260
+ for (const job of input.jobs) {
2261
+ insert.run(
2262
+ job.jobRecordId,
2263
+ input.tenantId,
2264
+ input.userId,
2265
+ input.workspaceId,
2266
+ input.repositoryId,
2267
+ input.prRecordId,
2268
+ job.provider,
2269
+ job.externalId,
2270
+ job.name,
2271
+ job.status,
2272
+ job.conclusion ?? null,
2273
+ job.url ?? null,
2274
+ job.startedAt ?? null,
2275
+ job.completedAt ?? null,
2276
+ input.observedAt,
2277
+ now,
2278
+ );
2279
+ }
2280
+ const listed = this.listGitHubPrJobs({
2281
+ prRecordId: input.prRecordId,
2282
+ });
2283
+ this.db.exec('COMMIT');
2284
+ return listed;
2285
+ } catch (error) {
2286
+ this.db.exec('ROLLBACK');
2287
+ throw error;
2288
+ }
2289
+ }
2290
+
2291
+ listGitHubPrJobs(query: ListGitHubPrJobsQuery = {}): ControlPlaneGitHubPrJobRecord[] {
2292
+ const clauses: string[] = [];
2293
+ const args: unknown[] = [];
2294
+ if (query.tenantId !== undefined) {
2295
+ clauses.push('tenant_id = ?');
2296
+ args.push(query.tenantId);
2297
+ }
2298
+ if (query.userId !== undefined) {
2299
+ clauses.push('user_id = ?');
2300
+ args.push(query.userId);
2301
+ }
2302
+ if (query.workspaceId !== undefined) {
2303
+ clauses.push('workspace_id = ?');
2304
+ args.push(query.workspaceId);
2305
+ }
2306
+ if (query.repositoryId !== undefined) {
2307
+ clauses.push('repository_id = ?');
2308
+ args.push(query.repositoryId);
2309
+ }
2310
+ if (query.prRecordId !== undefined) {
2311
+ clauses.push('pr_record_id = ?');
2312
+ args.push(query.prRecordId);
2313
+ }
2314
+ const whereClause = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '';
2315
+ const limitClause = query.limit === undefined ? '' : 'LIMIT ?';
2316
+ if (query.limit !== undefined) {
2317
+ args.push(query.limit);
2318
+ }
2319
+ const rows = this.db
2320
+ .prepare(
2321
+ `
2322
+ SELECT
2323
+ job_record_id,
2324
+ tenant_id,
2325
+ user_id,
2326
+ workspace_id,
2327
+ repository_id,
2328
+ pr_record_id,
2329
+ provider,
2330
+ external_id,
2331
+ name,
2332
+ status,
2333
+ conclusion,
2334
+ url,
2335
+ started_at,
2336
+ completed_at,
2337
+ observed_at,
2338
+ updated_at
2339
+ FROM github_pr_jobs
2340
+ ${whereClause}
2341
+ ORDER BY name ASC, external_id ASC
2342
+ ${limitClause}
2343
+ `,
2344
+ )
2345
+ .all(...args);
2346
+ return rows.map((row) => normalizeGitHubPrJobRow(row));
2347
+ }
2348
+
2349
+ upsertGitHubSyncState(input: UpsertGitHubSyncStateInput): ControlPlaneGitHubSyncStateRecord {
2350
+ this.db.exec('BEGIN IMMEDIATE TRANSACTION');
2351
+ try {
2352
+ const repository = this.getActiveRepository(input.repositoryId);
2353
+ this.assertScopeMatch(input, repository, 'github sync state');
2354
+ if (input.directoryId !== undefined && input.directoryId !== null) {
2355
+ const directory = this.getActiveDirectory(input.directoryId);
2356
+ this.assertScopeMatch(input, directory, 'github sync state');
2357
+ }
2358
+ this.db
2359
+ .prepare(
2360
+ `
2361
+ INSERT INTO github_sync_state (
2362
+ state_id,
2363
+ tenant_id,
2364
+ user_id,
2365
+ workspace_id,
2366
+ repository_id,
2367
+ directory_id,
2368
+ branch_name,
2369
+ last_sync_at,
2370
+ last_success_at,
2371
+ last_error,
2372
+ last_error_at
2373
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2374
+ ON CONFLICT(state_id) DO UPDATE SET
2375
+ last_sync_at = excluded.last_sync_at,
2376
+ last_success_at = excluded.last_success_at,
2377
+ last_error = excluded.last_error,
2378
+ last_error_at = excluded.last_error_at
2379
+ `,
2380
+ )
2381
+ .run(
2382
+ input.stateId,
2383
+ input.tenantId,
2384
+ input.userId,
2385
+ input.workspaceId,
2386
+ input.repositoryId,
2387
+ input.directoryId ?? null,
2388
+ input.branchName,
2389
+ input.lastSyncAt,
2390
+ input.lastSuccessAt ?? null,
2391
+ input.lastError ?? null,
2392
+ input.lastErrorAt ?? null,
2393
+ );
2394
+ const updated = this.getGitHubSyncState(input.stateId);
2395
+ if (updated === null) {
2396
+ throw new Error(`github sync state missing after upsert: ${input.stateId}`);
2397
+ }
2398
+ this.db.exec('COMMIT');
2399
+ return updated;
2400
+ } catch (error) {
2401
+ this.db.exec('ROLLBACK');
2402
+ throw error;
2403
+ }
2404
+ }
2405
+
2406
+ getGitHubSyncState(stateId: string): ControlPlaneGitHubSyncStateRecord | null {
2407
+ const row = this.db
2408
+ .prepare(
2409
+ `
2410
+ SELECT
2411
+ state_id,
2412
+ tenant_id,
2413
+ user_id,
2414
+ workspace_id,
2415
+ repository_id,
2416
+ directory_id,
2417
+ branch_name,
2418
+ last_sync_at,
2419
+ last_success_at,
2420
+ last_error,
2421
+ last_error_at
2422
+ FROM github_sync_state
2423
+ WHERE state_id = ?
2424
+ `,
2425
+ )
2426
+ .get(stateId);
2427
+ if (row === undefined) {
2428
+ return null;
2429
+ }
2430
+ return normalizeGitHubSyncStateRow(row);
2431
+ }
2432
+
2433
+ listGitHubSyncState(query: ListGitHubSyncStateQuery = {}): ControlPlaneGitHubSyncStateRecord[] {
2434
+ const clauses: string[] = [];
2435
+ const args: unknown[] = [];
2436
+ if (query.tenantId !== undefined) {
2437
+ clauses.push('tenant_id = ?');
2438
+ args.push(query.tenantId);
2439
+ }
2440
+ if (query.userId !== undefined) {
2441
+ clauses.push('user_id = ?');
2442
+ args.push(query.userId);
2443
+ }
2444
+ if (query.workspaceId !== undefined) {
2445
+ clauses.push('workspace_id = ?');
2446
+ args.push(query.workspaceId);
2447
+ }
2448
+ if (query.repositoryId !== undefined) {
2449
+ clauses.push('repository_id = ?');
2450
+ args.push(query.repositoryId);
2451
+ }
2452
+ if (query.directoryId !== undefined) {
2453
+ clauses.push('directory_id = ?');
2454
+ args.push(query.directoryId);
2455
+ }
2456
+ if (query.branchName !== undefined) {
2457
+ clauses.push('branch_name = ?');
2458
+ args.push(query.branchName);
2459
+ }
2460
+ const whereClause = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '';
2461
+ const limitClause = query.limit === undefined ? '' : 'LIMIT ?';
2462
+ if (query.limit !== undefined) {
2463
+ args.push(query.limit);
2464
+ }
2465
+ const rows = this.db
2466
+ .prepare(
2467
+ `
2468
+ SELECT
2469
+ state_id,
2470
+ tenant_id,
2471
+ user_id,
2472
+ workspace_id,
2473
+ repository_id,
2474
+ directory_id,
2475
+ branch_name,
2476
+ last_sync_at,
2477
+ last_success_at,
2478
+ last_error,
2479
+ last_error_at
2480
+ FROM github_sync_state
2481
+ ${whereClause}
2482
+ ORDER BY last_sync_at DESC, state_id ASC
2483
+ ${limitClause}
2484
+ `,
2485
+ )
2486
+ .all(...args);
2487
+ return rows.map((row) => normalizeGitHubSyncStateRow(row));
2488
+ }
2489
+
2490
+ private findRepositoryByScopeRemoteUrl(
2491
+ tenantId: string,
2492
+ userId: string,
2493
+ workspaceId: string,
2494
+ remoteUrl: string,
2495
+ ): ControlPlaneRepositoryRecord | null {
2496
+ const row = this.db
2497
+ .prepare(
2498
+ `
2499
+ SELECT
2500
+ repository_id,
2501
+ tenant_id,
2502
+ user_id,
2503
+ workspace_id,
2504
+ name,
2505
+ remote_url,
2506
+ default_branch,
2507
+ metadata_json,
2508
+ created_at,
2509
+ archived_at
2510
+ FROM repositories
2511
+ WHERE tenant_id = ? AND user_id = ? AND workspace_id = ? AND remote_url = ?
2512
+ `,
2513
+ )
2514
+ .get(tenantId, userId, workspaceId, remoteUrl);
2515
+ if (row === undefined) {
2516
+ return null;
2517
+ }
2518
+ return normalizeRepositoryRow(row);
2519
+ }
2520
+
2521
+ private getActiveRepository(repositoryId: string): ControlPlaneRepositoryRecord {
2522
+ const repository = this.getRepository(repositoryId);
2523
+ if (repository === null || repository.archivedAt !== null) {
2524
+ throw new Error(`repository not found: ${repositoryId}`);
2525
+ }
2526
+ return repository;
2527
+ }
2528
+
2529
+ private getActiveDirectory(directoryId: string): ControlPlaneDirectoryRecord {
2530
+ const directory = this.getDirectory(directoryId);
2531
+ if (directory === null || directory.archivedAt !== null) {
2532
+ throw new Error(`directory not found: ${directoryId}`);
2533
+ }
2534
+ return directory;
2535
+ }
2536
+
2537
+ private assertScopeMatch(
2538
+ left: { tenantId: string; userId: string; workspaceId: string },
2539
+ right: { tenantId: string; userId: string; workspaceId: string },
2540
+ context: string,
2541
+ ): void {
2542
+ if (
2543
+ left.tenantId !== right.tenantId ||
2544
+ left.userId !== right.userId ||
2545
+ left.workspaceId !== right.workspaceId
2546
+ ) {
2547
+ throw new Error(`${context} scope mismatch`);
2548
+ }
2549
+ }
2550
+
2551
+ private deriveTaskScopeKind(
2552
+ repositoryId: string | null,
2553
+ projectId: string | null,
2554
+ ): ControlPlaneTaskScopeKind {
2555
+ if (projectId !== null) {
2556
+ return 'project';
2557
+ }
2558
+ if (repositoryId !== null) {
2559
+ return 'repository';
2560
+ }
2561
+ return 'global';
2562
+ }
2563
+
2564
+ private normalizeAutomationScope(
2565
+ scope: ControlPlaneAutomationPolicyScope,
2566
+ scopeId: string | null,
2567
+ ): {
2568
+ scope: ControlPlaneAutomationPolicyScope;
2569
+ scopeId: string | null;
2570
+ scopeKey: string;
2571
+ } {
2572
+ if (scope === 'global') {
2573
+ return {
2574
+ scope,
2575
+ scopeId: null,
2576
+ scopeKey: '',
2577
+ };
2578
+ }
2579
+ const normalizedScopeId = normalizeNonEmptyLabel(scopeId ?? '', 'scopeId');
2580
+ return {
2581
+ scope,
2582
+ scopeId: normalizedScopeId,
2583
+ scopeKey: normalizedScopeId,
2584
+ };
2585
+ }
2586
+
2587
+ private nextTaskOrderIndex(tenantId: string, userId: string, workspaceId: string): number {
2588
+ const row = this.db
2589
+ .prepare(
2590
+ `
2591
+ SELECT COALESCE(MAX(order_index), -1) + 1 AS next_order
2592
+ FROM tasks
2593
+ WHERE tenant_id = ? AND user_id = ? AND workspace_id = ?
2594
+ `,
2595
+ )
2596
+ .get(tenantId, userId, workspaceId);
2597
+ const asRow = asRecord(row);
2598
+ const next = asNumberOrNull(asRow.next_order, 'next_order') as number;
2599
+ return Math.max(0, Math.floor(next));
2600
+ }
2601
+
2602
+ private findDirectoryByScopePath(
2603
+ tenantId: string,
2604
+ userId: string,
2605
+ workspaceId: string,
2606
+ path: string,
2607
+ ): ControlPlaneDirectoryRecord | null {
2608
+ const row = this.db
2609
+ .prepare(
2610
+ `
2611
+ SELECT
2612
+ directory_id,
2613
+ tenant_id,
2614
+ user_id,
2615
+ workspace_id,
2616
+ path,
2617
+ created_at,
2618
+ archived_at
2619
+ FROM directories
2620
+ WHERE tenant_id = ? AND user_id = ? AND workspace_id = ? AND path = ?
2621
+ `,
2622
+ )
2623
+ .get(tenantId, userId, workspaceId, path);
2624
+ if (row === undefined) {
2625
+ return null;
2626
+ }
2627
+ return normalizeDirectoryRow(row);
2628
+ }
2629
+
2630
+ private initializeSchema(): void {
2631
+ this.db.exec(`
2632
+ CREATE TABLE IF NOT EXISTS directories (
2633
+ directory_id TEXT PRIMARY KEY,
2634
+ tenant_id TEXT NOT NULL,
2635
+ user_id TEXT NOT NULL,
2636
+ workspace_id TEXT NOT NULL,
2637
+ path TEXT NOT NULL,
2638
+ created_at TEXT NOT NULL,
2639
+ archived_at TEXT,
2640
+ UNIQUE(tenant_id, user_id, workspace_id, path)
2641
+ );
2642
+ `);
2643
+ this.db.exec(`
2644
+ CREATE INDEX IF NOT EXISTS idx_directories_scope
2645
+ ON directories (tenant_id, user_id, workspace_id, created_at);
2646
+ `);
2647
+ this.db.exec(`
2648
+ CREATE TABLE IF NOT EXISTS conversations (
2649
+ conversation_id TEXT PRIMARY KEY,
2650
+ directory_id TEXT NOT NULL REFERENCES directories(directory_id),
2651
+ tenant_id TEXT NOT NULL,
2652
+ user_id TEXT NOT NULL,
2653
+ workspace_id TEXT NOT NULL,
2654
+ title TEXT NOT NULL,
2655
+ agent_type TEXT NOT NULL,
2656
+ created_at TEXT NOT NULL,
2657
+ archived_at TEXT,
2658
+ runtime_status TEXT NOT NULL,
2659
+ runtime_status_model_json TEXT NOT NULL DEFAULT '${DEFAULT_RUNTIME_STATUS_MODEL_JSON}',
2660
+ runtime_live INTEGER NOT NULL,
2661
+ runtime_attention_reason TEXT,
2662
+ runtime_process_id INTEGER,
2663
+ runtime_last_event_at TEXT,
2664
+ runtime_last_exit_code INTEGER,
2665
+ runtime_last_exit_signal TEXT,
2666
+ adapter_state_json TEXT NOT NULL DEFAULT '{}'
2667
+ );
2668
+ `);
2669
+ this.db.exec(`
2670
+ CREATE INDEX IF NOT EXISTS idx_conversations_directory
2671
+ ON conversations (directory_id, created_at);
2672
+ `);
2673
+ this.db.exec(`
2674
+ CREATE INDEX IF NOT EXISTS idx_conversations_scope
2675
+ ON conversations (tenant_id, user_id, workspace_id, created_at);
2676
+ `);
2677
+ this.ensureColumnExists(
2678
+ 'conversations',
2679
+ 'adapter_state_json',
2680
+ `adapter_state_json TEXT NOT NULL DEFAULT '{}'`,
2681
+ );
2682
+ this.ensureColumnExists(
2683
+ 'conversations',
2684
+ 'runtime_status_model_json',
2685
+ `runtime_status_model_json TEXT NOT NULL DEFAULT '${DEFAULT_RUNTIME_STATUS_MODEL_JSON}'`,
2686
+ );
2687
+ this.db.exec(`
2688
+ CREATE TABLE IF NOT EXISTS session_telemetry (
2689
+ telemetry_id INTEGER PRIMARY KEY AUTOINCREMENT,
2690
+ source TEXT NOT NULL,
2691
+ session_id TEXT,
2692
+ provider_thread_id TEXT,
2693
+ event_name TEXT,
2694
+ severity TEXT,
2695
+ summary TEXT,
2696
+ observed_at TEXT NOT NULL,
2697
+ ingested_at TEXT NOT NULL,
2698
+ payload_json TEXT NOT NULL,
2699
+ fingerprint TEXT NOT NULL UNIQUE
2700
+ );
2701
+ `);
2702
+ this.db.exec(`
2703
+ CREATE INDEX IF NOT EXISTS idx_session_telemetry_session
2704
+ ON session_telemetry (session_id, observed_at DESC, telemetry_id DESC);
2705
+ `);
2706
+ this.db.exec(`
2707
+ CREATE INDEX IF NOT EXISTS idx_session_telemetry_thread
2708
+ ON session_telemetry (provider_thread_id, observed_at DESC, telemetry_id DESC);
2709
+ `);
2710
+ this.db.exec(`
2711
+ CREATE TABLE IF NOT EXISTS repositories (
2712
+ repository_id TEXT PRIMARY KEY,
2713
+ tenant_id TEXT NOT NULL,
2714
+ user_id TEXT NOT NULL,
2715
+ workspace_id TEXT NOT NULL,
2716
+ name TEXT NOT NULL,
2717
+ remote_url TEXT NOT NULL,
2718
+ default_branch TEXT NOT NULL,
2719
+ metadata_json TEXT NOT NULL DEFAULT '{}',
2720
+ created_at TEXT NOT NULL,
2721
+ archived_at TEXT,
2722
+ UNIQUE(tenant_id, user_id, workspace_id, remote_url)
2723
+ );
2724
+ `);
2725
+ this.db.exec(`
2726
+ CREATE INDEX IF NOT EXISTS idx_repositories_scope
2727
+ ON repositories (tenant_id, user_id, workspace_id, created_at);
2728
+ `);
2729
+ this.db.exec(`
2730
+ CREATE TABLE IF NOT EXISTS tasks (
2731
+ task_id TEXT PRIMARY KEY,
2732
+ tenant_id TEXT NOT NULL,
2733
+ user_id TEXT NOT NULL,
2734
+ workspace_id TEXT NOT NULL,
2735
+ repository_id TEXT REFERENCES repositories(repository_id),
2736
+ scope_kind TEXT NOT NULL DEFAULT 'global',
2737
+ project_id TEXT REFERENCES directories(directory_id),
2738
+ title TEXT NOT NULL,
2739
+ description TEXT NOT NULL DEFAULT '',
2740
+ linear_json TEXT NOT NULL DEFAULT '{}',
2741
+ status TEXT NOT NULL,
2742
+ order_index INTEGER NOT NULL,
2743
+ claimed_by_controller_id TEXT,
2744
+ claimed_by_directory_id TEXT REFERENCES directories(directory_id),
2745
+ branch_name TEXT,
2746
+ base_branch TEXT,
2747
+ claimed_at TEXT,
2748
+ completed_at TEXT,
2749
+ created_at TEXT NOT NULL,
2750
+ updated_at TEXT NOT NULL
2751
+ );
2752
+ `);
2753
+ this.db.exec(`
2754
+ CREATE INDEX IF NOT EXISTS idx_tasks_scope
2755
+ ON tasks (tenant_id, user_id, workspace_id, order_index, created_at, task_id);
2756
+ `);
2757
+ this.ensureColumnExists('tasks', 'linear_json', `linear_json TEXT NOT NULL DEFAULT '{}'`);
2758
+ this.ensureColumnExists('tasks', 'scope_kind', `scope_kind TEXT NOT NULL DEFAULT 'global'`);
2759
+ this.ensureColumnExists(
2760
+ 'tasks',
2761
+ 'project_id',
2762
+ `project_id TEXT REFERENCES directories(directory_id)`,
2763
+ );
2764
+ this.db.exec(`
2765
+ CREATE INDEX IF NOT EXISTS idx_tasks_scope_kind
2766
+ ON tasks (tenant_id, user_id, workspace_id, scope_kind, repository_id, project_id, order_index);
2767
+ `);
2768
+ this.db.exec(`
2769
+ CREATE INDEX IF NOT EXISTS idx_tasks_status
2770
+ ON tasks (status, updated_at, task_id);
2771
+ `);
2772
+ this.db.exec(`
2773
+ UPDATE tasks
2774
+ SET scope_kind = CASE
2775
+ WHEN project_id IS NOT NULL THEN 'project'
2776
+ WHEN repository_id IS NOT NULL THEN 'repository'
2777
+ ELSE 'global'
2778
+ END
2779
+ WHERE scope_kind NOT IN ('global', 'repository', 'project');
2780
+ `);
2781
+ this.db.exec(`
2782
+ UPDATE tasks
2783
+ SET scope_kind = 'repository'
2784
+ WHERE scope_kind = 'global' AND repository_id IS NOT NULL AND project_id IS NULL;
2785
+ `);
2786
+
2787
+ this.db.exec(`
2788
+ CREATE TABLE IF NOT EXISTS project_settings (
2789
+ directory_id TEXT PRIMARY KEY REFERENCES directories(directory_id),
2790
+ tenant_id TEXT NOT NULL,
2791
+ user_id TEXT NOT NULL,
2792
+ workspace_id TEXT NOT NULL,
2793
+ pinned_branch TEXT,
2794
+ task_focus_mode TEXT NOT NULL DEFAULT 'balanced',
2795
+ thread_spawn_mode TEXT NOT NULL DEFAULT 'new-thread',
2796
+ created_at TEXT NOT NULL,
2797
+ updated_at TEXT NOT NULL
2798
+ );
2799
+ `);
2800
+ this.db.exec(`
2801
+ CREATE INDEX IF NOT EXISTS idx_project_settings_scope
2802
+ ON project_settings (tenant_id, user_id, workspace_id, directory_id);
2803
+ `);
2804
+
2805
+ this.db.exec(`
2806
+ CREATE TABLE IF NOT EXISTS automation_policies (
2807
+ policy_id TEXT PRIMARY KEY,
2808
+ tenant_id TEXT NOT NULL,
2809
+ user_id TEXT NOT NULL,
2810
+ workspace_id TEXT NOT NULL,
2811
+ scope_type TEXT NOT NULL,
2812
+ scope_id TEXT NOT NULL,
2813
+ automation_enabled INTEGER NOT NULL,
2814
+ frozen INTEGER NOT NULL,
2815
+ created_at TEXT NOT NULL,
2816
+ updated_at TEXT NOT NULL,
2817
+ UNIQUE (tenant_id, user_id, workspace_id, scope_type, scope_id)
2818
+ );
2819
+ `);
2820
+ this.db.exec(`
2821
+ CREATE INDEX IF NOT EXISTS idx_automation_policies_scope
2822
+ ON automation_policies (tenant_id, user_id, workspace_id, scope_type, scope_id);
2823
+ `);
2824
+
2825
+ this.db.exec(`
2826
+ CREATE TABLE IF NOT EXISTS github_pull_requests (
2827
+ pr_record_id TEXT PRIMARY KEY,
2828
+ tenant_id TEXT NOT NULL,
2829
+ user_id TEXT NOT NULL,
2830
+ workspace_id TEXT NOT NULL,
2831
+ repository_id TEXT NOT NULL REFERENCES repositories(repository_id),
2832
+ directory_id TEXT REFERENCES directories(directory_id),
2833
+ owner TEXT NOT NULL,
2834
+ repo TEXT NOT NULL,
2835
+ number INTEGER NOT NULL,
2836
+ title TEXT NOT NULL,
2837
+ url TEXT NOT NULL,
2838
+ author_login TEXT,
2839
+ head_branch TEXT NOT NULL,
2840
+ head_sha TEXT NOT NULL,
2841
+ base_branch TEXT NOT NULL,
2842
+ state TEXT NOT NULL,
2843
+ is_draft INTEGER NOT NULL,
2844
+ ci_rollup TEXT NOT NULL DEFAULT 'none',
2845
+ created_at TEXT NOT NULL,
2846
+ updated_at TEXT NOT NULL,
2847
+ closed_at TEXT,
2848
+ observed_at TEXT NOT NULL,
2849
+ UNIQUE(repository_id, number)
2850
+ );
2851
+ `);
2852
+ this.db.exec(`
2853
+ CREATE INDEX IF NOT EXISTS idx_github_pull_requests_scope
2854
+ ON github_pull_requests (
2855
+ tenant_id,
2856
+ user_id,
2857
+ workspace_id,
2858
+ repository_id,
2859
+ state,
2860
+ head_branch,
2861
+ updated_at
2862
+ );
2863
+ `);
2864
+
2865
+ this.db.exec(`
2866
+ CREATE TABLE IF NOT EXISTS github_pr_jobs (
2867
+ job_record_id TEXT PRIMARY KEY,
2868
+ tenant_id TEXT NOT NULL,
2869
+ user_id TEXT NOT NULL,
2870
+ workspace_id TEXT NOT NULL,
2871
+ repository_id TEXT NOT NULL REFERENCES repositories(repository_id),
2872
+ pr_record_id TEXT NOT NULL REFERENCES github_pull_requests(pr_record_id),
2873
+ provider TEXT NOT NULL,
2874
+ external_id TEXT NOT NULL,
2875
+ name TEXT NOT NULL,
2876
+ status TEXT NOT NULL,
2877
+ conclusion TEXT,
2878
+ url TEXT,
2879
+ started_at TEXT,
2880
+ completed_at TEXT,
2881
+ observed_at TEXT NOT NULL,
2882
+ updated_at TEXT NOT NULL,
2883
+ UNIQUE(pr_record_id, provider, external_id)
2884
+ );
2885
+ `);
2886
+ this.db.exec(`
2887
+ CREATE INDEX IF NOT EXISTS idx_github_pr_jobs_scope
2888
+ ON github_pr_jobs (
2889
+ tenant_id,
2890
+ user_id,
2891
+ workspace_id,
2892
+ repository_id,
2893
+ pr_record_id,
2894
+ updated_at
2895
+ );
2896
+ `);
2897
+
2898
+ this.db.exec(`
2899
+ CREATE TABLE IF NOT EXISTS github_sync_state (
2900
+ state_id TEXT PRIMARY KEY,
2901
+ tenant_id TEXT NOT NULL,
2902
+ user_id TEXT NOT NULL,
2903
+ workspace_id TEXT NOT NULL,
2904
+ repository_id TEXT NOT NULL REFERENCES repositories(repository_id),
2905
+ directory_id TEXT REFERENCES directories(directory_id),
2906
+ branch_name TEXT NOT NULL,
2907
+ last_sync_at TEXT NOT NULL,
2908
+ last_success_at TEXT,
2909
+ last_error TEXT,
2910
+ last_error_at TEXT
2911
+ );
2912
+ `);
2913
+ this.db.exec(`
2914
+ CREATE INDEX IF NOT EXISTS idx_github_sync_state_scope
2915
+ ON github_sync_state (
2916
+ tenant_id,
2917
+ user_id,
2918
+ workspace_id,
2919
+ repository_id,
2920
+ branch_name,
2921
+ last_sync_at
2922
+ );
2923
+ `);
2924
+ }
2925
+
2926
+ private configureConnection(): void {
2927
+ this.db.exec('PRAGMA journal_mode = WAL;');
2928
+ this.db.exec('PRAGMA synchronous = NORMAL;');
2929
+ this.db.exec('PRAGMA busy_timeout = 2000;');
2930
+ }
2931
+
2932
+ private ensureColumnExists(table: string, column: string, definition: string): void {
2933
+ const rows = this.db.prepare(`PRAGMA table_info(${table})`).all();
2934
+ const exists = rows.some((row) => {
2935
+ const asRow = row as Record<string, unknown>;
2936
+ return asRow['name'] === column;
2937
+ });
2938
+ if (exists) {
2939
+ return;
2940
+ }
2941
+ this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${definition};`);
2942
+ }
2943
+
2944
+ private preparePath(filePath: string): string {
2945
+ if (filePath === ':memory:') {
2946
+ return filePath;
2947
+ }
2948
+ mkdirSync(dirname(filePath), { recursive: true });
2949
+ return filePath;
2950
+ }
2951
+ }