@everystack/cli 0.4.2 → 0.4.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "description": "CLI and OTA updates for Expo apps on everystack",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "Scalable Technology, Inc. <licensing@scalable.technology>",
package/src/cli/aws.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * Authentication uses the developer's IAM credentials (default credential chain).
6
6
  */
7
7
 
8
- import { isOpsHandlerCrash, extractCrashDiagnosis, formatOpsCrashReport } from './ops-diagnostics.js';
8
+ import { isOpsHandlerCrash, formatOpsCrashReport, fetchDiagnosisWithRetry } from './ops-diagnostics.js';
9
9
  import { tagInvokeTransport } from './ops-advice.js';
10
10
 
11
11
  let s3Client: InstanceType<typeof import('@aws-sdk/client-s3').S3Client> | null = null;
@@ -325,50 +325,94 @@ async function fetchRecentLogText(region: string, logGroupName: string, windowMs
325
325
  return (res.events ?? []).map((e) => e.message ?? '').join('\n');
326
326
  }
327
327
 
328
+ /** Raw CloudWatch events for a function's log group (last `windowMs`, capped). */
329
+ export async function fetchRecentLogEvents(
330
+ region: string,
331
+ logGroupName: string,
332
+ windowMs: number,
333
+ ): Promise<Array<{ timestamp?: number; message?: string }>> {
334
+ const { CloudWatchLogsClient, FilterLogEventsCommand } = await import('@aws-sdk/client-cloudwatch-logs');
335
+ const client = new CloudWatchLogsClient({ region });
336
+ const res = await client.send(new FilterLogEventsCommand({
337
+ logGroupName,
338
+ startTime: Date.now() - windowMs,
339
+ limit: 500,
340
+ }));
341
+ return res.events ?? [];
342
+ }
343
+
328
344
  /**
329
345
  * Turn an opaque ops-handler runtime crash into a report naming the real cause: resolve the ops
330
- * function's log group, read the recent lines, and extract the failing import. Best-effort any
331
- * failure (no log access, no group) falls back to the where-to-look hint.
346
+ * function's log group, read the recent lines (retrying through CloudWatch ingest laga crash
347
+ * that JUST happened takes a few seconds to land), and extract the failing import. Best-effort
348
+ * any failure (no log access, no group) falls back to the everystack-native next step.
332
349
  */
333
350
  export async function diagnoseOpsCrash(region: string, functionName: string, baseMessage: string): Promise<string> {
351
+ let logGroup: string | undefined;
334
352
  try {
335
- const logGroup = await resolveLogGroup(region, functionName);
336
- const text = await fetchRecentLogText(region, logGroup);
337
- return formatOpsCrashReport(baseMessage, logGroup, extractCrashDiagnosis(text));
353
+ logGroup = await resolveLogGroup(region, functionName);
354
+ const group = logGroup;
355
+ const diagnosis = await fetchDiagnosisWithRetry(() => fetchRecentLogText(region, group));
356
+ return formatOpsCrashReport(baseMessage, logGroup, diagnosis);
338
357
  } catch {
339
- return formatOpsCrashReport(baseMessage);
358
+ return formatOpsCrashReport(baseMessage, logGroup);
340
359
  }
341
360
  }
342
361
 
