@abloatai/ablo 0.16.3 → 0.18.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.
@@ -133,6 +133,22 @@ export interface AbloOptions<S extends SchemaRecord = SchemaRecord> {
133
133
  * @default 'websocket'
134
134
  */
135
135
  transport?: 'websocket' | 'http' | undefined;
136
+ /**
137
+ * Turn Ablo's diagnostic logging on/off. `true` surfaces the `[Ablo]`
138
+ * coordination trace — claims requested / queued / granted / released, agent
139
+ * handovers, connection state — so you can SEE the human+agent coordination
140
+ * you built while debugging. Omitted/`false` keeps the quiet default (only
141
+ * warnings + errors). For a middle ground use {@link logLevel}. Env override:
142
+ * `ABLO_LOG_LEVEL`. Ignored if a custom logger is supplied.
143
+ */
144
+ debug?: boolean | undefined;
145
+ /**
146
+ * Log threshold for the default `[Ablo]` logger (takes precedence over
147
+ * {@link debug}). `'info'` = coordination + connection events without the
148
+ * per-model registration firehose; `'debug'` = everything; `'warn'` (default)
149
+ * = warnings + errors only; `'silent'` = nothing. Env override: `ABLO_LOG_LEVEL`.
150
+ */
151
+ logLevel?: 'debug' | 'info' | 'warn' | 'error' | 'silent' | undefined;
136
152
  /**
137
153
  * Bearer auth token. Hosted-cloud consumers pass `apiKey`; self-hosted
138
154
  * deployments may pass a bearer token minted by their own auth layer.
@@ -242,8 +258,24 @@ export interface InternalAbloOptions<S extends SchemaRecord = SchemaRecord> {
242
258
  * `apiKey` only; the server handles capability issuance.
243
259
  */
244
260
  capabilityToken?: string;
245
- /** Custom logger (default: console) */
261
+ /** Custom logger (default: console). Supplying one bypasses {@link debug}/{@link logLevel}. */
246
262
  logger?: SyncLogger;
263
+ /**
264
+ * Turn Ablo's diagnostic logging on/off. `true` surfaces the `[Ablo]`
265
+ * coordination trace — claims acquired / queued / granted / released, agent
266
+ * handovers, connection state — plus internal lifecycle, so you can SEE the
267
+ * human+agent coordination you built. Omitted/`false` keeps the quiet default
268
+ * (only warnings + errors). For a middle ground use {@link logLevel}.
269
+ * Env override: `ABLO_LOG_LEVEL`. Ignored if a custom {@link logger} is passed.
270
+ */
271
+ debug?: boolean;
272
+ /**
273
+ * Log threshold for the default `[Ablo]` logger (takes precedence over
274
+ * {@link debug}). `'info'` = coordination + connection events without the
275
+ * per-model registration firehose; `'debug'` = everything; `'warn'` (default)
276
+ * = warnings + errors only; `'silent'` = nothing. Env override: `ABLO_LOG_LEVEL`.
277
+ */
278
+ logLevel?: 'debug' | 'info' | 'warn' | 'error' | 'silent';
247
279
  /** ObjectPool size limit (default: 10000) */
248
280
  maxPoolSize?: number;
249
281
  /**
@@ -518,17 +518,44 @@ function createDynamicModelClass(modelName, jsonSubFields, fieldNames, computed,
518
518
  }
519
519
  return ModelClass;
520
520
  }
521
- // ── Default console logger ────────────────────────────────────────────────
522
- const consoleLogger = {
523
- debug: (...args) => { if (typeof console !== 'undefined')
524
- console.debug('[sync]', ...args); },
525
- info: (...args) => { if (typeof console !== 'undefined')
526
- console.info('[sync]', ...args); },
527
- warn: (...args) => { if (typeof console !== 'undefined')
528
- console.warn('[sync]', ...args); },
529
- error: (...args) => { if (typeof console !== 'undefined')
530
- console.error('[sync]', ...args); },
531
- };
521
+ const LOG_LEVEL_RANK = { debug: 10, info: 20, warn: 30, error: 40, silent: 99 };
522
+ /**
523
+ * Resolve the effective level. Precedence: explicit `logLevel` option →
524
+ * `debug: true` ( debug) → `ABLO_LOG_LEVEL` env → default `warn`. `debug: false`
525
+ * / omitted just means "don't raise the level" — it falls through to env/default
526
+ * rather than force-silencing an ops-set env override.
527
+ */
528
+ function resolveLogLevel(opts) {
529
+ if (opts?.logLevel && opts.logLevel in LOG_LEVEL_RANK)
530
+ return opts.logLevel;
531
+ if (opts?.debug === true)
532
+ return 'debug';
533
+ // `globalThis.process` guard keeps this safe in browser/edge runtimes that
534
+ // have no `process` binding — there we fall through to the default.
535
+ const raw = globalThis.process?.env?.ABLO_LOG_LEVEL;
536
+ const normalized = raw?.toLowerCase();
537
+ if (normalized && normalized in LOG_LEVEL_RANK)
538
+ return normalized;
539
+ return 'warn';
540
+ }
541
+ /**
542
+ * Build the default logger, gated at `level` and prefixed `[Ablo]` so a creator
543
+ * with a console full of other tools' logs can see at a glance what's ours.
544
+ */
545
+ function createConsoleLogger(level) {
546
+ const threshold = LOG_LEVEL_RANK[level];
547
+ const emit = (lvl, fn, args) => {
548
+ if (typeof console === 'undefined' || LOG_LEVEL_RANK[lvl] < threshold)
549
+ return;
550
+ fn('[Ablo]', ...args);
551
+ };
552
+ return {
553
+ debug: (...args) => emit('debug', console.debug, args),
554
+ info: (...args) => emit('info', console.info, args),
555
+ warn: (...args) => emit('warn', console.warn, args),
556
+ error: (...args) => emit('error', console.error, args),
557
+ };
558
+ }
532
559
  // `readProcessEnv` lives in `./auth` alongside the other resolvers
533
560
  // that read it. Re-exported there for use elsewhere in the file.
534
561
  // ── Default mutation executor (wire: `commit` frame over WebSocket) ──────
@@ -712,7 +739,10 @@ export function Ablo(options) {
712
739
  databaseUrl: configuredDatabaseUrl,
713
740
  dangerouslyAllowBrowser: options.dangerouslyAllowBrowser,
714
741
  });
