@intentius/chant-lexicon-temporal 0.1.14 → 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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "a00c2b9e855c60fed2e4a370789a4c295f495980c9fba2a007674c33d808dd23",
4
+ "manifest.json": "01f153ed27fa75f3ffeec4202cb7d3dcdef74712c8b196f9385e7b896c6da604",
5
5
  "meta.json": "c77a3c415993bed3865e07fa6db4fb7b9e87de0afa8d8675f64eadf50f914077",
6
6
  "types/index.d.ts": "5216f5256321f084a7c8211ef66ab599f621c74751cc3485cfc2a62502b81e2f",
7
7
  "rules/tmp001.ts": "689222211a93716a7e4432f6dd6a2a2ab54020b232049fb602893872585f9978",
@@ -11,5 +11,5 @@
11
11
  "skills/chant-temporal.md": "ff929983b33bb420c49ab61851f3799c5610256cb972d6c584792bb98b0cfb22",
12
12
  "skills/chant-temporal-ops.md": "7e83bc3e6e63af4ab14b8dc07aa00132d51991cf35b1bca29560d48934bff6dc"
13
13
  },
14
- "composite": "a7fbf3d5259994cbf34d80e3d056c643729fd3bc982671842269b26e856bcd37"
14
+ "composite": "08737cdf11946008dd7428f19bf68924eaebf67072c3885695a00759ba743f62"
15
15
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "temporal",
3
- "version": "0.1.14",
3
+ "version": "0.1.15",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "Temporal",
6
6
  "intrinsics": [],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-temporal",
3
- "version": "0.1.14",
3
+ "version": "0.1.15",
4
4
  "description": "Temporal lexicon for chant — server deployment, namespaces, search attributes, and schedules",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -37,6 +37,10 @@
37
37
  },
38
38
  "devDependencies": {
39
39
  "@intentius/chant": "*",
40
+ "@temporalio/common": "^1.17.2",
41
+ "@temporalio/testing": "^1.17.2",
42
+ "@temporalio/worker": "^1.17.2",
43
+ "@temporalio/workflow": "^1.17.2",
40
44
  "typescript": "^5.9.3"
41
45
  }
42
46
  }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * ApplyOp composite — the code → cloud workflow as an Op.
