@intentius/chant-lexicon-temporal 0.1.5 → 0.1.8

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,300 @@
1
+ /**
2
+ * Live introspection of a Temporal cluster — implements the
3
+ * LexiconPlugin.describeResources() contract for the temporal lexicon.
4
+ *
5
+ * Connects to the cluster identified by the chant config profile that
6
+ * matches the current environment (falls back to defaultProfile), then lists:
7
+ * - Namespaces via workflowService.listNamespaces
8
+ * - SearchAttributes via operatorService.listSearchAttributes (per namespace)
9
+ * - Schedules via scheduleClient.list (per namespace)
10
+ *
11
+ * Results are keyed by server-side identifier (e.g. "namespace/prod",
12
+ * "searchAttribute/prod/Project", "schedule/prod/daily-report"). Mapping back
13
+ * to chant entity names is a separate concern (would need entity props passed
14
+ * through the plugin contract).
15
+ */
16
+
17
+ import { loadChantConfig } from "@intentius/chant/config";
18
+ import {
19
+ loadTemporalClient,
20
+ connectionOptions,
21
+ resolveProfile,
22
+ type WorkerProfile,
23
+ } from "@intentius/chant/cli/handlers/run-client";
24
+ import type { ResourceMetadata } from "@intentius/chant/lexicon";
25
+
26
+ interface NamespaceListResponse {
27
+ namespaces?: Array<{
28
+ namespaceInfo?: {
29
+ name?: string;
30
+ state?: number | string;
31
+ description?: string;
32
+ ownerEmail?: string;
33
+ } | null;
34
+ config?: {
35
+ workflowExecutionRetentionTtl?: { seconds?: number | bigint | { toNumber(): number } } | null;
36
+ } | null;
37
+ isGlobalNamespace?: boolean;
38
+ }>;
39
+ nextPageToken?: Uint8Array | null;
40
+ }
41
+
42
+ interface SearchAttributesResponse {
43
+ customAttributes?: Record<string, number | string> | null;
44
+ systemAttributes?: Record<string, number | string> | null;
45
+ }
46
+
47
+ interface ScheduleSummary {
48
+ scheduleId?: string;
49
+ spec?: { cronExpressions?: string[] } | null;
50
+ action?: { type?: string; workflowType?: string } | null;
51
+ state?: { paused?: boolean; note?: string } | null;
52
+ }
53
+
54
+ interface RichConnection {
55
+ workflowService: {
56
+ listNamespaces(req: { pageSize?: number; nextPageToken?: Uint8Array }): Promise<NamespaceListResponse>;
57
+ };
58
+ operatorService: {
59
+ listSearchAttributes(req: { namespace: string }): Promise<SearchAttributesResponse>;
60
+ };
61
+ close?(): Promise<void>;
62
+ }
63
+
64
+ interface RichClient {
65
+ scheduleClient: {
66
+ list(opts: { namespace?: string }): AsyncIterable<ScheduleSummary>;
67
+ };
68
+ }
69
+
70
+ interface RichClientModule {
71
+ Connection: { connect(opts: Record<string, unknown>): Promise<RichConnection> };
72
+ Client: new (opts: { connection: RichConnection; namespace?: string }) => RichClient;
73
+ }
74
+
75
+ const NAMESPACE_STATE_NAMES: Record<number, string> = {
76
+ 0: "UNSPECIFIED",
77
+ 1: "REGISTERED",
78
+ 2: "DEPRECATED",
79
+ 3: "DELETED",
80
+ };
81
+
82
+ const VALUE_TYPE_NAMES: Record<number, string> = {
83
+ 0: "Unspecified",
84
+ 1: "Text",
85
+ 2: "Keyword",
86
+ 3: "Int",
87
+ 4: "Double",
88
+ 5: "Bool",
89
+ 6: "Datetime",
90
+ 7: "KeywordList",
91
+ };
92
+
93
+ function namespaceStateToString(state: number | string | undefined): string {
94
+ if (typeof state === "string") return state;
95
+ if (typeof state === "number") return NAMESPACE_STATE_NAMES[state] ?? `STATE_${state}`;
96
+ return "UNKNOWN";
97
+ }
98
+
99
+ function valueTypeToString(t: number | string | undefined): string {
100
+ if (typeof t === "string") return t;
101
+ if (typeof t === "number") return VALUE_TYPE_NAMES[t] ?? `TYPE_${t}`;
102
+ return "Unknown";
103
+ }
104
+
105
+ function retentionTtlToSeconds(
106
+ ttl: { seconds?: number | bigint | { toNumber(): number } } | null | undefined,
107
+ ): number | undefined {
108
+ if (!ttl?.seconds) return undefined;
109
+ const s = ttl.seconds;
110
+ if (typeof s === "number") return s;
111
+ if (typeof s === "bigint") return Number(s);
112
+ if (typeof s === "object" && "toNumber" in s) return s.toNumber();
113
+ return undefined;
114
+ }
115
+
116
+ function resolveProfileForEnv(
117
+ config: Record<string, unknown>,
118
+ environment: string,
119
+ ): WorkerProfile {
120
+ // Try env-named profile first, fall back to defaultProfile.
121
+ try {
122
+ return resolveProfile(config, environment);
123
+ } catch {
124
+ return resolveProfile(config);
125
+ }
126
+ }
127
+
128
+ async function paginateNamespaces(connection: RichConnection): Promise<NonNullable<NamespaceListResponse["namespaces"]>> {
129
+ const all: NonNullable<NamespaceListResponse["namespaces"]> = [];
130
+ let nextPageToken: Uint8Array | undefined;
131
+ do {
132
+ const res = await connection.workflowService.listNamespaces({
133
+ pageSize: 100,
134
+ ...(nextPageToken && { nextPageToken }),
135
+ });
136
+ if (res.namespaces) all.push(...res.namespaces);
137
+ nextPageToken = res.nextPageToken && res.nextPageToken.length > 0 ? res.nextPageToken : undefined;
138
+ } while (nextPageToken);
139
+ return all;
140
+ }
141
+
142
+ /**
143
+ * Build reverse-lookup maps from server-side identifiers back to chant
144
+ * entity names, using the `entities` map passed via the plugin contract.
145
+ *
146
+ * Mapping rules:
147
+ * - Namespace: props.name -> entity name
148
+ * - SearchAttribute: <ns>/<props.name> -> entity name
149
+ * - Schedule: <ns>/<props.scheduleId> -> entity name
150
+ *
151
+ * For SearchAttribute, the namespace defaults to the *first declared
152
+ * Temporal::Namespace's name* if the entity itself doesn't pin one — this
153
+ * matches the serializer's behavior when emitting registration commands.
154
+ */
155
+ function buildEntityIndex(
156
+ entities: Map<string, { entityType: string; props: Record<string, unknown> }>,
157
+ ): {
158
+ namespaceByName: Map<string, string>;
159
+ searchAttrByKey: Map<string, string>;
160
+ scheduleByKey: Map<string, string>;
161
+ defaultNamespace?: string;
162
+ } {
163
+ const namespaceByName = new Map<string, string>();
164
+ const searchAttrByKey = new Map<string, string>();
165
+ const scheduleByKey = new Map<string, string>();
166
+ let defaultNamespace: string | undefined;
167
+
168
+ for (const [entityName, { entityType, props }] of entities) {
169
+ if (entityType === "Temporal::Namespace") {
170
+ const nsName = (props.name as string) || "";
171
+ if (nsName) {
172
+ namespaceByName.set(nsName, entityName);
173
+ if (!defaultNamespace) defaultNamespace = nsName;
174
+ }
175
+ }
176
+ }
177
+
178
+ for (const [entityName, { entityType, props }] of entities) {
179
+ if (entityType === "Temporal::SearchAttribute") {
180
+ const attrName = (props.name as string) || "";
181
+ const ns = (props.namespace as string) || defaultNamespace || "";
182
+ if (attrName && ns) searchAttrByKey.set(`${ns}/${attrName}`, entityName);
183
+ } else if (entityType === "Temporal::Schedule") {
184
+ const scheduleId = (props.scheduleId as string) || "";
185
+ const ns = (props.namespace as string) || defaultNamespace || "";
186
+ if (scheduleId && ns) scheduleByKey.set(`${ns}/${scheduleId}`, entityName);
187
+ }
188
+ }
189
+
190
+ return { namespaceByName, searchAttrByKey, scheduleByKey, defaultNamespace };
191
+ }
192
+
193
+ export async function describeResources(options: {
194
+ environment: string;
195
+ buildOutput: string;
196
+ entityNames: string[];
197
+ entities: Map<string, { entityType: string; props: Record<string, unknown> }>;
198
+ }): Promise<Record<string, ResourceMetadata>> {
199
+ const { config } = await loadChantConfig(process.cwd());
200
+ const profile = resolveProfileForEnv(config as Record<string, unknown>, options.environment);
201
+
202
+ const mod = (await loadTemporalClient()) as unknown as RichClientModule;
203
+ const connection = await mod.Connection.connect(connectionOptions(profile));
204
+ const client = new mod.Client({ connection });
205
+
206
+ const idx = buildEntityIndex(options.entities);
207
+ const result: Record<string, ResourceMetadata> = {};
208
+
209
+ // Map a server-side identifier to a chant entity name when possible;
210
+ // otherwise fall back to the server-side prefixed key (orphan).
211
+ const keyForNamespace = (name: string): string =>
212
+ idx.namespaceByName.get(name) ?? `namespace/${name}`;
213
+ const keyForSearchAttr = (ns: string, attr: string): string =>
214
+ idx.searchAttrByKey.get(`${ns}/${attr}`) ?? `searchAttribute/${ns}/${attr}`;
215
+ const keyForSchedule = (ns: string, scheduleId: string): string =>
216
+ idx.scheduleByKey.get(`${ns}/${scheduleId}`) ?? `schedule/${ns}/${scheduleId}`;
217
+
218
+ try {
219
+ const namespaces = await paginateNamespaces(connection);
220
+
221
+ for (const ns of namespaces) {
222
+ const name = ns.namespaceInfo?.name;
223
+ if (!name) continue;
224
+
225
+ result[keyForNamespace(name)] = {
226
+ type: "Temporal::Namespace",
227
+ physicalId: name,
228
+ status: namespaceStateToString(ns.namespaceInfo?.state),
229
+ attributes: pruneUndefined({
230
+ description: ns.namespaceInfo?.description,
231
+ ownerEmail: ns.namespaceInfo?.ownerEmail,
232
+ isGlobalNamespace: ns.isGlobalNamespace,
233
+ retentionSeconds: retentionTtlToSeconds(ns.config?.workflowExecutionRetentionTtl ?? undefined),
234
+ }),
235
+ };
236
+
237
+ // SearchAttributes — failure on one namespace shouldn't abort others.
238
+ try {
239
+ const sa = await connection.operatorService.listSearchAttributes({ namespace: name });
240
+ for (const [attrName, valueType] of Object.entries(sa.customAttributes ?? {})) {
241
+ result[keyForSearchAttr(name, attrName)] = {
242
+ type: "Temporal::SearchAttribute",
243
+ physicalId: `${name}/${attrName}`,
244
+ status: "REGISTERED",
245
+ attributes: {
246
+ valueType: valueTypeToString(valueType),
247
+ namespace: name,
248
+ },
249
+ };
250
+ }
251
+ } catch (err) {
252
+ // eslint-disable-next-line no-console
253
+ console.warn(
254
+ `[temporal] failed to list search attributes for namespace "${name}": ${
255
+ err instanceof Error ? err.message : String(err)
256
+ }`,
257
+ );
258
+ }
259
+
260
+ // Schedules — same fail-soft policy.
261
+ try {
262
+ for await (const s of client.scheduleClient.list({ namespace: name })) {
263
+ if (!s.scheduleId) continue;
264
+ result[keyForSchedule(name, s.scheduleId)] = {
265
+ type: "Temporal::Schedule",
266
+ physicalId: `${name}/${s.scheduleId}`,
267
+ status: s.state?.paused ? "PAUSED" : "ACTIVE",
268
+ attributes: pruneUndefined({
269
+ namespace: name,
270
+ workflowType: s.action?.workflowType,
271
+ cronExpressions: s.spec?.cronExpressions,
272
+ note: s.state?.note,
273
+ }),
274
+ };
275
+ }
276
+ } catch (err) {
277
+ // eslint-disable-next-line no-console
278
+ console.warn(
279
+ `[temporal] failed to list schedules for namespace "${name}": ${
280
+ err instanceof Error ? err.message : String(err)
281
+ }`,
282
+ );
283
+ }
284
+ }
285
+ } finally {
286
+ if (typeof connection.close === "function") {
287
+ try { await connection.close(); } catch { /* best-effort */ }
288
+ }
289
+ }
290
+
291
+ return result;
292
+ }
293
+
294
+ function pruneUndefined<T extends Record<string, unknown>>(obj: T): Record<string, unknown> {
295
+ const out: Record<string, unknown> = {};
296
+ for (const [k, v] of Object.entries(obj)) {
297
+ if (v !== undefined) out[k] = v;
298
+ }
299
+ return out;
300
+ }
package/src/index.ts CHANGED
@@ -27,3 +27,22 @@ export { TemporalDevStack } from "./composites/dev-stack";
27
27
  export type { TemporalDevStackConfig, TemporalDevStackResources } from "./composites/dev-stack";