715
- const { logger = consoleLogger } = internalOptions;
742
+ // Custom logger wins; otherwise build the default `[Ablo]` logger at the level
743
+ // resolved from `debug`/`logLevel`/`ABLO_LOG_LEVEL` (default `warn`).
744
+ const logger = internalOptions.logger ??
745
+ createConsoleLogger(resolveLogLevel({ debug: options.debug, logLevel: options.logLevel }));
716
746
  // Nudge (once) if a stray DATABASE_URL is in the env but `databaseUrl` wasn't
717
747
  // passed — the env value is no longer auto-adopted (see resolveDatabaseUrl).
718
748
  warnIfDatabaseUrlEnvIgnored(authInput, (m) => logger.warn(m));
@@ -83,7 +83,7 @@ export function warnIfDatabaseUrlEnvIgnored(input, warn) {
83
83
  if (warn)
84
84
  warn(message);
85
85
  else if (typeof console !== 'undefined')
86
- console.warn('[sync]', message);
86
+ console.warn('[Ablo]', message);
87
87
  }
88
88
  export const ABLO_HOSTED_API_DOMAIN = 'api.abloatai.com';
89
89
  export const ABLO_HOSTED_HTTP_BASE_URL = `https://${ABLO_HOSTED_API_DOMAIN}`;
