@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.
Files changed (55) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +104 -0
  3. package/dist/src/cli.d.ts +2 -0
  4. package/dist/src/cli.js +36 -0
  5. package/dist/src/generated/hpack.json +1 -0
  6. package/dist/src/generated/temporal-api.json +1 -0
  7. package/dist/src/index.d.ts +18 -0
  8. package/dist/src/index.js +70 -0
  9. package/dist/src/temporal-budget.d.ts +31 -0
  10. package/dist/src/temporal-budget.js +50 -0
  11. package/dist/src/temporal-capabilities.d.ts +7 -0
  12. package/dist/src/temporal-capabilities.js +650 -0
  13. package/dist/src/temporal-conformance.d.ts +8 -0
  14. package/dist/src/temporal-conformance.js +54 -0
  15. package/dist/src/temporal-connector.d.ts +32 -0
  16. package/dist/src/temporal-connector.js +121 -0
  17. package/dist/src/temporal-engine.d.ts +274 -0
  18. package/dist/src/temporal-engine.js +2132 -0
  19. package/dist/src/temporal-grpc.d.ts +51 -0
  20. package/dist/src/temporal-grpc.js +124 -0
  21. package/dist/src/temporal-h2.d.ts +40 -0
  22. package/dist/src/temporal-h2.js +644 -0
  23. package/dist/src/temporal-history.d.ts +27 -0
  24. package/dist/src/temporal-history.js +85 -0
  25. package/dist/src/temporal-http.d.ts +17 -0
  26. package/dist/src/temporal-http.js +160 -0
  27. package/dist/src/temporal-json.d.ts +7 -0
  28. package/dist/src/temporal-json.js +68 -0
  29. package/dist/src/temporal-proto.d.ts +17 -0
  30. package/dist/src/temporal-proto.js +46 -0
  31. package/dist/src/temporal-server.d.ts +25 -0
  32. package/dist/src/temporal-server.js +56 -0
  33. package/dist/src/temporal-twin.d.ts +25 -0
  34. package/dist/src/temporal-twin.js +100 -0
  35. package/dist/src/temporal-visibility.d.ts +49 -0
  36. package/dist/src/temporal-visibility.js +293 -0
  37. package/package.json +59 -0
  38. package/src/cli.ts +36 -0
  39. package/src/generated/hpack.json +1 -0
  40. package/src/generated/temporal-api.json +1 -0
  41. package/src/index.ts +81 -0
  42. package/src/temporal-budget.ts +75 -0
  43. package/src/temporal-capabilities.ts +679 -0
  44. package/src/temporal-conformance.ts +52 -0
  45. package/src/temporal-connector.ts +118 -0
  46. package/src/temporal-engine.ts +2176 -0
  47. package/src/temporal-grpc.ts +139 -0
  48. package/src/temporal-h2.ts +614 -0
  49. package/src/temporal-history.ts +99 -0
  50. package/src/temporal-http.ts +146 -0
  51. package/src/temporal-json.ts +77 -0
  52. package/src/temporal-proto.ts +53 -0
  53. package/src/temporal-server.ts +67 -0
  54. package/src/temporal-twin.ts +102 -0
  55. package/src/temporal-visibility.ts +245 -0