362
+ /** IO seam for resolveLogGroup — injectable so the resolution order tests without the SDK. */
363
+ export interface LogGroupIo {
364
+ /** The log group the function's own LoggingConfig names, if readable. */
365
+ getFunctionLogGroup: (functionName: string) => Promise<string | undefined>;
366
+ /** Log groups matching a name prefix. */
367
+ describeLogGroups: (prefix: string, limit: number) => Promise<Array<{ logGroupName?: string; creationTime?: number }>>;
368
+ }
369
+
370
+ async function defaultLogGroupIo(region: string): Promise<LogGroupIo> {
371
+ return {
372
+ getFunctionLogGroup: async (functionName) => {
373
+ const client = await getLambda(region);
374
+ const { GetFunctionConfigurationCommand } = await import('@aws-sdk/client-lambda');
375
+ const cfg = await client.send(new GetFunctionConfigurationCommand({ FunctionName: functionName }));
376
+ return cfg.LoggingConfig?.LogGroup ?? undefined;
377
+ },
378
+ describeLogGroups: async (prefix, limit) => {
379
+ const { CloudWatchLogsClient, DescribeLogGroupsCommand } = await import('@aws-sdk/client-cloudwatch-logs');
380
+ const client = new CloudWatchLogsClient({ region });
381
+ const res = await client.send(new DescribeLogGroupsCommand({ logGroupNamePrefix: prefix, limit }));
382
+ return res.logGroups ?? [];
383
+ },
384
+ };
385
+ }
386
+
343
387
  /**
344
388
  * Resolve the CloudWatch log group for a Lambda function.
345
389
  *
346
- * First checks if the conventional `/aws/lambda/{functionName}` log group exists.
347
- * If not (e.g. SST replaced the function during deploy, changing the hash suffix),
348
- * searches for a log group matching the function's base name prefix.
390
+ * The function's own LoggingConfig is authoritative: SST Ion creates the log group as its OWN
391
+ * resource, so the function and its group carry INDEPENDENT random suffixes (`OpsFunction-vnmzcmed`
392
+ * writes to `/aws/lambda/...-OpsFunction-ubzfsutk`) deriving the group from the function name is
393
+ * wrong for every SST function. Name heuristics (conventional `/aws/lambda/{name}`, then a prefix
394
+ * search for a rotated suffix) are fallbacks for when the config read is denied.
349
395
  */
