@odla-ai/cli 0.25.8 → 0.25.10

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/README.md CHANGED
@@ -195,6 +195,8 @@ npx odla-ai provision --email owner@example.com --write-dev-vars --push-secrets
195
195
  npx odla-ai smoke --env dev
196
196
  npx odla-ai agent jobs --env dev --state dead_letter --json
197
197
  npx odla-ai agent retry <job-id> --env dev --json
198
+ npx odla-ai discuss watch <topic-id> --jsonl
199
+ npx odla-ai discuss watch <topic-id> --cursor <saved-cursor> --jsonl
198
200
  npx odla-ai secrets push --env dev
199
201
  npx odla-ai secrets set clerk_webhook_secret --env dev --stdin
200
202
  npx odla-ai secrets set-clerk-key --env dev --from-env CLERK_SECRET_KEY
@@ -225,6 +227,25 @@ currently running work. Successful receipts are retained for seven days (and
225
227
  bounded to the newest 1,000 per tenant); unresolved dead letters remain until
226
228
  an operator requeues them.
227
229
 
230
+ `discuss watch` waits at a server-issued snapshot boundary instead of comparing
231
+ client clocks or repeatedly reading a topic's oldest 200 posts. It is unbounded
232
+ unless `--timeout <seconds>` is supplied. `--jsonl` emits versioned
233
+ `checkpoint`, `event`, `heartbeat`, and degraded `status` records; persist the
234
+ latest cursor only after consuming its record and pass it back with `--cursor`
235
+ after a restart. Event IDs are stable for at-least-once deduplication. Transient
236
+ 429/5xx/network failures retry with bounded exponential backoff without
237
+ advancing the cursor. A restore or truncated history exits 3 and emits an
238
+ explicit replacement checkpoint; an explicit timeout exits 75, and an
239
+ exhausted remote retry exits 6.
240
+
241
+ `discuss read <topic> --json` follows the server's bounded forward post pages
242
+ before emitting one complete document. A long-lived agent can therefore watch
243
+ an event and then read the full topic rather than receiving an apparently
244
+ successful response capped to its oldest 200 posts. Use `--limit <n>
245
+ --offset <n>` to request one bounded page instead; complete reads fail closed
246
+ after 10,000 posts or three continuously changing scans rather than consuming
247
+ memory forever or claiming an inconsistent result.
248
+
228
249
  `code connect` is the first-party personal Code terminal runtime. Run the exact
229
250
  command copied from **Studio → Code → Terminal** while your shell is in the
230
251
  local Git checkout. `--platform`, `--app-id`, and `--env` make the requested
package/dist/bin.cjs CHANGED
@@ -4608,13 +4608,13 @@ async function createConversionRegistry(config) {
4608
4608
  policies.set(policy.conversionId, Object.freeze(policy));
4609
4609
  }
4610
4610
  const outputCounts = /* @__PURE__ */ new Map();
