@abloatai/ablo 0.56.0 → 0.58.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/AGENTS.md +10 -4
- package/CHANGELOG.md +428 -10
- package/LICENSE +1 -1
- package/NOTICE +3 -3
- package/README.md +2 -1
- package/dist/ai-sdk.d.ts +1 -1
- package/dist/ai-sdk.d.ts.map +1 -1
- package/dist/context/evidence.d.ts +6 -8
- package/dist/context/evidence.d.ts.map +1 -1
- package/dist/context/evidence.js +6 -20
- package/dist/context/evidence.js.map +1 -1
- package/dist/context/index.d.ts +23 -0
- package/dist/context/index.d.ts.map +1 -0
- package/dist/context/index.js +26 -0
- package/dist/context/index.js.map +1 -0
- package/dist/context/onChange.d.ts +9 -0
- package/dist/context/onChange.d.ts.map +1 -0
- package/dist/context/onChange.js +37 -0
- package/dist/context/onChange.js.map +1 -0
- package/dist/source-conformance.d.ts +1 -1
- package/dist/source-conformance.d.ts.map +1 -1
- package/dist/source-conformance.js +1 -1
- package/dist/source-conformance.js.map +1 -1
- package/dist/source-drizzle.d.ts +1 -1
- package/dist/source-drizzle.d.ts.map +1 -1
- package/dist/source-drizzle.js +1 -1
- package/dist/source-drizzle.js.map +1 -1
- package/dist/source-kysely.d.ts +1 -1
- package/dist/source-kysely.d.ts.map +1 -1
- package/dist/source-kysely.js +1 -1
- package/dist/source-kysely.js.map +1 -1
- package/dist/source-next.d.ts +1 -1
- package/dist/source-next.d.ts.map +1 -1
- package/dist/source-next.js +1 -1
- package/dist/source-next.js.map +1 -1
- package/docs/agent-integration-decision-guide.md +123 -0
- package/docs/agents.md +74 -13
- package/docs/api-keys.md +6 -6
- package/docs/api.md +117 -43
- package/docs/branch-development.md +23 -4
- package/docs/cli.md +16 -9
- package/docs/client-behavior.md +21 -15
- package/docs/concurrency-convention.md +67 -77
- package/docs/context.md +56 -31
- package/docs/coordination.md +115 -36
- package/docs/customer-organizations.md +49 -31
- package/docs/data-sources.md +12 -6
- package/docs/debugging.md +1 -1
- package/docs/examples/agent-human.md +6 -18
- package/docs/examples/coordination-conformance.md +69 -0
- package/docs/examples/existing-document-pipeline.md +488 -0
- package/docs/examples/existing-python-backend.md +10 -13
- package/docs/examples/nextjs.md +49 -6
- package/docs/examples/scoped-agent.md +18 -1
- package/docs/examples/server-agent.md +2 -2
- package/docs/groups.md +19 -139
- package/docs/guarantees.md +5 -6
- package/docs/identity.md +2 -1
- package/docs/index.md +5 -0
- package/docs/integration-guide.md +46 -19
- package/docs/integrations/sandbox-runtime.md +148 -0
- package/docs/integrations.md +9 -0
- package/docs/operating-on-your-database.md +7 -0
- package/docs/quickstart.md +19 -13
- package/docs/react.md +9 -9
- package/docs/schema-contract.md +14 -13
- package/docs/session-settings.md +9 -0
- package/docs/sessions.md +1 -1
- package/examples/README.md +2 -2
- package/examples/agent-turn.ts +1 -1
- package/examples/data-source/customer-server.ts +12 -5
- package/examples/expensive-agent-turn.ts +1 -1
- package/llms.txt +72 -10
- package/package.json +6 -6
- package/dist/context/sources.d.ts +0 -21
- package/dist/context/sources.d.ts.map +0 -1
- package/dist/context/sources.js +0 -36
- package/dist/context/sources.js.map +0 -1
- package/dist/context.d.ts +0 -22
- package/dist/context.d.ts.map +0 -1
- package/dist/context.js +0 -33
- package/dist/context.js.map +0 -1
|
@@ -64,9 +64,14 @@ import { schema } from './schema';
|
|
|
64
64
|
|
|
65
65
|
const server = Ablo({ schema, apiKey: process.env.ABLO_API_KEY });
|
|
66
66
|
|
|
67
|
-
export async function mintProjectAgentSession(
|
|
67
|
+
export async function mintProjectAgentSession(
|
|
68
|
+
workspaceId: string,
|
|
69
|
+
agentId: string,
|
|
70
|
+
requestingUserId: string,
|
|
71
|
+
) {
|
|
68
72
|
const { token } = await server.sessions.create({
|
|
69
73
|
agent: { id: agentId },
|
|
74
|
+
onBehalfOf: { user: { id: requestingUserId } },
|
|
70
75
|
can: { records: ['read', 'update'] }, // operation allowlist for this run
|
|
71
76
|
syncGroups: [syncGroup('workspace', workspaceId)], // narrowed to just this workspace
|
|
72
77
|
});
|
|
@@ -74,6 +79,18 @@ export async function mintProjectAgentSession(workspaceId: string, agentId: stri
|
|
|
74
79
|
}
|
|
75
80
|
```
|
|
76
81
|
|
|
82
|
+
If work crosses a queue, persist `requestingUserId` on the job before enqueueing
|
|
83
|
+
it and read that stored value when minting the worker's agent session. Request
|
|
84
|
+
context and in-memory enqueue arguments disappear across retries and process
|
|
85
|
+
boundaries; a durable job without its delegator can only produce agent-only or
|
|
86
|
+
system-only attribution.
|
|
87
|
+
|
|
88
|
+
In a collaborative deployment, do not silently fall back to direct database
|
|
89
|
+
writes when agent-session minting is unavailable. Those writes can still be
|
|
90
|
+
observed through WAL, but they have no trusted correlation and are therefore
|
|
91
|
+
recorded as `system`. Either fail the job for retry or make uncoordinated writes
|
|
92
|
+
an explicit deployment mode.
|
|
93
|
+
|
|
77
94
|
```tsx
|
|
78
95
|
// client — the browser client carries only the scoped token.
|
|
79
96
|
import Ablo from '@abloatai/ablo';
|
|
@@ -48,7 +48,7 @@ export async function completeTask(recordId: string, workerId: string) {
|
|
|
48
48
|
const ablo = await clientForWorker(workerId);
|
|
49
49
|
await ablo.ready();
|
|
50
50
|
|
|
51
|
-
const record = await ablo.records.
|
|
51
|
+
const record = await ablo.records.read({ id: recordId });
|
|
52
52
|
if (!record) return { status: 'not_found' };
|
|
53
53
|
|
|
54
54
|
const acquired = await ablo.records.claim({
|
|
@@ -76,7 +76,7 @@ export async function completeTask(recordId: string, workerId: string) {
|
|
|
76
76
|
}
|
|
77
77
|
```
|
|
78
78
|
|
|
79
|
-
`
|
|
79
|
+
`read({ id })` is an async server read — it hits the server and returns the
|
|
80
80
|
row (or `undefined`, which the early `not_found` guard handles). The update runs
|
|
81
81
|
while the claim is held; awaiting it resolves only once your database has
|
|
82
82
|
confirmed the row landed.
|
package/docs/groups.md
CHANGED
|
@@ -17,30 +17,28 @@ block `C`. Between those steps someone else edits `A`. The agent is now building
|
|
|
17
17
|
`C` on a premise that has moved — and nothing about writing `C` looks wrong in
|
|
18
18
|
isolation. That is stale context, and it is the thing sync groups let you catch.
|
|
19
19
|
|
|
20
|
-
The recipe is one field on the commit: declare the group you read as a premise
|
|
21
|
-
and say what should happen if it moved.
|
|
20
|
+
The recipe is one field on the commit: declare the group you read as a premise.
|
|
22
21
|
|
|
23
22
|
```ts
|
|
24
23
|
// The agent read everything under workspace:abc to compose this write.
|
|
25
24
|
await ablo.blocks.update({
|
|
26
25
|
id: 'block-C',
|
|
27
26
|
data: { text: revised },
|
|
28
|
-
reads: [{ group: 'workspace:abc', readAt: watermark
|
|
27
|
+
reads: [{ group: 'workspace:abc', readAt: watermark }],
|
|
29
28
|
});
|
|
30
29
|
```
|
|
31
30
|
|
|
32
31
|
At commit, inside the write transaction, the engine asks a single question: *did
|
|
33
32
|
any delta routed to `workspace:abc` land after `watermark`?* If nothing moved, the
|
|
34
|
-
write applies. If something moved,
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
never persists work built on a premise it can no longer see.
|
|
33
|
+
write applies. If something moved, Ablo rejects the batch with a `409`, so the
|
|
34
|
+
agent can re-read `workspace:abc`, regenerate, and submit a fresh guarded
|
|
35
|
+
write. The agent never persists work built on a premise it can no longer see.
|
|
38
36
|
|
|
39
37
|
---
|
|
40
38
|
|
|
41
39
|
## How you hear about it
|
|
42
40
|
|
|
43
|
-
|
|
41
|
+
Three channels carry "something changed", and they answer three different
|
|
44
42
|
questions. Pick by the question you have.
|
|
45
43
|
|
|
46
44
|
```ts
|
|
@@ -52,10 +50,9 @@ await using room = await ablo.records.join(documentIds, { ttl: '5m' });
|
|
|
52
50
|
room.peers;
|
|
53
51
|
|
|
54
52
|
// Stop this write if the thing I read moved while I composed it.
|
|
55
|
-
await ablo.
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
await ablo.records.track({ id: 's-1' });
|
|
53
|
+
const record = await ablo.records.read({ id: 's-1' });
|
|
54
|
+
if (!record) throw new Error('Record not found');
|
|
55
|
+
await ablo.blocks.update({ id, data, reads: [record] });
|
|
59
56
|
```
|
|
60
57
|
|
|
61
58
|
| Question | Channel | Arrives |
|
|
@@ -63,29 +60,12 @@ await ablo.records.track({ id: 's-1' });
|
|
|
63
60
|
| What do the rows say right now? | `onChange` | As deltas land, on the socket |
|
|
64
61
|
| Who else is working here? | `join`, then `room.peers` and `room.claims` | As participants come and go, on the socket |
|
|
65
62
|
| Did the premise for **this** write move? | `reads` on the write | On that write's receipt, before it applies |
|
|
66
|
-
| Has anything I read moved since? | `track` | On your next commit's receipt |
|
|
67
|
-
| Stop me writing if it has | `track` with `onStale: 'reject'` | Refuses that commit, at the chokepoint |
|
|
68
|
-
|
|
69
|
-
Two distinctions do most of the work here.
|
|
70
|
-
|
|
71
|
-
**`join` is about people; `track` is about data.** Both open a subscription and
|
|
72
|
-
both are scoped by sync group, which is why they look alike. `join` reports
|
|
73
|
-
participants: who is present, what they are doing, which rows they hold. `track`
|
|
74
|
-
reports the rows themselves: something you said you cared about moved, here is
|
|
75
|
-
the watermark to re-read it at. A tool that wants to avoid duplicating a peer's
|
|
76
|
-
work needs `join`. A tool whose output goes stale when its inputs change needs
|
|
77
|
-
`track`.
|
|
78
|
-
|
|
79
|
-
**`reads` guards one write; `track` outlives it.** They speak the same
|
|
80
|
-
vocabulary and produce the same `StaleNotification`. A `reads` entry is checked
|
|
81
|
-
once, at the commit that carried it, and discarded. A `track` is persisted and
|
|
82
|
-
re-checked against every delta after it, so a long-running actor hears about a
|
|
83
|
-
change that landed while it was thinking, on the next commit it makes.
|
|
84
63
|
|
|
85
64
|
`onChange` and `join` need a live socket, so they are available on the default
|
|
86
|
-
WebSocket client. `reads`
|
|
87
|
-
|
|
88
|
-
|
|
65
|
+
WebSocket client. `reads` rides the commit, so it reaches a socketless actor over
|
|
66
|
+
HTTP too. The row returned by `read` privately carries its model, id, and
|
|
67
|
+
watermark; passing that row in `reads` is enough to protect a later write. Ablo
|
|
68
|
+
does not retain the row contents as read evidence.
|
|
89
69
|
|
|
90
70
|
---
|
|
91
71
|
|
|
@@ -158,13 +138,12 @@ Two consequences worth designing around:
|
|
|
158
138
|
## Declaring the batch premise
|
|
159
139
|
|
|
160
140
|
`reads[]` declares what the commit was based on. Each entry is a premise, and
|
|
161
|
-
each governs the *whole* commit: if one goes stale,
|
|
162
|
-
|
|
163
|
-
entry.
|
|
141
|
+
each governs the *whole* commit: if one goes stale, every write in the batch
|
|
142
|
+
rejects. You choose the granularity per entry.
|
|
164
143
|
|
|
165
144
|
```ts
|
|
166
145
|
reads: [
|
|
167
|
-
{ group: 'workspace:abc', readAt: N
|
|
146
|
+
{ group: 'workspace:abc', readAt: N }, // did anything in the workspace move?
|
|
168
147
|
{ model: 'Document', id: 's-1', readAt: N, fields: ['title'] }, // did this row (this field) move?
|
|
169
148
|
]
|
|
170
149
|
```
|
|
@@ -175,108 +154,9 @@ literal: this object, optionally these fields. A row premise with `fields`
|
|
|
175
154
|
conflicts only on real field overlap, so two actors editing disjoint fields of
|
|
176
155
|
the same row don't collide.
|
|
177
156
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
For a group premise it carries the group name and the new watermark
|
|
182
|
-
(`observedSyncId`); re-read the group at that point and regenerate. This is the
|
|
183
|
-
setting a chain wants — the actor gets the truth and resolves it.
|
|
184
|
-
- **`reject`** aborts the batch with a `stale_context` error (`409`). The right
|
|
185
|
-
default when there is nothing to reconcile and the write should simply not
|
|
186
|
-
land.
|
|
187
|
-
- **`overwrite`** skips the check and lets the write land — last-write-wins, the
|
|
188
|
-
explicit escape hatch.
|
|
189
|
-
|
|
190
|
-
---
|
|
191
|
-
|
|
192
|
-
## Staying subscribed across commits: `track`
|
|
193
|
-
|
|
194
|
-
A batch premise guards a single commit: you state what you read, the engine
|
|
195
|
-
checks it, the premise is gone. That fits an actor that reads and writes in one
|
|
196
|
-
breath. It does not fit a long-running one — an agent that reads a row now,
|
|
197
|
-
works for a few minutes, and writes much later. By the time it commits, the
|
|
198
|
-
premise it would have declared is stale, and there was no commit in between on
|
|
199
|
-
which to hear that the ground had shifted.
|
|
200
|
-
|
|
201
|
-
`track` is the durable half of the same idea. Register what you are watching and
|
|
202
|
-
it persists on the server; the next time you commit anything, a change that
|
|
203
|
-
landed on the tracked target since you registered rides back on your receipt —
|
|
204
|
-
the same `StaleNotification` an `onStale: 'notify'` premise would have handed
|
|
205
|
-
you, arriving on the write you were going to make anyway.
|
|
206
|
-
|
|
207
|
-
```ts
|
|
208
|
-
// Register interest and walk away — no write required.
|
|
209
|
-
await ablo.records.track({ id: 's-1' });
|
|
210
|
-
|
|
211
|
-
// …minutes of other work later, on your next commit…
|
|
212
|
-
const res = await ablo.blocks.update({ id: 'block-C', data: { text: revised } });
|
|
213
|
-
res.notifications; // populated if s-1 moved under you in the meantime
|
|
214
|
-
```
|
|
215
|
-
|
|
216
|
-
The target is a row (`{ id }` on the model verb) or a sync group (as a write
|
|
217
|
-
option, below). A track is an idempotent registration: calling it again refreshes
|
|
218
|
-
the same subscription rather than stacking duplicates, and once a change fires the
|
|
219
|
-
track re-baselines, so the same change notifies once. Your own writes to a target
|
|
220
|
-
you track never notify you — the signal is about what *others* did.
|
|
221
|
-
|
|
222
|
-
You can also register a track as part of a write you are already making, the
|
|
223
|
-
persisted companion to `reads`:
|
|
224
|
-
|
|
225
|
-
```ts
|
|
226
|
-
await ablo.records.update({
|
|
227
|
-
id: 's-1',
|
|
228
|
-
data: { title: revised },
|
|
229
|
-
reads: [{ group: 'workspace:abc', readAt: N, onStale: 'notify' }], // guards THIS commit
|
|
230
|
-
track: [{ group: 'workspace:abc' }], // and keeps watching after it
|
|
231
|
-
});
|
|
232
|
-
```
|
|
233
|
-
|
|
234
|
-
So `reads` is the premise for the commit in hand; `track` is a standing
|
|
235
|
-
subscription that outlives it. Both speak the same notification vocabulary.
|
|
236
|
-
Delivery is on your next commit's receipt; a track does not yet push out of band
|
|
237
|
-
between commits.
|
|
238
|
-
|
|
239
|
-
### Reporting, or gating: `onStale`
|
|
240
|
-
|
|
241
|
-
A track says what a moved belief should do to your **next write**. Same enum the
|
|
242
|
-
`reads` premise carries, minus the one mode that cannot apply:
|
|
243
|
-
|
|
244
|
-
```ts
|
|
245
|
-
await ablo.records.track({ id: 's-1' }); // notify (default)
|
|
246
|
-
await ablo.records.track({ id: 's-1', onStale: 'reject' }); // gate
|
|
247
|
-
```
|
|
248
|
-
|
|
249
|
-
- **`notify`** — the change rides your next receipt and the commit proceeds. You
|
|
250
|
-
decide what it means.
|
|
251
|
-
- **`reject`** — your next commit is **refused** while that belief is stale, even
|
|
252
|
-
if it writes an unrelated row. The gate is on what you *know*, not on what you
|
|
253
|
-
are touching.
|
|
254
|
-
|
|
255
|
-
`reject` is for an actor that must not act on a stale picture — an agent that
|
|
256
|
-
read a row, reasoned for minutes, and is about to write based on what it
|
|
257
|
-
believed. Ablo enforces it at the commit chokepoint, so it is a guarantee rather
|
|
258
|
-
than a convention: the write cannot land.
|
|
259
|
-
|
|
260
|
-
The gate takes a key. It does **not** reopen on its own, because an agent that
|
|
261
|
-
retried blindly would land exactly the write `reject` was asked to prevent. You
|
|
262
|
-
re-read, then re-register the track to say so:
|
|
263
|
-
|
|
264
|
-
```ts
|
|
265
|
-
try {
|
|
266
|
-
await ablo.records.update({ id, data });
|
|
267
|
-
} catch (err) {
|
|
268
|
-
if (err.code === 'stale_context') {
|
|
269
|
-
const fresh = await ablo.records.get({ id: 's-1' }); // read
|
|
270
|
-
await ablo.records.track({ id: 's-1', onStale: 'reject' }); // acknowledge
|
|
271
|
-
await ablo.records.update({ id, data: reconsider(fresh) }); // now it lands
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
```
|
|
275
|
-
|
|
276
|
-
A track-only call is never gated, so acknowledging is always reachable.
|
|
277
|
-
|
|
278
|
-
`overwrite` is not offered: it means "apply my write anyway", and a track guards
|
|
279
|
-
no write of its own to apply.
|
|
157
|
+
Any stale premise aborts the batch with `stale_context` (`409`). Re-read and
|
|
158
|
+
regenerate if the work is still relevant. Omit `reads` only when the write is
|
|
159
|
+
intentionally unconditional.
|
|
280
160
|
|
|
281
161
|
---
|
|
282
162
|
|
package/docs/guarantees.md
CHANGED
|
@@ -44,11 +44,11 @@ The server remains the source of truth.
|
|
|
44
44
|
|
|
45
45
|
## Stale-Write Protection
|
|
46
46
|
|
|
47
|
-
Use `
|
|
48
|
-
read:
|
|
47
|
+
Use `read(...)` and carry the returned row in `reads` when a write depends on
|
|
48
|
+
state the agent already read:
|
|
49
49
|
|
|
50
50
|
```ts
|
|
51
|
-
const report = await ablo.weatherReports.
|
|
51
|
+
const report = await ablo.weatherReports.read({ id: 'report_stockholm' });
|
|
52
52
|
if (!report) throw new Error('report missing');
|
|
53
53
|
|
|
54
54
|
await ablo.weatherReports.update({
|
|
@@ -67,7 +67,7 @@ not acquire this guarantee.
|
|
|
67
67
|
|
|
68
68
|
Two other dispositions exist. `overwrite` applies the write with no stale check
|
|
69
69
|
at all. `notify` **holds** the write, so the row is left as it stands, and hands
|
|
70
|
-
back
|
|
70
|
+
back an `AbloStaleContextError` so the actor can re-read and reconcile
|
|
71
71
|
and re-issue; the rest of the batch still commits.
|
|
72
72
|
|
|
73
73
|
See [Concurrency Convention](./concurrency-convention.md) for the full taxonomy,
|
|
@@ -96,8 +96,7 @@ claim queues fairly behind the holder).
|
|
|
96
96
|
|
|
97
97
|
By default, a held claim rejects writes from other participants to the claimed
|
|
98
98
|
target. Contenders that call `claim` wait their turn; ordinary reads remain
|
|
99
|
-
open.
|
|
100
|
-
participant kind. While you hold a claim, the matching
|
|
99
|
+
open. While you hold a claim, the matching
|
|
101
100
|
`ablo.<model>.update({ id, ... })` is rejected with `AbloStaleContextError` if
|
|
102
101
|
the row changed underneath you after your claim point.
|
|
103
102
|
|
package/docs/identity.md
CHANGED
|
@@ -410,7 +410,7 @@ it to the provider:
|
|
|
410
410
|
|
|
411
411
|
```ts
|
|
412
412
|
// lib/ablo.ts
|
|
413
|
-
import Ablo from '@abloatai/ablo';
|
|
413
|
+
import { Ablo } from '@abloatai/ablo/react';
|
|
414
414
|
import { schema } from '@/ablo/schema';
|
|
415
415
|
|
|
416
416
|
// Build the client from the identity your server already resolved.
|
|
@@ -505,6 +505,7 @@ subset of what its user could see:
|
|
|
505
505
|
// (the floor). Build each group from the model's scope with `syncGroup(kind, id)`.
|
|
506
506
|
const session = await server.sessions.create({
|
|
507
507
|
agent: { id: agentId },
|
|
508
|
+
onBehalfOf: { user: { id: triggeringUser.id } },
|
|
508
509
|
can: { Document: ['read', 'update'], Workspace: ['read', 'update'] },
|
|
509
510
|
syncGroups: [syncGroup('document', recordId), syncGroup('workspace', workspaceId)],
|
|
510
511
|
});
|
package/docs/index.md
CHANGED
|
@@ -162,6 +162,11 @@ default caller, not a special one.
|
|
|
162
162
|
|
|
163
163
|
## Build
|
|
164
164
|
|
|
165
|
+
- [Agent Integration Decision Guide](./agent-integration-decision-guide.md) — choose identifier or row claims, captured reads, atomic boundaries, persistence ownership, transport, and the smallest proof before opening an example.
|
|
166
|
+
- [TypeScript Library](./libraries/typescript.md) — construct the server client,
|
|
167
|
+
keep schema ownership beneath one boundary, and select an integration approach.
|
|
168
|
+
- [GraphQL.js over an Existing Backend](./approaches/graphql/graphql-js.md) —
|
|
169
|
+
keep resolvers thin and call one named Ablo-backed domain operation.
|
|
165
170
|
- [Quickstart](./quickstart.md) — make your first coordinated write.
|
|
166
171
|
- [Integration Guide](./integration-guide.md) — the canonical end-to-end integration.
|
|
167
172
|
- [Integrations](./integrations.md) — long-running records, ingestion, and other application-edge runtimes.
|
|
@@ -77,7 +77,7 @@ When handing this to a coding agent, give it a concrete target:
|
|
|
77
77
|
```txt
|
|
78
78
|
Add Ablo to this app for one model your agents edit.
|
|
79
79
|
Run npx ablo dev and use its branch-bound key. Declare schema, add the Ablo client, replace
|
|
80
|
-
one
|
|
80
|
+
one decision read with ablo.<model>.read(...), pass that row in the write's reads array, and add a smoke test for two concurrent writers.
|
|
81
81
|
```
|
|
82
82
|
|
|
83
83
|
## 1. Declare A Schema
|
|
@@ -93,9 +93,9 @@ import { defineSchema, model, z } from '@abloatai/ablo/schema';
|
|
|
93
93
|
export const schema = defineSchema(
|
|
94
94
|
{
|
|
95
95
|
weatherReports: model({
|
|
96
|
-
//
|
|
97
|
-
// are
|
|
98
|
-
//
|
|
96
|
+
// `id` is the only field the SDK supplies; never declare it. Audit
|
|
97
|
+
// fields are ordinary fields: declare `createdAt` / `updatedAt` /
|
|
98
|
+
// `createdBy` here if you want columns for them.
|
|
99
99
|
projectId: z.string(),
|
|
100
100
|
location: z.string(),
|
|
101
101
|
status: z.enum(['pending', 'ready']),
|
|
@@ -158,7 +158,7 @@ falsy flag. See `packages/transaction/src/schema/model.ts` for the full option s
|
|
|
158
158
|
Trusted runtimes can use `ABLO_API_KEY`.
|
|
159
159
|
|
|
160
160
|
```ts
|
|
161
|
-
// src/ablo.ts
|
|
161
|
+
// src/client/ablo.ts
|
|
162
162
|
import Ablo from '@abloatai/ablo';
|
|
163
163
|
import { schema } from './ablo/schema';
|
|
164
164
|
|
|
@@ -176,7 +176,7 @@ client now).
|
|
|
176
176
|
|
|
177
177
|
```tsx
|
|
178
178
|
// src/ablo-client.ts
|
|
179
|
-
import Ablo from '@abloatai/ablo';
|
|
179
|
+
import { Ablo } from '@abloatai/ablo/react';
|
|
180
180
|
import { schema } from '@/ablo/schema';
|
|
181
181
|
|
|
182
182
|
// The browser never holds the API key. The client mints a short-lived token
|
|
@@ -256,7 +256,7 @@ refreshes before expiry.
|
|
|
256
256
|
## 3. Read State
|
|
257
257
|
|
|
258
258
|
Reads come in two flavors, and you pick based on whether you can wait.
|
|
259
|
-
`get({ id })` and `list({ where })` hit the server (and hydrate the local
|
|
259
|
+
`get({ id })`, `read({ id })`, and `list({ where })` hit the server (and hydrate the local
|
|
260
260
|
store) — they're async, so you `await` them. `local.get(id)`,
|
|
261
261
|
`local.list({ where })`, and `local.count({ where })` read the already-synced local
|
|
262
262
|
graph synchronously, so they're the ones you call in render — and the ones you
|
|
@@ -268,7 +268,7 @@ and waits.
|
|
|
268
268
|
```ts
|
|
269
269
|
await ablo.ready();
|
|
270
270
|
|
|
271
|
-
const report = await ablo.weatherReports.
|
|
271
|
+
const report = await ablo.weatherReports.read({ id: 'report_stockholm' });
|
|
272
272
|
if (!report) throw new Error('report not found');
|
|
273
273
|
```
|
|
274
274
|
|
|
@@ -318,17 +318,17 @@ For simple writes:
|
|
|
318
318
|
await ablo.weatherReports.update({ id: 'report_stockholm', data: { status: 'ready' } });
|
|
319
319
|
```
|
|
320
320
|
|
|
321
|
-
For writes based on state
|
|
322
|
-
|
|
321
|
+
For writes based on current state, use a decision read and pass the exact row
|
|
322
|
+
to the mutation:
|
|
323
323
|
|
|
324
324
|
```ts
|
|
325
|
-
const
|
|
325
|
+
const report = await ablo.weatherReports.read({ id: 'report_stockholm' });
|
|
326
|
+
if (!report) throw new Error('report not found');
|
|
326
327
|
|
|
327
328
|
await ablo.weatherReports.update({
|
|
328
|
-
id:
|
|
329
|
+
id: report.id,
|
|
329
330
|
data: { status: 'ready' },
|
|
330
|
-
|
|
331
|
-
onStale: 'reject',
|
|
331
|
+
reads: [report],
|
|
332
332
|
});
|
|
333
333
|
```
|
|
334
334
|
|
|
@@ -481,12 +481,12 @@ const completeReport = tool({
|
|
|
481
481
|
forecast: z.string(),
|
|
482
482
|
}),
|
|
483
483
|
execute: async ({ reportId, forecast }) => {
|
|
484
|
-
const
|
|
484
|
+
const report = await ablo.weatherReports.read({ id: reportId });
|
|
485
|
+
if (!report) throw new Error('report not found');
|
|
485
486
|
return ablo.weatherReports.update({
|
|
486
487
|
id: reportId,
|
|
487
488
|
data: { status: 'ready', forecast },
|
|
488
|
-
|
|
489
|
-
onStale: 'reject',
|
|
489
|
+
reads: [report],
|
|
490
490
|
});
|
|
491
491
|
},
|
|
492
492
|
});
|
|
@@ -494,6 +494,31 @@ const completeReport = tool({
|
|
|
494
494
|
|
|
495
495
|
Keep agent writes on the same schema client surface as the app.
|
|
496
496
|
|
|
497
|
+
## One command changes an Ablo model and an ORM-only table
|
|
498
|
+
|
|
499
|
+
Two independently committed calls are not one atomic command. If an Ablo write
|
|
500
|
+
lands and a following Prisma/Drizzle transaction fails—or the reverse—the
|
|
501
|
+
application must expect and repair the partial result. Calling that path
|
|
502
|
+
“coordinated” does not extend Ablo’s claims, stale-read checks, attribution, or
|
|
503
|
+
commit ordering into the ORM transaction.
|
|
504
|
+
|
|
505
|
+
The supported atomic answer is to model every invariant-bearing row in the
|
|
506
|
+
Ablo schema and submit the operations in one `commits.create` batch (the HTTP
|
|
507
|
+
equivalent is `POST /api/v1/commits`). This applies on both database paths:
|
|
508
|
+
|
|
509
|
+
- With direct logical replication, Ablo’s batch is one customer-database
|
|
510
|
+
transaction. A separate ORM transaction is still separate.
|
|
511
|
+
- With a signed Data Source endpoint, the adapter applies the Ablo batch,
|
|
512
|
+
idempotency record, and outbox entry in one customer-database transaction.
|
|
513
|
+
Unrelated ORM work outside that adapter is still separate.
|
|
514
|
+
|
|
515
|
+
There is no general transactional callback that can safely splice arbitrary
|
|
516
|
+
application SQL into the hosted direct-write path. If a table must remain
|
|
517
|
+
ORM-only, treat the command as a saga: give both steps the same durable business
|
|
518
|
+
operation id, make each step idempotent, record progress, retry unfinished
|
|
519
|
+
steps, and define compensation for a result that cannot be completed. State
|
|
520
|
+
that guarantee as eventual completion with repair—not atomicity.
|
|
521
|
+
|
|
497
522
|
## Optional Surface
|
|
498
523
|
|
|
499
524
|
| Optional piece | Why it exists |
|
|
@@ -504,7 +529,7 @@ Keep agent writes on the same schema client surface as the app.
|
|
|
504
529
|
| `persistence: 'indexeddb'` | Durable browser cache that survives reloads, for apps that need it. |
|
|
505
530
|
| `durableWrites: { store, namespace? }` | Recover unacknowledged worker writes after a process restart. |
|
|
506
531
|
| `claim` / `claim.state` / `claim.queue` | Show active work and coordinate before a write. |
|
|
507
|
-
| `
|
|
532
|
+
| `read` + `reads` | Reject writes based on stale state. |
|
|
508
533
|
| `mutable`, `readOnly`, `field`, `indexed` | Advanced schema and read tuning. |
|
|
509
534
|
|
|
510
535
|
The first integration should not need most of these. Start with schema and
|
|
@@ -515,8 +540,10 @@ them.
|
|
|
515
540
|
|
|
516
541
|
| Method | Use it for |
|
|
517
542
|
| -------------------------------------- | -------------------------------------------------------------------------------- |
|
|
518
|
-
| `get({ id })`
|
|
543
|
+
| `get({ id })` | Observe one row from the server (await it). |
|
|
544
|
+
| `read({ id })` | Read one decision input to carry in a mutation's `reads` array. |
|
|
519
545
|
| `list({ where })` | Async read of many rows from the server (await it). |
|
|
546
|
+
| `listAll({ where, maxPages?, signal? })` | Explicit bounded traversal of every matching page; filter before collecting. |
|
|
520
547
|
| `local.get(id)` | Synchronous local read of one synced row (use in render). |
|
|
521
548
|
| `local.list({ where })` | Synchronous local read of many synced rows. |
|
|
522
549
|
| `local.count({ where })` | Synchronous local count of synced rows. |
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# Anthropic Sandbox Runtime
|
|
2
|
+
|
|
3
|
+
> Run an Ablo agent with OS-enforced filesystem and network boundaries while its coordinated work remains durable outside the sandbox.
|
|
4
|
+
|
|
5
|
+
Anthropic Sandbox Runtime and Ablo own different boundaries:
|
|
6
|
+
|
|
7
|
+
| Concern | Owner |
|
|
8
|
+
|---|---|
|
|
9
|
+
| Filesystem, network, Unix sockets, process-tree restrictions | Sandbox Runtime |
|
|
10
|
+
| Typed reads and writes, claims, fencing, idempotency, confirmation | Ablo |
|
|
11
|
+
| Prompts, tools, model calls, and business behavior | Your application |
|
|
12
|
+
| Authentication, branch creation, schema push, and database connection | A trusted host workflow |
|
|
13
|
+
|
|
14
|
+
An **execution sandbox** is the disposable process boundary. An **Ablo branch**
|
|
15
|
+
is an isolated data and schema plane. Keep the names and lifecycles separate.
|
|
16
|
+
|
|
17
|
+
## Use two profiles
|
|
18
|
+
|
|
19
|
+
Do not give a repository-editing agent the same authority as a runtime agent.
|
|
20
|
+
|
|
21
|
+
The **integration profile** adapts source code. It receives no Ablo credential,
|
|
22
|
+
has no Ablo network access, cannot read real environment files, and writes only
|
|
23
|
+
inside the selected application root. It can still read the installed-version
|
|
24
|
+
documentation without a network connection:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npm exec --offline -- ablo docs integration-guide
|
|
28
|
+
npm exec --offline -- ablo docs api
|
|
29
|
+
npm exec --offline -- ablo setup --plan --json
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The **runtime profile** executes application work. It receives one short-lived,
|
|
33
|
+
branch-bound runtime credential, permits the Ablo API, and normally has no
|
|
34
|
+
repository write access. Give it only the model operations and sync groups one
|
|
35
|
+
run needs.
|
|
36
|
+
|
|
37
|
+
Never inject control-plane authority, a database URL, cloud credentials, or an
|
|
38
|
+
ambient host environment into either profile.
|
|
39
|
+
|
|
40
|
+
## Prepare authority outside the sandbox
|
|
41
|
+
|
|
42
|
+
Run control-plane and database operations in a trusted host workflow. That
|
|
43
|
+
workflow prepares the branch, pushes the reviewed schema, and delegates a
|
|
44
|
+
per-run `rk_` credential. Management and broad branch credentials are
|
|
45
|
+
infrastructure details and never enter the agent-facing launcher:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
# Supplied by trusted CI or a credential broker after branch preparation.
|
|
49
|
+
export ABLO_API_KEY=rk_...
|
|
50
|
+
npm run sandbox -- agent job_123
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The sandbox launcher should build an explicit child environment rather than
|
|
54
|
+
inherit `process.env`. A runtime credential is not a substitute for process
|
|
55
|
+
isolation, and process isolation is not a substitute for a narrowly scoped
|
|
56
|
+
credential.
|
|
57
|
+
|
|
58
|
+
## Keep the repository tree downward
|
|
59
|
+
|
|
60
|
+
Give the Ablo boundary and agent behavior separate entry points:
|
|
61
|
+
|
|
62
|
+
```text
|
|
63
|
+
src/
|
|
64
|
+
ablo/
|
|
65
|
+
index.ts schema-backed client boundary
|
|
66
|
+
client.ts
|
|
67
|
+
schema.ts
|
|
68
|
+
agent/
|
|
69
|
+
index.ts process entry point
|
|
70
|
+
processJob.ts
|
|
71
|
+
sandbox/
|
|
72
|
+
index.ts sanitized launcher and command catalog
|
|
73
|
+
integration.policy.json
|
|
74
|
+
runtime.policy.json
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
The agent enters through `agent/index.ts` and follows dependencies down into
|
|
78
|
+
`ablo/index.ts`. Ablo code never imports the agent or sandbox implementation.
|
|
79
|
+
|
|
80
|
+
## Coordinate effects that outlive the process
|
|
81
|
+
|
|
82
|
+
A filesystem sandbox cannot prevent two valid agents from overwriting the same
|
|
83
|
+
shared row. Read, claim, and write through the schema-backed Ablo client:
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
const claim = await ablo.jobs.claim({
|
|
87
|
+
id: jobId,
|
|
88
|
+
description: 'processing in a sandbox',
|
|
89
|
+
ttl: '30s',
|
|
90
|
+
heartbeat: { every: '10s' },
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
const result = await performWork(claim.data);
|
|
95
|
+
await ablo.jobs.update({
|
|
96
|
+
id: claim.data.id,
|
|
97
|
+
data: { status: 'complete', result },
|
|
98
|
+
claim,
|
|
99
|
+
idempotencyKey: `job:${claim.data.id}:complete`,
|
|
100
|
+
});
|
|
101
|
+
} finally {
|
|
102
|
+
await claim.release();
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
If the sandbox disappears, its heartbeat stops and the lease expires. A later
|
|
107
|
+
holder reads fresh state. If the old process resumes, the checked write is
|
|
108
|
+
refused because it no longer owns the claim.
|
|
109
|
+
|
|
110
|
+
## Start from deny-first policies
|
|
111
|
+
|
|
112
|
+
Sandbox Runtime denies writes and network access unless they are allowed, but
|
|
113
|
+
filesystem reads require explicit deny regions. A practical policy should:
|
|
114
|
+
|
|
115
|
+
- deny the user's home region, then re-allow the selected repository;
|
|
116
|
+
- keep real `.env*`, SSH material, cloud configuration, and credentials denied;
|
|
117
|
+
- allow writes only to the selected application root and a dedicated temporary directory;
|
|
118
|
+
- allow only `api.abloatai.com:443` for an Ablo runtime agent;
|
|
119
|
+
- add a model-provider domain only when the model process itself runs inside the sandbox;
|
|
120
|
+
- leave Docker sockets and Apple Events disabled; and
|
|
121
|
+
- use resolved literal paths because filesystem globs are not supported on Linux.
|
|
122
|
+
|
|
123
|
+
Domain allowlists are coarse: an allowed domain can still be an exfiltration
|
|
124
|
+
channel. Filesystem isolation, environment sanitation, and least-authority
|
|
125
|
+
credentials must be used together.
|
|
126
|
+
|
|
127
|
+
## Run the complete example
|
|
128
|
+
|
|
129
|
+
The repository example contains the launcher, both policies, the schema-backed
|
|
130
|
+
agent, and boundary tests:
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
cd examples/sandboxed-agent
|
|
134
|
+
npm install
|
|
135
|
+
npm run docs:sandboxed
|
|
136
|
+
npm test
|
|
137
|
+
npm run typecheck
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Sandbox Runtime is a beta research preview. Keep it behind the launcher boundary
|
|
141
|
+
so policy and API changes do not spread through application or Ablo code.
|
|
142
|
+
|
|
143
|
+
## References
|
|
144
|
+
|
|
145
|
+
- [Anthropic Sandbox Runtime](https://github.com/anthropic-experimental/sandbox-runtime)
|
|
146
|
+
- [Agents](../agents.md)
|
|
147
|
+
- [Branch-first development](../branch-development.md)
|
|
148
|
+
- [Coordination](../coordination.md)
|
package/docs/integrations.md
CHANGED
|
@@ -13,6 +13,7 @@ into authoritative application state.
|
|
|
13
13
|
|
|
14
14
|
| Category | Integration | Status | Use it for |
|
|
15
15
|
|---|---|---|---|
|
|
16
|
+
| Agent execution | [Anthropic Sandbox Runtime](./integrations/sandbox-runtime.md) | Available | Restricting the filesystem, network, sockets, and inherited authority of an agent process |
|
|
16
17
|
| Long-running records | [Temporal](./integrations/temporal.md) | Available | Durable Workflows, Activity retries, timers, cancellation, and durable AI SDK calls |
|
|
17
18
|
| Long-running records | [Inngest](./integrations/inngest.md) | Available | Event-driven durable functions, retriable steps, flow control, and checkpointed AI SDK calls |
|
|
18
19
|
| Data ingestion | Connector runtimes | Planned | Bringing external data into Ablo-backed models without creating a second write authority |
|
|
@@ -22,6 +23,14 @@ the boundary has been tested. A dedicated package comes later still: only
|
|
|
22
23
|
repeated production integrations that reveal substantial reusable behavior
|
|
23
24
|
justify adding another public runtime dependency.
|
|
24
25
|
|
|
26
|
+
## Agent execution
|
|
27
|
+
|
|
28
|
+
Use [Anthropic Sandbox Runtime](./integrations/sandbox-runtime.md) to enforce the
|
|
29
|
+
boundary around an agent process. The runtime owns filesystem and network
|
|
30
|
+
access. Ablo remains below it and owns typed shared-state operations, claims,
|
|
31
|
+
idempotency, and confirmation. The runnable example lives in
|
|
32
|
+
`examples/sandboxed-agent`.
|
|
33
|
+
|
|
25
34
|
## Long-running records
|
|
26
35
|
|
|
27
36
|
Use [Temporal](./integrations/temporal.md) when work must survive process
|