@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,99 @@
|
|
|
1
|
+
// THE ENGINE SLOT — where a workflow's history lives (docs/contributing/architecture.md#the-engine-slot).
|
|
2
|
+
//
|
|
3
|
+
// Temporal's state has two halves. What a list or a describe answers — a run's status, type, task
|
|
4
|
+
// queue, search attributes, and the server's own bookkeeping for it (pending tasks, timers, buffered
|
|
5
|
+
// events) — is a tree subject, `workflow-execution`, written through the kernel's write path like any
|
|
6
|
+
// pack's resource. The run's HISTORY is the other half: an append-only sequence of events a worker
|
|
7
|
+
// replays in full on every workflow task. Folding it into the subject would make each append rewrite
|
|
8
|
+
// every event before it, so it sits beside the tree as content-addressed chunks in the World's blob
|
|
9
|
+
// store (one chunk per transition's new events, keyed by its sha256), and the subject names its
|
|
10
|
+
// chunks in order (`_state.history`). A branch reads its chunks through `readResourceBlob`, which
|
|
11
|
+
// falls back to the ancestors that wrote them, so a fork replays exactly the history its tree names.
|
|
12
|
+
//
|
|
13
|
+
// This module is the only one that writes outside the tree, and it does so only through the blob
|
|
14
|
+
// store: no file, no database. Everything else reads through `TemporalStore`.
|
|
15
|
+
import { applyTwinWrite, blobDigest, getActiveBlobStore, readResourceBlob, resourceChain, twinResourcesOfType, ownFields, worldNow } from '@volter/world-core';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
|
|
18
|
+
export const TEMPORAL_SERVICE = 'temporal';
|
|
19
|
+
|
|
20
|
+
/** A history event in the engine's plain form (temporal-proto.ts `OBJECT`): `eventId`, `eventTime`,
|
|
21
|
+
* `eventType` and one `<kind>EventAttributes`. */
|
|
22
|
+
export type HistoryEvent = Record<string, unknown> & { eventId: number; eventType: string };
|
|
23
|
+
|
|
24
|
+
/** A stored subject: its fields as written (the kernel's `id`/`type`/`updatedAt` are its own). */
|
|
25
|
+
export type Stored = Record<string, unknown> & { id: string };
|
|
26
|
+
|
|
27
|
+
const chunkCache = new Map<string, HistoryEvent[]>(); // cache: a key names one immutable chunk; bounded, per root
|
|
28
|
+
/** Chunks held in memory; past this the least recently used go (a long-lived host reads the rest back). */
|
|
29
|
+
const CHUNK_CACHE_LIMIT = 4096;
|
|
30
|
+
|
|
31
|
+
function remember(key: string, events: HistoryEvent[]): void {
|
|
32
|
+
chunkCache.delete(key);
|
|
33
|
+
chunkCache.set(key, events);
|
|
34
|
+
while (chunkCache.size > CHUNK_CACHE_LIMIT) chunkCache.delete(chunkCache.keys().next().value!);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class TemporalStore {
|
|
38
|
+
readonly root: string | undefined;
|
|
39
|
+
|
|
40
|
+
constructor(root?: string) {
|
|
41
|
+
this.root = root;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Every subject of one type, as its own fields. READ-ONLY: the kernel's memoized tree and parsed
|
|
45
|
+
* log rows share nested objects with what this hands out, so a caller that changes a subject
|
|
46
|
+
* copies it first (the engine's RunTx does, deeply) — editing one in place corrupts the log the
|
|
47
|
+
* kernel digests. */
|
|
48
|
+
list(type: string): Stored[] {
|
|
49
|
+
return twinResourcesOfType(TEMPORAL_SERVICE, type, this.root).map((resource) => ({ ...ownFields(resource), id: resource.id }) as Stored);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
get(type: string, id: string): Stored | undefined {
|
|
53
|
+
return this.list(type).find((subject) => subject.id === id);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Write one subject (all of its fields) under `operation`, the RPC or the server act that moved it. */
|
|
57
|
+
async put(type: string, id: string, fields: Record<string, unknown>, operation: string, input?: Record<string, unknown>): Promise<void> {
|
|
58
|
+
await applyTwinWrite(TEMPORAL_SERVICE, {
|
|
59
|
+
operation,
|
|
60
|
+
subjectType: type,
|
|
61
|
+
subjectId: id,
|
|
62
|
+
// exactly what the log will hold: a JSON round trip drops undefined members, so the entry the
|
|
63
|
+
// kernel keeps in memory is the one it reads back (its history digests compare the two)
|
|
64
|
+
fields: JSON.parse(JSON.stringify(fields)) as Record<string, unknown>,
|
|
65
|
+
...(input ? { input } : {}),
|
|
66
|
+
occurredAt: worldNow(),
|
|
67
|
+
}, this.root);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Store a run's new events as one chunk; answers the chunk's key for the subject to name. */
|
|
71
|
+
async appendChunk(events: HistoryEvent[]): Promise<string> {
|
|
72
|
+
const bytes = new TextEncoder().encode(JSON.stringify(events));
|
|
73
|
+
const key = `history/${blobDigest(bytes)}.json`;
|
|
74
|
+
const dir = resourceChain(TEMPORAL_SERVICE, this.root)[0]!;
|
|
75
|
+
await getActiveBlobStore().put(join(dir, key), bytes);
|
|
76
|
+
// keyed by root: a branch's first read of a chunk goes through the blob store's ancestry, so a
|
|
77
|
+
// chunk missing from it is seen as missing, not served from another root's memory
|
|
78
|
+
remember(`${this.root ?? ''}\0${key}`, events);
|
|
79
|
+
return key;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A run's events, in order, from the chunks its subject names. */
|
|
83
|
+
async readChunks(keys: string[]): Promise<HistoryEvent[]> {
|
|
84
|
+
const out: HistoryEvent[] = [];
|
|
85
|
+
for (const key of keys) {
|
|
86
|
+
const cacheKey = `${this.root ?? ''}\0${key}`;
|
|
87
|
+
let events = chunkCache.get(cacheKey);
|
|
88
|
+
if (events) remember(cacheKey, events);
|
|
89
|
+
if (!events) {
|
|
90
|
+
const bytes = await readResourceBlob(TEMPORAL_SERVICE, key, this.root);
|
|
91
|
+
if (bytes === null) throw new Error(`temporal: history chunk ${key} is missing from the World's blob store`);
|
|
92
|
+
events = JSON.parse(new TextDecoder().decode(bytes)) as HistoryEvent[];
|
|
93
|
+
remember(cacheKey, events);
|
|
94
|
+
}
|
|
95
|
+
out.push(...events);
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// THE HTTP API — the frontend's HTTP/JSON surface (Temporal serves it on :7243 beside gRPC's :7233),
|
|
2
|
+
// routed by the google.api.http annotations the vendored protos carry on each method, over the same
|
|
3
|
+
// RPC table as the gRPC wire: a served method answers here exactly what it answers there, and every
|
|
4
|
+
// other annotated route answers 501 with the gRPC-gateway error body. Bodies and answers are proto3
|
|
5
|
+
// JSON (temporal-json.ts); the twin's own doors (GET /twin, the store) ride the same fetch.
|
|
6
|
+
//
|
|
7
|
+
// This is also the twin's fetch surface for the World (createTemporalTwinFetch): its doors, the
|
|
8
|
+
// replay the invariants run, and a hosted World's in-process wire all come through here.
|
|
9
|
+
import { createTwinFetchFromHandler, statefulTwinManifest, WORLD_BOOT_PATH, type TwinFetchHandlerRequest, type TwinFetchHandlerResult } from '@volter/world-core';
|
|
10
|
+
import type protobuf from 'protobufjs';
|
|
11
|
+
import { handleTemporalCall, SERVED } from './temporal-twin.ts';
|
|
12
|
+
import { engineForRoot, executionInfo, type TemporalEngine } from './temporal-engine.ts';
|
|
13
|
+
import { GrpcError, GrpcStatus } from './temporal-grpc.ts';
|
|
14
|
+
import { fromProto3Json, toProto3Json } from './temporal-json.ts';
|
|
15
|
+
import { OPERATOR_SERVICE, WORKFLOW_SERVICE, serviceMethods, temporalRoot } from './temporal-proto.ts';
|
|
16
|
+
|
|
17
|
+
type Obj = Record<string, unknown>;
|
|
18
|
+
type Route = { verb: string; pattern: RegExp; params: string[]; body: string | undefined; service: string; method: string; requestType: protobuf.Type; responseType: protobuf.Type };
|
|
19
|
+
|
|
20
|
+
/** gRPC status → HTTP status, as grpc-gateway maps them (runtime/errors.go HTTPStatusFromCode). */
|
|
21
|
+
const HTTP_STATUS: Record<number, number> = {
|
|
22
|
+
0: 200, 1: 499, 2: 500, 3: 400, 4: 504, 5: 404, 6: 409, 7: 403, 8: 429, 9: 400, 10: 409, 11: 400, 12: 501, 13: 500, 14: 503, 15: 500, 16: 401,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function routesOf(service: string): Route[] {
|
|
26
|
+
const out: Route[] = [];
|
|
27
|
+
const methods = temporalRoot.lookupService(service).methods;
|
|
28
|
+
for (const [method, shape] of serviceMethods(service)) {
|
|
29
|
+
// the annotations, as protobufjs keeps a method's options: `(google.api.http).post` → the path
|
|
30
|
+
const declared = (methods[method]!.options ?? {}) as Record<string, string>;
|
|
31
|
+
const bindings: Array<{ verb: string; path: string; body?: string }> = [];
|
|
32
|
+
for (const prefix of ['(google.api.http)', '(google.api.http).additional_bindings']) {
|
|
33
|
+
for (const verb of ['get', 'post', 'put', 'patch', 'delete']) {
|
|
34
|
+
const path = declared[`${prefix}.${verb}`];
|
|
35
|
+
if (path) bindings.push({ verb: verb.toUpperCase(), path, ...(declared[`${prefix}.body`] ? { body: declared[`${prefix}.body`] } : {}) });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
for (const binding of bindings) {
|
|
39
|
+
const params: string[] = [];
|
|
40
|
+
const source = binding.path.split(/(\{[^}]+\})/).map((part) => {
|
|
41
|
+
const variable = /^\{([a-z0-9_.]+)(?:=[^}]*)?\}$/.exec(part);
|
|
42
|
+
if (!variable) return part.replace(/[.*+?^$()|[\]\\]/g, '\\$&');
|
|
43
|
+
params.push(variable[1]!);
|
|
44
|
+
return '([^/]+)';
|
|
45
|
+
}).join('');
|
|
46
|
+
out.push({ verb: binding.verb, pattern: new RegExp(`^${source}$`), params, body: binding.body, service, method, requestType: shape.requestType, responseType: shape.responseType });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let routeTable: Route[] | undefined; // cache: a pure function of the committed descriptor
|
|
53
|
+
function routes(): Route[] {
|
|
54
|
+
routeTable ??= [...routesOf(WORKFLOW_SERVICE), ...routesOf(OPERATOR_SERVICE)];
|
|
55
|
+
return routeTable;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const camel = (name: string): string => name.replace(/_([a-z0-9])/g, (_, c: string) => c.toUpperCase());
|
|
59
|
+
|
|
60
|
+
/** Set a dotted proto path (`execution.workflow_id`) on a plain request. */
|
|
61
|
+
function setPath(target: Obj, path: string, value: unknown): void {
|
|
62
|
+
const parts = path.split('.').map(camel);
|
|
63
|
+
let cursor = target;
|
|
64
|
+
for (const part of parts.slice(0, -1)) cursor = (cursor[part] ??= {}) as Obj;
|
|
65
|
+
cursor[parts.at(-1)!] = value;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A query-string value, typed by the field it lands on. */
|
|
69
|
+
function typedQueryValue(type: protobuf.Type, path: string, raw: string): unknown {
|
|
70
|
+
let current: protobuf.Type | undefined = type;
|
|
71
|
+
let field: protobuf.Field | undefined;
|
|
72
|
+
for (const part of path.split('.').map(camel)) {
|
|
73
|
+
field = current?.fields[part];
|
|
74
|
+
if (!field) throw new GrpcError(GrpcStatus.INVALID_ARGUMENT, `unknown query parameter "${path}"`);
|
|
75
|
+
field.resolve();
|
|
76
|
+
current = field.resolvedType && 'fields' in field.resolvedType ? field.resolvedType as protobuf.Type : undefined;
|
|
77
|
+
}
|
|
78
|
+
if (!field) return raw;
|
|
79
|
+
if (['int32', 'uint32', 'sint32', 'fixed32', 'sfixed32', 'double', 'float'].includes(field.type)) return Number(raw);
|
|
80
|
+
if (field.type === 'bool') return raw === 'true';
|
|
81
|
+
return raw;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** One HTTP API request, answered through the RPC table. */
|
|
85
|
+
export async function handleTemporalHttpRequest(engine: TemporalEngine, request: { method: string; path: string; body: string }): Promise<TwinFetchHandlerResult> {
|
|
86
|
+
const url = new URL(request.path, 'http://twin.local');
|
|
87
|
+
const matches = routes().filter((route) => route.pattern.test(url.pathname));
|
|
88
|
+
const route = matches.find((candidate) => candidate.verb === request.method);
|
|
89
|
+
if (!route) {
|
|
90
|
+
if (matches.length) return { status: 405, body: { code: GrpcStatus.UNIMPLEMENTED, message: `Method Not Allowed`, details: [] } };
|
|
91
|
+
return { status: 404, body: { code: GrpcStatus.NOT_FOUND, message: 'Not Found', details: [] } };
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
let plain: Obj = {};
|
|
95
|
+
if (route.body === '*' && request.body.trim()) {
|
|
96
|
+
const parsed = JSON.parse(request.body) as unknown;
|
|
97
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new GrpcError(GrpcStatus.INVALID_ARGUMENT, 'the request body must be a JSON object');
|
|
98
|
+
try { plain = fromProto3Json(route.requestType, parsed as Obj); } catch (error) { throw new GrpcError(GrpcStatus.INVALID_ARGUMENT, (error as Error).message); }
|
|
99
|
+
} else if (route.body === undefined) {
|
|
100
|
+
for (const [name, raw] of url.searchParams) setPath(plain, name, typedQueryValue(route.requestType, name, raw));
|
|
101
|
+
try { plain = fromProto3Json(route.requestType, plain); } catch (error) { throw new GrpcError(GrpcStatus.INVALID_ARGUMENT, (error as Error).message); }
|
|
102
|
+
}
|
|
103
|
+
let values: string[];
|
|
104
|
+
try { values = route.pattern.exec(url.pathname)!.slice(1).map(decodeURIComponent); } catch { throw new GrpcError(GrpcStatus.INVALID_ARGUMENT, 'malformed percent-escape in the path'); }
|
|
105
|
+
route.params.forEach((param, i) => setPath(plain, param, values[i]));
|
|
106
|
+
const answer = await handleTemporalCall(engine, route.service, route.method, plain, { signal: new AbortController().signal, metadata: {} });
|
|
107
|
+
return { status: 200, body: toProto3Json(route.responseType, answer) };
|
|
108
|
+
} catch (error) {
|
|
109
|
+
if (error instanceof SyntaxError) return { status: 400, body: { code: GrpcStatus.INVALID_ARGUMENT, message: `invalid JSON body: ${error.message}`, details: [] } };
|
|
110
|
+
const code = error instanceof GrpcError ? error.code : GrpcStatus.INTERNAL;
|
|
111
|
+
return { status: HTTP_STATUS[code] ?? 500, body: { code, message: (error as Error).message, details: [] } };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Every route the HTTP API serves (the served subset of the annotated routes), for the manifest. */
|
|
116
|
+
export function servedHttpRoutes(): string[] {
|
|
117
|
+
return routes().filter((route) => SERVED[route.service]?.[route.method]).map((route) => `${route.verb} ${route.pattern.source.slice(1, -1).replace(/\(\[\^\/\]\+\)/g, '{…}').replace(/\\/g, '')} → ${route.method}`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export type TemporalTwinFetchOptions = { root?: string; readOnly?: boolean; engine?: TemporalEngine };
|
|
121
|
+
|
|
122
|
+
/** The twin's HTTP surface: the World's doors (GET /twin, GET /twin/store/workflows) and the HTTP API. */
|
|
123
|
+
export function createTemporalTwinFetch(options: TemporalTwinFetchOptions = {}): (request: Request) => Promise<Response> {
|
|
124
|
+
const engine = options.engine ?? engineForRoot({ ...(options.root !== undefined ? { root: options.root } : {}), ...(options.readOnly ? { readOnly: true } : {}) });
|
|
125
|
+
const serveDoors = createTwinFetchFromHandler((request: TwinFetchHandlerRequest) => handleTemporalHttpRequest(engine, request), {
|
|
126
|
+
...(options.root !== undefined ? { root: options.root } : {}),
|
|
127
|
+
...(options.readOnly ? { readOnly: true } : {}),
|
|
128
|
+
manifest: statefulTwinManifest({
|
|
129
|
+
vendor: 'temporal',
|
|
130
|
+
twinOf: 'the Temporal frontend (WorkflowService over gRPC; its HTTP API on this fetch)',
|
|
131
|
+
stores: 'namespaces, custom search attributes and workflow runs, each run\'s history as chunks beside the tree',
|
|
132
|
+
identity: 'No authentication, as Temporal\'s dev server: the gRPC wire and the HTTP API accept any caller on loopback.',
|
|
133
|
+
notes: 'Point the SDKs at TEMPORAL_ADDRESS (host:port, gRPC). Timers, timeouts and retries run on the World clock.',
|
|
134
|
+
}),
|
|
135
|
+
stores: {
|
|
136
|
+
workflows: () => engine.runs().map((run) => ({ namespace: run.namespace, ...executionInfo(run) })),
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
const boot = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env?.VOLTER_WORLD_BOOT_ID;
|
|
140
|
+
return async (request) => {
|
|
141
|
+
// the World asks each twin which boot it belongs to (serve-http.ts WORLD_BOOT_PATH); this fetch answers it itself
|
|
142
|
+
// because it is served by this pack's own listener, not the kernel's HTTP seam
|
|
143
|
+
if (boot && new URL(request.url).pathname === WORLD_BOOT_PATH) return Response.json({ boot });
|
|
144
|
+
return serveDoors(request);
|
|
145
|
+
};
|
|
146
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Proto3 JSON (https://protobuf.dev/programming-guides/json/) to and from the engine's plain form, for
|
|
2
|
+
// the frontend's HTTP API. The plain form already names fields in lowerCamelCase, enums by name and
|
|
3
|
+
// bytes in base64; what differs is 64-bit integers (strings in JSON), google.protobuf.Timestamp
|
|
4
|
+
// (RFC 3339) and google.protobuf.Duration ("1.5s"). Walked by the message type, so a field is
|
|
5
|
+
// converted by what the proto says it is, never by what its value looks like.
|
|
6
|
+
//
|
|
7
|
+
// Payloads are carried in their full form ({ metadata, data }); the HTTP API's payload shorthand
|
|
8
|
+
// (a bare JSON value standing for a json/plain payload) is not modeled.
|
|
9
|
+
import type protobuf from 'protobufjs';
|
|
10
|
+
import { durationMs, toDuration, toTimestamp } from './temporal-engine.ts';
|
|
11
|
+
|
|
12
|
+
type Obj = Record<string, unknown>;
|
|
13
|
+
const LONGS = new Set(['int64', 'uint64', 'sint64', 'fixed64', 'sfixed64']);
|
|
14
|
+
|
|
15
|
+
function rfc3339(value: Obj): string {
|
|
16
|
+
const ms = Number(value.seconds ?? 0) * 1000 + Math.floor(Number(value.nanos ?? 0) / 1e6);
|
|
17
|
+
return new Date(ms).toISOString();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function durationText(value: Obj): string {
|
|
21
|
+
const ms = durationMs(value);
|
|
22
|
+
return `${ms % 1000 === 0 ? ms / 1000 : (ms / 1000).toFixed(3).replace(/0+$/, '')}s`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function convert(field: protobuf.Field, value: unknown, direction: 'out' | 'in'): unknown {
|
|
26
|
+
field.resolve();
|
|
27
|
+
const one = (item: unknown): unknown => {
|
|
28
|
+
if (item === null || item === undefined) return item;
|
|
29
|
+
if (field.resolvedType && 'fields' in field.resolvedType) {
|
|
30
|
+
const type = field.resolvedType as protobuf.Type;
|
|
31
|
+
if (type.fullName === '.google.protobuf.Timestamp') {
|
|
32
|
+
if (direction === 'out') return rfc3339(item as Obj);
|
|
33
|
+
const ms = Date.parse(String(item));
|
|
34
|
+
if (Number.isNaN(ms)) throw new Error(`${field.name}: not an RFC 3339 timestamp`);
|
|
35
|
+
return toTimestamp(ms);
|
|
36
|
+
}
|
|
37
|
+
if (type.fullName === '.google.protobuf.Duration') {
|
|
38
|
+
if (direction === 'out') return durationText(item as Obj);
|
|
39
|
+
const match = /^(-?\d+(?:\.\d+)?)s$/.exec(String(item));
|
|
40
|
+
if (!match) throw new Error(`${field.name}: not a duration ("<seconds>s")`);
|
|
41
|
+
return toDuration(Math.round(Number(match[1]) * 1000)) ?? {};
|
|
42
|
+
}
|
|
43
|
+
return walk(type, item as Obj, direction);
|
|
44
|
+
}
|
|
45
|
+
if (LONGS.has(field.type)) return direction === 'out' ? String(item) : Number(item);
|
|
46
|
+
return item;
|
|
47
|
+
};
|
|
48
|
+
if (field.map) {
|
|
49
|
+
return Object.fromEntries(Object.entries(value as Obj).map(([key, item]) => [key, one(item)]));
|
|
50
|
+
}
|
|
51
|
+
if (field.repeated) return (value as unknown[]).map(one);
|
|
52
|
+
return one(value);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function walk(type: protobuf.Type, value: Obj, direction: 'out' | 'in'): Obj {
|
|
56
|
+
const out: Obj = {};
|
|
57
|
+
for (const [key, item] of Object.entries(value)) {
|
|
58
|
+
// proto3 JSON accepts the proto field name as well as the lowerCamel one
|
|
59
|
+
const field = type.fields[key] ?? type.fields[key.replace(/_([a-z0-9])/g, (_, c: string) => c.toUpperCase())];
|
|
60
|
+
if (!field) {
|
|
61
|
+
if (direction === 'in') throw new Error(`unknown field "${key}" for ${type.fullName.slice(1)}`);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
out[field.name] = convert(field, item, direction);
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The engine's plain form → proto3 JSON. */
|
|
70
|
+
export function toProto3Json(type: protobuf.Type, value: Obj): Obj {
|
|
71
|
+
return walk(type, value, 'out');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Proto3 JSON → the engine's plain form (throws on an unknown field or a malformed value). */
|
|
75
|
+
export function fromProto3Json(type: protobuf.Type, value: Obj): Obj {
|
|
76
|
+
return walk(type, value, 'in');
|
|
77
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// THE TEMPORAL API'S TYPES — protobufjs over the committed descriptor (src/generated/temporal-api.json,
|
|
2
|
+
// derived from spec/proto by ../proto-json.ts). Nothing here parses .proto text or reads a file: the
|
|
3
|
+
// descriptor is a JSON import, so the serve path loads the same bytes everywhere.
|
|
4
|
+
//
|
|
5
|
+
// The engine keeps every message in protobufjs's plain-object form with enums as their names, 64-bit
|
|
6
|
+
// integers as JS numbers, bytes as base64 strings and absent fields left out (`OBJECT`), which is
|
|
7
|
+
// what a JSON column stores and what `fromObject` takes back without loss for this API: the only
|
|
8
|
+
// 64-bit fields are event ids, counts and nanosecond-free seconds, all far below 2^53.
|
|
9
|
+
import protobuf from 'protobufjs';
|
|
10
|
+
import descriptor from './generated/temporal-api.json' with { type: 'json' };
|
|
11
|
+
|
|
12
|
+
export const temporalRoot: protobuf.Root = protobuf.Root.fromJSON(descriptor as protobuf.INamespace);
|
|
13
|
+
|
|
14
|
+
export const WORKFLOW_SERVICE = 'temporal.api.workflowservice.v1.WorkflowService';
|
|
15
|
+
export const OPERATOR_SERVICE = 'temporal.api.operatorservice.v1.OperatorService';
|
|
16
|
+
export const HEALTH_SERVICE = 'grpc.health.v1.Health';
|
|
17
|
+
|
|
18
|
+
/** protobufjs's toObject options for the engine's plain form (see the header). */
|
|
19
|
+
export const OBJECT: protobuf.IConversionOptions = { enums: String, longs: Number, bytes: String, defaults: false, arrays: false, objects: false, oneofs: false };
|
|
20
|
+
|
|
21
|
+
const typeCache = new Map<string, protobuf.Type>(); // cache: lookups into the committed descriptor
|
|
22
|
+
export function messageType(name: string): protobuf.Type {
|
|
23
|
+
let type = typeCache.get(name);
|
|
24
|
+
if (!type) {
|
|
25
|
+
type = temporalRoot.lookupType(name);
|
|
26
|
+
typeCache.set(name, type);
|
|
27
|
+
}
|
|
28
|
+
return type;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A service's methods, by name, with their request and response types. */
|
|
32
|
+
export function serviceMethods(service: string): Map<string, { requestType: protobuf.Type; responseType: protobuf.Type; requestStream: boolean; responseStream: boolean }> {
|
|
33
|
+
const svc = temporalRoot.lookupService(service);
|
|
34
|
+
svc.resolveAll();
|
|
35
|
+
const out = new Map<string, { requestType: protobuf.Type; responseType: protobuf.Type; requestStream: boolean; responseStream: boolean }>();
|
|
36
|
+
for (const method of svc.methodsArray) {
|
|
37
|
+
out.set(method.name, {
|
|
38
|
+
requestType: method.resolvedRequestType!,
|
|
39
|
+
responseType: method.resolvedResponseType!,
|
|
40
|
+
requestStream: Boolean(method.requestStream),
|
|
41
|
+
responseStream: Boolean(method.responseStream),
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function decodeMessage(type: protobuf.Type, bytes: Uint8Array): Record<string, unknown> {
|
|
48
|
+
return type.toObject(type.decode(bytes), OBJECT) as Record<string, unknown>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function encodeMessage(type: protobuf.Type, value: Record<string, unknown>): Uint8Array {
|
|
52
|
+
return type.encode(type.fromObject(value)).finish();
|
|
53
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// THE LISTENER — one loopback TCP port that speaks what a Temporal frontend speaks: gRPC over HTTP/2
|
|
2
|
+
// (h2c, prior knowledge; temporal-h2.ts + temporal-grpc.ts) for the SDKs, and HTTP/1 for the rest
|
|
3
|
+
// (temporal-http.ts: the World's doors and the frontend's HTTP API). Temporal serves its HTTP API on
|
|
4
|
+
// a second port (7243); here the first bytes of a connection say which it is, so a World's one port
|
|
5
|
+
// per service carries both. The World clock ticks here (the engine's timers, timeouts and retries).
|
|
6
|
+
import { createServer, type Server } from 'node:net';
|
|
7
|
+
import { acceptH2Connection, type H2Handler, type Http1Handler } from './temporal-h2.ts';
|
|
8
|
+
import { grpcStreamHandler, TEMPORAL_LOOPBACK_HOST } from './temporal-grpc.ts';
|
|
9
|
+
import { handleTemporalCall } from './temporal-twin.ts';
|
|
10
|
+
import { engineForRoot, releaseEngine, type TemporalEngine } from './temporal-engine.ts';
|
|
11
|
+
import { createTemporalTwinFetch } from './temporal-http.ts';
|
|
12
|
+
import { HEALTH_SERVICE, OPERATOR_SERVICE, WORKFLOW_SERVICE } from './temporal-proto.ts';
|
|
13
|
+
|
|
14
|
+
export { TEMPORAL_LOOPBACK_HOST };
|
|
15
|
+
|
|
16
|
+
export type H2Server = { port: number; stop: () => Promise<void> };
|
|
17
|
+
|
|
18
|
+
/** Listen on loopback for HTTP/2 (and, given `http1`, HTTP/1) connections. */
|
|
19
|
+
export async function serveH2(options: { port?: number; handler: H2Handler; http1?: Http1Handler }): Promise<H2Server> {
|
|
20
|
+
const connections = new Set<{ teardown(): void }>();
|
|
21
|
+
const server: Server = createServer((socket) => {
|
|
22
|
+
const connection = acceptH2Connection(socket, options.handler, options.http1);
|
|
23
|
+
connections.add(connection);
|
|
24
|
+
socket.on('close', () => connections.delete(connection));
|
|
25
|
+
});
|
|
26
|
+
await new Promise<void>((resolve, reject) => {
|
|
27
|
+
server.once('error', reject);
|
|
28
|
+
server.listen(options.port ?? 0, TEMPORAL_LOOPBACK_HOST, () => resolve());
|
|
29
|
+
});
|
|
30
|
+
const address = server.address();
|
|
31
|
+
const port = typeof address === 'object' && address ? address.port : 0;
|
|
32
|
+
return {
|
|
33
|
+
port,
|
|
34
|
+
stop: () => new Promise<void>((resolve) => {
|
|
35
|
+
for (const connection of connections) connection.teardown();
|
|
36
|
+
server.close(() => resolve());
|
|
37
|
+
}),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type TemporalTwinServer = {
|
|
42
|
+
/** the frontend port — what TEMPORAL_ADDRESS (`127.0.0.1:<port>`) names */
|
|
43
|
+
port: number;
|
|
44
|
+
engine: TemporalEngine;
|
|
45
|
+
stop: () => Promise<void>;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export async function createTemporalTwinServer(options: { port?: number; root?: string; readOnly?: boolean } = {}): Promise<TemporalTwinServer> {
|
|
49
|
+
const engine = engineForRoot({ ...(options.root !== undefined ? { root: options.root } : {}), ...(options.readOnly ? { readOnly: true } : {}) });
|
|
50
|
+
const server = await serveH2({
|
|
51
|
+
...(options.port !== undefined ? { port: options.port } : {}),
|
|
52
|
+
handler: grpcStreamHandler({
|
|
53
|
+
services: [WORKFLOW_SERVICE, OPERATOR_SERVICE, HEALTH_SERVICE],
|
|
54
|
+
handler: (service, method, request, ctx) => handleTemporalCall(engine, service, method, request, ctx),
|
|
55
|
+
}),
|
|
56
|
+
http1: createTemporalTwinFetch({ ...(options.root !== undefined ? { root: options.root } : {}), ...(options.readOnly ? { readOnly: true } : {}), engine }),
|
|
57
|
+
});
|
|
58
|
+
engine.start();
|
|
59
|
+
return {
|
|
60
|
+
port: server.port,
|
|
61
|
+
engine,
|
|
62
|
+
stop: async () => {
|
|
63
|
+
await server.stop();
|
|
64
|
+
releaseEngine(engine);
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// THE RPC TABLE — which of Temporal's frontend RPCs this twin answers, and with what.
|
|
2
|
+
//
|
|
3
|
+
// The denominator is the vendored protos' own service definitions (WorkflowService,
|
|
4
|
+
// OperatorService, and gRPC's Health): every method they declare reaches `handleTemporalCall`.
|
|
5
|
+
// A method in `SERVED` goes to the engine; every other one answers gRPC UNIMPLEMENTED naming the
|
|
6
|
+
// method, which is what the SDKs treat as "this server does not have that" — never a fabricated
|
|
7
|
+
// success. `temporal-capabilities.ts` reads the same table, so a served method is a `done` with a
|
|
8
|
+
// verify and an unserved one is a `todo`, by construction.
|
|
9
|
+
import { GrpcError, GrpcStatus, type GrpcCallContext } from './temporal-grpc.ts';
|
|
10
|
+
import { HEALTH_SERVICE, OPERATOR_SERVICE, WORKFLOW_SERVICE } from './temporal-proto.ts';
|
|
11
|
+
import type { TemporalEngine } from './temporal-engine.ts';
|
|
12
|
+
|
|
13
|
+
type Obj = Record<string, unknown>;
|
|
14
|
+
type Served = (engine: TemporalEngine, request: Obj, ctx: GrpcCallContext) => Promise<Obj> | Obj;
|
|
15
|
+
|
|
16
|
+
/** The Temporal server version this twin answers as: the API it vendors is the one the 1.15 SDKs
|
|
17
|
+
* (sdk-core 12c656f7) speak, which Temporal server 1.29 serves. */
|
|
18
|
+
export const TEMPORAL_SERVER_VERSION = '1.29.1';
|
|
19
|
+
|
|
20
|
+
/** GetSystemInfo's capabilities, each one true only when this twin does the thing it names. */
|
|
21
|
+
export const TEMPORAL_CAPABILITY_FLAGS = {
|
|
22
|
+
signalAndQueryHeader: true,
|
|
23
|
+
internalErrorDifferentiation: true,
|
|
24
|
+
activityFailureIncludeHeartbeat: true,
|
|
25
|
+
supportsSchedules: false,
|
|
26
|
+
encodedFailureAttributes: true,
|
|
27
|
+
buildIdBasedVersioning: false,
|
|
28
|
+
upsertMemo: true,
|
|
29
|
+
eagerWorkflowStart: false,
|
|
30
|
+
sdkMetadata: true,
|
|
31
|
+
countGroupByExecutionStatus: false,
|
|
32
|
+
nexus: false,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const SERVED: Record<string, Record<string, Served>> = {
|
|
36
|
+
[WORKFLOW_SERVICE]: {
|
|
37
|
+
GetSystemInfo: () => ({ serverVersion: TEMPORAL_SERVER_VERSION, capabilities: TEMPORAL_CAPABILITY_FLAGS }),
|
|
38
|
+
DescribeNamespace: (engine, request) => engine.namespace(String(request.namespace || request.id || '')),
|
|
39
|
+
ListNamespaces: (engine) => ({ namespaces: engine.namespaces() }),
|
|
40
|
+
RegisterNamespace: (engine, request) => engine.registerNamespace(request),
|
|
41
|
+
StartWorkflowExecution: (engine, request) => engine.startWorkflowExecution(request),
|
|
42
|
+
SignalWithStartWorkflowExecution: (engine, request) => engine.signalWithStartWorkflowExecution(request),
|
|
43
|
+
SignalWorkflowExecution: (engine, request) => engine.signalWorkflowExecution(request),
|
|
44
|
+
RequestCancelWorkflowExecution: (engine, request) => engine.requestCancelWorkflowExecution(request),
|
|
45
|
+
TerminateWorkflowExecution: (engine, request) => engine.terminateWorkflowExecution(request),
|
|
46
|
+
GetWorkflowExecutionHistory: (engine, request, ctx) => engine.getWorkflowExecutionHistory(request, ctx.signal, ctx.deadline),
|
|
47
|
+
DescribeWorkflowExecution: (engine, request) => engine.describeWorkflowExecution(request),
|
|
48
|
+
ListWorkflowExecutions: (engine, request) => engine.listWorkflowExecutions(request),
|
|
49
|
+
CountWorkflowExecutions: (engine, request) => engine.countWorkflowExecutions(request),
|
|
50
|
+
PollWorkflowTaskQueue: (engine, request, ctx) => engine.pollWorkflowTaskQueue(request, ctx.signal, ctx.deadline),
|
|
51
|
+
RespondWorkflowTaskCompleted: (engine, request) => engine.respondWorkflowTaskCompleted(request),
|
|
52
|
+
RespondWorkflowTaskFailed: (engine, request) => engine.respondWorkflowTaskFailed(request),
|
|
53
|
+
PollActivityTaskQueue: (engine, request, ctx) => engine.pollActivityTaskQueue(request, ctx.signal, ctx.deadline),
|
|
54
|
+
RespondActivityTaskCompleted: (engine, request) => engine.respondActivityTaskCompleted(request),
|
|
55
|
+
RespondActivityTaskCompletedById: (engine, request) => engine.respondActivityTaskCompleted(request, true),
|
|
56
|
+
RespondActivityTaskFailed: (engine, request) => engine.respondActivityTaskFailed(request),
|
|
57
|
+
RespondActivityTaskFailedById: (engine, request) => engine.respondActivityTaskFailed(request, true),
|
|
58
|
+
RespondActivityTaskCanceled: (engine, request) => engine.respondActivityTaskCanceled(request),
|
|
59
|
+
RespondActivityTaskCanceledById: (engine, request) => engine.respondActivityTaskCanceled(request, true),
|
|
60
|
+
RecordActivityTaskHeartbeat: (engine, request) => engine.recordActivityTaskHeartbeat(request),
|
|
61
|
+
RecordActivityTaskHeartbeatById: (engine, request) => engine.recordActivityTaskHeartbeat(request, true),
|
|
62
|
+
QueryWorkflow: (engine, request, ctx) => engine.queryWorkflow(request, ctx.signal, ctx.deadline),
|
|
63
|
+
RespondQueryTaskCompleted: (engine, request) => engine.respondQueryTaskCompleted(request),
|
|
64
|
+
// this server never routes a task to a sticky queue, so there is no stickiness to reset
|
|
65
|
+
ResetStickyTaskQueue: (engine, request) => {
|
|
66
|
+
const namespace = String(request.namespace ?? '');
|
|
67
|
+
engine.namespace(namespace);
|
|
68
|
+
engine.resolveRun(namespace, request.execution as Record<string, string> | undefined); // NOT_FOUND for no such execution
|
|
69
|
+
return {};
|
|
70
|
+
},
|
|
71
|
+
ShutdownWorker: (engine, request) => engine.shutdownWorker(request),
|
|
72
|
+
},
|
|
73
|
+
[OPERATOR_SERVICE]: {
|
|
74
|
+
ListSearchAttributes: (engine, request) => engine.listSearchAttributes(request),
|
|
75
|
+
AddSearchAttributes: (engine, request) => engine.addSearchAttributes(request),
|
|
76
|
+
RemoveSearchAttributes: (engine, request) => engine.removeSearchAttributes(request),
|
|
77
|
+
},
|
|
78
|
+
[HEALTH_SERVICE]: {
|
|
79
|
+
// grpc.health.v1: the empty name is the server as a whole; an unknown service is NOT_FOUND
|
|
80
|
+
Check: (_engine, request) => {
|
|
81
|
+
const service = String(request.service ?? '');
|
|
82
|
+
if (service && ![WORKFLOW_SERVICE, OPERATOR_SERVICE, HEALTH_SERVICE].includes(service)) throw new GrpcError(GrpcStatus.NOT_FOUND, `unknown service ${service}`);
|
|
83
|
+
return { status: 'SERVING' };
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/** One unary call, answered by the engine or refused UNIMPLEMENTED. */
|
|
89
|
+
export async function handleTemporalCall(engine: TemporalEngine, service: string, method: string, request: Obj, ctx: GrpcCallContext): Promise<Obj> {
|
|
90
|
+
const served = SERVED[service]?.[method];
|
|
91
|
+
const trace = process.env.TEMPORAL_TWIN_TRACE === '1';
|
|
92
|
+
if (trace) process.stderr.write(`[temporal] → ${method} ${JSON.stringify(request).slice(0, 400)}\n`);
|
|
93
|
+
if (!served) throw new GrpcError(GrpcStatus.UNIMPLEMENTED, `${method} is not implemented by this Temporal twin`);
|
|
94
|
+
try {
|
|
95
|
+
const answer = await served(engine, request, ctx);
|
|
96
|
+
if (trace) process.stderr.write(`[temporal] ← ${method} ${JSON.stringify(answer).slice(0, 400)}\n`);
|
|
97
|
+
return answer;
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if (trace) process.stderr.write(`[temporal] ✗ ${method} ${(error as Error).message}\n`);
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
}
|