4611
- const get2 = (id, kind) => {
4611
+ const get = (id, kind) => {
4612
4612
  const policy = policies.get(id);
4613
4613
  if (!policy || policy.output.kind !== kind) throw new CamelError("conversion_rejected", "Conversion policy is missing or has the wrong output kind.");
4614
4614
  return policy;
4615
4615
  };
4616
4616
  const checked = (source, id, kind) => {
4617
- const policy = get2(id, kind);
4617
+ const policy = get(id, kind);
4618
4618
  if (utf8Length(source.value) > policy.maximumSourceBytes) throw new CamelError("limit_exceeded", "Unsafe conversion input exceeds its byte bound.");
4619
4619
  return policy;
4620
4620
  };
@@ -6563,12 +6563,12 @@ Usage:
6563
6563
  odla-ai pm handoff --app <id> [--json]
6564
6564
  odla-ai discuss groups [--json]
6565
6565
  odla-ai discuss list [--app <id>] [--q <text>] [--state open|resolved|all] [--json]
6566
- odla-ai discuss read <topic> [--json]
6566
+ odla-ai discuss read <topic> [--limit <n> --offset <n>] [--json]
6567
6567
  odla-ai discuss post --app <id> --subject "..." --body "..." [--markup "... @[Label](kind/id)"] [--mutation-id <id>]
6568
6568
  odla-ai discuss reply <topic> --body "..." [--markup "..."] [--mutation-id <id>]
6569
6569
  odla-ai discuss resolve <topic> [--reopen] [--mutation-id <id>]
6570
6570
  odla-ai discuss who --q <text> [--app <id>] [--kinds user,pm:task] [--json]
6571
- odla-ai discuss watch [<topic>] [--by <authorId>] [--self <authorId>] [--interval <s>] [--timeout <s>] [--json]
6571
+ odla-ai discuss watch [<topic>] [--cursor <cursor>] [--by <authorId>] [--self <authorId>] [--interval <s>] [--timeout <s>] [--json|--jsonl]
6572
6572
  odla-ai agent jobs [--env dev] [--state pending|running|succeeded|dead_letter] [--limit 50] [--email <email>] [--json]
6573
6573
  odla-ai agent retry <job-id> [--env dev] [--email <email>] [--json]
6574
6574
  odla-ai o11y status [--app <id>] [--env prod] [--minutes 60] [--json]
@@ -7409,22 +7409,59 @@ async function discussList(ctx, parsed) {
7409
7409
  }
7410
7410
  });
7411
7411
  }
7412
- async function discussRead(ctx, id) {
7413
- const result = await request(
7414
- ctx,
7415
- "GET",
7416
- `/topics/${encodeURIComponent(id)}`
7417
- );
7418
- emit(ctx, result, () => {
7419
- ctx.out.log(`${result.topic.subject} [${state(result.topic)}] ${result.topic.appId ?? ""}`);
7420
- for (const post of result.posts) {
7421
- const who = post.authorKind === "bot" ? `${post.authorId} (agent)` : post.authorId;
7422
- ctx.out.log(`
7423
- \u2014 ${who}`);
7424
- ctx.out.log(bodyWithRefs(post));
7425
- for (const file of post.attachments ?? []) ctx.out.log(` [attachment] ${file.name} (${file.size} bytes)`);
7412
+ async function discussRead(ctx, id, parsed) {
7413
+ const requestedLimit = stringOpt(parsed.options.limit);
7414
+ const requestedOffset = stringOpt(parsed.options.offset);
7415
+ if (requestedLimit !== void 0 || requestedOffset !== void 0) {
7416
+ const query = new URLSearchParams({
7417
+ limit: requestedLimit ?? "200",
7418
+ offset: requestedOffset ?? "0"
7419
+ });
7420
+ const page = await request(ctx, "GET", `/topics/${encodeURIComponent(id)}?${query}`);
7421
+ emit(ctx, page, () => renderRead(ctx, page.topic, page.posts));
7422
+ return;
7423
+ }
7424
+ for (let scan = 0; scan < 3; scan++) {
7425
+ const posts = /* @__PURE__ */ new Map();
7426
+ let topic = null;
7427
+ let offset = 0;
7428
+ for (; ; ) {
7429
+ const page = await request(
7430
+ ctx,
7431
+ "GET",
7432
+ `/topics/${encodeURIComponent(id)}?limit=200&offset=${offset}`
7433
+ );
7434
+ topic = page.topic;
7435
+ for (const post of page.posts) posts.set(post.id, post);
7436
+ if (posts.size > 1e4) throw new Error("discuss read failed: conversation exceeds 10000 posts");
7437
+ if (!page.page?.hasMore) break;
7438
+ if (page.page.nextOffset === null || page.page.nextOffset <= offset) {
7439
+ throw new Error("discuss read failed: registry returned a non-advancing post page");
7440
+ }
7441
+ offset = page.page.nextOffset;
7426
7442
  }
7427
- });
7443
+ const ordered = [...posts.values()].sort(
7444
+ (a, b) => a.createdAt - b.createdAt || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)
7445
+ );
7446
+ const expected = Number.isInteger(topic.replyCount) ? topic.replyCount + 1 : ordered.length;
7447
+ if (ordered.length === expected) {
7448
+ const result = { topic, posts: ordered };
7449
+ emit(ctx, result, () => renderRead(ctx, result.topic, result.posts));
7450
+ return;
7451
+ }
7452
+ if (offset === 0) throw new Error("discuss read failed: registry did not provide forward post pages");
7453
+ }
7454
+ throw new Error("discuss read failed: conversation changed during every complete-read attempt");
7455
+ }
7456
+ function renderRead(ctx, topic, posts) {
7457
+ ctx.out.log(`${topic.subject} [${state(topic)}] ${topic.appId ?? ""}`);
7458
+ for (const post of posts) {
7459
+ const who = post.authorKind === "bot" ? `${post.authorId} (agent)` : post.authorId;
7460
+ ctx.out.log(`
7461
+ \u2014 ${who}`);
7462
+ ctx.out.log(bodyWithRefs(post));
7463
+ for (const file of post.attachments ?? []) ctx.out.log(` [attachment] ${file.name} (${file.size} bytes)`);
7464
+ }
7428
7465
  }
7429
7466
  async function discussPost(ctx, parsed) {
7430
7467
  const appId = stringOpt(parsed.options.app);
@@ -7473,61 +7510,214 @@ async function discussWho(ctx, parsed) {
7473
7510
  });
7474
7511
  }
