yaver-feedback-react-native 0.9.0 → 0.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/README.md +102 -1
  2. package/app.plugin.js +126 -9
  3. package/dist/AuthOverlay.d.ts +1 -14
  4. package/dist/AuthOverlay.js +9 -62
  5. package/dist/BlackBox.js +9 -1
  6. package/dist/DeployPanel.js +65 -0
  7. package/dist/Discovery.js +13 -8
  8. package/dist/DogfoodRuntime.d.ts +124 -0
  9. package/dist/DogfoodRuntime.js +273 -0
  10. package/dist/FeedbackModal.js +505 -65
  11. package/dist/LoginScreen.d.ts +1 -5
  12. package/dist/LoginScreen.js +2 -6
  13. package/dist/MachinePickerScreen.d.ts +1 -3
  14. package/dist/MachinePickerScreen.js +6 -18
  15. package/dist/P2PClient.d.ts +178 -2
  16. package/dist/P2PClient.js +493 -3
  17. package/dist/P2PDogfoodDriver.d.ts +12 -0
  18. package/dist/P2PDogfoodDriver.js +118 -0
  19. package/dist/PairDeviceModal.d.ts +2 -3
  20. package/dist/ShakeDetector.js +2 -0
  21. package/dist/VibeChatScreen.d.ts +11 -1
  22. package/dist/VibeChatScreen.js +200 -41
  23. package/dist/YaverFeedback.d.ts +132 -0
  24. package/dist/YaverFeedback.js +633 -65
  25. package/dist/YaverModeBadge.d.ts +22 -0
  26. package/dist/YaverModeBadge.js +219 -0
  27. package/dist/__tests__/AuthDevices.test.js +1 -48
  28. package/dist/__tests__/BlackBox.relayPassword.test.d.ts +1 -0
  29. package/dist/__tests__/BlackBox.relayPassword.test.js +105 -0
  30. package/dist/__tests__/BlackBoxAutoStart.test.d.ts +1 -0
  31. package/dist/__tests__/BlackBoxAutoStart.test.js +91 -0
  32. package/dist/__tests__/BlackBoxAutoStartColdStart.test.d.ts +1 -0
  33. package/dist/__tests__/BlackBoxAutoStartColdStart.test.js +156 -0
  34. package/dist/__tests__/BrowserLaneIcon.test.d.ts +3 -0
  35. package/dist/__tests__/BrowserLaneIcon.test.js +80 -0
  36. package/dist/__tests__/DogfoodRuntime.test.d.ts +1 -0
  37. package/dist/__tests__/DogfoodRuntime.test.js +116 -0
  38. package/dist/__tests__/P2PClient.test.js +2 -2
  39. package/dist/__tests__/P2PDogfoodDriver.test.d.ts +1 -0
  40. package/dist/__tests__/P2PDogfoodDriver.test.js +64 -0
  41. package/dist/__tests__/ReportIdentity.test.d.ts +25 -0
  42. package/dist/__tests__/ReportIdentity.test.js +180 -0
  43. package/dist/__tests__/SDKToken.test.js +1 -1
  44. package/dist/__tests__/ShakeToggle.test.d.ts +1 -0
  45. package/dist/__tests__/ShakeToggle.test.js +121 -0
  46. package/dist/__tests__/YaverFeedback.test.js +13 -3
  47. package/dist/__tests__/deviceDogfood.test.d.ts +1 -0
  48. package/dist/__tests__/deviceDogfood.test.js +86 -0
  49. package/dist/__tests__/dogfoodPolicy.test.d.ts +1 -0
  50. package/dist/__tests__/dogfoodPolicy.test.js +33 -0
  51. package/dist/__tests__/pickTargetDevice.test.d.ts +1 -0
  52. package/dist/__tests__/pickTargetDevice.test.js +89 -0
  53. package/dist/__tests__/reloadActions.test.d.ts +1 -0
  54. package/dist/__tests__/reloadActions.test.js +129 -0
  55. package/dist/__tests__/reloadActionsParity.test.d.ts +1 -0
  56. package/dist/__tests__/reloadActionsParity.test.js +42 -0
  57. package/dist/__tests__/types.test.js +5 -5
  58. package/dist/_core/ansi.d.ts +117 -0
  59. package/dist/_core/ansi.js +468 -0
  60. package/dist/_core/ansi.test.d.ts +1 -0
  61. package/dist/_core/ansi.test.js +225 -0
  62. package/dist/_core/buildFeedbackPrompt.d.ts +4 -9
  63. package/dist/_core/buildFeedbackPrompt.js +18 -72
  64. package/dist/_core/constants.d.ts +19 -6
  65. package/dist/_core/constants.js +20 -7
  66. package/dist/_core/device.d.ts +1 -5
  67. package/dist/_core/device.js +5 -14
  68. package/dist/_core/endpoints.d.ts +0 -7
  69. package/dist/_core/endpoints.js +0 -7
  70. package/dist/_core/index.d.ts +4 -0
  71. package/dist/_core/index.js +4 -0
  72. package/dist/_core/remoteless.d.ts +44 -0
  73. package/dist/_core/remoteless.js +75 -0
  74. package/dist/_core/trace.d.ts +47 -0
  75. package/dist/_core/trace.js +38 -0
  76. package/dist/_core/trace.test.d.ts +1 -0
  77. package/dist/_core/trace.test.js +60 -0
  78. package/dist/auth.d.ts +3 -60
  79. package/dist/auth.js +6 -88
  80. package/dist/deviceDogfood.d.ts +53 -0
  81. package/dist/deviceDogfood.js +137 -0
  82. package/dist/dogfoodPolicy.d.ts +25 -0
  83. package/dist/dogfoodPolicy.js +24 -0
  84. package/dist/index.d.ts +17 -4
  85. package/dist/index.js +33 -7
  86. package/dist/reloadActions.d.ts +88 -0
  87. package/dist/reloadActions.js +200 -0
  88. package/dist/storeShots.d.ts +67 -0
  89. package/dist/storeShots.js +137 -0
  90. package/dist/types.d.ts +208 -8
  91. package/package.json +13 -4
  92. package/src/AuthOverlay.tsx +20 -106
  93. package/src/BlackBox.ts +10 -1
  94. package/src/DeployPanel.tsx +74 -0
  95. package/src/Discovery.ts +13 -8
  96. package/src/DogfoodRuntime.ts +373 -0
  97. package/src/FeedbackModal.tsx +619 -79
  98. package/src/LoginScreen.tsx +2 -22
  99. package/src/MachinePickerScreen.tsx +6 -21
  100. package/src/P2PClient.ts +580 -4
  101. package/src/P2PDogfoodDriver.ts +132 -0
  102. package/src/PairDeviceModal.tsx +2 -3
  103. package/src/ShakeDetector.ts +1 -0
  104. package/src/VibeChatScreen.tsx +232 -42
  105. package/src/YaverFeedback.ts +631 -68
  106. package/src/YaverModeBadge.tsx +234 -0
  107. package/src/__tests__/AuthDevices.test.ts +1 -52
  108. package/src/__tests__/BlackBox.relayPassword.test.ts +129 -0
  109. package/src/__tests__/BlackBoxAutoStart.test.ts +111 -0
  110. package/src/__tests__/BlackBoxAutoStartColdStart.test.ts +191 -0
  111. package/src/__tests__/BrowserLaneIcon.test.ts +85 -0
  112. package/src/__tests__/DogfoodRuntime.test.ts +135 -0
  113. package/src/__tests__/P2PClient.test.ts +2 -2
  114. package/src/__tests__/P2PDogfoodDriver.test.ts +80 -0
  115. package/src/__tests__/ReportIdentity.test.ts +212 -0
  116. package/src/__tests__/SDKToken.test.ts +1 -1
  117. package/src/__tests__/ShakeToggle.test.ts +153 -0
  118. package/src/__tests__/YaverFeedback.test.ts +15 -3
  119. package/src/__tests__/deviceDogfood.test.ts +77 -0
  120. package/src/__tests__/dogfoodPolicy.test.ts +34 -0
  121. package/src/__tests__/pickTargetDevice.test.ts +101 -0
  122. package/src/__tests__/reloadActions.test.ts +171 -0
  123. package/src/__tests__/reloadActionsParity.test.ts +49 -0
  124. package/src/__tests__/types.test.ts +5 -5
  125. package/src/_core/ansi.test.ts +250 -0
  126. package/src/_core/ansi.ts +475 -0
  127. package/src/_core/buildFeedbackPrompt.ts +18 -95
  128. package/src/_core/constants.ts +20 -6
  129. package/src/_core/device.ts +5 -16
  130. package/src/_core/endpoints.ts +0 -7
  131. package/src/_core/index.ts +4 -0
  132. package/src/_core/remoteless.ts +110 -0
  133. package/src/_core/trace.test.ts +58 -0
  134. package/src/_core/trace.ts +75 -0
  135. package/src/auth.ts +7 -156
  136. package/src/deviceDogfood.ts +171 -0
  137. package/src/dogfoodPolicy.ts +40 -0
  138. package/src/index.ts +61 -11
  139. package/src/reloadActions.ts +273 -0
  140. package/src/storeShots.ts +189 -0
  141. package/src/types.ts +209 -8
  142. package/dist/GuestOnboardingScreen.d.ts +0 -8
  143. package/dist/GuestOnboardingScreen.js +0 -282
  144. package/src/GuestOnboardingScreen.tsx +0 -307
