runcloud 0.2.164 → 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.
@@ -106,6 +106,7 @@ export async function runBundleDeploy(projectId, client, dir, env, allServices,
106
106
  secrets: s.secrets,
107
107
  }));
108
108
  const bundle = await buildDeployBundle({
109
+ projectId,
109
110
  projectDir: dir,
110
111
  services: bundleServices,
111
112
  run: runCmd,
@@ -144,7 +145,7 @@ export function registerDeploy(program) {
144
145
  .option('--region <region>', "Newly server region hint for first-time dev provisioning (defaults to 'auto')", 'auto')
145
146
  .option('--project <id>', 'project id (defaults to the linked directory)')
146
147
  .option('--service <names>', 'comma-separated service names from backend/newly.services.json')
147
- .option('--git-sha <sha>', 'prod git revision to deploy from canonical project code', 'HEAD')
148
+ .option('--git-sha <sha>', 'legacy prod deploys only; ignored when the project deploys your local backend/ folder', 'HEAD')
148
149
  .option('--yes', 'skip the prod confirmation prompt', false)
149
150
  .action(async (opts) => {
150
151
  try {
@@ -10,6 +10,7 @@ export function registerLink(program) {
10
10
  writeProjectLink(dir, {
11
11
  projectId,
12
12
  pulledSha: existing?.projectId === projectId ? existing.pulledSha : null,
13
+ pulledBackendHash: existing?.projectId === projectId ? existing.pulledBackendHash : undefined,
13
14
  pulledAt: new Date().toISOString(),
14
15
  });
15
16
  console.log(`Linked ${dir} to project ${projectId}. You can now run \`newly deploy\`, \`newly status\`, etc. here.`);
@@ -1,7 +1,9 @@
1
- import { readdirSync } from 'node:fs';
1
+ import { existsSync, readdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
2
3
  import { ApiClient, friendlyApiError } from '../api.js';
3
4
  import { requireCredentials, resolveProject, writeProjectLink } from '../config.js';
4
- import { extractSnapshot, pruneStaleTopLevel, snapshotTopLevelEntries } from '../archive.js';
5
+ import { extractSnapshot, listSnapshot, pruneStaleTopLevel, snapshotTopLevelEntries } from '../archive.js';
6
+ import { hashBackendSource } from '../deploy-bundle.js';
5
7
  export function assertPullTarget(dir, force) {
6
8
  const entries = readdirSync(dir).filter((e) => e !== '.newly');
7
9
  if (entries.length > 0 && !force) {
@@ -38,10 +40,12 @@ export function registerPull(program) {
38
40
  }
39
41
  await extractTarball(data, dir);
40
42
  const pulledBranch = res.headers.get('x-newly-branch');
43
+ const pulledPaths = new Set((await listSnapshot(data)).map((name) => name.replace(/^\.?\//, '')));
41
44
  writeProjectLink(dir, {
42
45
  projectId,
43
46
  pulledSha: res.headers.get('x-newly-head-sha'),
44
47
  pulledAt: new Date().toISOString(),
48
+ pulledBackendHash: existsSync(join(dir, 'backend')) ? hashBackendSource(dir, pulledPaths) : undefined,
45
49
  });
46
50
  console.log(`Pulled${pulledBranch ? ` branch ${pulledBranch}` : ''} into ${dir} and linked to project ${projectId}.`);
47
51
  }
@@ -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
+ }
@@ -1,9 +1,11 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
2
3
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync, } from 'node:fs';
3
4
  import { tmpdir } from 'node:os';
4
5
  import { join } from 'node:path';
5
6
  import { Buffer } from 'node:buffer';
6
7
  import { create as tarCreate } from 'tar';
8
+ import { readProjectLink } from './config.js';
7
9
  export const NEWLY_BACKEND_DEPLOY_BUNDLE_CONTENT_TYPE = 'application/vnd.newly.deploy-bundle+gzip';
8
10
  export const NEWLY_BACKEND_DEPLOY_MODE_BUNDLE = 'bundle';
9
11
  const MANIFEST_PATH = 'newly-deploy.json';
@@ -17,7 +19,7 @@ export class DeployBuildError extends Error {
17
19
  }
18
20
  }
19
21
  export async function buildDeployBundle(options) {
20
- const { projectDir, services, run, log } = options;
22
+ const { projectId, projectDir, services, run, log } = options;
21
23
  const bunVersion = await run('bun', ['--version'], projectDir);
22
24
  if (bunVersion.code !== 0) {
23
25
  throw new DeployBuildError('newly deploy needs bun on your PATH (https://bun.sh)');
@@ -26,11 +28,11 @@ export async function buildDeployBundle(options) {
26
28
  const servicesDir = join(bundleDir, 'services');
27
29
  mkdirSync(servicesDir, { recursive: true });
28
30
  try {
31
+ const gitSha = await resolveGitSha(run, projectDir, projectId);
29
32
  const manifestServices = [];
30
33
  for (const service of services) {
31
34
  manifestServices.push(await buildService(service, projectDir, servicesDir, run, log));
32
35
  }
33
- const gitSha = await resolveGitSha(run, projectDir);
34
36
  const manifest = { version: 1, git_sha: gitSha, services: manifestServices };
35
37
  writeFileSync(join(bundleDir, MANIFEST_PATH), JSON.stringify(manifest, null, 2));
36
38
  const bundle = await tarBundle(bundleDir);
@@ -110,12 +112,40 @@ function stageMigrations(dir, serviceDir, name) {
110
112
  }
111
113
  return `services/${name}/drizzle`;
112
114
  }
113
- async function resolveGitSha(run, projectDir) {
114
- const result = await run('git', ['rev-parse', 'HEAD'], projectDir);
115
- if (result.code !== 0)
115
+ async function resolveGitSha(run, projectDir, projectId) {
116
+ const link = readProjectLink(projectDir);
117
+ if (link?.projectId === projectId && link.pulledSha && link.pulledBackendHash) {
118
+ try {
119
+ if (link.pulledBackendHash === hashBackendSource(projectDir))
120
+ return link.pulledSha;
121
+ }
122
+ catch {
123
+ }
124
+ }
125
+ const head = await run('git', ['rev-parse', 'HEAD'], projectDir);
126
+ const sha = head.code === 0 ? head.output.trim() : '';
127
+ if (!sha)
128
+ return null;
129
+ const tracked = await run('git', ['ls-files', '--error-unmatch', '--', 'backend'], projectDir);
130
+ if (tracked.code !== 0)
116
131
  return null;
117
- const sha = result.output.trim();
118
- return sha || null;
132
+ const changes = await run('git', ['--no-optional-locks', 'status', '--porcelain', '--', 'backend'], projectDir);
133
+ return changes.code === 0 && changes.output.trim() === '' ? sha : null;
134
+ }
135
+ export function hashBackendSource(projectDir, only) {
136
+ const digest = (data) => createHash('sha256').update(data).digest('hex');
137
+ const files = backendSourceFiles(projectDir, 'backend').filter((path) => !only || only.has(path));
138
+ return digest(files.sort().map((path) => `${path}\0${digest(readFileSync(join(projectDir, path)))}`).join('\n'));
139
+ }
140
+ function backendSourceFiles(projectDir, rel) {
141
+ return readdirSync(join(projectDir, rel), { withFileTypes: true }).flatMap((entry) => {
142
+ if (entry.name.startsWith('.') || entry.name === 'node_modules')
143
+ return [];
144
+ const path = `${rel}/${entry.name}`;
145
+ if (entry.isDirectory())
146
+ return backendSourceFiles(projectDir, path);
147
+ return entry.isFile() ? [path] : [];
148
+ });
119
149
  }
120
150
  async function tarBundle(bundleDir) {
121
151
  const chunks = [];
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.164';
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.164",
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