7475
7512
 
7513
+ // src/discuss-watch-http.ts
7514
+ var WatchRequestError = class extends Error {
7515
+ constructor(message2, retryable, status, code) {
7516
+ super(message2);
7517
+ this.retryable = retryable;
7518
+ this.status = status;
7519
+ this.code = code;
7520
+ this.name = "WatchRequestError";
7521
+ }
7522
+ retryable;
7523
+ status;
7524
+ code;
7525
+ };
7526
+ var WatchCheckpointError = class extends Error {
7527
+ constructor(cursor, streamId) {
7528
+ super("discussion cursor requires a new checkpoint");
7529
+ this.cursor = cursor;
7530
+ this.streamId = streamId;
7531
+ this.name = "WatchCheckpointError";
7532
+ }
7533
+ cursor;
7534
+ streamId;
7535
+ code = "checkpoint_required";
7536
+ };
7537
+ var WatchRemoteError = class extends Error {
7538
+ constructor(cursor, cause) {
7539
+ super("discussion watch dependency remained unavailable", { cause });
7540
+ this.cursor = cursor;
7541
+ this.name = "WatchRemoteError";
7542
+ }
7543
+ cursor;
7544
+ code = "remote_unavailable";
7545
+ };
7546
+ var WatchTimeoutError = class extends Error {
7547
+ constructor(cursor) {
7548
+ super("nothing new before the timeout");
7549
+ this.cursor = cursor;
7550
+ this.name = "WatchTimeoutError";
7551
+ }
7552
+ cursor;
7553
+ code = "watch_timeout";
7554
+ };
7555
+ function requestPath(topicId, app, cursor) {
7556
+ const params = new URLSearchParams();
7557
+ if (topicId) params.set("topic", topicId);
7558
+ if (app) params.set("app", app);
7559
+ if (cursor) params.set("cursor", cursor);
7560
+ return `/watch?${params}`;
7561
+ }
7562
+ async function getWatchPage(ctx, path) {
7563
+ let res;
7564
+ try {
7565
+ res = await ctx.doFetch(`${ctx.platformUrl}/registry/discuss${path}`, {
7566
+ headers: { authorization: `Bearer ${ctx.token}` }
7567
+ });
7568
+ } catch (error) {
7569
+ throw new WatchRequestError(
7570
+ `discuss watch request failed: ${error instanceof Error ? error.message : String(error)}`,
7571
+ true
7572
+ );
7573
+ }
7574
+ const data = await res.json().catch(() => ({}));
7575
+ if (res.status === 409 && data.code === "checkpoint_required") {
7576
+ throw new WatchCheckpointError(data.cursor, data.streamId);
7577
+ }
7578
+ if (!res.ok) {
7579
+ throw new WatchRequestError(
7580
+ `discuss watch failed: ${data.error ?? `registry returned ${res.status}`}`,
7581
+ res.status === 429 || res.status >= 500,
7582
+ res.status,
7583
+ data.code ?? (res.status === 401 || res.status === 403 ? "auth_failed" : void 0)
7584
+ );
7585
+ }
7586
+ return data;
7587
+ }
7588
+
7476
7589
  // src/discuss-watch.ts
