@xmemory/temporal 1.0.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 +21 -0
- package/NOTICE +27 -0
- package/README.md +371 -0
- package/dist/activities.d.ts +27 -0
- package/dist/activities.js +195 -0
- package/dist/config.d.ts +53 -0
- package/dist/config.js +114 -0
- package/dist/deadline.d.ts +32 -0
- package/dist/deadline.js +50 -0
- package/dist/defaults.d.ts +49 -0
- package/dist/defaults.js +58 -0
- package/dist/dto.d.ts +54 -0
- package/dist/dto.js +83 -0
- package/dist/errors.d.ts +18 -0
- package/dist/errors.js +247 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +38 -0
- package/dist/interceptor.d.ts +38 -0
- package/dist/interceptor.js +113 -0
- package/dist/names.d.ts +29 -0
- package/dist/names.js +47 -0
- package/dist/plugin.d.ts +31 -0
- package/dist/plugin.js +81 -0
- package/dist/workflow.d.ts +130 -0
- package/dist/workflow.js +412 -0
- package/package.json +70 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The workflow-facing xmemory surface.
|
|
3
|
+
*
|
|
4
|
+
* `WorkflowXmemory` mirrors the plain client's method *names*, so existing agent
|
|
5
|
+
* call sites keep working and just dispatch to an activity. Two differences: the
|
|
6
|
+
* enqueue is `writeAsyncStart`, and results are this package's own DTOs rather than
|
|
7
|
+
* the client's raw shapes. Replay-safe by construction: only `proxyActivities` and
|
|
8
|
+
* `sleep`, no I/O and no wall-clock.
|
|
9
|
+
*/
|
|
10
|
+
import type { Duration, RetryPolicy } from '@temporalio/common';
|
|
11
|
+
import type { ReadMode, WriteMutation, ReadScope, ReadOutput, WriteOutput, WriteStartOutput, WriteStatusOutput } from './dto';
|
|
12
|
+
/** How one status poll retries. Turned into a RetryPolicy by this package. */
|
|
13
|
+
export interface WriteStatusRetry {
|
|
14
|
+
/** Attempts per poll, including the first. Default 10; `Infinity` for unlimited. */
|
|
15
|
+
attempts?: number;
|
|
16
|
+
/** Delay before the first retry, in ms. Default 1000. */
|
|
17
|
+
intervalMs?: number;
|
|
18
|
+
/** Ceiling on the backed-off delay, in ms. Default 20000. */
|
|
19
|
+
maxIntervalMs?: number;
|
|
20
|
+
/**
|
|
21
|
+
* Failure `type`s that end the polling instead of being retried.
|
|
22
|
+
*
|
|
23
|
+
* Only useful for types this package maps as retryable — an already
|
|
24
|
+
* non-retryable failure stops the polling on its own.
|
|
25
|
+
*/
|
|
26
|
+
nonRetryableErrorTypes?: readonly string[];
|
|
27
|
+
}
|
|
28
|
+
export interface WorkflowXmemoryOptions {
|
|
29
|
+
readTimeout?: Duration;
|
|
30
|
+
writeTimeout?: Duration;
|
|
31
|
+
writeStartTimeout?: Duration;
|
|
32
|
+
writeStatusTimeout?: Duration;
|
|
33
|
+
readRetryPolicy?: RetryPolicy;
|
|
34
|
+
writeRetryPolicy?: RetryPolicy;
|
|
35
|
+
/**
|
|
36
|
+
* How each status poll retries.
|
|
37
|
+
*
|
|
38
|
+
* Scalars rather than a `RetryPolicy`: Temporal compiles a policy when it
|
|
39
|
+
* schedules the Activity, and for the first poll that is after `writeDurable` has
|
|
40
|
+
* enqueued the write. Built here, it cannot be a policy Temporal refuses. Distinct
|
|
41
|
+
* from `WriteDurableOptions.pollIntervalMs`, which paces the loop between polls.
|
|
42
|
+
*/
|
|
43
|
+
writeStatusRetry?: WriteStatusRetry;
|
|
44
|
+
/**
|
|
45
|
+
* Total bound on a call including its retries, as `scheduleToCloseTimeout`.
|
|
46
|
+
*
|
|
47
|
+
* The per-call timeouts above bound one *attempt*. Nothing bounds the sequence
|
|
48
|
+
* unless this is set, and the server's own `Retry-After` is honoured as given —
|
|
49
|
+
* so a rate-limited read with an hour-long hint can sit in retries for hours.
|
|
50
|
+
* Unset by default, which is Temporal's behaviour; set it when a call has a
|
|
51
|
+
* deadline of its own.
|
|
52
|
+
*/
|
|
53
|
+
totalTimeout?: Duration;
|
|
54
|
+
includeContentInSummary?: boolean;
|
|
55
|
+
}
|
|
56
|
+
export interface WriteDurableOptions {
|
|
57
|
+
extractionLogic?: 'fast' | 'deep';
|
|
58
|
+
diffEngine?: boolean;
|
|
59
|
+
structuredMutations?: readonly WriteMutation[];
|
|
60
|
+
pollIntervalMs?: number;
|
|
61
|
+
maxPollIntervalMs?: number;
|
|
62
|
+
maxWaitMs?: number;
|
|
63
|
+
}
|
|
64
|
+
export declare class WorkflowXmemory {
|
|
65
|
+
private readonly opts;
|
|
66
|
+
constructor(options?: WorkflowXmemoryOptions);
|
|
67
|
+
read(query: string, options?: {
|
|
68
|
+
readMode?: ReadMode;
|
|
69
|
+
scope?: ReadScope;
|
|
70
|
+
}): Promise<ReadOutput>;
|
|
71
|
+
/**
|
|
72
|
+
* Write memory, from free `text` or explicit `structuredMutations`.
|
|
73
|
+
*
|
|
74
|
+
* Structured mutations skip extraction, so an update or delete addressed by a
|
|
75
|
+
* primary key is safe to retry. A create without one is not: the server assigns
|
|
76
|
+
* the key, so a lost response plus a retry inserts the record twice.
|
|
77
|
+
*/
|
|
78
|
+
write(text: string, options?: {
|
|
79
|
+
extractionLogic?: 'fast' | 'deep';
|
|
80
|
+
diffEngine?: boolean;
|
|
81
|
+
structuredMutations?: readonly WriteMutation[];
|
|
82
|
+
}): Promise<WriteOutput>;
|
|
83
|
+
/**
|
|
84
|
+
* Enqueue a write and return its id, without waiting for it.
|
|
85
|
+
*
|
|
86
|
+
* Nothing is forced here: an omitted `extractionLogic` resolves worker-side to
|
|
87
|
+
* `XmemoryConfig.defaultExtractionLogic`. Only `writeDurable` asks for `deep`.
|
|
88
|
+
*/
|
|
89
|
+
writeAsyncStart(text: string, options?: {
|
|
90
|
+
extractionLogic?: 'fast' | 'deep';
|
|
91
|
+
diffEngine?: boolean;
|
|
92
|
+
structuredMutations?: readonly WriteMutation[];
|
|
93
|
+
}): Promise<WriteStartOutput>;
|
|
94
|
+
/** Poll a queued write once. The caller owns the overall wait. */
|
|
95
|
+
writeStatus(writeId: string): Promise<WriteStatusOutput>;
|
|
96
|
+
/**
|
|
97
|
+
* `writeStatus` with an optional deadline, for `writeDurable`.
|
|
98
|
+
*
|
|
99
|
+
* `bound` caps the whole poll, retries included. Without it a rate-limited poll
|
|
100
|
+
* can back off far past the caller's `maxWaitMs` and surface as
|
|
101
|
+
* XmemoryRateLimited instead of XmemoryWriteTimeout.
|
|
102
|
+
*
|
|
103
|
+
* `singleAttempt` is for the last look taken *at* the deadline, which nothing
|
|
104
|
+
* else bounds. Letting it retry turned a 10s wait into 35s.
|
|
105
|
+
*/
|
|
106
|
+
private pollStatus;
|
|
107
|
+
/**
|
|
108
|
+
* Enqueue a write and poll it to completion, durably. The poll loop lives in
|
|
109
|
+
* workflow history, so a slow extraction survives worker restarts — the whole
|
|
110
|
+
* reason to put Temporal in front of xmemory. The enqueue is the only
|
|
111
|
+
* non-idempotent step; the extraction is observed through idempotent polls.
|
|
112
|
+
*
|
|
113
|
+
* `text` is required, with no default: a default made it optional in the emitted
|
|
114
|
+
* declaration, and `writeDurable()` then enqueued an empty deep write. A caller
|
|
115
|
+
* sending only `structuredMutations` passes `''` and means it.
|
|
116
|
+
*/
|
|
117
|
+
writeDurable(text: string, options?: WriteDurableOptions): Promise<WriteStatusOutput>;
|
|
118
|
+
/** The configured total bound, as proxy options. Empty when none is set. */
|
|
119
|
+
private totalBound;
|
|
120
|
+
/** The tighter of the loop's own bound and the caller's `totalTimeout`. */
|
|
121
|
+
private pollBound;
|
|
122
|
+
private summary;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* A {@link WorkflowXmemory} handle. Carries only configuration (no per-call
|
|
126
|
+
* mutable state), so making a fresh one per call is correct and cheap.
|
|
127
|
+
*/
|
|
128
|
+
export declare function xmemoryForWorkflow(options?: WorkflowXmemoryOptions): WorkflowXmemory;
|
|
129
|
+
export { TYPE_AUTH_FAILED, TYPE_BAD_OPTIONS, TYPE_BAD_REQUEST, TYPE_DAILY_QUOTA_EXCEEDED, TYPE_DEADLINE_EXPIRED, TYPE_MONTHLY_QUOTA_EXCEEDED, TYPE_NOT_BOUND, TYPE_NOT_FOUND, TYPE_NO_DEADLINE, TYPE_QUOTA_EXCEEDED, TYPE_RATE_LIMITED, TYPE_SCHEMA_REJECTED, TYPE_SERVER_ERROR, TYPE_UNAVAILABLE, TYPE_UNKNOWN, TYPE_WRITE_FAILED, TYPE_WRITE_NOT_FOUND, TYPE_WRITE_TIMEOUT, } from './names';
|
|
130
|
+
export type { ObjectMutationBody, ReadMode, ReadOutput, ReadScope, RelationEndpoint, RelationMutationBody, RelationsScope, ScopeObject, SubAnswer, WriteMutation, WriteOutput, WriteStartOutput, WriteStatusOutput, } from './dto';
|
package/dist/workflow.js
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The workflow-facing xmemory surface.
|
|
4
|
+
*
|
|
5
|
+
* `WorkflowXmemory` mirrors the plain client's method *names*, so existing agent
|
|
6
|
+
* call sites keep working and just dispatch to an activity. Two differences: the
|
|
7
|
+
* enqueue is `writeAsyncStart`, and results are this package's own DTOs rather than
|
|
8
|
+
* the client's raw shapes. Replay-safe by construction: only `proxyActivities` and
|
|
9
|
+
* `sleep`, no I/O and no wall-clock.
|
|
10
|
+
*/
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.TYPE_WRITE_TIMEOUT = exports.TYPE_WRITE_NOT_FOUND = exports.TYPE_WRITE_FAILED = exports.TYPE_UNKNOWN = exports.TYPE_UNAVAILABLE = exports.TYPE_SERVER_ERROR = exports.TYPE_SCHEMA_REJECTED = exports.TYPE_RATE_LIMITED = exports.TYPE_QUOTA_EXCEEDED = exports.TYPE_NO_DEADLINE = exports.TYPE_NOT_FOUND = exports.TYPE_NOT_BOUND = exports.TYPE_MONTHLY_QUOTA_EXCEEDED = exports.TYPE_DEADLINE_EXPIRED = exports.TYPE_DAILY_QUOTA_EXCEEDED = exports.TYPE_BAD_REQUEST = exports.TYPE_BAD_OPTIONS = exports.TYPE_AUTH_FAILED = exports.WorkflowXmemory = void 0;
|
|
13
|
+
exports.xmemoryForWorkflow = xmemoryForWorkflow;
|
|
14
|
+
const workflow_1 = require("@temporalio/workflow");
|
|
15
|
+
const common_1 = require("@temporalio/common");
|
|
16
|
+
const names_1 = require("./names");
|
|
17
|
+
// The workflow owns every activity budget: what is set here is what Temporal
|
|
18
|
+
// enforces AND what each activity derives its client timeout from, so the two
|
|
19
|
+
// can never disagree. `DEFAULT_TIMEOUTS` is only the source of the numbers.
|
|
20
|
+
const defaults_1 = require("./defaults");
|
|
21
|
+
// Terminal `WriteQueueStatus` values (see xmemory's WriteQueueStatus).
|
|
22
|
+
const STATUS_COMPLETED = 'completed';
|
|
23
|
+
const STATUS_FAILED = 'failed';
|
|
24
|
+
const STATUS_NOT_FOUND = 'not_found';
|
|
25
|
+
// Non-terminal states we keep polling through. Listed explicitly so a new,
|
|
26
|
+
// unseen server-side state is recognised as unknown and logged, rather than
|
|
27
|
+
// passing silently — the loop polls on either way, since a state the enum has
|
|
28
|
+
// grown must not fail a write that is still in flight.
|
|
29
|
+
const STATUS_IN_PROGRESS = new Set(['queued', 'processing', 'extracting', 'extracted', 'applying']);
|
|
30
|
+
const DEFAULT_READ_RETRY = {
|
|
31
|
+
initialInterval: '1s',
|
|
32
|
+
backoffCoefficient: 2,
|
|
33
|
+
maximumInterval: '30s',
|
|
34
|
+
maximumAttempts: 10,
|
|
35
|
+
};
|
|
36
|
+
// At-most-once: xmemory assigns primary keys with a model, so a re-extraction can
|
|
37
|
+
// normalize the same value differently and fork the record. A failed write is
|
|
38
|
+
// surfaced to the workflow rather than retried. See the README's idempotency
|
|
39
|
+
// section for when opting in is safe.
|
|
40
|
+
const DEFAULT_WRITE_RETRY = { maximumAttempts: 1 };
|
|
41
|
+
/**
|
|
42
|
+
* A duration in ms, or a non-retryable option failure.
|
|
43
|
+
*
|
|
44
|
+
* `msToNumber` throws a raw TypeError on a malformed string and passes `Infinity`
|
|
45
|
+
* straight through; either reaches Temporal and fails the Workflow Task over and
|
|
46
|
+
* over, with a durable write already enqueued.
|
|
47
|
+
*/
|
|
48
|
+
function durationMs(value, label) {
|
|
49
|
+
let ms;
|
|
50
|
+
try {
|
|
51
|
+
ms = (0, common_1.msToNumber)(value);
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
throw workflow_1.ApplicationFailure.create({
|
|
55
|
+
message: `${label} is not a valid duration: ${String(err)}`,
|
|
56
|
+
type: names_1.TYPE_BAD_OPTIONS,
|
|
57
|
+
nonRetryable: true,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
// `< 1`, not `<= 0`: Temporal truncates a sub-millisecond duration to zero, and a
|
|
61
|
+
// zeroed startToClose falls back to the default schedule-to-close — ten years.
|
|
62
|
+
if (!Number.isFinite(ms) || ms < 1 || ms > defaults_1.MAX_DURATION_MS) {
|
|
63
|
+
throw workflow_1.ApplicationFailure.create({
|
|
64
|
+
message: `${label} must be between 1 and ${defaults_1.MAX_DURATION_MS}ms, got ${ms}`,
|
|
65
|
+
type: names_1.TYPE_BAD_OPTIONS,
|
|
66
|
+
nonRetryable: true,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
return ms;
|
|
70
|
+
}
|
|
71
|
+
const DEFAULT_WRITE_STATUS_RETRY = { attempts: 10, intervalMs: 1_000, maxIntervalMs: 20_000 };
|
|
72
|
+
/**
|
|
73
|
+
* A poll retry policy built from scalars rather than taken from the caller.
|
|
74
|
+
*
|
|
75
|
+
* Temporal compiles a retry policy when it schedules the Activity, which for the
|
|
76
|
+
* first status poll is *after* `writeDurable` has enqueued the write — so an
|
|
77
|
+
* unusable one leaves a queued write nobody polls. Building it here means there is
|
|
78
|
+
* no policy to be unusable: three numbers are checked, and what Temporal gets is
|
|
79
|
+
* something it always accepts.
|
|
80
|
+
*/
|
|
81
|
+
function buildWriteStatusRetry(retry) {
|
|
82
|
+
const opts = retry ?? {};
|
|
83
|
+
const attempts = opts.attempts ?? DEFAULT_WRITE_STATUS_RETRY.attempts;
|
|
84
|
+
// `Infinity` is Temporal's own spelling of unlimited, and compiles away to unset.
|
|
85
|
+
if (attempts !== Number.POSITIVE_INFINITY && (!Number.isInteger(attempts) || attempts < 1)) {
|
|
86
|
+
throw workflow_1.ApplicationFailure.create({
|
|
87
|
+
message: `writeStatusRetry.attempts must be a positive integer or Infinity, got ${String(attempts)}`,
|
|
88
|
+
type: names_1.TYPE_BAD_OPTIONS,
|
|
89
|
+
nonRetryable: true,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
const intervalMs = durationMs(opts.intervalMs ?? DEFAULT_WRITE_STATUS_RETRY.intervalMs, 'writeStatusRetry.intervalMs');
|
|
93
|
+
const maxIntervalMs = durationMs(opts.maxIntervalMs ?? DEFAULT_WRITE_STATUS_RETRY.maxIntervalMs, 'writeStatusRetry.maxIntervalMs');
|
|
94
|
+
if (maxIntervalMs < intervalMs) {
|
|
95
|
+
throw workflow_1.ApplicationFailure.create({
|
|
96
|
+
message: `writeStatusRetry.maxIntervalMs (${maxIntervalMs}) must not be below intervalMs (${intervalMs})`,
|
|
97
|
+
type: names_1.TYPE_BAD_OPTIONS,
|
|
98
|
+
nonRetryable: true,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
const types = opts.nonRetryableErrorTypes;
|
|
102
|
+
return {
|
|
103
|
+
initialInterval: intervalMs,
|
|
104
|
+
backoffCoefficient: 2,
|
|
105
|
+
maximumInterval: maxIntervalMs,
|
|
106
|
+
maximumAttempts: attempts,
|
|
107
|
+
...(types !== undefined ? { nonRetryableErrorTypes: [...types] } : {}),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
class WorkflowXmemory {
|
|
111
|
+
opts;
|
|
112
|
+
constructor(options = {}) {
|
|
113
|
+
this.opts = {
|
|
114
|
+
readTimeout: options.readTimeout ?? defaults_1.DEFAULT_TIMEOUTS.readMs,
|
|
115
|
+
writeTimeout: options.writeTimeout ?? defaults_1.DEFAULT_TIMEOUTS.writeMs,
|
|
116
|
+
writeStartTimeout: options.writeStartTimeout ?? defaults_1.DEFAULT_TIMEOUTS.writeStartMs,
|
|
117
|
+
writeStatusTimeout: options.writeStatusTimeout ?? defaults_1.DEFAULT_TIMEOUTS.writeStatusMs,
|
|
118
|
+
readRetry: options.readRetryPolicy ?? DEFAULT_READ_RETRY,
|
|
119
|
+
writeRetry: options.writeRetryPolicy ?? DEFAULT_WRITE_RETRY,
|
|
120
|
+
pollRetry: buildWriteStatusRetry(options.writeStatusRetry),
|
|
121
|
+
totalTimeout: options.totalTimeout,
|
|
122
|
+
// `=== true`, not truthiness: options can be built from config, and the string
|
|
123
|
+
// "false" would otherwise put memory text into the Activity summary, which is
|
|
124
|
+
// persisted to workflow history.
|
|
125
|
+
includeContent: options.includeContentInSummary === true,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
async read(query, options = {}) {
|
|
129
|
+
const acts = (0, workflow_1.proxyActivities)({
|
|
130
|
+
startToCloseTimeout: this.opts.readTimeout,
|
|
131
|
+
...this.totalBound(),
|
|
132
|
+
retry: this.opts.readRetry,
|
|
133
|
+
summary: this.summary('read', query),
|
|
134
|
+
});
|
|
135
|
+
// Named fields, not a spread, and the positional value last: an options object
|
|
136
|
+
// carrying its own `query` would otherwise replace what the caller passed.
|
|
137
|
+
return acts[names_1.ACTIVITY_READ]({ readMode: options.readMode, scope: options.scope, query });
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Write memory, from free `text` or explicit `structuredMutations`.
|
|
141
|
+
*
|
|
142
|
+
* Structured mutations skip extraction, so an update or delete addressed by a
|
|
143
|
+
* primary key is safe to retry. A create without one is not: the server assigns
|
|
144
|
+
* the key, so a lost response plus a retry inserts the record twice.
|
|
145
|
+
*/
|
|
146
|
+
async write(text, options = {}) {
|
|
147
|
+
const acts = (0, workflow_1.proxyActivities)({
|
|
148
|
+
startToCloseTimeout: this.opts.writeTimeout,
|
|
149
|
+
...this.totalBound(),
|
|
150
|
+
retry: this.opts.writeRetry,
|
|
151
|
+
summary: this.summary('write', text, options.extractionLogic),
|
|
152
|
+
});
|
|
153
|
+
return acts[names_1.ACTIVITY_WRITE]({
|
|
154
|
+
extractionLogic: options.extractionLogic,
|
|
155
|
+
diffEngine: options.diffEngine,
|
|
156
|
+
structuredMutations: options.structuredMutations,
|
|
157
|
+
text,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Enqueue a write and return its id, without waiting for it.
|
|
162
|
+
*
|
|
163
|
+
* Nothing is forced here: an omitted `extractionLogic` resolves worker-side to
|
|
164
|
+
* `XmemoryConfig.defaultExtractionLogic`. Only `writeDurable` asks for `deep`.
|
|
165
|
+
*/
|
|
166
|
+
async writeAsyncStart(text, options = {}) {
|
|
167
|
+
const logic = options.extractionLogic;
|
|
168
|
+
const acts = (0, workflow_1.proxyActivities)({
|
|
169
|
+
startToCloseTimeout: this.opts.writeStartTimeout,
|
|
170
|
+
...this.totalBound(),
|
|
171
|
+
retry: this.opts.writeRetry,
|
|
172
|
+
summary: this.summary('write_start', text, logic),
|
|
173
|
+
});
|
|
174
|
+
return acts[names_1.ACTIVITY_WRITE_START]({
|
|
175
|
+
text,
|
|
176
|
+
extractionLogic: logic,
|
|
177
|
+
diffEngine: options.diffEngine,
|
|
178
|
+
structuredMutations: options.structuredMutations,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
/** Poll a queued write once. The caller owns the overall wait. */
|
|
182
|
+
async writeStatus(writeId) {
|
|
183
|
+
return this.pollStatus(writeId, undefined);
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* `writeStatus` with an optional deadline, for `writeDurable`.
|
|
187
|
+
*
|
|
188
|
+
* `bound` caps the whole poll, retries included. Without it a rate-limited poll
|
|
189
|
+
* can back off far past the caller's `maxWaitMs` and surface as
|
|
190
|
+
* XmemoryRateLimited instead of XmemoryWriteTimeout.
|
|
191
|
+
*
|
|
192
|
+
* `singleAttempt` is for the last look taken *at* the deadline, which nothing
|
|
193
|
+
* else bounds. Letting it retry turned a 10s wait into 35s.
|
|
194
|
+
*/
|
|
195
|
+
async pollStatus(writeId, bound, singleAttempt = false) {
|
|
196
|
+
const acts = (0, workflow_1.proxyActivities)({
|
|
197
|
+
startToCloseTimeout: this.opts.writeStatusTimeout,
|
|
198
|
+
...this.pollBound(bound),
|
|
199
|
+
retry: singleAttempt ? { ...this.opts.pollRetry, maximumAttempts: 1 } : this.opts.pollRetry,
|
|
200
|
+
summary: `xmemory write_status: ${writeId}`,
|
|
201
|
+
});
|
|
202
|
+
return acts[names_1.ACTIVITY_WRITE_STATUS]({ writeId });
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Enqueue a write and poll it to completion, durably. The poll loop lives in
|
|
206
|
+
* workflow history, so a slow extraction survives worker restarts — the whole
|
|
207
|
+
* reason to put Temporal in front of xmemory. The enqueue is the only
|
|
208
|
+
* non-idempotent step; the extraction is observed through idempotent polls.
|
|
209
|
+
*
|
|
210
|
+
* `text` is required, with no default: a default made it optional in the emitted
|
|
211
|
+
* declaration, and `writeDurable()` then enqueued an empty deep write. A caller
|
|
212
|
+
* sending only `structuredMutations` passes `''` and means it.
|
|
213
|
+
*/
|
|
214
|
+
async writeDurable(text, options = {}) {
|
|
215
|
+
// Everything below is validated before the enqueue: a rejected option must not
|
|
216
|
+
// leave a queued write behind that nobody is waiting on.
|
|
217
|
+
let delayMs = options.pollIntervalMs ?? 2_000;
|
|
218
|
+
const capMs = options.maxPollIntervalMs ?? 30_000;
|
|
219
|
+
const maxWaitMs = options.maxWaitMs ?? 15 * 60_000;
|
|
220
|
+
const statusBudgetMs = durationMs(this.opts.writeStatusTimeout, 'writeDurable: writeStatusTimeout');
|
|
221
|
+
for (const [name, value] of [
|
|
222
|
+
['maxWaitMs', maxWaitMs],
|
|
223
|
+
['pollIntervalMs', delayMs],
|
|
224
|
+
['maxPollIntervalMs', capMs],
|
|
225
|
+
]) {
|
|
226
|
+
// Below a millisecond Temporal truncates to zero, which hot-polls against its
|
|
227
|
+
// timer floor; the upper bound is what setTimeout and the service can represent.
|
|
228
|
+
if (!Number.isFinite(value) || value < 1 || value > defaults_1.MAX_DURATION_MS) {
|
|
229
|
+
throw workflow_1.ApplicationFailure.create({
|
|
230
|
+
message: `writeDurable: ${name} must be between 1 and ${defaults_1.MAX_DURATION_MS}, got ${value}`,
|
|
231
|
+
type: names_1.TYPE_BAD_OPTIONS,
|
|
232
|
+
nonRetryable: true,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (capMs < delayMs) {
|
|
237
|
+
throw workflow_1.ApplicationFailure.create({
|
|
238
|
+
message: `writeDurable: maxPollIntervalMs (${capMs}) must not be below pollIntervalMs (${delayMs})`,
|
|
239
|
+
type: names_1.TYPE_BAD_OPTIONS,
|
|
240
|
+
nonRetryable: true,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
const start = await this.writeAsyncStart(text, {
|
|
244
|
+
extractionLogic: options.extractionLogic ?? 'deep',
|
|
245
|
+
structuredMutations: options.structuredMutations,
|
|
246
|
+
diffEngine: options.diffEngine,
|
|
247
|
+
});
|
|
248
|
+
// `Date.now()` is workflow time inside a Temporal workflow, so this is
|
|
249
|
+
// replay-safe.
|
|
250
|
+
const deadline = Date.now() + maxWaitMs;
|
|
251
|
+
let warnedHistory = false;
|
|
252
|
+
let lastStatus;
|
|
253
|
+
const maxWaitElapsed = () => workflow_1.ApplicationFailure.create({
|
|
254
|
+
message: `xmemory write ${start.writeId} did not complete within ${maxWaitMs}ms`,
|
|
255
|
+
type: names_1.TYPE_WRITE_TIMEOUT,
|
|
256
|
+
nonRetryable: true,
|
|
257
|
+
details: [{ writeId: start.writeId, lastStatus: lastStatus ?? 'unknown' }],
|
|
258
|
+
});
|
|
259
|
+
let final = false;
|
|
260
|
+
for (;;) {
|
|
261
|
+
let leftMs = deadline - Date.now();
|
|
262
|
+
if (leftMs <= 0 && !final)
|
|
263
|
+
throw maxWaitElapsed();
|
|
264
|
+
// Ordinary polls are bounded by the wait. The last observation happens *at*
|
|
265
|
+
// the deadline and gets one ordinary budget: the grace on top of the wait.
|
|
266
|
+
const boundMs = final ? statusBudgetMs : Math.min(leftMs, statusBudgetMs);
|
|
267
|
+
let backoffMs = delayMs;
|
|
268
|
+
let hintMs;
|
|
269
|
+
try {
|
|
270
|
+
const status = await this.pollStatus(start.writeId, boundMs, final);
|
|
271
|
+
lastStatus = status.writeStatus;
|
|
272
|
+
const terminal = interpretStatus(status);
|
|
273
|
+
if (terminal)
|
|
274
|
+
return terminal;
|
|
275
|
+
}
|
|
276
|
+
catch (err) {
|
|
277
|
+
// Cancellation must reach the caller as cancellation, not as a timeout
|
|
278
|
+
// verdict we invented.
|
|
279
|
+
if ((0, workflow_1.isCancellation)(err))
|
|
280
|
+
throw err;
|
|
281
|
+
if (!(err instanceof common_1.ActivityFailure))
|
|
282
|
+
throw err;
|
|
283
|
+
const cause = err.cause;
|
|
284
|
+
if (cause instanceof common_1.ApplicationFailure && cause.nonRetryable)
|
|
285
|
+
throw err;
|
|
286
|
+
// A `nonRetryableErrorTypes` match is reported here, not on the cause,
|
|
287
|
+
// whose own `nonRetryable` stays false. Polling on would hide the failure
|
|
288
|
+
// behind a timeout verdict of our own.
|
|
289
|
+
if (err.retryState === common_1.RetryState.NON_RETRYABLE_FAILURE)
|
|
290
|
+
throw err;
|
|
291
|
+
// A poll ending is not the wait ending: Temporal stops one when its retries
|
|
292
|
+
// are spent. Honor the server's pacing, which arrives in `details`.
|
|
293
|
+
const detail = (cause instanceof common_1.ApplicationFailure ? cause.details?.[0] : undefined);
|
|
294
|
+
if (typeof detail?.retryAfterSeconds === 'number') {
|
|
295
|
+
hintMs = detail.retryAfterSeconds * 1_000;
|
|
296
|
+
backoffMs = Math.max(backoffMs, hintMs);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
// Polling grows history, and this helper cannot call continueAsNew from
|
|
300
|
+
// inside the caller's workflow. Surface Temporal's own signal so they can
|
|
301
|
+
// move the durable write into a child workflow. Checked every iteration, so
|
|
302
|
+
// a loop that is about to stop still reports it.
|
|
303
|
+
if (!warnedHistory && (0, workflow_1.workflowInfo)().continueAsNewSuggested) {
|
|
304
|
+
warnedHistory = true;
|
|
305
|
+
workflow_1.log.warn('xmemory writeDurable has polled into a history Temporal suggests continuing-as-new', {
|
|
306
|
+
writeId: start.writeId,
|
|
307
|
+
hint: 'run it in a child workflow, or raise maxPollIntervalMs',
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
if (final)
|
|
311
|
+
throw maxWaitElapsed();
|
|
312
|
+
// Decided on the clock, not the budget allocated before the poll: a fast
|
|
313
|
+
// reply must leave room for the next one.
|
|
314
|
+
leftMs = deadline - Date.now();
|
|
315
|
+
if (backoffMs < leftMs) {
|
|
316
|
+
await (0, workflow_1.sleep)(backoffMs);
|
|
317
|
+
delayMs = Math.min(delayMs * 1.5, capMs);
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
if (hintMs !== undefined && hintMs > leftMs) {
|
|
321
|
+
// The server will not answer before the deadline, so a last look would only
|
|
322
|
+
// arrive early.
|
|
323
|
+
await (0, workflow_1.sleep)(Math.max(0, leftMs));
|
|
324
|
+
throw maxWaitElapsed();
|
|
325
|
+
}
|
|
326
|
+
// Our own cadence does not fit. Wait the rest of the wait out, then take one
|
|
327
|
+
// last look: the write may still land inside it.
|
|
328
|
+
final = true;
|
|
329
|
+
await (0, workflow_1.sleep)(Math.max(0, leftMs));
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
/** The configured total bound, as proxy options. Empty when none is set. */
|
|
333
|
+
totalBound() {
|
|
334
|
+
return this.opts.totalTimeout === undefined ? {} : { scheduleToCloseTimeout: this.opts.totalTimeout };
|
|
335
|
+
}
|
|
336
|
+
/** The tighter of the loop's own bound and the caller's `totalTimeout`. */
|
|
337
|
+
pollBound(bound) {
|
|
338
|
+
if (bound === undefined)
|
|
339
|
+
return this.totalBound();
|
|
340
|
+
if (this.opts.totalTimeout === undefined)
|
|
341
|
+
return { scheduleToCloseTimeout: bound };
|
|
342
|
+
// Temporal already accepted `totalTimeout` when it scheduled the enqueue.
|
|
343
|
+
return { scheduleToCloseTimeout: Math.min((0, common_1.msToNumber)(bound), (0, common_1.msToNumber)(this.opts.totalTimeout)) };
|
|
344
|
+
}
|
|
345
|
+
summary(op, content, logic) {
|
|
346
|
+
const label = logic ? `xmemory ${op} (${logic})` : `xmemory ${op}`;
|
|
347
|
+
return this.opts.includeContent ? `${label}: ${content.slice(0, 60)}` : `${label}: ${content.length} chars`;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
exports.WorkflowXmemory = WorkflowXmemory;
|
|
351
|
+
function interpretStatus(status) {
|
|
352
|
+
const value = status.writeStatus;
|
|
353
|
+
if (value === STATUS_COMPLETED)
|
|
354
|
+
return status;
|
|
355
|
+
if (value === STATUS_FAILED) {
|
|
356
|
+
// The server's detail never leaves the worker: Temporal persists both Activity
|
|
357
|
+
// results and failure details to cleartext history.
|
|
358
|
+
throw workflow_1.ApplicationFailure.create({
|
|
359
|
+
message: `xmemory write ${status.writeId} failed`,
|
|
360
|
+
type: names_1.TYPE_WRITE_FAILED,
|
|
361
|
+
nonRetryable: true,
|
|
362
|
+
details: [{ writeId: status.writeId, writeStatus: status.writeStatus }],
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
if (value === STATUS_NOT_FOUND) {
|
|
366
|
+
// `writeAsync` is transactional, so a returned id is always queryable. A
|
|
367
|
+
// not_found here means the write is genuinely gone.
|
|
368
|
+
throw workflow_1.ApplicationFailure.create({
|
|
369
|
+
message: `xmemory write ${status.writeId} not found`,
|
|
370
|
+
type: names_1.TYPE_WRITE_NOT_FOUND,
|
|
371
|
+
nonRetryable: true,
|
|
372
|
+
details: [{ writeId: status.writeId }],
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
// In-progress or unrecognized: keep polling. The enum has grown before, and a new
|
|
376
|
+
// state must not fail in-flight writes.
|
|
377
|
+
if (!STATUS_IN_PROGRESS.has(value)) {
|
|
378
|
+
workflow_1.log.warn('xmemory returned an unrecognized write status; continuing to poll', {
|
|
379
|
+
writeId: status.writeId,
|
|
380
|
+
writeStatus: value,
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
return null;
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* A {@link WorkflowXmemory} handle. Carries only configuration (no per-call
|
|
387
|
+
* mutable state), so making a fresh one per call is correct and cheap.
|
|
388
|
+
*/
|
|
389
|
+
function xmemoryForWorkflow(options = {}) {
|
|
390
|
+
return new WorkflowXmemory(options ?? {});
|
|
391
|
+
}
|
|
392
|
+
// Re-exported here so workflow code can branch on a failure's `type` without
|
|
393
|
+
// importing the package root, which pulls in the worker and client modules.
|
|
394
|
+
var names_2 = require("./names");
|
|
395
|
+
Object.defineProperty(exports, "TYPE_AUTH_FAILED", { enumerable: true, get: function () { return names_2.TYPE_AUTH_FAILED; } });
|
|
396
|
+
Object.defineProperty(exports, "TYPE_BAD_OPTIONS", { enumerable: true, get: function () { return names_2.TYPE_BAD_OPTIONS; } });
|
|
397
|
+
Object.defineProperty(exports, "TYPE_BAD_REQUEST", { enumerable: true, get: function () { return names_2.TYPE_BAD_REQUEST; } });
|
|
398
|
+
Object.defineProperty(exports, "TYPE_DAILY_QUOTA_EXCEEDED", { enumerable: true, get: function () { return names_2.TYPE_DAILY_QUOTA_EXCEEDED; } });
|
|
399
|
+
Object.defineProperty(exports, "TYPE_DEADLINE_EXPIRED", { enumerable: true, get: function () { return names_2.TYPE_DEADLINE_EXPIRED; } });
|
|
400
|
+
Object.defineProperty(exports, "TYPE_MONTHLY_QUOTA_EXCEEDED", { enumerable: true, get: function () { return names_2.TYPE_MONTHLY_QUOTA_EXCEEDED; } });
|
|
401
|
+
Object.defineProperty(exports, "TYPE_NOT_BOUND", { enumerable: true, get: function () { return names_2.TYPE_NOT_BOUND; } });
|
|
402
|
+
Object.defineProperty(exports, "TYPE_NOT_FOUND", { enumerable: true, get: function () { return names_2.TYPE_NOT_FOUND; } });
|
|
403
|
+
Object.defineProperty(exports, "TYPE_NO_DEADLINE", { enumerable: true, get: function () { return names_2.TYPE_NO_DEADLINE; } });
|
|
404
|
+
Object.defineProperty(exports, "TYPE_QUOTA_EXCEEDED", { enumerable: true, get: function () { return names_2.TYPE_QUOTA_EXCEEDED; } });
|
|
405
|
+
Object.defineProperty(exports, "TYPE_RATE_LIMITED", { enumerable: true, get: function () { return names_2.TYPE_RATE_LIMITED; } });
|
|
406
|
+
Object.defineProperty(exports, "TYPE_SCHEMA_REJECTED", { enumerable: true, get: function () { return names_2.TYPE_SCHEMA_REJECTED; } });
|
|
407
|
+
Object.defineProperty(exports, "TYPE_SERVER_ERROR", { enumerable: true, get: function () { return names_2.TYPE_SERVER_ERROR; } });
|
|
408
|
+
Object.defineProperty(exports, "TYPE_UNAVAILABLE", { enumerable: true, get: function () { return names_2.TYPE_UNAVAILABLE; } });
|
|
409
|
+
Object.defineProperty(exports, "TYPE_UNKNOWN", { enumerable: true, get: function () { return names_2.TYPE_UNKNOWN; } });
|
|
410
|
+
Object.defineProperty(exports, "TYPE_WRITE_FAILED", { enumerable: true, get: function () { return names_2.TYPE_WRITE_FAILED; } });
|
|
411
|
+
Object.defineProperty(exports, "TYPE_WRITE_NOT_FOUND", { enumerable: true, get: function () { return names_2.TYPE_WRITE_NOT_FOUND; } });
|
|
412
|
+
Object.defineProperty(exports, "TYPE_WRITE_TIMEOUT", { enumerable: true, get: function () { return names_2.TYPE_WRITE_TIMEOUT; } });
|
package/package.json
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@xmemory/temporal",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Temporal plugin for xmemory \u2014 durable agent memory as Temporal Activities",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/xmemory-ai/xmemory-temporal-ts.git"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/xmemory-ai/xmemory-temporal-ts#readme",
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/xmemory-ai/xmemory-temporal-ts/issues"
|
|
13
|
+
},
|
|
14
|
+
"type": "commonjs",
|
|
15
|
+
"main": "dist/index.js",
|
|
16
|
+
"types": "dist/index.d.ts",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"default": "./dist/index.js"
|
|
21
|
+
},
|
|
22
|
+
"./workflow": {
|
|
23
|
+
"types": "./dist/workflow.d.ts",
|
|
24
|
+
"default": "./dist/workflow.js"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"//sideEffects": "No module has import-time side effects, so a bundler can drop what a consumer does not use. Workflow code imports @xmemory/temporal/workflow, a leaf that reaches no Activity or client code.",
|
|
28
|
+
"sideEffects": false,
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=22.12"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"dist",
|
|
34
|
+
"NOTICE"
|
|
35
|
+
],
|
|
36
|
+
"//pin": "The @temporalio worker plugin surface is experimental; pin to the 1.20.x line (~) rather than allowing 1.x minors, so a plugin-API change cannot land unreviewed.",
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"xmemory": "^3.8.3"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@temporalio/activity": "~1.20.0",
|
|
42
|
+
"@temporalio/client": "~1.20.0",
|
|
43
|
+
"@temporalio/common": "~1.20.0",
|
|
44
|
+
"@temporalio/testing": "~1.20.0",
|
|
45
|
+
"@temporalio/worker": "~1.20.0",
|
|
46
|
+
"@temporalio/workflow": "~1.20.0",
|
|
47
|
+
"@types/ms": "^2.1.0",
|
|
48
|
+
"@types/node": "^22",
|
|
49
|
+
"tsx": "^4.21.0",
|
|
50
|
+
"typescript": "^5.6.0"
|
|
51
|
+
},
|
|
52
|
+
"scripts": {
|
|
53
|
+
"build": "tsc -p tsconfig.json",
|
|
54
|
+
"lint": "tsc --noEmit -p tsconfig.test.json",
|
|
55
|
+
"test": "tsc --noEmit -p tsconfig.test.json && node --import tsx --test test/*.test.ts",
|
|
56
|
+
"check:bundle": "node --import tsx scripts/check-workflow-bundle.ts",
|
|
57
|
+
"check:declarations": "node --import tsx scripts/check-declarations.ts",
|
|
58
|
+
"check:smoke": "node --import tsx scripts/check-package-smoke.ts",
|
|
59
|
+
"prepublishOnly": "npm run build",
|
|
60
|
+
"prepare": "npm run build"
|
|
61
|
+
},
|
|
62
|
+
"peerDependencies": {
|
|
63
|
+
"@temporalio/activity": "~1.20.0",
|
|
64
|
+
"@temporalio/client": "~1.20.0",
|
|
65
|
+
"@temporalio/common": "~1.20.0",
|
|
66
|
+
"@temporalio/worker": "~1.20.0",
|
|
67
|
+
"@temporalio/workflow": "~1.20.0"
|
|
68
|
+
},
|
|
69
|
+
"//peer": "The Worker plugin contract is the host SDK's, and a plugin that bundles its own copy would run against a second, different Temporal runtime \u2014 the host's Worker would not even recognise it. Declared as peers so one SDK is installed, and kept in devDependencies for building and testing here."
|
|
70
|
+
}
|