peaks-loop 4.0.35 → 4.0.37
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 +40 -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.d.ts +5 -2
- package/dist/cli/commands/code-runtime-commands.js +78 -7
- package/dist/cli/commands/core/doctor-command.d.ts +8 -0
- package/dist/cli/commands/core/doctor-command.js +44 -2
- package/dist/cli/commands/core/memory-command.js +5 -1
- package/dist/cli/commands/dispatch-commands.js +15 -3
- package/dist/cli/commands/dispatch-from-dag.js +17 -0
- package/dist/cli/commands/hooks-commands.js +10 -1
- package/dist/cli/commands/job-commands.js +107 -25
- package/dist/cli/commands/memory-commands.d.ts +24 -0
- package/dist/cli/commands/memory-commands.js +77 -10
- package/dist/cli/commands/request-commands.d.ts +8 -0
- package/dist/cli/commands/request-commands.js +23 -2
- package/dist/cli/commands/scan-commands.js +1 -1
- package/dist/cli/commands/sub-agent-commands.js +2 -0
- package/dist/cli/commands/wave-plan-commands.d.ts +24 -0
- package/dist/cli/commands/wave-plan-commands.js +93 -0
- 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 +66 -9
- package/dist/services/context/build-dispatch-system-prompt.js +132 -17
- package/dist/services/context/context-audit-hint.d.ts +79 -0
- package/dist/services/context/context-audit-hint.js +150 -0
- package/dist/services/context/context-audit.d.ts +100 -0
- package/dist/services/context/context-audit.js +322 -0
- package/dist/services/context/summary-view.d.ts +54 -0
- package/dist/services/context/summary-view.js +114 -0
- package/dist/services/dispatch/file-overlap-wave-planner.d.ts +70 -0
- package/dist/services/dispatch/file-overlap-wave-planner.js +119 -0
- package/dist/services/dispatch/session-capsule.d.ts +23 -0
- package/dist/services/dispatch/session-capsule.js +56 -0
- package/dist/services/dispatch/slice-dag.d.ts +9 -0
- package/dist/services/dispatch/slice-dag.js +9 -1
- package/dist/services/dispatch/test-tool-detection.d.ts +12 -1
- package/dist/services/dispatch/test-tool-detection.js +14 -13
- package/dist/services/hooks/auto-compact-hook-install.js +10 -1
- package/dist/services/hooks/write-gate.js +88 -0
- package/dist/services/ide/adapters/claude-code-adapter.d.ts +10 -0
- package/dist/services/ide/adapters/claude-code-adapter.js +20 -1
- package/dist/services/ide/ide-types.d.ts +15 -0
- package/dist/services/lint/detect-eslint.d.ts +2 -0
- package/dist/services/lint/detect-eslint.js +23 -9
- package/dist/services/lint/npx-resolver.d.ts +6 -0
- package/dist/services/lint/npx-resolver.js +38 -14
- package/dist/services/memory/project-memory-service/parsers/frontmatter.d.ts +5 -0
- package/dist/services/memory/project-memory-service/parsers/frontmatter.js +55 -5
- 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 +33 -0
- package/dist/services/skills/hooks-codegate-superpowers.js +34 -3
- package/dist/services/skills/hooks-settings-service.d.ts +10 -0
- package/dist/services/skills/hooks-settings-service.js +152 -61
- 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 +41 -5
- package/dist/services/workspace/claude-settings-template.js +116 -64
- package/dist/services/workspace/workspace-claude-settings-materializer.js +5 -1
- 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/bee/peaks-qa/SKILL.md +2 -0
- package/skills/bee/peaks-qa/references/qa-sub-agent-dispatch.md +12 -0
- package/skills/bee/peaks-rd/SKILL.md +2 -0
- package/skills/bee/peaks-rd/references/rd-sub-agent-dispatch.md +14 -0
- package/skills/bee/peaks-txt/SKILL.md +2 -0
- package/skills/bee/peaks-ui/SKILL.md +2 -0
- package/skills/peaks-code/SKILL.md +18 -0
- package/skills/peaks-code/references/browser-workflow.md +10 -1
- package/skills/peaks-code/references/context-governance.md +29 -0
- package/skills/peaks-doctor/SKILL.md +2 -0
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* All Playwright operations for `peaks web` (slice S1, file 11).
|
|
3
|
+
*
|
|
4
|
+
* One browser process per session; one browser CONTEXT per dispatch (Q8). The
|
|
5
|
+
* context is what isolates cookies/localStorage between dispatches, which is
|
|
6
|
+
* why the per-dispatch `pw-profiles/<dispatchId>` convention stores a
|
|
7
|
+
* Playwright `storageState.json` and never a Chromium `userDataDir`
|
|
8
|
+
* (orchestrator decision C1 — `launchPersistentContext` would spawn one browser
|
|
9
|
+
* process per dispatch and violate Q8).
|
|
10
|
+
*
|
|
11
|
+
* Every write target is passed through `assertUnder` and every screenshot uses
|
|
12
|
+
* an explicit absolute `path`, so nothing can land in the project root (AC1).
|
|
13
|
+
*
|
|
14
|
+
* `open` may additionally name a user-level LOGIN PROFILE (design §2:
|
|
15
|
+
* `peaks web open <url> [--profile <name>]`, the only verb that takes one). That
|
|
16
|
+
* profile is **READ-ONLY here**: it is loaded into the dispatch's context and is
|
|
17
|
+
* never written back. Whether an automated browse should refresh a user-level
|
|
18
|
+
* credential file is an undecided design question, so this module does not
|
|
19
|
+
* decide it — see `contextFor`.
|
|
20
|
+
*/
|
|
21
|
+
import { existsSync, mkdirSync, statSync } from 'node:fs';
|
|
22
|
+
import { dirname } from 'node:path';
|
|
23
|
+
import { getErrorMessage } from 'peaks-loop-shared/result';
|
|
24
|
+
import { capText, MAX_SNAP_BYTES, MAX_SNAP_DEPTH, MAX_TEXT_BYTES } from './bounded-output.js';
|
|
25
|
+
import { pruneAriaSnapshot, renderSnapshot } from './snapshot-pruner.js';
|
|
26
|
+
import { assertUnder, userWebProfilesDir, webContextStatePath, webDir, webProfilesDir, webShotPath } from './web-artifact-paths.js';
|
|
27
|
+
import { loginStorageStatePath, resolveProfileName } from './web-login-profile.js';
|
|
28
|
+
/**
|
|
29
|
+
* Core Web Vitals need observers registered BEFORE the page loads
|
|
30
|
+
* (orchestrator decision C4), so this is installed as a context init script
|
|
31
|
+
* rather than run after navigation.
|
|
32
|
+
*/
|
|
33
|
+
const VITALS_INIT_SCRIPT = `(() => {
|
|
34
|
+
if (globalThis.__peaksWebVitals) return;
|
|
35
|
+
const vitals = (globalThis.__peaksWebVitals = { lcp: null, cls: 0, inp: null });
|
|
36
|
+
try {
|
|
37
|
+
new PerformanceObserver((list) => {
|
|
38
|
+
const entries = list.getEntries();
|
|
39
|
+
if (entries.length > 0) vitals.lcp = entries[entries.length - 1].startTime;
|
|
40
|
+
}).observe({ type: 'largest-contentful-paint', buffered: true });
|
|
41
|
+
new PerformanceObserver((list) => {
|
|
42
|
+
for (const entry of list.getEntries()) {
|
|
43
|
+
if (!entry.hadRecentInput) vitals.cls += entry.value;
|
|
44
|
+
}
|
|
45
|
+
}).observe({ type: 'layout-shift', buffered: true });
|
|
46
|
+
new PerformanceObserver((list) => {
|
|
47
|
+
const entries = list.getEntries();
|
|
48
|
+
if (entries.length > 0) {
|
|
49
|
+
vitals.inp = Math.max(vitals.inp ?? 0, entries[entries.length - 1].duration);
|
|
50
|
+
}
|
|
51
|
+
}).observe({ type: 'event', buffered: true, durationThreshold: 16 });
|
|
52
|
+
} catch {
|
|
53
|
+
// Observer types unsupported by this engine: metrics stays unavailable.
|
|
54
|
+
}
|
|
55
|
+
})();`;
|
|
56
|
+
/**
|
|
57
|
+
* Total budget for one teardown, and the budget for any single step inside it.
|
|
58
|
+
*
|
|
59
|
+
* `stopDaemon` waits `STOP_EXIT_TIMEOUT_MS = 10 s` for the daemon to leave and
|
|
60
|
+
* then falls back to `SIGTERM`, which on Windows is `TerminateProcess`: it kills
|
|
61
|
+
* the daemon mid-teardown and orphans its chromium. An unbounded `closeAll` /
|
|
62
|
+
* `browser.close` therefore converts a SLOW teardown into a LEAKED browser — so
|
|
63
|
+
* every step is bounded, and the whole thing finishes inside the waiter.
|
|
64
|
+
*
|
|
65
|
+
* Retuned after the S3 acceptance run recorded
|
|
66
|
+
* `teardown step failed: WEB_TEARDOWN_TIMEOUT: browser.close did not finish
|
|
67
|
+
* within 1500 ms` on a loaded machine. Measured here: an idle `browser.close()`
|
|
68
|
+
* on the headless shell takes **102 ms**, so the old step budget left only ~15×
|
|
69
|
+
* on a quiet box and ran out when the machine was busy. The step budget is now
|
|
70
|
+
* 2 000 ms.
|
|
71
|
+
*
|
|
72
|
+
* The loop budget stays ABOVE the step budget — that ordering is a separate
|
|
73
|
+
* guarantee, tested by its own case: after ONE wedged step is abandoned, the
|
|
74
|
+
* loop must still reach the remaining contexts. So the constant that moved to
|
|
75
|
+
* pay for the larger step is the waiter, not the loop:
|
|
76
|
+
*
|
|
77
|
+
* loop 3 000 + 4 x step 2 000 = 11 000 ms < STOP_EXIT_TIMEOUT_MS = 15 000 ms
|
|
78
|
+
*
|
|
79
|
+
* (worst case: the loop's last iteration starts just under its deadline and
|
|
80
|
+
* runs two steps, then `browser.close`, then `stopListening`). The waiter polls,
|
|
81
|
+
* so a normal teardown returns as soon as the process exits and pays nothing for
|
|
82
|
+
* the larger ceiling; what it buys is that a slow-but-bounded teardown is no
|
|
83
|
+
* longer SIGTERM'd mid-way, which is what orphans a chromium (AC6). The failure
|
|
84
|
+
* stays visible either way: `reportTeardownFailure` writes every overrun to
|
|
85
|
+
* `daemon.log`, which is how this one was found.
|
|
86
|
+
*/
|
|
87
|
+
const TEARDOWN_BUDGET_MS = 3_000;
|
|
88
|
+
const TEARDOWN_STEP_TIMEOUT_MS = 2_000;
|
|
89
|
+
/**
|
|
90
|
+
* The most distinct dispatch contexts one daemon will hold open.
|
|
91
|
+
*
|
|
92
|
+
* A `dispatchId` comes off the wire and there is deliberately no idle-exit
|
|
93
|
+
* (Q7), so an unbounded map would let a token-holder grow the daemon's memory
|
|
94
|
+
* one context at a time for its whole life. Past the cap the op is refused by
|
|
95
|
+
* name rather than silently evicting a context a live dispatch is still using.
|
|
96
|
+
*/
|
|
97
|
+
const MAX_DISPATCH_CONTEXTS = 32;
|
|
98
|
+
/**
|
|
99
|
+
* Resolve with `work`, or reject once the step has taken longer than the step
|
|
100
|
+
* budget. The late settlement of `work` is consumed here, so a step that
|
|
101
|
+
* finishes after its deadline cannot become an unhandled rejection.
|
|
102
|
+
*/
|
|
103
|
+
export function boundedTeardownStep(work, label) {
|
|
104
|
+
return new Promise((settle, reject) => {
|
|
105
|
+
const timer = setTimeout(() => {
|
|
106
|
+
reject(new Error(`WEB_TEARDOWN_TIMEOUT: ${label} did not finish within ${String(TEARDOWN_STEP_TIMEOUT_MS)} ms`));
|
|
107
|
+
}, TEARDOWN_STEP_TIMEOUT_MS);
|
|
108
|
+
work.then((value) => {
|
|
109
|
+
clearTimeout(timer);
|
|
110
|
+
settle(value);
|
|
111
|
+
}, (error) => {
|
|
112
|
+
clearTimeout(timer);
|
|
113
|
+
reject(error);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
export class BrowserSessionManager {
|
|
118
|
+
browser;
|
|
119
|
+
projectRoot;
|
|
120
|
+
sessionId;
|
|
121
|
+
sessions = new Map();
|
|
122
|
+
constructor(browser, options) {
|
|
123
|
+
this.browser = browser;
|
|
124
|
+
this.projectRoot = options.projectRoot;
|
|
125
|
+
this.sessionId = options.sessionId;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* The context for a dispatch, created on first use and reused afterwards.
|
|
129
|
+
*
|
|
130
|
+
* `profile` names a user-level login profile (`peaks web login --profile`) to
|
|
131
|
+
* build the context from. When given it REPLACES the dispatch's own state: a
|
|
132
|
+
* context holds one storage state, so "start from the profile" and "start from
|
|
133
|
+
* the per-dispatch state" are alternatives, not layers.
|
|
134
|
+
*
|
|
135
|
+
* The profile is **READ-ONLY**. This module reads it into the context and
|
|
136
|
+
* never writes, refreshes or trims it — write-back is an undecided design
|
|
137
|
+
* question (it would keep a login fresh without re-running `login`, and it
|
|
138
|
+
* would also let an automated browse silently rewrite a user-level credential
|
|
139
|
+
* file), so this slice does not decide it. `closeAll` still persists the
|
|
140
|
+
* dispatch's OWN `pw-profiles/<dispatchId>/storageState.json`; nothing under
|
|
141
|
+
* `~/.peaks/web-profiles/` is ever created or modified from here.
|
|
142
|
+
*
|
|
143
|
+
* The name is validated HERE and not only in the CLI, because it arrives off
|
|
144
|
+
* the wire: `resolveProfileName` is the SAME resolver `login` uses (folds to
|
|
145
|
+
* lower case, refuses a traversing name, a leading dot, a device name), and
|
|
146
|
+
* the read is anchored with `assertUnder`. A profile that does not exist is a
|
|
147
|
+
* NAMED failure — falling through to an unauthenticated context would answer
|
|
148
|
+
* "open this with my profile" with a logged-out browser and no signal.
|
|
149
|
+
*/
|
|
150
|
+
async contextFor(dispatchId, rawProfile) {
|
|
151
|
+
// Resolved before the dedup check, so the value compared below is the
|
|
152
|
+
// canonical, ≤64-char name and never the raw text off the wire.
|
|
153
|
+
const profile = rawProfile === undefined ? null : resolveProfileName(rawProfile);
|
|
154
|
+
const existing = this.sessions.get(dispatchId);
|
|
155
|
+
if (existing !== undefined) {
|
|
156
|
+
if (profile !== null && profile !== existing.profile) {
|
|
157
|
+
// Not a silent wrong answer: this dispatch already has a context and it
|
|
158
|
+
// was NOT built from the profile just asked for, so reusing it would
|
|
159
|
+
// serve a request for one profile with a browser logged in as another
|
|
160
|
+
// (or as nobody).
|
|
161
|
+
throw new Error(`WEB_PROFILE_CONFLICT: dispatch ${dispatchId} already has a browser context ` +
|
|
162
|
+
(existing.profile === null
|
|
163
|
+
? 'that was not created from a profile'
|
|
164
|
+
: `created from profile "${existing.profile}"`) +
|
|
165
|
+
`; it cannot be reopened as "${profile}" — use a fresh dispatch id or stop the daemon`);
|
|
166
|
+
}
|
|
167
|
+
return existing.context;
|
|
168
|
+
}
|
|
169
|
+
if (this.sessions.size >= MAX_DISPATCH_CONTEXTS) {
|
|
170
|
+
throw new Error(`WEB_DISPATCH_LIMIT: this daemon already holds ${String(MAX_DISPATCH_CONTEXTS)} dispatch contexts; ` +
|
|
171
|
+
'stop the daemon to release them');
|
|
172
|
+
}
|
|
173
|
+
const statePath = webContextStatePath(this.projectRoot, this.sessionId, dispatchId);
|
|
174
|
+
// The state file lives under `pw-profiles/`, a SIBLING of `web/` — guarding
|
|
175
|
+
// it with `webDir` rejects every dispatch.
|
|
176
|
+
assertUnder(statePath, webProfilesDir(this.projectRoot, this.sessionId));
|
|
177
|
+
const profileStatePath = profile === null ? null : existingProfileStatePath(profile);
|
|
178
|
+
const context = await this.browser.newContext({
|
|
179
|
+
acceptDownloads: false,
|
|
180
|
+
...(profileStatePath !== null
|
|
181
|
+
? { storageState: profileStatePath }
|
|
182
|
+
: existsSync(statePath)
|
|
183
|
+
? { storageState: statePath }
|
|
184
|
+
: {})
|
|
185
|
+
});
|
|
186
|
+
await context.addInitScript(VITALS_INIT_SCRIPT);
|
|
187
|
+
this.sessions.set(dispatchId, { context, profile, page: null });
|
|
188
|
+
return context;
|
|
189
|
+
}
|
|
190
|
+
async open(dispatchId, url, profile) {
|
|
191
|
+
assertNavigableUrl(url);
|
|
192
|
+
const page = await this.pageFor(dispatchId, profile);
|
|
193
|
+
await page.goto(url, { waitUntil: 'load' });
|
|
194
|
+
// `url()` after redirects and `title()` are both page-controlled.
|
|
195
|
+
return {
|
|
196
|
+
url: capText(page.url(), MAX_TEXT_BYTES).text,
|
|
197
|
+
title: capText(await page.title(), MAX_TEXT_BYTES).text
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
async text(dispatchId, selector) {
|
|
201
|
+
const page = await this.pageFor(dispatchId);
|
|
202
|
+
const raw = await page.locator(selector ?? 'body').innerText();
|
|
203
|
+
const capped = capText(raw, MAX_TEXT_BYTES);
|
|
204
|
+
return { text: capped.text, truncated: capped.truncated, droppedBytes: capped.droppedBytes };
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Primary path is `locator.ariaSnapshotJSON()` (Playwright 1.63, our exact
|
|
208
|
+
* pin). We do NOT re-derive the tree from the YAML form: parsing YAML by
|
|
209
|
+
* indentation is the fragile option, and under an exact pin the JSON API is
|
|
210
|
+
* always present (tech-doc §4.1/§4.2). A missing API is an explicit error
|
|
211
|
+
* rather than a silent degradation.
|
|
212
|
+
*/
|
|
213
|
+
async snap(dispatchId, selector) {
|
|
214
|
+
const page = await this.pageFor(dispatchId);
|
|
215
|
+
const locator = page.locator(selector ?? 'body');
|
|
216
|
+
const captureJson = locator.ariaSnapshotJSON?.bind(locator);
|
|
217
|
+
if (captureJson === undefined) {
|
|
218
|
+
throw new Error(`WEB_SNAP_UNSUPPORTED: locator.ariaSnapshotJSON is missing (peaks web pins playwright@1.63.0)`);
|
|
219
|
+
}
|
|
220
|
+
const raw = await captureJson({ mode: 'default', depth: MAX_SNAP_DEPTH });
|
|
221
|
+
const pruned = pruneAriaSnapshot(asAriaNodes(raw));
|
|
222
|
+
const capped = capText(renderSnapshot(pruned.nodes), MAX_SNAP_BYTES);
|
|
223
|
+
return {
|
|
224
|
+
snapshot: capped.text,
|
|
225
|
+
droppedNodes: pruned.droppedNodes,
|
|
226
|
+
depthCapped: pruned.depthCapped,
|
|
227
|
+
nodeCapped: pruned.nodeCapped,
|
|
228
|
+
truncated: capped.truncated,
|
|
229
|
+
droppedBytes: capped.droppedBytes
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
async click(dispatchId, selector) {
|
|
233
|
+
const page = await this.pageFor(dispatchId);
|
|
234
|
+
await page.locator(selector).click();
|
|
235
|
+
// The interpolated title is page-controlled; only the selector is ours.
|
|
236
|
+
return { result: capText(`clicked ${selector} (page: ${await page.title()})`, MAX_TEXT_BYTES).text };
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Screenshot to an absolute path under `web/`. Always an explicit `path`:
|
|
240
|
+
* never the Playwright default, and never a Buffer we write ourselves
|
|
241
|
+
* (tech-doc §7.2 rule 3).
|
|
242
|
+
*/
|
|
243
|
+
async shot(dispatchId, selector) {
|
|
244
|
+
const page = await this.pageFor(dispatchId);
|
|
245
|
+
const target = webShotPath(this.projectRoot, this.sessionId, shotTimestamp());
|
|
246
|
+
assertUnder(target, webDir(this.projectRoot, this.sessionId));
|
|
247
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
248
|
+
if (selector === undefined) {
|
|
249
|
+
await page.screenshot({ path: target, type: 'png' });
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
await page.locator(selector).screenshot({ path: target, type: 'png' });
|
|
253
|
+
}
|
|
254
|
+
return { path: target, bytes: statSync(target).size };
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Core Web Vitals for the dispatch's page. Returns `available: false` with a
|
|
258
|
+
* reason — never fabricated zeros — when there is no observation window
|
|
259
|
+
* (orchestrator decision C4), and never echoes the page's object back: only
|
|
260
|
+
* the three contract keys, only finite numbers (R2).
|
|
261
|
+
*/
|
|
262
|
+
async metrics(dispatchId) {
|
|
263
|
+
const session = this.sessions.get(dispatchId);
|
|
264
|
+
if (session === undefined || session.page === null) {
|
|
265
|
+
return { available: false, reason: 'no-observation-window', values: null };
|
|
266
|
+
}
|
|
267
|
+
const values = readVitals(await session.page.evaluate('globalThis.__peaksWebVitals ?? null'));
|
|
268
|
+
if (values === null) {
|
|
269
|
+
return { available: false, reason: 'no-observation-window', values: null };
|
|
270
|
+
}
|
|
271
|
+
return { available: true, reason: null, values };
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Persist each dispatch's storage state, then close every context exactly
|
|
275
|
+
* once. A state file that cannot be written must not block shutdown — but it
|
|
276
|
+
* must be reported: a silent skip here kills S4's persistence with no signal
|
|
277
|
+
* anywhere (R1).
|
|
278
|
+
*/
|
|
279
|
+
async closeAll() {
|
|
280
|
+
let closedContexts = 0;
|
|
281
|
+
const stateWriteFailures = [];
|
|
282
|
+
const deadline = Date.now() + TEARDOWN_BUDGET_MS;
|
|
283
|
+
for (const [dispatchId, session] of this.sessions) {
|
|
284
|
+
if (Date.now() >= deadline) {
|
|
285
|
+
// Out of budget: `browser.close()` below is what reaps the remaining
|
|
286
|
+
// contexts, and it must still get its turn before the caller's
|
|
287
|
+
// SIGTERM. `closedContexts` keeps counting only what really closed.
|
|
288
|
+
break;
|
|
289
|
+
}
|
|
290
|
+
const statePath = webContextStatePath(this.projectRoot, this.sessionId, dispatchId);
|
|
291
|
+
try {
|
|
292
|
+
assertUnder(statePath, webProfilesDir(this.projectRoot, this.sessionId));
|
|
293
|
+
mkdirSync(dirname(statePath), { recursive: true });
|
|
294
|
+
await boundedTeardownStep(session.context.storageState({ path: statePath }), `storageState for dispatch ${dispatchId}`);
|
|
295
|
+
}
|
|
296
|
+
catch (error) {
|
|
297
|
+
stateWriteFailures.push({ dispatchId, reason: getErrorMessage(error) });
|
|
298
|
+
}
|
|
299
|
+
try {
|
|
300
|
+
await boundedTeardownStep(session.context.close(), `context close for dispatch ${dispatchId}`);
|
|
301
|
+
closedContexts += 1;
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
// Already closed, or wedged past its budget: teardown must still reach
|
|
305
|
+
// the remaining dispatches and the final `browser.close()`.
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
this.sessions.clear();
|
|
309
|
+
return { closedContexts, stateWriteFailures };
|
|
310
|
+
}
|
|
311
|
+
async pageFor(dispatchId, profile) {
|
|
312
|
+
// Called on EVERY path, not only when the dispatch has no session yet:
|
|
313
|
+
// `contextFor` is already the dedup lookup (it returns the existing
|
|
314
|
+
// context), and reuse is where a profile mismatch has to be caught.
|
|
315
|
+
await this.contextFor(dispatchId, profile);
|
|
316
|
+
const active = this.sessions.get(dispatchId);
|
|
317
|
+
if (active === undefined) {
|
|
318
|
+
throw new Error(`WEB_CONTEXT_MISSING: no browser context for dispatch ${dispatchId}`);
|
|
319
|
+
}
|
|
320
|
+
if (active.page === null) {
|
|
321
|
+
active.page = await active.context.newPage();
|
|
322
|
+
}
|
|
323
|
+
return active.page;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* The storage state a named profile loads: `~/.peaks/web-profiles/<name>/storageState.json`.
|
|
328
|
+
*
|
|
329
|
+
* READ-ONLY by construction: this function only ever READS the path it returns,
|
|
330
|
+
* and the module never writes one.
|
|
331
|
+
*
|
|
332
|
+
* `name` is already canonical (the caller ran `resolveProfileName`); the
|
|
333
|
+
* resolver inside `loginStorageStatePath` is idempotent on a canonical name, so
|
|
334
|
+
* the path literal stays in one place. The read is anchored with `assertUnder`
|
|
335
|
+
* the same way the write site is, and `resolveProfileName`'s own `assertUnder`
|
|
336
|
+
* has already rejected a name that climbs out of the profile root.
|
|
337
|
+
*
|
|
338
|
+
* A missing profile is a NAMED failure. The `existsSync`-guarded spread in
|
|
339
|
+
* `contextFor` is what it must never become: that pattern silently yields an
|
|
340
|
+
* unauthenticated context, which is the one answer a caller who asked for a
|
|
341
|
+
* profile must not be given.
|
|
342
|
+
*/
|
|
343
|
+
function existingProfileStatePath(name) {
|
|
344
|
+
const statePath = loginStorageStatePath(name);
|
|
345
|
+
assertUnder(statePath, userWebProfilesDir());
|
|
346
|
+
if (!existsSync(statePath)) {
|
|
347
|
+
throw new Error(`WEB_PROFILE_NOT_FOUND: there is no login profile "${name}" at ${statePath} — create it ` +
|
|
348
|
+
`with \`peaks web login --profile ${name}\``);
|
|
349
|
+
}
|
|
350
|
+
return statePath;
|
|
351
|
+
}
|
|
352
|
+
/** `YYYYMMDDTHHMMSSmmmZ` — no colons, so the file name is Windows-safe (§7.1). */
|
|
353
|
+
function shotTimestamp(now = new Date()) {
|
|
354
|
+
return now.toISOString().replace(/[-:.]/g, '');
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* The aria payload as nodes. A shape mismatch is an explicit error, matching
|
|
358
|
+
* `snap`'s policy for a missing API: an empty-but-`ok` snapshot would be read
|
|
359
|
+
* as "the page is empty" rather than "the response was not a node array".
|
|
360
|
+
* An empty array stays valid — that is a genuinely empty page.
|
|
361
|
+
*/
|
|
362
|
+
function asAriaNodes(raw) {
|
|
363
|
+
if (!Array.isArray(raw)) {
|
|
364
|
+
throw new Error(`WEB_SNAP_SHAPE_UNEXPECTED: ariaSnapshotJSON returned ${typeof raw}, not a node array`);
|
|
365
|
+
}
|
|
366
|
+
const nodes = raw.filter((node) => typeof node === 'object' && node !== null && typeof node.role === 'string');
|
|
367
|
+
if (nodes.length !== raw.length) {
|
|
368
|
+
throw new Error(`WEB_SNAP_SHAPE_UNEXPECTED: ariaSnapshotJSON returned ${raw.length - nodes.length} node(s) without a string role`);
|
|
369
|
+
}
|
|
370
|
+
return nodes;
|
|
371
|
+
}
|
|
372
|
+
/** The only vitals keys `metrics` reports (orchestrator decision C4's contract). */
|
|
373
|
+
const VITALS_KEYS = ['lcp', 'cls', 'inp'];
|
|
374
|
+
/**
|
|
375
|
+
* The page's vitals, read as DATA we bounded ourselves. `addInitScript`
|
|
376
|
+
* installs the object, but a page may replace it afterwards, so nothing is
|
|
377
|
+
* copied through: only C4's three keys, and only finite numbers. A payload
|
|
378
|
+
* without a single numeric metric is not an observation window at all.
|
|
379
|
+
*/
|
|
380
|
+
function readVitals(raw) {
|
|
381
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
382
|
+
return null;
|
|
383
|
+
}
|
|
384
|
+
const source = raw;
|
|
385
|
+
const values = {};
|
|
386
|
+
let observed = false;
|
|
387
|
+
for (const key of VITALS_KEYS) {
|
|
388
|
+
const value = source[key];
|
|
389
|
+
const numeric = typeof value === 'number' && Number.isFinite(value);
|
|
390
|
+
values[key] = numeric ? value : null;
|
|
391
|
+
observed = observed || numeric;
|
|
392
|
+
}
|
|
393
|
+
return observed ? values : null;
|
|
394
|
+
}
|
|
395
|
+
/** The only schemes `open` will navigate to. */
|
|
396
|
+
const ALLOWED_URL_SCHEMES = new Set(['http:', 'https:']);
|
|
397
|
+
/**
|
|
398
|
+
* `page.goto` would follow `file:` (a local-disk read) and any loopback or
|
|
399
|
+
* cloud-metadata host, so the scheme is allowlisted at the argument — before a
|
|
400
|
+
* context exists. Host policy belongs to S3's disable gate, not here.
|
|
401
|
+
*/
|
|
402
|
+
function assertNavigableUrl(raw) {
|
|
403
|
+
let parsed;
|
|
404
|
+
try {
|
|
405
|
+
parsed = new URL(raw);
|
|
406
|
+
}
|
|
407
|
+
catch {
|
|
408
|
+
throw new Error(`WEB_URL_INVALID: ${raw} is not an absolute URL`);
|
|
409
|
+
}
|
|
410
|
+
if (!ALLOWED_URL_SCHEMES.has(parsed.protocol)) {
|
|
411
|
+
throw new Error(`WEB_URL_SCHEME_REJECTED: ${parsed.protocol} is not http: or https:`);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `peaks web` daemon process entry (slice S2, file 15).
|
|
3
|
+
*
|
|
4
|
+
* This file IS the process: it is what `spawnDaemon` launches, so it must run
|
|
5
|
+
* under plain `node` (built tree) as well as under `tsx` (source tree). It is
|
|
6
|
+
* deliberately a thin shell — read the environment, start the service, register
|
|
7
|
+
* with the parent, wire the signals. Everything worth testing lives in
|
|
8
|
+
* `web-daemon-service.ts`.
|
|
9
|
+
*
|
|
10
|
+
* stdout and stderr are already redirected to `web/daemon/daemon.log` by
|
|
11
|
+
* `spawnDaemon`, which is why every diagnostic here is a plain stderr write and
|
|
12
|
+
* never a `console.log` to a terminal (tech-doc §7.2 rule 6).
|
|
13
|
+
*/
|
|
14
|
+
import { getErrorMessage } from 'peaks-loop-shared/result';
|
|
15
|
+
import { CLI_VERSION } from 'peaks-loop-shared/version';
|
|
16
|
+
import { registerWithParent } from './daemon-supervisor.js';
|
|
17
|
+
import { webDir } from './web-artifact-paths.js';
|
|
18
|
+
import { startWebDaemon } from './web-daemon-service.js';
|
|
19
|
+
function fatal(reason) {
|
|
20
|
+
process.stderr.write(`peaks web daemon: ${reason}\n`);
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
async function main() {
|
|
24
|
+
const projectRoot = process.env['PEAKS_WEB_PROJECT_ROOT'] ?? '';
|
|
25
|
+
const sessionId = process.env['PEAKS_WEB_SESSION_ID'] ?? '';
|
|
26
|
+
if (projectRoot === '' || sessionId === '') {
|
|
27
|
+
fatal('PEAKS_WEB_PROJECT_ROOT and PEAKS_WEB_SESSION_ID are both required');
|
|
28
|
+
}
|
|
29
|
+
// The artifact dir is derived, never taken on trust: a mis-set env would make
|
|
30
|
+
// the daemon write outside this session's `web/` tree (AC1), and the failure
|
|
31
|
+
// would surface as a mystery `WEB_PATH_ESCAPE` much later.
|
|
32
|
+
const derivedArtifactDir = webDir(projectRoot, sessionId);
|
|
33
|
+
const declaredArtifactDir = process.env['PEAKS_WEB_ARTIFACT_DIR'] ?? '';
|
|
34
|
+
if (declaredArtifactDir !== derivedArtifactDir) {
|
|
35
|
+
fatal(`PEAKS_WEB_ARTIFACT_DIR ${JSON.stringify(declaredArtifactDir)} does not match this session's web dir ${JSON.stringify(derivedArtifactDir)}`);
|
|
36
|
+
}
|
|
37
|
+
const daemon = await startWebDaemon({ projectRoot, sessionId, version: CLI_VERSION });
|
|
38
|
+
// Q7: reuse the existing sub-agent shutdown registry, best-effort. Lifetime
|
|
39
|
+
// is register + parent kill + `peaks web stop` — there is no idle-exit.
|
|
40
|
+
registerWithParent(process.pid, process.env['PEAKS_DISPATCH_ID'] ?? 'current');
|
|
41
|
+
let closing = false;
|
|
42
|
+
const shutdown = async () => {
|
|
43
|
+
if (closing) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
closing = true;
|
|
47
|
+
const closed = await daemon.close();
|
|
48
|
+
// Never silent: a dispatch whose `storageState.json` did not persist is
|
|
49
|
+
// silent data loss for S4's login profile, and the daemon's stderr is the
|
|
50
|
+
// only channel that reaches `daemon.log` (R1).
|
|
51
|
+
for (const failure of closed.stateWriteFailures) {
|
|
52
|
+
process.stderr.write(`peaks web daemon: could not persist storageState for dispatch ${failure.dispatchId}: ${failure.reason}\n`);
|
|
53
|
+
}
|
|
54
|
+
process.exit(0);
|
|
55
|
+
};
|
|
56
|
+
process.on('SIGTERM', () => {
|
|
57
|
+
void shutdown();
|
|
58
|
+
});
|
|
59
|
+
process.on('SIGINT', () => {
|
|
60
|
+
void shutdown();
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
main().catch((error) => {
|
|
64
|
+
fatal(`startup failed: ${getErrorMessage(error)}`);
|
|
65
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type WebDaemonInfo } from './web-protocol.js';
|
|
2
|
+
/** The token's directory: readable only by its owner, same reasoning as above. */
|
|
3
|
+
export declare const DAEMON_DIR_MODE = 448;
|
|
4
|
+
export declare function writeDaemonInfo(projectRoot: string, sessionId: string, info: WebDaemonInfo): void;
|
|
5
|
+
/**
|
|
6
|
+
* Read + validate `daemon.json`. `null` when absent, malformed, a stale
|
|
7
|
+
* protocol, or — R6 — when the file claims to describe a DIFFERENT
|
|
8
|
+
* `(projectRoot, sessionId)` than the caller's.
|
|
9
|
+
*
|
|
10
|
+
* The ownership comparison belongs here rather than in `parseDaemonInfo`,
|
|
11
|
+
* because only the caller knows what the record is supposed to describe. A
|
|
12
|
+
* planted `daemon.json` naming someone else's session would otherwise redirect
|
|
13
|
+
* every op (page URL, selectors, bearer token) to a port of the attacker's
|
|
14
|
+
* choosing, and — via `stopDaemon` — aim `process.kill` at an unrelated pid.
|
|
15
|
+
*/
|
|
16
|
+
export declare function readDaemonInfo(projectRoot: string, sessionId: string): WebDaemonInfo | null;
|
|
17
|
+
/** Remove `daemon.json`. Idempotent: a missing file is not an error. */
|
|
18
|
+
export declare function removeDaemonInfo(projectRoot: string, sessionId: string): void;
|
|
19
|
+
/**
|
|
20
|
+
* `process.kill(pid, 0)` is the cross-platform existence probe: it throws
|
|
21
|
+
* `ESRCH` when no such process exists and `EPERM` when one exists but belongs
|
|
22
|
+
* to another user and so may not be signalled. Only `ESRCH` means dead — an
|
|
23
|
+
* alive-but-unsignalable pid must not be reported as dead, or `stopDaemon`
|
|
24
|
+
* skips the kill and `acquireSpawnLock` reclaims a lock whose owner still
|
|
25
|
+
* holds it.
|
|
26
|
+
*/
|
|
27
|
+
export declare function isProcessAlive(pid: number): boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Take the cold-start lock (O_EXCL create). Returns `false` when another
|
|
30
|
+
* process holds a live lock, and reclaims a STALE one — owner pid dead, or
|
|
31
|
+
* older than `SPAWN_LOCK_STALE_MS` — before retrying once (tech-doc §1.4).
|
|
32
|
+
*/
|
|
33
|
+
export declare function acquireSpawnLock(projectRoot: string, sessionId: string): boolean;
|
|
34
|
+
/** Release the spawn lock, but only when this process is its owner. */
|
|
35
|
+
export declare function releaseSpawnLock(projectRoot: string, sessionId: string): void;
|
|
36
|
+
/**
|
|
37
|
+
* The daemon instances for a session. There is one daemon per
|
|
38
|
+
* `(projectRoot, sessionId)` (design §10.2), so this list holds at most one
|
|
39
|
+
* entry; it is an array because the S2 status report folds it together with the
|
|
40
|
+
* lock and `/health` classification of each instance.
|
|
41
|
+
*/
|
|
42
|
+
export declare function listSessionDaemons(projectRoot: string, sessionId: string): WebDaemonInfo[];
|