@@ -1,6 +1,7 @@
1
1
  import React, { useCallback, useEffect, useState } from 'react';
2
2
  import {
3
3
  ActivityIndicator,
4
+ Alert,
4
5
  Pressable,
5
6
  ScrollView,
6
7
  StyleSheet,
@@ -184,6 +185,47 @@ export const DeployPanel: React.FC<DeployPanelProps> = ({ onClose }) => {
184
185
  );
185
186
  };
186
187
 
188
+ // First-class App Store screenshots: walk the app's routes on THIS
189
+ // device, screenshot each, upload to the agent (which runs the ASC
190
+ // backend). Closes the overlay first so the captures are clean (no
191
+ // modal in frame), then reports via an alert.
192
+ const runStoreShots = () => {
193
+ const cfg = YaverFeedback.getConfig() as { storeShots?: any } | null;
194
+ const ss = cfg?.storeShots;
195
+ if (!ss?.routes?.length) {
196
+ setStatus('Set config.storeShots.routes (and a navigationRef) to enable.');
197
+ setStatusTone('error');
198
+ return;
199
+ }
200
+ const app = resolveAppSlug();
201
+ onClose();
202
+ // Defer so the overlay is fully dismissed before the first capture.
203
+ setTimeout(async () => {
204
+ try {
205
+ const res = await YaverFeedback.captureStoreScreenshots({
206
+ app,
207
+ routes: ss.routes,
208
+ navigationRef: ss.navigationRef,
209
+ screens: ss.screens,
210
+ submit: ss.submit,
211
+ });
212
+ const msg = res.ok
213
+ ? res.submitted
214
+ ? 'Submitted for App Store review 🎉'
215
+ : res.staged
216
+ ? `Uploaded ${res.uploaded} screenshots — staged. One tap left in App Store Connect.`
217
+ : `Uploaded ${res.uploaded} App Store screenshots.`
218
+ : res.message || 'Capture failed.';
219
+ Alert.alert('App Store screenshots', msg);
220
+ } catch (e: any) {
221
+ Alert.alert('App Store screenshots', e?.message ?? 'Capture failed.');
222
+ }
223
+ }, 500);
224
+ };
225
+
226
+ const storeShotsEnabled =
227
+ ((YaverFeedback.getConfig() as { storeShots?: any } | null)?.storeShots?.routes?.length ?? 0) > 0;
228
+
187
229
  const triggerDeploy = async (machine: string) => {
188
230
  if (!options) return;
189
231
  setShipping(true);
@@ -277,6 +319,19 @@ export const DeployPanel: React.FC<DeployPanelProps> = ({ onClose }) => {
277
319
  <ScrollView style={styles.list}>{options.devices.map(machineRow)}</ScrollView>
278
320
  ) : null}
