@expo/build-tools 21.6.0 → 21.7.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/steps/functions/startAgentDeviceRemoteSession.js +13 -0
- package/dist/steps/functions/startArgentRemoteSession.js +13 -0
- package/dist/steps/utils/agentDeviceEvents.d.ts +2 -3
- package/dist/steps/utils/argentArtifacts.js +22 -1
- package/dist/steps/utils/argentEvents.d.ts +2 -3
- package/dist/steps/utils/deviceRunSessionEvents.d.ts +11 -3
- package/dist/steps/utils/deviceRunSessionEvents.js +10 -1
- package/dist/steps/utils/remoteDeviceRunSession.d.ts +12 -1
- package/dist/steps/utils/remoteDeviceRunSession.js +47 -1
- package/dist/steps/utils/serveSimMetricsArtifacts.js +4 -2
- package/package.json +2 -2
|
@@ -37,6 +37,11 @@ function createStartAgentDeviceRemoteSessionBuildFunction(ctx) {
|
|
|
37
37
|
required: false,
|
|
38
38
|
allowedValueTypeName: steps_1.BuildStepInputValueTypeName.STRING,
|
|
39
39
|
}),
|
|
40
|
+
steps_1.BuildStepInput.createProvider({
|
|
41
|
+
id: 'max_idle_time_minutes',
|
|
42
|
+
required: false,
|
|
43
|
+
allowedValueTypeName: steps_1.BuildStepInputValueTypeName.NUMBER,
|
|
44
|
+
}),
|
|
40
45
|
],
|
|
41
46
|
fn: async ({ logger, global }, { inputs, env, signal }) => {
|
|
42
47
|
// Fail fast before any expensive setup if the injected env
|
|
@@ -47,6 +52,8 @@ function createStartAgentDeviceRemoteSessionBuildFunction(ctx) {
|
|
|
47
52
|
const ngrokTunnelDomain = (0, remoteDeviceRunSession_1.getNgrokTunnelDomainOrThrow)(env);
|
|
48
53
|
const ngrokAuthtoken = (0, remoteDeviceRunSession_1.getNgrokAuthtokenOrThrow)(env);
|
|
49
54
|
const packageVersion = inputs.package_version.value;
|
|
55
|
+
// A missing or non-positive value disables the idle timeout (opt-in feature).
|
|
56
|
+
const maxIdleTimeMinutes = inputs.max_idle_time_minutes.value;
|
|
50
57
|
const { runtimePlatform } = global;
|
|
51
58
|
logger.info(`Starting agent-device remote session (version: ${packageVersion ?? 'latest'}, runtime: ${runtimePlatform}).`);
|
|
52
59
|
if (runtimePlatform === steps_1.BuildRuntimePlatform.DARWIN) {
|
|
@@ -109,6 +116,12 @@ function createStartAgentDeviceRemoteSessionBuildFunction(ctx) {
|
|
|
109
116
|
deviceRunSessionId,
|
|
110
117
|
logger,
|
|
111
118
|
signal,
|
|
119
|
+
idleTimeout: maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
|
|
120
|
+
? {
|
|
121
|
+
maxIdleTimeMinutes,
|
|
122
|
+
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
|
|
123
|
+
}
|
|
124
|
+
: undefined,
|
|
112
125
|
});
|
|
113
126
|
}
|
|
114
127
|
finally {
|
|
@@ -52,6 +52,11 @@ function createStartArgentRemoteSessionBuildFunction(ctx) {
|
|
|
52
52
|
required: false,
|
|
53
53
|
allowedValueTypeName: steps_1.BuildStepInputValueTypeName.STRING,
|
|
54
54
|
}),
|
|
55
|
+
steps_1.BuildStepInput.createProvider({
|
|
56
|
+
id: 'max_idle_time_minutes',
|
|
57
|
+
required: false,
|
|
58
|
+
allowedValueTypeName: steps_1.BuildStepInputValueTypeName.NUMBER,
|
|
59
|
+
}),
|
|
55
60
|
],
|
|
56
61
|
fn: async ({ logger, global }, { inputs, env, signal }) => {
|
|
57
62
|
// Fail fast before any expensive setup if the injected env
|
|
@@ -62,6 +67,8 @@ function createStartArgentRemoteSessionBuildFunction(ctx) {
|
|
|
62
67
|
const ngrokTunnelDomain = (0, remoteDeviceRunSession_1.getNgrokTunnelDomainOrThrow)(env);
|
|
63
68
|
const ngrokAuthtoken = (0, remoteDeviceRunSession_1.getNgrokAuthtokenOrThrow)(env);
|
|
64
69
|
const packageVersion = inputs.package_version.value;
|
|
70
|
+
// A missing or non-positive value disables the idle timeout (opt-in feature).
|
|
71
|
+
const maxIdleTimeMinutes = inputs.max_idle_time_minutes.value;
|
|
65
72
|
warnIfArgentPackageVersionCannotBeVerified({ packageVersion, logger });
|
|
66
73
|
const versionSpec = packageVersion ?? 'latest';
|
|
67
74
|
const { runtimePlatform } = global;
|
|
@@ -172,6 +179,12 @@ function createStartArgentRemoteSessionBuildFunction(ctx) {
|
|
|
172
179
|
deviceRunSessionId,
|
|
173
180
|
logger,
|
|
174
181
|
signal,
|
|
182
|
+
idleTimeout: maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
|
|
183
|
+
? {
|
|
184
|
+
maxIdleTimeMinutes,
|
|
185
|
+
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
|
|
186
|
+
}
|
|
187
|
+
: undefined,
|
|
175
188
|
});
|
|
176
189
|
}
|
|
177
190
|
finally {
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import { type bunyan } from '@expo/logger';
|
|
2
2
|
import { type CustomBuildContext } from '../../customBuildContext';
|
|
3
|
+
import { type DeviceRunSessionEventCollection } from './deviceRunSessionEvents';
|
|
3
4
|
export declare function startAgentDeviceEventCollectionAsync({ ctx, deviceRunSessionId, stateDir, logger, pollIntervalMs, }: {
|
|
4
5
|
ctx: CustomBuildContext;
|
|
5
6
|
deviceRunSessionId: string;
|
|
6
7
|
stateDir: string;
|
|
7
8
|
logger: bunyan;
|
|
8
9
|
pollIntervalMs?: number;
|
|
9
|
-
}): Promise<
|
|
10
|
-
stopAsync: () => Promise<void>;
|
|
11
|
-
}>;
|
|
10
|
+
}): Promise<DeviceRunSessionEventCollection>;
|
|
@@ -21,6 +21,17 @@ const deviceRunSessionArtifacts_1 = require("./deviceRunSessionArtifacts");
|
|
|
21
21
|
const ARGENT_ARTIFACT_UPLOAD_POLL_INTERVAL_MS = 5_000;
|
|
22
22
|
const ARGENT_ARTIFACT_UPLOAD_CLEANUP_TIMEOUT_MS = 30_000;
|
|
23
23
|
const ARGENT_ARTIFACT_FETCH_TIMEOUT_MS = 10_000;
|
|
24
|
+
// Kinds the EAS dashboard groups device run session artifacts by. Only media types the dashboard
|
|
25
|
+
// renders specially are mapped; anything else stays unclassified and lands in its "Other" group.
|
|
26
|
+
// Note that these kinds only affect grouping and labelling. The dashboard gates its inline video
|
|
27
|
+
// player on the `__eas_screen_recording` metadata flag, which Argent artifacts deliberately do not
|
|
28
|
+
// set, so a `screen-recording` kind here does not add a player to the session page.
|
|
29
|
+
const ARGENT_ARTIFACT_KIND_BY_MIME_TYPE = new Map([
|
|
30
|
+
['image/png', 'screenshot'],
|
|
31
|
+
['image/jpeg', 'screenshot'],
|
|
32
|
+
['video/mp4', 'screen-recording'],
|
|
33
|
+
['video/quicktime', 'screen-recording'],
|
|
34
|
+
]);
|
|
24
35
|
const ArgentArtifactSchema = zod_1.z.object({
|
|
25
36
|
id: zod_1.z.string(),
|
|
26
37
|
filename: zod_1.z.string(),
|
|
@@ -30,6 +41,16 @@ const ArgentArtifactSchema = zod_1.z.object({
|
|
|
30
41
|
const ArgentArtifactsListResponseSchema = zod_1.z.object({
|
|
31
42
|
artifacts: zod_1.z.array(ArgentArtifactSchema),
|
|
32
43
|
});
|
|
44
|
+
function getArgentArtifactKind(artifact) {
|
|
45
|
+
// Directories are repackaged as a tarball before upload, so the reported media type describes the
|
|
46
|
+
// contents rather than the file we actually store.
|
|
47
|
+
if (artifact.isDirectory) {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
// Media types are case-insensitive and may carry parameters, e.g. `image/png; charset=binary`.
|
|
51
|
+
const mediaType = artifact.mimeType.split(';')[0].trim().toLowerCase();
|
|
52
|
+
return ARGENT_ARTIFACT_KIND_BY_MIME_TYPE.get(mediaType);
|
|
53
|
+
}
|
|
33
54
|
async function pollArgentArtifactsForUploadAsync(ctx, { deviceRunSessionId, toolsUrl, toolsAuthToken, logger, signal, }) {
|
|
34
55
|
logger.info('Started polling Argent tool-server for artifacts.');
|
|
35
56
|
const seenArtifactIds = new Set();
|
|
@@ -126,7 +147,7 @@ async function uploadArgentArtifactAsync(ctx, { deviceRunSessionId, toolsUrl, to
|
|
|
126
147
|
artifactId: artifact.id,
|
|
127
148
|
name: `${filename} (${artifact.id})`,
|
|
128
149
|
filename,
|
|
129
|
-
kind:
|
|
150
|
+
kind: getArgentArtifactKind(artifact),
|
|
130
151
|
size,
|
|
131
152
|
stream: (0, node_fs_1.createReadStream)(temporaryArtifactPath),
|
|
132
153
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type bunyan } from '@expo/logger';
|
|
2
2
|
import { type CustomBuildContext } from '../../customBuildContext';
|
|
3
|
+
import { type DeviceRunSessionEventCollection } from './deviceRunSessionEvents';
|
|
3
4
|
export declare const ARGENT_EVENT_LOG_FILENAME = "tool-server-events.jsonl";
|
|
4
5
|
export declare function startArgentEventCollectionAsync({ ctx, deviceRunSessionId, eventLogPath, logger, pollIntervalMs, }: {
|
|
5
6
|
ctx: CustomBuildContext;
|
|
@@ -7,6 +8,4 @@ export declare function startArgentEventCollectionAsync({ ctx, deviceRunSessionI
|
|
|
7
8
|
eventLogPath: string;
|
|
8
9
|
logger: bunyan;
|
|
9
10
|
pollIntervalMs?: number;
|
|
10
|
-
}): Promise<
|
|
11
|
-
stopAsync: () => Promise<void>;
|
|
12
|
-
}>;
|
|
11
|
+
}): Promise<DeviceRunSessionEventCollection>;
|
|
@@ -53,12 +53,20 @@ export type DeviceRunSessionEventSource = {
|
|
|
53
53
|
deviceRunSessionId: string;
|
|
54
54
|
}) => DeviceRunSessionEventParseResult;
|
|
55
55
|
};
|
|
56
|
+
export type DeviceRunSessionEventCollection = {
|
|
57
|
+
stopAsync: () => Promise<void>;
|
|
58
|
+
/**
|
|
59
|
+
* Local arrival time of the most recently collected event, or `undefined`
|
|
60
|
+
* when no event has been observed yet. Uses the collector's clock rather
|
|
61
|
+
* than the event's embedded timestamp so producer clock skew cannot affect
|
|
62
|
+
* idle detection.
|
|
63
|
+
*/
|
|
64
|
+
getLastEventObservedAt: () => Date | undefined;
|
|
65
|
+
};
|
|
56
66
|
export declare function startDeviceRunSessionEventCollectionAsync({ ctx, deviceRunSessionId, source, logger, pollIntervalMs, }: {
|
|
57
67
|
ctx: CustomBuildContext;
|
|
58
68
|
deviceRunSessionId: string;
|
|
59
69
|
source: DeviceRunSessionEventSource;
|
|
60
70
|
logger: bunyan;
|
|
61
71
|
pollIntervalMs?: number;
|
|
62
|
-
}): Promise<
|
|
63
|
-
stopAsync: () => Promise<void>;
|
|
64
|
-
}>;
|
|
72
|
+
}): Promise<DeviceRunSessionEventCollection>;
|
|
@@ -58,7 +58,13 @@ async function startDeviceRunSessionEventCollectionAsync({ ctx, deviceRunSession
|
|
|
58
58
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
59
59
|
logger.warn({ err: error }, 'Could not persist device run session events to the artifact.');
|
|
60
60
|
reportEventLogFailure(error, 'setup');
|
|
61
|
-
return {
|
|
61
|
+
return {
|
|
62
|
+
stopAsync: async () => { },
|
|
63
|
+
// Collection is disabled, so session activity is invisible here. Report
|
|
64
|
+
// fresh activity so an enabled idle timeout never stops a session it
|
|
65
|
+
// cannot observe.
|
|
66
|
+
getLastEventObservedAt: () => new Date(),
|
|
67
|
+
};
|
|
62
68
|
}
|
|
63
69
|
let didReportRealtimeLogFailure = false;
|
|
64
70
|
const reportRealtimeLogFailure = (error, operation) => {
|
|
@@ -83,6 +89,7 @@ async function startDeviceRunSessionEventCollectionAsync({ ctx, deviceRunSession
|
|
|
83
89
|
}
|
|
84
90
|
const states = new Map();
|
|
85
91
|
const controller = new AbortController();
|
|
92
|
+
let lastEventObservedAt;
|
|
86
93
|
let parseFailureCount = 0;
|
|
87
94
|
const parseFailureCounts = {
|
|
88
95
|
'invalid-json': 0,
|
|
@@ -106,6 +113,7 @@ async function startDeviceRunSessionEventCollectionAsync({ ctx, deviceRunSession
|
|
|
106
113
|
source,
|
|
107
114
|
deviceRunSessionId,
|
|
108
115
|
writeEvent: event => {
|
|
116
|
+
lastEventObservedAt = new Date();
|
|
109
117
|
eventLogStream.write(event);
|
|
110
118
|
realtimeLogStream?.write({ ...event, logId: event.eventId });
|
|
111
119
|
},
|
|
@@ -170,6 +178,7 @@ async function startDeviceRunSessionEventCollectionAsync({ ctx, deviceRunSession
|
|
|
170
178
|
// A diagnostics failure must not recreate an unhandled rejection in this best-effort poller.
|
|
171
179
|
});
|
|
172
180
|
return {
|
|
181
|
+
getLastEventObservedAt: () => lastEventObservedAt,
|
|
173
182
|
stopAsync: async () => {
|
|
174
183
|
controller.abort();
|
|
175
184
|
await pollingPromise;
|
|
@@ -15,11 +15,22 @@ export declare function selectXcodeDeveloperDirectoryAsync({ env, logger, }: {
|
|
|
15
15
|
env: BuildStepEnv;
|
|
16
16
|
logger: bunyan;
|
|
17
17
|
}): Promise<void>;
|
|
18
|
-
export
|
|
18
|
+
export type DeviceRunSessionIdleTimeout = {
|
|
19
|
+
/** Stop the session after this many minutes without observed activity. */
|
|
20
|
+
maxIdleTimeMinutes: number;
|
|
21
|
+
/**
|
|
22
|
+
* Local arrival time of the most recent session event, or `undefined` when
|
|
23
|
+
* no event has been observed yet. The idle clock starts when the wait
|
|
24
|
+
* begins, so a session nobody ever connects to still times out.
|
|
25
|
+
*/
|
|
26
|
+
getLastEventObservedAt: () => Date | undefined;
|
|
27
|
+
};
|
|
28
|
+
export declare function waitForDeviceRunSessionStoppedAsync({ ctx, deviceRunSessionId, logger, signal, idleTimeout, }: {
|
|
19
29
|
ctx: CustomBuildContext;
|
|
20
30
|
deviceRunSessionId: string;
|
|
21
31
|
logger: bunyan;
|
|
22
32
|
signal?: AbortSignal;
|
|
33
|
+
idleTimeout?: DeviceRunSessionIdleTimeout;
|
|
23
34
|
}): Promise<void>;
|
|
24
35
|
/**
|
|
25
36
|
* Install ffmpeg when the runtime does not already provide it, so argent's
|
|
@@ -97,6 +97,16 @@ const DEVICE_RUN_SESSION_STATUS_QUERY = (0, gql_tada_1.graphql)(`
|
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
99
|
`);
|
|
100
|
+
const ENSURE_DEVICE_RUN_SESSION_STOPPED_MUTATION = (0, gql_tada_1.graphql)(`
|
|
101
|
+
mutation EnsureDeviceRunSessionStopped($deviceRunSessionId: ID!) {
|
|
102
|
+
deviceRunSession {
|
|
103
|
+
ensureDeviceRunSessionStopped(deviceRunSessionId: $deviceRunSessionId) {
|
|
104
|
+
id
|
|
105
|
+
status
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
`);
|
|
100
110
|
const DEVICE_RUN_SESSION_STATUS_POLL_INTERVAL_MS = 5_000;
|
|
101
111
|
function getDeviceRunSessionIdOrThrow(env) {
|
|
102
112
|
const deviceRunSessionId = env.DEVICE_RUN_SESSION_ID;
|
|
@@ -142,10 +152,26 @@ async function selectXcodeDeveloperDirectoryAsync({ env, logger, }) {
|
|
|
142
152
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
143
153
|
});
|
|
144
154
|
}
|
|
145
|
-
async function waitForDeviceRunSessionStoppedAsync({ ctx, deviceRunSessionId, logger, signal, }) {
|
|
155
|
+
async function waitForDeviceRunSessionStoppedAsync({ ctx, deviceRunSessionId, logger, signal, idleTimeout, }) {
|
|
146
156
|
logger.info(`Remote session is live. Polling device run session ${deviceRunSessionId} until it is stopped.`);
|
|
157
|
+
if (idleTimeout) {
|
|
158
|
+
logger.info(`The session stops automatically after ${idleTimeout.maxIdleTimeMinutes} minute(s) without activity.`);
|
|
159
|
+
}
|
|
147
160
|
let pollErrorCount = 0;
|
|
161
|
+
let lastActivityAt = new Date();
|
|
148
162
|
while (!signal?.aborted) {
|
|
163
|
+
if (idleTimeout) {
|
|
164
|
+
const lastEventObservedAt = idleTimeout.getLastEventObservedAt();
|
|
165
|
+
if (lastEventObservedAt && lastEventObservedAt > lastActivityAt) {
|
|
166
|
+
lastActivityAt = lastEventObservedAt;
|
|
167
|
+
}
|
|
168
|
+
if (Date.now() - lastActivityAt.getTime() >= idleTimeout.maxIdleTimeMinutes * 60_000) {
|
|
169
|
+
logger.info(`Device run session ${deviceRunSessionId} had no activity for ` +
|
|
170
|
+
`${idleTimeout.maxIdleTimeMinutes} minute(s) (max idle time). Stopping the session.`);
|
|
171
|
+
await ensureDeviceRunSessionStoppedSafelyAsync({ ctx, deviceRunSessionId, logger });
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
149
175
|
try {
|
|
150
176
|
const result = await ctx.graphqlClient
|
|
151
177
|
.query(DEVICE_RUN_SESSION_STATUS_QUERY, { deviceRunSessionId })
|
|
@@ -180,6 +206,26 @@ async function waitForDeviceRunSessionStoppedAsync({ ctx, deviceRunSessionId, lo
|
|
|
180
206
|
await sleepUntilAbortedAsync(DEVICE_RUN_SESSION_STATUS_POLL_INTERVAL_MS, signal);
|
|
181
207
|
}
|
|
182
208
|
}
|
|
209
|
+
// Best effort: when this fails, the caller still tears the session down and the
|
|
210
|
+
// job run finishes, which clients also treat as the session ending.
|
|
211
|
+
async function ensureDeviceRunSessionStoppedSafelyAsync({ ctx, deviceRunSessionId, logger, }) {
|
|
212
|
+
try {
|
|
213
|
+
const result = await ctx.graphqlClient
|
|
214
|
+
.mutation(ENSURE_DEVICE_RUN_SESSION_STOPPED_MUTATION, { deviceRunSessionId })
|
|
215
|
+
.toPromise();
|
|
216
|
+
if (result.error) {
|
|
217
|
+
throw result.error;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
catch (err) {
|
|
221
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
222
|
+
sentry_1.Sentry.capture('Could not mark idle device run session as stopped', error, {
|
|
223
|
+
level: 'warning',
|
|
224
|
+
extras: { deviceRunSessionId },
|
|
225
|
+
});
|
|
226
|
+
logger.warn({ err: error }, `Could not mark device run session ${deviceRunSessionId} as stopped. The session job ends anyway.`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
183
229
|
async function sleepUntilAbortedAsync(timeoutMs, signal) {
|
|
184
230
|
try {
|
|
185
231
|
await (0, promises_1.setTimeout)(timeoutMs, undefined, signal ? { signal } : undefined);
|
|
@@ -20,17 +20,19 @@ async function uploadServeSimMetricsFileAsync(ctx, { deviceRunSessionId, udid, f
|
|
|
20
20
|
logger.info(`serve-sim metrics file for ${udid} is empty; skipping upload.`);
|
|
21
21
|
return;
|
|
22
22
|
}
|
|
23
|
+
const deviceName = typeof metadata?.deviceName === 'string' ? metadata.deviceName : undefined;
|
|
23
24
|
try {
|
|
24
|
-
logger.info(`Uploading serve-sim metrics for ${udid} (${(0, artifacts_1.formatBytes)(size)}).`);
|
|
25
|
+
logger.info(`Uploading serve-sim metrics for ${deviceName ?? udid} (${(0, artifacts_1.formatBytes)(size)}).`);
|
|
25
26
|
await (0, deviceRunSessionArtifacts_1.uploadDeviceRunSessionArtifactAsync)(ctx, {
|
|
26
27
|
deviceRunSessionId,
|
|
27
28
|
artifactId: `metrics-${udid}`,
|
|
28
|
-
name: `Performance metrics (${udid.slice(0, 8)})`,
|
|
29
|
+
name: `Performance metrics (${deviceName ?? udid.slice(0, 8)})`,
|
|
29
30
|
filename: METRICS_ARTIFACT_FILENAME,
|
|
30
31
|
kind: 'performance-metrics',
|
|
31
32
|
metadata: {
|
|
32
33
|
__eas_type: 'performance-metrics',
|
|
33
34
|
udid,
|
|
35
|
+
...(deviceName && { deviceName }),
|
|
34
36
|
...(metadata && {
|
|
35
37
|
hostCores: metadata.hostCores,
|
|
36
38
|
sampleIntervalMs: metadata.sampleIntervalMs,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@expo/build-tools",
|
|
3
|
-
"version": "21.
|
|
3
|
+
"version": "21.7.0",
|
|
4
4
|
"bugs": "https://github.com/expo/eas-cli/issues",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Expo <support@expo.io>",
|
|
@@ -100,5 +100,5 @@
|
|
|
100
100
|
"typescript": "^5.5.4",
|
|
101
101
|
"uuid": "^9.0.1"
|
|
102
102
|
},
|
|
103
|
-
"gitHead": "
|
|
103
|
+
"gitHead": "f923181857de80919ec7ee9861c9b71c2ee5635d"
|
|
104
104
|
}
|