@catalyst-cloud/sdk 0.12.3 → 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.
- package/README.md +23 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/ndjson.d.ts +18 -0
- package/dist/ndjson.d.ts.map +1 -0
- package/dist/ndjson.js +80 -0
- package/dist/ndjson.js.map +1 -0
- package/dist/replica/catalyst-replica.d.ts.map +1 -1
- package/dist/replica/catalyst-replica.js +1 -76
- package/dist/replica/catalyst-replica.js.map +1 -1
- package/dist/tenant-client.d.ts +233 -4
- package/dist/tenant-client.d.ts.map +1 -1
- package/dist/tenant-client.js +437 -7
- package/dist/tenant-client.js.map +1 -1
- package/package.json +4 -4
package/dist/tenant-client.js
CHANGED
|
@@ -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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
}
|