@devosurf/vynt 0.1.2

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/src/bridge.ts ADDED
@@ -0,0 +1,858 @@
1
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"
2
+ import { watch, type FSWatcher } from "node:fs"
3
+ import { readFile } from "node:fs/promises"
4
+ import { dirname, resolve } from "node:path"
5
+ import { fileURLToPath } from "node:url"
6
+ import { applySelectionsToWorkspace, getLatestSnapshot, rollbackWorkspaceToSnapshot } from "./switch.js"
7
+ import { loadState, saveState } from "./state.js"
8
+ import type { Objective, PreviewProfile, VariantArtifact, VariantStore } from "./types.js"
9
+
10
+ const __filename = fileURLToPath(import.meta.url)
11
+ const __dirname = dirname(__filename)
12
+ const TOOLBAR_SCRIPT_PATH = resolve(__dirname, "..", "web", "vynt-toolbar.js")
13
+
14
+ type BridgeEventType =
15
+ | "server.started"
16
+ | "apply.started"
17
+ | "apply.succeeded"
18
+ | "apply.failed"
19
+ | "finalize.started"
20
+ | "finalize.succeeded"
21
+ | "finalize.failed"
22
+ | "rollback.started"
23
+ | "rollback.succeeded"
24
+ | "rollback.failed"
25
+ | "state.changed"
26
+
27
+ interface BridgeEvent {
28
+ id: number
29
+ type: BridgeEventType
30
+ timestamp: string
31
+ payload: Record<string, unknown>
32
+ }
33
+
34
+ export interface StartBridgeServerOptions {
35
+ host: string
36
+ port: number
37
+ projectRoot: string
38
+ }
39
+
40
+ export interface BridgeServerHandle {
41
+ close: () => Promise<void>
42
+ host: string
43
+ port: number
44
+ url: string
45
+ }
46
+
47
+ interface BridgeStatus {
48
+ initialized: boolean
49
+ latestSnapshot: null | {
50
+ baseRef: string
51
+ createdAt: string
52
+ filePath: string
53
+ head: string
54
+ id: string
55
+ selections: Array<{ objectiveId: string; patchFile: string; variantId: string }>
56
+ }
57
+ state: null | {
58
+ activeProfileId: string | null
59
+ baseRef: string
60
+ objectives: Array<{
61
+ activeVariantId: string | null
62
+ id: string
63
+ name: string
64
+ status: string
65
+ variants: Array<{ changedFiles: string[]; id: string; name: string; status: string; updatedAt: string }>
66
+ winnerVariantId: string | null
67
+ }>
68
+ profiles: Array<{
69
+ id: string
70
+ lastAppliedAt: string | null
71
+ name: string
72
+ selections: Record<string, string>
73
+ updatedAt: string
74
+ }>
75
+ }
76
+ }
77
+
78
+ function findObjective(state: VariantStore, objectiveId: string): Objective {
79
+ const found = state.objectives.find((objective) => objective.id === objectiveId)
80
+ if (!found) {
81
+ throw new Error(`Objective not found: ${objectiveId}`)
82
+ }
83
+ return found
84
+ }
85
+
86
+ function findVariant(objective: Objective, variantId: string): VariantArtifact {
87
+ const found = objective.variants.find((variant) => variant.id === variantId)
88
+ if (!found) {
89
+ throw new Error(`Variant not found in objective ${objective.id}: ${variantId}`)
90
+ }
91
+ return found
92
+ }
93
+
94
+ function findVariantAcrossObjectives(
95
+ state: VariantStore,
96
+ variantId: string,
97
+ ): { objective: Objective; variant: VariantArtifact } {
98
+ const matches: Array<{ objective: Objective; variant: VariantArtifact }> = []
99
+
100
+ for (const objective of state.objectives) {
101
+ const variant = objective.variants.find((item) => item.id === variantId)
102
+ if (variant) {
103
+ matches.push({ objective, variant })
104
+ }
105
+ }
106
+
107
+ if (matches.length === 0) {
108
+ throw new Error(`Variant not found: ${variantId}`)
109
+ }
110
+
111
+ if (matches.length > 1) {
112
+ const objectiveIds = matches.map((item) => item.objective.id).join(", ")
113
+ throw new Error(
114
+ `Variant id ${variantId} is ambiguous across objectives (${objectiveIds}). Use objectiveId + variantId.`,
115
+ )
116
+ }
117
+
118
+ return matches[0] as { objective: Objective; variant: VariantArtifact }
119
+ }
120
+
121
+ function findProfile(state: VariantStore, profileId: string): PreviewProfile {
122
+ const found = state.profiles.find((profile) => profile.id === profileId)
123
+ if (!found) {
124
+ throw new Error(`Profile not found: ${profileId}`)
125
+ }
126
+ return found
127
+ }
128
+
129
+ function clearActiveSelections(state: VariantStore): VariantStore {
130
+ const now = new Date().toISOString()
131
+
132
+ return {
133
+ ...state,
134
+ activeProfileId: undefined,
135
+ objectives: state.objectives.map((objectiveItem) => ({
136
+ ...objectiveItem,
137
+ activeVariantId: undefined,
138
+ updatedAt: now,
139
+ variants: objectiveItem.variants.map((variant) => {
140
+ if (variant.status !== "selected") return variant
141
+ return {
142
+ ...variant,
143
+ status: "ready" as const,
144
+ updatedAt: now,
145
+ }
146
+ }),
147
+ })),
148
+ }
149
+ }
150
+
151
+ function markVariantSelectedInState(state: VariantStore, objectiveId: string, variantId: string): VariantStore {
152
+ const objectiveItem = findObjective(state, objectiveId)
153
+ const now = new Date().toISOString()
154
+
155
+ return {
156
+ ...state,
157
+ activeProfileId: undefined,
158
+ objectives: state.objectives.map((item) => {
159
+ if (item.id !== objectiveId) return item
160
+
161
+ return {
162
+ ...objectiveItem,
163
+ activeVariantId: variantId,
164
+ status: objectiveItem.status === "open" ? "review" : objectiveItem.status,
165
+ updatedAt: now,
166
+ variants: objectiveItem.variants.map((variant) => {
167
+ if (variant.id === variantId) {
168
+ return {
169
+ ...variant,
170
+ status: "selected" as const,
171
+ updatedAt: now,
172
+ }
173
+ }
174
+
175
+ if (variant.status === "selected") {
176
+ return {
177
+ ...variant,
178
+ status: "ready" as const,
179
+ updatedAt: now,
180
+ }
181
+ }
182
+
183
+ return variant
184
+ }),
185
+ }
186
+ }),
187
+ }
188
+ }
189
+
190
+ function finalizeVariantInObjectiveState(state: VariantStore, objectiveId: string, variantId: string): VariantStore {
191
+ const objectiveItem = findObjective(state, objectiveId)
192
+ findVariant(objectiveItem, variantId)
193
+ const now = new Date().toISOString()
194
+
195
+ return {
196
+ ...state,
197
+ activeProfileId: undefined,
198
+ objectives: state.objectives.map((item) => {
199
+ if (item.id !== objectiveId) return item
200
+
201
+ return {
202
+ ...objectiveItem,
203
+ activeVariantId: variantId,
204
+ winnerVariantId: variantId,
205
+ status: "finalized" as const,
206
+ updatedAt: now,
207
+ variants: objectiveItem.variants.map((variant) => {
208
+ if (variant.id === variantId) {
209
+ return {
210
+ ...variant,
211
+ status: "selected" as const,
212
+ updatedAt: now,
213
+ }
214
+ }
215
+
216
+ return {
217
+ ...variant,
218
+ status: "archived" as const,
219
+ updatedAt: now,
220
+ }
221
+ }),
222
+ }
223
+ }),
224
+ }
225
+ }
226
+
227
+ function resolveSingleApplyTarget(
228
+ state: VariantStore,
229
+ variantId: string,
230
+ objectiveId?: string,
231
+ ): { objective: Objective; variant: VariantArtifact } {
232
+ if (objectiveId) {
233
+ const objectiveItem = findObjective(state, objectiveId)
234
+ const variantItem = findVariant(objectiveItem, variantId)
235
+ return { objective: objectiveItem, variant: variantItem }
236
+ }
237
+
238
+ return findVariantAcrossObjectives(state, variantId)
239
+ }
240
+
241
+ function applyProfileSelectionsInState(state: VariantStore, profileId: string): VariantStore {
242
+ const profileItem = findProfile(state, profileId)
243
+ const selectionEntries = Object.entries(profileItem.selections)
244
+ if (selectionEntries.length === 0) {
245
+ throw new Error(`Profile ${profileId} has no selections`)
246
+ }
247
+
248
+ const resolved = selectionEntries.map(([objectiveId, variantId]) => {
249
+ const objectiveItem = findObjective(state, objectiveId)
250
+ const variant = findVariant(objectiveItem, variantId)
251
+ return {
252
+ objective: objectiveItem,
253
+ variant,
254
+ }
255
+ })
256
+
257
+ const fileOwners = new Map<string, string[]>()
258
+ for (const item of resolved) {
259
+ for (const file of item.variant.changedFiles) {
260
+ const owners = fileOwners.get(file) ?? []
261
+ owners.push(`${item.objective.id}:${item.variant.id}`)
262
+ fileOwners.set(file, owners)
263
+ }
264
+ }
265
+
266
+ const conflicts = Array.from(fileOwners.entries()).filter(([, owners]) => owners.length > 1)
267
+ if (conflicts.length > 0) {
268
+ const conflictText = conflicts
269
+ .map(([file, owners]) => `${file} -> ${owners.join(", ")}`)
270
+ .join("\n")
271
+ throw new Error(`Profile ${profileId} has overlapping file selections:\n${conflictText}`)
272
+ }
273
+
274
+ const now = new Date().toISOString()
275
+ const selectionByObjective = new Map(selectionEntries)
276
+
277
+ const nextObjectives = state.objectives.map((objectiveItem) => {
278
+ const selectedVariantId = selectionByObjective.get(objectiveItem.id)
279
+ if (!selectedVariantId) return objectiveItem
280
+
281
+ return {
282
+ ...objectiveItem,
283
+ activeVariantId: selectedVariantId,
284
+ status: objectiveItem.status === "open" ? "review" : objectiveItem.status,
285
+ updatedAt: now,
286
+ variants: objectiveItem.variants.map((variant) => {
287
+ if (variant.id === selectedVariantId) {
288
+ return {
289
+ ...variant,
290
+ status: "selected" as const,
291
+ updatedAt: now,
292
+ }
293
+ }
294
+
295
+ if (variant.status === "selected") {
296
+ return {
297
+ ...variant,
298
+ status: "ready" as const,
299
+ updatedAt: now,
300
+ }
301
+ }
302
+
303
+ return variant
304
+ }),
305
+ }
306
+ })
307
+
308
+ const nextProfiles = state.profiles.map((current) => {
309
+ if (current.id !== profileId) return current
310
+ return {
311
+ ...current,
312
+ lastAppliedAt: now,
313
+ updatedAt: now,
314
+ }
315
+ })
316
+
317
+ return {
318
+ ...state,
319
+ activeProfileId: profileId,
320
+ objectives: nextObjectives,
321
+ profiles: nextProfiles,
322
+ }
323
+ }
324
+
325
+ function collectActiveSelectionChangedFiles(state: VariantStore): string[] {
326
+ const files = new Set<string>()
327
+
328
+ for (const objective of state.objectives) {
329
+ if (!objective.activeVariantId) continue
330
+ const activeVariant = objective.variants.find((variant) => variant.id === objective.activeVariantId)
331
+ if (!activeVariant) continue
332
+
333
+ for (const file of activeVariant.changedFiles) {
334
+ files.add(file)
335
+ }
336
+ }
337
+
338
+ return [...files]
339
+ }
340
+
341
+ async function applySelectionsWithActiveRollback(
342
+ projectRoot: string,
343
+ state: VariantStore,
344
+ selections: Array<{ objectiveId: string; patchFile: string; variantId: string; changedFiles?: string[] }>,
345
+ ): Promise<{ snapshotPath: string }> {
346
+ try {
347
+ return await applySelectionsToWorkspace(projectRoot, state.baseRef, selections)
348
+ } catch (error: unknown) {
349
+ const message = error instanceof Error ? error.message : String(error)
350
+ if (!message.includes("Workspace has uncommitted changes")) {
351
+ throw error
352
+ }
353
+
354
+ const allowDirtyFiles = collectActiveSelectionChangedFiles(state)
355
+ if (allowDirtyFiles.length === 0) {
356
+ throw new Error(
357
+ `${message}. No active changedFiles available for safe auto-rollback; run rollback first or register variants with changed files.`,
358
+ )
359
+ }
360
+
361
+ await rollbackWorkspaceToSnapshot(projectRoot, undefined, { allowDirtyFiles })
362
+ return applySelectionsToWorkspace(projectRoot, state.baseRef, selections)
363
+ }
364
+ }
365
+
366
+ function readPort(value: string | undefined, fallback: number): number {
367
+ const candidate = Number(value)
368
+ if (!Number.isInteger(candidate) || candidate < 0 || candidate > 65535) {
369
+ return fallback
370
+ }
371
+ return candidate
372
+ }
373
+
374
+ async function parseBody<T>(req: IncomingMessage): Promise<T> {
375
+ return new Promise((resolve, reject) => {
376
+ let body = ""
377
+ req.on("data", (chunk) => {
378
+ body += chunk
379
+ })
380
+ req.on("end", () => {
381
+ if (!body.trim()) {
382
+ resolve({} as T)
383
+ return
384
+ }
385
+
386
+ try {
387
+ resolve(JSON.parse(body) as T)
388
+ } catch {
389
+ reject(new Error("Invalid JSON body"))
390
+ }
391
+ })
392
+ req.on("error", reject)
393
+ })
394
+ }
395
+
396
+ function sendJson(res: ServerResponse, status: number, data: unknown): void {
397
+ res.writeHead(status, {
398
+ "Access-Control-Allow-Headers": "Content-Type, Accept",
399
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
400
+ "Access-Control-Allow-Origin": "*",
401
+ "Cache-Control": "no-store",
402
+ "Content-Type": "application/json",
403
+ })
404
+ res.end(JSON.stringify(data))
405
+ }
406
+
407
+ function sendScript(res: ServerResponse, content: string): void {
408
+ res.writeHead(200, {
409
+ "Access-Control-Allow-Origin": "*",
410
+ "Cache-Control": "no-store",
411
+ "Content-Type": "application/javascript; charset=utf-8",
412
+ })
413
+ res.end(content)
414
+ }
415
+
416
+ function sendCors(res: ServerResponse): void {
417
+ res.writeHead(204, {
418
+ "Access-Control-Allow-Headers": "Content-Type, Accept",
419
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
420
+ "Access-Control-Allow-Origin": "*",
421
+ "Access-Control-Max-Age": "86400",
422
+ })
423
+ res.end()
424
+ }
425
+
426
+ function summarizeState(state: VariantStore | undefined, latestSnapshot: Awaited<ReturnType<typeof getLatestSnapshot>>): BridgeStatus {
427
+ if (!state) {
428
+ return {
429
+ initialized: false,
430
+ latestSnapshot: null,
431
+ state: null,
432
+ }
433
+ }
434
+
435
+ return {
436
+ initialized: true,
437
+ latestSnapshot: latestSnapshot
438
+ ? {
439
+ baseRef: latestSnapshot.baseRef,
440
+ createdAt: latestSnapshot.createdAt,
441
+ filePath: latestSnapshot.filePath,
442
+ head: latestSnapshot.head,
443
+ id: latestSnapshot.id,
444
+ selections: latestSnapshot.selections,
445
+ }
446
+ : null,
447
+ state: {
448
+ activeProfileId: state.activeProfileId ?? null,
449
+ baseRef: state.baseRef,
450
+ objectives: state.objectives.map((objective) => ({
451
+ activeVariantId: objective.activeVariantId ?? null,
452
+ id: objective.id,
453
+ name: objective.name,
454
+ status: objective.status,
455
+ variants: objective.variants.map((variant) => ({
456
+ changedFiles: variant.changedFiles,
457
+ id: variant.id,
458
+ name: variant.name,
459
+ status: variant.status,
460
+ updatedAt: variant.updatedAt,
461
+ })),
462
+ winnerVariantId: objective.winnerVariantId ?? null,
463
+ })),
464
+ profiles: state.profiles.map((profile) => ({
465
+ id: profile.id,
466
+ lastAppliedAt: profile.lastAppliedAt ?? null,
467
+ name: profile.name,
468
+ selections: profile.selections,
469
+ updatedAt: profile.updatedAt,
470
+ })),
471
+ },
472
+ }
473
+ }
474
+
475
+ function sendSseEvent(res: ServerResponse, event: BridgeEvent): void {
476
+ res.write(`id: ${event.id}\n`)
477
+ res.write(`event: ${event.type}\n`)
478
+ res.write(`data: ${JSON.stringify(event)}\n\n`)
479
+ }
480
+
481
+ function getServerAddress(server: Server): { host: string; port: number; url: string } {
482
+ const address = server.address()
483
+ if (!address || typeof address === "string") {
484
+ throw new Error("Unable to determine bridge server address")
485
+ }
486
+
487
+ const host = address.address === "::" ? "127.0.0.1" : address.address
488
+ return {
489
+ host,
490
+ port: address.port,
491
+ url: `http://${host}:${address.port}`,
492
+ }
493
+ }
494
+
495
+ export async function startBridgeServer(options: StartBridgeServerOptions): Promise<BridgeServerHandle> {
496
+ const projectRoot = resolve(options.projectRoot)
497
+ const host = options.host || "127.0.0.1"
498
+ const requestedPort = readPort(String(options.port), 4173)
499
+
500
+ const sseClients = new Set<ServerResponse>()
501
+ let sequence = 0
502
+ let stateWatcher: FSWatcher | null = null
503
+ let stateWatchTimer: ReturnType<typeof setTimeout> | null = null
504
+
505
+ const emitEvent = (type: BridgeEventType, payload: Record<string, unknown>) => {
506
+ const event: BridgeEvent = {
507
+ id: ++sequence,
508
+ payload,
509
+ timestamp: new Date().toISOString(),
510
+ type,
511
+ }
512
+
513
+ for (const client of sseClients) {
514
+ sendSseEvent(client, event)
515
+ }
516
+ }
517
+
518
+ const scheduleExternalStateChangeEvent = () => {
519
+ if (stateWatchTimer) {
520
+ clearTimeout(stateWatchTimer)
521
+ }
522
+
523
+ stateWatchTimer = setTimeout(() => {
524
+ emitEvent("state.changed", { reason: "external-state-change" })
525
+ }, 120)
526
+ }
527
+
528
+ const startStateWatcher = () => {
529
+ const vyntDir = resolve(projectRoot, ".vynt")
530
+ try {
531
+ stateWatcher = watch(vyntDir, { persistent: false }, (_eventType, filename) => {
532
+ const fileName = typeof filename === "string" ? filename : ""
533
+ if (fileName.length > 0 && fileName !== "state.json") {
534
+ return
535
+ }
536
+ scheduleExternalStateChangeEvent()
537
+ })
538
+ } catch {
539
+ stateWatcher = null
540
+ }
541
+ }
542
+
543
+ const server = createServer(async (req, res) => {
544
+ const method = req.method ?? "GET"
545
+ const url = new URL(req.url ?? "/", "http://localhost")
546
+
547
+ if (method === "OPTIONS") {
548
+ sendCors(res)
549
+ return
550
+ }
551
+
552
+ if (method === "GET" && url.pathname === "/events") {
553
+ res.writeHead(200, {
554
+ "Access-Control-Allow-Origin": "*",
555
+ "Cache-Control": "no-cache",
556
+ Connection: "keep-alive",
557
+ "Content-Type": "text/event-stream",
558
+ })
559
+
560
+ res.write(": connected\n\n")
561
+ sseClients.add(res)
562
+
563
+ req.on("close", () => {
564
+ sseClients.delete(res)
565
+ })
566
+
567
+ return
568
+ }
569
+
570
+ if (method === "GET" && url.pathname === "/toolbar.js") {
571
+ try {
572
+ const scriptContent = await readFile(TOOLBAR_SCRIPT_PATH, "utf8")
573
+ sendScript(res, scriptContent)
574
+ } catch (error: unknown) {
575
+ const message = error instanceof Error ? error.message : String(error)
576
+ sendJson(res, 500, { error: `Failed to load toolbar script: ${message}` })
577
+ }
578
+ return
579
+ }
580
+
581
+ if (method === "GET" && url.pathname === "/health") {
582
+ sendJson(res, 200, {
583
+ projectRoot,
584
+ status: "ok",
585
+ })
586
+ return
587
+ }
588
+
589
+ if (method === "GET" && url.pathname === "/status") {
590
+ const state = await loadState(projectRoot)
591
+ const latestSnapshot = await getLatestSnapshot(projectRoot)
592
+ sendJson(res, 200, summarizeState(state, latestSnapshot))
593
+ return
594
+ }
595
+
596
+ if (method === "GET" && url.pathname === "/state") {
597
+ const state = await loadState(projectRoot)
598
+ if (!state) {
599
+ sendJson(res, 409, { error: "Variant store not initialized. Run: vynt init <base-ref>" })
600
+ return
601
+ }
602
+ sendJson(res, 200, state)
603
+ return
604
+ }
605
+
606
+ if (method === "POST" && url.pathname === "/apply") {
607
+ try {
608
+ const state = await loadState(projectRoot)
609
+ if (!state) {
610
+ sendJson(res, 409, { error: "Variant store not initialized. Run: vynt init <base-ref>" })
611
+ return
612
+ }
613
+
614
+ const body = await parseBody<{ objectiveId?: string; profileId?: string; variantId?: string }>(req)
615
+
616
+ if (body.profileId && body.variantId) {
617
+ throw new Error("Use either profileId or variantId, not both")
618
+ }
619
+
620
+ if (!body.profileId && !body.variantId) {
621
+ throw new Error("apply requires profileId or variantId")
622
+ }
623
+
624
+ emitEvent("apply.started", {
625
+ mode: body.profileId ? "profile" : "single",
626
+ objectiveId: body.objectiveId,
627
+ profileId: body.profileId,
628
+ variantId: body.variantId,
629
+ })
630
+
631
+ if (body.profileId) {
632
+ const profileItem = findProfile(state, body.profileId)
633
+ const selectionEntries = Object.entries(profileItem.selections)
634
+ if (selectionEntries.length === 0) {
635
+ throw new Error(`Profile ${body.profileId} has no selections`)
636
+ }
637
+
638
+ const resolved = selectionEntries.map(([objectiveId, variantId]) => {
639
+ const objectiveItem = findObjective(state, objectiveId)
640
+ const variant = findVariant(objectiveItem, variantId)
641
+ return {
642
+ objective: objectiveItem,
643
+ variant,
644
+ }
645
+ })
646
+
647
+ const { snapshotPath } = await applySelectionsWithActiveRollback(
648
+ projectRoot,
649
+ state,
650
+ resolved.map((item) => ({
651
+ objectiveId: item.objective.id,
652
+ patchFile: item.variant.patchFile,
653
+ variantId: item.variant.id,
654
+ changedFiles: item.variant.changedFiles,
655
+ })),
656
+ )
657
+
658
+ const nextState = applyProfileSelectionsInState(state, body.profileId)
659
+ await saveState(projectRoot, nextState)
660
+
661
+ emitEvent("apply.succeeded", {
662
+ mode: "profile",
663
+ profileId: body.profileId,
664
+ snapshotPath,
665
+ })
666
+ emitEvent("state.changed", {
667
+ reason: "apply-profile",
668
+ })
669
+
670
+ sendJson(res, 200, {
671
+ mode: "profile",
672
+ profileId: body.profileId,
673
+ snapshotPath,
674
+ })
675
+ return
676
+ }
677
+
678
+ const variantId = body.variantId as string
679
+ const target = resolveSingleApplyTarget(state, variantId, body.objectiveId)
680
+
681
+ const { snapshotPath } = await applySelectionsWithActiveRollback(projectRoot, state, [
682
+ {
683
+ objectiveId: target.objective.id,
684
+ patchFile: target.variant.patchFile,
685
+ variantId: target.variant.id,
686
+ changedFiles: target.variant.changedFiles,
687
+ },
688
+ ])
689
+
690
+ const nextState = markVariantSelectedInState(state, target.objective.id, target.variant.id)
691
+ await saveState(projectRoot, nextState)
692
+
693
+ emitEvent("apply.succeeded", {
694
+ mode: "single",
695
+ objectiveId: target.objective.id,
696
+ snapshotPath,
697
+ variantId: target.variant.id,
698
+ })
699
+ emitEvent("state.changed", {
700
+ reason: "apply-single",
701
+ })
702
+
703
+ sendJson(res, 200, {
704
+ mode: "single",
705
+ objectiveId: target.objective.id,
706
+ snapshotPath,
707
+ variantId: target.variant.id,
708
+ })
709
+ } catch (error: unknown) {
710
+ const message = error instanceof Error ? error.message : String(error)
711
+ emitEvent("apply.failed", { error: message })
712
+ sendJson(res, 400, { error: message })
713
+ }
714
+ return
715
+ }
716
+
717
+ if (method === "POST" && url.pathname === "/finalize") {
718
+ try {
719
+ const state = await loadState(projectRoot)
720
+ if (!state) {
721
+ sendJson(res, 409, { error: "Variant store not initialized. Run: vynt init <base-ref>" })
722
+ return
723
+ }
724
+
725
+ const body = await parseBody<{ objectiveId?: string; variantId?: string }>(req)
726
+ if (!body.objectiveId || !body.variantId) {
727
+ throw new Error("finalize requires objectiveId and variantId")
728
+ }
729
+
730
+ const target = resolveSingleApplyTarget(state, body.variantId, body.objectiveId)
731
+
732
+ emitEvent("finalize.started", {
733
+ objectiveId: target.objective.id,
734
+ variantId: target.variant.id,
735
+ })
736
+
737
+ const { snapshotPath } = await applySelectionsWithActiveRollback(projectRoot, state, [
738
+ {
739
+ objectiveId: target.objective.id,
740
+ patchFile: target.variant.patchFile,
741
+ variantId: target.variant.id,
742
+ changedFiles: target.variant.changedFiles,
743
+ },
744
+ ])
745
+
746
+ const nextState = finalizeVariantInObjectiveState(state, target.objective.id, target.variant.id)
747
+ await saveState(projectRoot, nextState)
748
+
749
+ emitEvent("finalize.succeeded", {
750
+ objectiveId: target.objective.id,
751
+ snapshotPath,
752
+ variantId: target.variant.id,
753
+ })
754
+ emitEvent("state.changed", {
755
+ reason: "finalize-objective",
756
+ })
757
+
758
+ sendJson(res, 200, {
759
+ objectiveId: target.objective.id,
760
+ snapshotPath,
761
+ variantId: target.variant.id,
762
+ })
763
+ } catch (error: unknown) {
764
+ const message = error instanceof Error ? error.message : String(error)
765
+ emitEvent("finalize.failed", { error: message })
766
+ sendJson(res, 400, { error: message })
767
+ }
768
+ return
769
+ }
770
+
771
+ if (method === "POST" && url.pathname === "/rollback") {
772
+ try {
773
+ const state = await loadState(projectRoot)
774
+ if (!state) {
775
+ sendJson(res, 409, { error: "Variant store not initialized. Run: vynt init <base-ref>" })
776
+ return
777
+ }
778
+
779
+ const body = await parseBody<{ snapshotId?: string }>(req)
780
+ emitEvent("rollback.started", { snapshotId: body.snapshotId ?? null })
781
+
782
+ const { snapshot } = await rollbackWorkspaceToSnapshot(projectRoot, body.snapshotId, {
783
+ allowDirtyFiles: collectActiveSelectionChangedFiles(state),
784
+ })
785
+ const nextState = clearActiveSelections(state)
786
+ await saveState(projectRoot, nextState)
787
+
788
+ emitEvent("rollback.succeeded", {
789
+ snapshotHead: snapshot.head,
790
+ snapshotId: snapshot.id,
791
+ })
792
+ emitEvent("state.changed", {
793
+ reason: "rollback",
794
+ snapshotId: snapshot.id,
795
+ })
796
+
797
+ sendJson(res, 200, {
798
+ snapshotHead: snapshot.head,
799
+ snapshotId: snapshot.id,
800
+ })
801
+ } catch (error: unknown) {
802
+ const message = error instanceof Error ? error.message : String(error)
803
+ emitEvent("rollback.failed", { error: message })
804
+ sendJson(res, 400, { error: message })
805
+ }
806
+ return
807
+ }
808
+
809
+ sendJson(res, 404, { error: "Not found" })
810
+ })
811
+
812
+ await new Promise<void>((resolveListen, rejectListen) => {
813
+ server.once("error", rejectListen)
814
+ server.listen(requestedPort, host, () => {
815
+ server.removeListener("error", rejectListen)
816
+ resolveListen()
817
+ })
818
+ })
819
+
820
+ const address = getServerAddress(server)
821
+ startStateWatcher()
822
+ emitEvent("server.started", {
823
+ host: address.host,
824
+ port: address.port,
825
+ projectRoot,
826
+ url: address.url,
827
+ })
828
+
829
+ return {
830
+ close: async () => {
831
+ if (stateWatchTimer) {
832
+ clearTimeout(stateWatchTimer)
833
+ stateWatchTimer = null
834
+ }
835
+ if (stateWatcher) {
836
+ stateWatcher.close()
837
+ stateWatcher = null
838
+ }
839
+
840
+ for (const client of sseClients) {
841
+ client.end()
842
+ }
843
+
844
+ await new Promise<void>((resolveClose, rejectClose) => {
845
+ server.close((error) => {
846
+ if (error) {
847
+ rejectClose(error)
848
+ return
849
+ }
850
+ resolveClose()
851
+ })
852
+ })
853
+ },
854
+ host: address.host,
855
+ port: address.port,
856
+ url: address.url,
857
+ }
858
+ }