3
+ *
4
+ * Compute the plan, then apply via the target's native mechanism. Authority
5
+ * stays with the platform (the CloudFormation stack, the Kubernetes API
6
+ * server, the ARM resource group) — chant never hosts a state file.
7
+ *
8
+ * Phases: build → plan → [approve] → apply. Deletes are limited to chant-owned
9
+ * orphans, ridden on the native prune/complete path scoped to the ownership
10
+ * marker, so a foreign resource is never touched.
11
+ *
12
+ * An ungated apply may run on the local Op executor; a gated apply needs
13
+ * Temporal for the durable approval wait (added in #125).
14
+ *
15
+ * @example
16
+ * ```typescript
17
+ * // additive, local executor
18
+ * export const { op } = ApplyOp({ name: "prod-apply", env: "prod", target: "kubectl" });
19
+ *
20
+ * // gated destructive apply on Temporal
21
+ * export const { op } = ApplyOp({
22
+ * name: "prod-apply",
23
+ * env: "prod",
24
+ * target: "kubectl",
25
+ * delete: "gated",
26
+ * gate: { signalName: "approve-apply", description: "Approve prod apply with deletes" },
27
+ * });
28
+ * ```
29
+ *
30
+ * @see #112 — stateless-authoritative state model + live import
31
+ */
32
+
33
+ import { Op, phase, activity, gate, OpResource } from "@intentius/chant/op";
34
+ import type { ApplyTarget, DeleteMode } from "../op/activities/apply";
35
+
36
+ export interface ApplyOpConfig {
37
+ /** Op name (kebab-case). */
38
+ name: string;
39
+ /** Environment — CFN stack name / ARM resource group / kube context env. */
40
+ env: string;
41
+ /** Native apply mechanism. Default: "kubectl". */
42
+ target?: ApplyTarget;
43
+ /** Built manifest/template path (or directory for kubectl). Default: "dist". */
44
+ output?: string;
45
+ /** Project directory to build. Default: ".". */
46
+ path?: string;
47
+ /** Delete handling. Default: "never". */
48
+ delete?: DeleteMode;
49
+ /**
50
+ * Approval gate before the apply. Implied when `delete: "gated"`; may also be
51
+ * set explicitly. Omit `signalName` to default to `approve-<name>`.
52
+ */
53
+ gate?: { signalName?: string; timeout?: string; description?: string };
54
+ /**
55
+ * Saga-style rollback on partial apply failure, run as an `onFailure` phase.
56
+ * Defaults on whenever the apply is destructive (`delete !== "never"`). Pass
57
+ * `{ command }` to supply a rollback command for targets without a native one
58
+ * (kubectl, ARM); `false` to disable.
59
+ */
60
+ compensate?: boolean | { command?: string };
61
+ /** Override the task queue. Defaults to `name`. */
62
+ taskQueue?: string;
63
+ }
64
+
65
+ export interface ApplyOpResources {
66
+ /** Op resource — generates the build→plan→[approve]→apply workflow. */
67
+ op: InstanceType<typeof OpResource>;
68
+ }
69
+
70
+ export function ApplyOp(config: ApplyOpConfig): ApplyOpResources {
71
+ const taskQueue = config.taskQueue ?? config.name;
72
+ const target = config.target ?? "kubectl";
73
+ const output = config.output ?? "dist";
74
+ const deleteMode = config.delete ?? "never";
75
+ const gated = deleteMode === "gated" || config.gate !== undefined;
76
+
77
+ const phases = [
78
+ phase("Build", [activity("chantBuild", { path: config.path ?? "." })]),
79
+ phase("Plan", [
80
+ {
81
+ kind: "activity" as const,
82
+ fn: "lifecycleDiff",
83
+ args: { env: config.env, live: true },
84
+ outcomeAttribute: { name: "Drift", from: "drifted" },
85
+ },
86
+ ]),
87
+ ];
88
+
89
+ if (gated) {
90
+ phases.push(
91
+ phase("Approve", [
92
+ gate(config.gate?.signalName ?? `approve-${config.name}`, {
93
+ ...(config.gate?.timeout ? { timeout: config.gate.timeout } : {}),
94
+ description:
95
+ config.gate?.description ?? `Approve apply to ${config.env} (delete mode: ${deleteMode})`,
96
+ }),
97
+ ]),
98
+ );
99
+ }
100
+
101
+ phases.push(
102
+ phase("Apply", [
103
+ activity("nativeApply", { target, env: config.env, output, deleteMode }, "longInfra"),
104
+ ]),
105
+ );
106
+
107
+ // Compensation defaults on for destructive applies — a partial failure should
108
+ // unwind rather than leave the cloud half-applied.
109
+ const compensateDefault = deleteMode !== "never";
110
+ const compensateEnabled = config.compensate === undefined ? compensateDefault : config.compensate !== false;
111
+ const compensateCommand =
112
+ typeof config.compensate === "object" ? config.compensate.command : undefined;
113
+
114
+ const op = Op({
115
+ name: config.name,
116
+ overview: `Apply declared source to the ${config.env} environment (code → cloud)`,
117
+ taskQueue,
118
+ searchAttributes: {
119
+ Apply: "true",
120
+ Env: config.env,
121
+ },
122
+ phases,
123
+ ...(compensateEnabled
124
+ ? {
125
+ onFailure: [
126
+ phase("Rollback", [
127
+ activity("compensateApply", {
128
+ target,
129
+ env: config.env,
130
+ ...(compensateCommand ? { command: compensateCommand } : {}),
131
+ }),
132
+ ]),
133
+ ],
134
+ }
135
+ : {}),
136
+ });
137
+
138
+ return { op };
139
+ }
@@ -6,6 +6,8 @@ import { describe, test, expect } from "vitest";
6
6
  import { TemporalDevStack } from "./dev-stack";
7
7
  import { TemporalCloudStack } from "./cloud-stack";
8
8
  import { WatchOp } from "./watch-op";
9
+ import { ReconcileOp } from "./reconcile-op";
10
+ import { ApplyOp } from "./apply-op";
9
11
  import { serializeOps } from "../op/serializer";
10
12
  import { DECLARABLE_MARKER } from "@intentius/chant/declarable";
11
13
 
@@ -165,8 +167,8 @@ describe("WatchOp: configuration", () => {
165
167
  expect(phases.map((p) => p.name)).toEqual(["Snapshot", "Diff"]);
166
168
  const snapStep = (phases[0].steps as Array<Record<string, unknown>>)[0];
167
169
  const diffStep = (phases[1].steps as Array<Record<string, unknown>>)[0];
168
- expect(snapStep.fn).toBe("stateSnapshot");
169
- expect(diffStep.fn).toBe("stateDiff");
170
+ expect(snapStep.fn).toBe("lifecycleSnapshot");
171
+ expect(diffStep.fn).toBe("lifecycleDiff");
170
172
  expect(snapStep.args).toEqual({ env: "prod" });
171
173
  expect(diffStep.args).toEqual({ env: "prod", live: true });
172
174
  // Drift is surfaced as a workflow search attribute via outcomeAttribute (#41)
@@ -225,9 +227,146 @@ describe("WatchOp: serialization", () => {
225
227
  expect(wf).toContain('upsertSearchAttributes({"OpName":["prod-watch"],"Watch":["true"],"Env":["prod"]});');
226
228
  expect(wf).toContain('upsertSearchAttributes({ Phase: ["Snapshot"] });');
227
229
  expect(wf).toContain('upsertSearchAttributes({ Phase: ["Diff"] });');
228
- expect(wf).toContain("stateSnapshot(");
230
+ expect(wf).toContain("lifecycleSnapshot(");
229
231
  // Diff result captured + Drift search attribute auto-emitted
230
- expect(wf).toContain("const __r0 = await stateDiff(");
232
+ expect(wf).toContain("const __r0 = await lifecycleDiff(");
231
233
  expect(wf).toContain('upsertSearchAttributes({ "Drift": [String(__r0?.drifted)] });');
232
234
  });
233
235
  });
236
+
237
+ // ── ReconcileOp ──────────────────────────────────────────────────────
238
+
239
+ describe("ReconcileOp: shape", () => {
240
+ test("one-shot (no schedule) returns op only", () => {
241
+ const result = ReconcileOp({ name: "prod-reconcile", env: "prod" });
242
+ expect(result.op).toBeDefined();
243
+ expect(result.schedule).toBeUndefined();
244
+ expect(getEntityType(result.op)).toBe("Temporal::Op");
245
+ expect((result.op as Record<symbol, unknown>)[DECLARABLE_MARKER]).toBe(true);
246
+ });
247
+
248
+ test("with schedule returns op + schedule", () => {
249
+ const result = ReconcileOp({ name: "prod-reconcile", env: "prod", schedule: "0 * * * *" });
250
+ expect(result.op).toBeDefined();
251
+ expect(result.schedule).toBeDefined();
252
+ expect(getEntityType(result.schedule)).toBe("Temporal::Schedule");
253
+ });
254
+ });
255
+
256
+ describe("ReconcileOp: configuration", () => {
257
+ test("phases are Snapshot → Plan → Reconcile with the right activities", () => {
258
+ const { op } = ReconcileOp({ name: "p", env: "prod" });
259
+ const phases = (getProps(op).phases as Array<Record<string, unknown>>) ?? [];
260
+ expect(phases.map((p) => p.name)).toEqual(["Snapshot", "Plan", "Reconcile"]);
261
+ const reconcileStep = (phases[2].steps as Array<Record<string, unknown>>)[0];
262
+ expect(reconcileStep.fn).toBe("reconcilePr");
263
+ expect(reconcileStep.args).toEqual({ env: "prod", mode: "pull-request", owned: false });
264
+ });
265
+
266
+ test("scope.owned + onDrift flow into the reconcilePr step", () => {
267
+ const { op } = ReconcileOp({ name: "p", env: "prod", onDrift: "issue", scope: { owned: true } });
268
+ const phases = getProps(op).phases as Array<Record<string, unknown>>;
269
+ const reconcileStep = (phases[2].steps as Array<Record<string, unknown>>)[0];
270
+ expect(reconcileStep.args).toEqual({ env: "prod", mode: "issue", owned: true });
271
+ });
272
+
273
+ test("auto-emit search attrs include Reconcile + Env", () => {
274
+ const { op } = ReconcileOp({ name: "p", env: "prod" });
275
+ expect(getProps(op).searchAttributes).toEqual({ Reconcile: "true", Env: "prod" });
276
+ });
277
+
278
+ test("Plan phase surfaces Drift as a search attribute", () => {
279
+ const { op } = ReconcileOp({ name: "p", env: "prod" });
280
+ const phases = getProps(op).phases as Array<Record<string, unknown>>;
281
+ const diffStep = (phases[1].steps as Array<Record<string, unknown>>)[0];
282
+ expect(diffStep.outcomeAttribute).toEqual({ name: "Drift", from: "drifted" });
283
+ });
284
+ });
285
+
286
+ // ── ApplyOp ──────────────────────────────────────────────────────────
287
+
288
+ describe("ApplyOp: shape", () => {
289
+ test("returns an op (no schedule)", () => {
290
+ const result = ApplyOp({ name: "prod-apply", env: "prod" });
291
+ expect(result.op).toBeDefined();
292
+ expect(getEntityType(result.op)).toBe("Temporal::Op");
293
+ expect((result.op as Record<symbol, unknown>)[DECLARABLE_MARKER]).toBe(true);
294
+ });
295
+
296
+ test("ungated apply: Build → Plan → Apply (no Approve phase)", () => {
297
+ const { op } = ApplyOp({ name: "p", env: "prod", target: "kubectl" });
298
+ const phases = getProps(op).phases as Array<Record<string, unknown>>;
299
+ expect(phases.map((p) => p.name)).toEqual(["Build", "Plan", "Apply"]);
300
+ const applyStep = (phases[2].steps as Array<Record<string, unknown>>)[0];
301
+ expect(applyStep.fn).toBe("nativeApply");
302
+ expect(applyStep.args).toEqual({ target: "kubectl", env: "prod", output: "dist", deleteMode: "never" });
303
+ });
304
+ });
305
+
306
+ describe("ApplyOp: gating + deletes", () => {
307
+ test("delete: gated inserts an Approve gate phase before Apply", () => {
308
+ const { op } = ApplyOp({ name: "p", env: "prod", delete: "gated" });
309
+ const phases = getProps(op).phases as Array<Record<string, unknown>>;
310
+ expect(phases.map((p) => p.name)).toEqual(["Build", "Plan", "Approve", "Apply"]);
311
+ const gateStep = (phases[2].steps as Array<Record<string, unknown>>)[0];
312
+ expect(gateStep.kind).toBe("gate");
313
+ expect(gateStep.signalName).toBe("approve-p");
314
+ });
315
+
316
+ test("explicit gate config is honored", () => {
317
+ const { op } = ApplyOp({
318
+ name: "p",
319
+ env: "prod",
320
+ gate: { signalName: "go", description: "ship it" },
321
+ });
322
+ const phases = getProps(op).phases as Array<Record<string, unknown>>;
323
+ const gateStep = (phases[2].steps as Array<Record<string, unknown>>)[0];
324
+ expect(gateStep.signalName).toBe("go");
325
+ expect(gateStep.description).toBe("ship it");
326
+ });
327
+
328
+ test("deleteMode flows into the nativeApply step", () => {
329
+ const { op } = ApplyOp({ name: "p", env: "prod", delete: "owned-only" });
330
+ const phases = getProps(op).phases as Array<Record<string, unknown>>;
331
+ const applyStep = (phases.find((p) => p.name === "Apply")!.steps as Array<Record<string, unknown>>)[0];
332
+ expect((applyStep.args as Record<string, unknown>).deleteMode).toBe("owned-only");
333
+ });
334
+
335
+ test("auto-emit search attrs include Apply + Env", () => {
336
+ const { op } = ApplyOp({ name: "p", env: "prod" });
337
+ expect(getProps(op).searchAttributes).toEqual({ Apply: "true", Env: "prod" });
338
+ });
339
+ });
340
+
341
+ describe("ApplyOp: compensation (#125)", () => {
342
+ test("destructive apply adds an onFailure Rollback phase by default", () => {
343
+ const { op } = ApplyOp({ name: "p", env: "prod", delete: "owned-only" });
344
+ const onFailure = getProps(op).onFailure as Array<Record<string, unknown>> | undefined;
345
+ expect(onFailure?.map((p) => p.name)).toEqual(["Rollback"]);
346
+ const step = (onFailure![0].steps as Array<Record<string, unknown>>)[0];
347
+ expect(step.fn).toBe("compensateApply");
348
+ expect(step.args).toEqual({ target: "kubectl", env: "prod" });
349
+ });
350
+
351
+ test("additive apply has no compensation by default", () => {
352
+ const { op } = ApplyOp({ name: "p", env: "prod", delete: "never" });
353
+ expect(getProps(op).onFailure).toBeUndefined();
354
+ });
355
+
356
+ test("compensate: false disables rollback even when destructive", () => {
357
+ const { op } = ApplyOp({ name: "p", env: "prod", delete: "owned-only", compensate: false });
358
+ expect(getProps(op).onFailure).toBeUndefined();
359
+ });
360
+
361
+ test("compensate.command supplies a rollback for targets without a native one", () => {
362
+ const { op } = ApplyOp({
363
+ name: "p",
364
+ env: "prod",
365
+ target: "kubectl",
366
+ compensate: { command: "kubectl rollout undo deployment/web" },
367
+ });
368
+ const onFailure = getProps(op).onFailure as Array<Record<string, unknown>>;
369
+ const step = (onFailure[0].steps as Array<Record<string, unknown>>)[0];
370
+ expect((step.args as Record<string, unknown>).command).toBe("kubectl rollout undo deployment/web");
371
+ });
372
+ });
@@ -0,0 +1,112 @@
1
+ /**
2
+ * ReconcileOp composite — the cloud → code workflow as an Op.
3
+ *
4
+ * Keeps source tracking reality: when live drifts from declarations, open a PR
5
+ * that regenerates the affected TypeScript. Mirrors the {@link WatchOp} shape.
6
+ *
7
+ * Phases: snapshot → plan → regenerate (live import) → open PR. The
8
+ * regenerate-and-PR step is the `reconcilePr` activity (#122), which derives
9
+ * the change set from `chant lifecycle plan` and opens a reviewable PR.
10
+ *
11
+ * Runs on the local Op executor for a one-shot `chant run`; on Temporal when a
12
+ * `schedule` is given (the cron + run history are the value, as with WatchOp).
13
+ *
14
+ * @example
15
+ * ```typescript
16
+ * // one-shot, local executor
17
+ * export const { op } = ReconcileOp({ name: "prod-reconcile", env: "prod" });
18
+ *
19
+ * // scheduled on Temporal
20
+ * export const { op, schedule } = ReconcileOp({
21
+ * name: "prod-reconcile",
22
+ * env: "prod",
23
+ * schedule: "0 * * * *", // hourly
24
+ * scope: { owned: true },
25
+ * });
26
+ * ```
27
+ *
28
+ * @see #112 — stateless-authoritative state model + live import
29
+ */
30
+
31
+ import { Op, phase, activity, OpResource } from "@intentius/chant/op";
32
+ import { TemporalSchedule } from "../resources";
33
+ import type { ReconcileMode } from "../op/activities/reconcile";
34
+
35
+ function kebabToCamel(s: string): string {
36
+ return s.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
37
+ }
38
+
39
+ export interface ReconcileOpConfig {
40
+ /** Op name (kebab-case). Used as workflow function name, task queue, schedule id base. */
41
+ name: string;
42
+ /** Environment to reconcile (e.g. "prod"). */
43
+ env: string;
44
+ /**
45
+ * Cron expression. When set, a TemporalSchedule fires the workflow; omit for
46
+ * one-shot `chant run` on the local executor.
47
+ */
48
+ schedule?: string;
49
+ /**
50
+ * What to produce on drift. Default: "pull-request".
51
+ * @default "pull-request"
52
+ */
53
+ onDrift?: ReconcileMode;
54
+ /** Restrict reconciliation to chant-owned resources. */
55
+ scope?: { owned?: boolean };
56
+ /** Override the task queue. Defaults to `name`. */
57
+ taskQueue?: string;
58
+ }
59
+
60
+ export interface ReconcileOpResources {
61
+ /** Op resource — generates the snapshot→plan→regenerate→PR workflow. */
62
+ op: InstanceType<typeof OpResource>;
63
+ /** Temporal schedule, present only when `schedule` was given. */
64
+ schedule?: InstanceType<typeof TemporalSchedule>;
65
+ }
66
+
67
+ export function ReconcileOp(config: ReconcileOpConfig): ReconcileOpResources {
68
+ const taskQueue = config.taskQueue ?? config.name;
69
+ const onDrift = config.onDrift ?? "pull-request";
70
+ const owned = config.scope?.owned ?? false;
71
+
72
+ const op = Op({
73
+ name: config.name,
74
+ overview: `Reconcile the ${config.env} environment into source (cloud → code)`,
75
+ taskQueue,
76
+ searchAttributes: {
77
+ Reconcile: "true",
78
+ Env: config.env,
79
+ },
80
+ phases: [
81
+ phase("Snapshot", [activity("lifecycleSnapshot", { env: config.env })]),
82
+ phase("Plan", [
83
+ {
84
+ kind: "activity",
85
+ fn: "lifecycleDiff",
86
+ args: { env: config.env, live: true },
87
+ outcomeAttribute: { name: "Drift", from: "drifted" },
88
+ },
89
+ ]),
90
+ phase("Reconcile", [
91
+ // reconcilePr derives the change set from `chant lifecycle plan`,
92
+ // regenerates via `chant import --from`, and opens a PR.
93
+ activity("reconcilePr", { env: config.env, mode: onDrift, owned }),
94
+ ]),
95
+ ],
96
+ });
97
+
98
+ if (!config.schedule) {
99
+ return { op };
100
+ }
101
+
102
+ const schedule = new TemporalSchedule({
103
+ scheduleId: `${config.name}-schedule`,
104
+ spec: { cronExpressions: [config.schedule] },
105
+ action: {
106
+ workflowType: kebabToCamel(config.name) + "Workflow",
107
+ taskQueue,
108
+ },
109
+ } as Record<string, unknown>);
110
+
111
+ return { op, schedule };
112
+ }
@@ -5,7 +5,7 @@
5
5
  * Composes existing pieces:
6
6
  * - The Op codegen (#7) emits a workflow that runs phases sequentially
7
7
  * - The auto-emit search-attribute behavior (#28) tags each phase
8
- * - The pre-built stateSnapshot + stateDiff activities (this commit)
8
+ * - The pre-built lifecycleSnapshot + lifecycleDiff activities (this commit)
9
9
  * - TemporalSchedule fires the workflow on a cron schedule
10
10
  *
11
11
  * @example
@@ -46,7 +46,7 @@ export interface WatchOpConfig {
46
46
  */
47
47
  taskQueue?: string;
48
48
  /**
49
- * Run `chant state diff --live` (queries cloud APIs) instead of the
49
+ * Run `chant lifecycle diff --live` (queries cloud APIs) instead of the
50
50
  * default digest-only diff. Recommended for real drift detection.
51
51
  * @default true
52
52
  */
@@ -73,14 +73,14 @@ export function WatchOp(config: WatchOpConfig): WatchOpResources {
73
73
  Env: config.env,
74
74
  },
75
75
  phases: [
76
- phase("Snapshot", [activity("stateSnapshot", { env: config.env })]),
76
+ phase("Snapshot", [activity("lifecycleSnapshot", { env: config.env })]),
77
77
  phase("Diff", [
78
- // outcomeAttribute surfaces stateDiff's `drifted` boolean as a
78
+ // outcomeAttribute surfaces lifecycleDiff's `drifted` boolean as a
79
79
  // workflow-level Drift search attribute, making 'show me runs that
80
80
  // detected drift' a one-filter UI query.
81
81
  {
82
82
  kind: "activity",
83
- fn: "stateDiff",
83
+ fn: "lifecycleDiff",
84
84
  args: { env: config.env, live },
85
85
  outcomeAttribute: { name: "Drift", from: "drifted" },
86
86
  },
package/src/config.ts CHANGED
@@ -69,6 +69,13 @@ export interface TemporalActivityProfile {
69
69
  maximumAttempts?: number;
70
70
  /** Cap on retry intervals (e.g. "5m"). */
71
71
  maximumInterval?: string;
72
+ /**
73
+ * Error names (`Error.name`) that fail immediately without retry. Honored
74
+ * in both modes: the generated worker spreads this profile into
75
+ * `proxyActivities`, so Temporal's RetryPolicy uses it; the local executor
76
+ * reads it directly to short-circuit its retry loop.
77
+ */
78
+ nonRetryableErrorTypes?: string[];
72
79
  };
73
80
  }
74
81
 
@@ -118,6 +125,24 @@ export const TEMPORAL_ACTIVITY_PROFILES = {
118
125
  heartbeatTimeout: "90s",
119
126
  retry: { maximumAttempts: 1 },
120
127
  },
128
+
129
+ /**
130
+ * Argo CD sync waits: poll an Application until `health=Healthy && sync=Synced`
131
+ * (`waitForArgoSync`). Long timeout for slow first syncs, 60s heartbeat, cheap
132
+ * idempotent retries — re-polling is free. A terminal-unhealthy Application
133
+ * fails fast via `ArgoSyncFailedError` (non-retryable).
134
+ */
135
+ argoSync: {
136
+ startToCloseTimeout: "30m",
137
+ heartbeatTimeout: "60s",
138
+ retry: {
139
+ maximumAttempts: 5,
140
+ initialInterval: "10s",
141
+ backoffCoefficient: 2,
142
+ maximumInterval: "1m",
143
+ nonRetryableErrorTypes: ["ArgoSyncFailedError"],
144
+ },
145
+ },
121
146
  } as const satisfies Record<string, TemporalActivityProfile>;
122
147
 
123
148
  export interface TemporalWorkerProfile {
package/src/index.ts CHANGED
@@ -29,6 +29,10 @@ export { TemporalCloudStack } from "./composites/cloud-stack";
29
29
  export type { TemporalCloudStackConfig, TemporalCloudStackResources } from "./composites/cloud-stack";
30
30
  export { WatchOp } from "./composites/watch-op";
31
31
  export type { WatchOpConfig, WatchOpResources } from "./composites/watch-op";
32
+ export { ReconcileOp } from "./composites/reconcile-op";
33
+ export type { ReconcileOpConfig, ReconcileOpResources } from "./composites/reconcile-op";
34
+ export { ApplyOp } from "./composites/apply-op";
35
+ export type { ApplyOpConfig, ApplyOpResources } from "./composites/apply-op";
32
36
 
33
37
  // Op builders (re-exported from core for single-import convenience)
34
38
  export {
@@ -41,7 +45,7 @@ export {
41
45
  helmInstall,
42
46
  waitForStack,
43
47
  gitlabPipeline,
44
- stateSnapshot,
48
+ lifecycleSnapshot,
45
49
  shell,
46
50
  teardown,
47
51
  } from "@intentius/chant/op";
@@ -0,0 +1,54 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { applyCommand, rollbackCommand } from "./apply";
3
+
4
+ describe("applyCommand (#124)", () => {
5
+ test("kubectl never → plain apply, no prune", () => {
6
+ const cmd = applyCommand("kubectl", "prod", "dist", "never");
7
+ expect(cmd).toContain("kubectl apply -f dist");
8
+ expect(cmd).not.toContain("--prune");
9
+ });
10
+
11
+ test("kubectl owned-only → prune scoped to the chant ownership marker", () => {
12
+ const cmd = applyCommand("kubectl", "prod", "dist", "owned-only");
13
+ expect(cmd).toContain("--prune");
14
+ expect(cmd).toContain("--selector app.kubernetes.io/managed-by=chant");
15
+ });
16
+
17
+ test("kubectl gated → same owned-scoped prune as owned-only", () => {
18
+ expect(applyCommand("kubectl", "prod", "dist", "gated")).toBe(
19
+ applyCommand("kubectl", "prod", "dist", "owned-only"),
20
+ );
21
+ });
22
+
23
+ test("cloudformation deploys to the env stack", () => {
24
+ const cmd = applyCommand("cloudformation", "prod", "stack.json", "owned-only");
25
+ expect(cmd).toContain("aws cloudformation deploy");
26
+ expect(cmd).toContain("--stack-name prod");
27
+ expect(cmd).toContain("--template-file stack.json");
28
+ });
29
+
30
+ test("arm uses Complete mode only when deleting", () => {
31
+ expect(applyCommand("arm", "rg", "t.json", "owned-only")).toContain("--mode Complete");
32
+ expect(applyCommand("arm", "rg", "t.json", "never")).toContain("--mode Incremental");
33
+ });
34
+
35
+ test("never deletes a foreign resource: prune is always marker-scoped", () => {
36
+ // The only delete path is the marker-scoped prune/complete — there is no
37
+ // unscoped delete command, so a foreign (unmarked) resource is never pruned.
38
+ const cmd = applyCommand("kubectl", "prod", "dist", "owned-only");
39
+ expect(cmd).toContain("managed-by=chant");
40
+ });
41
+ });
42
+
43
+ describe("rollbackCommand (#125)", () => {
44
+ test("cloudformation has a native rollback", () => {
45
+ expect(rollbackCommand("cloudformation", "prod")).toBe(
46
+ "aws cloudformation rollback-stack --stack-name prod",
47
+ );
48
+ });
49
+
50
+ test("kubectl / arm have no native single-command rollback", () => {
51
+ expect(rollbackCommand("kubectl", "prod")).toBeUndefined();
52
+ expect(rollbackCommand("arm", "rg")).toBeUndefined();
53
+ });
54
+ });