@outputai/cli 0.8.2-next.edf06bb.0 → 0.9.1-dev.000c5f3.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.
- package/dist/api/generated/api.d.ts +79 -0
- package/dist/api/generated/api.js +32 -0
- package/dist/assets/docker/docker-compose-dev.yml +1 -1
- package/dist/commands/workflow/history.d.ts +19 -0
- package/dist/commands/workflow/history.js +99 -0
- package/dist/commands/workflow/history.spec.d.ts +1 -0
- package/dist/commands/workflow/history.spec.js +26 -0
- package/dist/generated/framework_version.json +1 -1
- package/dist/services/workflow_history/correlator.d.ts +26 -0
- package/dist/services/workflow_history/correlator.js +261 -0
- package/dist/services/workflow_history/correlator.spec.d.ts +1 -0
- package/dist/services/workflow_history/correlator.spec.js +116 -0
- package/dist/services/workflow_history.d.ts +23 -0
- package/dist/services/workflow_history.js +79 -0
- package/dist/services/workflow_history.spec.d.ts +1 -0
- package/dist/services/workflow_history.spec.js +87 -0
- package/dist/utils/span_labels.d.ts +11 -0
- package/dist/utils/span_labels.js +22 -0
- package/dist/utils/span_labels.spec.d.ts +1 -0
- package/dist/utils/span_labels.spec.js +36 -0
- package/dist/utils/waterfall.d.ts +31 -0
- package/dist/utils/waterfall.js +192 -0
- package/dist/utils/waterfall.spec.d.ts +1 -0
- package/dist/utils/waterfall.spec.js +100 -0
- package/dist/views/dev/dev_app.js +17 -3
- package/dist/views/dev/hooks/use_step_graph.d.ts +22 -0
- package/dist/views/dev/hooks/use_step_graph.js +76 -0
- package/dist/views/dev/modals/run_modal.d.ts +9 -0
- package/dist/views/dev/modals/run_modal.js +56 -56
- package/dist/views/dev/modals/run_modal.spec.d.ts +1 -0
- package/dist/views/dev/modals/run_modal.spec.js +34 -0
- package/dist/views/dev/modals/step_graph_modal.d.ts +6 -0
- package/dist/views/dev/modals/step_graph_modal.js +148 -0
- package/dist/views/dev/modals/steps_modal.js +1 -1
- package/dist/views/dev/panels/help_panel.js +1 -1
- package/dist/views/dev/panels/runs_panel.js +7 -2
- package/dist/views/dev/panels/workflows_panel.js +1 -1
- package/dist/views/dev/state/ui_state.d.ts +8 -0
- package/dist/views/dev/state/ui_state.js +5 -1
- package/dist/views/dev/utils/json_editor.d.ts +2 -0
- package/dist/views/dev/utils/json_editor.js +28 -15
- package/dist/views/dev/utils/json_editor.spec.js +16 -1
- package/oclif.manifest.json +81 -1
- package/package.json +4 -4
- /package/dist/views/dev/utils/{constants.d.ts → ui_constants.d.ts} +0 -0
- /package/dist/views/dev/utils/{constants.js → ui_constants.js} +0 -0
|
@@ -452,6 +452,26 @@ export type GetWorkflowIdRunsRidHistory200 = {
|
|
|
452
452
|
/** @nullable */
|
|
453
453
|
nextPageToken?: string | null;
|
|
454
454
|
};
|
|
455
|
+
export type GetWorkflowIdHistoryStreamParams = {
|
|
456
|
+
/**
|
|
457
|
+
* Include decoded input/output payloads in events
|
|
458
|
+
*/
|
|
459
|
+
includePayloads?: boolean;
|
|
460
|
+
/**
|
|
461
|
+
* Resume from this event ID (alternative to Last-Event-ID header)
|
|
462
|
+
*/
|
|
463
|
+
lastEventId?: number;
|
|
464
|
+
};
|
|
465
|
+
export type GetWorkflowIdRunsRidHistoryStreamParams = {
|
|
466
|
+
/**
|
|
467
|
+
* Include decoded input/output payloads in events
|
|
468
|
+
*/
|
|
469
|
+
includePayloads?: boolean;
|
|
470
|
+
/**
|
|
471
|
+
* Resume from this event ID (alternative to Last-Event-ID header)
|
|
472
|
+
*/
|
|
473
|
+
lastEventId?: number;
|
|
474
|
+
};
|
|
455
475
|
export type GetWorkflowCatalogId200 = {
|
|
456
476
|
/** Each workflow available in this catalog */
|
|
457
477
|
workflows?: Workflow[];
|
|
@@ -1043,6 +1063,65 @@ export type getWorkflowIdRunsRidHistoryResponseError = (getWorkflowIdRunsRidHist
|
|
|
1043
1063
|
export type getWorkflowIdRunsRidHistoryResponse = (getWorkflowIdRunsRidHistoryResponseSuccess | getWorkflowIdRunsRidHistoryResponseError);
|
|
1044
1064
|
export declare const getGetWorkflowIdRunsRidHistoryUrl: (id: string, rid: string, params?: GetWorkflowIdRunsRidHistoryParams) => string;
|
|
1045
1065
|
export declare const getWorkflowIdRunsRidHistory: (id: string, rid: string, params?: GetWorkflowIdRunsRidHistoryParams, options?: ApiRequestOptions) => Promise<getWorkflowIdRunsRidHistoryResponse>;
|
|
1066
|
+
/**
|
|
1067
|
+
* Opens a persistent SSE connection that delivers Temporal workflow history events in real time. Emits named events: `workflow` (metadata, once), `history` (event batches), `done` (terminal state), `server_error` (post-flush errors). The `done` event carries `{ reason, newRunId? }` where `reason` is the terminal Temporal event type (`WORKFLOW_EXECUTION_COMPLETED`, `WORKFLOW_EXECUTION_FAILED`, `WORKFLOW_EXECUTION_TIMED_OUT`, `WORKFLOW_EXECUTION_CANCELED`, `WORKFLOW_EXECUTION_TERMINATED`, `WORKFLOW_EXECUTION_CONTINUED_AS_NEW`) and `newRunId` is present only when the terminal event chains a follow-on run. `server_error` carries `{ error, message, workflowId, runId }`. Errors before the stream opens are returned as JSON HTTP responses (400/404); once open, failures arrive as a `server_error` event. Supports reconnect via `Last-Event-ID` header or `lastEventId` query param.
|
|
1068
|
+
|
|
1069
|
+
* @summary Stream workflow history events via Server-Sent Events
|
|
1070
|
+
*/
|
|
1071
|
+
export type getWorkflowIdHistoryStreamResponse200 = {
|
|
1072
|
+
data: string;
|
|
1073
|
+
status: 200;
|
|
1074
|
+
};
|
|
1075
|
+
export type getWorkflowIdHistoryStreamResponse400 = {
|
|
1076
|
+
data: BadRequestResponse;
|
|
1077
|
+
status: 400;
|
|
1078
|
+
};
|
|
1079
|
+
export type getWorkflowIdHistoryStreamResponse404 = {
|
|
1080
|
+
data: NotFoundResponse;
|
|
1081
|
+
status: 404;
|
|
1082
|
+
};
|
|
1083
|
+
export type getWorkflowIdHistoryStreamResponse500 = {
|
|
1084
|
+
data: InternalServerErrorResponse;
|
|
1085
|
+
status: 500;
|
|
1086
|
+
};
|
|
1087
|
+
export type getWorkflowIdHistoryStreamResponseSuccess = (getWorkflowIdHistoryStreamResponse200) & {
|
|
1088
|
+
headers: Headers;
|
|
1089
|
+
};
|
|
1090
|
+
export type getWorkflowIdHistoryStreamResponseError = (getWorkflowIdHistoryStreamResponse400 | getWorkflowIdHistoryStreamResponse404 | getWorkflowIdHistoryStreamResponse500) & {
|
|
1091
|
+
headers: Headers;
|
|
1092
|
+
};
|
|
1093
|
+
export type getWorkflowIdHistoryStreamResponse = (getWorkflowIdHistoryStreamResponseSuccess | getWorkflowIdHistoryStreamResponseError);
|
|
1094
|
+
export declare const getGetWorkflowIdHistoryStreamUrl: (id: string, params?: GetWorkflowIdHistoryStreamParams) => string;
|
|
1095
|
+
export declare const getWorkflowIdHistoryStream: (id: string, params?: GetWorkflowIdHistoryStreamParams, options?: ApiRequestOptions) => Promise<getWorkflowIdHistoryStreamResponse>;
|
|
1096
|
+
/**
|
|
1097
|
+
* Same as /workflow/{id}/history/stream but targets a specific run ID.
|
|
1098
|
+
* @summary Stream pinned-run workflow history events via Server-Sent Events
|
|
1099
|
+
*/
|
|
1100
|
+
export type getWorkflowIdRunsRidHistoryStreamResponse200 = {
|
|
1101
|
+
data: string;
|
|
1102
|
+
status: 200;
|
|
1103
|
+
};
|
|
1104
|
+
export type getWorkflowIdRunsRidHistoryStreamResponse400 = {
|
|
1105
|
+
data: BadRequestResponse;
|
|
1106
|
+
status: 400;
|
|
1107
|
+
};
|
|
1108
|
+
export type getWorkflowIdRunsRidHistoryStreamResponse404 = {
|
|
1109
|
+
data: NotFoundResponse;
|
|
1110
|
+
status: 404;
|
|
1111
|
+
};
|
|
1112
|
+
export type getWorkflowIdRunsRidHistoryStreamResponse500 = {
|
|
1113
|
+
data: InternalServerErrorResponse;
|
|
1114
|
+
status: 500;
|
|
1115
|
+
};
|
|
1116
|
+
export type getWorkflowIdRunsRidHistoryStreamResponseSuccess = (getWorkflowIdRunsRidHistoryStreamResponse200) & {
|
|
1117
|
+
headers: Headers;
|
|
1118
|
+
};
|
|
1119
|
+
export type getWorkflowIdRunsRidHistoryStreamResponseError = (getWorkflowIdRunsRidHistoryStreamResponse400 | getWorkflowIdRunsRidHistoryStreamResponse404 | getWorkflowIdRunsRidHistoryStreamResponse500) & {
|
|
1120
|
+
headers: Headers;
|
|
1121
|
+
};
|
|
1122
|
+
export type getWorkflowIdRunsRidHistoryStreamResponse = (getWorkflowIdRunsRidHistoryStreamResponseSuccess | getWorkflowIdRunsRidHistoryStreamResponseError);
|
|
1123
|
+
export declare const getGetWorkflowIdRunsRidHistoryStreamUrl: (id: string, rid: string, params?: GetWorkflowIdRunsRidHistoryStreamParams) => string;
|
|
1124
|
+
export declare const getWorkflowIdRunsRidHistoryStream: (id: string, rid: string, params?: GetWorkflowIdRunsRidHistoryStreamParams, options?: ApiRequestOptions) => Promise<getWorkflowIdRunsRidHistoryStreamResponse>;
|
|
1046
1125
|
/**
|
|
1047
1126
|
* @summary Get a specific workflow catalog by ID
|
|
1048
1127
|
*/
|
|
@@ -231,6 +231,38 @@ export const getWorkflowIdRunsRidHistory = async (id, rid, params, options) => {
|
|
|
231
231
|
method: 'GET'
|
|
232
232
|
});
|
|
233
233
|
};
|
|
234
|
+
export const getGetWorkflowIdHistoryStreamUrl = (id, params) => {
|
|
235
|
+
const normalizedParams = new URLSearchParams();
|
|
236
|
+
Object.entries(params || {}).forEach(([key, value]) => {
|
|
237
|
+
if (value !== undefined) {
|
|
238
|
+
normalizedParams.append(key, value === null ? 'null' : value.toString());
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
const stringifiedParams = normalizedParams.toString();
|
|
242
|
+
return stringifiedParams.length > 0 ? `/workflow/${id}/history/stream?${stringifiedParams}` : `/workflow/${id}/history/stream`;
|
|
243
|
+
};
|
|
244
|
+
export const getWorkflowIdHistoryStream = async (id, params, options) => {
|
|
245
|
+
return customFetchInstance(getGetWorkflowIdHistoryStreamUrl(id, params), {
|
|
246
|
+
...options,
|
|
247
|
+
method: 'GET'
|
|
248
|
+
});
|
|
249
|
+
};
|
|
250
|
+
export const getGetWorkflowIdRunsRidHistoryStreamUrl = (id, rid, params) => {
|
|
251
|
+
const normalizedParams = new URLSearchParams();
|
|
252
|
+
Object.entries(params || {}).forEach(([key, value]) => {
|
|
253
|
+
if (value !== undefined) {
|
|
254
|
+
normalizedParams.append(key, value === null ? 'null' : value.toString());
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
const stringifiedParams = normalizedParams.toString();
|
|
258
|
+
return stringifiedParams.length > 0 ? `/workflow/${id}/runs/${rid}/history/stream?${stringifiedParams}` : `/workflow/${id}/runs/${rid}/history/stream`;
|
|
259
|
+
};
|
|
260
|
+
export const getWorkflowIdRunsRidHistoryStream = async (id, rid, params, options) => {
|
|
261
|
+
return customFetchInstance(getGetWorkflowIdRunsRidHistoryStreamUrl(id, rid, params), {
|
|
262
|
+
...options,
|
|
263
|
+
method: 'GET'
|
|
264
|
+
});
|
|
265
|
+
};
|
|
234
266
|
export const getGetWorkflowCatalogIdUrl = (id) => {
|
|
235
267
|
return `/workflow/catalog/${id}`;
|
|
236
268
|
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Command } from '@oclif/core';
|
|
2
|
+
export default class WorkflowHistory extends Command {
|
|
3
|
+
static description: string;
|
|
4
|
+
static examples: string[];
|
|
5
|
+
static args: {
|
|
6
|
+
workflowId: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
|
|
7
|
+
};
|
|
8
|
+
static flags: {
|
|
9
|
+
'run-id': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
10
|
+
format: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
11
|
+
raw: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
12
|
+
'include-payloads': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
13
|
+
width: import("@oclif/core/interfaces").OptionFlag<number | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
14
|
+
color: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
15
|
+
};
|
|
16
|
+
run(): Promise<void>;
|
|
17
|
+
private buildHeader;
|
|
18
|
+
catch(error: Error): Promise<void>;
|
|
19
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { Args, Command, Flags } from '@oclif/core';
|
|
2
|
+
import { fetchWorkflowHistory } from '#services/workflow_history.js';
|
|
3
|
+
import buildSpanLabels from '#utils/span_labels.js';
|
|
4
|
+
import renderWaterfall, { formatDurationLabel } from '#utils/waterfall.js';
|
|
5
|
+
import { handleApiError } from '#utils/error_handler.js';
|
|
6
|
+
const DEFAULT_WIDTH = 80;
|
|
7
|
+
const OUTPUT_FORMAT = { JSON: 'json', TEXT: 'text' };
|
|
8
|
+
export default class WorkflowHistory extends Command {
|
|
9
|
+
static description = 'Show a workflow run\'s step timeline as a waterfall (durations and start times)';
|
|
10
|
+
static examples = [
|
|
11
|
+
'<%= config.bin %> <%= command.id %> wf-12345',
|
|
12
|
+
'<%= config.bin %> <%= command.id %> wf-12345 --run-id 2fe0b36b-...',
|
|
13
|
+
'<%= config.bin %> <%= command.id %> wf-12345 --format json',
|
|
14
|
+
'<%= config.bin %> <%= command.id %> wf-12345 --raw --include-payloads'
|
|
15
|
+
];
|
|
16
|
+
static args = {
|
|
17
|
+
workflowId: Args.string({
|
|
18
|
+
description: 'The workflow ID to show history for',
|
|
19
|
+
required: true
|
|
20
|
+
})
|
|
21
|
+
};
|
|
22
|
+
static flags = {
|
|
23
|
+
'run-id': Flags.string({
|
|
24
|
+
char: 'r',
|
|
25
|
+
description: 'Show a specific run (defaults to the latest run)'
|
|
26
|
+
}),
|
|
27
|
+
format: Flags.string({
|
|
28
|
+
char: 'f',
|
|
29
|
+
description: 'Output format',
|
|
30
|
+
options: [OUTPUT_FORMAT.TEXT, OUTPUT_FORMAT.JSON],
|
|
31
|
+
default: OUTPUT_FORMAT.TEXT
|
|
32
|
+
}),
|
|
33
|
+
raw: Flags.boolean({
|
|
34
|
+
description: 'Print the history endpoint\'s raw response (workflow + events)',
|
|
35
|
+
default: false
|
|
36
|
+
}),
|
|
37
|
+
'include-payloads': Flags.boolean({
|
|
38
|
+
description: 'Include decoded step input/output payloads',
|
|
39
|
+
default: false
|
|
40
|
+
}),
|
|
41
|
+
width: Flags.integer({
|
|
42
|
+
description: 'Override the detected terminal width'
|
|
43
|
+
}),
|
|
44
|
+
color: Flags.boolean({
|
|
45
|
+
description: 'Colorize the waterfall (use --no-color to disable)',
|
|
46
|
+
default: true,
|
|
47
|
+
allowNo: true
|
|
48
|
+
})
|
|
49
|
+
};
|
|
50
|
+
async run() {
|
|
51
|
+
const { args, flags } = await this.parse(WorkflowHistory);
|
|
52
|
+
const result = await fetchWorkflowHistory({
|
|
53
|
+
workflowId: args.workflowId,
|
|
54
|
+
runId: flags['run-id'],
|
|
55
|
+
includePayloads: flags['include-payloads']
|
|
56
|
+
});
|
|
57
|
+
if (flags.raw) {
|
|
58
|
+
this.log(JSON.stringify({
|
|
59
|
+
workflow: result.workflow,
|
|
60
|
+
runId: result.runId,
|
|
61
|
+
events: result.events
|
|
62
|
+
}, null, 2));
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (flags.format === OUTPUT_FORMAT.JSON) {
|
|
66
|
+
this.log(JSON.stringify({
|
|
67
|
+
workflow: result.workflow,
|
|
68
|
+
runId: result.runId,
|
|
69
|
+
totalDurationMs: result.totalDurationMs,
|
|
70
|
+
spans: result.spans
|
|
71
|
+
}, null, 2));
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const labels = buildSpanLabels(result.spans);
|
|
75
|
+
const width = flags.width ?? process.stdout.columns ?? DEFAULT_WIDTH;
|
|
76
|
+
const color = flags.color && !process.env.NO_COLOR &&
|
|
77
|
+
(!!process.env.FORCE_COLOR || process.stdout.isTTY === true);
|
|
78
|
+
this.log(renderWaterfall(result.spans, result.totalDurationMs, {
|
|
79
|
+
width,
|
|
80
|
+
color,
|
|
81
|
+
labels,
|
|
82
|
+
header: this.buildHeader(args.workflowId, result.runId, result.workflow?.status, result.totalDurationMs)
|
|
83
|
+
}));
|
|
84
|
+
// Failure reasons live in the payloads the server strips by default, so a
|
|
85
|
+
// failed run shows red bars but no messages until payloads are requested.
|
|
86
|
+
if (!flags['include-payloads'] && result.spans.some(span => span.status === 'failed')) {
|
|
87
|
+
this.log('\nSome steps failed — re-run with --include-payloads to see their error messages.');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
buildHeader(workflowId, runId, status, totalDurationMs) {
|
|
91
|
+
const shortRun = runId ? runId.slice(0, 8) : 'unknown';
|
|
92
|
+
return `${workflowId} · run ${shortRun} · ${status ?? 'unknown'} · ${formatDurationLabel(totalDurationMs)}`;
|
|
93
|
+
}
|
|
94
|
+
async catch(error) {
|
|
95
|
+
return handleApiError(error, (...args) => this.error(...args), {
|
|
96
|
+
404: 'Workflow not found. Check the workflow ID.'
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
// Isolate the command module from the API/service layer at import time.
|
|
3
|
+
vi.mock('../../services/workflow_history.js', () => ({
|
|
4
|
+
fetchWorkflowHistory: vi.fn()
|
|
5
|
+
}));
|
|
6
|
+
describe('workflow history command', () => {
|
|
7
|
+
it('exports a valid OCLIF command with a workflowId arg', async () => {
|
|
8
|
+
const WorkflowHistory = (await import('./history.js')).default;
|
|
9
|
+
expect(WorkflowHistory).toBeDefined();
|
|
10
|
+
expect(WorkflowHistory.description).toContain('waterfall');
|
|
11
|
+
expect(WorkflowHistory.args).toHaveProperty('workflowId');
|
|
12
|
+
expect(WorkflowHistory.args.workflowId.required).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
it('declares the expected flags and defaults', async () => {
|
|
15
|
+
const WorkflowHistory = (await import('./history.js')).default;
|
|
16
|
+
const flags = WorkflowHistory.flags;
|
|
17
|
+
expect(flags).toHaveProperty('run-id');
|
|
18
|
+
expect(flags).toHaveProperty('raw');
|
|
19
|
+
expect(flags).toHaveProperty('include-payloads');
|
|
20
|
+
expect(flags).toHaveProperty('color');
|
|
21
|
+
expect(flags).toHaveProperty('width');
|
|
22
|
+
expect(flags.format.options).toEqual(['text', 'json']);
|
|
23
|
+
expect(flags.format.default).toBe('text');
|
|
24
|
+
expect(flags.raw.default).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export type SpanStatus = 'pending' | 'running' | 'completed' | 'failed';
|
|
2
|
+
export type SpanKind = 'activity' | 'child_workflow';
|
|
3
|
+
export interface Span {
|
|
4
|
+
id: string;
|
|
5
|
+
name: string;
|
|
6
|
+
technicalName: string;
|
|
7
|
+
description: string | null;
|
|
8
|
+
status: SpanStatus;
|
|
9
|
+
kind: SpanKind;
|
|
10
|
+
attempt: number;
|
|
11
|
+
startedAt: string | null;
|
|
12
|
+
scheduledAt: string | null;
|
|
13
|
+
completedAt: string | null;
|
|
14
|
+
startOffsetMs: number;
|
|
15
|
+
endOffsetMs: number;
|
|
16
|
+
durationMs: number;
|
|
17
|
+
failureMessage: string | null;
|
|
18
|
+
input?: unknown;
|
|
19
|
+
output?: unknown;
|
|
20
|
+
}
|
|
21
|
+
export type HistoryEvent = Record<string, unknown>;
|
|
22
|
+
/**
|
|
23
|
+
* @param events - flat Temporal history events, in chronological order
|
|
24
|
+
* @param workflowStartTimeMs - epoch ms used as the timeline origin (0 offset)
|
|
25
|
+
*/
|
|
26
|
+
export declare function correlate(events: HistoryEvent[], workflowStartTimeMs: number | null): Span[];
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Correlates a flat list of Temporal history events into per-activity spans.
|
|
3
|
+
*
|
|
4
|
+
* Each ACTIVITY_TASK_SCHEDULED event opens a span (its eventId IS the
|
|
5
|
+
* scheduledEventId every subsequent ACTIVITY_TASK_* event for the same activity
|
|
6
|
+
* references). STARTED, COMPLETED, FAILED, TIMED_OUT, and CANCELED events extend
|
|
7
|
+
* that span. Spans without a terminal event are 'running' (if STARTED was seen)
|
|
8
|
+
* or 'pending'.
|
|
9
|
+
*
|
|
10
|
+
* Child workflows follow the same pattern but with their own event family:
|
|
11
|
+
* START_CHILD_WORKFLOW_EXECUTION_INITIATED opens the span, then
|
|
12
|
+
* CHILD_WORKFLOW_EXECUTION_STARTED / _COMPLETED / _FAILED / _TIMED_OUT /
|
|
13
|
+
* _CANCELED / _TERMINATED reference it via `initiatedEventId`.
|
|
14
|
+
*
|
|
15
|
+
* Pure function — no I/O. A TypeScript port of Atlas's
|
|
16
|
+
* `OutputWorkflows::WorkflowHistory::Correlator` (Ruby). Kept structurally
|
|
17
|
+
* faithful so the two stay easy to diff. The CLI has no step catalog, so a
|
|
18
|
+
* span's display name is the humanized step name rather than a catalog label.
|
|
19
|
+
*/
|
|
20
|
+
import { capitalCase } from 'change-case';
|
|
21
|
+
// Framework-level activities the user doesn't care about — Output's own trace
|
|
22
|
+
// destination resolution and the lifecycle webhook back to Atlas.
|
|
23
|
+
const NOISE_STEP_PREFIXES = ['__internal#'];
|
|
24
|
+
const NOISE_STEP_EXACT = ['$shared#callWebhook'];
|
|
25
|
+
const ACTIVITY_TERMINAL_TYPES = [
|
|
26
|
+
'ACTIVITY_TASK_COMPLETED', 'ACTIVITY_TASK_FAILED',
|
|
27
|
+
'ACTIVITY_TASK_TIMED_OUT', 'ACTIVITY_TASK_CANCELED'
|
|
28
|
+
];
|
|
29
|
+
const CHILD_TERMINAL_TYPES = [
|
|
30
|
+
'CHILD_WORKFLOW_EXECUTION_COMPLETED', 'CHILD_WORKFLOW_EXECUTION_FAILED',
|
|
31
|
+
'CHILD_WORKFLOW_EXECUTION_TIMED_OUT', 'CHILD_WORKFLOW_EXECUTION_CANCELED',
|
|
32
|
+
'CHILD_WORKFLOW_EXECUTION_TERMINATED', 'START_CHILD_WORKFLOW_EXECUTION_FAILED'
|
|
33
|
+
];
|
|
34
|
+
function eventTypeName(event) {
|
|
35
|
+
return event.eventTypeName ?? '';
|
|
36
|
+
}
|
|
37
|
+
function eventId(event) {
|
|
38
|
+
return String(event.eventId);
|
|
39
|
+
}
|
|
40
|
+
function eventAttributes(event) {
|
|
41
|
+
const key = event && Object.keys(event).find(k => k.endsWith('EventAttributes'));
|
|
42
|
+
return key ? event[key] : undefined;
|
|
43
|
+
}
|
|
44
|
+
// Temporal's int64 fields arrive as either a String (the API serializer
|
|
45
|
+
// stringifies scheduledEventId / startedEventId) or a raw protobuf Long struct
|
|
46
|
+
// `{ low, high, unsigned }` (initiatedEventId, etc). Normalize to a String key
|
|
47
|
+
// so both shapes match the opener event's stringified eventId.
|
|
48
|
+
function normalizeEventId(value) {
|
|
49
|
+
if (value === null || value === undefined) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
if (typeof value === 'string') {
|
|
53
|
+
return value || null;
|
|
54
|
+
}
|
|
55
|
+
if (typeof value === 'object' && 'low' in value) {
|
|
56
|
+
const low = value.low;
|
|
57
|
+
return low === null || low === undefined ? null : String(low);
|
|
58
|
+
}
|
|
59
|
+
return String(value) || null;
|
|
60
|
+
}
|
|
61
|
+
function scheduledEventIdFor(event) {
|
|
62
|
+
return normalizeEventId(eventAttributes(event)?.scheduledEventId);
|
|
63
|
+
}
|
|
64
|
+
function initiatedEventIdFor(event) {
|
|
65
|
+
return normalizeEventId(eventAttributes(event)?.initiatedEventId);
|
|
66
|
+
}
|
|
67
|
+
// For the noise filter: the full `workflow#step` name (prefixes/exacts match it).
|
|
68
|
+
function fullStepName(scheduled) {
|
|
69
|
+
const attrs = eventAttributes(scheduled) ?? {};
|
|
70
|
+
return attrs.activityType?.name ??
|
|
71
|
+
attrs.stepName ??
|
|
72
|
+
'unknown';
|
|
73
|
+
}
|
|
74
|
+
// For display: the bare step name (segment after `#`). The API serializer
|
|
75
|
+
// already exposes this as `stepName`; fall back to splitting `activityType.name`.
|
|
76
|
+
function cleanStepName(scheduled) {
|
|
77
|
+
const attrs = eventAttributes(scheduled) ?? {};
|
|
78
|
+
const explicit = attrs.stepName;
|
|
79
|
+
if (explicit) {
|
|
80
|
+
return explicit;
|
|
81
|
+
}
|
|
82
|
+
const full = attrs.activityType?.name;
|
|
83
|
+
if (full) {
|
|
84
|
+
return full.includes('#') ? full.split('#').pop() : full;
|
|
85
|
+
}
|
|
86
|
+
return 'unknown';
|
|
87
|
+
}
|
|
88
|
+
function isNoise(stepName) {
|
|
89
|
+
return NOISE_STEP_EXACT.includes(stepName) ||
|
|
90
|
+
NOISE_STEP_PREFIXES.some(prefix => stepName.startsWith(prefix));
|
|
91
|
+
}
|
|
92
|
+
function failureMessageOf(attrs) {
|
|
93
|
+
const failure = attrs.failure;
|
|
94
|
+
return failure?.message ?? null;
|
|
95
|
+
}
|
|
96
|
+
function attemptFor(started) {
|
|
97
|
+
return eventAttributes(started ?? undefined)?.attempt ?? 1;
|
|
98
|
+
}
|
|
99
|
+
function statusFor(started, terminalStatus) {
|
|
100
|
+
if (terminalStatus) {
|
|
101
|
+
return terminalStatus;
|
|
102
|
+
}
|
|
103
|
+
return started ? 'running' : 'pending';
|
|
104
|
+
}
|
|
105
|
+
function parseTime(value) {
|
|
106
|
+
if (value === null || value === undefined || value === '') {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
return String(value);
|
|
110
|
+
}
|
|
111
|
+
function toMs(iso) {
|
|
112
|
+
if (!iso) {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
const ms = Date.parse(iso);
|
|
116
|
+
return Number.isNaN(ms) ? null : ms;
|
|
117
|
+
}
|
|
118
|
+
// Offset math relative to the workflow start (timeline origin). Mirrors
|
|
119
|
+
// Atlas's Span#start_offset_ms / #end_offset_ms / #duration_ms.
|
|
120
|
+
function withOffsets(span, startMs) {
|
|
121
|
+
const startAnchor = toMs(span.startedAt) ?? toMs(span.scheduledAt);
|
|
122
|
+
const startOffsetMs = startMs !== null && startAnchor !== null ? Math.round(startAnchor - startMs) : 0;
|
|
123
|
+
const endAnchor = toMs(span.completedAt) ?? toMs(span.startedAt);
|
|
124
|
+
const endOffsetMs = startMs !== null && endAnchor !== null ? Math.round(endAnchor - startMs) : startOffsetMs;
|
|
125
|
+
return { ...span, startOffsetMs, endOffsetMs, durationMs: endOffsetMs - startOffsetMs };
|
|
126
|
+
}
|
|
127
|
+
function resolveActivityTerminal(terminal) {
|
|
128
|
+
if (!terminal) {
|
|
129
|
+
return {};
|
|
130
|
+
}
|
|
131
|
+
const attrs = eventAttributes(terminal) ?? {};
|
|
132
|
+
const completedAt = parseTime(terminal.eventTime);
|
|
133
|
+
switch (eventTypeName(terminal)) {
|
|
134
|
+
case 'ACTIVITY_TASK_COMPLETED':
|
|
135
|
+
return { status: 'completed', completedAt, output: attrs.result };
|
|
136
|
+
case 'ACTIVITY_TASK_FAILED':
|
|
137
|
+
return { status: 'failed', completedAt, failureMessage: failureMessageOf(attrs) };
|
|
138
|
+
case 'ACTIVITY_TASK_TIMED_OUT':
|
|
139
|
+
return { status: 'failed', completedAt, failureMessage: 'Timed out' };
|
|
140
|
+
case 'ACTIVITY_TASK_CANCELED':
|
|
141
|
+
return { status: 'failed', completedAt, failureMessage: 'Canceled' };
|
|
142
|
+
default:
|
|
143
|
+
return {};
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function resolveChildTerminal(terminal) {
|
|
147
|
+
if (!terminal) {
|
|
148
|
+
return {};
|
|
149
|
+
}
|
|
150
|
+
const attrs = eventAttributes(terminal) ?? {};
|
|
151
|
+
const completedAt = parseTime(terminal.eventTime);
|
|
152
|
+
switch (eventTypeName(terminal)) {
|
|
153
|
+
case 'CHILD_WORKFLOW_EXECUTION_COMPLETED':
|
|
154
|
+
return { status: 'completed', completedAt, output: attrs.result };
|
|
155
|
+
case 'CHILD_WORKFLOW_EXECUTION_FAILED':
|
|
156
|
+
return { status: 'failed', completedAt, failureMessage: failureMessageOf(attrs) };
|
|
157
|
+
case 'CHILD_WORKFLOW_EXECUTION_TIMED_OUT':
|
|
158
|
+
return { status: 'failed', completedAt, failureMessage: 'Timed out' };
|
|
159
|
+
case 'CHILD_WORKFLOW_EXECUTION_CANCELED':
|
|
160
|
+
return { status: 'failed', completedAt, failureMessage: 'Canceled' };
|
|
161
|
+
case 'CHILD_WORKFLOW_EXECUTION_TERMINATED':
|
|
162
|
+
return { status: 'failed', completedAt, failureMessage: 'Terminated' };
|
|
163
|
+
case 'START_CHILD_WORKFLOW_EXECUTION_FAILED':
|
|
164
|
+
return { status: 'failed', completedAt, failureMessage: attrs.cause || 'Failed to start' };
|
|
165
|
+
default:
|
|
166
|
+
return {};
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
function buildActivitySpan(id, slot, startMs) {
|
|
170
|
+
const { scheduled, started, terminal } = slot;
|
|
171
|
+
const scheduledAttrs = eventAttributes(scheduled) ?? {};
|
|
172
|
+
const terminalFields = resolveActivityTerminal(terminal);
|
|
173
|
+
return withOffsets({
|
|
174
|
+
id,
|
|
175
|
+
name: capitalCase(cleanStepName(scheduled)),
|
|
176
|
+
technicalName: fullStepName(scheduled),
|
|
177
|
+
description: null,
|
|
178
|
+
status: statusFor(started, terminalFields.status),
|
|
179
|
+
kind: 'activity',
|
|
180
|
+
attempt: attemptFor(started),
|
|
181
|
+
input: scheduledAttrs.input,
|
|
182
|
+
output: terminalFields.output,
|
|
183
|
+
startedAt: parseTime(started?.eventTime),
|
|
184
|
+
scheduledAt: parseTime(scheduled.eventTime),
|
|
185
|
+
completedAt: terminalFields.completedAt ?? null,
|
|
186
|
+
failureMessage: terminalFields.failureMessage ?? null
|
|
187
|
+
}, startMs);
|
|
188
|
+
}
|
|
189
|
+
// Child workflow spans render from the initiated event time so the full child
|
|
190
|
+
// duration is visible (matching Temporal's own UI). Pending children anchor at
|
|
191
|
+
// initiated time rather than null, else they'd all stack at offset 0.
|
|
192
|
+
function buildChildSpan(id, slot, startMs) {
|
|
193
|
+
const { scheduled, started, terminal } = slot;
|
|
194
|
+
const scheduledAttrs = eventAttributes(scheduled) ?? {};
|
|
195
|
+
const workflowType = scheduledAttrs.workflowType?.name ??
|
|
196
|
+
'child_workflow';
|
|
197
|
+
const terminalFields = resolveChildTerminal(terminal);
|
|
198
|
+
return withOffsets({
|
|
199
|
+
id: `child-${id}`,
|
|
200
|
+
name: capitalCase(workflowType),
|
|
201
|
+
technicalName: workflowType,
|
|
202
|
+
description: null,
|
|
203
|
+
status: statusFor(started, terminalFields.status),
|
|
204
|
+
kind: 'child_workflow',
|
|
205
|
+
attempt: 1,
|
|
206
|
+
input: scheduledAttrs.input,
|
|
207
|
+
output: terminalFields.output,
|
|
208
|
+
startedAt: parseTime(scheduled.eventTime),
|
|
209
|
+
scheduledAt: parseTime(scheduled.eventTime),
|
|
210
|
+
completedAt: terminalFields.completedAt ?? null,
|
|
211
|
+
failureMessage: terminalFields.failureMessage ?? null
|
|
212
|
+
}, startMs);
|
|
213
|
+
}
|
|
214
|
+
function emptySlot(opener) {
|
|
215
|
+
return { scheduled: opener, started: null, terminal: null };
|
|
216
|
+
}
|
|
217
|
+
function attach(slots, event, key) {
|
|
218
|
+
if (!key) {
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const slot = slots.get(key);
|
|
222
|
+
if (!slot) {
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (eventTypeName(event).endsWith('_STARTED')) {
|
|
226
|
+
slot.started = event;
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
slot.terminal = event;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* @param events - flat Temporal history events, in chronological order
|
|
234
|
+
* @param workflowStartTimeMs - epoch ms used as the timeline origin (0 offset)
|
|
235
|
+
*/
|
|
236
|
+
export function correlate(events, workflowStartTimeMs) {
|
|
237
|
+
const activities = new Map();
|
|
238
|
+
const children = new Map();
|
|
239
|
+
for (const event of events) {
|
|
240
|
+
const type = eventTypeName(event);
|
|
241
|
+
if (type === 'ACTIVITY_TASK_SCHEDULED') {
|
|
242
|
+
activities.set(eventId(event), emptySlot(event));
|
|
243
|
+
}
|
|
244
|
+
else if (type === 'ACTIVITY_TASK_STARTED' || ACTIVITY_TERMINAL_TYPES.includes(type)) {
|
|
245
|
+
attach(activities, event, scheduledEventIdFor(event));
|
|
246
|
+
}
|
|
247
|
+
else if (type === 'START_CHILD_WORKFLOW_EXECUTION_INITIATED') {
|
|
248
|
+
children.set(eventId(event), emptySlot(event));
|
|
249
|
+
}
|
|
250
|
+
else if (type === 'CHILD_WORKFLOW_EXECUTION_STARTED' || CHILD_TERMINAL_TYPES.includes(type)) {
|
|
251
|
+
attach(children, event, initiatedEventIdFor(event));
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
const activitySpans = [...activities.entries()]
|
|
255
|
+
.filter(([, slot]) => !isNoise(fullStepName(slot.scheduled)))
|
|
256
|
+
.map(([id, slot]) => buildActivitySpan(id, slot, workflowStartTimeMs));
|
|
257
|
+
const childSpans = [...children.entries()]
|
|
258
|
+
.map(([id, slot]) => buildChildSpan(id, slot, workflowStartTimeMs))
|
|
259
|
+
.filter(span => !isNoise(span.technicalName));
|
|
260
|
+
return [...activitySpans, ...childSpans].sort((a, b) => a.startOffsetMs - b.startOffsetMs);
|
|
261
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|