@tapflowio/relay 0.28.1 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (204) hide show
  1. package/dist/RelayServer.d.ts +81 -0
  2. package/dist/RelayServer.d.ts.map +1 -1
  3. package/dist/RelayServer.js +543 -51
  4. package/dist/RelayServer.js.map +1 -1
  5. package/dist/SessionManager.d.ts +41 -2
  6. package/dist/SessionManager.d.ts.map +1 -1
  7. package/dist/SessionManager.js +31 -1
  8. package/dist/SessionManager.js.map +1 -1
  9. package/dist/admission/HostBootGate.d.ts +181 -0
  10. package/dist/admission/HostBootGate.d.ts.map +1 -0
  11. package/dist/admission/HostBootGate.js +388 -0
  12. package/dist/admission/HostBootGate.js.map +1 -0
  13. package/dist/admission/capacity.d.ts +51 -0
  14. package/dist/admission/capacity.d.ts.map +1 -0
  15. package/dist/admission/capacity.js +49 -0
  16. package/dist/admission/capacity.js.map +1 -0
  17. package/dist/admission/classifyBoot.d.ts +17 -0
  18. package/dist/admission/classifyBoot.d.ts.map +1 -0
  19. package/dist/admission/classifyBoot.js +23 -0
  20. package/dist/admission/classifyBoot.js.map +1 -0
  21. package/dist/admission/hostKey.d.ts +10 -0
  22. package/dist/admission/hostKey.d.ts.map +1 -0
  23. package/dist/admission/hostKey.js +13 -0
  24. package/dist/admission/hostKey.js.map +1 -0
  25. package/dist/admission/leaseChoice.d.ts +38 -0
  26. package/dist/admission/leaseChoice.d.ts.map +1 -0
  27. package/dist/admission/leaseChoice.js +40 -0
  28. package/dist/admission/leaseChoice.js.map +1 -0
  29. package/dist/admission/settle.d.ts +35 -0
  30. package/dist/admission/settle.d.ts.map +1 -0
  31. package/dist/admission/settle.js +41 -0
  32. package/dist/admission/settle.js.map +1 -0
  33. package/dist/api/runs.d.ts +21 -2
  34. package/dist/api/runs.d.ts.map +1 -1
  35. package/dist/api/runs.js +181 -26
  36. package/dist/api/runs.js.map +1 -1
  37. package/dist/index.d.ts +2 -2
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +1 -1
  40. package/dist/index.js.map +1 -1
  41. package/dist/lib/config.d.ts +14 -0
  42. package/dist/lib/config.d.ts.map +1 -1
  43. package/dist/lib/config.js +98 -0
  44. package/dist/lib/config.js.map +1 -1
  45. package/dist/lib/launchDefaults.d.ts +25 -0
  46. package/dist/lib/launchDefaults.d.ts.map +1 -0
  47. package/dist/lib/launchDefaults.js +41 -0
  48. package/dist/lib/launchDefaults.js.map +1 -0
  49. package/dist/migrations/017_flow_run_skipped.sql +34 -0
  50. package/dist/migrations/018_flow_run_service_tier.sql +6 -0
  51. package/dist/migrations/019_flow_run_device_reset.sql +7 -0
  52. package/dist/server.js +2 -2
  53. package/dist/server.js.map +1 -1
  54. package/package.json +4 -4
  55. package/public/assets/{AISessionWatch-C2h4NZ_I.js → AISessionWatch-DxsyhjAy.js} +7 -7
  56. package/public/assets/AISessionWatch-DxsyhjAy.js.br +0 -0
  57. package/public/assets/AISessionWatch-DxsyhjAy.js.gz +0 -0
  58. package/public/assets/{AppCenter-S1y-8_7l.js → AppCenter-CZMTgfBh.js} +6 -6
  59. package/public/assets/AppCenter-CZMTgfBh.js.br +0 -0
  60. package/public/assets/{AppCenter-S1y-8_7l.js.gz → AppCenter-CZMTgfBh.js.gz} +0 -0
  61. package/public/assets/{Default-C-l1O-g9.js → Default-DCk7wCIg.js} +1 -1
  62. package/public/assets/Default-DCk7wCIg.js.br +0 -0
  63. package/public/assets/Default-DCk7wCIg.js.gz +0 -0
  64. package/public/assets/{Invite-BCndVxaO.js → Invite-BNgu8qb9.js} +1 -1
  65. package/public/assets/Invite-BNgu8qb9.js.br +0 -0
  66. package/public/assets/Invite-BNgu8qb9.js.gz +0 -0
  67. package/public/assets/{MacResources-41cFDgE1.js → MacResources-B26lSN5B.js} +1 -1
  68. package/public/assets/MacResources-B26lSN5B.js.br +0 -0
  69. package/public/assets/MacResources-B26lSN5B.js.gz +0 -0
  70. package/public/assets/QASession-Rin1Ol-2.js +81 -0
  71. package/public/assets/QASession-Rin1Ol-2.js.br +0 -0
  72. package/public/assets/QASession-Rin1Ol-2.js.gz +0 -0
  73. package/public/assets/{ResetPassword-8rj7ASt1.js → ResetPassword-CQsALVBn.js} +1 -1
  74. package/public/assets/ResetPassword-CQsALVBn.js.br +0 -0
  75. package/public/assets/ResetPassword-CQsALVBn.js.gz +0 -0
  76. package/public/assets/RunDetail-D0JKuXAS.js +16 -0
  77. package/public/assets/RunDetail-D0JKuXAS.js.br +0 -0
  78. package/public/assets/RunDetail-D0JKuXAS.js.gz +0 -0
  79. package/public/assets/Runs-DSHpDs0m.js +6 -0
  80. package/public/assets/Runs-DSHpDs0m.js.br +0 -0
  81. package/public/assets/Runs-DSHpDs0m.js.gz +0 -0
  82. package/public/assets/{Setup-DZrhA_7b.js → Setup-4-HC1rwb.js} +1 -1
  83. package/public/assets/Setup-4-HC1rwb.js.br +0 -0
  84. package/public/assets/Setup-4-HC1rwb.js.gz +0 -0
  85. package/public/assets/{Team-CJThCjd3.js → Team-CgJM95TR.js} +2 -2
  86. package/public/assets/Team-CgJM95TR.js.br +0 -0
  87. package/public/assets/Team-CgJM95TR.js.gz +0 -0
  88. package/public/assets/{Tokens-DGURtHWi.js → Tokens-D2wbiDVG.js} +1 -1
  89. package/public/assets/Tokens-D2wbiDVG.js.br +0 -0
  90. package/public/assets/Tokens-D2wbiDVG.js.gz +0 -0
  91. package/public/assets/{aiSession-BW09Jhqz.js → aiSession-DE80E1QU.js} +6 -6
  92. package/public/assets/aiSession-DE80E1QU.js.br +0 -0
  93. package/public/assets/aiSession-DE80E1QU.js.gz +0 -0
  94. package/public/assets/{alert-dialog-t5vlOabq.js → alert-dialog-nxQTqXgk.js} +1 -1
  95. package/public/assets/alert-dialog-nxQTqXgk.js.br +0 -0
  96. package/public/assets/alert-dialog-nxQTqXgk.js.gz +0 -0
  97. package/public/assets/{badge-DrDCTAQQ.js → badge-DM1m4CLq.js} +1 -1
  98. package/public/assets/badge-DM1m4CLq.js.br +0 -0
  99. package/public/assets/badge-DM1m4CLq.js.gz +0 -0
  100. package/public/assets/{dialog-BOFZkCES.js → dialog-CTc84wr4.js} +1 -1
  101. package/public/assets/dialog-CTc84wr4.js.br +0 -0
  102. package/public/assets/dialog-CTc84wr4.js.gz +0 -0
  103. package/public/assets/{field-hint-B9fPUgi7.js → field-hint-xptj_gfn.js} +1 -1
  104. package/public/assets/field-hint-xptj_gfn.js.br +0 -0
  105. package/public/assets/field-hint-xptj_gfn.js.gz +0 -0
  106. package/public/assets/{index-Bixhy5MB.js → index-2zL1a5_0.js} +39 -39
  107. package/public/assets/index-2zL1a5_0.js.br +0 -0
  108. package/public/assets/index-2zL1a5_0.js.gz +0 -0
  109. package/public/assets/{list-view-BpOY1e-d.js → list-view-BzI8tLvX.js} +1 -1
  110. package/public/assets/list-view-BzI8tLvX.js.br +0 -0
  111. package/public/assets/list-view-BzI8tLvX.js.gz +0 -0
  112. package/public/assets/{pencil-ZlrAsWuU.js → pencil-CEWs8Of9.js} +2 -2
  113. package/public/assets/pencil-CEWs8Of9.js.br +0 -0
  114. package/public/assets/pencil-CEWs8Of9.js.gz +0 -0
  115. package/public/assets/{play-4CsGZdQU.js → play-Dab2J3Tw.js} +2 -2
  116. package/public/assets/play-Dab2J3Tw.js.br +0 -0
  117. package/public/assets/play-Dab2J3Tw.js.gz +0 -0
  118. package/public/assets/{runs-CInQb6v1.js → runs-CFQ6M4rx.js} +1 -1
  119. package/public/assets/runs-CFQ6M4rx.js.br +0 -0
  120. package/public/assets/runs-CFQ6M4rx.js.gz +0 -0
  121. package/public/assets/{search-input-G_QpBvPM.js → search-input-BK7cFT1m.js} +2 -2
  122. package/public/assets/search-input-BK7cFT1m.js.br +0 -0
  123. package/public/assets/search-input-BK7cFT1m.js.gz +0 -0
  124. package/public/assets/{select-DDzv2YfY.js → select-BH7E2Q0r.js} +3 -3
  125. package/public/assets/select-BH7E2Q0r.js.br +0 -0
  126. package/public/assets/select-BH7E2Q0r.js.gz +0 -0
  127. package/public/assets/{slider-DE7lQdK4.js → slider-B4sPZi8m.js} +1 -1
  128. package/public/assets/slider-B4sPZi8m.js.br +0 -0
  129. package/public/assets/slider-B4sPZi8m.js.gz +0 -0
  130. package/public/assets/{tabs-Co1N3x_X.js → tabs-CYoB-S2t.js} +1 -1
  131. package/public/assets/tabs-CYoB-S2t.js.br +0 -0
  132. package/public/assets/tabs-CYoB-S2t.js.gz +0 -0
  133. package/public/assets/{tech-label-BMvuJmUf.js → tech-label-fz5PvoAL.js} +1 -1
  134. package/public/assets/tech-label-fz5PvoAL.js.br +3 -0
  135. package/public/assets/tech-label-fz5PvoAL.js.gz +0 -0
  136. package/public/assets/{trash-Cwj2dbgB.js → trash-CZ-V9h2G.js} +3 -3
  137. package/public/assets/trash-CZ-V9h2G.js.br +0 -0
  138. package/public/assets/trash-CZ-V9h2G.js.gz +0 -0
  139. package/public/assets/{video-swsaHrfi.js → video-CilstoqV.js} +2 -2
  140. package/public/assets/video-CilstoqV.js.br +0 -0
  141. package/public/assets/video-CilstoqV.js.gz +0 -0
  142. package/public/index.html +1 -1
  143. package/public/index.html.br +0 -0
  144. package/public/index.html.gz +0 -0
  145. package/public/assets/AISessionWatch-C2h4NZ_I.js.br +0 -0
  146. package/public/assets/AISessionWatch-C2h4NZ_I.js.gz +0 -0
  147. package/public/assets/AppCenter-S1y-8_7l.js.br +0 -0
  148. package/public/assets/Default-C-l1O-g9.js.br +0 -0
  149. package/public/assets/Default-C-l1O-g9.js.gz +0 -0
  150. package/public/assets/Invite-BCndVxaO.js.br +0 -0
  151. package/public/assets/Invite-BCndVxaO.js.gz +0 -0
  152. package/public/assets/MacResources-41cFDgE1.js.br +0 -0
  153. package/public/assets/MacResources-41cFDgE1.js.gz +0 -0
  154. package/public/assets/QASession-B67RsIPm.js +0 -81
  155. package/public/assets/QASession-B67RsIPm.js.br +0 -0
  156. package/public/assets/QASession-B67RsIPm.js.gz +0 -0
  157. package/public/assets/ResetPassword-8rj7ASt1.js.br +0 -0
  158. package/public/assets/ResetPassword-8rj7ASt1.js.gz +0 -0
  159. package/public/assets/RunDetail-BIgq9gJ0.js +0 -16
  160. package/public/assets/RunDetail-BIgq9gJ0.js.br +0 -0
  161. package/public/assets/RunDetail-BIgq9gJ0.js.gz +0 -0
  162. package/public/assets/Runs-DpK5aoBr.js +0 -6
  163. package/public/assets/Runs-DpK5aoBr.js.br +0 -0
  164. package/public/assets/Runs-DpK5aoBr.js.gz +0 -0
  165. package/public/assets/Setup-DZrhA_7b.js.br +0 -0
  166. package/public/assets/Setup-DZrhA_7b.js.gz +0 -0
  167. package/public/assets/Team-CJThCjd3.js.br +0 -0
  168. package/public/assets/Team-CJThCjd3.js.gz +0 -0
  169. package/public/assets/Tokens-DGURtHWi.js.br +0 -0
  170. package/public/assets/Tokens-DGURtHWi.js.gz +0 -0
  171. package/public/assets/aiSession-BW09Jhqz.js.br +0 -0
  172. package/public/assets/aiSession-BW09Jhqz.js.gz +0 -0
  173. package/public/assets/alert-dialog-t5vlOabq.js.br +0 -0
  174. package/public/assets/alert-dialog-t5vlOabq.js.gz +0 -0
  175. package/public/assets/badge-DrDCTAQQ.js.br +0 -0
  176. package/public/assets/badge-DrDCTAQQ.js.gz +0 -0
  177. package/public/assets/dialog-BOFZkCES.js.br +0 -0
  178. package/public/assets/dialog-BOFZkCES.js.gz +0 -0
  179. package/public/assets/field-hint-B9fPUgi7.js.br +0 -0
  180. package/public/assets/field-hint-B9fPUgi7.js.gz +0 -0
  181. package/public/assets/index-Bixhy5MB.js.br +0 -0
  182. package/public/assets/index-Bixhy5MB.js.gz +0 -0
  183. package/public/assets/list-view-BpOY1e-d.js.br +0 -0
  184. package/public/assets/list-view-BpOY1e-d.js.gz +0 -0
  185. package/public/assets/pencil-ZlrAsWuU.js.br +0 -0
  186. package/public/assets/pencil-ZlrAsWuU.js.gz +0 -0
  187. package/public/assets/play-4CsGZdQU.js.br +0 -0
  188. package/public/assets/play-4CsGZdQU.js.gz +0 -0
  189. package/public/assets/runs-CInQb6v1.js.br +0 -0
  190. package/public/assets/runs-CInQb6v1.js.gz +0 -0
  191. package/public/assets/search-input-G_QpBvPM.js.br +0 -0
  192. package/public/assets/search-input-G_QpBvPM.js.gz +0 -0
  193. package/public/assets/select-DDzv2YfY.js.br +0 -0
  194. package/public/assets/select-DDzv2YfY.js.gz +0 -0
  195. package/public/assets/slider-DE7lQdK4.js.br +0 -0
  196. package/public/assets/slider-DE7lQdK4.js.gz +0 -0
  197. package/public/assets/tabs-Co1N3x_X.js.br +0 -0
  198. package/public/assets/tabs-Co1N3x_X.js.gz +0 -0
  199. package/public/assets/tech-label-BMvuJmUf.js.br +0 -0
  200. package/public/assets/tech-label-BMvuJmUf.js.gz +0 -0
  201. package/public/assets/trash-Cwj2dbgB.js.br +0 -0
  202. package/public/assets/trash-Cwj2dbgB.js.gz +0 -0
  203. package/public/assets/video-swsaHrfi.js.br +0 -0
  204. package/public/assets/video-swsaHrfi.js.gz +0 -0
