@abloatai/ablo 0.5.1 → 0.6.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/CHANGELOG.md +16 -0
- package/README.md +217 -122
- package/dist/BaseSyncedStore.d.ts +2 -2
- package/dist/BaseSyncedStore.js +2 -2
- package/dist/api/index.d.ts +3 -3
- package/dist/api/index.js +1 -1
- package/dist/client/Ablo.d.ts +90 -93
- package/dist/client/Ablo.js +121 -60
- package/dist/client/ApiClient.d.ts +14 -14
- package/dist/client/ApiClient.js +81 -55
- package/dist/client/createInternalComponents.d.ts +2 -3
- package/dist/client/createInternalComponents.js +2 -3
- package/dist/client/createModelProxy.d.ts +90 -87
- package/dist/client/createModelProxy.js +124 -127
- package/dist/client/index.d.ts +6 -7
- package/dist/client/index.js +4 -5
- package/dist/client/validateAbloOptions.js +3 -3
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.js +7 -0
- package/dist/errors.d.ts +8 -8
- package/dist/errors.js +18 -10
- package/dist/index.d.ts +9 -8
- package/dist/index.js +7 -11
- package/dist/interfaces/index.d.ts +2 -10
- package/dist/mutators/Transaction.d.ts +2 -2
- package/dist/mutators/Transaction.js +2 -2
- package/dist/mutators/mutateActions.d.ts +44 -0
- package/dist/{react/useMutate.js → mutators/mutateActions.js} +11 -28
- package/dist/mutators/readerActions.d.ts +32 -0
- package/dist/{react/useReader.js → mutators/readerActions.js} +2 -18
- package/dist/query/types.d.ts +1 -1
- package/dist/react/AbloProvider.d.ts +1 -1
- package/dist/react/AbloProvider.js +3 -3
- package/dist/react/context.d.ts +4 -4
- package/dist/react/index.d.ts +4 -5
- package/dist/react/index.js +3 -7
- package/dist/react/useAblo.d.ts +14 -14
- package/dist/react/useAblo.js +26 -26
- package/dist/react/useIntent.d.ts +2 -2
- package/dist/react/useIntent.js +2 -2
- package/dist/react/useMutators.d.ts +1 -1
- package/dist/react/usePresence.d.ts +3 -3
- package/dist/react/usePresence.js +4 -4
- package/dist/react/useUndoScope.d.ts +1 -1
- package/dist/schema/diff.d.ts +161 -0
- package/dist/schema/diff.js +262 -0
- package/dist/schema/generate.d.ts +19 -0
- package/dist/schema/generate.js +87 -0
- package/dist/schema/index.d.ts +4 -1
- package/dist/schema/index.js +7 -1
- package/dist/schema/schema.d.ts +83 -32
- package/dist/schema/schema.js +58 -12
- package/dist/schema/serialize.d.ts +92 -0
- package/dist/schema/serialize.js +227 -0
- package/dist/sync/SyncWebSocket.d.ts +17 -0
- package/dist/sync/SyncWebSocket.js +46 -1
- package/dist/sync/awaitIntentGrant.d.ts +26 -0
- package/dist/sync/awaitIntentGrant.js +60 -0
- package/dist/sync/createIntentStream.js +43 -4
- package/dist/sync/createPresenceStream.js +1 -1
- package/dist/sync/participants.d.ts +2 -2
- package/dist/sync/participants.js +4 -4
- package/dist/types/global.d.ts +43 -52
- package/dist/types/global.js +16 -18
- package/dist/types/streams.d.ts +37 -9
- package/docs/api.md +68 -158
- package/docs/audit.md +5 -5
- package/docs/client-behavior.md +41 -42
- package/docs/coordination.md +294 -0
- package/docs/data-sources.md +14 -14
- package/docs/examples/agent-human.md +30 -32
- package/docs/examples/ai-sdk-tool.md +32 -33
- package/docs/examples/existing-python-backend.md +35 -33
- package/docs/examples/nextjs.md +24 -25
- package/docs/examples/server-agent.md +20 -61
- package/docs/guarantees.md +30 -55
- package/docs/identity.md +458 -0
- package/docs/index.md +12 -24
- package/docs/integration-guide.md +106 -116
- package/docs/interaction-model.md +29 -95
- package/docs/mcp/claude-code.md +3 -3
- package/docs/mcp/cursor.md +1 -1
- package/docs/mcp/windsurf.md +1 -1
- package/docs/mcp.md +11 -26
- package/docs/quickstart.md +43 -49
- package/docs/react.md +73 -23
- package/docs/roadmap.md +5 -7
- package/llms.txt +34 -39
- package/package.json +1 -1
- package/dist/react/useMutate.d.ts +0 -83
- package/dist/react/useQuery.d.ts +0 -123
- package/dist/react/useQuery.js +0 -145
- package/dist/react/useReader.d.ts +0 -69
- package/docs/capabilities.md +0 -163
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
# Coordination Reference
|
|
2
|
+
|
|
3
|
+
Coordinate long-running work on a row so humans and agents don't clobber each
|
|
4
|
+
other. Most writes need none of this — `ablo.<model>.update(id, …)` is optimistic
|
|
5
|
+
and the server rejects it if the row moved. Reach for `claim` only when you'll
|
|
6
|
+
**hold a row across a slow gap** (read → LLM call → write).
|
|
7
|
+
|
|
8
|
+
Claims are **fair**: on contention a second claimer joins a **server-side FIFO
|
|
9
|
+
queue** and blocks until promoted to the head of the line — it does not fail and
|
|
10
|
+
does not poll. Reads are open by default; reading a claimed row is allowed unless
|
|
11
|
+
the caller explicitly asks for claimed gating. A claim carries a TTL so a crashed
|
|
12
|
+
holder is auto-released and the queue advances.
|
|
13
|
+
|
|
14
|
+
This reference has three sections: the [claim state object](#the-claim-state-object),
|
|
15
|
+
the SDK [methods](#methods) (`claim` · `claimState` · `queue` · `release` ·
|
|
16
|
+
[writing under a claim](#writing-under-a-claim)), and the [errors](#errors) you
|
|
17
|
+
can catch.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## The claim state object
|
|
22
|
+
|
|
23
|
+
The claim state object is the live record that a participant is coordinating work on
|
|
24
|
+
a model row. It's what `claimState()` returns and what observers render.
|
|
25
|
+
|
|
26
|
+
| field | type | description |
|
|
27
|
+
|---|---|---|
|
|
28
|
+
| `id` | `string` | The claim id (distinct from the target row id). |
|
|
29
|
+
| `status` | `ClaimStatus` | `'active' \| 'queued' \| 'committed' \| 'expired' \| 'canceled'`. `active` = the holder; `queued` = waiting in line behind it. |
|
|
30
|
+
| `target` | `EntityRef` | What is being coordinated (`{ model, id, field? }`). |
|
|
31
|
+
| `action` | `string` | Human-readable phase — `'editing'`, `'writing'`, `'reviewing'`. |
|
|
32
|
+
| `heldBy` | `string` | Participant holding (or waiting on) it (e.g. `'agent:forecaster'`). |
|
|
33
|
+
| `participantKind` | `'human' \| 'agent'` | Who's behind it. |
|
|
34
|
+
| `position` | `number?` | 0-based place in the FIFO line — present only when `status: 'queued'` (`0` = next behind the holder). |
|
|
35
|
+
| `createdAt` | `string?` | Ms-epoch the holder opened it. Optional — derived shapes may omit it. |
|
|
36
|
+
| `expiresAt` | `string` | Ms-epoch the server reclaims it if the holder goes **silent**. Renewed automatically while the holder's connection stays alive — a crash-cleanup floor, not a duration you size. |
|
|
37
|
+
|
|
38
|
+
```jsonc
|
|
39
|
+
// A live claim state, as returned by claimState():
|
|
40
|
+
{
|
|
41
|
+
"id": "claim_8fJ2",
|
|
42
|
+
"status": "active",
|
|
43
|
+
"target": { "model": "weatherReports", "id": "report_stockholm" },
|
|
44
|
+
"action": "editing",
|
|
45
|
+
"heldBy": "agent:forecaster",
|
|
46
|
+
"participantKind": "agent",
|
|
47
|
+
"createdAt": "1748160000000",
|
|
48
|
+
"expiresAt": "1748160030000"
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## Methods
|
|
55
|
+
|
|
56
|
+
Each method below follows one fixed shape: **signature · what it does ·
|
|
57
|
+
parameters · returns · example**.
|
|
58
|
+
|
|
59
|
+
### `claim`
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
ablo.<model>.claim(id, work, options?): Promise<R> // callback form
|
|
63
|
+
ablo.<model>.claim(id, options?): Promise<ClaimedRow<T>>
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Claim a row so other writers serialize behind you until you're done; reads stay
|
|
67
|
+
open by default. The claim acquires through the server's fair FIFO queue: if the
|
|
68
|
+
target is free the lease is yours immediately, and if another participant holds
|
|
69
|
+
it your claim **waits in line** and resolves only once it reaches the head —
|
|
70
|
+
then re-reads so the claimed snapshot reflects what the previous holder
|
|
71
|
+
committed. There's no client-side poll and no TOCTOU gap: the server orders
|
|
72
|
+
contenders.
|
|
73
|
+
|
|
74
|
+
**Parameters**
|
|
75
|
+
|
|
76
|
+
| name | type | required | description |
|
|
77
|
+
|---|---|---|---|
|
|
78
|
+
| `id` | `string` | yes | The row id — same id as `retrieve` / `update`. |
|
|
79
|
+
| `options.action` | `string` | no | Phase shown to observers (default `'editing'`). |
|
|
80
|
+
| `options.field` | `string` | no | Field-level target, for fine-grained claimed-state badges. |
|
|
81
|
+
| `options.wait` | `boolean` | no | `true` (default) queues and waits for the lease. `false` is fail-fast — if another participant holds the row, reject immediately with `AbloClaimedError('entity_claimed')` instead of queuing (claim-or-skip, for work dedup where waiting would double-process). |
|
|
82
|
+
| `options.maxQueueDepth` | `number` | no | Backpressure: reject with `AbloClaimedError('queue_too_deep')` instead of joining a line already `>= maxQueueDepth` deep. Omit to wait however deep the queue is. |
|
|
83
|
+
| `options.ttl` | `Duration` | no | Crash-cleanup floor. Rarely set — the lease renews while your connection is alive, so it only matters once you go silent. |
|
|
84
|
+
| `work` | `(row) => …` | no | Callback form: hold the claim for the callback, release when it returns. |
|
|
85
|
+
|
|
86
|
+
The high-level `claim` queues by default, so on contention you either get the row
|
|
87
|
+
when your turn arrives or one of the [queue errors](#errors) (`claim_lost`,
|
|
88
|
+
`grant_timeout`).
|
|
89
|
+
|
|
90
|
+
**Returns** — with the callback form, returns whatever `work` returns and
|
|
91
|
+
releases after the callback returns or throws. The manual form returns the
|
|
92
|
+
claimed row (`ClaimedRow<T> = T & AsyncDisposable`): the row data plus a
|
|
93
|
+
release hook for manual scopes.
|
|
94
|
+
|
|
95
|
+
**Example**
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
// Callback form — works on any toolchain:
|
|
99
|
+
const forecast = await ablo.weatherReports.claim('report_stockholm', async (report) => {
|
|
100
|
+
const weather = await weatherAgent.getWeather(report.location);
|
|
101
|
+
await ablo.weatherReports.update(report.id, { forecast: weather });
|
|
102
|
+
return weather;
|
|
103
|
+
});
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The manual scoped form is still available for wider TS 5.2+ scopes, but ordinary
|
|
107
|
+
held work should use the callback form above.
|
|
108
|
+
|
|
109
|
+
### Claim-gated reads
|
|
110
|
+
|
|
111
|
+
`claimState(id)` always returns immediately. Model reads such as
|
|
112
|
+
`ablo.<model>.retrieve(id)` are local reads and stay available while a claim is
|
|
113
|
+
held. Server/model reads can choose a claimed policy:
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
await ablo.model('weatherReports').retrieve('report_stockholm', {
|
|
117
|
+
ifClaimed: 'wait', // wait until the active claim clears
|
|
118
|
+
claimedTimeout: 30_000, // maximum wait
|
|
119
|
+
});
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
- `ifClaimed: 'return'` reads now and includes active work metadata.
|
|
123
|
+
- `ifClaimed: 'wait'` waits for the active claim to clear before reading.
|
|
124
|
+
- `ifClaimed: 'fail'` throws `AbloClaimedError` if the row is claimed.
|
|
125
|
+
|
|
126
|
+
### `claimState`
|
|
127
|
+
|
|
128
|
+
```ts
|
|
129
|
+
ablo.<model>.claimState(id)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Read who's currently working on a row, for observers and UI. Synchronous and
|
|
133
|
+
reactive (it reads the local coordination snapshot). Never blocks.
|
|
134
|
+
|
|
135
|
+
**Parameters**
|
|
136
|
+
|
|
137
|
+
| name | type | required | description |
|
|
138
|
+
|---|---|---|---|
|
|
139
|
+
| `id` | `string` | yes | The row id. |
|
|
140
|
+
|
|
141
|
+
**Returns** — the active [claim state object](#the-claim-state-object), or `null` when the row
|
|
142
|
+
is free.
|
|
143
|
+
|
|
144
|
+
**Example**
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
const who = ablo.weatherReports.claimState('report_stockholm');
|
|
148
|
+
if (who) console.log(`${who.heldBy} is ${who.action}`); // 'agent:forecaster is editing'
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
```jsonc
|
|
152
|
+
// Resolved value when the row is held:
|
|
153
|
+
{
|
|
154
|
+
"id": "claim_8fJ2",
|
|
155
|
+
"status": "active",
|
|
156
|
+
"target": { "model": "weatherReports", "id": "report_stockholm" },
|
|
157
|
+
"action": "editing",
|
|
158
|
+
"heldBy": "agent:forecaster",
|
|
159
|
+
"participantKind": "agent",
|
|
160
|
+
"expiresAt": "1748160030000"
|
|
161
|
+
}
|
|
162
|
+
// → null when free.
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### `queue`
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
ablo.<model>.queue(id)
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Read the **wait line** behind a row — the FIFO of claims queued behind the
|
|
172
|
+
current holder, in promotion order. Like `claimState`, it's synchronous and
|
|
173
|
+
reactive (it reads the local coordination snapshot, kept current by the server's
|
|
174
|
+
queue-mutation frames), and reading never blocks. Where `claimState` answers "who
|
|
175
|
+
holds it," `queue` answers "who's lined up next" — render "3rd in line", or
|
|
176
|
+
decide the wait isn't worth it.
|
|
177
|
+
|
|
178
|
+
**Parameters**
|
|
179
|
+
|
|
180
|
+
| name | type | required | description |
|
|
181
|
+
|---|---|---|---|
|
|
182
|
+
| `id` | `string` | yes | The row id. |
|
|
183
|
+
|
|
184
|
+
**Returns** — a list envelope. `data` contains the queued
|
|
185
|
+
[claim state objects](#the-claim-state-object) in promotion order (head first), excluding
|
|
186
|
+
the active holder; `[]` when no one is waiting.
|
|
187
|
+
|
|
188
|
+
**Example**
|
|
189
|
+
|
|
190
|
+
```ts
|
|
191
|
+
const { data: waiting } = ablo.weatherReports.queue('report_stockholm');
|
|
192
|
+
console.log(`${waiting.length} ahead of you`);
|
|
193
|
+
console.log(waiting.map((i) => i.heldBy)); // ['agent:b', 'agent:c']
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### `release`
|
|
197
|
+
|
|
198
|
+
```ts
|
|
199
|
+
ablo.<model>.release(id): Promise<void>
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Release a claim you hold. Usually **implicit** — the callback returning releases
|
|
203
|
+
for you, and TTL cleans up a crashed holder.
|
|
204
|
+
Call this only to give a manually held claim back early (claimed, then decided
|
|
205
|
+
not to write).
|
|
206
|
+
Releasing **promotes the head of the queue**: the next waiter receives the claim.
|
|
207
|
+
|
|
208
|
+
**Parameters**
|
|
209
|
+
|
|
210
|
+
| name | type | required | description |
|
|
211
|
+
|---|---|---|---|
|
|
212
|
+
| `id` | `string` | yes | The row id you hold a claim on. No-op if you don't hold it. |
|
|
213
|
+
|
|
214
|
+
**Returns** — resolves once the claim is released.
|
|
215
|
+
|
|
216
|
+
**Example**
|
|
217
|
+
|
|
218
|
+
```ts
|
|
219
|
+
const report = await ablo.weatherReports.claim('report_stockholm', { action: 'reviewing' });
|
|
220
|
+
try {
|
|
221
|
+
const ok = await reviewExternally(report);
|
|
222
|
+
if (!ok) return; // abandon, no write
|
|
223
|
+
await ablo.weatherReports.update(report.id, { status: 'ready' });
|
|
224
|
+
} finally {
|
|
225
|
+
await ablo.weatherReports.release(report.id);
|
|
226
|
+
}
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
### Writing under a claim
|
|
230
|
+
|
|
231
|
+
There is no separate "write" method on a claim — use the normal flat
|
|
232
|
+
`ablo.<model>.update(id, data)`. While you hold a claim on `id`, that `update` is
|
|
233
|
+
automatically stale-guarded against the snapshot the claim took (`readAt` =
|
|
234
|
+
snapshot watermark, `onStale: 'reject'`) and attributed to the claim's lease, so
|
|
235
|
+
it rejects with [`AbloStaleContextError`](#errors) if the row changed under you.
|
|
236
|
+
|
|
237
|
+
```ts
|
|
238
|
+
await ablo.weatherReports.claim(id, async (report) => {
|
|
239
|
+
await ablo.weatherReports.update(report.id, { status: 'ready' }); // guarded by the claim
|
|
240
|
+
});
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
Claims are **enforced server-side**: if you `update`/`delete` a row that *another*
|
|
244
|
+
participant holds, the commit is rejected with [`AbloClaimedError`](#errors) (`code:
|
|
245
|
+
'entity_claimed'`). To proceed, `claim` the row yourself — the claim queues
|
|
246
|
+
behind the current holder and re-reads once it's yours, so your `update` lands
|
|
247
|
+
on fresh data. You never conflict with your own claim, and reads are never gated.
|
|
248
|
+
|
|
249
|
+
```ts
|
|
250
|
+
try {
|
|
251
|
+
await ablo.weatherReports.update(id, { status: 'ready' });
|
|
252
|
+
} catch (err) {
|
|
253
|
+
if (err instanceof AbloClaimedError) {
|
|
254
|
+
// someone else holds it — claim the row and retry from fresh state
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
---
|
|
260
|
+
|
|
261
|
+
## Errors
|
|
262
|
+
|
|
263
|
+
All extend `AbloError` (`packages/sync-engine/src/errors.ts`). Catch by `type` or
|
|
264
|
+
inspect the `code`.
|
|
265
|
+
|
|
266
|
+
| error | `code` | thrown when | carries |
|
|
267
|
+
|---|---|---|---|
|
|
268
|
+
| `AbloClaimedError` | `claim_lost` | A held/queued claim was taken away (holder TTL lapse on disconnect, or revoke) while you were holding or waiting. | `claims?` |
|
|
269
|
+
| `AbloClaimedError` | `grant_timeout` | The optional `timeoutMs` elapsed while you were still queued for a grant. | `claims?` |
|
|
270
|
+
| `AbloClaimedError` | `queue_too_deep` | `claim` was passed `maxQueueDepth` and the wait line was already that deep when you tried to join — fail-fast instead of waiting. | `claims?` |
|
|
271
|
+
| `AbloClaimedError` | `claim_conflict` | An `update`/`delete` targets a row another participant holds — the server's pre-commit check rejected it. | — |
|
|
272
|
+
| `AbloClaimedError` | `entity_claimed` | Same conflict, from the commit guard backstop. | — |
|
|
273
|
+
| `AbloStaleContextError` | — | A guarded `update` (under a claim, or any write carrying `readAt`) targets a row that received deltas since the snapshot — your reasoning is stale. | `readAt`, `conflicts[]` |
|
|
274
|
+
| `AbloValidationError` | `model_claim_not_configured` | `claim` called on a model without collaboration wiring. | — |
|
|
275
|
+
| `AbloValidationError` | `entity_not_found` | The row id doesn't exist locally or on load. | — |
|
|
276
|
+
|
|
277
|
+
`AbloStaleContextError.conflicts` lists the `(model, id, observedSyncId)` rows
|
|
278
|
+
that moved during your generation window — use it for selective regeneration
|
|
279
|
+
(re-think only the slides that changed, not the whole deck) and for metrics.
|
|
280
|
+
|
|
281
|
+
```ts
|
|
282
|
+
try {
|
|
283
|
+
await ablo.weatherReports.claim('report_stockholm', async (report) => {
|
|
284
|
+
const weather = await weatherAgent.getWeather(report.location); // slow gap
|
|
285
|
+
await ablo.weatherReports.update(report.id, { forecast: weather });
|
|
286
|
+
});
|
|
287
|
+
} catch (err) {
|
|
288
|
+
if (err instanceof AbloClaimedError && err.code === 'claim_lost') {
|
|
289
|
+
// Our lease lapsed mid-flight (we stalled past the TTL). Re-claim and retry.
|
|
290
|
+
} else if (err instanceof AbloStaleContextError) {
|
|
291
|
+
// The row moved under us — re-read and regenerate from the fresh snapshot.
|
|
292
|
+
} else throw err;
|
|
293
|
+
}
|
|
294
|
+
```
|
package/docs/data-sources.md
CHANGED
|
@@ -8,7 +8,7 @@ the SDK, agents, realtime subscriptions, and the Data Source endpoint. Use
|
|
|
8
8
|
`schema.prisma`.
|
|
9
9
|
|
|
10
10
|
By default, Ablo stores the rows for the models you declare. That makes Ablo the
|
|
11
|
-
managed state store for those
|
|
11
|
+
managed state store for those models, the same way Stripe stores `Customer`
|
|
12
12
|
and `PaymentIntent` objects that you create through Stripe's API.
|
|
13
13
|
|
|
14
14
|
If you already have application tables and want those tables to remain
|
|
@@ -54,9 +54,9 @@ ABLO_API_KEY=sk_live_...
|
|
|
54
54
|
The SDK call is the same in both modes:
|
|
55
55
|
|
|
56
56
|
```ts
|
|
57
|
-
await ablo.
|
|
58
|
-
await ablo.
|
|
59
|
-
const
|
|
57
|
+
await ablo.weatherReports.create({ location: 'Stockholm', status: 'pending' });
|
|
58
|
+
await ablo.weatherReports.update('report_stockholm', { status: 'ready' });
|
|
59
|
+
const report = ablo.weatherReports.retrieve('report_stockholm');
|
|
60
60
|
```
|
|
61
61
|
|
|
62
62
|
Only the backing store changes.
|
|
@@ -120,13 +120,13 @@ export const POST = dataSource({
|
|
|
120
120
|
return { rows };
|
|
121
121
|
},
|
|
122
122
|
|
|
123
|
-
|
|
123
|
+
reports: {
|
|
124
124
|
async load({ id, context }) {
|
|
125
|
-
return context.auth.db.
|
|
125
|
+
return context.auth.db.report.findUnique({ where: { id } });
|
|
126
126
|
},
|
|
127
127
|
|
|
128
128
|
async list({ query, context }) {
|
|
129
|
-
return context.auth.db.
|
|
129
|
+
return context.auth.db.report.findMany({
|
|
130
130
|
take: query.limit ?? 100,
|
|
131
131
|
});
|
|
132
132
|
},
|
|
@@ -137,9 +137,9 @@ export const POST = dataSource({
|
|
|
137
137
|
Your app code still writes through the normal model API:
|
|
138
138
|
|
|
139
139
|
```ts
|
|
140
|
-
await ablo.
|
|
141
|
-
'
|
|
142
|
-
{ status: '
|
|
140
|
+
await ablo.weatherReports.update(
|
|
141
|
+
'report_stockholm',
|
|
142
|
+
{ status: 'ready' },
|
|
143
143
|
{ wait: 'confirmed', readAt: snap.stamp, onStale: 'reject' },
|
|
144
144
|
);
|
|
145
145
|
```
|
|
@@ -155,9 +155,9 @@ When Ablo calls your Data Source, it sends a signed JSON request:
|
|
|
155
155
|
operations: [
|
|
156
156
|
{
|
|
157
157
|
type: 'UPDATE',
|
|
158
|
-
model: '
|
|
159
|
-
id: '
|
|
160
|
-
input: { status: '
|
|
158
|
+
model: 'weatherReports',
|
|
159
|
+
id: 'report_stockholm',
|
|
160
|
+
input: { status: 'ready' },
|
|
161
161
|
readAt: 1042,
|
|
162
162
|
onStale: 'reject',
|
|
163
163
|
},
|
|
@@ -177,7 +177,7 @@ Return canonical rows:
|
|
|
177
177
|
```ts
|
|
178
178
|
{
|
|
179
179
|
rows: [
|
|
180
|
-
{ id: '
|
|
180
|
+
{ id: 'report_stockholm', location: 'Stockholm', status: 'ready' },
|
|
181
181
|
],
|
|
182
182
|
}
|
|
183
183
|
```
|
|
@@ -1,57 +1,57 @@
|
|
|
1
1
|
# Agent + Human
|
|
2
2
|
|
|
3
|
-
A
|
|
3
|
+
A report-writing agent that yields when a human is editing the same report.
|
|
4
4
|
|
|
5
5
|
## Scenario
|
|
6
6
|
|
|
7
|
-
A product queue has
|
|
7
|
+
A product queue has reports that humans and agents both update. They must not
|
|
8
8
|
collide:
|
|
9
9
|
|
|
10
10
|
- If the user is editing, the agent waits or yields.
|
|
11
11
|
- If the agent is updating, the UI can show who is active.
|
|
12
|
-
- If the
|
|
12
|
+
- If the report changes mid-run, the commit rejects instead of overwriting newer
|
|
13
13
|
state.
|
|
14
14
|
|
|
15
15
|
## Schema-Backed Worker
|
|
16
16
|
|
|
17
|
-
Use the same schema client the app uses. The worker loads the
|
|
18
|
-
|
|
17
|
+
Use the same schema client the app uses. The worker loads the report, claims the
|
|
18
|
+
row, and writes through `ablo.weatherReports.update(...)`.
|
|
19
19
|
|
|
20
20
|
```ts
|
|
21
21
|
import Ablo from '@abloatai/ablo';
|
|
22
22
|
import { defineSchema, model, z } from '@abloatai/ablo/schema';
|
|
23
23
|
|
|
24
24
|
const schema = defineSchema({
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
status: z.enum(['
|
|
25
|
+
weatherReports: model({
|
|
26
|
+
location: z.string(),
|
|
27
|
+
status: z.enum(['pending', 'ready']),
|
|
28
28
|
}),
|
|
29
29
|
});
|
|
30
30
|
|
|
31
31
|
const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY });
|
|
32
32
|
|
|
33
|
-
export async function
|
|
33
|
+
export async function markReady(reportId: string) {
|
|
34
34
|
await ablo.ready();
|
|
35
35
|
|
|
36
|
-
const [
|
|
37
|
-
if (!
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
36
|
+
const [report] = await ablo.weatherReports.load({ where: { id: reportId } });
|
|
37
|
+
if (!report) return { status: 'not_found' };
|
|
38
|
+
|
|
39
|
+
const updated = await ablo.weatherReports.claim(
|
|
40
|
+
reportId,
|
|
41
|
+
async (claimed) =>
|
|
42
|
+
ablo.weatherReports.update(
|
|
43
|
+
claimed.id,
|
|
44
|
+
{ status: 'ready' },
|
|
45
|
+
{ wait: 'confirmed' },
|
|
46
|
+
),
|
|
47
|
+
{ wait: false, action: 'marking_ready' },
|
|
47
48
|
);
|
|
48
49
|
|
|
49
|
-
return { status: '
|
|
50
|
+
return { status: 'ready', report: updated };
|
|
50
51
|
}
|
|
51
52
|
```
|
|
52
53
|
|
|
53
|
-
|
|
54
|
-
not the first integration path.
|
|
54
|
+
Keep workers on the same schema-backed client as the app.
|
|
55
55
|
|
|
56
56
|
## UI
|
|
57
57
|
|
|
@@ -60,16 +60,14 @@ not the first integration path.
|
|
|
60
60
|
|
|
61
61
|
import { useAblo } from '@abloatai/ablo/react';
|
|
62
62
|
|
|
63
|
-
export function
|
|
64
|
-
const data = useAblo((ablo) => ablo.
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
) ?? [];
|
|
68
|
-
const agentActive = intents.some((i) => i.participantKind === 'agent');
|
|
63
|
+
export function ReportRow({ report: serverReport }: Props) {
|
|
64
|
+
const data = useAblo((ablo) => ablo.weatherReports.retrieve(serverReport.id)) ?? serverReport;
|
|
65
|
+
const active = useAblo((ablo) => ablo.weatherReports.claimState(serverReport.id));
|
|
66
|
+
const agentActive = active?.participantKind === 'agent';
|
|
69
67
|
|
|
70
68
|
return (
|
|
71
69
|
<div>
|
|
72
|
-
<span>{data.
|
|
70
|
+
<span>{data.location}</span>
|
|
73
71
|
{agentActive ? <span>Agent is updating...</span> : null}
|
|
74
72
|
</div>
|
|
75
73
|
);
|
|
@@ -78,7 +76,7 @@ export function TaskRow({ task: serverTask }: Props) {
|
|
|
78
76
|
|
|
79
77
|
## Why It Works
|
|
80
78
|
|
|
81
|
-
-
|
|
82
|
-
- `
|
|
79
|
+
- Claims are visible through `claimState(id)` and over the live stream.
|
|
80
|
+
- `claim(id, work)` lets agents wait for active work instead of racing.
|
|
83
81
|
- `readAt` plus `onStale: 'reject'` turns mid-flight changes into typed errors.
|
|
84
82
|
- Audit rows tie each accepted write back to the run that caused it.
|
|
@@ -9,10 +9,10 @@ import { streamText, tool } from 'ai';
|
|
|
9
9
|
import { z } from 'zod';
|
|
10
10
|
|
|
11
11
|
const schema = defineSchema({
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
status: schemaZ.enum(['
|
|
15
|
-
|
|
12
|
+
weatherReports: model({
|
|
13
|
+
location: schemaZ.string(),
|
|
14
|
+
status: schemaZ.enum(['pending', 'ready']),
|
|
15
|
+
forecast: schemaZ.string().optional(),
|
|
16
16
|
}),
|
|
17
17
|
});
|
|
18
18
|
|
|
@@ -21,35 +21,35 @@ const ablo = Ablo({
|
|
|
21
21
|
apiKey: process.env.ABLO_API_KEY,
|
|
22
22
|
});
|
|
23
23
|
|
|
24
|
-
const
|
|
25
|
-
description: 'Update a
|
|
24
|
+
const updateReport = tool({
|
|
25
|
+
description: 'Update a weather report in the product database.',
|
|
26
26
|
inputSchema: z.object({
|
|
27
|
-
|
|
28
|
-
status: z.enum(['
|
|
29
|
-
|
|
27
|
+
reportId: z.string(),
|
|
28
|
+
status: z.enum(['pending', 'ready']).optional(),
|
|
29
|
+
forecast: z.string().optional(),
|
|
30
30
|
}),
|
|
31
|
-
execute: async ({
|
|
31
|
+
execute: async ({ reportId, status, forecast }) => {
|
|
32
32
|
await ablo.ready();
|
|
33
33
|
|
|
34
|
-
const [
|
|
35
|
-
if (!
|
|
34
|
+
const [report] = await ablo.weatherReports.load({ where: { id: reportId } });
|
|
35
|
+
if (!report) return { ok: false, reason: 'not_found' };
|
|
36
36
|
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
// claim is advisory: if another participant holds the row, it waits for
|
|
38
|
+
// them to finish and re-reads before entering the callback. Released when
|
|
39
|
+
// the callback returns or throws.
|
|
40
|
+
return ablo.weatherReports.claim(
|
|
41
|
+
reportId,
|
|
42
|
+
async (claimed) => {
|
|
43
|
+
// update is stale-guarded under the held claim
|
|
44
|
+
const updated = await ablo.weatherReports.update(claimed.id, {
|
|
45
|
+
status: status ?? claimed.status,
|
|
46
|
+
forecast: forecast ?? claimed.forecast,
|
|
47
|
+
});
|
|
39
48
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
status: status ?? task.status,
|
|
45
|
-
summary: summary ?? task.summary,
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
return { ok: true, task: updated };
|
|
49
|
-
} catch (err) {
|
|
50
|
-
await claim.finish();
|
|
51
|
-
throw err;
|
|
52
|
-
}
|
|
49
|
+
return { ok: true, report: updated };
|
|
50
|
+
},
|
|
51
|
+
{ action: 'editing', ttl: '2m' },
|
|
52
|
+
);
|
|
53
53
|
},
|
|
54
54
|
});
|
|
55
55
|
|
|
@@ -59,7 +59,7 @@ export async function POST(req: Request) {
|
|
|
59
59
|
return streamText({
|
|
60
60
|
model,
|
|
61
61
|
messages,
|
|
62
|
-
tools: {
|
|
62
|
+
tools: { updateReport },
|
|
63
63
|
}).toUIMessageStreamResponse();
|
|
64
64
|
}
|
|
65
65
|
```
|
|
@@ -67,9 +67,8 @@ export async function POST(req: Request) {
|
|
|
67
67
|
The important part is not the model provider. The important part is that the
|
|
68
68
|
tool:
|
|
69
69
|
|
|
70
|
-
- loads the latest
|
|
71
|
-
-
|
|
72
|
-
-
|
|
73
|
-
-
|
|
70
|
+
- loads the latest weather report,
|
|
71
|
+
- claims the row (advisory — serializes behind any current holder, then re-reads),
|
|
72
|
+
- writes through the normal stale-guarded `update`,
|
|
73
|
+
- releases the claim automatically when the callback returns or throws,
|
|
74
74
|
- waits for server confirmation.
|
|
75
|
-
|