@intentius/chant-lexicon-temporal 0.1.12 → 0.1.15

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.
@@ -3,32 +3,32 @@ import { promisify } from "node:util";
3
3
 
4
4
  const execAsync = promisify(exec);
5
5
 
6
- export interface StateSnapshotArgs {
6
+ export interface LifecycleSnapshotArgs {
7
7
  /** Environment name (e.g. "dev", "staging", "prod"). */
8
8
  env: string;
9
9
  }
10
10
 
11
11
  /**
12
- * Take a chant state snapshot for the given environment.
12
+ * Take a chant lifecycle snapshot for the given environment.
13
13
  * Uses fastIdempotent profile — 5m timeout, 3 retries.
14
14
  */
15
- export async function stateSnapshot(args: StateSnapshotArgs): Promise<void> {
16
- const { stdout, stderr } = await execAsync(`chant state snapshot ${args.env}`);
15
+ export async function lifecycleSnapshot(args: LifecycleSnapshotArgs, signal?: AbortSignal): Promise<void> {
16
+ const { stdout, stderr } = await execAsync(`chant lifecycle snapshot ${args.env}`, { signal });
17
17
  if (stdout) console.log(stdout);
18
18
  if (stderr) console.error(stderr);
19
19
  }
20
20
 
21
- export interface StateDiffArgs {
21
+ export interface LifecycleDiffArgs {
22
22
  /** Environment name (e.g. "dev", "staging", "prod"). */
23
23
  env: string;
24
24
  /**
25
- * When true, run `chant state diff <env> --live` (queries cloud APIs).
25
+ * When true, run `chant lifecycle diff <env> --live` (queries cloud APIs).
26
26
  * When false (default), run digest-only diff against the last snapshot.
27
27
  */
28
28
  live?: boolean;
29
29
  }
30
30
 
31
- export interface StateDiffResult {
31
+ export interface LifecycleDiffResult {
32
32
  /** Combined stdout + stderr from the chant command. */
33
33
  output: string;
34
34
  /** Process exit code (0 = success). */
@@ -36,13 +36,13 @@ export interface StateDiffResult {
36
36
  /**
37
37
  * True when the diff output contains any drift indicators
38
38
  * (MISSING / ORPHAN / DRIFTED / DISAPPEARED section headers from
39
- * `chant state diff --live`).
39
+ * `chant lifecycle diff --live`).
40
40
  */
41
41
  drifted: boolean;
42
42
  }
43
43
 
44
44
  /**
45
- * Section headers emitted by `chant state diff --live` that indicate a
45
+ * Section headers emitted by `chant lifecycle diff --live` that indicate a
46
46
  * non-empty drift category. See packages/core/src/cli/handlers/state.ts.
47
47
  */
48
48
  const DRIFT_HEADERS = [
@@ -60,7 +60,7 @@ function detectDrift(output: string): boolean {
60
60
  }
61
61
 
62
62
  /**
63
- * Run `chant state diff <env>` and return the output + structured drift
63
+ * Run `chant lifecycle diff <env>` and return the output + structured drift
64
64
  * flag. Read-only; intended for use inside watch/observation workflows.
65
65
  * Uses fastIdempotent profile.
66
66
  *
@@ -69,10 +69,10 @@ function detectDrift(output: string): boolean {
69
69
  * cli/state.mdx. Pair with `outcomeAttribute: { name: "Drift", from: "drifted" }`
70
70
  * on a WatchOp activity step to surface drift as a workflow search attribute.
71
71
  */
72
- export async function stateDiff(args: StateDiffArgs): Promise<StateDiffResult> {
72
+ export async function lifecycleDiff(args: LifecycleDiffArgs, signal?: AbortSignal): Promise<LifecycleDiffResult> {
73
73
  const liveFlag = args.live ? " --live" : "";
74
74
  try {
75
- const { stdout, stderr } = await execAsync(`chant state diff ${args.env}${liveFlag}`);
75
+ const { stdout, stderr } = await execAsync(`chant lifecycle diff ${args.env}${liveFlag}`, { signal });
76
76
  const output = `${stdout}${stderr}`.trim();
77
77
  if (output) console.log(output);
78
78
  return { output, exitCode: 0, drifted: detectDrift(output) };
@@ -0,0 +1,59 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { reconcilePr, reconcileSummary, reconcileBranchName, entriesFromPlan } from "./reconcile";
3
+
4
+ const entries = [
5
+ { name: "bucket", action: "adopt", type: "AWS::S3::Bucket" },
6
+ { name: "queue", action: "update", type: "AWS::SQS::Queue" },
7
+ ];
8
+
9
+ describe("reconcileBranchName (#122)", () => {
10
+ test("deterministic, slugified per env", () => {
11
+ expect(reconcileBranchName("prod")).toBe("chant/reconcile-prod");
12
+ expect(reconcileBranchName("us-east/1")).toBe("chant/reconcile-us-east-1");
13
+ });
14
+ });
15
+
16
+ describe("reconcileSummary (#122)", () => {
17
+ test("summarizes which entries triggered the reconcile", () => {
18
+ const body = reconcileSummary("prod", entries);
19
+ expect(body).toContain("live environment `prod`");
20
+ expect(body).toContain("| bucket | adopt | AWS::S3::Bucket |");
21
+ expect(body).toContain("| queue | update | AWS::SQS::Queue |");
22
+ });
23
+
24
+ test("handles an empty entry set", () => {
25
+ expect(reconcileSummary("prod", [])).toContain("_(none)_");
26
+ });
27
+ });
28
+
29
+ describe("entriesFromPlan (#123)", () => {
30
+ test("maps a ChangeSet, dropping noop entries", () => {
31
+ const plan = JSON.stringify({
32
+ env: "prod",
33
+ entries: [
34
+ { name: "a", action: "create", type: "T1", evidence: {}, ownership: "unknown" },
35
+ { name: "b", action: "noop", type: "T2", evidence: {}, ownership: "unknown" },
36
+ { name: "c", action: "delete", type: "T3", evidence: {}, ownership: "owned" },
37
+ ],
38
+ });
39
+ expect(entriesFromPlan(plan)).toEqual([
40
+ { name: "a", action: "create", type: "T1" },
41
+ { name: "c", action: "delete", type: "T3" },
42
+ ]);
43
+ });
44
+
45
+ test("tolerates an empty / entry-less plan", () => {
46
+ expect(entriesFromPlan(JSON.stringify({ env: "prod" }))).toEqual([]);
47
+ });
48
+ });
49
+
50
+ describe("reconcilePr report mode (#122)", () => {
51
+ test("returns the summary without any git/network IO", async () => {
52
+ const result = await reconcilePr({ env: "prod", entries, mode: "report" });
53
+ expect(result.mode).toBe("report");
54
+ expect(result.prUrl).toBeUndefined();
55
+ expect(result.branch).toBeUndefined();
56
+ expect(result.summary).toContain("| bucket | adopt | AWS::S3::Bucket |");
57
+ expect(result.entries).toEqual(entries);
58
+ });
59
+ });
@@ -0,0 +1,165 @@
1
+ import { exec } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+
4
+ const execAsync = promisify(exec);
5
+
6
+ /** What the reconcile activity does with the regenerated source. */
7
+ export type ReconcileMode = "pull-request" | "issue" | "report";
8
+
9
+ /** A change-set entry that triggered reconciliation. */
10
+ export interface ReconcileEntry {
11
+ /** chant entity name. */
12
+ name: string;
13
+ /** create | update | delete | adopt | noop (from `chant lifecycle plan`). */
14
+ action: string;
15
+ /** Resource type, when known. */
16
+ type?: string;
17
+ }
18
+
19
+ export interface ReconcilePrArgs {
20
+ /** Environment to reconcile from (passed to `chant import --from`). */
21
+ env: string;
22
+ /**
23
+ * The change-set entries that triggered this reconcile. Omit to derive them
24
+ * from `chant lifecycle plan <env> --json` at run time — the form used inside a
25
+ * workflow, where the entries aren't known until the activity runs.
26
+ */
27
+ entries?: ReconcileEntry[];
28
+ /** What to produce. Default: pull-request. */
29
+ mode?: ReconcileMode;
30
+ /** Output directory for regenerated source. Default: ./infra. */
31
+ output?: string;
32
+ /** Branch to open the PR from. Default: chant/reconcile-<env>. */
33
+ branch?: string;
34
+ /** Restrict live import to chant-owned resources. */
35
+ owned?: boolean;
36
+ /** PR / issue title. Default derived from env. */
37
+ title?: string;
38
+ }
39
+
40
+ export interface ReconcileResult {
41
+ mode: ReconcileMode;
42
+ /** Branch created (pull-request mode). */
43
+ branch?: string;
44
+ /** Opened PR URL (pull-request mode). */
45
+ prUrl?: string;
46
+ /** Opened issue URL (issue mode). */
47
+ issueUrl?: string;
48
+ /** The markdown summary used as the PR/issue body. */
49
+ summary: string;
50
+ /** The entries that triggered the reconcile. */
51
+ entries: ReconcileEntry[];
52
+ }
53
+
54
+ /** Default branch name for a reconcile PR. Deterministic — no timestamp. */
55
+ export function reconcileBranchName(env: string): string {
56
+ const safe = env.replace(/[^a-zA-Z0-9._-]+/g, "-");
57
+ return `chant/reconcile-${safe}`;
58
+ }
59
+
60
+ /**
61
+ * Build the markdown body summarizing which change-set entries triggered the
62
+ * reconcile. Pure — used as the PR/issue body and returned in `report` mode.
63
+ */
64
+ export function reconcileSummary(env: string, entries: ReconcileEntry[]): string {
65
+ const lines = [
66
+ `Reconcile from live environment \`${env}\`.`,
67
+ "",
68
+ "This PR regenerates chant TypeScript from live state to close the gap between the cloud and source. It was triggered by the following change-set entries:",
69
+ "",
70
+ "| Entry | Action | Type |",
71
+ "|---|---|---|",
72
+ ];
73
+ for (const e of entries) {
74
+ lines.push(`| ${e.name} | ${e.action} | ${e.type ?? ""} |`);
75
+ }
76
+ if (entries.length === 0) {
77
+ lines.push("| _(none)_ | | |");
78
+ }
79
+ lines.push("");
80
+ lines.push("Review the diff before merging — live import may surface values that need redaction.");
81
+ return lines.join("\n");
82
+ }
83
+
84
+ function shellQuote(s: string): string {
85
+ return `'${s.replace(/'/g, "'\\''")}'`;
86
+ }
87
+
88
+ /**
89
+ * Map a `chant lifecycle plan --json` ChangeSet to reconcile entries, dropping
90
+ * `noop` entries (nothing to reconcile). Pure — exported for testing.
91
+ */
92
+ export function entriesFromPlan(planJson: string): ReconcileEntry[] {
93
+ const cs = JSON.parse(planJson) as {
94
+ entries?: Array<{ name: string; action: string; type?: string }>;
95
+ };
96
+ return (cs.entries ?? [])
97
+ .filter((e) => e.action !== "noop")
98
+ .map((e) => ({ name: e.name, action: e.action, type: e.type }));
99
+ }
100
+
101
+ /** Derive reconcile entries from `chant lifecycle plan`. */
102
+ async function derivePlanEntries(
103
+ env: string,
104
+ owned: boolean,
105
+ signal?: AbortSignal,
106
+ ): Promise<ReconcileEntry[]> {
107
+ const ownedFlag = owned ? " --owned" : "";
108
+ const { stdout } = await execAsync(
109
+ `chant lifecycle plan ${shellQuote(env)}${ownedFlag} --json`,
110
+ { signal },
111
+ );
112
+ return entriesFromPlan(stdout);
113
+ }
114
+
115
+ /**
116
+ * Reconcile activity: turn regenerated TypeScript into a reviewable artifact.
117
+ *
118
+ * - `report` — return the summary only; no git, no network.
119
+ * - `issue` — open a GitHub issue describing the drift (no code change).
120
+ * - `pull-request` — create a branch, regenerate source via
121
+ * `chant import --from <env>`, commit, push, and open a PR whose diff is the
122
+ * regenerated TypeScript. Never commits to the main branch.
123
+ *
124
+ * Requires `chant` and (for non-report modes) `gh`/`git` in the environment.
125
+ */
126
+ export async function reconcilePr(args: ReconcilePrArgs, signal?: AbortSignal): Promise<ReconcileResult> {
127
+ const mode = args.mode ?? "pull-request";
128
+ const owned = args.owned ?? false;
129
+ const entries = args.entries ?? (await derivePlanEntries(args.env, owned, signal));
130
+ const summary = reconcileSummary(args.env, entries);
131
+ const title = args.title ?? `Reconcile ${args.env}: ${entries.length} change(s) from live`;
132
+
133
+ if (mode === "report") {
134
+ return { mode, summary, entries };
135
+ }
136
+
137
+ if (mode === "issue") {
138
+ const { stdout } = await execAsync(
139
+ `gh issue create --title ${shellQuote(title)} --body ${shellQuote(summary)}`,
140
+ { signal },
141
+ );
142
+ return { mode, summary, entries, issueUrl: stdout.trim() };
143
+ }
144
+
145
+ // pull-request
146
+ const branch = args.branch ?? reconcileBranchName(args.env);
147
+ const output = args.output ?? "./infra";
148
+ const ownedFlag = owned ? " --owned" : "";
149
+
150
+ // Never touch the main branch: cut a fresh branch first.
151
+ await execAsync(`git checkout -b ${shellQuote(branch)}`, { signal });
152
+ await execAsync(
153
+ `chant import --from ${shellQuote(args.env)}${ownedFlag} --output ${shellQuote(output)} --force`,
154
+ { signal },
155
+ );
156
+ await execAsync(`git add ${shellQuote(output)}`, { signal });
157
+ await execAsync(`git commit -m ${shellQuote(title)}`, { signal });
158
+ await execAsync(`git push -u origin ${shellQuote(branch)}`, { signal });
159
+ const { stdout } = await execAsync(
160
+ `gh pr create --title ${shellQuote(title)} --body ${shellQuote(summary)} --head ${shellQuote(branch)}`,
161
+ { signal },
162
+ );
163
+
164
+ return { mode, branch, summary, entries, prUrl: stdout.trim() };
165
+ }
@@ -15,10 +15,11 @@ export interface ShellCmdArgs {
15
15
  * Run an arbitrary shell command.
16
16
  * Uses fastIdempotent profile — 5m timeout, 3 retries.
17
17
  */
18
- export async function shellCmd(args: ShellCmdArgs): Promise<string> {
18
+ export async function shellCmd(args: ShellCmdArgs, signal?: AbortSignal): Promise<string> {
19
19
  const { stdout, stderr } = await execAsync(args.cmd, {
20
20
  cwd: args.cwd,
21
21
  env: { ...process.env, ...args.env },
22
+ signal,
22
23
  });
23
24
  if (stderr) console.error(stderr);
24
25
  return stdout.trim();
@@ -10,11 +10,12 @@ export interface ChantTeardownArgs {
10
10
 
11
11
  /**
12
12
  * Run `chant teardown` in the given project path.
13
- * Uses longInfra profile — 20m timeout, heartbeat every 60s.
13
+ * Uses longInfra profile — 20m timeout.
14
14
  */
15
- export async function chantTeardown(args: ChantTeardownArgs): Promise<void> {
15
+ export async function chantTeardown(args: ChantTeardownArgs, signal?: AbortSignal): Promise<void> {
16
16
  const { stdout, stderr } = await execAsync("npm run teardown", {
17
17
  cwd: args.path,
18
+ signal,
18
19
  });
19
20
  if (stdout) console.log(stdout);
20
21
  if (stderr) console.error(stderr);
@@ -0,0 +1,24 @@
1
+ /** Shared helpers for activity implementations. */
2
+
3
+ /**
4
+ * Sleep for `ms`, rejecting early if `signal` aborts. Polling activities use
5
+ * this between attempts so a local-executor timeout or Ctrl-C interrupts the
6
+ * wait instead of running it to completion.
7
+ */
8
+ export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
9
+ return new Promise((resolve, reject) => {
10
+ if (signal?.aborted) {
11
+ reject(new Error("aborted"));
12
+ return;
13
+ }
14
+ const onAbort = () => {
15
+ clearTimeout(timer);
16
+ reject(new Error("aborted"));
17
+ };
18
+ const timer = setTimeout(() => {
19
+ signal?.removeEventListener("abort", onAbort);
20
+ resolve();
21
+ }, ms);
22
+ signal?.addEventListener("abort", onAbort, { once: true });
23
+ });
24
+ }
@@ -1,6 +1,7 @@
1
1
  import { exec } from "node:child_process";
2
2
  import { promisify } from "node:util";
3
- import { Context } from "@temporalio/activity";
3
+ import { safeHeartbeat } from "./heartbeat";
4
+ import { sleep } from "./util";
4
5
 
5
6
  const execAsync = promisify(exec);
6
7
 
@@ -17,36 +18,41 @@ export interface WaitForStackArgs {
17
18
 
18
19
  /**
19
20
  * Poll until a Kubernetes Deployment or StatefulSet named `name` is fully rolled out.
20
- * Uses k8sWait profile — 15m timeout, heartbeat every 60s.
21
+ * Uses k8sWait profile — 15m timeout, heartbeat every poll.
21
22
  */
22
- export async function waitForStack(args: WaitForStackArgs): Promise<void> {
23
+ export async function waitForStack(args: WaitForStackArgs, signal?: AbortSignal): Promise<void> {
23
24
  const ns = args.namespace ? `-n ${args.namespace}` : "";
24
25
  const ctx = args.context ? `--context ${args.context}` : "";
25
26
  const interval = args.intervalMs ?? 10_000;
26
27
  let attempt = 0;
27
28
 
28
29
  while (true) {
30
+ if (signal?.aborted) throw new Error("waitForStack aborted");
29
31
  attempt++;
30
- Context.current().heartbeat({ step: "waitForStack", stack: args.name, attempt });
32
+ safeHeartbeat({ step: "waitForStack", stack: args.name, attempt });
31
33
 
32
34
  try {
33
35
  await execAsync(
34
36
  `kubectl rollout status deployment/${args.name} ${ns} ${ctx} --timeout=30s`,
37
+ { signal },
35
38
  );
36
39
  return;
37
40
  } catch {
41
+ if (signal?.aborted) throw new Error("waitForStack aborted");
38
42
  // Not ready yet — wait and retry
39
43
  }
40
44
 
41
45
  try {
42
46
  await execAsync(
43
47
  `kubectl rollout status statefulset/${args.name} ${ns} ${ctx} --timeout=30s`,
48
+ { signal },
44
49
  );
45
50
  return;
46
51
  } catch {
52
+ if (signal?.aborted) throw new Error("waitForStack aborted");
47
53
  // Not ready yet
48
54
  }
49
55
 
50
- await new Promise((r) => setTimeout(r, interval));
56
+ await sleep(interval, signal);
51
57
  }
52
58
  }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Generated-output compile-smoke (#162).
3
+ *
4
+ * Type-checks the serializer's emitted workflow.ts / worker.ts / activities.ts
5
+ * against the REAL activity signatures and the @temporalio types. The runtime
6
+ * harness (runtime.test.ts) proves the emitted control flow runs; this proves
7
+ * the emitted code still type-checks — so an activity signature change that the
8
+ * serializer or its callers don't track is caught as a compile error rather
9
+ * than drifting silently.
10
+ *
11
+ * Each op is serialized into a temp project laid out exactly as `chant build`
12
+ * writes it (chant.config.ts at the root, files under ops/<name>/), then run
13
+ * through the TypeScript compiler API. Expectation: zero diagnostics.
14
+ */
15
+ import { describe, test, expect, afterAll } from "vitest";
16
+ import ts from "typescript";
17
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
18
+ import { join } from "node:path";
19
+ import { fileURLToPath } from "node:url";
20
+ import { serializeOps } from "./serializer";
21
+ import { ApplyOp } from "../composites/apply-op";
22
+ import { ReconcileOp } from "../composites/reconcile-op";
23
+ import type { Declarable } from "@intentius/chant/declarable";
24
+
25
+ const ROOT = fileURLToPath(new URL("./__compile__", import.meta.url));
26
+
27
+ afterAll(() => rmSync(ROOT, { recursive: true, force: true }));
28
+
29
+ // Minimal chant.config.ts so the generated worker's `../../chant.config.js`
30
+ // import resolves. The worker reads it dynamically, so the shape only needs to
31
+ // type-check.
32
+ const CHANT_CONFIG = `import type { TemporalChantConfig } from "@intentius/chant-lexicon-temporal";
33
+ // Annotated (not \`satisfies\`) so \`profiles\` keeps its Record type and the
34
+ // generated worker can index it by a runtime profile name.
35
+ const temporal: TemporalChantConfig = {
36
+ profiles: { local: { address: "localhost:7233", namespace: "default", taskQueue: "tq" } },
37
+ defaultProfile: "local",
38
+ };
39
+ export default { temporal };
40
+ `;
41
+
42
+ /** Serialize `ops` into a temp project and return any type diagnostics. */
43
+ function compileGenerated(label: string, ops: Map<string, Declarable>): string[] {
44
+ const projectDir = join(ROOT, label);
45
+ mkdirSync(projectDir, { recursive: true });
46
+ writeFileSync(join(projectDir, "chant.config.ts"), CHANT_CONFIG);
47
+
48
+ const files = serializeOps(ops);
49
+ const targets: string[] = [];
50
+ for (const [rel, content] of Object.entries(files)) {
51
+ const abs = join(projectDir, rel);
52
+ mkdirSync(join(abs, ".."), { recursive: true });
53
+ writeFileSync(abs, content as string);
54
+ targets.push(abs);
55
+ }
56
+
57
+ const program = ts.createProgram(targets, {
58
+ noEmit: true,
59
+ strict: true,
60
+ skipLibCheck: true,
61
+ target: ts.ScriptTarget.ES2022,
62
+ module: ts.ModuleKind.ESNext,
63
+ // Bundler resolution mirrors how chant resolves its `.js`-suffixed ESM
64
+ // imports back to the `.ts` sources (the same model tsx uses at runtime).
65
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
66
+ esModuleInterop: true,
67
+ types: [],
68
+ });
69
+
70
+ // Only assert on diagnostics in the GENERATED files. The program transitively
71
+ // pulls in chant + @temporalio source; their diagnostics aren't what this test
72
+ // is about (they're checked by their own builds).
73
+ return ts
74
+ .getPreEmitDiagnostics(program)
75
+ .filter((d) => d.file && d.file.fileName.startsWith(projectDir))
76
+ .map((d) => {
77
+ const where = d.file!.fileName.split("/").slice(-2).join("/");
78
+ return `${where}: ${ts.flattenDiagnosticMessageText(d.messageText, " ")}`;
79
+ });
80
+ }
81
+
82
+ function opMap(...resources: Array<{ op: unknown }>): Map<string, Declarable> {
83
+ const m = new Map<string, Declarable>();
84
+ for (const r of resources) {
85
+ const op = r.op as Declarable & { props?: { name?: string } };
86
+ m.set(op.props?.name ?? String(m.size), op);
87
+ }
88
+ return m;
89
+ }
90
+
91
+ describe("generated output compile-smoke (#162)", () => {
92
+ test("ApplyOp output (build → plan → gate → apply, onFailure rollback) type-checks", () => {
93
+ const diags = compileGenerated(
94
+ "apply",
95
+ opMap(ApplyOp({ name: "prod-apply", env: "prod", target: "cloudformation", delete: "gated" })),
96
+ );
97
+ expect(diags).toEqual([]);
98
+ });
99
+
100
+ test("ReconcileOp output (snapshot → plan → reconcile PR) type-checks", () => {
101
+ const diags = compileGenerated(
102
+ "reconcile",
103
+ opMap(ReconcileOp({ name: "prod-reconcile", env: "prod", scope: { owned: true } })),
104
+ );
105
+ expect(diags).toEqual([]);
106
+ });
107
+
108
+ test("workflow.ts calls activities with their declared argument types", () => {
109
+ // A hand-built Op that passes a WRONG arg shape must surface as a type
110
+ // error — guards the smoke itself against false greens.
111
+ const bad: Map<string, Declarable> = new Map([
112
+ ["bad", {
113
+ props: {
114
+ name: "bad", overview: "o", taskQueue: "bad",
115
+ phases: [{ name: "P", steps: [{ kind: "activity", fn: "chantBuild", args: { nope: 1 } }] }],
116
+ },
117
+ } as unknown as Declarable],
118
+ ]);
119
+ const diags = compileGenerated("bad", bad);
120
+ expect(diags.some((d) => d.includes("workflow.ts"))).toBe(true);
121
+ });
122
+ });
@@ -94,6 +94,35 @@ describe("serializeOps()", () => {
94
94
  expect(wf).toContain("TEMPORAL_ACTIVITY_PROFILES.longInfra");
95
95
  });
96
96
 
97
+ it("passes the whole profile object to proxyActivities (carries every retry field, incl. nonRetryableErrorTypes)", () => {
98
+ // The worker must spread the entire profile — not a hand-picked subset of
99
+ // fields — so retry policy reaches Temporal's ActivityOptions intact. This
100
+ // is what makes the --temporal path honor nonRetryableErrorTypes the same
101
+ // way the local executor does. Reconstructing the options inline would
102
+ // silently drop any field not explicitly copied; this locks that out.
103
+ const ops = new Map([
104
+ makeOp({
105
+ name: "deploy", overview: "o",
106
+ phases: [{ name: "Build", steps: [{ kind: "activity", fn: "chantBuild", args: { path: "./a" } }] }],
107
+ }),
108
+ ]);
109
+ const wf = serializeOps(ops)["ops/deploy/workflow.ts"];
110
+ expect(wf).toMatch(/proxyActivities<typeof activities>\(\s*TEMPORAL_ACTIVITY_PROFILES\.fastIdempotent,\s*\)/);
111
+ });
112
+
113
+ it("imports activity profiles from the config leaf, not the package root (workflow-sandbox-safe)", () => {
114
+ // The package root re-exports the plugin/serializer (node:fs/path), which
115
+ // Temporal's workflow sandbox forbids — importing profiles from the root
116
+ // makes the worker's bundler reject the generated workflow. config.ts is
117
+ // import-free, so the workflow bundle stays Node-free.
118
+ const ops = new Map([
119
+ makeOp({ name: "deploy", overview: "o", phases: [{ name: "Build", steps: [{ kind: "activity", fn: "chantBuild" }] }] }),
120
+ ]);
121
+ const wf = serializeOps(ops)["ops/deploy/workflow.ts"];
122
+ expect(wf).toContain("from '@intentius/chant-lexicon-temporal/config'");
123
+ expect(wf).not.toContain("from '@intentius/chant-lexicon-temporal';");
124
+ });
125
+
97
126
  it("generates sequential await calls for a non-parallel phase", () => {
98
127
  const ops = new Map([
99
128
  makeOp({
@@ -211,6 +240,50 @@ describe("serializeOps()", () => {
211
240
  expect(wf).toContain("onFailure compensation");
212
241
  expect(wf).toContain("// Phase: Rollback");
213
242
  });
243
+
244
+ it("runs onFailure compensation ONLY on failure: main phases are wrapped in try/catch (#168)", () => {
245
+ const ops = new Map([
246
+ makeOp({
247
+ name: "deploy", overview: "o",
248
+ phases: [{ name: "Apply", steps: [{ kind: "activity", fn: "helmInstall", args: { name: "r", chart: "c" } }] }],
249
+ onFailure: [{ name: "Rollback", steps: [{ kind: "activity", fn: "helmInstall", args: { name: "r", chart: "c" } }] }],
250
+ }),
251
+ ]);
252
+ const wf = serializeOps(ops)["ops/deploy/workflow.ts"];
253
+ // Main phase guarded; compensation lives in the catch and re-throws the original error.
254
+ expect(wf).toContain("try {");
255
+ expect(wf).toContain("} catch (__opErr) {");
256
+ expect(wf).toContain("throw __opErr;");
257
+ // The compensation Phase upsert must appear after the catch opens, never before.
258
+ expect(wf.indexOf("catch (__opErr)")).toBeLessThan(wf.indexOf('Phase: ["Rollback"]'));
259
+ });
260
+
261
+ it("has no try/catch when there is no onFailure (#168)", () => {
262
+ const ops = new Map([
263
+ makeOp({
264
+ name: "plain", overview: "o",
265
+ phases: [{ name: "Apply", steps: [{ kind: "activity", fn: "helmInstall", args: { name: "r", chart: "c" } }] }],
266
+ }),
267
+ ]);
268
+ const wf = serializeOps(ops)["ops/plain/workflow.ts"];
269
+ expect(wf).not.toContain("catch (__opErr)");
270
+ });
271
+
272
+ it("runs onFailure phases in reverse order, matching the local executor (#168)", () => {
273
+ const ops = new Map([
274
+ makeOp({
275
+ name: "deploy", overview: "o",
276
+ phases: [{ name: "Apply", steps: [] }],
277
+ onFailure: [
278
+ { name: "Rollback", steps: [] },
279
+ { name: "Notify", steps: [] },
280
+ ],
281
+ }),
282
+ ]);
283
+ const wf = serializeOps(ops)["ops/deploy/workflow.ts"];
284
+ // Declared order Rollback→Notify, so compensation emits Notify before Rollback.
285
+ expect(wf.indexOf('Phase: ["Notify"]')).toBeLessThan(wf.indexOf('Phase: ["Rollback"]'));
286
+ });
214
287
  });
215
288
 
216
289
  // ── activities.ts ───────────────────────────────────────────────────────────
@@ -394,14 +467,14 @@ describe("serializeOps()", () => {
394
467
  {
395
468
  name: "Diff",
396
469
  steps: [
397
- { kind: "activity", fn: "stateDiff", args: { env: "prod" }, outcomeAttribute: { name: "Drift", from: "drifted" } },
470
+ { kind: "activity", fn: "lifecycleDiff", args: { env: "prod" }, outcomeAttribute: { name: "Drift", from: "drifted" } },
398
471
  ],
399
472
  },
400
473
  ],
401
474
  }),
402
475
  ]);
403
476
  const wf = serializeOps(ops)["ops/watch/workflow.ts"];
404
- expect(wf).toContain('const __r0 = await stateDiff({"env":"prod"});');
477
+ expect(wf).toContain('const __r0 = await lifecycleDiff({"env":"prod"});');
405
478
  expect(wf).toContain('upsertSearchAttributes({ "Drift": [String(__r0?.drifted)] });');
406
479
  });
407
480