@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.
- package/dist/RelayServer.d.ts +81 -0
- package/dist/RelayServer.d.ts.map +1 -1
- package/dist/RelayServer.js +543 -51
- package/dist/RelayServer.js.map +1 -1
- package/dist/SessionManager.d.ts +41 -2
- package/dist/SessionManager.d.ts.map +1 -1
- package/dist/SessionManager.js +31 -1
- package/dist/SessionManager.js.map +1 -1
- package/dist/admission/HostBootGate.d.ts +181 -0
- package/dist/admission/HostBootGate.d.ts.map +1 -0
- package/dist/admission/HostBootGate.js +388 -0
- package/dist/admission/HostBootGate.js.map +1 -0
- package/dist/admission/capacity.d.ts +51 -0
- package/dist/admission/capacity.d.ts.map +1 -0
- package/dist/admission/capacity.js +49 -0
- package/dist/admission/capacity.js.map +1 -0
- package/dist/admission/classifyBoot.d.ts +17 -0
- package/dist/admission/classifyBoot.d.ts.map +1 -0
- package/dist/admission/classifyBoot.js +23 -0
- package/dist/admission/classifyBoot.js.map +1 -0
- package/dist/admission/hostKey.d.ts +10 -0
- package/dist/admission/hostKey.d.ts.map +1 -0
- package/dist/admission/hostKey.js +13 -0
- package/dist/admission/hostKey.js.map +1 -0
- package/dist/admission/leaseChoice.d.ts +38 -0
- package/dist/admission/leaseChoice.d.ts.map +1 -0
- package/dist/admission/leaseChoice.js +40 -0
- package/dist/admission/leaseChoice.js.map +1 -0
- package/dist/admission/settle.d.ts +35 -0
- package/dist/admission/settle.d.ts.map +1 -0
- package/dist/admission/settle.js +41 -0
- package/dist/admission/settle.js.map +1 -0
- package/dist/api/runs.d.ts +21 -2
- package/dist/api/runs.d.ts.map +1 -1
- package/dist/api/runs.js +181 -26
- package/dist/api/runs.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/config.d.ts +14 -0
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +98 -0
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/launchDefaults.d.ts +25 -0
- package/dist/lib/launchDefaults.d.ts.map +1 -0
- package/dist/lib/launchDefaults.js +41 -0
- package/dist/lib/launchDefaults.js.map +1 -0
- package/dist/migrations/017_flow_run_skipped.sql +34 -0
- package/dist/migrations/018_flow_run_service_tier.sql +6 -0
- package/dist/migrations/019_flow_run_device_reset.sql +7 -0
- package/dist/server.js +2 -2
- package/dist/server.js.map +1 -1
- package/package.json +4 -4
- package/public/assets/{AISessionWatch-C2h4NZ_I.js → AISessionWatch-DxsyhjAy.js} +7 -7
- package/public/assets/AISessionWatch-DxsyhjAy.js.br +0 -0
- package/public/assets/AISessionWatch-DxsyhjAy.js.gz +0 -0
- package/public/assets/{AppCenter-S1y-8_7l.js → AppCenter-CZMTgfBh.js} +6 -6
- package/public/assets/AppCenter-CZMTgfBh.js.br +0 -0
- package/public/assets/{AppCenter-S1y-8_7l.js.gz → AppCenter-CZMTgfBh.js.gz} +0 -0
- package/public/assets/{Default-C-l1O-g9.js → Default-DCk7wCIg.js} +1 -1
- package/public/assets/Default-DCk7wCIg.js.br +0 -0
- package/public/assets/Default-DCk7wCIg.js.gz +0 -0
- package/public/assets/{Invite-BCndVxaO.js → Invite-BNgu8qb9.js} +1 -1
- package/public/assets/Invite-BNgu8qb9.js.br +0 -0
- package/public/assets/Invite-BNgu8qb9.js.gz +0 -0
- package/public/assets/{MacResources-41cFDgE1.js → MacResources-B26lSN5B.js} +1 -1
- package/public/assets/MacResources-B26lSN5B.js.br +0 -0
- package/public/assets/MacResources-B26lSN5B.js.gz +0 -0
- package/public/assets/QASession-Rin1Ol-2.js +81 -0
- package/public/assets/QASession-Rin1Ol-2.js.br +0 -0
- package/public/assets/QASession-Rin1Ol-2.js.gz +0 -0
- package/public/assets/{ResetPassword-8rj7ASt1.js → ResetPassword-CQsALVBn.js} +1 -1
- package/public/assets/ResetPassword-CQsALVBn.js.br +0 -0
- package/public/assets/ResetPassword-CQsALVBn.js.gz +0 -0
- package/public/assets/RunDetail-D0JKuXAS.js +16 -0
- package/public/assets/RunDetail-D0JKuXAS.js.br +0 -0
- package/public/assets/RunDetail-D0JKuXAS.js.gz +0 -0
- package/public/assets/Runs-DSHpDs0m.js +6 -0
- package/public/assets/Runs-DSHpDs0m.js.br +0 -0
- package/public/assets/Runs-DSHpDs0m.js.gz +0 -0
- package/public/assets/{Setup-DZrhA_7b.js → Setup-4-HC1rwb.js} +1 -1
- package/public/assets/Setup-4-HC1rwb.js.br +0 -0
- package/public/assets/Setup-4-HC1rwb.js.gz +0 -0
- package/public/assets/{Team-CJThCjd3.js → Team-CgJM95TR.js} +2 -2
- package/public/assets/Team-CgJM95TR.js.br +0 -0
- package/public/assets/Team-CgJM95TR.js.gz +0 -0
- package/public/assets/{Tokens-DGURtHWi.js → Tokens-D2wbiDVG.js} +1 -1
- package/public/assets/Tokens-D2wbiDVG.js.br +0 -0
- package/public/assets/Tokens-D2wbiDVG.js.gz +0 -0
- package/public/assets/{aiSession-BW09Jhqz.js → aiSession-DE80E1QU.js} +6 -6
- package/public/assets/aiSession-DE80E1QU.js.br +0 -0
- package/public/assets/aiSession-DE80E1QU.js.gz +0 -0
- package/public/assets/{alert-dialog-t5vlOabq.js → alert-dialog-nxQTqXgk.js} +1 -1
- package/public/assets/alert-dialog-nxQTqXgk.js.br +0 -0
- package/public/assets/alert-dialog-nxQTqXgk.js.gz +0 -0
- package/public/assets/{badge-DrDCTAQQ.js → badge-DM1m4CLq.js} +1 -1
- package/public/assets/badge-DM1m4CLq.js.br +0 -0
- package/public/assets/badge-DM1m4CLq.js.gz +0 -0
- package/public/assets/{dialog-BOFZkCES.js → dialog-CTc84wr4.js} +1 -1
- package/public/assets/dialog-CTc84wr4.js.br +0 -0
- package/public/assets/dialog-CTc84wr4.js.gz +0 -0
- package/public/assets/{field-hint-B9fPUgi7.js → field-hint-xptj_gfn.js} +1 -1
- package/public/assets/field-hint-xptj_gfn.js.br +0 -0
- package/public/assets/field-hint-xptj_gfn.js.gz +0 -0
- package/public/assets/{index-Bixhy5MB.js → index-2zL1a5_0.js} +39 -39
- package/public/assets/index-2zL1a5_0.js.br +0 -0
- package/public/assets/index-2zL1a5_0.js.gz +0 -0
- package/public/assets/{list-view-BpOY1e-d.js → list-view-BzI8tLvX.js} +1 -1
- package/public/assets/list-view-BzI8tLvX.js.br +0 -0
- package/public/assets/list-view-BzI8tLvX.js.gz +0 -0
- package/public/assets/{pencil-ZlrAsWuU.js → pencil-CEWs8Of9.js} +2 -2
- package/public/assets/pencil-CEWs8Of9.js.br +0 -0
- package/public/assets/pencil-CEWs8Of9.js.gz +0 -0
- package/public/assets/{play-4CsGZdQU.js → play-Dab2J3Tw.js} +2 -2
- package/public/assets/play-Dab2J3Tw.js.br +0 -0
- package/public/assets/play-Dab2J3Tw.js.gz +0 -0
- package/public/assets/{runs-CInQb6v1.js → runs-CFQ6M4rx.js} +1 -1
- package/public/assets/runs-CFQ6M4rx.js.br +0 -0
- package/public/assets/runs-CFQ6M4rx.js.gz +0 -0
- package/public/assets/{search-input-G_QpBvPM.js → search-input-BK7cFT1m.js} +2 -2
- package/public/assets/search-input-BK7cFT1m.js.br +0 -0
- package/public/assets/search-input-BK7cFT1m.js.gz +0 -0
- package/public/assets/{select-DDzv2YfY.js → select-BH7E2Q0r.js} +3 -3
- package/public/assets/select-BH7E2Q0r.js.br +0 -0
- package/public/assets/select-BH7E2Q0r.js.gz +0 -0
- package/public/assets/{slider-DE7lQdK4.js → slider-B4sPZi8m.js} +1 -1
- package/public/assets/slider-B4sPZi8m.js.br +0 -0
- package/public/assets/slider-B4sPZi8m.js.gz +0 -0
- package/public/assets/{tabs-Co1N3x_X.js → tabs-CYoB-S2t.js} +1 -1
- package/public/assets/tabs-CYoB-S2t.js.br +0 -0
- package/public/assets/tabs-CYoB-S2t.js.gz +0 -0
- package/public/assets/{tech-label-BMvuJmUf.js → tech-label-fz5PvoAL.js} +1 -1
- package/public/assets/tech-label-fz5PvoAL.js.br +3 -0
- package/public/assets/tech-label-fz5PvoAL.js.gz +0 -0
- package/public/assets/{trash-Cwj2dbgB.js → trash-CZ-V9h2G.js} +3 -3
- package/public/assets/trash-CZ-V9h2G.js.br +0 -0
- package/public/assets/trash-CZ-V9h2G.js.gz +0 -0
- package/public/assets/{video-swsaHrfi.js → video-CilstoqV.js} +2 -2
- package/public/assets/video-CilstoqV.js.br +0 -0
- package/public/assets/video-CilstoqV.js.gz +0 -0
- package/public/index.html +1 -1
- package/public/index.html.br +0 -0
- package/public/index.html.gz +0 -0
- package/public/assets/AISessionWatch-C2h4NZ_I.js.br +0 -0
- package/public/assets/AISessionWatch-C2h4NZ_I.js.gz +0 -0
- package/public/assets/AppCenter-S1y-8_7l.js.br +0 -0
- package/public/assets/Default-C-l1O-g9.js.br +0 -0
- package/public/assets/Default-C-l1O-g9.js.gz +0 -0
- package/public/assets/Invite-BCndVxaO.js.br +0 -0
- package/public/assets/Invite-BCndVxaO.js.gz +0 -0
- package/public/assets/MacResources-41cFDgE1.js.br +0 -0
- package/public/assets/MacResources-41cFDgE1.js.gz +0 -0
- package/public/assets/QASession-B67RsIPm.js +0 -81
- package/public/assets/QASession-B67RsIPm.js.br +0 -0
- package/public/assets/QASession-B67RsIPm.js.gz +0 -0
- package/public/assets/ResetPassword-8rj7ASt1.js.br +0 -0
- package/public/assets/ResetPassword-8rj7ASt1.js.gz +0 -0
- package/public/assets/RunDetail-BIgq9gJ0.js +0 -16
- package/public/assets/RunDetail-BIgq9gJ0.js.br +0 -0
- package/public/assets/RunDetail-BIgq9gJ0.js.gz +0 -0
- package/public/assets/Runs-DpK5aoBr.js +0 -6
- package/public/assets/Runs-DpK5aoBr.js.br +0 -0
- package/public/assets/Runs-DpK5aoBr.js.gz +0 -0
- package/public/assets/Setup-DZrhA_7b.js.br +0 -0
- package/public/assets/Setup-DZrhA_7b.js.gz +0 -0
- package/public/assets/Team-CJThCjd3.js.br +0 -0
- package/public/assets/Team-CJThCjd3.js.gz +0 -0
- package/public/assets/Tokens-DGURtHWi.js.br +0 -0
- package/public/assets/Tokens-DGURtHWi.js.gz +0 -0
- package/public/assets/aiSession-BW09Jhqz.js.br +0 -0
- package/public/assets/aiSession-BW09Jhqz.js.gz +0 -0
- package/public/assets/alert-dialog-t5vlOabq.js.br +0 -0
- package/public/assets/alert-dialog-t5vlOabq.js.gz +0 -0
- package/public/assets/badge-DrDCTAQQ.js.br +0 -0
- package/public/assets/badge-DrDCTAQQ.js.gz +0 -0
- package/public/assets/dialog-BOFZkCES.js.br +0 -0
- package/public/assets/dialog-BOFZkCES.js.gz +0 -0
- package/public/assets/field-hint-B9fPUgi7.js.br +0 -0
- package/public/assets/field-hint-B9fPUgi7.js.gz +0 -0
- package/public/assets/index-Bixhy5MB.js.br +0 -0
- package/public/assets/index-Bixhy5MB.js.gz +0 -0
- package/public/assets/list-view-BpOY1e-d.js.br +0 -0
- package/public/assets/list-view-BpOY1e-d.js.gz +0 -0
- package/public/assets/pencil-ZlrAsWuU.js.br +0 -0
- package/public/assets/pencil-ZlrAsWuU.js.gz +0 -0
- package/public/assets/play-4CsGZdQU.js.br +0 -0
- package/public/assets/play-4CsGZdQU.js.gz +0 -0
- package/public/assets/runs-CInQb6v1.js.br +0 -0
- package/public/assets/runs-CInQb6v1.js.gz +0 -0
- package/public/assets/search-input-G_QpBvPM.js.br +0 -0
- package/public/assets/search-input-G_QpBvPM.js.gz +0 -0
- package/public/assets/select-DDzv2YfY.js.br +0 -0
- package/public/assets/select-DDzv2YfY.js.gz +0 -0
- package/public/assets/slider-DE7lQdK4.js.br +0 -0
- package/public/assets/slider-DE7lQdK4.js.gz +0 -0
- package/public/assets/tabs-Co1N3x_X.js.br +0 -0
- package/public/assets/tabs-Co1N3x_X.js.gz +0 -0
- package/public/assets/tech-label-BMvuJmUf.js.br +0 -0
- package/public/assets/tech-label-BMvuJmUf.js.gz +0 -0
- package/public/assets/trash-Cwj2dbgB.js.br +0 -0
- package/public/assets/trash-Cwj2dbgB.js.gz +0 -0
- package/public/assets/video-swsaHrfi.js.br +0 -0
- 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"}
|
package/dist/api/runs.d.ts
CHANGED
|
@@ -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
|
|
10
|
-
|
|
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;
|
package/dist/api/runs.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"runs.d.ts","sourceRoot":"","sources":["../../src/api/runs.ts"],"names":[],"mappings":"AAAA,OAAO,IAAI,MAAM,MAAM,CAAA;
|
|
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 (
|
|
141
|
-
|
|
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 (
|
|
159
|
-
|
|
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
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
508
|
-
|
|
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
|
|
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();
|