@volter/twin-temporal 0.1.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/LICENSE +202 -0
- package/README.md +104 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +36 -0
- package/dist/src/generated/hpack.json +1 -0
- package/dist/src/generated/temporal-api.json +1 -0
- package/dist/src/index.d.ts +18 -0
- package/dist/src/index.js +70 -0
- package/dist/src/temporal-budget.d.ts +31 -0
- package/dist/src/temporal-budget.js +50 -0
- package/dist/src/temporal-capabilities.d.ts +7 -0
- package/dist/src/temporal-capabilities.js +650 -0
- package/dist/src/temporal-conformance.d.ts +8 -0
- package/dist/src/temporal-conformance.js +54 -0
- package/dist/src/temporal-connector.d.ts +32 -0
- package/dist/src/temporal-connector.js +121 -0
- package/dist/src/temporal-engine.d.ts +274 -0
- package/dist/src/temporal-engine.js +2132 -0
- package/dist/src/temporal-grpc.d.ts +51 -0
- package/dist/src/temporal-grpc.js +124 -0
- package/dist/src/temporal-h2.d.ts +40 -0
- package/dist/src/temporal-h2.js +644 -0
- package/dist/src/temporal-history.d.ts +27 -0
- package/dist/src/temporal-history.js +85 -0
- package/dist/src/temporal-http.d.ts +17 -0
- package/dist/src/temporal-http.js +160 -0
- package/dist/src/temporal-json.d.ts +7 -0
- package/dist/src/temporal-json.js +68 -0
- package/dist/src/temporal-proto.d.ts +17 -0
- package/dist/src/temporal-proto.js +46 -0
- package/dist/src/temporal-server.d.ts +25 -0
- package/dist/src/temporal-server.js +56 -0
- package/dist/src/temporal-twin.d.ts +25 -0
- package/dist/src/temporal-twin.js +100 -0
- package/dist/src/temporal-visibility.d.ts +49 -0
- package/dist/src/temporal-visibility.js +293 -0
- package/package.json +59 -0
- package/src/cli.ts +36 -0
- package/src/generated/hpack.json +1 -0
- package/src/generated/temporal-api.json +1 -0
- package/src/index.ts +81 -0
- package/src/temporal-budget.ts +75 -0
- package/src/temporal-capabilities.ts +679 -0
- package/src/temporal-conformance.ts +52 -0
- package/src/temporal-connector.ts +118 -0
- package/src/temporal-engine.ts +2176 -0
- package/src/temporal-grpc.ts +139 -0
- package/src/temporal-h2.ts +614 -0
- package/src/temporal-history.ts +99 -0
- package/src/temporal-http.ts +146 -0
- package/src/temporal-json.ts +77 -0
- package/src/temporal-proto.ts +53 -0
- package/src/temporal-server.ts +67 -0
- package/src/temporal-twin.ts +102 -0
- package/src/temporal-visibility.ts +245 -0
|
@@ -0,0 +1,679 @@
|
|
|
1
|
+
// THE CAPABILITY MANIFEST — the Temporal frontend's declared surface as the denominator.
|
|
2
|
+
//
|
|
3
|
+
// The denominator is read from the vendored protos, not written by hand: every method of
|
|
4
|
+
// temporal.api.workflowservice.v1.WorkflowService, temporal.api.operatorservice.v1.OperatorService
|
|
5
|
+
// and grpc.health.v1.Health is one capability, and every CommandType a workflow task can return is
|
|
6
|
+
// another (the history service's surface, which no RPC names). A method is `done` exactly when the
|
|
7
|
+
// twin serves it (temporal-twin.ts SERVED) AND a verify below proves what it answers; everything
|
|
8
|
+
// else is `todo`, and on the wire it answers gRPC UNIMPLEMENTED. The behaviours no single RPC owns
|
|
9
|
+
// (retries, timers on the World clock, buffered events, parent-close policy) are the `history` area.
|
|
10
|
+
//
|
|
11
|
+
// Every verify drives a fresh World root through `handleTemporalCall` (the mutation seam in
|
|
12
|
+
// gate.ts) the way a worker and a client would, and asserts values a blank answer cannot fake.
|
|
13
|
+
// The real SDKs are proven end to end in temporal-sdk.integration.test.ts.
|
|
14
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
15
|
+
import { tmpdir } from 'node:os';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { checkCapabilities, type CapabilityReport, type CapabilitySpec, verifyBoundary } from '@volter/world-tooling';
|
|
18
|
+
import { twinResources } from '@volter/world-core';
|
|
19
|
+
import { handleTemporalCall, SERVED } from './temporal-twin.ts';
|
|
20
|
+
import { TemporalEngine, jsonPayload, payloadValue } from './temporal-engine.ts';
|
|
21
|
+
import { HEALTH_SERVICE, OPERATOR_SERVICE, WORKFLOW_SERVICE, serviceMethods, temporalRoot } from './temporal-proto.ts';
|
|
22
|
+
import { GrpcError } from './temporal-grpc.ts';
|
|
23
|
+
import { syncTemporalFromReal } from './temporal-connector.ts';
|
|
24
|
+
import { createTemporalTwinFetch } from './temporal-http.ts';
|
|
25
|
+
|
|
26
|
+
type Obj = Record<string, any>;
|
|
27
|
+
const done = (id: string, area: string, title: string, tier: CapabilitySpec['tier'], verify: CapabilitySpec['verify']): CapabilitySpec =>
|
|
28
|
+
({ id, area, title, dimension: 'api', tier, expected: 'done', verify });
|
|
29
|
+
const todo = (id: string, area: string, title: string, tier: CapabilitySpec['tier']): CapabilitySpec =>
|
|
30
|
+
({ id, area, title, dimension: 'api', tier, expected: 'todo' });
|
|
31
|
+
|
|
32
|
+
const T0 = '2026-01-01T00:00:00.000Z';
|
|
33
|
+
|
|
34
|
+
type Twin = {
|
|
35
|
+
engine: TemporalEngine;
|
|
36
|
+
root: string;
|
|
37
|
+
/** one unary call through the seam; a GrpcError comes back as `{ grpcError: code, message }` */
|
|
38
|
+
call(method: string, request?: Obj, deadlineMs?: number): Promise<Obj>;
|
|
39
|
+
/** move the World clock forward */
|
|
40
|
+
advance(ms: number): Promise<void>;
|
|
41
|
+
start(workflowId: string, extra?: Obj): Promise<string>;
|
|
42
|
+
pollWorkflow(queue?: string): Promise<Obj>;
|
|
43
|
+
complete(task: Obj, commands: Obj[], extra?: Obj): Promise<Obj>;
|
|
44
|
+
pollActivity(queue?: string): Promise<Obj>;
|
|
45
|
+
events(workflowId: string, runId?: string): Promise<Obj[]>;
|
|
46
|
+
describe(workflowId: string, runId?: string): Promise<Obj>;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const SERVICE_OF = new Map<string, string>();
|
|
50
|
+
for (const service of [WORKFLOW_SERVICE, OPERATOR_SERVICE, HEALTH_SERVICE]) for (const method of serviceMethods(service).keys()) SERVICE_OF.set(method, service);
|
|
51
|
+
|
|
52
|
+
/** A fresh World root with its own pinned clock; the verify proves the capability from nothing. */
|
|
53
|
+
async function withTwin(fn: (twin: Twin) => Promise<boolean>): Promise<boolean> {
|
|
54
|
+
const root = mkdtempSync(join(tmpdir(), 'temporal-cap-'));
|
|
55
|
+
const clock = join(root, 'world-clock');
|
|
56
|
+
writeFileSync(clock, `${T0}\n`);
|
|
57
|
+
const previous = process.env.TWIN_WORLD_CLOCK_FILE;
|
|
58
|
+
process.env.TWIN_WORLD_CLOCK_FILE = clock;
|
|
59
|
+
const engine = new TemporalEngine({ root });
|
|
60
|
+
let now = Date.parse(T0);
|
|
61
|
+
const call = async (method: string, request: Obj = {}, deadlineMs = 300): Promise<Obj> => {
|
|
62
|
+
const controller = new AbortController();
|
|
63
|
+
try {
|
|
64
|
+
return await handleTemporalCall(engine, SERVICE_OF.get(method)!, method, { namespace: 'default', ...request }, { signal: controller.signal, deadline: Date.now() + 1000 + deadlineMs, metadata: {} });
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (error instanceof GrpcError) return { grpcError: error.code, message: error.message };
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
const twin: Twin = {
|
|
71
|
+
engine,
|
|
72
|
+
root,
|
|
73
|
+
call,
|
|
74
|
+
advance: async (ms) => {
|
|
75
|
+
now += ms;
|
|
76
|
+
writeFileSync(clock, `${new Date(now).toISOString()}\n`);
|
|
77
|
+
await engine.tick();
|
|
78
|
+
},
|
|
79
|
+
start: async (workflowId, extra = {}) => (await call('StartWorkflowExecution', { workflowId, workflowType: { name: 'wf' }, taskQueue: { name: 'q' }, requestId: `r-${workflowId}-${Math.random()}`, input: { payloads: [jsonPayload('in')] }, ...extra })).runId,
|
|
80
|
+
pollWorkflow: (queue = 'q') => call('PollWorkflowTaskQueue', { taskQueue: { name: queue, kind: 'TASK_QUEUE_KIND_NORMAL' }, identity: 'w' }),
|
|
81
|
+
complete: (task, commands, extra = {}) => call('RespondWorkflowTaskCompleted', { taskToken: task.taskToken, commands, identity: 'w', ...extra }),
|
|
82
|
+
pollActivity: (queue = 'q') => call('PollActivityTaskQueue', { taskQueue: { name: queue }, identity: 'w' }),
|
|
83
|
+
events: async (workflowId, runId) => (await call('GetWorkflowExecutionHistory', { execution: { workflowId, ...(runId ? { runId } : {}) } })).history?.events ?? [],
|
|
84
|
+
describe: (workflowId, runId) => call('DescribeWorkflowExecution', { execution: { workflowId, ...(runId ? { runId } : {}) } }),
|
|
85
|
+
};
|
|
86
|
+
try {
|
|
87
|
+
return await verifyBoundary('temporal.withTwin', () => fn(twin));
|
|
88
|
+
} finally {
|
|
89
|
+
engine.stop();
|
|
90
|
+
if (previous === undefined) delete process.env.TWIN_WORLD_CLOCK_FILE;
|
|
91
|
+
else process.env.TWIN_WORLD_CLOCK_FILE = previous;
|
|
92
|
+
rmSync(root, { recursive: true, force: true });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const types = (events: Obj[]) => events.map((event) => String(event.eventType).replace('EVENT_TYPE_', ''));
|
|
97
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
98
|
+
const command = (commandType: string, attributes: Obj): Obj => {
|
|
99
|
+
const key = `${commandType.replace('COMMAND_TYPE_', '').toLowerCase().replace(/_([a-z])/g, (_, c: string) => c.toUpperCase())}CommandAttributes`;
|
|
100
|
+
return { commandType, [key]: attributes };
|
|
101
|
+
};
|
|
102
|
+
const activityCommand = (activityId: string, extra: Obj = {}) => command('COMMAND_TYPE_SCHEDULE_ACTIVITY_TASK', {
|
|
103
|
+
activityId, activityType: { name: 'act' }, taskQueue: { name: 'q' }, startToCloseTimeout: { seconds: 60 }, input: { payloads: [jsonPayload(activityId)] }, ...extra,
|
|
104
|
+
});
|
|
105
|
+
const completeCommand = (value: unknown) => command('COMMAND_TYPE_COMPLETE_WORKFLOW_EXECUTION', { result: { payloads: [jsonPayload(value)] } });
|
|
106
|
+
|
|
107
|
+
/** start → first task → the given commands; answers the run id and the events after. */
|
|
108
|
+
async function afterFirstTask(twin: Twin, workflowId: string, commands: Obj[], extra: Obj = {}): Promise<{ runId: string; events: Obj[]; answer: Obj }> {
|
|
109
|
+
const runId = await twin.start(workflowId, extra);
|
|
110
|
+
const task = await twin.pollWorkflow();
|
|
111
|
+
const answer = await twin.complete(task, commands);
|
|
112
|
+
return { runId, events: await twin.events(workflowId, runId), answer };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ---- the RPC verifies ------------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
const RPC_VERIFY: Record<string, { tier: CapabilitySpec['tier']; verify: () => Promise<boolean> }> = {
|
|
118
|
+
GetSystemInfo: { tier: 'core', verify: () => withTwin(async (t) => {
|
|
119
|
+
const info = await t.call('GetSystemInfo');
|
|
120
|
+
return /^\d+\.\d+\.\d+$/.test(String(info.serverVersion)) && info.capabilities?.sdkMetadata === true && info.capabilities?.supportsSchedules === false;
|
|
121
|
+
}) },
|
|
122
|
+
DescribeNamespace: { tier: 'core', verify: () => withTwin(async (t) => {
|
|
123
|
+
const ns = await t.call('DescribeNamespace', { namespace: 'default' });
|
|
124
|
+
const missing = await t.call('DescribeNamespace', { namespace: 'nope' });
|
|
125
|
+
return ns.namespaceInfo?.name === 'default' && ns.namespaceInfo?.state === 'NAMESPACE_STATE_REGISTERED' && UUID.test(ns.namespaceInfo?.id) && missing.grpcError === 5;
|
|
126
|
+
}) },
|
|
127
|
+
ListNamespaces: { tier: 'common', verify: () => withTwin(async (t) => {
|
|
128
|
+
await t.call('RegisterNamespace', { namespace: 'other', workflowExecutionRetentionPeriod: { seconds: 3600 } });
|
|
129
|
+
const names = ((await t.call('ListNamespaces')).namespaces ?? []).map((ns: Obj) => ns.namespaceInfo.name);
|
|
130
|
+
return names.includes('default') && names.includes('other');
|
|
131
|
+
}) },
|
|
132
|
+
RegisterNamespace: { tier: 'common', verify: () => withTwin(async (t) => {
|
|
133
|
+
const first = await t.call('RegisterNamespace', { namespace: 'team', description: 'd' });
|
|
134
|
+
const again = await t.call('RegisterNamespace', { namespace: 'team' });
|
|
135
|
+
const described = await t.call('DescribeNamespace', { namespace: 'team' });
|
|
136
|
+
return first.grpcError === undefined && again.grpcError === 6 && described.namespaceInfo?.description === 'd';
|
|
137
|
+
}) },
|
|
138
|
+
StartWorkflowExecution: { tier: 'core', verify: () => withTwin(async (t) => {
|
|
139
|
+
const answer = await t.call('StartWorkflowExecution', { workflowId: 'w1', workflowType: { name: 'wf' }, taskQueue: { name: 'q' }, requestId: 'req-1' });
|
|
140
|
+
const again = await t.call('StartWorkflowExecution', { workflowId: 'w1', workflowType: { name: 'wf' }, taskQueue: { name: 'q' }, requestId: 'req-1' });
|
|
141
|
+
const conflict = await t.call('StartWorkflowExecution', { workflowId: 'w1', workflowType: { name: 'wf' }, taskQueue: { name: 'q' }, requestId: 'req-2' });
|
|
142
|
+
const useExisting = await t.call('StartWorkflowExecution', { workflowId: 'w1', workflowType: { name: 'wf' }, taskQueue: { name: 'q' }, requestId: 'req-3', workflowIdConflictPolicy: 'WORKFLOW_ID_CONFLICT_POLICY_USE_EXISTING' });
|
|
143
|
+
const events = types(await t.events('w1'));
|
|
144
|
+
return UUID.test(answer.runId) && answer.started === true && again.runId === answer.runId && conflict.grpcError === 6
|
|
145
|
+
&& useExisting.runId === answer.runId && useExisting.started === false && events.join() === 'WORKFLOW_EXECUTION_STARTED,WORKFLOW_TASK_SCHEDULED';
|
|
146
|
+
}) },
|
|
147
|
+
SignalWithStartWorkflowExecution: { tier: 'core', verify: () => withTwin(async (t) => {
|
|
148
|
+
const req = { workflowId: 'sws', workflowType: { name: 'wf' }, taskQueue: { name: 'q' }, signalName: 'go', signalInput: { payloads: [jsonPayload(1)] } };
|
|
149
|
+
const first = await t.call('SignalWithStartWorkflowExecution', { ...req, requestId: 'a' });
|
|
150
|
+
const second = await t.call('SignalWithStartWorkflowExecution', { ...req, requestId: 'b' });
|
|
151
|
+
const events = types(await t.events('sws'));
|
|
152
|
+
return first.started === true && second.started === false && second.runId === first.runId
|
|
153
|
+
&& events.join() === 'WORKFLOW_EXECUTION_STARTED,WORKFLOW_EXECUTION_SIGNALED,WORKFLOW_TASK_SCHEDULED,WORKFLOW_EXECUTION_SIGNALED';
|
|
154
|
+
}) },
|
|
155
|
+
SignalWorkflowExecution: { tier: 'core', verify: () => withTwin(async (t) => {
|
|
156
|
+
await t.start('sig');
|
|
157
|
+
const task = await t.pollWorkflow();
|
|
158
|
+
// a signal while the task is in flight is buffered until it closes
|
|
159
|
+
await t.call('SignalWorkflowExecution', { workflowExecution: { workflowId: 'sig' }, signalName: 'poke', input: { payloads: [jsonPayload('x')] } });
|
|
160
|
+
const during = types(await t.events('sig'));
|
|
161
|
+
await t.complete(task, []);
|
|
162
|
+
const after = await t.events('sig');
|
|
163
|
+
const signaled = after.find((e) => e.eventType === 'EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED');
|
|
164
|
+
const missing = await t.call('SignalWorkflowExecution', { workflowExecution: { workflowId: 'nope' }, signalName: 'poke' });
|
|
165
|
+
return !during.includes('WORKFLOW_EXECUTION_SIGNALED') && signaled?.workflowExecutionSignaledEventAttributes?.signalName === 'poke'
|
|
166
|
+
&& payloadValue(signaled.workflowExecutionSignaledEventAttributes.input.payloads[0]) === 'x'
|
|
167
|
+
&& types(after).slice(-2).join() === 'WORKFLOW_EXECUTION_SIGNALED,WORKFLOW_TASK_SCHEDULED' && missing.grpcError === 5;
|
|
168
|
+
}) },
|
|
169
|
+
RequestCancelWorkflowExecution: { tier: 'common', verify: () => withTwin(async (t) => {
|
|
170
|
+
await afterFirstTask(t, 'rc', []);
|
|
171
|
+
await t.call('RequestCancelWorkflowExecution', { workflowExecution: { workflowId: 'rc' }, reason: 'stop', requestId: 'c1' });
|
|
172
|
+
const task = await t.pollWorkflow();
|
|
173
|
+
await t.complete(task, [command('COMMAND_TYPE_CANCEL_WORKFLOW_EXECUTION', {})]);
|
|
174
|
+
const events = types(await t.events('rc'));
|
|
175
|
+
return events.includes('WORKFLOW_EXECUTION_CANCEL_REQUESTED') && events.at(-1) === 'WORKFLOW_EXECUTION_CANCELED'
|
|
176
|
+
&& (await t.describe('rc')).workflowExecutionInfo?.status === 'WORKFLOW_EXECUTION_STATUS_CANCELED';
|
|
177
|
+
}) },
|
|
178
|
+
TerminateWorkflowExecution: { tier: 'core', verify: () => withTwin(async (t) => {
|
|
179
|
+
await t.start('term');
|
|
180
|
+
const answer = await t.call('TerminateWorkflowExecution', { workflowExecution: { workflowId: 'term' }, reason: 'enough', identity: 'op' });
|
|
181
|
+
const again = await t.call('TerminateWorkflowExecution', { workflowExecution: { workflowId: 'term' }, reason: 'again' });
|
|
182
|
+
const events = await t.events('term');
|
|
183
|
+
const last = events.at(-1)!;
|
|
184
|
+
return answer.grpcError === undefined && again.grpcError === 5 && last.eventType === 'EVENT_TYPE_WORKFLOW_EXECUTION_TERMINATED'
|
|
185
|
+
&& last.workflowExecutionTerminatedEventAttributes?.reason === 'enough' && (await t.describe('term')).workflowExecutionInfo?.status === 'WORKFLOW_EXECUTION_STATUS_TERMINATED';
|
|
186
|
+
}) },
|
|
187
|
+
GetWorkflowExecutionHistory: { tier: 'core', verify: () => withTwin(async (t) => {
|
|
188
|
+
await afterFirstTask(t, 'hist', [completeCommand('ok')]);
|
|
189
|
+
const all = await t.call('GetWorkflowExecutionHistory', { execution: { workflowId: 'hist' } });
|
|
190
|
+
const close = await t.call('GetWorkflowExecutionHistory', { execution: { workflowId: 'hist' }, historyEventFilterType: 'HISTORY_EVENT_FILTER_TYPE_CLOSE_EVENT', waitNewEvent: true });
|
|
191
|
+
const paged = await t.call('GetWorkflowExecutionHistory', { execution: { workflowId: 'hist' }, maximumPageSize: 2 });
|
|
192
|
+
const ids = (all.history?.events ?? []).map((e: Obj) => e.eventId);
|
|
193
|
+
return ids.join() === '1,2,3,4,5' && close.history?.events?.length === 1 && close.history.events[0].eventType === 'EVENT_TYPE_WORKFLOW_EXECUTION_COMPLETED'
|
|
194
|
+
&& paged.history?.events?.length === 2 && typeof paged.nextPageToken === 'string';
|
|
195
|
+
}) },
|
|
196
|
+
DescribeWorkflowExecution: { tier: 'core', verify: () => withTwin(async (t) => {
|
|
197
|
+
const { runId } = await afterFirstTask(t, 'desc', [activityCommand('a1')], { memo: { fields: { m: jsonPayload('v') } } });
|
|
198
|
+
const described = await t.describe('desc');
|
|
199
|
+
const info = described.workflowExecutionInfo ?? {};
|
|
200
|
+
return info.execution?.runId === runId && info.type?.name === 'wf' && info.status === 'WORKFLOW_EXECUTION_STATUS_RUNNING' && info.taskQueue === 'q'
|
|
201
|
+
&& info.historyLength === 5 && described.pendingActivities?.[0]?.activityId === 'a1' && described.pendingActivities[0].state === 'PENDING_ACTIVITY_STATE_SCHEDULED'
|
|
202
|
+
&& payloadValue(info.memo?.fields?.m) === 'v' && (await t.describe('nope')).grpcError === 5;
|
|
203
|
+
}) },
|
|
204
|
+
ListWorkflowExecutions: { tier: 'core', verify: () => withTwin(async (t) => {
|
|
205
|
+
await t.call('AddSearchAttributes', { searchAttributes: { postId: 'INDEXED_VALUE_TYPE_TEXT' } });
|
|
206
|
+
await t.start('p-1', { searchAttributes: { indexedFields: { postId: jsonPayload('abc', { type: 'Text' }) } } });
|
|
207
|
+
await t.start('p-2', { searchAttributes: { indexedFields: { postId: jsonPayload('zzz', { type: 'Text' }) } } });
|
|
208
|
+
await t.call('TerminateWorkflowExecution', { workflowExecution: { workflowId: 'p-2' } });
|
|
209
|
+
const running = await t.call('ListWorkflowExecutions', { query: 'postId="abc" AND ExecutionStatus="Running"' });
|
|
210
|
+
const either = await t.call('ListWorkflowExecutions', { query: "WorkflowId IN ('p-1', 'p-2') ORDER BY StartTime DESC" });
|
|
211
|
+
const bad = await t.call('ListWorkflowExecutions', { query: 'nope = 1' });
|
|
212
|
+
return running.executions?.map((e: Obj) => e.execution.workflowId).join() === 'p-1' && either.executions?.length === 2 && bad.grpcError === 3;
|
|
213
|
+
}) },
|
|
214
|
+
CountWorkflowExecutions: { tier: 'common', verify: () => withTwin(async (t) => {
|
|
215
|
+
await t.start('c-1');
|
|
216
|
+
await t.start('c-2');
|
|
217
|
+
await t.call('TerminateWorkflowExecution', { workflowExecution: { workflowId: 'c-2' } });
|
|
218
|
+
const running = await t.call('CountWorkflowExecutions', { query: 'ExecutionStatus = "Running"' });
|
|
219
|
+
const all = await t.call('CountWorkflowExecutions', { query: '' });
|
|
220
|
+
return running.count === 1 && all.count === 2;
|
|
221
|
+
}) },
|
|
222
|
+
PollWorkflowTaskQueue: { tier: 'core', verify: () => withTwin(async (t) => {
|
|
223
|
+
const runId = await t.start('poll');
|
|
224
|
+
const task = await t.pollWorkflow();
|
|
225
|
+
const empty = await t.pollWorkflow();
|
|
226
|
+
const events = task.history?.events ?? [];
|
|
227
|
+
return typeof task.taskToken === 'string' && task.workflowExecution?.runId === runId && task.startedEventId === 3 && task.attempt === 1
|
|
228
|
+
&& types(events).join() === 'WORKFLOW_EXECUTION_STARTED,WORKFLOW_TASK_SCHEDULED,WORKFLOW_TASK_STARTED' && empty.taskToken === undefined;
|
|
229
|
+
}) },
|
|
230
|
+
RespondWorkflowTaskCompleted: { tier: 'core', verify: () => withTwin(async (t) => {
|
|
231
|
+
await t.start('rwc');
|
|
232
|
+
const task = await t.pollWorkflow();
|
|
233
|
+
const answer = await t.complete(task, [activityCommand('a1')]);
|
|
234
|
+
const stale = await t.complete(task, []);
|
|
235
|
+
const events = await t.events('rwc');
|
|
236
|
+
const completed = events.find((e) => e.eventType === 'EVENT_TYPE_WORKFLOW_TASK_COMPLETED');
|
|
237
|
+
return answer.grpcError === undefined && completed?.workflowTaskCompletedEventAttributes?.scheduledEventId === 2 && completed.workflowTaskCompletedEventAttributes.startedEventId === 3
|
|
238
|
+
&& types(events).at(-1) === 'ACTIVITY_TASK_SCHEDULED' && stale.grpcError === 5;
|
|
239
|
+
}) },
|
|
240
|
+
RespondWorkflowTaskFailed: { tier: 'core', verify: () => withTwin(async (t) => {
|
|
241
|
+
await t.start('rwf');
|
|
242
|
+
const task = await t.pollWorkflow();
|
|
243
|
+
await t.call('RespondWorkflowTaskFailed', { taskToken: task.taskToken, cause: 'WORKFLOW_TASK_FAILED_CAUSE_WORKFLOW_WORKER_UNHANDLED_FAILURE', failure: { message: 'boom' } });
|
|
244
|
+
const retried = await t.pollWorkflow();
|
|
245
|
+
const events = await t.events('rwf');
|
|
246
|
+
const failed = events.find((e) => e.eventType === 'EVENT_TYPE_WORKFLOW_TASK_FAILED');
|
|
247
|
+
return failed?.workflowTaskFailedEventAttributes?.failure?.message === 'boom' && retried.attempt === 2 && retried.startedEventId === 6;
|
|
248
|
+
}) },
|
|
249
|
+
PollActivityTaskQueue: { tier: 'core', verify: () => withTwin(async (t) => {
|
|
250
|
+
const { runId } = await afterFirstTask(t, 'pa', [activityCommand('a1', { heartbeatTimeout: { seconds: 10 } })]);
|
|
251
|
+
const task = await t.pollActivity();
|
|
252
|
+
const empty = await t.pollActivity();
|
|
253
|
+
return task.activityId === 'a1' && task.activityType?.name === 'act' && task.workflowExecution?.runId === runId && task.attempt === 1
|
|
254
|
+
&& payloadValue(task.input?.payloads?.[0]) === 'a1' && task.startToCloseTimeout?.seconds === 60 && task.heartbeatTimeout?.seconds === 10 && empty.taskToken === undefined;
|
|
255
|
+
}) },
|
|
256
|
+
RespondActivityTaskCompleted: { tier: 'core', verify: () => withTwin(async (t) => {
|
|
257
|
+
await afterFirstTask(t, 'rac', [activityCommand('a1')]);
|
|
258
|
+
const task = await t.pollActivity();
|
|
259
|
+
await t.call('RespondActivityTaskCompleted', { taskToken: task.taskToken, result: { payloads: [jsonPayload(42)] }, identity: 'w' });
|
|
260
|
+
const events = await t.events('rac');
|
|
261
|
+
const completed = events.find((e) => e.eventType === 'EVENT_TYPE_ACTIVITY_TASK_COMPLETED');
|
|
262
|
+
const started = events.find((e) => e.eventType === 'EVENT_TYPE_ACTIVITY_TASK_STARTED');
|
|
263
|
+
const again = await t.call('RespondActivityTaskCompleted', { taskToken: task.taskToken });
|
|
264
|
+
return payloadValue(completed?.activityTaskCompletedEventAttributes?.result?.payloads?.[0]) === 42 && completed?.activityTaskCompletedEventAttributes?.startedEventId === started?.eventId
|
|
265
|
+
&& types(events).at(-1) === 'WORKFLOW_TASK_SCHEDULED' && again.grpcError === 5;
|
|
266
|
+
}) },
|
|
267
|
+
RespondActivityTaskCompletedById: { tier: 'niche', verify: () => withTwin(async (t) => {
|
|
268
|
+
await afterFirstTask(t, 'racid', [activityCommand('a1')]);
|
|
269
|
+
await t.pollActivity();
|
|
270
|
+
await t.call('RespondActivityTaskCompletedById', { workflowId: 'racid', activityId: 'a1', result: { payloads: [jsonPayload('by id')] } });
|
|
271
|
+
const completed = (await t.events('racid')).find((e) => e.eventType === 'EVENT_TYPE_ACTIVITY_TASK_COMPLETED');
|
|
272
|
+
return payloadValue(completed?.activityTaskCompletedEventAttributes?.result?.payloads?.[0]) === 'by id';
|
|
273
|
+
}) },
|
|
274
|
+
RespondActivityTaskFailed: { tier: 'core', verify: () => withTwin(async (t) => {
|
|
275
|
+
await afterFirstTask(t, 'raf', [activityCommand('a1', { retryPolicy: { maximumAttempts: 1 } })]);
|
|
276
|
+
const task = await t.pollActivity();
|
|
277
|
+
await t.call('RespondActivityTaskFailed', { taskToken: task.taskToken, failure: { message: 'nope', applicationFailureInfo: { type: 'E' } } });
|
|
278
|
+
const failed = (await t.events('raf')).find((e) => e.eventType === 'EVENT_TYPE_ACTIVITY_TASK_FAILED');
|
|
279
|
+
return failed?.activityTaskFailedEventAttributes?.failure?.message === 'nope' && failed.activityTaskFailedEventAttributes.retryState === 'RETRY_STATE_MAXIMUM_ATTEMPTS_REACHED';
|
|
280
|
+
}) },
|
|
281
|
+
RespondActivityTaskFailedById: { tier: 'niche', verify: () => withTwin(async (t) => {
|
|
282
|
+
await afterFirstTask(t, 'rafid', [activityCommand('a1', { retryPolicy: { maximumAttempts: 1 } })]);
|
|
283
|
+
await t.pollActivity();
|
|
284
|
+
await t.call('RespondActivityTaskFailedById', { workflowId: 'rafid', activityId: 'a1', failure: { message: 'by id' } });
|
|
285
|
+
const failed = (await t.events('rafid')).find((e) => e.eventType === 'EVENT_TYPE_ACTIVITY_TASK_FAILED');
|
|
286
|
+
return failed?.activityTaskFailedEventAttributes?.failure?.message === 'by id';
|
|
287
|
+
}) },
|
|
288
|
+
RespondActivityTaskCanceled: { tier: 'common', verify: () => withTwin(async (t) => {
|
|
289
|
+
await t.start('ract');
|
|
290
|
+
const first = await t.pollWorkflow();
|
|
291
|
+
await t.complete(first, [activityCommand('a1')]);
|
|
292
|
+
const activity = await t.pollActivity();
|
|
293
|
+
const early = await t.call('RespondActivityTaskCanceled', { taskToken: activity.taskToken });
|
|
294
|
+
await t.call('SignalWorkflowExecution', { workflowExecution: { workflowId: 'ract' }, signalName: 's' });
|
|
295
|
+
const second = await t.pollWorkflow();
|
|
296
|
+
await t.complete(second, [command('COMMAND_TYPE_REQUEST_CANCEL_ACTIVITY_TASK', { scheduledEventId: 5 })]);
|
|
297
|
+
const beat = await t.call('RecordActivityTaskHeartbeat', { taskToken: activity.taskToken });
|
|
298
|
+
await t.call('RespondActivityTaskCanceled', { taskToken: activity.taskToken, details: { payloads: [jsonPayload('bye')] } });
|
|
299
|
+
const canceled = (await t.events('ract')).find((e) => e.eventType === 'EVENT_TYPE_ACTIVITY_TASK_CANCELED');
|
|
300
|
+
return early.grpcError === 3 && beat.cancelRequested === true && payloadValue(canceled?.activityTaskCanceledEventAttributes?.details?.payloads?.[0]) === 'bye';
|
|
301
|
+
}) },
|
|
302
|
+
RespondActivityTaskCanceledById: { tier: 'niche', verify: () => withTwin(async (t) => {
|
|
303
|
+
await t.start('ractid');
|
|
304
|
+
await t.complete(await t.pollWorkflow(), [activityCommand('a1')]);
|
|
305
|
+
await t.pollActivity();
|
|
306
|
+
await t.call('SignalWorkflowExecution', { workflowExecution: { workflowId: 'ractid' }, signalName: 's' });
|
|
307
|
+
await t.complete(await t.pollWorkflow(), [command('COMMAND_TYPE_REQUEST_CANCEL_ACTIVITY_TASK', { scheduledEventId: 5 })]);
|
|
308
|
+
await t.call('RespondActivityTaskCanceledById', { workflowId: 'ractid', activityId: 'a1' });
|
|
309
|
+
return types(await t.events('ractid')).includes('ACTIVITY_TASK_CANCELED');
|
|
310
|
+
}) },
|
|
311
|
+
RecordActivityTaskHeartbeat: { tier: 'core', verify: () => withTwin(async (t) => {
|
|
312
|
+
await afterFirstTask(t, 'hb', [activityCommand('a1', { heartbeatTimeout: { seconds: 5 } })]);
|
|
313
|
+
const task = await t.pollActivity();
|
|
314
|
+
await t.advance(4000);
|
|
315
|
+
const beat = await t.call('RecordActivityTaskHeartbeat', { taskToken: task.taskToken, details: { payloads: [jsonPayload('halfway')] } });
|
|
316
|
+
await t.advance(4000); // 8 s after start, 4 s after the heartbeat: not timed out
|
|
317
|
+
const described = await t.describe('hb');
|
|
318
|
+
return beat.cancelRequested === false && described.pendingActivities?.[0]?.state === 'PENDING_ACTIVITY_STATE_STARTED'
|
|
319
|
+
&& payloadValue(described.pendingActivities[0].heartbeatDetails?.payloads?.[0]) === 'halfway';
|
|
320
|
+
}) },
|
|
321
|
+
RecordActivityTaskHeartbeatById: { tier: 'niche', verify: () => withTwin(async (t) => {
|
|
322
|
+
await afterFirstTask(t, 'hbid', [activityCommand('a1')]);
|
|
323
|
+
await t.pollActivity();
|
|
324
|
+
await t.call('RecordActivityTaskHeartbeatById', { workflowId: 'hbid', activityId: 'a1', details: { payloads: [jsonPayload(7)] } });
|
|
325
|
+
return payloadValue((await t.describe('hbid')).pendingActivities?.[0]?.heartbeatDetails?.payloads?.[0]) === 7;
|
|
326
|
+
}) },
|
|
327
|
+
QueryWorkflow: { tier: 'common', verify: () => withTwin(async (t) => {
|
|
328
|
+
await afterFirstTask(t, 'qw', []);
|
|
329
|
+
const answer = t.call('QueryWorkflow', { execution: { workflowId: 'qw' }, query: { queryType: 'stage' } }, 3000);
|
|
330
|
+
const task = await t.pollWorkflow();
|
|
331
|
+
await t.call('RespondQueryTaskCompleted', { taskToken: task.taskToken, completedType: 'QUERY_RESULT_TYPE_ANSWERED', queryResult: { payloads: [jsonPayload('waiting')] } });
|
|
332
|
+
const result = await answer;
|
|
333
|
+
return task.query?.queryType === 'stage' && payloadValue(result.queryResult?.payloads?.[0]) === 'waiting';
|
|
334
|
+
}) },
|
|
335
|
+
RespondQueryTaskCompleted: { tier: 'common', verify: () => withTwin(async (t) => {
|
|
336
|
+
await afterFirstTask(t, 'rq', []);
|
|
337
|
+
const answer = t.call('QueryWorkflow', { execution: { workflowId: 'rq' }, query: { queryType: 'nope' } }, 3000);
|
|
338
|
+
const task = await t.pollWorkflow();
|
|
339
|
+
await t.call('RespondQueryTaskCompleted', { taskToken: task.taskToken, completedType: 'QUERY_RESULT_TYPE_FAILED', errorMessage: 'unknown query type nope' });
|
|
340
|
+
const result = await answer;
|
|
341
|
+
return result.grpcError === 3 && String(result.message).includes('unknown query type nope');
|
|
342
|
+
}) },
|
|
343
|
+
ResetStickyTaskQueue: { tier: 'niche', verify: () => withTwin(async (t) => {
|
|
344
|
+
await t.start('sticky');
|
|
345
|
+
const ok = await t.call('ResetStickyTaskQueue', { execution: { workflowId: 'sticky' } });
|
|
346
|
+
const bad = await t.call('ResetStickyTaskQueue', { namespace: 'nope', execution: { workflowId: 'sticky' } });
|
|
347
|
+
const missing = await t.call('ResetStickyTaskQueue', { execution: { workflowId: 'no-such-run' } });
|
|
348
|
+
return ok.grpcError === undefined && bad.grpcError === 5 && missing.grpcError === 5;
|
|
349
|
+
}) },
|
|
350
|
+
ShutdownWorker: { tier: 'core', verify: () => withTwin(async (t) => {
|
|
351
|
+
// a sticky poll is held open (this server never routes to a sticky queue) until the worker that
|
|
352
|
+
// owns the queue says it is shutting down, which releases it at once
|
|
353
|
+
const poll = t.call('PollWorkflowTaskQueue', { taskQueue: { name: 'sticky-1', kind: 'TASK_QUEUE_KIND_STICKY' } }, 5000);
|
|
354
|
+
const held = await Promise.race([poll.then(() => 'answered'), new Promise((resolve) => setTimeout(() => resolve('held'), 300))]);
|
|
355
|
+
const began = Date.now();
|
|
356
|
+
await t.call('ShutdownWorker', { stickyTaskQueue: 'sticky-1', identity: 'w' });
|
|
357
|
+
const answer = await poll;
|
|
358
|
+
return held === 'held' && answer.taskToken === undefined && answer.grpcError === undefined && Date.now() - began < 2000;
|
|
359
|
+
}) },
|
|
360
|
+
ListSearchAttributes: { tier: 'core', verify: () => withTwin(async (t) => {
|
|
361
|
+
await t.call('AddSearchAttributes', { searchAttributes: { postId: 'INDEXED_VALUE_TYPE_TEXT' } });
|
|
362
|
+
const listed = await t.call('ListSearchAttributes');
|
|
363
|
+
return listed.customAttributes?.postId === 'INDEXED_VALUE_TYPE_TEXT' && listed.systemAttributes?.WorkflowId === 'INDEXED_VALUE_TYPE_KEYWORD';
|
|
364
|
+
}) },
|
|
365
|
+
AddSearchAttributes: { tier: 'core', verify: () => withTwin(async (t) => {
|
|
366
|
+
const before = await t.start('sa-before', { searchAttributes: { indexedFields: { orgId: jsonPayload('o', { type: 'Text' }) } } });
|
|
367
|
+
await t.call('AddSearchAttributes', { searchAttributes: { orgId: 'INDEXED_VALUE_TYPE_TEXT' } });
|
|
368
|
+
const after = await t.start('sa-after', { searchAttributes: { indexedFields: { orgId: jsonPayload('o', { type: 'Text' }) } } });
|
|
369
|
+
const clash = await t.call('AddSearchAttributes', { searchAttributes: { orgId: 'INDEXED_VALUE_TYPE_INT' } });
|
|
370
|
+
return before === undefined && UUID.test(after) && clash.grpcError === 6;
|
|
371
|
+
}) },
|
|
372
|
+
RemoveSearchAttributes: { tier: 'niche', verify: () => withTwin(async (t) => {
|
|
373
|
+
await t.call('AddSearchAttributes', { searchAttributes: { gone: 'INDEXED_VALUE_TYPE_KEYWORD' } });
|
|
374
|
+
await t.call('RemoveSearchAttributes', { searchAttributes: ['gone'] });
|
|
375
|
+
const again = await t.call('RemoveSearchAttributes', { searchAttributes: ['gone'] });
|
|
376
|
+
return (await t.call('ListSearchAttributes')).customAttributes?.gone === undefined && again.grpcError === 5;
|
|
377
|
+
}) },
|
|
378
|
+
Check: { tier: 'common', verify: () => withTwin(async (t) => (await t.call('Check', { service: '' })).status === 'SERVING'
|
|
379
|
+
&& (await t.call('Check', { service: 'temporal.api.workflowservice.v1.WorkflowService' })).status === 'SERVING'
|
|
380
|
+
&& (await t.call('Check', { service: 'no.such.Service' })).grpcError === 5) },
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
// ---- the command verifies (the history service's surface) ----------------------------------------
|
|
384
|
+
|
|
385
|
+
const COMMAND_VERIFY: Record<string, { tier: CapabilitySpec['tier']; verify: () => Promise<boolean> }> = {
|
|
386
|
+
COMMAND_TYPE_SCHEDULE_ACTIVITY_TASK: { tier: 'core', verify: () => withTwin(async (t) => {
|
|
387
|
+
const { events } = await afterFirstTask(t, 'sat', [activityCommand('a1', { scheduleToCloseTimeout: { seconds: 30 }, startToCloseTimeout: { seconds: 60 } })]);
|
|
388
|
+
const scheduled = events.at(-1)?.activityTaskScheduledEventAttributes;
|
|
389
|
+
const dup = await t.call('SignalWorkflowExecution', { workflowExecution: { workflowId: 'sat' }, signalName: 's' });
|
|
390
|
+
const task = await t.pollWorkflow();
|
|
391
|
+
await t.complete(task, [activityCommand('a1')]);
|
|
392
|
+
const failed = (await t.events('sat')).find((e) => e.eventType === 'EVENT_TYPE_WORKFLOW_TASK_FAILED');
|
|
393
|
+
// the server's defaults: start-to-close capped at schedule-to-close, the default retry policy filled
|
|
394
|
+
return scheduled?.activityId === 'a1' && scheduled.startToCloseTimeout?.seconds === 30 && scheduled.retryPolicy?.backoffCoefficient === 2
|
|
395
|
+
&& scheduled.retryPolicy?.initialInterval?.seconds === 1 && dup.grpcError === undefined
|
|
396
|
+
&& failed?.workflowTaskFailedEventAttributes?.cause === 'WORKFLOW_TASK_FAILED_CAUSE_SCHEDULE_ACTIVITY_DUPLICATE_ID';
|
|
397
|
+
}) },
|
|
398
|
+
COMMAND_TYPE_REQUEST_CANCEL_ACTIVITY_TASK: { tier: 'common', verify: () => withTwin(async (t) => {
|
|
399
|
+
await t.start('rcat');
|
|
400
|
+
await t.complete(await t.pollWorkflow(), [activityCommand('a1')]);
|
|
401
|
+
await t.call('SignalWorkflowExecution', { workflowExecution: { workflowId: 'rcat' }, signalName: 's' });
|
|
402
|
+
await t.complete(await t.pollWorkflow(), [command('COMMAND_TYPE_REQUEST_CANCEL_ACTIVITY_TASK', { scheduledEventId: 5 })]);
|
|
403
|
+
const events = types(await t.events('rcat'));
|
|
404
|
+
// never started: canceled at once, and the workflow hears of it
|
|
405
|
+
return events.includes('ACTIVITY_TASK_CANCEL_REQUESTED') && events.includes('ACTIVITY_TASK_CANCELED') && events.at(-1) === 'WORKFLOW_TASK_SCHEDULED';
|
|
406
|
+
}) },
|
|
407
|
+
COMMAND_TYPE_START_TIMER: { tier: 'core', verify: () => withTwin(async (t) => {
|
|
408
|
+
await afterFirstTask(t, 'timer', [command('COMMAND_TYPE_START_TIMER', { timerId: 't1', startToFireTimeout: { seconds: 3600 } })]);
|
|
409
|
+
await t.advance(3_599_000);
|
|
410
|
+
const early = types(await t.events('timer'));
|
|
411
|
+
await t.advance(1000);
|
|
412
|
+
const events = await t.events('timer');
|
|
413
|
+
const fired = events.find((e) => e.eventType === 'EVENT_TYPE_TIMER_FIRED');
|
|
414
|
+
return !early.includes('TIMER_FIRED') && fired?.timerFiredEventAttributes?.timerId === 't1'
|
|
415
|
+
&& fired.eventTime?.seconds === Date.parse(T0) / 1000 + 3600 && types(events).at(-1) === 'WORKFLOW_TASK_SCHEDULED';
|
|
416
|
+
}) },
|
|
417
|
+
COMMAND_TYPE_CANCEL_TIMER: { tier: 'common', verify: () => withTwin(async (t) => {
|
|
418
|
+
await afterFirstTask(t, 'ct', [command('COMMAND_TYPE_START_TIMER', { timerId: 't1', startToFireTimeout: { seconds: 10 } })]);
|
|
419
|
+
await t.call('SignalWorkflowExecution', { workflowExecution: { workflowId: 'ct' }, signalName: 's' });
|
|
420
|
+
await t.complete(await t.pollWorkflow(), [command('COMMAND_TYPE_CANCEL_TIMER', { timerId: 't1' })]);
|
|
421
|
+
await t.advance(20_000);
|
|
422
|
+
const events = types(await t.events('ct'));
|
|
423
|
+
return events.includes('TIMER_CANCELED') && !events.includes('TIMER_FIRED');
|
|
424
|
+
}) },
|
|
425
|
+
COMMAND_TYPE_COMPLETE_WORKFLOW_EXECUTION: { tier: 'core', verify: () => withTwin(async (t) => {
|
|
426
|
+
const { events } = await afterFirstTask(t, 'cw', [completeCommand({ ok: true })]);
|
|
427
|
+
const last = events.at(-1)!;
|
|
428
|
+
return last.eventType === 'EVENT_TYPE_WORKFLOW_EXECUTION_COMPLETED' && (payloadValue(last.workflowExecutionCompletedEventAttributes?.result?.payloads?.[0]) as Obj)?.ok === true
|
|
429
|
+
&& (await t.describe('cw')).workflowExecutionInfo?.status === 'WORKFLOW_EXECUTION_STATUS_COMPLETED';
|
|
430
|
+
}) },
|
|
431
|
+
COMMAND_TYPE_FAIL_WORKFLOW_EXECUTION: { tier: 'core', verify: () => withTwin(async (t) => {
|
|
432
|
+
const { events } = await afterFirstTask(t, 'fw', [command('COMMAND_TYPE_FAIL_WORKFLOW_EXECUTION', { failure: { message: 'bad' } })]);
|
|
433
|
+
const last = events.at(-1)!;
|
|
434
|
+
return last.eventType === 'EVENT_TYPE_WORKFLOW_EXECUTION_FAILED' && last.workflowExecutionFailedEventAttributes?.failure?.message === 'bad'
|
|
435
|
+
&& last.workflowExecutionFailedEventAttributes.retryState === 'RETRY_STATE_RETRY_POLICY_NOT_SET';
|
|
436
|
+
}) },
|
|
437
|
+
COMMAND_TYPE_CANCEL_WORKFLOW_EXECUTION: { tier: 'common', verify: () => withTwin(async (t) => {
|
|
438
|
+
const { events } = await afterFirstTask(t, 'cwe', [command('COMMAND_TYPE_CANCEL_WORKFLOW_EXECUTION', { details: { payloads: [jsonPayload('d')] } })]);
|
|
439
|
+
return events.at(-1)?.eventType === 'EVENT_TYPE_WORKFLOW_EXECUTION_CANCELED' && payloadValue(events.at(-1)!.workflowExecutionCanceledEventAttributes?.details?.payloads?.[0]) === 'd';
|
|
440
|
+
}) },
|
|
441
|
+
COMMAND_TYPE_CONTINUE_AS_NEW_WORKFLOW_EXECUTION: { tier: 'core', verify: () => withTwin(async (t) => {
|
|
442
|
+
const { runId, events } = await afterFirstTask(t, 'can', [command('COMMAND_TYPE_CONTINUE_AS_NEW_WORKFLOW_EXECUTION', { workflowType: { name: 'wf' }, taskQueue: { name: 'q' }, input: { payloads: [jsonPayload(2)] } })]);
|
|
443
|
+
const continued = events.at(-1)!.workflowExecutionContinuedAsNewEventAttributes;
|
|
444
|
+
const next = await t.describe('can');
|
|
445
|
+
const nextEvents = await t.events('can');
|
|
446
|
+
const started = nextEvents[0]!.workflowExecutionStartedEventAttributes;
|
|
447
|
+
return UUID.test(continued?.newExecutionRunId) && next.workflowExecutionInfo?.execution?.runId === continued.newExecutionRunId
|
|
448
|
+
&& next.workflowExecutionInfo.firstRunId === runId && started?.continuedExecutionRunId === runId && payloadValue(started.input?.payloads?.[0]) === 2
|
|
449
|
+
&& (await t.describe('can', runId)).workflowExecutionInfo?.status === 'WORKFLOW_EXECUTION_STATUS_CONTINUED_AS_NEW';
|
|
450
|
+
}) },
|
|
451
|
+
COMMAND_TYPE_RECORD_MARKER: { tier: 'core', verify: () => withTwin(async (t) => {
|
|
452
|
+
const { events } = await afterFirstTask(t, 'rm', [command('COMMAND_TYPE_RECORD_MARKER', { markerName: 'core_patch', details: { patch_data: { payloads: [jsonPayload({ id: 'p1' })] } } })]);
|
|
453
|
+
const marker = events.at(-1)!.markerRecordedEventAttributes;
|
|
454
|
+
return marker?.markerName === 'core_patch' && (payloadValue(marker.details?.patch_data?.payloads?.[0]) as Obj)?.id === 'p1';
|
|
455
|
+
}) },
|
|
456
|
+
COMMAND_TYPE_UPSERT_WORKFLOW_SEARCH_ATTRIBUTES: { tier: 'common', verify: () => withTwin(async (t) => {
|
|
457
|
+
await t.call('AddSearchAttributes', { searchAttributes: { stage: 'INDEXED_VALUE_TYPE_KEYWORD' } });
|
|
458
|
+
await afterFirstTask(t, 'usa', [command('COMMAND_TYPE_UPSERT_WORKFLOW_SEARCH_ATTRIBUTES', { searchAttributes: { indexedFields: { stage: jsonPayload('two'), TemporalChangeVersion: jsonPayload(['p-1']) } } })]);
|
|
459
|
+
const listed = await t.call('ListWorkflowExecutions', { query: 'stage = "two"' });
|
|
460
|
+
return listed.executions?.[0]?.execution?.workflowId === 'usa';
|
|
461
|
+
}) },
|
|
462
|
+
COMMAND_TYPE_MODIFY_WORKFLOW_PROPERTIES: { tier: 'niche', verify: () => withTwin(async (t) => {
|
|
463
|
+
await afterFirstTask(t, 'mwp', [command('COMMAND_TYPE_MODIFY_WORKFLOW_PROPERTIES', { upsertedMemo: { fields: { note: jsonPayload('n') } } })]);
|
|
464
|
+
return payloadValue((await t.describe('mwp')).workflowExecutionInfo?.memo?.fields?.note) === 'n';
|
|
465
|
+
}) },
|
|
466
|
+
COMMAND_TYPE_START_CHILD_WORKFLOW_EXECUTION: { tier: 'core', verify: () => withTwin(async (t) => {
|
|
467
|
+
const { runId } = await afterFirstTask(t, 'parent', [command('COMMAND_TYPE_START_CHILD_WORKFLOW_EXECUTION', { workflowId: 'child', workflowType: { name: 'kid' }, taskQueue: { name: 'q' }, parentClosePolicy: 'PARENT_CLOSE_POLICY_ABANDON' })]);
|
|
468
|
+
const child = await t.describe('child');
|
|
469
|
+
const parentEvents = types(await t.events('parent'));
|
|
470
|
+
// the parent's next task, then the child's; the child completes and the parent hears it
|
|
471
|
+
const next = await t.pollWorkflow();
|
|
472
|
+
await t.complete(next, []);
|
|
473
|
+
const childTask = await t.pollWorkflow();
|
|
474
|
+
await t.complete(childTask, [completeCommand('grown')]);
|
|
475
|
+
const closed = (await t.events('parent')).find((e) => e.eventType === 'EVENT_TYPE_CHILD_WORKFLOW_EXECUTION_COMPLETED');
|
|
476
|
+
return child.workflowExecutionInfo?.parentExecution?.runId === runId && parentEvents.includes('CHILD_WORKFLOW_EXECUTION_STARTED')
|
|
477
|
+
&& childTask.workflowType?.name === 'kid' && payloadValue(closed?.childWorkflowExecutionCompletedEventAttributes?.result?.payloads?.[0]) === 'grown';
|
|
478
|
+
}) },
|
|
479
|
+
COMMAND_TYPE_SIGNAL_EXTERNAL_WORKFLOW_EXECUTION: { tier: 'common', verify: () => withTwin(async (t) => {
|
|
480
|
+
await t.start('target', { taskQueue: { name: 'elsewhere' } });
|
|
481
|
+
await afterFirstTask(t, 'sender', [command('COMMAND_TYPE_SIGNAL_EXTERNAL_WORKFLOW_EXECUTION', { execution: { workflowId: 'target' }, signalName: 'hey', input: { payloads: [jsonPayload('x')] } })]);
|
|
482
|
+
const target = (await t.events('target')).find((e) => e.eventType === 'EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED');
|
|
483
|
+
return target?.workflowExecutionSignaledEventAttributes?.signalName === 'hey' && types(await t.events('sender')).includes('EXTERNAL_WORKFLOW_EXECUTION_SIGNALED');
|
|
484
|
+
}) },
|
|
485
|
+
COMMAND_TYPE_REQUEST_CANCEL_EXTERNAL_WORKFLOW_EXECUTION: { tier: 'niche', verify: () => withTwin(async (t) => {
|
|
486
|
+
await t.start('victim', { taskQueue: { name: 'elsewhere' } });
|
|
487
|
+
await afterFirstTask(t, 'canceler', [
|
|
488
|
+
command('COMMAND_TYPE_REQUEST_CANCEL_EXTERNAL_WORKFLOW_EXECUTION', { workflowId: 'missing' }),
|
|
489
|
+
command('COMMAND_TYPE_REQUEST_CANCEL_EXTERNAL_WORKFLOW_EXECUTION', { workflowId: 'victim' }),
|
|
490
|
+
]);
|
|
491
|
+
const canceler = await t.events('canceler');
|
|
492
|
+
const failed = canceler.find((e) => e.eventType === 'EVENT_TYPE_REQUEST_CANCEL_EXTERNAL_WORKFLOW_EXECUTION_FAILED');
|
|
493
|
+
const requested = canceler.find((e) => e.eventType === 'EVENT_TYPE_EXTERNAL_WORKFLOW_EXECUTION_CANCEL_REQUESTED');
|
|
494
|
+
const victim = (await t.events('victim')).find((e) => e.eventType === 'EVENT_TYPE_WORKFLOW_EXECUTION_CANCEL_REQUESTED');
|
|
495
|
+
return failed?.requestCancelExternalWorkflowExecutionFailedEventAttributes?.cause === 'CANCEL_EXTERNAL_WORKFLOW_EXECUTION_FAILED_CAUSE_EXTERNAL_WORKFLOW_EXECUTION_NOT_FOUND'
|
|
496
|
+
&& requested?.externalWorkflowExecutionCancelRequestedEventAttributes?.workflowExecution?.workflowId === 'victim'
|
|
497
|
+
&& victim?.workflowExecutionCancelRequestedEventAttributes?.externalWorkflowExecution?.workflowId === 'canceler';
|
|
498
|
+
}) },
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
// ---- behaviours no one RPC owns --------------------------------------------------------------------
|
|
502
|
+
|
|
503
|
+
const HISTORY: CapabilitySpec[] = [
|
|
504
|
+
done('temporal.history.activity_retry', 'history', 'a failed activity attempt retries with the retry policy\'s backoff on the World clock, invisibly to the history until it closes', 'core', () => withTwin(async (t) => {
|
|
505
|
+
await afterFirstTask(t, 'retry', [activityCommand('a1', { retryPolicy: { initialInterval: { seconds: 2 }, backoffCoefficient: 1, maximumAttempts: 3 } })]);
|
|
506
|
+
const first = await t.pollActivity();
|
|
507
|
+
await t.call('RespondActivityTaskFailed', { taskToken: first.taskToken, failure: { message: 'try again' } });
|
|
508
|
+
const tooSoon = await t.pollActivity();
|
|
509
|
+
await t.advance(2000);
|
|
510
|
+
const second = await t.pollActivity();
|
|
511
|
+
await t.call('RespondActivityTaskCompleted', { taskToken: second.taskToken, result: { payloads: [jsonPayload('ok')] } });
|
|
512
|
+
const events = await t.events('retry');
|
|
513
|
+
const started = events.find((e) => e.eventType === 'EVENT_TYPE_ACTIVITY_TASK_STARTED')!.activityTaskStartedEventAttributes;
|
|
514
|
+
return tooSoon.taskToken === undefined && second.attempt === 2 && started?.attempt === 2 && started.lastFailure?.message === 'try again'
|
|
515
|
+
&& types(events).filter((e) => e.startsWith('ACTIVITY_TASK_')).join() === 'ACTIVITY_TASK_SCHEDULED,ACTIVITY_TASK_STARTED,ACTIVITY_TASK_COMPLETED';
|
|
516
|
+
})),
|
|
517
|
+
done('temporal.history.activity_timeouts', 'history', 'start-to-close and heartbeat timeouts on the World clock, the last heartbeat details carried in the timeout failure', 'core', () => withTwin(async (t) => {
|
|
518
|
+
await afterFirstTask(t, 'hbt', [activityCommand('a1', { heartbeatTimeout: { seconds: 5 }, retryPolicy: { maximumAttempts: 1 } })]);
|
|
519
|
+
const task = await t.pollActivity();
|
|
520
|
+
await t.call('RecordActivityTaskHeartbeat', { taskToken: task.taskToken, details: { payloads: [jsonPayload('x: publishing')] } });
|
|
521
|
+
await t.advance(5000);
|
|
522
|
+
const timedOut = (await t.events('hbt')).find((e) => e.eventType === 'EVENT_TYPE_ACTIVITY_TASK_TIMED_OUT')?.activityTaskTimedOutEventAttributes;
|
|
523
|
+
return timedOut?.failure?.timeoutFailureInfo?.timeoutType === 'TIMEOUT_TYPE_HEARTBEAT' && payloadValue(timedOut.failure.timeoutFailureInfo.lastHeartbeatDetails?.payloads?.[0]) === 'x: publishing'
|
|
524
|
+
&& timedOut.retryState === 'RETRY_STATE_MAXIMUM_ATTEMPTS_REACHED';
|
|
525
|
+
})),
|
|
526
|
+
done('temporal.history.buffered_events', 'history', 'events arriving during a workflow task are appended after it; a completion that would close over them is refused UNHANDLED_COMMAND', 'core', () => withTwin(async (t) => {
|
|
527
|
+
await t.start('buf');
|
|
528
|
+
const task = await t.pollWorkflow();
|
|
529
|
+
await t.call('SignalWorkflowExecution', { workflowExecution: { workflowId: 'buf' }, signalName: 'late' });
|
|
530
|
+
await t.complete(task, [completeCommand('too early')]);
|
|
531
|
+
const events = types(await t.events('buf'));
|
|
532
|
+
return events.join() === 'WORKFLOW_EXECUTION_STARTED,WORKFLOW_TASK_SCHEDULED,WORKFLOW_TASK_STARTED,WORKFLOW_TASK_FAILED,WORKFLOW_EXECUTION_SIGNALED,WORKFLOW_TASK_SCHEDULED'
|
|
533
|
+
&& (await t.describe('buf')).workflowExecutionInfo?.status === 'WORKFLOW_EXECUTION_STATUS_RUNNING';
|
|
534
|
+
})),
|
|
535
|
+
done('temporal.history.workflow_task_timeout', 'history', 'a workflow task not completed within its start-to-close timeout times out and is scheduled again', 'common', () => withTwin(async (t) => {
|
|
536
|
+
await t.start('wtt', { workflowTaskTimeout: { seconds: 5 } });
|
|
537
|
+
const task = await t.pollWorkflow();
|
|
538
|
+
await t.advance(5000);
|
|
539
|
+
const late = await t.complete(task, []);
|
|
540
|
+
const retry = await t.pollWorkflow();
|
|
541
|
+
return late.grpcError === 5 && retry.attempt === 2 && types(await t.events('wtt')).includes('WORKFLOW_TASK_TIMED_OUT');
|
|
542
|
+
})),
|
|
543
|
+
done('temporal.history.workflow_timeouts', 'history', 'a run timeout closes the run TimedOut on the World clock', 'common', () => withTwin(async (t) => {
|
|
544
|
+
await afterFirstTask(t, 'rto', [], { workflowRunTimeout: { seconds: 60 } });
|
|
545
|
+
await t.advance(60_000);
|
|
546
|
+
return (await t.describe('rto')).workflowExecutionInfo?.status === 'WORKFLOW_EXECUTION_STATUS_TIMED_OUT';
|
|
547
|
+
})),
|
|
548
|
+
done('temporal.history.workflow_retry', 'history', 'a workflow with a retry policy that fails is retried as a new run of the same chain after the backoff', 'common', () => withTwin(async (t) => {
|
|
549
|
+
const { runId } = await afterFirstTask(t, 'wr', [command('COMMAND_TYPE_FAIL_WORKFLOW_EXECUTION', { failure: { message: 'first' } })], { retryPolicy: { initialInterval: { seconds: 10 }, maximumAttempts: 2 } });
|
|
550
|
+
const failed = (await t.events('wr', runId)).at(-1)!.workflowExecutionFailedEventAttributes;
|
|
551
|
+
await t.advance(10_000);
|
|
552
|
+
const task = await t.pollWorkflow();
|
|
553
|
+
return failed?.retryState === 'RETRY_STATE_IN_PROGRESS' && UUID.test(failed.newExecutionRunId) && task.workflowExecution?.runId === failed.newExecutionRunId && task.workflowExecution.workflowId === 'wr';
|
|
554
|
+
})),
|
|
555
|
+
done('temporal.history.parent_close_policy', 'history', 'closing a parent terminates its TERMINATE children and leaves ABANDON children running', 'common', () => withTwin(async (t) => {
|
|
556
|
+
await afterFirstTask(t, 'pcp', [
|
|
557
|
+
command('COMMAND_TYPE_START_CHILD_WORKFLOW_EXECUTION', { workflowId: 'kept', workflowType: { name: 'kid' }, taskQueue: { name: 'other' }, parentClosePolicy: 'PARENT_CLOSE_POLICY_ABANDON' }),
|
|
558
|
+
command('COMMAND_TYPE_START_CHILD_WORKFLOW_EXECUTION', { workflowId: 'ended', workflowType: { name: 'kid' }, taskQueue: { name: 'other' } }),
|
|
559
|
+
]);
|
|
560
|
+
await t.call('TerminateWorkflowExecution', { workflowExecution: { workflowId: 'pcp' } });
|
|
561
|
+
return (await t.describe('kept')).workflowExecutionInfo?.status === 'WORKFLOW_EXECUTION_STATUS_RUNNING'
|
|
562
|
+
&& (await t.describe('ended')).workflowExecutionInfo?.status === 'WORKFLOW_EXECUTION_STATUS_TERMINATED';
|
|
563
|
+
})),
|
|
564
|
+
done('temporal.history.start_delay', 'history', 'a start delay holds the first workflow task until the World clock reaches it', 'niche', () => withTwin(async (t) => {
|
|
565
|
+
await t.start('sd', { workflowStartDelay: { seconds: 30 } });
|
|
566
|
+
const early = await t.pollWorkflow();
|
|
567
|
+
await t.advance(30_000);
|
|
568
|
+
const task = await t.pollWorkflow();
|
|
569
|
+
return early.taskToken === undefined && task.startedEventId === 3;
|
|
570
|
+
})),
|
|
571
|
+
done('temporal.history.state_in_the_world', 'history', 'a run is a World subject (workflow-execution) whose history chunks live in the World\'s blob store', 'core', () => withTwin(async (t) => {
|
|
572
|
+
const runId = await t.start('world');
|
|
573
|
+
const subject = twinResources('temporal', t.root).find((r) => r.type === 'workflow-execution' && r.id === runId) as Obj | undefined;
|
|
574
|
+
const reread = new TemporalEngine({ root: t.root });
|
|
575
|
+
const events = await reread.history(reread.run(runId)!);
|
|
576
|
+
return subject?.status === 'WORKFLOW_EXECUTION_STATUS_RUNNING' && Array.isArray(subject._state?.history) && subject._state.history.length === 1 && events.length === 2;
|
|
577
|
+
})),
|
|
578
|
+
todo('temporal.history.sticky_queues', 'history', 'sticky task queues (tasks routed to the worker that holds the run, with partial history) — every task goes to the normal queue with its full history, which the SDKs accept', 'niche'),
|
|
579
|
+
todo('temporal.history.cron', 'history', 'cron schedules on start, child and continue-as-new (refused UNIMPLEMENTED / FEATURE_DISABLED by name)', 'niche'),
|
|
580
|
+
todo('temporal.history.updates', 'history', 'workflow updates (protocol messages in workflow tasks) — UpdateWorkflowExecution answers UNIMPLEMENTED', 'common'),
|
|
581
|
+
todo('temporal.history.eager_execution', 'history', 'eager workflow start and eager activity dispatch (the SDKs fall back to polling when a server declines)', 'niche'),
|
|
582
|
+
todo('temporal.history.completion_callbacks', 'history', 'completion callbacks and links (Nexus-backed), refused UNIMPLEMENTED on start', 'niche'),
|
|
583
|
+
todo('temporal.history.worker_versioning', 'history', 'worker deployments, build ids and versioning overrides', 'niche'),
|
|
584
|
+
todo('temporal.history.transient_workflow_task_attempts', 'history', 'workflow-task attempts after the first kept transient (the server writes them only once a task completes); every failed attempt is written, so a crash-looping workflow grows its history', 'common'),
|
|
585
|
+
todo('temporal.history.retention', 'history', 'the namespace retention period: closed runs are kept until the World is purged, never deleted after retention', 'niche'),
|
|
586
|
+
todo('temporal.history.search_attribute_types', 'history', 'search-attribute values checked against their declared type (only names are checked)', 'niche'),
|
|
587
|
+
todo('temporal.history.visibility_literal_types', 'history', 'list-filter literals checked against the attribute type (a duration literal such as "10m" on ExecutionDuration, Datetime custom attributes compared as instants, a number against a Keyword) — such a filter matches nothing rather than being read or refused', 'niche'),
|
|
588
|
+
todo('temporal.history.text_search', 'history', 'Text search attributes are matched by equality, not tokenized full-text search as Elasticsearch/SQL visibility does', 'niche'),
|
|
589
|
+
];
|
|
590
|
+
|
|
591
|
+
const WIRE: CapabilitySpec[] = [
|
|
592
|
+
todo('temporal.wire.tls', 'wire', 'TLS and mTLS on the frontend (an SDK configured with an api key turns TLS on; this loopback server speaks h2c only)', 'common'),
|
|
593
|
+
todo('temporal.wire.compression', 'wire', 'grpc-encoding (gzip) message compression — a compressed message is refused UNIMPLEMENTED', 'niche'),
|
|
594
|
+
todo('temporal.wire.status_details', 'wire', 'google.rpc.Status details (grpc-status-details-bin: WorkflowExecutionAlreadyStartedFailure, NotFoundFailure …) on errors', 'common'),
|
|
595
|
+
done('temporal.wire.http_api', 'wire', 'the frontend\'s HTTP API: the protos\' google.api.http routes for every served method, proto3 JSON in and out, gateway error bodies', 'niche', () => withTwin(async (t) => {
|
|
596
|
+
const fetch = createTemporalTwinFetch({ root: t.root, engine: t.engine });
|
|
597
|
+
const call = async (method: string, path: string, body?: unknown) => {
|
|
598
|
+
const response = await fetch(new Request(`http://twin.local${path}`, { method, ...(body === undefined ? {} : { body: JSON.stringify(body) }) }));
|
|
599
|
+
return { status: response.status, body: await response.json() as Obj };
|
|
600
|
+
};
|
|
601
|
+
const started = await call('POST', '/api/v1/namespaces/default/workflows/via-http', { workflowType: { name: 'wf' }, taskQueue: { name: 'q' }, requestId: 'h1' });
|
|
602
|
+
const described = await call('GET', '/namespaces/default/workflows/via-http');
|
|
603
|
+
const missing = await call('GET', '/api/v1/namespaces/default/workflows/none');
|
|
604
|
+
return UUID.test(started.body.runId) && described.body.workflowExecutionInfo?.execution?.runId === started.body.runId
|
|
605
|
+
&& described.body.workflowExecutionInfo.historyLength === '2' && described.body.workflowExecutionInfo.startTime === T0
|
|
606
|
+
&& missing.status === 404 && missing.body.code === 5;
|
|
607
|
+
})),
|
|
608
|
+
todo('temporal.wire.http_payload_shorthand', 'wire', 'the HTTP API\'s payload shorthand (a bare JSON value for a json/plain payload); payloads travel in their full { metadata, data } form', 'niche'),
|
|
609
|
+
];
|
|
610
|
+
|
|
611
|
+
const CONNECTOR: CapabilitySpec[] = [
|
|
612
|
+
done('temporal.connector.pull', 'connector', 'pull a real frontend\'s namespaces and custom search attributes into the World', 'common', () => withTwin(async (t) => {
|
|
613
|
+
const execute = async (_service: string, method: string, request: Obj): Promise<Obj> => {
|
|
614
|
+
if (method === 'ListNamespaces') return { namespaces: [{ namespaceInfo: { name: 'prod', state: 'NAMESPACE_STATE_REGISTERED', id: 'n-1' }, config: {} }] };
|
|
615
|
+
if (method === 'ListSearchAttributes' && request.namespace === 'prod') return { customAttributes: { postId: 'INDEXED_VALUE_TYPE_TEXT' } };
|
|
616
|
+
throw new Error(`unexpected ${method}`);
|
|
617
|
+
};
|
|
618
|
+
const { pulled } = await syncTemporalFromReal(execute, { root: t.root, occurredAt: T0 });
|
|
619
|
+
return pulled === 1 && (await t.call('DescribeNamespace', { namespace: 'prod' })).namespaceInfo?.id === 'n-1'
|
|
620
|
+
&& (await t.call('ListSearchAttributes', { namespace: 'prod' })).customAttributes?.postId === 'INDEXED_VALUE_TYPE_TEXT';
|
|
621
|
+
})),
|
|
622
|
+
todo('temporal.connector.pull_runs', 'connector', 'pull a real cluster\'s workflow runs (a run is its history, advanced by workers; not modeled)', 'niche'),
|
|
623
|
+
todo('temporal.connector.push', 'connector', 'perform a World\'s local runs on a real cluster (refused by name: pushPendingTemporalActions)', 'niche'),
|
|
624
|
+
];
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
const TIER_OF: Record<string, CapabilitySpec['tier']> = {
|
|
628
|
+
CreateSchedule: 'common', DescribeSchedule: 'common', ListSchedules: 'common', DeleteSchedule: 'common', UpdateSchedule: 'common', PatchSchedule: 'common',
|
|
629
|
+
UpdateWorkflowExecution: 'common', PollWorkflowExecutionUpdate: 'common', ExecuteMultiOperation: 'common', DescribeTaskQueue: 'common',
|
|
630
|
+
ResetWorkflowExecution: 'common', GetWorkflowExecutionHistoryReverse: 'common', ScanWorkflowExecutions: 'niche', RecordWorkerHeartbeat: 'common',
|
|
631
|
+
ListOpenWorkflowExecutions: 'niche', ListClosedWorkflowExecutions: 'niche', DeleteWorkflowExecution: 'common', UpdateNamespace: 'common',
|
|
632
|
+
};
|
|
633
|
+
|
|
634
|
+
function rpcCapabilities(service: string, area: string): CapabilitySpec[] {
|
|
635
|
+
return [...serviceMethods(service).entries()].map(([method, shape]) => {
|
|
636
|
+
const id = `temporal.${area}.${method.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase()}`;
|
|
637
|
+
const served = Boolean(SERVED[service]?.[method]);
|
|
638
|
+
const proof = RPC_VERIFY[method];
|
|
639
|
+
if (served && proof) return done(id, area, `${method}`, proof.tier, proof.verify);
|
|
640
|
+
const note = shape.requestStream || shape.responseStream ? ' (streaming; this server answers unary calls only)' : '';
|
|
641
|
+
return todo(id, area, `${method} — answers gRPC UNIMPLEMENTED${note}`, TIER_OF[method] ?? 'niche');
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function commandCapabilities(): CapabilitySpec[] {
|
|
646
|
+
const values = Object.keys((temporalRoot.lookupEnum('temporal.api.enums.v1.CommandType')).values).filter((name) => name !== 'COMMAND_TYPE_UNSPECIFIED').sort();
|
|
647
|
+
return values.map((name) => {
|
|
648
|
+
const id = `temporal.commands.${name.replace('COMMAND_TYPE_', '').toLowerCase()}`;
|
|
649
|
+
const proof = COMMAND_VERIFY[name];
|
|
650
|
+
return proof ? done(id, 'commands', name, proof.tier, proof.verify) : todo(id, 'commands', `${name} — fails the workflow task with WORKFLOW_TASK_FAILED_CAUSE_FEATURE_DISABLED`, 'niche');
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
export const TEMPORAL_CAPABILITIES: CapabilitySpec[] = [
|
|
655
|
+
...rpcCapabilities(WORKFLOW_SERVICE, 'workflowservice'),
|
|
656
|
+
...rpcCapabilities(OPERATOR_SERVICE, 'operatorservice'),
|
|
657
|
+
...rpcCapabilities(HEALTH_SERVICE, 'health'),
|
|
658
|
+
...commandCapabilities(),
|
|
659
|
+
...HISTORY,
|
|
660
|
+
...WIRE,
|
|
661
|
+
...CONNECTOR,
|
|
662
|
+
];
|
|
663
|
+
|
|
664
|
+
/** Committed area census: the three services of the vendored protos, the command table, the history
|
|
665
|
+
* service's own behaviours, the wire, and the connector. A committed literal, so a shrinking
|
|
666
|
+
* regeneration reddens instead of silently dropping an area. */
|
|
667
|
+
export const TEMPORAL_AREAS = [
|
|
668
|
+
'commands',
|
|
669
|
+
'connector',
|
|
670
|
+
'health',
|
|
671
|
+
'history',
|
|
672
|
+
'operatorservice',
|
|
673
|
+
'wire',
|
|
674
|
+
'workflowservice',
|
|
675
|
+
] as const;
|
|
676
|
+
|
|
677
|
+
export function temporalCapabilities(): Promise<CapabilityReport> {
|
|
678
|
+
return checkCapabilities('temporal', TEMPORAL_CAPABILITIES);
|
|
679
|
+
}
|