@@ -0,0 +1,38 @@
1
+ import type { AdmissionRefusal } from '@tapflowio/protocol';
2
+ /** A device a lease could take, with what the relay would answer if it tried. */
3
+ export interface LeaseCandidate {
4
+ sessionId: string;
5
+ hostKey: string;
6
+ /** Up from an earlier automated boot and idle, so leasing it boots nothing. */
7
+ pool: boolean;
8
+ /** When automation booted it; the older of two pool devices goes first, as the cap's eviction would take it. */
9
+ bootedAt: number;
10
+ /** Why taking it now would be refused, or `null` if it would go. */
11
+ refusal: AdmissionRefusal | null;
12
+ }
13
+ /** What the relay knows of a Mac's room, from its agents' reports. `null` when it has not reported. */
14
+ export interface LeaseHost {
15
+ /** Memory left, by its latest report. */
16
+ freeMB: number | null;
17
+ cpuPercent: number | null;
18
+ /** Automation devices up or starting on it. */
19
+ automated: number;
20
+ }
21
+ export type LeaseChoice = {
22
+ ok: true;
23
+ sessionId: string;
24
+ }
25
+ /** `hostKey`: a Mac that refused for critical memory, so the caller can free an idle device there. */
26
+ | {
27
+ ok: false;
28
+ reason: AdmissionRefusal;
29
+ criticalHost?: string;
30
+ };
31
+ /**
32
+ * Pick the device for a lease. A device already up wins over one to boot, on any Mac: it costs no boot. Between
33
+ * boots, the Mac with the most memory left goes first, then the idler CPU, then the one running fewer automation
34
+ * devices; a Mac that has not reported goes last, since nothing says it has room. One cold boot per Mac is the
35
+ * gate's rule, so spreading boots this way is what lets two Macs boot two devices at once.
36
+ */
37
+ export declare function chooseLease(candidates: readonly LeaseCandidate[], hosts: ReadonlyMap<string, LeaseHost>): LeaseChoice;
38
+ //# sourceMappingURL=leaseChoice.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"leaseChoice.d.ts","sourceRoot":"","sources":["../../src/admission/leaseChoice.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAA;AAE3D,iFAAiF;AACjF,MAAM,WAAW,cAAc;IAC7B,SAAS,EAAE,MAAM,CAAA;IACjB,OAAO,EAAE,MAAM,CAAA;IACf,+EAA+E;IAC/E,IAAI,EAAE,OAAO,CAAA;IACb,gHAAgH;IAChH,QAAQ,EAAE,MAAM,CAAA;IAChB,oEAAoE;IACpE,OAAO,EAAE,gBAAgB,GAAG,IAAI,CAAA;CACjC;AAED,uGAAuG;AACvG,MAAM,WAAW,SAAS;IACxB,yCAAyC;IACzC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;IACzB,+CAA+C;IAC/C,SAAS,EAAE,MAAM,CAAA;CAClB;AAED,MAAM,MAAM,WAAW,GACnB;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE;AACjC,sGAAsG;GACpG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,gBAAgB,CAAC;IAAC,YAAY,CAAC,EAAE,MAAM,CAAA;CAAE,CAAA;AASlE;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,UAAU,EAAE,SAAS,cAAc,EAAE,EAAE,KAAK,EAAE,WAAW,CAAC,MAAM,EAAE,SAAS,CAAC,GAAG,WAAW,CAuBrH"}
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Which refusal to report when every candidate was refused: the one a client is likeliest to see lift by waiting.
3
+ * A boot settles within a minute or two; the cap lifts when a run lets a device go; a busy CPU or critical memory can
4
+ * last as long as the load behind it.
5
+ */
6
+ const WAIT_ORDER = ['host-booting', 'host-at-capacity', 'agent-resources-exhausted', 'host-memory-critical'];
7
+ /**
8
+ * Pick the device for a lease. A device already up wins over one to boot, on any Mac: it costs no boot. Between
9
+ * boots, the Mac with the most memory left goes first, then the idler CPU, then the one running fewer automation
10
+ * devices; a Mac that has not reported goes last, since nothing says it has room. One cold boot per Mac is the
11
+ * gate's rule, so spreading boots this way is what lets two Macs boot two devices at once.
12
+ */
13
+ export function chooseLease(candidates, hosts) {
14
+ const open = candidates.filter((c) => c.refusal === null);
15
+ if (open.length > 0) {
16
+ const room = (c) => hosts.get(c.hostKey)?.freeMB ?? null;
17
+ const byRoom = (a, b) => {
18
+ const ra = room(a), rb = room(b);
19
+ if (ra === null || rb === null)
20
+ return ra === rb ? 0 : ra === null ? 1 : -1;
21
+ return rb - ra;
22
+ };
23
+ const pool = open.filter((c) => c.pool).sort((a, b) => byRoom(a, b) || a.bootedAt - b.bootedAt);
24
+ if (pool.length > 0)
25
+ return { ok: true, sessionId: pool[0].sessionId };
26
+ const cpu = (c) => hosts.get(c.hostKey)?.cpuPercent ?? Infinity;
27
+ const automated = (c) => hosts.get(c.hostKey)?.automated ?? 0;
28
+ const boot = [...open].sort((a, b) => byRoom(a, b) || cpu(a) - cpu(b) || automated(a) - automated(b));
29
+ return { ok: true, sessionId: boot[0].sessionId };
30
+ }
31
+ const refused = candidates.filter((c) => c.refusal !== null);
32
+ const reason = WAIT_ORDER.find((r) => refused.some((c) => c.refusal === r)) ?? 'host-booting';
33
+ if (reason !== 'host-memory-critical')
34
+ return { ok: false, reason };
35
+ // The Mac where freeing a device would help most: the one with the most memory already, so one device may be enough.
36
+ const critical = refused.filter((c) => c.refusal === 'host-memory-critical');
37
+ const best = [...critical].sort((a, b) => (hosts.get(b.hostKey)?.freeMB ?? -Infinity) - (hosts.get(a.hostKey)?.freeMB ?? -Infinity))[0];
38
+ return { ok: false, reason, ...(best ? { criticalHost: best.hostKey } : {}) };
39
+ }
40
+ //# sourceMappingURL=leaseChoice.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"leaseChoice.js","sourceRoot":"","sources":["../../src/admission/leaseChoice.ts"],"names":[],"mappings":"AA4BA;;;;GAIG;AACH,MAAM,UAAU,GAAgC,CAAC,cAAc,EAAE,kBAAkB,EAAE,2BAA2B,EAAE,sBAAsB,CAAC,CAAA;AAEzI;;;;;GAKG;AACH,MAAM,UAAU,WAAW,CAAC,UAAqC,EAAE,KAAqC;IACtG,MAAM,IAAI,GAAG,UAAU,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,OAAO,KAAK,IAAI,CAAC,CAAA;IACzD,IAAI,IAAI,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACpB,MAAM,IAAI,GAAG,CAAC,CAAiB,EAAE,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,OAAO,CAAC,EAAE,MAAM,IAAI,IAAI,CAAA;QACxE,MAAM,MAAM,GAAG,CAAC,CAAiB,EAAE,CAAiB,EAAE,EAAE;YACtD,MAAM,EAAE,GAAG,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,GAAG,IAAI,CAAC,CAAC,CAAC,CAAA;YAChC,IAAI,EAAE,KAAK,IAAI,IAAI,EAAE,KAAK,IAAI;gBAAE,OAAO,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,KAAK,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAA;YAC3E,OAAO,EAAE,GAAG,EAAE,CAAA;QAChB,CAAC,CAAA;QACD,MAAM,IAAI,GAAG,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC,QAAQ,GAAG,CAAC,CAAC,QAAQ,CAAC,CAAA;QAC/F,IAAI,IAAI,CAAC,MAAM,GAAG,CAAC;YAAE,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,CAAC,CAAC,CAAE,CAAC,SAAS,EAAE,CAAA;QACvE,MAAM,GAAG,GAAG,CAAC,CAAiB,EAAE,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,OAAO,CAAC,EAAE,UAAU,IAAI,QAAQ,CAAA;QAC/E,MAAM,SAAS,GAAG,CAAC,CAAiB,EAAE,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,OAAO,CAAC,EAAE,SAAS,IAAI,CAAC,CAAA;QAC7E,MAAM,IAAI,GAAG,CAAC,GAAG,IAAI,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,GAAG,CAAC,CAAC,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,IAAI,SAAS,CAAC,CAAC,CAAC,GAAG,SAAS,CAAC,CAAC,CAAC,CAAC,CAAA;QACrG,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,CAAC,CAAC,CAAE,CAAC,SAAS,EAAE,CAAA;IACpD,CAAC;IACD,MAAM,OAAO,GAAG,UAAU,CAAC,MAAM,CAAC,CAAC,CAAC,EAAuD,EAAE,CAAC,CAAC,CAAC,OAAO,KAAK,IAAI,CAAC,CAAA;IACjH,MAAM,MAAM,GAAG,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,OAAO,KAAK,CAAC,CAAC,CAAC,IAAI,cAAc,CAAA;IAC7F,IAAI,MAAM,KAAK,sBAAsB;QAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,CAAA;IACnE,qHAAqH;IACrH,MAAM,QAAQ,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,OAAO,KAAK,sBAAsB,CAAC,CAAA;IAC5E,MAAM,IAAI,GAAG,CAAC,GAAG,QAAQ,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,CAAA;IACvI,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,YAAY,EAAE,IAAI,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,CAAA;AAC/E,CAAC"}
@@ -0,0 +1,35 @@
1
+ /**
2
+ * When a booted device has stopped costing its Mac.
3
+ *
4
+ * The harm a boot does to other sessions on the same Mac lasts 30–90 s after it, while the kernel compresses and
5
+ * pages the other devices' memory to make room (`contributing/simulator-boot-contention.md`). The boot gate holds
6
+ * its slot until that is over, and this is how it tells.
7
+ *
8
+ * **CPU only, and flat rather than low.** Each further device raises the CPU a settled Mac idles at, and an 8 GB
9
+ * Mac compresses all the time, so "back to where it was before the boot" never arrives and a rule built on it is
10
+ * a fixed timer in disguise. Decompressions are left out for the same reason: an Android emulator and a lean
11
+ * iOS simulator go on decompressing 10–30 thousand pages a second once settled while the person's session is at
12
+ * baseline (M2, 2026-10-23), so a floor on them would hold the slot until the cap every time.
13
+ *
14
+ * The numbers come from 27 measured boots on an 8 GB Mac (L2 admission plan, appendix 숫자 결정): three flat
15
+ * samples put the settle point at 45 s after boot on average, 76–84 s for an unmodified simulator, and inside
16
+ * the harm in 7 of 27 boots, most of them at the rate a Mac with no boot shows anyway. Four samples cut that
17
+ * to 5 and moved the average to 58 s.
18
+ */
19
+ export declare const SETTLE: {
20
+ /** Consecutive samples that must agree. Agents report every 5 s, so this is 15 s. */
21
+ readonly samples: 3;
22
+ /** Largest spread across those samples, in percentage points. */
23
+ readonly spread: 10;
24
+ /** Every one of them below this. A Mac pinned near 100 % is not settled however flat it reads. */
25
+ readonly ceiling: 90;
26
+ /** A sample older than this is not about now. Three missed reports. */
27
+ readonly freshMs: 15000;
28
+ };
29
+ /**
30
+ * Whether the samples taken since the device reported ready say it has settled. The caller drops the first
31
+ * sample after ready: an agent's CPU reading averages over the interval since its previous one, so it still
32
+ * covers the boot.
33
+ */
34
+ export declare function isSettled(cpuSinceReady: readonly number[]): boolean;
35
+ //# sourceMappingURL=settle.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"settle.d.ts","sourceRoot":"","sources":["../../src/admission/settle.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,MAAM;IACjB,qFAAqF;;IAErF,iEAAiE;;IAEjE,kGAAkG;;IAElG,uEAAuE;;CAE/D,CAAA;AAEV;;;;GAIG;AACH,wBAAgB,SAAS,CAAC,aAAa,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAKnE"}
@@ -0,0 +1,41 @@
1
+ /**
2
+ * When a booted device has stopped costing its Mac.
3
+ *
4
+ * The harm a boot does to other sessions on the same Mac lasts 30–90 s after it, while the kernel compresses and
5
+ * pages the other devices' memory to make room (`contributing/simulator-boot-contention.md`). The boot gate holds
6
+ * its slot until that is over, and this is how it tells.
7
+ *
8
+ * **CPU only, and flat rather than low.** Each further device raises the CPU a settled Mac idles at, and an 8 GB
9
+ * Mac compresses all the time, so "back to where it was before the boot" never arrives and a rule built on it is
10
+ * a fixed timer in disguise. Decompressions are left out for the same reason: an Android emulator and a lean
11
+ * iOS simulator go on decompressing 10–30 thousand pages a second once settled while the person's session is at
12
+ * baseline (M2, 2026-10-23), so a floor on them would hold the slot until the cap every time.
13
+ *
14
+ * The numbers come from 27 measured boots on an 8 GB Mac (L2 admission plan, appendix 숫자 결정): three flat
15
+ * samples put the settle point at 45 s after boot on average, 76–84 s for an unmodified simulator, and inside
16
+ * the harm in 7 of 27 boots, most of them at the rate a Mac with no boot shows anyway. Four samples cut that
17
+ * to 5 and moved the average to 58 s.
18
+ */
19
+ export const SETTLE = {
20
+ /** Consecutive samples that must agree. Agents report every 5 s, so this is 15 s. */
21
+ samples: 3,
22
+ /** Largest spread across those samples, in percentage points. */
23
+ spread: 10,
24
+ /** Every one of them below this. A Mac pinned near 100 % is not settled however flat it reads. */
25
+ ceiling: 90,
26
+ /** A sample older than this is not about now. Three missed reports. */
27
+ freshMs: 15_000,
28
+ };
29
+ /**
30
+ * Whether the samples taken since the device reported ready say it has settled. The caller drops the first
31
+ * sample after ready: an agent's CPU reading averages over the interval since its previous one, so it still
32
+ * covers the boot.
33
+ */
34
+ export function isSettled(cpuSinceReady) {
35
+ if (cpuSinceReady.length < SETTLE.samples)
36
+ return false;
37
+ const last = cpuSinceReady.slice(-SETTLE.samples);
38
+ const high = Math.max(...last);
39
+ return high < SETTLE.ceiling && high - Math.min(...last) <= SETTLE.spread;
40
+ }
41
+ //# sourceMappingURL=settle.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"settle.js","sourceRoot":"","sources":["../../src/admission/settle.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,CAAC,MAAM,MAAM,GAAG;IACpB,qFAAqF;IACrF,OAAO,EAAE,CAAC;IACV,iEAAiE;IACjE,MAAM,EAAE,EAAE;IACV,kGAAkG;IAClG,OAAO,EAAE,EAAE;IACX,uEAAuE;IACvE,OAAO,EAAE,MAAM;CACP,CAAA;AAEV;;;;GAIG;AACH,MAAM,UAAU,SAAS,CAAC,aAAgC;IACxD,IAAI,aAAa,CAAC,MAAM,GAAG,MAAM,CAAC,OAAO;QAAE,OAAO,KAAK,CAAA;IACvD,MAAM,IAAI,GAAG,aAAa,CAAC,KAAK,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAA;IACjD,MAAM,IAAI,GAAG,IAAI,CAAC,GAAG,CAAC,GAAG,IAAI,CAAC,CAAA;IAC9B,OAAO,IAAI,GAAG,MAAM,CAAC,OAAO,IAAI,IAAI,GAAG,IAAI,CAAC,GAAG,CAAC,GAAG,IAAI,CAAC,IAAI,MAAM,CAAC,MAAM,CAAA;AAC3E,CAAC"}
@@ -6,9 +6,18 @@ import http from 'http';
6
6
  export interface RunHolders {
7
7
  /** The owner keys of every open socket, built once per pass so a check costs a lookup, not a scan. */
8
8
  connectedKeys(): Set<string>;
9
- /** The session a socket with one of these keys holds now — the one a watch link should open — or null. */
10
- heldSession(keys: string[]): string | null;
9
+ /** The sessions the sockets with these keys hold now — the ones a watch link should open. A parallel run holds one
10
+ * per device. */
11
+ heldSessions(keys: string[]): string[];
11
12
  }
