@intentius/chant-lexicon-temporal 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,300 @@
1
+ /**
2
+ * Live introspection of a Temporal cluster — implements the
3
+ * LexiconPlugin.describeResources() contract for the temporal lexicon.
4
+ *
5
+ * Connects to the cluster identified by the chant config profile that
6
+ * matches the current environment (falls back to defaultProfile), then lists:
7
+ * - Namespaces via workflowService.listNamespaces
8
+ * - SearchAttributes via operatorService.listSearchAttributes (per namespace)
9
+ * - Schedules via scheduleClient.list (per namespace)
10
+ *
11
+ * Results are keyed by server-side identifier (e.g. "namespace/prod",
12
+ * "searchAttribute/prod/Project", "schedule/prod/daily-report"). Mapping back
13
+ * to chant entity names is a separate concern (would need entity props passed
14
+ * through the plugin contract).
15
+ */
16
+
17
+ import { loadChantConfig } from "@intentius/chant/config";
18
+ import {
19
+ loadTemporalClient,
20
+ connectionOptions,
21
+ resolveProfile,
22
+ type WorkerProfile,
23
+ } from "@intentius/chant/cli/handlers/run-client";
24
+ import type { ResourceMetadata } from "@intentius/chant/lexicon";
25
+
26
+ interface NamespaceListResponse {
27
+ namespaces?: Array<{
28
+ namespaceInfo?: {
29
+ name?: string;
30
+ state?: number | string;
31
+ description?: string;
32
+ ownerEmail?: string;
33
+ } | null;
34
+ config?: {
35
+ workflowExecutionRetentionTtl?: { seconds?: number | bigint | { toNumber(): number } } | null;
36
+ } | null;
37
+ isGlobalNamespace?: boolean;
38
+ }>;
39
+ nextPageToken?: Uint8Array | null;
40
+ }
41
+
42
+ interface SearchAttributesResponse {
43
+ customAttributes?: Record<string, number | string> | null;
44
+ systemAttributes?: Record<string, number | string> | null;
45
+ }
46
+
47
+ interface ScheduleSummary {
48
+ scheduleId?: string;
49
+ spec?: { cronExpressions?: string[] } | null;
50
+ action?: { type?: string; workflowType?: string } | null;
51
+ state?: { paused?: boolean; note?: string } | null;
52
+ }
53
+
54
+ interface RichConnection {
55
+ workflowService: {
56
+ listNamespaces(req: { pageSize?: number; nextPageToken?: Uint8Array }): Promise<NamespaceListResponse>;
57
+ };
58
+ operatorService: {
59
+ listSearchAttributes(req: { namespace: string }): Promise<SearchAttributesResponse>;
60
+ };
61
+ close?(): Promise<void>;
62
+ }
63
+
64
+ interface RichClient {
65
+ scheduleClient: {
66
+ list(opts: { namespace?: string }): AsyncIterable<ScheduleSummary>;
67
+ };
68
+ }
69
+
70
+ interface RichClientModule {
71
+ Connection: { connect(opts: Record<string, unknown>): Promise<RichConnection> };
72
+ Client: new (opts: { connection: RichConnection; namespace?: string }) => RichClient;
73
+ }
74
+
75
+ const NAMESPACE_STATE_NAMES: Record<number, string> = {
76
+ 0: "UNSPECIFIED",
77
+ 1: "REGISTERED",
78
+ 2: "DEPRECATED",
79
+ 3: "DELETED",
80
+ };
81
+
82
+ const VALUE_TYPE_NAMES: Record<number, string> = {
83
+ 0: "Unspecified",
84
+ 1: "Text",
85
+ 2: "Keyword",
86
+ 3: "Int",
87
+ 4: "Double",
88
+ 5: "Bool",
89
+ 6: "Datetime",
90
+ 7: "KeywordList",
91
+ };
92
+
93
+ function namespaceStateToString(state: number | string | undefined): string {
94
+ if (typeof state === "string") return state;
95
+ if (typeof state === "number") return NAMESPACE_STATE_NAMES[state] ?? `STATE_${state}`;
96
+ return "UNKNOWN";
97
+ }
98
+
99
+ function valueTypeToString(t: number | string | undefined): string {
100
+ if (typeof t === "string") return t;
101
+ if (typeof t === "number") return VALUE_TYPE_NAMES[t] ?? `TYPE_${t}`;
102
+ return "Unknown";
103
+ }
104
+
105
+ function retentionTtlToSeconds(
106
+ ttl: { seconds?: number | bigint | { toNumber(): number } } | null | undefined,
107
+ ): number | undefined {
108
+ if (!ttl?.seconds) return undefined;
109
+ const s = ttl.seconds;
110
+ if (typeof s === "number") return s;
111
+ if (typeof s === "bigint") return Number(s);
112
+ if (typeof s === "object" && "toNumber" in s) return s.toNumber();
113
+ return undefined;
114
+ }
115
+
116
+ function resolveProfileForEnv(
117
+ config: Record<string, unknown>,
118
+ environment: string,
119
+ ): WorkerProfile {
120
+ // Try env-named profile first, fall back to defaultProfile.
121
+ try {
122
+ return resolveProfile(config, environment);
123
+ } catch {
124
+ return resolveProfile(config);
125
+ }
126
+ }
127
+
128
+ async function paginateNamespaces(connection: RichConnection): Promise<NonNullable<NamespaceListResponse["namespaces"]>> {
129
+ const all: NonNullable<NamespaceListResponse["namespaces"]> = [];
130
+ let nextPageToken: Uint8Array | undefined;
131
+ do {
132
+ const res = await connection.workflowService.listNamespaces({
133
+ pageSize: 100,
134
+ ...(nextPageToken && { nextPageToken }),
135
+ });
136
+ if (res.namespaces) all.push(...res.namespaces);
137
+ nextPageToken = res.nextPageToken && res.nextPageToken.length > 0 ? res.nextPageToken : undefined;
138
+ } while (nextPageToken);
139
+ return all;
140
+ }
141
+
142
+ /**
143
+ * Build reverse-lookup maps from server-side identifiers back to chant
144
+ * entity names, using the `entities` map passed via the plugin contract.
145
+ *
146
+ * Mapping rules:
147
+ * - Namespace: props.name -> entity name
148
+ * - SearchAttribute: <ns>/<props.name> -> entity name
149
+ * - Schedule: <ns>/<props.scheduleId> -> entity name
150
+ *
151
+ * For SearchAttribute, the namespace defaults to the *first declared
152
+ * Temporal::Namespace's name* if the entity itself doesn't pin one — this
153
+ * matches the serializer's behavior when emitting registration commands.
154
+ */
155
+ function buildEntityIndex(
156
+ entities: Map<string, { entityType: string; props: Record<string, unknown> }>,
157
+ ): {
158
+ namespaceByName: Map<string, string>;
159
+ searchAttrByKey: Map<string, string>;
160
+ scheduleByKey: Map<string, string>;
161
+ defaultNamespace?: string;
162
+ } {
163
+ const namespaceByName = new Map<string, string>();
164
+ const searchAttrByKey = new Map<string, string>();
165
+ const scheduleByKey = new Map<string, string>();
166
+ let defaultNamespace: string | undefined;
167
+
168
+ for (const [entityName, { entityType, props }] of entities) {
169
+ if (entityType === "Temporal::Namespace") {
170
+ const nsName = (props.name as string) || "";
171
+ if (nsName) {
172
+ namespaceByName.set(nsName, entityName);
173
+ if (!defaultNamespace) defaultNamespace = nsName;
174
+ }
175
+ }
176
+ }
177
+
178
+ for (const [entityName, { entityType, props }] of entities) {
179
+ if (entityType === "Temporal::SearchAttribute") {
180
+ const attrName = (props.name as string) || "";
181
+ const ns = (props.namespace as string) || defaultNamespace || "";
182
+ if (attrName && ns) searchAttrByKey.set(`${ns}/${attrName}`, entityName);
183
+ } else if (entityType === "Temporal::Schedule") {
184
+ const scheduleId = (props.scheduleId as string) || "";
185
+ const ns = (props.namespace as string) || defaultNamespace || "";
186
+ if (scheduleId && ns) scheduleByKey.set(`${ns}/${scheduleId}`, entityName);
187
+ }
188
+ }
189
+
190
+ return { namespaceByName, searchAttrByKey, scheduleByKey, defaultNamespace };
191
+ }
192
+
193
+ export async function describeResources(options: {
194
+ environment: string;
195
+ buildOutput: string;
196
+ entityNames: string[];
197
+ entities: Map<string, { entityType: string; props: Record<string, unknown> }>;
198
+ }): Promise<Record<string, ResourceMetadata>> {
199
+ const { config } = await loadChantConfig(process.cwd());
200
+ const profile = resolveProfileForEnv(config as Record<string, unknown>, options.environment);
201
+
202
+ const mod = (await loadTemporalClient()) as unknown as RichClientModule;
203
+ const connection = await mod.Connection.connect(connectionOptions(profile));
204
+ const client = new mod.Client({ connection });
205
+
206
+ const idx = buildEntityIndex(options.entities);
207
+ const result: Record<string, ResourceMetadata> = {};
208
+
209
+ // Map a server-side identifier to a chant entity name when possible;
210
+ // otherwise fall back to the server-side prefixed key (orphan).
211
+ const keyForNamespace = (name: string): string =>
212
+ idx.namespaceByName.get(name) ?? `namespace/${name}`;
213
+ const keyForSearchAttr = (ns: string, attr: string): string =>
214
+ idx.searchAttrByKey.get(`${ns}/${attr}`) ?? `searchAttribute/${ns}/${attr}`;
215
+ const keyForSchedule = (ns: string, scheduleId: string): string =>
216
+ idx.scheduleByKey.get(`${ns}/${scheduleId}`) ?? `schedule/${ns}/${scheduleId}`;
217
+
218
+ try {
219
+ const namespaces = await paginateNamespaces(connection);
220
+
221
+ for (const ns of namespaces) {
222
+ const name = ns.namespaceInfo?.name;
223
+ if (!name) continue;
224
+
225
+ result[keyForNamespace(name)] = {
226
+ type: "Temporal::Namespace",
227
+ physicalId: name,
228
+ status: namespaceStateToString(ns.namespaceInfo?.state),
229
+ attributes: pruneUndefined({
230
+ description: ns.namespaceInfo?.description,
231
+ ownerEmail: ns.namespaceInfo?.ownerEmail,
232
+ isGlobalNamespace: ns.isGlobalNamespace,
233
+ retentionSeconds: retentionTtlToSeconds(ns.config?.workflowExecutionRetentionTtl ?? undefined),
234
+ }),
235
+ };
236
+
237
+ // SearchAttributes — failure on one namespace shouldn't abort others.
238
+ try {
239
+ const sa = await connection.operatorService.listSearchAttributes({ namespace: name });
240
+ for (const [attrName, valueType] of Object.entries(sa.customAttributes ?? {})) {
241
+ result[keyForSearchAttr(name, attrName)] = {
242
+ type: "Temporal::SearchAttribute",
243
+ physicalId: `${name}/${attrName}`,
244
+ status: "REGISTERED",
245
+ attributes: {
246
+ valueType: valueTypeToString(valueType),
247
+ namespace: name,
248
+ },
249
+ };
250
+ }
251
+ } catch (err) {
252
+ // eslint-disable-next-line no-console
253
+ console.warn(
254
+ `[temporal] failed to list search attributes for namespace "${name}": ${
255
+ err instanceof Error ? err.message : String(err)
256
+ }`,
257
+ );
258
+ }
259
+
260
+ // Schedules — same fail-soft policy.
261
+ try {
262
+ for await (const s of client.scheduleClient.list({ namespace: name })) {
263
+ if (!s.scheduleId) continue;
264
+ result[keyForSchedule(name, s.scheduleId)] = {
265
+ type: "Temporal::Schedule",
266
+ physicalId: `${name}/${s.scheduleId}`,
267
+ status: s.state?.paused ? "PAUSED" : "ACTIVE",
268
+ attributes: pruneUndefined({
269
+ namespace: name,
270
+ workflowType: s.action?.workflowType,
271
+ cronExpressions: s.spec?.cronExpressions,
272
+ note: s.state?.note,
273
+ }),
274
+ };
275
+ }
276
+ } catch (err) {
277
+ // eslint-disable-next-line no-console
278
+ console.warn(
279
+ `[temporal] failed to list schedules for namespace "${name}": ${
280
+ err instanceof Error ? err.message : String(err)
281
+ }`,
282
+ );
283
+ }
284
+ }
285
+ } finally {
286
+ if (typeof connection.close === "function") {
287
+ try { await connection.close(); } catch { /* best-effort */ }
288
+ }
289
+ }
290
+
291
+ return result;
292
+ }
293
+
294
+ function pruneUndefined<T extends Record<string, unknown>>(obj: T): Record<string, unknown> {
295
+ const out: Record<string, unknown> = {};
296
+ for (const [k, v] of Object.entries(obj)) {
297
+ if (v !== undefined) out[k] = v;
298
+ }
299
+ return out;
300
+ }
package/src/index.ts CHANGED
@@ -27,6 +27,8 @@ export { TemporalDevStack } from "./composites/dev-stack";
27
27
  export type { TemporalDevStackConfig, TemporalDevStackResources } from "./composites/dev-stack";