7477
7590
  var DEFAULT_INTERVAL_MS = 15e3;
7478
- var DEFAULT_TIMEOUT_MS = 15 * 6e4;
7591
+ var MAX_CONSECUTIVE_FAILURES = 5;
7592
+ var MAX_BACKOFF_MS = 3e4;
7479
7593
  function numberOpt2(parsed, flag, fallback) {
7480
7594
  const raw = stringOpt(parsed.options[flag]);
7481
7595
  const value = raw == null ? NaN : Number(raw);
7482
7596
  return Number.isFinite(value) && value > 0 ? value : fallback;
7483
7597
  }
7484
- async function get(ctx, path) {
7485
- const res = await ctx.doFetch(`${ctx.platformUrl}/registry/discuss${path}`, {
7486
- headers: { authorization: `Bearer ${ctx.token}` }
7487
- });
7488
- const data = await res.json().catch(() => ({}));
7489
- if (!res.ok) throw new Error(`discuss watch failed: ${data.error ?? `registry returned ${res.status}`}`);
7490
- return data;
7598
+ function jsonl(ctx, parsed, value) {
7599
+ if (parsed.options.jsonl === true) ctx.out.log(JSON.stringify({ v: 1, ...value }));
7491
7600
  }
7492
7601
  async function discussWatch(ctx, topicId, parsed) {
7602
+ if (ctx.json && parsed.options.jsonl === true) throw new Error("--json and --jsonl cannot be combined");
7493
7603
  const sleep = ctx.sleep ?? ((ms) => new Promise((resolve11) => setTimeout(resolve11, ms)));
7494
7604
  const now = ctx.now ?? Date.now;
7495
- const intervalMs = numberOpt2(parsed, "interval", DEFAULT_INTERVAL_MS / 1e3) * 1e3;
7496
- const timeoutMs = numberOpt2(parsed, "timeout", DEFAULT_TIMEOUT_MS / 1e3) * 1e3;
7605
+ const intervalMs = (numberOpt2(parsed, "interval", DEFAULT_INTERVAL_MS / 1e3) ?? DEFAULT_INTERVAL_MS / 1e3) * 1e3;
7606
+ const timeoutSeconds = numberOpt2(parsed, "timeout");
7607
+ const deadline = timeoutSeconds === void 0 ? void 0 : now() + timeoutSeconds * 1e3;
7497
7608
  const by = stringOpt(parsed.options.by);
7498
7609
  const self = stringOpt(parsed.options.self);
7499
- const deadline = now() + timeoutMs;
7500
- const fresh = (post) => (!by || post.authorId === by) && (!self || post.authorId !== self);
7501
- let seen = /* @__PURE__ */ new Set();
7502
- let since = now();
7503
- if (topicId) {
7504
- const first = await get(ctx, `/topics/${encodeURIComponent(topicId)}`);
7505
- seen = new Set(first.posts.map((post) => post.id));
7506
- }
7610
+ const app = stringOpt(parsed.options.app);
7611
+ let cursor = stringOpt(parsed.options.cursor);
7612
+ let firstSuccess = true;
7613
+ let consecutiveFailures = 0;
7507
7614
  for (; ; ) {
7508
- if (topicId) {
7509
- const { posts } = await get(ctx, `/topics/${encodeURIComponent(topicId)}`);
7510
- const added = posts.filter((post) => !seen.has(post.id) && fresh(post));
7511
- if (added.length > 0) return report2(ctx, { found: true, posts: added });
7512
- for (const post of posts) seen.add(post.id);
7513
- } else {
7514
- const app = stringOpt(parsed.options.app);
7515
- const { topics } = await get(
7516
- ctx,
7517
- `/topics?state=all${app ? `&app=${encodeURIComponent(app)}` : ""}`
7518
- );
7519
- const active = topics.filter((topic) => topic.lastActivityAt > since);
7520
- if (active.length > 0) return report2(ctx, { found: true, topics: active });
7521
- since = now();
7615
+ let page;
7616
+ try {
7617
+ page = await getWatchPage(ctx, requestPath(topicId, app, cursor));
7618
+ consecutiveFailures = 0;
7619
+ } catch (error) {
7620
+ if (!(error instanceof WatchRequestError) || !error.retryable) {
7621
+ if (error instanceof WatchCheckpointError) {
7622
+ jsonl(ctx, parsed, {
7623
+ type: "status",
7624
+ state: "checkpoint_required",
7625
+ retryable: false,
7626
+ ...error.cursor ? { cursor: error.cursor } : {},
7627
+ ...error.streamId ? { streamId: error.streamId } : {}
7628
+ });
7629
+ }
7630
+ throw error;
7631
+ }
7632
+ consecutiveFailures++;
7633
+ jsonl(ctx, parsed, {
7634
+ type: "status",
7635
+ state: "degraded",
7636
+ retryable: true,
7637
+ attempt: consecutiveFailures,
7638
+ ...cursor ? { cursor } : {},
7639
+ ...error.status ? { status: error.status } : {}
7640
+ });
7641
+ if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
7642
+ throw new WatchRemoteError(cursor, error);
7643
+ }
7644
+ if (deadline !== void 0 && now() >= deadline) {
7645
+ const result = report2(ctx, parsed, { found: false, cursor: cursor ?? "" });
7646
+ throw new WatchTimeoutError(result.cursor);
7647
+ }
7648
+ const base = Math.min(intervalMs, 1e3);
7649
+ const backoff = Math.min(MAX_BACKOFF_MS, base * 2 ** (consecutiveFailures - 1));
7650
+ await sleep(deadline === void 0 ? backoff : Math.min(backoff, Math.max(0, deadline - now())));
7651
+ continue;
7652
+ }
7653
+ cursor = page.cursor;
7654
+ const baseline = firstSuccess && page.events.length === 0;
7655
+ if (baseline) {
7656
+ jsonl(ctx, parsed, {
7657
+ type: "checkpoint",
7658
+ streamId: page.streamId,
7659
+ cursor,
7660
+ serverTime: page.serverTime
7661
+ });
7662
+ }
7663
+ firstSuccess = false;
7664
+ const matching = page.events.filter((event) => {
7665
+ if (topicId && (event.type !== "message" || event.action !== "created")) return false;
7666
+ return (!by || event.actor.id === by) && (!self || event.actor.id !== self);
7667
+ });
7668
+ for (const event of matching) {
7669
+ jsonl(ctx, parsed, {
7670
+ type: "event",
7671
+ streamId: page.streamId,
7672
+ eventId: event.id,
7673
+ cursor: event.cursor,
7674
+ event
7675
+ });
7676
+ }
7677
+ if (matching.length > 0) {
7678
+ jsonl(ctx, parsed, {
7679
+ type: "checkpoint",
7680
+ streamId: page.streamId,
7681
+ cursor,
7682
+ serverTime: page.serverTime
7683
+ });
7684
+ const posts = topicId ? matching.filter((event) => event.type === "message").map((event) => event.payload) : void 0;
7685
+ const topics = topicId ? void 0 : matching.filter((event) => event.type === "activity").map((event) => event.payload);
7686
+ return report2(ctx, parsed, {
7687
+ found: true,
7688
+ cursor,
7689
+ events: matching,
7690
+ ...posts && posts.length > 0 ? { posts } : {},
7691
+ ...topics && topics.length > 0 ? { topics } : {}
7692
+ });
7522
7693
  }
7523
- if (now() >= deadline) return report2(ctx, { found: false });
7524
- await sleep(Math.min(intervalMs, Math.max(0, deadline - now())));
7694
+ if (page.events.length > 0) {
7695
+ jsonl(ctx, parsed, {
7696
+ type: "checkpoint",
7697
+ streamId: page.streamId,
7698
+ cursor,
7699
+ serverTime: page.serverTime
7700
+ });
7701
+ } else if (!baseline) {
7702
+ jsonl(ctx, parsed, {
7703
+ type: "heartbeat",
7704
+ streamId: page.streamId,
7705
+ cursor,
7706
+ serverTime: page.serverTime
7707
+ });
7708
+ }
7709
+ if (page.hasMore) continue;
7710
+ if (deadline !== void 0 && now() >= deadline) {
7711
+ return report2(ctx, parsed, { found: false, cursor });
7712
+ }
7713
+ const wait2 = deadline === void 0 ? intervalMs : Math.min(intervalMs, Math.max(0, deadline - now()));
7714
+ await sleep(wait2);
7525
7715
  }
7526
7716
  }
7527
- function report2(ctx, result) {
7717
+ function report2(ctx, parsed, result) {
7528
7718
  if (ctx.json) {
7529
7719
  ctx.out.log(JSON.stringify(result, null, 2));
7530
- } else if (result.found) {
7720
+ } else if (parsed.options.jsonl !== true && result.found) {
7531
7721
  for (const post of result.posts ?? []) {
7532
7722
  const who = post.authorKind === "bot" ? `${post.authorId} (agent)` : post.authorId;
7533
7723
  ctx.out.log(`\u2014 ${who}
@@ -7539,13 +7729,6 @@ ${post.body}`);
7539
7729
  }
7540
7730
  return result;
7541
7731
  }
7542
- var WatchTimeoutError = class extends Error {
7543
- code = "watch_timeout";
7544
- constructor() {
7545
- super("nothing new before the timeout");
7546
- this.name = "WatchTimeoutError";
7547
- }
7548
- };
7549
7732
 
7550
7733
  // src/discuss-command.ts
7551
7734
  var ALLOWED = [
@@ -7567,6 +7750,8 @@ var ALLOWED = [
7567
7750
  "self",
7568
7751
  "interval",
7569
7752
  "timeout",
7753
+ "cursor",
7754
+ "jsonl",
7570
7755
  "mutation-id"
7571
7756
  ];
7572
7757
  function requireId(id, action) {
@@ -7603,7 +7788,7 @@ async function discussCommand(parsed, deps = {}) {
7603
7788
  case "topics":
7604
7789
  return discussList(ctx, parsed);
7605
7790
  case "read":
7606
- return discussRead(ctx, requireId(id, "read"));
7791
+ return discussRead(ctx, requireId(id, "read"), parsed);
7607
7792
  case "post":
7608
7793
  return discussPost(ctx, parsed);
7609
7794
  case "reply":
@@ -7614,7 +7799,7 @@ async function discussCommand(parsed, deps = {}) {
7614
7799
  return discussWho(ctx, parsed);
7615
7800
  case "watch": {
7616
7801
  const result = await discussWatch(ctx, id, parsed);
7617
- if (!result.found) throw new WatchTimeoutError();
7802
+ if (!result.found) throw new WatchTimeoutError(result.cursor);
7618
7803
  return;
7619
7804
  }
7620
7805
  default:
@@ -10378,7 +10563,12 @@ async function securityStatus(parsed, dependencies) {
10378
10563
  // src/cli.ts
10379
10564
  function exitCodeFor(err) {
10380
10565
  const code = err?.code;
10381
- return code === "handshake_pending" || code === "watch_timeout" ? 75 : 1;
10566
+ if (code === "handshake_pending" || code === "watch_timeout") return 75;
10567
+ if (code === "checkpoint_required") return 3;
10568
+ if (code === "remote_unavailable") return 6;
10569
+ if (code === "auth_failed") return 5;
10570
+ if (code === "invalid_cursor") return 2;
10571
+ return 1;
10382
10572
  }
10383
10573
  async function runCli(argv = process.argv.slice(2), dependencies = {}) {
10384
10574
  const runtime = {