13
+ /**
14
+ * Drop the started flows of every run that is no longer running. The paths in this file forget a run as it ends, but a
15
+ * run's row can also be deleted under it — its build or app deleted, its expiry passed — by code that never calls in
16
+ * here; the daily purge sweeps those, whichever path took them.
17
+ */
18
+ export declare function forgetStartedFlowsOfStoppedRuns(): void;
19
+ /** How many runs have started flows held in memory, for tests of when they are let go. */
20
+ export declare function startedFlowRunCount(): number;
12
21
  /**
13
22
  * The owner keys the runner's socket can have (`ownerKeyFor` in `RelayServer`). **Two, because the socket
14
23
  * and REST disagree on loopback**: the handshake does not read a PAT from a local connection, so a runner on
@@ -31,6 +40,8 @@ export interface RunRow {
31
40
  ci_branch: string | null;
32
41
  ci_commit: string | null;
33
42
  ci_job_url: string | null;
43
+ service_tier_json: string | null;
44
+ device_reset_json: string | null;
34
45
  status: 'running' | 'passed' | 'failed' | 'aborted';
35
46
  finished_by: 'client' | 'relay' | null;
36
47
  failure_kind: string | null;
@@ -67,7 +78,15 @@ graceAfterFinishSeconds?: number): RunRow | null;
67
78
  export declare function finishedWithin(run: Pick<RunRow, 'finished_at'>, seconds: number): boolean;
68
79
  export declare function handleCreateRun(req: http.IncomingMessage, res: http.ServerResponse): Promise<void>;
69
80
  export declare function handleReportFlow(req: http.IncomingMessage, res: http.ServerResponse, params: Record<string, string>): Promise<void>;
81
+ /**
82
+ * The runner started flow `idx` on a device. Without it the page could only guess the first unreported flow, which a
83
+ * run on several devices makes wrong: several flows run at once, and not in order. A start for a flow already
84
+ * reported changes nothing on the page, which reads a reported flow's row before this, so nothing here checks for one
85
+ * or forgets an entry when its flow reports; entries go with the run, and there are at most `flows_total`.
86
+ */
87
+ export declare function handleStartFlow(req: http.IncomingMessage, res: http.ServerResponse, params: Record<string, string>): Promise<void>;
70
88
  export declare function handleUploadFlowScreenshot(req: http.IncomingMessage, res: http.ServerResponse, params: Record<string, string>, screenshotsDir: string): Promise<void>;
