@jmoyers/harness 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +145 -0
- package/native/ptyd/Cargo.lock +16 -0
- package/native/ptyd/Cargo.toml +7 -0
- package/native/ptyd/src/main.rs +257 -0
- package/package.json +90 -0
- package/scripts/build-ptyd.sh +73 -0
- package/scripts/control-plane-daemon.ts +277 -0
- package/scripts/cursor-hook-relay.ts +82 -0
- package/scripts/harness-animate.ts +469 -0
- package/scripts/harness-bin.js +77 -0
- package/scripts/harness-core.ts +1 -0
- package/scripts/harness-inspector.ts +439 -0
- package/scripts/harness.ts +2493 -0
- package/src/adapters/agent-session-state.ts +390 -0
- package/src/cli/gateway-record.ts +173 -0
- package/src/codex/live-session.ts +872 -0
- package/src/config/config-core.ts +1359 -0
- package/src/config/secrets-core.ts +170 -0
- package/src/control-plane/agent-realtime-api.ts +2441 -0
- package/src/control-plane/codex-session-stream.ts +392 -0
- package/src/control-plane/codex-telemetry.ts +1325 -0
- package/src/control-plane/lifecycle-hooks.ts +706 -0
- package/src/control-plane/session-summary.ts +380 -0
- package/src/control-plane/status/agent-status-reducer.ts +21 -0
- package/src/control-plane/status/reducer-base.ts +170 -0
- package/src/control-plane/status/reducers/claude-status-reducer.ts +37 -0
- package/src/control-plane/status/reducers/codex-status-reducer.ts +48 -0
- package/src/control-plane/status/reducers/critique-status-reducer.ts +15 -0
- package/src/control-plane/status/reducers/cursor-status-reducer.ts +37 -0
- package/src/control-plane/status/reducers/terminal-status-reducer.ts +15 -0
- package/src/control-plane/status/session-status-engine.ts +76 -0
- package/src/control-plane/stream-client.ts +396 -0
- package/src/control-plane/stream-command-parser.ts +1673 -0
- package/src/control-plane/stream-protocol.ts +1808 -0
- package/src/control-plane/stream-server-background.ts +486 -0
- package/src/control-plane/stream-server-command.ts +2557 -0
- package/src/control-plane/stream-server-connection.ts +234 -0
- package/src/control-plane/stream-server-observed-filter.ts +112 -0
- package/src/control-plane/stream-server-session-runtime.ts +566 -0
- package/src/control-plane/stream-server-state-store.ts +15 -0
- package/src/control-plane/stream-server.ts +3192 -0
- package/src/cursor/managed-hooks.ts +282 -0
- package/src/domain/conversations.ts +414 -0
- package/src/domain/directories.ts +78 -0
- package/src/domain/repositories.ts +123 -0
- package/src/domain/tasks.ts +148 -0
- package/src/domain/workspace.ts +156 -0
- package/src/events/normalized-events.ts +124 -0
- package/src/mux/ansi-integrity.ts +103 -0
- package/src/mux/control-plane-op-queue.ts +212 -0
- package/src/mux/conversation-rail.ts +339 -0
- package/src/mux/double-click.ts +78 -0
- package/src/mux/dual-pane-core.ts +435 -0
- package/src/mux/harness-core-ui.ts +817 -0
- package/src/mux/input-shortcuts.ts +667 -0
- package/src/mux/live-mux/actions-conversation.ts +344 -0
- package/src/mux/live-mux/actions-repository.ts +246 -0
- package/src/mux/live-mux/actions-task.ts +115 -0
- package/src/mux/live-mux/args.ts +142 -0
- package/src/mux/live-mux/command-menu.ts +298 -0
- package/src/mux/live-mux/control-plane-records.ts +546 -0
- package/src/mux/live-mux/conversation-state.ts +188 -0
- package/src/mux/live-mux/directory-resolution.ts +34 -0
- package/src/mux/live-mux/event-mapping.ts +96 -0
- package/src/mux/live-mux/gateway-profiler.ts +152 -0
- package/src/mux/live-mux/gateway-render-trace.ts +177 -0
- package/src/mux/live-mux/gateway-status-timeline.ts +166 -0
- package/src/mux/live-mux/git-parsing.ts +131 -0
- package/src/mux/live-mux/git-snapshot.ts +263 -0
- package/src/mux/live-mux/git-state.ts +136 -0
- package/src/mux/live-mux/global-shortcut-handlers.ts +143 -0
- package/src/mux/live-mux/home-pane-actions.ts +58 -0
- package/src/mux/live-mux/home-pane-drop.ts +44 -0
- package/src/mux/live-mux/home-pane-entity-click.ts +96 -0
- package/src/mux/live-mux/home-pane-pointer.ts +96 -0
- package/src/mux/live-mux/input-forwarding.ts +112 -0
- package/src/mux/live-mux/layout.ts +30 -0
- package/src/mux/live-mux/left-nav-activation.ts +103 -0
- package/src/mux/live-mux/left-nav.ts +85 -0
- package/src/mux/live-mux/left-rail-actions.ts +118 -0
- package/src/mux/live-mux/left-rail-conversation-click.ts +82 -0
- package/src/mux/live-mux/left-rail-pointer.ts +74 -0
- package/src/mux/live-mux/modal-command-menu-handler.ts +101 -0
- package/src/mux/live-mux/modal-conversation-handlers.ts +217 -0
- package/src/mux/live-mux/modal-input-reducers.ts +94 -0
- package/src/mux/live-mux/modal-overlays.ts +287 -0
- package/src/mux/live-mux/modal-pointer.ts +70 -0
- package/src/mux/live-mux/modal-prompt-handlers.ts +187 -0
- package/src/mux/live-mux/modal-task-editor-handler.ts +156 -0
- package/src/mux/live-mux/observed-stream.ts +87 -0
- package/src/mux/live-mux/palette-parsing.ts +128 -0
- package/src/mux/live-mux/pointer-routing.ts +108 -0
- package/src/mux/live-mux/process-usage.ts +53 -0
- package/src/mux/live-mux/project-pane-pointer.ts +44 -0
- package/src/mux/live-mux/rail-layout.ts +244 -0
- package/src/mux/live-mux/render-trace-analysis.ts +213 -0
- package/src/mux/live-mux/render-trace-state.ts +84 -0
- package/src/mux/live-mux/repository-folding.ts +207 -0
- package/src/mux/live-mux/runtime-shutdown.ts +51 -0
- package/src/mux/live-mux/selection.ts +411 -0
- package/src/mux/live-mux/startup-utils.ts +187 -0
- package/src/mux/live-mux/status-timeline-state.ts +82 -0
- package/src/mux/live-mux/task-pane-shortcuts.ts +206 -0
- package/src/mux/live-mux/terminal-palette.ts +79 -0
- package/src/mux/new-thread-prompt.ts +165 -0
- package/src/mux/project-tree.ts +295 -0
- package/src/mux/render-frame.ts +113 -0
- package/src/mux/runtime-wiring.ts +185 -0
- package/src/mux/selector-index.ts +160 -0
- package/src/mux/startup-sequencer.ts +238 -0
- package/src/mux/task-composer.ts +289 -0
- package/src/mux/task-focused-pane.ts +417 -0
- package/src/mux/task-screen-keybindings.ts +539 -0
- package/src/mux/terminal-input-modes.ts +35 -0
- package/src/mux/workspace-path.ts +55 -0
- package/src/mux/workspace-rail-model.ts +701 -0
- package/src/mux/workspace-rail.ts +247 -0
- package/src/perf/perf-core.ts +307 -0
- package/src/pty/pty_host.ts +217 -0
- package/src/pty/session-broker.ts +158 -0
- package/src/recording/terminal-recording.ts +383 -0
- package/src/services/control-plane.ts +567 -0
- package/src/services/conversation-lifecycle.ts +176 -0
- package/src/services/conversation-startup-hydration.ts +47 -0
- package/src/services/directory-hydration.ts +49 -0
- package/src/services/event-persistence.ts +104 -0
- package/src/services/mux-ui-state-persistence.ts +82 -0
- package/src/services/output-load-sampler.ts +231 -0
- package/src/services/process-usage-refresh.ts +88 -0
- package/src/services/recording.ts +75 -0
- package/src/services/render-trace-recorder.ts +177 -0
- package/src/services/runtime-control-actions.ts +123 -0
- package/src/services/runtime-control-plane-ops.ts +131 -0
- package/src/services/runtime-conversation-actions.ts +113 -0
- package/src/services/runtime-conversation-activation.ts +78 -0
- package/src/services/runtime-conversation-starter.ts +171 -0
- package/src/services/runtime-conversation-title-edit.ts +149 -0
- package/src/services/runtime-directory-actions.ts +164 -0
- package/src/services/runtime-envelope-handler.ts +198 -0
- package/src/services/runtime-git-state.ts +92 -0
- package/src/services/runtime-input-pipeline.ts +50 -0
- package/src/services/runtime-input-router.ts +202 -0
- package/src/services/runtime-layout-resize.ts +236 -0
- package/src/services/runtime-left-rail-render.ts +159 -0
- package/src/services/runtime-main-pane-input.ts +230 -0
- package/src/services/runtime-modal-input.ts +119 -0
- package/src/services/runtime-navigation-input.ts +207 -0
- package/src/services/runtime-process-wiring.ts +68 -0
- package/src/services/runtime-rail-input.ts +287 -0
- package/src/services/runtime-render-flush.ts +146 -0
- package/src/services/runtime-render-lifecycle.ts +104 -0
- package/src/services/runtime-render-orchestrator.ts +108 -0
- package/src/services/runtime-render-pipeline.ts +167 -0
- package/src/services/runtime-render-state.ts +72 -0
- package/src/services/runtime-repository-actions.ts +197 -0
- package/src/services/runtime-right-pane-render.ts +132 -0
- package/src/services/runtime-shutdown.ts +79 -0
- package/src/services/runtime-stream-subscriptions.ts +56 -0
- package/src/services/runtime-task-composer-persistence.ts +139 -0
- package/src/services/runtime-task-editor-actions.ts +83 -0
- package/src/services/runtime-task-pane-actions.ts +198 -0
- package/src/services/runtime-task-pane-shortcuts.ts +189 -0
- package/src/services/runtime-task-pane.ts +62 -0
- package/src/services/runtime-workspace-actions.ts +153 -0
- package/src/services/runtime-workspace-observed-events.ts +190 -0
- package/src/services/session-projection-instrumentation.ts +190 -0
- package/src/services/startup-background-probe.ts +91 -0
- package/src/services/startup-background-resume.ts +65 -0
- package/src/services/startup-orchestrator.ts +166 -0
- package/src/services/startup-output-tracker.ts +54 -0
- package/src/services/startup-paint-tracker.ts +115 -0
- package/src/services/startup-persisted-conversation-queue.ts +45 -0
- package/src/services/startup-settled-gate.ts +67 -0
- package/src/services/startup-shutdown.ts +53 -0
- package/src/services/startup-span-tracker.ts +77 -0
- package/src/services/startup-state-hydration.ts +94 -0
- package/src/services/startup-visibility.ts +35 -0
- package/src/services/status-timeline-recorder.ts +144 -0
- package/src/services/task-pane-selection-actions.ts +153 -0
- package/src/services/task-planning-hydration.ts +58 -0
- package/src/services/task-planning-observed-events.ts +89 -0
- package/src/services/workspace-observed-events.ts +113 -0
- package/src/store/control-plane-store-normalize.ts +760 -0
- package/src/store/control-plane-store-types.ts +224 -0
- package/src/store/control-plane-store.ts +2951 -0
- package/src/store/event-store.ts +253 -0
- package/src/store/sqlite.ts +81 -0
- package/src/terminal/compat-matrix.ts +345 -0
- package/src/terminal/differential-checkpoints.ts +132 -0
- package/src/terminal/parity-suite.ts +441 -0
- package/src/terminal/snapshot-oracle.ts +1840 -0
- package/src/ui/conversation-input-forwarder.ts +114 -0
- package/src/ui/conversation-selection-input.ts +103 -0
- package/src/ui/debug-footer-notice.ts +39 -0
- package/src/ui/global-shortcut-input.ts +126 -0
- package/src/ui/input-preflight.ts +68 -0
- package/src/ui/input-token-router.ts +312 -0
- package/src/ui/input.ts +238 -0
- package/src/ui/kit.ts +509 -0
- package/src/ui/left-nav-input.ts +80 -0
- package/src/ui/left-rail-pointer-input.ts +148 -0
- package/src/ui/main-pane-pointer-input.ts +150 -0
- package/src/ui/modals/manager.ts +192 -0
- package/src/ui/mux-theme.ts +529 -0
- package/src/ui/panes/conversation.ts +19 -0
- package/src/ui/panes/home-gridfire.ts +302 -0
- package/src/ui/panes/home.ts +109 -0
- package/src/ui/panes/left-rail.ts +12 -0
- package/src/ui/panes/project.ts +44 -0
- package/src/ui/pointer-routing-input.ts +158 -0
- package/src/ui/repository-fold-input.ts +91 -0
- package/src/ui/screen.ts +210 -0
- package/src/ui/surface.ts +224 -0
|
@@ -0,0 +1,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
|
+
}
|