28
28
  export { TemporalCloudStack } from "./composites/cloud-stack";
29
29
  export type { TemporalCloudStackConfig, TemporalCloudStackResources } from "./composites/cloud-stack";
30
+ export { WatchOp } from "./composites/watch-op";
31
+ export type { WatchOpConfig, WatchOpResources } from "./composites/watch-op";
30
32
 
31
33
  // Op builders (re-exported from core for single-import convenience)
32
34
  export {
@@ -16,8 +16,8 @@ export type { GitlabPipelineArgs } from "./gitlab";
16
16
  export { shellCmd } from "./shell";
17
17
  export type { ShellCmdArgs } from "./shell";
18
18
 
19
- export { stateSnapshot } from "./state";
20
- export type { StateSnapshotArgs } from "./state";
19
+ export { stateSnapshot, stateDiff } from "./state";
20
+ export type { StateSnapshotArgs, StateDiffArgs, StateDiffResult } from "./state";
21
21
 
22
22
  export { chantTeardown } from "./teardown";
23
23
  export type { ChantTeardownArgs } from "./teardown";
@@ -17,3 +17,69 @@ export async function stateSnapshot(args: StateSnapshotArgs): Promise<void> {
17
17
  if (stdout) console.log(stdout);
18
18
  if (stderr) console.error(stderr);
19
19
  }
20
+
21
+ export interface StateDiffArgs {
22
+ /** Environment name (e.g. "dev", "staging", "prod"). */
23
+ env: string;
24
+ /**
25
+ * When true, run `chant state diff <env> --live` (queries cloud APIs).
26
+ * When false (default), run digest-only diff against the last snapshot.
27
+ */
28
+ live?: boolean;
29
+ }
30
+
31
+ export interface StateDiffResult {
32
+ /** Combined stdout + stderr from the chant command. */
33
+ output: string;
34
+ /** Process exit code (0 = success). */
35
+ exitCode: number;
36
+ /**
37
+ * True when the diff output contains any drift indicators
38
+ * (MISSING / ORPHAN / DRIFTED / DISAPPEARED section headers from
39
+ * `chant state diff --live`).
40
+ */
41
+ drifted: boolean;
42
+ }
43
+
44
+ /**
45
+ * Section headers emitted by `chant state diff --live` that indicate a
46
+ * non-empty drift category. See packages/core/src/cli/handlers/state.ts.
47
+ */
48
+ const DRIFT_HEADERS = [
49
+ "MISSING",
50
+ "ORPHAN",
51
+ "DISAPPEARED",
52
+ "DRIFTED",
53
+ "ARTIFACTS ADDED",
54
+ "ARTIFACTS REMOVED",
55
+ "ARTIFACTS CHANGED",
56
+ ];
57
+
58
+ function detectDrift(output: string): boolean {
59
+ return DRIFT_HEADERS.some((h) => output.includes(`${h} (`) || output.includes(`\n${h}`));
60
+ }
61
+
62
+ /**
63
+ * Run `chant state diff <env>` and return the output + structured drift
64
+ * flag. Read-only; intended for use inside watch/observation workflows.
65
+ * Uses fastIdempotent profile.
66
+ *
67
+ * The `drifted` field is computed by scanning the output for any of the
68
+ * MISSING / ORPHAN / DRIFTED / DISAPPEARED section headers documented in
69
+ * cli/state.mdx. Pair with `outcomeAttribute: { name: "Drift", from: "drifted" }`
70
+ * on a WatchOp activity step to surface drift as a workflow search attribute.
71
+ */
72
+ export async function stateDiff(args: StateDiffArgs): Promise<StateDiffResult> {
73
+ const liveFlag = args.live ? " --live" : "";
74
+ try {
75
+ const { stdout, stderr } = await execAsync(`chant state diff ${args.env}${liveFlag}`);
76
+ const output = `${stdout}${stderr}`.trim();
77
+ if (output) console.log(output);
78
+ return { output, exitCode: 0, drifted: detectDrift(output) };
79
+ } catch (err) {
80
+ const e = err as { code?: number; stdout?: string; stderr?: string };
81
+ const output = `${e.stdout ?? ""}${e.stderr ?? ""}`.trim();
82
+ if (output) console.error(output);
83
+ return { output, exitCode: e.code ?? 1, drifted: detectDrift(output) };
84
+ }
85
+ }
@@ -194,6 +194,23 @@ describe("serializeOps()", () => {
194
194
  const wf = serializeOps(ops)["ops/op/workflow.ts"];
195
195
  expect(wf).toContain("// Phase: Build and Test");
196
196
  });
197
+
198
+ it("renders onFailure compensation phases after main phases", () => {
199
+ const ops = new Map([
200
+ makeOp({
201
+ name: "safe-op", overview: "o",
202
+ phases: [
203
+ { name: "Deploy", steps: [{ kind: "activity", fn: "helmInstall", args: { name: "r", chart: "c" } }] },
204
+ ],
205
+ onFailure: [
206
+ { name: "Rollback", steps: [{ kind: "activity", fn: "helmInstall", args: { name: "r", chart: "c" } }] },
207
+ ],
208
+ }),
209
+ ]);
210
+ const wf = serializeOps(ops)["ops/safe-op/workflow.ts"];
211
+ expect(wf).toContain("onFailure compensation");
212
+ expect(wf).toContain("// Phase: Rollback");
213
+ });
197
214
  });
