@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 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>;
@@ -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
- const body = await requestJson('/v1/commits', {
260
- method: 'POST',
261
- idempotencyKey: clientTxId,
262
- body: JSON.stringify({
263
- clientTxId,
291
+ let body;
292
+ try {
293
+ body = await requestJson('/v1/commits', {
294
+ method: 'POST',
264
295
  idempotencyKey: clientTxId,
265
- claim: normalizeClaimId(commitOptions.claimRef) ?? claim?.id,
266
- operations,
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
- const body = await requestJson(path, {
515
- method,
516
- idempotencyKey: clientTxId,
517
- body: JSON.stringify(requestBody),
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 {
@@ -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
- return import(specifier);
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abloatai/ablo",
3
- "version": "0.20.2",
3
+ "version": "0.21.0",
4
4
  "description": "The Collaboration Layer For AI Agents",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",