@bratsos/workflow-engine 0.7.0 → 0.8.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/README.md +93 -0
- package/dist/{chunk-2HEV5ZJL.js → chunk-NANAXHS5.js} +2 -2
- package/dist/chunk-NANAXHS5.js.map +1 -0
- package/dist/{chunk-Q2XDO3UF.js → chunk-TWYPSP7P.js} +92 -3
- package/dist/chunk-TWYPSP7P.js.map +1 -0
- package/dist/{chunk-5C7LRNM7.js → chunk-XPWAEYOO.js} +449 -59
- package/dist/chunk-XPWAEYOO.js.map +1 -0
- package/dist/{client-DYs5wlHp.d.ts → client-YFKVt4p7.d.ts} +3 -3
- package/dist/client.d.ts +4 -4
- package/dist/conventions/index.d.ts +114 -0
- package/dist/conventions/index.js +95 -0
- package/dist/conventions/index.js.map +1 -0
- package/dist/{events-D_P24UaY.d.ts → events-B3XPPu0c.d.ts} +23 -1
- package/dist/{index-aNuJ2QgN.d.ts → index-CL0KmlyW.d.ts} +4 -1
- package/dist/index.d.ts +10 -10
- package/dist/index.js +3 -3
- package/dist/{interface-BeEPzTFy.d.ts → interface-BPz138Hf.d.ts} +108 -1
- package/dist/kernel/index.d.ts +6 -6
- package/dist/kernel/index.js +2 -2
- package/dist/kernel/testing/index.d.ts +3 -3
- package/dist/persistence/index.d.ts +2 -2
- package/dist/persistence/index.js +2 -2
- package/dist/persistence/prisma/index.d.ts +2 -2
- package/dist/persistence/prisma/index.js +2 -2
- package/dist/{plugins-Cl0WVVrE.d.ts → plugins-zT-aIcEZ.d.ts} +63 -4
- package/dist/{ports-swhiWFw4.d.ts → ports-DUL4hlQr.d.ts} +11 -2
- package/dist/{stage-_7BKqqUG.d.ts → stage-WuK0mfrC.d.ts} +81 -1
- package/dist/testing/index.d.ts +8 -1
- package/dist/testing/index.js +86 -1
- package/dist/testing/index.js.map +1 -1
- package/package.json +6 -1
- package/skills/workflow-engine/SKILL.md +58 -1
- package/skills/workflow-engine/migrations/README.md +275 -0
- package/skills/workflow-engine/migrations/migrate-0.7-to-0.8.md +528 -0
- package/skills/workflow-engine/references/10-annotations.md +479 -0
- package/dist/chunk-2HEV5ZJL.js.map +0 -1
- package/dist/chunk-5C7LRNM7.js.map +0 -1
- package/dist/chunk-Q2XDO3UF.js.map +0 -1
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
# Annotations — first-class provenance
|
|
2
|
+
|
|
3
|
+
**Available from v0.8.0.** First-class API for attaching typed key-value facts to runs and stages, so future agents can read a run's history coherently. Inspired by OpenTelemetry semantic conventions.
|
|
4
|
+
|
|
5
|
+
## When to use
|
|
6
|
+
|
|
7
|
+
- **Trigger context** at `run.create`: who/what initiated the run, parent runs, source system.
|
|
8
|
+
- **Decision records** inside a stage: outcomes, rationales, evidence, alternatives.
|
|
9
|
+
- **Approvals and reviews**: post-hoc human sign-off, plugin-attributed events.
|
|
10
|
+
- **Anything you'd later want to query by stable key.**
|
|
11
|
+
|
|
12
|
+
## When *not* to use
|
|
13
|
+
|
|
14
|
+
- **Time-ordered narrative logs**: use `ctx.log(...)`. Annotations are key-addressable; logs are time-ordered.
|
|
15
|
+
- **State transitions**: the kernel emits `stage:started`/`completed`/`failed` events automatically. Don't duplicate them.
|
|
16
|
+
- **Large rich blobs**: put queryable scalars in `attributes`, attach the blob via the `payload` slot.
|
|
17
|
+
|
|
18
|
+
## Three call forms
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
// 1. TypedKey form — value type checked against the convention
|
|
22
|
+
import { Decision } from "@bratsos/workflow-engine/conventions";
|
|
23
|
+
|
|
24
|
+
ctx.annotate(Decision.outcome, "low");
|
|
25
|
+
ctx.annotate(Decision.confidence, 0.42);
|
|
26
|
+
ctx.annotate(Decision.confidence, "high"); // ❌ TS error — expected number
|
|
27
|
+
|
|
28
|
+
// 2. String key form — escape hatch for custom org keys
|
|
29
|
+
ctx.annotate("acme.compliance.signoff", "alice@acme.com");
|
|
30
|
+
|
|
31
|
+
// 3. Batch form — shared envelope for many attributes
|
|
32
|
+
ctx.annotate({
|
|
33
|
+
actor: { kind: "agent", id: "triage-v3", version: "3.0.1" },
|
|
34
|
+
attributes: {
|
|
35
|
+
"decision.outcome": "low",
|
|
36
|
+
"decision.rationale": "AI confidence below threshold",
|
|
37
|
+
"decision.confidence": 0.42,
|
|
38
|
+
"decision.alternatives": ["low", "medium", "high"],
|
|
39
|
+
"decision.used_fallback": true,
|
|
40
|
+
},
|
|
41
|
+
payload: { fullModelResponse: { /* opt-in rich blob */ } },
|
|
42
|
+
idempotencyKey: "decision-stage-1-attempt-0",
|
|
43
|
+
});
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
All three return `void` — writes are buffered and flushed atomically with the stage outcome. `undefined` values are dropped silently (OTel pattern: `ctx.annotate("x.id", maybeId)` is safe without guards).
|
|
47
|
+
|
|
48
|
+
## At run creation
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
await kernel.dispatch({
|
|
52
|
+
type: "run.create",
|
|
53
|
+
workflowId: "ticket-triage",
|
|
54
|
+
input: { ticket },
|
|
55
|
+
annotations: [
|
|
56
|
+
{
|
|
57
|
+
actor: { kind: "system", id: "zendesk-integration" },
|
|
58
|
+
attributes: {
|
|
59
|
+
"trigger.source": "webhook:zendesk",
|
|
60
|
+
"trigger.parent_run_id": previousRunId,
|
|
61
|
+
"trigger.reason": "auto-triage on ticket create",
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
});
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Annotations land in the same transaction as `createRun`. If creation fails, no annotations persist.
|
|
69
|
+
|
|
70
|
+
## External attach (plugins, post-hoc reviews)
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
await kernel.annotations.attach(workflowRunId, {
|
|
74
|
+
actor: { kind: "user", id: "alice@acme.com" },
|
|
75
|
+
attributes: { "review.disposition": "approved-anyway" },
|
|
76
|
+
scope: "run", // default
|
|
77
|
+
idempotencyKey: "review-2026-05-24-alice",
|
|
78
|
+
});
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Single atomic transaction. With `idempotencyKey`, retries on the same `(runId, key, idempotencyKey)` are silently deduped.
|
|
82
|
+
|
|
83
|
+
## Query API
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
// Everything attached to a run, ordered by createdAt then id
|
|
87
|
+
await kernel.annotations.list(runId);
|
|
88
|
+
|
|
89
|
+
// Filters (all AND-combined)
|
|
90
|
+
await kernel.annotations.list(runId, {
|
|
91
|
+
key: "decision.outcome", // exact key
|
|
92
|
+
keyPrefix: "decision.", // namespace prefix (uses index on Postgres)
|
|
93
|
+
scope: "stage", // "run" | "stage" | custom
|
|
94
|
+
scopeId: "classify-urgency",
|
|
95
|
+
actorId: "triage-v3",
|
|
96
|
+
actorKind: "agent",
|
|
97
|
+
attempt: 0,
|
|
98
|
+
since: new Date("2026-05-20T00:00:00Z"),
|
|
99
|
+
until: new Date("2026-05-25T00:00:00Z"),
|
|
100
|
+
limit: 100, // default 1000
|
|
101
|
+
});
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Cross-DB notes
|
|
105
|
+
|
|
106
|
+
- **Postgres**: `keyPrefix` uses the `(workflowRunId, key)` btree index (LIKE 'prefix.%' range scan).
|
|
107
|
+
- **SQLite**: same query path; `LIKE` is case-insensitive by default and may scan at scale. The engine convention is lowercase keys, which keeps the scan fast for small-to-mid datasets. For high-volume SQLite, consider Postgres.
|
|
108
|
+
|
|
109
|
+
Value content is never queryable via the persistence port — only by key. If you need indexed value filtering for a specific attribute, extract that column in your own schema.
|
|
110
|
+
|
|
111
|
+
## Conventions catalog (v0.8)
|
|
112
|
+
|
|
113
|
+
Importable from `@bratsos/workflow-engine/conventions`. All `stable` unless noted.
|
|
114
|
+
|
|
115
|
+
### `Trigger.*`
|
|
116
|
+
|
|
117
|
+
What initiated the run. Attach at `run.create`.
|
|
118
|
+
|
|
119
|
+
| Key | Value type | Description |
|
|
120
|
+
|---|---|---|
|
|
121
|
+
| `trigger.source` | `string` | What system or path initiated the run, e.g. `"webhook:zendesk"`, `"manual:cli"`, `"schedule:cron"` |
|
|
122
|
+
| `trigger.parent_run_id` | `string` | Run ID of the prior run when this is a follow-up |
|
|
123
|
+
| `trigger.reason` | `string` | Free-text rationale for triggering this run |
|
|
124
|
+
| `trigger.actor.kind` | `string` | Recommended: `"user"` / `"agent"` / `"system"`. Open string. |
|
|
125
|
+
| `trigger.actor.id` | `string` | Stable identifier for the triggering actor |
|
|
126
|
+
|
|
127
|
+
### `Decision.*`
|
|
128
|
+
|
|
129
|
+
Choices made during execution. Attach from inside a stage.
|
|
130
|
+
|
|
131
|
+
| Key | Value type | Description |
|
|
132
|
+
|---|---|---|
|
|
133
|
+
| `decision.outcome` | `unknown` | The chosen outcome. Consumer-defined shape. |
|
|
134
|
+
| `decision.rationale` | `string` | Human-readable reason |
|
|
135
|
+
| `decision.confidence` | `number` | Confidence score, typically 0–1 |
|
|
136
|
+
| `decision.alternatives` | `unknown[]` | Alternatives that were considered but not selected |
|
|
137
|
+
| `decision.used_fallback` | `boolean` | Whether a fallback heuristic was used (experimental) |
|
|
138
|
+
|
|
139
|
+
### `Approval.*`
|
|
140
|
+
|
|
141
|
+
Sign-off events. Pluralization rule: `approvers` is always an array.
|
|
142
|
+
|
|
143
|
+
| Key | Value type | Description |
|
|
144
|
+
|---|---|---|
|
|
145
|
+
| `approval.approvers` | `string[]` | All approvers; always an array, `["alice"]` for one |
|
|
146
|
+
| `approval.timestamp` | `string` | ISO 8601 timestamp |
|
|
147
|
+
| `approval.policy.version` | `string` | Policy version (experimental) |
|
|
148
|
+
|
|
149
|
+
### `Revision.*`
|
|
150
|
+
|
|
151
|
+
This run as a revision of a prior run.
|
|
152
|
+
|
|
153
|
+
| Key | Value type | Description |
|
|
154
|
+
|---|---|---|
|
|
155
|
+
| `revision.previous_run_id` | `string` | Run ID this revision supersedes |
|
|
156
|
+
| `revision.reason` | `string` | Why this revision was created |
|
|
157
|
+
|
|
158
|
+
## Naming rules for custom keys
|
|
159
|
+
|
|
160
|
+
- Lowercase, dot-delimited segments, underscores within a segment OK.
|
|
161
|
+
- Org-prefixed for custom keys: `acme.compliance.signoff`, not `compliance.signoff`.
|
|
162
|
+
- Pluralization rule (OTel): singular name = scalar value; plural name = array value. So `approval.approvers: string[]` always, even with one approver.
|
|
163
|
+
- Three-segment structure preferred: `namespace.noun.qualifier`.
|
|
164
|
+
|
|
165
|
+
## Migration from `WorkflowRun.metadata`
|
|
166
|
+
|
|
167
|
+
The `metadata` parameter on `RunCreateCommand` is `@deprecated` in 0.8 and will be removed in 1.0. Existing rows with `WorkflowRun.metadata` populated are automatically projected as virtual `legacy.metadata.*` annotations when you call `kernel.annotations.list(runId)`:
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
// Run was created with:
|
|
171
|
+
// metadata: { tenantId: "acme", source: "webhook" }
|
|
172
|
+
|
|
173
|
+
await kernel.annotations.list(runId);
|
|
174
|
+
// → [
|
|
175
|
+
// { key: "legacy.metadata.source", value: "webhook", scope: "run", ... },
|
|
176
|
+
// { key: "legacy.metadata.tenantId", value: "acme", scope: "run", ... },
|
|
177
|
+
// ]
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
No dual-write — the column remains source of truth for legacy rows. If you want to explicitly migrate a row, attach the keys yourself and the shim stops projecting:
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
await kernel.annotations.attach(runId, {
|
|
184
|
+
attributes: { "legacy.metadata.tenantId": "acme" },
|
|
185
|
+
});
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## Annotation vs log vs outbox — decision matrix
|
|
189
|
+
|
|
190
|
+
A fresh agent dropped into a workflow will mis-classify if they don't internalize this:
|
|
191
|
+
|
|
192
|
+
| You want to record... | Use | Why |
|
|
193
|
+
|---|---|---|
|
|
194
|
+
| Time-ordered narrative for debugging | `ctx.log("INFO", "msg", { meta })` | Logs are streams; `metadata` is per-line context, not indexed by key |
|
|
195
|
+
| A state transition (started/completed/failed/suspended) | Don't — kernel emits the outbox event for you | Don't duplicate kernel-emitted facts |
|
|
196
|
+
| A decision the agent made (outcome, rationale, evidence, alternatives) | `ctx.annotate(Decision.*, ...)` | Key-addressable, queryable across runs, atomic with stage outcome |
|
|
197
|
+
| Who/what triggered the run | `run.create` with `annotations: [{ "trigger.*": ... }]` | Atomic with run creation, queryable later |
|
|
198
|
+
| Post-hoc human action on a run (approval, override, review note) | `kernel.annotations.attach(...)` | Single transaction, idempotent retries |
|
|
199
|
+
| The actual stage output (what downstream stages consume) | `return { output: ... }` | Outputs are the contract between stages; annotations are out-of-band |
|
|
200
|
+
| Large blob (full model response, debug trace) | `ctx.annotate({ attributes, payload: { ... } })` | `payload` is not indexed but lives alongside the queryable attributes |
|
|
201
|
+
| A workflow-level artifact (file, embedding, generated content) | `ctx.storage.save(key, data)` | Artifacts live in the BlobStore; annotations are for labels/facts about them |
|
|
202
|
+
|
|
203
|
+
**Rule of thumb**: if you'd want to filter or query by it across runs, it's an annotation. If it's a story being told in order, it's a log. If it's a state transition, the kernel handles it.
|
|
204
|
+
|
|
205
|
+
## Query patterns
|
|
206
|
+
|
|
207
|
+
These are the queries most consumers and future agents actually want to run.
|
|
208
|
+
|
|
209
|
+
### Timeline reconstruction — "what happened in this run, in order?"
|
|
210
|
+
|
|
211
|
+
```ts
|
|
212
|
+
const timeline = await kernel.annotations.list(runId);
|
|
213
|
+
// Already sorted by createdAt then id — stable across calls.
|
|
214
|
+
|
|
215
|
+
for (const a of timeline) {
|
|
216
|
+
console.log(
|
|
217
|
+
`[${a.createdAt.toISOString()}] ` +
|
|
218
|
+
`${a.actorKind ?? "?"}:${a.actorId ?? "?"} → ` +
|
|
219
|
+
`${a.key} = ${JSON.stringify(a.value)}`,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
### Decision audit — "what decisions did the triage agent make?"
|
|
225
|
+
|
|
226
|
+
```ts
|
|
227
|
+
const decisions = await kernel.annotations.list(runId, {
|
|
228
|
+
keyPrefix: "decision.",
|
|
229
|
+
actorId: "triage-agent",
|
|
230
|
+
});
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
### Who-did-what across one run
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
const trail = await kernel.annotations.list(runId, {
|
|
237
|
+
actorId: "compliance-agent-v2",
|
|
238
|
+
});
|
|
239
|
+
// Group by stage:
|
|
240
|
+
const byStage = new Map<string, typeof trail>();
|
|
241
|
+
for (const a of trail) {
|
|
242
|
+
const k = a.scopeId ?? "<run>";
|
|
243
|
+
byStage.set(k, [...(byStage.get(k) ?? []), a]);
|
|
244
|
+
}
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
### Walking a chain of runs via `trigger.parent_run_id`
|
|
248
|
+
|
|
249
|
+
```ts
|
|
250
|
+
async function findChainRoot(runId: string): Promise<string> {
|
|
251
|
+
const trigger = await kernel.annotations.list(runId, {
|
|
252
|
+
key: "trigger.parent_run_id",
|
|
253
|
+
limit: 1,
|
|
254
|
+
});
|
|
255
|
+
const parent = trigger[0]?.value as string | undefined;
|
|
256
|
+
return parent ? findChainRoot(parent) : runId;
|
|
257
|
+
}
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
### Recent annotations only
|
|
261
|
+
|
|
262
|
+
```ts
|
|
263
|
+
const lastHour = await kernel.annotations.list(runId, {
|
|
264
|
+
since: new Date(Date.now() - 60 * 60 * 1000),
|
|
265
|
+
});
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
## Patterns for structured decision provenance
|
|
269
|
+
|
|
270
|
+
The minimal-but-useful "decision" annotation shape, derived from agent-orchestration use cases:
|
|
271
|
+
|
|
272
|
+
```ts
|
|
273
|
+
ctx.annotate({
|
|
274
|
+
actor: { kind: "agent", id: "triage", version: "v3" },
|
|
275
|
+
attributes: {
|
|
276
|
+
"decision.outcome": chosen, // the answer
|
|
277
|
+
"decision.rationale": explanation, // why
|
|
278
|
+
"decision.confidence": score, // 0–1
|
|
279
|
+
"decision.alternatives": alternativesConsidered,// what else was on the table
|
|
280
|
+
"decision.used_fallback": ranKeywordFallback, // did we use the primary signal?
|
|
281
|
+
},
|
|
282
|
+
payload: {
|
|
283
|
+
rawAIResponse: fullModelOutput, // the evidence
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
Why each field earns its place:
|
|
289
|
+
- `outcome`: queryable. Lets you ask "how many runs decided X?"
|
|
290
|
+
- `rationale`: human-readable; what a human reviewer or future agent reads first.
|
|
291
|
+
- `confidence`: lets you find low-confidence decisions to spot-check.
|
|
292
|
+
- `alternatives`: makes the decision space visible — "we considered X, picked Y." Plural per OTel rule.
|
|
293
|
+
- `used_fallback`: cheap boolean for filtering all fallback paths.
|
|
294
|
+
- `payload.rawAIResponse`: the evidence trail without polluting the queryable layer.
|
|
295
|
+
|
|
296
|
+
## Testing annotations
|
|
297
|
+
|
|
298
|
+
The in-memory persistence adapter exposes a test helper for asserting on writes:
|
|
299
|
+
|
|
300
|
+
```ts
|
|
301
|
+
import { InMemoryWorkflowPersistence } from "@bratsos/workflow-engine/testing";
|
|
302
|
+
|
|
303
|
+
const persistence = new InMemoryWorkflowPersistence();
|
|
304
|
+
// ... drive your workflow ...
|
|
305
|
+
|
|
306
|
+
const annotations = persistence.getAllAnnotations();
|
|
307
|
+
expect(annotations).toContainEqual(
|
|
308
|
+
expect.objectContaining({
|
|
309
|
+
key: "decision.outcome",
|
|
310
|
+
value: "low",
|
|
311
|
+
scope: "stage",
|
|
312
|
+
scopeId: "classify-urgency",
|
|
313
|
+
}),
|
|
314
|
+
);
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
Or via the public query API:
|
|
318
|
+
|
|
319
|
+
```ts
|
|
320
|
+
const decisions = await kernel.annotations.list(runId, {
|
|
321
|
+
keyPrefix: "decision.",
|
|
322
|
+
});
|
|
323
|
+
expect(decisions).toHaveLength(3);
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
Annotations are flushed inside the stage-completion transaction. In tests this means: after `kernel.dispatch({ type: "job.execute", ... })` returns, all annotations written via `ctx.annotate` are persisted and queryable.
|
|
327
|
+
|
|
328
|
+
## Common pitfalls
|
|
329
|
+
|
|
330
|
+
### Don't put high-cardinality values in keys
|
|
331
|
+
|
|
332
|
+
```ts
|
|
333
|
+
// ❌ Wrong — every unique run gets its own key
|
|
334
|
+
ctx.annotate(`decision.outcome.${runId}`, "low");
|
|
335
|
+
|
|
336
|
+
// ✅ Right — the key is stable; the value carries the variance
|
|
337
|
+
ctx.annotate("decision.outcome", "low");
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
The unique constraint and `(workflowRunId, key)` index only help when keys repeat. A key per run defeats the design.
|
|
341
|
+
|
|
342
|
+
### Don't annotate state transitions
|
|
343
|
+
|
|
344
|
+
```ts
|
|
345
|
+
// ❌ Wrong — kernel already emits this as an outbox event
|
|
346
|
+
ctx.annotate("stage.completed", true);
|
|
347
|
+
|
|
348
|
+
// ✅ Right — annotate the *content* of what was decided, not the fact that the stage ran
|
|
349
|
+
ctx.annotate("decision.outcome", "low");
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
`stage:started` / `stage:completed` / `stage:failed` / `stage:suspended` are kernel-emitted outbox events. Duplicating them in annotations clutters queries.
|
|
353
|
+
|
|
354
|
+
### Don't write annotations from outside the kernel's transactional boundary
|
|
355
|
+
|
|
356
|
+
```ts
|
|
357
|
+
// ❌ Wrong — bypasses transactional semantics, no atomicity with stage outcome
|
|
358
|
+
await persistence.appendAnnotations([{ ... }]);
|
|
359
|
+
|
|
360
|
+
// ✅ Right — let the kernel handle it
|
|
361
|
+
ctx.annotate(...);
|
|
362
|
+
// or for plugin / external attach:
|
|
363
|
+
await kernel.annotations.attach(runId, { ... });
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
Calling `persistence.appendAnnotations` directly bypasses the buffer-and-flush guarantee. Annotations could persist while the stage rolls back, or vice versa.
|
|
367
|
+
|
|
368
|
+
### Don't use `value` as a blob slot
|
|
369
|
+
|
|
370
|
+
```ts
|
|
371
|
+
// ❌ Wrong — defeats key-prefix querying, breaks cross-DB indexing
|
|
372
|
+
ctx.annotate("decision", {
|
|
373
|
+
outcome: "low",
|
|
374
|
+
evidence: { /* large nested object */ },
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
// ✅ Right — scalars in `value`, blobs in `payload`
|
|
378
|
+
ctx.annotate({
|
|
379
|
+
attributes: {
|
|
380
|
+
"decision.outcome": "low",
|
|
381
|
+
},
|
|
382
|
+
payload: { evidence: { /* large nested object */ } },
|
|
383
|
+
});
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
The `value` column is indexed and meant for scalars/arrays. The `payload` column is the explicit blob slot.
|
|
387
|
+
|
|
388
|
+
### Don't use `idempotencyKey` for stage-scope annotations
|
|
389
|
+
|
|
390
|
+
```ts
|
|
391
|
+
// ❌ Unnecessary — stage annotations are already atomic with stage outcome
|
|
392
|
+
ctx.annotate({
|
|
393
|
+
attributes: { "decision.outcome": "low" },
|
|
394
|
+
idempotencyKey: "stage-1-decision", // redundant; stage outcome handles it
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
// ✅ Right — reserve idempotencyKey for external attach and run.create
|
|
398
|
+
await kernel.annotations.attach(runId, {
|
|
399
|
+
attributes: { "review.disposition": "approved" },
|
|
400
|
+
idempotencyKey: "review-2026-05-24-alice",
|
|
401
|
+
});
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
The buffer-and-flush model makes stage-scope writes inherently atomic. Idempotency keys are for retry-prone *external* paths.
|
|
405
|
+
|
|
406
|
+
## Best practices
|
|
407
|
+
|
|
408
|
+
- **Use TypedKeys for well-known concepts**: `ctx.annotate(Decision.outcome, ...)` beats `ctx.annotate("decision.outcome", ...)` because the value type is checked and typos are caught at compile time.
|
|
409
|
+
- **Prefer scalars and scalar arrays in `attributes`**: they're indexable and queryable. Use the `payload` slot for rich blobs.
|
|
410
|
+
- **Set `actor` on every annotation**: the future agent reading this should know who recorded it.
|
|
411
|
+
- **Org-prefix custom keys**: `acme.compliance.signoff`, not `compliance.signoff`. Prevents accidental collisions with future engine conventions.
|
|
412
|
+
- **Use `idempotencyKey` for retry-sensitive paths**: external attach calls especially. Stage-scope annotations don't need it.
|
|
413
|
+
- **Don't annotate state transitions**: the kernel emits outbox events for those.
|
|
414
|
+
- **Keep per-stage annotation counts modest**: a chatty agent writing 100+ annotations per stage will fan out 100+ rows per execution. Aim for a small set of high-signal facts, not full traces.
|
|
415
|
+
- **Use logs for narrative, annotations for facts**: if it has a timestamp and reads as a sentence, it's a log. If it has a key and a typed value, it's an annotation.
|
|
416
|
+
|
|
417
|
+
## Reruns and the `attempt` axis
|
|
418
|
+
|
|
419
|
+
`run.rerunFrom` recreates stage records at and after the target group. The engine assigns the new stage records a fresh `attempt` value (one higher than the max attempt across the deleted stages), and `ctx.annotate(...)` inherits that value for the new annotations. Annotations from the prior attempt survive (the FK to the deleted stage record is `SetNull`, preserving the row with its original `attempt` value).
|
|
420
|
+
|
|
421
|
+
This means a single run's annotations can carry multiple `attempt` values, distinguishing decisions made on different runs of the same logical stage. Filter by `attempt` to look at just one attempt:
|
|
422
|
+
|
|
423
|
+
```ts
|
|
424
|
+
// Just the most recent attempt
|
|
425
|
+
const latestAttempt = await kernel.annotations.list(runId, { attempt: 1 });
|
|
426
|
+
|
|
427
|
+
// All attempts in chronological order
|
|
428
|
+
const allAttempts = await kernel.annotations.list(runId, { keyPrefix: "decision." });
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
## Outbox emission (opt-in)
|
|
432
|
+
|
|
433
|
+
By default, writing an annotation does **not** emit an outbox event. If a plugin or external system needs push notifications on annotation writes (audit pipelines, SIEM, live dashboards), set `emitEvent: true` and the engine writes an `annotation:created` outbox event in the same transaction as the annotation row.
|
|
434
|
+
|
|
435
|
+
**Caveat when combining `emitEvent` with `idempotencyKey`:** the engine skips duplicate `(workflowRunId, key, idempotencyKey)` rows under the unique constraint, but emits one `annotation:created` event per input regardless. On a retry with the same idempotency key, the row is deduplicated (correct) but the event fires again. If your downstream consumer cares about exactly-once delivery, dedupe events on `(causationId, key)` or use a stable identifier in the event payload — don't assume one event per persisted row.
|
|
436
|
+
|
|
437
|
+
```ts
|
|
438
|
+
// Stage-scope
|
|
439
|
+
ctx.annotate("decision.outcome", "low", { emitEvent: true });
|
|
440
|
+
|
|
441
|
+
// Batch
|
|
442
|
+
ctx.annotate({
|
|
443
|
+
attributes: { "approval.approvers": ["alice", "bob"] },
|
|
444
|
+
emitEvent: true,
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
// External attach
|
|
448
|
+
await kernel.annotations.attach(runId, {
|
|
449
|
+
attributes: { "review.disposition": "approved" },
|
|
450
|
+
emitEvent: true,
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
// At run.create
|
|
454
|
+
annotations: [{
|
|
455
|
+
attributes: { "trigger.source": "webhook" },
|
|
456
|
+
emitEvent: true,
|
|
457
|
+
}]
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
Subscribe via the existing event sink (the kernel routes `annotation:created` events through the same outbox machinery as `stage:completed` etc.):
|
|
461
|
+
|
|
462
|
+
```ts
|
|
463
|
+
const plugin = definePlugin({
|
|
464
|
+
id: "annotation-audit",
|
|
465
|
+
name: "Annotation Audit",
|
|
466
|
+
on: ["annotation:created"],
|
|
467
|
+
async handle(event) {
|
|
468
|
+
// event.key, event.value, event.scope, event.actorId, etc.
|
|
469
|
+
},
|
|
470
|
+
});
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
Keep emission off for high-volume runs — every emit-on annotation is one outbox row to flush.
|
|
474
|
+
|
|
475
|
+
## Cancellation safety
|
|
476
|
+
|
|
477
|
+
When `run.cancel` commits **before** a stage's Phase 3 (or Phase 2 suspended-poll) transaction begins, the transaction reads the cancelled status from the run, throws, and rolls back atomically — the stage's annotations, status update, and outbox events all roll back together. This closes the common race window where cancel had clearly committed first but the stage was still mid-flight.
|
|
478
|
+
|
|
479
|
+
There is a residual narrow window where cancel commits **after** the transaction's status check but **before** the transaction commits. Under standard READ COMMITTED isolation the status check is not a row lock, so a concurrent cancel can still slip through that gap. The window is small (microseconds inside a transaction) and applies equally to all Phase 3 writes (stage status, outbox events, annotations) — annotations don't introduce the race, they just inherit it. A future engine release may close this fully via `SELECT ... FOR UPDATE` or coordinated optimistic locking; for now consumers who need stronger cancel atomicity should rely on the kernel's existing ghost-job detection downstream.
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/persistence/interface.ts"],"names":[],"mappings":";AAkDO,IAAM,iBAAA,GAAN,cAAgC,KAAA,CAAM;AAAA,EAC3C,WAAA,CACkB,MAAA,EACA,EAAA,EACA,QAAA,EACA,MAAA,EAChB;AACA,IAAA,KAAA;AAAA,MACE,oBAAoB,MAAM,CAAA,CAAA,EAAI,EAAE,CAAA,WAAA,EAAc,QAAQ,SAAS,MAAM,CAAA;AAAA,KACvE;AAPgB,IAAA,IAAA,CAAA,MAAA,GAAA,MAAA;AACA,IAAA,IAAA,CAAA,EAAA,GAAA,EAAA;AACA,IAAA,IAAA,CAAA,QAAA,GAAA,QAAA;AACA,IAAA,IAAA,CAAA,MAAA,GAAA,MAAA;AAKhB,IAAA,IAAA,CAAK,IAAA,GAAO,mBAAA;AAAA,EACd;AACF","file":"chunk-2HEV5ZJL.js","sourcesContent":["/**\n * Persistence Interfaces for Workflow Engine\n *\n * These interfaces abstract database operations to enable:\n * - Testing with mock implementations\n * - Future extraction into @bratsos/workflow-engine package\n * - Alternative database backends\n *\n * Implementations:\n * - PrismaWorkflowPersistence (default, in ./prisma/)\n * - InMemoryPersistence (for testing)\n */\n\n// ============================================================================\n// Unified Status Type\n// ============================================================================\n\n/**\n * Unified status type for workflows, stages, and jobs.\n *\n * - PENDING: Not started yet\n * - RUNNING: Currently executing\n * - SUSPENDED: Paused, waiting for external event (e.g., batch job completion)\n * - COMPLETED: Finished successfully\n * - FAILED: Finished with error\n * - CANCELLED: Manually stopped by user\n * - SKIPPED: Stage-specific - bypassed due to condition\n */\nexport type Status =\n | \"PENDING\"\n | \"RUNNING\"\n | \"SUSPENDED\"\n | \"COMPLETED\"\n | \"FAILED\"\n | \"CANCELLED\"\n | \"SKIPPED\";\n\n/** @deprecated Use Status instead */\nexport type WorkflowStatus = Status;\n\n/** @deprecated Use Status instead */\nexport type WorkflowStageStatus = Status;\n\n/** @deprecated Use Status instead. Note: PROCESSING is now RUNNING. */\nexport type JobStatus = Status;\n\nexport type LogLevel = \"DEBUG\" | \"INFO\" | \"WARN\" | \"ERROR\";\n\nexport type ArtifactType = \"STAGE_OUTPUT\" | \"ARTIFACT\" | \"METADATA\";\n\nexport class StaleVersionError extends Error {\n constructor(\n public readonly entity: string,\n public readonly id: string,\n public readonly expected: number,\n public readonly actual: number,\n ) {\n super(\n `Stale version on ${entity} ${id}: expected ${expected}, got ${actual}`,\n );\n this.name = \"StaleVersionError\";\n }\n}\n\n// ============================================================================\n// Record Types (minimal fields needed by the workflow engine)\n// ============================================================================\n\nexport interface WorkflowRunRecord {\n id: string;\n createdAt: Date;\n updatedAt: Date;\n version: number;\n workflowId: string;\n workflowName: string;\n workflowType: string;\n status: WorkflowStatus;\n startedAt: Date | null;\n completedAt: Date | null;\n duration: number | null;\n input: unknown;\n output: unknown | null;\n config: unknown;\n totalCost: number;\n totalTokens: number;\n priority: number;\n metadata: unknown | null;\n}\n\nexport interface WorkflowStageRecord {\n id: string;\n createdAt: Date;\n updatedAt: Date;\n version: number;\n workflowRunId: string;\n stageId: string;\n stageName: string;\n stageNumber: number;\n executionGroup: number;\n status: WorkflowStageStatus;\n startedAt: Date | null;\n completedAt: Date | null;\n duration: number | null;\n inputData: unknown | null;\n outputData: unknown | null;\n config: unknown | null;\n suspendedState: unknown | null;\n resumeData: unknown | null;\n nextPollAt: Date | null;\n pollInterval: number | null;\n maxWaitUntil: Date | null;\n metrics: unknown | null;\n embeddingInfo: unknown | null;\n errorMessage: string | null;\n}\n\nexport interface WorkflowLogRecord {\n id: string;\n createdAt: Date;\n workflowStageId: string | null;\n workflowRunId: string | null;\n level: LogLevel;\n message: string;\n metadata: unknown | null;\n}\n\nexport interface WorkflowArtifactRecord {\n id: string;\n createdAt: Date;\n updatedAt: Date;\n workflowRunId: string;\n workflowStageId: string | null;\n key: string;\n type: ArtifactType;\n data: unknown;\n size: number;\n metadata: unknown | null;\n}\n\n// ============================================================================\n// Outbox and Idempotency Record Types (for kernel transactional outbox)\n// ============================================================================\n\nexport interface OutboxRecord {\n id: string;\n workflowRunId: string;\n sequence: number;\n eventType: string;\n payload: unknown;\n causationId: string;\n occurredAt: Date;\n publishedAt: Date | null;\n retryCount: number;\n dlqAt: Date | null;\n}\n\nexport interface CreateOutboxEventInput {\n workflowRunId: string;\n eventType: string;\n payload: unknown;\n causationId: string;\n occurredAt: Date;\n}\n\nexport interface IdempotencyRecord {\n key: string;\n commandType: string;\n result: unknown;\n createdAt: Date;\n}\n\n// ============================================================================\n// AI Call Record Types\n// ============================================================================\n\nexport interface AICallRecord {\n id: string;\n createdAt: Date;\n topic: string;\n callType: string;\n modelKey: string;\n modelId: string;\n prompt: string;\n response: string;\n inputTokens: number;\n outputTokens: number;\n cost: number;\n metadata: unknown | null;\n}\n\nexport interface JobRecord {\n id: string;\n createdAt: Date;\n updatedAt: Date;\n workflowRunId: string;\n workflowId: string;\n stageId: string;\n status: JobStatus;\n priority: number;\n workerId: string | null;\n lockedAt: Date | null;\n startedAt: Date | null;\n completedAt: Date | null;\n attempt: number;\n maxAttempts: number;\n lastError: string | null;\n nextPollAt: Date | null;\n payload: Record<string, unknown>;\n}\n\n// ============================================================================\n// Input Types (for creating/updating records)\n// ============================================================================\n\nexport interface CreateRunInput {\n id?: string;\n workflowId: string;\n workflowName: string;\n workflowType: string;\n input: unknown;\n config?: unknown;\n priority?: number;\n /** Optional metadata stored as JSON on the run record. NOT spread into Prisma fields. */\n metadata?: Record<string, unknown>;\n}\n\nexport interface UpdateRunInput {\n status?: WorkflowStatus;\n startedAt?: Date;\n completedAt?: Date | null;\n duration?: number | null;\n output?: unknown;\n totalCost?: number;\n totalTokens?: number;\n expectedVersion?: number;\n}\n\nexport interface CreateStageInput {\n workflowRunId: string;\n stageId: string;\n stageName: string;\n stageNumber: number;\n executionGroup: number;\n status?: WorkflowStageStatus;\n startedAt?: Date;\n config?: unknown;\n inputData?: unknown;\n}\n\nexport interface UpdateStageInput {\n status?: WorkflowStageStatus;\n startedAt?: Date;\n completedAt?: Date;\n duration?: number;\n outputData?: unknown;\n config?: unknown;\n suspendedState?: unknown;\n resumeData?: unknown;\n nextPollAt?: Date | null;\n pollInterval?: number;\n maxWaitUntil?: Date;\n metrics?: unknown;\n embeddingInfo?: unknown;\n artifacts?: unknown;\n errorMessage?: string;\n expectedVersion?: number;\n}\n\nexport interface UpsertStageInput {\n workflowRunId: string;\n stageId: string;\n create: CreateStageInput;\n update: UpdateStageInput;\n}\n\nexport interface CreateLogInput {\n workflowRunId?: string;\n workflowStageId?: string;\n level: LogLevel;\n message: string;\n metadata?: unknown;\n}\n\nexport interface SaveArtifactInput {\n workflowRunId: string;\n workflowStageId?: string;\n key: string;\n type: ArtifactType;\n data: unknown;\n size: number;\n metadata?: unknown;\n}\n\nexport interface CreateAICallInput {\n topic: string;\n callType: string;\n modelKey: string;\n modelId: string;\n prompt: string;\n response: string;\n inputTokens: number;\n outputTokens: number;\n cost: number;\n metadata?: unknown;\n}\n\nexport interface EnqueueJobInput {\n workflowRunId: string;\n workflowId: string;\n stageId: string;\n priority?: number;\n payload?: Record<string, unknown>;\n scheduledFor?: Date;\n}\n\nexport interface DequeueResult {\n jobId: string;\n workflowRunId: string;\n workflowId: string;\n stageId: string;\n priority: number;\n attempt: number;\n maxAttempts: number;\n payload: Record<string, unknown>;\n}\n\n// ============================================================================\n// WorkflowPersistence Interface\n// ============================================================================\n\nexport interface WorkflowPersistence {\n /** Execute operations within a transaction boundary. */\n withTransaction<T>(fn: (tx: WorkflowPersistence) => Promise<T>): Promise<T>;\n\n // WorkflowRun operations\n createRun(data: CreateRunInput): Promise<WorkflowRunRecord>;\n updateRun(id: string, data: UpdateRunInput): Promise<void>;\n getRun(id: string): Promise<WorkflowRunRecord | null>;\n getRunStatus(id: string): Promise<WorkflowStatus | null>;\n getRunsByStatus(status: WorkflowStatus): Promise<WorkflowRunRecord[]>;\n getStuckRuns(stuckSince: Date): Promise<WorkflowRunRecord[]>;\n\n /**\n * Atomically claim a pending workflow run for processing.\n * Uses atomic update with WHERE status = 'PENDING' to prevent race conditions.\n *\n * @param id - The workflow run ID to claim\n * @returns true if successfully claimed, false if already claimed by another worker\n */\n claimPendingRun(id: string): Promise<boolean>;\n\n /**\n * Atomically find and claim the next pending workflow run.\n * Uses FOR UPDATE SKIP LOCKED pattern (in Postgres) to prevent race conditions\n * when multiple workers try to claim workflows simultaneously.\n *\n * Priority ordering: higher priority first, then oldest (FIFO within same priority).\n *\n * @returns The claimed workflow run (now with status RUNNING), or null if no pending runs\n */\n claimNextPendingRun(): Promise<WorkflowRunRecord | null>;\n\n // WorkflowStage operations\n createStage(data: CreateStageInput): Promise<WorkflowStageRecord>;\n upsertStage(data: UpsertStageInput): Promise<WorkflowStageRecord>;\n updateStage(id: string, data: UpdateStageInput): Promise<void>;\n updateStageByRunAndStageId(\n workflowRunId: string,\n stageId: string,\n data: UpdateStageInput,\n ): Promise<void>;\n getStage(runId: string, stageId: string): Promise<WorkflowStageRecord | null>;\n getStageById(id: string): Promise<WorkflowStageRecord | null>;\n getStagesByRun(\n runId: string,\n options?: { status?: WorkflowStageStatus; orderBy?: \"asc\" | \"desc\" },\n ): Promise<WorkflowStageRecord[]>;\n getSuspendedStages(beforeDate: Date): Promise<WorkflowStageRecord[]>;\n getFirstSuspendedStageReadyToResume(\n runId: string,\n ): Promise<WorkflowStageRecord | null>;\n getFirstFailedStage(runId: string): Promise<WorkflowStageRecord | null>;\n getLastCompletedStage(runId: string): Promise<WorkflowStageRecord | null>;\n getLastCompletedStageBefore(\n runId: string,\n executionGroup: number,\n ): Promise<WorkflowStageRecord | null>;\n deleteStage(id: string): Promise<void>;\n\n // WorkflowLog operations\n createLog(data: CreateLogInput): Promise<void>;\n\n // WorkflowArtifact operations (for StageStorage)\n saveArtifact(data: SaveArtifactInput): Promise<void>;\n loadArtifact(runId: string, key: string): Promise<unknown>;\n hasArtifact(runId: string, key: string): Promise<boolean>;\n deleteArtifact(runId: string, key: string): Promise<void>;\n listArtifacts(runId: string): Promise<WorkflowArtifactRecord[]>;\n getStageIdForArtifact(runId: string, stageId: string): Promise<string | null>;\n\n // Stage output convenience methods (replaces separate StageStorage)\n saveStageOutput(\n runId: string,\n workflowType: string,\n stageId: string,\n output: unknown,\n ): Promise<string>;\n\n // Outbox DLQ operations\n /** Increment retry count for a failed outbox event. Returns new count. */\n incrementOutboxRetryCount(id: string): Promise<number>;\n\n /** Move an outbox event to DLQ (sets dlqAt). */\n moveOutboxEventToDLQ(id: string): Promise<void>;\n\n /** Reset DLQ events so they can be reprocessed by outbox.flush. Returns count reset. */\n replayDLQEvents(maxEvents: number): Promise<number>;\n\n // Outbox operations\n /** Write events to the outbox. Sequences are auto-assigned per workflowRunId. */\n appendOutboxEvents(events: CreateOutboxEventInput[]): Promise<void>;\n\n /** Read unpublished events ordered by (workflowRunId, sequence). */\n getUnpublishedOutboxEvents(limit?: number): Promise<OutboxRecord[]>;\n\n /** Mark events as published. */\n markOutboxEventsPublished(ids: string[]): Promise<void>;\n\n // Idempotency operations\n /** Atomically acquire an idempotency key for command execution. */\n acquireIdempotencyKey(\n key: string,\n commandType: string,\n ): Promise<\n | { status: \"acquired\" }\n | { status: \"replay\"; result: unknown }\n | { status: \"in_progress\" }\n >;\n\n /** Mark an idempotency key as completed and cache the command result. */\n completeIdempotencyKey(\n key: string,\n commandType: string,\n result: unknown,\n ): Promise<void>;\n\n /** Release an in-progress idempotency key after command failure. */\n releaseIdempotencyKey(key: string, commandType: string): Promise<void>;\n}\n\n// ============================================================================\n// AICallLogger Interface\n// ============================================================================\n\nexport interface AIHelperStats {\n totalCalls: number;\n totalInputTokens: number;\n totalOutputTokens: number;\n totalCost: number;\n perModel: Record<\n string,\n { calls: number; inputTokens: number; outputTokens: number; cost: number }\n >;\n}\n\nexport interface AICallLogger {\n /**\n * Log a single AI call (fire and forget)\n */\n logCall(call: CreateAICallInput): void;\n\n /**\n * Log batch results (for recording batch API results)\n */\n logBatchResults(batchId: string, results: CreateAICallInput[]): Promise<void>;\n\n /**\n * Get aggregated stats for a topic prefix\n */\n getStats(topicPrefix: string): Promise<AIHelperStats>;\n\n /**\n * Check if batch results are already recorded\n */\n isRecorded(batchId: string): Promise<boolean>;\n}\n\n// ============================================================================\n// JobQueue Interface\n// ============================================================================\n\nexport interface JobQueue {\n /**\n * Add a new job to the queue\n */\n enqueue(options: EnqueueJobInput): Promise<string>;\n\n /**\n * Enqueue multiple stages in parallel (same execution group)\n */\n enqueueParallel(jobs: EnqueueJobInput[]): Promise<string[]>;\n\n /**\n * Atomically dequeue the next available job\n */\n dequeue(): Promise<DequeueResult | null>;\n\n /**\n * Mark job as completed\n */\n complete(jobId: string): Promise<void>;\n\n /**\n * Mark job as suspended (for async-batch)\n */\n suspend(jobId: string, nextPollAt: Date): Promise<void>;\n\n /**\n * Mark job as failed\n */\n fail(jobId: string, error: string, shouldRetry?: boolean): Promise<void>;\n\n /**\n * Get suspended jobs that are ready to be checked\n */\n getSuspendedJobsReadyToPoll(): Promise<\n Array<{ jobId: string; stageId: string; workflowRunId: string }>\n >;\n\n /**\n * Release stale locks (for crashed workers)\n */\n releaseStaleJobs(staleThresholdMs?: number): Promise<number>;\n\n /**\n * Cancel all pending/suspended jobs for a workflow run.\n * Returns count of cancelled jobs.\n */\n cancelByRun(workflowRunId: string): Promise<number>;\n}\n\n// ============================================================================\n// Default Implementations (lazy loaded to avoid circular deps)\n// ============================================================================\n\n// Re-export from prisma implementations for convenience\n// These will be the default implementations used when no custom persistence is provided\n"]}
|