@purista/harness 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 +201 -0
- package/README.md +23 -0
- package/dist/agents/index.d.ts +34 -0
- package/dist/agents/index.js +301 -0
- package/dist/errors/catalog.d.ts +185 -0
- package/dist/errors/catalog.js +144 -0
- package/dist/errors/harness-error.d.ts +64 -0
- package/dist/errors/harness-error.js +58 -0
- package/dist/errors/index.d.ts +3 -0
- package/dist/errors/index.js +3 -0
- package/dist/errors/redaction.d.ts +5 -0
- package/dist/errors/redaction.js +64 -0
- package/dist/harness/defineHarness.d.ts +640 -0
- package/dist/harness/defineHarness.js +176 -0
- package/dist/harness/errors.d.ts +62 -0
- package/dist/harness/errors.js +67 -0
- package/dist/harness/types.d.ts +27 -0
- package/dist/harness/types.js +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +12 -0
- package/dist/logger/index.d.ts +2 -0
- package/dist/logger/index.js +2 -0
- package/dist/logger/json-logger.d.ts +31 -0
- package/dist/logger/json-logger.js +65 -0
- package/dist/logger/logger.d.ts +31 -0
- package/dist/logger/logger.js +1 -0
- package/dist/models/json.d.ts +6 -0
- package/dist/models/json.js +1 -0
- package/dist/models/registry.d.ts +112 -0
- package/dist/models/registry.js +286 -0
- package/dist/models/state.d.ts +64 -0
- package/dist/models/state.js +1 -0
- package/dist/ports/base-model-provider.d.ts +56 -0
- package/dist/ports/base-model-provider.js +343 -0
- package/dist/ports/capabilities.d.ts +70 -0
- package/dist/ports/capabilities.js +38 -0
- package/dist/ports/feedback.d.ts +29 -0
- package/dist/ports/feedback.js +1 -0
- package/dist/ports/harness-context.d.ts +20 -0
- package/dist/ports/harness-context.js +1 -0
- package/dist/ports/index.d.ts +6 -0
- package/dist/ports/index.js +6 -0
- package/dist/ports/model-provider.d.ts +280 -0
- package/dist/ports/model-provider.js +1 -0
- package/dist/ports/state.d.ts +72 -0
- package/dist/ports/state.js +24 -0
- package/dist/runtime/durable.d.ts +134 -0
- package/dist/runtime/durable.js +185 -0
- package/dist/runtime/index.d.ts +2 -0
- package/dist/runtime/index.js +2 -0
- package/dist/runtime/steps.d.ts +22 -0
- package/dist/runtime/steps.js +51 -0
- package/dist/sandbox/index.d.ts +111 -0
- package/dist/sandbox/index.js +165 -0
- package/dist/sessions/index.d.ts +23 -0
- package/dist/sessions/index.js +718 -0
- package/dist/skills/index.d.ts +8 -0
- package/dist/skills/index.js +88 -0
- package/dist/state/in-memory.d.ts +35 -0
- package/dist/state/in-memory.js +140 -0
- package/dist/telemetry/index.d.ts +1 -0
- package/dist/telemetry/index.js +1 -0
- package/dist/telemetry/shim.d.ts +26 -0
- package/dist/telemetry/shim.js +120 -0
- package/dist/testing/capabilities.d.ts +11 -0
- package/dist/testing/capabilities.js +20 -0
- package/dist/testing/fakeModelProvider.d.ts +25 -0
- package/dist/testing/fakeModelProvider.js +79 -0
- package/dist/testing/feedback.d.ts +10 -0
- package/dist/testing/feedback.js +24 -0
- package/dist/testing/fixtures/mcp/fake-http-server.d.ts +8 -0
- package/dist/testing/fixtures/mcp/fake-http-server.js +95 -0
- package/dist/testing/index.d.ts +8 -0
- package/dist/testing/index.js +11 -0
- package/dist/testing/sandboxContract.d.ts +4 -0
- package/dist/testing/sandboxContract.js +74 -0
- package/dist/testing/sandboxSnapshot.d.ts +7 -0
- package/dist/testing/sandboxSnapshot.js +201 -0
- package/dist/testing/stateStoreContract.d.ts +2 -0
- package/dist/testing/stateStoreContract.js +109 -0
- package/dist/tools/index.d.ts +9 -0
- package/dist/tools/index.js +123 -0
- package/dist/tools/mcp/http.d.ts +2 -0
- package/dist/tools/mcp/http.js +109 -0
- package/dist/tools/mcp/index.d.ts +2 -0
- package/dist/tools/mcp/index.js +2 -0
- package/dist/tools/mcp/runner.d.ts +74 -0
- package/dist/tools/mcp/runner.js +238 -0
- package/dist/tools/mcp/schema.d.ts +41 -0
- package/dist/tools/mcp/schema.js +251 -0
- package/dist/tools/mcp/stdio.d.ts +2 -0
- package/dist/tools/mcp/stdio.js +122 -0
- package/dist/ulid/index.d.ts +6 -0
- package/dist/ulid/index.js +35 -0
- package/dist/workflows/index.d.ts +8 -0
- package/dist/workflows/index.js +26 -0
- package/package.json +75 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import type { JsonValue } from '../models/json.js';
|
|
2
|
+
/**
|
|
3
|
+
* Model capabilities declared by aliases in `.models(...)`.
|
|
4
|
+
*/
|
|
5
|
+
export type ModelCapability =
|
|
6
|
+
/** Synchronous plain text generation. */
|
|
7
|
+
'text'
|
|
8
|
+
/** Streaming plain text generation. */
|
|
9
|
+
| 'text_stream'
|
|
10
|
+
/** Synchronous structured object generation. */
|
|
11
|
+
| 'object'
|
|
12
|
+
/** Streaming structured object generation. */
|
|
13
|
+
| 'object_stream'
|
|
14
|
+
/** Function/tool calling support. */
|
|
15
|
+
| 'tool_use'
|
|
16
|
+
/** Image input understanding. */
|
|
17
|
+
| 'vision_input'
|
|
18
|
+
/** Audio input understanding. */
|
|
19
|
+
| 'audio_input'
|
|
20
|
+
/** File input understanding. */
|
|
21
|
+
| 'file_input'
|
|
22
|
+
/** Embedding vector generation. */
|
|
23
|
+
| 'embeddings'
|
|
24
|
+
/** Document reranking. */
|
|
25
|
+
| 'rerank';
|
|
26
|
+
/** Default generation parameters applied per alias. */
|
|
27
|
+
export interface ModelDefaults {
|
|
28
|
+
temperature?: number;
|
|
29
|
+
maxTokens?: number;
|
|
30
|
+
topP?: number;
|
|
31
|
+
stopSequences?: string[];
|
|
32
|
+
providerOptions?: Record<string, unknown>;
|
|
33
|
+
}
|
|
34
|
+
/** Per-call generation overrides. */
|
|
35
|
+
export interface ModelCallOptions {
|
|
36
|
+
temperature?: number;
|
|
37
|
+
maxTokens?: number;
|
|
38
|
+
topP?: number;
|
|
39
|
+
stopSequences?: string[];
|
|
40
|
+
providerOptions?: Record<string, unknown>;
|
|
41
|
+
}
|
|
42
|
+
/** Tool call envelope emitted by model adapters. */
|
|
43
|
+
export interface ToolCallSpec {
|
|
44
|
+
id: string;
|
|
45
|
+
name: string;
|
|
46
|
+
arguments: JsonValue;
|
|
47
|
+
}
|
|
48
|
+
/** Multimodal message content part. */
|
|
49
|
+
export type ContentPart =
|
|
50
|
+
/** Plain text input content. */
|
|
51
|
+
{
|
|
52
|
+
kind: 'text';
|
|
53
|
+
text: string;
|
|
54
|
+
}
|
|
55
|
+
/** Inline image content encoded as base64 data. */
|
|
56
|
+
| {
|
|
57
|
+
kind: 'image';
|
|
58
|
+
mimeType: string;
|
|
59
|
+
dataBase64: string;
|
|
60
|
+
}
|
|
61
|
+
/** Remote image reference. */
|
|
62
|
+
| {
|
|
63
|
+
kind: 'image_url';
|
|
64
|
+
url: string;
|
|
65
|
+
mimeType?: string;
|
|
66
|
+
}
|
|
67
|
+
/** Inline audio content encoded as base64 data. */
|
|
68
|
+
| {
|
|
69
|
+
kind: 'audio';
|
|
70
|
+
mimeType: string;
|
|
71
|
+
dataBase64: string;
|
|
72
|
+
}
|
|
73
|
+
/** Inline file content encoded as base64 data. */
|
|
74
|
+
| {
|
|
75
|
+
kind: 'file';
|
|
76
|
+
mimeType: string;
|
|
77
|
+
dataBase64: string;
|
|
78
|
+
filename?: string;
|
|
79
|
+
}
|
|
80
|
+
/** Remote file reference. */
|
|
81
|
+
| {
|
|
82
|
+
kind: 'file_url';
|
|
83
|
+
url: string;
|
|
84
|
+
mimeType?: string;
|
|
85
|
+
filename?: string;
|
|
86
|
+
};
|
|
87
|
+
/** Optional data-only model feature descriptor exposed by provider packages. */
|
|
88
|
+
export interface ModelProviderInfo {
|
|
89
|
+
providerId: string;
|
|
90
|
+
genAiSystem: string;
|
|
91
|
+
packageName?: string;
|
|
92
|
+
packageVersion?: string;
|
|
93
|
+
models?: Record<string, ModelFeatureSet>;
|
|
94
|
+
}
|
|
95
|
+
/** Static capabilities known for a provider model. */
|
|
96
|
+
export interface ModelFeatureSet {
|
|
97
|
+
capabilities: readonly ModelCapability[];
|
|
98
|
+
contextWindow?: number;
|
|
99
|
+
maxOutputTokens?: number;
|
|
100
|
+
supportedInputParts?: readonly ContentPartKind[];
|
|
101
|
+
supportedOutputModes?: readonly OutputMode[];
|
|
102
|
+
}
|
|
103
|
+
/** Provider-neutral input content part kinds. */
|
|
104
|
+
export type ContentPartKind = 'text' | 'image' | 'audio' | 'file';
|
|
105
|
+
/** Provider-neutral output operation modes. */
|
|
106
|
+
export type OutputMode = 'text' | 'object' | 'embedding' | 'rerank';
|
|
107
|
+
/** Message schema shared across provider adapters. */
|
|
108
|
+
export type ModelMessage = {
|
|
109
|
+
role: 'system';
|
|
110
|
+
content: string;
|
|
111
|
+
} | {
|
|
112
|
+
role: 'user';
|
|
113
|
+
content: string | ContentPart[];
|
|
114
|
+
} | {
|
|
115
|
+
role: 'assistant';
|
|
116
|
+
content: string | ContentPart[];
|
|
117
|
+
toolCalls?: ToolCallSpec[];
|
|
118
|
+
} | {
|
|
119
|
+
role: 'tool';
|
|
120
|
+
toolCallId: string;
|
|
121
|
+
content: string;
|
|
122
|
+
};
|
|
123
|
+
/** Base request shape for all model-provider methods. */
|
|
124
|
+
export interface BaseRequest {
|
|
125
|
+
model: string;
|
|
126
|
+
messages: ModelMessage[];
|
|
127
|
+
defaults?: ModelDefaults | undefined;
|
|
128
|
+
call?: ModelCallOptions | undefined;
|
|
129
|
+
signal: AbortSignal;
|
|
130
|
+
traceparent?: string | undefined;
|
|
131
|
+
}
|
|
132
|
+
/** Token usage accounting normalized across providers. */
|
|
133
|
+
export interface TokenUsage {
|
|
134
|
+
inputTokens: number;
|
|
135
|
+
outputTokens: number;
|
|
136
|
+
totalTokens: number;
|
|
137
|
+
}
|
|
138
|
+
/** Normalized finish reasons from model providers. */
|
|
139
|
+
export type FinishReason =
|
|
140
|
+
/** Natural model stop sequence. */
|
|
141
|
+
'stop'
|
|
142
|
+
/** Token budget reached. */
|
|
143
|
+
| 'length'
|
|
144
|
+
/** Model requested tool calls. */
|
|
145
|
+
| 'tool_calls'
|
|
146
|
+
/** Provider content filter interrupted generation. */
|
|
147
|
+
| 'content_filter'
|
|
148
|
+
/** Provider or adapter error fallback. */
|
|
149
|
+
| 'error';
|
|
150
|
+
/** Tool declaration exposed to model adapters. */
|
|
151
|
+
export interface ModelToolSpec {
|
|
152
|
+
name: string;
|
|
153
|
+
description: string;
|
|
154
|
+
parameters: JsonValue;
|
|
155
|
+
}
|
|
156
|
+
/** Request for text/text-stream model methods. */
|
|
157
|
+
export interface TextRequest extends BaseRequest {
|
|
158
|
+
tools?: ModelToolSpec[] | undefined;
|
|
159
|
+
}
|
|
160
|
+
/** Response from synchronous text generation. */
|
|
161
|
+
export interface TextResponse {
|
|
162
|
+
content: string;
|
|
163
|
+
toolCalls?: ToolCallSpec[];
|
|
164
|
+
usage: TokenUsage;
|
|
165
|
+
finishReason: FinishReason;
|
|
166
|
+
raw?: unknown;
|
|
167
|
+
}
|
|
168
|
+
/** Stream chunk from text-stream generation. */
|
|
169
|
+
export type TextStreamChunk = {
|
|
170
|
+
kind: 'delta';
|
|
171
|
+
text: string;
|
|
172
|
+
} | {
|
|
173
|
+
kind: 'tool_call';
|
|
174
|
+
call: ToolCallSpec;
|
|
175
|
+
} | {
|
|
176
|
+
kind: 'finish';
|
|
177
|
+
usage: TokenUsage;
|
|
178
|
+
finishReason: FinishReason;
|
|
179
|
+
};
|
|
180
|
+
/** Request for object/object-stream model methods. */
|
|
181
|
+
export interface ObjectRequest<T extends JsonValue = JsonValue> extends BaseRequest {
|
|
182
|
+
schema: JsonValue;
|
|
183
|
+
schemaName?: string;
|
|
184
|
+
tools?: ModelToolSpec[] | undefined;
|
|
185
|
+
}
|
|
186
|
+
/** Response from synchronous structured object generation. */
|
|
187
|
+
export interface ObjectResponse<T extends JsonValue = JsonValue> {
|
|
188
|
+
object: T;
|
|
189
|
+
toolCalls?: ToolCallSpec[];
|
|
190
|
+
usage: TokenUsage;
|
|
191
|
+
finishReason: FinishReason;
|
|
192
|
+
raw?: unknown;
|
|
193
|
+
}
|
|
194
|
+
/** Stream chunk from structured object streaming. */
|
|
195
|
+
export type ObjectStreamChunk<T extends JsonValue = JsonValue> = {
|
|
196
|
+
kind: 'partial';
|
|
197
|
+
partial: JsonValue;
|
|
198
|
+
} | {
|
|
199
|
+
kind: 'delta';
|
|
200
|
+
path: readonly (string | number)[];
|
|
201
|
+
value: JsonValue;
|
|
202
|
+
} | {
|
|
203
|
+
kind: 'tool_call';
|
|
204
|
+
call: ToolCallSpec;
|
|
205
|
+
} | {
|
|
206
|
+
kind: 'finish';
|
|
207
|
+
object: T;
|
|
208
|
+
usage: TokenUsage;
|
|
209
|
+
finishReason: FinishReason;
|
|
210
|
+
};
|
|
211
|
+
/** Request for embedding generation. */
|
|
212
|
+
export interface EmbeddingRequest {
|
|
213
|
+
model: string;
|
|
214
|
+
input: string | readonly string[];
|
|
215
|
+
dimensions?: number;
|
|
216
|
+
call?: ModelCallOptions | undefined;
|
|
217
|
+
signal: AbortSignal;
|
|
218
|
+
traceparent?: string | undefined;
|
|
219
|
+
}
|
|
220
|
+
/** Response from embedding generation. */
|
|
221
|
+
export interface EmbeddingResponse {
|
|
222
|
+
embeddings: readonly Embedding[];
|
|
223
|
+
usage: TokenUsage;
|
|
224
|
+
raw?: unknown;
|
|
225
|
+
}
|
|
226
|
+
/** Single embedding vector with input-order index. */
|
|
227
|
+
export interface Embedding {
|
|
228
|
+
index: number;
|
|
229
|
+
vector: readonly number[];
|
|
230
|
+
}
|
|
231
|
+
/** Request for document reranking. */
|
|
232
|
+
export interface RerankRequest {
|
|
233
|
+
model: string;
|
|
234
|
+
query: string;
|
|
235
|
+
documents: readonly RerankDocument[];
|
|
236
|
+
topN?: number;
|
|
237
|
+
call?: ModelCallOptions | undefined;
|
|
238
|
+
signal: AbortSignal;
|
|
239
|
+
traceparent?: string | undefined;
|
|
240
|
+
}
|
|
241
|
+
/** Rerankable document. */
|
|
242
|
+
export interface RerankDocument {
|
|
243
|
+
id: string;
|
|
244
|
+
text: string;
|
|
245
|
+
metadata?: Record<string, JsonValue>;
|
|
246
|
+
}
|
|
247
|
+
/** Response from document reranking. */
|
|
248
|
+
export interface RerankResponse {
|
|
249
|
+
results: readonly RerankResult[];
|
|
250
|
+
usage?: TokenUsage;
|
|
251
|
+
raw?: unknown;
|
|
252
|
+
}
|
|
253
|
+
/** Single rerank result referencing one submitted document. */
|
|
254
|
+
export interface RerankResult {
|
|
255
|
+
id: string;
|
|
256
|
+
index: number;
|
|
257
|
+
score: number;
|
|
258
|
+
metadata?: Record<string, JsonValue>;
|
|
259
|
+
}
|
|
260
|
+
/** Provider adapter interface implemented by packages such as `@purista/harness-openai`. */
|
|
261
|
+
export interface ModelProvider {
|
|
262
|
+
readonly id: string;
|
|
263
|
+
readonly genAiSystem: string;
|
|
264
|
+
readonly info?: ModelProviderInfo;
|
|
265
|
+
text?(req: TextRequest): Promise<TextResponse>;
|
|
266
|
+
textStream?(req: TextRequest): AsyncIterable<TextStreamChunk>;
|
|
267
|
+
object?<T extends JsonValue = JsonValue>(req: ObjectRequest<T>): Promise<ObjectResponse<T>>;
|
|
268
|
+
objectStream?<T extends JsonValue = JsonValue>(req: ObjectRequest<T>): AsyncIterable<ObjectStreamChunk<T>>;
|
|
269
|
+
embed?(req: EmbeddingRequest): Promise<EmbeddingResponse>;
|
|
270
|
+
rerank?(req: RerankRequest): Promise<RerankResponse>;
|
|
271
|
+
close?(): Promise<void>;
|
|
272
|
+
}
|
|
273
|
+
/** Alias entry used in harness model configuration. */
|
|
274
|
+
export interface ModelAlias {
|
|
275
|
+
provider: ModelProvider;
|
|
276
|
+
model: string;
|
|
277
|
+
capabilities: readonly ModelCapability[];
|
|
278
|
+
defaults?: ModelDefaults;
|
|
279
|
+
providerOptions?: Record<string, unknown>;
|
|
280
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { StateError } from '../errors/index.js';
|
|
2
|
+
import type { Message, PersistedRunEvent, RunRecord, SessionRecord } from '../models/state.js';
|
|
3
|
+
import type { Logger } from '../logger/index.js';
|
|
4
|
+
import type { TelemetryShim } from '../telemetry/index.js';
|
|
5
|
+
import type { HarnessAdapterContext } from './harness-context.js';
|
|
6
|
+
/** Fields allowed when marking a run as finished. */
|
|
7
|
+
export type FinishRunPatch = Pick<RunRecord, 'status' | 'finishedAt' | 'output' | 'error'>;
|
|
8
|
+
/**
|
|
9
|
+
* Persistence port for session state, history, run metadata, and streamed events.
|
|
10
|
+
*
|
|
11
|
+
* Implement this interface to provide durable backends (Postgres, Redis, etc.).
|
|
12
|
+
*/
|
|
13
|
+
export interface StateStore {
|
|
14
|
+
getSession(id: string): Promise<SessionRecord | undefined>;
|
|
15
|
+
upsertSession(record: SessionRecord): Promise<void>;
|
|
16
|
+
closeSession(id: string): Promise<void>;
|
|
17
|
+
appendMessages(sessionId: string, messages: Message[]): Promise<void>;
|
|
18
|
+
listMessages(sessionId: string, opts?: {
|
|
19
|
+
limit?: number;
|
|
20
|
+
before?: string;
|
|
21
|
+
}): Promise<Message[]>;
|
|
22
|
+
clearMessages(sessionId: string): Promise<void>;
|
|
23
|
+
createRun(record: RunRecord): Promise<void>;
|
|
24
|
+
finishRun(runId: string, patch: FinishRunPatch): Promise<void>;
|
|
25
|
+
getRun(runId: string): Promise<RunRecord | undefined>;
|
|
26
|
+
listRuns(sessionId: string, opts?: {
|
|
27
|
+
limit?: number;
|
|
28
|
+
before?: string;
|
|
29
|
+
}): Promise<RunRecord[]>;
|
|
30
|
+
appendEvents(runId: string, events: PersistedRunEvent[]): Promise<void>;
|
|
31
|
+
listEvents(runId: string, opts?: {
|
|
32
|
+
limit?: number;
|
|
33
|
+
after?: string;
|
|
34
|
+
}): Promise<PersistedRunEvent[]>;
|
|
35
|
+
close?(): Promise<void>;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Optional base for durable state adapters.
|
|
39
|
+
*
|
|
40
|
+
* Concrete adapters still implement the `StateStore` port directly, while this
|
|
41
|
+
* base provides shared error normalization so provider-specific code can stay
|
|
42
|
+
* focused on mapping records to the backing store.
|
|
43
|
+
*/
|
|
44
|
+
export declare abstract class StateStoreAdapterBase implements StateStore {
|
|
45
|
+
protected logger: Logger | undefined;
|
|
46
|
+
protected telemetry: TelemetryShim | undefined;
|
|
47
|
+
protected harnessName: string | undefined;
|
|
48
|
+
abstract getSession(id: string): Promise<SessionRecord | undefined>;
|
|
49
|
+
abstract upsertSession(record: SessionRecord): Promise<void>;
|
|
50
|
+
abstract closeSession(id: string): Promise<void>;
|
|
51
|
+
abstract appendMessages(sessionId: string, messages: Message[]): Promise<void>;
|
|
52
|
+
abstract listMessages(sessionId: string, opts?: {
|
|
53
|
+
limit?: number;
|
|
54
|
+
before?: string;
|
|
55
|
+
}): Promise<Message[]>;
|
|
56
|
+
abstract clearMessages(sessionId: string): Promise<void>;
|
|
57
|
+
abstract createRun(record: RunRecord): Promise<void>;
|
|
58
|
+
abstract finishRun(runId: string, patch: FinishRunPatch): Promise<void>;
|
|
59
|
+
abstract getRun(runId: string): Promise<RunRecord | undefined>;
|
|
60
|
+
abstract listRuns(sessionId: string, opts?: {
|
|
61
|
+
limit?: number;
|
|
62
|
+
before?: string;
|
|
63
|
+
}): Promise<RunRecord[]>;
|
|
64
|
+
abstract appendEvents(runId: string, events: PersistedRunEvent[]): Promise<void>;
|
|
65
|
+
abstract listEvents(runId: string, opts?: {
|
|
66
|
+
limit?: number;
|
|
67
|
+
after?: string;
|
|
68
|
+
}): Promise<PersistedRunEvent[]>;
|
|
69
|
+
close(): Promise<void>;
|
|
70
|
+
configureHarnessContext(context: HarnessAdapterContext): void;
|
|
71
|
+
protected stateError(op: Parameters<StateStore['appendEvents']>[0] extends never ? never : ConstructorParameters<typeof StateError>[1]['op'], message: string, error: unknown, reason?: string): StateError;
|
|
72
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { StateError } from '../errors/index.js';
|
|
2
|
+
/**
|
|
3
|
+
* Optional base for durable state adapters.
|
|
4
|
+
*
|
|
5
|
+
* Concrete adapters still implement the `StateStore` port directly, while this
|
|
6
|
+
* base provides shared error normalization so provider-specific code can stay
|
|
7
|
+
* focused on mapping records to the backing store.
|
|
8
|
+
*/
|
|
9
|
+
export class StateStoreAdapterBase {
|
|
10
|
+
logger;
|
|
11
|
+
telemetry;
|
|
12
|
+
harnessName;
|
|
13
|
+
async close() { }
|
|
14
|
+
configureHarnessContext(context) {
|
|
15
|
+
this.logger ??= context.logger;
|
|
16
|
+
this.telemetry ??= context.telemetry;
|
|
17
|
+
this.harnessName ??= context.harnessName;
|
|
18
|
+
}
|
|
19
|
+
stateError(op, message, error, reason) {
|
|
20
|
+
if (error instanceof StateError)
|
|
21
|
+
return error;
|
|
22
|
+
return new StateError(message, { op, ...(reason ? { reason } : {}) }, error);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type { AdapterCapability } from '../ports/capabilities.js';
|
|
2
|
+
import type { JsonValue } from '../models/json.js';
|
|
3
|
+
import type { RunStatus, SerializedError } from '../models/state.js';
|
|
4
|
+
/** Non-terminal run status used while durable work can still be resumed. */
|
|
5
|
+
export type DurableActiveRunStatus = 'running';
|
|
6
|
+
/** Terminal run statuses that must never be resumed by a durable runtime. */
|
|
7
|
+
export type DurableTerminalRunStatus = Exclude<RunStatus, DurableActiveRunStatus>;
|
|
8
|
+
/** Durable run lifecycle status. */
|
|
9
|
+
export type DurableRunStatus = DurableActiveRunStatus | DurableTerminalRunStatus;
|
|
10
|
+
/** Input metadata required to create or retry a durable run. */
|
|
11
|
+
export interface DurableRunStart {
|
|
12
|
+
/** Stable run id. Retries of the same logical run must reuse this value. */
|
|
13
|
+
readonly runId: string;
|
|
14
|
+
/** Stable session id owned by the run while it mutates session state. */
|
|
15
|
+
readonly sessionId: string;
|
|
16
|
+
/** Worker/process id requesting ownership of the run. */
|
|
17
|
+
readonly workerId: string;
|
|
18
|
+
/** Initial durable step id. Retried metadata preserves this value. */
|
|
19
|
+
readonly stepId: string;
|
|
20
|
+
/** Original run input. Retried metadata preserves this value. */
|
|
21
|
+
readonly input: JsonValue;
|
|
22
|
+
/** Optional caller-supplied attempt. The runtime may increase it on retry. */
|
|
23
|
+
readonly attempt?: number;
|
|
24
|
+
/** Adapter-neutral metadata persisted with the durable run. */
|
|
25
|
+
readonly metadata?: Record<string, JsonValue>;
|
|
26
|
+
}
|
|
27
|
+
/** Exclusive ownership token returned by a durable runtime. */
|
|
28
|
+
export interface DurableRunLease {
|
|
29
|
+
/** Stable run id owned by this lease. */
|
|
30
|
+
readonly runId: string;
|
|
31
|
+
/** Stable session id owned by this lease. */
|
|
32
|
+
readonly sessionId: string;
|
|
33
|
+
/** Worker/process id that owns this lease. */
|
|
34
|
+
readonly workerId: string;
|
|
35
|
+
/** Runtime-issued lease id. */
|
|
36
|
+
readonly leaseId: string;
|
|
37
|
+
/** Current attempt number for this run. */
|
|
38
|
+
readonly attempt: number;
|
|
39
|
+
/** True when the run has a committed checkpoint to resume from. */
|
|
40
|
+
readonly resumed: boolean;
|
|
41
|
+
/** Metadata from the original run start record with runtime attempt applied. */
|
|
42
|
+
readonly start: DurableRunStart & {
|
|
43
|
+
readonly attempt: number;
|
|
44
|
+
};
|
|
45
|
+
/** Last committed checkpoint, if any. */
|
|
46
|
+
readonly checkpoint?: RunCheckpoint;
|
|
47
|
+
/** Releases this in-memory lease without making the run terminal. */
|
|
48
|
+
release(): Promise<void>;
|
|
49
|
+
}
|
|
50
|
+
/** Stable checkpoint boundary committed by a durable runtime. */
|
|
51
|
+
export interface RunCheckpoint {
|
|
52
|
+
/** Stable run id. */
|
|
53
|
+
readonly runId: string;
|
|
54
|
+
/** Stable session id. */
|
|
55
|
+
readonly sessionId: string;
|
|
56
|
+
/** Runtime-issued lease id that owns this checkpoint write. */
|
|
57
|
+
readonly leaseId: string;
|
|
58
|
+
/** Worker/process id that owns this checkpoint write. */
|
|
59
|
+
readonly workerId: string;
|
|
60
|
+
/** Stable step id for the committed boundary. */
|
|
61
|
+
readonly stepId: string;
|
|
62
|
+
/** Original run input associated with this retry chain. */
|
|
63
|
+
readonly input: JsonValue;
|
|
64
|
+
/** Attempt that produced this checkpoint. */
|
|
65
|
+
readonly attempt: number;
|
|
66
|
+
/** Monotonic checkpoint sequence for the run. */
|
|
67
|
+
readonly sequence: number;
|
|
68
|
+
/** JSON-serializable checkpoint payload. */
|
|
69
|
+
readonly output?: JsonValue;
|
|
70
|
+
/** Adapter-neutral checkpoint metadata. */
|
|
71
|
+
readonly metadata?: Record<string, JsonValue>;
|
|
72
|
+
/** ISO timestamp for the commit. */
|
|
73
|
+
readonly committedAt?: string;
|
|
74
|
+
}
|
|
75
|
+
/** Patch used to make a durable run terminal. */
|
|
76
|
+
export interface FinishRunPatch {
|
|
77
|
+
/** Terminal run status. */
|
|
78
|
+
readonly status: DurableTerminalRunStatus;
|
|
79
|
+
/** Optional terminal output. */
|
|
80
|
+
readonly output?: JsonValue;
|
|
81
|
+
/** Optional terminal error. */
|
|
82
|
+
readonly error?: SerializedError;
|
|
83
|
+
/** ISO timestamp for terminal completion. */
|
|
84
|
+
readonly finishedAt?: string;
|
|
85
|
+
}
|
|
86
|
+
/** Optional failure injection settings for the in-memory durable runtime. */
|
|
87
|
+
export interface InMemoryDurableRuntimeOptions {
|
|
88
|
+
/**
|
|
89
|
+
* Throws after committing the Nth checkpoint. The checkpoint remains stored,
|
|
90
|
+
* which lets tests prove resume starts from the last consistent boundary.
|
|
91
|
+
*/
|
|
92
|
+
readonly failAfterCheckpoint?: number;
|
|
93
|
+
}
|
|
94
|
+
/** Durable runtime adapter contract for checkpointed execution. */
|
|
95
|
+
export interface DurableRuntime {
|
|
96
|
+
/** Adapter capabilities supported by this runtime. */
|
|
97
|
+
readonly capabilities: readonly AdapterCapability[];
|
|
98
|
+
/** Starts or retries a run and returns an exclusive lease. */
|
|
99
|
+
startRun(record: DurableRunStart): Promise<DurableRunLease>;
|
|
100
|
+
/** Loads the last committed checkpoint for a run. */
|
|
101
|
+
loadCheckpoint(runId: string): Promise<RunCheckpoint | undefined>;
|
|
102
|
+
/** Commits a stable checkpoint boundary. */
|
|
103
|
+
commitCheckpoint(checkpoint: RunCheckpoint): Promise<void>;
|
|
104
|
+
/** Marks a run terminal and releases any owned lease. */
|
|
105
|
+
finishRun(runId: string, patch: FinishRunPatch): Promise<void>;
|
|
106
|
+
/** Executes a callback while holding an exclusive session lock. */
|
|
107
|
+
withSessionLock<T>(sessionId: string, fn: () => Promise<T>): Promise<T>;
|
|
108
|
+
}
|
|
109
|
+
/** Error thrown when a terminal durable run is started again. */
|
|
110
|
+
export declare class DurableTerminalRunError extends Error {
|
|
111
|
+
constructor(runId: string, status: DurableTerminalRunStatus);
|
|
112
|
+
}
|
|
113
|
+
/** Error thrown when a durable run/session is already owned by another worker. */
|
|
114
|
+
export declare class DurableRunLeaseError extends Error {
|
|
115
|
+
constructor(message: string);
|
|
116
|
+
}
|
|
117
|
+
/** Returns true when a durable run status is terminal. */
|
|
118
|
+
export declare function isTerminalRunStatus(status: DurableRunStatus): status is DurableTerminalRunStatus;
|
|
119
|
+
/**
|
|
120
|
+
* Creates a self-contained in-memory durable runtime for tests and prototypes.
|
|
121
|
+
*
|
|
122
|
+
* @example
|
|
123
|
+
* ```ts
|
|
124
|
+
* const runtime = inMemoryDurableRuntime({ failAfterCheckpoint: 1 })
|
|
125
|
+
* const lease = await runtime.startRun({
|
|
126
|
+
* runId: 'run-1',
|
|
127
|
+
* sessionId: 'session-1',
|
|
128
|
+
* workerId: 'worker-1',
|
|
129
|
+
* stepId: 'draft',
|
|
130
|
+
* input: { topic: 'durability' }
|
|
131
|
+
* })
|
|
132
|
+
* ```
|
|
133
|
+
*/
|
|
134
|
+
export declare function inMemoryDurableRuntime(options?: InMemoryDurableRuntimeOptions): DurableRuntime;
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/** Error thrown when a terminal durable run is started again. */
|
|
2
|
+
export class DurableTerminalRunError extends Error {
|
|
3
|
+
constructor(runId, status) {
|
|
4
|
+
super(`Durable run "${runId}" is terminal (${status}) and cannot be resumed.`);
|
|
5
|
+
this.name = 'DurableTerminalRunError';
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
/** Error thrown when a durable run/session is already owned by another worker. */
|
|
9
|
+
export class DurableRunLeaseError extends Error {
|
|
10
|
+
constructor(message) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = 'DurableRunLeaseError';
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/** Returns true when a durable run status is terminal. */
|
|
16
|
+
export function isTerminalRunStatus(status) {
|
|
17
|
+
return status === 'succeeded' || status === 'failed' || status === 'cancelled';
|
|
18
|
+
}
|
|
19
|
+
class AsyncMutex {
|
|
20
|
+
current = Promise.resolve();
|
|
21
|
+
async lock(fn) {
|
|
22
|
+
const prev = this.current;
|
|
23
|
+
let release;
|
|
24
|
+
this.current = new Promise((resolve) => {
|
|
25
|
+
release = resolve;
|
|
26
|
+
});
|
|
27
|
+
await prev;
|
|
28
|
+
try {
|
|
29
|
+
return await fn();
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
release?.();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
class InMemoryDurableRuntime {
|
|
37
|
+
options;
|
|
38
|
+
capabilities = [
|
|
39
|
+
'runtime.checkpoint',
|
|
40
|
+
'runtime.retry',
|
|
41
|
+
'runtime.distributed_lock',
|
|
42
|
+
'runtime.resume_from_checkpoint'
|
|
43
|
+
];
|
|
44
|
+
runs = new Map();
|
|
45
|
+
runLeases = new Map();
|
|
46
|
+
sessionLeases = new Map();
|
|
47
|
+
sessionLocks = new Map();
|
|
48
|
+
leaseCounter = 0;
|
|
49
|
+
checkpointCommitCount = 0;
|
|
50
|
+
constructor(options = {}) {
|
|
51
|
+
this.options = options;
|
|
52
|
+
}
|
|
53
|
+
async startRun(record) {
|
|
54
|
+
return this.withSessionLock(record.sessionId, async () => {
|
|
55
|
+
const current = this.runs.get(record.runId);
|
|
56
|
+
if (current && isTerminalRunStatus(current.status)) {
|
|
57
|
+
throw new DurableTerminalRunError(record.runId, current.status);
|
|
58
|
+
}
|
|
59
|
+
this.assertNoConflictingLease(record);
|
|
60
|
+
const state = current ?? {
|
|
61
|
+
start: record,
|
|
62
|
+
status: 'running',
|
|
63
|
+
attempt: Math.max(1, record.attempt ?? 1)
|
|
64
|
+
};
|
|
65
|
+
if (current) {
|
|
66
|
+
state.attempt += 1;
|
|
67
|
+
}
|
|
68
|
+
this.runs.set(record.runId, state);
|
|
69
|
+
const lease = {
|
|
70
|
+
leaseId: `lease-${++this.leaseCounter}`,
|
|
71
|
+
runId: record.runId,
|
|
72
|
+
sessionId: record.sessionId,
|
|
73
|
+
workerId: record.workerId
|
|
74
|
+
};
|
|
75
|
+
this.runLeases.set(record.runId, lease);
|
|
76
|
+
this.sessionLeases.set(record.sessionId, lease);
|
|
77
|
+
return this.toLease(state, lease);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
async loadCheckpoint(runId) {
|
|
81
|
+
return this.runs.get(runId)?.checkpoint;
|
|
82
|
+
}
|
|
83
|
+
async commitCheckpoint(checkpoint) {
|
|
84
|
+
await this.withSessionLock(checkpoint.sessionId, async () => {
|
|
85
|
+
const lease = this.runLeases.get(checkpoint.runId);
|
|
86
|
+
if (!lease || lease.leaseId !== checkpoint.leaseId || lease.workerId !== checkpoint.workerId) {
|
|
87
|
+
throw new DurableRunLeaseError(`Durable run "${checkpoint.runId}" is not owned by this lease.`);
|
|
88
|
+
}
|
|
89
|
+
const state = this.runs.get(checkpoint.runId);
|
|
90
|
+
if (!state) {
|
|
91
|
+
throw new DurableRunLeaseError(`Durable run "${checkpoint.runId}" has not been started.`);
|
|
92
|
+
}
|
|
93
|
+
if (isTerminalRunStatus(state.status)) {
|
|
94
|
+
throw new DurableTerminalRunError(checkpoint.runId, state.status);
|
|
95
|
+
}
|
|
96
|
+
const committedAt = checkpoint.committedAt ?? new Date().toISOString();
|
|
97
|
+
state.checkpoint = { ...checkpoint, committedAt };
|
|
98
|
+
this.checkpointCommitCount += 1;
|
|
99
|
+
if (this.options.failAfterCheckpoint === this.checkpointCommitCount) {
|
|
100
|
+
this.releaseLease(lease);
|
|
101
|
+
throw new Error(`Injected durable runtime failure after checkpoint ${this.checkpointCommitCount}.`);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
async finishRun(runId, patch) {
|
|
106
|
+
const state = this.runs.get(runId);
|
|
107
|
+
if (!state)
|
|
108
|
+
return;
|
|
109
|
+
state.status = patch.status;
|
|
110
|
+
state.finished = {
|
|
111
|
+
...patch,
|
|
112
|
+
finishedAt: patch.finishedAt ?? new Date().toISOString()
|
|
113
|
+
};
|
|
114
|
+
const lease = this.runLeases.get(runId);
|
|
115
|
+
if (lease) {
|
|
116
|
+
this.releaseLease(lease);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
async withSessionLock(sessionId, fn) {
|
|
120
|
+
let lock = this.sessionLocks.get(sessionId);
|
|
121
|
+
if (!lock) {
|
|
122
|
+
lock = new AsyncMutex();
|
|
123
|
+
this.sessionLocks.set(sessionId, lock);
|
|
124
|
+
}
|
|
125
|
+
return lock.lock(fn);
|
|
126
|
+
}
|
|
127
|
+
assertNoConflictingLease(record) {
|
|
128
|
+
const runLease = this.runLeases.get(record.runId);
|
|
129
|
+
if (runLease && runLease.workerId !== record.workerId) {
|
|
130
|
+
throw new DurableRunLeaseError(`Durable run "${record.runId}" is already owned by worker "${runLease.workerId}".`);
|
|
131
|
+
}
|
|
132
|
+
const sessionLease = this.sessionLeases.get(record.sessionId);
|
|
133
|
+
if (sessionLease && (sessionLease.runId !== record.runId || sessionLease.workerId !== record.workerId)) {
|
|
134
|
+
throw new DurableRunLeaseError(`Durable session "${record.sessionId}" is already owned by run "${sessionLease.runId}".`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
toLease(state, lease) {
|
|
138
|
+
return {
|
|
139
|
+
runId: lease.runId,
|
|
140
|
+
sessionId: lease.sessionId,
|
|
141
|
+
workerId: lease.workerId,
|
|
142
|
+
leaseId: lease.leaseId,
|
|
143
|
+
attempt: state.attempt,
|
|
144
|
+
resumed: Boolean(state.checkpoint),
|
|
145
|
+
start: {
|
|
146
|
+
...state.start,
|
|
147
|
+
attempt: state.attempt
|
|
148
|
+
},
|
|
149
|
+
...(state.checkpoint ? { checkpoint: state.checkpoint } : {}),
|
|
150
|
+
release: async () => {
|
|
151
|
+
await this.withSessionLock(lease.sessionId, async () => {
|
|
152
|
+
this.releaseLease(lease);
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
releaseLease(lease) {
|
|
158
|
+
const activeRunLease = this.runLeases.get(lease.runId);
|
|
159
|
+
if (activeRunLease?.leaseId === lease.leaseId) {
|
|
160
|
+
this.runLeases.delete(lease.runId);
|
|
161
|
+
}
|
|
162
|
+
const activeSessionLease = this.sessionLeases.get(lease.sessionId);
|
|
163
|
+
if (activeSessionLease?.leaseId === lease.leaseId) {
|
|
164
|
+
this.sessionLeases.delete(lease.sessionId);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Creates a self-contained in-memory durable runtime for tests and prototypes.
|
|
170
|
+
*
|
|
171
|
+
* @example
|
|
172
|
+
* ```ts
|
|
173
|
+
* const runtime = inMemoryDurableRuntime({ failAfterCheckpoint: 1 })
|
|
174
|
+
* const lease = await runtime.startRun({
|
|
175
|
+
* runId: 'run-1',
|
|
176
|
+
* sessionId: 'session-1',
|
|
177
|
+
* workerId: 'worker-1',
|
|
178
|
+
* stepId: 'draft',
|
|
179
|
+
* input: { topic: 'durability' }
|
|
180
|
+
* })
|
|
181
|
+
* ```
|
|
182
|
+
*/
|
|
183
|
+
export function inMemoryDurableRuntime(options) {
|
|
184
|
+
return new InMemoryDurableRuntime(options);
|
|
185
|
+
}
|