diff --git a/examples/gitlab-aws-alb-op/ops/alb-deploy.op.ts b/examples/gitlab-aws-alb-op/ops/alb-deploy.op.ts new file mode 100644 index 00000000..5c18ef62 --- /dev/null +++ b/examples/gitlab-aws-alb-op/ops/alb-deploy.op.ts @@ -0,0 +1,39 @@ +/** + * ALB multi-service deploy Op. + * + * Demonstrates the Op pattern: a named, phased Temporal workflow declared + * as infrastructure code. Run `chant build ops/ -o dist/` to generate + * dist/ops/alb-deploy/workflow.ts, worker.ts, and activities.ts. + * + * Phases: + * 1. Build (parallel) — build all three services concurrently + * 2. Deploy — apply manifests sequentially (ordered by dependency) + * 3. Verify — wait for rollout, then snapshot state + */ +import { Op, phase, build, kubectlApply, waitForStack, stateSnapshot } from "@intentius/chant-lexicon-temporal"; + +export default Op({ + name: "alb-deploy", + overview: "Build and deploy the ALB multi-service stack to the target environment", + taskQueue: "alb-deploy", + + phases: [ + phase("Build", [ + build("examples/gitlab-aws-alb-infra"), + build("examples/gitlab-aws-alb-api"), + build("examples/gitlab-aws-alb-ui"), + ], { parallel: true }), + + phase("Deploy", [ + kubectlApply("dist/alb-infra.yaml"), + kubectlApply("dist/alb-api.yaml"), + kubectlApply("dist/alb-ui.yaml"), + ]), + + phase("Verify", [ + waitForStack("alb-api", { namespace: "alb" }), + waitForStack("alb-ui", { namespace: "alb" }), + stateSnapshot("staging"), + ]), + ], +}); diff --git a/lexicons/temporal/package.json b/lexicons/temporal/package.json index 6d46f5eb..babcd295 100644 --- a/lexicons/temporal/package.json +++ b/lexicons/temporal/package.json @@ -1,6 +1,6 @@ { "name": "@intentius/chant-lexicon-temporal", - "version": "0.1.5", + "version": "0.1.6", "description": "Temporal lexicon for chant — server deployment, namespaces, search attributes, and schedules", "license": "Apache-2.0", "type": "module", @@ -11,6 +11,7 @@ "exports": { ".": "./src/index.ts", "./*": "./src/*.ts", + "./op/activities": "./src/op/activities/index.ts", "./manifest": "./dist/manifest.json", "./meta": "./dist/meta.json", "./types": "./dist/types/index.d.ts" diff --git a/lexicons/temporal/src/index.ts b/lexicons/temporal/src/index.ts index b431d424..fe2d8fda 100644 --- a/lexicons/temporal/src/index.ts +++ b/lexicons/temporal/src/index.ts @@ -27,3 +27,20 @@ export { TemporalDevStack } from "./composites/dev-stack"; export type { TemporalDevStackConfig, TemporalDevStackResources } from "./composites/dev-stack"; export { TemporalCloudStack } from "./composites/cloud-stack"; export type { TemporalCloudStackConfig, TemporalCloudStackResources } from "./composites/cloud-stack"; + +// Op builders (re-exported from core for single-import convenience) +export { + Op, + phase, + activity, + gate, + build, + kubectlApply, + helmInstall, + waitForStack, + gitlabPipeline, + stateSnapshot, + shell, + teardown, +} from "@intentius/chant/op"; +export type { OpConfig, PhaseDefinition, StepDefinition, ActivityStep, GateStep } from "@intentius/chant/op"; diff --git a/lexicons/temporal/src/op/activities/build.ts b/lexicons/temporal/src/op/activities/build.ts new file mode 100644 index 00000000..372626cc --- /dev/null +++ b/lexicons/temporal/src/op/activities/build.ts @@ -0,0 +1,23 @@ +import { exec } from "node:child_process"; +import { promisify } from "node:util"; + +const execAsync = promisify(exec); + +export interface ChantBuildArgs { + path: string; + /** Optional extra env vars to pass to the build command. */ + env?: Record; +} + +/** + * Run `npm run build` in the given project directory. + * Uses fastIdempotent profile — 5m timeout, 3 retries. + */ +export async function chantBuild(args: ChantBuildArgs): Promise { + const { stdout, stderr } = await execAsync("npm run build", { + cwd: args.path, + env: { ...process.env, ...args.env }, + }); + if (stdout) console.log(stdout); + if (stderr) console.error(stderr); +} diff --git a/lexicons/temporal/src/op/activities/gitlab.ts b/lexicons/temporal/src/op/activities/gitlab.ts new file mode 100644 index 00000000..ad9c9eab --- /dev/null +++ b/lexicons/temporal/src/op/activities/gitlab.ts @@ -0,0 +1,56 @@ +import { exec } from "node:child_process"; +import { promisify } from "node:util"; +import { Context } from "@temporalio/activity"; + +const execAsync = promisify(exec); + +export interface GitlabPipelineArgs { + /** GitLab project name or path (e.g. "group/project"). */ + name: string; + /** Git ref to run the pipeline on. Default: current branch. */ + ref?: string; + /** Poll interval in ms. Default: 30000. */ + intervalMs?: number; +} + +/** + * Trigger a GitLab CI pipeline and wait for it to complete successfully. + * Requires `glab` CLI authenticated in the environment. + * Uses longInfra profile — 20m timeout, heartbeat every 60s. + */ +export async function gitlabPipeline(args: GitlabPipelineArgs): Promise { + const ref = args.ref ?? "HEAD"; + const interval = args.intervalMs ?? 30_000; + + // Trigger + const { stdout: triggerOut } = await execAsync( + `glab ci run --project ${args.name} --ref ${ref}`, + ); + console.log(triggerOut); + + // Poll status + let attempt = 0; + while (true) { + attempt++; + Context.current().heartbeat({ step: "gitlabPipeline", project: args.name, attempt }); + + const { stdout } = await execAsync( + `glab ci status --project ${args.name} --format json`, + ); + + let status: string | undefined; + try { + const parsed = JSON.parse(stdout) as { status?: string }[]; + status = parsed[0]?.status; + } catch { + // Non-JSON output — keep polling + } + + if (status === "success") return; + if (status === "failed" || status === "canceled") { + throw new Error(`GitLab pipeline for ${args.name} ended with status: ${status}`); + } + + await new Promise((r) => setTimeout(r, interval)); + } +} diff --git a/lexicons/temporal/src/op/activities/helm.ts b/lexicons/temporal/src/op/activities/helm.ts new file mode 100644 index 00000000..65c042ed --- /dev/null +++ b/lexicons/temporal/src/op/activities/helm.ts @@ -0,0 +1,41 @@ +import { exec } from "node:child_process"; +import { promisify } from "node:util"; +import { Context } from "@temporalio/activity"; + +const execAsync = promisify(exec); + +export interface HelmInstallArgs { + /** Helm release name. */ + name: string; + /** Chart reference (local path or `repo/chart`). */ + chart: string; + /** Path to a values file. */ + values?: string; + /** Kubernetes namespace. */ + namespace?: string; + /** Additional --set arguments. */ + set?: Record; +} + +/** + * Run `helm upgrade --install `. + * Uses longInfra profile — 20m timeout, heartbeat every 60s. + */ +export async function helmInstall(args: HelmInstallArgs): Promise { + const parts = ["helm", "upgrade", "--install", "--wait", args.name, args.chart]; + if (args.namespace) parts.push("--namespace", args.namespace, "--create-namespace"); + if (args.values) parts.push("-f", args.values); + for (const [k, v] of Object.entries(args.set ?? {})) parts.push("--set", `${k}=${v}`); + + const heartbeatInterval = setInterval(() => { + Context.current().heartbeat({ step: "helm install", release: args.name }); + }, 15_000); + + try { + const { stdout, stderr } = await execAsync(parts.join(" ")); + if (stdout) console.log(stdout); + if (stderr) console.error(stderr); + } finally { + clearInterval(heartbeatInterval); + } +} diff --git a/lexicons/temporal/src/op/activities/index.ts b/lexicons/temporal/src/op/activities/index.ts new file mode 100644 index 00000000..af7e2a00 --- /dev/null +++ b/lexicons/temporal/src/op/activities/index.ts @@ -0,0 +1,23 @@ +export { chantBuild } from "./build"; +export type { ChantBuildArgs } from "./build"; + +export { kubectlApply } from "./kubectl"; +export type { KubectlApplyArgs } from "./kubectl"; + +export { helmInstall } from "./helm"; +export type { HelmInstallArgs } from "./helm"; + +export { waitForStack } from "./wait"; +export type { WaitForStackArgs } from "./wait"; + +export { gitlabPipeline } from "./gitlab"; +export type { GitlabPipelineArgs } from "./gitlab"; + +export { shellCmd } from "./shell"; +export type { ShellCmdArgs } from "./shell"; + +export { stateSnapshot } from "./state"; +export type { StateSnapshotArgs } from "./state"; + +export { chantTeardown } from "./teardown"; +export type { ChantTeardownArgs } from "./teardown"; diff --git a/lexicons/temporal/src/op/activities/kubectl.ts b/lexicons/temporal/src/op/activities/kubectl.ts new file mode 100644 index 00000000..4faf9567 --- /dev/null +++ b/lexicons/temporal/src/op/activities/kubectl.ts @@ -0,0 +1,32 @@ +import { exec } from "node:child_process"; +import { promisify } from "node:util"; +import { Context } from "@temporalio/activity"; + +const execAsync = promisify(exec); + +export interface KubectlApplyArgs { + manifest: string; + /** kubectl context name. Uses current context if omitted. */ + context?: string; +} + +/** + * Run `kubectl apply -f `. + * Uses longInfra profile — 20m timeout, heartbeat every 60s. + */ +export async function kubectlApply(args: KubectlApplyArgs): Promise { + const ctx = args.context ? `--context ${args.context}` : ""; + const heartbeatInterval = setInterval(() => { + Context.current().heartbeat({ step: "kubectl apply", manifest: args.manifest }); + }, 15_000); + + try { + const { stdout, stderr } = await execAsync( + `kubectl apply -f ${args.manifest} ${ctx} --wait=true`, + ); + if (stdout) console.log(stdout); + if (stderr) console.error(stderr); + } finally { + clearInterval(heartbeatInterval); + } +} diff --git a/lexicons/temporal/src/op/activities/shell.ts b/lexicons/temporal/src/op/activities/shell.ts new file mode 100644 index 00000000..744d5e6a --- /dev/null +++ b/lexicons/temporal/src/op/activities/shell.ts @@ -0,0 +1,25 @@ +import { exec } from "node:child_process"; +import { promisify } from "node:util"; + +const execAsync = promisify(exec); + +export interface ShellCmdArgs { + cmd: string; + /** Additional environment variables. */ + env?: Record; + /** Working directory. Default: process.cwd(). */ + cwd?: string; +} + +/** + * Run an arbitrary shell command. + * Uses fastIdempotent profile — 5m timeout, 3 retries. + */ +export async function shellCmd(args: ShellCmdArgs): Promise { + const { stdout, stderr } = await execAsync(args.cmd, { + cwd: args.cwd, + env: { ...process.env, ...args.env }, + }); + if (stderr) console.error(stderr); + return stdout.trim(); +} diff --git a/lexicons/temporal/src/op/activities/state.ts b/lexicons/temporal/src/op/activities/state.ts new file mode 100644 index 00000000..75db9ae4 --- /dev/null +++ b/lexicons/temporal/src/op/activities/state.ts @@ -0,0 +1,19 @@ +import { exec } from "node:child_process"; +import { promisify } from "node:util"; + +const execAsync = promisify(exec); + +export interface StateSnapshotArgs { + /** Environment name (e.g. "dev", "staging", "prod"). */ + env: string; +} + +/** + * Take a chant state snapshot for the given environment. + * Uses fastIdempotent profile — 5m timeout, 3 retries. + */ +export async function stateSnapshot(args: StateSnapshotArgs): Promise { + const { stdout, stderr } = await execAsync(`chant state snapshot ${args.env}`); + if (stdout) console.log(stdout); + if (stderr) console.error(stderr); +} diff --git a/lexicons/temporal/src/op/activities/teardown.ts b/lexicons/temporal/src/op/activities/teardown.ts new file mode 100644 index 00000000..9e3ed31c --- /dev/null +++ b/lexicons/temporal/src/op/activities/teardown.ts @@ -0,0 +1,21 @@ +import { exec } from "node:child_process"; +import { promisify } from "node:util"; + +const execAsync = promisify(exec); + +export interface ChantTeardownArgs { + /** Path to the chant project to tear down. */ + path: string; +} + +/** + * Run `chant teardown` in the given project path. + * Uses longInfra profile — 20m timeout, heartbeat every 60s. + */ +export async function chantTeardown(args: ChantTeardownArgs): Promise { + const { stdout, stderr } = await execAsync("npm run teardown", { + cwd: args.path, + }); + if (stdout) console.log(stdout); + if (stderr) console.error(stderr); +} diff --git a/lexicons/temporal/src/op/activities/wait.ts b/lexicons/temporal/src/op/activities/wait.ts new file mode 100644 index 00000000..423be1c0 --- /dev/null +++ b/lexicons/temporal/src/op/activities/wait.ts @@ -0,0 +1,52 @@ +import { exec } from "node:child_process"; +import { promisify } from "node:util"; +import { Context } from "@temporalio/activity"; + +const execAsync = promisify(exec); + +export interface WaitForStackArgs { + /** Stack name — used to locate the kubectl deployment/statefulset to poll. */ + name: string; + /** Kubernetes namespace. */ + namespace?: string; + /** kubectl context. */ + context?: string; + /** Poll interval in ms. Default: 10000. */ + intervalMs?: number; +} + +/** + * Poll until a Kubernetes Deployment or StatefulSet named `name` is fully rolled out. + * Uses k8sWait profile — 15m timeout, heartbeat every 60s. + */ +export async function waitForStack(args: WaitForStackArgs): Promise { + const ns = args.namespace ? `-n ${args.namespace}` : ""; + const ctx = args.context ? `--context ${args.context}` : ""; + const interval = args.intervalMs ?? 10_000; + let attempt = 0; + + while (true) { + attempt++; + Context.current().heartbeat({ step: "waitForStack", stack: args.name, attempt }); + + try { + await execAsync( + `kubectl rollout status deployment/${args.name} ${ns} ${ctx} --timeout=30s`, + ); + return; + } catch { + // Not ready yet — wait and retry + } + + try { + await execAsync( + `kubectl rollout status statefulset/${args.name} ${ns} ${ctx} --timeout=30s`, + ); + return; + } catch { + // Not ready yet + } + + await new Promise((r) => setTimeout(r, interval)); + } +} diff --git a/lexicons/temporal/src/op/op-serializer.test.ts b/lexicons/temporal/src/op/op-serializer.test.ts new file mode 100644 index 00000000..2dd3e054 --- /dev/null +++ b/lexicons/temporal/src/op/op-serializer.test.ts @@ -0,0 +1,277 @@ +/** + * Op serializer tests — verifies that Temporal::Op entities generate + * the correct workflow.ts, activities.ts, and worker.ts files. + */ + +import { describe, expect, it } from "vitest"; +import { serializeOps } from "./serializer"; +import { DECLARABLE_MARKER } from "@intentius/chant/declarable"; +import type { OpConfig } from "@intentius/chant/op"; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function makeOp(config: OpConfig): [string, Record] { + return [ + config.name, + { + [DECLARABLE_MARKER]: true, + entityType: "Temporal::Op", + lexicon: "temporal", + kind: "resource", + props: config, + attributes: {}, + }, + ]; +} + +// ── Basic generation ────────────────────────────────────────────────────────── + +describe("serializeOps()", () => { + it("returns empty object for empty map", () => { + expect(serializeOps(new Map())).toEqual({}); + }); + + it("generates workflow.ts, activities.ts, worker.ts for each Op", () => { + const ops = new Map([ + makeOp({ name: "alb-deploy", overview: "ALB deploy", phases: [] }), + ]); + const files = serializeOps(ops); + expect(files["ops/alb-deploy/workflow.ts"]).toBeDefined(); + expect(files["ops/alb-deploy/activities.ts"]).toBeDefined(); + expect(files["ops/alb-deploy/worker.ts"]).toBeDefined(); + }); + + it("generates files for multiple Ops under separate directories", () => { + const ops = new Map([ + makeOp({ name: "op-a", overview: "A", phases: [] }), + makeOp({ name: "op-b", overview: "B", phases: [] }), + ]); + const files = serializeOps(ops); + expect(files["ops/op-a/workflow.ts"]).toBeDefined(); + expect(files["ops/op-b/workflow.ts"]).toBeDefined(); + }); + + // ── workflow.ts ───────────────────────────────────────────────────────────── + + describe("workflow.ts", () => { + it("exports a camelCase workflow function named after the Op", () => { + const ops = new Map([ + makeOp({ name: "alb-deploy", overview: "o", phases: [] }), + ]); + const wf = serializeOps(ops)["ops/alb-deploy/workflow.ts"]; + expect(wf).toContain("export async function albDeployWorkflow()"); + }); + + it("imports proxyActivities, condition, defineSignal, setHandler from @temporalio/workflow", () => { + const ops = new Map([makeOp({ name: "my-op", overview: "o", phases: [] })]); + const wf = serializeOps(ops)["ops/my-op/workflow.ts"]; + expect(wf).toContain("from '@temporalio/workflow'"); + expect(wf).toContain("proxyActivities"); + expect(wf).toContain("condition"); + expect(wf).toContain("defineSignal"); + expect(wf).toContain("setHandler"); + }); + + it("imports TEMPORAL_ACTIVITY_PROFILES from @intentius/chant-lexicon-temporal", () => { + const ops = new Map([makeOp({ name: "my-op", overview: "o", phases: [] })]); + const wf = serializeOps(ops)["ops/my-op/workflow.ts"]; + expect(wf).toContain("TEMPORAL_ACTIVITY_PROFILES"); + expect(wf).toContain("@intentius/chant-lexicon-temporal"); + }); + + it("groups activities by profile in proxyActivities calls", () => { + const ops = new Map([ + makeOp({ + name: "deploy", overview: "o", + phases: [ + { name: "Build", steps: [{ kind: "activity", fn: "chantBuild", args: { path: "./a" } }] }, + { name: "Deploy", steps: [{ kind: "activity", fn: "helmInstall", args: { name: "r", chart: "c" }, profile: "longInfra" }] }, + ], + }), + ]); + const wf = serializeOps(ops)["ops/deploy/workflow.ts"]; + expect(wf).toContain("TEMPORAL_ACTIVITY_PROFILES.fastIdempotent"); + expect(wf).toContain("TEMPORAL_ACTIVITY_PROFILES.longInfra"); + }); + + it("generates sequential await calls for a non-parallel phase", () => { + const ops = new Map([ + makeOp({ + name: "seq-op", overview: "o", + phases: [{ + name: "Deploy", + steps: [ + { kind: "activity", fn: "chantBuild", args: { path: "./a" } }, + { kind: "activity", fn: "kubectlApply", args: { manifest: "out.yaml" }, profile: "longInfra" }, + ], + }], + }), + ]); + const wf = serializeOps(ops)["ops/seq-op/workflow.ts"]; + expect(wf).toContain("await chantBuild("); + expect(wf).toContain("await kubectlApply("); + expect(wf).not.toContain("Promise.all"); + }); + + it("generates Promise.all for a parallel phase", () => { + const ops = new Map([ + makeOp({ + name: "par-op", overview: "o", + phases: [{ + name: "Build", + parallel: true, + steps: [ + { kind: "activity", fn: "chantBuild", args: { path: "./a" } }, + { kind: "activity", fn: "chantBuild", args: { path: "./b" } }, + ], + }], + }), + ]); + const wf = serializeOps(ops)["ops/par-op/workflow.ts"]; + expect(wf).toContain("Promise.all"); + expect(wf).toContain("chantBuild({"); + }); + + it("generates gate: defineSignal, setHandler, condition", () => { + const ops = new Map([ + makeOp({ + name: "gate-op", overview: "o", + phases: [{ + name: "Approval", + steps: [{ kind: "gate", signalName: "gate-dns-delegation", timeout: "48h" }], + }], + }), + ]); + const wf = serializeOps(ops)["ops/gate-op/workflow.ts"]; + expect(wf).toContain("defineSignal"); + expect(wf).toContain('"gate-dns-delegation"'); + expect(wf).toContain("setHandler"); + expect(wf).toContain("condition"); + expect(wf).toContain('"48h"'); + }); + + it("uses 48h as default gate timeout when not specified", () => { + const ops = new Map([ + makeOp({ + name: "gate-op", overview: "o", + phases: [{ name: "Wait", steps: [{ kind: "gate", signalName: "my-signal" }] }], + }), + ]); + const wf = serializeOps(ops)["ops/gate-op/workflow.ts"]; + expect(wf).toContain('"48h"'); + }); + + it("uses kebab-to-camel for signal handler variable name", () => { + const ops = new Map([ + makeOp({ + name: "op", overview: "o", + phases: [{ name: "W", steps: [{ kind: "gate", signalName: "gate-dns-delegation" }] }], + }), + ]); + const wf = serializeOps(ops)["ops/op/workflow.ts"]; + // "gate-dns-delegation" → resumeDnsDelegation + expect(wf).toContain("resumeDnsDelegation"); + }); + + it("passes activity args as JSON object", () => { + const ops = new Map([ + makeOp({ + name: "op", overview: "o", + phases: [{ name: "P", steps: [{ kind: "activity", fn: "chantBuild", args: { path: "my/path" } }] }], + }), + ]); + const wf = serializeOps(ops)["ops/op/workflow.ts"]; + expect(wf).toContain('"path":"my/path"'); + }); + + it("includes phase comment for each phase", () => { + const ops = new Map([ + makeOp({ + name: "op", overview: "o", + phases: [{ name: "Build and Test", steps: [{ kind: "activity", fn: "chantBuild", args: { path: "./" } }] }], + }), + ]); + const wf = serializeOps(ops)["ops/op/workflow.ts"]; + expect(wf).toContain("// Phase: Build and Test"); + }); + }); + + // ── activities.ts ─────────────────────────────────────────────────────────── + + describe("activities.ts", () => { + it("re-exports from @intentius/chant-lexicon-temporal/op/activities", () => { + const ops = new Map([makeOp({ name: "op", overview: "o", phases: [] })]); + const act = serializeOps(ops)["ops/op/activities.ts"]; + expect(act).toContain("export * from '@intentius/chant-lexicon-temporal/op/activities'"); + }); + }); + + // ── worker.ts ─────────────────────────────────────────────────────────────── + + describe("worker.ts", () => { + it("imports Worker and NativeConnection from @temporalio/worker", () => { + const ops = new Map([makeOp({ name: "op", overview: "o", phases: [] })]); + const w = serializeOps(ops)["ops/op/worker.ts"]; + expect(w).toContain("@temporalio/worker"); + expect(w).toContain("Worker"); + expect(w).toContain("NativeConnection"); + }); + + it("reads chant.config.js (relative import from ops//)", () => { + const ops = new Map([makeOp({ name: "op", overview: "o", phases: [] })]); + const w = serializeOps(ops)["ops/op/worker.ts"]; + expect(w).toContain("chant.config.js"); + expect(w).toContain("../../chant.config.js"); + }); + + it("uses op name as default task queue when taskQueue not specified", () => { + const ops = new Map([makeOp({ name: "alb-deploy", overview: "o", phases: [] })]); + const w = serializeOps(ops)["ops/alb-deploy/worker.ts"]; + expect(w).toContain("alb-deploy"); + }); + + it("uses custom taskQueue when specified", () => { + const ops = new Map([makeOp({ name: "my-op", overview: "o", phases: [], taskQueue: "custom-q" })]); + const w = serializeOps(ops)["ops/my-op/worker.ts"]; + expect(w).toContain("custom-q"); + }); + + it("references workflow.js (compiled JS) not workflow.ts", () => { + const ops = new Map([makeOp({ name: "op", overview: "o", phases: [] })]); + const w = serializeOps(ops)["ops/op/worker.ts"]; + expect(w).toContain("./workflow.js"); + }); + + it("imports activities from ./activities.js", () => { + const ops = new Map([makeOp({ name: "op", overview: "o", phases: [] })]); + const w = serializeOps(ops)["ops/op/worker.ts"]; + expect(w).toContain("./activities.js"); + }); + + it("resolves TLS and apiKey from profile", () => { + const ops = new Map([makeOp({ name: "op", overview: "o", phases: [] })]); + const w = serializeOps(ops)["ops/op/worker.ts"]; + expect(w).toContain("profile.tls"); + expect(w).toContain("apiKey"); + }); + }); + + // ── depends validation ────────────────────────────────────────────────────── + + describe("depends validation", () => { + it("accepts depends on known Op names", () => { + const ops = new Map([ + makeOp({ name: "first", overview: "o", phases: [] }), + makeOp({ name: "second", overview: "o", phases: [], depends: ["first"] }), + ]); + expect(() => serializeOps(ops)).not.toThrow(); + }); + + it("throws when depends references an unknown Op name", () => { + const ops = new Map([ + makeOp({ name: "op", overview: "o", phases: [], depends: ["nonexistent-op"] }), + ]); + expect(() => serializeOps(ops)).toThrow(/nonexistent-op/); + }); + }); +}); diff --git a/lexicons/temporal/src/op/serializer.ts b/lexicons/temporal/src/op/serializer.ts new file mode 100644 index 00000000..9fe5dc81 --- /dev/null +++ b/lexicons/temporal/src/op/serializer.ts @@ -0,0 +1,287 @@ +/** + * Op serializer — generates Temporal workflow, worker, and activities files + * for each Temporal::Op entity. + * + * For an Op named "alb-deploy" it emits three files under dist/ops/alb-deploy/: + * workflow.ts — the Temporal workflow function + * activities.ts — re-exports from the pre-built activity library + * worker.ts — bootstrap worker that reads chant.config.ts + */ + +import type { Declarable } from "@intentius/chant/declarable"; +import type { OpConfig, PhaseDefinition, StepDefinition, ActivityStep, GateStep } from "@intentius/chant/op"; + +// ── Name helpers ────────────────────────────────────────────────────────────── + +function kebabToCamel(s: string): string { + return s.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase()); +} + +function workflowFnName(opName: string): string { + return kebabToCamel(opName) + "Workflow"; +} + +function signalVarName(signalName: string): string { + // "gate-dns-delegation" → "resumeDnsDelegation" + const withoutGate = signalName.startsWith("gate-") ? signalName.slice(5) : signalName; + return "resume" + kebabToCamel(withoutGate).replace(/^./, (c) => c.toUpperCase()); +} + +// ── Type helpers ────────────────────────────────────────────────────────────── + +function isActivityStep(s: StepDefinition): s is ActivityStep { + return s.kind === "activity"; +} + +function isGateStep(s: StepDefinition): s is GateStep { + return s.kind === "gate"; +} + +function effectiveProfile(step: ActivityStep): string { + return step.profile ?? "fastIdempotent"; +} + +// ── Workflow code generation ────────────────────────────────────────────────── + +function collectActivitySteps(phases: PhaseDefinition[]): ActivityStep[] { + return phases.flatMap((p) => p.steps.filter(isActivityStep)); +} + +function groupByProfile(steps: ActivityStep[]): Map> { + const map = new Map>(); + for (const step of steps) { + const prof = effectiveProfile(step); + if (!map.has(prof)) map.set(prof, new Set()); + map.get(prof)!.add(step.fn); + } + return map; +} + +function generateWorkflow(config: OpConfig): string { + const allActivitySteps = [ + ...collectActivitySteps(config.phases), + ...(config.onFailure ? collectActivitySteps(config.onFailure) : []), + ]; + + const byProfile = groupByProfile(allActivitySteps); + + const allGateSteps = [ + ...config.phases.flatMap((p) => p.steps.filter(isGateStep)), + ...(config.onFailure ?? []).flatMap((p) => p.steps.filter(isGateStep)), + ]; + + const fnName = workflowFnName(config.name); + + const lines: string[] = [ + "// Generated by chant — do not edit directly.", + `// Source: ${config.name}.op.ts`, + "import { proxyActivities, condition, defineSignal, setHandler } from '@temporalio/workflow';", + "import { TEMPORAL_ACTIVITY_PROFILES } from '@intentius/chant-lexicon-temporal';", + "import type * as activities from './activities';", + "", + ]; + + // proxyActivities per profile + if (byProfile.size === 0) { + lines.push("// No activities defined."); + lines.push(""); + } else { + for (const [prof, fns] of byProfile) { + const destructured = [...fns].join(", "); + lines.push(`const { ${destructured} } = proxyActivities(`); + lines.push(` TEMPORAL_ACTIVITY_PROFILES.${prof},`); + lines.push(`);`); + } + lines.push(""); + } + + // Gate signals declarations + if (allGateSteps.length > 0) { + for (const gate of allGateSteps) { + const varName = signalVarName(gate.signalName); + lines.push(`const ${varName} = defineSignal<[]>(${JSON.stringify(gate.signalName)});`); + } + lines.push(""); + } + + // Workflow function + lines.push(`export async function ${fnName}(): Promise {`); + + const renderPhases = (phases: PhaseDefinition[]) => { + for (const phase of phases) { + const phaseLines: string[] = []; + phaseLines.push(` // Phase: ${phase.name}`); + + const activitySteps = phase.steps.filter(isActivityStep); + const gateSteps = phase.steps.filter(isGateStep); + + if (phase.parallel && activitySteps.length > 1) { + phaseLines.push(" await Promise.all(["); + for (const step of activitySteps) { + const argsStr = step.args && Object.keys(step.args).length > 0 + ? JSON.stringify(step.args) + : "{}"; + phaseLines.push(` ${step.fn}(${argsStr}),`); + } + phaseLines.push(" ]);"); + } else { + for (const step of activitySteps) { + const argsStr = step.args && Object.keys(step.args).length > 0 + ? JSON.stringify(step.args) + : "{}"; + phaseLines.push(` await ${step.fn}(${argsStr});`); + } + } + + for (const gateStep of gateSteps) { + const varName = signalVarName(gateStep.signalName); + const timeout = gateStep.timeout ?? "48h"; + if (gateStep.description) { + phaseLines.push(` // Gate: ${gateStep.signalName} — ${gateStep.description}`); + } else { + phaseLines.push(` // Gate: ${gateStep.signalName}`); + } + phaseLines.push(` let ${varName}Cleared = false;`); + phaseLines.push(` setHandler(${varName}, () => { ${varName}Cleared = true; });`); + phaseLines.push(` await condition(() => ${varName}Cleared, ${JSON.stringify(timeout)});`); + } + + lines.push(...phaseLines); + lines.push(""); + } + }; + + renderPhases(config.phases); + + if (config.onFailure && config.onFailure.length > 0) { + lines.push(" // onFailure compensation (executed on terminal failure only)"); + renderPhases(config.onFailure); + } + + lines.push("}"); + lines.push(""); + + return lines.join("\n"); +} + +// ── Activities re-export ────────────────────────────────────────────────────── + +function generateActivities(): string { + return [ + "// Generated by chant — do not edit directly.", + "// Re-exports all pre-built activity implementations.", + "export * from '@intentius/chant-lexicon-temporal/op/activities';", + "", + ].join("\n"); +} + +// ── Worker bootstrap ────────────────────────────────────────────────────────── + +function generateWorker(config: OpConfig): string { + const taskQueue = config.taskQueue ?? config.name; + + return [ + "// Generated by chant — do not edit directly.", + `// Run: npx tsx ops/${config.name}/worker.ts`, + "import { Worker, NativeConnection } from '@temporalio/worker';", + "import { fileURLToPath } from 'url';", + "import * as activities from './activities.js';", + "", + "async function run(): Promise {", + " const { default: chantConfig } = await import('../../chant.config.js');", + "", + ` const profileName = process.env.TEMPORAL_PROFILE ?? chantConfig.temporal?.defaultProfile ?? 'local';`, + " const profile = chantConfig.temporal?.profiles?.[profileName];", + "", + " if (!profile) {", + " console.error(", + ` \`Unknown Temporal profile "\${profileName}". Available: \${Object.keys(chantConfig.temporal?.profiles ?? {}).join(', ')}\`,`, + " );", + " process.exit(1);", + " }", + "", + " const apiKey =", + " typeof profile.apiKey === 'object' && profile.apiKey !== null", + " ? process.env[(profile.apiKey as { env: string }).env]", + " : (profile.apiKey as string | undefined);", + "", + " const connection = await NativeConnection.connect({", + " address: profile.address,", + " ...(profile.tls && {", + " tls: typeof profile.tls === 'object' ? profile.tls : {},", + " metadata: apiKey ? { Authorization: `Bearer ${apiKey}` } : {},", + " }),", + " });", + "", + " const worker = await Worker.create({", + " connection,", + " namespace: profile.namespace,", + ` taskQueue: profile.taskQueue ?? ${JSON.stringify(taskQueue)},`, + " workflowsPath: fileURLToPath(new URL('./workflow.js', import.meta.url)),", + " activities,", + " });", + "", + ` console.log(\`Worker ready — polling task queue: \${profile.taskQueue ?? ${JSON.stringify(taskQueue)}}\`);`, + " await worker.run();", + "}", + "", + "run().catch((err: unknown) => {", + " console.error(err);", + " process.exit(1);", + "});", + "", + ].join("\n"); +} + +// ── Public API ──────────────────────────────────────────────────────────────── + +function getProps(entity: Declarable): Record { + if ("props" in entity && typeof entity.props === "object" && entity.props !== null) { + return entity.props as Record; + } + return {}; +} + +/** + * Serialize a map of Temporal::Op entities into generated file content. + * + * Returns a map of relative output paths → file content. + * e.g. `{ "ops/alb-deploy/workflow.ts": "...", ... }` + * + * Throws if a `depends` reference names an Op that is not in the entity map. + */ +export function serializeOps(ops: Map): Record { + const knownNames = new Set(); + + // First pass: collect all names + for (const [, entity] of ops) { + const props = getProps(entity) as OpConfig; + if (props.name) knownNames.add(props.name); + } + + const files: Record = {}; + + for (const [, entity] of ops) { + const config = getProps(entity) as OpConfig; + + if (!config.name) { + throw new Error("Op entity missing required `name` field."); + } + + // Validate depends + for (const dep of config.depends ?? []) { + if (!knownNames.has(dep)) { + throw new Error( + `Op "${config.name}" depends on unknown Op "${dep}". Known Ops: ${[...knownNames].join(", ")}`, + ); + } + } + + const dir = `ops/${config.name}`; + files[`${dir}/workflow.ts`] = generateWorkflow(config); + files[`${dir}/activities.ts`] = generateActivities(); + files[`${dir}/worker.ts`] = generateWorker(config); + } + + return files; +} diff --git a/lexicons/temporal/src/serializer.test.ts b/lexicons/temporal/src/serializer.test.ts index 4677e268..0a3d83b1 100644 --- a/lexicons/temporal/src/serializer.test.ts +++ b/lexicons/temporal/src/serializer.test.ts @@ -289,4 +289,43 @@ describe("temporal serializer", () => { const result = temporalSerializer.serialize(entities) as { primary: string; files: Record }; expect(result.files["temporal-setup.sh"]).toContain("set -euo pipefail"); }); + + // ── Temporal::Op ────────────────────────────────────────────────── + + function makeOp(name: string, phases: unknown[] = []): [string, Record] { + return [name, makeEntity("Temporal::Op", { name, overview: `${name} op`, phases })]; + } + + it("includes ops//workflow.ts when an Op entity is present", () => { + const entities = new Map([makeOp("alb-deploy")]); + const result = temporalSerializer.serialize(entities) as { primary: string; files: Record }; + expect(typeof result).toBe("object"); + expect(result.files["ops/alb-deploy/workflow.ts"]).toBeDefined(); + }); + + it("includes ops//activities.ts and worker.ts for each Op", () => { + const entities = new Map([makeOp("my-op")]); + const result = temporalSerializer.serialize(entities) as { primary: string; files: Record }; + expect(result.files["ops/my-op/activities.ts"]).toBeDefined(); + expect(result.files["ops/my-op/worker.ts"]).toBeDefined(); + }); + + it("still returns SerializerResult (not plain string) when only Op entities present", () => { + const entities = new Map([makeOp("op")]); + const result = temporalSerializer.serialize(entities); + expect(typeof result).toBe("object"); + }); + + it("combines Op files with schedule and namespace files in mixed entities", () => { + const entities = new Map([ + makeServer(), + makeNamespace("default"), + makeSchedule("weekly"), + makeOp("deploy-op"), + ]); + const result = temporalSerializer.serialize(entities) as { primary: string; files: Record }; + expect(result.files["ops/deploy-op/workflow.ts"]).toBeDefined(); + expect(result.files["temporal-setup.sh"]).toBeDefined(); + expect(result.files["schedules/weekly.ts"]).toBeDefined(); + }); }); diff --git a/lexicons/temporal/src/serializer.ts b/lexicons/temporal/src/serializer.ts index 24f6ce76..da2d5b33 100644 --- a/lexicons/temporal/src/serializer.ts +++ b/lexicons/temporal/src/serializer.ts @@ -14,6 +14,7 @@ import type { Declarable } from "@intentius/chant/declarable"; import type { Serializer, SerializerResult } from "@intentius/chant/serializer"; import type { TemporalServerProps, TemporalNamespaceProps, SearchAttributeProps, TemporalScheduleProps } from "./resources"; +import { serializeOps } from "./op/serializer"; // ── Helpers ───────────────────────────────────────────────────────── @@ -263,6 +264,7 @@ export const temporalSerializer: Serializer = { const namespaces = new Map(); const searchAttrs = new Map(); const schedules = new Map(); + const ops = new Map(); for (const [name, entity] of entities) { const et = entityType(entity); @@ -270,21 +272,17 @@ export const temporalSerializer: Serializer = { else if (et === "Temporal::Namespace") namespaces.set(name, entity); else if (et === "Temporal::SearchAttribute") searchAttrs.set(name, entity); else if (et === "Temporal::Schedule") schedules.set(name, entity); + else if (et === "Temporal::Op") ops.set(name, entity); } const primary = serializeDockerCompose(servers); - const hasExtraFiles = - servers.size > 0 || // always emit helm-values when a server exists - namespaces.size > 0 || - searchAttrs.size > 0 || - schedules.size > 0; - - // Only-server case: no extra files needed beyond docker-compose → return string + // Only-server case with no Ops: no extra files needed beyond docker-compose → return string if ( namespaces.size === 0 && searchAttrs.size === 0 && - schedules.size === 0 + schedules.size === 0 && + ops.size === 0 ) { return primary; } @@ -305,6 +303,10 @@ export const temporalSerializer: Serializer = { files[`schedules/${scheduleId}.ts`] = serializeSchedule(scheduleId, props); } + if (ops.size > 0) { + Object.assign(files, serializeOps(ops)); + } + return { primary, files }; }, }; diff --git a/packages/core/package.json b/packages/core/package.json index 77301d5d..8641d8ed 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@intentius/chant", - "version": "0.1.5", + "version": "0.1.6", "description": "Declarative infrastructure-as-code toolkit — TypeScript on Node.js", "license": "Apache-2.0", "homepage": "https://intentius.io/chant", @@ -36,6 +36,7 @@ ".": "./src/index.ts", "./cli": "./src/cli/index.ts", "./cli/*": "./src/cli/*.ts", + "./op": "./src/op/index.ts", "./*": "./src/*.ts" }, "dependencies": { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 11c53de0..c202aef8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -61,3 +61,7 @@ export * from "./lsp/lexicon-providers"; export * from "./mcp/types"; export * from "./state/index"; export * from "./spell/index"; +// Op builders — use explicit exports to avoid collision with the core `build` function +export { Op, phase, activity, gate, kubectlApply, helmInstall, waitForStack, + gitlabPipeline, stateSnapshot, shell, teardown, OpResource } from "./op/index"; +export type { OpConfig, PhaseDefinition, StepDefinition, ActivityStep, GateStep, RetryPolicy } from "./op/index"; diff --git a/packages/core/src/op/builders.ts b/packages/core/src/op/builders.ts new file mode 100644 index 00000000..813defa4 --- /dev/null +++ b/packages/core/src/op/builders.ts @@ -0,0 +1,96 @@ +import { OpResource } from "./resource"; +import type { OpConfig, PhaseDefinition, StepDefinition, ActivityStep, GateStep } from "./types"; + +// ── Core builders ───────────────────────────────────────────────────────────── + +/** + * Declare a named, phased Temporal workflow. + * + * @example + * ```ts + * export default Op({ + * name: "alb-deploy", + * overview: "Build and deploy the ALB multi-service stack", + * phases: [ + * phase("Build", [build("examples/gitlab-aws-alb-infra")], { parallel: true }), + * phase("Deploy", [kubectlApply("dist/alb-infra.yaml")]), + * ], + * }); + * ``` + */ +export function Op(config: OpConfig): InstanceType { + return new OpResource(config as unknown as Record); +} + +/** Define a named execution phase containing one or more steps. */ +export function phase( + name: string, + steps: StepDefinition[], + opts?: { parallel?: boolean }, +): PhaseDefinition { + return { name, steps, ...(opts?.parallel ? { parallel: true } : {}) }; +} + +/** Reference a pre-built or custom activity by function name. */ +export function activity( + fn: string, + args?: Record, + profile?: ActivityStep["profile"], +): ActivityStep { + return { + kind: "activity", + fn, + ...(args && Object.keys(args).length > 0 ? { args } : {}), + ...(profile ? { profile } : {}), + }; +} + +/** Insert a human gate — the workflow pauses until the named signal is received. */ +export function gate( + signalName: string, + opts?: { timeout?: string; description?: string }, +): GateStep { + return { + kind: "gate", + signalName, + ...(opts?.timeout ? { timeout: opts.timeout } : {}), + ...(opts?.description ? { description: opts.description } : {}), + }; +} + +// ── Pre-built activity shortcuts ────────────────────────────────────────────── + +/** Run `npm run build` (or `chant build`) in the given project directory. */ +export const build = (path: string, opts?: Record): ActivityStep => + activity("chantBuild", { path, ...opts }); + +/** Run `kubectl apply -f `. Uses `longInfra` profile. */ +export const kubectlApply = (manifest: string, opts?: Record): ActivityStep => + activity("kubectlApply", { manifest, ...opts }, "longInfra"); + +/** Run `helm upgrade --install`. Uses `longInfra` profile. */ +export const helmInstall = ( + name: string, + chart: string, + opts?: { values?: string; namespace?: string; [k: string]: unknown }, +): ActivityStep => activity("helmInstall", { name, chart, ...opts }, "longInfra"); + +/** Poll for stack readiness (kubectl rollout, CloudFormation complete, etc). Uses `k8sWait` profile. */ +export const waitForStack = (name: string, opts?: Record): ActivityStep => + activity("waitForStack", { name, ...opts }, "k8sWait"); + +/** Trigger and wait for a GitLab CI pipeline to complete. Uses `longInfra` profile. */ +export const gitlabPipeline = (name: string, opts?: Record): ActivityStep => + activity("gitlabPipeline", { name, ...opts }, "longInfra"); + +/** Take a chant state snapshot for the given environment. */ +export const stateSnapshot = (env: string): ActivityStep => + activity("stateSnapshot", { env }); + +/** Run an arbitrary shell command. */ +export const shell = (cmd: string, opts?: { env?: Record }): ActivityStep => + activity("shellCmd", { cmd, ...opts }); + +/** Run `chant teardown` in the given project directory. Uses `longInfra` profile. */ +export const teardown = (path: string): ActivityStep => + activity("chantTeardown", { path }, "longInfra"); diff --git a/packages/core/src/op/index.ts b/packages/core/src/op/index.ts new file mode 100644 index 00000000..135326c6 --- /dev/null +++ b/packages/core/src/op/index.ts @@ -0,0 +1,4 @@ +export { Op, phase, activity, gate, build, kubectlApply, helmInstall, waitForStack, + gitlabPipeline, stateSnapshot, shell, teardown } from "./builders"; +export { OpResource } from "./resource"; +export type { OpConfig, PhaseDefinition, StepDefinition, ActivityStep, GateStep, RetryPolicy } from "./types"; diff --git a/packages/core/src/op/op.test.ts b/packages/core/src/op/op.test.ts new file mode 100644 index 00000000..0ceb697d --- /dev/null +++ b/packages/core/src/op/op.test.ts @@ -0,0 +1,199 @@ +/** + * Core Op builder tests. + */ + +import { describe, expect, it } from "vitest"; +import { Op, phase, activity, gate, build, kubectlApply, helmInstall, + waitForStack, gitlabPipeline, stateSnapshot, shell, teardown } from "./builders"; +import { DECLARABLE_MARKER, type Declarable } from "../declarable"; + +// ── Op() ────────────────────────────────────────────────────────────────────── + +describe("Op()", () => { + function opProps(op: Declarable): Record { + return (op as unknown as { props: Record }).props; + } + + it("returns a Declarable with correct lexicon and entityType", () => { + const op = Op({ name: "my-op", overview: "Test op", phases: [] }); + expect(op[DECLARABLE_MARKER]).toBe(true); + expect(op.lexicon).toBe("temporal"); + expect(op.entityType).toBe("Temporal::Op"); + expect(op.kind).toBe("resource"); + }); + + it("stores name and overview in props", () => { + const op = Op({ name: "deploy-op", overview: "Deploy infra", phases: [] }); + const props = opProps(op); + expect(props.name).toBe("deploy-op"); + expect(props.overview).toBe("Deploy infra"); + }); + + it("stores phases in props", () => { + const p = phase("Build", [build("path/to/project")]); + const op = Op({ name: "op", overview: "o", phases: [p] }); + const props = opProps(op); + expect(Array.isArray(props.phases)).toBe(true); + expect((props.phases as unknown[]).length).toBe(1); + }); + + it("stores optional depends in props", () => { + const op = Op({ name: "second-op", overview: "o", phases: [], depends: ["first-op"] }); + const props = opProps(op); + expect(props.depends).toEqual(["first-op"]); + }); + + it("stores optional onFailure in props", () => { + const compensation = phase("Rollback", [shell("echo rollback")]); + const op = Op({ name: "op", overview: "o", phases: [], onFailure: [compensation] }); + const props = opProps(op); + expect(Array.isArray(props.onFailure)).toBe(true); + }); + + it("stores optional taskQueue in props", () => { + const op = Op({ name: "op", overview: "o", phases: [], taskQueue: "custom-queue" }); + const props = opProps(op); + expect(props.taskQueue).toBe("custom-queue"); + }); +}); + +// ── phase() ─────────────────────────────────────────────────────────────────── + +describe("phase()", () => { + it("returns correct shape with name and steps", () => { + const p = phase("Deploy", [build("./")]); + expect(p.name).toBe("Deploy"); + expect(p.steps).toHaveLength(1); + }); + + it("sets parallel: true when passed as option", () => { + const p = phase("Build", [build("a"), build("b")], { parallel: true }); + expect(p.parallel).toBe(true); + }); + + it("omits parallel key when not set", () => { + const p = phase("Build", [build("a")]); + expect("parallel" in p).toBe(false); + }); +}); + +// ── activity() ──────────────────────────────────────────────────────────────── + +describe("activity()", () => { + it("returns ActivityStep with kind 'activity'", () => { + const a = activity("myFn"); + expect(a.kind).toBe("activity"); + expect(a.fn).toBe("myFn"); + }); + + it("includes args when provided", () => { + const a = activity("myFn", { key: "val" }); + expect(a.args).toEqual({ key: "val" }); + }); + + it("omits args key when no args provided", () => { + const a = activity("myFn"); + expect("args" in a).toBe(false); + }); + + it("includes profile when provided", () => { + const a = activity("myFn", {}, "longInfra"); + expect(a.profile).toBe("longInfra"); + }); + + it("omits profile key when not provided", () => { + const a = activity("myFn"); + expect("profile" in a).toBe(false); + }); +}); + +// ── gate() ──────────────────────────────────────────────────────────────────── + +describe("gate()", () => { + it("returns GateStep with kind 'gate' and signalName", () => { + const g = gate("dns-delegation"); + expect(g.kind).toBe("gate"); + expect(g.signalName).toBe("dns-delegation"); + }); + + it("includes timeout when provided", () => { + const g = gate("approval", { timeout: "24h" }); + expect(g.timeout).toBe("24h"); + }); + + it("includes description when provided", () => { + const g = gate("approval", { description: "Awaiting DNS delegation" }); + expect(g.description).toBe("Awaiting DNS delegation"); + }); + + it("omits timeout and description when not provided", () => { + const g = gate("sig"); + expect("timeout" in g).toBe(false); + expect("description" in g).toBe(false); + }); +}); + +// ── Pre-built shortcuts ─────────────────────────────────────────────────────── + +describe("pre-built shortcuts", () => { + it("build() produces chantBuild activity with path arg", () => { + const a = build("./my-project"); + expect(a.kind).toBe("activity"); + expect(a.fn).toBe("chantBuild"); + expect(a.args?.path).toBe("./my-project"); + }); + + it("build() uses default fastIdempotent profile (no profile key)", () => { + const a = build("./p"); + expect("profile" in a).toBe(false); + }); + + it("kubectlApply() produces kubectlApply activity with longInfra profile", () => { + const a = kubectlApply("dist/infra.yaml"); + expect(a.fn).toBe("kubectlApply"); + expect(a.args?.manifest).toBe("dist/infra.yaml"); + expect(a.profile).toBe("longInfra"); + }); + + it("helmInstall() produces helmInstall activity with name and chart", () => { + const a = helmInstall("my-release", "charts/app"); + expect(a.fn).toBe("helmInstall"); + expect(a.args?.name).toBe("my-release"); + expect(a.args?.chart).toBe("charts/app"); + expect(a.profile).toBe("longInfra"); + }); + + it("waitForStack() produces waitForStack activity with k8sWait profile", () => { + const a = waitForStack("my-stack"); + expect(a.fn).toBe("waitForStack"); + expect(a.args?.name).toBe("my-stack"); + expect(a.profile).toBe("k8sWait"); + }); + + it("gitlabPipeline() produces gitlabPipeline activity with longInfra profile", () => { + const a = gitlabPipeline("group/project"); + expect(a.fn).toBe("gitlabPipeline"); + expect(a.args?.name).toBe("group/project"); + expect(a.profile).toBe("longInfra"); + }); + + it("stateSnapshot() produces stateSnapshot activity with env arg", () => { + const a = stateSnapshot("prod"); + expect(a.fn).toBe("stateSnapshot"); + expect(a.args?.env).toBe("prod"); + expect("profile" in a).toBe(false); + }); + + it("shell() produces shellCmd activity with cmd arg", () => { + const a = shell("echo hello"); + expect(a.fn).toBe("shellCmd"); + expect(a.args?.cmd).toBe("echo hello"); + }); + + it("teardown() produces chantTeardown activity with longInfra profile", () => { + const a = teardown("./project"); + expect(a.fn).toBe("chantTeardown"); + expect(a.args?.path).toBe("./project"); + expect(a.profile).toBe("longInfra"); + }); +}); diff --git a/packages/core/src/op/resource.ts b/packages/core/src/op/resource.ts new file mode 100644 index 00000000..32294dc4 --- /dev/null +++ b/packages/core/src/op/resource.ts @@ -0,0 +1,8 @@ +import { createResource } from "../runtime"; + +/** + * The Declarable resource backing an Op definition. + * entityType: "Temporal::Op", lexicon: "temporal" + * Discovered automatically alongside infra files — no pipeline changes needed. + */ +export const OpResource = createResource("Temporal::Op", "temporal", {}); diff --git a/packages/core/src/op/types.ts b/packages/core/src/op/types.ts new file mode 100644 index 00000000..30b2579e --- /dev/null +++ b/packages/core/src/op/types.ts @@ -0,0 +1,66 @@ +/** + * Op type definitions — the data model for a named, phased Temporal workflow. + * + * These types are intentionally free of Temporal SDK imports so they can live + * in core without pulling in @temporalio/* as a dependency. + */ + +export interface OpConfig { + /** Kebab-case identifier. Used as the workflow function name (camelCase) and output directory name. */ + name: string; + /** Human-readable description shown in `chant run list` and deployment reports. */ + overview: string; + /** Temporal task queue. Defaults to `name`. */ + taskQueue?: string; + /** Temporal namespace. Defaults to chant.config.ts defaultProfile's namespace. */ + namespace?: string; + /** Ordered list of execution phases. */ + phases: PhaseDefinition[]; + /** Other Op names that must be complete before this Op can run. */ + depends?: string[]; + /** Compensation phases executed on terminal failure (run in reverse order). */ + onFailure?: PhaseDefinition[]; + /** Search attributes to upsert at workflow start. */ + searchAttributes?: Record; +} + +export interface PhaseDefinition { + /** Display name shown in progress output and Temporal UI. */ + name: string; + /** Ordered steps within the phase. */ + steps: StepDefinition[]; + /** Run all steps concurrently via Promise.all. Default: false. */ + parallel?: boolean; +} + +export type StepDefinition = ActivityStep | GateStep; + +export interface ActivityStep { + kind: "activity"; + /** Name of the exported activity function in the pre-built activity library. */ + fn: string; + /** Arguments passed to the activity function. */ + args?: Record; + /** + * Key from TEMPORAL_ACTIVITY_PROFILES controlling timeout + retry. + * Default: "fastIdempotent" + */ + profile?: "fastIdempotent" | "longInfra" | "k8sWait" | "humanGate"; +} + +export interface GateStep { + kind: "gate"; + /** Signal name. The generated workflow waits for this signal before continuing. */ + signalName: string; + /** Temporal duration string. Default: "48h". */ + timeout?: string; + /** Human-readable description of the action required to unblock this gate. */ + description?: string; +} + +export interface RetryPolicy { + initialInterval?: string; + backoffCoefficient?: number; + maximumAttempts?: number; + maximumInterval?: string; +}