@@ -274,7 +274,9 @@ export class StoreManager {
274
274
  * Clear all stores
275
275
  */
276
276
  async clearAllStores() {
277
- getContext().logger.warn('Clearing all stores');
277
+ // Lifecycle chatter (logout / identity switch / reset) NOT a warning.
278
+ // `debug` so it's silent under the default `warn` threshold.
279
+ getContext().logger.debug('Clearing all stores');
278
280
  const promises = Array.from(this.stores.values()).map((store) => store.clear());
279
281
  await Promise.all(promises);
280
282
  getContext().logger.info('All stores cleared');
package/dist/errors.d.ts CHANGED
@@ -77,6 +77,15 @@ export declare class AbloError extends Error {
77
77
  request_id?: string;
78
78
  [key: string]: unknown;
79
79
  };
80
+ /**
81
+ * A single, leak-proof line for logs and `String(err)` / template
82
+ * interpolation: `AbloValidationError [code]: message (see docs) [request_id: …]`.
83
+ *
84
+ * Deliberately does NOT dump `details`, `cause`, or the stack — the thing that
85
+ * makes `console.error(richError)` an unreadable wall of text. The structured
86
+ * payload stays available via {@link toJSON}; this is the human one-liner.
87
+ */
88
+ toString(): string;
80
89
  }
81
90
  /**
82
91
  * Map a stable error `code` to its docs URL — the one place the convention
package/dist/errors.js CHANGED
@@ -91,6 +91,20 @@ export class AbloError extends Error {
91
91
  ...(this.details ?? {}),
92
92
  };
93
93
  }
94
+ /**
95
+ * A single, leak-proof line for logs and `String(err)` / template
96
+ * interpolation: `AbloValidationError [code]: message (see docs) [request_id: …]`.
97
+ *
98
+ * Deliberately does NOT dump `details`, `cause`, or the stack — the thing that
99
+ * makes `console.error(richError)` an unreadable wall of text. The structured
100
+ * payload stays available via {@link toJSON}; this is the human one-liner.
101
+ */
102
+ toString() {
103
+ const code = this.code ? ` [${this.code}]` : '';
104
+ const docs = this.docUrl ? ` (see ${this.docUrl})` : '';
105
+ const req = this.requestId ? ` [request_id: ${this.requestId}]` : '';
106
+ return `${this.name}${code}: ${this.message}${docs}${req}`;
107
+ }
94
108
  }
95
109
  /**
96
110
  * Map a stable error `code` to its docs URL — the one place the convention
package/dist/index.d.ts CHANGED
@@ -62,6 +62,8 @@ export { defaultPolicy, capabilityPreemptPolicy, interpretConflictAxis } from '.
62
62
  export { SyncSessionError, AbloError, AbloAuthenticationError, AbloPermissionError, AbloRateLimitError, AbloIdempotencyError, AbloConnectionError, AbloValidationError, AbloNotFoundError, AbloServerError, AbloStaleContextError, AbloClaimedError, CapabilityError, translateHttpError, hasWireCode, errorFromWire, toAbloError, ERROR_CODES, ERROR_CONTRACT_VERSION, errorCodeSpec, isRetryableCode, classifyRecovery, recoveryClassSchema, RECOVERY_CLASSES, } from './errors.js';
63
63
  export type { CommitReceipt, RequiredCapability } from './errors.js';
64
64
  export type { ErrorCode, WireErrorCode, ErrorCategory, ErrorCodeSpec, RecoveryClass } from './errors.js';
65
+ export { errorEnvelope, statusForType } from './wire/errorEnvelope.js';
66
+ export type { ErrorEnvelope } from './wire/errorEnvelope.js';
65
67
  export { WS_BEARER_SUBPROTOCOL_PREFIX, WS_SYNC_SUBPROTOCOL } from './auth/credentialSource.js';
66
68
  export { ENVIRONMENTS, environmentSchema, normalizeEnvironment, environmentFromKeyPrefix, environmentToKeyPrefix, isSandboxEnvironment, } from './environment.js';
67
69
  export type { Environment, KeyPrefixEnvironment } from './environment.js';
package/dist/index.js CHANGED
@@ -85,6 +85,11 @@ export { defaultPolicy, capabilityPreemptPolicy, interpretConflictAxis } from '.
85
85
  // consumers need to discriminate failures (`e instanceof AbloX` or
86
86
  // `e.type === 'AbloX'`) plus the HTTP-response translator.
87
87
  export { SyncSessionError, AbloError, AbloAuthenticationError, AbloPermissionError, AbloRateLimitError, AbloIdempotencyError, AbloConnectionError, AbloValidationError, AbloNotFoundError, AbloServerError, AbloStaleContextError, AbloClaimedError, CapabilityError, translateHttpError, hasWireCode, errorFromWire, toAbloError, ERROR_CODES, ERROR_CONTRACT_VERSION, errorCodeSpec, isRetryableCode, classifyRecovery, recoveryClassSchema, RECOVERY_CLASSES, } from './errors.js';
88
+ // Canonical wire-egress contract (dependency-free): the error envelope shape +
89
+ // the AbloError-subclass→HTTP-status table. Re-exported so server consumers
90
+ // (e.g. apps/sync-server, which keeps its own self-contained copy) can assert
91
+ // against the ONE source instead of silently drifting. See wire/errorEnvelope.ts.
92
+ export { errorEnvelope, statusForType } from './wire/errorEnvelope.js';
88
93
  export { WS_BEARER_SUBPROTOCOL_PREFIX, WS_SYNC_SUBPROTOCOL } from './auth/credentialSource.js';
89
94
  export { ENVIRONMENTS, environmentSchema, normalizeEnvironment, environmentFromKeyPrefix, environmentToKeyPrefix, isSandboxEnvironment, } from './environment.js';
90
95
  // THE write-options contract — the one Zod schema for the option bag every
@@ -122,18 +122,32 @@ export type ConflictDecision = {
122
122
  */