89
+ export declare function handleReportDevice(req: http.IncomingMessage, res: http.ServerResponse, params: Record<string, string>): Promise<void>;
71
90
  export declare function handleFinishRun(req: http.IncomingMessage, res: http.ServerResponse, params: Record<string, string>): Promise<void>;
72
91
  export declare function handleListRuns(req: http.IncomingMessage, res: http.ServerResponse, holders: RunHolders): void;
73
92
  export declare function handleGetRun(req: http.IncomingMessage, res: http.ServerResponse, params: Record<string, string>, holders: RunHolders, videosOf?: (run: RunRow) => Map<number, unknown>): void;
@@ -1 +1 @@
1
- {"version":3,"file":"runs.d.ts","sourceRoot":"","sources":["../../src/api/runs.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"runs.d.ts","sourceRoot":"","sources":["../../src/api/runs.ts"],"names":[],"mappings":"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"}
package/dist/api/runs.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { readRunResetReport, readServiceTier } from '@tapflowio/protocol/validate';
1
2
  import fs from 'fs';
2
3
  import path from 'path';
3
4
  import { randomUUID } from 'crypto';
@@ -29,6 +30,30 @@ const BUILDLESS_TTL_DAYS = 7;
29
30
  * close handler is the normal path (`abandonRunsOf`); this only catches what it could not.
30
31
  */
31
32
  const ORPHAN_GRACE_SECONDS = 60;
33
+ /**
34
+ * The flows each running run has started and not yet reported, by run id. **Memory, not the database**: which flow is
35
+ * on a device right now means something only while the runner's socket is open, and a relay restart drops that socket,
36
+ * so the run is closed as `holder-lost` within `ORPHAN_GRACE_SECONDS` anyway. A run is present from its first start
37
+ * report, even with nothing in flight, so the page can tell a runner that reports starts from one that predates them.
38
+ */
39
+ const startedFlows = new Map();
40
+ /**
41
+ * Drop the started flows of every run that is no longer running. The paths in this file forget a run as it ends, but a
42
+ * run's row can also be deleted under it — its build or app deleted, its expiry passed — by code that never calls in
43
+ * here; the daily purge sweeps those, whichever path took them.
44
+ */
45
+ export function forgetStartedFlowsOfStoppedRuns() {
46
+ if (startedFlows.size === 0)
47
+ return;
48
+ const running = getDb().prepare(`SELECT 1 FROM flow_runs WHERE id = ? AND status = 'running'`);
49
+ for (const id of startedFlows.keys())
50
+ if (!running.get(id))
51
+ startedFlows.delete(id);
52
+ }
53
+ /** How many runs have started flows held in memory, for tests of when they are let go. */
54
+ export function startedFlowRunCount() {
55
+ return startedFlows.size;
56
+ }
32
57
  /**
33
58
  * The owner keys the runner's socket can have (`ownerKeyFor` in `RelayServer`). **Two, because the socket
34
59
  * and REST disagree on loopback**: the handshake does not read a PAT from a local connection, so a runner on
@@ -130,15 +155,17 @@ export function abandonRunsOf(socketKey, holders) {
130
155
  const client = socketKey.slice(colon + 1);
131
156
  const db = getDb();
132
157
  const runs = (user === 'anon'
133
- ? db.prepare(`SELECT seq, created_by, holder_client FROM flow_runs WHERE status = 'running' AND holder_client = ?`).all(client)
134
- : db.prepare(`SELECT seq, created_by, holder_client FROM flow_runs WHERE status = 'running' AND holder_client = ? AND created_by = ?`).all(client, Number(user)));
158
+ ? db.prepare(`SELECT seq, id, created_by, holder_client FROM flow_runs WHERE status = 'running' AND holder_client = ?`).all(client)
159
+ : db.prepare(`SELECT seq, id, created_by, holder_client FROM flow_runs WHERE status = 'running' AND holder_client = ? AND created_by = ?`).all(client, Number(user)));
135
160
  if (runs.length === 0)
136
161
  return;
137
162
  const open = holders.connectedKeys();
138
163
  const stmt = db.prepare(`${ABANDON} AND seq = ?`);
139
164
  for (const r of runs) {
140
- if (!holderKeys(r.created_by, r.holder_client).some((k) => open.has(k)))
141
- stmt.run(r.seq);
165
+ if (holderKeys(r.created_by, r.holder_client).some((k) => open.has(k)))
166
+ continue;
167
+ stmt.run(r.seq);
168
+ startedFlows.delete(r.id);
142
169
  }
143
170
  }
144
171
  /**
@@ -147,7 +174,7 @@ export function abandonRunsOf(socketKey, holders) {
147
174
  */
148
175
  export function settleOrphanedRuns(holders) {
149
176
  const running = getDb().prepare(`
150
- SELECT seq, created_by, holder_client FROM flow_runs
177
+ SELECT seq, id, created_by, holder_client FROM flow_runs
151
178
  WHERE status = 'running' AND last_activity_at < datetime('now', ?)
152
179
  `).all(`-${ORPHAN_GRACE_SECONDS} seconds`);
153
180
  if (running.length === 0)
@@ -155,8 +182,10 @@ export function settleOrphanedRuns(holders) {
155
182
  const open = holders.connectedKeys();
156
183
  const stmt = getDb().prepare(`${ABANDON} AND seq = ?`);
157
184
  for (const r of running) {
158
- if (!holderKeys(r.created_by, r.holder_client).some((k) => open.has(k)))
159
- stmt.run(r.seq);
185
+ if (holderKeys(r.created_by, r.holder_client).some((k) => open.has(k)))
186
+ continue;
187
+ stmt.run(r.seq);
188
+ startedFlows.delete(r.id);
160
189
  }
161
190
  }
162
191
  // ── writes (the run's creator only) ─────────────────────────────────────────
@@ -228,15 +257,20 @@ export async function handleCreateRun(req, res) {
228
257
  }
229
258
  }
230
259
  const ci = isObj(body.ci) ? body.ci : null;
260
+ // Dropped rather than refused when it is not a tier, as `ci`'s fields are: recording never fails a run.
261
+ const serviceTier = readServiceTier(body.serviceTier);
262
+ const deviceReset = typeof body.resetRequested === 'boolean' ? { requested: body.resetRequested } : null;
231
263
  const id = randomUUID();
232
264
  getDb().prepare(`
233
265
  INSERT INTO flow_runs (id, build_id, holder_client, created_by, pat_id, is_ci, ci_provider, ci_branch, ci_commit,
234
- ci_job_url, flows_json, flows_total, delete_after)
235
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CASE WHEN ? IS NULL THEN datetime('now', ?) END)
236
- `).run(id, buildId, client, auth.userId, auth.patId ?? null, ci ? 1 : 0, ci ? str(ci.provider, 50) : null, ci ? str(ci.branch, MAX_NAME) : null, ci ? str(ci.commit, 100) : null, ci ? httpUrl(ci.jobUrl) : null, JSON.stringify(flows), flows.length, buildId, `+${BUILDLESS_TTL_DAYS} days`);
266
+ ci_job_url, service_tier_json, device_reset_json, flows_json, flows_total, delete_after)
267
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CASE WHEN ? IS NULL THEN datetime('now', ?) END)
268
+ `).run(id, buildId, client, auth.userId, auth.patId ?? null, ci ? 1 : 0, ci ? str(ci.provider, 50) : null, ci ? str(ci.branch, MAX_NAME) : null, ci ? str(ci.commit, 100) : null, ci ? httpUrl(ci.jobUrl) : null, serviceTier ? JSON.stringify(serviceTier) : null, deviceReset ? JSON.stringify(deviceReset) : null, JSON.stringify(flows), flows.length, buildId, `+${BUILDLESS_TTL_DAYS} days`);
237
269
  json(res, 201, { id });
238
270
  }
239
- const FLOW_STATUSES = new Set(['passed', 'failed']);
271
+ const FLOW_STATUSES = new Set(['passed', 'failed', 'skipped']);
272
+ /** Why the runner did not replay a flow — flow-runner's `SkipReason`. */
273
+ const SKIP_REASONS = new Set(['app-mismatch', 'tag-filter']);
240
274
  const STEP_STATUSES = new Set(['passed', 'failed', 'skipped']);
241
275
  export async function handleReportFlow(req, res, params) {
242
276
  const run = ownRun(req, res, params.id);
@@ -249,7 +283,15 @@ export async function handleReportFlow(req, res, params) {
249
283
  if (!body)
250
284
  return;
251
285
  if (typeof body.status !== 'string' || !FLOW_STATUSES.has(body.status)) {
252
- return json(res, 400, { error: 'status must be passed or failed' });
286
+ return json(res, 400, { error: 'status must be passed, failed or skipped' });
287
+ }
288
+ // The reason comes with a skip and with nothing else, so a row cannot say "passed" and give a reason it was not run.
289
+ const skipReason = body.status === 'skipped' ? body.skipReason : undefined;
290
+ if (body.status === 'skipped' && (typeof skipReason !== 'string' || !SKIP_REASONS.has(skipReason))) {
291
+ return json(res, 400, { error: 'a skipped flow needs a skipReason of app-mismatch or tag-filter' });
292
+ }
293
+ if (body.status !== 'skipped' && body.skipReason != null) {
294
+ return json(res, 400, { error: 'skipReason is only for a skipped flow' });
253
295
  }
254
296
  const durationMs = nonNegInt(body.durationMs);
255
297
  if (durationMs === null)
@@ -285,16 +327,23 @@ export async function handleReportFlow(req, res, params) {
285
327
  return 'gone';
286
328
  if (now.finished_by === 'client')
287
329
  return 'finished';
330
+ // A flow that ran cannot become one that was skipped: its screenshot would stay behind a row that says nothing
331
+ // was on screen. The runner decides skips before any flow runs, so only a confused client gets here.
332
+ if (body.status === 'skipped') {
333
+ const was = db.prepare('SELECT status FROM flow_run_flows WHERE run_seq = ? AND idx = ?').get(run.seq, idx);
334
+ if (was && was.status !== 'skipped')
335
+ return 'ran';
336
+ }
288
337
  // An upsert that keeps the screenshot: the CLI may retry a report, and the screenshot arrives separately.
289
338
  db.prepare(`
