|
6 | 6 | serializeContext, |
7 | 7 | parseContext, |
8 | 8 | OBS_CONTEXT_HEADER, |
| 9 | + type CapacitySnapshot, |
9 | 10 | type CloseReason, |
10 | 11 | type RequestContext, |
11 | 12 | } from "./observability"; |
@@ -53,6 +54,18 @@ export class BaseSession extends Container<Env> { |
53 | 54 | // embed, which is cosmetic — never a correctness concern. |
54 | 55 | protected obsContext?: RequestContext; |
55 | 56 |
|
| 57 | + private async fetchCapacitySnapshot(): Promise<CapacitySnapshot | undefined> { |
| 58 | + try { |
| 59 | + const queueDO = this.env.QUEUE_DO.get(this.env.QUEUE_DO.idFromName("global-queue")); |
| 60 | + const res = await queueDO.fetch(`http://do/internal/capacity?branch=${encodeURIComponent(this.branch)}`); |
| 61 | + if (!res.ok) return undefined; |
| 62 | + return await res.json<CapacitySnapshot>(); |
| 63 | + } catch (e) { |
| 64 | + console.error("[obs] fetchCapacitySnapshot failed:", e); |
| 65 | + return undefined; |
| 66 | + } |
| 67 | + } |
| 68 | + |
56 | 69 | constructor(ctx: DurableObjectState, env: Env) { |
57 | 70 | // Pass the container config to the super constructor. |
58 | 71 | super(ctx, env, { |
@@ -92,11 +105,15 @@ export class BaseSession extends Container<Env> { |
92 | 105 | this.sessionState = "PROVISIONING"; |
93 | 106 |
|
94 | 107 | this.obsContext = parseContext(request.headers.get(OBS_CONTEXT_HEADER)); |
95 | | - void notify.sessionStarted(this.env, { |
96 | | - sessionId, |
97 | | - branch: this.branch, |
98 | | - context: this.obsContext ?? { ip: "unknown" }, |
99 | | - }); |
| 108 | + void (async () => { |
| 109 | + const capacity = await this.fetchCapacitySnapshot(); |
| 110 | + await notify.sessionStarted(this.env, { |
| 111 | + sessionId, |
| 112 | + branch: this.branch, |
| 113 | + context: this.obsContext ?? { ip: "unknown" }, |
| 114 | + capacity, |
| 115 | + }); |
| 116 | + })(); |
100 | 117 |
|
101 | 118 | // Start the container, but don't wait for it to finish. |
102 | 119 | void this.startContainer(sessionId); |
@@ -384,17 +401,21 @@ export class BaseSession extends Container<Env> { |
384 | 401 |
|
385 | 402 | await this.notifyQueueManagerOfClosure(); |
386 | 403 |
|
387 | | - void notify.sessionEnded(this.env, { |
388 | | - sessionId: this.ctx.id.name ?? "unknown", |
389 | | - branch: this.branch, |
390 | | - reason, |
391 | | - startedAt: this.sessionMetadata?.startedAt, |
392 | | - endedAt, |
393 | | - scriptCount: this.scriptCount, |
394 | | - logLineCount: this.logLineCount, |
395 | | - extensionGranted: this.extensionGranted, |
396 | | - context: this.obsContext, |
397 | | - }); |
| 404 | + void (async () => { |
| 405 | + const capacity = await this.fetchCapacitySnapshot(); |
| 406 | + await notify.sessionEnded(this.env, { |
| 407 | + sessionId: this.ctx.id.name ?? "unknown", |
| 408 | + branch: this.branch, |
| 409 | + reason, |
| 410 | + startedAt: this.sessionMetadata?.startedAt, |
| 411 | + endedAt, |
| 412 | + scriptCount: this.scriptCount, |
| 413 | + logLineCount: this.logLineCount, |
| 414 | + extensionGranted: this.extensionGranted, |
| 415 | + context: this.obsContext, |
| 416 | + capacity, |
| 417 | + }); |
| 418 | + })(); |
398 | 419 | } |
399 | 420 |
|
400 | 421 | async notifyQueueManagerOfClosure() { |
@@ -576,9 +597,31 @@ export class QueueDO extends DurableObject<Env> { |
576 | 597 | return this.activeCountForType(type) < max && this.activeSessions.size < this.maxTotalSessions; |
577 | 598 | } |
578 | 599 |
|
| 600 | + private snapshotFor(branch: string): CapacitySnapshot { |
| 601 | + return { |
| 602 | + branch, |
| 603 | + branchUsed: this.activeCountForType(branch), |
| 604 | + branchMax: this.maxPerType[branch] ?? 2, |
| 605 | + totalUsed: this.activeSessions.size, |
| 606 | + totalMax: this.maxTotalSessions, |
| 607 | + queueDepth: this.waitingQueue.length, |
| 608 | + }; |
| 609 | + } |
| 610 | + |
579 | 611 | async fetch(request: Request): Promise<Response> { |
580 | 612 | const url = new URL(request.url); |
581 | 613 |
|
| 614 | + // Internal-only: the worker entrypoint routes /api/* and /ws/* to DOs, |
| 615 | + // so /internal/* paths are unreachable externally (404 at the worker) |
| 616 | + // but work over DO stub-to-stub calls, which bypass the entrypoint. |
| 617 | + if (url.pathname === "/internal/capacity") { |
| 618 | + const branch = url.searchParams.get("branch"); |
| 619 | + if (!branch) return new Response("Missing branch", { status: 400 }); |
| 620 | + return new Response(JSON.stringify(this.snapshotFor(branch)), { |
| 621 | + headers: { "Content-Type": "application/json" }, |
| 622 | + }); |
| 623 | + } |
| 624 | + |
582 | 625 | if (url.pathname === "/api/request-session") { |
583 | 626 | const sessionType = url.searchParams.get("type") || "public"; |
584 | 627 | const rawIP = request.headers.get("CF-Connecting-IP") || "unknown"; |
@@ -613,6 +656,16 @@ export class QueueDO extends DurableObject<Env> { |
613 | 656 | }}); |
614 | 657 | const position = this.waitingQueue.filter(w => w.sessionType === sessionType).length; |
615 | 658 |
|
| 659 | + const obsContext = parseContext(request.headers.get(OBS_CONTEXT_HEADER)); |
| 660 | + if (obsContext) { |
| 661 | + void notify.queueEntered(this.env, { |
| 662 | + sessionType, |
| 663 | + position, |
| 664 | + capacity: this.snapshotFor(sessionType), |
| 665 | + context: obsContext, |
| 666 | + }); |
| 667 | + } |
| 668 | + |
616 | 669 | return new Response(JSON.stringify({ |
617 | 670 | status: "QUEUED", |
618 | 671 | ticketId, |
|
0 commit comments