123
123
  export type ConflictPolicy = (conflict: Conflict) => ConflictDecision | Promise<ConflictDecision>;
124
124
  /**
125
- * Default policy.
125
+ * Default policy — **Law 7 (the human is the principal) is the engine-wide
126
+ * default, not an opt-in.**
126
127
  *
127
- * `claim_held` conflicts always reject (a foreign claim is honored unless a
128
- * privileged policy preempts). `stale_context` conflicts honor the committer's
129
- * declared `onStale` intent:
128
+ * A `claim_held` conflict resolves by the COMMITTER's kind. This default is, by
129
+ * construction, identical to `coordination(humansOverwrite(), agentsReject())`
130
+ * applied to every model — so a developer who wants different behaviour just
131
+ * declares the conflict axis on that model (`humansReject()`, `humansNotify()`,
132
+ * …) and it wins. The default and the override speak the same vocabulary; the
133
+ * default is the one you get for free.
134
+ *
135
+ * • `user` → `allow` — a human is never blocked by a claim (a claim is a
136
+ * coordination hint among agents, not a lock on people)
137
+ * • `system` → `allow` — automation principals at the root of a trust chain
138
+ * behave like humans here (matches `mayBypassClaims`)
139
+ * • `agent` → `reject` — an agent yields to a foreign claim (the no-bypass
140
+ * invariant; the ONLY sanctioned agent override is the
141
+ * privileged `claim.preempt` capability seam, see
142
+ * {@link capabilityPreemptPolicy})
143
+ *
144
+ * `stale_context` conflicts honor the committer's declared `onStale` intent:
130
145
  *
131
146
  * • `'notify'` → notify + hold (op withheld; the actor resolves)
132
147
  * • anything else (incl. `'reject'`, absent) → reject
133
148
  *
134
- * `'overwrite'` never reaches a policy — it's a hard opt-out resolved before
135
- * detection. This preserves the legacy always-reject default for callers that
136
- * don't opt into `notify`.
149
+ * `'overwrite'` never reaches a policy on the stale path — it's a hard opt-out
150
+ * resolved before detection.
137
151
  */
138
152
  export declare const defaultPolicy: (conflict: Conflict) => ConflictDecision;