198
215
 
199
216
  // ── activities.ts ───────────────────────────────────────────────────────────
@@ -274,4 +291,218 @@ describe("serializeOps()", () => {
274
291
  expect(() => serializeOps(ops)).toThrow(/nonexistent-op/);
275
292
  });
276
293
  });
294
+
295
+ // ── Auto-emit upsertSearchAttributes (#28) ──────────────────────────────────
296
+
297
+ describe("upsertSearchAttributes auto-emit", () => {
298
+ function countOccurrences(haystack: string, needle: string): number {
299
+ return haystack.split(needle).length - 1;
300
+ }
301
+
302
+ it("imports upsertSearchAttributes from @temporalio/workflow", () => {
303
+ const ops = new Map([
304
+ makeOp({ name: "op", overview: "o", phases: [{ name: "Phase1", steps: [] }] }),
305
+ ]);
306
+ const wf = serializeOps(ops)["ops/op/workflow.ts"];
307
+ expect(wf).toMatch(
308
+ /import \{[^}]*\bupsertSearchAttributes\b[^}]*\} from '@temporalio\/workflow'/,
309
+ );
310
+ });
311
+
312
+ it("3-phase Op with no searchAttributes emits exactly 4 upsert calls", () => {
313
+ const ops = new Map([
314
+ makeOp({
315
+ name: "deploy",
316
+ overview: "o",
317
+ phases: [
318
+ { name: "init", steps: [] },
319
+ { name: "apply", steps: [] },
320
+ { name: "verify", steps: [] },
321
+ ],
322
+ }),
323
+ ]);
324
+ const wf = serializeOps(ops)["ops/deploy/workflow.ts"];
325
+
326
+ expect(countOccurrences(wf, "upsertSearchAttributes(")).toBe(4);
327
+ // Initial call: OpName only, no Phase yet
328
+ expect(wf).toContain('upsertSearchAttributes({"OpName":["deploy"]});');
329
+ // Phase upserts at each phase boundary
330
+ expect(wf).toContain('upsertSearchAttributes({ Phase: ["init"] });');
331
+ expect(wf).toContain('upsertSearchAttributes({ Phase: ["apply"] });');
332
+ expect(wf).toContain('upsertSearchAttributes({ Phase: ["verify"] });');
333
+ });
334
+
335
+ it("merges user-provided searchAttributes into the initial call (each value as a 1-element array)", () => {
336
+ const ops = new Map([
337
+ makeOp({
338
+ name: "deploy",
339
+ overview: "o",
340
+ phases: [{ name: "Build", steps: [] }],
341
+ searchAttributes: { Region: "us-east-1", Environment: "prod" },
342
+ }),
343
+ ]);
344
+ const wf = serializeOps(ops)["ops/deploy/workflow.ts"];
345
+
346
+ expect(wf).toContain(
347
+ 'upsertSearchAttributes({"OpName":["deploy"],"Region":["us-east-1"],"Environment":["prod"]});',
348
+ );
349
+ });
350
+
351
+ it("single-phase Op produces exactly 2 upsert calls", () => {
352
+ const ops = new Map([
353
+ makeOp({
354
+ name: "tiny",
355
+ overview: "o",
356
+ phases: [{ name: "Only", steps: [] }],
357
+ }),
358
+ ]);
359
+ const wf = serializeOps(ops)["ops/tiny/workflow.ts"];
360
+
361
+ expect(countOccurrences(wf, "upsertSearchAttributes(")).toBe(2);
362
+ });
363
+
364
+ it("emits Phase upserts inside onFailure compensation phases", () => {
365
+ const ops = new Map([
366
+ makeOp({
367
+ name: "deploy",
368
+ overview: "o",
369
+ phases: [{ name: "Apply", steps: [] }],
370
+ onFailure: [
371
+ { name: "Rollback", steps: [] },
372
+ { name: "Notify", steps: [] },
373
+ ],
374
+ }),
375
+ ]);
376
+ const wf = serializeOps(ops)["ops/deploy/workflow.ts"];
377
+
378
+ // 1 initial + 1 Apply + 2 onFailure phases = 4
379
+ expect(countOccurrences(wf, "upsertSearchAttributes(")).toBe(4);
380
+ expect(wf).toContain('upsertSearchAttributes({ Phase: ["Rollback"] });');
381
+ expect(wf).toContain('upsertSearchAttributes({ Phase: ["Notify"] });');
382
+ });
383
+ });
384
+
385
+ // ── outcomeAttribute (#41) ──────────────────────────────────────────────────
386
+
387
+ describe("outcomeAttribute auto-emit", () => {
388
+ it("captures activity result and emits an upsert with from-path", () => {
389
+ const ops = new Map([
390
+ makeOp({
391
+ name: "watch",
392
+ overview: "watch",
393
+ phases: [
394
+ {
395
+ name: "Diff",
396
+ steps: [
397
+ { kind: "activity", fn: "stateDiff", args: { env: "prod" }, outcomeAttribute: { name: "Drift", from: "drifted" } },
398
+ ],
399
+ },
400
+ ],
401
+ }),
402
+ ]);
403
+ const wf = serializeOps(ops)["ops/watch/workflow.ts"];
404
+ expect(wf).toContain('const __r0 = await stateDiff({"env":"prod"});');
405
+ expect(wf).toContain('upsertSearchAttributes({ "Drift": [String(__r0?.drifted)] });');
406
+ });
407
+
408
+ it("stringifies whole result when from is omitted", () => {
409
+ const ops = new Map([
410
+ makeOp({
411
+ name: "watch",
412
+ overview: "watch",
413
+ phases: [
414
+ {
415
+ name: "Check",
416
+ steps: [
417
+ { kind: "activity", fn: "checkSomething", outcomeAttribute: { name: "Status" } },
418
+ ],
419
+ },
420
+ ],
421
+ }),
422
+ ]);
423
+ const wf = serializeOps(ops)["ops/watch/workflow.ts"];
424
+ expect(wf).toContain('upsertSearchAttributes({ "Status": [String(__r0)] });');
425
+ });
426
+
427
+ it("nested from-path uses optional-chain stringify", () => {
428
+ const ops = new Map([
429
+ makeOp({
430
+ name: "watch",
431
+ overview: "watch",
432
+ phases: [
433
+ {
434
+ name: "Inspect",
435
+ steps: [
436
+ { kind: "activity", fn: "deepInspect", outcomeAttribute: { name: "Ok", from: "result.healthy" } },
437
+ ],
438
+ },
439
+ ],
440
+ }),
441
+ ]);
442
+ const wf = serializeOps(ops)["ops/watch/workflow.ts"];
443
+ expect(wf).toContain('upsertSearchAttributes({ "Ok": [String(__r0?.result?.healthy)] });');
444
+ });
445
+
446
+ it("counter is workflow-scoped: multiple outcome attrs use __r0, __r1, ...", () => {
447
+ const ops = new Map([
448
+ makeOp({
449
+ name: "watch",
450
+ overview: "watch",
451
+ phases: [
452
+ { name: "A", steps: [{ kind: "activity", fn: "first", outcomeAttribute: { name: "FirstResult" } }] },
453
+ { name: "B", steps: [{ kind: "activity", fn: "second", outcomeAttribute: { name: "SecondResult" } }] },
454
+ ],
455
+ }),
456
+ ]);
457
+ const wf = serializeOps(ops)["ops/watch/workflow.ts"];
458
+ expect(wf).toContain("const __r0 = await first(");
459
+ expect(wf).toContain("const __r1 = await second(");
460
+ expect(wf).toContain('upsertSearchAttributes({ "FirstResult": [String(__r0)] });');
461
+ expect(wf).toContain('upsertSearchAttributes({ "SecondResult": [String(__r1)] });');
462
+ });
463
+
464
+ it("activities without outcomeAttribute keep the bare-await form", () => {
465
+ const ops = new Map([
466
+ makeOp({
467
+ name: "mixed",
468
+ overview: "mixed",
469
+ phases: [
470
+ {
471
+ name: "Mix",
472
+ steps: [
473
+ { kind: "activity", fn: "noOutcome" },
474
+ { kind: "activity", fn: "withOutcome", outcomeAttribute: { name: "Result" } },
475
+ ],
476
+ },
477
+ ],
478
+ }),
479
+ ]);
480
+ const wf = serializeOps(ops)["ops/mixed/workflow.ts"];
481
+ expect(wf).toContain("await noOutcome({});");
482
+ expect(wf).toContain("const __r0 = await withOutcome({});");
483
+ });
484
+
485
+ it("parallel phase with outcome attrs destructures Promise.all results", () => {
486
+ const ops = new Map([
487
+ makeOp({
488
+ name: "fan",
489
+ overview: "fan",
490
+ phases: [
491
+ {
492
+ name: "Parallel",
493
+ parallel: true,
494
+ steps: [
495
+ { kind: "activity", fn: "alpha", outcomeAttribute: { name: "AlphaOk", from: "ok" } },
496
+ { kind: "activity", fn: "beta", outcomeAttribute: { name: "BetaOk", from: "ok" } },
497
+ ],
498
+ },
499
+ ],
500
+ }),
501
+ ]);
502
+ const wf = serializeOps(ops)["ops/fan/workflow.ts"];
503
+ expect(wf).toContain("const [__r0, __r1] = await Promise.all([");
504
+ expect(wf).toContain('upsertSearchAttributes({ "AlphaOk": [String(__r0?.ok)] });');
505
+ expect(wf).toContain('upsertSearchAttributes({ "BetaOk": [String(__r1?.ok)] });');
506
+ });
507
+ });
277
508
  });