@abloatai/ablo 0.20.2 → 0.21.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 +10 -0
- package/dist/client/ApiClient.d.ts +7 -0
- package/dist/client/ApiClient.js +77 -14
- package/dist/client/auth.js +7 -1
- package/dist/sync/SyncWebSocket.js +30 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.21.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- Coordination observability now fires on BOTH transports. Previously `captureClaim`/`captureConflict` were emitted only by the WebSocket transport, so a `ClaimLog` (or any `observability` provider) handed to a stateless HTTP client — the transport server-side agents use via `Ablo({ transport: 'http' })` — stayed empty, and even on WebSocket a hard commit rejection went unrecorded. Fixed:
|
|
8
|
+
- **HTTP transport now emits.** `Ablo({ transport: 'http', observability })` records `claim` acquisition (`captureClaim`) and coordination-conflict rejections (`captureConflict`, code `stale_context` / `claim_conflict` / `entity_claimed`) on BOTH HTTP write doors (`commits.create` and per-model `ablo.<model>.update/create/delete`). The conflict names the collided rows — from the server's `conflicts` detail when present, otherwise the ops the write attempted. `observability` is now a documented option on the HTTP client.
|
|
9
|
+
- **WebSocket rejections now recorded.** A commit rejected by the conflict policy (`mutation_result` `success: false` with a coordination code) now calls `captureConflict`, mirroring the existing notify-on-success path. So `ClaimLog.collisions()` no longer silently misses rejected writes.
|
|
10
|
+
|
|
11
|
+
Net effect: a `ClaimLog` behaves identically regardless of transport — `entries`, `collisions()`, and `onChange` reflect the real coordination timeline for headless agent evals and live activity feeds alike.
|
|
12
|
+
|
|
3
13
|
## 0.20.2
|
|
4
14
|
|
|
5
15
|
### Patch Changes
|
|
@@ -9,9 +9,16 @@ import type { AbloOptions, CommitResource, ClaimCreateOptions, ClaimWaitOptions,
|
|
|
9
9
|
import type { SchemaRecord } from '../schema/schema.js';
|
|
10
10
|
import type { Duration } from '../utils/duration.js';
|
|
11
11
|
import type { Claim } from '../types/streams.js';
|
|
12
|
+
import type { SyncObservabilityProvider } from '../interfaces/index.js';
|
|
12
13
|
export type AbloApiClientOptions = Omit<AbloOptions, 'schema'> & {
|
|
13
14
|
readonly schema?: null | undefined;
|
|
14
15
|
readonly bootstrapBaseUrl?: string | undefined;
|
|
16
|
+
/**
|
|
17
|
+
* Observability provider forwarded from `Ablo({ observability })`. The HTTP
|
|
18
|
+
* transport emits the same claim/conflict seams as the WS transport so a
|
|
19
|
+
* `ClaimLog` works identically for headless (server-agent) evals.
|
|
20
|
+
*/
|
|
21
|
+
readonly observability?: SyncObservabilityProvider;
|
|
15
22
|
};
|
|
16
23
|
export interface AbloApiClaims {
|
|
17
24
|
create(options: ClaimCreateOptions): Promise<Claim>;
|
package/dist/client/ApiClient.js
CHANGED
|
@@ -28,6 +28,38 @@ export function createProtocolClient(options) {
|
|
|
28
28
|
databaseUrl: configuredDatabaseUrl,
|
|
29
29
|
dangerouslyAllowBrowser: options.dangerouslyAllowBrowser,
|
|
30
30
|
});
|
|
31
|
+
// Observability seam for the STATELESS HTTP transport. The WS transport emits
|
|
32
|
+
// claim/conflict events from SyncWebSocket; the HTTP path (server-side agents,
|
|
33
|
+
// `transport: 'http'`) emitted nothing, so a `ClaimLog` handed to a headless
|
|
34
|
+
// agent eval stayed empty. Mirror the two WS seams here: claim acquired +
|
|
35
|
+
// coordination-conflict rejection. No-op when no provider is configured.
|
|
36
|
+
const observability = options.observability;
|
|
37
|
+
// Shared by the two HTTP write doors (`commits.create` + per-model
|
|
38
|
+
// `mutateModel`): a rejected write whose code is a coordination conflict is
|
|
39
|
+
// the collision ClaimLog exists to surface. Prefer the server's `conflicts`
|
|
40
|
+
// detail (carried on the typed error / envelope); fall back to the rows the
|
|
41
|
+
// caller tried to write so the collision always names a target. Inert without
|
|
42
|
+
// a provider or for non-conflict errors. Never throws (capture is best-effort).
|
|
43
|
+
const recordCoordinationConflict = (error, clientTxId, fallbackRows) => {
|
|
44
|
+
if (!observability)
|
|
45
|
+
return;
|
|
46
|
+
const code = error?.code;
|
|
47
|
+
const isConflict = code === 'stale_context' ||
|
|
48
|
+
code === 'claim_conflict' ||
|
|
49
|
+
code === 'entity_claimed' ||
|
|
50
|
+
(typeof code === 'string' && code.startsWith('policy:'));
|
|
51
|
+
if (!isConflict)
|
|
52
|
+
return;
|
|
53
|
+
const rawConflicts = error?.conflicts;
|
|
54
|
+
const rows = Array.isArray(rawConflicts) && rawConflicts.length > 0
|
|
55
|
+
? rawConflicts.map((r) => ({
|
|
56
|
+
model: typeof r.model === 'string' ? r.model : 'unknown',
|
|
57
|
+
id: typeof r.id === 'string' ? r.id : 'unknown',
|
|
58
|
+
fields: [],
|
|
59
|
+
}))
|
|
60
|
+
: fallbackRows.map((r) => ({ model: r.model, id: r.id, fields: [] }));
|
|
61
|
+
observability.captureConflict({ clientTxId, rows });
|
|
62
|
+
};
|
|
31
63
|
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
32
64
|
if (typeof fetchImpl !== 'function') {
|
|
33
65
|
throw new AbloConnectionError('Ablo API client requires a fetch implementation. Pass `fetch` in Ablo({ ... }) for this runtime.', { code: 'fetch_unavailable' });
|
|
@@ -256,16 +288,29 @@ export function createProtocolClient(options) {
|
|
|
256
288
|
readAt: commitOptions.readAt ?? claim?.readAt ?? null,
|
|
257
289
|
onStale: commitOptions.onStale ?? (claim?.readAt !== undefined ? 'reject' : null),
|
|
258
290
|
});
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
clientTxId,
|
|
291
|
+
let body;
|
|
292
|
+
try {
|
|
293
|
+
body = await requestJson('/v1/commits', {
|
|
294
|
+
method: 'POST',
|
|
264
295
|
idempotencyKey: clientTxId,
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
296
|
+
body: JSON.stringify({
|
|
297
|
+
clientTxId,
|
|
298
|
+
idempotencyKey: clientTxId,
|
|
299
|
+
claim: normalizeClaimId(commitOptions.claimRef) ?? claim?.id,
|
|
300
|
+
operations,
|
|
301
|
+
}),
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
catch (error) {
|
|
305
|
+
// Coordination collision over HTTP — surface it to observability on the
|
|
306
|
+
// same footing as the WS transport, then rethrow unchanged. Fall back to
|
|
307
|
+
// the ops we tried to write so the collision always names a row.
|
|
308
|
+
recordCoordinationConflict(error, clientTxId, operations.map((o) => ({
|
|
309
|
+
model: typeof o.model === 'string' ? o.model : 'unknown',
|
|
310
|
+
id: typeof o.id === 'string' ? o.id : 'unknown',
|
|
311
|
+
})));
|
|
312
|
+
throw error;
|
|
313
|
+
}
|
|
269
314
|
// `requestJson` throws via `translateHttpError` on any non-2xx,
|
|
270
315
|
// so reaching here implies success. Narrow `status` to the
|
|
271
316
|
// `CommitWait`-compatible subset; `'rejected'` only appears on
|
|
@@ -511,11 +556,21 @@ export function createProtocolClient(options) {
|
|
|
511
556
|
requestBody.id = id;
|
|
512
557
|
if (data !== undefined)
|
|
513
558
|
requestBody.data = data;
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
559
|
+
let body;
|
|
560
|
+
try {
|
|
561
|
+
body = await requestJson(path, {
|
|
562
|
+
method,
|
|
563
|
+
idempotencyKey: clientTxId,
|
|
564
|
+
body: JSON.stringify(requestBody),
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
catch (error) {
|
|
568
|
+
// Per-model write door (`ablo.<model>.update/create/delete`) — the path
|
|
569
|
+
// the demo editor + server agents actually take. Capture coordination
|
|
570
|
+
// collisions here too; this single row IS the fallback target.
|
|
571
|
+
recordCoordinationConflict(error, clientTxId, [{ model: modelName, id }]);
|
|
572
|
+
throw error;
|
|
573
|
+
}
|
|
519
574
|
// `requestJson` throws via `translateHttpError` on any non-2xx, so reaching
|
|
520
575
|
// here implies success. Narrow `status` to the `CommitWait`-compatible
|
|
521
576
|
// subset; `'rejected'` only appears on a thrown rejection body.
|
|
@@ -563,6 +618,14 @@ export function createProtocolClient(options) {
|
|
|
563
618
|
const releaseClaim = (params) => requestJson(claimPath(isClaimHandle(params) ? params.target.id : params.id), { method: 'DELETE' }).then(() => undefined);
|
|
564
619
|
async function claimImpl(params) {
|
|
565
620
|
const claimId = await acquireClaim(params);
|
|
621
|
+
observability?.captureClaim({
|
|
622
|
+
phase: 'acquired',
|
|
623
|
+
claimId,
|
|
624
|
+
model: name,
|
|
625
|
+
id: params.id,
|
|
626
|
+
...(params.field ? { field: params.field } : {}),
|
|
627
|
+
reason: params.reason ?? 'editing',
|
|
628
|
+
});
|
|
566
629
|
const { data, stamp } = await retrieveModel(name, { id: params.id });
|
|
567
630
|
const release = () => releaseClaim(params);
|
|
568
631
|
return {
|
package/dist/client/auth.js
CHANGED
|
@@ -96,7 +96,13 @@ function activeProjectSlug(value) {
|
|
|
96
96
|
return typeof slug === 'string' && slug.length > 0 ? slug : undefined;
|
|
97
97
|
}
|
|
98
98
|
function importNodeBuiltin(specifier) {
|
|
99
|
-
|
|
99
|
+
// Node-only runtime import (CLI credential snapshot). The call is dead in
|
|
100
|
+
// browser/edge builds — guarded by `process.versions.node` and `window`
|
|
101
|
+
// checks in the callers — but bundlers still try to resolve the dynamic
|
|
102
|
+
// `import()` and Turbopack fails the build on the unanalyzable specifier.
|
|
103
|
+
// The magic comments tell each bundler to emit the import verbatim and never
|
|
104
|
+
// resolve it. Keep both so webpack and Turbopack consumers are covered.
|
|
105
|
+
return import(/* webpackIgnore: true */ /* turbopackIgnore: true */ specifier);
|
|
100
106
|
}
|
|
101
107
|
async function readJsonIfPresent(path) {
|
|
102
108
|
try {
|
|
@@ -404,6 +404,36 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
404
404
|
else {
|
|
405
405
|
errorMessage = 'mutation failed on server';
|
|
406
406
|
}
|
|
407
|
+
// Coordination collision: a stale-context rejection (the write's
|
|
408
|
+
// readAt premise moved underneath) or a foreign-claim conflict is
|
|
409
|
+
// exactly the collision ClaimLog exists to surface. The notify
|
|
410
|
+
// path (success + notifications) emits captureConflict above; a
|
|
411
|
+
// HARD rejection must too — otherwise observability.collisions()
|
|
412
|
+
// silently misses every rejected write. The conflicted rows ride
|
|
413
|
+
// along on the typed error's `conflicts` detail (see
|
|
414
|
+
// AbloStaleContextError.toJSON / errorEnvelope).
|
|
415
|
+
if (errorCode === 'stale_context' ||
|
|
416
|
+
errorCode === 'claim_conflict' ||
|
|
417
|
+
errorCode === 'entity_claimed' ||
|
|
418
|
+
errorCode?.startsWith('policy:') === true) {
|
|
419
|
+
const rawConflicts = error != null &&
|
|
420
|
+
typeof error === 'object' &&
|
|
421
|
+
Array.isArray(error.conflicts)
|
|
422
|
+
? error
|
|
423
|
+
.conflicts
|
|
424
|
+
: [];
|
|
425
|
+
const conflictEvent = {
|
|
426
|
+
clientTxId: typeof clientTxId === 'string' ? clientTxId : '',
|
|
427
|
+
rows: rawConflicts.map((r) => ({
|
|
428
|
+
model: typeof r.model === 'string' ? r.model : 'unknown',
|
|
429
|
+
id: typeof r.id === 'string' ? r.id : 'unknown',
|
|
430
|
+
fields: [],
|
|
431
|
+
})),
|
|
432
|
+
};
|
|
433
|
+
const ctx = getContext();
|
|
434
|
+
ctx.observability.breadcrumb(formatConflict(conflictEvent), 'sync.coordination', 'warning');
|
|
435
|
+
ctx.observability.captureConflict(conflictEvent);
|
|
436
|
+
}
|
|
407
437
|
// Build the proper typed AbloError from the wire code via the
|
|
408
438
|
// shared factory — the same code→class mapping the HTTP commit
|
|
409
439
|
// path uses (`translateHttpError`). This keeps rejected commits
|