139
153
  /**
@@ -172,8 +186,9 @@ export interface ConflictAxis {
172
186
  * synchronous (no I/O), so it runs on either side of the schema-agnostic
173
187
  * boundary. Looks up the committer's kind and maps the declared `OnStaleMode`:
174
188
  *
175
- * - undefined → the engine default (`defaultPolicy`: reject; honor
176
- * `onStale: 'notify'` on a stale write)
189
+ * - undefined → the engine default (`defaultPolicy`: Law 7 — a human/system
190
+ * committer is allowed (never blocked), an agent rejects on a
191
+ * `claim_held`; on a stale write, honor `onStale: 'notify'`)
177
192
  * - `overwrite` → `allow` — the write wins / the committer is never blocked
178
193
  * - `reject` → `reject` — the committer yields
179
194
  * - `notify` → on `stale_context`: notify + hold (re-read & re-apply);
@@ -7,18 +7,32 @@
7
7
  * Adding new shapes is additive on the discriminated union.
8
8
  */
9
9
  /**
10
- * Default policy.
10
+ * Default policy — **Law 7 (the human is the principal) is the engine-wide
11
+ * default, not an opt-in.**
11
12
  *
12
- * `claim_held` conflicts always reject (a foreign claim is honored unless a
13
- * privileged policy preempts). `stale_context` conflicts honor the committer's
14
- * declared `onStale` intent:
13
+ * A `claim_held` conflict resolves by the COMMITTER's kind. This default is, by
14
+ * construction, identical to `coordination(humansOverwrite(), agentsReject())`
15
+ * applied to every model — so a developer who wants different behaviour just
16
+ * declares the conflict axis on that model (`humansReject()`, `humansNotify()`,
17
+ * …) and it wins. The default and the override speak the same vocabulary; the
18
+ * default is the one you get for free.
19
+ *
20
+ * • `user` → `allow` — a human is never blocked by a claim (a claim is a
21
+ * coordination hint among agents, not a lock on people)
22
+ * • `system` → `allow` — automation principals at the root of a trust chain
23
+ * behave like humans here (matches `mayBypassClaims`)
24
+ * • `agent` → `reject` — an agent yields to a foreign claim (the no-bypass
25
+ * invariant; the ONLY sanctioned agent override is the
26
+ * privileged `claim.preempt` capability seam, see
27
+ * {@link capabilityPreemptPolicy})
28
+ *
29
+ * `stale_context` conflicts honor the committer's declared `onStale` intent:
15
30
  *
16
31
  * • `'notify'` → notify + hold (op withheld; the actor resolves)
17
32
  * • anything else (incl. `'reject'`, absent) → reject
18
33
  *
19
- * `'overwrite'` never reaches a policy — it's a hard opt-out resolved before
20
- * detection. This preserves the legacy always-reject default for callers that
21
- * don't opt into `notify`.
34
+ * `'overwrite'` never reaches a policy on the stale path — it's a hard opt-out
35
+ * resolved before detection.
22
36
  */
23
37
  // Typed by its real SYNCHRONOUS shape (via `satisfies`) rather than the
24
38
  // async-permissive `ConflictPolicy` alias, so sync callers like
@@ -26,8 +40,14 @@
26
40
  // `ConflictDecision` back (not `… | Promise<…>`). Still assignable to
27
41
  // `ConflictPolicy` everywhere it's used as a policy.