350
396
  export async function resolveLogGroup(
351
397
  region: string,
352
398
  functionName: string,
399
+ io?: LogGroupIo,
353
400
  ): Promise<string> {
401
+ const ops = io ?? await defaultLogGroupIo(region);
354
402
  const conventional = `/aws/lambda/${functionName}`;
355
403
 
356
- const {
357
- CloudWatchLogsClient,
358
- DescribeLogGroupsCommand,
359
- } = await import('@aws-sdk/client-cloudwatch-logs');
360
-
361
- const client = new CloudWatchLogsClient({ region });
404
+ // Authoritative: what the Lambda itself says it logs to.
405
+ try {
406
+ const configured = await ops.getFunctionLogGroup(functionName);
407
+ if (configured) return configured;
408
+ } catch {
409
+ // No lambda:GetFunctionConfiguration (or the function is gone) — fall back to name heuristics
410
+ }
362
411
 
363
- // Fast path: conventional name exists
412
+ // Conventional name exists
364
413
  try {
365
- const res = await client.send(
366
- new DescribeLogGroupsCommand({
367
- logGroupNamePrefix: conventional,
368
- limit: 1,
369
- }),
370
- );
371
- if (res.logGroups?.some(lg => lg.logGroupName === conventional)) {
414
+ const res = await ops.describeLogGroups(conventional, 1);
415
+ if (res.some(lg => lg.logGroupName === conventional)) {
372
416
  return conventional;
373
417
  }
374
418
  } catch {
@@ -382,20 +426,13 @@ export async function resolveLogGroup(
382
426
  if (lastDash > 0) {
383
427
  const basePrefix = `/aws/lambda/${functionName.slice(0, lastDash)}`;
384
428
  try {
385
- const res = await client.send(
386
- new DescribeLogGroupsCommand({
387
- logGroupNamePrefix: basePrefix,
388
- limit: 5,
389
- }),
390
- );
391
- if (res.logGroups?.length) {
392
- // Return the most recently created matching log group
393
- const sorted = res.logGroups
394
- .filter(lg => lg.logGroupName)
395
- .sort((a, b) => (b.creationTime || 0) - (a.creationTime || 0));
396
- if (sorted.length > 0) {
397
- return sorted[0].logGroupName!;
398
- }
429
+ const res = await ops.describeLogGroups(basePrefix, 5);
430
+ // Return the most recently created matching log group
431
+ const sorted = res
432
+ .filter(lg => lg.logGroupName)
433
+ .sort((a, b) => (b.creationTime || 0) - (a.creationTime || 0));
434
+ if (sorted.length > 0) {
435
+ return sorted[0].logGroupName!;
399
436
  }
400
437
  } catch {
401
438
  // Fall through to conventional name
@@ -13,6 +13,8 @@
13
13
  import { spawn } from 'node:child_process';
14
14
  import { step, success, fail, info } from '../output.js';
15
15
  import { invalidateCachedConfig } from '../discover.js';
16
+ import { resolveConfig } from '../config.js';
17
+ import { probeDeployedFunctions } from '../deploy-probe.js';
16
18
 
17
19
  /** The `sst` argv `everystack deploy` runs. Pure, so the mapping is unit-tested. */
18
20
  export function buildDeployArgs(flags: Record<string, string>): string[] {
@@ -46,5 +48,22 @@ export async function deployCommand(flags: Record<string, string>): Promise<void
46
48
  // A deploy can change topology (a new Ops/Worker function, rotated Lambda hashes) that a
47
49
  // stale per-stage cache would shadow — bust it so the next stage command re-discovers live.
48
50
  await invalidateCachedConfig(stage);
51
+
52
+ // `sst deploy` exiting 0 only proves the code UPLOADED — a bundle that crashes at INIT still
53
+ // deploys "green" and takes the whole ops plane down until the next verb trips over it. Probe
54
+ // the deployed functions now so the operator learns here, with the real cause attached.
55
+ try {
56
+ const config = await resolveConfig(stage);
57
+ const { fatal } = await probeDeployedFunctions(stage, config);
58
+ if (fatal) {
59
+ fail('Deploy landed, but a deployed function cannot serve — every --stage verb will fail until this is fixed and redeployed.');
60
+ process.exit(1);
61
+ }
62
+ } catch (err: any) {
63
+ // The probe is a safety net, not a gate on discovery: a stack with nothing to probe
64
+ // (V1 static) or an operator without invoke rights still gets their deploy.
65
+ info(`Skipped the post-deploy probe: ${err?.message ?? err}`);
66
+ }
67
+
49
68
  info('Next: `everystack db:migrate --stage ' + stage + '` to apply migrations, `everystack update` to publish the app bundle.');
50
69
  }
@@ -11,7 +11,8 @@
11
11
  */
12
12
 
13
13
  import { resolveConfig } from '../config.js';
14
- import { resolveLogGroup } from '../aws.js';
14
+ import { resolveLogGroup, fetchRecentLogEvents } from '../aws.js';
15
+ import { summarizeRuntimeFailures } from '../ops-diagnostics.js';
15
16
  import { queryLogArchive, type ObservabilityQuery } from '../observability.js';
16
17
  import { step, success, fail, info } from '../output.js';
17
18
 
@@ -32,6 +33,46 @@ function parseDurationMs(duration: string): number {
32
33
  }
33
34
  }
34
35
 
36
+ /**
37
+ * Sweep the stage's Lambda log groups for runtime-level failures — INIT crashes, import errors,
38
+ * timeouts. These kill the handler BEFORE it can ship anything to the S3 archive, so without this
39
+ * sweep `logs:errors` answers "No errors found!" while the ops plane is down on every invoke (a
40
+ * real consumer incident). Best-effort per function: no CloudWatch read access just means the S3
41
+ * results stand alone.
42
+ */
43
+ async function sweepRuntimeFailures(
44
+ config: {
45
+ region: string;
46
+ opsFunctionName?: string;
47
+ apiFunctionName?: string;
48
+ workerFunctionName?: string;
49
+ imageFunctionName?: string;
50
+ },
51
+ windowMs: number,
52
+ ): Promise<Array<{ label: string; functionName: string; lines: string[] }>> {
53
+ const candidates: Array<[string, string | undefined]> = [
54
+ ['ops', config.opsFunctionName],
55
+ ['api', config.apiFunctionName],
56
+ ['worker', config.workerFunctionName],
57
+ ['media', config.imageFunctionName],
58
+ ];
59
+ const seen = new Set<string>();
60
+ const failures: Array<{ label: string; functionName: string; lines: string[] }> = [];
61
+ for (const [label, functionName] of candidates) {
62
+ if (!functionName || seen.has(functionName)) continue;
63
+ seen.add(functionName);
64
+ try {
65
+ const logGroup = await resolveLogGroup(config.region, functionName);
66
+ const events = await fetchRecentLogEvents(config.region, logGroup, windowMs);
67
+ const lines = summarizeRuntimeFailures(events);
68
+ if (lines.length) failures.push({ label, functionName, lines });
69
+ } catch {
70
+ // No logs:FilterLogEvents (or no group yet) — skip; the S3 results stand alone.
71
+ }
72
+ }
73
+ return failures;
74
+ }
75
+
35
76
  export async function logsErrorsCommand(flags: Record<string, string>): Promise<void> {
36
77
  step('Resolving deployed config...');
37
78
  let config;
@@ -60,12 +101,30 @@ export async function logsErrorsCommand(flags: Record<string, string>): Promise<
60
101
  try {
61
102
  const errors = await queryLogArchive(config.region, config.updatesBucket, prefix, query);
62
103
 
63
- if (errors.length === 0) {
104
+ // Runtime-level failures never reach the S3 archive — sweep CloudWatch in the same window.
105
+ const windowMs = (flags.since && parseDurationMs(flags.since)) || 60 * 60 * 1000;
106
+ const runtimeFailures = await sweepRuntimeFailures(config, windowMs);
107
+
108
+ if (errors.length === 0 && runtimeFailures.length === 0) {
64
109
  success('No errors found!');
65
110
  return;
66
111
  }
67
112
 
68
- success(`Found ${errors.length} ${level} log(s) (s3):\n`);
113
+ if (errors.length === 0) {
114
+ info('No app-level errors in the S3 archive.');
115
+ } else {
116
+ success(`Found ${errors.length} ${level} log(s) (s3):\n`);
117
+ }
118
+ if (runtimeFailures.length > 0) {
119
+ fail(`Lambda runtime failures (CloudWatch) — these crash the handler before it can log to S3:\n`);
120
+ for (const failure of runtimeFailures) {
121
+ console.log(`${failure.label} (${failure.functionName}):`);
122
+ for (const line of failure.lines) console.log(` ${line}`);
123
+ console.log('');
124
+ }
125
+ info(`Inspect further: everystack logs:tail --origin ${runtimeFailures[0].label}${flags.stage ? ` --stage ${flags.stage}` : ''}\n`);
126
+ }
127
+ if (errors.length === 0) return;
69
128
 
70
129
  for (const error of errors) {
71
130
  const ts = new Date(error.timestamp).toISOString();
@@ -97,14 +156,14 @@ export async function logsErrorsCommand(flags: Record<string, string>): Promise<
97
156
  }
98
157
  }
99
158
 
100
- const VALID_LOG_ORIGINS = ['api', 'media', 'worker'] as const;
159
+ const VALID_LOG_ORIGINS = ['api', 'media', 'worker', 'ops'] as const;
101
160
 
102
161
  /**
103
162
  * Resolve a Lambda function name from the --origin flag.
104
163
  * Defaults to 'api' if no origin is specified.
105
164
  */
106
165
  function resolveFunctionForOrigin(
107
- config: { apiFunctionName: string; imageFunctionName?: string; workerFunctionName?: string },
166
+ config: { apiFunctionName: string; imageFunctionName?: string; workerFunctionName?: string; opsFunctionName?: string },
108
167
  origin?: string,
109
168
  ): string {
110
169
  const resolved = origin || 'api';
@@ -128,6 +187,13 @@ function resolveFunctionForOrigin(
128
187
  );
129
188
  }
130
189
  return config.workerFunctionName;
190
+ case 'ops':
191
+ if (!config.opsFunctionName) {
192
+ throw new Error(
193
+ 'No ops function found. Add opsFunctionName to sst.config.ts outputs or use --stage for auto-discovery.',
194
+ );
195
+ }
196
+ return config.opsFunctionName;
131
197
  default:
132
198
  return config.apiFunctionName;
133
199
  }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Post-deploy ops-plane probe — deploy must not exit green when the functions it just shipped
3
+ * cannot boot.
4
+ *
5
+ * A deploy that lands a Lambda whose bundle crashes at INIT (a failing top-level import, an
6
+ * import-time side effect) reports success from `sst deploy` — CloudFormation only proves the
7
+ * code UPLOADED, not that it runs. The operator then learns at the next db verb, from an opaque
8
+ * "Runtime exited with error: exit status 1", with the whole ops plane already down. The probe
9
+ * invokes each deployed function with `_action: '_health'` right after deploy, so the failure is
10
+ * caught at deploy time with the real cause attached.
11
+ *
12
+ * The probe proves BOOT, not features: an older @everystack/server without a `_health` action
13
+ * answers `{ error: 'Unknown action: _health' }` — the runtime evaluated the bundle, initialized,
14
+ * and dispatched, which is exactly what deploy needs to verify. Only a FunctionError (the handler
15
+ * never answered) can fail the probe; a transport/IAM error means the probe itself couldn't run
16
+ * and must never fail the deploy.
17
+ */
18
+
19
+ import { isOpsHandlerCrash } from './ops-diagnostics.js';
20
+ import { diagnoseOpsCrash } from './aws.js';
21
+ import { step, success, fail, info } from './output.js';
22
+
23
+ /** The raw outcome of one probe invoke, before classification. */
24
+ export interface ProbeOutcome {
25
+ /** Lambda FunctionError message — the handler threw uncaught or the runtime crashed. */
26
+ functionError?: string;
27
+ /** Parsed response payload of a successful invoke. */
28
+ payload?: unknown;
29
+ /** The invoke call itself failed (transport/IAM) — the probe could not run. */
30
+ invokeError?: { name?: string; message?: string };
31
+ }
32
+
33
+ export interface ProbeVerdict {
34
+ verdict: 'healthy' | 'boot-ok' | 'crashed' | 'failed' | 'unreachable';
35
+ detail?: string;
36
+ }
37
+
38
+ /** Pure classification of a probe outcome — the decision table this feature IS. */
39
+ export function classifyProbe(outcome: ProbeOutcome): ProbeVerdict {
40
+ if (outcome.invokeError) {
41
+ return {
42
+ verdict: 'unreachable',
43
+ detail: outcome.invokeError.message ?? outcome.invokeError.name ?? 'invoke failed',
44
+ };
45
+ }
46
+ if (outcome.functionError) {
47
+ return {
48
+ verdict: isOpsHandlerCrash(outcome.functionError) ? 'crashed' : 'failed',
49
+ detail: outcome.functionError,
50
+ };
51
+ }
52
+ const payload = outcome.payload;
53
+ if (payload && typeof payload === 'object') {
54
+ const p = payload as { ok?: unknown; error?: unknown };
55
+ if (p.ok === true) return { verdict: 'healthy' };
56
+ if (p.ok === false) return { verdict: 'failed', detail: String(p.error ?? 'handler reported unhealthy') };
57
+ if (typeof p.error === 'string') {
58
+ // Pre-_health servers answer "Unknown action: _health" — boot proven, feature absent.
59
+ if (p.error.startsWith('Unknown action')) return { verdict: 'boot-ok' };
60
+ return { verdict: 'failed', detail: p.error };
61
+ }
62
+ }
63
+ // Any other answer (null, {}, a string) still came from a booted handler.
64
+ return { verdict: 'boot-ok' };
65
+ }
66
+
67
+ /** Which deployed functions to probe. Deduped: older apps route ops actions to the api function. */
68
+ export function probeTargets(config: {
69
+ opsFunctionName?: string;
70
+ apiFunctionName?: string;
71
+ }): Array<{ label: string; functionName: string }> {
72
+ const targets: Array<{ label: string; functionName: string }> = [];
73
+ if (config.opsFunctionName) targets.push({ label: 'ops', functionName: config.opsFunctionName });
74
+ if (config.apiFunctionName && config.apiFunctionName !== config.opsFunctionName) {
75
+ targets.push({ label: 'api', functionName: config.apiFunctionName });
76
+ }
77
+ return targets;
78
+ }
79
+
80
+ /** One raw `_health` invoke → outcome. Never throws; every failure mode lands in the outcome. */
81
+ async function probeFunction(region: string, functionName: string): Promise<ProbeOutcome> {
82
+ try {
83
+ const { LambdaClient, InvokeCommand } = await import('@aws-sdk/client-lambda');
84
+ const client = new LambdaClient({ region });
85
+ const response = await client.send(new InvokeCommand({
86
+ FunctionName: functionName,
87
+ Payload: new TextEncoder().encode(JSON.stringify({ _action: '_health', _payload: {} })),
88
+ }));
89
+ const payload = response.Payload
90
+ ? JSON.parse(new TextDecoder().decode(response.Payload))
91
+ : null;
92
+ if (response.FunctionError) {
93
+ return { functionError: String((payload as { errorMessage?: string })?.errorMessage ?? response.FunctionError) };
94
+ }
95
+ return { payload };
96
+ } catch (err: unknown) {
97
+ const e = err as { name?: string; message?: string };
98
+ return { invokeError: { name: e?.name, message: e?.message } };
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Probe the stage's deployed functions and report. Returns whether deploy must fail: a function
104
+ * that cannot boot (or answers unhealthy) is fatal; a probe that couldn't run is a warning.
105
+ */
106
+ export async function probeDeployedFunctions(
107
+ stage: string,
108
+ config: { region: string; opsFunctionName?: string; apiFunctionName?: string },
109
+ ): Promise<{ fatal: boolean }> {
110
+ const targets = probeTargets(config);
111
+ if (targets.length === 0) return { fatal: false };
112
+
113
+ step('Probing deployed functions...');
114
+ let fatal = false;
115
+ for (const target of targets) {
116
+ const outcome = await probeFunction(config.region, target.functionName);
117
+ const result = classifyProbe(outcome);
118
+ switch (result.verdict) {
119
+ case 'healthy':
120
+ success(`${target.label} function healthy (${target.functionName})`);
121
+ break;
122
+ case 'boot-ok':
123
+ success(`${target.label} function boots (${target.functionName})`);
124
+ break;
125
+ case 'crashed': {
126
+ fatal = true;
127
+ const report = await diagnoseOpsCrash(config.region, target.functionName, result.detail ?? 'Runtime crash');
128
+ fail(`${target.label} function crashed at startup (${target.functionName})`);
129
+ console.error(report);
130
+ break;
131
+ }
132
+ case 'failed':
133
+ fatal = true;
134
+ fail(`${target.label} function is unhealthy (${target.functionName}): ${result.detail}`);
135
+ break;
136
+ case 'unreachable':
137
+ info(`Could not probe ${target.label} function (${target.functionName}): ${result.detail}`);
138
+ break;
139
+ }
140
+ }
141
+ return { fatal };
142
+ }
@@ -43,8 +43,11 @@ export function extractCrashDiagnosis(logText: string): string | null {
43
43
 
44
44
  /**
45
45
  * Assemble the operator-facing report for an ops-handler crash: the raw message, the "it's the
46
- * handler, not your command" framing, and either the real cause from CloudWatch or a where-to-look
47
- * hint when the logs couldn't be read.
46
+ * handler, not your command" framing, and either the real cause from CloudWatch or an
47
+ * everystack-native next step when the crash lines couldn't be read. The fallback must never
48
+ * send the operator to the AWS console — a consumer operator may have no console access at all,
49
+ * and an agent told to "check CloudWatch" will reach for raw `aws` with whatever credentials the
50
+ * shell holds.
48
51
  */
49
52
  export function formatOpsCrashReport(
50
53
  baseMessage: string,
@@ -54,7 +57,7 @@ export function formatOpsCrashReport(
54
57
  const lines = [
55
58
  baseMessage,
56
59
  '',
57
- 'The ops handler crashed at startup — usually a missing dependency in the deployed bundle, not a problem with this command.',
60
+ 'The ops handler crashed at startup — a failing top-level import or import-time side effect in the deployed bundle, not a problem with this command.',
58
61
  ];
59
62
  if (diagnosis) {
60
63
  lines.push(
@@ -62,10 +65,67 @@ export function formatOpsCrashReport(
62
65
  `From CloudWatch${logGroup ? ` (${logGroup})` : ''}:`,
63
66
  diagnosis,
64
67
  '',
65
- 'Fix the import/dependency and redeploy.',
68
+ 'Fix the failing import in the ops handler and redeploy.',
66
69
  );
67
70
  } else {
68
- lines.push('Check its CloudWatch logs for the failing top-level import, then redeploy.');
71
+ lines.push(
72
+ '',
73
+ logGroup
74
+ ? `No diagnostic lines in ${logGroup} yet — CloudWatch ingest can lag by a few seconds.`
75
+ : 'The crash lines could not be read from CloudWatch.',
76
+ 'See the startup error with: everystack logs:tail --origin ops --stage <stage>',
77
+ 'Fix the failing import in the ops handler and redeploy.',
78
+ );
69
79
  }
70
80
  return lines.join('\n');
71
81
  }
82
+
83
+ /**
84
+ * Fetch-and-extract with retry: CloudWatch ingest lags a crash by a few seconds, so a single
85
+ * fetch right after the invoke routinely reads an empty window and the diagnosis degrades to
86
+ * the where-to-look fallback (a real consumer incident). Retries until lines land or the
87
+ * attempt budget runs out. Takes the fetch and sleep as thunks so it tests without the SDK.
88
+ */
89
+ export async function fetchDiagnosisWithRetry(
90
+ fetchText: () => Promise<string>,
91
+ opts?: { attempts?: number; sleep?: () => Promise<void> },
92
+ ): Promise<string | null> {
93
+ const attempts = opts?.attempts ?? 4;
94
+ const sleep = opts?.sleep ?? (() => new Promise<void>((r) => setTimeout(r, 2500)));
95
+ for (let i = 0; i < attempts; i++) {
96
+ if (i > 0) await sleep();
97
+ const diagnosis = extractCrashDiagnosis(await fetchText());
98
+ if (diagnosis) return diagnosis;
99
+ }
100
+ return null;
101
+ }
102
+
103
+ /**
104
+ * Runtime-level failure lines: the Lambda platform's own signals that a handler never ran —
105
+ * INIT crashes, import errors, timeouts. These never reach the app-level S3 log archive (the
106
+ * handler dies before it can ship them), so `logs:errors` reads them from CloudWatch and this
107
+ * decides which lines are worth showing.
108
+ */
109
+ const RUNTIME_FAILURE = /Runtime exited|exited with error|INIT_REPORT.*Status: (error|timeout)|Runtime\.\w+(Error|Exit)|Cannot find (module|package)|ERR_MODULE_NOT_FOUND|\bMODULE_NOT_FOUND\b|Task timed out|Process exited before completing request|(?:Reference|Type|Syntax|Range)?Error:/i;
110
+
111
+ /**
112
+ * Reduce raw CloudWatch events to the runtime-failure lines an operator needs: filter to the
113
+ * platform failure signals, de-duplicate (a crash-looping Lambda emits the same line on every
114
+ * invoke), stamp each with its event time, and cap the output.
115
+ */
116
+ export function summarizeRuntimeFailures(
117
+ events: Array<{ timestamp?: number; message?: string }>,
118
+ cap = 8,
119
+ ): string[] {
120
+ const seen = new Set<string>();
121
+ const out: string[] = [];
122
+ for (const event of events) {
123
+ const line = (event.message ?? '').trim();
124
+ if (!line || !RUNTIME_FAILURE.test(line) || seen.has(line)) continue;
125
+ seen.add(line);
126
+ const stamp = event.timestamp ? `[${new Date(event.timestamp).toISOString()}] ` : '';
127
+ out.push(`${stamp}${line}`);
128
+ if (out.length >= cap) break;
129
+ }
130
+ return out;
131
+ }