@intentius/chant-lexicon-temporal 0.1.5 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/package.json +2 -1
- package/src/codegen/docs.ts +1 -0
- package/src/composites/composites.test.ts +103 -1
- package/src/composites/watch-op.ts +101 -0
- package/src/describe-resources.test.ts +328 -0
- package/src/describe-resources.ts +300 -0
- package/src/index.ts +19 -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 +85 -0
- package/src/op/activities/teardown.ts +21 -0
- package/src/op/activities/wait.ts +52 -0
- package/src/op/op-serializer.test.ts +508 -0
- package/src/op/serializer.ts +349 -0
- package/src/plugin.test.ts +2 -2
- package/src/plugin.ts +7 -0
- package/src/serializer.test.ts +39 -0
- package/src/serializer.ts +10 -8
|
@@ -0,0 +1,349 @@
|
|
|
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, upsertSearchAttributes } 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
|
+
// Initial search attributes — OpName plus any user-provided attrs.
|
|
111
|
+
// Each value is wrapped in a single-element array (classic
|
|
112
|
+
// upsertSearchAttributes API takes arrays).
|
|
113
|
+
const initialAttrs: Record<string, string[]> = {
|
|
114
|
+
OpName: [config.name],
|
|
115
|
+
};
|
|
116
|
+
for (const [k, v] of Object.entries(config.searchAttributes ?? {})) {
|
|
117
|
+
initialAttrs[k] = [v];
|
|
118
|
+
}
|
|
119
|
+
lines.push(` upsertSearchAttributes(${JSON.stringify(initialAttrs)});`);
|
|
120
|
+
lines.push("");
|
|
121
|
+
|
|
122
|
+
// Counter for outcome-attribute capture variables (workflow-scoped).
|
|
123
|
+
let resultCounter = 0;
|
|
124
|
+
const nextResultVar = (): string => `__r${resultCounter++}`;
|
|
125
|
+
|
|
126
|
+
// Build a `String(<var>?.<from-path>)` fragment from a dot-path.
|
|
127
|
+
const stringifyFromPath = (varName: string, from?: string): string => {
|
|
128
|
+
if (!from) return `String(${varName})`;
|
|
129
|
+
const parts = from.split(".");
|
|
130
|
+
return `String(${varName}?.${parts.join("?.")})`;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// Emit `upsertSearchAttributes({ <name>: [<expr>] })` for an outcome attr.
|
|
134
|
+
const emitOutcomeUpsert = (
|
|
135
|
+
step: ActivityStep,
|
|
136
|
+
varName: string,
|
|
137
|
+
indent = " ",
|
|
138
|
+
): string | null => {
|
|
139
|
+
if (!step.outcomeAttribute) return null;
|
|
140
|
+
const { name, from } = step.outcomeAttribute;
|
|
141
|
+
return `${indent}upsertSearchAttributes({ ${JSON.stringify(name)}: [${stringifyFromPath(varName, from)}] });`;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const renderPhases = (phases: PhaseDefinition[]) => {
|
|
145
|
+
for (const phase of phases) {
|
|
146
|
+
const phaseLines: string[] = [];
|
|
147
|
+
phaseLines.push(` // Phase: ${phase.name}`);
|
|
148
|
+
phaseLines.push(` upsertSearchAttributes({ Phase: ${JSON.stringify([phase.name])} });`);
|
|
149
|
+
|
|
150
|
+
const activitySteps = phase.steps.filter(isActivityStep);
|
|
151
|
+
const gateSteps = phase.steps.filter(isGateStep);
|
|
152
|
+
|
|
153
|
+
if (phase.parallel && activitySteps.length > 1) {
|
|
154
|
+
// Capture results into an array if any step has an outcome attribute,
|
|
155
|
+
// otherwise just await Promise.all without the destructure.
|
|
156
|
+
const anyOutcome = activitySteps.some((s) => s.outcomeAttribute);
|
|
157
|
+
if (anyOutcome) {
|
|
158
|
+
const vars = activitySteps.map(() => nextResultVar());
|
|
159
|
+
phaseLines.push(` const [${vars.join(", ")}] = await Promise.all([`);
|
|
160
|
+
for (let i = 0; i < activitySteps.length; i++) {
|
|
161
|
+
const step = activitySteps[i];
|
|
162
|
+
const argsStr = step.args && Object.keys(step.args).length > 0
|
|
163
|
+
? JSON.stringify(step.args)
|
|
164
|
+
: "{}";
|
|
165
|
+
phaseLines.push(` ${step.fn}(${argsStr}),`);
|
|
166
|
+
}
|
|
167
|
+
phaseLines.push(" ]);");
|
|
168
|
+
for (let i = 0; i < activitySteps.length; i++) {
|
|
169
|
+
const upsert = emitOutcomeUpsert(activitySteps[i], vars[i]);
|
|
170
|
+
if (upsert) phaseLines.push(upsert);
|
|
171
|
+
}
|
|
172
|
+
} else {
|
|
173
|
+
phaseLines.push(" await Promise.all([");
|
|
174
|
+
for (const step of activitySteps) {
|
|
175
|
+
const argsStr = step.args && Object.keys(step.args).length > 0
|
|
176
|
+
? JSON.stringify(step.args)
|
|
177
|
+
: "{}";
|
|
178
|
+
phaseLines.push(` ${step.fn}(${argsStr}),`);
|
|
179
|
+
}
|
|
180
|
+
phaseLines.push(" ]);");
|
|
181
|
+
}
|
|
182
|
+
} else {
|
|
183
|
+
for (const step of activitySteps) {
|
|
184
|
+
const argsStr = step.args && Object.keys(step.args).length > 0
|
|
185
|
+
? JSON.stringify(step.args)
|
|
186
|
+
: "{}";
|
|
187
|
+
if (step.outcomeAttribute) {
|
|
188
|
+
const v = nextResultVar();
|
|
189
|
+
phaseLines.push(` const ${v} = await ${step.fn}(${argsStr});`);
|
|
190
|
+
const upsert = emitOutcomeUpsert(step, v);
|
|
191
|
+
if (upsert) phaseLines.push(upsert);
|
|
192
|
+
} else {
|
|
193
|
+
phaseLines.push(` await ${step.fn}(${argsStr});`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
for (const gateStep of gateSteps) {
|
|
199
|
+
const varName = signalVarName(gateStep.signalName);
|
|
200
|
+
const timeout = gateStep.timeout ?? "48h";
|
|
201
|
+
if (gateStep.description) {
|
|
202
|
+
phaseLines.push(` // Gate: ${gateStep.signalName} — ${gateStep.description}`);
|
|
203
|
+
} else {
|
|
204
|
+
phaseLines.push(` // Gate: ${gateStep.signalName}`);
|
|
205
|
+
}
|
|
206
|
+
phaseLines.push(` let ${varName}Cleared = false;`);
|
|
207
|
+
phaseLines.push(` setHandler(${varName}, () => { ${varName}Cleared = true; });`);
|
|
208
|
+
phaseLines.push(` await condition(() => ${varName}Cleared, ${JSON.stringify(timeout)});`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
lines.push(...phaseLines);
|
|
212
|
+
lines.push("");
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
renderPhases(config.phases);
|
|
217
|
+
|
|
218
|
+
if (config.onFailure && config.onFailure.length > 0) {
|
|
219
|
+
lines.push(" // onFailure compensation (executed on terminal failure only)");
|
|
220
|
+
renderPhases(config.onFailure);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
lines.push("}");
|
|
224
|
+
lines.push("");
|
|
225
|
+
|
|
226
|
+
return lines.join("\n");
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ── Activities re-export ──────────────────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
function generateActivities(): string {
|
|
232
|
+
return [
|
|
233
|
+
"// Generated by chant — do not edit directly.",
|
|
234
|
+
"// Re-exports all pre-built activity implementations.",
|
|
235
|
+
"export * from '@intentius/chant-lexicon-temporal/op/activities';",
|
|
236
|
+
"",
|
|
237
|
+
].join("\n");
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ── Worker bootstrap ──────────────────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
function generateWorker(config: OpConfig): string {
|
|
243
|
+
const taskQueue = config.taskQueue ?? config.name;
|
|
244
|
+
|
|
245
|
+
return [
|
|
246
|
+
"// Generated by chant — do not edit directly.",
|
|
247
|
+
`// Run: npx tsx ops/${config.name}/worker.ts`,
|
|
248
|
+
"import { Worker, NativeConnection } from '@temporalio/worker';",
|
|
249
|
+
"import { fileURLToPath } from 'url';",
|
|
250
|
+
"import * as activities from './activities.js';",
|
|
251
|
+
"",
|
|
252
|
+
"async function run(): Promise<void> {",
|
|
253
|
+
" const { default: chantConfig } = await import('../../chant.config.js');",
|
|
254
|
+
"",
|
|
255
|
+
` const profileName = process.env.TEMPORAL_PROFILE ?? chantConfig.temporal?.defaultProfile ?? 'local';`,
|
|
256
|
+
" const profile = chantConfig.temporal?.profiles?.[profileName];",
|
|
257
|
+
"",
|
|
258
|
+
" if (!profile) {",
|
|
259
|
+
" console.error(",
|
|
260
|
+
` \`Unknown Temporal profile "\${profileName}". Available: \${Object.keys(chantConfig.temporal?.profiles ?? {}).join(', ')}\`,`,
|
|
261
|
+
" );",
|
|
262
|
+
" process.exit(1);",
|
|
263
|
+
" }",
|
|
264
|
+
"",
|
|
265
|
+
" const apiKey =",
|
|
266
|
+
" typeof profile.apiKey === 'object' && profile.apiKey !== null",
|
|
267
|
+
" ? process.env[(profile.apiKey as { env: string }).env]",
|
|
268
|
+
" : (profile.apiKey as string | undefined);",
|
|
269
|
+
"",
|
|
270
|
+
" const connection = await NativeConnection.connect({",
|
|
271
|
+
" address: profile.address,",
|
|
272
|
+
" ...(profile.tls && {",
|
|
273
|
+
" tls: typeof profile.tls === 'object' ? profile.tls : {},",
|
|
274
|
+
" metadata: apiKey ? { Authorization: `Bearer ${apiKey}` } : {},",
|
|
275
|
+
" }),",
|
|
276
|
+
" });",
|
|
277
|
+
"",
|
|
278
|
+
" const worker = await Worker.create({",
|
|
279
|
+
" connection,",
|
|
280
|
+
" namespace: profile.namespace,",
|
|
281
|
+
` taskQueue: profile.taskQueue ?? ${JSON.stringify(taskQueue)},`,
|
|
282
|
+
" workflowsPath: fileURLToPath(new URL('./workflow.js', import.meta.url)),",
|
|
283
|
+
" activities,",
|
|
284
|
+
" });",
|
|
285
|
+
"",
|
|
286
|
+
` console.log(\`Worker ready — polling task queue: \${profile.taskQueue ?? ${JSON.stringify(taskQueue)}}\`);`,
|
|
287
|
+
" await worker.run();",
|
|
288
|
+
"}",
|
|
289
|
+
"",
|
|
290
|
+
"run().catch((err: unknown) => {",
|
|
291
|
+
" console.error(err);",
|
|
292
|
+
" process.exit(1);",
|
|
293
|
+
"});",
|
|
294
|
+
"",
|
|
295
|
+
].join("\n");
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ── Public API ────────────────────────────────────────────────────────────────
|
|
299
|
+
|
|
300
|
+
function getProps(entity: Declarable): Record<string, unknown> {
|
|
301
|
+
if ("props" in entity && typeof entity.props === "object" && entity.props !== null) {
|
|
302
|
+
return entity.props as Record<string, unknown>;
|
|
303
|
+
}
|
|
304
|
+
return {};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Serialize a map of Temporal::Op entities into generated file content.
|
|
309
|
+
*
|
|
310
|
+
* Returns a map of relative output paths → file content.
|
|
311
|
+
* e.g. `{ "ops/alb-deploy/workflow.ts": "...", ... }`
|
|
312
|
+
*
|
|
313
|
+
* Throws if a `depends` reference names an Op that is not in the entity map.
|
|
314
|
+
*/
|
|
315
|
+
export function serializeOps(ops: Map<string, Declarable>): Record<string, string> {
|
|
316
|
+
const knownNames = new Set<string>();
|
|
317
|
+
|
|
318
|
+
// First pass: collect all names
|
|
319
|
+
for (const [, entity] of ops) {
|
|
320
|
+
const props = getProps(entity) as OpConfig;
|
|
321
|
+
if (props.name) knownNames.add(props.name);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const files: Record<string, string> = {};
|
|
325
|
+
|
|
326
|
+
for (const [, entity] of ops) {
|
|
327
|
+
const config = getProps(entity) as OpConfig;
|
|
328
|
+
|
|
329
|
+
if (!config.name) {
|
|
330
|
+
throw new Error("Op entity missing required `name` field.");
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Validate depends
|
|
334
|
+
for (const dep of config.depends ?? []) {
|
|
335
|
+
if (!knownNames.has(dep)) {
|
|
336
|
+
throw new Error(
|
|
337
|
+
`Op "${config.name}" depends on unknown Op "${dep}". Known Ops: ${[...knownNames].join(", ")}`,
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const dir = `ops/${config.name}`;
|
|
343
|
+
files[`${dir}/workflow.ts`] = generateWorkflow(config);
|
|
344
|
+
files[`${dir}/activities.ts`] = generateActivities();
|
|
345
|
+
files[`${dir}/worker.ts`] = generateWorker(config);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return files;
|
|
349
|
+
}
|
package/src/plugin.test.ts
CHANGED
|
@@ -32,7 +32,7 @@ describe("temporal plugin", () => {
|
|
|
32
32
|
const tools = temporalPlugin.mcpTools?.();
|
|
33
33
|
expect(Array.isArray(tools)).toBe(true);
|
|
34
34
|
expect(tools?.length).toBe(1);
|
|
35
|
-
expect(tools?.[0].name).toBe("diff");
|
|
35
|
+
expect(tools?.[0].name).toBe("temporal:diff");
|
|
36
36
|
});
|
|
37
37
|
|
|
38
38
|
it("mcpResources() returns at least 2 resources including resource-catalog", () => {
|
|
@@ -40,7 +40,7 @@ describe("temporal plugin", () => {
|
|
|
40
40
|
expect(Array.isArray(resources)).toBe(true);
|
|
41
41
|
expect((resources?.length ?? 0)).toBeGreaterThanOrEqual(2);
|
|
42
42
|
const uris = resources?.map((r) => r.uri);
|
|
43
|
-
expect(uris).toContain("resource-catalog");
|
|
43
|
+
expect(uris).toContain("temporal:resource-catalog");
|
|
44
44
|
});
|
|
45
45
|
|
|
46
46
|
it("skills() returns 2 skill entries", () => {
|
package/src/plugin.ts
CHANGED
|
@@ -103,6 +103,7 @@ export const temporalPlugin: LexiconPlugin = {
|
|
|
103
103
|
createDiffTool(
|
|
104
104
|
temporalSerializer,
|
|
105
105
|
"Compare current Temporal build output (docker-compose.yml, temporal-setup.sh, schedules/) against previous version",
|
|
106
|
+
"temporal",
|
|
106
107
|
),
|
|
107
108
|
];
|
|
108
109
|
},
|
|
@@ -114,6 +115,7 @@ export const temporalPlugin: LexiconPlugin = {
|
|
|
114
115
|
"Temporal Resource Types",
|
|
115
116
|
"All supported Temporal resource types: TemporalServer, TemporalNamespace, SearchAttribute, TemporalSchedule",
|
|
116
117
|
"lexicon-temporal.json",
|
|
118
|
+
"temporal",
|
|
117
119
|
),
|
|
118
120
|
{
|
|
119
121
|
uri: "examples/dev-server",
|
|
@@ -283,4 +285,9 @@ export const temporalPlugin: LexiconPlugin = {
|
|
|
283
285
|
const { generateDocs } = await import("./codegen/docs");
|
|
284
286
|
return generateDocs(options);
|
|
285
287
|
},
|
|
288
|
+
|
|
289
|
+
async describeResources(options) {
|
|
290
|
+
const { describeResources } = await import("./describe-resources");
|
|
291
|
+
return describeResources(options);
|
|
292
|
+
},
|
|
286
293
|
};
|
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
|
};
|