28
42
  export const defaultPolicy = ((conflict) => {
29
- if (conflict.kind !== 'stale_context') {
30
- return { action: 'reject', reason: 'claim_conflict' };
43
+ if (conflict.kind === 'claim_held') {
44
+ // Law 7 universal default: principals (human / system) are never blocked;
45
+ // agents yield. Keeping `agent → reject` here is what makes the no-bypass
46
+ // invariant hold even on the registry/default resolution path (which, unlike
47
+ // the declared-axis path, has no separate agent-degradation guard).
48
+ return conflict.committer.kind === 'agent'
49
+ ? { action: 'reject', reason: 'claim_conflict' }
50
+ : { action: 'allow', note: 'principal:not-blocked' };
31
51
  }
32
52
  return conflict.requestedMode === 'notify'
33
53
  ? { action: 'notify', reason: 'stale_notify_hold' }
@@ -53,8 +73,9 @@ export const capabilityPreemptPolicy = (conflict) => {
53
73
  * synchronous (no I/O), so it runs on either side of the schema-agnostic
54
74
  * boundary. Looks up the committer's kind and maps the declared `OnStaleMode`:
55
75
  *
56
- * - undefined → the engine default (`defaultPolicy`: reject; honor
57
- * `onStale: 'notify'` on a stale write)
76
+ * - undefined → the engine default (`defaultPolicy`: Law 7 — a human/system
77
+ * committer is allowed (never blocked), an agent rejects on a
78
+ * `claim_held`; on a stale write, honor `onStale: 'notify'`)
58
79
  * - `overwrite` → `allow` — the write wins / the committer is never blocked
59
80
  * - `reject` → `reject` — the committer yields
60
81
  * - `notify` → on `stale_context`: notify + hold (re-read & re-apply);
@@ -133,6 +133,20 @@ export interface MigrationSignal {
133
133
  readonly model: string;
134
134
  readonly field?: string;
135
135
  readonly detail: string;
136
+ /**
137
+ * Reader-visibility context for a removal that shadows an existing artifact:
138
+ * the active schema this push is being diffed against. Lets the CLI show the
139
+ * baseline — version + WHEN it was pushed — so "incompatible" isn't a mystery
140
+ * (e.g. a first sandbox push diffed against a months-old production schema).
141
+ */
142
+ readonly shadowed?: {
143
+ readonly environment: string;
144
+ readonly version: number;
145
+ /** ISO timestamp the shadowed artifact was activated/pushed, or null. */
146
+ readonly pushedAt: string | null;
147
+ /** Who pushed it (e.g. `apikey:…`), or null. */
148
+ readonly pushedBy: string | null;
149
+ };
136
150
  }
137
151
  export interface MigrationClassification {
138
152
  /** Execute but may lose or risk data on a non-empty table. */
package/dist/surface.d.ts CHANGED
@@ -23,7 +23,7 @@ export declare const PUBLIC_MODEL_VERBS: readonly ["retrieve", "list", "get", "g
23
23
  export declare const PUBLIC_LIST_OPTION_KEYS: readonly ["where", "filter", "orderBy", "limit", "offset", "state"];
24
24
  /** Public keys of `AbloOptions`. `schema` is required; the rest are optional
25
25
  * (the locked happy path is `Ablo({ schema, apiKey, databaseUrl, transport })`). */
26
- export declare const PUBLIC_ABLO_OPTION_KEYS: readonly ["schema", "apiKey", "databaseUrl", "persistence", "transport", "authToken", "baseURL", "fetch", "defaultHeaders", "defaultQuery", "dangerouslyAllowBrowser"];
26
+ export declare const PUBLIC_ABLO_OPTION_KEYS: readonly ["schema", "apiKey", "databaseUrl", "persistence", "transport", "debug", "logLevel", "authToken", "baseURL", "fetch", "defaultHeaders", "defaultQuery", "dangerouslyAllowBrowser"];
27
27
  export type ModelVerb = (typeof PUBLIC_MODEL_VERBS)[number];
28
28
  export type ListOptionKey = (typeof PUBLIC_LIST_OPTION_KEYS)[number];
29
29
  export type AbloOptionKey = (typeof PUBLIC_ABLO_OPTION_KEYS)[number];
package/dist/surface.js CHANGED
@@ -51,6 +51,8 @@ export const PUBLIC_ABLO_OPTION_KEYS = [
51
51
  'databaseUrl',
52
52
  'persistence',
53
53
  'transport',
54
+ 'debug',
55
+ 'logLevel',
54
56
  'authToken',
55
57
  'baseURL',
56
58
  'fetch',
@@ -177,7 +177,7 @@ export class SyncWebSocket extends EventEmitter {
177
177
  // which is unreliable but the only signal available; in Node it returns true
178
178
  // (assume online) so the sidecar/agent path doesn't short-circuit here.
179
179
  if (!getContext().onlineStatus.isOnline()) {
180
- getContext().logger.warn('onlineStatus reports offline, but attempting connection anyway');
180
+ getContext().logger.debug('onlineStatus reports offline, but attempting connection anyway');
181
181
  }
182
182
  this.isConnecting = true;
183
183
  this.isManualClose = false;
@@ -13,6 +13,7 @@
13
13
  * fake; `SyncWebSocket` satisfies it structurally.
14
14
  */
15
15
  import { AbloClaimedError, formatClaimedErrorMessage, claimTargetLabel, } from '../errors.js';
16
+ import { getContext } from '../context.js';
16
17
  export function awaitClaimGrant(transport, claimId, options) {
17
18
  return new Promise((resolve, reject) => {
18
19
  const unsubs = [];
@@ -28,12 +29,18 @@ export function awaitClaimGrant(transport, claimId, options) {
28
29
  // we waited in line, and reached the head → `claim_granted`. Either frame
29
30
  // means the lease is now ours; `waited` records which path it was.
30
31
  unsubs.push(transport.subscribe('claim_acquired', (p) => {
31
- if (p?.claimId === claimId)
32
+ if (p?.claimId === claimId) {
33
+ getContext().logger.debug(`claim: acquired ${claimId} (target was free)`);
32
34
  settle(() => resolve({ waited: false }));
35
+ }
33
36
  }));
34
37
  unsubs.push(transport.subscribe('claim_granted', (p) => {
35
- if (p?.claimId === claimId)
38
+ if (p?.claimId === claimId) {
39
+ // Promoted to the head of the line — the creator's "it's the agent's
40
+ // turn now" moment after waiting behind a holder.
41
+ getContext().logger.info(`claim: granted ${claimId} — your turn (waited in queue)`);
36
42
  settle(() => resolve({ waited: true }));
43
+ }
37
44
  }));
38
45
  if (options?.maxQueueDepth !== undefined) {
39
46
  const max = options.maxQueueDepth;
@@ -24,6 +24,11 @@
24
24
  import { asyncIteratorFrom } from '../utils/asyncIterator.js';
25
25
  import { toMs } from '../utils/duration.js';
26
26
  import { descriptionFromMeta, participantKindFromWire, } from '../coordination/schema.js';
27
+ import { getContext } from '../context.js';
28
+ /** Readable target for the coordination trace: `documents:abc` / `documents:abc.title`. */
29
+ function claimLabel(type, id, field) {
30
+ return field ? `${type}:${id}.${field}` : `${type}:${id}`;
31
+ }
27
32
  export function createClaimStream(config, transport = null) {
28
33
  const { participantId } = config;
29
34
  // ── State: others' open claims, keyed by claimId ───────────────
@@ -37,6 +42,9 @@ export function createClaimStream(config, transport = null) {
37
42
  const queueByEntity = new Map();
38
43
  const entityKey = (type, id) => `${type}:${id}`;
39
44
  const EMPTY_QUEUE = Object.freeze([]);
45
+ // Last queue position we logged per own-claim, so advancing in line is traced
46
+ // once per change (not re-logged on every server re-fan of the same line).
47
+ const lastLoggedQueuePos = new Map();
40
48
  // ── Subscribers ──────────────────────────────────────────────────
41
49
  const listeners = new Set();
42
50
  const rejectionListeners = new Set();
@@ -122,6 +130,12 @@ export function createClaimStream(config, transport = null) {
122
130
  unsubs.push(t.subscribe('claim_rejected', (rejection) => {
123
131
  if (!rejection.claimId)
124
132
  return;
133
+ if (ownClaims.has(rejection.claimId)) {
134
+ const tgt = rejection.target
135
+ ? claimLabel(rejection.target.entityType, rejection.target.entityId, rejection.target.field)
136
+ : rejection.claimId;
137
+ getContext().logger.info(`claim: rejected ${tgt}${rejection.heldBy ? ` — held by ${rejection.heldBy}` : ''}`, { claimId: rejection.claimId, reason: rejection.reason });
138
+ }
125
139
  // Drop the rejected own-claim so reconnect doesn't re-announce
126
140
  // a claim the server already rejected (would just spam both
127
141
  // sides with conflicts).
@@ -141,6 +155,10 @@ export function createClaimStream(config, transport = null) {
141
155
  const lost = payload;
142
156
  if (!lost.claimId)
143
157
  return;
158
+ if (ownClaims.has(lost.claimId)) {
159
+ const c = ownClaims.get(lost.claimId);
160
+ getContext().logger.info(`claim: lost ${c ? claimLabel(c.entityType, c.entityId, c.field) : lost.claimId} (preempted or expired)`, { claimId: lost.claimId });
161
+ }
144
162
  // Drop the lost own-claim so reconnect doesn't re-announce a lease we
145
163
  // no longer hold.
146
164
  ownClaims.delete(lost.claimId);
@@ -166,6 +184,16 @@ export function createClaimStream(config, transport = null) {
166
184
  queueByEntity.delete(key);
167
185
  else
168
186
  queueByEntity.set(key, Object.freeze([...line]));
187
+ // If WE are in this line, trace our position (the "agent queued behind a
188
+ // claim" moment) — once per position change, so advancing is visible.
189
+ const ourIndex = line.findIndex((c) => ownClaims.has(c.id));
190
+ if (ourIndex >= 0) {
191
+ const ourId = line[ourIndex].id;
192
+ if (lastLoggedQueuePos.get(ourId) !== ourIndex) {
193
+ lastLoggedQueuePos.set(ourId, ourIndex);
194
+ getContext().logger.info(`claim: queued for ${claimLabel(p.target.type, p.target.id)} — position ${ourIndex + 1} of ${line.length}, waiting`, { claimId: ourId });
195
+ }
196
+ }
169
197
  notifyListeners();
170
198
  }));
171
199
  // (3) On reconnect, re-announce every open self-claim — the
@@ -251,6 +279,9 @@ export function createClaimStream(config, transport = null) {
251
279
  };
252
280
  ownClaims.set(claimId, claim);
253
281
  sendBegin(claimId, claim);
282
+ // Coordination trace (info): the creator can SEE their human/agent claims.
283
+ getContext().logger.info(`claim: requesting ${claimLabel(claim.entityType, claim.entityId, claim.field)} for "${claim.reason}"` +
284
+ (claim.queue ? ' (will queue if contended)' : ''), { claimId });
254
285
  let revoked = false;
255
286
  const revoke = () => {
256
287
  if (revoked)
@@ -258,6 +289,7 @@ export function createClaimStream(config, transport = null) {
258
289
  revoked = true;
259
290
  ownClaims.delete(claimId);
260
291
  sendAbandon(claimId, claim);
292
+ getContext().logger.info(`claim: released ${claimLabel(claim.entityType, claim.entityId, claim.field)}`, { claimId });
261
293
  };
262
294
  return {
263
295
  object: 'claim',
@@ -8,6 +8,19 @@ export interface ErrorEnvelope {
8
8
  readonly message: string;
9
9
  readonly doc_url?: string;
10
10
  readonly request_id?: string;
11
+ /** Aggregate field-level failures — so one 4xx can report EVERY invalid input
12
+ * at once (schema push, batch commit, CLI-arg validation) instead of failing
13
+ * on the first. `param` stays the single-field convenience case. (RFC 9457
14
+ * `errors[]` / JSON:API `errors[]` / Google `BadRequest.fieldViolations[]`.) */
15
+ readonly errors?: ReadonlyArray<{
16
+ readonly code?: string;
17
+ readonly message: string;
18
+ readonly param?: string;
19
+ }>;
20
+ /** Typed-details slot: `AbloError.toJSON()` spreads its `details` (e.g.
21
+ * `missingIds`, `conflicts`, `retryAfterSeconds`) as top-level members.
22
+ * Consumers MUST ignore members they don't recognize (forward-compat). */
23
+ readonly [key: string]: unknown;
11
24
  }
12
25
  /** {@link AbloError} subclass → default HTTP status. The subclass is chosen to
13
26
  * match status semantics (a validation error is a 400, a permission error a