@intentius/chant-lexicon-temporal 0.1.5 → 0.1.6
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.
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/package.json +2 -1
- package/src/index.ts +17 -0
- package/src/op/activities/build.ts +23 -0
- package/src/op/activities/gitlab.ts +56 -0
- package/src/op/activities/helm.ts +41 -0
- package/src/op/activities/index.ts +23 -0
- package/src/op/activities/kubectl.ts +32 -0
- package/src/op/activities/shell.ts +25 -0
- package/src/op/activities/state.ts +19 -0
- package/src/op/activities/teardown.ts +21 -0
- package/src/op/activities/wait.ts +52 -0
- package/src/op/op-serializer.test.ts +277 -0
- package/src/op/serializer.ts +287 -0
- package/src/serializer.test.ts +39 -0
- package/src/serializer.ts +10 -8
package/dist/integrity.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"algorithm": "sha256",
|
|
3
3
|
"artifacts": {
|
|
4
|
-
"manifest.json": "
|
|
4
|
+
"manifest.json": "2bb766cde85d9582aad27502fb33c83b64fabb393c1e0ad1ad57fbac1dd62821",
|
|
5
5
|
"meta.json": "c77a3c415993bed3865e07fa6db4fb7b9e87de0afa8d8675f64eadf50f914077",
|
|
6
6
|
"types/index.d.ts": "5216f5256321f084a7c8211ef66ab599f621c74751cc3485cfc2a62502b81e2f",
|
|
7
7
|
"rules/tmp001.ts": "689222211a93716a7e4432f6dd6a2a2ab54020b232049fb602893872585f9978",
|
|
@@ -11,5 +11,5 @@
|
|
|
11
11
|
"skills/chant-temporal.md": "ff929983b33bb420c49ab61851f3799c5610256cb972d6c584792bb98b0cfb22",
|
|
12
12
|
"skills/chant-temporal-ops.md": "7e83bc3e6e63af4ab14b8dc07aa00132d51991cf35b1bca29560d48934bff6dc"
|
|
13
13
|
},
|
|
14
|
-
"composite": "
|
|
14
|
+
"composite": "187667fff0222f3498a5063bcec56e25cd716b1de3acd51e88cad3d45b4aba05"
|
|
15
15
|
}
|
package/dist/manifest.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/chant-lexicon-temporal",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"description": "Temporal lexicon for chant — server deployment, namespaces, search attributes, and schedules",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"exports": {
|
|
12
12
|
".": "./src/index.ts",
|
|
13
13
|
"./*": "./src/*.ts",
|
|
14
|
+
"./op/activities": "./src/op/activities/index.ts",
|
|
14
15
|
"./manifest": "./dist/manifest.json",
|
|
15
16
|
"./meta": "./dist/meta.json",
|
|
16
17
|
"./types": "./dist/types/index.d.ts"
|
package/src/index.ts
CHANGED
|
@@ -27,3 +27,20 @@ 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
|
+
|
|
31
|
+
// Op builders (re-exported from core for single-import convenience)
|
|
32
|
+
export {
|
|
33
|
+
Op,
|
|
34
|
+
phase,
|
|
35
|
+
activity,
|
|
36
|
+
gate,
|
|
37
|
+
build,
|
|
38
|
+
kubectlApply,
|
|
39
|
+
helmInstall,
|
|
40
|
+
waitForStack,
|
|
41
|
+
gitlabPipeline,
|
|
42
|
+
stateSnapshot,
|
|
43
|
+
shell,
|
|
44
|
+
teardown,
|
|
45
|
+
} from "@intentius/chant/op";
|
|
46
|
+
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 } from "./state";
|
|
20
|
+
export type { StateSnapshotArgs } 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,19 @@
|
|
|
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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Op serializer tests — verifies that Temporal::Op entities generate
|
|
3
|
+
* the correct workflow.ts, activities.ts, and worker.ts files.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, it } from "vitest";
|
|
7
|
+
import { serializeOps } from "./serializer";
|
|
8
|
+
import { DECLARABLE_MARKER } from "@intentius/chant/declarable";
|
|
9
|
+
import type { OpConfig } from "@intentius/chant/op";
|
|
10
|
+
|
|
11
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
function makeOp(config: OpConfig): [string, Record<string, unknown>] {
|
|
14
|
+
return [
|
|
15
|
+
config.name,
|
|
16
|
+
{
|
|
17
|
+
[DECLARABLE_MARKER]: true,
|
|
18
|
+
entityType: "Temporal::Op",
|
|
19
|
+
lexicon: "temporal",
|
|
20
|
+
kind: "resource",
|
|
21
|
+
props: config,
|
|
22
|
+
attributes: {},
|
|
23
|
+
},
|
|
24
|
+
];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ── Basic generation ──────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
describe("serializeOps()", () => {
|
|
30
|
+
it("returns empty object for empty map", () => {
|
|
31
|
+
expect(serializeOps(new Map())).toEqual({});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("generates workflow.ts, activities.ts, worker.ts for each Op", () => {
|
|
35
|
+
const ops = new Map([
|
|
36
|
+
makeOp({ name: "alb-deploy", overview: "ALB deploy", phases: [] }),
|
|
37
|
+
]);
|
|
38
|
+
const files = serializeOps(ops);
|
|
39
|
+
expect(files["ops/alb-deploy/workflow.ts"]).toBeDefined();
|
|
40
|
+
expect(files["ops/alb-deploy/activities.ts"]).toBeDefined();
|
|
41
|
+
expect(files["ops/alb-deploy/worker.ts"]).toBeDefined();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("generates files for multiple Ops under separate directories", () => {
|
|
45
|
+
const ops = new Map([
|
|
46
|
+
makeOp({ name: "op-a", overview: "A", phases: [] }),
|
|
47
|
+
makeOp({ name: "op-b", overview: "B", phases: [] }),
|
|
48
|
+
]);
|
|
49
|
+
const files = serializeOps(ops);
|
|
50
|
+
expect(files["ops/op-a/workflow.ts"]).toBeDefined();
|
|
51
|
+
expect(files["ops/op-b/workflow.ts"]).toBeDefined();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// ── workflow.ts ─────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
describe("workflow.ts", () => {
|
|
57
|
+
it("exports a camelCase workflow function named after the Op", () => {
|
|
58
|
+
const ops = new Map([
|
|
59
|
+
makeOp({ name: "alb-deploy", overview: "o", phases: [] }),
|
|
60
|
+
]);
|
|
61
|
+
const wf = serializeOps(ops)["ops/alb-deploy/workflow.ts"];
|
|
62
|
+
expect(wf).toContain("export async function albDeployWorkflow()");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("imports proxyActivities, condition, defineSignal, setHandler from @temporalio/workflow", () => {
|
|
66
|
+
const ops = new Map([makeOp({ name: "my-op", overview: "o", phases: [] })]);
|
|
67
|
+
const wf = serializeOps(ops)["ops/my-op/workflow.ts"];
|
|
68
|
+
expect(wf).toContain("from '@temporalio/workflow'");
|
|
69
|
+
expect(wf).toContain("proxyActivities");
|
|
70
|
+
expect(wf).toContain("condition");
|
|
71
|
+
expect(wf).toContain("defineSignal");
|
|
72
|
+
expect(wf).toContain("setHandler");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("imports TEMPORAL_ACTIVITY_PROFILES from @intentius/chant-lexicon-temporal", () => {
|
|
76
|
+
const ops = new Map([makeOp({ name: "my-op", overview: "o", phases: [] })]);
|
|
77
|
+
const wf = serializeOps(ops)["ops/my-op/workflow.ts"];
|
|
78
|
+
expect(wf).toContain("TEMPORAL_ACTIVITY_PROFILES");
|
|
79
|
+
expect(wf).toContain("@intentius/chant-lexicon-temporal");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("groups activities by profile in proxyActivities calls", () => {
|
|
83
|
+
const ops = new Map([
|
|
84
|
+
makeOp({
|
|
85
|
+
name: "deploy", overview: "o",
|
|
86
|
+
phases: [
|
|
87
|
+
{ name: "Build", steps: [{ kind: "activity", fn: "chantBuild", args: { path: "./a" } }] },
|
|
88
|
+
{ name: "Deploy", steps: [{ kind: "activity", fn: "helmInstall", args: { name: "r", chart: "c" }, profile: "longInfra" }] },
|
|
89
|
+
],
|
|
90
|
+
}),
|
|
91
|
+
]);
|
|
92
|
+
const wf = serializeOps(ops)["ops/deploy/workflow.ts"];
|
|
93
|
+
expect(wf).toContain("TEMPORAL_ACTIVITY_PROFILES.fastIdempotent");
|
|
94
|
+
expect(wf).toContain("TEMPORAL_ACTIVITY_PROFILES.longInfra");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("generates sequential await calls for a non-parallel phase", () => {
|
|
98
|
+
const ops = new Map([
|
|
99
|
+
makeOp({
|
|
100
|
+
name: "seq-op", overview: "o",
|
|
101
|
+
phases: [{
|
|
102
|
+
name: "Deploy",
|
|
103
|
+
steps: [
|
|
104
|
+
{ kind: "activity", fn: "chantBuild", args: { path: "./a" } },
|
|
105
|
+
{ kind: "activity", fn: "kubectlApply", args: { manifest: "out.yaml" }, profile: "longInfra" },
|
|
106
|
+
],
|
|
107
|
+
}],
|
|
108
|
+
}),
|
|
109
|
+
]);
|
|
110
|
+
const wf = serializeOps(ops)["ops/seq-op/workflow.ts"];
|
|
111
|
+
expect(wf).toContain("await chantBuild(");
|
|
112
|
+
expect(wf).toContain("await kubectlApply(");
|
|
113
|
+
expect(wf).not.toContain("Promise.all");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("generates Promise.all for a parallel phase", () => {
|
|
117
|
+
const ops = new Map([
|
|
118
|
+
makeOp({
|
|
119
|
+
name: "par-op", overview: "o",
|
|
120
|
+
phases: [{
|
|
121
|
+
name: "Build",
|
|
122
|
+
parallel: true,
|
|
123
|
+
steps: [
|
|
124
|
+
{ kind: "activity", fn: "chantBuild", args: { path: "./a" } },
|
|
125
|
+
{ kind: "activity", fn: "chantBuild", args: { path: "./b" } },
|
|
126
|
+
],
|
|
127
|
+
}],
|
|
128
|
+
}),
|
|
129
|
+
]);
|
|
130
|
+
const wf = serializeOps(ops)["ops/par-op/workflow.ts"];
|
|
131
|
+
expect(wf).toContain("Promise.all");
|
|
132
|
+
expect(wf).toContain("chantBuild({");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("generates gate: defineSignal, setHandler, condition", () => {
|
|
136
|
+
const ops = new Map([
|
|
137
|
+
makeOp({
|
|
138
|
+
name: "gate-op", overview: "o",
|
|
139
|
+
phases: [{
|
|
140
|
+
name: "Approval",
|
|
141
|
+
steps: [{ kind: "gate", signalName: "gate-dns-delegation", timeout: "48h" }],
|
|
142
|
+
}],
|
|
143
|
+
}),
|
|
144
|
+
]);
|
|
145
|
+
const wf = serializeOps(ops)["ops/gate-op/workflow.ts"];
|
|
146
|
+
expect(wf).toContain("defineSignal");
|
|
147
|
+
expect(wf).toContain('"gate-dns-delegation"');
|
|
148
|
+
expect(wf).toContain("setHandler");
|
|
149
|
+
expect(wf).toContain("condition");
|
|
150
|
+
expect(wf).toContain('"48h"');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("uses 48h as default gate timeout when not specified", () => {
|
|
154
|
+
const ops = new Map([
|
|
155
|
+
makeOp({
|
|
156
|
+
name: "gate-op", overview: "o",
|
|
157
|
+
phases: [{ name: "Wait", steps: [{ kind: "gate", signalName: "my-signal" }] }],
|
|
158
|
+
}),
|
|
159
|
+
]);
|
|
160
|
+
const wf = serializeOps(ops)["ops/gate-op/workflow.ts"];
|
|
161
|
+
expect(wf).toContain('"48h"');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("uses kebab-to-camel for signal handler variable name", () => {
|
|
165
|
+
const ops = new Map([
|
|
166
|
+
makeOp({
|
|
167
|
+
name: "op", overview: "o",
|
|
168
|
+
phases: [{ name: "W", steps: [{ kind: "gate", signalName: "gate-dns-delegation" }] }],
|
|
169
|
+
}),
|
|
170
|
+
]);
|
|
171
|
+
const wf = serializeOps(ops)["ops/op/workflow.ts"];
|
|
172
|
+
// "gate-dns-delegation" → resumeDnsDelegation
|
|
173
|
+
expect(wf).toContain("resumeDnsDelegation");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("passes activity args as JSON object", () => {
|
|
177
|
+
const ops = new Map([
|
|
178
|
+
makeOp({
|
|
179
|
+
name: "op", overview: "o",
|
|
180
|
+
phases: [{ name: "P", steps: [{ kind: "activity", fn: "chantBuild", args: { path: "my/path" } }] }],
|
|
181
|
+
}),
|
|
182
|
+
]);
|
|
183
|
+
const wf = serializeOps(ops)["ops/op/workflow.ts"];
|
|
184
|
+
expect(wf).toContain('"path":"my/path"');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("includes phase comment for each phase", () => {
|
|
188
|
+
const ops = new Map([
|
|
189
|
+
makeOp({
|
|
190
|
+
name: "op", overview: "o",
|
|
191
|
+
phases: [{ name: "Build and Test", steps: [{ kind: "activity", fn: "chantBuild", args: { path: "./" } }] }],
|
|
192
|
+
}),
|
|
193
|
+
]);
|
|
194
|
+
const wf = serializeOps(ops)["ops/op/workflow.ts"];
|
|
195
|
+
expect(wf).toContain("// Phase: Build and Test");
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// ── activities.ts ───────────────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
describe("activities.ts", () => {
|
|
202
|
+
it("re-exports from @intentius/chant-lexicon-temporal/op/activities", () => {
|
|
203
|
+
const ops = new Map([makeOp({ name: "op", overview: "o", phases: [] })]);
|
|
204
|
+
const act = serializeOps(ops)["ops/op/activities.ts"];
|
|
205
|
+
expect(act).toContain("export * from '@intentius/chant-lexicon-temporal/op/activities'");
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// ── worker.ts ───────────────────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
describe("worker.ts", () => {
|
|
212
|
+
it("imports Worker and NativeConnection from @temporalio/worker", () => {
|
|
213
|
+
const ops = new Map([makeOp({ name: "op", overview: "o", phases: [] })]);
|
|
214
|
+
const w = serializeOps(ops)["ops/op/worker.ts"];
|
|
215
|
+
expect(w).toContain("@temporalio/worker");
|
|
216
|
+
expect(w).toContain("Worker");
|
|
217
|
+
expect(w).toContain("NativeConnection");
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("reads chant.config.js (relative import from ops/<name>/)", () => {
|
|
221
|
+
const ops = new Map([makeOp({ name: "op", overview: "o", phases: [] })]);
|
|
222
|
+
const w = serializeOps(ops)["ops/op/worker.ts"];
|
|
223
|
+
expect(w).toContain("chant.config.js");
|
|
224
|
+
expect(w).toContain("../../chant.config.js");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("uses op name as default task queue when taskQueue not specified", () => {
|
|
228
|
+
const ops = new Map([makeOp({ name: "alb-deploy", overview: "o", phases: [] })]);
|
|
229
|
+
const w = serializeOps(ops)["ops/alb-deploy/worker.ts"];
|
|
230
|
+
expect(w).toContain("alb-deploy");
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("uses custom taskQueue when specified", () => {
|
|
234
|
+
const ops = new Map([makeOp({ name: "my-op", overview: "o", phases: [], taskQueue: "custom-q" })]);
|
|
235
|
+
const w = serializeOps(ops)["ops/my-op/worker.ts"];
|
|
236
|
+
expect(w).toContain("custom-q");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("references workflow.js (compiled JS) not workflow.ts", () => {
|
|
240
|
+
const ops = new Map([makeOp({ name: "op", overview: "o", phases: [] })]);
|
|
241
|
+
const w = serializeOps(ops)["ops/op/worker.ts"];
|
|
242
|
+
expect(w).toContain("./workflow.js");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("imports activities from ./activities.js", () => {
|
|
246
|
+
const ops = new Map([makeOp({ name: "op", overview: "o", phases: [] })]);
|
|
247
|
+
const w = serializeOps(ops)["ops/op/worker.ts"];
|
|
248
|
+
expect(w).toContain("./activities.js");
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("resolves TLS and apiKey from profile", () => {
|
|
252
|
+
const ops = new Map([makeOp({ name: "op", overview: "o", phases: [] })]);
|
|
253
|
+
const w = serializeOps(ops)["ops/op/worker.ts"];
|
|
254
|
+
expect(w).toContain("profile.tls");
|
|
255
|
+
expect(w).toContain("apiKey");
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// ── depends validation ──────────────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
describe("depends validation", () => {
|
|
262
|
+
it("accepts depends on known Op names", () => {
|
|
263
|
+
const ops = new Map([
|
|
264
|
+
makeOp({ name: "first", overview: "o", phases: [] }),
|
|
265
|
+
makeOp({ name: "second", overview: "o", phases: [], depends: ["first"] }),
|
|
266
|
+
]);
|
|
267
|
+
expect(() => serializeOps(ops)).not.toThrow();
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("throws when depends references an unknown Op name", () => {
|
|
271
|
+
const ops = new Map([
|
|
272
|
+
makeOp({ name: "op", overview: "o", phases: [], depends: ["nonexistent-op"] }),
|
|
273
|
+
]);
|
|
274
|
+
expect(() => serializeOps(ops)).toThrow(/nonexistent-op/);
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
});
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Op serializer — generates Temporal workflow, worker, and activities files
|
|
3
|
+
* for each Temporal::Op entity.
|
|
4
|
+
*
|
|
5
|
+
* For an Op named "alb-deploy" it emits three files under dist/ops/alb-deploy/:
|
|
6
|
+
* workflow.ts — the Temporal workflow function
|
|
7
|
+
* activities.ts — re-exports from the pre-built activity library
|
|
8
|
+
* worker.ts — bootstrap worker that reads chant.config.ts
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Declarable } from "@intentius/chant/declarable";
|
|
12
|
+
import type { OpConfig, PhaseDefinition, StepDefinition, ActivityStep, GateStep } from "@intentius/chant/op";
|
|
13
|
+
|
|
14
|
+
// ── Name helpers ──────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
function kebabToCamel(s: string): string {
|
|
17
|
+
return s.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function workflowFnName(opName: string): string {
|
|
21
|
+
return kebabToCamel(opName) + "Workflow";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function signalVarName(signalName: string): string {
|
|
25
|
+
// "gate-dns-delegation" → "resumeDnsDelegation"
|
|
26
|
+
const withoutGate = signalName.startsWith("gate-") ? signalName.slice(5) : signalName;
|
|
27
|
+
return "resume" + kebabToCamel(withoutGate).replace(/^./, (c) => c.toUpperCase());
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ── Type helpers ──────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
function isActivityStep(s: StepDefinition): s is ActivityStep {
|
|
33
|
+
return s.kind === "activity";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isGateStep(s: StepDefinition): s is GateStep {
|
|
37
|
+
return s.kind === "gate";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function effectiveProfile(step: ActivityStep): string {
|
|
41
|
+
return step.profile ?? "fastIdempotent";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── Workflow code generation ──────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
function collectActivitySteps(phases: PhaseDefinition[]): ActivityStep[] {
|
|
47
|
+
return phases.flatMap((p) => p.steps.filter(isActivityStep));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function groupByProfile(steps: ActivityStep[]): Map<string, Set<string>> {
|
|
51
|
+
const map = new Map<string, Set<string>>();
|
|
52
|
+
for (const step of steps) {
|
|
53
|
+
const prof = effectiveProfile(step);
|
|
54
|
+
if (!map.has(prof)) map.set(prof, new Set());
|
|
55
|
+
map.get(prof)!.add(step.fn);
|
|
56
|
+
}
|
|
57
|
+
return map;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function generateWorkflow(config: OpConfig): string {
|
|
61
|
+
const allActivitySteps = [
|
|
62
|
+
...collectActivitySteps(config.phases),
|
|
63
|
+
...(config.onFailure ? collectActivitySteps(config.onFailure) : []),
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
const byProfile = groupByProfile(allActivitySteps);
|
|
67
|
+
|
|
68
|
+
const allGateSteps = [
|
|
69
|
+
...config.phases.flatMap((p) => p.steps.filter(isGateStep)),
|
|
70
|
+
...(config.onFailure ?? []).flatMap((p) => p.steps.filter(isGateStep)),
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
const fnName = workflowFnName(config.name);
|
|
74
|
+
|
|
75
|
+
const lines: string[] = [
|
|
76
|
+
"// Generated by chant — do not edit directly.",
|
|
77
|
+
`// Source: ${config.name}.op.ts`,
|
|
78
|
+
"import { proxyActivities, condition, defineSignal, setHandler } from '@temporalio/workflow';",
|
|
79
|
+
"import { TEMPORAL_ACTIVITY_PROFILES } from '@intentius/chant-lexicon-temporal';",
|
|
80
|
+
"import type * as activities from './activities';",
|
|
81
|
+
"",
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
// proxyActivities per profile
|
|
85
|
+
if (byProfile.size === 0) {
|
|
86
|
+
lines.push("// No activities defined.");
|
|
87
|
+
lines.push("");
|
|
88
|
+
} else {
|
|
89
|
+
for (const [prof, fns] of byProfile) {
|
|
90
|
+
const destructured = [...fns].join(", ");
|
|
91
|
+
lines.push(`const { ${destructured} } = proxyActivities<typeof activities>(`);
|
|
92
|
+
lines.push(` TEMPORAL_ACTIVITY_PROFILES.${prof},`);
|
|
93
|
+
lines.push(`);`);
|
|
94
|
+
}
|
|
95
|
+
lines.push("");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Gate signals declarations
|
|
99
|
+
if (allGateSteps.length > 0) {
|
|
100
|
+
for (const gate of allGateSteps) {
|
|
101
|
+
const varName = signalVarName(gate.signalName);
|
|
102
|
+
lines.push(`const ${varName} = defineSignal<[]>(${JSON.stringify(gate.signalName)});`);
|
|
103
|
+
}
|
|
104
|
+
lines.push("");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Workflow function
|
|
108
|
+
lines.push(`export async function ${fnName}(): Promise<void> {`);
|
|
109
|
+
|
|
110
|
+
const renderPhases = (phases: PhaseDefinition[]) => {
|
|
111
|
+
for (const phase of phases) {
|
|
112
|
+
const phaseLines: string[] = [];
|
|
113
|
+
phaseLines.push(` // Phase: ${phase.name}`);
|
|
114
|
+
|
|
115
|
+
const activitySteps = phase.steps.filter(isActivityStep);
|
|
116
|
+
const gateSteps = phase.steps.filter(isGateStep);
|
|
117
|
+
|
|
118
|
+
if (phase.parallel && activitySteps.length > 1) {
|
|
119
|
+
phaseLines.push(" await Promise.all([");
|
|
120
|
+
for (const step of activitySteps) {
|
|
121
|
+
const argsStr = step.args && Object.keys(step.args).length > 0
|
|
122
|
+
? JSON.stringify(step.args)
|
|
123
|
+
: "{}";
|
|
124
|
+
phaseLines.push(` ${step.fn}(${argsStr}),`);
|
|
125
|
+
}
|
|
126
|
+
phaseLines.push(" ]);");
|
|
127
|
+
} else {
|
|
128
|
+
for (const step of activitySteps) {
|
|
129
|
+
const argsStr = step.args && Object.keys(step.args).length > 0
|
|
130
|
+
? JSON.stringify(step.args)
|
|
131
|
+
: "{}";
|
|
132
|
+
phaseLines.push(` await ${step.fn}(${argsStr});`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
for (const gateStep of gateSteps) {
|
|
137
|
+
const varName = signalVarName(gateStep.signalName);
|
|
138
|
+
const timeout = gateStep.timeout ?? "48h";
|
|
139
|
+
if (gateStep.description) {
|
|
140
|
+
phaseLines.push(` // Gate: ${gateStep.signalName} — ${gateStep.description}`);
|
|
141
|
+
} else {
|
|
142
|
+
phaseLines.push(` // Gate: ${gateStep.signalName}`);
|
|
143
|
+
}
|
|
144
|
+
phaseLines.push(` let ${varName}Cleared = false;`);
|
|
145
|
+
phaseLines.push(` setHandler(${varName}, () => { ${varName}Cleared = true; });`);
|
|
146
|
+
phaseLines.push(` await condition(() => ${varName}Cleared, ${JSON.stringify(timeout)});`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
lines.push(...phaseLines);
|
|
150
|
+
lines.push("");
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
renderPhases(config.phases);
|
|
155
|
+
|
|
156
|
+
if (config.onFailure && config.onFailure.length > 0) {
|
|
157
|
+
lines.push(" // onFailure compensation (executed on terminal failure only)");
|
|
158
|
+
renderPhases(config.onFailure);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
lines.push("}");
|
|
162
|
+
lines.push("");
|
|
163
|
+
|
|
164
|
+
return lines.join("\n");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── Activities re-export ──────────────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
function generateActivities(): string {
|
|
170
|
+
return [
|
|
171
|
+
"// Generated by chant — do not edit directly.",
|
|
172
|
+
"// Re-exports all pre-built activity implementations.",
|
|
173
|
+
"export * from '@intentius/chant-lexicon-temporal/op/activities';",
|
|
174
|
+
"",
|
|
175
|
+
].join("\n");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ── Worker bootstrap ──────────────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
function generateWorker(config: OpConfig): string {
|
|
181
|
+
const taskQueue = config.taskQueue ?? config.name;
|
|
182
|
+
|
|
183
|
+
return [
|
|
184
|
+
"// Generated by chant — do not edit directly.",
|
|
185
|
+
`// Run: npx tsx ops/${config.name}/worker.ts`,
|
|
186
|
+
"import { Worker, NativeConnection } from '@temporalio/worker';",
|
|
187
|
+
"import { fileURLToPath } from 'url';",
|
|
188
|
+
"import * as activities from './activities.js';",
|
|
189
|
+
"",
|
|
190
|
+
"async function run(): Promise<void> {",
|
|
191
|
+
" const { default: chantConfig } = await import('../../chant.config.js');",
|
|
192
|
+
"",
|
|
193
|
+
` const profileName = process.env.TEMPORAL_PROFILE ?? chantConfig.temporal?.defaultProfile ?? 'local';`,
|
|
194
|
+
" const profile = chantConfig.temporal?.profiles?.[profileName];",
|
|
195
|
+
"",
|
|
196
|
+
" if (!profile) {",
|
|
197
|
+
" console.error(",
|
|
198
|
+
` \`Unknown Temporal profile "\${profileName}". Available: \${Object.keys(chantConfig.temporal?.profiles ?? {}).join(', ')}\`,`,
|
|
199
|
+
" );",
|
|
200
|
+
" process.exit(1);",
|
|
201
|
+
" }",
|
|
202
|
+
"",
|
|
203
|
+
" const apiKey =",
|
|
204
|
+
" typeof profile.apiKey === 'object' && profile.apiKey !== null",
|
|
205
|
+
" ? process.env[(profile.apiKey as { env: string }).env]",
|
|
206
|
+
" : (profile.apiKey as string | undefined);",
|
|
207
|
+
"",
|
|
208
|
+
" const connection = await NativeConnection.connect({",
|
|
209
|
+
" address: profile.address,",
|
|
210
|
+
" ...(profile.tls && {",
|
|
211
|
+
" tls: typeof profile.tls === 'object' ? profile.tls : {},",
|
|
212
|
+
" metadata: apiKey ? { Authorization: `Bearer ${apiKey}` } : {},",
|
|
213
|
+
" }),",
|
|
214
|
+
" });",
|
|
215
|
+
"",
|
|
216
|
+
" const worker = await Worker.create({",
|
|
217
|
+
" connection,",
|
|
218
|
+
" namespace: profile.namespace,",
|
|
219
|
+
` taskQueue: profile.taskQueue ?? ${JSON.stringify(taskQueue)},`,
|
|
220
|
+
" workflowsPath: fileURLToPath(new URL('./workflow.js', import.meta.url)),",
|
|
221
|
+
" activities,",
|
|
222
|
+
" });",
|
|
223
|
+
"",
|
|
224
|
+
` console.log(\`Worker ready — polling task queue: \${profile.taskQueue ?? ${JSON.stringify(taskQueue)}}\`);`,
|
|
225
|
+
" await worker.run();",
|
|
226
|
+
"}",
|
|
227
|
+
"",
|
|
228
|
+
"run().catch((err: unknown) => {",
|
|
229
|
+
" console.error(err);",
|
|
230
|
+
" process.exit(1);",
|
|
231
|
+
"});",
|
|
232
|
+
"",
|
|
233
|
+
].join("\n");
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ── Public API ────────────────────────────────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
function getProps(entity: Declarable): Record<string, unknown> {
|
|
239
|
+
if ("props" in entity && typeof entity.props === "object" && entity.props !== null) {
|
|
240
|
+
return entity.props as Record<string, unknown>;
|
|
241
|
+
}
|
|
242
|
+
return {};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Serialize a map of Temporal::Op entities into generated file content.
|
|
247
|
+
*
|
|
248
|
+
* Returns a map of relative output paths → file content.
|
|
249
|
+
* e.g. `{ "ops/alb-deploy/workflow.ts": "...", ... }`
|
|
250
|
+
*
|
|
251
|
+
* Throws if a `depends` reference names an Op that is not in the entity map.
|
|
252
|
+
*/
|
|
253
|
+
export function serializeOps(ops: Map<string, Declarable>): Record<string, string> {
|
|
254
|
+
const knownNames = new Set<string>();
|
|
255
|
+
|
|
256
|
+
// First pass: collect all names
|
|
257
|
+
for (const [, entity] of ops) {
|
|
258
|
+
const props = getProps(entity) as OpConfig;
|
|
259
|
+
if (props.name) knownNames.add(props.name);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const files: Record<string, string> = {};
|
|
263
|
+
|
|
264
|
+
for (const [, entity] of ops) {
|
|
265
|
+
const config = getProps(entity) as OpConfig;
|
|
266
|
+
|
|
267
|
+
if (!config.name) {
|
|
268
|
+
throw new Error("Op entity missing required `name` field.");
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Validate depends
|
|
272
|
+
for (const dep of config.depends ?? []) {
|
|
273
|
+
if (!knownNames.has(dep)) {
|
|
274
|
+
throw new Error(
|
|
275
|
+
`Op "${config.name}" depends on unknown Op "${dep}". Known Ops: ${[...knownNames].join(", ")}`,
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const dir = `ops/${config.name}`;
|
|
281
|
+
files[`${dir}/workflow.ts`] = generateWorkflow(config);
|
|
282
|
+
files[`${dir}/activities.ts`] = generateActivities();
|
|
283
|
+
files[`${dir}/worker.ts`] = generateWorker(config);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return files;
|
|
287
|
+
}
|
package/src/serializer.test.ts
CHANGED
|
@@ -289,4 +289,43 @@ describe("temporal serializer", () => {
|
|
|
289
289
|
const result = temporalSerializer.serialize(entities) as { primary: string; files: Record<string, string> };
|
|
290
290
|
expect(result.files["temporal-setup.sh"]).toContain("set -euo pipefail");
|
|
291
291
|
});
|
|
292
|
+
|
|
293
|
+
// ── Temporal::Op ──────────────────────────────────────────────────
|
|
294
|
+
|
|
295
|
+
function makeOp(name: string, phases: unknown[] = []): [string, Record<string, unknown>] {
|
|
296
|
+
return [name, makeEntity("Temporal::Op", { name, overview: `${name} op`, phases })];
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
it("includes ops/<name>/workflow.ts when an Op entity is present", () => {
|
|
300
|
+
const entities = new Map([makeOp("alb-deploy")]);
|
|
301
|
+
const result = temporalSerializer.serialize(entities) as { primary: string; files: Record<string, string> };
|
|
302
|
+
expect(typeof result).toBe("object");
|
|
303
|
+
expect(result.files["ops/alb-deploy/workflow.ts"]).toBeDefined();
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("includes ops/<name>/activities.ts and worker.ts for each Op", () => {
|
|
307
|
+
const entities = new Map([makeOp("my-op")]);
|
|
308
|
+
const result = temporalSerializer.serialize(entities) as { primary: string; files: Record<string, string> };
|
|
309
|
+
expect(result.files["ops/my-op/activities.ts"]).toBeDefined();
|
|
310
|
+
expect(result.files["ops/my-op/worker.ts"]).toBeDefined();
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("still returns SerializerResult (not plain string) when only Op entities present", () => {
|
|
314
|
+
const entities = new Map([makeOp("op")]);
|
|
315
|
+
const result = temporalSerializer.serialize(entities);
|
|
316
|
+
expect(typeof result).toBe("object");
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("combines Op files with schedule and namespace files in mixed entities", () => {
|
|
320
|
+
const entities = new Map([
|
|
321
|
+
makeServer(),
|
|
322
|
+
makeNamespace("default"),
|
|
323
|
+
makeSchedule("weekly"),
|
|
324
|
+
makeOp("deploy-op"),
|
|
325
|
+
]);
|
|
326
|
+
const result = temporalSerializer.serialize(entities) as { primary: string; files: Record<string, string> };
|
|
327
|
+
expect(result.files["ops/deploy-op/workflow.ts"]).toBeDefined();
|
|
328
|
+
expect(result.files["temporal-setup.sh"]).toBeDefined();
|
|
329
|
+
expect(result.files["schedules/weekly.ts"]).toBeDefined();
|
|
330
|
+
});
|
|
292
331
|
});
|
package/src/serializer.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import type { Declarable } from "@intentius/chant/declarable";
|
|
15
15
|
import type { Serializer, SerializerResult } from "@intentius/chant/serializer";
|
|
16
16
|
import type { TemporalServerProps, TemporalNamespaceProps, SearchAttributeProps, TemporalScheduleProps } from "./resources";
|
|
17
|
+
import { serializeOps } from "./op/serializer";
|
|
17
18
|
|
|
18
19
|
// ── Helpers ─────────────────────────────────────────────────────────
|
|
19
20
|
|
|
@@ -263,6 +264,7 @@ export const temporalSerializer: Serializer = {
|
|
|
263
264
|
const namespaces = new Map<string, Declarable>();
|
|
264
265
|
const searchAttrs = new Map<string, Declarable>();
|
|
265
266
|
const schedules = new Map<string, Declarable>();
|
|
267
|
+
const ops = new Map<string, Declarable>();
|
|
266
268
|
|
|
267
269
|
for (const [name, entity] of entities) {
|
|
268
270
|
const et = entityType(entity);
|
|
@@ -270,21 +272,17 @@ export const temporalSerializer: Serializer = {
|
|
|
270
272
|
else if (et === "Temporal::Namespace") namespaces.set(name, entity);
|
|
271
273
|
else if (et === "Temporal::SearchAttribute") searchAttrs.set(name, entity);
|
|
272
274
|
else if (et === "Temporal::Schedule") schedules.set(name, entity);
|
|
275
|
+
else if (et === "Temporal::Op") ops.set(name, entity);
|
|
273
276
|
}
|
|
274
277
|
|
|
275
278
|
const primary = serializeDockerCompose(servers);
|
|
276
279
|
|
|
277
|
-
|
|
278
|
-
servers.size > 0 || // always emit helm-values when a server exists
|
|
279
|
-
namespaces.size > 0 ||
|
|
280
|
-
searchAttrs.size > 0 ||
|
|
281
|
-
schedules.size > 0;
|
|
282
|
-
|
|
283
|
-
// Only-server case: no extra files needed beyond docker-compose → return string
|
|
280
|
+
// Only-server case with no Ops: no extra files needed beyond docker-compose → return string
|
|
284
281
|
if (
|
|
285
282
|
namespaces.size === 0 &&
|
|
286
283
|
searchAttrs.size === 0 &&
|
|
287
|
-
schedules.size === 0
|
|
284
|
+
schedules.size === 0 &&
|
|
285
|
+
ops.size === 0
|
|
288
286
|
) {
|
|
289
287
|
return primary;
|
|
290
288
|
}
|
|
@@ -305,6 +303,10 @@ export const temporalSerializer: Serializer = {
|
|
|
305
303
|
files[`schedules/${scheduleId}.ts`] = serializeSchedule(scheduleId, props);
|
|
306
304
|
}
|
|
307
305
|
|
|
306
|
+
if (ops.size > 0) {
|
|
307
|
+
Object.assign(files, serializeOps(ops));
|
|
308
|
+
}
|
|
309
|
+
|
|
308
310
|
return { primary, files };
|
|
309
311
|
},
|
|
310
312
|
};
|