@go-to-k/cdkd 0.219.9 → 0.220.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { A as withErrorHandling, B as generateResourceNameWithFallback, C as StackHasActiveImportsError, F as getLiveRenderer, H as withStackName, I as PATTERN_B_NAME_PROPERTIES, L as PATTERN_B_RESOURCE_TYPES, M as getLogger, P as runStackBuffered, S as ResourceUpdateNotSupportedError, V as withSkipPrefix, _ as MissingCdkCliError, a as assertRegionMatch, b as ProvisioningError, f as LocalInvokeBuildError$1, i as resolveExplicitPhysicalId, k as normalizeAwsError, l as CdkdError, m as LocalStartServiceError, n as matchesCdkPath, o as disableInstanceApiTermination, p as LocalMigrateError, r as normalizeAwsTagsToCfn, s as isTerminationProtectionPropagationError, t as CDK_PATH_TAG, v as NestedStackChildDirectDestroyError, w as StackTerminationProtectionError, x as ResourceTimeoutError, y as PartialFailureError, z as generateResourceName } from "./import-helpers-wLipXr5g.js";
3
3
  import { a as setAwsClients, i as resetAwsClients, r as getAwsClients, t as AwsClients } from "./aws-clients-pjPwZz1r.js";
4
- import { A as WorkGraph, B as resolveCaptureObservedState, C as DagBuilder, D as shouldRetainResource, E as S3StateBackend, F as runDockerStreaming, G as CFN_TEMPLATE_BODY_LIMIT, H as resolveStateBucketWithDefault, I as Synthesizer, J as findLargeInlineResources, K as CFN_TEMPLATE_URL_LIMIT, L as getDefaultStateBucketName, M as formatDockerLoginError, N as getDockerCmd, O as AssetPublisher, P as runDockerForeground, Q as resolveBucketRegion, R as getLegacyStateBucketName, S as DiffCalculator, T as LockManager, U as resolveStateBucketWithDefaultAndSource, V as resolveSkipPrefix, W as warnDeprecatedNoPrefixCliFlag, X as AssemblyReader, Y as uploadCfnTemplate, _ as ProviderRegistry, a as withRetry, b as IntrinsicFunctionResolver, c as formatResourceLine, d as gray, f as green, g as collectInlinePolicyNamesManagedBySiblings, h as IAMRoleProvider, i as withResourceDeadline, j as buildDockerImage, k as stringifyValue, l as bold, m as yellow, n as DEFAULT_RESOURCE_WARN_AFTER_MS, o as isRetryableTransientError, p as red, q as MIGRATE_TMP_PREFIX, r as DeployEngine, s as IMPLICIT_DELETE_DEPENDENCIES, t as DEFAULT_RESOURCE_TIMEOUT_MS, u as cyan, v as findActionableSilentDrops, w as TemplateParser, x as applyRoleArnIfSet, y as CloudControlProvider, z as resolveApp } from "./deploy-engine-DVP4vDXG.js";
4
+ import { $ as resolveBucketRegion, A as stringifyValue, B as resolveApp, C as DiffCalculator, D as S3StateBackend, E as LockManager, F as runDockerForeground, G as warnDeprecatedNoPrefixCliFlag, H as resolveSkipPrefix, I as runDockerStreaming, J as MIGRATE_TMP_PREFIX, K as CFN_TEMPLATE_BODY_LIMIT, L as Synthesizer, M as buildDockerImage, N as formatDockerLoginError, O as shouldRetainResource, P as getDockerCmd, R as getDefaultStateBucketName, S as applyRoleArnIfSet, T as TemplateParser, U as resolveStateBucketWithDefault, V as resolveCaptureObservedState, W as resolveStateBucketWithDefaultAndSource, X as uploadCfnTemplate, Y as findLargeInlineResources, Z as AssemblyReader, _ as collectInlinePolicyNamesManagedBySiblings, a as withRetry, b as CloudControlProvider, c as extractDeploymentEventError, d as cyan, f as gray, g as IAMRoleProvider, h as yellow, i as withResourceDeadline, j as WorkGraph, k as AssetPublisher, l as formatResourceLine, m as red, n as DEFAULT_RESOURCE_WARN_AFTER_MS, o as isRetryableTransientError, p as green, q as CFN_TEMPLATE_URL_LIMIT, r as DeployEngine, s as IMPLICIT_DELETE_DEPENDENCIES, t as DEFAULT_RESOURCE_TIMEOUT_MS, u as bold, v as ProviderRegistry, w as DagBuilder, x as IntrinsicFunctionResolver, y as findActionableSilentDrops, z as getLegacyStateBucketName } from "./deploy-engine-CFaK71E9.js";
5
5
  import { t as ASGProvider } from "./asg-provider-B_hrCxRx.js";
6
6
  import { AsyncLocalStorage } from "node:async_hooks";
7
7
  import { randomBytes, randomUUID } from "node:crypto";
@@ -1507,6 +1507,387 @@ async function promptRecreateConfirm(input) {
1507
1507
  }
1508
1508
  }
1509
1509
 
