peaks-loop 4.0.36 → 4.0.38
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/CHANGELOG.md +42 -0
- package/README-en.md +1 -1
- package/README.md +1 -1
- package/bin/peaks.js +71 -1
- package/dist/cli/cli-helpers.js +7 -0
- package/dist/cli/commands/_register.js +2 -0
- package/dist/cli/commands/best-practice-scan-command.d.ts +14 -1
- package/dist/cli/commands/best-practice-scan-command.js +67 -9
- package/dist/cli/commands/code-runtime-commands.js +21 -5
- package/dist/cli/commands/hooks-commands.js +41 -7
- package/dist/cli/commands/job-commands.js +107 -25
- package/dist/cli/commands/scan-commands.js +1 -1
- package/dist/cli/commands/web-commands.d.ts +28 -0
- package/dist/cli/commands/web-commands.js +327 -0
- package/dist/cli/commands/web-lifecycle-commands.d.ts +49 -0
- package/dist/cli/commands/web-lifecycle-commands.js +321 -0
- package/dist/services/best-practice/scan-orchestrator.d.ts +22 -0
- package/dist/services/best-practice/scan-orchestrator.js +14 -5
- package/dist/services/code/orchestrator-can-do.js +27 -4
- package/dist/services/context/build-dispatch-system-prompt.d.ts +35 -1
- package/dist/services/context/build-dispatch-system-prompt.js +55 -3
- package/dist/services/context/context-audit-hint.d.ts +79 -0
- package/dist/services/context/context-audit-hint.js +150 -0
- package/dist/services/hooks/auto-compact-hook-install.js +10 -1
- package/dist/services/hooks/write-gate.js +88 -0
- package/dist/services/lint/detect-eslint.d.ts +2 -0
- package/dist/services/lint/detect-eslint.js +23 -9
- package/dist/services/lint/detect-ocr-18.d.ts +2 -0
- package/dist/services/lint/detect-ocr-18.js +36 -5
- package/dist/services/lint/npx-resolver.d.ts +6 -0
- package/dist/services/lint/npx-resolver.js +38 -14
- package/dist/services/lint/ocr-multilang-adapter.js +9 -2
- package/dist/services/release/version-precheck-service.js +9 -2
- package/dist/services/scan/file-size-scan.d.ts +29 -0
- package/dist/services/scan/file-size-scan.js +63 -0
- package/dist/services/session/caller-binding-service.d.ts +24 -0
- package/dist/services/session/caller-binding-service.js +34 -0
- package/dist/services/session/getSessionDir.js +15 -10
- package/dist/services/skills/hooks-codegate-superpowers.d.ts +74 -0
- package/dist/services/skills/hooks-codegate-superpowers.js +129 -3
- package/dist/services/skills/hooks-settings-service.d.ts +26 -0
- package/dist/services/skills/hooks-settings-service.js +186 -62
- package/dist/services/slice/slice-check-service.d.ts +14 -0
- package/dist/services/slice/slice-check-service.js +110 -50
- package/dist/services/slice/slice-check-types.d.ts +12 -7
- package/dist/services/slice/slice-check-types.js +8 -3
- package/dist/services/slice/slice-decompose-runners.js +24 -21
- package/dist/services/sop/sop-check-service.js +12 -1
- package/dist/services/web/bounded-output.d.ts +34 -0
- package/dist/services/web/bounded-output.js +68 -0
- package/dist/services/web/browser-acquire.d.ts +14 -0
- package/dist/services/web/browser-acquire.js +84 -0
- package/dist/services/web/browser-session-manager.d.ts +111 -0
- package/dist/services/web/browser-session-manager.js +413 -0
- package/dist/services/web/daemon-entry.d.ts +1 -0
- package/dist/services/web/daemon-entry.js +65 -0
- package/dist/services/web/daemon-registry.d.ts +42 -0
- package/dist/services/web/daemon-registry.js +164 -0
- package/dist/services/web/daemon-supervisor.d.ts +144 -0
- package/dist/services/web/daemon-supervisor.js +455 -0
- package/dist/services/web/playwright-loader.d.ts +89 -0
- package/dist/services/web/playwright-loader.js +253 -0
- package/dist/services/web/snapshot-pruner.d.ts +48 -0
- package/dist/services/web/snapshot-pruner.js +241 -0
- package/dist/services/web/untrusted-envelope.d.ts +27 -0
- package/dist/services/web/untrusted-envelope.js +44 -0
- package/dist/services/web/web-artifact-paths.d.ts +79 -0
- package/dist/services/web/web-artifact-paths.js +163 -0
- package/dist/services/web/web-client.d.ts +19 -0
- package/dist/services/web/web-client.js +55 -0
- package/dist/services/web/web-daemon-service.d.ts +38 -0
- package/dist/services/web/web-daemon-service.js +416 -0
- package/dist/services/web/web-fallback.d.ts +70 -0
- package/dist/services/web/web-fallback.js +121 -0
- package/dist/services/web/web-install-service.d.ts +91 -0
- package/dist/services/web/web-install-service.js +346 -0
- package/dist/services/web/web-login-profile.d.ts +89 -0
- package/dist/services/web/web-login-profile.js +612 -0
- package/dist/services/web/web-login-staging.d.ts +27 -0
- package/dist/services/web/web-login-staging.js +173 -0
- package/dist/services/web/web-protocol.d.ts +58 -0
- package/dist/services/web/web-protocol.js +58 -0
- package/dist/services/web/web-status-report.d.ts +33 -0
- package/dist/services/web/web-status-report.js +47 -0
- package/dist/services/workspace/claude-settings-template.d.ts +59 -7
- package/dist/services/workspace/claude-settings-template.js +139 -67
- package/dist/services/workspace/workspace-claude-settings-materializer.js +46 -19
- package/dist/services/workspace/workspace-service.js +33 -0
- package/package.json +5 -5
- package/scripts/copy-templates.mjs +12 -0
- package/scripts/sync-version.mjs +20 -0
- package/skills/peaks-code/SKILL.md +10 -0
- package/skills/peaks-code/references/browser-workflow.md +10 -1
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `peaks web` daemon: loopback HTTP server, op routing, shutdown
|
|
3
|
+
* (slice S2, file 16).
|
|
4
|
+
*
|
|
5
|
+
* One daemon per `(projectRoot, sessionId)` (design §10.2). It binds
|
|
6
|
+
* `127.0.0.1:0` — an OS-assigned port, loopback only — and writes
|
|
7
|
+
* `web/daemon/daemon.json` with pid/port/token/version (tech-doc §1.3).
|
|
8
|
+
*
|
|
9
|
+
* Two deliberate properties:
|
|
10
|
+
*
|
|
11
|
+
* - **Chromium is acquired lazily**, on the first op that needs a page. A
|
|
12
|
+
* daemon that launched a browser at boot would make `peaks web status` and
|
|
13
|
+
* a cold `stop` pay ~700 MB and a process per session for a session
|
|
14
|
+
* that may never touch a page — and would make AC6's "no browser survives
|
|
15
|
+
* the session" unverifiable, because booting would already have started
|
|
16
|
+
* one.
|
|
17
|
+
* - **`routeOp` is exported and takes the manager as an argument**, so the
|
|
18
|
+
* whole op surface is testable without a listening socket.
|
|
19
|
+
*/
|
|
20
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
21
|
+
import { createServer } from 'node:http';
|
|
22
|
+
import { getErrorMessage } from 'peaks-loop-shared/result';
|
|
23
|
+
import { acquireChromium } from './browser-acquire.js';
|
|
24
|
+
import { boundedTeardownStep, BrowserSessionManager } from './browser-session-manager.js';
|
|
25
|
+
import { removeDaemonInfo, writeDaemonInfo } from './daemon-registry.js';
|
|
26
|
+
import { PROTOCOL_VERSION } from './web-protocol.js';
|
|
27
|
+
/** Loopback only: the bearer token is the second lock, not the only one. */
|
|
28
|
+
export const DAEMON_HOST = '127.0.0.1';
|
|
29
|
+
/**
|
|
30
|
+
* Largest `/op` body we will buffer. Args are a URL, a selector and a
|
|
31
|
+
* dispatch id; 64 KiB is orders of magnitude above the real payload and stops
|
|
32
|
+
* an unrelated local process from growing the daemon's heap.
|
|
33
|
+
*/
|
|
34
|
+
const MAX_REQUEST_BYTES = 64 * 1024;
|
|
35
|
+
/** The request's `dispatchId` when the caller sent none — same default as the CLI. */
|
|
36
|
+
const DEFAULT_DISPATCH_ID = 'current';
|
|
37
|
+
/**
|
|
38
|
+
* Start the daemon. Resolves once the server is listening and `daemon.json`
|
|
39
|
+
* exists, so the caller (and every poller in `ensureDaemon`) can treat the
|
|
40
|
+
* resolution as "reachable".
|
|
41
|
+
*/
|
|
42
|
+
export async function startWebDaemon(config) {
|
|
43
|
+
const { projectRoot, sessionId } = config;
|
|
44
|
+
const token = randomBytes(32).toString('hex');
|
|
45
|
+
let browser = null;
|
|
46
|
+
let manager = null;
|
|
47
|
+
let shutdown = null;
|
|
48
|
+
/** Set synchronously by `close()` before it awaits anything. */
|
|
49
|
+
let shuttingDown = false;
|
|
50
|
+
/**
|
|
51
|
+
* The first PERMANENT acquisition failure, remembered for the daemon's whole
|
|
52
|
+
* lifetime.
|
|
53
|
+
*
|
|
54
|
+
* `managerFor` used to retry on every op, and the `MISSING_EXECUTABLE` branch
|
|
55
|
+
* of `acquireChromium` re-ran `playwright install chromium` — a blocking,
|
|
56
|
+
* network-touching `spawnSync`. One attempt per daemon lifetime, then fail
|
|
57
|
+
* fast, is still right for a failure that cannot heal (`PLAYWRIGHT_NOT_
|
|
58
|
+
* RESOLVABLE`: nothing on this machine holds the pin).
|
|
59
|
+
*
|
|
60
|
+
* It is NOT right for a failure the next minute can fix (R5). `WEB_INSTALL_
|
|
61
|
+
* BUSY` is "someone else is downloading right now"; `WEB_INSTALL_REQUIRED` is
|
|
62
|
+
* "the user has not run `peaks web install` yet". Latching either poisoned the
|
|
63
|
+
* daemon for its whole life: after the CLI's install finished successfully,
|
|
64
|
+
* every browser op still failed until the daemon was stopped. Those codes are
|
|
65
|
+
* re-attempted instead — and re-attempting is now cheap, because acquisition
|
|
66
|
+
* spawns nothing (R3).
|
|
67
|
+
*/
|
|
68
|
+
let acquireFailure = null;
|
|
69
|
+
/**
|
|
70
|
+
* The acquisition already under way, so two ops arriving together share one
|
|
71
|
+
* launch instead of both passing the `manager === null` check and starting a
|
|
72
|
+
* browser each — the loser's handle would be overwritten and never closed.
|
|
73
|
+
*/
|
|
74
|
+
let acquiring = null;
|
|
75
|
+
const managerFor = async () => {
|
|
76
|
+
if (acquireFailure !== null) {
|
|
77
|
+
throw acquireFailure;
|
|
78
|
+
}
|
|
79
|
+
if (manager === null) {
|
|
80
|
+
// The shutdown check lives INSIDE the shared promise, so it runs once for
|
|
81
|
+
// every caller waiting on it.
|
|
82
|
+
acquiring ??= (async () => {
|
|
83
|
+
let acquired;
|
|
84
|
+
try {
|
|
85
|
+
acquired = await acquireChromium();
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
// Only a failure that cannot heal is latched; a transient one is
|
|
89
|
+
// re-attempted on the next op (R5, see the field's docstring).
|
|
90
|
+
if (!isTransientAcquireFailure(error)) {
|
|
91
|
+
acquireFailure = error;
|
|
92
|
+
}
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
if (shuttingDown) {
|
|
96
|
+
// `close()` ran while chromium was launching, so it snapshotted
|
|
97
|
+
// `browser === null` and would never close this one. Without this the
|
|
98
|
+
// op returns into a daemon that is about to `process.exit(0)`, and
|
|
99
|
+
// the fresh chromium outlives it — AC6's false pass.
|
|
100
|
+
await acquired.browser.close();
|
|
101
|
+
throw new Error('WEB_DAEMON_SHUTTING_DOWN: the daemon is closing; the acquired browser was closed');
|
|
102
|
+
}
|
|
103
|
+
return acquired;
|
|
104
|
+
})();
|
|
105
|
+
let acquired;
|
|
106
|
+
try {
|
|
107
|
+
acquired = await acquiring;
|
|
108
|
+
}
|
|
109
|
+
finally {
|
|
110
|
+
// Released on FAILURE too. Leaving a rejected promise in place meant
|
|
111
|
+
// every later op awaited it and re-threw the same error without ever
|
|
112
|
+
// retrying, so a transient failure was permanent in practice whatever
|
|
113
|
+
// the latch did (R5).
|
|
114
|
+
acquiring = null;
|
|
115
|
+
}
|
|
116
|
+
browser = acquired.browser;
|
|
117
|
+
manager = new BrowserSessionManager(acquired.browser, { projectRoot, sessionId });
|
|
118
|
+
}
|
|
119
|
+
return manager;
|
|
120
|
+
};
|
|
121
|
+
const close = async () => {
|
|
122
|
+
shuttingDown = true;
|
|
123
|
+
if (shutdown !== null) {
|
|
124
|
+
return shutdown;
|
|
125
|
+
}
|
|
126
|
+
shutdown = (async () => {
|
|
127
|
+
const closed = manager === null
|
|
128
|
+
? { closedContexts: 0, stateWriteFailures: [] }
|
|
129
|
+
: await manager.closeAll();
|
|
130
|
+
if (browser !== null) {
|
|
131
|
+
// Closing the browser ends the chromium process. Without this the
|
|
132
|
+
// daemon could record a clean stop while the browser outlived it —
|
|
133
|
+
// the exact false pass AC6 is written against.
|
|
134
|
+
await boundedTeardownStep(browser.close(), 'browser.close').catch(reportTeardownFailure);
|
|
135
|
+
}
|
|
136
|
+
await boundedTeardownStep(stopListening(server), 'stopListening').catch(reportTeardownFailure);
|
|
137
|
+
removeDaemonInfo(projectRoot, sessionId);
|
|
138
|
+
return closed;
|
|
139
|
+
})();
|
|
140
|
+
return shutdown;
|
|
141
|
+
};
|
|
142
|
+
const server = createServer((request, response) => {
|
|
143
|
+
void handleRequest(request, response).catch((error) => {
|
|
144
|
+
sendJson(response, 500, failureResponse(error));
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
async function handleRequest(request, response) {
|
|
148
|
+
const url = request.url ?? '';
|
|
149
|
+
// `/health` is unauthenticated by contract (tech-doc §1.3): it answers
|
|
150
|
+
// liveness, and the caller already proved ownership by reading daemon.json.
|
|
151
|
+
if (request.method === 'GET' && url === '/health') {
|
|
152
|
+
sendJson(response, 200, { ok: true });
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (request.method !== 'POST' || url !== '/op') {
|
|
156
|
+
sendJson(response, 404, failureResponse(new Error('WEB_DAEMON_NOT_FOUND: no such endpoint')));
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (!isAuthorized(request, token)) {
|
|
160
|
+
sendJson(response, 401, failureResponse(new Error('WEB_DAEMON_UNAUTHORIZED: bad or missing bearer token')));
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const request_ = parseOpRequest(await readBody(request));
|
|
164
|
+
if (request_ === null) {
|
|
165
|
+
sendJson(response, 400, failureResponse(new Error('WEB_DAEMON_BAD_REQUEST: body is not a WebOpRequest')));
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (request_.op === 'whoami') {
|
|
169
|
+
// The ownership proof `stopDaemon` needs, answered from this process's
|
|
170
|
+
// own identity. Handled here rather than in `routeOp` for the same reason
|
|
171
|
+
// `stop` is: it must answer without ever acquiring a browser, and only the
|
|
172
|
+
// daemon knows who it is. It sits behind the bearer check above, which is
|
|
173
|
+
// what makes it evidence and `/health` not.
|
|
174
|
+
sendJson(response, 200, succeeded({ pid: process.pid, projectRoot, sessionId }));
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (request_.op === 'stop') {
|
|
178
|
+
// Answer FIRST, then tear down: the caller must learn that the stop was
|
|
179
|
+
// accepted even though the socket is about to disappear.
|
|
180
|
+
sendJson(response, 200, {
|
|
181
|
+
ok: true,
|
|
182
|
+
data: null,
|
|
183
|
+
code: null,
|
|
184
|
+
message: null,
|
|
185
|
+
warnings: [],
|
|
186
|
+
nextActions: []
|
|
187
|
+
});
|
|
188
|
+
response.once('finish', () => {
|
|
189
|
+
void close()
|
|
190
|
+
.catch((error) => {
|
|
191
|
+
process.stderr.write(`peaks web: daemon shutdown failed: ${getErrorMessage(error)}\n`);
|
|
192
|
+
})
|
|
193
|
+
.then(() => {
|
|
194
|
+
// Re-raise the signal instead of exiting here: `daemon-entry.ts`'s
|
|
195
|
+
// handler is the single place that ends the process, so the
|
|
196
|
+
// graceful HTTP path and an OS signal take the same route. In a
|
|
197
|
+
// test (no listener) this is a no-op and the teardown above is
|
|
198
|
+
// what the assertions see.
|
|
199
|
+
process.emit('SIGTERM');
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
sendJson(response, 200, await routeOp(request_.op, request_.args, managerFor));
|
|
205
|
+
}
|
|
206
|
+
const port = await listen(server);
|
|
207
|
+
writeDaemonInfo(projectRoot, sessionId, {
|
|
208
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
209
|
+
pid: process.pid,
|
|
210
|
+
port,
|
|
211
|
+
token,
|
|
212
|
+
version: config.version,
|
|
213
|
+
projectRoot,
|
|
214
|
+
sessionId,
|
|
215
|
+
startedAt: new Date().toISOString()
|
|
216
|
+
});
|
|
217
|
+
// One line per boot, on the daemon's stderr (= `daemon.log`). It is the only
|
|
218
|
+
// surviving record of how many daemons have started for this session:
|
|
219
|
+
// `daemon.json` holds the LAST writer, so a second, racing daemon would
|
|
220
|
+
// otherwise leave no trace beyond an orphaned port.
|
|
221
|
+
process.stderr.write(`peaks web daemon listening on ${DAEMON_HOST}:${String(port)} (pid ${String(process.pid)}, session ${sessionId})\n`);
|
|
222
|
+
return { port, token, close };
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Serve one op. Exported separately from the server so every verb's payload
|
|
226
|
+
* shape and failure code are unit-testable without a socket.
|
|
227
|
+
*
|
|
228
|
+
* The manager arrives as a PROVIDER, not a value: acquiring it is what launches
|
|
229
|
+
* chromium, so it must happen only for ops that actually need a page. An op the
|
|
230
|
+
* daemon does not serve (`install`, `login`, a typo) must answer without
|
|
231
|
+
* starting a browser at all.
|
|
232
|
+
*/
|
|
233
|
+
export async function routeOp(op, args, managerFor) {
|
|
234
|
+
const dispatchId = stringArg(args['dispatchId']) || DEFAULT_DISPATCH_ID;
|
|
235
|
+
try {
|
|
236
|
+
switch (op) {
|
|
237
|
+
case 'open': {
|
|
238
|
+
const manager = await managerFor();
|
|
239
|
+
// The ONLY verb that accepts a profile (design §2). The name is passed
|
|
240
|
+
// down raw and validated again in the manager: it arrives off the wire,
|
|
241
|
+
// so the CLI's own check is not evidence here (same rule as S1's
|
|
242
|
+
// `assertUnder` / sid-slug pair). A mistyped optional arg means "not
|
|
243
|
+
// provided", never `[object Object]` — like every other optional arg.
|
|
244
|
+
return succeeded(await manager.open(dispatchId, stringArg(args['url']), optionalArg(args['profile'])));
|
|
245
|
+
}
|
|
246
|
+
case 'text': {
|
|
247
|
+
const manager = await managerFor();
|
|
248
|
+
return succeeded(await manager.text(dispatchId, optionalArg(args['selector'])));
|
|
249
|
+
}
|
|
250
|
+
case 'snap': {
|
|
251
|
+
const manager = await managerFor();
|
|
252
|
+
return succeeded(await manager.snap(dispatchId, optionalArg(args['selector'])));
|
|
253
|
+
}
|
|
254
|
+
case 'click': {
|
|
255
|
+
const manager = await managerFor();
|
|
256
|
+
return succeeded(await manager.click(dispatchId, stringArg(args['selector'])));
|
|
257
|
+
}
|
|
258
|
+
case 'shot': {
|
|
259
|
+
const manager = await managerFor();
|
|
260
|
+
return succeeded(await manager.shot(dispatchId, optionalArg(args['selector'])));
|
|
261
|
+
}
|
|
262
|
+
case 'metrics': {
|
|
263
|
+
const manager = await managerFor();
|
|
264
|
+
return succeeded(await manager.metrics(dispatchId));
|
|
265
|
+
}
|
|
266
|
+
default:
|
|
267
|
+
// `install` / `login` are S3/S4's; the slot exists so they can be
|
|
268
|
+
// added here as one `case` each rather than by reshaping the router.
|
|
269
|
+
return failureResponse(new Error(`WEB_OP_UNSUPPORTED: the daemon does not serve '${op}' yet`));
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
catch (error) {
|
|
273
|
+
// Covers both the op itself and chromium acquisition (a missing download,
|
|
274
|
+
// a resolvable-but-broken playwright).
|
|
275
|
+
return failureResponse(error);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* The failure codes that mean "try again later", not "this can never work".
|
|
280
|
+
*
|
|
281
|
+
* `acquireChromium` raises the first when the browser simply is not installed
|
|
282
|
+
* yet and the second when another process holds the install lock — both are
|
|
283
|
+
* facts about this minute, not about this machine (R5). Everything else
|
|
284
|
+
* (`PLAYWRIGHT_NOT_RESOLVABLE`, a broken package) is permanent for the daemon's
|
|
285
|
+
* lifetime and is latched.
|
|
286
|
+
*/
|
|
287
|
+
const TRANSIENT_ACQUIRE_FAILURE_RE = /^(WEB_INSTALL_REQUIRED|WEB_INSTALL_BUSY|WEB_INSTALL_TIMEOUT)\b/;
|
|
288
|
+
function isTransientAcquireFailure(error) {
|
|
289
|
+
return TRANSIENT_ACQUIRE_FAILURE_RE.test(getErrorMessage(error));
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* A bounded teardown step that ran out of budget must not abort the rest of the
|
|
293
|
+
* teardown — the record removal and the exit are what stop a wedged daemon from
|
|
294
|
+
* becoming invisible — but it must not be silent either, so it says so on the
|
|
295
|
+
* daemon's stderr (= `daemon.log`).
|
|
296
|
+
*/
|
|
297
|
+
function reportTeardownFailure(error) {
|
|
298
|
+
process.stderr.write(`peaks web daemon: teardown step failed: ${getErrorMessage(error)}\n`);
|
|
299
|
+
}
|
|
300
|
+
/** `{ok:true}` with the op payload as `data`. */
|
|
301
|
+
function succeeded(data) {
|
|
302
|
+
return { ok: true, data, code: null, message: null, warnings: [], nextActions: [] };
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Map a thrown error onto the envelope. S1's modules signal their failure code
|
|
306
|
+
* as a `CODE: detail` prefix, so the code survives the trip to the CLI instead
|
|
307
|
+
* of collapsing every daemon failure into one opaque `WEB_OP_FAILED`.
|
|
308
|
+
*/
|
|
309
|
+
function failureResponse(error) {
|
|
310
|
+
const message = getErrorMessage(error);
|
|
311
|
+
const match = /^([A-Z][A-Z0-9_]{0,63}):\s*([\s\S]*)$/.exec(message);
|
|
312
|
+
return {
|
|
313
|
+
ok: false,
|
|
314
|
+
data: null,
|
|
315
|
+
code: match?.[1] ?? 'WEB_OP_FAILED',
|
|
316
|
+
message: match?.[2] ?? message,
|
|
317
|
+
warnings: [],
|
|
318
|
+
nextActions: []
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Constant-time bearer check. `timingSafeEqual` needs equal byte lengths, so
|
|
323
|
+
* the length is compared first — a length mismatch is not a secret (the token
|
|
324
|
+
* is a fixed 64 hex chars) and the comparison is otherwise byte-for-byte.
|
|
325
|
+
*/
|
|
326
|
+
function isAuthorized(request, token) {
|
|
327
|
+
const header = request.headers.authorization ?? '';
|
|
328
|
+
const presented = header.startsWith('Bearer ') ? header.slice('Bearer '.length) : '';
|
|
329
|
+
const expected = Buffer.from(token, 'utf8');
|
|
330
|
+
const actual = Buffer.from(presented, 'utf8');
|
|
331
|
+
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
|
332
|
+
}
|
|
333
|
+
/** Read the body, refusing anything past `MAX_REQUEST_BYTES`. */
|
|
334
|
+
async function readBody(request) {
|
|
335
|
+
const chunks = [];
|
|
336
|
+
let size = 0;
|
|
337
|
+
for await (const chunk of request) {
|
|
338
|
+
const bytes = chunk;
|
|
339
|
+
size += bytes.length;
|
|
340
|
+
if (size > MAX_REQUEST_BYTES) {
|
|
341
|
+
throw new Error(`WEB_DAEMON_BODY_TOO_LARGE: body exceeds ${String(MAX_REQUEST_BYTES)} bytes`);
|
|
342
|
+
}
|
|
343
|
+
chunks.push(bytes);
|
|
344
|
+
}
|
|
345
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
346
|
+
}
|
|
347
|
+
/** `{op, args}` or `null` when the body is not shaped like a `WebOpRequest`. */
|
|
348
|
+
function parseOpRequest(body) {
|
|
349
|
+
let parsed;
|
|
350
|
+
try {
|
|
351
|
+
parsed = JSON.parse(body);
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
const { op, args } = parsed;
|
|
360
|
+
if (typeof op !== 'string' || op.length === 0) {
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
return {
|
|
364
|
+
op: op,
|
|
365
|
+
args: typeof args === 'object' && args !== null && !Array.isArray(args)
|
|
366
|
+
? args
|
|
367
|
+
: {}
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
function stringArg(value) {
|
|
371
|
+
return typeof value === 'string' ? value : '';
|
|
372
|
+
}
|
|
373
|
+
/** An absent OR mistyped optional argument means "not provided", never `[object Object]`. */
|
|
374
|
+
function optionalArg(value) {
|
|
375
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
376
|
+
}
|
|
377
|
+
function sendJson(response, status, body) {
|
|
378
|
+
if (response.headersSent) {
|
|
379
|
+
response.end();
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
const payload = JSON.stringify(body);
|
|
383
|
+
response.writeHead(status, {
|
|
384
|
+
'content-type': 'application/json',
|
|
385
|
+
'content-length': Buffer.byteLength(payload, 'utf8')
|
|
386
|
+
});
|
|
387
|
+
response.end(payload);
|
|
388
|
+
}
|
|
389
|
+
/** Bind, and report the OS-assigned port. */
|
|
390
|
+
function listen(server) {
|
|
391
|
+
return new Promise((settle, reject) => {
|
|
392
|
+
server.once('error', reject);
|
|
393
|
+
server.listen(0, DAEMON_HOST, () => {
|
|
394
|
+
server.removeListener('error', reject);
|
|
395
|
+
const address = server.address();
|
|
396
|
+
if (address === null || typeof address === 'string') {
|
|
397
|
+
reject(new Error('WEB_DAEMON_BIND_FAILED: no TCP address after listen'));
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
settle(address.port);
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Stop accepting work and release the socket. Idle keep-alive connections are
|
|
406
|
+
* dropped first: `fetch` keeps its connection open, and `server.close()` alone
|
|
407
|
+
* would wait for it until its own timeout.
|
|
408
|
+
*/
|
|
409
|
+
function stopListening(server) {
|
|
410
|
+
return new Promise((settle) => {
|
|
411
|
+
server.close(() => {
|
|
412
|
+
settle();
|
|
413
|
+
});
|
|
414
|
+
server.closeAllConnections();
|
|
415
|
+
});
|
|
416
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The degradation chain, in one place (slice S3, file 19; AC5, tech-doc §5.4).
|
|
3
|
+
*
|
|
4
|
+
* Design §6 orders the chain: 1 local browser → 2 lazy download → 3 MCP
|
|
5
|
+
* fallback → 4 explicit error + install guidance. Orchestrator decision C3 is
|
|
6
|
+
* binding on how tiers 3 and 4 are represented here: they are **not a branch
|
|
7
|
+
* this process can take**. Whether `mcp__playwright__*` is available is harness
|
|
8
|
+
* state — the CLI is a subprocess and cannot see the caller's tool list — so
|
|
9
|
+
* the envelope carries BOTH the MCP fallback action and the install
|
|
10
|
+
* instruction, and the calling LLM picks. Naming the MCP tool explicitly, and
|
|
11
|
+
* saying where a screenshot taken through it lands, is what keeps the two
|
|
12
|
+
* options distinguishable; a blob that reads as one undifferentiated warning
|
|
13
|
+
* would defeat the purpose (C3's "consequence to keep").
|
|
14
|
+
*
|
|
15
|
+
* `installHint` is derived from `installCommandLine()`'s pin so the guidance
|
|
16
|
+
* and the command that is actually run cannot drift apart.
|
|
17
|
+
*/
|
|
18
|
+
import { type ResultEnvelope } from 'peaks-loop-shared/result';
|
|
19
|
+
import type { WebOp } from './web-protocol.js';
|
|
20
|
+
/** 3 = fall back to MCP and/or install locally; 4 = nothing available. */
|
|
21
|
+
export type DegradationTier = 3 | 4;
|
|
22
|
+
/**
|
|
23
|
+
* The MCP tool that replaces each op on the fallback path.
|
|
24
|
+
*
|
|
25
|
+
* `status` / `stop` / `whoami` have no browser path at all, so they have no MCP
|
|
26
|
+
* equivalent and no degradation envelope: C2's matrix keeps them working
|
|
27
|
+
* (`status` reports the disabled flag, `stop` stops a process). The empty
|
|
28
|
+
* string is that "no fallback exists" answer, and `degradedEnvelope` — the only
|
|
29
|
+
* caller — says so rather than naming a tool that cannot do the job.
|
|
30
|
+
*
|
|
31
|
+
* `login` is empty for the SAME reason, and it is the one browser op where that
|
|
32
|
+
* is not obvious (S4 R3). No `mcp__playwright__*` tool persists a storage state,
|
|
33
|
+
* so `browser_navigate` was a dead end for the only verb whose whole purpose is
|
|
34
|
+
* persistence: an envelope that told a caller to call it would contradict its own
|
|
35
|
+
* `nextActions`. The machine-readable field now agrees with the human-readable
|
|
36
|
+
* one.
|
|
37
|
+
*/
|
|
38
|
+
export declare const MCP_TOOL_FOR_OP: Record<WebOp, string>;
|
|
39
|
+
/**
|
|
40
|
+
* Design §6 tier-3 wording. Two approved artifacts spell it two ways — the
|
|
41
|
+
* design says 截图会落根目录, `qa/test-cases/peaks-web.md` §5 quotes
|
|
42
|
+
* 截图会落项目根目录 as "原文" — so both are carried, rather than picking one and
|
|
43
|
+
* failing the other's assertion. The second half says it in English for the
|
|
44
|
+
* same reason.
|
|
45
|
+
*/
|
|
46
|
+
export declare const MCP_ROOT_DIR_WARNING = "MCP fallback screenshots land in the project root\uFF08\u622A\u56FE\u4F1A\u843D\u6839\u76EE\u5F55 / \u622A\u56FE\u4F1A\u843D\u9879\u76EE\u6839\u76EE\u5F55\uFF09";
|
|
47
|
+
/** The command a user or the LLM would run to get the local path back. */
|
|
48
|
+
export declare const INSTALL_HINT = "npx --yes --package playwright@1.63.0 -- playwright install chromium";
|
|
49
|
+
export interface DegradedData {
|
|
50
|
+
readonly tier: DegradationTier;
|
|
51
|
+
/** Empty when the op has no MCP equivalent (see `MCP_TOOL_FOR_OP`). */
|
|
52
|
+
readonly mcpTool: string;
|
|
53
|
+
readonly installHint: string;
|
|
54
|
+
/**
|
|
55
|
+
* The op's own arguments, carried so the fallback is *actionable* (R9).
|
|
56
|
+
* "Call `mcp__playwright__browser_navigate` instead" is not something a caller
|
|
57
|
+
* can execute without the URL; `browser_evaluate` for `text`/`metrics` is not
|
|
58
|
+
* something a caller can execute without the selector. Only string arguments
|
|
59
|
+
* are carried — they are the op surface, and dropping the rest keeps a
|
|
60
|
+
* `dispatchId`-shaped value from leaking into a fallback blob.
|
|
61
|
+
*/
|
|
62
|
+
readonly args: Readonly<Record<string, string>>;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* One envelope for the whole tier-3/4 branch (C3): `ok:false`, a code, the
|
|
66
|
+
* fallback tool, the install command, the op's own arguments, and the two things
|
|
67
|
+
* a caller must know — the local path was skipped and where the MCP path puts
|
|
68
|
+
* its screenshots.
|
|
69
|
+
*/
|
|
70
|
+
export declare function degradedEnvelope(op: WebOp, reason: string, tier?: DegradationTier, opArgs?: Readonly<Record<string, unknown>>): ResultEnvelope<DegradedData>;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The degradation chain, in one place (slice S3, file 19; AC5, tech-doc §5.4).
|
|
3
|
+
*
|
|
4
|
+
* Design §6 orders the chain: 1 local browser → 2 lazy download → 3 MCP
|
|
5
|
+
* fallback → 4 explicit error + install guidance. Orchestrator decision C3 is
|
|
6
|
+
* binding on how tiers 3 and 4 are represented here: they are **not a branch
|
|
7
|
+
* this process can take**. Whether `mcp__playwright__*` is available is harness
|
|
8
|
+
* state — the CLI is a subprocess and cannot see the caller's tool list — so
|
|
9
|
+
* the envelope carries BOTH the MCP fallback action and the install
|
|
10
|
+
* instruction, and the calling LLM picks. Naming the MCP tool explicitly, and
|
|
11
|
+
* saying where a screenshot taken through it lands, is what keeps the two
|
|
12
|
+
* options distinguishable; a blob that reads as one undifferentiated warning
|
|
13
|
+
* would defeat the purpose (C3's "consequence to keep").
|
|
14
|
+
*
|
|
15
|
+
* `installHint` is derived from `installCommandLine()`'s pin so the guidance
|
|
16
|
+
* and the command that is actually run cannot drift apart.
|
|
17
|
+
*/
|
|
18
|
+
import { fail } from 'peaks-loop-shared/result';
|
|
19
|
+
import { PLAYWRIGHT_VERSION_PIN } from './playwright-loader.js';
|
|
20
|
+
/**
|
|
21
|
+
* The MCP tool that replaces each op on the fallback path.
|
|
22
|
+
*
|
|
23
|
+
* `status` / `stop` / `whoami` have no browser path at all, so they have no MCP
|
|
24
|
+
* equivalent and no degradation envelope: C2's matrix keeps them working
|
|
25
|
+
* (`status` reports the disabled flag, `stop` stops a process). The empty
|
|
26
|
+
* string is that "no fallback exists" answer, and `degradedEnvelope` — the only
|
|
27
|
+
* caller — says so rather than naming a tool that cannot do the job.
|
|
28
|
+
*
|
|
29
|
+
* `login` is empty for the SAME reason, and it is the one browser op where that
|
|
30
|
+
* is not obvious (S4 R3). No `mcp__playwright__*` tool persists a storage state,
|
|
31
|
+
* so `browser_navigate` was a dead end for the only verb whose whole purpose is
|
|
32
|
+
* persistence: an envelope that told a caller to call it would contradict its own
|
|
33
|
+
* `nextActions`. The machine-readable field now agrees with the human-readable
|
|
34
|
+
* one.
|
|
35
|
+
*/
|
|
36
|
+
export const MCP_TOOL_FOR_OP = {
|
|
37
|
+
open: 'mcp__playwright__browser_navigate',
|
|
38
|
+
text: 'mcp__playwright__browser_evaluate',
|
|
39
|
+
snap: 'mcp__playwright__browser_snapshot',
|
|
40
|
+
click: 'mcp__playwright__browser_click',
|
|
41
|
+
shot: 'mcp__playwright__browser_take_screenshot',
|
|
42
|
+
metrics: 'mcp__playwright__browser_evaluate',
|
|
43
|
+
login: '',
|
|
44
|
+
install: 'mcp__playwright__browser_install',
|
|
45
|
+
status: '',
|
|
46
|
+
stop: '',
|
|
47
|
+
whoami: ''
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Design §6 tier-3 wording. Two approved artifacts spell it two ways — the
|
|
51
|
+
* design says 截图会落根目录, `qa/test-cases/peaks-web.md` §5 quotes
|
|
52
|
+
* 截图会落项目根目录 as "原文" — so both are carried, rather than picking one and
|
|
53
|
+
* failing the other's assertion. The second half says it in English for the
|
|
54
|
+
* same reason.
|
|
55
|
+
*/
|
|
56
|
+
export const MCP_ROOT_DIR_WARNING = 'MCP fallback screenshots land in the project root(截图会落根目录 / 截图会落项目根目录)';
|
|
57
|
+
/** The command a user or the LLM would run to get the local path back. */
|
|
58
|
+
export const INSTALL_HINT = `npx --yes --package playwright@${PLAYWRIGHT_VERSION_PIN} -- playwright install chromium`;
|
|
59
|
+
/**
|
|
60
|
+
* `CODE: detail` — the same prefix convention `web-daemon-service`'s
|
|
61
|
+
* `failureResponse` parses, reused here so a caller can pass either a bare
|
|
62
|
+
* reason (the gate) or a coded failure (`WEB_INSTALL_FAILED: …`) and get the
|
|
63
|
+
* right `code` on the envelope without a second parameter.
|
|
64
|
+
*/
|
|
65
|
+
const CODE_PREFIX_RE = /^([A-Z][A-Z0-9_]{0,63}):\s*/;
|
|
66
|
+
/**
|
|
67
|
+
* One envelope for the whole tier-3/4 branch (C3): `ok:false`, a code, the
|
|
68
|
+
* fallback tool, the install command, the op's own arguments, and the two things
|
|
69
|
+
* a caller must know — the local path was skipped and where the MCP path puts
|
|
70
|
+
* its screenshots.
|
|
71
|
+
*/
|
|
72
|
+
export function degradedEnvelope(op, reason, tier = 3, opArgs = {}) {
|
|
73
|
+
const mcpTool = MCP_TOOL_FOR_OP[op];
|
|
74
|
+
const code = CODE_PREFIX_RE.exec(reason)?.[1] ?? (tier === 4 ? 'WEB_UNAVAILABLE' : 'WEB_DISABLED');
|
|
75
|
+
const detail = reason.replace(CODE_PREFIX_RE, '') || reason;
|
|
76
|
+
return {
|
|
77
|
+
...fail(`peaks.web.${op}`, code, `peaks web ${op} did not run locally: ${detail}`, { tier, mcpTool, installHint: INSTALL_HINT, args: stringArgs(opArgs) }, nextActions(op, mcpTool)),
|
|
78
|
+
warnings: [`local browser skipped (${detail})`, MCP_ROOT_DIR_WARNING]
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/** The op's own string arguments — `selector`, `url` — and nothing else. */
|
|
82
|
+
function stringArgs(opArgs) {
|
|
83
|
+
const args = {};
|
|
84
|
+
for (const [key, value] of Object.entries(opArgs)) {
|
|
85
|
+
if (typeof value === 'string' && value.length > 0) {
|
|
86
|
+
args[key] = value;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return args;
|
|
90
|
+
}
|
|
91
|
+
function nextActions(op, mcpTool) {
|
|
92
|
+
// `install`'s own next action is a RE-run with `--force`: the recovery path
|
|
93
|
+
// R6 names, and the only thing that helps when the installer exited 0 without
|
|
94
|
+
// landing the browser (R7).
|
|
95
|
+
if (op === 'install') {
|
|
96
|
+
return [
|
|
97
|
+
'Re-run `peaks web install --force` to re-download the browser',
|
|
98
|
+
'Or call mcp__playwright__browser_install instead'
|
|
99
|
+
];
|
|
100
|
+
}
|
|
101
|
+
const install = 'Run `peaks web install` for the local path';
|
|
102
|
+
// `login` is the one browser op its MCP fallback cannot stand in for: no
|
|
103
|
+
// `mcp__playwright__*` tool persists a storage state, so naming
|
|
104
|
+
// `browser_navigate` would send the caller to a dead end for the only verb
|
|
105
|
+
// whose whole purpose is persistence (S4 R3). The gate is the real recovery.
|
|
106
|
+
if (op === 'login') {
|
|
107
|
+
return [
|
|
108
|
+
'Unset PEAKS_WEB_DISABLED and re-run `peaks web login --profile <name>` — ' +
|
|
109
|
+
'the MCP fallback cannot save a login profile',
|
|
110
|
+
install
|
|
111
|
+
];
|
|
112
|
+
}
|
|
113
|
+
if (mcpTool === '') {
|
|
114
|
+
return [install];
|
|
115
|
+
}
|
|
116
|
+
return [
|
|
117
|
+
`Call ${mcpTool} directly instead (MCP fallback for \`peaks web ${op}\`; ` +
|
|
118
|
+
'screenshots taken that way land in the project root)',
|
|
119
|
+
install
|
|
120
|
+
];
|
|
121
|
+
}
|