@intentius/chant-lexicon-temporal 0.1.5 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ }
@@ -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
  };
@@ -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
- const hasExtraFiles =
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
  };