surf-cli 2.14.0 → 2.15.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 +62 -33
- package/agents/gpt-pro.md +19 -0
- package/native/browser-scheduler.cjs +348 -0
- package/native/browser-session-store.cjs +271 -0
- package/native/cli.cjs +331 -60
- package/native/do-executor.cjs +5 -0
- package/native/host-helpers.cjs +19 -3
- package/native/host-sessions.cjs +8 -1
- package/native/host.cjs +766 -19
- package/native/playbook-cli.cjs +16 -3
- package/native/surf-error.cjs +47 -0
- package/native/tool-scope.cjs +107 -0
- package/native/workflow-definition.cjs +7 -0
- package/package.json +8 -2
- package/pi-extension/surf.ts +18 -4
- package/skills/surf/SKILL.md +48 -21
package/README.md
CHANGED
|
@@ -142,7 +142,7 @@ surf remote revoke agent-macbook
|
|
|
142
142
|
surf remote list
|
|
143
143
|
```
|
|
144
144
|
|
|
145
|
-
`--remote <host>:<port>` takes precedence over `SURF_REMOTE`; `--remote-credential` takes precedence over `SURF_REMOTE_CREDENTIAL`. A selected remote endpoint overrides `SURF_SOCKET` and the default local socket. Local and remote requests share
|
|
145
|
+
`--remote <host>:<port>` takes precedence over `SURF_REMOTE`; `--remote-credential` takes precedence over `SURF_REMOTE_CREDENTIAL`. A selected remote endpoint overrides `SURF_SOCKET` and the default local socket. Local and remote requests share the same host scheduler: each tab has a FIFO lane, different tabs may execute concurrently, and browser-wide writers are exclusive. Disconnects and timeouts abort queued or in-flight work and retain admission until request-owned cleanup drains or the hard deadline is reached. Browser side effects that already completed are not rolled back.
|
|
146
146
|
|
|
147
147
|
`surf install --listen` persists the explicit Tailnet address in the native-host wrapper. Re-run `surf install` without `--listen` to remove it. The address must be a Tailscale IPv4 or IPv6 address with a port; Surf does not bind every interface. Remote listeners currently require a POSIX browser host and are not supported by Windows native-host wrappers.
|
|
148
148
|
|
|
@@ -348,44 +348,65 @@ surf tab.switch "dashboard" # Switch by name
|
|
|
348
348
|
surf tab.group --name "Work" --color blue
|
|
349
349
|
```
|
|
350
350
|
|
|
351
|
-
###
|
|
351
|
+
### Browser Sessions and Concurrent Agents
|
|
352
352
|
|
|
353
|
-
|
|
353
|
+
Give every independent agent a durable Surf session before its first browser command. `session.ensure` is idempotent: it creates a missing session, reuses a live one, and reopens a stale or closed binding.
|
|
354
354
|
|
|
355
355
|
```bash
|
|
356
|
-
#
|
|
357
|
-
|
|
358
|
-
|
|
356
|
+
# First command rule for every independent agent shell
|
|
357
|
+
export SURF_SESSION="$(basename "$PWD" | sed 's/[^A-Za-z0-9._-]/-/g')"
|
|
358
|
+
surf session.ensure "$SURF_SESSION" about:blank
|
|
359
359
|
|
|
360
|
-
#
|
|
361
|
-
surf
|
|
362
|
-
surf read
|
|
363
|
-
surf
|
|
360
|
+
# All later tab-scoped commands use that session automatically
|
|
361
|
+
surf go "https://example.com"
|
|
362
|
+
surf read
|
|
363
|
+
surf click e5
|
|
364
|
+
```
|
|
364
365
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
366
|
+
Use a distinct worktree/directory name per agent. When several agents share one directory, append a stable agent identifier instead of reusing the same `SURF_SESSION` value.
|
|
367
|
+
|
|
368
|
+
A session owns one explicit Chrome tab. New sessions use a separate **unfocused normal window** by default, so Chrome focus changes cannot retarget another agent's commands.
|
|
369
|
+
|
|
370
|
+
```bash
|
|
371
|
+
surf session.new research "https://example.com" # separate unfocused window
|
|
372
|
+
surf session.ensure research about:blank # safe to run repeatedly
|
|
373
|
+
surf session.new scout about:blank --tab # inactive tab instead
|
|
374
|
+
|
|
375
|
+
surf --session research read # explicit selector
|
|
376
|
+
SURF_SESSION=research surf screenshot # environment selector
|
|
368
377
|
|
|
369
|
-
#
|
|
370
|
-
surf
|
|
371
|
-
surf
|
|
372
|
-
surf
|
|
373
|
-
surf
|
|
378
|
+
surf session.list --refresh # all bindings + queue state
|
|
379
|
+
surf session.info research --refresh # target and scheduler details
|
|
380
|
+
surf session.close research # closes Surf-created target
|
|
381
|
+
surf session.rebind research --tab-id 789 # adopt an existing tab
|
|
382
|
+
surf session.reopen research # recreate from last URL
|
|
374
383
|
```
|
|
375
384
|
|
|
376
|
-
|
|
385
|
+
Commands for the same session tab run FIFO. Commands for different session tabs can overlap. Browser-wide mutations—such as creating, moving, closing, or focusing tabs/windows and writing cookies—wait for active tab lanes to drain. Add `--no-wait` to return `tab_busy` or `browser_busy` immediately instead of queueing.
|
|
377
386
|
|
|
378
|
-
|
|
387
|
+
Recovery errors print an exact command that can be copied directly:
|
|
379
388
|
|
|
380
|
-
|
|
389
|
+
```text
|
|
390
|
+
Error: The tab for session research is gone.
|
|
391
|
+
Recovery: surf session.reopen research
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
`session.info` distinguishes work queued on the session's own tab, activity on other tabs, and an active or waiting browser-wide writer. Browser-login provider commands such as `surf chatgpt`, `surf gemini`, and `surf oracle ask` print a warning before taking exclusive browser access, so a queued provider flow is not mistaken for a hung command.
|
|
395
|
+
|
|
396
|
+
Sessions share the same Chrome profile. Cookies, authentication, same-origin storage, downloads, history, bookmarks, and other profile state are shared. For hard isolation, use separate browser profiles/instances with separate native hosts and `SURF_SOCKET` values.
|
|
397
|
+
|
|
398
|
+
### Explicit Tabs and Windows
|
|
399
|
+
|
|
400
|
+
Session targeting is the recommended coordination mechanism. Explicit IDs and named tabs remain available for one-off work:
|
|
381
401
|
|
|
382
402
|
```bash
|
|
383
|
-
|
|
384
|
-
|
|
403
|
+
surf window.new "https://example.com"
|
|
404
|
+
surf read --tab-id 789
|
|
405
|
+
surf click e5 --window-id 123456
|
|
406
|
+
surf tab.name dashboard --tab-id 789
|
|
407
|
+
surf tab.switch dashboard
|
|
385
408
|
```
|
|
386
409
|
|
|
387
|
-
Surf does not yet provide `session.new`, session IDs, or independent per-agent CDP sessions.
|
|
388
|
-
|
|
389
410
|
### Device Emulation
|
|
390
411
|
|
|
391
412
|
Test responsive designs and mobile layouts:
|
|
@@ -505,7 +526,7 @@ surf aistudio.build "game" --keep-open --timeout 600 # Keep tab open, 1
|
|
|
505
526
|
|
|
506
527
|
#### Oracle
|
|
507
528
|
|
|
508
|
-
Use `surf oracle` for a durable, local ChatGPT consult instead of a quick `surf chatgpt` one-shot. It persists jobs by conversation URL, supports repeatable file-context globs, and verifies requested model and reasoning effort before submission. ChatGPT model aliases include `instant`, `thinking`, `pro`, `gpt-5.5`, and `gpt-5.6-sol
|
|
529
|
+
Use `surf oracle` for a durable, local ChatGPT consult instead of a quick `surf chatgpt` one-shot. It persists jobs by conversation URL, supports repeatable file-context globs, and verifies requested model and reasoning effort before submission. ChatGPT model aliases include `instant`, `thinking`, `pro`, `gpt-5.5`, and `gpt-5.6-sol`; `pro` selects the current ChatGPT GPT-5.6 Sol Pro web mode.
|
|
509
530
|
|
|
510
531
|
```bash
|
|
511
532
|
surf oracle ask "review this change" --files "src/**/*.ts" --model gpt-5.5 --effort pro --detach --json
|
|
@@ -764,11 +785,13 @@ Generated manifests declare provenance and authentication environment inputs. Su
|
|
|
764
785
|
## Global Options
|
|
765
786
|
|
|
766
787
|
```bash
|
|
767
|
-
--
|
|
768
|
-
--
|
|
769
|
-
--
|
|
788
|
+
--session <name> # Target a durable browser session (or set SURF_SESSION)
|
|
789
|
+
--tab-id <id> # Target a specific tab
|
|
790
|
+
--window-id <id> # Target a specific window
|
|
791
|
+
--no-wait # Return tab_busy/browser_busy instead of queueing
|
|
792
|
+
--json # Raw JSON including resolved target metadata
|
|
770
793
|
--soft-fail # Warn instead of error (exit 0) on restricted pages
|
|
771
|
-
--no-lock # Bypass the
|
|
794
|
+
--no-lock # Bypass the legacy lock for compound client-side commands
|
|
772
795
|
--no-screenshot # Skip auto-screenshot after actions
|
|
773
796
|
--full # Full resolution screenshots (skip resize)
|
|
774
797
|
```
|
|
@@ -777,6 +800,8 @@ Generated manifests declare provenance and authentication environment inputs. Su
|
|
|
777
800
|
|
|
778
801
|
```bash
|
|
779
802
|
SURF_NETWORK_PATH # Native-host network state root (default: ~/.surf/state/network)
|
|
803
|
+
SURF_STATE_DIR # Private Surf state root, including browser sessions (default: ~/.surf/state)
|
|
804
|
+
SURF_SESSION # Default named browser session for tab-scoped commands
|
|
780
805
|
SURF_SOCKET # Socket path or named pipe (default: /tmp/surf.sock, Windows: //./pipe/surf)
|
|
781
806
|
SURF_REMOTE # Remote Surf endpoint as host:port (overrides SURF_SOCKET)
|
|
782
807
|
SURF_REMOTE_CREDENTIAL # Client Ed25519 credential for the selected remote endpoint
|
|
@@ -788,7 +813,9 @@ SURF_EXTENSION_PATH # Path to extension dist/ directory
|
|
|
788
813
|
```
|
|
789
814
|
|
|
790
815
|
**Use cases:**
|
|
791
|
-
- `
|
|
816
|
+
- `SURF_SESSION`: Per-shell default session. Give each independent agent a unique value and run `surf session.ensure "$SURF_SESSION" about:blank` before its first browser command.
|
|
817
|
+
- `SURF_STATE_DIR`: Private mode-0700 state root for durable browser-session bindings and other Surf state.
|
|
818
|
+
- `SURF_SOCKET`: Advanced socket override. Set it for both the native host and CLI when separate browser/profile instances need hard isolation.
|
|
792
819
|
- `SURF_REMOTE`: Remote client endpoint. `--remote <host>:<port>` overrides it; both override `SURF_SOCKET`.
|
|
793
820
|
- `SURF_REMOTE_CREDENTIAL`: Credential used for mutual remote authentication. `--remote-credential <path>` overrides it.
|
|
794
821
|
- `SURF_REMOTE_STATE_DIR`: Advanced host-side override for the mode-0700 identity and client registry directory.
|
|
@@ -969,9 +996,11 @@ pi -e /path/to/surf-cli/pi-extension/surf.ts
|
|
|
969
996
|
|
|
970
997
|
It registers `surf_read`, `surf_screenshot`, `surf_click`, `surf_type`, `surf_tool`, and the `surf_oracle_*` tools. Browser calls use Surf's native-host socket, not shell commands. If `pi-subagents/background-work` is installed, the extension also reports active oracle jobs started by that Pi session. Pi still loads the browser tools when pi-subagents is not installed.
|
|
971
998
|
|
|
972
|
-
The extension also registers a `surf-oracle` external-job provider when a Pi runtime exposes that provider bridge. The provider has `start`, `status`, `result`, `reattach`, and `follow` operations. Each operation returns Surf job metadata with the durable conversation URL, requested and verified ChatGPT model and effort, prompt digest, result text when captured, and failure details when present. Capacity stays fail-closed: Surf returns the blocking job id instead of silently queueing a second ChatGPT job.
|
|
999
|
+
The extension also registers a `surf-oracle` external-job provider when a Pi runtime exposes that provider bridge. The provider has `start`, `status`, `result`, `reattach`, and `follow` operations. It reads `options.model` and `options.effort` for starts and follows, so a Pi profile can request `model: pro` and reach the ChatGPT GPT-5.6 Sol Pro web mode through Surf. Each operation returns Surf job metadata with the durable conversation URL, requested and verified ChatGPT model and effort, prompt digest, result text when captured, and failure details when present. Capacity stays fail-closed: Surf returns the blocking job id instead of silently queueing a second ChatGPT job.
|
|
1000
|
+
|
|
1001
|
+
When Surf is installed as a Pi package, it also exposes an optional `gpt-pro` package agent for `pi-subagents`. That profile uses `runner.type: external-job`, provider `surf-oracle`, and `options.model: pro`. Surf remains useful without Pi or `pi-subagents`; the package agent only wires Surf's browser-backed model alias into Pi's agent picker.
|
|
973
1002
|
|
|
974
|
-
|
|
1003
|
+
Shell-based agents should select a unique session with `SURF_SESSION` and call `surf session.ensure` before their first browser command. The optional Pi extension still uses its existing socket-tool interface; callers that coordinate several Pi workers should pass explicit tab targets until session selection is exposed by that integration.
|
|
975
1004
|
|
|
976
1005
|
## Development
|
|
977
1006
|
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: gpt-pro
|
|
3
|
+
description: Surf GPT Pro advisor through ChatGPT GPT-5.6 Sol Pro web mode
|
|
4
|
+
runner:
|
|
5
|
+
type: external-job
|
|
6
|
+
provider: surf-oracle
|
|
7
|
+
options:
|
|
8
|
+
model: pro
|
|
9
|
+
async: true
|
|
10
|
+
systemPromptMode: replace
|
|
11
|
+
inheritProjectContext: false
|
|
12
|
+
inheritSkills: false
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
You are a read-only GPT Pro advisor reached through Surf Oracle.
|
|
16
|
+
|
|
17
|
+
Review the supplied task and context.
|
|
18
|
+
Return clear advice, risks, and recommended next steps.
|
|
19
|
+
Do not claim you edited files or ran local tools.
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
const { surfError } = require("./surf-error.cjs");
|
|
2
|
+
|
|
3
|
+
const DEFAULT_MAX_QUEUED = 64;
|
|
4
|
+
const DEFAULT_MAX_PER_LANE = 16;
|
|
5
|
+
const DEFAULT_QUEUE_TIMEOUT_MS = 60000;
|
|
6
|
+
const WRITE_SCOPES = new Set(["browser-write", "provider"]);
|
|
7
|
+
|
|
8
|
+
function normalizeResourceKeys(resourceKeys) {
|
|
9
|
+
if (!Array.isArray(resourceKeys)) return [];
|
|
10
|
+
return [...new Set(resourceKeys.filter((key) => typeof key === "string" && key).map(String))].sort();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
class BrowserScheduler {
|
|
14
|
+
constructor({
|
|
15
|
+
maxQueued = DEFAULT_MAX_QUEUED,
|
|
16
|
+
maxPerLane = DEFAULT_MAX_PER_LANE,
|
|
17
|
+
queueTimeoutMs = DEFAULT_QUEUE_TIMEOUT_MS,
|
|
18
|
+
audit = () => {},
|
|
19
|
+
} = {}) {
|
|
20
|
+
this.maxQueued = maxQueued;
|
|
21
|
+
this.maxPerLane = maxPerLane;
|
|
22
|
+
this.queueTimeoutMs = queueTimeoutMs;
|
|
23
|
+
this.audit = audit;
|
|
24
|
+
this.queue = [];
|
|
25
|
+
this.activeTabs = new Map();
|
|
26
|
+
this.activeReaders = new Set();
|
|
27
|
+
this.activeWriter = null;
|
|
28
|
+
this.activeResources = new Map();
|
|
29
|
+
this.sequence = 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
acquire({ scope, laneKey, resourceKeys = [], wait = true, signal, request, session } = {}) {
|
|
33
|
+
const normalizedResources = normalizeResourceKeys(resourceKeys);
|
|
34
|
+
if (scope === "host" && normalizedResources.length === 0) {
|
|
35
|
+
return Promise.resolve(this.#token({
|
|
36
|
+
scope,
|
|
37
|
+
laneKey,
|
|
38
|
+
resourceKeys: normalizedResources,
|
|
39
|
+
request,
|
|
40
|
+
session,
|
|
41
|
+
queuedAt: Date.now(),
|
|
42
|
+
}));
|
|
43
|
+
}
|
|
44
|
+
if (scope === "tab" && !laneKey) {
|
|
45
|
+
return Promise.reject(surfError("target_required", "tab-scoped command requires a resolved tab"));
|
|
46
|
+
}
|
|
47
|
+
if (!scope || !["host", "tab", "browser-read", "browser-write", "provider"].includes(scope)) {
|
|
48
|
+
return Promise.reject(surfError("scheduler_scope_invalid", `invalid browser scheduler scope: ${scope}`));
|
|
49
|
+
}
|
|
50
|
+
if (signal?.aborted) return Promise.reject(signal.reason || surfError("request_cancelled", "Request cancelled"));
|
|
51
|
+
|
|
52
|
+
const entry = {
|
|
53
|
+
id: ++this.sequence,
|
|
54
|
+
scope,
|
|
55
|
+
laneKey,
|
|
56
|
+
resourceKeys: normalizedResources,
|
|
57
|
+
wait,
|
|
58
|
+
signal,
|
|
59
|
+
request,
|
|
60
|
+
session,
|
|
61
|
+
queuedAt: Date.now(),
|
|
62
|
+
resolve: null,
|
|
63
|
+
reject: null,
|
|
64
|
+
timer: null,
|
|
65
|
+
abortCleanup: null,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
if (this.#canGrantImmediately(entry)) {
|
|
69
|
+
return Promise.resolve(this.#grant(entry));
|
|
70
|
+
}
|
|
71
|
+
if (!wait) {
|
|
72
|
+
const blockedResources = this.#blockedResourceKeys(entry);
|
|
73
|
+
if (blockedResources.length > 0) {
|
|
74
|
+
return Promise.reject(surfError("resource_busy", `shared resource is busy: ${blockedResources.join(", ")}`, {
|
|
75
|
+
laneKey,
|
|
76
|
+
session,
|
|
77
|
+
resourceKeys: blockedResources,
|
|
78
|
+
retryable: true,
|
|
79
|
+
queue: this.stats({ laneKey, resourceKeys: normalizedResources }),
|
|
80
|
+
recoveryCommand: session ? `surf session.info ${session}` : "surf session.list --refresh",
|
|
81
|
+
}));
|
|
82
|
+
}
|
|
83
|
+
const browserBlocked = Boolean(this.activeWriter) || this.#hasQueuedWriter() || WRITE_SCOPES.has(scope);
|
|
84
|
+
const code = browserBlocked ? "browser_busy" : "tab_busy";
|
|
85
|
+
const message = browserBlocked
|
|
86
|
+
? "a browser-wide writer is active or waiting"
|
|
87
|
+
: `tab lane is busy: ${laneKey}`;
|
|
88
|
+
return Promise.reject(surfError(code, message, {
|
|
89
|
+
laneKey,
|
|
90
|
+
session,
|
|
91
|
+
retryable: true,
|
|
92
|
+
queue: this.stats({ laneKey, resourceKeys: normalizedResources }),
|
|
93
|
+
recoveryCommand: session ? `surf session.info ${session}` : "surf session.list --refresh",
|
|
94
|
+
}));
|
|
95
|
+
}
|
|
96
|
+
if (this.queue.length >= this.maxQueued) {
|
|
97
|
+
return Promise.reject(surfError("queue_full", "browser scheduler queue is full", { retryable: true }));
|
|
98
|
+
}
|
|
99
|
+
if (scope === "tab") {
|
|
100
|
+
const laneDepth = this.queue.filter((queued) => queued.scope === "tab" && queued.laneKey === laneKey).length;
|
|
101
|
+
if (laneDepth >= this.maxPerLane) {
|
|
102
|
+
return Promise.reject(surfError("queue_full", `tab lane queue is full: ${laneKey}`, {
|
|
103
|
+
laneKey,
|
|
104
|
+
retryable: true,
|
|
105
|
+
}));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return new Promise((resolve, reject) => {
|
|
110
|
+
entry.resolve = resolve;
|
|
111
|
+
entry.reject = reject;
|
|
112
|
+
entry.timer = setTimeout(() => {
|
|
113
|
+
this.#removeQueued(entry);
|
|
114
|
+
reject(surfError("queue_timeout", "timed out waiting for browser admission", {
|
|
115
|
+
laneKey,
|
|
116
|
+
session,
|
|
117
|
+
resourceKeys: normalizedResources,
|
|
118
|
+
retryable: true,
|
|
119
|
+
queue: this.stats({ laneKey, resourceKeys: normalizedResources }),
|
|
120
|
+
recoveryCommand: session ? `surf session.info ${session}` : "surf session.list --refresh",
|
|
121
|
+
}));
|
|
122
|
+
this.audit({
|
|
123
|
+
event: "scheduler",
|
|
124
|
+
outcome: "queue-timeout",
|
|
125
|
+
request,
|
|
126
|
+
scope,
|
|
127
|
+
laneKey,
|
|
128
|
+
resourceKeys: normalizedResources,
|
|
129
|
+
});
|
|
130
|
+
this.#drain();
|
|
131
|
+
}, this.queueTimeoutMs);
|
|
132
|
+
if (signal) {
|
|
133
|
+
const onAbort = () => {
|
|
134
|
+
if (!this.#removeQueued(entry)) return;
|
|
135
|
+
reject(signal.reason || surfError("request_cancelled", "Request cancelled"));
|
|
136
|
+
this.audit({
|
|
137
|
+
event: "scheduler",
|
|
138
|
+
outcome: "queue-cancel",
|
|
139
|
+
request,
|
|
140
|
+
scope,
|
|
141
|
+
laneKey,
|
|
142
|
+
resourceKeys: normalizedResources,
|
|
143
|
+
});
|
|
144
|
+
this.#drain();
|
|
145
|
+
};
|
|
146
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
147
|
+
entry.abortCleanup = () => signal.removeEventListener("abort", onAbort);
|
|
148
|
+
}
|
|
149
|
+
this.queue.push(entry);
|
|
150
|
+
this.audit({
|
|
151
|
+
event: "scheduler",
|
|
152
|
+
outcome: "queued",
|
|
153
|
+
request,
|
|
154
|
+
scope,
|
|
155
|
+
laneKey,
|
|
156
|
+
resourceKeys: normalizedResources,
|
|
157
|
+
queueDepth: this.queue.length,
|
|
158
|
+
});
|
|
159
|
+
this.#drain();
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
stats({ laneKey, resourceKeys = [] } = {}) {
|
|
164
|
+
const queuedByLane = {};
|
|
165
|
+
for (const entry of this.queue) {
|
|
166
|
+
if (entry.scope !== "tab") continue;
|
|
167
|
+
queuedByLane[entry.laneKey] = (queuedByLane[entry.laneKey] || 0) + 1;
|
|
168
|
+
}
|
|
169
|
+
const writer = this.activeWriter
|
|
170
|
+
? {
|
|
171
|
+
scope: this.activeWriter.scope,
|
|
172
|
+
session: this.activeWriter.session || null,
|
|
173
|
+
acquiredAt: this.activeWriter.acquiredAt,
|
|
174
|
+
}
|
|
175
|
+
: null;
|
|
176
|
+
const queuedWriters = this.queue.filter((entry) => WRITE_SCOPES.has(entry.scope));
|
|
177
|
+
const normalizedResources = normalizeResourceKeys(resourceKeys);
|
|
178
|
+
const activeResources = [...this.activeResources.entries()].map(([key, token]) => ({
|
|
179
|
+
key,
|
|
180
|
+
scope: token.scope,
|
|
181
|
+
laneKey: token.laneKey || null,
|
|
182
|
+
session: token.session || null,
|
|
183
|
+
acquiredAt: token.acquiredAt,
|
|
184
|
+
}));
|
|
185
|
+
return {
|
|
186
|
+
activeTabLanes: [...this.activeTabs.entries()].map(([key, token]) => ({
|
|
187
|
+
laneKey: key,
|
|
188
|
+
session: token.session || null,
|
|
189
|
+
acquiredAt: token.acquiredAt,
|
|
190
|
+
})),
|
|
191
|
+
activeReaders: this.activeReaders.size,
|
|
192
|
+
writerActive: Boolean(writer),
|
|
193
|
+
writer,
|
|
194
|
+
activeResources,
|
|
195
|
+
blockedResourceKeys: normalizedResources.filter((key) => this.activeResources.has(key)),
|
|
196
|
+
queued: this.queue.length,
|
|
197
|
+
queuedWriters: queuedWriters.length,
|
|
198
|
+
queuedWriterSessions: queuedWriters.map((entry) => entry.session || null),
|
|
199
|
+
queuedByLane,
|
|
200
|
+
lane: laneKey ? {
|
|
201
|
+
laneKey,
|
|
202
|
+
active: this.activeTabs.has(laneKey),
|
|
203
|
+
queued: queuedByLane[laneKey] || 0,
|
|
204
|
+
blockedBy: this.activeWriter || queuedWriters.length > 0
|
|
205
|
+
? "browser-writer"
|
|
206
|
+
: this.activeTabs.has(laneKey)
|
|
207
|
+
? "own-tab"
|
|
208
|
+
: null,
|
|
209
|
+
} : undefined,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
#blockedResourceKeys(entry) {
|
|
214
|
+
return entry.resourceKeys.filter((key) => this.activeResources.has(key));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
#hasQueuedWriter() {
|
|
218
|
+
return this.queue.some((entry) => WRITE_SCOPES.has(entry.scope));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
#canGrantImmediately(entry) {
|
|
222
|
+
if (this.queue.length > 0) return false;
|
|
223
|
+
if (this.#blockedResourceKeys(entry).length > 0) return false;
|
|
224
|
+
if (entry.scope === "host") return true;
|
|
225
|
+
if (WRITE_SCOPES.has(entry.scope)) {
|
|
226
|
+
return !this.activeWriter && this.activeReaders.size === 0 && this.activeTabs.size === 0;
|
|
227
|
+
}
|
|
228
|
+
if (this.activeWriter || this.#hasQueuedWriter()) return false;
|
|
229
|
+
if (entry.scope === "browser-read") return true;
|
|
230
|
+
return !this.activeTabs.has(entry.laneKey);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
#grant(entry) {
|
|
234
|
+
if (entry.timer) clearTimeout(entry.timer);
|
|
235
|
+
entry.abortCleanup?.();
|
|
236
|
+
const token = this.#token(entry);
|
|
237
|
+
if (WRITE_SCOPES.has(entry.scope)) this.activeWriter = token;
|
|
238
|
+
else if (entry.scope === "browser-read") this.activeReaders.add(token);
|
|
239
|
+
else if (entry.scope === "tab") this.activeTabs.set(entry.laneKey, token);
|
|
240
|
+
for (const key of entry.resourceKeys) this.activeResources.set(key, token);
|
|
241
|
+
this.audit({
|
|
242
|
+
event: "scheduler",
|
|
243
|
+
outcome: "acquired",
|
|
244
|
+
request: entry.request,
|
|
245
|
+
scope: entry.scope,
|
|
246
|
+
laneKey: entry.laneKey,
|
|
247
|
+
resourceKeys: entry.resourceKeys,
|
|
248
|
+
queueMs: Date.now() - entry.queuedAt,
|
|
249
|
+
});
|
|
250
|
+
return token;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
#token(entry) {
|
|
254
|
+
let released = false;
|
|
255
|
+
const token = {
|
|
256
|
+
scope: entry.scope,
|
|
257
|
+
laneKey: entry.laneKey,
|
|
258
|
+
resourceKeys: entry.resourceKeys,
|
|
259
|
+
session: entry.session,
|
|
260
|
+
queuedAt: entry.queuedAt,
|
|
261
|
+
acquiredAt: Date.now(),
|
|
262
|
+
release: () => {
|
|
263
|
+
if (released) return;
|
|
264
|
+
released = true;
|
|
265
|
+
if (WRITE_SCOPES.has(entry.scope)) {
|
|
266
|
+
if (this.activeWriter === token) this.activeWriter = null;
|
|
267
|
+
} else if (entry.scope === "browser-read") this.activeReaders.delete(token);
|
|
268
|
+
else if (entry.scope === "tab" && this.activeTabs.get(entry.laneKey) === token) {
|
|
269
|
+
this.activeTabs.delete(entry.laneKey);
|
|
270
|
+
}
|
|
271
|
+
for (const key of entry.resourceKeys) {
|
|
272
|
+
if (this.activeResources.get(key) === token) this.activeResources.delete(key);
|
|
273
|
+
}
|
|
274
|
+
this.audit({
|
|
275
|
+
event: "scheduler",
|
|
276
|
+
outcome: "released",
|
|
277
|
+
request: entry.request,
|
|
278
|
+
scope: entry.scope,
|
|
279
|
+
laneKey: entry.laneKey,
|
|
280
|
+
resourceKeys: entry.resourceKeys,
|
|
281
|
+
session: entry.session,
|
|
282
|
+
});
|
|
283
|
+
this.#drain();
|
|
284
|
+
},
|
|
285
|
+
};
|
|
286
|
+
return token;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
#removeQueued(entry) {
|
|
290
|
+
const index = this.queue.indexOf(entry);
|
|
291
|
+
if (index === -1) return false;
|
|
292
|
+
this.queue.splice(index, 1);
|
|
293
|
+
if (entry.timer) clearTimeout(entry.timer);
|
|
294
|
+
entry.abortCleanup?.();
|
|
295
|
+
return true;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
#drain() {
|
|
299
|
+
for (let index = 0; index < this.queue.length;) {
|
|
300
|
+
const entry = this.queue[index];
|
|
301
|
+
if (entry.scope !== "host" || this.#blockedResourceKeys(entry).length > 0) {
|
|
302
|
+
index += 1;
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
this.queue.splice(index, 1);
|
|
306
|
+
entry.resolve(this.#grant(entry));
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (this.activeWriter) return;
|
|
310
|
+
const writerIndex = this.queue.findIndex((entry) => WRITE_SCOPES.has(entry.scope));
|
|
311
|
+
if (writerIndex !== -1) {
|
|
312
|
+
const writer = this.queue[writerIndex];
|
|
313
|
+
if (
|
|
314
|
+
this.activeReaders.size > 0 ||
|
|
315
|
+
this.activeTabs.size > 0 ||
|
|
316
|
+
this.#blockedResourceKeys(writer).length > 0
|
|
317
|
+
) return;
|
|
318
|
+
this.queue.splice(writerIndex, 1);
|
|
319
|
+
writer.resolve(this.#grant(writer));
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
for (let index = 0; index < this.queue.length;) {
|
|
324
|
+
const entry = this.queue[index];
|
|
325
|
+
if (this.#blockedResourceKeys(entry).length > 0) {
|
|
326
|
+
index += 1;
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
let grant = false;
|
|
330
|
+
if (entry.scope === "browser-read") grant = true;
|
|
331
|
+
else if (entry.scope === "tab") grant = !this.activeTabs.has(entry.laneKey);
|
|
332
|
+
if (!grant) {
|
|
333
|
+
index += 1;
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
this.queue.splice(index, 1);
|
|
337
|
+
entry.resolve(this.#grant(entry));
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
module.exports = {
|
|
343
|
+
BrowserScheduler,
|
|
344
|
+
DEFAULT_MAX_PER_LANE,
|
|
345
|
+
DEFAULT_MAX_QUEUED,
|
|
346
|
+
DEFAULT_QUEUE_TIMEOUT_MS,
|
|
347
|
+
normalizeResourceKeys,
|
|
348
|
+
};
|