@abloatai/ablo 0.5.1 → 0.7.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 +61 -0
- package/README.md +248 -124
- package/dist/BaseSyncedStore.d.ts +3 -3
- package/dist/BaseSyncedStore.js +3 -3
- package/dist/api/index.d.ts +3 -3
- package/dist/api/index.js +1 -1
- package/dist/client/Ablo.d.ts +91 -93
- package/dist/client/Ablo.js +122 -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 +116 -90
- package/dist/client/createModelProxy.js +128 -128
- package/dist/client/index.d.ts +6 -7
- package/dist/client/index.js +4 -5
- package/dist/client/validateAbloOptions.js +5 -5
- package/dist/coordination/index.d.ts +6 -0
- package/dist/coordination/index.js +6 -0
- package/dist/coordination/schema.d.ts +329 -0
- package/dist/coordination/schema.js +209 -0
- package/dist/core/QueryView.d.ts +4 -1
- package/dist/core/QueryView.js +1 -1
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.js +7 -0
- package/dist/core/query-utils.d.ts +7 -10
- package/dist/core/query-utils.js +2 -3
- package/dist/errorCodes.d.ts +264 -0
- package/dist/errorCodes.js +251 -0
- package/dist/errors.d.ts +59 -14
- package/dist/errors.js +73 -12
- package/dist/index.d.ts +11 -9
- package/dist/index.js +8 -12
- 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/policy/index.d.ts +1 -1
- package/dist/policy/index.js +1 -1
- package/dist/policy/types.d.ts +31 -0
- package/dist/policy/types.js +15 -0
- package/dist/query/types.d.ts +1 -1
- package/dist/react/AbloProvider.d.ts +13 -1
- package/dist/react/AbloProvider.js +14 -6
- 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/ddl.d.ts +62 -0
- package/dist/schema/ddl.js +317 -0
- package/dist/schema/diff.d.ts +167 -0
- package/dist/schema/diff.js +280 -0
- package/dist/schema/field.d.ts +16 -19
- package/dist/schema/field.js +30 -17
- package/dist/schema/generate.d.ts +19 -0
- package/dist/schema/generate.js +87 -0
- package/dist/schema/index.d.ts +9 -3
- package/dist/schema/index.js +14 -2
- package/dist/schema/model.d.ts +87 -25
- package/dist/schema/model.js +33 -3
- package/dist/schema/relation.d.ts +17 -0
- package/dist/schema/roles.d.ts +148 -0
- package/dist/schema/roles.js +149 -0
- package/dist/schema/schema.d.ts +10 -69
- package/dist/schema/schema.js +58 -24
- package/dist/schema/select.d.ts +25 -0
- package/dist/schema/select.js +55 -0
- package/dist/schema/serialize.d.ts +96 -0
- package/dist/schema/serialize.js +231 -0
- package/dist/schema/sugar.d.ts +20 -3
- package/dist/schema/sugar.js +5 -1
- package/dist/schema/tenancy.d.ts +66 -0
- package/dist/schema/tenancy.js +58 -0
- package/dist/sync/HydrationCoordinator.d.ts +2 -0
- package/dist/sync/HydrationCoordinator.js +23 -17
- 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.d.ts +2 -1
- package/dist/sync/createIntentStream.js +89 -5
- package/dist/sync/createPresenceStream.js +1 -1
- package/dist/sync/participants.d.ts +2 -2
- package/dist/sync/participants.js +9 -18
- package/dist/types/global.d.ts +43 -52
- package/dist/types/global.js +16 -18
- package/dist/types/streams.d.ts +90 -42
- package/docs/api-keys.md +44 -0
- package/docs/api.md +72 -173
- package/docs/audit.md +5 -5
- package/docs/cli.md +212 -0
- package/docs/client-behavior.md +42 -43
- package/docs/coordination.md +343 -0
- package/docs/data-sources.md +16 -16
- package/docs/examples/agent-human.md +30 -32
- package/docs/examples/ai-sdk-tool.md +32 -33
- package/docs/examples/existing-python-backend.md +38 -36
- package/docs/examples/nextjs.md +24 -25
- package/docs/examples/scoped-agent.md +78 -0
- package/docs/examples/server-agent.md +20 -61
- package/docs/guarantees.md +34 -56
- package/docs/identity.md +529 -0
- package/docs/index.md +18 -24
- package/docs/integration-guide.md +130 -144
- package/docs/interaction-model.md +32 -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 +74 -24
- package/docs/roadmap.md +17 -7
- package/llms.txt +34 -39
- package/package.json +8 -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
|
@@ -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
|
-
|
|
@@ -9,7 +9,7 @@ that need multiplayer now and agent-safe writes later.
|
|
|
9
9
|
|
|
10
10
|
This also applies to any API-backed app, not only Python. A product like a YC
|
|
11
11
|
company's existing dashboard can keep its current endpoint/service/database
|
|
12
|
-
shape and migrate one coordinated
|
|
12
|
+
shape and migrate one coordinated model at a time.
|
|
13
13
|
|
|
14
14
|
```txt
|
|
15
15
|
Browser UI
|
|
@@ -26,14 +26,14 @@ Browser UI
|
|
|
26
26
|
Create a schema for the records that need realtime coordination.
|
|
27
27
|
|
|
28
28
|
```ts
|
|
29
|
-
// web/ablo
|
|
29
|
+
// web/ablo/schema.ts
|
|
30
30
|
import { defineSchema, model, z } from '@abloatai/ablo/schema';
|
|
31
31
|
|
|
32
32
|
export const schema = defineSchema({
|
|
33
|
-
|
|
33
|
+
weatherReports: model({
|
|
34
34
|
id: z.string(),
|
|
35
|
-
|
|
36
|
-
status: z.enum(['
|
|
35
|
+
location: z.string(),
|
|
36
|
+
status: z.enum(['pending', 'ready']),
|
|
37
37
|
updatedAt: z.string(),
|
|
38
38
|
}),
|
|
39
39
|
});
|
|
@@ -42,7 +42,7 @@ export const schema = defineSchema({
|
|
|
42
42
|
```ts
|
|
43
43
|
// web/ablo.ts
|
|
44
44
|
import Ablo from '@abloatai/ablo';
|
|
45
|
-
import { schema } from './ablo
|
|
45
|
+
import { schema } from './ablo/schema';
|
|
46
46
|
|
|
47
47
|
export const ablo = Ablo({
|
|
48
48
|
schema,
|
|
@@ -51,14 +51,14 @@ export const ablo = Ablo({
|
|
|
51
51
|
```
|
|
52
52
|
|
|
53
53
|
Mount the React provider near the app root so client components can subscribe to
|
|
54
|
-
model
|
|
54
|
+
model clients without importing server credentials.
|
|
55
55
|
|
|
56
56
|
```tsx
|
|
57
57
|
// web/app/providers.tsx
|
|
58
58
|
'use client';
|
|
59
59
|
|
|
60
60
|
import { AbloProvider } from '@abloatai/ablo/react';
|
|
61
|
-
import { schema } from '@/ablo
|
|
61
|
+
import { schema } from '@/ablo/schema';
|
|
62
62
|
|
|
63
63
|
export function Providers({ children }: { children: React.ReactNode }) {
|
|
64
64
|
return <AbloProvider schema={schema}>{children}</AbloProvider>;
|
|
@@ -68,30 +68,32 @@ export function Providers({ children }: { children: React.ReactNode }) {
|
|
|
68
68
|
## 2. Add Live Reads In The UI
|
|
69
69
|
|
|
70
70
|
Keep the first render backed by the existing Python endpoint. After that,
|
|
71
|
-
subscribe to the same model
|
|
71
|
+
subscribe to the same model client Ablo writes through.
|
|
72
72
|
|
|
73
73
|
```tsx
|
|
74
74
|
'use client';
|
|
75
75
|
|
|
76
76
|
import { useAblo } from '@abloatai/ablo/react';
|
|
77
77
|
|
|
78
|
-
export function
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const
|
|
78
|
+
export function ReportRow({
|
|
79
|
+
report: serverReport,
|
|
80
|
+
}: {
|
|
81
|
+
report: { id: string; location: string; status: string };
|
|
82
|
+
}) {
|
|
83
|
+
const report = useAblo((ablo) => ablo.weatherReports.retrieve(serverReport.id)) ?? serverReport;
|
|
84
|
+
const active = useAblo((ablo) => ablo.weatherReports.claimState(serverReport.id));
|
|
85
|
+
const claimed = Boolean(active);
|
|
84
86
|
|
|
85
87
|
return (
|
|
86
|
-
<button disabled={
|
|
87
|
-
{
|
|
88
|
+
<button disabled={claimed || report.status === 'ready'}>
|
|
89
|
+
{claimed ? 'Someone is editing' : report.location}
|
|
88
90
|
</button>
|
|
89
91
|
);
|
|
90
92
|
}
|
|
91
93
|
```
|
|
92
94
|
|
|
93
95
|
No string model key is needed in the first example. The selector reads from
|
|
94
|
-
`ablo.
|
|
96
|
+
`ablo.weatherReports`, so React uses the same model client as writes and agents.
|
|
95
97
|
|
|
96
98
|
## 3. Add One Python Data Source Endpoint
|
|
97
99
|
|
|
@@ -120,7 +122,7 @@ import os
|
|
|
120
122
|
import time
|
|
121
123
|
from fastapi import APIRouter, HTTPException, Request
|
|
122
124
|
|
|
123
|
-
from app.services.
|
|
125
|
+
from app.services.reports import get_report, list_reports, apply_report_operations
|
|
124
126
|
|
|
125
127
|
router = APIRouter()
|
|
126
128
|
|
|
@@ -160,15 +162,15 @@ async def ablo_source(request: Request):
|
|
|
160
162
|
body = json.loads(raw_body)
|
|
161
163
|
|
|
162
164
|
if body["type"] == "load":
|
|
163
|
-
if body["model"] == "
|
|
164
|
-
return {"row": await
|
|
165
|
+
if body["model"] == "weatherReports":
|
|
166
|
+
return {"row": await get_report(body["id"])}
|
|
165
167
|
|
|
166
168
|
if body["type"] == "list":
|
|
167
|
-
if body["model"] == "
|
|
168
|
-
return {"rows": await
|
|
169
|
+
if body["model"] == "weatherReports":
|
|
170
|
+
return {"rows": await list_reports(body.get("query", {}))}
|
|
169
171
|
|
|
170
172
|
if body["type"] == "commit":
|
|
171
|
-
rows = await
|
|
173
|
+
rows = await apply_report_operations(
|
|
172
174
|
operations=body["operations"],
|
|
173
175
|
client_tx_id=body.get("clientTxId"),
|
|
174
176
|
scope=body.get("scope", {}),
|
|
@@ -178,7 +180,7 @@ async def ablo_source(request: Request):
|
|
|
178
180
|
raise HTTPException(status_code=400, detail="unsupported request")
|
|
179
181
|
```
|
|
180
182
|
|
|
181
|
-
`
|
|
183
|
+
`apply_report_operations` should reuse the same transaction and validation logic
|
|
182
184
|
the existing Python endpoints already use. Dedupe by `clientTxId` so retries are
|
|
183
185
|
safe.
|
|
184
186
|
|
|
@@ -193,20 +195,20 @@ Button -> Python endpoint -> service -> database
|
|
|
193
195
|
Target button path:
|
|
194
196
|
|
|
195
197
|
```txt
|
|
196
|
-
Button -> ablo.
|
|
198
|
+
Button -> ablo.weatherReports.update(...)
|
|
197
199
|
Ablo -> Python Data Source endpoint
|
|
198
200
|
Python service -> database
|
|
199
201
|
Ablo -> realtime fanout and receipt
|
|
200
202
|
```
|
|
201
203
|
|
|
202
|
-
The app does not need a flag-day rewrite. Move one
|
|
204
|
+
The app does not need a flag-day rewrite. Move one model at a time.
|
|
203
205
|
|
|
204
206
|
```ts
|
|
205
|
-
const snap = ablo.snapshot({
|
|
207
|
+
const snap = ablo.snapshot({ weatherReports: reportId });
|
|
206
208
|
|
|
207
|
-
await ablo.
|
|
208
|
-
|
|
209
|
-
{ status: '
|
|
209
|
+
await ablo.weatherReports.update(
|
|
210
|
+
reportId,
|
|
211
|
+
{ status: 'ready' },
|
|
210
212
|
{ readAt: snap.stamp, onStale: 'reject', wait: 'confirmed' },
|
|
211
213
|
);
|
|
212
214
|
```
|
|
@@ -235,12 +237,12 @@ and timestamp. If the change originated from an Ablo commit, include the same
|
|
|
235
237
|
Agents use the same model API as the UI:
|
|
236
238
|
|
|
237
239
|
```ts
|
|
238
|
-
const [
|
|
239
|
-
const snap = ablo.snapshot({
|
|
240
|
+
const [report] = await ablo.weatherReports.load({ where: { id: reportId } });
|
|
241
|
+
const snap = ablo.snapshot({ weatherReports: reportId });
|
|
240
242
|
|
|
241
|
-
await ablo.
|
|
242
|
-
|
|
243
|
-
{ status: '
|
|
243
|
+
await ablo.weatherReports.update(
|
|
244
|
+
reportId,
|
|
245
|
+
{ status: 'ready' },
|
|
244
246
|
{ readAt: snap.stamp, onStale: 'reject', wait: 'confirmed' },
|
|
245
247
|
);
|
|
246
248
|
```
|
package/docs/examples/nextjs.md
CHANGED
|
@@ -7,11 +7,11 @@ Server Components, and live client subscriptions.
|
|
|
7
7
|
|
|
8
8
|
```txt
|
|
9
9
|
app/
|
|
10
|
-
|
|
10
|
+
reports/
|
|
11
11
|
[id]/
|
|
12
12
|
page.tsx # RSC: retrieve + render
|
|
13
13
|
actions.ts # Server Action: schema update with stale-state check
|
|
14
|
-
|
|
14
|
+
ReportEditor.tsx # Client: live updates
|
|
15
15
|
lib/
|
|
16
16
|
ablo.ts # Schema-backed Ablo client for server actions
|
|
17
17
|
```
|
|
@@ -19,40 +19,41 @@ app/
|
|
|
19
19
|
## RSC Initial Render
|
|
20
20
|
|
|
21
21
|
```tsx
|
|
22
|
-
// app/
|
|
22
|
+
// app/reports/[id]/page.tsx
|
|
23
23
|
import { ablo } from '@/lib/ablo';
|
|
24
24
|
|
|
25
|
-
export default async function
|
|
25
|
+
export default async function ReportPage({
|
|
26
26
|
params,
|
|
27
27
|
}: { params: { id: string } }) {
|
|
28
28
|
await ablo.ready();
|
|
29
|
-
const [
|
|
30
|
-
if (!
|
|
29
|
+
const [report] = await ablo.weatherReports.load({ where: { id: params.id } });
|
|
30
|
+
if (!report) return null;
|
|
31
31
|
|
|
32
|
-
return <
|
|
32
|
+
return <ReportEditor report={report} />;
|
|
33
33
|
}
|
|
34
34
|
```
|
|
35
35
|
|
|
36
36
|
## Server Action Commit
|
|
37
37
|
|
|
38
38
|
```ts
|
|
39
|
-
// app/
|
|
39
|
+
// app/reports/[id]/actions.ts
|
|
40
40
|
'use server';
|
|
41
41
|
|
|
42
42
|
import { ablo } from '@/lib/ablo';
|
|
43
43
|
|
|
44
|
-
export async function
|
|
45
|
-
const
|
|
46
|
-
if (busy.length > 0) return { status: 'busy', intents: busy };
|
|
47
|
-
|
|
48
|
-
const snap = ablo.snapshot({ tasks: id });
|
|
49
|
-
const task = await ablo.tasks.update(
|
|
44
|
+
export async function markReady(id: string) {
|
|
45
|
+
const report = await ablo.weatherReports.claim(
|
|
50
46
|
id,
|
|
51
|
-
|
|
52
|
-
|
|
47
|
+
async (claimed) =>
|
|
48
|
+
ablo.weatherReports.update(
|
|
49
|
+
claimed.id,
|
|
50
|
+
{ status: 'ready' },
|
|
51
|
+
{ wait: 'confirmed' },
|
|
52
|
+
),
|
|
53
|
+
{ wait: false, action: 'marking_ready' },
|
|
53
54
|
);
|
|
54
55
|
|
|
55
|
-
return { status: '
|
|
56
|
+
return { status: 'ready', report };
|
|
56
57
|
}
|
|
57
58
|
```
|
|
58
59
|
|
|
@@ -66,16 +67,14 @@ rejects. The action can re-fetch and ask the user to retry.
|
|
|
66
67
|
|
|
67
68
|
import { useAblo } from '@abloatai/ablo/react';
|
|
68
69
|
|
|
69
|
-
export function
|
|
70
|
-
const data = useAblo((ablo) => ablo.
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
) ?? [];
|
|
74
|
-
const busy = intents.length > 0;
|
|
70
|
+
export function ReportEditor({ report: serverReport }: Props) {
|
|
71
|
+
const data = useAblo((ablo) => ablo.weatherReports.retrieve(serverReport.id)) ?? serverReport;
|
|
72
|
+
const active = useAblo((ablo) => ablo.weatherReports.claimState(serverReport.id));
|
|
73
|
+
const claimed = Boolean(active);
|
|
75
74
|
|
|
76
75
|
return (
|
|
77
|
-
<button disabled={
|
|
78
|
-
{
|
|
76
|
+
<button disabled={claimed || data.status === 'ready'}>
|
|
77
|
+
{claimed ? 'Someone is editing' : 'Mark ready'}
|
|
79
78
|
</button>
|
|
80
79
|
);
|
|
81
80
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Agent Scoped to One Deck
|
|
2
|
+
|
|
3
|
+
An agent that edits **one deck** and receives realtime updates for **only that
|
|
4
|
+
deck** — not the whole org. Shows the sync-group model end to end: a scope root,
|
|
5
|
+
a containment (`parent`) edge, identity roles, and the model-form `scope`.
|
|
6
|
+
|
|
7
|
+
See [Identity & Sync Groups](../identity.md) for the full reference.
|
|
8
|
+
|
|
9
|
+
## 1. Schema — declare the scope, once
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { defineSchema, identityRole, model, relation, z } from '@abloatai/ablo/schema';
|
|
13
|
+
|
|
14
|
+
export const schema = defineSchema(
|
|
15
|
+
{
|
|
16
|
+
// A scope root: deck rows form the group `deck:<id>`.
|
|
17
|
+
decks: model(
|
|
18
|
+
{ title: z.string() },
|
|
19
|
+
{},
|
|
20
|
+
{ orgScoped: true, scope: 'deck' },
|
|
21
|
+
),
|
|
22
|
+
// A child: no group of its own. It inherits its deck's group via the
|
|
23
|
+
// `parent` edge, so a slide write reaches everyone viewing the deck —
|
|
24
|
+
// even a slide edit that doesn't touch `deckId` (routing is keyed on the
|
|
25
|
+
// row's id, not the changed columns).
|
|
26
|
+
slides: model(
|
|
27
|
+
{ deckId: z.string(), body: z.string() },
|
|
28
|
+
{ deck: relation.belongsTo('decks', 'deckId', { parent: true }) },
|
|
29
|
+
{ orgScoped: true },
|
|
30
|
+
),
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
// Humans get their full org scope automatically from these.
|
|
34
|
+
identityRoles: [
|
|
35
|
+
identityRole({ kind: 'org', source: 'organizationId' }),
|
|
36
|
+
identityRole({ kind: 'user', source: 'userId' }),
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
);
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## 2. Dispatch — narrow the agent to the deck it's working on
|
|
43
|
+
|
|
44
|
+
The agent inherits the triggering user's identity (its ceiling) and is narrowed
|
|
45
|
+
to one deck (the floor). You pass the **model and id** — never a `deck:<id>`
|
|
46
|
+
string; the engine builds the group from the model's `scope`.
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
const ablo = Ablo({
|
|
50
|
+
schema,
|
|
51
|
+
url: process.env.ABLO_URL,
|
|
52
|
+
kind: 'agent',
|
|
53
|
+
agentId: 'agent:slide-writer',
|
|
54
|
+
userId: triggeringUser.id, // ceiling: can't exceed this user's reach
|
|
55
|
+
organizationId: triggeringUser.organizationId,
|
|
56
|
+
scope: { decks: deckId }, // floor: just this deck → deck:<deckId>
|
|
57
|
+
});
|
|
58
|
+
await ablo.ready();
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## 3. Write — it fans out to everyone on that deck
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
// Other participants subscribed to deck:<deckId> — the human in the editor,
|
|
65
|
+
// a reviewer agent — receive this delta in realtime. Participants on other
|
|
66
|
+
// decks never see it.
|
|
67
|
+
await ablo.slides.update(slideId, { body: 'Q4 revenue up 12% YoY' });
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
The slide's delta is stamped `deck:<deckId>` (derived server-side from the
|
|
71
|
+
slide → deck `parent` edge), so it reaches the deck's audience authoritatively —
|
|
72
|
+
regardless of which groups the agent happened to subscribe to. And `scope` only
|
|
73
|
+
ever *narrows*: the agent can't reach a deck its triggering user couldn't.
|
|
74
|
+
|
|
75
|
+
## See also
|
|
76
|
+
|
|
77
|
+
- [Identity & Sync Groups](../identity.md) — the full scope / parent / grants model.
|
|
78
|
+
- [Agent + Human](./agent-human.md) — yielding when a human edits the same row.
|
|
@@ -8,10 +8,10 @@ import Ablo from '@abloatai/ablo';
|
|
|
8
8
|
import { defineSchema, model, z } from '@abloatai/ablo/schema';
|
|
9
9
|
|
|
10
10
|
const schema = defineSchema({
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
status: z.enum(['
|
|
14
|
-
|
|
11
|
+
weatherReports: model({
|
|
12
|
+
location: z.string(),
|
|
13
|
+
status: z.enum(['pending', 'ready']),
|
|
14
|
+
forecast: z.string().optional(),
|
|
15
15
|
}),
|
|
16
16
|
});
|
|
17
17
|
|
|
@@ -20,67 +20,26 @@ const ablo = Ablo({
|
|
|
20
20
|
apiKey: process.env.ABLO_API_KEY,
|
|
21
21
|
});
|
|
22
22
|
|
|
23
|
-
export async function
|
|
23
|
+
export async function completeReport(reportId: string) {
|
|
24
24
|
await ablo.ready();
|
|
25
25
|
|
|
26
|
-
const [
|
|
27
|
-
if (!
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
{
|
|
38
|
-
{ readAt: snap.stamp, onStale: 'reject', wait: 'confirmed' },
|
|
26
|
+
const [report] = await ablo.weatherReports.load({ where: { id: reportId } });
|
|
27
|
+
if (!report) return { status: 'not_found' };
|
|
28
|
+
|
|
29
|
+
const updated = await ablo.weatherReports.claim(
|
|
30
|
+
reportId,
|
|
31
|
+
async (claimed) =>
|
|
32
|
+
ablo.weatherReports.update(
|
|
33
|
+
claimed.id,
|
|
34
|
+
{ status: 'ready' },
|
|
35
|
+
{ wait: 'confirmed' },
|
|
36
|
+
),
|
|
37
|
+
{ wait: false, action: 'completing' },
|
|
39
38
|
);
|
|
40
39
|
|
|
41
|
-
return { status: '
|
|
40
|
+
return { status: 'ready', report: updated };
|
|
42
41
|
}
|
|
43
42
|
```
|
|
44
43
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
Use `agent.run(...)` when the worker intentionally cannot import the app schema.
|
|
48
|
-
It creates the run envelope and returns `done`, `failed`, or `cancelled`.
|
|
49
|
-
|
|
50
|
-
```ts
|
|
51
|
-
const api = Ablo({ apiKey: process.env.ABLO_API_KEY });
|
|
52
|
-
|
|
53
|
-
const result = await api.agent('task-writer', {
|
|
54
|
-
can: ['tasks.retrieve', 'tasks.update'],
|
|
55
|
-
syncGroups: ['workspace:acme'],
|
|
56
|
-
}).run(
|
|
57
|
-
{
|
|
58
|
-
prompt: 'Mark task_123 done.',
|
|
59
|
-
surface: 'agent_worker',
|
|
60
|
-
},
|
|
61
|
-
async ({ resource }) => {
|
|
62
|
-
const tasks = resource<{ title: string; status: string }>('tasks');
|
|
63
|
-
|
|
64
|
-
const { data, stamp, intents } = await tasks.retrieve('task_123', {
|
|
65
|
-
ifBusy: 'return',
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
if (intents.length > 0) {
|
|
69
|
-
return { skipped: true, reason: 'busy' };
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
return tasks.update(
|
|
73
|
-
'task_123',
|
|
74
|
-
{ status: 'done' },
|
|
75
|
-
{ readAt: stamp, onStale: 'reject', wait: 'confirmed' },
|
|
76
|
-
);
|
|
77
|
-
},
|
|
78
|
-
);
|
|
79
|
-
|
|
80
|
-
if (result.status === 'failed') throw result.error;
|
|
81
|
-
if (result.status === 'cancelled') return;
|
|
82
|
-
```
|
|
83
|
-
|
|
84
|
-
Use the schema-backed version first. The schema-less version is for generic
|
|
85
|
-
agent infrastructure, MCP routes, and platform code.
|
|
86
|
-
|
|
44
|
+
Use the schema-backed version for server agents so the worker, app, and React UI
|
|
45
|
+
share the same model methods.
|