@agentrhq/webcmd 0.3.1 → 0.3.3
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 +13 -6
- package/cli-manifest.json +11 -11
- package/clis/producthunt/browse.js +9 -2
- package/clis/producthunt/browser-commands.test.js +66 -0
- package/clis/producthunt/hot.js +3 -3
- package/clis/producthunt/utils.js +27 -0
- package/clis/producthunt/utils.test.js +8 -0
- package/clis/spotify/spotify.js +11 -11
- package/dist/src/browser/daemon-client.d.ts +1 -0
- package/dist/src/browser/daemon-client.js +25 -1
- package/dist/src/browser/daemon-client.test.js +86 -1
- package/dist/src/browser/daemon-transport.d.ts +2 -0
- package/dist/src/browser/protocol.d.ts +12 -1
- package/dist/src/browser/runtime/local-cloak/actions.d.ts +1 -0
- package/dist/src/browser/runtime/local-cloak/actions.js +9 -9
- package/dist/src/browser/runtime/local-cloak/provider.d.ts +1 -0
- package/dist/src/browser/runtime/local-cloak/provider.js +6 -3
- package/dist/src/browser/runtime/local-cloak/provider.test.js +1 -0
- package/dist/src/browser/runtime/local-cloak/session-manager.d.ts +5 -0
- package/dist/src/browser/runtime/local-cloak/session-manager.js +95 -19
- package/dist/src/browser/runtime/local-cloak/session-manager.test.js +235 -0
- package/dist/src/browser/runtime/provider.d.ts +1 -0
- package/dist/src/cli.js +36 -12
- package/dist/src/cli.test.js +5 -0
- package/dist/src/daemon/server.js +65 -24
- package/dist/src/daemon/server.test.js +211 -2
- package/dist/src/docs-sync-review-cli.test.js +1 -1
- package/dist/src/errors.d.ts +5 -0
- package/dist/src/errors.js +23 -0
- package/dist/src/errors.test.js +58 -1
- package/dist/src/execution.js +57 -6
- package/dist/src/execution.test.js +426 -2
- package/dist/src/hosted/availability.test.js +12 -1
- package/dist/src/hosted/client.js +3 -1
- package/dist/src/hosted/client.test.js +62 -0
- package/dist/src/hosted/main-lifecycle.test.js +66 -5
- package/dist/src/hosted/types.d.ts +1 -0
- package/dist/src/main.js +4 -0
- package/dist/src/package-bin-process.d.ts +13 -0
- package/dist/src/package-bin-process.js +24 -0
- package/dist/src/package-bin-process.test.d.ts +1 -0
- package/dist/src/package-bin-process.test.js +22 -0
- package/dist/src/session-lease.d.ts +82 -0
- package/dist/src/session-lease.js +163 -0
- package/dist/src/session-lease.test.d.ts +1 -0
- package/dist/src/session-lease.test.js +217 -0
- package/dist/src/skills.d.ts +8 -4
- package/dist/src/skills.js +30 -1
- package/dist/src/skills.test.js +40 -12
- package/hosted-contract.json +45 -34
- package/package.json +3 -2
- package/scripts/check-package-bin.mjs +7 -6
- package/scripts/collect-ci-diagnostics.ps1 +274 -0
- package/scripts/docs-sync-review.ts +1 -1
- package/skills/webcmd-adapter-author/SKILL.md +1 -1
- package/skills/webcmd-adapter-author/references/adapter-template.md +2 -6
package/dist/src/execution.js
CHANGED
|
@@ -15,11 +15,11 @@ import * as crypto from 'node:crypto';
|
|
|
15
15
|
import * as fs from 'node:fs';
|
|
16
16
|
import * as os from 'node:os';
|
|
17
17
|
import { executePipeline } from './pipeline/index.js';
|
|
18
|
-
import { adapterLoadError, ArgumentError, CommandExecutionError, attachTraceReceipt, getErrorMessage } from './errors.js';
|
|
18
|
+
import { adapterLoadError, ArgumentError, CommandExecutionError, TimeoutError, attachTraceReceipt, getErrorMessage } from './errors.js';
|
|
19
19
|
import { shouldUseBrowserSession } from './capabilityRouting.js';
|
|
20
20
|
import { getBrowserFactory, browserSession, runWithTimeout, DEFAULT_BROWSER_COMMAND_TIMEOUT } from './runtime.js';
|
|
21
21
|
import { profileRouteParams, resolveProfileSelection } from './browser/profile.js';
|
|
22
|
-
import { setDaemonCommandTimeoutSeconds } from './browser/daemon-client.js';
|
|
22
|
+
import { releaseSiteSessionLease, setDaemonCommandTimeoutSeconds } from './browser/daemon-client.js';
|
|
23
23
|
import { emitHook } from './hooks.js';
|
|
24
24
|
import { log } from './logger.js';
|
|
25
25
|
import { isElectronApp } from './electron-apps.js';
|
|
@@ -27,10 +27,16 @@ import { probeCDP, resolveElectronEndpoint } from './launcher.js';
|
|
|
27
27
|
import { ObservationSession, exportObservationSession } from './observation/index.js';
|
|
28
28
|
import { resolveAdapterSourcePath } from './adapter-source.js';
|
|
29
29
|
import { coerceCommandArguments, TRACE_MODES } from './command-surface.js';
|
|
30
|
+
import { clearDaemonRunContext, generateRunId, isUnknownOutcomeError, runWithDaemonRunContext } from './session-lease.js';
|
|
30
31
|
const _loadedModules = new Map();
|
|
31
32
|
/** Track mtime of loaded user adapter files for hot-reload in daemon mode. */
|
|
32
33
|
const _moduleMtimes = new Map();
|
|
33
34
|
const _userClisDir = `${os.homedir()}/.webcmd/clis/`;
|
|
35
|
+
async function finalizeRun(runId, release) {
|
|
36
|
+
clearDaemonRunContext(runId);
|
|
37
|
+
if (release)
|
|
38
|
+
await releaseSiteSessionLease(runId).catch(() => undefined);
|
|
39
|
+
}
|
|
34
40
|
function normalizeTraceMode(raw) {
|
|
35
41
|
if (raw === undefined || raw === null || raw === '' || raw === 'off')
|
|
36
42
|
return 'off';
|
|
@@ -178,7 +184,15 @@ export async function executeCommand(cmd, rawKwargs, debug = false, opts = {}) {
|
|
|
178
184
|
const session = resolveAdapterBrowserSession(cmd, siteSession);
|
|
179
185
|
const keepTab = resolveKeepTab(siteSession, opts.keepTab);
|
|
180
186
|
const windowMode = resolveBrowserWindowMode(cmd.defaultWindowMode ?? 'background', opts.windowMode);
|
|
181
|
-
|
|
187
|
+
const surface = 'adapter';
|
|
188
|
+
const canonicalCommand = fullName(cmd);
|
|
189
|
+
const leaseEligible = surface === 'adapter'
|
|
190
|
+
&& siteSession === 'persistent'
|
|
191
|
+
&& cmd.access === 'write';
|
|
192
|
+
const runId = leaseEligible ? generateRunId() : undefined;
|
|
193
|
+
let releaseRun = true;
|
|
194
|
+
let deferRunFinalization = false;
|
|
195
|
+
const executeAdapter = async (page) => {
|
|
182
196
|
const observation = traceMode === 'off'
|
|
183
197
|
? null
|
|
184
198
|
: new ObservationSession({
|
|
@@ -223,6 +237,8 @@ export async function executeCommand(cmd, rawKwargs, debug = false, opts = {}) {
|
|
|
223
237
|
});
|
|
224
238
|
}
|
|
225
239
|
catch (err) {
|
|
240
|
+
if (runId && isUnknownOutcomeError(err))
|
|
241
|
+
releaseRun = false;
|
|
226
242
|
observation?.record({
|
|
227
243
|
stream: 'action',
|
|
228
244
|
name: 'pre_navigate',
|
|
@@ -244,13 +260,16 @@ export async function executeCommand(cmd, rawKwargs, debug = false, opts = {}) {
|
|
|
244
260
|
throw wrapped;
|
|
245
261
|
}
|
|
246
262
|
}
|
|
263
|
+
const adapterPromise = Promise.resolve(runCommand(cmd, page, kwargs, debug));
|
|
264
|
+
let adapterSettled = false;
|
|
265
|
+
void adapterPromise.then(() => { adapterSettled = true; }, () => { adapterSettled = true; });
|
|
247
266
|
try {
|
|
248
267
|
const browserTimeout = userTimeoutSec !== null
|
|
249
268
|
? userTimeoutSec + RUNTIME_TIMEOUT_PADDING_SECONDS
|
|
250
269
|
: DEFAULT_BROWSER_COMMAND_TIMEOUT;
|
|
251
|
-
const result = await runWithTimeout(
|
|
270
|
+
const result = await runWithTimeout(adapterPromise, {
|
|
252
271
|
timeout: browserTimeout,
|
|
253
|
-
label:
|
|
272
|
+
label: canonicalCommand,
|
|
254
273
|
});
|
|
255
274
|
observation?.record({
|
|
256
275
|
stream: 'action',
|
|
@@ -269,6 +288,15 @@ export async function executeCommand(cmd, rawKwargs, debug = false, opts = {}) {
|
|
|
269
288
|
return result;
|
|
270
289
|
}
|
|
271
290
|
catch (err) {
|
|
291
|
+
if (runId && isUnknownOutcomeError(err))
|
|
292
|
+
releaseRun = false;
|
|
293
|
+
if (runId && err instanceof TimeoutError && !adapterSettled) {
|
|
294
|
+
releaseRun = false;
|
|
295
|
+
deferRunFinalization = true;
|
|
296
|
+
void adapterPromise
|
|
297
|
+
.then(() => finalizeRun(runId, true), (lateError) => finalizeRun(runId, !isUnknownOutcomeError(lateError)))
|
|
298
|
+
.catch(() => undefined);
|
|
299
|
+
}
|
|
272
300
|
if (observation) {
|
|
273
301
|
observation.record({
|
|
274
302
|
stream: 'action',
|
|
@@ -293,7 +321,30 @@ export async function executeCommand(cmd, rawKwargs, debug = false, opts = {}) {
|
|
|
293
321
|
await page.closeWindow?.().catch(() => { });
|
|
294
322
|
throw err;
|
|
295
323
|
}
|
|
296
|
-
}
|
|
324
|
+
};
|
|
325
|
+
const executeBrowser = () => browserSession(BrowserFactory, executeAdapter, {
|
|
326
|
+
session,
|
|
327
|
+
cdpEndpoint,
|
|
328
|
+
...profileRouting,
|
|
329
|
+
windowMode,
|
|
330
|
+
surface,
|
|
331
|
+
siteSession,
|
|
332
|
+
freshPage: cmd.freshPage === true && siteSession === 'persistent',
|
|
333
|
+
});
|
|
334
|
+
try {
|
|
335
|
+
result = runId
|
|
336
|
+
? await runWithDaemonRunContext({ runId, command: canonicalCommand, access: 'write' }, executeBrowser)
|
|
337
|
+
: await executeBrowser();
|
|
338
|
+
}
|
|
339
|
+
catch (err) {
|
|
340
|
+
if (runId && isUnknownOutcomeError(err))
|
|
341
|
+
releaseRun = false;
|
|
342
|
+
throw err;
|
|
343
|
+
}
|
|
344
|
+
finally {
|
|
345
|
+
if (runId && !deferRunFinalization)
|
|
346
|
+
await finalizeRun(runId, releaseRun);
|
|
347
|
+
}
|
|
297
348
|
}
|
|
298
349
|
else {
|
|
299
350
|
// Non-browser commands: enforce a timeout only when the command exposes
|
|
@@ -1,14 +1,16 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from 'vitest';
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
2
|
import * as fs from 'node:fs';
|
|
3
3
|
import * as os from 'node:os';
|
|
4
4
|
import * as path from 'node:path';
|
|
5
|
-
const { mockSetDaemonCommandTimeoutSeconds } = vi.hoisted(() => ({
|
|
5
|
+
const { mockReleaseSiteSessionLease, mockSetDaemonCommandTimeoutSeconds } = vi.hoisted(() => ({
|
|
6
|
+
mockReleaseSiteSessionLease: vi.fn().mockResolvedValue(undefined),
|
|
6
7
|
mockSetDaemonCommandTimeoutSeconds: vi.fn(),
|
|
7
8
|
}));
|
|
8
9
|
vi.mock('./browser/daemon-client.js', async () => {
|
|
9
10
|
const actual = await vi.importActual('./browser/daemon-client.js');
|
|
10
11
|
return {
|
|
11
12
|
...actual,
|
|
13
|
+
releaseSiteSessionLease: mockReleaseSiteSessionLease,
|
|
12
14
|
setDaemonCommandTimeoutSeconds: mockSetDaemonCommandTimeoutSeconds,
|
|
13
15
|
};
|
|
14
16
|
});
|
|
@@ -17,6 +19,17 @@ import { ArgumentError, TimeoutError, toEnvelope } from './errors.js';
|
|
|
17
19
|
import { cli, Strategy } from './registry.js';
|
|
18
20
|
import * as runtime from './runtime.js';
|
|
19
21
|
import * as capRouting from './capabilityRouting.js';
|
|
22
|
+
import { clearDaemonRunContext, getDaemonRunContext } from './session-lease.js';
|
|
23
|
+
import { sendCommand } from './browser/daemon-client.js';
|
|
24
|
+
function deferred() {
|
|
25
|
+
let resolve;
|
|
26
|
+
let reject;
|
|
27
|
+
const promise = new Promise((res, rej) => {
|
|
28
|
+
resolve = res;
|
|
29
|
+
reject = rej;
|
|
30
|
+
});
|
|
31
|
+
return { promise, resolve, reject };
|
|
32
|
+
}
|
|
20
33
|
describe('executeCommand — non-browser timeout', () => {
|
|
21
34
|
it('applies the user --timeout arg as the ceiling for non-browser commands', async () => {
|
|
22
35
|
const runWithTimeoutSpy = vi.spyOn(runtime, 'runWithTimeout');
|
|
@@ -148,6 +161,417 @@ describe('executeCommand — non-browser timeout', () => {
|
|
|
148
161
|
});
|
|
149
162
|
vi.restoreAllMocks();
|
|
150
163
|
});
|
|
164
|
+
describe('persistent write run ownership', () => {
|
|
165
|
+
afterEach(() => {
|
|
166
|
+
const activeRun = getDaemonRunContext();
|
|
167
|
+
if (activeRun)
|
|
168
|
+
clearDaemonRunContext(activeRun.runId);
|
|
169
|
+
mockReleaseSiteSessionLease.mockReset().mockResolvedValue(undefined);
|
|
170
|
+
vi.restoreAllMocks();
|
|
171
|
+
vi.unstubAllGlobals();
|
|
172
|
+
});
|
|
173
|
+
it('binds a run only for browser-backed persistent writes', async () => {
|
|
174
|
+
const seen = new Map();
|
|
175
|
+
const mockPage = { closeWindow: vi.fn().mockResolvedValue(undefined) };
|
|
176
|
+
vi.spyOn(capRouting, 'shouldUseBrowserSession').mockImplementation(cmd => cmd.browser !== false);
|
|
177
|
+
vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn) => fn(mockPage));
|
|
178
|
+
const makeCommand = (name, access, browser, siteSession) => cli({
|
|
179
|
+
site: 'test-execution',
|
|
180
|
+
name,
|
|
181
|
+
access,
|
|
182
|
+
description: 'test logical run eligibility',
|
|
183
|
+
browser,
|
|
184
|
+
strategy: Strategy.PUBLIC,
|
|
185
|
+
siteSession,
|
|
186
|
+
func: async () => {
|
|
187
|
+
seen.set(name, getDaemonRunContext());
|
|
188
|
+
return [{ ok: true }];
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
await executeCommand(makeCommand('run-eligible', 'write', true, 'persistent'), {});
|
|
192
|
+
await executeCommand(makeCommand('run-read', 'read', true, 'persistent'), {});
|
|
193
|
+
await executeCommand(makeCommand('run-ephemeral', 'write', true, 'ephemeral'), {});
|
|
194
|
+
await executeCommand(makeCommand('run-non-browser', 'write', false, 'persistent'), {});
|
|
195
|
+
const eligibleRun = seen.get('run-eligible');
|
|
196
|
+
expect(eligibleRun).toMatchObject({
|
|
197
|
+
runId: expect.stringMatching(/^run_/),
|
|
198
|
+
command: 'test-execution/run-eligible',
|
|
199
|
+
access: 'write',
|
|
200
|
+
});
|
|
201
|
+
expect(seen.get('run-read')).toBeUndefined();
|
|
202
|
+
expect(seen.get('run-ephemeral')).toBeUndefined();
|
|
203
|
+
expect(seen.get('run-non-browser')).toBeUndefined();
|
|
204
|
+
expect(mockReleaseSiteSessionLease).toHaveBeenCalledOnce();
|
|
205
|
+
expect(mockReleaseSiteSessionLease).toHaveBeenCalledWith(eligibleRun?.runId);
|
|
206
|
+
});
|
|
207
|
+
it('binds one canonical run before session setup, pre-navigation, and adapter execution', async () => {
|
|
208
|
+
const seen = [];
|
|
209
|
+
const capture = (stage) => seen.push({ stage, run: getDaemonRunContext() });
|
|
210
|
+
const mockPage = {
|
|
211
|
+
closeWindow: vi.fn().mockResolvedValue(undefined),
|
|
212
|
+
getCurrentUrl: vi.fn().mockImplementation(async () => {
|
|
213
|
+
capture('current-url');
|
|
214
|
+
return 'about:blank';
|
|
215
|
+
}),
|
|
216
|
+
goto: vi.fn().mockImplementation(async () => capture('pre-navigation')),
|
|
217
|
+
};
|
|
218
|
+
vi.spyOn(capRouting, 'shouldUseBrowserSession').mockReturnValue(true);
|
|
219
|
+
vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn) => {
|
|
220
|
+
capture('session');
|
|
221
|
+
return fn(mockPage);
|
|
222
|
+
});
|
|
223
|
+
const cmd = cli({
|
|
224
|
+
site: 'test-execution',
|
|
225
|
+
name: 'run-bound-before-operations',
|
|
226
|
+
access: 'write',
|
|
227
|
+
description: 'test one run spans the complete adapter execution',
|
|
228
|
+
browser: true,
|
|
229
|
+
strategy: Strategy.COOKIE,
|
|
230
|
+
domain: 'example.com',
|
|
231
|
+
siteSession: 'persistent',
|
|
232
|
+
func: async () => {
|
|
233
|
+
capture('adapter');
|
|
234
|
+
return [{ ok: true }];
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
await expect(executeCommand(cmd, {})).resolves.toEqual([{ ok: true }]);
|
|
238
|
+
expect(seen.map(entry => entry.stage)).toEqual([
|
|
239
|
+
'session',
|
|
240
|
+
'current-url',
|
|
241
|
+
'pre-navigation',
|
|
242
|
+
'adapter',
|
|
243
|
+
]);
|
|
244
|
+
const runId = seen[0]?.run?.runId;
|
|
245
|
+
expect(runId).toMatch(/^run_/);
|
|
246
|
+
for (const entry of seen) {
|
|
247
|
+
expect(entry.run).toEqual({
|
|
248
|
+
runId,
|
|
249
|
+
command: 'test-execution/run-bound-before-operations',
|
|
250
|
+
access: 'write',
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
expect(getDaemonRunContext()).toBeUndefined();
|
|
254
|
+
expect(mockReleaseSiteSessionLease).toHaveBeenCalledOnce();
|
|
255
|
+
expect(mockReleaseSiteSessionLease).toHaveBeenCalledWith(runId);
|
|
256
|
+
});
|
|
257
|
+
it('clears and releases the run once after an ordinary adapter error', async () => {
|
|
258
|
+
let runId;
|
|
259
|
+
const mockPage = { closeWindow: vi.fn().mockResolvedValue(undefined) };
|
|
260
|
+
vi.spyOn(capRouting, 'shouldUseBrowserSession').mockReturnValue(true);
|
|
261
|
+
vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn) => fn(mockPage));
|
|
262
|
+
const cmd = cli({
|
|
263
|
+
site: 'test-execution',
|
|
264
|
+
name: 'run-ordinary-error',
|
|
265
|
+
access: 'write',
|
|
266
|
+
description: 'test ordinary errors release run ownership',
|
|
267
|
+
browser: true,
|
|
268
|
+
strategy: Strategy.PUBLIC,
|
|
269
|
+
siteSession: 'persistent',
|
|
270
|
+
func: async () => {
|
|
271
|
+
runId = getDaemonRunContext()?.runId;
|
|
272
|
+
throw new Error('ordinary adapter failure');
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
await expect(executeCommand(cmd, {})).rejects.toThrow('ordinary adapter failure');
|
|
276
|
+
expect(runId).toMatch(/^run_/);
|
|
277
|
+
expect(getDaemonRunContext()).toBeUndefined();
|
|
278
|
+
expect(mockReleaseSiteSessionLease).toHaveBeenCalledOnce();
|
|
279
|
+
expect(mockReleaseSiteSessionLease).toHaveBeenCalledWith(runId);
|
|
280
|
+
});
|
|
281
|
+
it('clears but does not release a run when an error cause has unknown outcome', async () => {
|
|
282
|
+
let runId;
|
|
283
|
+
const mockPage = { closeWindow: vi.fn().mockResolvedValue(undefined) };
|
|
284
|
+
vi.spyOn(capRouting, 'shouldUseBrowserSession').mockReturnValue(true);
|
|
285
|
+
vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn) => fn(mockPage));
|
|
286
|
+
const unknownOutcome = new Error('wrapped adapter failure');
|
|
287
|
+
unknownOutcome.cause = { code: 'COMMAND_RESULT_UNKNOWN' };
|
|
288
|
+
const cmd = cli({
|
|
289
|
+
site: 'test-execution',
|
|
290
|
+
name: 'run-unknown-outcome',
|
|
291
|
+
access: 'write',
|
|
292
|
+
description: 'test unknown outcomes retain the daemon lease',
|
|
293
|
+
browser: true,
|
|
294
|
+
strategy: Strategy.PUBLIC,
|
|
295
|
+
siteSession: 'persistent',
|
|
296
|
+
func: async () => {
|
|
297
|
+
runId = getDaemonRunContext()?.runId;
|
|
298
|
+
throw unknownOutcome;
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
await expect(executeCommand(cmd, {})).rejects.toBe(unknownOutcome);
|
|
302
|
+
expect(runId).toMatch(/^run_/);
|
|
303
|
+
expect(getDaemonRunContext()).toBeUndefined();
|
|
304
|
+
expect(mockReleaseSiteSessionLease).not.toHaveBeenCalled();
|
|
305
|
+
});
|
|
306
|
+
it('does not release a run when pre-navigation has unknown outcome', async () => {
|
|
307
|
+
let runId;
|
|
308
|
+
const unknownOutcome = new Error('navigation result unknown');
|
|
309
|
+
unknownOutcome.cause = { errorCode: 'COMMAND_LOST' };
|
|
310
|
+
const mockPage = {
|
|
311
|
+
closeWindow: vi.fn().mockResolvedValue(undefined),
|
|
312
|
+
getCurrentUrl: vi.fn().mockResolvedValue('about:blank'),
|
|
313
|
+
goto: vi.fn().mockImplementation(async () => {
|
|
314
|
+
runId = getDaemonRunContext()?.runId;
|
|
315
|
+
throw unknownOutcome;
|
|
316
|
+
}),
|
|
317
|
+
};
|
|
318
|
+
vi.spyOn(capRouting, 'shouldUseBrowserSession').mockReturnValue(true);
|
|
319
|
+
vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn) => fn(mockPage));
|
|
320
|
+
const cmd = cli({
|
|
321
|
+
site: 'test-execution',
|
|
322
|
+
name: 'run-unknown-prenavigation',
|
|
323
|
+
access: 'write',
|
|
324
|
+
description: 'test unknown pre-navigation retains the daemon lease',
|
|
325
|
+
browser: true,
|
|
326
|
+
strategy: Strategy.COOKIE,
|
|
327
|
+
domain: 'example.com',
|
|
328
|
+
siteSession: 'persistent',
|
|
329
|
+
func: async () => [{ ok: true }],
|
|
330
|
+
});
|
|
331
|
+
await expect(executeCommand(cmd, {})).rejects.toThrow('Pre-navigation');
|
|
332
|
+
expect(runId).toMatch(/^run_/);
|
|
333
|
+
expect(getDaemonRunContext()).toBeUndefined();
|
|
334
|
+
expect(mockReleaseSiteSessionLease).not.toHaveBeenCalled();
|
|
335
|
+
});
|
|
336
|
+
it('releases after a wrapper timeout when the adapter later succeeds', async () => {
|
|
337
|
+
const adapter = deferred();
|
|
338
|
+
let runId;
|
|
339
|
+
let settledRunId;
|
|
340
|
+
let adapterSettled = false;
|
|
341
|
+
const mockPage = { closeWindow: vi.fn().mockResolvedValue(undefined) };
|
|
342
|
+
vi.spyOn(capRouting, 'shouldUseBrowserSession').mockReturnValue(true);
|
|
343
|
+
vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn) => fn(mockPage));
|
|
344
|
+
vi.spyOn(runtime, 'runWithTimeout').mockRejectedValueOnce(new TimeoutError('logical run', 1));
|
|
345
|
+
const cmd = cli({
|
|
346
|
+
site: 'test-execution',
|
|
347
|
+
name: 'run-timeout',
|
|
348
|
+
access: 'write',
|
|
349
|
+
description: 'test timeout defers local run cleanup',
|
|
350
|
+
browser: true,
|
|
351
|
+
strategy: Strategy.PUBLIC,
|
|
352
|
+
siteSession: 'persistent',
|
|
353
|
+
func: async () => {
|
|
354
|
+
runId = getDaemonRunContext()?.runId;
|
|
355
|
+
await adapter.promise;
|
|
356
|
+
settledRunId = getDaemonRunContext()?.runId;
|
|
357
|
+
adapterSettled = true;
|
|
358
|
+
},
|
|
359
|
+
});
|
|
360
|
+
await expect(executeCommand(cmd, {})).rejects.toBeInstanceOf(TimeoutError);
|
|
361
|
+
expect(runId).toMatch(/^run_/);
|
|
362
|
+
expect(adapterSettled).toBe(false);
|
|
363
|
+
expect(getDaemonRunContext()).toBeUndefined();
|
|
364
|
+
expect(mockReleaseSiteSessionLease).not.toHaveBeenCalled();
|
|
365
|
+
adapter.resolve();
|
|
366
|
+
await vi.waitFor(() => expect(adapterSettled).toBe(true));
|
|
367
|
+
expect(settledRunId).toBe(runId);
|
|
368
|
+
expect(getDaemonRunContext()).toBeUndefined();
|
|
369
|
+
await vi.waitFor(() => expect(mockReleaseSiteSessionLease).toHaveBeenCalledOnce());
|
|
370
|
+
expect(mockReleaseSiteSessionLease).toHaveBeenCalledWith(runId);
|
|
371
|
+
});
|
|
372
|
+
it('releases after a wrapper timeout when the adapter later fails ordinarily', async () => {
|
|
373
|
+
const adapter = deferred();
|
|
374
|
+
let runId;
|
|
375
|
+
const mockPage = { closeWindow: vi.fn().mockResolvedValue(undefined) };
|
|
376
|
+
vi.spyOn(capRouting, 'shouldUseBrowserSession').mockReturnValue(true);
|
|
377
|
+
vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn) => fn(mockPage));
|
|
378
|
+
vi.spyOn(runtime, 'runWithTimeout').mockRejectedValueOnce(new TimeoutError('logical run', 1));
|
|
379
|
+
const cmd = cli({
|
|
380
|
+
site: 'test-execution',
|
|
381
|
+
name: 'run-timeout-late-ordinary-failure',
|
|
382
|
+
access: 'write',
|
|
383
|
+
description: 'test late ordinary failure releases run ownership',
|
|
384
|
+
browser: true,
|
|
385
|
+
strategy: Strategy.PUBLIC,
|
|
386
|
+
siteSession: 'persistent',
|
|
387
|
+
func: async () => {
|
|
388
|
+
runId = getDaemonRunContext()?.runId;
|
|
389
|
+
await adapter.promise;
|
|
390
|
+
},
|
|
391
|
+
});
|
|
392
|
+
await expect(executeCommand(cmd, {})).rejects.toBeInstanceOf(TimeoutError);
|
|
393
|
+
expect(runId).toMatch(/^run_/);
|
|
394
|
+
expect(mockReleaseSiteSessionLease).not.toHaveBeenCalled();
|
|
395
|
+
adapter.reject(new Error('late ordinary failure'));
|
|
396
|
+
await vi.waitFor(() => expect(mockReleaseSiteSessionLease).toHaveBeenCalledOnce());
|
|
397
|
+
expect(mockReleaseSiteSessionLease).toHaveBeenCalledWith(runId);
|
|
398
|
+
expect(getDaemonRunContext()).toBeUndefined();
|
|
399
|
+
});
|
|
400
|
+
it('retains ownership for TTL after a wrapper timeout when the adapter later has an unknown outcome', async () => {
|
|
401
|
+
const adapter = deferred();
|
|
402
|
+
let runId;
|
|
403
|
+
let adapterSettled = false;
|
|
404
|
+
const mockPage = { closeWindow: vi.fn().mockResolvedValue(undefined) };
|
|
405
|
+
vi.spyOn(capRouting, 'shouldUseBrowserSession').mockReturnValue(true);
|
|
406
|
+
vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn) => fn(mockPage));
|
|
407
|
+
vi.spyOn(runtime, 'runWithTimeout').mockRejectedValueOnce(new TimeoutError('logical run', 1));
|
|
408
|
+
const cmd = cli({
|
|
409
|
+
site: 'test-execution',
|
|
410
|
+
name: 'run-timeout-late-unknown-failure',
|
|
411
|
+
access: 'write',
|
|
412
|
+
description: 'test late unknown failure retains ownership for TTL',
|
|
413
|
+
browser: true,
|
|
414
|
+
strategy: Strategy.PUBLIC,
|
|
415
|
+
siteSession: 'persistent',
|
|
416
|
+
func: async () => {
|
|
417
|
+
runId = getDaemonRunContext()?.runId;
|
|
418
|
+
try {
|
|
419
|
+
await adapter.promise;
|
|
420
|
+
}
|
|
421
|
+
finally {
|
|
422
|
+
adapterSettled = true;
|
|
423
|
+
}
|
|
424
|
+
},
|
|
425
|
+
});
|
|
426
|
+
await expect(executeCommand(cmd, {})).rejects.toBeInstanceOf(TimeoutError);
|
|
427
|
+
expect(runId).toMatch(/^run_/);
|
|
428
|
+
expect(mockReleaseSiteSessionLease).not.toHaveBeenCalled();
|
|
429
|
+
const unknownOutcome = new Error('late result unknown');
|
|
430
|
+
unknownOutcome.cause = { code: 'COMMAND_RESULT_UNKNOWN' };
|
|
431
|
+
adapter.reject(unknownOutcome);
|
|
432
|
+
await vi.waitFor(() => expect(adapterSettled).toBe(true));
|
|
433
|
+
expect(getDaemonRunContext()).toBeUndefined();
|
|
434
|
+
expect(mockReleaseSiteSessionLease).not.toHaveBeenCalled();
|
|
435
|
+
});
|
|
436
|
+
it('releases normally when the adapter itself rejects with TimeoutError', async () => {
|
|
437
|
+
let runId;
|
|
438
|
+
const adapterTimeout = new TimeoutError('adapter operation', 1);
|
|
439
|
+
const mockPage = { closeWindow: vi.fn().mockResolvedValue(undefined) };
|
|
440
|
+
vi.spyOn(capRouting, 'shouldUseBrowserSession').mockReturnValue(true);
|
|
441
|
+
vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn) => fn(mockPage));
|
|
442
|
+
const cmd = cli({
|
|
443
|
+
site: 'test-execution',
|
|
444
|
+
name: 'run-adapter-timeout',
|
|
445
|
+
access: 'write',
|
|
446
|
+
description: 'test a settled adapter timeout releases normally',
|
|
447
|
+
browser: true,
|
|
448
|
+
strategy: Strategy.PUBLIC,
|
|
449
|
+
siteSession: 'persistent',
|
|
450
|
+
func: async () => {
|
|
451
|
+
runId = getDaemonRunContext()?.runId;
|
|
452
|
+
throw adapterTimeout;
|
|
453
|
+
},
|
|
454
|
+
});
|
|
455
|
+
await expect(executeCommand(cmd, {})).rejects.toBe(adapterTimeout);
|
|
456
|
+
expect(runId).toMatch(/^run_/);
|
|
457
|
+
expect(getDaemonRunContext()).toBeUndefined();
|
|
458
|
+
expect(mockReleaseSiteSessionLease).toHaveBeenCalledOnce();
|
|
459
|
+
expect(mockReleaseSiteSessionLease).toHaveBeenCalledWith(runId);
|
|
460
|
+
});
|
|
461
|
+
it('does not let deferred timeout cleanup clear a later run', async () => {
|
|
462
|
+
const firstAdapter = deferred();
|
|
463
|
+
const secondAdapter = deferred();
|
|
464
|
+
let firstRunId;
|
|
465
|
+
let secondRunId;
|
|
466
|
+
const mockPage = { closeWindow: vi.fn().mockResolvedValue(undefined) };
|
|
467
|
+
vi.spyOn(capRouting, 'shouldUseBrowserSession').mockReturnValue(true);
|
|
468
|
+
vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn) => fn(mockPage));
|
|
469
|
+
vi.spyOn(runtime, 'runWithTimeout')
|
|
470
|
+
.mockRejectedValueOnce(new TimeoutError('first logical run', 1))
|
|
471
|
+
.mockImplementationOnce(async (promise) => promise);
|
|
472
|
+
const firstCommand = cli({
|
|
473
|
+
site: 'test-execution',
|
|
474
|
+
name: 'run-timeout-first',
|
|
475
|
+
access: 'write',
|
|
476
|
+
description: 'test deferred cleanup for an older run',
|
|
477
|
+
browser: true,
|
|
478
|
+
strategy: Strategy.PUBLIC,
|
|
479
|
+
siteSession: 'persistent',
|
|
480
|
+
func: () => {
|
|
481
|
+
firstRunId = getDaemonRunContext()?.runId;
|
|
482
|
+
return firstAdapter.promise;
|
|
483
|
+
},
|
|
484
|
+
});
|
|
485
|
+
const secondCommand = cli({
|
|
486
|
+
site: 'test-execution',
|
|
487
|
+
name: 'run-timeout-second',
|
|
488
|
+
access: 'write',
|
|
489
|
+
description: 'test a newer run survives old deferred cleanup',
|
|
490
|
+
browser: true,
|
|
491
|
+
strategy: Strategy.PUBLIC,
|
|
492
|
+
siteSession: 'persistent',
|
|
493
|
+
func: () => {
|
|
494
|
+
secondRunId = getDaemonRunContext()?.runId;
|
|
495
|
+
return secondAdapter.promise;
|
|
496
|
+
},
|
|
497
|
+
});
|
|
498
|
+
await expect(executeCommand(firstCommand, {})).rejects.toBeInstanceOf(TimeoutError);
|
|
499
|
+
const secondExecution = executeCommand(secondCommand, {});
|
|
500
|
+
await vi.waitFor(() => expect(secondRunId).toMatch(/^run_/));
|
|
501
|
+
expect(secondRunId).not.toBe(firstRunId);
|
|
502
|
+
firstAdapter.resolve();
|
|
503
|
+
await firstAdapter.promise;
|
|
504
|
+
await vi.waitFor(() => expect(getDaemonRunContext()).toBeUndefined());
|
|
505
|
+
secondAdapter.resolve();
|
|
506
|
+
await expect(secondExecution).resolves.toBeUndefined();
|
|
507
|
+
expect(getDaemonRunContext()).toBeUndefined();
|
|
508
|
+
expect(mockReleaseSiteSessionLease).toHaveBeenCalledTimes(2);
|
|
509
|
+
expect(mockReleaseSiteSessionLease).toHaveBeenCalledWith(firstRunId);
|
|
510
|
+
expect(mockReleaseSiteSessionLease).toHaveBeenCalledWith(secondRunId);
|
|
511
|
+
});
|
|
512
|
+
it('keeps daemon metadata scoped to overlapping timed-out and active runs', async () => {
|
|
513
|
+
const resumeFirst = deferred();
|
|
514
|
+
const firstFinished = deferred();
|
|
515
|
+
const finishSecond = deferred();
|
|
516
|
+
const secondStarted = deferred();
|
|
517
|
+
let firstRunId;
|
|
518
|
+
let secondRunId;
|
|
519
|
+
const mockPage = { closeWindow: vi.fn().mockResolvedValue(undefined) };
|
|
520
|
+
vi.spyOn(capRouting, 'shouldUseBrowserSession').mockReturnValue(true);
|
|
521
|
+
vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn) => fn(mockPage));
|
|
522
|
+
vi.spyOn(runtime, 'runWithTimeout')
|
|
523
|
+
.mockRejectedValueOnce(new TimeoutError('first logical run', 1))
|
|
524
|
+
.mockImplementationOnce(async (promise) => promise);
|
|
525
|
+
vi.stubGlobal('fetch', vi.fn().mockImplementation(async (_input, init) => {
|
|
526
|
+
const request = JSON.parse(String(init?.body));
|
|
527
|
+
return {
|
|
528
|
+
status: 200,
|
|
529
|
+
json: async () => ({ id: request.id, ok: true, data: 'ok' }),
|
|
530
|
+
};
|
|
531
|
+
}));
|
|
532
|
+
const firstCommand = cli({
|
|
533
|
+
site: 'test-execution',
|
|
534
|
+
name: 'run-overlap-first',
|
|
535
|
+
access: 'write',
|
|
536
|
+
description: 'test timed-out run keeps its daemon metadata',
|
|
537
|
+
browser: true,
|
|
538
|
+
strategy: Strategy.PUBLIC,
|
|
539
|
+
siteSession: 'persistent',
|
|
540
|
+
func: async () => {
|
|
541
|
+
firstRunId = getDaemonRunContext()?.runId;
|
|
542
|
+
await resumeFirst.promise;
|
|
543
|
+
await sendCommand('exec', { code: 'first-after-second' });
|
|
544
|
+
firstFinished.resolve();
|
|
545
|
+
},
|
|
546
|
+
});
|
|
547
|
+
const secondCommand = cli({
|
|
548
|
+
site: 'test-execution',
|
|
549
|
+
name: 'run-overlap-second',
|
|
550
|
+
access: 'write',
|
|
551
|
+
description: 'test active run keeps its daemon metadata',
|
|
552
|
+
browser: true,
|
|
553
|
+
strategy: Strategy.PUBLIC,
|
|
554
|
+
siteSession: 'persistent',
|
|
555
|
+
func: async () => {
|
|
556
|
+
secondRunId = getDaemonRunContext()?.runId;
|
|
557
|
+
await sendCommand('exec', { code: 'second-active' });
|
|
558
|
+
secondStarted.resolve();
|
|
559
|
+
await finishSecond.promise;
|
|
560
|
+
},
|
|
561
|
+
});
|
|
562
|
+
await expect(executeCommand(firstCommand, {})).rejects.toBeInstanceOf(TimeoutError);
|
|
563
|
+
const secondExecution = executeCommand(secondCommand, {});
|
|
564
|
+
await secondStarted.promise;
|
|
565
|
+
resumeFirst.resolve();
|
|
566
|
+
await firstFinished.promise;
|
|
567
|
+
const bodies = vi.mocked(fetch).mock.calls.map(([, init]) => JSON.parse(String(init?.body)));
|
|
568
|
+
expect(bodies.find(body => body.code === 'first-after-second')?.runId).toBe(firstRunId);
|
|
569
|
+
expect(bodies.find(body => body.code === 'second-active')?.runId).toBe(secondRunId);
|
|
570
|
+
expect(firstRunId).not.toBe(secondRunId);
|
|
571
|
+
finishSecond.resolve();
|
|
572
|
+
await expect(secondExecution).resolves.toBeUndefined();
|
|
573
|
+
});
|
|
574
|
+
});
|
|
151
575
|
it('reuses a persistent site browser session and keeps the tab lease open', async () => {
|
|
152
576
|
const closeWindow = vi.fn().mockResolvedValue(undefined);
|
|
153
577
|
const mockPage = { closeWindow };
|
|
@@ -10,6 +10,17 @@ const EXPECTED_LOCAL_TOOLS = [
|
|
|
10
10
|
'antigravity/state-keys',
|
|
11
11
|
'antigravity/workspaces-list',
|
|
12
12
|
'mercury/reimbursement-plan',
|
|
13
|
+
'spotify/auth',
|
|
14
|
+
'spotify/next',
|
|
15
|
+
'spotify/pause',
|
|
16
|
+
'spotify/play',
|
|
17
|
+
'spotify/prev',
|
|
18
|
+
'spotify/queue',
|
|
19
|
+
'spotify/repeat',
|
|
20
|
+
'spotify/search',
|
|
21
|
+
'spotify/shuffle',
|
|
22
|
+
'spotify/status',
|
|
23
|
+
'spotify/volume',
|
|
13
24
|
'trae-solo/extensions-list',
|
|
14
25
|
'trae-solo/recent-workspaces',
|
|
15
26
|
'trae-solo/settings-read',
|
|
@@ -174,7 +185,7 @@ describe('hosted availability', () => {
|
|
|
174
185
|
const desktopApps = (byReason.get('desktop-app') ?? []).sort();
|
|
175
186
|
expect(exceptionDiff(localTools, EXPECTED_LOCAL_TOOLS)).toEqual({ added: [], missing: [] });
|
|
176
187
|
expect(exceptionDiff(desktopApps, EXPECTED_DESKTOP_APPS)).toEqual({ added: [], missing: [] });
|
|
177
|
-
expect(localTools).toHaveLength(
|
|
188
|
+
expect(localTools).toHaveLength(30);
|
|
178
189
|
expect(desktopApps).toHaveLength(111);
|
|
179
190
|
});
|
|
180
191
|
});
|
|
@@ -293,7 +293,7 @@ function isHostedManifestCommand(value) {
|
|
|
293
293
|
if (!hasOnlyKeys(value, [
|
|
294
294
|
'site', 'name', 'aliases', 'command', 'description', 'access', 'example', 'domain', 'strategy', 'browser',
|
|
295
295
|
'args', 'columns', 'pipeline', 'defaultFormat', 'type', 'modulePath', 'sourceFile', 'navigateBefore',
|
|
296
|
-
'siteSession', 'defaultWindowMode', 'adapterPackageId', 'adapterPackageName', 'adapterPackageVersion',
|
|
296
|
+
'siteSession', 'freshPage', 'defaultWindowMode', 'adapterPackageId', 'adapterPackageName', 'adapterPackageVersion',
|
|
297
297
|
]))
|
|
298
298
|
return false;
|
|
299
299
|
if (typeof value.site !== 'string' || typeof value.name !== 'string' || typeof value.command !== 'string')
|
|
@@ -318,6 +318,8 @@ function isHostedManifestCommand(value) {
|
|
|
318
318
|
if (value[key] !== undefined && typeof value[key] !== 'string')
|
|
319
319
|
return false;
|
|
320
320
|
}
|
|
321
|
+
if (value.freshPage !== undefined && typeof value.freshPage !== 'boolean')
|
|
322
|
+
return false;
|
|
321
323
|
return value.navigateBefore === undefined || typeof value.navigateBefore === 'boolean' || typeof value.navigateBefore === 'string';
|
|
322
324
|
}
|
|
323
325
|
function isHostedManifestArg(value) {
|