@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.
@@ -0,0 +1,128 @@
1
+ import { exec } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { ownershipKeys, OWNERSHIP_MANAGED_BY_VALUE } from "@intentius/chant/ownership";
4
+
5
+ const execAsync = promisify(exec);
6
+
7
+ /** The native apply mechanism for a target. */
8
+ export type ApplyTarget = "cloudformation" | "kubectl" | "arm";
9
+
10
+ /**
11
+ * How apply treats resources no longer declared.
12
+ * - `never` — additive only; never deletes.
13
+ * - `owned-only` — deletes only chant-owned orphans, via the target's native
14
+ * prune/complete mechanism scoped to the ownership marker.
15
+ * - `gated` — same delete scope as `owned-only`, but the workflow pauses for
16
+ * approval before the destructive apply (the gate lives in the composite).
17
+ */
18
+ export type DeleteMode = "never" | "owned-only" | "gated";
19
+
20
+ export interface NativeApplyArgs {
21
+ /** Native mechanism to delegate to. */
22
+ target: ApplyTarget;
23
+ /** Environment — CFN stack name / ARM resource group. */
24
+ env: string;
25
+ /** Built manifest/template path (or directory for kubectl). Default: dist. */
26
+ output?: string;
27
+ /** Delete handling. Default: never. */
28
+ deleteMode?: DeleteMode;
29
+ }
30
+
31
+ /**
32
+ * Build the native apply command. Pure — exported for testing.
33
+ *
34
+ * Authority stays with the platform: the CloudFormation stack, the Kubernetes
35
+ * API server, the ARM resource group. chant hosts no state file. Owned-only
36
+ * deletes ride the native delete path, scoped to the ownership marker so a
37
+ * foreign resource is never touched:
38
+ * - kubectl: `--prune --selector <managed-by>=chant` prunes only chant-owned
39
+ * objects absent from the apply set.
40
+ * - CloudFormation: the stack is the boundary; `deploy` deletes resources
41
+ * removed from the template within it.
42
+ * - ARM: `--mode Complete` removes resources not in the template from the RG.
43
+ */
44
+ export function applyCommand(
45
+ target: ApplyTarget,
46
+ env: string,
47
+ output: string,
48
+ deleteMode: DeleteMode,
49
+ ): string {
50
+ const deletes = deleteMode !== "never";
51
+ switch (target) {
52
+ case "kubectl": {
53
+ const prune = deletes
54
+ ? ` --prune --selector ${ownershipKeys("label").managedBy}=${OWNERSHIP_MANAGED_BY_VALUE}`
55
+ : "";
56
+ return `kubectl apply -f ${output}${prune} --wait=true`;
57
+ }
58
+ case "cloudformation":
59
+ // CFN deletes resources removed from the template within the stack itself.
60
+ return `aws cloudformation deploy --template-file ${output} --stack-name ${env} --capabilities CAPABILITY_NAMED_IAM`;
61
+ case "arm": {
62
+ const mode = deletes ? " --mode Complete" : " --mode Incremental";
63
+ return `az deployment group create --resource-group ${env} --template-file ${output}${mode}`;
64
+ }
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Apply declared source to the cloud via the target's native mechanism.
70
+ * Deletes (when enabled) are limited to chant-owned orphans by construction —
71
+ * the native prune/complete path is scoped to the ownership marker.
72
+ */
73
+ export async function nativeApply(args: NativeApplyArgs, signal?: AbortSignal): Promise<{ command: string }> {
74
+ const output = args.output ?? "dist";
75
+ const deleteMode = args.deleteMode ?? "never";
76
+ const command = applyCommand(args.target, args.env, output, deleteMode);
77
+ const { stdout, stderr } = await execAsync(command, { signal });
78
+ if (stdout) console.log(stdout);
79
+ if (stderr) console.error(stderr);
80
+ return { command };
81
+ }
82
+
83
+ /**
84
+ * The native rollback command for a target, or undefined when the target has
85
+ * no single-command rollback. Pure — exported for testing.
86
+ *
87
+ * Only CloudFormation has a native "return to last known good state" command.
88
+ * For kubectl/ARM the caller must supply a rollback command; otherwise the
89
+ * compensation degrades to a logged warning rather than silently doing nothing.
90
+ */
91
+ export function rollbackCommand(target: ApplyTarget, env: string): string | undefined {
92
+ switch (target) {
93
+ case "cloudformation":
94
+ return `aws cloudformation rollback-stack --stack-name ${env}`;
95
+ case "kubectl":
96
+ case "arm":
97
+ return undefined;
98
+ }
99
+ }
100
+
101
+ export interface CompensateApplyArgs {
102
+ /** Native mechanism that was applied. */
103
+ target: ApplyTarget;
104
+ /** Environment — CFN stack name / ARM resource group. */
105
+ env: string;
106
+ /** Explicit rollback command, used in preference to the native default. */
107
+ command?: string;
108
+ }
109
+
110
+ /**
111
+ * Compensation step (saga rollback) for a partial apply failure. Runs the
112
+ * explicit `command` if given, else the target's native rollback. Where no
113
+ * automatic rollback exists, it warns rather than silently no-op'ing — partial
114
+ * state should never look reverted when it isn't.
115
+ */
116
+ export async function compensateApply(args: CompensateApplyArgs, signal?: AbortSignal): Promise<{ command?: string }> {
117
+ const command = args.command ?? rollbackCommand(args.target, args.env);
118
+ if (!command) {
119
+ console.warn(
120
+ `[apply] no automatic rollback for target "${args.target}" — partial apply to ${args.env} was NOT reverted; supply compensate.command to enable rollback`,
121
+ );
122
+ return {};
123
+ }
124
+ const { stdout, stderr } = await execAsync(command, { signal });
125
+ if (stdout) console.log(stdout);
126
+ if (stderr) console.error(stderr);
127
+ return { command };
128
+ }
@@ -0,0 +1,78 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import {
3
+ waitForArgoSync,
4
+ ArgoSyncFailedError,
5
+ type ArgoAppStatus,
6
+ type ArgoStatusFetcher,
7
+ } from "./argo";
8
+ import { TEMPORAL_ACTIVITY_PROFILES } from "../../config";
9
+
10
+ /** A fetcher that returns a scripted sequence of statuses, repeating the last. */
11
+ function scriptedFetcher(sequence: ArgoAppStatus[]): ArgoStatusFetcher {
12
+ let i = 0;
13
+ return async () => {
14
+ const status = sequence[Math.min(i, sequence.length - 1)];
15
+ i++;
16
+ return status;
17
+ };
18
+ }
19
+
20
+ const fast = { appName: "guestbook", intervalMs: 0 };
21
+
22
+ describe("waitForArgoSync", () => {
23
+ test("resolves once the Application is Healthy and Synced", async () => {
24
+ const fetcher = scriptedFetcher([
25
+ { health: "Progressing", sync: "OutOfSync" },
26
+ { health: "Progressing", sync: "Synced" },
27
+ { health: "Healthy", sync: "Synced" },
28
+ ]);
29
+ const result = await waitForArgoSync(fast, undefined, fetcher);
30
+ expect(result).toEqual({ health: "Healthy", sync: "Synced" });
31
+ });
32
+
33
+ test("does not resolve while Synced but still Progressing", async () => {
34
+ // First Healthy+Synced read is the third; ensure it polls past the
35
+ // Progressing reads rather than returning early.
36
+ let calls = 0;
37
+ const fetcher: ArgoStatusFetcher = async () => {
38
+ calls++;
39
+ if (calls < 3) return { health: "Progressing", sync: "Synced" };
40
+ return { health: "Healthy", sync: "Synced" };
41
+ };
42
+ await waitForArgoSync(fast, undefined, fetcher);
43
+ expect(calls).toBe(3);
44
+ });
45
+
46
+ test("throws ArgoSyncFailedError when the Application is Degraded", async () => {
47
+ const fetcher = scriptedFetcher([{ health: "Degraded", sync: "Synced" }]);
48
+ await expect(waitForArgoSync(fast, undefined, fetcher)).rejects.toBeInstanceOf(
49
+ ArgoSyncFailedError,
50
+ );
51
+ });
52
+
53
+ test("throws ArgoSyncFailedError when the Application is Missing", async () => {
54
+ const fetcher = scriptedFetcher([{ health: "Missing", sync: "OutOfSync" }]);
55
+ await expect(waitForArgoSync(fast, undefined, fetcher)).rejects.toThrow(/Missing/);
56
+ });
57
+
58
+ test("honors an aborted signal", async () => {
59
+ const controller = new AbortController();
60
+ controller.abort();
61
+ const fetcher = scriptedFetcher([{ health: "Progressing", sync: "OutOfSync" }]);
62
+ await expect(waitForArgoSync(fast, controller.signal, fetcher)).rejects.toThrow(/aborted/);
63
+ });
64
+ });
65
+
66
+ describe("argoSync profile", () => {
67
+ test("is exported with a long timeout and 60s heartbeat", () => {
68
+ const p = TEMPORAL_ACTIVITY_PROFILES.argoSync;
69
+ expect(p.startToCloseTimeout).toBe("30m");
70
+ expect(p.heartbeatTimeout).toBe("60s");
71
+ });
72
+
73
+ test("treats ArgoSyncFailedError as non-retryable", () => {
74
+ expect(TEMPORAL_ACTIVITY_PROFILES.argoSync.retry?.nonRetryableErrorTypes).toContain(
75
+ "ArgoSyncFailedError",
76
+ );
77
+ });
78
+ });
@@ -0,0 +1,147 @@
1
+ import { exec } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { safeHeartbeat } from "./heartbeat";
4
+ import { sleep } from "./util";
5
+
6
+ const execAsync = promisify(exec);
7
+
8
+ /**
9
+ * waitForArgoSync — block until an Argo CD Application reports
10
+ * `health=Healthy && sync=Synced`.
11
+ *
12
+ * This activity is intentionally **dependency-free**: it must not import the k8s
13
+ * lexicon or its generated Argo CRD types. Its signature is primitives-only
14
+ * (app name / namespace / server). It reads the Application's status either via
15
+ * `kubectl get application` (default) or the Argo CD REST API (when `server` is
16
+ * given), so Temporal can gate procedural steps on a declarative apply that Argo
17
+ * owns.
18
+ */
19
+
20
+ export interface WaitForArgoSyncArgs {
21
+ /** Argo Application name. */
22
+ appName: string;
23
+ /** Namespace the Application object lives in (default "argocd"). */
24
+ namespace?: string;
25
+ /**
26
+ * Argo CD API base URL (e.g. https://argocd.example.com). When set, status is
27
+ * read from the REST API instead of kubectl. Pass `authToken` with it.
28
+ */
29
+ server?: string;
30
+ /** Bearer token for the Argo CD REST API (used with `server`). */
31
+ authToken?: string;
32
+ /** Skip TLS verification for the REST API (default false). */
33
+ insecure?: boolean;
34
+ /** kubectl context (used when `server` is not set). */
35
+ context?: string;
36
+ /** Poll interval in ms (default 15000). Heartbeats every poll. */
37
+ intervalMs?: number;
38
+ }
39
+
40
+ /** The two status fields the activity gates on. */
41
+ export interface ArgoAppStatus {
42
+ /** Application health: Healthy | Progressing | Degraded | Missing | Suspended | Unknown. */
43
+ health: string;
44
+ /** Sync status: Synced | OutOfSync | Unknown. */
45
+ sync: string;
46
+ }
47
+
48
+ /** Pluggable status reader — overridden in tests with a faked Argo API. */
49
+ export type ArgoStatusFetcher = (
50
+ args: WaitForArgoSyncArgs,
51
+ signal?: AbortSignal,
52
+ ) => Promise<ArgoAppStatus>;
53
+
54
+ /** Health states that will never become Healthy without intervention. */
55
+ const TERMINAL_UNHEALTHY = new Set(["Degraded", "Missing"]);
56
+
57
+ /** Error thrown when the Application reaches a terminal unhealthy state. */
58
+ export class ArgoSyncFailedError extends Error {
59
+ constructor(message: string) {
60
+ super(message);
61
+ this.name = "ArgoSyncFailedError";
62
+ }
63
+ }
64
+
65
+ /** Read status via the Argo CD REST API. */
66
+ async function fetchViaApi(args: WaitForArgoSyncArgs, signal?: AbortSignal): Promise<ArgoAppStatus> {
67
+ const base = args.server!.replace(/\/$/, "");
68
+ const ns = args.namespace ?? "argocd";
69
+ const url = `${base}/api/v1/applications/${encodeURIComponent(args.appName)}?appNamespace=${encodeURIComponent(ns)}`;
70
+ const headers: Record<string, string> = { Accept: "application/json" };
71
+ if (args.authToken) headers.Authorization = `Bearer ${args.authToken}`;
72
+
73
+ // Honor `insecure` without importing https Agent types — Node respects this
74
+ // env toggle for the duration of the call.
75
+ const prevTlsReject = process.env.NODE_TLS_REJECT_UNAUTHORIZED;
76
+ if (args.insecure) process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
77
+ try {
78
+ const res = await fetch(url, { headers, signal });
79
+ if (!res.ok) {
80
+ throw new Error(`Argo CD API returned ${res.status} for application "${args.appName}"`);
81
+ }
82
+ const body = (await res.json()) as { status?: { health?: { status?: string }; sync?: { status?: string } } };
83
+ return {
84
+ health: body.status?.health?.status ?? "Unknown",
85
+ sync: body.status?.sync?.status ?? "Unknown",
86
+ };
87
+ } finally {
88
+ if (args.insecure) {
89
+ if (prevTlsReject === undefined) delete process.env.NODE_TLS_REJECT_UNAUTHORIZED;
90
+ else process.env.NODE_TLS_REJECT_UNAUTHORIZED = prevTlsReject;
91
+ }
92
+ }
93
+ }
94
+
95
+ /** Read status via `kubectl get application -o json`. */
96
+ async function fetchViaKubectl(args: WaitForArgoSyncArgs, signal?: AbortSignal): Promise<ArgoAppStatus> {
97
+ const ns = args.namespace ?? "argocd";
98
+ const ctx = args.context ? `--context ${args.context}` : "";
99
+ const cmd =
100
+ `kubectl get application ${args.appName} -n ${ns} ${ctx} ` +
101
+ `-o jsonpath='{.status.health.status}|{.status.sync.status}'`;
102
+ const { stdout } = await execAsync(cmd, { signal });
103
+ const [health = "Unknown", sync = "Unknown"] = stdout.trim().replace(/^'|'$/g, "").split("|");
104
+ return { health: health || "Unknown", sync: sync || "Unknown" };
105
+ }
106
+
107
+ /** Default fetcher: REST API when `server` is set, else kubectl. */
108
+ export const defaultArgoStatusFetcher: ArgoStatusFetcher = (args, signal) =>
109
+ args.server ? fetchViaApi(args, signal) : fetchViaKubectl(args, signal);
110
+
111
+ /**
112
+ * Poll until the Application is Healthy and Synced. Throws
113
+ * `ArgoSyncFailedError` if it reaches a terminal unhealthy state (Degraded /
114
+ * Missing). Heartbeats every poll so the `argoSync` profile's 60s heartbeat
115
+ * timeout never trips.
116
+ *
117
+ * @param fetcher injectable status reader (defaults to kubectl/REST). Tests pass
118
+ * a fake to drive Healthy/Progressing/Degraded transitions.
119
+ */
120
+ export async function waitForArgoSync(
121
+ args: WaitForArgoSyncArgs,
122
+ signal?: AbortSignal,
123
+ fetcher: ArgoStatusFetcher = defaultArgoStatusFetcher,
124
+ ): Promise<ArgoAppStatus> {
125
+ const interval = args.intervalMs ?? 15_000;
126
+ let attempt = 0;
127
+
128
+ while (true) {
129
+ if (signal?.aborted) throw new Error("waitForArgoSync aborted");
130
+ attempt++;
131
+
132
+ const status = await fetcher(args, signal);
133
+ safeHeartbeat({ step: "waitForArgoSync", app: args.appName, attempt, ...status });
134
+
135
+ if (TERMINAL_UNHEALTHY.has(status.health)) {
136
+ throw new ArgoSyncFailedError(
137
+ `Argo Application "${args.appName}" is ${status.health} (sync=${status.sync}) — it will not become Healthy without intervention.`,
138
+ );
139
+ }
140
+
141
+ if (status.health === "Healthy" && status.sync === "Synced") {
142
+ return status;
143
+ }
144
+
145
+ await sleep(interval, signal);
146
+ }
147
+ }
@@ -13,10 +13,11 @@ export interface ChantBuildArgs {
13
13
  * Run `npm run build` in the given project directory.
14
14
  * Uses fastIdempotent profile — 5m timeout, 3 retries.
15
15
  */
16
- export async function chantBuild(args: ChantBuildArgs): Promise<void> {
16
+ export async function chantBuild(args: ChantBuildArgs, signal?: AbortSignal): Promise<void> {
17
17
  const { stdout, stderr } = await execAsync("npm run build", {
18
18
  cwd: args.path,
19
19
  env: { ...process.env, ...args.env },
20
+ signal,
20
21
  });
21
22
  if (stdout) console.log(stdout);
22
23
  if (stderr) console.error(stderr);
@@ -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
 
@@ -16,26 +17,29 @@ export interface GitlabPipelineArgs {
16
17
  /**
17
18
  * Trigger a GitLab CI pipeline and wait for it to complete successfully.
18
19
  * Requires `glab` CLI authenticated in the environment.
19
- * Uses longInfra profile — 20m timeout, heartbeat every 60s.
20
+ * Uses longInfra profile — 20m timeout, heartbeat every poll.
20
21
  */
21
- export async function gitlabPipeline(args: GitlabPipelineArgs): Promise<void> {
22
+ export async function gitlabPipeline(args: GitlabPipelineArgs, signal?: AbortSignal): Promise<void> {
22
23
  const ref = args.ref ?? "HEAD";
23
24
  const interval = args.intervalMs ?? 30_000;
24
25
 
25
26
  // Trigger
26
27
  const { stdout: triggerOut } = await execAsync(
27
28
  `glab ci run --project ${args.name} --ref ${ref}`,
29
+ { signal },
28
30
  );
29
31
  console.log(triggerOut);
30
32
 
31
33
  // Poll status
32
34
  let attempt = 0;
33
35
  while (true) {
36
+ if (signal?.aborted) throw new Error("gitlabPipeline aborted");
34
37
  attempt++;
35
- Context.current().heartbeat({ step: "gitlabPipeline", project: args.name, attempt });
38
+ safeHeartbeat({ step: "gitlabPipeline", project: args.name, attempt });
36
39
 
37
40
  const { stdout } = await execAsync(
38
41
  `glab ci status --project ${args.name} --format json`,
42
+ { signal },
39
43
  );
40
44
 
41
45
  let status: string | undefined;
@@ -51,6 +55,6 @@ export async function gitlabPipeline(args: GitlabPipelineArgs): Promise<void> {
51
55
  throw new Error(`GitLab pipeline for ${args.name} ended with status: ${status}`);
52
56
  }
53
57
 
54
- await new Promise((r) => setTimeout(r, interval));
58
+ await sleep(interval, signal);
55
59
  }
56
60
  }
@@ -0,0 +1,29 @@
1
+ import { describe, test, expect, vi } from "vitest";
2
+
3
+ // Positive path: prove the shim actually heartbeats once `@temporalio/activity`
4
+ // is present. The SDK isn't installed in this repo, so we virtual-mock it with a
5
+ // fake `Context` whose `heartbeat` we can observe. Lives in its own file so the
6
+ // module-level cache in heartbeat.ts starts fresh (vitest isolates files), and
7
+ // so heartbeat.test.ts can keep exercising the no-SDK path unmocked.
8
+ const { heartbeat } = vi.hoisted(() => ({ heartbeat: vi.fn() }));
9
+
10
+ vi.mock("@temporalio/activity", () => ({
11
+ Context: { current: () => ({ heartbeat }) },
12
+ }));
13
+
14
+ describe("safeHeartbeat with @temporalio/activity present", () => {
15
+ test("heartbeats once the lazy import resolves", async () => {
16
+ const { safeHeartbeat } = await import("./heartbeat");
17
+
18
+ // First call only kicks off the one-time lazy load and returns immediately.
19
+ safeHeartbeat({ step: "first" });
20
+ expect(heartbeat).not.toHaveBeenCalled();
21
+
22
+ // After the dynamic import settles, subsequent calls heartbeat through to
23
+ // the (mocked) activity Context, preserving the passed details.
24
+ await vi.waitFor(() => {
25
+ safeHeartbeat({ step: "after-load" });
26
+ expect(heartbeat).toHaveBeenCalledWith({ step: "after-load" });
27
+ });
28
+ });
29
+ });
@@ -0,0 +1,10 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { safeHeartbeat } from "./heartbeat";
3
+
4
+ describe("safeHeartbeat", () => {
5
+ test("no-ops outside a Temporal worker instead of throwing", () => {
6
+ // No activity execution context and (in this env) no @temporalio/activity.
7
+ expect(() => safeHeartbeat({ step: "test" })).not.toThrow();
8
+ expect(() => safeHeartbeat()).not.toThrow();
9
+ });
10
+ });
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Activity heartbeat shim.
3
+ *
4
+ * Activities heartbeat so a Temporal worker knows they are still alive. Two
5
+ * things make a naive `Context.current().heartbeat()` call unsafe outside a
6
+ * worker:
7
+ *
8
+ * 1. `@temporalio/activity` may not be installed at all — chant's local
9
+ * executor runs these same activity implementations with no Temporal SDK
10
+ * present. A static `import` would make the entire activity library fail
11
+ * to load. So we resolve `Context` lazily via dynamic import and cache it.
12
+ * 2. Even when installed, `Context.current()` throws when called outside an
13
+ * activity execution context. We swallow that too.
14
+ *
15
+ * Net effect: identical activity code heartbeats under a Temporal worker and
16
+ * no-ops locally, and the activity library imports cleanly without the SDK.
17
+ */
18
+
19
+ interface ActivityContext {
20
+ current(): { heartbeat(details?: unknown): void };
21
+ }
22
+
23
+ // undefined = not yet attempted; null = unavailable; object = resolved Context.
24
+ let cachedContext: ActivityContext | null | undefined;
25
+ let loading: Promise<void> | undefined;
26
+
27
+ function ensureContext(): void {
28
+ if (cachedContext !== undefined || loading) return;
29
+ // Variable specifier so bundlers/tsc do not statically require the optional dep.
30
+ const spec = "@temporalio/activity";
31
+ loading = import(spec)
32
+ .then((mod: unknown) => {
33
+ cachedContext = (mod as { Context?: ActivityContext }).Context ?? null;
34
+ })
35
+ .catch(() => {
36
+ cachedContext = null;
37
+ });
38
+ }
39
+
40
+ /**
41
+ * Emit an activity heartbeat if running under a Temporal worker; otherwise no-op.
42
+ *
43
+ * The first call kicks off a one-time lazy load of `@temporalio/activity` and
44
+ * returns immediately; once resolved, subsequent calls heartbeat. Heartbeats
45
+ * are periodic (every ~15s, well inside the 60s heartbeat timeout), so the
46
+ * single missed first tick is harmless.
47
+ */
48
+ export function safeHeartbeat(details?: unknown): void {
49
+ if (cachedContext === undefined) {
50
+ ensureContext();
51
+ return;
52
+ }
53
+ if (cachedContext === null) return;
54
+ try {
55
+ cachedContext.current().heartbeat(details);
56
+ } catch {
57
+ // Not inside an activity execution context — nothing to do.
58
+ }
59
+ }
@@ -1,6 +1,6 @@
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
4
 
5
5
  const execAsync = promisify(exec);
6
6
 
@@ -19,20 +19,20 @@ export interface HelmInstallArgs {
19
19
 
20
20
  /**
21
21
  * Run `helm upgrade --install <name> <chart>`.
22
- * Uses longInfra profile — 20m timeout, heartbeat every 60s.
22
+ * Uses longInfra profile — 20m timeout, heartbeat every 15s.
23
23
  */
24
- export async function helmInstall(args: HelmInstallArgs): Promise<void> {
24
+ export async function helmInstall(args: HelmInstallArgs, signal?: AbortSignal): Promise<void> {
25
25
  const parts = ["helm", "upgrade", "--install", "--wait", args.name, args.chart];
26
26
  if (args.namespace) parts.push("--namespace", args.namespace, "--create-namespace");
27
27
  if (args.values) parts.push("-f", args.values);
28
28
  for (const [k, v] of Object.entries(args.set ?? {})) parts.push("--set", `${k}=${v}`);
29
29
 
30
30
  const heartbeatInterval = setInterval(() => {
31
- Context.current().heartbeat({ step: "helm install", release: args.name });
31
+ safeHeartbeat({ step: "helm install", release: args.name });
32
32
  }, 15_000);
33
33
 
34
34
  try {
35
- const { stdout, stderr } = await execAsync(parts.join(" "));
35
+ const { stdout, stderr } = await execAsync(parts.join(" "), { signal });
36
36
  if (stdout) console.log(stdout);
37
37
  if (stderr) console.error(stderr);
38
38
  } finally {
@@ -16,8 +16,17 @@ export type { GitlabPipelineArgs } from "./gitlab";
16
16
  export { shellCmd } from "./shell";
17
17
  export type { ShellCmdArgs } from "./shell";
18
18
 
19
- export { stateSnapshot, stateDiff } from "./state";
20
- export type { StateSnapshotArgs, StateDiffArgs, StateDiffResult } from "./state";
19
+ export { lifecycleSnapshot, lifecycleDiff } from "./lifecycle";
20
+ export type { LifecycleSnapshotArgs, LifecycleDiffArgs, LifecycleDiffResult } from "./lifecycle";
21
21
 
22
22
  export { chantTeardown } from "./teardown";
23
23
  export type { ChantTeardownArgs } from "./teardown";
24
+
25
+ export { reconcilePr } from "./reconcile";
26
+ export type { ReconcilePrArgs, ReconcileResult, ReconcileMode, ReconcileEntry } from "./reconcile";
27
+
28
+ export { nativeApply, compensateApply } from "./apply";
29
+ export type { NativeApplyArgs, CompensateApplyArgs, ApplyTarget, DeleteMode } from "./apply";
30
+
31
+ export { waitForArgoSync, defaultArgoStatusFetcher, ArgoSyncFailedError } from "./argo";
32
+ export type { WaitForArgoSyncArgs, ArgoAppStatus, ArgoStatusFetcher } from "./argo";
@@ -1,6 +1,6 @@
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
4
 
5
5
  const execAsync = promisify(exec);
6
6
 
@@ -12,17 +12,18 @@ export interface KubectlApplyArgs {
12
12
 
13
13
  /**
14
14
  * Run `kubectl apply -f <manifest>`.
15
- * Uses longInfra profile — 20m timeout, heartbeat every 60s.
15
+ * Uses longInfra profile — 20m timeout, heartbeat every 15s.
16
16
  */
17
- export async function kubectlApply(args: KubectlApplyArgs): Promise<void> {
17
+ export async function kubectlApply(args: KubectlApplyArgs, signal?: AbortSignal): Promise<void> {
18
18
  const ctx = args.context ? `--context ${args.context}` : "";
19
19
  const heartbeatInterval = setInterval(() => {
20
- Context.current().heartbeat({ step: "kubectl apply", manifest: args.manifest });
20
+ safeHeartbeat({ step: "kubectl apply", manifest: args.manifest });
21
21
  }, 15_000);
22
22
 
23
23
  try {
24
24
  const { stdout, stderr } = await execAsync(
25
25
  `kubectl apply -f ${args.manifest} ${ctx} --wait=true`,
26
+ { signal },
26
27
  );
27
28
  if (stdout) console.log(stdout);
28
29
  if (stderr) console.error(stderr);