prjct-cli 0.17.0 → 0.18.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/core/__tests__/agentic/memory-system.test.ts +2 -1
- package/core/__tests__/agentic/plan-mode.test.ts +2 -1
- package/core/agentic/agent-router.ts +1 -1
- package/core/command-registry/setup-commands.ts +15 -0
- package/core/domain/agent-generator.ts +9 -17
- package/core/infrastructure/path-manager.ts +23 -1
- package/core/storage/ideas-storage.ts +4 -0
- package/core/storage/queue-storage.ts +4 -0
- package/core/storage/shipped-storage.ts +4 -0
- package/core/storage/state-storage.ts +4 -0
- package/core/storage/storage-manager.ts +10 -7
- package/core/sync/auth-config.ts +145 -0
- package/core/sync/index.ts +30 -0
- package/core/sync/oauth-handler.ts +148 -0
- package/core/sync/sync-client.ts +252 -0
- package/core/sync/sync-manager.ts +358 -0
- package/package.json +1 -1
- package/templates/commands/auth.md +234 -0
- package/templates/commands/sync.md +55 -0
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sync Client - HTTP client for prjct API
|
|
3
|
+
*
|
|
4
|
+
* Handles communication with the backend API for push/pull operations.
|
|
5
|
+
* Uses native fetch API (available in Node 18+ and Bun).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import authConfig from './auth-config'
|
|
9
|
+
import type { SyncEvent } from '../events'
|
|
10
|
+
|
|
11
|
+
// ============================================
|
|
12
|
+
// Types
|
|
13
|
+
// ============================================
|
|
14
|
+
|
|
15
|
+
export interface SyncBatchResult {
|
|
16
|
+
success: boolean
|
|
17
|
+
processed: number
|
|
18
|
+
errors: Array<{ index: number; error: string }>
|
|
19
|
+
syncedAt: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SyncPullResult {
|
|
23
|
+
events: Array<{
|
|
24
|
+
type: string
|
|
25
|
+
path: string[]
|
|
26
|
+
data: unknown
|
|
27
|
+
timestamp: string
|
|
28
|
+
}>
|
|
29
|
+
syncedAt: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface SyncStatus {
|
|
33
|
+
projectId: string
|
|
34
|
+
lastSync: string | null
|
|
35
|
+
pendingCount: number
|
|
36
|
+
hasConflicts: boolean
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface SyncClientError {
|
|
40
|
+
code: 'AUTH_REQUIRED' | 'NETWORK_ERROR' | 'API_ERROR' | 'UNKNOWN'
|
|
41
|
+
message: string
|
|
42
|
+
status?: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ============================================
|
|
46
|
+
// Sync Client
|
|
47
|
+
// ============================================
|
|
48
|
+
|
|
49
|
+
class SyncClient {
|
|
50
|
+
private retryConfig = {
|
|
51
|
+
maxRetries: 3,
|
|
52
|
+
baseDelayMs: 1000,
|
|
53
|
+
maxDelayMs: 30000,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Push local events to the server
|
|
58
|
+
*/
|
|
59
|
+
async pushEvents(projectId: string, events: SyncEvent[]): Promise<SyncBatchResult> {
|
|
60
|
+
const { apiUrl, apiKey } = await this.getAuthHeaders()
|
|
61
|
+
|
|
62
|
+
if (!apiKey) {
|
|
63
|
+
throw this.createError('AUTH_REQUIRED', 'No API key configured')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const response = await this.fetchWithRetry(`${apiUrl}/sync/batch`, {
|
|
67
|
+
method: 'POST',
|
|
68
|
+
headers: {
|
|
69
|
+
'Content-Type': 'application/json',
|
|
70
|
+
'X-Api-Key': apiKey,
|
|
71
|
+
},
|
|
72
|
+
body: JSON.stringify({
|
|
73
|
+
projectId,
|
|
74
|
+
events: events.map((e) => ({
|
|
75
|
+
type: e.type,
|
|
76
|
+
path: e.path,
|
|
77
|
+
data: e.data,
|
|
78
|
+
timestamp: e.timestamp,
|
|
79
|
+
})),
|
|
80
|
+
}),
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
if (!response.ok) {
|
|
84
|
+
const error = await this.parseErrorResponse(response)
|
|
85
|
+
throw error
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return (await response.json()) as SyncBatchResult
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Pull events from the server since a timestamp
|
|
93
|
+
*/
|
|
94
|
+
async pullEvents(projectId: string, since?: string): Promise<SyncPullResult> {
|
|
95
|
+
const { apiUrl, apiKey } = await this.getAuthHeaders()
|
|
96
|
+
|
|
97
|
+
if (!apiKey) {
|
|
98
|
+
throw this.createError('AUTH_REQUIRED', 'No API key configured')
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const response = await this.fetchWithRetry(`${apiUrl}/sync/pull`, {
|
|
102
|
+
method: 'POST',
|
|
103
|
+
headers: {
|
|
104
|
+
'Content-Type': 'application/json',
|
|
105
|
+
'X-Api-Key': apiKey,
|
|
106
|
+
},
|
|
107
|
+
body: JSON.stringify({
|
|
108
|
+
projectId,
|
|
109
|
+
since,
|
|
110
|
+
}),
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
if (!response.ok) {
|
|
114
|
+
const error = await this.parseErrorResponse(response)
|
|
115
|
+
throw error
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return (await response.json()) as SyncPullResult
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Get sync status for a project
|
|
123
|
+
*/
|
|
124
|
+
async getStatus(projectId: string): Promise<SyncStatus> {
|
|
125
|
+
const { apiUrl, apiKey } = await this.getAuthHeaders()
|
|
126
|
+
|
|
127
|
+
if (!apiKey) {
|
|
128
|
+
throw this.createError('AUTH_REQUIRED', 'No API key configured')
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const response = await this.fetchWithRetry(`${apiUrl}/sync/status/${projectId}`, {
|
|
132
|
+
method: 'GET',
|
|
133
|
+
headers: {
|
|
134
|
+
'X-Api-Key': apiKey,
|
|
135
|
+
},
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
if (!response.ok) {
|
|
139
|
+
const error = await this.parseErrorResponse(response)
|
|
140
|
+
throw error
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return (await response.json()) as SyncStatus
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Test connection to the API
|
|
148
|
+
*/
|
|
149
|
+
async testConnection(): Promise<boolean> {
|
|
150
|
+
try {
|
|
151
|
+
const { apiUrl, apiKey } = await this.getAuthHeaders()
|
|
152
|
+
|
|
153
|
+
if (!apiKey) {
|
|
154
|
+
return false
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const response = await fetch(`${apiUrl}/health`, {
|
|
158
|
+
method: 'GET',
|
|
159
|
+
headers: {
|
|
160
|
+
'X-Api-Key': apiKey,
|
|
161
|
+
},
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
return response.ok
|
|
165
|
+
} catch {
|
|
166
|
+
return false
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Check if we have valid authentication
|
|
172
|
+
*/
|
|
173
|
+
async hasAuth(): Promise<boolean> {
|
|
174
|
+
return await authConfig.hasAuth()
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ============================================
|
|
178
|
+
// Private helpers
|
|
179
|
+
// ============================================
|
|
180
|
+
|
|
181
|
+
private async getAuthHeaders(): Promise<{ apiUrl: string; apiKey: string | null }> {
|
|
182
|
+
const [apiUrl, apiKey] = await Promise.all([authConfig.getApiUrl(), authConfig.getApiKey()])
|
|
183
|
+
return { apiUrl, apiKey }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private async fetchWithRetry(
|
|
187
|
+
url: string,
|
|
188
|
+
options: RequestInit,
|
|
189
|
+
retryCount = 0
|
|
190
|
+
): Promise<Response> {
|
|
191
|
+
try {
|
|
192
|
+
const response = await fetch(url, options)
|
|
193
|
+
|
|
194
|
+
// Retry on server errors (5xx) but not client errors (4xx)
|
|
195
|
+
if (response.status >= 500 && retryCount < this.retryConfig.maxRetries) {
|
|
196
|
+
const delay = Math.min(
|
|
197
|
+
this.retryConfig.baseDelayMs * Math.pow(2, retryCount),
|
|
198
|
+
this.retryConfig.maxDelayMs
|
|
199
|
+
)
|
|
200
|
+
await this.sleep(delay)
|
|
201
|
+
return this.fetchWithRetry(url, options, retryCount + 1)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return response
|
|
205
|
+
} catch (error) {
|
|
206
|
+
// Retry on network errors
|
|
207
|
+
if (retryCount < this.retryConfig.maxRetries) {
|
|
208
|
+
const delay = Math.min(
|
|
209
|
+
this.retryConfig.baseDelayMs * Math.pow(2, retryCount),
|
|
210
|
+
this.retryConfig.maxDelayMs
|
|
211
|
+
)
|
|
212
|
+
await this.sleep(delay)
|
|
213
|
+
return this.fetchWithRetry(url, options, retryCount + 1)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
throw this.createError(
|
|
217
|
+
'NETWORK_ERROR',
|
|
218
|
+
error instanceof Error ? error.message : 'Network request failed'
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private async parseErrorResponse(response: Response): Promise<SyncClientError> {
|
|
224
|
+
try {
|
|
225
|
+
const body = (await response.json()) as { message?: string; error?: string }
|
|
226
|
+
const message = body.message || body.error || `HTTP ${response.status}`
|
|
227
|
+
|
|
228
|
+
if (response.status === 401 || response.status === 403) {
|
|
229
|
+
return this.createError('AUTH_REQUIRED', message, response.status)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return this.createError('API_ERROR', message, response.status)
|
|
233
|
+
} catch {
|
|
234
|
+
return this.createError('API_ERROR', `HTTP ${response.status}`, response.status)
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private createError(
|
|
239
|
+
code: SyncClientError['code'],
|
|
240
|
+
message: string,
|
|
241
|
+
status?: number
|
|
242
|
+
): SyncClientError {
|
|
243
|
+
return { code, message, status }
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private sleep(ms: number): Promise<void> {
|
|
247
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export const syncClient = new SyncClient()
|
|
252
|
+
export default syncClient
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sync Manager - Orchestrates push/pull operations
|
|
3
|
+
*
|
|
4
|
+
* Main entry point for sync operations.
|
|
5
|
+
* Handles the coordination between local storage (EventBus) and remote API (SyncClient).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { syncClient, type SyncBatchResult, type SyncPullResult, type SyncStatus } from './sync-client'
|
|
9
|
+
import authConfig from './auth-config'
|
|
10
|
+
import eventBus, { type SyncEvent } from '../events'
|
|
11
|
+
import { stateStorage } from '../storage/state-storage'
|
|
12
|
+
import { queueStorage } from '../storage/queue-storage'
|
|
13
|
+
import { ideasStorage, type IdeaPriority } from '../storage/ideas-storage'
|
|
14
|
+
import { shippedStorage } from '../storage/shipped-storage'
|
|
15
|
+
import type { TaskType, Priority, TaskSection } from '../schemas/state'
|
|
16
|
+
|
|
17
|
+
// ============================================
|
|
18
|
+
// Types
|
|
19
|
+
// ============================================
|
|
20
|
+
|
|
21
|
+
export interface SyncResult {
|
|
22
|
+
success: boolean
|
|
23
|
+
skipped: boolean
|
|
24
|
+
reason?: 'no_auth' | 'no_pending' | 'error'
|
|
25
|
+
pushed?: {
|
|
26
|
+
count: number
|
|
27
|
+
syncedAt: string
|
|
28
|
+
}
|
|
29
|
+
pulled?: {
|
|
30
|
+
count: number
|
|
31
|
+
syncedAt: string
|
|
32
|
+
}
|
|
33
|
+
error?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface PushResult {
|
|
37
|
+
success: boolean
|
|
38
|
+
skipped: boolean
|
|
39
|
+
reason?: 'no_auth' | 'no_pending' | 'error'
|
|
40
|
+
count?: number
|
|
41
|
+
syncedAt?: string
|
|
42
|
+
error?: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface PullResult {
|
|
46
|
+
success: boolean
|
|
47
|
+
skipped: boolean
|
|
48
|
+
reason?: 'no_auth' | 'error'
|
|
49
|
+
count?: number
|
|
50
|
+
applied?: number
|
|
51
|
+
syncedAt?: string
|
|
52
|
+
error?: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ============================================
|
|
56
|
+
// Sync Manager
|
|
57
|
+
// ============================================
|
|
58
|
+
|
|
59
|
+
class SyncManager {
|
|
60
|
+
/**
|
|
61
|
+
* Check if user is authenticated
|
|
62
|
+
*/
|
|
63
|
+
async hasAuth(): Promise<boolean> {
|
|
64
|
+
return await authConfig.hasAuth()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Get sync status from API
|
|
69
|
+
*/
|
|
70
|
+
async getStatus(projectId: string): Promise<SyncStatus | null> {
|
|
71
|
+
if (!(await this.hasAuth())) {
|
|
72
|
+
return null
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
return await syncClient.getStatus(projectId)
|
|
77
|
+
} catch {
|
|
78
|
+
return null
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Full sync: push local changes, then pull remote changes
|
|
84
|
+
*/
|
|
85
|
+
async sync(projectId: string): Promise<SyncResult> {
|
|
86
|
+
// Check auth first
|
|
87
|
+
if (!(await this.hasAuth())) {
|
|
88
|
+
return { success: true, skipped: true, reason: 'no_auth' }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const result: SyncResult = { success: true, skipped: false }
|
|
92
|
+
|
|
93
|
+
// Push first
|
|
94
|
+
const pushResult = await this.push(projectId)
|
|
95
|
+
if (pushResult.success && !pushResult.skipped) {
|
|
96
|
+
result.pushed = {
|
|
97
|
+
count: pushResult.count || 0,
|
|
98
|
+
syncedAt: pushResult.syncedAt || new Date().toISOString(),
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Then pull
|
|
103
|
+
const pullResult = await this.pull(projectId)
|
|
104
|
+
if (pullResult.success && !pullResult.skipped) {
|
|
105
|
+
result.pulled = {
|
|
106
|
+
count: pullResult.count || 0,
|
|
107
|
+
syncedAt: pullResult.syncedAt || new Date().toISOString(),
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Determine overall success
|
|
112
|
+
if (!pushResult.success || !pullResult.success) {
|
|
113
|
+
result.success = false
|
|
114
|
+
result.error = pushResult.error || pullResult.error
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return result
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Push local pending events to the server
|
|
122
|
+
*/
|
|
123
|
+
async push(projectId: string): Promise<PushResult> {
|
|
124
|
+
// Check auth first
|
|
125
|
+
if (!(await this.hasAuth())) {
|
|
126
|
+
return { success: true, skipped: true, reason: 'no_auth' }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
// Get pending events
|
|
131
|
+
const pending = await eventBus.getPending(projectId)
|
|
132
|
+
|
|
133
|
+
if (pending.length === 0) {
|
|
134
|
+
return { success: true, skipped: true, reason: 'no_pending' }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Push to server
|
|
138
|
+
const result: SyncBatchResult = await syncClient.pushEvents(projectId, pending)
|
|
139
|
+
|
|
140
|
+
if (result.success) {
|
|
141
|
+
// Clear pending events on success
|
|
142
|
+
await eventBus.clearPending(projectId)
|
|
143
|
+
await eventBus.updateLastSync(projectId)
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
success: true,
|
|
147
|
+
skipped: false,
|
|
148
|
+
count: result.processed,
|
|
149
|
+
syncedAt: result.syncedAt,
|
|
150
|
+
}
|
|
151
|
+
} else {
|
|
152
|
+
// Partial success - some events failed
|
|
153
|
+
const successCount = result.processed
|
|
154
|
+
const errorCount = result.errors.length
|
|
155
|
+
const errorMessages = result.errors.map((e) => e.error).join(', ')
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
success: false,
|
|
159
|
+
skipped: false,
|
|
160
|
+
count: successCount,
|
|
161
|
+
syncedAt: result.syncedAt,
|
|
162
|
+
error: `${errorCount} events failed: ${errorMessages}`,
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
} catch (error) {
|
|
166
|
+
const message = error instanceof Error ? error.message : 'Unknown error'
|
|
167
|
+
return {
|
|
168
|
+
success: false,
|
|
169
|
+
skipped: false,
|
|
170
|
+
reason: 'error',
|
|
171
|
+
error: message,
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Pull remote changes from the server
|
|
178
|
+
*/
|
|
179
|
+
async pull(projectId: string): Promise<PullResult> {
|
|
180
|
+
// Check auth first
|
|
181
|
+
if (!(await this.hasAuth())) {
|
|
182
|
+
return { success: true, skipped: true, reason: 'no_auth' }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
// Get last sync timestamp
|
|
187
|
+
const lastSync = await eventBus.getLastSync(projectId)
|
|
188
|
+
const since = lastSync?.timestamp
|
|
189
|
+
|
|
190
|
+
// Pull from server
|
|
191
|
+
const result: SyncPullResult = await syncClient.pullEvents(projectId, since)
|
|
192
|
+
|
|
193
|
+
if (result.events.length === 0) {
|
|
194
|
+
return {
|
|
195
|
+
success: true,
|
|
196
|
+
skipped: false,
|
|
197
|
+
count: 0,
|
|
198
|
+
applied: 0,
|
|
199
|
+
syncedAt: result.syncedAt,
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Apply pulled events to local storage
|
|
204
|
+
const applied = await this.applyPulledEvents(projectId, result.events)
|
|
205
|
+
|
|
206
|
+
// Update last sync timestamp
|
|
207
|
+
await eventBus.updateLastSync(projectId)
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
success: true,
|
|
211
|
+
skipped: false,
|
|
212
|
+
count: result.events.length,
|
|
213
|
+
applied,
|
|
214
|
+
syncedAt: result.syncedAt,
|
|
215
|
+
}
|
|
216
|
+
} catch (error) {
|
|
217
|
+
const message = error instanceof Error ? error.message : 'Unknown error'
|
|
218
|
+
return {
|
|
219
|
+
success: false,
|
|
220
|
+
skipped: false,
|
|
221
|
+
reason: 'error',
|
|
222
|
+
error: message,
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Apply pulled events to local storage
|
|
229
|
+
* Returns number of events successfully applied
|
|
230
|
+
*/
|
|
231
|
+
async applyPulledEvents(
|
|
232
|
+
projectId: string,
|
|
233
|
+
events: Array<{ type: string; path: string[]; data: unknown; timestamp: string }>
|
|
234
|
+
): Promise<number> {
|
|
235
|
+
let applied = 0
|
|
236
|
+
|
|
237
|
+
for (const event of events) {
|
|
238
|
+
try {
|
|
239
|
+
await this.applyEvent(projectId, event)
|
|
240
|
+
applied++
|
|
241
|
+
} catch (error) {
|
|
242
|
+
// Log but continue with other events
|
|
243
|
+
console.error(`Failed to apply event ${event.type}:`, error)
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return applied
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Apply a single event to local storage
|
|
252
|
+
*/
|
|
253
|
+
private async applyEvent(
|
|
254
|
+
projectId: string,
|
|
255
|
+
event: { type: string; path: string[]; data: unknown; timestamp: string }
|
|
256
|
+
): Promise<void> {
|
|
257
|
+
const [entity, action] = event.type.split('.') as [string, string]
|
|
258
|
+
const data = event.data as Record<string, unknown>
|
|
259
|
+
|
|
260
|
+
switch (entity) {
|
|
261
|
+
case 'task':
|
|
262
|
+
await this.applyTaskEvent(projectId, action, data)
|
|
263
|
+
break
|
|
264
|
+
case 'idea':
|
|
265
|
+
await this.applyIdeaEvent(projectId, action, data)
|
|
266
|
+
break
|
|
267
|
+
case 'shipped':
|
|
268
|
+
await this.applyShippedEvent(projectId, action, data)
|
|
269
|
+
break
|
|
270
|
+
// Add more entity handlers as needed
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private async applyTaskEvent(
|
|
275
|
+
projectId: string,
|
|
276
|
+
action: string,
|
|
277
|
+
data: Record<string, unknown>
|
|
278
|
+
): Promise<void> {
|
|
279
|
+
switch (action) {
|
|
280
|
+
case 'started':
|
|
281
|
+
// Update state if this is a newer task
|
|
282
|
+
await stateStorage.update(projectId, (state) => {
|
|
283
|
+
if (!state.currentTask || (data.id as string) !== state.currentTask.id) {
|
|
284
|
+
return {
|
|
285
|
+
...state,
|
|
286
|
+
currentTask: {
|
|
287
|
+
id: data.id as string,
|
|
288
|
+
description: data.description as string,
|
|
289
|
+
startedAt: data.startedAt as string,
|
|
290
|
+
sessionId: data.sessionId as string,
|
|
291
|
+
},
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return state
|
|
295
|
+
})
|
|
296
|
+
break
|
|
297
|
+
case 'completed':
|
|
298
|
+
// Clear current task if it matches
|
|
299
|
+
await stateStorage.update(projectId, (state) => {
|
|
300
|
+
if (state.currentTask?.id === data.id) {
|
|
301
|
+
return { ...state, currentTask: null }
|
|
302
|
+
}
|
|
303
|
+
return state
|
|
304
|
+
})
|
|
305
|
+
break
|
|
306
|
+
case 'created':
|
|
307
|
+
// Add to queue
|
|
308
|
+
await queueStorage.addTask(projectId, {
|
|
309
|
+
description: data.description as string,
|
|
310
|
+
priority: (data.priority as Priority) || 'medium',
|
|
311
|
+
type: (data.type as TaskType) || 'feature',
|
|
312
|
+
section: 'backlog' as TaskSection,
|
|
313
|
+
})
|
|
314
|
+
break
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
private async applyIdeaEvent(
|
|
319
|
+
projectId: string,
|
|
320
|
+
action: string,
|
|
321
|
+
data: Record<string, unknown>
|
|
322
|
+
): Promise<void> {
|
|
323
|
+
switch (action) {
|
|
324
|
+
case 'created':
|
|
325
|
+
await ideasStorage.addIdea(
|
|
326
|
+
projectId,
|
|
327
|
+
(data.title as string) || (data.text as string),
|
|
328
|
+
{ priority: (data.priority as IdeaPriority) || 'medium' }
|
|
329
|
+
)
|
|
330
|
+
break
|
|
331
|
+
case 'archived':
|
|
332
|
+
await ideasStorage.update(projectId, (ideas) => ({
|
|
333
|
+
...ideas,
|
|
334
|
+
ideas: ideas.ideas.map((idea) =>
|
|
335
|
+
idea.id === data.id ? { ...idea, status: 'archived' as const } : idea
|
|
336
|
+
),
|
|
337
|
+
}))
|
|
338
|
+
break
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
private async applyShippedEvent(
|
|
343
|
+
projectId: string,
|
|
344
|
+
action: string,
|
|
345
|
+
data: Record<string, unknown>
|
|
346
|
+
): Promise<void> {
|
|
347
|
+
if (action === 'created') {
|
|
348
|
+
await shippedStorage.addShipped(projectId, {
|
|
349
|
+
name: (data.name as string) || (data.title as string),
|
|
350
|
+
version: data.version as string,
|
|
351
|
+
description: data.description as string,
|
|
352
|
+
})
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export const syncManager = new SyncManager()
|
|
358
|
+
export default syncManager
|