@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.
- 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 +254 -4
- package/dist/tenant-client.d.ts.map +1 -1
- package/dist/tenant-client.js +450 -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`. */
|
|
@@ -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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
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
|
}
|