290
- INSERT INTO flow_run_flows (run_seq, idx, name, file, device_id, device_name, platform, status, failure_kind,
291
- failure_message, duration_ms, steps_json)
292
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
339
+ INSERT INTO flow_run_flows (run_seq, idx, name, file, device_id, device_name, platform, status, skip_reason,
340
+ failure_kind, failure_message, duration_ms, steps_json)
341
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
293
342
  ON CONFLICT (run_seq, idx) DO UPDATE SET
294
343
  device_id = excluded.device_id, device_name = excluded.device_name, platform = excluded.platform,
295
- status = excluded.status, failure_kind = excluded.failure_kind, failure_message = excluded.failure_message,
296
- duration_ms = excluded.duration_ms, steps_json = excluded.steps_json
297
- `).run(run.seq, idx, planned.name, planned.file, str(body.deviceId, MAX_NAME), str(body.deviceName, MAX_NAME), str(body.platform, 50), body.status, str(body.failureKind, 50), str(body.failureMessage, MAX_MESSAGE), durationMs, JSON.stringify(steps));
344
+ status = excluded.status, skip_reason = excluded.skip_reason, failure_kind = excluded.failure_kind,
345
+ failure_message = excluded.failure_message, duration_ms = excluded.duration_ms, steps_json = excluded.steps_json
346
+ `).run(run.seq, idx, planned.name, planned.file, str(body.deviceId, MAX_NAME), str(body.deviceName, MAX_NAME), str(body.platform, 50), body.status, skipReason ?? null, body.status === 'skipped' ? null : str(body.failureKind, 50), str(body.failureMessage, MAX_MESSAGE), durationMs, JSON.stringify(steps));
298
347
  db.prepare(`UPDATE flow_runs SET last_activity_at = datetime('now') WHERE seq = ?`).run(run.seq);