1510
+ //#endregion
1511
+ //#region src/state/deployment-events-store.ts
1512
+ /**
1513
+ * Deployment-events store (issue #808) — persists the structured
1514
+ * deployment events emitted by the deploy engine / destroy runner as
1515
+ * JSONL to the state bucket, plus a small per-stack run index:
1516
+ *
1517
+ * - Events: `{prefix}/{stackName}/{region}/deployments/{runId}.jsonl`
1518
+ * - Index: `{prefix}/{stackName}/{region}/deployments/index.json`
1519
+ *
1520
+ * Design constraints (all load-bearing):
1521
+ *
1522
+ * - **Best-effort, never blocking**: `record()` is synchronous and only
1523
+ * buffers in memory; flushes run asynchronously (debounced timer +
1524
+ * size threshold) and are serialized on a write chain. A failed S3
1525
+ * write warns once and degrades to debug-level afterwards — it can
1526
+ * NEVER fail or block the deploy/destroy itself.
1527
+ * - **No locking**: each run writes to its own unique `{runId}.jsonl`
1528
+ * key (no concurrent writer by construction). `index.json` is
1529
+ * last-writer-wins — acceptable for a derived view; the `.jsonl`
1530
+ * files are the source of truth and `cdkd events` can read a run
1531
+ * directly by id even if the index lost the race.
1532
+ * - **No resource properties** in events — errors + metadata only
1533
+ * (properties may contain secrets and already live in state.json).
1534
+ * - **Separate keys from state.json** — no state schema bump; event
1535
+ * files survive `cdkd destroy` (state deletion does not touch
1536
+ * `deployments/`), preserving post-mortem context.
1537
+ */
1538
+ /** Max runs retained in `deployments/index.json` (newest first). */
1539
+ const DEPLOYMENT_EVENTS_MAX_INDEX_RUNS = 20;
1540
+ /** Debounce window between buffered events and the async S3 flush. */
1541
+ const FLUSH_INTERVAL_MS = 2e3;
1542
+ /** Flush immediately once this many events are buffered. */
1543
+ const FLUSH_EVENT_THRESHOLD = 50;
1544
+ /** Build-time cdkd version, with a dev fallback for non-built contexts. */
1545
+ function getCdkdVersion() {
1546
+ return "0.220.0";
1547
+ }
1548
+ /**
1549
+ * Generate a time-sortable unique run id, e.g.
1550
+ * `20260613T012345678Z-1a2b3c4d`. The timestamp prefix keeps S3 listings
1551
+ * and `cdkd events` output chronologically meaningful; the random suffix
1552
+ * guarantees uniqueness across concurrent runs.
1553
+ */
1554
+ function newDeploymentRunId(now = /* @__PURE__ */ new Date()) {
1555
+ return `${now.toISOString().replace(/[-:.]/g, "")}-${randomUUID().slice(0, 8)}`;
1556
+ }
1557
+ /** S3 key of a run's JSONL event stream. */
1558
+ function deploymentEventsKey(prefix, stackName, region, runId) {
1559
+ return `${prefix}/${stackName}/${region}/deployments/${runId}.jsonl`;
1560
+ }
1561
+ /** S3 key of a stack's run index. */
1562
+ function deploymentEventsIndexKey(prefix, stackName, region) {
1563
+ return `${prefix}/${stackName}/${region}/deployments/index.json`;
1564
+ }
1565
+ /**
1566
+ * Buffering JSONL writer for one deployment run. Implements the
1567
+ * {@link DeploymentEventRecorder} seam the deploy engine / destroy
1568
+ * runner emit through.
1569
+ */
1570
+ var DeploymentEventsStore = class {
1571
+ logger = getLogger().child("DeploymentEvents");
1572
+ backend;
1573
+ stackName;
1574
+ region;
1575
+ command;
1576
+ runId;
1577
+ cdkdVersion;
1578
+ startedAt;
1579
+ /** All events recorded so far (the full JSONL body is re-PUT per flush —
1580
+ * S3 has no append, and metadata-only events are small). */
1581
+ events = [];
1582
+ /** Number of events already persisted by the last successful flush. */
1583
+ persistedCount = 0;
1584
+ flushTimer;
1585
+ /** Serializes S3 writes so flushes never interleave. */
1586
+ writeChain = Promise.resolve();
1587
+ warnedOnce = false;
1588
+ finalized = false;
1589
+ constructor(backend, options) {
1590
+ this.backend = backend;
1591
+ this.stackName = options.stackName;
1592
+ this.region = options.region;
1593
+ this.command = options.command;
1594
+ this.runId = options.runId ?? newDeploymentRunId();
1595
+ this.cdkdVersion = options.cdkdVersion ?? getCdkdVersion();
1596
+ this.startedAt = (/* @__PURE__ */ new Date()).toISOString();
1597
+ }
1598
+ /**
1599
+ * Buffer one event (synchronous, never throws). The timestamp is
1600
+ * stamped here so emitters don't need to.
1601
+ */
1602
+ record(event) {
1603
+ try {
1604
+ if (this.finalized) return;
1605
+ this.events.push({
1606
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1607
+ ...event
1608
+ });
1609
+ if (this.events.length - this.persistedCount >= FLUSH_EVENT_THRESHOLD) this.scheduleFlush(0);
1610
+ else this.scheduleFlush(FLUSH_INTERVAL_MS);
1611
+ } catch {}
1612
+ }
1613
+ /**
1614
+ * Final flush + index update. Called by the owner (deploy CLI per-stack
1615
+ * finally / destroy runner finally) after the run reaches a terminal
1616
+ * state. Best-effort: never throws.
1617
+ */
1618
+ async finalize(result) {
1619
+ if (this.finalized) return;
1620
+ this.finalized = true;
1621
+ if (this.flushTimer) {
1622
+ clearTimeout(this.flushTimer);
1623
+ this.flushTimer = void 0;
1624
+ }
1625
+ if (this.events.length === 0) return;
1626
+ await this.enqueueWrite(async () => {
1627
+ await this.doFlush();
1628
+ await this.updateIndex(result);
1629
+ });
1630
+ }
1631
+ /** Await any in-flight async flushes (used by tests). */
1632
+ async drain() {
1633
+ await this.writeChain;
1634
+ }
1635
+ scheduleFlush(delayMs) {
1636
+ if (this.flushTimer) {
1637
+ if (delayMs > 0) return;
1638
+ clearTimeout(this.flushTimer);
1639
+ }
1640
+ this.flushTimer = setTimeout(() => {
1641
+ this.flushTimer = void 0;
1642
+ this.enqueueWrite(() => this.doFlush());
1643
+ }, delayMs);
1644
+ this.flushTimer.unref?.();
1645
+ }
1646
+ enqueueWrite(op) {
1647
+ const next = this.writeChain.then(op).catch((err) => {
1648
+ this.warnOnce(`Failed to persist deployment events for run ${this.runId}: ${err instanceof Error ? err.message : String(err)}`);
1649
+ });
1650
+ this.writeChain = next;
1651
+ return next;
1652
+ }
1653
+ async doFlush() {
1654
+ if (this.events.length === 0 || this.events.length === this.persistedCount) return;
1655
+ const snapshotCount = this.events.length;
1656
+ const body = this.events.slice(0, snapshotCount).map((e) => JSON.stringify(e)).join("\n");
1657
+ await this.backend.putRawObject(deploymentEventsKey(this.backend.prefix, this.stackName, this.region, this.runId), body + "\n", "application/x-ndjson");
1658
+ this.persistedCount = snapshotCount;
1659
+ }
1660
+ /**
1661
+ * Prepend this run's summary to `deployments/index.json`, truncated to
1662
+ * the last {@link DEPLOYMENT_EVENTS_MAX_INDEX_RUNS} runs. Read-modify-
1663
+ * write WITHOUT optimistic locking — last-writer-wins (documented
1664
+ * trade-off; the per-run `.jsonl` files are the source of truth).
1665
+ */
1666
+ async updateIndex(result) {
1667
+ const key = deploymentEventsIndexKey(this.backend.prefix, this.stackName, this.region);
1668
+ let existingRuns = [];
1669
+ try {
1670
+ const raw = await this.backend.getRawObject(key);
1671
+ if (raw !== null) {
1672
+ const parsed = JSON.parse(raw);
1673
+ if (Array.isArray(parsed.runs)) existingRuns = parsed.runs;
1674
+ }
1675
+ } catch (err) {
1676
+ this.logger.debug(`Deployment-events index unreadable, rewriting: ${err instanceof Error ? err.message : String(err)}`);
1677
+ }
1678
+ const runs = [{
1679
+ runId: this.runId,
1680
+ command: this.command,
1681
+ cdkdVersion: this.cdkdVersion,
1682
+ startedAt: this.startedAt,
1683
+ finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
1684
+ result,
1685
+ eventCount: this.persistedCount
1686
+ }, ...existingRuns.filter((r) => r.runId !== this.runId)].slice(0, 20);
1687
+ const file = {
1688
+ indexVersion: 1,
1689
+ stackName: this.stackName,
1690
+ region: this.region,
1691
+ runs,
1692
+ lastModified: Date.now()
1693
+ };
1694
+ await this.backend.putRawObject(key, JSON.stringify(file, null, 2));
1695
+ }
1696
+ warnOnce(message) {
1697
+ if (this.warnedOnce) {
1698
+ this.logger.debug(message);
1699
+ return;
1700
+ }
1701
+ this.warnedOnce = true;
1702
+ this.logger.warn(`${message} — continuing; deployment events are best-effort and never block the run.`);
1703
+ }
1704
+ };
1705
+ /**
1706
+ * Read side for `cdkd events`: discovers regions / runs / event streams
1707
+ * under `{prefix}/{stackName}/{region}/deployments/`. Region discovery
1708
+ * deliberately does NOT rely on state.json — event files survive
1709
+ * `cdkd destroy`, so a destroyed stack's runs stay readable.
1710
+ */
1711
+ var DeploymentEventsReader = class {
1712
+ backend;
1713
+ constructor(backend) {
1714
+ this.backend = backend;
1715
+ }
1716
+ /**
1717
+ * Regions that have a `deployments/` index or event stream for the
1718
+ * stack. Derived from the raw key listing under `{prefix}/{stackName}/`.
1719
+ */
1720
+ async listRegions(stackName) {
1721
+ const prefix = `${this.backend.prefix}/${stackName}/`;
1722
+ const keys = await this.backend.listRawKeys(prefix);
1723
+ const regions = /* @__PURE__ */ new Set();
1724
+ for (const key of keys) {
1725
+ const segments = key.slice(prefix.length).split("/");
1726
+ if (segments.length === 3 && segments[1] === "deployments" && segments[0]) regions.add(segments[0]);
1727
+ }
1728
+ return [...regions].sort();
1729
+ }
1730
+ /**
1731
+ * Run summaries for `(stackName, region)`, newest first. Returns the
1732
+ * index file's `runs` (already newest-first); when the index is missing
1733
+ * or unreadable, falls back to enumerating `{runId}.jsonl` keys (sorted
1734
+ * descending — runIds are time-prefixed).
1735
+ *
1736
+ * In the fallback path the run's terminal result / command / version are
1737
+ * NOT known from the key alone, so each run's JSONL is read and its last
1738
+ * `RUN_FINISHED` (+ first `RUN_STARTED`) event mined for the true result,
1739
+ * command, version, and timestamps. A run whose JSONL has no terminal
1740
+ * `RUN_FINISHED` (interrupted run, or one whose index write lost the
1741
+ * race) is reported as `'UNKNOWN'` — it is NEVER fabricated as `'FAILED'`,
1742
+ * which would mislabel a successful run that merely failed to update the
1743
+ * derived index.
1744
+ */
1745
+ async listRuns(stackName, region) {
1746
+ const key = deploymentEventsIndexKey(this.backend.prefix, stackName, region);
1747
+ try {
1748
+ const raw = await this.backend.getRawObject(key);
1749
+ if (raw !== null) {
1750
+ const parsed = JSON.parse(raw);
1751
+ if (Array.isArray(parsed.runs)) return parsed.runs;
1752
+ }
1753
+ } catch {}
1754
+ const dirPrefix = `${this.backend.prefix}/${stackName}/${region}/deployments/`;
1755
+ const runIds = (await this.backend.listRawKeys(dirPrefix)).filter((k) => k.endsWith(".jsonl")).map((k) => k.slice(dirPrefix.length, -6)).sort().reverse();
1756
+ return Promise.all(runIds.map((runId) => this.summarizeRunFromJsonl(stackName, region, runId)));
1757
+ }
1758
+ /**
1759
+ * Reconstruct a {@link DeploymentRunSummary} for the index-fallback path
1760
+ * by reading the run's JSONL. Derives the true result from the last
1761
+ * `RUN_FINISHED` event; absent that, returns `'UNKNOWN'` rather than
1762
+ * fabricating a definitive failure.
1763
+ */
1764
+ async summarizeRunFromJsonl(stackName, region, runId) {
1765
+ const fallback = {
1766
+ runId,
1767
+ command: "deploy",
1768
+ cdkdVersion: "unknown",
1769
+ startedAt: "",
1770
+ finishedAt: "",
1771
+ result: "UNKNOWN",
1772
+ eventCount: 0
1773
+ };
1774
+ let events = null;
1775
+ try {
1776
+ events = await this.readRunEvents(stackName, region, runId);
1777
+ } catch {
1778
+ return fallback;
1779
+ }
1780
+ if (events === null || events.length === 0) return fallback;
1781
+ const started = events.find((e) => e.eventType === "RUN_STARTED");
1782
+ let finished;
1783
+ for (const e of events) if (e.eventType === "RUN_FINISHED") finished = e;
1784
+ return {
1785
+ runId,
1786
+ command: started?.command ?? fallback.command,
1787
+ cdkdVersion: started?.cdkdVersion ?? fallback.cdkdVersion,
1788
+ startedAt: started?.timestamp ?? fallback.startedAt,
1789
+ finishedAt: finished?.timestamp ?? fallback.finishedAt,
1790
+ result: finished?.result ?? "UNKNOWN",
1791
+ eventCount: events.length
1792
+ };
1793
+ }
1794
+ /**
1795
+ * Parse one run's JSONL event stream. Returns `null` when the run file
1796
+ * does not exist. Malformed lines are skipped (a torn final line from
1797
+ * an interrupted flush must not hide the rest of the stream).
1798
+ */
1799
+ async readRunEvents(stackName, region, runId) {
1800
+ const key = deploymentEventsKey(this.backend.prefix, stackName, region, runId);
1801
+ const raw = await this.backend.getRawObject(key);
1802
+ if (raw === null) return null;
1803
+ const events = [];
1804
+ for (const line of raw.split("\n")) {
1805
+ const trimmed = line.trim();
1806
+ if (!trimmed) continue;
1807
+ try {
1808
+ events.push(JSON.parse(trimmed));
1809
+ } catch {}
1810
+ }
1811
+ return events;
1812
+ }
1813
+ };
1814
+
1815
+ //#endregion
1816
+ //#region src/cli/commands/deployment-events-run.ts
1817
+ /**
1818
+ * Run-level deployment-event bracket helpers (issue #808).
1819
+ *
1820
+ * The per-resource + rollback events are emitted by `DeployEngine` /
1821
+ * `destroy-runner.ts`; the RUN-level events (`RUN_STARTED` /
1822
+ * `RUN_FINISHED`) and the recorder lifecycle (`finalize()` in a `finally`)
1823
+ * are owned by the `cdkd deploy` / `cdkd destroy` CLIs. That run-level
1824
+ * bracket was originally inline in `deploy.ts` / `destroy.ts` and could
1825
+ * only be exercised through the full synth → STS → work-graph pipeline.
1826
+ *
1827
+ * These thin helpers extract the bracket so it is directly unit-testable
1828
+ * (and shared between the two commands), with the exact contract:
1829
+ *
1830
+ * - **`--dry-run` creates NO recorder** (no events at all for a dry run).
1831
+ * - `RUN_STARTED` is emitted the moment the recorder is created.
1832
+ * - A successful run emits `RUN_FINISHED { result: 'SUCCEEDED', counts }`.
1833
+ * - A failed run emits `RUN_FINISHED { result: 'FAILED', error }` via
1834
+ * {@link extractDeploymentEventError} (error metadata only — no props).
1835
+ * - `finalize(result)` is always called (caller's `finally`).
1836
+ *
1837
+ * All of this is best-effort: the recorder's `record()` / `finalize()`
1838
+ * never throw, so these helpers never need their own try/catch.
1839
+ */
1840
+ /**
1841
+ * Create a {@link DeploymentEventsStore} and immediately emit its
1842
+ * `RUN_STARTED` event. Returns `undefined` under `--dry-run` (no recorder,
1843
+ * no events). The caller wires the returned recorder into the engine /
1844
+ * runner and `finalize()`s it in a `finally`.
1845
+ */
1846
+ function startRunRecorder(args) {
1847
+ if (args.dryRun) return void 0;
1848
+ const recorder = new DeploymentEventsStore(args.backend, {
1849
+ stackName: args.stackName,
1850
+ region: args.region,
1851
+ command: args.command,
1852
+ ...args.runId !== void 0 && { runId: args.runId },
1853
+ ...args.cdkdVersion !== void 0 && { cdkdVersion: args.cdkdVersion }
1854
+ });
1855
+ recorder.record({
1856
+ eventType: "RUN_STARTED",
1857
+ stackName: args.stackName,
1858
+ command: args.command,
1859
+ region: args.region,
1860
+ cdkdVersion: recorder.cdkdVersion
1861
+ });
1862
+ return recorder;
1863
+ }
1864
+ /**
1865
+ * Emit a success `RUN_FINISHED`. No-op when `recorder` is `undefined`
1866
+ * (dry-run / older state). `durationMs` is optional (destroy does not
1867
+ * carry one at the run level).
1868
+ */
1869
+ function recordRunSucceeded(recorder, stackName, counts, durationMs) {
1870
+ recorder?.record({
1871
+ eventType: "RUN_FINISHED",
1872
+ stackName,
1873
+ result: "SUCCEEDED",
1874
+ ...durationMs !== void 0 && { durationMs },
1875
+ counts
1876
+ });
1877
+ }
1878
+ /**
1879
+ * Emit a failure `RUN_FINISHED` carrying the extracted error metadata.
1880
+ * No-op when `recorder` is `undefined`.
1881
+ */
1882
+ function recordRunFailed(recorder, stackName, error) {
1883
+ recorder?.record({
1884
+ eventType: "RUN_FINISHED",
1885
+ stackName,
1886
+ result: "FAILED",
1887
+ error: extractDeploymentEventError(error)
1888
+ });
1889
+ }
1890
+
1510
1891
  //#endregion