28
28
  export { TemporalCloudStack } from "./composites/cloud-stack";
29
29
  export type { TemporalCloudStackConfig, TemporalCloudStackResources } from "./composites/cloud-stack";
30
+ export { WatchOp } from "./composites/watch-op";
31
+ export type { WatchOpConfig, WatchOpResources } from "./composites/watch-op";
32
+
33
+ // Op builders (re-exported from core for single-import convenience)
34
+ export {
35
+ Op,
36
+ phase,
37
+ activity,
38
+ gate,
39
+ build,
40
+ kubectlApply,
41
+ helmInstall,
42
+ waitForStack,
43
+ gitlabPipeline,
44
+ stateSnapshot,
45
+ shell,
46
+ teardown,
47
+ } from "@intentius/chant/op";
48
+ export type { OpConfig, PhaseDefinition, StepDefinition, ActivityStep, GateStep } from "@intentius/chant/op";
@@ -0,0 +1,23 @@
1
+ import { exec } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+
4
+ const execAsync = promisify(exec);
5
+
6
+ export interface ChantBuildArgs {
7
+ path: string;
8
+ /** Optional extra env vars to pass to the build command. */
9
+ env?: Record<string, string>;
10
+ }
11
+
12
+ /**
13
+ * Run `npm run build` in the given project directory.
14
+ * Uses fastIdempotent profile — 5m timeout, 3 retries.
15
+ */
16
+ export async function chantBuild(args: ChantBuildArgs): Promise<void> {
17
+ const { stdout, stderr } = await execAsync("npm run build", {
18
+ cwd: args.path,
19
+ env: { ...process.env, ...args.env },
20
+ });
21
+ if (stdout) console.log(stdout);
22
+ if (stderr) console.error(stderr);
23
+ }
@@ -0,0 +1,56 @@
1
+ import { exec } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { Context } from "@temporalio/activity";
4
+
5
+ const execAsync = promisify(exec);
6
+
7
+ export interface GitlabPipelineArgs {
8
+ /** GitLab project name or path (e.g. "group/project"). */
9
+ name: string;
10
+ /** Git ref to run the pipeline on. Default: current branch. */
11
+ ref?: string;
12
+ /** Poll interval in ms. Default: 30000. */
13
+ intervalMs?: number;
14
+ }
15
+
16
+ /**
17
+ * Trigger a GitLab CI pipeline and wait for it to complete successfully.
18
+ * Requires `glab` CLI authenticated in the environment.
19
+ * Uses longInfra profile — 20m timeout, heartbeat every 60s.
20
+ */
21
+ export async function gitlabPipeline(args: GitlabPipelineArgs): Promise<void> {
22
+ const ref = args.ref ?? "HEAD";
23
+ const interval = args.intervalMs ?? 30_000;
24
+
25
+ // Trigger
26
+ const { stdout: triggerOut } = await execAsync(
27
+ `glab ci run --project ${args.name} --ref ${ref}`,
28
+ );
29
+ console.log(triggerOut);
30
+
31
+ // Poll status
32
+ let attempt = 0;
33
+ while (true) {
34
+ attempt++;
35
+ Context.current().heartbeat({ step: "gitlabPipeline", project: args.name, attempt });
36
+
37
+ const { stdout } = await execAsync(
38
+ `glab ci status --project ${args.name} --format json`,
39
+ );
40
+
41
+ let status: string | undefined;
42
+ try {
43
+ const parsed = JSON.parse(stdout) as { status?: string }[];
44
+ status = parsed[0]?.status;
45
+ } catch {
46
+ // Non-JSON output — keep polling
47
+ }
48
+
49
+ if (status === "success") return;
50
+ if (status === "failed" || status === "canceled") {
51
+ throw new Error(`GitLab pipeline for ${args.name} ended with status: ${status}`);
52
+ }
53
+
54
+ await new Promise((r) => setTimeout(r, interval));
55
+ }
56
+ }
@@ -0,0 +1,41 @@
1
+ import { exec } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { Context } from "@temporalio/activity";
4
+
5
+ const execAsync = promisify(exec);
6
+
7
+ export interface HelmInstallArgs {
8
+ /** Helm release name. */
9
+ name: string;
10
+ /** Chart reference (local path or `repo/chart`). */
11
+ chart: string;
12
+ /** Path to a values file. */
13
+ values?: string;
14
+ /** Kubernetes namespace. */
15
+ namespace?: string;
16
+ /** Additional --set arguments. */
17
+ set?: Record<string, string>;
18
+ }
19
+
20
+ /**
21
+ * Run `helm upgrade --install <name> <chart>`.
22
+ * Uses longInfra profile — 20m timeout, heartbeat every 60s.
23
+ */
24
+ export async function helmInstall(args: HelmInstallArgs): Promise<void> {
25
+ const parts = ["helm", "upgrade", "--install", "--wait", args.name, args.chart];
26
+ if (args.namespace) parts.push("--namespace", args.namespace, "--create-namespace");
27
+ if (args.values) parts.push("-f", args.values);
28
+ for (const [k, v] of Object.entries(args.set ?? {})) parts.push("--set", `${k}=${v}`);
29
+
30
+ const heartbeatInterval = setInterval(() => {
31
+ Context.current().heartbeat({ step: "helm install", release: args.name });
32
+ }, 15_000);
33
+
34
+ try {
35
+ const { stdout, stderr } = await execAsync(parts.join(" "));
36
+ if (stdout) console.log(stdout);
37
+ if (stderr) console.error(stderr);
38
+ } finally {
39
+ clearInterval(heartbeatInterval);
40
+ }
41
+ }
@@ -0,0 +1,23 @@
1
+ export { chantBuild } from "./build";
2
+ export type { ChantBuildArgs } from "./build";
3
+
4
+ export { kubectlApply } from "./kubectl";
5
+ export type { KubectlApplyArgs } from "./kubectl";
6
+
7
+ export { helmInstall } from "./helm";
8
+ export type { HelmInstallArgs } from "./helm";
9
+
10
+ export { waitForStack } from "./wait";
11
+ export type { WaitForStackArgs } from "./wait";
12
+
13
+ export { gitlabPipeline } from "./gitlab";
14
+ export type { GitlabPipelineArgs } from "./gitlab";
15
+
16
+ export { shellCmd } from "./shell";
17
+ export type { ShellCmdArgs } from "./shell";
18
+
19
+ export { stateSnapshot, stateDiff } from "./state";
20
+ export type { StateSnapshotArgs, StateDiffArgs, StateDiffResult } from "./state";
21
+
22
+ export { chantTeardown } from "./teardown";
23
+ export type { ChantTeardownArgs } from "./teardown";
@@ -0,0 +1,32 @@
1
+ import { exec } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { Context } from "@temporalio/activity";
4
+
5
+ const execAsync = promisify(exec);
6
+
7
+ export interface KubectlApplyArgs {
8
+ manifest: string;
9
+ /** kubectl context name. Uses current context if omitted. */
10
+ context?: string;
11
+ }
12
+
13
+ /**
14
+ * Run `kubectl apply -f <manifest>`.
15
+ * Uses longInfra profile — 20m timeout, heartbeat every 60s.
16
+ */
17
+ export async function kubectlApply(args: KubectlApplyArgs): Promise<void> {
18
+ const ctx = args.context ? `--context ${args.context}` : "";
19
+ const heartbeatInterval = setInterval(() => {
20
+ Context.current().heartbeat({ step: "kubectl apply", manifest: args.manifest });
21
+ }, 15_000);
22
+
23
+ try {
24
+ const { stdout, stderr } = await execAsync(
25
+ `kubectl apply -f ${args.manifest} ${ctx} --wait=true`,
26
+ );
27
+ if (stdout) console.log(stdout);
28
+ if (stderr) console.error(stderr);
29
+ } finally {
30
+ clearInterval(heartbeatInterval);
31
+ }
32
+ }
@@ -0,0 +1,25 @@
1
+ import { exec } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+
4
+ const execAsync = promisify(exec);
5
+
6
+ export interface ShellCmdArgs {
7
+ cmd: string;
8
+ /** Additional environment variables. */
9
+ env?: Record<string, string>;
10
+ /** Working directory. Default: process.cwd(). */
11
+ cwd?: string;
12
+ }
13
+
14
+ /**
15
+ * Run an arbitrary shell command.
16
+ * Uses fastIdempotent profile — 5m timeout, 3 retries.
17
+ */
18
+ export async function shellCmd(args: ShellCmdArgs): Promise<string> {
19
+ const { stdout, stderr } = await execAsync(args.cmd, {
20
+ cwd: args.cwd,
21
+ env: { ...process.env, ...args.env },
22
+ });
23
+ if (stderr) console.error(stderr);
24
+ return stdout.trim();
25
+ }
@@ -0,0 +1,85 @@
1
+ import { exec } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+
4
+ const execAsync = promisify(exec);
5
+
6
+ export interface StateSnapshotArgs {
7
+ /** Environment name (e.g. "dev", "staging", "prod"). */
8
+ env: string;
9
+ }
10
+
11
+ /**
12
+ * Take a chant state snapshot for the given environment.
13
+ * Uses fastIdempotent profile — 5m timeout, 3 retries.
14
+ */
15
+ export async function stateSnapshot(args: StateSnapshotArgs): Promise<void> {
16
+ const { stdout, stderr } = await execAsync(`chant state snapshot ${args.env}`);
17
+ if (stdout) console.log(stdout);
18
+ if (stderr) console.error(stderr);
19
+ }
20
+
21
+ export interface StateDiffArgs {
22
+ /** Environment name (e.g. "dev", "staging", "prod"). */
23
+ env: string;
24
+ /**
25
+ * When true, run `chant state diff <env> --live` (queries cloud APIs).
26
+ * When false (default), run digest-only diff against the last snapshot.
27
+ */
28
+ live?: boolean;
29
+ }
30
+
31
+ export interface StateDiffResult {
32
+ /** Combined stdout + stderr from the chant command. */
33
+ output: string;
34
+ /** Process exit code (0 = success). */
35
+ exitCode: number;
36
+ /**
37
+ * True when the diff output contains any drift indicators
38
+ * (MISSING / ORPHAN / DRIFTED / DISAPPEARED section headers from
39
+ * `chant state diff --live`).
40
+ */
41
+ drifted: boolean;
42
+ }
43
+
44
+ /**
45
+ * Section headers emitted by `chant state diff --live` that indicate a
46
+ * non-empty drift category. See packages/core/src/cli/handlers/state.ts.
47
+ */
48
+ const DRIFT_HEADERS = [
49
+ "MISSING",
50
+ "ORPHAN",
51
+ "DISAPPEARED",
52
+ "DRIFTED",
53
+ "ARTIFACTS ADDED",
54
+ "ARTIFACTS REMOVED",
55
+ "ARTIFACTS CHANGED",
56
+ ];
57
+
58
+ function detectDrift(output: string): boolean {
59
+ return DRIFT_HEADERS.some((h) => output.includes(`${h} (`) || output.includes(`\n${h}`));
60
+ }
61
+
62
+ /**
63
+ * Run `chant state diff <env>` and return the output + structured drift
64
+ * flag. Read-only; intended for use inside watch/observation workflows.
65
+ * Uses fastIdempotent profile.
66
+ *
67
+ * The `drifted` field is computed by scanning the output for any of the
68
+ * MISSING / ORPHAN / DRIFTED / DISAPPEARED section headers documented in
69
+ * cli/state.mdx. Pair with `outcomeAttribute: { name: "Drift", from: "drifted" }`
70
+ * on a WatchOp activity step to surface drift as a workflow search attribute.
71
+ */
72
+ export async function stateDiff(args: StateDiffArgs): Promise<StateDiffResult> {
73
+ const liveFlag = args.live ? " --live" : "";
74
+ try {
75
+ const { stdout, stderr } = await execAsync(`chant state diff ${args.env}${liveFlag}`);
76
+ const output = `${stdout}${stderr}`.trim();
77
+ if (output) console.log(output);
78
+ return { output, exitCode: 0, drifted: detectDrift(output) };
79
+ } catch (err) {
80
+ const e = err as { code?: number; stdout?: string; stderr?: string };
81
+ const output = `${e.stdout ?? ""}${e.stderr ?? ""}`.trim();
82
+ if (output) console.error(output);
83
+ return { output, exitCode: e.code ?? 1, drifted: detectDrift(output) };
84
+ }
85
+ }
@@ -0,0 +1,21 @@
1
+ import { exec } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+
4
+ const execAsync = promisify(exec);
5
+
6
+ export interface ChantTeardownArgs {
7
+ /** Path to the chant project to tear down. */
8
+ path: string;
9
+ }
10
+
11
+ /**
12
+ * Run `chant teardown` in the given project path.
13
+ * Uses longInfra profile — 20m timeout, heartbeat every 60s.
14
+ */
15
+ export async function chantTeardown(args: ChantTeardownArgs): Promise<void> {
16
+ const { stdout, stderr } = await execAsync("npm run teardown", {
17
+ cwd: args.path,
18
+ });
19
+ if (stdout) console.log(stdout);
20
+ if (stderr) console.error(stderr);
21
+ }
@@ -0,0 +1,52 @@
1
+ import { exec } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { Context } from "@temporalio/activity";
4
+
5
+ const execAsync = promisify(exec);
6
+
7
+ export interface WaitForStackArgs {
8
+ /** Stack name — used to locate the kubectl deployment/statefulset to poll. */
9
+ name: string;
10
+ /** Kubernetes namespace. */
11
+ namespace?: string;
12
+ /** kubectl context. */
13
+ context?: string;
14
+ /** Poll interval in ms. Default: 10000. */
15
+ intervalMs?: number;
16
+ }
17
+
18
+ /**
19
+ * Poll until a Kubernetes Deployment or StatefulSet named `name` is fully rolled out.
20
+ * Uses k8sWait profile — 15m timeout, heartbeat every 60s.
21
+ */
22
+ export async function waitForStack(args: WaitForStackArgs): Promise<void> {
23
+ const ns = args.namespace ? `-n ${args.namespace}` : "";
24
+ const ctx = args.context ? `--context ${args.context}` : "";
25
+ const interval = args.intervalMs ?? 10_000;
26
+ let attempt = 0;
27
+
28
+ while (true) {
29
+ attempt++;
30
+ Context.current().heartbeat({ step: "waitForStack", stack: args.name, attempt });
31
+
32
+ try {
33
+ await execAsync(
34
+ `kubectl rollout status deployment/${args.name} ${ns} ${ctx} --timeout=30s`,
35
+ );
36
+ return;
37
+ } catch {
38
+ // Not ready yet — wait and retry
39
+ }
40
+
41
+ try {
42
+ await execAsync(
43
+ `kubectl rollout status statefulset/${args.name} ${ns} ${ctx} --timeout=30s`,
44
+ );
45
+ return;
46
+ } catch {
47
+ // Not ready yet
48
+ }
49
+
50
+ await new Promise((r) => setTimeout(r, interval));
51
+ }
52
+ }