@web-auto/camo 0.1.2 → 0.1.4
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 +137 -0
- package/package.json +7 -3
- package/scripts/check-file-size.mjs +80 -0
- package/scripts/file-size-policy.json +8 -0
- package/src/autoscript/action-providers/index.mjs +9 -0
- package/src/autoscript/action-providers/xhs/comments.mjs +412 -0
- package/src/autoscript/action-providers/xhs/common.mjs +77 -0
- package/src/autoscript/action-providers/xhs/detail.mjs +181 -0
- package/src/autoscript/action-providers/xhs/interaction.mjs +466 -0
- package/src/autoscript/action-providers/xhs/like-rules.mjs +57 -0
- package/src/autoscript/action-providers/xhs/persistence.mjs +167 -0
- package/src/autoscript/action-providers/xhs/search.mjs +174 -0
- package/src/autoscript/action-providers/xhs.mjs +133 -0
- package/src/autoscript/impact-engine.mjs +78 -0
- package/src/autoscript/runtime.mjs +1015 -0
- package/src/autoscript/schema.mjs +370 -0
- package/src/autoscript/xhs-unified-template.mjs +931 -0
- package/src/cli.mjs +190 -78
- package/src/commands/autoscript.mjs +1100 -0
- package/src/commands/browser.mjs +20 -4
- package/src/commands/container.mjs +401 -0
- package/src/commands/events.mjs +152 -0
- package/src/commands/lifecycle.mjs +17 -3
- package/src/commands/window.mjs +32 -1
- package/src/container/change-notifier.mjs +311 -0
- package/src/container/element-filter.mjs +143 -0
- package/src/container/index.mjs +3 -0
- package/src/container/runtime-core/checkpoint.mjs +195 -0
- package/src/container/runtime-core/index.mjs +21 -0
- package/src/container/runtime-core/operations/index.mjs +351 -0
- package/src/container/runtime-core/operations/selector-scripts.mjs +68 -0
- package/src/container/runtime-core/operations/tab-pool.mjs +544 -0
- package/src/container/runtime-core/operations/viewport.mjs +143 -0
- package/src/container/runtime-core/subscription.mjs +87 -0
- package/src/container/runtime-core/utils.mjs +94 -0
- package/src/container/runtime-core/validation.mjs +127 -0
- package/src/container/runtime-core.mjs +1 -0
- package/src/container/subscription-registry.mjs +459 -0
- package/src/core/actions.mjs +573 -0
- package/src/core/browser.mjs +270 -0
- package/src/core/index.mjs +53 -0
- package/src/core/utils.mjs +87 -0
- package/src/events/daemon-entry.mjs +33 -0
- package/src/events/daemon.mjs +80 -0
- package/src/events/progress-log.mjs +109 -0
- package/src/events/ws-server.mjs +239 -0
- package/src/lib/client.mjs +200 -0
- package/src/lifecycle/session-registry.mjs +8 -4
- package/src/lifecycle/session-watchdog.mjs +220 -0
- package/src/utils/browser-service.mjs +232 -9
- package/src/utils/help.mjs +28 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core browser control module - Direct camoufox integration
|
|
3
|
+
* No external browser-service dependency
|
|
4
|
+
*/
|
|
5
|
+
import { spawn, execSync } from 'node:child_process';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
|
|
10
|
+
const CONFIG_DIR = path.join(os.homedir(), '.webauto');
|
|
11
|
+
const PROFILES_DIR = path.join(CONFIG_DIR, 'profiles');
|
|
12
|
+
|
|
13
|
+
// Active browser instances registry (in-memory)
|
|
14
|
+
const activeBrowsers = new Map();
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Detect camoufox executable path
|
|
18
|
+
*/
|
|
19
|
+
export function detectCamoufoxPath() {
|
|
20
|
+
try {
|
|
21
|
+
const cmd = process.platform === 'win32' ? 'python -m camoufox path' : 'python3 -m camoufox path';
|
|
22
|
+
const out = execSync(cmd, {
|
|
23
|
+
encoding: 'utf8',
|
|
24
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
25
|
+
timeout: 30000,
|
|
26
|
+
});
|
|
27
|
+
const lines = out.trim().split(/\r?\n/);
|
|
28
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
29
|
+
const line = lines[i].trim();
|
|
30
|
+
if (line && (line.startsWith('/') || line.match(/^[A-Z]:\\/))) return line;
|
|
31
|
+
}
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Ensure camoufox is installed
|
|
40
|
+
*/
|
|
41
|
+
export async function ensureCamoufox() {
|
|
42
|
+
const camoufoxPath = detectCamoufoxPath();
|
|
43
|
+
if (camoufoxPath) return camoufoxPath;
|
|
44
|
+
|
|
45
|
+
console.log('Camoufox not found. Installing...');
|
|
46
|
+
try {
|
|
47
|
+
execSync('npx --yes --package=camoufox camoufox fetch', { stdio: 'inherit' });
|
|
48
|
+
const newPath = detectCamoufoxPath();
|
|
49
|
+
if (!newPath) throw new Error('Camoufox install finished but executable was not detected');
|
|
50
|
+
console.log('Camoufox installed at:', newPath);
|
|
51
|
+
return newPath;
|
|
52
|
+
} catch (err) {
|
|
53
|
+
throw new Error(`Failed to install camoufox: ${err.message}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Get profile directory
|
|
59
|
+
*/
|
|
60
|
+
export function getProfileDir(profileId) {
|
|
61
|
+
return path.join(PROFILES_DIR, profileId);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Ensure profile exists
|
|
66
|
+
*/
|
|
67
|
+
export function ensureProfile(profileId) {
|
|
68
|
+
const profileDir = getProfileDir(profileId);
|
|
69
|
+
if (!fs.existsSync(profileDir)) {
|
|
70
|
+
fs.mkdirSync(profileDir, { recursive: true });
|
|
71
|
+
}
|
|
72
|
+
return profileDir;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Check if browser is running for profile
|
|
77
|
+
*/
|
|
78
|
+
export function isBrowserRunning(profileId) {
|
|
79
|
+
const browser = activeBrowsers.get(profileId);
|
|
80
|
+
if (!browser) return false;
|
|
81
|
+
return browser.process && !browser.process.killed;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Launch browser for profile
|
|
86
|
+
*/
|
|
87
|
+
export async function launchBrowser(profileId, options = {}) {
|
|
88
|
+
if (isBrowserRunning(profileId)) {
|
|
89
|
+
throw new Error(`Browser already running for profile: ${profileId}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const camoufoxPath = await ensureCamoufox();
|
|
93
|
+
const profileDir = ensureProfile(profileId);
|
|
94
|
+
|
|
95
|
+
// Build launch arguments
|
|
96
|
+
const args = [
|
|
97
|
+
'-P', profileDir,
|
|
98
|
+
'--headless=false',
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
if (options.url) {
|
|
102
|
+
args.push('-url', options.url);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Launch camoufox
|
|
106
|
+
const browserProcess = spawn(camoufoxPath, args, {
|
|
107
|
+
detached: false,
|
|
108
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const browser = {
|
|
112
|
+
profileId,
|
|
113
|
+
process: browserProcess,
|
|
114
|
+
profileDir,
|
|
115
|
+
startTime: Date.now(),
|
|
116
|
+
pages: [],
|
|
117
|
+
currentPage: 0,
|
|
118
|
+
wsEndpoint: null,
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
activeBrowsers.set(profileId, browser);
|
|
122
|
+
|
|
123
|
+
// Handle process exit
|
|
124
|
+
browserProcess.on('exit', (code) => {
|
|
125
|
+
activeBrowsers.delete(profileId);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// Wait for browser to be ready
|
|
129
|
+
await new Promise((resolve, reject) => {
|
|
130
|
+
const timeout = setTimeout(() => {
|
|
131
|
+
browserProcess.kill();
|
|
132
|
+
reject(new Error('Browser failed to start within timeout'));
|
|
133
|
+
}, 30000);
|
|
134
|
+
|
|
135
|
+
// Check for ready signal in stdout
|
|
136
|
+
browserProcess.stdout.on('data', (data) => {
|
|
137
|
+
const line = data.toString();
|
|
138
|
+
if (line.includes('Browser ready') || line.includes('Listening')) {
|
|
139
|
+
clearTimeout(timeout);
|
|
140
|
+
resolve();
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// Also resolve after a short delay as fallback
|
|
145
|
+
setTimeout(() => {
|
|
146
|
+
clearTimeout(timeout);
|
|
147
|
+
resolve();
|
|
148
|
+
}, 5000);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
return browser;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Stop browser for profile
|
|
156
|
+
*/
|
|
157
|
+
export async function stopBrowser(profileId) {
|
|
158
|
+
const browser = activeBrowsers.get(profileId);
|
|
159
|
+
if (!browser) {
|
|
160
|
+
throw new Error(`No browser running for profile: ${profileId}`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (browser.process && !browser.process.killed) {
|
|
164
|
+
browser.process.kill('SIGTERM');
|
|
165
|
+
// Wait for graceful shutdown
|
|
166
|
+
await new Promise((resolve) => {
|
|
167
|
+
const timeout = setTimeout(() => {
|
|
168
|
+
if (browser.process && !browser.process.killed) {
|
|
169
|
+
browser.process.kill('SIGKILL');
|
|
170
|
+
}
|
|
171
|
+
resolve();
|
|
172
|
+
}, 5000);
|
|
173
|
+
|
|
174
|
+
browser.process.on('exit', () => {
|
|
175
|
+
clearTimeout(timeout);
|
|
176
|
+
resolve();
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
activeBrowsers.delete(profileId);
|
|
182
|
+
return { ok: true, profileId, stopped: true };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Get browser status
|
|
187
|
+
*/
|
|
188
|
+
export function getBrowserStatus(profileId) {
|
|
189
|
+
if (profileId) {
|
|
190
|
+
const browser = activeBrowsers.get(profileId);
|
|
191
|
+
if (!browser) return null;
|
|
192
|
+
return {
|
|
193
|
+
profileId,
|
|
194
|
+
running: isBrowserRunning(profileId),
|
|
195
|
+
startTime: browser.startTime,
|
|
196
|
+
uptime: Date.now() - browser.startTime,
|
|
197
|
+
pages: browser.pages,
|
|
198
|
+
currentPage: browser.currentPage,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Return all sessions
|
|
203
|
+
return Array.from(activeBrowsers.entries()).map(([id, b]) => ({
|
|
204
|
+
profileId: id,
|
|
205
|
+
running: isBrowserRunning(id),
|
|
206
|
+
startTime: b.startTime,
|
|
207
|
+
uptime: Date.now() - b.startTime,
|
|
208
|
+
pages: b.pages,
|
|
209
|
+
currentPage: b.currentPage,
|
|
210
|
+
}));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Get Playwright browser instance for profile
|
|
215
|
+
* Creates one if needed using camoufox-js
|
|
216
|
+
*/
|
|
217
|
+
export async function getPlaywrightBrowser(profileId) {
|
|
218
|
+
const { chromium } = await import('playwright');
|
|
219
|
+
|
|
220
|
+
const browser = activeBrowsers.get(profileId);
|
|
221
|
+
if (!browser) {
|
|
222
|
+
throw new Error(`No browser session for profile: ${profileId}. Run 'camo start ${profileId}' first.`);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (browser.pwBrowser) {
|
|
226
|
+
return browser.pwBrowser;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Connect to camoufox using CDP
|
|
230
|
+
const pwBrowser = await chromium.connectOverCDP(browser.wsEndpoint || 'http://127.0.0.1:9222');
|
|
231
|
+
browser.pwBrowser = pwBrowser;
|
|
232
|
+
return pwBrowser;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Get current page for profile
|
|
237
|
+
*/
|
|
238
|
+
export async function getCurrentPage(profileId) {
|
|
239
|
+
const browser = activeBrowsers.get(profileId);
|
|
240
|
+
if (!browser) {
|
|
241
|
+
throw new Error(`No browser session for profile: ${profileId}`);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (browser.currentPage) {
|
|
245
|
+
return browser.currentPage;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Get page from Playwright
|
|
249
|
+
const pwBrowser = await getPlaywrightBrowser(profileId);
|
|
250
|
+
const contexts = pwBrowser.contexts();
|
|
251
|
+
if (contexts.length === 0) {
|
|
252
|
+
throw new Error('No browser contexts available');
|
|
253
|
+
}
|
|
254
|
+
const pages = contexts[0].pages();
|
|
255
|
+
if (pages.length === 0) {
|
|
256
|
+
throw new Error('No pages available');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
browser.currentPage = pages[pages.length - 1];
|
|
260
|
+
return browser.currentPage;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Get active browser (alias for registry lookup)
|
|
266
|
+
*/
|
|
267
|
+
export function getActiveBrowser(profileId) {
|
|
268
|
+
return activeBrowsers.get(profileId) || null;
|
|
269
|
+
}
|
|
270
|
+
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core module exports
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export * from './browser.mjs';
|
|
6
|
+
export * from './actions.mjs';
|
|
7
|
+
export * from './utils.mjs';
|
|
8
|
+
|
|
9
|
+
// Re-export commonly used functions
|
|
10
|
+
export {
|
|
11
|
+
detectCamoufoxPath,
|
|
12
|
+
ensureCamoufox,
|
|
13
|
+
launchBrowser,
|
|
14
|
+
stopBrowser,
|
|
15
|
+
getBrowserStatus,
|
|
16
|
+
isBrowserRunning,
|
|
17
|
+
getPlaywrightBrowser,
|
|
18
|
+
getCurrentPage,
|
|
19
|
+
getActiveBrowser,
|
|
20
|
+
} from './browser.mjs';
|
|
21
|
+
|
|
22
|
+
export {
|
|
23
|
+
navigateTo,
|
|
24
|
+
goBack,
|
|
25
|
+
takeScreenshot,
|
|
26
|
+
scrollPage,
|
|
27
|
+
clickElement,
|
|
28
|
+
typeText,
|
|
29
|
+
pressKey,
|
|
30
|
+
highlightElement,
|
|
31
|
+
clearHighlights,
|
|
32
|
+
setViewport,
|
|
33
|
+
getPageInfo,
|
|
34
|
+
getDOMSnapshot,
|
|
35
|
+
queryElements,
|
|
36
|
+
evaluateJS,
|
|
37
|
+
createNewPage,
|
|
38
|
+
listPages,
|
|
39
|
+
switchPage,
|
|
40
|
+
closePage,
|
|
41
|
+
mouseMove,
|
|
42
|
+
mouseClick,
|
|
43
|
+
mouseWheel,
|
|
44
|
+
} from './actions.mjs';
|
|
45
|
+
|
|
46
|
+
export {
|
|
47
|
+
waitFor,
|
|
48
|
+
retry,
|
|
49
|
+
withTimeout,
|
|
50
|
+
ensureUrlScheme,
|
|
51
|
+
looksLikeUrlToken,
|
|
52
|
+
getPositionals,
|
|
53
|
+
} from './utils.mjs';
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core utilities
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Wait helper
|
|
7
|
+
*/
|
|
8
|
+
export function waitFor(ms) {
|
|
9
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Retry with backoff
|
|
14
|
+
*/
|
|
15
|
+
export async function retry(fn, options = {}) {
|
|
16
|
+
const maxAttempts = options.maxAttempts || 3;
|
|
17
|
+
const delay = options.delay || 1000;
|
|
18
|
+
const backoff = options.backoff || 2;
|
|
19
|
+
|
|
20
|
+
let lastError;
|
|
21
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
22
|
+
try {
|
|
23
|
+
return await fn();
|
|
24
|
+
} catch (err) {
|
|
25
|
+
lastError = err;
|
|
26
|
+
if (attempt < maxAttempts) {
|
|
27
|
+
await waitFor(delay * Math.pow(backoff, attempt - 1));
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
throw lastError;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Timeout wrapper
|
|
37
|
+
*/
|
|
38
|
+
export async function withTimeout(promise, ms, message = 'Timeout') {
|
|
39
|
+
const timeout = new Promise((_, reject) => {
|
|
40
|
+
setTimeout(() => reject(new Error(message)), ms);
|
|
41
|
+
});
|
|
42
|
+
return Promise.race([promise, timeout]);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Format URL (ensure scheme)
|
|
47
|
+
*/
|
|
48
|
+
export function ensureUrlScheme(url) {
|
|
49
|
+
if (!url) return url;
|
|
50
|
+
if (url.startsWith('http://') || url.startsWith('https://')) return url;
|
|
51
|
+
if (url.startsWith('localhost') || url.match(/^\\d+\\.\\d+/)) {
|
|
52
|
+
return `http://${url}`;
|
|
53
|
+
}
|
|
54
|
+
return `https://${url}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Looks like URL token
|
|
59
|
+
*/
|
|
60
|
+
export function looksLikeUrlToken(token) {
|
|
61
|
+
if (!token || typeof token !== 'string') return false;
|
|
62
|
+
if (token.startsWith('http://') || token.startsWith('https://')) return true;
|
|
63
|
+
if (token.includes('.') && !token.includes(' ')) return true;
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Get positional args (exclude flags)
|
|
69
|
+
*/
|
|
70
|
+
export function getPositionals(args, excludeFlags = []) {
|
|
71
|
+
const result = [];
|
|
72
|
+
for (let i = 0; i < args.length; i++) {
|
|
73
|
+
const arg = args[i];
|
|
74
|
+
if (arg.startsWith('--')) {
|
|
75
|
+
if (!excludeFlags.includes(arg)) {
|
|
76
|
+
i++; // skip value
|
|
77
|
+
}
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (excludeFlags.includes(arg)) {
|
|
81
|
+
i++; // skip value
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
result.push(arg);
|
|
85
|
+
}
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { createProgressWsServer } from './ws-server.mjs';
|
|
2
|
+
|
|
3
|
+
function readFlagValue(args, names) {
|
|
4
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
5
|
+
if (!names.includes(args[i])) continue;
|
|
6
|
+
const value = args[i + 1];
|
|
7
|
+
if (!value || String(value).startsWith('-')) return null;
|
|
8
|
+
return value;
|
|
9
|
+
}
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function main() {
|
|
14
|
+
const args = process.argv.slice(2);
|
|
15
|
+
const host = readFlagValue(args, ['--host']) || process.env.CAMO_PROGRESS_WS_HOST || '127.0.0.1';
|
|
16
|
+
const port = Math.max(1, Number(readFlagValue(args, ['--port']) || process.env.CAMO_PROGRESS_WS_PORT || 7788) || 7788);
|
|
17
|
+
const server = createProgressWsServer({ host, port });
|
|
18
|
+
await server.start();
|
|
19
|
+
|
|
20
|
+
const stop = async () => {
|
|
21
|
+
await server.stop();
|
|
22
|
+
process.exit(0);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
process.on('SIGINT', stop);
|
|
26
|
+
process.on('SIGTERM', stop);
|
|
27
|
+
await new Promise(() => {});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
main().catch(() => {
|
|
31
|
+
process.exit(1);
|
|
32
|
+
});
|
|
33
|
+
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
const DEFAULT_HOST = process.env.CAMO_PROGRESS_WS_HOST || '127.0.0.1';
|
|
6
|
+
const DEFAULT_PORT = Math.max(1, Number(process.env.CAMO_PROGRESS_WS_PORT || 7788) || 7788);
|
|
7
|
+
const DEFAULT_HEALTH_TIMEOUT_MS = 800;
|
|
8
|
+
const DEFAULT_START_TIMEOUT_MS = 4000;
|
|
9
|
+
const HEALTH_POLL_INTERVAL_MS = 140;
|
|
10
|
+
|
|
11
|
+
function sleep(ms) {
|
|
12
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function resolveProgressWsConfig(options = {}) {
|
|
16
|
+
const host = String(options.host || DEFAULT_HOST).trim() || DEFAULT_HOST;
|
|
17
|
+
const port = Math.max(1, Number(options.port || DEFAULT_PORT) || DEFAULT_PORT);
|
|
18
|
+
return { host, port };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function buildProgressHealthUrl(options = {}) {
|
|
22
|
+
const { host, port } = resolveProgressWsConfig(options);
|
|
23
|
+
return `http://${host}:${port}/health`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function checkProgressEventDaemon(options = {}) {
|
|
27
|
+
const timeoutMs = Math.max(150, Number(options.timeoutMs || DEFAULT_HEALTH_TIMEOUT_MS) || DEFAULT_HEALTH_TIMEOUT_MS);
|
|
28
|
+
const healthUrl = buildProgressHealthUrl(options);
|
|
29
|
+
try {
|
|
30
|
+
const response = await fetch(healthUrl, { signal: AbortSignal.timeout(timeoutMs) });
|
|
31
|
+
if (!response.ok) return false;
|
|
32
|
+
const body = await response.json().catch(() => null);
|
|
33
|
+
return Boolean(body?.ok);
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function getDaemonEntryPath() {
|
|
40
|
+
const dir = path.dirname(fileURLToPath(import.meta.url));
|
|
41
|
+
return path.join(dir, 'daemon-entry.mjs');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function spawnProgressDaemon({ host, port }) {
|
|
45
|
+
const entry = getDaemonEntryPath();
|
|
46
|
+
const child = spawn(
|
|
47
|
+
process.execPath,
|
|
48
|
+
[entry, '--host', host, '--port', String(port)],
|
|
49
|
+
{
|
|
50
|
+
detached: true,
|
|
51
|
+
stdio: 'ignore',
|
|
52
|
+
env: {
|
|
53
|
+
...process.env,
|
|
54
|
+
CAMO_PROGRESS_DAEMON: '1',
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
);
|
|
58
|
+
child.unref();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function ensureProgressEventDaemon(options = {}) {
|
|
62
|
+
const { host, port } = resolveProgressWsConfig(options);
|
|
63
|
+
const startTimeoutMs = Math.max(400, Number(options.startTimeoutMs || DEFAULT_START_TIMEOUT_MS) || DEFAULT_START_TIMEOUT_MS);
|
|
64
|
+
if (await checkProgressEventDaemon({ host, port })) {
|
|
65
|
+
return { ok: true, started: false, host, port };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
spawnProgressDaemon({ host, port });
|
|
69
|
+
|
|
70
|
+
const deadline = Date.now() + startTimeoutMs;
|
|
71
|
+
while (Date.now() < deadline) {
|
|
72
|
+
if (await checkProgressEventDaemon({ host, port })) {
|
|
73
|
+
return { ok: true, started: true, host, port };
|
|
74
|
+
}
|
|
75
|
+
await sleep(HEALTH_POLL_INTERVAL_MS);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return { ok: false, started: true, host, port, error: 'progress_daemon_start_timeout' };
|
|
79
|
+
}
|
|
80
|
+
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { CONFIG_DIR, ensureDir } from '../utils/config.mjs';
|
|
4
|
+
|
|
5
|
+
const DEFAULT_EVENTS_DIR = path.join(CONFIG_DIR, 'run', 'events');
|
|
6
|
+
const DEFAULT_EVENTS_FILE = path.join(DEFAULT_EVENTS_DIR, 'progress-events.jsonl');
|
|
7
|
+
const MAX_REPLAY_BYTES = Math.max(64 * 1024, Number(process.env.CAMO_PROGRESS_REPLAY_MAX_BYTES) || (2 * 1024 * 1024));
|
|
8
|
+
|
|
9
|
+
let localSeq = 0;
|
|
10
|
+
|
|
11
|
+
function resolveEventsFile() {
|
|
12
|
+
const raw = String(process.env.CAMO_PROGRESS_EVENTS_FILE || DEFAULT_EVENTS_FILE).trim();
|
|
13
|
+
return raw || DEFAULT_EVENTS_FILE;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function nextSeq() {
|
|
17
|
+
localSeq = (localSeq + 1) % 1_000_000_000;
|
|
18
|
+
return `${Date.now()}-${process.pid}-${localSeq}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function normalizePayload(payload) {
|
|
22
|
+
if (payload === undefined) return null;
|
|
23
|
+
if (payload === null) return null;
|
|
24
|
+
if (typeof payload === 'object') return payload;
|
|
25
|
+
return { value: payload };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function getProgressEventsFile() {
|
|
29
|
+
return resolveEventsFile();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function ensureProgressEventStore() {
|
|
33
|
+
const eventFile = resolveEventsFile();
|
|
34
|
+
ensureDir(path.dirname(eventFile));
|
|
35
|
+
if (!fs.existsSync(eventFile)) {
|
|
36
|
+
fs.writeFileSync(eventFile, '', 'utf8');
|
|
37
|
+
}
|
|
38
|
+
return eventFile;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function buildProgressEvent({
|
|
42
|
+
ts = null,
|
|
43
|
+
seq = null,
|
|
44
|
+
source = 'camo',
|
|
45
|
+
mode = 'normal',
|
|
46
|
+
profileId = null,
|
|
47
|
+
runId = null,
|
|
48
|
+
event = 'unknown',
|
|
49
|
+
payload = null,
|
|
50
|
+
} = {}) {
|
|
51
|
+
return {
|
|
52
|
+
ts: ts || new Date().toISOString(),
|
|
53
|
+
seq: seq || nextSeq(),
|
|
54
|
+
source: String(source || 'camo'),
|
|
55
|
+
mode: String(mode || 'normal'),
|
|
56
|
+
profileId: profileId ? String(profileId) : null,
|
|
57
|
+
runId: runId ? String(runId) : null,
|
|
58
|
+
event: String(event || 'unknown'),
|
|
59
|
+
payload: normalizePayload(payload),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function appendProgressEvent(input = {}) {
|
|
64
|
+
const eventFile = ensureProgressEventStore();
|
|
65
|
+
const event = buildProgressEvent(input);
|
|
66
|
+
fs.appendFileSync(eventFile, `${JSON.stringify(event)}\n`, 'utf8');
|
|
67
|
+
return event;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function safeAppendProgressEvent(input = {}) {
|
|
71
|
+
try {
|
|
72
|
+
return appendProgressEvent(input);
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function readRecentProgressEvents(limit = 100) {
|
|
79
|
+
const eventFile = ensureProgressEventStore();
|
|
80
|
+
const maxItems = Math.max(0, Number(limit) || 0);
|
|
81
|
+
if (maxItems === 0) return [];
|
|
82
|
+
const stat = fs.statSync(eventFile);
|
|
83
|
+
if (stat.size <= 0) return [];
|
|
84
|
+
|
|
85
|
+
const start = Math.max(0, stat.size - MAX_REPLAY_BYTES);
|
|
86
|
+
const fd = fs.openSync(eventFile, 'r');
|
|
87
|
+
try {
|
|
88
|
+
const length = stat.size - start;
|
|
89
|
+
const buffer = Buffer.alloc(length);
|
|
90
|
+
fs.readSync(fd, buffer, 0, length, start);
|
|
91
|
+
const raw = buffer.toString('utf8');
|
|
92
|
+
return raw
|
|
93
|
+
.split('\n')
|
|
94
|
+
.map((line) => line.trim())
|
|
95
|
+
.filter(Boolean)
|
|
96
|
+
.slice(-maxItems)
|
|
97
|
+
.map((line) => {
|
|
98
|
+
try {
|
|
99
|
+
return JSON.parse(line);
|
|
100
|
+
} catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
})
|
|
104
|
+
.filter(Boolean);
|
|
105
|
+
} finally {
|
|
106
|
+
fs.closeSync(fd);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|