1511
1892
  //#region src/state/export-index-store.ts
1512
1893
  /**
@@ -33932,10 +34313,27 @@ async function runDestroyForStack(stackName, state, ctx) {
33932
34313
  if (shouldRetainResource(resource.deletionPolicy)) {
33933
34314
  logger.info(` ⊘ ${logicalId} (${resource.resourceType}) retained — DeletionPolicy: ${resource.deletionPolicy}`);
33934
34315
  result.retainedCount++;
34316
+ ctx.eventRecorder?.record({
34317
+ eventType: "RESOURCE_RETAINED",
34318
+ stackName,
34319
+ operation: "DELETE",
34320
+ logicalId,
34321
+ resourceType: resource.resourceType,
34322
+ ...resource.provisionedBy && { provisionedBy: resource.provisionedBy }
34323
+ });
33935
34324
  return;
33936
34325
  }
33937
34326
  const baseLabel = `Deleting ${logicalId} (${resource.resourceType})`;
33938
34327
  renderer.addTask(logicalId, baseLabel);
34328
+ const resourceStartedAt = Date.now();
34329
+ ctx.eventRecorder?.record({
34330
+ eventType: "RESOURCE_STARTED",
34331
+ stackName,
34332
+ operation: "DELETE",
34333
+ logicalId,
34334
+ resourceType: resource.resourceType,
34335
+ ...resource.provisionedBy && { provisionedBy: resource.provisionedBy }
34336
+ });
33939
34337
  try {
33940
34338
  const provider = destroyProviderRegistry.getProviderFor({
33941
34339
  resourceType: resource.resourceType,
@@ -33979,6 +34377,16 @@ async function runDestroyForStack(stackName, state, ctx) {
33979
34377
  renderer.removeTask(logicalId);
33980
34378
  logger.info(` ${formatResourceLine("deleted", logicalId, resource.resourceType)}`);
33981
34379
  result.deletedCount++;
34380
+ ctx.eventRecorder?.record({
34381
+ eventType: "RESOURCE_SUCCEEDED",
34382
+ stackName,
34383
+ operation: "DELETE",
34384
+ logicalId,
34385
+ resourceType: resource.resourceType,
34386
+ ...resource.provisionedBy && { provisionedBy: resource.provisionedBy },
34387
+ ...resource.physicalId && { physicalId: resource.physicalId },
34388
+ durationMs: Date.now() - resourceStartedAt
34389
+ });
33982
34390
  delete remainingResources[logicalId];
33983
34391
  persistStateAfterDelete(logicalId);
33984
34392
  } catch (error) {
@@ -33987,15 +34395,45 @@ async function runDestroyForStack(stackName, state, ctx) {
33987
34395
  if (msg.includes("does not exist") || msg.includes("not found") || msg.includes("No policy found") || msg.includes("NoSuchEntity") || msg.includes("NotFoundException")) {
33988
34396
  logger.debug(` ${logicalId} already deleted, removing from state`);
33989
34397
  result.deletedCount++;
34398
+ ctx.eventRecorder?.record({
34399
+ eventType: "RESOURCE_SUCCEEDED",
34400
+ stackName,
34401
+ operation: "DELETE",
34402
+ logicalId,
34403
+ resourceType: resource.resourceType,
34404
+ ...resource.provisionedBy && { provisionedBy: resource.provisionedBy },
34405
+ ...resource.physicalId && { physicalId: resource.physicalId },
34406
+ durationMs: Date.now() - resourceStartedAt
34407
+ });
33990
34408
  delete remainingResources[logicalId];
33991
34409
  persistStateAfterDelete(logicalId);
33992
34410
  } else if (error instanceof ResourceTimeoutError) {
33993
34411
  const wrapped = new ProvisioningError(error.message, resource.resourceType, logicalId, resource.physicalId, error);
33994
34412
  logger.error(` ✗ Failed to delete ${logicalId}:`, wrapped.message);
33995
34413
  result.errorCount++;
34414
+ ctx.eventRecorder?.record({
34415
+ eventType: "RESOURCE_FAILED",
34416
+ stackName,
34417
+ operation: "DELETE",
34418
+ logicalId,
34419
+ resourceType: resource.resourceType,
34420
+ ...resource.provisionedBy && { provisionedBy: resource.provisionedBy },
34421
+ durationMs: Date.now() - resourceStartedAt,
34422
+ error: extractDeploymentEventError(wrapped)
34423
+ });
33996
34424
  } else {
33997
34425
  logger.error(` ✗ Failed to delete ${logicalId}:`, String(error));
33998
34426
  result.errorCount++;
34427
+ ctx.eventRecorder?.record({
34428
+ eventType: "RESOURCE_FAILED",
34429
+ stackName,
34430
+ operation: "DELETE",
34431
+ logicalId,
34432
+ resourceType: resource.resourceType,
34433
+ ...resource.provisionedBy && { provisionedBy: resource.provisionedBy },
34434
+ durationMs: Date.now() - resourceStartedAt,
34435
+ error: extractDeploymentEventError(error)
34436
+ });
33999
34437
  }
34000
34438
  } finally {
34001
34439
  renderer.removeTask(logicalId);
@@ -35097,6 +35535,14 @@ async function deployCommand(stacks, options) {
35097
35535
  stackProviderRegistry.setCustomResourceResponseBucket(stateBucket, baseRegion);
35098
35536
  if (options.allowUnsupportedTypes?.length) stackProviderRegistry.allowUnsupportedTypes(options.allowUnsupportedTypes);
35099
35537
  if (options.allowUnsupportedProperties?.length) stackProviderRegistry.allowUnsupportedProperties(options.allowUnsupportedProperties);
35538
+ const eventRecorder = startRunRecorder({
35539
+ backend: stackStateBackend,
35540
+ stackName: stackInfo.stackName,
35541
+ region: stackRegion,
35542
+ command: "deploy",
35543
+ dryRun: options.dryRun
35544
+ });
35545
+ let runResult = "SUCCEEDED";
35100
35546
  try {
35101
35547
  if (skipPrefix) {
35102
35548
  const existing = await stackStateBackend.getState(stackInfo.stackName, stackRegion);
@@ -35152,6 +35598,7 @@ async function deployCommand(stacks, options) {
35152
35598
  concurrency: options.concurrency,
35153
35599
  dryRun: options.dryRun,
35154
35600
  noRollback: !options.rollback,
35601
+ ...eventRecorder && { eventRecorder },
35155
35602
  ...recreateViaCcApiTargets && recreateViaCcApiTargets.size > 0 && { recreateViaCcApiTargets },
35156
35603
  ...recreateViaSdkProviderTargets && recreateViaSdkProviderTargets.size > 0 && { recreateViaSdkProviderTargets },
35157
35604
  captureObservedState: resolveCaptureObservedState(options.captureObservedState),
@@ -35189,7 +35636,17 @@ async function deployCommand(stacks, options) {
35189
35636
  }
35190
35637
  if (options.dryRun) logger.info(`\n${green("✓")} ${bold("Dry run completed")} - no actual changes made`);
35191
35638
  else logger.info(`\n${green("✓")} ${bold("Deployment completed successfully")}`);
35639
+ recordRunSucceeded(eventRecorder, stackInfo.stackName, {
35640
+ created: deployResult.created,
35641
+ updated: deployResult.updated,
35642
+ deleted: deployResult.deleted
35643
+ }, deployResult.durationMs);
35644
+ } catch (deployError) {
35645
+ runResult = "FAILED";
35646
+ recordRunFailed(eventRecorder, stackInfo.stackName, deployError);
35647
+ throw deployError;
35192
35648
  } finally {
35649
+ if (eventRecorder) await eventRecorder.finalize(runResult);
35193
35650
  stackAwsClients.destroy();
35194
35651
  stateS3Client.destroy();
35195
35652
  switchRegion(baseRegion);
@@ -37691,42 +38148,70 @@ async function destroyCommand(stackArgs, options) {
37691
38148
  totalErrors++;
37692
38149
  continue;
37693
38150
  }
37694
- const result = await withNestedStackContext({
37695
- stateBackend,
37696
- lockManager,
37697
- providerRegistry,
37698
- parentStackName: stackName,
37699
- parentRegion: stackTargetRegion,
37700
- accountId,
37701
- awsClients,
37702
- stateBucket,
37703
- exportIndexStore,
37704
- destroyOptions: {
38151
+ const eventRecorder = startRunRecorder({
38152
+ backend: stateBackend,
38153
+ stackName,
38154
+ region: stackTargetRegion,
38155
+ command: "destroy"
38156
+ });
38157
+ let destroyRunResult = "SUCCEEDED";
38158
+ try {
38159
+ const result = await withNestedStackContext({
38160
+ stateBackend,
38161
+ lockManager,
38162
+ providerRegistry,
38163
+ parentStackName: stackName,
38164
+ parentRegion: stackTargetRegion,
38165
+ accountId,
38166
+ awsClients,
38167
+ stateBucket,
38168
+ exportIndexStore,
38169
+ destroyOptions: {
38170
+ ...options.profile && { profile: options.profile },
38171
+ ...options.removeProtection === true && { removeProtection: true },
38172
+ ...options.resourceWarnAfter?.globalMs !== void 0 && { resourceWarnAfterMs: options.resourceWarnAfter.globalMs },
38173
+ ...options.resourceTimeout?.globalMs !== void 0 && { resourceTimeoutMs: options.resourceTimeout.globalMs },
38174
+ ...options.resourceWarnAfter?.perTypeMs && { resourceWarnAfterByType: options.resourceWarnAfter.perTypeMs },
38175
+ ...options.resourceTimeout?.perTypeMs && { resourceTimeoutByType: options.resourceTimeout.perTypeMs }
38176
+ }
38177
+ }, () => runDestroyForStack(stackName, stateResult.state, {
38178
+ stateBackend,
38179
+ lockManager,
38180
+ providerRegistry,
38181
+ baseAwsClients: awsClients,
38182
+ baseRegion: region,
37705
38183
  ...options.profile && { profile: options.profile },
37706
- ...options.removeProtection === true && { removeProtection: true },
38184
+ stateBucket,
38185
+ skipConfirmation: options.yes || options.force,
38186
+ removeProtection: options.removeProtection === true,
38187
+ exportIndexStore,
38188
+ ...options.allowUnsupportedTypes?.length && { allowUnsupportedTypes: options.allowUnsupportedTypes },
37707
38189
  ...options.resourceWarnAfter?.globalMs !== void 0 && { resourceWarnAfterMs: options.resourceWarnAfter.globalMs },
37708
38190
  ...options.resourceTimeout?.globalMs !== void 0 && { resourceTimeoutMs: options.resourceTimeout.globalMs },
37709
38191
  ...options.resourceWarnAfter?.perTypeMs && { resourceWarnAfterByType: options.resourceWarnAfter.perTypeMs },
37710
- ...options.resourceTimeout?.perTypeMs && { resourceTimeoutByType: options.resourceTimeout.perTypeMs }
37711
- }
37712
- }, () => runDestroyForStack(stackName, stateResult.state, {
37713
- stateBackend,
37714
- lockManager,
37715
- providerRegistry,
37716
- baseAwsClients: awsClients,
37717
- baseRegion: region,
37718
- ...options.profile && { profile: options.profile },
37719
- stateBucket,
37720
- skipConfirmation: options.yes || options.force,
37721
- removeProtection: options.removeProtection === true,
37722
- exportIndexStore,
37723
- ...options.allowUnsupportedTypes?.length && { allowUnsupportedTypes: options.allowUnsupportedTypes },
37724
- ...options.resourceWarnAfter?.globalMs !== void 0 && { resourceWarnAfterMs: options.resourceWarnAfter.globalMs },
37725
- ...options.resourceTimeout?.globalMs !== void 0 && { resourceTimeoutMs: options.resourceTimeout.globalMs },
37726
- ...options.resourceWarnAfter?.perTypeMs && { resourceWarnAfterByType: options.resourceWarnAfter.perTypeMs },
37727
- ...options.resourceTimeout?.perTypeMs && { resourceTimeoutByType: options.resourceTimeout.perTypeMs }
37728
- }));
37729
- totalErrors += result.errorCount;
38192
+ ...options.resourceTimeout?.perTypeMs && { resourceTimeoutByType: options.resourceTimeout.perTypeMs },
38193
+ eventRecorder
38194
+ }));
38195
+ totalErrors += result.errorCount;
38196
+ destroyRunResult = result.errorCount > 0 ? "FAILED" : "SUCCEEDED";
38197
+ eventRecorder.record({
38198
+ eventType: "RUN_FINISHED",
38199
+ stackName,
38200
+ result: destroyRunResult,
38201
+ counts: {
38202
+ created: 0,
38203
+ updated: 0,
38204
+ deleted: result.deletedCount,
38205
+ ...result.errorCount > 0 && { failed: result.errorCount }
38206
+ }
38207
+ });
38208
+ } catch (destroyError) {
38209
+ destroyRunResult = "FAILED";
38210
+ recordRunFailed(eventRecorder, stackName, destroyError);
38211
+ throw destroyError;
38212
+ } finally {
38213
+ await eventRecorder.finalize(destroyRunResult);
38214
+ }
37730
38215
  }
37731
38216
  if (totalErrors > 0) throw new PartialFailureError(`Destroy completed with ${totalErrors} resource error(s). State preserved — inspect 'cdkd state show <stack>' and re-run 'cdkd destroy' to retry.`);
37732
38217
  } finally {
@@ -37751,6 +38236,147 @@ function createDestroyCommand() {
37751
38236
  return cmd;
37752
38237
  }
37753
38238
 
38239
+ //#endregion
38240
+ //#region src/cli/commands/events.ts
38241
+ /**
38242
+ * `cdkd events <stack>` — read back the structured deployment events
38243
+ * (issue #808) cdkd persists per deploy / destroy run, the local
38244
+ * equivalent of CloudFormation's `DescribeStackEvents`.
38245
+ *
38246
+ * Two modes:
38247
+ * - No `--run`: list the recorded runs for the stack, newest first
38248
+ * (runId, command, cdkd version, result, started/finished, event count).
38249
+ * - `--run <id>`: print the full ordered event stream for that one run.
38250
+ *
38251
+ * `--format json` (alias `--json`) emits machine-readable JSON for tooling
38252
+ * / AI-agent hand-off. Events survive `cdkd destroy` (they live under a
38253
+ * separate `deployments/` key family from `state.json`), so a destroyed
38254
+ * stack's failure history stays readable.
38255
+ */
38256
+ async function eventsCommand(stackName, options) {
38257
+ const logger = getLogger();
38258
+ if (options.verbose) logger.setLevel("debug");
38259
+ warnIfDeprecatedRegion(options);
38260
+ const asJson = options.json === true;
38261
+ const awsClients = new AwsClients({
38262
+ ...options.region && { region: options.region },
38263
+ ...options.profile && { profile: options.profile }
38264
+ });
38265
+ setAwsClients(awsClients);
38266
+ try {
38267
+ const region = options.region || process.env["AWS_REGION"] || "us-east-1";
38268
+ const bucket = await resolveStateBucketWithDefault(options.stateBucket, region);
38269
+ const prefix = options.statePrefix ?? "cdkd";
38270
+ const stateBackend = new S3StateBackend(awsClients.s3, {
38271
+ bucket,
38272
+ prefix
38273
+ }, {
38274
+ region,
38275
+ ...options.profile && { profile: options.profile }
38276
+ });
38277
+ await stateBackend.verifyBucketExists();
38278
+ const reader = new DeploymentEventsReader(stateBackend);
38279
+ const targetRegion = await resolveEventsRegion(reader, stackName, options.stackRegion);
38280
+ if (options.run) {
38281
+ const events = await reader.readRunEvents(stackName, targetRegion, options.run);
38282
+ if (events === null) throw new CdkdError(`No deployment-event stream found for run '${options.run}' of stack '${stackName}' in region '${targetRegion}'.`, "EVENTS_RUN_NOT_FOUND");
38283
+ if (asJson) {
38284
+ process.stdout.write(JSON.stringify(events, null, 2) + "\n");
38285
+ return;
38286
+ }
38287
+ printRunEvents(stackName, targetRegion, options.run, events);
38288
+ return;
38289
+ }
38290
+ const runs = await reader.listRuns(stackName, targetRegion);
38291
+ if (asJson) {
38292
+ process.stdout.write(JSON.stringify({
38293
+ stackName,
38294
+ region: targetRegion,
38295
+ runs
38296
+ }, null, 2) + "\n");
38297
+ return;
38298
+ }
38299
+ printRunList(stackName, targetRegion, runs);
38300
+ } finally {
38301
+ awsClients.destroy();
38302
+ }
38303
+ }
38304
+ /**
38305
+ * Pick the region whose `deployments/` key family holds this stack's run
38306
+ * history. When `--stack-region` is supplied it is honored verbatim;
38307
+ * otherwise the single discovered region is used, and an ambiguous (>1)
38308
+ * or missing (0) history surfaces an actionable error.
38309
+ */
38310
+ async function resolveEventsRegion(reader, stackName, explicitRegion) {
38311
+ if (explicitRegion) return explicitRegion;
38312
+ const regions = await reader.listRegions(stackName);
38313
+ if (regions.length === 0) throw new CdkdError(`No deployment-event history found for stack '${stackName}'. Events are recorded by 'cdkd deploy' / 'cdkd destroy' (issue #808); a stack deployed by an older cdkd version has none.`, "EVENTS_NOT_FOUND");
38314
+ if (regions.length > 1) throw new CdkdError(`Stack '${stackName}' has deployment-event history in multiple regions: ${regions.join(", ")}. Re-run with '--stack-region <region>' to disambiguate.`, "EVENTS_REGION_AMBIGUOUS");
38315
+ return regions[0];
38316
+ }
38317
+ /** Human-readable run listing (newest first). */
38318
+ function printRunList(stackName, region, runs) {
38319
+ const logger = getLogger();
38320
+ logger.info(`${bold("Deployment runs for")} ${cyan(stackName)} ${gray(`(${region})`)}`);
38321
+ if (runs.length === 0) {
38322
+ logger.info(gray(" (no runs recorded)"));
38323
+ return;
38324
+ }
38325
+ for (const run of runs) {
38326
+ const resultColored = run.result === "SUCCEEDED" ? green(run.result) : run.result === "UNKNOWN" ? gray(run.result) : red(run.result);
38327
+ logger.info(` ${cyan(run.runId)} ${run.command} ${resultColored} ${gray(run.startedAt || "?")} -> ${gray(run.finishedAt || "?")} ${gray(`cdkd ${run.cdkdVersion}`)} ${gray(`${run.eventCount} events`)}`);
38328
+ }
38329
+ logger.info(gray(`\nUse 'cdkd events ${stackName} --run <runId>' to read one run's events.`));
38330
+ }
38331
+ /** Human-readable single-run event stream (in recorded order). */
38332
+ function printRunEvents(stackName, region, runId, events) {
38333
+ const logger = getLogger();
38334
+ logger.info(`${bold("Events for run")} ${cyan(runId)} ${gray(`(${stackName}, ${region})`)}`);
38335
+ if (events.length === 0) {
38336
+ logger.info(gray(" (no events)"));
38337
+ return;
38338
+ }
38339
+ for (const e of events) {
38340
+ const parts = [gray(e.timestamp), colorizeEventType(e.eventType)];
38341
+ if (e.logicalId) parts.push(`${e.logicalId}${e.resourceType ? ` (${e.resourceType})` : ""}`);
38342
+ if (e.operation) parts.push(gray(e.operation));
38343
+ if (e.provisionedBy) parts.push(gray(`[${e.provisionedBy}]`));
38344
+ if (e.command) parts.push(gray(e.command));
38345
+ if (e.region) parts.push(gray(e.region));
38346
+ if (e.cdkdVersion) parts.push(gray(`cdkd ${e.cdkdVersion}`));
38347
+ if (e.result) parts.push(e.result === "SUCCEEDED" ? green(e.result) : red(e.result));
38348
+ if (typeof e.durationMs === "number") parts.push(gray(`${e.durationMs}ms`));
38349
+ if (e.counts) parts.push(gray(`+${e.counts.created}/~${e.counts.updated}/-${e.counts.deleted}` + (e.counts.failed ? ` !${e.counts.failed}` : "")));
38350
+ logger.info(` ${parts.join(" ")}`);
38351
+ if (e.error) {
38352
+ const code = e.error.awsErrorCode ? ` (${e.error.awsErrorCode})` : "";
38353
+ const reqId = e.error.requestId ? gray(` requestId=${e.error.requestId}`) : "";
38354
+ logger.info(` ${red(`${e.error.name}${code}: ${e.error.message}`)}${reqId}`);
38355
+ }
38356
+ }
38357
+ }
38358
+ /** Color the event-type token by its lifecycle phase. */
38359
+ function colorizeEventType(eventType) {
38360
+ if (eventType.endsWith("FAILED")) return red(eventType);
38361
+ if (eventType.endsWith("SUCCEEDED") || eventType === "RUN_FINISHED") return green(eventType);
38362
+ if (eventType.startsWith("ROLLBACK")) return yellow(eventType);
38363
+ return cyan(eventType);
38364
+ }
38365
+ /**
38366
+ * Create the `cdkd events` command.
38367
+ */
38368
+ function createEventsCommand() {
38369
+ const cmd = new Command("events").description("Read back structured deployment events (cdkd's DescribeStackEvents equivalent, issue #808)").argument("<stack>", "Stack name (physical CloudFormation name)").option("--run <runId>", "Read a single run's full event stream instead of the run listing").option("--stack-region <region>", "Disambiguate a stack with event history in multiple regions").option("--json", "Output as JSON", false).option("--format <format>", "Output format ('json' is equivalent to --json)").action(withErrorHandling((stack, options) => {
38370
+ return eventsCommand(stack, {
38371
+ ...options,
38372
+ json: options.json === true || options.format === "json"
38373
+ });
38374
+ }));
38375
+ [...commonOptions, ...stateOptions].forEach((opt) => cmd.addOption(opt));
38376
+ cmd.addOption(deprecatedRegionOption);
38377
+ return cmd;
38378
+ }
38379
+
37754
38380
  //#endregion
37755
38381
  //#region src/cli/cdk-path.ts
37756
38382
  /**
@@ -52914,7 +53540,7 @@ function reorderArgs(argv) {
52914
53540
  async function main() {
52915
53541
  installPipeCloseHandler();
52916
53542
  const program = new Command();
52917
- program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.219.9");
53543
+ program.name("cdkd").description("CDK Direct - Deploy AWS CDK apps directly via SDK/Cloud Control API").version("0.220.0");
52918
53544
  program.addCommand(createBootstrapCommand());
52919
53545
  program.addCommand(createSynthCommand());
52920
53546
  program.addCommand(createListCommand());
@@ -52922,6 +53548,7 @@ async function main() {
52922
53548
  program.addCommand(createDiffCommand());
52923
53549
  program.addCommand(createDriftCommand());
52924
53550
  program.addCommand(createDestroyCommand());
53551
+ program.addCommand(createEventsCommand());
52925
53552
  program.addCommand(createOrphanCommand());
52926
53553
  program.addCommand(createImportCommand());
52927
53554
  program.addCommand(createPublishAssetsCommand());