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