runcloud 0.2.165 → 0.2.166

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.
@@ -0,0 +1,447 @@
1
+ import { readFileSync, statSync } from 'node:fs';
2
+ import { ApiClient, ApiError } from '../api.js';
3
+ import { requireCredentials } from '../config.js';
4
+ const ROOT = '/run-cloud/admin/simulators';
5
+ const RANGES = ['1h', '6h', '24h', '7d', '30d', '90d', '1y', '2y'];
6
+ export const ACCESSIBILITY_CODES = ['accessibility_device_unavailable', 'accessibility_capture_failed'];
7
+ const row = (value) => value !== null && typeof value === 'object' && !Array.isArray(value) ? value : {};
8
+ const rows = (value) => Array.isArray(value) ? value.map(row) : [];
9
+ const str = (value) => typeof value === 'string' && value.length > 0 ? value : null;
10
+ const num = (value) => typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null;
11
+ const stamp = (value) => str(value) && Number.isFinite(Date.parse(String(value))) ? new Date(String(value)).toISOString() : null;
12
+ const time = (value) => stamp(value) ? Date.parse(String(value)) : 0;
13
+ export function safeFleetText(value) {
14
+ const text = str(value);
15
+ return text ? text.slice(0, 8000)
16
+ .replace(/\bBearer\s+[^\s"'<>]+/gi, 'Bearer [redacted]')
17
+ .replace(/\b(?:rc_live_|rcsf_|rc_dev_)[A-Za-z0-9_.-]+/g, '[redacted]')
18
+ .replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, '[redacted]')
19
+ .replace(/\b([a-zA-Z0-9_]*(?:token|password|secret|api[_-]?key)|authorization)\s*[=:]\s*[^\s,;]+/gi, '$1=[redacted]')
20
+ .replace(/(?:https?|wss?):\/\/[^\s<>"']+/gi, '[url redacted]') : null;
21
+ }
22
+ function observation(value, now, ttl, error) {
23
+ const observedAt = stamp(value);
24
+ const ageSeconds = observedAt ? Math.round((now - Date.parse(observedAt)) / 1000) : null;
25
+ if (error)
26
+ return { observedAt, ageSeconds, state: 'unavailable', reason: 'collection_failed; retained evidence only' };
27
+ if (!observedAt)
28
+ return { observedAt, ageSeconds, state: 'missing', reason: 'observation_timestamp_missing' };
29
+ if (ageSeconds < -5)
30
+ return { observedAt, ageSeconds, state: 'unavailable', reason: 'observation_timestamp_in_future' };
31
+ return { observedAt, ageSeconds, state: ageSeconds > ttl ? 'stale' : 'fresh', reason: ageSeconds > ttl ? 'observation_expired' : null };
32
+ }
33
+ function hostedObservation(host, now, ttl) {
34
+ const heartbeat = observation(host.observedAt, now, ttl);
35
+ const freshness = row(host.freshness);
36
+ if (!Object.keys(freshness).length)
37
+ return { ...heartbeat, ingestion: null, reportedFreshness: null };
38
+ const ingestion = observation(freshness.lastSuccessfulUpdateAt, now, ttl);
39
+ const reportedFreshness = str(freshness.status);
40
+ if (heartbeat.state === 'fresh' && (reportedFreshness !== 'fresh' || ingestion.state !== 'fresh')) {
41
+ const state = ingestion.state === 'unavailable' ? 'unavailable' : reportedFreshness === 'never' ? 'missing' : 'stale';
42
+ return { ...heartbeat, state: state, reason: `hosted_ingestion_${ingestion.state === 'fresh' ? reportedFreshness ?? 'unknown' : ingestion.state}`, ingestion, reportedFreshness };
43
+ }
44
+ return { ...heartbeat, ingestion, reportedFreshness };
45
+ }
46
+ function bound(value, fallback, max, name) {
47
+ const result = value ?? fallback;
48
+ if (!Number.isSafeInteger(result) || result < 1 || result > max)
49
+ throw new Error(`${name} must be an integer from 1 to ${max}`);
50
+ return result;
51
+ }
52
+ function validate(options) {
53
+ if (!RANGES.includes(options.range ?? '24h'))
54
+ throw new Error(`range must be ${RANGES.join(', ')}`);
55
+ if (options.hostId && !/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(options.hostId))
56
+ throw new Error('Invalid host ID');
57
+ if (options.deep && !options.hostId)
58
+ throw new Error('--deep requires a host ID');
59
+ bound(options.limit, 200, 500, 'limit');
60
+ bound(options.eventPages, 1, 10, 'event-pages');
61
+ }
62
+ async function readSource(client, path) {
63
+ try {
64
+ const data = await client.get(path, { signal: AbortSignal.timeout(30000) });
65
+ if (!data || typeof data !== 'object' || Array.isArray(data) || data instanceof Response)
66
+ return { state: 'unavailable', data: {}, reason: 'invalid_json_response' };
67
+ return { state: 'available', data: row(data), reason: null };
68
+ }
69
+ catch (error) {
70
+ return { state: 'unavailable', data: {}, reason: error instanceof ApiError ? `HTTP ${error.status}` : 'read_failed' };
71
+ }
72
+ }
73
+ export async function readFleetEvents(client, options = {}) {
74
+ validate(options);
75
+ const items = [];
76
+ let cursor = null, pagesRead = 0, reason = null, generatedAt = null;
77
+ const cursors = new Set();
78
+ for (let page = 0; page < (options.eventPages ?? 1); page++) {
79
+ const query = new URLSearchParams({ range: options.range ?? '24h', limit: String(options.limit ?? 200) });
80
+ if (options.hostId)
81
+ query.set('hostId', options.hostId);
82
+ if (cursor)
83
+ query.set('cursor', cursor);
84
+ const result = await readSource(client, `${ROOT}/events?${query}`);
85
+ if (result.state === 'unavailable') {
86
+ reason = result.reason;
87
+ break;
88
+ }
89
+ if (!Array.isArray(result.data.items)) {
90
+ reason = 'invalid_events_response';
91
+ break;
92
+ }
93
+ generatedAt ??= stamp(result.data.generatedAt);
94
+ items.push(...rows(result.data.items));
95
+ pagesRead++;
96
+ cursor = str(result.data.nextCursor);
97
+ if (!cursor)
98
+ break;
99
+ if (cursors.has(cursor)) {
100
+ reason = 'repeated_event_cursor';
101
+ break;
102
+ }
103
+ cursors.add(cursor);
104
+ }
105
+ return { items, coverage: { range: options.range ?? '24h', pagesRead, rowsRead: items.length,
106
+ complete: reason === null && cursor === null && pagesRead > 0, nextCursor: cursor,
107
+ reason: reason ?? (cursor ? 'page_budget_reached; matching failures may be outside returned pages' : null), generatedAt,
108
+ oldestReturnedAt: stamp(items.at(-1)?.timestamp), newestReturnedAt: stamp(items[0]?.timestamp) } };
109
+ }
110
+ function componentHosts(shared) {
111
+ const result = new Map();
112
+ const snapshots = rows(shared.snapshots).map(entry => row(entry.snapshot));
113
+ for (const snapshot of snapshots)
114
+ for (const host of rows(snapshot.hosts)) {
115
+ const id = str(host.id);
116
+ if (!id)
117
+ continue;
118
+ const before = result.get(id) ?? {};
119
+ const newest = !result.has(id) || time(host.observedAt) > time(before.observedAt) ? host : before;
120
+ const merged = { ...before, ...newest };
121
+ for (const key of ['hostTelemetry', 'hostDiagnostics', 'processScan']) {
122
+ const candidate = row(host[key]), previous = row(before[key]);
123
+ const observationKey = key === 'processScan' ? 'attemptedAt' : 'observedAt';
124
+ merged[key] = time(candidate[observationKey] ?? candidate.observedAt) > time(previous[observationKey] ?? previous.observedAt) ? candidate : previous;
125
+ }
126
+ result.set(id, merged);
127
+ }
128
+ return result;
129
+ }
130
+ function cpuReading(host, extra, now, fleetTtl) {
131
+ const telemetry = row(extra.hostTelemetry), cpu = row(telemetry.cpu);
132
+ const cpuValue = (value) => num(value) !== null && Number(value) <= 100 ? Number(value) : null;
133
+ const candidates = [
134
+ { percent: cpuValue(cpu.busyPercent), source: 'operator_resource_probe', ...observation(telemetry.observedAt, now, num(telemetry.staleAfterSeconds) ?? 45, telemetry.collectionError) },
135
+ { percent: cpuValue(host.cpuUtilizationPercent), source: 'host_heartbeat', ...hostedObservation(host, now, fleetTtl) },
136
+ ].map(entry => entry.percent === null && entry.state === 'fresh' ? { ...entry, state: 'missing', reason: 'cpu_value_missing' } : entry);
137
+ candidates.sort((a, b) => Number(b.state === 'fresh') - Number(a.state === 'fresh') || time(b.observedAt) - time(a.observedAt));
138
+ return { ...candidates[0], candidates, meaning: 'host busy percent, 0..100; fresh samples only in fleet mean' };
139
+ }
140
+ const pick = (source, keys) => Object.fromEntries(keys.filter(key => source[key] !== undefined).map(key => [key, source[key]]));
141
+ function fence(value) {
142
+ const source = row(value);
143
+ return Object.keys(source).length ? { ...pick(source, ['id', 'kind', 'state', 'createdAt', 'expiresAt', 'component']), reason: safeFleetText(source.reason) } : null;
144
+ }
145
+ function hostReport(host, canonical, extra, diagnoses, now, fleetTtl, deep = {}) {
146
+ const id = String(host.id ?? canonical.id ?? extra.id);
147
+ const health = hostedObservation(host, now, fleetTtl);
148
+ const diagnostic = row(extra.hostDiagnostics), process = row(extra.processScan);
149
+ const supplementalChecks = observation(diagnostic.observedAt, now, num(diagnostic.staleAfterSeconds) ?? 900, diagnostic.collectionError);
150
+ const deepChecks = observation(row(deep.security).checkedAt, now, fleetTtl);
151
+ const useDeep = deepChecks.state === 'fresh' && Array.isArray(deep.services) && deep.services.length > 0 && time(deepChecks.observedAt) > time(supplementalChecks.observedAt);
152
+ const checks = useDeep ? deepChecks : supplementalChecks;
153
+ const processCheck = observation(process.observedAt, now, num(process.staleAfterSeconds) ?? 900, process.error);
154
+ const release = row(host.release);
155
+ const services = rows(useDeep ? deep.services : diagnostic.services).map(service => ({ name: safeFleetText(service.name), state: safeFleetText(service.state), lastExitCode: service.lastExitCode ?? null,
156
+ error: safeFleetText(service.lastError ?? service.error) }));
157
+ const faults = checks.state === 'fresh' ? services.filter(service => ['stopped', 'failed', 'crashed', 'inactive', 'missing', 'exited', 'disabled'].includes(service.state ?? '')) : [];
158
+ const findings = rows(process.findings).map(finding => ({ ...pick(finding, ['fingerprint', 'reason', 'confidence', 'firstObservedAt', 'lastObservedAt', 'ownerUid', 'ageSeconds']), message: safeFleetText(finding.message ?? finding.summary) }));
159
+ const diagnosesForHost = diagnoses.filter(diagnosis => diagnosis.hostId === id).map(diagnosis => ({ ...pick(diagnosis, ['code', 'severity', 'deviceId']),
160
+ title: safeFleetText(diagnosis.title), message: safeFleetText(diagnosis.message), reportedAt: stamp(diagnosis.observedAt) }));
161
+ const requestedFence = fence(canonical.requestedFence ?? host.requestedFence), appliedFence = fence(canonical.fence ?? host.fence);
162
+ const state = health.state === 'fresh' ? str(host.state) ?? 'unknown' : 'unknown';
163
+ const sentences = [`${id}: ${state}${health.state === 'fresh' ? '' : ` (${health.state} heartbeat)`}.`];
164
+ if (appliedFence || requestedFence)
165
+ sentences.push(`Hold ${appliedFence ? 'reported applied' : 'requested'}; admission evidence is separate.`);
166
+ if (health.state === 'fresh' && release.matches === false)
167
+ sentences.push('Release drift reported.');
168
+ if (faults.length)
169
+ sentences.push(`Service faults: ${faults.map(service => `${service.name} ${service.state}`).join(', ')}.`);
170
+ if (checks.state !== 'fresh')
171
+ sentences.push(`Service checks ${checks.state}; current service health unproved.`);
172
+ if (processCheck.state !== 'fresh')
173
+ sentences.push(`Process checks ${processCheck.state}.`);
174
+ if (process.status === 'critical' || rows(process.iocs).some(ioc => ioc.status === 'present') || rows(process.findings).some(finding => finding.reason === 'known_ioc'))
175
+ sentences.push('Known malicious scanner evidence retained; inspect signals and scan age.');
176
+ else if (rows(process.findings).some(finding => ['rooted_workload', 'long_lived_workload', 'identity_changed', 'unreviewed_persistence'].includes(String(finding.reason))))
177
+ sentences.push('Process identities require review; inspect findings and scan age.');
178
+ if (rows(process.iocs).some(ioc => ioc.contained === true))
179
+ sentences.push('Contained IOC evidence retained.');
180
+ const cpu = cpuReading(host, extra, now, fleetTtl);
181
+ sentences.push(cpu.state === 'fresh' ? `CPU ${cpu.percent.toFixed(1)}%.` : `CPU ${cpu.state}.`);
182
+ return { id, name: safeFleetText(canonical.name ?? host.name ?? extra.name), platform: canonical.platform ?? host.platform ?? extra.platform ?? null,
183
+ role: canonical.machineType ?? extra.role ?? null, inventoryPresent: Object.keys(canonical).length > 0, livePresent: Object.keys(host).length > 0,
184
+ health: { reportedState: str(host.state), effectiveState: state, ...health }, diagnosis: sentences.join(' '), cpu,
185
+ release: { ...pick(release, ['expectedPackage', 'actualPackage', 'expectedVersion', 'actualVersion', 'expectedBuild', 'actualBuild', 'matches']), version: str(host.version), build: str(host.build), observedAt: stamp(host.observedAt) },
186
+ admission: { enabled: canonical.enabled ?? null, acceptsLeases: canonical.acceptsLeases ?? null, observedAt: stamp(canonical.admissionObservedAt), reason: safeFleetText(canonical.admissionReason), requestedFence, fence: appliedFence },
187
+ capacity: pick(row(host.capacity), ['clean', 'leased', 'installing', 'dirty', 'preparing', 'recycling', 'total']),
188
+ serviceChecks: { ...checks, source: useDeep ? 'hosted_deep_evidence' : 'operator_diagnostics', services,
189
+ software: { ...pick(row(diagnostic.software), ['serveSimPackage', 'serveSimVersion', 'serveSimRevision', 'xcodeVersion']), observation: supplementalChecks, packageMatchesService: diagnostic.packageMatchesService ?? null } },
190
+ processChecks: { ...processCheck, ...pick(process, ['attemptedAt', 'inventoryObservedAt', 'maxVariableAgeSeconds', 'root', 'mode', 'coverage', 'counts']),
191
+ status: process.status ?? null, baseline: pick(row(process.baseline), ['state', 'profileIds', 'methods']),
192
+ transportTrust: pick(row(process.transportTrust), ['state', 'verifiedAt', 'pinsSha256']), findings,
193
+ iocs: rows(process.iocs).map(ioc => ({ ...pick(ioc, ['family', 'status', 'contained', 'lastObservedAt']), signals: Array.isArray(ioc.signals) ? ioc.signals.map(safeFleetText) : [] })) },
194
+ resources: { ...observation(row(extra.hostTelemetry).observedAt, now, 45, row(extra.hostTelemetry).collectionError),
195
+ memory: pick(row(row(extra.hostTelemetry).memory), ['totalBytes', 'usedBytes', 'availableBytes', 'swapUsedBytes']),
196
+ perDeviceCpu: rows(row(row(extra.hostTelemetry).cpu).perDevice).map(device => pick(device, ['id', 'cpuPercent', 'rssBytes', 'processes'])),
197
+ processCpuMeaning: 'OS process percent may exceed 100 on multicore hosts; independent from host busy percent' },
198
+ devices: rows(host.devices).map(device => ({ id: str(device.id), ...pick(device, ['state', 'sessionId', 'leaseId', 'updatedAt']), helper: {
199
+ ...pick(row(device.helper), ['state', 'pid', 'lastSeenAt']), observation: observation(row(device.helper).lastSeenAt, now, fleetTtl),
200
+ observationMeaning: 'last helper evidence; freshness uses fleet heartbeat threshold', error: safeFleetText(row(device.helper).error)
201
+ }, lastError: safeFleetText(device.lastError) })),
202
+ diagnoses: diagnosesForHost,
203
+ };
204
+ }
205
+ function originalLogsUrl(value) {
206
+ try {
207
+ const url = new URL(String(value));
208
+ return url.protocol === 'https:' && url.hostname === 'console.cloud.google.com' && url.pathname.startsWith('/logs/') &&
209
+ !url.username && !url.password && !/token|secret|signature|api.?key|credential/i.test(url.search) ? url.href : null;
210
+ }
211
+ catch {
212
+ return null;
213
+ }
214
+ }
215
+ export function failureEvidence(value) {
216
+ const entry = row(value), payload = row(entry.jsonPayload ?? entry), context = { ...row(payload.context), ...row(payload.metadata), ...payload };
217
+ let error = row(context.err ?? context.error);
218
+ const errorProviderSessionId = str(error.sessionId);
219
+ const causeCodes = Array.isArray(context.causeCodes) ? context.causeCodes.filter(value => typeof value === 'string') : [];
220
+ const errorChain = [];
221
+ for (let depth = 0; depth < 8 && Object.keys(error).length; depth++) {
222
+ if (str(error.code))
223
+ causeCodes.push(String(error.code));
224
+ errorChain.push({ ...pick(error, ['code', 'httpStatus', 'status', 'retryable', 'stage']), message: safeFleetText(error.message) });
225
+ error = row(error.cause);
226
+ }
227
+ const service = str(row(row(entry.resource).labels).service_name) ?? '';
228
+ const session = str(context.sessionId), explicitProvider = str(context.providerSessionId);
229
+ const providerSessionId = explicitProvider ?? errorProviderSessionId ?? (/^rc-api(?:-|$)/.test(service) ? session : null);
230
+ const newlySessionId = str(context.newlySessionId ?? context['newly.session_id']) ?? (/^cp-(?:api|worker)(?:-|$)/.test(service) ? session : null);
231
+ const timestamp = stamp(entry.timestamp ?? context.timestamp ?? context.observedAt);
232
+ const atFailureObservation = (observedAt) => timestamp ? observation(observedAt, time(timestamp), 45) :
233
+ { observedAt: stamp(observedAt), ageSeconds: null, state: 'unavailable', reason: 'original_failure_timestamp_missing' };
234
+ const helper = row(context.helper), resources = row(context.resourceTelemetry);
235
+ const project = str(row(row(entry.resource).labels).project_id);
236
+ const trace = str(entry.trace ?? context.traceId);
237
+ let generatedLogsUrl = null;
238
+ if (project && /^[a-z][a-z0-9-]{4,62}$/.test(project) && timestamp) {
239
+ const identity = trace ? `trace="${trace.replace(/[^a-zA-Z0-9_/-]/g, '')}"` : providerSessionId ?
240
+ `(jsonPayload.providerSessionId="${providerSessionId.replace(/[^a-zA-Z0-9_-]/g, '')}" OR jsonPayload.sessionId="${providerSessionId.replace(/[^a-zA-Z0-9_-]/g, '')}")` : null;
241
+ if (identity)
242
+ generatedLogsUrl = `https://console.cloud.google.com/logs/query?${new URLSearchParams({ project, query: `${identity}\ntimestamp>="${new Date(time(timestamp) - 60000).toISOString()}"\ntimestamp<="${new Date(time(timestamp) + 60000).toISOString()}"` })}`;
243
+ }
244
+ return { source: 'original_failure_export', timestamp, insertId: str(entry.insertId),
245
+ code: str(context.code ?? context.errorCode) ?? causeCodes.at(-1) ?? null, causeCodes, errorChain, type: str(context.event ?? context.type),
246
+ severity: str(entry.severity ?? context.severity), message: safeFleetText(context.message ?? entry.textPayload),
247
+ hostId: str(context.hostId), deviceId: str(context.deviceId), providerSessionId, newlySessionId,
248
+ unclassifiedSessionId: providerSessionId || newlySessionId ? null : session,
249
+ platform: str(context.platform), retryable: context.retryable ?? errorChain[0]?.retryable ?? null, httpStatus: context.httpStatus ?? errorChain[0]?.httpStatus ?? null,
250
+ serviceAtFailure: { name: service || null, revision: str(context.revision), version: str(context.version) },
251
+ traceId: trace, requestId: str(context.requestId ?? context.reqId), launchId: str(context.launchId), operation: str(context.operation),
252
+ releaseVersionAtFailure: str(context.serveSimVersion ?? context.hostVersion ?? context.releaseVersion),
253
+ helperAtFailure: Object.keys(helper).length ? { ...pick(helper, ['state', 'lastSeenAt', 'observedAt']), observation: atFailureObservation(helper.observedAt ?? helper.lastSeenAt) } : null,
254
+ resourceTelemetryAtFailure: Object.keys(resources).length ? { ...pick(resources, ['observedAt', 'cpuUtilizationPercent', 'memoryUsedBytes', 'memoryTotalBytes']), observation: atFailureObservation(resources.observedAt) } : null,
255
+ originalFailureLogsUrl: originalLogsUrl(context.originalFailureLogsUrl ?? context.logsUrl) ?? generatedLogsUrl,
256
+ originalFailureLogsUrlBasis: originalLogsUrl(context.originalFailureLogsUrl ?? context.logsUrl) ? 'provided_link' : generatedLogsUrl ? 'derived_from_original_log_identity_and_timestamp' : 'missing',
257
+ };
258
+ }
259
+ function eventEvidence(value) {
260
+ return { ...failureEvidence({ ...value, code: value.code ?? value.type, providerSessionId: value.providerSessionId }), source: 'hosted_event' };
261
+ }
262
+ function selectedFailure(event, codes) {
263
+ return codes.length ? codes.some(code => event.code === code || event.causeCodes.includes(code) || event.type === code || event.message?.includes(code)) : ['warning', 'critical', 'error', 'ERROR', 'CRITICAL'].includes(event.severity ?? '');
264
+ }
265
+ function correlate(event, fleetHosts, reports, now) {
266
+ let hostId = event.hostId, basis = hostId ? 'reported_host_id' : 'unresolved', deviceId = event.deviceId;
267
+ const matches = [];
268
+ if (!hostId && event.providerSessionId && event.timestamp) {
269
+ for (const host of fleetHosts)
270
+ for (const session of rows(host.activeSessions)) {
271
+ const end = session.expiresAt === null && ['active', 'provisioning'].includes(String(session.status)) ? now : time(session.expiresAt);
272
+ if (session.id === event.providerSessionId && time(session.createdAt) > 0 && end > 0 &&
273
+ time(session.createdAt) <= time(event.timestamp) && time(event.timestamp) <= end) {
274
+ matches.push({ hostId: String(host.id), deviceId: str(session.deviceId) });
275
+ }
276
+ }
277
+ if (matches.length === 1) {
278
+ hostId = matches[0].hostId;
279
+ deviceId ??= matches[0].deviceId;
280
+ basis = 'exact_provider_session_and_lease_interval';
281
+ }
282
+ else if (matches.length > 1)
283
+ basis = 'ambiguous_provider_session';
284
+ }
285
+ const host = reports.find(candidate => candidate.id === hostId);
286
+ const device = host?.devices.find(candidate => candidate.id === deviceId);
287
+ return { ...event, hostId, deviceId, correlation: { basis, historicalHostResolved: hostId !== null,
288
+ missingEvidence: [!hostId && 'historical_host', !deviceId && 'device', !event.providerSessionId && 'provider_session',
289
+ !event.releaseVersionAtFailure && 'release_at_failure', event.helperAtFailure?.observation.state !== 'fresh' && 'fresh_helper_at_failure',
290
+ event.resourceTelemetryAtFailure?.observation.state !== 'fresh' && 'fresh_resources_at_failure'].filter(Boolean),
291
+ currentSnapshot: host ? { observedAt: host.health.observedAt, diagnosis: host.diagnosis, release: host.release, cpu: host.cpu,
292
+ helper: device?.helper ?? null, helperBasis: device ? 'current_device_snapshot; not helper state at failure' : 'device_missing_in_current_snapshot' } : null,
293
+ causation: 'unproved; current telemetry does not establish conditions at failure' } };
294
+ }
295
+ export async function inspectFleet(client, options = {}, now) {
296
+ validate(options);
297
+ const query = new URLSearchParams(options.hostId ? { hostId: options.hostId } : {});
298
+ if (options.deep)
299
+ query.set('include', 'details');
300
+ const fleet = await readSource(client, ROOT);
301
+ if (fleet.state === 'unavailable' || !Array.isArray(fleet.data.hosts))
302
+ throw new Error(`Fleet read unavailable (${fleet.reason ?? 'invalid_response'}); use an admin identity with read permission`);
303
+ const [inventory, shared, diagnostics, events, history] = await Promise.all([
304
+ readSource(client, '/run-cloud/admin/fleet-inventory'),
305
+ readSource(client, `${ROOT}/operator-inventory`),
306
+ readSource(client, `${ROOT}/diagnostics?${query}`),
307
+ readFleetEvents(client, options),
308
+ options.hostId ? readSource(client, `${ROOT}/history?${new URLSearchParams({ range: options.range ?? '24h', hostId: options.hostId })}`) : Promise.resolve(null),
309
+ ]);
310
+ for (const [source, key] of [[inventory, 'hosts'], [shared, 'snapshots'], [diagnostics, 'diagnoses']]) {
311
+ if (source.state === 'available' && !Array.isArray(source.data[key])) {
312
+ source.state = 'unavailable';
313
+ source.reason = 'invalid_response_shape';
314
+ source.data = {};
315
+ }
316
+ }
317
+ if (history?.state === 'available' && !Array.isArray(history.data.points)) {
318
+ history.state = 'unavailable';
319
+ history.reason = 'invalid_response_shape';
320
+ history.data = {};
321
+ }
322
+ now ??= Date.now();
323
+ const supplemental = componentHosts(shared.data);
324
+ const live = new Map(rows(fleet.data.hosts).map(host => [String(host.id), host]));
325
+ const expected = new Map(rows(inventory.data.hosts).map(host => [String(host.id), host]));
326
+ const ids = new Set([...expected.keys(), ...live.keys(), ...supplemental.keys()]);
327
+ if (options.hostId && !ids.has(options.hostId))
328
+ throw new Error(`Host ${options.hostId} is absent from returned inventories`);
329
+ const hosts = [...ids].filter(id => !options.hostId || id === options.hostId).sort().map(id => hostReport(live.get(id) ?? {}, expected.get(id) ?? {}, supplemental.get(id) ?? {}, rows(diagnostics.data.diagnoses), now, num(fleet.data.staleAfterSeconds) ?? 45, options.deep ? row(row(diagnostics.data.hostDetails)[id]) : {}));
330
+ const freshCpu = hosts.filter(host => host.cpu.state === 'fresh' && host.cpu.percent !== null);
331
+ const codes = options.codes ?? [];
332
+ const failureInputs = [...events.items.map(eventEvidence), ...(options.failures ?? []).map(failureEvidence)];
333
+ const start = now - ({ '1h': 3600, '6h': 21600, '24h': 86400, '7d': 604800, '30d': 2592000, '90d': 7776000, '1y': 31536000, '2y': 63072000 }[options.range ?? '24h']) * 1000;
334
+ const failures = failureInputs.filter(event => selectedFailure(event, codes) && (!event.timestamp || (time(event.timestamp) >= start && time(event.timestamp) <= now)))
335
+ .map(event => ({ ...correlate(event, rows(fleet.data.hosts), hosts, now), windowMembership: event.timestamp ? 'inside_requested_window' : 'unknown; original timestamp missing' }))
336
+ .filter(event => !options.hostId || event.hostId === options.hostId || event.hostId === null);
337
+ const sourceStatus = (source) => ({ state: source.state, reason: source.reason, generatedAt: stamp(source.data.generatedAt), meaning: 'response generation time; does not renew component observations' });
338
+ return { schemaVersion: 1, generatedAt: new Date(now).toISOString(), readOnly: true,
339
+ sources: { fleet: sourceStatus(fleet), inventory: sourceStatus(inventory), shared: sourceStatus(shared), diagnostics: sourceStatus(diagnostics),
340
+ ...(history ? { history: sourceStatus(history) } : {}) },
341
+ summary: { hosts: hosts.length, canonicalInventoryHosts: inventory.state === 'available' ? expected.size : null, liveHosts: live.size,
342
+ healthCounts: hosts.reduce((counts, host) => { const state = host.health.effectiveState; counts[state] = (counts[state] ?? 0) + 1; return counts; }, { ready: 0, degraded: 0, unreachable: 0, unknown: 0 }),
343
+ cpu: { meanBusyPercent: freshCpu.length ? freshCpu.reduce((sum, host) => sum + host.cpu.percent, 0) / freshCpu.length : null,
344
+ meaning: 'arithmetic mean across fresh host samples; not CPU core weighted; aliases may represent the same physical machine',
345
+ freshHosts: freshCpu.length, totalHosts: hosts.length, oldestObservedAt: freshCpu.map(host => host.cpu.observedAt).sort()[0] ?? null,
346
+ staleHosts: hosts.filter(host => host.cpu.state === 'stale').length, missingHosts: hosts.filter(host => host.cpu.state === 'missing').length,
347
+ unavailableHosts: hosts.filter(host => host.cpu.state === 'unavailable').length }, matchingFailureRows: failures.length,
348
+ failures: { meaning: 'log/event rows, not distinct incidents; provider and consumer layers can describe the same failure',
349
+ uniqueProviderSessions: new Set(failures.map(event => event.providerSessionId).filter(Boolean)).size,
350
+ unresolvedHostRows: failures.filter(event => !event.hostId).length,
351
+ bySourceAndCode: failures.reduce((counts, event) => {
352
+ const key = `${event.source}:${event.code ?? event.type ?? 'unknown'}`;
353
+ counts[key] = (counts[key] ?? 0) + 1;
354
+ return counts;
355
+ }, {}) } },
356
+ eventCoverage: events.coverage, originalFailureCoverage: { importedRows: options.failures?.length ?? 0, meaning: 'caller supplied export; completeness unverified' },
357
+ failureFilter: { codes, range: options.range ?? '24h', since: new Date(start).toISOString(), until: new Date(now).toISOString() },
358
+ hosts, failures,
359
+ historicalTelemetry: history ? { ...sourceStatus(history), hostId: options.hostId, range: options.range ?? '24h',
360
+ meaning: 'aggregated time buckets; not instantaneous resource conditions at failure',
361
+ points: rows(history.data.points).map(point => pick(point, ['timestamp', 'cpuUtilizationPercent', 'memoryUtilizationPercent', 'storageUtilizationPercent', 'activeSessions'])) } : null,
362
+ deepDetails: options.deep ? sanitizeDeep(row(row(diagnostics.data.hostDetails)[options.hostId])) : null,
363
+ };
364
+ }
365
+ function sanitizeDeep(details) {
366
+ return { observedAt: stamp(row(details.security).checkedAt), services: rows(details.services).map(service => ({ ...pick(service, ['name', 'state', 'pid', 'lastExitCode']), error: safeFleetText(service.lastError ?? service.error) })),
367
+ processes: rows(details.processes).map(process => pick(process, ['name', 'pid', 'uptimeSeconds', 'cpuUtilizationPercent', 'memoryBytes'])),
368
+ toolchain: pick(row(details.toolchain), ['nodeVersion', 'xcodeVersion', 'simctlVersion', 'architecture', 'runtimes']),
369
+ logs: rows(details.logs).map(log => ({ ...pick(log, ['source', 'timestamp', 'severity']), message: safeFleetText(log.message) })) };
370
+ }
371
+ export function readFailureExport(path) {
372
+ if (!statSync(path).isFile() || statSync(path).size > 16 * 1024 * 1024)
373
+ throw new Error('Failure export must be a file at most 16 MiB');
374
+ const text = readFileSync(path, 'utf8').trim();
375
+ let parsed;
376
+ try {
377
+ parsed = JSON.parse(text);
378
+ }
379
+ catch {
380
+ try {
381
+ parsed = text.split('\n').filter(Boolean).map(line => JSON.parse(line));
382
+ }
383
+ catch {
384
+ throw new Error('Failure export is not valid JSON or JSONL');
385
+ }
386
+ }
387
+ const entries = Array.isArray(parsed) ? parsed : Array.isArray(row(parsed).entries) ? row(parsed).entries : [parsed];
388
+ if (entries.length > 500)
389
+ throw new Error('Failure export exceeds 500 rows; narrow the log query');
390
+ return entries;
391
+ }
392
+ export async function readFleetLogs(client, hostId, source = 'service', limit = 100) {
393
+ validate({ hostId, limit });
394
+ if (!['service', 'warm_pool', 'event_log'].includes(source))
395
+ throw new Error('source must be service, warm_pool, or event_log');
396
+ const result = await readSource(client, `${ROOT}/diagnostics/logs?${new URLSearchParams({ hostId, source, limit: String(limit) })}`);
397
+ if (result.state === 'available' && !Array.isArray(result.data.lines)) {
398
+ result.state = 'unavailable';
399
+ result.reason = 'invalid_response_shape';
400
+ }
401
+ return { state: result.state, reason: result.reason, hostId, source, readAt: stamp(result.data.generatedAt),
402
+ observationMeaning: 'readAt is retrieval time; original timestamps, when present, remain inside lines',
403
+ truncated: result.state === 'unavailable' || typeof result.data.truncated !== 'boolean' ? null : result.data.truncated,
404
+ lines: Array.isArray(result.data.lines) ? result.data.lines.map(safeFleetText) : [] };
405
+ }
406
+ function credentialsClient() { const credentials = requireCredentials(); return new ApiClient(credentials.apiUrl, credentials.token); }
407
+ function action(fn) {
408
+ return fn().catch(error => { console.error(safeFleetText(error instanceof Error ? error.message : 'Fleet read failed')); process.exitCode = 1; });
409
+ }
410
+ function printReport(report, json) {
411
+ if (json) {
412
+ console.log(JSON.stringify(report, null, 2));
413
+ return;
414
+ }
415
+ console.log(`${report.generatedAt}: ${report.summary.hosts} hosts, ${report.summary.cpu.freshHosts} fresh CPU samples; mean ${report.summary.cpu.meanBusyPercent?.toFixed(1) ?? 'unknown'}%.`);
416
+ for (const host of report.hosts)
417
+ console.log(host.diagnosis);
418
+ console.log(`Matching failure rows: ${report.failures.length}. Event window ${report.eventCoverage.complete ? 'fully read' : 'incomplete'} (${report.eventCoverage.rowsRead} rows).`);
419
+ for (const failure of report.failures)
420
+ console.log(`${failure.timestamp ?? 'time missing'} ${failure.code ?? failure.type ?? 'failure'} host=${failure.hostId ?? 'unknown'} device=${failure.deviceId ?? 'unknown'} providerSession=${failure.providerSessionId ?? 'unknown'}; ${failure.correlation.causation}`);
421
+ }
422
+ export function registerRunCloudFleet(program) {
423
+ const fleet = program.command('fleet').description('Read-only admin fleet telemetry, diagnosis, events and original failure evidence');
424
+ fleet.command('inspect [hostId]').description('Inspect whole fleet or one machine; existing admin read authorization required')
425
+ .option('--json', 'Structured evidence with source observations and missing data')
426
+ .option('--range <range>', 'Event/history window', '24h').option('--limit <rows>', 'Events per page (1..500)', '200')
427
+ .option('--event-pages <pages>', 'Maximum GET event pages (1..10)', '1')
428
+ .option('--code <code>', 'Failure code to correlate; repeatable', (value, previous) => [...previous, value], [])
429
+ .option('--deep', 'Read existing detailed diagnostics for one host')
430
+ .option('--failure-log <path>', 'Import original JSON/JSONL logs; repeatable, 500 rows/16 MiB per file', (value, previous) => [...previous, value], [])
431
+ .action((hostId, opts) => action(async () => {
432
+ const options = { hostId, range: opts.range, limit: Number(opts.limit), eventPages: Number(opts.eventPages), deep: opts.deep,
433
+ codes: opts.code, failures: opts.failureLog.flatMap((path) => readFailureExport(path)) };
434
+ validate(options);
435
+ printReport(await inspectFleet(credentialsClient(), options), opts.json);
436
+ }));
437
+ fleet.command('events').description('Read bounded admin event pages; emits coverage and sanitized event metadata')
438
+ .option('--host <hostId>').option('--range <range>', 'Event window', '24h').option('--limit <rows>', 'Rows per page', '200')
439
+ .option('--event-pages <pages>', 'Maximum pages (1..10)', '1')
440
+ .action(opts => action(async () => {
441
+ const result = await readFleetEvents(credentialsClient(), { hostId: opts.host, range: opts.range, limit: Number(opts.limit), eventPages: Number(opts.eventPages) });
442
+ console.log(JSON.stringify({ coverage: result.coverage, items: result.items.map(eventEvidence) }, null, 2));
443
+ }));
444
+ fleet.command('logs <hostId>').description('Read bounded retained host logs; no SSH or host changes')
445
+ .option('--source <source>', 'service, warm_pool, or event_log', 'service').option('--limit <rows>', 'Maximum lines (1..500)', '100')
446
+ .action((hostId, opts) => action(async () => { console.log(JSON.stringify(await readFleetLogs(credentialsClient(), hostId, opts.source, Number(opts.limit)), null, 2)); }));
447
+ }
package/dist/run-cloud.js CHANGED
@@ -3,6 +3,7 @@ import { Command } from 'commander';
3
3
  import { registerImage } from './commands/image.js';
4
4
  import { registerLogin } from './commands/login.js';
5
5
  import { registerRunCloud } from './commands/run-cloud.js';
6
+ import { registerRunCloudFleet } from './commands/run-cloud-fleet.js';
6
7
  import { registerSandbox } from './commands/sandbox.js';
7
8
  import { registerSecrets } from './commands/secrets.js';
8
9
  import { registerToken } from './commands/token.js';
@@ -13,6 +14,7 @@ const program = new Command('runcloud')
13
14
  .version(CLI_VERSION);
14
15
  registerLogin(program);
15
16
  registerRunCloud(program);
17
+ registerRunCloudFleet(program);
16
18
  registerSandbox(program);
17
19
  registerSecrets(program);
18
20
  registerToken(program);
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const CLI_VERSION = '0.2.165';
1
+ export const CLI_VERSION = '0.2.166';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runcloud",
3
- "version": "0.2.165",
3
+ "version": "0.2.166",
4
4
  "description": "Create and control run.cloud remote mobile simulators and cloud sandboxes",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": [
Binary file
Binary file