@intentius/chant 0.50.0 → 0.51.0

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.
Files changed (63) hide show
  1. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  2. package/dist/cli/handlers/op-progress.d.ts +57 -0
  3. package/dist/cli/handlers/op-progress.d.ts.map +1 -0
  4. package/dist/cli/handlers/run-client.d.ts +21 -1
  5. package/dist/cli/handlers/run-client.d.ts.map +1 -1
  6. package/dist/cli/handlers/run-report.d.ts.map +1 -1
  7. package/dist/cli/handlers/run.d.ts.map +1 -1
  8. package/dist/cli/handlers/search.d.ts +22 -0
  9. package/dist/cli/handlers/search.d.ts.map +1 -1
  10. package/dist/cli/main.d.ts.map +1 -1
  11. package/dist/cli/mcp/op-tools.d.ts.map +1 -1
  12. package/dist/cli/mcp/resource-handlers.d.ts.map +1 -1
  13. package/dist/cli/registry.d.ts +33 -1
  14. package/dist/cli/registry.d.ts.map +1 -1
  15. package/dist/components/run-progress.d.ts +7 -5
  16. package/dist/components/run-progress.d.ts.map +1 -1
  17. package/dist/lexicon.d.ts +41 -0
  18. package/dist/lexicon.d.ts.map +1 -1
  19. package/dist/lifecycle/assert-live.d.ts +77 -0
  20. package/dist/lifecycle/assert-live.d.ts.map +1 -0
  21. package/dist/lifecycle/change-set.d.ts +17 -0
  22. package/dist/lifecycle/change-set.d.ts.map +1 -1
  23. package/dist/lifecycle/disruption.d.ts +96 -0
  24. package/dist/lifecycle/disruption.d.ts.map +1 -0
  25. package/dist/lifecycle/index.d.ts +2 -0
  26. package/dist/lifecycle/index.d.ts.map +1 -1
  27. package/dist/lifecycle/replay.d.ts +2 -0
  28. package/dist/lifecycle/replay.d.ts.map +1 -1
  29. package/dist/op/local-executor.d.ts +7 -1
  30. package/dist/op/local-executor.d.ts.map +1 -1
  31. package/dist/testing.d.ts +23 -2
  32. package/dist/testing.d.ts.map +1 -1
  33. package/package.json +1 -1
  34. package/src/cli/handlers/lifecycle.test.ts +90 -0
  35. package/src/cli/handlers/lifecycle.ts +18 -1
  36. package/src/cli/handlers/op-progress.test.ts +202 -0
  37. package/src/cli/handlers/op-progress.ts +192 -0
  38. package/src/cli/handlers/run-client.test.ts +82 -0
  39. package/src/cli/handlers/run-client.ts +85 -2
  40. package/src/cli/handlers/run-report.test.ts +62 -0
  41. package/src/cli/handlers/run-report.ts +20 -58
  42. package/src/cli/handlers/run.test.ts +144 -0
  43. package/src/cli/handlers/run.ts +40 -18
  44. package/src/cli/handlers/search-drift.test.ts +263 -0
  45. package/src/cli/handlers/search.ts +150 -1
  46. package/src/cli/main.ts +9 -0
  47. package/src/cli/mcp/op-tools.ts +17 -6
  48. package/src/cli/mcp/resource-handlers.ts +13 -5
  49. package/src/cli/registry.ts +33 -1
  50. package/src/components/run-progress.ts +9 -5
  51. package/src/lexicon.ts +51 -0
  52. package/src/lifecycle/assert-live.test.ts +125 -0
  53. package/src/lifecycle/assert-live.ts +154 -0
  54. package/src/lifecycle/change-set.ts +35 -3
  55. package/src/lifecycle/disruption.test.ts +186 -0
  56. package/src/lifecycle/disruption.ts +224 -0
  57. package/src/lifecycle/index.ts +2 -0
  58. package/src/lifecycle/replay.test.ts +25 -0
  59. package/src/lifecycle/replay.ts +11 -3
  60. package/src/op/local-executor.ts +7 -1
  61. package/src/op/local-output.ts +1 -1
  62. package/src/testing.test.ts +89 -2
  63. package/src/testing.ts +63 -3
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Per-phase progress reconstructed from a Temporal Op workflow's history
3
+ * (chant #1676) — the durable-path counterpart to the local executor's
4
+ * `StepRecord[]` (op/local-executor.ts), so `chant run <name> --temporal
5
+ * --progress-json` and `op-status` can report the same shape `chant run
6
+ * <name> --json` already does for the local path, instead of the two raw
7
+ * `ActivityTaskScheduled`/`ActivityTaskCompleted` counts.
8
+ *
9
+ * A step is joined to its declared (phase, fn) by position, not by name: the
10
+ * declared step list (`declaredStepOrder`, mirroring the serializer's own
11
+ * `collectActivitySteps` traversal — main phases, then `onFailure` phases
12
+ * *reversed*, matching how the generated workflow actually runs them) is
13
+ * walked in order, and each step consumes the next not-yet-consumed
14
+ * `ActivityTaskScheduled` event whose activity type matches its `fn` — a
15
+ * per-fn FIFO queue, not a global "does this name appear anywhere" scan.
16
+ * Two steps calling the same activity therefore each get their own
17
+ * scheduled event instead of the later one overwriting the earlier
18
+ * (the collision the prior `run-report.ts` join had). A step whose queue is
19
+ * empty never ran this execution — an untaken effect branch, or an
20
+ * `onFailure` phase on a run that succeeded.
21
+ */
22
+
23
+ import type { OpConfig, PhaseDefinition } from "../../op/types";
24
+ import type { StepRecord } from "../../op/local-executor";
25
+ import type { WorkflowHistoryRaw, HistoryEvent, WorkflowHandleRaw } from "./run-client";
26
+
27
+ // Synthesized step names an effect step's read-compare-run-write compiles
28
+ // to (lexicons/temporal/src/op/serializer.ts's RECEIPT_READ_STEP/
29
+ // RECEIPT_WRITE_STEP) — duplicated here rather than imported so this module
30
+ // stays lexicon-free, same constraint as op/local-executor.ts.
31
+ const RECEIPT_READ_FN = "receiptRead";
32
+ const RECEIPT_WRITE_FN = "receiptWrite";
33
+
34
+ interface DeclaredStep {
35
+ phase: string;
36
+ fn: string;
37
+ }
38
+
39
+ function flattenPhases(phases: PhaseDefinition[]): DeclaredStep[] {
40
+ const out: DeclaredStep[] = [];
41
+ for (const phase of phases) {
42
+ for (const step of phase.steps) {
43
+ if (step.kind === "activity") {
44
+ out.push({ phase: phase.name, fn: step.fn });
45
+ } else if (step.kind === "effect") {
46
+ out.push({ phase: phase.name, fn: RECEIPT_READ_FN });
47
+ for (const nested of step.steps) {
48
+ if (nested.kind === "activity") out.push({ phase: phase.name, fn: nested.fn });
49
+ }
50
+ out.push({ phase: phase.name, fn: RECEIPT_WRITE_FN });
51
+ }
52
+ // Gate steps schedule no activity — nothing to join them to.
53
+ }
54
+ }
55
+ return out;
56
+ }
57
+
58
+ /** Declared order matching actual scheduling order: main phases, then `onFailure` reversed (compensation runs last-declared-first — serializer.ts's `renderPhases([...config.onFailure].reverse())`). */
59
+ function declaredStepOrder(config: OpConfig): DeclaredStep[] {
60
+ return [...flattenPhases(config.phases), ...flattenPhases([...(config.onFailure ?? [])].reverse())];
61
+ }
62
+
63
+ interface ScheduledEntry {
64
+ scheduledEventId: string;
65
+ scheduledAt?: Date;
66
+ }
67
+
68
+ /** Every `ActivityTaskScheduled` event, grouped by activity type name, in chronological order. */
69
+ function scheduledQueues(events: HistoryEvent[]): Map<string, ScheduledEntry[]> {
70
+ const byFn = new Map<string, ScheduledEntry[]>();
71
+ for (const e of events) {
72
+ if (e.eventType !== "ActivityTaskScheduled") continue;
73
+ const attrs = e.activityTaskScheduledEventAttributes;
74
+ if (!attrs) continue;
75
+ const fn = attrs.activityType?.name ?? "unknown";
76
+ if (!byFn.has(fn)) byFn.set(fn, []);
77
+ byFn.get(fn)!.push({ scheduledEventId: e.eventId ?? "", scheduledAt: e.eventTime });
78
+ }
79
+ return byFn;
80
+ }
81
+
82
+ interface TerminalOutcome {
83
+ status: "ok" | "fail";
84
+ at?: Date;
85
+ error?: string;
86
+ }
87
+
88
+ /**
89
+ * The terminal outcome of each scheduled activity, keyed by the scheduling
90
+ * event's own id. A retried activity emits one `ActivityTaskFailed` per
91
+ * attempt under the same `scheduledEventId`; walking events in order and
92
+ * always overwriting on a later event leaves whichever came last —
93
+ * `ActivityTaskCompleted` on an eventual success, otherwise the final
94
+ * attempt's failure.
95
+ */
96
+ function terminalOutcomes(events: HistoryEvent[]): Map<string, TerminalOutcome> {
97
+ const out = new Map<string, TerminalOutcome>();
98
+ for (const e of events) {
99
+ if (e.eventType === "ActivityTaskCompleted" && e.activityTaskCompletedEventAttributes) {
100
+ const id = e.activityTaskCompletedEventAttributes.scheduledEventId;
101
+ if (id) out.set(id, { status: "ok", at: e.eventTime });
102
+ } else if (e.eventType === "ActivityTaskFailed" && e.activityTaskFailedEventAttributes) {
103
+ const id = e.activityTaskFailedEventAttributes.scheduledEventId;
104
+ if (id) {
105
+ out.set(id, {
106
+ status: "fail",
107
+ at: e.eventTime,
108
+ error: e.activityTaskFailedEventAttributes.failure?.message ?? "unknown error",
109
+ });
110
+ }
111
+ }
112
+ }
113
+ return out;
114
+ }
115
+
116
+ /**
117
+ * Reconstruct per-step progress records from a workflow's history, in
118
+ * declared order. Only settled steps (matched to a scheduled event that has
119
+ * since completed or failed) produce a record — a step that hasn't been
120
+ * scheduled yet simply isn't in the result, so a live poll only ever
121
+ * appends. Pass `final: true` once the workflow has reached a terminal
122
+ * status to also emit a `"skipped"` record for every declared step that
123
+ * never got a matching scheduled event (an untaken effect branch, or an
124
+ * `onFailure` phase on a run that succeeded) — never before, or a
125
+ * still-running Op would show its not-yet-reached steps as skipped.
126
+ */
127
+ export function extractStepRecords(
128
+ config: OpConfig,
129
+ history: WorkflowHistoryRaw,
130
+ opts: { final?: boolean } = {},
131
+ ): StepRecord[] {
132
+ const events = history.events ?? [];
133
+ const queues = scheduledQueues(events);
134
+ const terminals = terminalOutcomes(events);
135
+ const nextIndex = new Map<string, number>();
136
+
137
+ const records: StepRecord[] = [];
138
+ for (const step of declaredStepOrder(config)) {
139
+ const queue = queues.get(step.fn);
140
+ const idx = nextIndex.get(step.fn) ?? 0;
141
+ const entry = queue?.[idx];
142
+ if (!entry) {
143
+ if (opts.final) records.push({ phase: step.phase, fn: step.fn, status: "skipped", durationMs: 0 });
144
+ continue;
145
+ }
146
+ nextIndex.set(step.fn, idx + 1);
147
+
148
+ const terminal = terminals.get(entry.scheduledEventId);
149
+ if (!terminal) continue; // scheduled but not yet settled
150
+
151
+ const durationMs =
152
+ entry.scheduledAt && terminal.at ? terminal.at.getTime() - entry.scheduledAt.getTime() : 0;
153
+ records.push({
154
+ phase: step.phase,
155
+ fn: step.fn,
156
+ status: terminal.status,
157
+ durationMs,
158
+ ...(terminal.error ? { error: terminal.error } : {}),
159
+ });
160
+ }
161
+ return records;
162
+ }
163
+
164
+ /** `{completed, scheduled}` activity counts for the summary line `chant run status`/`op-status` show alongside the full record list. */
165
+ export function countActivities(history: WorkflowHistoryRaw): { completed: number; scheduled: number } {
166
+ const events = history.events ?? [];
167
+ return {
168
+ completed: events.filter((e) => e.eventType === "ActivityTaskCompleted").length,
169
+ scheduled: events.filter((e) => e.eventType === "ActivityTaskScheduled").length,
170
+ };
171
+ }
172
+
173
+ /** The `gateState` workflow query's result (op/serializer.ts's `emitGate`) — the gate currently blocking the workflow, or `null` when none is pending. */
174
+ export interface GateQueryResult {
175
+ signalName: string;
176
+ description?: string;
177
+ since: string;
178
+ }
179
+
180
+ /**
181
+ * Best-effort `gateState` query. `undefined` (as opposed to `null`) means
182
+ * the query itself failed — an Op with no gate steps never registers a
183
+ * `gateState` handler, so querying it is expected to fail, not an error
184
+ * worth surfacing.
185
+ */
186
+ export async function queryGateState(handle: WorkflowHandleRaw): Promise<GateQueryResult | null | undefined> {
187
+ try {
188
+ return await handle.query<GateQueryResult | null>("gateState");
189
+ } catch {
190
+ return undefined;
191
+ }
192
+ }
@@ -0,0 +1,82 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { fetchNormalizedHistory, type WorkflowHandleRaw } from "./run-client";
3
+
4
+ /**
5
+ * `@temporalio/client`'s `WorkflowHandle.fetchHistory()` returns the raw
6
+ * Temporal wire proto: `eventType` a numeric enum, `eventId`/
7
+ * `scheduledEventId` protobuf `Long` instances, `eventTime` a
8
+ * `{seconds, nanos}` Timestamp. What turns those into the friendly
9
+ * `"EVENT_TYPE_ACTIVITY_TASK_SCHEDULED"` string / decimal strings is the
10
+ * proto message's own `toJSON()` — confirmed against a real
11
+ * `TestWorkflowEnvironment` run (Temporal SDK 1.17.2) during development.
12
+ * These fixtures simulate exactly that: plain values by default, but with a
13
+ * `toJSON()` on the fields that have one on the wire, so a JSON round-trip
14
+ * behaves the same way it does against a real server.
15
+ */
16
+ function fakeLong(n: number) {
17
+ return { low: n, high: 0, unsigned: false, toJSON: () => String(n) };
18
+ }
19
+
20
+ function fakeTimestamp(seconds: number, nanos = 0) {
21
+ return { seconds: fakeLong(seconds), nanos };
22
+ }
23
+
24
+ function makeHandle(events: unknown[]): WorkflowHandleRaw {
25
+ return {
26
+ workflowId: "wf",
27
+ async fetchHistory() {
28
+ return { events } as never;
29
+ },
30
+ } as unknown as WorkflowHandleRaw;
31
+ }
32
+
33
+ describe("fetchNormalizedHistory", () => {
34
+ test("converts the raw wire shape to short eventType, decimal eventId, and a real Date", async () => {
35
+ const raw = {
36
+ eventId: fakeLong(7),
37
+ eventTime: fakeTimestamp(1_788_000_000, 500_000_000),
38
+ eventType: "EVENT_TYPE_ACTIVITY_TASK_SCHEDULED",
39
+ activityTaskScheduledEventAttributes: {
40
+ activityId: "1",
41
+ activityType: { name: "dup" },
42
+ },
43
+ };
44
+ const history = await fetchNormalizedHistory(makeHandle([raw]));
45
+ const event = history.events?.[0];
46
+ expect(event?.eventType).toBe("ActivityTaskScheduled");
47
+ expect(event?.eventId).toBe("7");
48
+ expect(event?.eventTime).toBeInstanceOf(Date);
49
+ expect(event?.eventTime?.getTime()).toBe(1_788_000_000_500);
50
+ expect(event?.activityTaskScheduledEventAttributes).toEqual({ activityId: "1", activityType: { name: "dup" } });
51
+ });
52
+
53
+ test("ActivityTaskCompleted's scheduledEventId round-trips to a decimal string, joinable against eventId", async () => {
54
+ const scheduled = {
55
+ eventId: fakeLong(7),
56
+ eventType: "EVENT_TYPE_ACTIVITY_TASK_SCHEDULED",
57
+ activityTaskScheduledEventAttributes: { activityId: "1", activityType: { name: "dup" } },
58
+ };
59
+ const completedEvent = {
60
+ eventId: fakeLong(9),
61
+ eventType: "EVENT_TYPE_ACTIVITY_TASK_COMPLETED",
62
+ activityTaskCompletedEventAttributes: { scheduledEventId: fakeLong(7) },
63
+ };
64
+ const history = await fetchNormalizedHistory(makeHandle([scheduled, completedEvent]));
65
+ expect(history.events?.[0].eventId).toBe(history.events?.[1].activityTaskCompletedEventAttributes?.scheduledEventId);
66
+ });
67
+
68
+ test("a short PascalCase eventType (already-normalized test fixture data) passes through unchanged", async () => {
69
+ const history = await fetchNormalizedHistory(makeHandle([{ eventType: "ActivityTaskScheduled" }]));
70
+ expect(history.events?.[0].eventType).toBe("ActivityTaskScheduled");
71
+ });
72
+
73
+ test("an ISO eventTime string (already-normalized data) round-trips to the same instant", async () => {
74
+ const history = await fetchNormalizedHistory(makeHandle([{ eventTime: "2026-05-01T00:00:00.000Z" }]));
75
+ expect(history.events?.[0].eventTime?.toISOString()).toBe("2026-05-01T00:00:00.000Z");
76
+ });
77
+
78
+ test("no events → empty array, not an error", async () => {
79
+ const history = await fetchNormalizedHistory(makeHandle([]));
80
+ expect(history.events).toEqual([]);
81
+ });
82
+ });
@@ -30,6 +30,7 @@ export interface WorkflowHandleRaw {
30
30
  describe(): Promise<WorkflowExecutionDescription>;
31
31
  fetchHistory(): Promise<WorkflowHistoryRaw>;
32
32
  signal(signalName: string, ...args: unknown[]): Promise<void>;
33
+ query<T = unknown>(queryName: string, ...args: unknown[]): Promise<T>;
33
34
  cancel(): Promise<void>;
34
35
  }
35
36
 
@@ -56,16 +57,98 @@ export interface WorkflowHistoryRaw {
56
57
  events?: HistoryEvent[];
57
58
  }
58
59
 
60
+ /**
61
+ * `eventType` is the short PascalCase form (`"ActivityTaskScheduled"`, not
62
+ * the wire enum's `"EVENT_TYPE_ACTIVITY_TASK_SCHEDULED"`) and `eventId`/
63
+ * `scheduledEventId` are decimal strings — the shape {@link fetchNormalizedHistory}
64
+ * produces, not `WorkflowHandleRaw.fetchHistory()`'s own raw return value.
65
+ */
59
66
  export interface HistoryEvent {
67
+ eventId?: string;
60
68
  eventType?: string;
61
69
  eventTime?: Date;
62
- activityTaskCompletedEventAttributes?: { scheduledEventId?: string | number };
70
+ activityTaskCompletedEventAttributes?: { scheduledEventId?: string };
63
71
  activityTaskScheduledEventAttributes?: { activityId?: string; activityType?: { name?: string } };
64
- activityTaskFailedEventAttributes?: { failure?: { message?: string } };
72
+ activityTaskFailedEventAttributes?: { scheduledEventId?: string; failure?: { message?: string } };
65
73
  workflowExecutionCompletedEventAttributes?: unknown;
66
74
  workflowExecutionFailedEventAttributes?: { failure?: { message?: string } };
67
75
  }
68
76
 
77
+ /**
78
+ * `WorkflowHandleRaw.fetchHistory()` returns the Temporal wire proto
79
+ * untouched: `eventType` is the numeric enum, `eventId`/`scheduledEventId`
80
+ * are `Long` instances, `eventTime` is a `{seconds, nanos}` protobuf
81
+ * Timestamp — none of them comparable or constructible the way the rest of
82
+ * this file (and run-report.ts, op-progress.ts) needs. The proto message's
83
+ * own `toJSON()` is what turns enums into their string name and `Long`s into
84
+ * decimal strings, so a JSON round-trip is the normalization step; only
85
+ * `eventTime` then needs a further Timestamp-to-Date conversion.
86
+ */
87
+ export async function fetchNormalizedHistory(handle: WorkflowHandleRaw): Promise<WorkflowHistoryRaw> {
88
+ const raw = (await handle.fetchHistory()) as unknown as { events?: unknown[] };
89
+ const events = JSON.parse(JSON.stringify(raw.events ?? [])) as Array<Record<string, unknown>>;
90
+ return { events: events.map(normalizeHistoryEvent) };
91
+ }
92
+
93
+ /** `"EVENT_TYPE_ACTIVITY_TASK_SCHEDULED"` → `"ActivityTaskScheduled"`. A value that isn't ALL_CAPS_WITH_UNDERSCORES (e.g. already-short test fixture data) passes through unchanged, so normalizing twice is a no-op. */
94
+ function shortEventType(raw: unknown): string | undefined {
95
+ if (typeof raw !== "string" || raw.length === 0) return undefined;
96
+ if (!/^[A-Z][A-Z0-9_]*$/.test(raw)) return raw;
97
+ const stripped = raw.startsWith("EVENT_TYPE_") ? raw.slice("EVENT_TYPE_".length) : raw;
98
+ return stripped
99
+ .split("_")
100
+ .filter(Boolean)
101
+ .map((w) => w[0] + w.slice(1).toLowerCase())
102
+ .join("");
103
+ }
104
+
105
+ /** Accepts a protobuf `{seconds, nanos}` Timestamp (its `seconds` a decimal string post-JSON-round-trip), an ISO string (already-normalized test fixture data, or the same value normalized twice), or a `Date`. */
106
+ function protoTimestampToDate(ts: unknown): Date | undefined {
107
+ if (!ts) return undefined;
108
+ if (ts instanceof Date) return ts;
109
+ if (typeof ts === "string") {
110
+ const d = new Date(ts);
111
+ return Number.isNaN(d.getTime()) ? undefined : d;
112
+ }
113
+ if (typeof ts !== "object") return undefined;
114
+ const seconds = Number((ts as { seconds?: unknown }).seconds);
115
+ const nanos = Number((ts as { nanos?: unknown }).nanos ?? 0);
116
+ if (!Number.isFinite(seconds)) return undefined;
117
+ return new Date(seconds * 1000 + Math.floor(nanos / 1e6));
118
+ }
119
+
120
+ /** `undefined`/`null` stays `undefined`; anything else (a decimal string post-JSON-round-trip, or a bare number/Long from an un-round-tripped test fixture) becomes a string — `scheduledEventId` is joined against `eventId` as a Map key, and a number `7` and a string `"7"` are different keys. */
121
+ function idString(v: unknown): string | undefined {
122
+ return v === undefined || v === null ? undefined : String(v);
123
+ }
124
+
125
+ function normalizeHistoryEvent(raw: Record<string, unknown>): HistoryEvent {
126
+ const scheduled = raw.activityTaskScheduledEventAttributes as
127
+ | { activityId?: unknown; activityType?: { name?: string } }
128
+ | undefined;
129
+ const completed = raw.activityTaskCompletedEventAttributes as { scheduledEventId?: unknown } | undefined;
130
+ const failed = raw.activityTaskFailedEventAttributes as
131
+ | { scheduledEventId?: unknown; failure?: { message?: string } }
132
+ | undefined;
133
+
134
+ return {
135
+ eventId: idString(raw.eventId),
136
+ eventType: shortEventType(raw.eventType),
137
+ eventTime: protoTimestampToDate(raw.eventTime),
138
+ ...(scheduled
139
+ ? { activityTaskScheduledEventAttributes: { activityId: idString(scheduled.activityId), activityType: scheduled.activityType } }
140
+ : {}),
141
+ ...(completed
142
+ ? { activityTaskCompletedEventAttributes: { scheduledEventId: idString(completed.scheduledEventId) } }
143
+ : {}),
144
+ ...(failed
145
+ ? { activityTaskFailedEventAttributes: { scheduledEventId: idString(failed.scheduledEventId), failure: failed.failure } }
146
+ : {}),
147
+ workflowExecutionCompletedEventAttributes: raw.workflowExecutionCompletedEventAttributes,
148
+ workflowExecutionFailedEventAttributes: raw.workflowExecutionFailedEventAttributes as HistoryEvent["workflowExecutionFailedEventAttributes"],
149
+ };
150
+ }
151
+
69
152
  /**
70
153
  * Dynamically import @temporalio/client from the user's project node_modules.
71
154
  * Fails with a helpful message if not installed.
@@ -0,0 +1,62 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { generateReport } from "./run-report";
3
+ import type { OpConfig } from "../../op/types";
4
+ import type { WorkflowExecutionDescription, WorkflowHistoryRaw, HistoryEvent } from "./run-client";
5
+
6
+ function scheduled(eventId: string, fn: string): HistoryEvent {
7
+ return {
8
+ eventId,
9
+ eventType: "ActivityTaskScheduled",
10
+ eventTime: new Date(0),
11
+ activityTaskScheduledEventAttributes: { activityId: eventId, activityType: { name: fn } },
12
+ };
13
+ }
14
+ function completed(scheduledEventId: string): HistoryEvent {
15
+ return {
16
+ eventType: "ActivityTaskCompleted",
17
+ eventTime: new Date(1000),
18
+ activityTaskCompletedEventAttributes: { scheduledEventId },
19
+ };
20
+ }
21
+
22
+ const desc: WorkflowExecutionDescription = {
23
+ workflowId: "wf", runId: "r1", status: { name: "COMPLETED" },
24
+ startTime: new Date(0), closeTime: new Date(2000), taskQueue: "q", type: { name: "opWorkflow" },
25
+ };
26
+
27
+ describe("generateReport", () => {
28
+ test("two steps calling the same activity each get their own row (no last-wins collision, chant #1676)", () => {
29
+ const config: OpConfig = {
30
+ name: "op", overview: "o",
31
+ phases: [{ name: "P1", steps: [{ kind: "activity", fn: "dup" }, { kind: "activity", fn: "dup" }] }],
32
+ };
33
+ const history: WorkflowHistoryRaw = {
34
+ events: [scheduled("1", "dup"), completed("1"), scheduled("2", "dup"), completed("2")],
35
+ };
36
+ const md = generateReport("op", config, desc, history);
37
+ // Both rows present — the prior fn-name join would have produced one
38
+ // row for "dup" (the second scheduled event overwriting the first).
39
+ const dupRows = md.split("\n").filter((line) => line.includes("| P1 | dup |"));
40
+ expect(dupRows).toHaveLength(2);
41
+ expect(dupRows.every((line) => line.includes("✓ completed"))).toBe(true);
42
+ });
43
+
44
+ test("a declared step with no matching history is reported skipped, not a phantom 'running' row", () => {
45
+ const config: OpConfig = {
46
+ name: "op", overview: "o",
47
+ phases: [
48
+ { name: "P1", steps: [{ kind: "activity", fn: "a" }] },
49
+ { name: "P2", steps: [{ kind: "activity", fn: "b" }] },
50
+ ],
51
+ onFailure: [{ name: "C1", steps: [{ kind: "activity", fn: "comp" }] }],
52
+ };
53
+ const history: WorkflowHistoryRaw = { events: [scheduled("1", "a"), completed("1"), scheduled("2", "b"), completed("2")] };
54
+ const md = generateReport("op", config, desc, history);
55
+ expect(md).toContain("| P1 | a | 1.0s | ✓ completed |");
56
+ expect(md).toContain("| P2 | b | 1.0s | ✓ completed |");
57
+ // The timeline table is scoped to config.phases only (unchanged from
58
+ // before this fix) — onFailure phases never appear in it regardless of
59
+ // whether they ran.
60
+ expect(md).not.toContain("C1");
61
+ });
62
+ });
@@ -2,13 +2,12 @@ import { writeFileSync, mkdirSync } from "node:fs";
2
2
  import { join, dirname } from "node:path";
3
3
  import type { OpConfig } from "../../op/types";
4
4
  import type { WorkflowExecutionDescription, WorkflowHistoryRaw, HistoryEvent } from "./run-client";
5
+ import { extractStepRecords } from "./op-progress";
5
6
 
6
7
  interface ActivityRecord {
7
8
  name: string;
8
- startTime?: Date;
9
- endTime?: Date;
10
9
  durationMs?: number;
11
- status: "completed" | "failed" | "running";
10
+ status: "completed" | "failed" | "skipped";
12
11
  error?: string;
13
12
  }
14
13
 
@@ -25,61 +24,24 @@ function formatDuration(ms: number): string {
25
24
  return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
26
25
  }
27
26
 
28
- function extractPhaseRecords(config: OpConfig, history: WorkflowHistoryRaw): PhaseRecord[] {
29
- const events = history.events ?? [];
30
-
31
- // Build map: scheduled event ID → activity name
32
- const scheduledActivities = new Map<string, string>();
33
- const scheduledTimes = new Map<string, Date>();
34
- const completedTimes = new Map<string, Date>();
35
- const failedActivities = new Map<string, string>();
36
-
37
- for (const event of events) {
38
- if (event.eventType === "ActivityTaskScheduled" && event.activityTaskScheduledEventAttributes) {
39
- const attrs = event.activityTaskScheduledEventAttributes;
40
- const id = String(attrs.activityId ?? "");
41
- const name = attrs.activityType?.name ?? "unknown";
42
- scheduledActivities.set(id, name);
43
- if (event.eventTime) scheduledTimes.set(id, new Date(event.eventTime));
44
- }
45
- if (event.eventType === "ActivityTaskCompleted" && event.activityTaskCompletedEventAttributes) {
46
- const scheduledId = String(event.activityTaskCompletedEventAttributes.scheduledEventId ?? "");
47
- if (event.eventTime) completedTimes.set(scheduledId, new Date(event.eventTime));
48
- }
49
- if (event.eventType === "ActivityTaskFailed" && event.activityTaskFailedEventAttributes) {
50
- const msg = event.activityTaskFailedEventAttributes.failure?.message ?? "unknown error";
51
- failedActivities.set(String(events.indexOf(event)), msg);
52
- }
27
+ /**
28
+ * Group `extractStepRecords`' flat, declared-order step list back into
29
+ * per-phase rows for the markdown table. `final` (a closed workflow has
30
+ * every settled step it's ever going to have) decides whether an unmatched
31
+ * declared step is reported "skipped" or simply omitted for now — see
32
+ * `extractStepRecords`'s own doc.
33
+ */
34
+ function extractPhaseRecords(config: OpConfig, history: WorkflowHistoryRaw, final: boolean): PhaseRecord[] {
35
+ const byPhase = new Map<string, ActivityRecord[]>();
36
+ for (const phase of config.phases) byPhase.set(phase.name, []);
37
+
38
+ for (const step of extractStepRecords(config, history, { final })) {
39
+ const status = step.status === "ok" ? "completed" : step.status === "fail" ? "failed" : "skipped";
40
+ const list = byPhase.get(step.phase) ?? (byPhase.set(step.phase, []), byPhase.get(step.phase)!);
41
+ list.push({ name: step.fn, durationMs: step.durationMs, status, ...(step.error ? { error: step.error } : {}) });
53
42
  }
54
43
 
55
- return config.phases.map((phase) => {
56
- const activities: ActivityRecord[] = phase.steps
57
- .filter((s) => s.kind === "activity")
58
- .map((step) => {
59
- if (step.kind !== "activity") return null;
60
- const fn = step.fn;
61
- // Find this activity by name in the history
62
- let record: ActivityRecord | null = null;
63
- for (const [id, name] of scheduledActivities) {
64
- if (name === fn) {
65
- const start = scheduledTimes.get(id);
66
- const end = completedTimes.get(id);
67
- const durationMs = start && end ? end.getTime() - start.getTime() : undefined;
68
- record = {
69
- name: fn,
70
- startTime: start,
71
- endTime: end,
72
- durationMs,
73
- status: end ? "completed" : "running",
74
- };
75
- }
76
- }
77
- return record ?? { name: fn, status: "running" };
78
- })
79
- .filter((r): r is ActivityRecord => r !== null);
80
-
81
- return { name: phase.name, activities };
82
- });
44
+ return config.phases.map((phase) => ({ name: phase.name, activities: byPhase.get(phase.name) ?? [] }));
83
45
  }
84
46
 
85
47
  export function generateReport(
@@ -116,14 +78,14 @@ export function generateReport(
116
78
  lines.push("| Phase | Activity | Duration | Status |");
117
79
  lines.push("|---|---|---|---|");
118
80
 
119
- const phases = extractPhaseRecords(config, history);
81
+ const phases = extractPhaseRecords(config, history, Boolean(closeTime));
120
82
  for (const phase of phases) {
121
83
  if (phase.activities.length === 0) {
122
84
  lines.push(`| ${phase.name} | — | — | — |`);
123
85
  }
124
86
  for (const act of phase.activities) {
125
87
  const dur = act.durationMs !== undefined ? formatDuration(act.durationMs) : "—";
126
- const statusEmoji = act.status === "completed" ? "✓" : act.status === "failed" ? "✗" : "…";
88
+ const statusEmoji = act.status === "completed" ? "✓" : act.status === "failed" ? "✗" : "○";
127
89
  lines.push(`| ${phase.name} | ${act.name} | ${dur} | ${statusEmoji} ${act.status} |`);
128
90
  }
129
91
  }