@catalyst-cloud/sdk 0.12.3 → 0.13.1

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.
@@ -1,4 +1,6 @@
1
1
  import { parseLinearIdentityView } from "./linear-identity.js";
2
+ import { AuthError } from "./live-sync-client.js";
3
+ import { iterateNdjson } from "./ndjson.js";
2
4
  import { CONTRACT_ROUTE, readTenantContract, routeByName, } from "./tenant-contract.js";
3
5
  // ── Options ─────────────────────────────────────────────────────────────────────────────────────
4
6
  /** Default per-request deadline — the bundle's `REQUEST_TIMEOUT_MS`. */
@@ -166,6 +168,14 @@ function teamList(value) {
166
168
  typeof value["liveTeamRead"]["attempted"] === "boolean" &&
167
169
  (value["liveTeamRead"]["error"] === null || typeof value["liveTeamRead"]["error"] === "string");
168
170
  }
171
+ function projectWipLimit(value) {
172
+ return isRecord(value) && isRecord(value["team"]) &&
173
+ typeof value["team"]["id"] === "string" && typeof value["team"]["key"] === "string" &&
174
+ typeof value["limit"] === "number" &&
175
+ (value["source"] === "project" || value["source"] === "flag" || value["source"] === "default") &&
176
+ (value["stored"] === null || typeof value["stored"] === "number") &&
177
+ typeof value["inProgress"] === "number" && typeof value["countedAt"] === "number";
178
+ }
169
179
  function teamView(value) {
170
180
  if (!isRecord(value) || !isRecord(value["config"]))
171
181
  return false;
@@ -238,6 +248,7 @@ export function createTenantClient(opts) {
238
248
  const store = opts.contractCache ?? memoryContractCache();
239
249
  const now = opts.now ?? Date.now;
240
250
  const origin = normalizeBaseUrl(opts.baseUrl);
251
+ const auth = opts.auth ?? { kind: "token", token: opts.key };
241
252
  function url(path, query) {
242
253
  const u = new URL(`${origin}${path}`);
243
254
  for (const [k, v] of Object.entries(query ?? {})) {
@@ -246,12 +257,32 @@ export function createTenantClient(opts) {
246
257
  }
247
258
  return u.toString();
248
259
  }
260
+ /** Async because a `bearer` token rotates (~15 min) and must be resolved FRESH per request — the
261
+ * same rule `CatalystReplica.feedHeaders()` follows (src/replica/catalyst-replica.ts:1416). */
262
+ async function authHeaders() {
263
+ if (auth.kind === "token")
264
+ return { authorization: `Bearer ${auth.token}` };
265
+ if (auth.kind === "cookie")
266
+ return {};
267
+ let token;
268
+ try {
269
+ token = await auth.getToken();
270
+ }
271
+ catch (err) {
272
+ throw new AuthError(401, `bearer getToken() rejected: ${err instanceof Error ? err.message : String(err)}`);
273
+ }
274
+ return { authorization: `Bearer ${token}` };
275
+ }
249
276
  async function send(method, target, extraHeaders, body) {
250
- const headers = {
251
- authorization: `Bearer ${opts.key}`,
252
- accept: "application/json",
253
- ...extraHeaders,
254
- };
277
+ let resolved;
278
+ try {
279
+ resolved = await authHeaders();
280
+ }
281
+ catch (err) {
282
+ const reason = err instanceof AuthError ? err.message : err instanceof Error ? err.message : String(err);
283
+ return { ok: false, failure: { outcome: "unauthorized", status: 401, reason, ref: null } };
284
+ }
285
+ const headers = { ...resolved, accept: "application/json", ...extraHeaders };
255
286
  if (body !== undefined)
256
287
  headers["content-type"] = "application/json";
257
288
  let res;
@@ -262,6 +293,7 @@ export function createTenantClient(opts) {
262
293
  headers,
263
294
  ...(body === undefined ? {} : { body: JSON.stringify(body) }),
264
295
  signal: AbortSignal.timeout(timeoutMs),
296
+ ...(auth.kind === "cookie" ? { credentials: "include" } : {}),
265
297
  });
266
298
  // ⛔ THE BODY READ IS INSIDE THE TRY (Codex #65 r1, P2). `fetch` resolves once the HEADERS
267
299
  // arrive; the stream can still fail, or the deadline fire, while `text()` consumes it — and
@@ -284,6 +316,108 @@ export function createTenantClient(opts) {
284
316
  }
285
317
  return { ok: true, answer: { status: res.status, headers: res.headers, json, textHead: text.slice(0, 200) } };
286
318
  }
319
+ /**
320
+ * An IDLE deadline for the streaming path, NOT the wall-clock one `AbortSignal.timeout()` gives.
321
+ * ⛔ `AbortSignal.timeout(timeoutMs)` keeps governing the response BODY once the headers have
322
+ * arrived, so it truncated any `/changes` replay that took longer than `timeoutMs` to drain and
323
+ * lost every row already read (CTC-2132 validate attempt 1, code-review Finding 2) — which defeats
324
+ * the whole point of `changes.stream`. The timer is rearmed on every chunk (`iterateNdjson`'s
325
+ * `onProgress` refund, the same one the replica's snapshot seed uses), so a feed that keeps
326
+ * delivering never expires while a feed that STALLS for `timeoutMs` still does. `release()` clears
327
+ * it when the stream ends, normally or not.
328
+ */
329
+ function idleDeadline() {
330
+ const controller = new AbortController();
331
+ let timer;
332
+ const release = () => {
333
+ if (timer !== undefined)
334
+ clearTimeout(timer);
335
+ timer = undefined;
336
+ };
337
+ const rearm = () => {
338
+ if (controller.signal.aborted)
339
+ return;
340
+ release();
341
+ timer = setTimeout(() => {
342
+ timer = undefined;
343
+ controller.abort(new Error(`The operation timed out after ${timeoutMs}ms without progress.`));
344
+ }, timeoutMs);
345
+ };
346
+ rearm();
347
+ return { signal: controller.signal, rearm, release };
348
+ }
349
+ /** A raw-response sibling of `send()`: resolves auth exactly as `send()` does but hands back the
350
+ * `Response` UNREAD, so an NDJSON body can be streamed instead of buffered through `text()`. The
351
+ * returned `deadline` is the caller's to drive — see {@link idleDeadline}.
352
+ *
353
+ * ⛔ `signal` IS THE CALLER'S OWN, AND IT REACHES THE FETCH (CTC-2132 validate attempt 14,
354
+ * code-review Finding 4). Threading it into `iterateNdjson` alone bounds only the BODY loop, so an
355
+ * abort raised before the response HEADERS arrived left the request in flight and uncancelled —
356
+ * invisible to a suite that only ever aborts mid-body. It is composed with the idle deadline's
357
+ * signal here, so EITHER cancels the request at any point in its life. */
358
+ async function sendRaw(method, target, extraHeaders, body, signal) {
359
+ let resolved;
360
+ try {
361
+ resolved = await authHeaders();
362
+ }
363
+ catch (err) {
364
+ const reason = err instanceof Error ? err.message : String(err);
365
+ return { ok: false, failure: { outcome: "unauthorized", status: 401, reason, ref: null } };
366
+ }
367
+ const headers = { ...resolved, ...extraHeaders };
368
+ if (body !== undefined)
369
+ headers["content-type"] = "application/json";
370
+ const deadline = idleDeadline();
371
+ const composed = signal === undefined ? deadline.signal : AbortSignal.any([deadline.signal, signal]);
372
+ try {
373
+ const res = await fetchImpl(target, {
374
+ method,
375
+ headers,
376
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
377
+ signal: composed,
378
+ ...(auth.kind === "cookie" ? { credentials: "include" } : {}),
379
+ });
380
+ deadline.rearm(); // the headers arrived: the deadline now bounds IDLE time on the body, not the whole read
381
+ return { ok: true, res, deadline };
382
+ }
383
+ catch (err) {
384
+ deadline.release();
385
+ const reason = err instanceof Error ? err.message : String(err);
386
+ return { ok: false, failure: { outcome: "network", reason: `could not reach ${target}: ${reason}` } };
387
+ }
388
+ }
389
+ /**
390
+ * Read a `Response` `sendRaw` handed back into the same `Answer` shape `send()` produces — used for
391
+ * the refusal path of an NDJSON route, whose refusal body IS JSON.
392
+ *
393
+ * ⛔ THE BODY READ IS INSIDE THE TRY, for exactly the reason `send()`'s is (CTC-2132 validate
394
+ * attempt 14, code-review Finding 1). `fetch` resolved when the HEADERS arrived; this stream can
395
+ * still fault — or the idle deadline fire — while `text()` drains it, and that is a transport
396
+ * failure to FOLD, never an exception the public call may leak. It leaked: `changes.list()` and
397
+ * `snapshot.head()` are both documented as never-throwing, and a refusal whose socket reset
398
+ * mid-body rejected straight out of them. Returns `Sent` so every caller folds this read exactly
399
+ * as it already folds `send()`'s.
400
+ */
401
+ async function answerOf(res, target) {
402
+ let text;
403
+ try {
404
+ text = await res.text();
405
+ }
406
+ catch (err) {
407
+ const reason = err instanceof Error ? err.message : String(err);
408
+ return { ok: false, failure: { outcome: "network", reason: `could not read ${target}: ${reason}` } };
409
+ }
410
+ let json;
411
+ if (text !== "") {
412
+ try {
413
+ json = JSON.parse(text);
414
+ }
415
+ catch {
416
+ json = undefined;
417
+ }
418
+ }
419
+ return { ok: true, answer: { status: res.status, headers: res.headers, json, textHead: text.slice(0, 200) } };
420
+ }
287
421
  // ── contract() ────────────────────────────────────────────────────────────────────────────────
288
422
  async function contract(o = {}) {
289
423
  const cached = await store.get();
@@ -321,8 +455,14 @@ export function createTenantClient(opts) {
321
455
  return fromCache(cached);
322
456
  };
323
457
  const sent = await send("GET", url(CONTRACT_ROUTE), cached?.etag ? { "if-none-match": cached.etag } : {});
458
+ // CTC-2132 widened `Sent`'s failure arm from `network` to `network | unauthorized` so a bearer
459
+ // `getToken()` rejection can be returned as a typed arm — which made this line funnel a CREDENTIAL
460
+ // REFUSAL through the stale-cache tolerance above, so a client whose OAuth refresh had failed was
461
+ // told `{outcome:"ok",source:"cache"}` (validate attempt 1, code-review Finding 3). A refusal is
462
+ // not transient; it is returned as itself, exactly as the comment above `unavailable` states and
463
+ // as the 401/403 answered by the SERVER already is (they fall through to `classify` below).
324
464
  if (!sent.ok)
325
- return unavailable(sent.failure);
465
+ return sent.failure.outcome === "unauthorized" ? sent.failure : unavailable(sent.failure);
326
466
  const { answer } = sent;
327
467
  if (answer.status === 429 || answer.status >= 500)
328
468
  return unavailable(classify(answer));
@@ -482,6 +622,290 @@ export function createTenantClient(opts) {
482
622
  }
483
623
  return { outcome: "ok", account, slug, name, permissions, principal };
484
624
  }
625
+ async function issuesExecution(identifier) {
626
+ const sent = await send("GET", url(`/api/v1/issues/${encodeURIComponent(identifier)}/execution`), {});
627
+ if (!sent.ok)
628
+ return sent.failure;
629
+ const { answer } = sent;
630
+ if (answer.status === 404)
631
+ return { outcome: "not-found", status: 404 };
632
+ if (answer.status !== 200)
633
+ return classify(answer);
634
+ if (!isRecord(answer.json)) {
635
+ return { outcome: "shape", status: 200, reason: "GET /api/v1/issues/:id/execution did not answer an object" };
636
+ }
637
+ return { outcome: "ok", status: 200, report: answer.json };
638
+ }
639
+ // ── The diagnosis / telemetry reads ───────────────────────────────────────────────────────────
640
+ /** A literal-path open read whose body is one object. The shape check is the CONTAINER only
641
+ * (Decision 4, CTC-2132) — `check` never inspects a field the cloud might rename. */
642
+ async function objectRead(path, query, label, check = () => true) {
643
+ const sent = await send("GET", url(path, query), {});
644
+ if (!sent.ok)
645
+ return sent.failure;
646
+ const { answer } = sent;
647
+ if (answer.status !== 200)
648
+ return classify(answer);
649
+ if (!isRecord(answer.json) || !check(answer.json)) {
650
+ return { outcome: "shape", status: 200, reason: `${label} did not answer the documented shape` };
651
+ }
652
+ return { outcome: "ok", status: 200, body: answer.json };
653
+ }
654
+ /** A literal-path open read whose body is a bare array. */
655
+ async function arrayRead(path, query, label) {
656
+ const sent = await send("GET", url(path, query), {});
657
+ if (!sent.ok)
658
+ return sent.failure;
659
+ const { answer } = sent;
660
+ if (answer.status !== 200)
661
+ return classify(answer);
662
+ const rows = Array.isArray(answer.json) ? rowsOf(answer.json) : null;
663
+ if (rows === null)
664
+ return { outcome: "shape", status: 200, reason: `${label} did not answer an array` };
665
+ return { outcome: "ok", status: 200, rows };
666
+ }
667
+ async function workEligibility(params = {}) {
668
+ const r = await objectRead("/api/v1/work-eligibility", { team: params.team }, "GET /api/v1/work-eligibility");
669
+ return r.outcome === "ok" ? { outcome: "ok", status: 200, report: r.body } : r;
670
+ }
671
+ async function dispatchQueue(params = {}) {
672
+ const r = await objectRead("/api/v1/dispatch-queue/current", { team: params.team }, "GET /api/v1/dispatch-queue/current", (body) => Array.isArray(body["entries"]));
673
+ return r.outcome === "ok" ? { outcome: "ok", status: 200, queue: r.body } : r;
674
+ }
675
+ async function fleetActivity(params = {}) {
676
+ const r = await arrayRead("/api/v1/fleet-activity/current", { account: params.account }, "GET /api/v1/fleet-activity/current");
677
+ return r.outcome === "ok" ? { outcome: "ok", status: 200, rows: r.rows } : r;
678
+ }
679
+ async function agentRoster() {
680
+ const r = await arrayRead("/api/v1/agent-roster/current", {}, "GET /api/v1/agent-roster/current");
681
+ return r.outcome === "ok" ? { outcome: "ok", status: 200, rows: r.rows } : r;
682
+ }
683
+ async function leaseAttributions(params) {
684
+ const sent = await send("GET", url("/api/v1/lease/attributions", { ticket: params.ticket, phase: params.phase }), {});
685
+ if (!sent.ok)
686
+ return sent.failure;
687
+ const { answer } = sent;
688
+ if (answer.status !== 200)
689
+ return classify(answer);
690
+ if (answer.json === undefined) {
691
+ return { outcome: "shape", status: 200, reason: "GET /api/v1/lease/attributions did not answer JSON" };
692
+ }
693
+ return { outcome: "ok", status: 200, body: answer.json };
694
+ }
695
+ async function codingAccounts() {
696
+ const r = await objectRead("/api/v1/coding-accounts", {}, "GET /api/v1/coding-accounts", (body) => Array.isArray(body["accounts"]));
697
+ return r.outcome === "ok" ? { outcome: "ok", status: 200, report: r.body } : r;
698
+ }
699
+ // ── The query reads ────────────────────────────────────────────────────────────────────────────
700
+ async function cyclesList() {
701
+ const r = await arrayRead("/api/v1/cycles", {}, "GET /api/v1/cycles");
702
+ return r.outcome === "ok" ? { outcome: "ok", status: 200, rows: r.rows } : r;
703
+ }
704
+ async function search(params) {
705
+ const r = await objectRead("/api/v1/search", { q: params.q, limit: params.limit }, "GET /api/v1/search");
706
+ return r.outcome === "ok" ? { outcome: "ok", status: 200, results: r.body } : r;
707
+ }
708
+ /** Tolerant (Decision 7): accepts `{stages, source?}` OR a bare array, normalizing both — mirroring
709
+ * the bundle's own `fetchWorkflowStates`, which already defends against three shapes. */
710
+ async function workflowStages() {
711
+ const sent = await send("GET", url("/api/v1/workflow-stages"), {});
712
+ if (!sent.ok)
713
+ return sent.failure;
714
+ const { answer } = sent;
715
+ if (answer.status !== 200)
716
+ return classify(answer);
717
+ if (Array.isArray(answer.json)) {
718
+ const rows = rowsOf(answer.json);
719
+ if (rows !== null)
720
+ return { outcome: "ok", status: 200, stages: rows, source: null };
721
+ }
722
+ if (isRecord(answer.json) && Array.isArray(answer.json["stages"])) {
723
+ const rows = rowsOf(answer.json["stages"]);
724
+ if (rows !== null) {
725
+ return { outcome: "ok", status: 200, stages: rows, source: stringField(answer.json, "source") };
726
+ }
727
+ }
728
+ return { outcome: "shape", status: 200, reason: "GET /api/v1/workflow-stages did not answer a recognised shape" };
729
+ }
730
+ /** ⛔ The returned generator MAY THROW mid-iteration — a transport fault after the promise already
731
+ * resolved cannot be folded into an already-resolved union. Use {@link changesList} for the
732
+ * nothing-throws, buffered alternative. */
733
+ async function changesStream(params) {
734
+ const target = url("/api/v1/changes", { since: params.since });
735
+ const sent = await sendRaw("GET", target, { accept: "application/x-ndjson" }, undefined, params.signal);
736
+ if (!sent.ok)
737
+ return sent.failure;
738
+ const { res, deadline } = sent;
739
+ if (res.status === 409) {
740
+ // ⛔ RELEASE THE DEADLINE *AFTER* THE REFUSAL BODY, NEVER BEFORE (CTC-2132 validate attempt 14,
741
+ // code-review Finding 2). The deadline's signal is the ONLY one bounding this read — it is the
742
+ // signal `sendRaw` handed the fetch — so releasing first left a tenant that sends 409 HEADERS
743
+ // and then stalls the body wedged with no timeout at all. `send()` does not behave this way:
744
+ // its own `AbortSignal.timeout` keeps governing its body read.
745
+ const head = integerHeader(res.headers, HEAD_SEQ_HEADER);
746
+ const read = await answerOf(res, target).finally(() => deadline.release());
747
+ if (!read.ok)
748
+ return read.failure;
749
+ return { outcome: "resync", status: 409, head, reason: reasonOf(read.answer) };
750
+ }
751
+ if (res.status !== 200) {
752
+ const read = await answerOf(res, target).finally(() => deadline.release());
753
+ return read.ok ? classify(read.answer) : read.failure;
754
+ }
755
+ const head = integerHeader(res.headers, HEAD_SEQ_HEADER);
756
+ // ⛔ ARM THE IDLE DEADLINE LAZILY, AND HAND BACK A `close()` (CTC-2132 validate attempt 14,
757
+ // code-review Finding 3). `deadline.release()` lives in `rows()`'s `finally`, which NEVER RUNS for
758
+ // a caller that awaits this promise, reads `head` and then discards the generator — the shape
759
+ // `test/tenant-client-changes.test.ts`'s own `since:'head'` case has. That left an armed timer
760
+ // holding the event loop open for `timeoutMs` and then firing an abort on a controller nobody was
761
+ // listening to, with `res.body` never cancelled and the connection still referenced. So: nothing
762
+ // is armed while the stream is UNSTARTED (`rows()` arms on entry and `onProgress` refunds per
763
+ // chunk thereafter), and a caller that will not drain the feed releases the body through `close()`.
764
+ deadline.release();
765
+ let closed = false;
766
+ const close = async () => {
767
+ if (closed)
768
+ return;
769
+ closed = true;
770
+ deadline.release();
771
+ // Locked once `rows()` holds a reader — `iterateNdjson`'s own `finally` cancels that one.
772
+ await res.body?.cancel().catch(() => { });
773
+ };
774
+ async function* rows() {
775
+ deadline.rearm(); // the idle clock starts at the first READ, not when the headers arrived
776
+ try {
777
+ // `onProgress` refunds the idle deadline per chunk, so a long replay is never truncated at
778
+ // `timeoutMs` (attempt 1, code-review Finding 2); `finally` releases it on every exit — EOF,
779
+ // caller abort, a `break` out of the for-await, or a throw from the parse below.
780
+ for await (const line of iterateNdjson(res, {
781
+ ...(params.signal === undefined ? {} : { signal: params.signal }),
782
+ onProgress: deadline.rearm,
783
+ })) {
784
+ let parsed;
785
+ try {
786
+ parsed = JSON.parse(line);
787
+ }
788
+ catch {
789
+ throw new Error(`/api/v1/changes emitted a line that is not JSON: ${line.slice(0, 120)}`);
790
+ }
791
+ if (!isRecord(parsed))
792
+ throw new Error("/api/v1/changes emitted a line that is not an object");
793
+ // ⛔ PAUSE THE IDLE CLOCK WHILE SUSPENDED AT `yield` (CTC-2132 validate attempt 30). It bounds
794
+ // time spent awaiting the NETWORK; the caller's per-row work between pulls is not a stall,
795
+ // so a slow consumer of one chunk's rows no longer aborts a feed that never went quiet.
796
+ deadline.release();
797
+ yield parsed;
798
+ deadline.rearm();
799
+ }
800
+ }
801
+ finally {
802
+ deadline.release();
803
+ }
804
+ }
805
+ return { outcome: "ok", status: 200, head, rows: rows(), close };
806
+ }
807
+ async function changesListFn(params) {
808
+ const sent = await changesStream(params);
809
+ if (sent.outcome !== "ok")
810
+ return sent;
811
+ try {
812
+ const rows = [];
813
+ for await (const row of sent.rows)
814
+ rows.push(row);
815
+ return { outcome: "ok", status: 200, head: sent.head, rows };
816
+ }
817
+ catch (err) {
818
+ const reason = err instanceof Error ? err.message : String(err);
819
+ return { outcome: "network", reason: `/api/v1/changes stream faulted mid-body: ${reason}` };
820
+ }
821
+ finally {
822
+ // `rows()`'s own `finally` already ran on every path above; `close()` is idempotent and makes
823
+ // the buffered caller provably leak-free without it having to know that (Finding 3).
824
+ await sent.close();
825
+ }
826
+ }
827
+ /** The cheap probe (Decision 7) — NEVER falls back to a full snapshot fetch. Reads the head from
828
+ * `X-Mirror-Cursor` if present, else a JSON body's `head`/`seq`/`cursor`, else the first NDJSON
829
+ * line parsed as JSON. */
830
+ async function snapshotHead() {
831
+ const target = url("/api/v1/snapshot", { head: 1 });
832
+ const sent = await sendRaw("GET", target, { accept: "application/x-ndjson, application/json" });
833
+ if (!sent.ok)
834
+ return sent.failure;
835
+ const { res, deadline } = sent;
836
+ try {
837
+ // ⛔ STATUS FIRST, THEN THE HEADER. `X-Mirror-Cursor` rides REFUSALS too — `changesStream`
838
+ // above reads the head straight off a 409 — so reading it before the status gate reported a
839
+ // 403 as `{outcome:"ok",status:200,head:…}` and told a liveness probe the mirror was healthy
840
+ // while the client was de-authorized (CTC-2132 validate attempt 1, code-review Finding 1).
841
+ // Past this gate `res.status` IS 200, so the literal below is the status, not an assumption.
842
+ if (res.status !== 200) {
843
+ const read = await answerOf(res, target);
844
+ return read.ok ? classify(read.answer) : read.failure;
845
+ }
846
+ const headerHead = integerHeader(res.headers, HEAD_SEQ_HEADER);
847
+ if (headerHead !== null) {
848
+ await res.body?.cancel().catch(() => { });
849
+ return { outcome: "ok", status: 200, head: headerHead, source: "header" };
850
+ }
851
+ const contentType = res.headers.get("content-type") ?? "";
852
+ if (contentType.includes("application/json")) {
853
+ const read = await answerOf(res, target);
854
+ if (!read.ok)
855
+ return read.failure;
856
+ const answer = read.answer;
857
+ const fromBody = isRecord(answer.json)
858
+ ? (numberField(answer.json, "head") ?? numberField(answer.json, "seq") ?? numberField(answer.json, "cursor"))
859
+ : null;
860
+ if (fromBody !== null)
861
+ return { outcome: "ok", status: 200, head: fromBody, source: "body" };
862
+ return { outcome: "shape", status: 200, reason: "GET /api/v1/snapshot?head=1 did not answer a finite head" };
863
+ }
864
+ for await (const line of iterateNdjson(res, { onProgress: deadline.rearm })) {
865
+ let parsed;
866
+ try {
867
+ parsed = JSON.parse(line);
868
+ }
869
+ catch {
870
+ continue;
871
+ }
872
+ if (isRecord(parsed)) {
873
+ const fromLine = numberField(parsed, "head") ?? numberField(parsed, "seq") ?? numberField(parsed, "cursor");
874
+ if (fromLine !== null)
875
+ return { outcome: "ok", status: 200, head: fromLine, source: "body" };
876
+ }
877
+ break;
878
+ }
879
+ return { outcome: "shape", status: 200, reason: "GET /api/v1/snapshot?head=1 did not answer a finite head" };
880
+ }
881
+ catch (err) {
882
+ // The SAME never-throws contract Finding 1 is about, on this function's OTHER body read: the
883
+ // NDJSON line scan above streams `res.body`, which can fault (or the deadline fire) exactly as
884
+ // `answerOf`'s `text()` can. `SnapshotHeadResult` has no throw arm — fold it as `send()` would.
885
+ const reason = err instanceof Error ? err.message : String(err);
886
+ return { outcome: "network", reason: `could not read ${target}: ${reason}` };
887
+ }
888
+ finally {
889
+ deadline.release();
890
+ }
891
+ }
892
+ // ── The generic escape hatch ───────────────────────────────────────────────────────────────────
893
+ async function request(req) {
894
+ if (!req.path.startsWith("/") || req.path.startsWith("//")) {
895
+ return {
896
+ outcome: "rejected",
897
+ status: 0,
898
+ reason: `request(): path must be an absolute path under the tenant origin, got ${req.path}`,
899
+ };
900
+ }
901
+ const sent = await send(req.method ?? "GET", url(req.path, req.query), req.headers ?? {}, req.body);
902
+ if (!sent.ok)
903
+ return sent.failure;
904
+ const { answer } = sent;
905
+ if (answer.status < 200 || answer.status >= 300)
906
+ return classify(answer);
907
+ return { outcome: "ok", status: answer.status, json: answer.json, headers: answer.headers };
908
+ }
485
909
  async function linearIdentityCall(linearUserId) {
486
910
  const sent = await send(linearUserId === undefined ? "GET" : "POST", url("/me/linear-identity"), {}, linearUserId === undefined ? undefined : { linearUserId });
487
911
  if (!sent.ok)
@@ -783,12 +1207,31 @@ export function createTenantClient(opts) {
783
1207
  return {
784
1208
  contract,
785
1209
  me,
1210
+ request,
786
1211
  linearIdentity: { get: () => linearIdentityCall(), set: (linearUserId) => linearIdentityCall(linearUserId) },
787
1212
  personalConnections: { start: personalConnectionStart, status: personalConnectionStatus },
788
1213
  teamWorkflow,
789
- issues: { list: issuesList, get: issuesGet },
1214
+ getProjectWipLimit: (team) => teamCall("GET", `/api/v1/agent/team-wip-limit?team=${encodeURIComponent(team)}`, undefined, projectWipLimit),
1215
+ // A non-finite number would serialize as `null`, which the cloud reads as "back to the default".
1216
+ setProjectWipLimit: (team, limit) => limit === null || Number.isFinite(limit)
1217
+ ? teamCall("POST", "/api/v1/agent/team-wip-limit", { team, limit }, projectWipLimit)
1218
+ : Promise.resolve({ outcome: "rejected", status: 0, error: "invalid-limit", reason: "limit must be a finite number, or null for the default" }),
1219
+ issues: { list: issuesList, get: issuesGet, execution: issuesExecution },
790
1220
  pulls: { list: pullsList, get: pullsGet },
791
1221
  projects: { list: projectsList },
1222
+ cycles: { list: cyclesList },
1223
+ search,
1224
+ workflowStages,
1225
+ changes: { stream: changesStream, list: changesListFn },
1226
+ snapshot: { head: snapshotHead },
1227
+ diagnostics: {
1228
+ workEligibility,
1229
+ dispatchQueue,
1230
+ fleetActivity,
1231
+ agentRoster,
1232
+ leaseAttributions,
1233
+ codingAccounts,
1234
+ },
792
1235
  agent,
793
1236
  };
794
1237
  }