279
321
 
322
+ {storeShotsEnabled && (
323
+ <Pressable
324
+ onPress={runStoreShots}
325
+ disabled={shipping}
326
+ style={({ pressed }) => [styles.shotsBtn, pressed && styles.rowPressed]}
327
+ >
328
+ <Text style={styles.shotsBtnText}>📸 App Store screenshots</Text>
329
+ <Text style={styles.shotsBtnSub}>
330
+ capture this app on-device + upload to App Store Connect
331
+ </Text>
332
+ </Pressable>
333
+ )}
334
+
280
335
  {status && (
281
336
  <Text
282
337
  style={[
@@ -388,6 +443,25 @@ const styles = StyleSheet.create({
388
443
  rowMetaWarning: {
389
444
  color: 'rgb(255,178,115)',
390
445
  },
446
+ shotsBtn: {
447
+ marginTop: 12,
448
+ paddingVertical: 12,
449
+ paddingHorizontal: 14,
450
+ borderRadius: 10,
451
+ backgroundColor: 'rgba(124,109,255,0.16)',
452
+ borderWidth: 1,
453
+ borderColor: 'rgba(124,109,255,0.4)',
454
+ },
455
+ shotsBtnText: {
456
+ color: '#fff',
457
+ fontSize: 14,
458
+ fontWeight: '600',
459
+ },
460
+ shotsBtnSub: {
461
+ color: 'rgba(255,255,255,0.55)',
462
+ fontSize: 12,
463
+ marginTop: 2,
464
+ },
391
465
  status: {
392
466
  color: 'rgba(255,255,255,0.55)',
393
467
  fontSize: 12,
package/src/Discovery.ts CHANGED
@@ -7,6 +7,14 @@ type AsyncStorageLike = {
7
7
  setItem: (key: string, value: string) => Promise<void>;
8
8
  removeItem: (key: string) => Promise<void>;
9
9
  };
10
+
11
+ function unrefTimer(timer: ReturnType<typeof setTimeout>): void {
12
+ const maybeNodeTimer = timer as unknown as { unref?: () => void };
13
+ if (typeof maybeNodeTimer.unref === 'function') {
14
+ maybeNodeTimer.unref();
15
+ }
16
+ }
17
+
10
18
  function getAsyncStorage(): AsyncStorageLike | null {
11
19
  try {
12
20
  const mod = require('@react-native-async-storage/async-storage');
@@ -187,12 +195,6 @@ export class YaverDiscovery {
187
195
  needsAuth: !!d.needsAuth,
188
196
  runnerDown: !!d.runnerDown,
189
197
  lastHeartbeat: d.lastHeartbeat ?? 0,
190
- isGuest: !!d.isGuest,
191
- hostUserId: d.hostUserId,
192
- hostName: d.hostName,
193
- hostEmail: d.hostEmail,
194
- hostUserIdString: d.hostUserIdString,
195
- accessScope: d.accessScope ?? 'owner',
196
198
  quicHost: d.quicHost ?? d.host ?? '',
197
199
  quicPort: d.quicPort ?? 0,
198
200
  httpPort: d.httpPort ?? d.quicPort,
@@ -362,14 +364,15 @@ export class YaverDiscovery {
362
364
  static async probe(url: string): Promise<DiscoveryResult | null> {
363
365
  const base = url.replace(/\/$/, '');
364
366
  const start = Date.now();
367
+ let timeoutId: ReturnType<typeof setTimeout> | null = null;
365
368
  try {
366
369
  const controller = new AbortController();
367
- const timeoutId = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
370
+ timeoutId = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
371
+ unrefTimer(timeoutId);
368
372
  const response = await fetch(`${base}/health`, {
369
373
  method: 'GET',
370
374
  signal: controller.signal,
371
375
  });
372
- clearTimeout(timeoutId);
373
376
  if (!response.ok) return null;
374
377
  const latency = Date.now() - start;
375
378
  let hostname = 'Unknown';
@@ -384,6 +387,8 @@ export class YaverDiscovery {
384
387
  return { url: base, hostname, version, latency };
385
388
  } catch {
386
389
  return null;
390
+ } finally {
391
+ if (timeoutId) clearTimeout(timeoutId);
387
392
  }
388
393
  }
389
394
 
@@ -0,0 +1,373 @@
1
+ /**
2
+ * Embeddable Dogfood orchestration for React Native hosts.
3
+ *
4
+ * The SDK deliberately does not own the host's button, WebView, navigation, or
5
+ * Yaver transport. It owns the failure-prone lifecycle underneath them:
6
+ * explicit trigger -> prepare -> start -> ready, bounded log retention,
7
+ * generation-safe Retry, and deterministic cleanup of partial sessions.
8
+ *
9
+ * Yaver mobile uses this class for Yaver-on-Yaver. Third-party apps use the
10
+ * same class with their own project descriptor and a driver backed by
11
+ * P2PClient (or another authenticated Yaver transport).
12
+ */
13
+
14
+ export type DogfoodLane = 'browser' | 'hermes' | 'webrtc';
15
+ export type DogfoodPhase =
16
+ | 'idle'
17
+ | 'preparing'
18
+ | 'starting'
19
+ | 'compiling'
20
+ | 'ready'
21
+ | 'failed'
22
+ | 'stopping'
23
+ | 'stopped';
24
+
25
+ export interface DogfoodProject {
26
+ name: string;
27
+ workDir: string;
28
+ framework: string;
29
+ lane: DogfoodLane;
30
+ /** Optional source URL for drivers that can clone missing source. */
31
+ repositoryUrl?: string;
32
+ /** Optional native target from /remote-runtime/capabilities for WebRTC. */
33
+ nativeTargetId?: string;
34
+ }
35
+
36
+ export interface DogfoodLaneOption {
37
+ lane: DogfoodLane;
38
+ label: string;
39
+ supported: boolean;
40
+ default: boolean;
41
+ reason?: string;
42
+ }
43
+
44
+ /** One framework-to-lane matrix for Yaver and third-party consumers. */
45
+ export function dogfoodLaneOptions(
46
+ framework: string,
47
+ capabilities: { nativeRuntimeAvailable?: boolean; selfDevelopment?: boolean } = {},
48
+ ): DogfoodLaneOption[] {
49
+ const normalized = String(framework || '').trim().toLowerCase();
50
+ const reactNative = normalized === 'expo' || normalized === 'react-native';
51
+ const browserCapable = reactNative || [
52
+ 'flutter', 'web', 'next', 'nextjs', 'vite', 'remix', 'svelte', 'vue', 'angular',
53
+ ].includes(normalized);
54
+ const nativeAvailable = capabilities.nativeRuntimeAvailable === true;
55
+ const hermesReason = reactNative
56
+ ? undefined
57
+ : 'Hermes is available only for Expo and React Native projects.';
58
+ const browserReason = browserCapable
59
+ ? undefined
60
+ : 'The browser lane is available for browser-capable projects such as React Native, Expo, Flutter, and web apps.';
61
+ return [
62
+ { lane: 'browser', label: 'Browser lane', supported: browserCapable, default: browserCapable, reason: browserReason },
63
+ { lane: 'hermes', label: 'Hermes', supported: !hermesReason, default: false, reason: hermesReason },
64
+ {
65
+ lane: 'webrtc', label: 'WebRTC native', supported: nativeAvailable, default: false,
66
+ reason: nativeAvailable ? undefined : 'No native simulator, emulator, or device runtime is available on this machine.',
67
+ },
68
+ ];
69
+ }
70
+
71
+ export function defaultDogfoodLane(framework: string): DogfoodLane {
72
+ return dogfoodLaneOptions(framework).find((option) => option.default && option.supported)?.lane || 'browser';
73
+ }
74
+
75
+ export interface DogfoodLogLine {
76
+ text: string;
77
+ at: number;
78
+ stream?: 'stdout' | 'stderr' | 'system';
79
+ }
80
+
81
+ export interface DogfoodFailure {
82
+ code: string;
83
+ error: string;
84
+ remedy: string;
85
+ retryable: boolean;
86
+ fixPrompt?: string;
87
+ }
88
+
89
+ export interface DogfoodResult {
90
+ lane: DogfoodLane;
91
+ url?: string;
92
+ sessionId?: string;
93
+ metadata?: Record<string, unknown>;
94
+ }
95
+
96
+ export interface DogfoodSnapshot {
97
+ phase: DogfoodPhase;
98
+ attempt: number;
99
+ project: DogfoodProject;
100
+ message: string;
101
+ logs: readonly DogfoodLogLine[];
102
+ startedAt?: number;
103
+ lastOutputAt?: number;
104
+ result?: DogfoodResult;
105
+ failure?: DogfoodFailure;
106
+ }
107
+
108
+ export interface DogfoodRunContext {
109
+ project: DogfoodProject;
110
+ attempt: number;
111
+ /** Raw package-manager/compiler output. ANSI is intentionally preserved. */
112
+ log(line: string | DogfoodLogLine): void;
113
+ setPhase(phase: Extract<DogfoodPhase, 'preparing' | 'starting' | 'compiling'>, message: string): void;
114
+ /**
115
+ * Register cleanup immediately after acquiring a resource.
116
+ *
117
+ * `session` cleanups run on failure/stop. `transient` cleanups also run when
118
+ * ownership is handed to another screen (normally an SSE subscription).
119
+ */
120
+ registerCleanup(cleanup: () => void | Promise<void>, scope?: 'session' | 'transient'): void;
121
+ isCurrent(): boolean;
122
+ }
123
+
124
+ export interface DogfoodDriver {
125
+ prepare?(context: DogfoodRunContext): Promise<void>;
126
+ start(context: DogfoodRunContext): Promise<DogfoodResult>;
127
+ }
128
+
129
+ export interface DogfoodControllerOptions {
130
+ maxLogLines?: number;
131
+ onChange?: (snapshot: DogfoodSnapshot) => void;
132
+ }
133
+
134
+ export class DogfoodRuntimeError extends Error {
135
+ readonly failure: DogfoodFailure;
136
+
137
+ constructor(failure: DogfoodFailure) {
138
+ super(failure.error);
139
+ this.name = 'DogfoodRuntimeError';
140
+ this.failure = failure;
141
+ }
142
+ }
143
+
144
+ type Cleanup = { fn: () => void | Promise<void>; scope: 'session' | 'transient' };
145
+
146
+ export function validateDogfoodProject(project: DogfoodProject): DogfoodFailure | null {
147
+ const framework = String(project.framework || '').trim().toLowerCase();
148
+ if (!String(project.workDir || '').trim()) {
149
+ return {
150
+ code: 'DOGFOOD_PROJECT_PATH_REQUIRED',
151
+ error: 'Dogfood needs the project directory on the selected machine.',
152
+ remedy: 'Choose or clone the project source, then trigger Dogfood again.',
153
+ retryable: false,
154
+ };
155
+ }
156
+ if (project.lane === 'hermes' && framework !== 'expo' && framework !== 'react-native') {
157
+ return {
158
+ code: 'DOGFOOD_HERMES_FRAMEWORK_UNSUPPORTED',
159
+ error: `Hermes cannot run a ${framework || 'non-React-Native'} project.`,
160
+ remedy: framework === 'flutter'
161
+ ? 'Use the browser lane (Flutter web) or WebRTC for a native Flutter runtime.'
162
+ : 'Use the browser or WebRTC lane, or select an Expo/React Native project.',
163
+ retryable: false,
164
+ };
165
+ }
166
+ return null;
167
+ }
168
+
169
+ /** Convert one /dev/events frame into console lines without hiding raw output. */
170
+ export function runtimeLogLinesFromDevEvent(event: any): string[] {
171
+ if (!event || typeof event !== 'object') return [];
172
+ if (event.type === 'log' && typeof event.logLine === 'string') {
173
+ return event.logLine.trimEnd() ? [event.logLine.trimEnd()] : [];
174
+ }
175
+ if (event.type === 'snapshot' && Array.isArray(event.snapshot?.recentLogs)) {
176
+ return event.snapshot.recentLogs
177
+ .map((line: unknown) => String(line).trimEnd())
178
+ .filter(Boolean);
179
+ }
180
+ if (event.type === 'progress' || event.type === 'phase') {
181
+ const phase = typeof event.phase === 'string' ? event.phase.replace(/_/g, ' ') : 'working';
182
+ const pct = typeof event.pct === 'number' ? ` ${Math.round(event.pct)}%` : '';
183
+ const file = typeof event.currentFile === 'string' && event.currentFile
184
+ ? ` · ${String(event.currentFile).split('/').slice(-2).join('/')}`
185
+ : '';
186
+ return [`${phase}${pct}${file}`.trim()];
187
+ }
188
+ if (event.type === 'error') {
189
+ return String(event.message || 'Dev server failed')
190
+ .split('\n')
191
+ .map((line) => line.trimEnd())
192
+ .filter(Boolean);
193
+ }
194
+ if (typeof event.message === 'string' && event.message.trim() &&
195
+ ['starting', 'building', 'phase', 'ready', 'reload', 'stopped'].includes(String(event.type))) {
196
+ return [event.message.trimEnd()];
197
+ }
198
+ return [];
199
+ }
200
+
201
+ /** Backwards-friendly name for consumers that discovered this via Dogfood. */
202
+ export const dogfoodLogLinesFromDevEvent = runtimeLogLinesFromDevEvent;
203
+
204
+ function failureFrom(error: unknown): DogfoodFailure {
205
+ if (error instanceof DogfoodRuntimeError) return error.failure;
206
+ const candidate = error as Partial<DogfoodFailure> | undefined;
207
+ return {
208
+ code: typeof candidate?.code === 'string' ? candidate.code : 'DOGFOOD_START_FAILED',
209
+ error: error instanceof Error ? error.message : String(error || 'Dogfood could not start.'),
210
+ remedy: typeof candidate?.remedy === 'string'
211
+ ? candidate.remedy
212
+ : 'Fix the named project or runtime failure, then retry Dogfood.',
213
+ retryable: candidate?.retryable !== false,
214
+ fixPrompt: typeof candidate?.fixPrompt === 'string' ? candidate.fixPrompt : undefined,
215
+ };
216
+ }
217
+
218
+ export class DogfoodController {
219
+ readonly project: DogfoodProject;
220
+ private readonly driver: DogfoodDriver;
221
+ private generation = 0;
222
+ // Cleanup ownership is per attempt. An old async attempt may finish after
223
+ // Stop + Retry has already acquired a new runtime; a shared list lets that
224
+ // obsolete attempt tear down the newer session. Generation-keyed ownership
225
+ // makes that race impossible while keeping Stop able to release everything.
226
+ private cleanups = new Map<number, Cleanup[]>();
227
+ private runPromise: Promise<DogfoodResult> | null = null;
228
+ private readonly maxLogLines: number;
229
+ private readonly onChange?: (snapshot: DogfoodSnapshot) => void;
230
+ private state: DogfoodSnapshot;
231
+
232
+ constructor(
233
+ project: DogfoodProject,
234
+ driver: DogfoodDriver,
235
+ options: DogfoodControllerOptions = {},
236
+ ) {
237
+ this.project = project;
238
+ this.driver = driver;
239
+ this.maxLogLines = Math.max(20, options.maxLogLines ?? 200);
240
+ this.onChange = options.onChange;
241
+ this.state = { phase: 'idle', attempt: 0, project, message: 'Ready to dogfood', logs: [] };
242
+ }
243
+
244
+ snapshot(): DogfoodSnapshot {
245
+ return { ...this.state, logs: [...this.state.logs] };
246
+ }
247
+
248
+ /** Nothing starts until the host calls this method from its explicit action. */
249
+ trigger(): Promise<DogfoodResult> {
250
+ if (this.runPromise) return this.runPromise;
251
+ const promise = this.run();
252
+ this.runPromise = promise;
253
+ void promise.finally(() => {
254
+ if (this.runPromise === promise) this.runPromise = null;
255
+ }).catch(() => {});
256
+ return promise;
257
+ }
258
+
259
+ retry(): Promise<DogfoodResult> {
260
+ return this.trigger();
261
+ }
262
+
263
+ private async run(): Promise<DogfoodResult> {
264
+ const generation = ++this.generation;
265
+ const attempt = this.state.attempt + 1;
266
+ await this.runCleanups('all');
267
+ const invalid = validateDogfoodProject(this.project);
268
+ if (invalid) {
269
+ this.replace({ phase: 'failed', attempt, project: this.project, message: invalid.error, logs: [], failure: invalid });
270
+ throw new DogfoodRuntimeError(invalid);
271
+ }
272
+ this.replace({
273
+ phase: 'preparing', attempt, project: this.project,
274
+ message: `Preparing ${this.project.name}…`, logs: [], startedAt: Date.now(),
275
+ });
276
+
277
+ const context: DogfoodRunContext = {
278
+ project: this.project,
279
+ attempt,
280
+ log: (line) => {
281
+ if (generation !== this.generation) return;
282
+ const entry: DogfoodLogLine = typeof line === 'string'
283
+ ? { text: line, at: Date.now(), stream: 'stdout' }
284
+ : { ...line, at: line.at || Date.now() };
285
+ if (!entry.text.trim()) return;
286
+ const logs = [...this.state.logs, entry].slice(-this.maxLogLines);
287
+ this.replace({ ...this.state, logs, lastOutputAt: entry.at });
288
+ },
289
+ setPhase: (phase, message) => {
290
+ if (generation === this.generation) this.replace({ ...this.state, phase, message });
291
+ },
292
+ registerCleanup: (cleanup, scope = 'session') => {
293
+ if (generation !== this.generation) {
294
+ void Promise.resolve(cleanup()).catch(() => {});
295
+ return;
296
+ }
297
+ const owned = this.cleanups.get(generation) || [];
298
+ owned.push({ fn: cleanup, scope });
299
+ this.cleanups.set(generation, owned);
300
+ },
301
+ isCurrent: () => generation === this.generation,
302
+ };
303
+
304
+ try {
305
+ await this.driver.prepare?.(context);
306
+ if (!context.isCurrent()) throw new DogfoodRuntimeError({
307
+ code: 'DOGFOOD_ATTEMPT_REPLACED', error: 'A newer Dogfood attempt replaced this one.',
308
+ remedy: 'Wait for the newer attempt.', retryable: true,
309
+ });
310
+ context.setPhase('starting', `Starting ${this.project.name} on the ${this.project.lane} lane…`);
311
+ const result = await this.driver.start(context);
312
+ if (!context.isCurrent()) {
313
+ await this.runCleanups('all', generation);
314
+ throw new DogfoodRuntimeError({
315
+ code: 'DOGFOOD_ATTEMPT_REPLACED', error: 'A newer Dogfood attempt replaced this one.',
316
+ remedy: 'Wait for the newer attempt.', retryable: true,
317
+ });
318
+ }
319
+ this.replace({ ...this.state, phase: 'ready', message: `${this.project.name} is ready`, result, failure: undefined });
320
+ return result;
321
+ } catch (error) {
322
+ const failure = failureFrom(error);
323
+ await this.runCleanups('all', generation);
324
+ if (generation === this.generation) {
325
+ this.replace({ ...this.state, phase: 'failed', message: failure.error, failure, result: undefined });
326
+ }
327
+ throw error instanceof DogfoodRuntimeError ? error : new DogfoodRuntimeError(failure);
328
+ }
329
+ }
330
+
331
+ /** Stop the active/partial run and release every resource. Idempotent. */
332
+ async stop(): Promise<void> {
333
+ ++this.generation;
334
+ this.runPromise = null;
335
+ this.replace({ ...this.state, phase: 'stopping', message: `Stopping ${this.project.name}…` });
336
+ await this.runCleanups('all');
337
+ this.replace({ ...this.state, phase: 'stopped', message: `${this.project.name} stopped`, result: undefined });
338
+ }
339
+
340
+ /**
341
+ * Transfer the live session to a host-owned preview screen. Stream/timer
342
+ * cleanups run; session cleanup is discarded because the receiving screen
343
+ * now owns it.
344
+ */
345
+ async handoff(): Promise<DogfoodResult | undefined> {
346
+ if (this.state.phase !== 'ready') return undefined;
347
+ await this.runCleanups('transient', this.generation);
348
+ // The receiving screen now owns the live session. Forget its session
349
+ // cleanup without touching cleanups belonging to any other generation.
350
+ this.cleanups.delete(this.generation);
351
+ return this.state.result;
352
+ }
353
+
354
+ private async runCleanups(which: 'all' | 'transient', generation?: number): Promise<void> {
355
+ const generations = generation === undefined ? [...this.cleanups.keys()] : [generation];
356
+ const selected: Cleanup[] = [];
357
+ for (const key of generations) {
358
+ const owned = this.cleanups.get(key) || [];
359
+ selected.push(...(which === 'all' ? owned : owned.filter((item) => item.scope === 'transient')));
360
+ const retained = which === 'all' ? [] : owned.filter((item) => item.scope !== 'transient');
361
+ if (retained.length) this.cleanups.set(key, retained);
362
+ else this.cleanups.delete(key);
363
+ }
364
+ for (const cleanup of selected.reverse()) {
365
+ try { await cleanup.fn(); } catch { /* cleanup is best-effort but never skipped */ }
366
+ }
367
+ }
368
+
369
+ private replace(next: DogfoodSnapshot): void {
370
+ this.state = next;
371
+ this.onChange?.(this.snapshot());
372
+ }
373
+ }