299
348
  return null;
300
349
  })();
@@ -302,6 +351,45 @@ export async function handleReportFlow(req, res, params) {
302
351
  return json(res, 404, { error: 'Run not found' });
303
352
  if (refusal === 'finished')
304
353
  return json(res, 409, { error: 'Run already finished' });
354
+ if (refusal === 'ran')
355
+ return json(res, 409, { error: 'This flow already ran; it cannot be reported as skipped' });
356
+ json(res, 200, { ok: true });
357
+ }
358
+ /**
359
+ * The runner started flow `idx` on a device. Without it the page could only guess the first unreported flow, which a
360
+ * run on several devices makes wrong: several flows run at once, and not in order. A start for a flow already
361
+ * reported changes nothing on the page, which reads a reported flow's row before this, so nothing here checks for one
362
+ * or forgets an entry when its flow reports; entries go with the run, and there are at most `flows_total`.
363
+ */
364
+ export async function handleStartFlow(req, res, params) {
365
+ const run = ownRun(req, res, params.id);
366
+ if (!run)
367
+ return;
368
+ const idx = Number(params.idx);
369
+ if (!Number.isInteger(idx) || idx < 0 || idx >= run.flows_total)
370
+ return json(res, 404, { error: 'No such flow in this run' });
371
+ const body = await readJsonBody(req, res);
372
+ if (!body)
373
+ return;
374
+ const sessionId = str(body.sessionId, MAX_NAME);
375
+ if (sessionId === null)
376
+ return json(res, 400, { error: 'sessionId is required' });
377
+ const db = getDb();
378
+ // Judged after the body, as the flow report is: the run may have finished or gone while it arrived.
379
+ const now = db.prepare('SELECT status FROM flow_runs WHERE seq = ?').get(run.seq);
380
+ if (!now)
381
+ return json(res, 404, { error: 'Run not found' });
382
+ // A run the relay closed (`holder-lost`) still takes the runner's reports, as `ownRun` allows, but has nothing
383
+ // running to show; `ownRun` already refused one the runner finished itself.
384
+ if (now.status === 'running') {
385
+ let started = startedFlows.get(run.id);
386
+ if (!started) {
387
+ started = new Map();
388
+ startedFlows.set(run.id, started);
389
+ }
390
+ started.set(idx, { sessionId, deviceId: str(body.deviceId, MAX_NAME), deviceName: str(body.deviceName, MAX_NAME) });
391
+ }
392
+ db.prepare(`UPDATE flow_runs SET last_activity_at = datetime('now') WHERE seq = ?`).run(run.seq);
305
393
  json(res, 200, { ok: true });
306
394
  }