@@ -0,0 +1,52 @@
1
+ // Conformance: the twin's wire, probed as a client finds it — a real socket, HTTP/2, gRPC framing —
2
+ // against a fresh World root. Dev-only (cli.ts imports it lazily).
3
+ import { mkdtempSync, rmSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { liveTemporalExecute } from './temporal-connector.ts';
7
+ import { createTemporalTwinServer } from './temporal-server.ts';
8
+ import { GrpcError } from './temporal-grpc.ts';
9
+ import { OPERATOR_SERVICE, WORKFLOW_SERVICE, HEALTH_SERVICE } from './temporal-proto.ts';
10
+
11
+ export type TemporalConformanceReport = { ok: boolean; checksRun: number; failures: string[] };
12
+
13
+ export async function checkTemporalConformance(options: { root?: string } = {}): Promise<TemporalConformanceReport> {
14
+ const root = options.root ?? mkdtempSync(join(tmpdir(), 'temporal-conformance-'));
15
+ const server = await createTemporalTwinServer({ root });
16
+ const failures: string[] = [];
17
+ let checksRun = 0;
18
+ const check = async (name: string, probe: () => Promise<boolean>): Promise<void> => {
19
+ checksRun += 1;
20
+ try {
21
+ if (!(await probe())) failures.push(name);
22
+ } catch (error) {
23
+ failures.push(`${name}: ${(error as Error).message}`);
24
+ }
25
+ };
26
+ const call = liveTemporalExecute({ address: `127.0.0.1:${server.port}`, tls: false, budgetOptions: { root } });
27
+ const refusal = async (service: string, method: string, request: Record<string, unknown>): Promise<number | undefined> => {
28
+ try {
29
+ await call(service, method, request);
30
+ return undefined;
31
+ } catch (error) {
32
+ return error instanceof GrpcError ? error.code : -1;
33
+ }
34
+ };
35
+ try {
36
+ await check('GetSystemInfo answers a server version over h2c', async () => /^\d+\.\d+\.\d+$/.test(String((await call(WORKFLOW_SERVICE, 'GetSystemInfo', {})).serverVersion)));
37
+ await check('the default namespace exists', async () => ((await call(WORKFLOW_SERVICE, 'DescribeNamespace', { namespace: 'default' })).namespaceInfo as { name?: string } | undefined)?.name === 'default');
38
+ await check('an unknown namespace is NOT_FOUND', async () => (await refusal(WORKFLOW_SERVICE, 'DescribeNamespace', { namespace: 'nope' })) === 5);
39
+ await check('an RPC the twin does not serve is UNIMPLEMENTED', async () => (await refusal(WORKFLOW_SERVICE, 'ListSchedules', { namespace: 'default' })) === 12);
40
+ await check('an OperatorService RPC the twin does not serve is UNIMPLEMENTED', async () => (await refusal(OPERATOR_SERVICE, 'ListClusters', {})) === 12);
41
+ await check('a start answers a run id, and a second start of the id is ALREADY_EXISTS', async () => {
42
+ const start = { namespace: 'default', workflowId: 'conformance', workflowType: { name: 'wf' }, taskQueue: { name: 'q' } };
43
+ const first = await call(WORKFLOW_SERVICE, 'StartWorkflowExecution', { ...start, requestId: 'a' });
44
+ return /^[0-9a-f-]{36}$/.test(String(first.runId)) && (await refusal(WORKFLOW_SERVICE, 'StartWorkflowExecution', { ...start, requestId: 'b' })) === 6;
45
+ });
46
+ await check('health Check answers SERVING', async () => (await call(HEALTH_SERVICE, 'Check', {})).status === 'SERVING');
47
+ } finally {
48
+ await server.stop();
49
+ if (!options.root) rmSync(root, { recursive: true, force: true });
50
+ }
51
+ return { ok: failures.length === 0, checksRun, failures };
52
+ }
@@ -0,0 +1,118 @@
1
+ // The connector: pull a real Temporal frontend's namespace configuration into the World, over an
2
+ // INJECTED unary-call function (the real network exists only inside liveTemporalExecute).
3
+ //
4
+ // What a pull observes is what a World needs to stand in for a real cluster before any app runs:
5
+ // its namespaces (DescribeNamespaceResponse, the vendor's shape) and each namespace's custom search
6
+ // attributes (an app's starts are refused over an attribute the namespace does not define). A real
7
+ // cluster's workflow runs are NOT pulled: a run's state is its history, written by the workers that
8
+ // execute it, and a copied history without those workers is a run nothing can advance.
9
+ //
10
+ // Push refuses by name. A World's local runs cannot be performed on a real cluster — a start or a
11
+ // signal could be resent, but everything after it (workflow and activity tasks) is the work of
12
+ // workers, which a deploy does not move.
13
+ import { assertBudgetGuardIntact, observeResource, worldNow, type PushOutcome, type RemoteExecute, type TwinAction } from '@volter/world-core';
14
+ import { TemporalBudget, temporalCallWeight, type TemporalBudgetOptions } from './temporal-budget.ts';
15
+ import http2 from 'node:http2';
16
+ import { decodeMessage, encodeMessage, serviceMethods, OPERATOR_SERVICE, WORKFLOW_SERVICE } from './temporal-proto.ts';
17
+ import { GrpcError, grpcFrame, splitGrpcFrames } from './temporal-grpc.ts';
18
+
19
+ type Obj = Record<string, unknown>;
20
+
21
+ /** One unary call on a Temporal frontend: (service, method, request) → response, in the engine's plain form. */
22
+ export type TemporalExecute = (service: string, method: string, request: Obj) => Promise<Obj>;
23
+
24
+ /** THE CHOKE POINT — the one place this pack reaches a real Temporal frontend. `address` is host:port
25
+ * (TEMPORAL_ADDRESS); `apiKey` (TEMPORAL_API_KEY) is sent as a bearer token, and a key implies TLS,
26
+ * as the SDKs do. Every call is charged to the rate budget BEFORE it connects (a refused budget
27
+ * throws and nothing is sent) and settled after, even when the call fails; a stand-in budget whose
28
+ * guard methods are not the kernel's is refused rather than trusted. */
29
+ export function liveTemporalExecute(options: { address?: string; apiKey?: string; tls?: boolean; namespace?: string; budget?: TemporalBudget; budgetOptions?: TemporalBudgetOptions } = {}): TemporalExecute {
30
+ const address = options.address ?? process.env.TEMPORAL_ADDRESS;
31
+ if (!address) throw new Error('liveTemporalExecute: TEMPORAL_ADDRESS is not set (host:port of a Temporal frontend)');
32
+ const apiKey = options.apiKey ?? process.env.TEMPORAL_API_KEY;
33
+ const tls = options.tls ?? Boolean(apiKey);
34
+ if (options.budget !== undefined && options.budget !== null) assertBudgetGuardIntact(options.budget, TemporalBudget, 'liveTemporalExecute');
35
+ const budget = options.budget instanceof TemporalBudget ? options.budget : new TemporalBudget({ token: address, ...(options.budgetOptions ?? {}) });
36
+ return async (service, method, request) => {
37
+ const weight = temporalCallWeight(service, method);
38
+ const reservation = budget.checkBudget(weight);
39
+ try {
40
+ return await unaryCall(address, tls, apiKey, options.namespace, service, method, request);
41
+ } finally {
42
+ budget.recordCall(weight, {}, { reservation });
43
+ }
44
+ };
45
+ }
46
+
47
+ function unaryCall(address: string, tls: boolean, apiKey: string | undefined, namespace: string | undefined, service: string, method: string, request: Obj): Promise<Obj> {
48
+ const options = { namespace };
49
+ return new Promise((resolve, reject) => {
50
+ const table = serviceMethods(service).get(method);
51
+ if (!table) { reject(new Error(`liveTemporalExecute: ${service}/${method} is not in the vendored API`)); return; }
52
+ const session = http2.connect(`${tls ? 'https' : 'http'}://${address}`);
53
+ session.on('error', (error) => { session.destroy(); reject(error); });
54
+ const stream = session.request({
55
+ ':method': 'POST', ':path': `/${service}/${method}`, 'content-type': 'application/grpc', te: 'trailers',
56
+ ...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}),
57
+ ...(options.namespace ? { 'temporal-namespace': options.namespace } : {}),
58
+ });
59
+ const chunks: Buffer[] = [];
60
+ let status = '';
61
+ let message = '';
62
+ stream.on('response', (headers) => { status = String(headers['grpc-status'] ?? status); message = String(headers['grpc-message'] ?? message); });
63
+ stream.on('trailers', (trailers) => { status = String(trailers['grpc-status'] ?? status); message = String(trailers['grpc-message'] ?? message); });
64
+ stream.on('data', (chunk: Buffer) => chunks.push(chunk));
65
+ stream.on('error', (error) => { session.destroy(); reject(error); });
66
+ stream.on('end', () => {
67
+ session.close();
68
+ if (status !== '0') {
69
+ let text = message;
70
+ try { text = decodeURIComponent(message); } catch { /* a grpc-message that is not percent-encoding is shown as sent */ }
71
+ reject(new GrpcError(Number(status || 2), text));
72
+ return;
73
+ }
74
+ try {
75
+ const [frame] = splitGrpcFrames(new Uint8Array(Buffer.concat(chunks)));
76
+ resolve(frame ? decodeMessage(table.responseType, frame.message) : {});
77
+ } catch (error) { reject(error); }
78
+ });
79
+ stream.end(Buffer.from(grpcFrame(encodeMessage(table.requestType, request))));
80
+ });
81
+ }
82
+
83
+
84
+ /** Pull a real frontend's namespaces and their custom search attributes into the tree. */
85
+ export async function syncTemporalFromReal(execute: TemporalExecute, options: { root?: string; occurredAt?: string } = {}): Promise<{ pulled: number }> {
86
+ const at = options.occurredAt ?? worldNow();
87
+ const scope = { ...(options.root !== undefined ? { root: options.root } : {}), at };
88
+ // read everything first: a call that fails part way leaves the tree as it was
89
+ const namespaces: Obj[] = [];
90
+ let pageToken: unknown;
91
+ do {
92
+ const listing = await execute(WORKFLOW_SERVICE, 'ListNamespaces', { pageSize: 100, ...(pageToken ? { nextPageToken: pageToken } : {}) });
93
+ namespaces.push(...((listing.namespaces ?? []) as Obj[]));
94
+ pageToken = listing.nextPageToken;
95
+ } while (pageToken);
96
+ const observed: Array<{ name: string; fields: Obj }> = [];
97
+ for (const described of namespaces) {
98
+ const name = String((described.namespaceInfo as Obj | undefined)?.name ?? '');
99
+ if (!name) throw new Error('temporal: a listed namespace has no name — refusing to fold a partial pull');
100
+ const attributes = await execute(OPERATOR_SERVICE, 'ListSearchAttributes', { namespace: name });
101
+ // the namespace's custom search attributes ride its subject as bookkeeping, as the engine keeps them
102
+ observed.push({ name, fields: { ...described, _searchAttributes: (attributes.customAttributes ?? {}) as Record<string, string> } });
103
+ }
104
+ for (const { name, fields } of observed) observeResource('temporal', { type: 'namespace', id: name, fields }, scope);
105
+ return { pulled: observed.length };
106
+ }
107
+
108
+ /** The real-system half of the state system (head.ts): performing one landed entry against a real
109
+ * cluster. Refused, by name, for every entry — see the header; a real head over this twin stops its
110
+ * deploy at the first entry with this reason rather than pretending the entry landed. */
111
+ export async function performTemporalAction(_execute: RemoteExecute, action: TwinAction): Promise<PushOutcome> {
112
+ throw new Error(`temporal: performing ${action.operation} on a real Temporal cluster is not supported — a run's history is written by the workers that execute it, which a deploy does not move`);
113
+ }
114
+
115
+ /** Deploying a World's local runs to a real cluster is refused, by name (see the header). */
116
+ export async function pushPendingTemporalActions(): Promise<never> {
117
+ throw new Error('temporal: pushing local workflow runs to a real Temporal cluster is not supported — a run\'s history is written by the workers that execute it, which a deploy does not move');
118
+ }