307
395
  const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
@@ -318,9 +406,13 @@ export async function handleUploadFlowScreenshot(req, res, params, screenshotsDi
318
406
  const run = ownRun(req, res, params.id);
319
407
  if (!run)
320
408
  return;
321
- const current = getDb().prepare('SELECT screenshot_file FROM flow_run_flows WHERE run_seq = ? AND idx = ?');
322
- if (!current.get(run.seq, Number(params.idx)))
409
+ const current = getDb().prepare('SELECT screenshot_file, status FROM flow_run_flows WHERE run_seq = ? AND idx = ?');
410
+ const row = current.get(run.seq, Number(params.idx));
411
+ if (!row)
323
412
  return json(res, 404, { error: 'Report the flow before its screenshot' });
413
+ // Nothing was on a screen for a flow that never ran.
414
+ if (row.status === 'skipped')
415
+ return json(res, 409, { error: 'A skipped flow has no screenshot' });
324
416
  const buf = await readBodyLimited(req, MAX_SCREENSHOT_BYTES);
325
417
  if (buf === null)
326
418
  return json(res, 413, { error: `Screenshot larger than ${MAX_SCREENSHOT_BYTES} bytes` });
@@ -345,6 +437,49 @@ export async function handleUploadFlowScreenshot(req, res, params, screenshotsDi
345
437
  unlinkSafe(path.join(screenshotsDir, before.screenshot_file), 'run screenshot');
346
438
  json(res, 200, { ok: true });
347
439
  }
440
+ export async function handleReportDevice(req, res, params) {
441
+ const run = ownRun(req, res, params.id);
442
+ if (!run)
443
+ return;
444
+ const body = await readJsonBody(req, res);
445
+ if (!body)
446
+ return;
447
+ // A field that cannot be read is dropped rather than refused, as the create's are: recording never fails a run.
448
+ const report = readRunResetReport(body.runReset);
449
+ const cold = typeof body.cold === 'boolean' ? { cold: body.cold } : {};
450
+ const sessionId = str(body.sessionId, MAX_NAME);
451
+ const deviceId = str(body.deviceId, MAX_NAME);
452
+ const deviceName = str(body.deviceName, MAX_NAME);
453
+ const db = getDb();
454
+ // **Read inside the write, not from `run`**: that row was read before the body arrived, and the devices of a parallel
455
+ // run report at once, so merging into it would keep whichever report wrote last and lose the others.
456
+ const gone = db.transaction(() => {
457
+ const row = db.prepare('SELECT device_reset_json FROM flow_runs WHERE seq = ?').get(run.seq);
458
+ if (!row)
459
+ return true;
460
+ const before = row.device_reset_json ? JSON.parse(row.device_reset_json) : null;
461
+ const devices = [...(before?.devices ?? [])];
462
+ if (sessionId !== null) {
463
+ const device = {
464
+ sessionId, ...(deviceId !== null ? { deviceId } : {}), ...(deviceName !== null ? { deviceName } : {}), ...cold, ...(report ? { report } : {}),
465
+ };
466
+ const at = devices.findIndex((d) => d.sessionId === sessionId);
467
+ if (at >= 0)
468
+ devices[at] = device;
469
+ else
470
+ devices.push(device);
471
+ }
472
+ const first = sessionId === null || devices[0]?.sessionId === sessionId;
473
+ const next = first
474
+ ? { requested: before?.requested ?? false, booted: true, ...cold, ...(report ? { report } : {}), ...(devices.length > 0 ? { devices } : {}) }
475
+ : { ...before, devices };
476
+ db.prepare(`UPDATE flow_runs SET device_reset_json = ?, last_activity_at = datetime('now') WHERE seq = ?`).run(JSON.stringify(next), run.seq);
477
+ return false;
478
+ })();
479
+ if (gone)
480
+ return json(res, 404, { error: 'Run not found' });
481
+ json(res, 200, { ok: true });
482
+ }
348
483
  const FINAL_STATUSES = new Set(['passed', 'failed', 'aborted']);
349
484
  export async function handleFinishRun(req, res, params) {
350
485
  const run = ownRun(req, res, params.id);
@@ -375,6 +510,7 @@ export async function handleFinishRun(req, res, params) {
375
510
  ? json(res, 409, { error: 'Run already finished' })
376
511
  : json(res, 404, { error: 'Run not found' });
377
512
  }
513
+ startedFlows.delete(run.id);
378
514
  json(res, 200, { ok: true });
379
515
  }
380
516
  function buildSummary(buildId) {
@@ -408,6 +544,8 @@ function runSummary(run) {
408
544
  ci: run.is_ci === 1
409
545
  ? { provider: run.ci_provider, branch: run.ci_branch, commit: run.ci_commit, jobUrl: run.ci_job_url }
410
546
  : null,
547
+ serviceTier: run.service_tier_json ? JSON.parse(run.service_tier_json) : null,
548
+ deviceReset: run.device_reset_json ? JSON.parse(run.device_reset_json) : null,
411
549
  build: buildSummary(run.build_id),
412
550
  createdBy: creatorName(run.created_by),
413
551
  flowsTotal: run.flows_total,
@@ -467,7 +605,8 @@ export function handleListRuns(req, res, holders) {
467
605
  const sql = (conds, order) => `
468
606
  SELECT r.*,
469
607
  (SELECT COUNT(*) FROM flow_run_flows f WHERE f.run_seq = r.seq AND f.status = 'passed') AS flows_passed,
470
- (SELECT COUNT(*) FROM flow_run_flows f WHERE f.run_seq = r.seq AND f.status = 'failed') AS flows_failed
608
+ (SELECT COUNT(*) FROM flow_run_flows f WHERE f.run_seq = r.seq AND f.status = 'failed') AS flows_failed,
609
+ (SELECT COUNT(*) FROM flow_run_flows f WHERE f.run_seq = r.seq AND f.status = 'skipped') AS flows_skipped
471
610
  FROM flow_runs r
472
611
  ${conds.length ? `WHERE ${conds.join(' AND ')}` : ''}
473
612
  ORDER BY r.seq ${order}
@@ -484,7 +623,7 @@ export function handleListRuns(req, res, holders) {
484
623
  const first = page[0];
485
624
  const last = page[page.length - 1];
486
625
  json(res, 200, {
487
- items: page.map((r) => ({ ...runSummary(r), flowsPassed: r.flows_passed, flowsFailed: r.flows_failed })),
626
+ items: page.map((r) => ({ ...runSummary(r), flowsPassed: r.flows_passed, flowsFailed: r.flows_failed, flowsSkipped: r.flows_skipped })),
488
627
  nextCursor: last && exists('r.seq < ?', last.seq) ? String(last.seq) : null,
489
628
  prevCursor: first && exists('r.seq > ?', first.seq) ? String(first.seq) : null,
490
629
  });
@@ -501,13 +640,28 @@ export function handleGetRun(req, res, params, holders, videosOf = () => new Map
501
640
  .map((f) => [f.idx, f]));
502
641
  const planned = JSON.parse(run.flows_json);
503
642
  const firstUnreported = planned.findIndex((_, i) => !reported.has(i));
643
+ if (run.status !== 'running')
644
+ startedFlows.delete(run.id);
645
+ const started = startedFlows.get(run.id);
504
646
  const flows = planned.map((p, idx) => {
505
647
  const f = reported.get(idx);
506
648
  if (!f) {
507
- // Not reported yet: the first is the one running now, the rest are waiting — unless the run is over.
508
- const status = run.status !== 'running' ? 'not-run' : idx === firstUnreported ? 'running' : 'pending';
649
+ if (run.status !== 'running')
650
+ return { idx, name: p.name, file: p.file, status: 'not-run', video: videos.get(idx) ?? null };
651
+ // A runner that reports starts says which flows are on a device. One that predates them leaves a guess: the
652
+ // first unreported flow is the one running, which holds while a run replays its flows one at a time.
653
+ const on = started?.get(idx);
654
+ if (on) {
655
+ const device = { id: on.deviceId, name: on.deviceName, sessionId: on.sessionId };
656
+ return { idx, name: p.name, file: p.file, status: 'running', device, video: videos.get(idx) ?? null };
657
+ }
658
+ const status = !started && idx === firstUnreported ? 'running' : 'pending';
509
659
  return { idx, name: p.name, file: p.file, status, video: videos.get(idx) ?? null };
510
660
  }
661
+ // A skipped flow never ran, so it has no device, steps, duration or screen to report.
662
+ if (f.status === 'skipped') {
663
+ return { idx, name: f.name, file: f.file, status: f.status, skipReason: f.skip_reason, failureMessage: f.failure_message, video: videos.get(idx) ?? null };
664
+ }
511
665
  return {
512
666
  idx, name: f.name, file: f.file, status: f.status,
513
667
  device: f.device_id ? { id: f.device_id, name: f.device_name, platform: f.platform } : null,
@@ -519,8 +673,8 @@ export function handleGetRun(req, res, params, holders, videosOf = () => new Map
519
673
  });
520
674
  // The session to watch is the one the holder holds **now**, never a stored one: session ids outlive runs,
521
675
  // so a stored id would open whoever took the device next.
522
- const watchSessionId = run.status === 'running' ? holders.heldSession(holderKeys(run.created_by, run.holder_client)) : null;
523
- json(res, 200, { ...runSummary(run), flows, watchSessionId });
676
+ const watchSessionIds = run.status === 'running' ? holders.heldSessions(holderKeys(run.created_by, run.holder_client)) : [];
677
+ json(res, 200, { ...runSummary(run), flows, watchSessionId: watchSessionIds[0] ?? null, watchSessionIds });
524
678
  }
525
679
  export function handleGetFlowScreenshot(req, res, params, screenshotsDir) {
526
680
  if (!requireViewAuth(req, res))
@@ -551,6 +705,7 @@ export function handleGetFlowScreenshot(req, res, params, screenshotsDir) {
551
705
  export function purgeExpiredRuns(screenshotsDir, videosDir, holders) {
552
706
  settleOrphanedRuns(holders);
553
707
  const db = getDb();
708
+ forgetStartedFlowsOfStoppedRuns();
554
709
  const expired = db.prepare(`
555
710
  SELECT seq FROM flow_runs WHERE build_id IS NULL AND delete_after IS NOT NULL AND delete_after < datetime('now')
556
711
  `).all();