@agentrhq/webcmd 0.3.2 → 0.3.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 +12 -7
- package/clis/producthunt/browse.js +9 -2
- package/clis/producthunt/browser-commands.test.js +66 -0
- package/clis/producthunt/hot.js +2 -1
- package/clis/producthunt/utils.js +27 -0
- package/clis/producthunt/utils.test.js +8 -0
- package/dist/src/browser/ax-snapshot.js +15 -5
- package/dist/src/browser/ax-snapshot.test.js +49 -0
- 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/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 +1 -1
- package/package.json +3 -1
- 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/smart-search/SKILL.md +20 -6
- package/skills/webcmd-adapter-author/SKILL.md +1 -1
- package/skills/webcmd-adapter-author/references/adapter-template.md +2 -6
- package/skills/webcmd-browser/SKILL.md +16 -2
- package/skills/webcmd-usage/SKILL.md +21 -6
|
@@ -1,8 +1,37 @@
|
|
|
1
1
|
import { createServer } from 'node:http';
|
|
2
2
|
import { DAEMON_HEADER_NAME, DEFAULT_DAEMON_PORT } from '../constants.js';
|
|
3
3
|
import { buildCommandTimeoutFailure, getResponseCorsHeaders } from '../daemon-utils.js';
|
|
4
|
+
import { getSessionLeaseKey, isSessionLeaseCommand, SessionLeaseRegistry } from '../session-lease.js';
|
|
4
5
|
const MAX_BODY = 1024 * 1024;
|
|
5
6
|
const LOG_BUFFER_SIZE = 200;
|
|
7
|
+
function commandTimeoutMs(command) {
|
|
8
|
+
return typeof command.deadlineAt === 'number' && command.deadlineAt > 0
|
|
9
|
+
? Math.max(1000, command.deadlineAt - Date.now())
|
|
10
|
+
: (typeof command.timeout === 'number' && command.timeout > 0 ? command.timeout * 1000 : 120_000);
|
|
11
|
+
}
|
|
12
|
+
function waitForCommandResult(command, providerPromise) {
|
|
13
|
+
const timeoutMs = commandTimeoutMs(command);
|
|
14
|
+
let timeoutId;
|
|
15
|
+
const responsePromise = Promise.race([
|
|
16
|
+
providerPromise,
|
|
17
|
+
new Promise((resolve) => {
|
|
18
|
+
timeoutId = setTimeout(() => {
|
|
19
|
+
const failure = buildCommandTimeoutFailure(command.action, timeoutMs);
|
|
20
|
+
resolve({
|
|
21
|
+
id: command.id,
|
|
22
|
+
ok: false,
|
|
23
|
+
errorCode: failure.errorCode,
|
|
24
|
+
error: failure.message,
|
|
25
|
+
errorHint: failure.errorHint,
|
|
26
|
+
});
|
|
27
|
+
}, timeoutMs);
|
|
28
|
+
}),
|
|
29
|
+
]);
|
|
30
|
+
return responsePromise.finally(() => {
|
|
31
|
+
if (timeoutId)
|
|
32
|
+
clearTimeout(timeoutId);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
6
35
|
function readBody(req) {
|
|
7
36
|
return new Promise((resolve, reject) => {
|
|
8
37
|
const chunks = [];
|
|
@@ -37,6 +66,8 @@ export function createDaemonServer(provider, opts) {
|
|
|
37
66
|
const host = opts.host ?? '127.0.0.1';
|
|
38
67
|
const logBuffer = [];
|
|
39
68
|
const pending = new Map();
|
|
69
|
+
const leases = new SessionLeaseRegistry();
|
|
70
|
+
const hasPendingWork = (runId) => [...pending.values()].some((entry) => entry.runId === runId);
|
|
40
71
|
let shutdownStarted = false;
|
|
41
72
|
function pushLog(level, msg) {
|
|
42
73
|
logBuffer.push({ level, msg, ts: Date.now() });
|
|
@@ -82,6 +113,7 @@ export function createDaemonServer(provider, opts) {
|
|
|
82
113
|
daemonVersion: opts.version,
|
|
83
114
|
...runtime,
|
|
84
115
|
pending: pending.size + runtime.pending,
|
|
116
|
+
sessionLeases: leases.list(hasPendingWork).map(({ runId: _runId, ...lease }) => lease),
|
|
85
117
|
memoryMB: Math.round(mem.rss / 1024 / 1024 * 10) / 10,
|
|
86
118
|
port,
|
|
87
119
|
});
|
|
@@ -117,35 +149,44 @@ export function createDaemonServer(provider, opts) {
|
|
|
117
149
|
}
|
|
118
150
|
const existing = pending.get(body.id);
|
|
119
151
|
if (existing) {
|
|
120
|
-
const result = await existing;
|
|
152
|
+
const result = await waitForCommandResult(body, existing.promise);
|
|
121
153
|
jsonResponse(res, result.ok ? 200 : result.errorCode === 'command_result_unknown' ? 408 : 400, result);
|
|
122
154
|
return;
|
|
123
155
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
156
|
+
if (body.action === 'lease-release') {
|
|
157
|
+
const released = typeof body.runId === 'string' ? leases.releaseByRunId(body.runId) : 0;
|
|
158
|
+
jsonResponse(res, 200, { id: body.id, ok: true, data: { released } });
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
let leaseKey;
|
|
162
|
+
let runId;
|
|
163
|
+
if (isSessionLeaseCommand(body)) {
|
|
164
|
+
const profileId = provider.resolveProfileId?.(body)
|
|
165
|
+
?? body.profileId
|
|
166
|
+
?? body.contextId
|
|
167
|
+
?? body.preferredContextId
|
|
168
|
+
?? 'default';
|
|
169
|
+
leaseKey = getSessionLeaseKey(profileId, body.surface, body.session);
|
|
170
|
+
runId = body.runId;
|
|
171
|
+
const acquired = leases.acquire({
|
|
172
|
+
key: leaseKey,
|
|
173
|
+
runId,
|
|
174
|
+
command: body.command ?? body.action,
|
|
175
|
+
pid: body.pid,
|
|
176
|
+
}, hasPendingWork);
|
|
177
|
+
if (!acquired.acquired) {
|
|
178
|
+
const { key: _key, runId: _runId, ...holder } = acquired.holder;
|
|
179
|
+
jsonResponse(res, 409, { ok: false, code: 'session_busy', holder });
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
const commandPromise = provider.dispatch(body).finally(() => {
|
|
184
|
+
if (leaseKey && runId)
|
|
185
|
+
leases.heartbeat(leaseKey, runId);
|
|
145
186
|
pending.delete(body.id);
|
|
146
187
|
});
|
|
147
|
-
pending.set(body.id, commandPromise);
|
|
148
|
-
const result = await commandPromise;
|
|
188
|
+
pending.set(body.id, { promise: commandPromise, runId, leaseKey });
|
|
189
|
+
const result = await waitForCommandResult(body, commandPromise);
|
|
149
190
|
if (!result.ok)
|
|
150
191
|
pushLog('warn', `Command ${body.id} failed: ${result.error ?? result.errorCode ?? 'unknown error'}`);
|
|
151
192
|
jsonResponse(res, result.ok ? 200 : result.errorCode === 'command_result_unknown' ? 408 : 400, result);
|
|
@@ -5,6 +5,11 @@ class FakeProvider {
|
|
|
5
5
|
commands = [];
|
|
6
6
|
shutdownCalled = false;
|
|
7
7
|
delayMs = 0;
|
|
8
|
+
dispatchImpl;
|
|
9
|
+
resolveProfileId;
|
|
10
|
+
result(command) {
|
|
11
|
+
return { id: command.id, ok: true, data: { action: command.action }, page: 'page-1' };
|
|
12
|
+
}
|
|
8
13
|
async status() {
|
|
9
14
|
return {
|
|
10
15
|
runtimeConnected: true,
|
|
@@ -16,10 +21,12 @@ class FakeProvider {
|
|
|
16
21
|
};
|
|
17
22
|
}
|
|
18
23
|
async dispatch(command) {
|
|
24
|
+
this.commands.push(command);
|
|
25
|
+
if (this.dispatchImpl)
|
|
26
|
+
return this.dispatchImpl(command);
|
|
19
27
|
if (this.delayMs > 0)
|
|
20
28
|
await new Promise((resolve) => setTimeout(resolve, this.delayMs));
|
|
21
|
-
this.
|
|
22
|
-
return { id: command.id, ok: true, data: { action: command.action }, page: 'page-1' };
|
|
29
|
+
return this.result(command);
|
|
23
30
|
}
|
|
24
31
|
async shutdown() {
|
|
25
32
|
this.shutdownCalled = true;
|
|
@@ -30,6 +37,7 @@ describe('createDaemonServer', () => {
|
|
|
30
37
|
afterEach(async () => {
|
|
31
38
|
while (servers.length)
|
|
32
39
|
await servers.pop().close();
|
|
40
|
+
vi.useRealTimers();
|
|
33
41
|
});
|
|
34
42
|
async function start(provider = new FakeProvider()) {
|
|
35
43
|
const daemon = createDaemonServer(provider, { port: 0, host: '127.0.0.1', version: 'test' });
|
|
@@ -38,6 +46,28 @@ describe('createDaemonServer', () => {
|
|
|
38
46
|
const address = daemon.server.address();
|
|
39
47
|
return { provider, baseUrl: `http://127.0.0.1:${address.port}` };
|
|
40
48
|
}
|
|
49
|
+
function postCommand(baseUrl, body) {
|
|
50
|
+
return fetch(`${baseUrl}/command`, {
|
|
51
|
+
method: 'POST',
|
|
52
|
+
headers: { [DAEMON_HEADER_NAME]: '1', 'Content-Type': 'application/json' },
|
|
53
|
+
body: JSON.stringify(body),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
function persistentWrite(id, runId, overrides = {}) {
|
|
57
|
+
return {
|
|
58
|
+
id,
|
|
59
|
+
action: 'exec',
|
|
60
|
+
code: '1',
|
|
61
|
+
surface: 'adapter',
|
|
62
|
+
siteSession: 'persistent',
|
|
63
|
+
access: 'write',
|
|
64
|
+
session: 'site:example',
|
|
65
|
+
runId,
|
|
66
|
+
command: 'example write',
|
|
67
|
+
pid: 4242,
|
|
68
|
+
...overrides,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
41
71
|
it('returns runtime-named status fields without extension aliases', async () => {
|
|
42
72
|
const { baseUrl } = await start();
|
|
43
73
|
const res = await fetch(`${baseUrl}/status`, { headers: { [DAEMON_HEADER_NAME]: '1' } });
|
|
@@ -145,6 +175,185 @@ describe('createDaemonServer', () => {
|
|
|
145
175
|
await expect(second.json()).resolves.toMatchObject({ id: 'same-id', ok: true });
|
|
146
176
|
expect(provider.commands).toHaveLength(1);
|
|
147
177
|
});
|
|
178
|
+
it('rejects a second persistent adapter writer for the same resolved profile and session', async () => {
|
|
179
|
+
const provider = new FakeProvider();
|
|
180
|
+
provider.resolveProfileId = () => 'resolved-work';
|
|
181
|
+
const { baseUrl } = await start(provider);
|
|
182
|
+
const first = await postCommand(baseUrl, persistentWrite('first', 'run_100_1_1'));
|
|
183
|
+
expect(first.status).toBe(200);
|
|
184
|
+
const second = await postCommand(baseUrl, persistentWrite('second', 'run_200_2_2'));
|
|
185
|
+
expect(second.status).toBe(409);
|
|
186
|
+
await expect(second.json()).resolves.toMatchObject({
|
|
187
|
+
ok: false,
|
|
188
|
+
code: 'session_busy',
|
|
189
|
+
holder: {
|
|
190
|
+
command: 'example write',
|
|
191
|
+
pid: 4242,
|
|
192
|
+
acquiredAt: expect.any(Number),
|
|
193
|
+
heartbeatAt: expect.any(Number),
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
expect(provider.commands.map((command) => command.id)).toEqual(['first']);
|
|
197
|
+
});
|
|
198
|
+
it('lets one logical run issue multiple operations and heartbeat its lease', async () => {
|
|
199
|
+
let now = 1_000;
|
|
200
|
+
vi.spyOn(Date, 'now').mockImplementation(() => now);
|
|
201
|
+
const { provider, baseUrl } = await start();
|
|
202
|
+
expect((await postCommand(baseUrl, persistentWrite('first', 'run_100_1_1'))).status).toBe(200);
|
|
203
|
+
now = 20_000;
|
|
204
|
+
expect((await postCommand(baseUrl, persistentWrite('second', 'run_100_1_1'))).status).toBe(200);
|
|
205
|
+
const status = await fetch(`${baseUrl}/status`, { headers: { [DAEMON_HEADER_NAME]: '1' } });
|
|
206
|
+
await expect(status.json()).resolves.toMatchObject({
|
|
207
|
+
sessionLeases: [{
|
|
208
|
+
key: 'default␟adapter␟site%3Aexample',
|
|
209
|
+
command: 'example write',
|
|
210
|
+
acquiredAt: 1_000,
|
|
211
|
+
heartbeatAt: 20_000,
|
|
212
|
+
}],
|
|
213
|
+
});
|
|
214
|
+
expect(provider.commands.map((command) => command.id)).toEqual(['first', 'second']);
|
|
215
|
+
});
|
|
216
|
+
it.each([
|
|
217
|
+
['read access', { access: 'read' }],
|
|
218
|
+
['ephemeral sessions', { siteSession: 'ephemeral' }],
|
|
219
|
+
['raw browser surface', { surface: 'browser' }],
|
|
220
|
+
['different sites', { session: 'site:other' }],
|
|
221
|
+
['different resolved profiles', { profileId: 'other' }],
|
|
222
|
+
])('does not conflict across %s', async (_case, overrides) => {
|
|
223
|
+
const provider = new FakeProvider();
|
|
224
|
+
provider.resolveProfileId = (command) => command.profileId ?? 'default';
|
|
225
|
+
const { baseUrl } = await start(provider);
|
|
226
|
+
expect((await postCommand(baseUrl, persistentWrite('owner', 'run_100_1_1'))).status).toBe(200);
|
|
227
|
+
expect((await postCommand(baseUrl, persistentWrite('other', 'run_200_2_2', overrides))).status).toBe(200);
|
|
228
|
+
expect(provider.commands.map((command) => command.id)).toEqual(['owner', 'other']);
|
|
229
|
+
});
|
|
230
|
+
it('keeps pending holders live past the TTL and heartbeats them before pending removal', async () => {
|
|
231
|
+
let now = 1_000;
|
|
232
|
+
vi.spyOn(Date, 'now').mockImplementation(() => now);
|
|
233
|
+
let settle;
|
|
234
|
+
const provider = new FakeProvider();
|
|
235
|
+
provider.dispatchImpl = (command) => new Promise((resolve) => {
|
|
236
|
+
settle = () => resolve({ id: command.id, ok: true, data: 'done' });
|
|
237
|
+
});
|
|
238
|
+
const { baseUrl } = await start(provider);
|
|
239
|
+
const pending = postCommand(baseUrl, persistentWrite('pending', 'run_100_1_1'));
|
|
240
|
+
try {
|
|
241
|
+
await vi.waitFor(() => expect(provider.commands).toHaveLength(1));
|
|
242
|
+
now = 46_001;
|
|
243
|
+
const whilePending = await fetch(`${baseUrl}/status`, { headers: { [DAEMON_HEADER_NAME]: '1' } });
|
|
244
|
+
await expect(whilePending.json()).resolves.toMatchObject({
|
|
245
|
+
sessionLeases: [{ command: 'example write', heartbeatAt: 1_000 }],
|
|
246
|
+
});
|
|
247
|
+
const conflict = await postCommand(baseUrl, persistentWrite('conflict', 'run_200_2_2'));
|
|
248
|
+
expect(conflict.status).toBe(409);
|
|
249
|
+
expect(provider.commands).toHaveLength(1);
|
|
250
|
+
now = 50_000;
|
|
251
|
+
settle?.();
|
|
252
|
+
expect((await pending).status).toBe(200);
|
|
253
|
+
const afterSettle = await fetch(`${baseUrl}/status`, { headers: { [DAEMON_HEADER_NAME]: '1' } });
|
|
254
|
+
const statusBody = await afterSettle.json();
|
|
255
|
+
expect(statusBody.sessionLeases).toEqual([expect.objectContaining({ heartbeatAt: 50_000 })]);
|
|
256
|
+
expect(statusBody.sessionLeases[0]).not.toHaveProperty('runId');
|
|
257
|
+
}
|
|
258
|
+
finally {
|
|
259
|
+
settle?.();
|
|
260
|
+
await pending.catch(() => undefined);
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
it('keeps timed-out provider work pending until provider settlement', async () => {
|
|
264
|
+
let now = 1_000;
|
|
265
|
+
const dateNow = vi.spyOn(Date, 'now').mockImplementation(() => now);
|
|
266
|
+
let settleProvider;
|
|
267
|
+
let providerStarted;
|
|
268
|
+
const providerStartedPromise = new Promise((resolve) => {
|
|
269
|
+
providerStarted = resolve;
|
|
270
|
+
});
|
|
271
|
+
const provider = new FakeProvider();
|
|
272
|
+
provider.dispatchImpl = (command) => {
|
|
273
|
+
if (command.id !== 'timed-out') {
|
|
274
|
+
return Promise.resolve({ id: command.id, ok: true, data: 'done' });
|
|
275
|
+
}
|
|
276
|
+
if (provider.commands.filter(({ id }) => id === command.id).length > 1) {
|
|
277
|
+
return Promise.resolve({ id: command.id, ok: true, data: 'duplicate dispatch' });
|
|
278
|
+
}
|
|
279
|
+
providerStarted();
|
|
280
|
+
return new Promise((resolve) => {
|
|
281
|
+
settleProvider = () => resolve({ id: command.id, ok: true, data: 'done' });
|
|
282
|
+
});
|
|
283
|
+
};
|
|
284
|
+
const { baseUrl } = await start(provider);
|
|
285
|
+
const firstRequest = postCommand(baseUrl, persistentWrite('timed-out', 'run_100_1_1', { timeout: 0.01 }));
|
|
286
|
+
try {
|
|
287
|
+
await providerStartedPromise;
|
|
288
|
+
const timedOut = await firstRequest;
|
|
289
|
+
expect(timedOut.status).toBe(408);
|
|
290
|
+
await expect(timedOut.json()).resolves.toMatchObject({
|
|
291
|
+
id: 'timed-out',
|
|
292
|
+
ok: false,
|
|
293
|
+
errorCode: 'command_result_unknown',
|
|
294
|
+
});
|
|
295
|
+
now = 47_001;
|
|
296
|
+
const duplicateRequest = postCommand(baseUrl, persistentWrite('timed-out', 'run_100_1_1', { timeout: 120 }));
|
|
297
|
+
const whileProviderPending = await fetch(`${baseUrl}/status`, { headers: { [DAEMON_HEADER_NAME]: '1' } });
|
|
298
|
+
await expect(whileProviderPending.json()).resolves.toMatchObject({
|
|
299
|
+
pending: 1,
|
|
300
|
+
sessionLeases: [{ command: 'example write', heartbeatAt: 1_000 }],
|
|
301
|
+
});
|
|
302
|
+
expect(provider.commands.map((command) => command.id)).toEqual(['timed-out']);
|
|
303
|
+
const conflict = await postCommand(baseUrl, persistentWrite('conflict', 'run_200_2_2'));
|
|
304
|
+
expect(conflict.status).toBe(409);
|
|
305
|
+
expect(provider.commands.map((command) => command.id)).toEqual(['timed-out']);
|
|
306
|
+
settleProvider?.();
|
|
307
|
+
const duplicate = await duplicateRequest;
|
|
308
|
+
expect(duplicate.status).toBe(200);
|
|
309
|
+
await expect(duplicate.json()).resolves.toMatchObject({ data: 'done' });
|
|
310
|
+
const afterProviderSettle = await fetch(`${baseUrl}/status`, { headers: { [DAEMON_HEADER_NAME]: '1' } });
|
|
311
|
+
await expect(afterProviderSettle.json()).resolves.toMatchObject({
|
|
312
|
+
pending: 0,
|
|
313
|
+
sessionLeases: [{ command: 'example write', heartbeatAt: 47_001 }],
|
|
314
|
+
});
|
|
315
|
+
now = 92_002;
|
|
316
|
+
const reclaimed = await postCommand(baseUrl, persistentWrite('reclaimed', 'run_200_2_2'));
|
|
317
|
+
expect(reclaimed.status).toBe(200);
|
|
318
|
+
expect(provider.commands.map((command) => command.id)).toEqual(['timed-out', 'reclaimed']);
|
|
319
|
+
}
|
|
320
|
+
finally {
|
|
321
|
+
settleProvider?.();
|
|
322
|
+
await firstRequest.catch(() => undefined);
|
|
323
|
+
dateNow.mockRestore();
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
it('handles lease-release locally and permits a new owner', async () => {
|
|
327
|
+
const { provider, baseUrl } = await start();
|
|
328
|
+
expect((await postCommand(baseUrl, persistentWrite('owner', 'run_100_1_1'))).status).toBe(200);
|
|
329
|
+
const released = await postCommand(baseUrl, {
|
|
330
|
+
id: 'release',
|
|
331
|
+
action: 'lease-release',
|
|
332
|
+
runId: 'run_100_1_1',
|
|
333
|
+
});
|
|
334
|
+
expect(released.status).toBe(200);
|
|
335
|
+
await expect(released.json()).resolves.toMatchObject({
|
|
336
|
+
id: 'release',
|
|
337
|
+
ok: true,
|
|
338
|
+
data: { released: 1 },
|
|
339
|
+
});
|
|
340
|
+
expect(provider.commands.map((command) => command.id)).toEqual(['owner']);
|
|
341
|
+
expect((await postCommand(baseUrl, persistentWrite('next-owner', 'run_200_2_2'))).status).toBe(200);
|
|
342
|
+
expect(provider.commands.map((command) => command.id)).toEqual(['owner', 'next-owner']);
|
|
343
|
+
});
|
|
344
|
+
it('returns only sanitized current holders from status', async () => {
|
|
345
|
+
let now = 1_000;
|
|
346
|
+
vi.spyOn(Date, 'now').mockImplementation(() => now);
|
|
347
|
+
const { baseUrl } = await start();
|
|
348
|
+
expect((await postCommand(baseUrl, persistentWrite('owner', 'run_100_1_1'))).status).toBe(200);
|
|
349
|
+
const current = await fetch(`${baseUrl}/status`, { headers: { [DAEMON_HEADER_NAME]: '1' } });
|
|
350
|
+
const currentBody = await current.json();
|
|
351
|
+
expect(currentBody.sessionLeases).toHaveLength(1);
|
|
352
|
+
expect(currentBody.sessionLeases[0]).not.toHaveProperty('runId');
|
|
353
|
+
now = 46_001;
|
|
354
|
+
const expired = await fetch(`${baseUrl}/status`, { headers: { [DAEMON_HEADER_NAME]: '1' } });
|
|
355
|
+
await expect(expired.json()).resolves.toMatchObject({ sessionLeases: [] });
|
|
356
|
+
});
|
|
148
357
|
it('requires X-Webcmd on non-ping endpoints', async () => {
|
|
149
358
|
const { baseUrl } = await start();
|
|
150
359
|
const res = await fetch(`${baseUrl}/status`);
|
|
@@ -310,7 +310,7 @@ describe('documentation sync workflow', () => {
|
|
|
310
310
|
expect(workflow).toContain(event);
|
|
311
311
|
}
|
|
312
312
|
expect(workflow).toContain('contents: read');
|
|
313
|
-
expect(workflow).toContain('pull-requests:
|
|
313
|
+
expect(workflow).toContain('pull-requests: write');
|
|
314
314
|
expect(workflow).toContain('issues: write');
|
|
315
315
|
expect(workflow).toContain('ref: ${{ github.event.repository.default_branch }}');
|
|
316
316
|
expect(workflow).toContain('GH_TOKEN: ${{ github.token }}');
|
package/dist/src/errors.d.ts
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* 130 Interrupted by Ctrl-C (set by tui.ts SIGINT handler)
|
|
21
21
|
*/
|
|
22
22
|
import type { ObservationTraceReceipt } from './observation/events.js';
|
|
23
|
+
import { type SessionLeaseHolder } from './session-lease.js';
|
|
23
24
|
export declare const EXIT_CODES: {
|
|
24
25
|
readonly SUCCESS: 0;
|
|
25
26
|
readonly GENERIC_ERROR: 1;
|
|
@@ -72,6 +73,10 @@ export declare class TimeoutError extends CliError {
|
|
|
72
73
|
export declare class ArgumentError extends CliError {
|
|
73
74
|
constructor(message: string, hint?: string);
|
|
74
75
|
}
|
|
76
|
+
/** A persistent write session is temporarily owned by another logical run. */
|
|
77
|
+
export declare class SessionBusyError extends CliError {
|
|
78
|
+
constructor(holder: SessionLeaseHolder, platform?: string);
|
|
79
|
+
}
|
|
75
80
|
export declare class EmptyResultError extends CliError {
|
|
76
81
|
constructor(command: string, hint?: string);
|
|
77
82
|
}
|
package/dist/src/errors.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isActionablePid } from './session-lease.js';
|
|
1
2
|
// ── Exit code table ──────────────────────────────────────────────────────────
|
|
2
3
|
export const EXIT_CODES = {
|
|
3
4
|
SUCCESS: 0,
|
|
@@ -81,6 +82,28 @@ export class ArgumentError extends CliError {
|
|
|
81
82
|
super('ARGUMENT', message, hint, EXIT_CODES.USAGE_ERROR);
|
|
82
83
|
}
|
|
83
84
|
}
|
|
85
|
+
function formatBusyMessage(holder) {
|
|
86
|
+
const owner = !isActionablePid(holder.pid)
|
|
87
|
+
? holder.command
|
|
88
|
+
: `${holder.command} (pid ${holder.pid})`;
|
|
89
|
+
return `Session is busy: ${owner} is already driving it.`;
|
|
90
|
+
}
|
|
91
|
+
function formatBusyHint(holder, platform) {
|
|
92
|
+
if (platform === 'win32') {
|
|
93
|
+
return !isActionablePid(holder.pid)
|
|
94
|
+
? 'Wait for it to finish, or use Task Manager to stop the owning process if it is stuck.'
|
|
95
|
+
: `Wait for it to finish, or run \`Stop-Process -Id ${holder.pid}\` in PowerShell if it is stuck.`;
|
|
96
|
+
}
|
|
97
|
+
return !isActionablePid(holder.pid)
|
|
98
|
+
? 'Wait for it to finish, or stop the owning process if it is stuck.'
|
|
99
|
+
: `Wait for it to finish, or run \`kill ${holder.pid}\` if it is stuck.`;
|
|
100
|
+
}
|
|
101
|
+
/** A persistent write session is temporarily owned by another logical run. */
|
|
102
|
+
export class SessionBusyError extends CliError {
|
|
103
|
+
constructor(holder, platform = process.platform) {
|
|
104
|
+
super('SESSION_BUSY', formatBusyMessage(holder), formatBusyHint(holder, platform), EXIT_CODES.TEMPFAIL);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
84
107
|
export class EmptyResultError extends CliError {
|
|
85
108
|
constructor(command, hint) {
|
|
86
109
|
super('EMPTY_RESULT', `${command} returned no data`, hint ?? 'The page structure may have changed, or you may need to log in', EXIT_CODES.EMPTY_RESULT);
|
package/dist/src/errors.test.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { CliError, BrowserConnectError, adapterLoadError, CommandExecutionError, ConfigError, AuthRequiredError, TimeoutError, ArgumentError, EmptyResultError, selectorError, attachTraceReceipt, toEnvelope, } from './errors.js';
|
|
2
|
+
import { CliError, BrowserConnectError, adapterLoadError, CommandExecutionError, ConfigError, AuthRequiredError, TimeoutError, ArgumentError, EmptyResultError, selectorError, SessionBusyError, attachTraceReceipt, toEnvelope, } from './errors.js';
|
|
3
3
|
describe('Error type hierarchy', () => {
|
|
4
4
|
it('all error types extend CliError', () => {
|
|
5
5
|
const errors = [
|
|
@@ -71,6 +71,23 @@ describe('toEnvelope', () => {
|
|
|
71
71
|
},
|
|
72
72
|
});
|
|
73
73
|
});
|
|
74
|
+
it('serializes SessionBusyError with the public code and temporary-failure exit code', () => {
|
|
75
|
+
const err = new SessionBusyError({
|
|
76
|
+
command: 'chatgpt ask',
|
|
77
|
+
pid: 4242,
|
|
78
|
+
acquiredAt: 1_000,
|
|
79
|
+
heartbeatAt: 2_000,
|
|
80
|
+
});
|
|
81
|
+
expect(toEnvelope(err)).toEqual({
|
|
82
|
+
ok: false,
|
|
83
|
+
error: {
|
|
84
|
+
code: 'SESSION_BUSY',
|
|
85
|
+
message: expect.stringContaining('chatgpt ask'),
|
|
86
|
+
help: expect.any(String),
|
|
87
|
+
exitCode: 75,
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
});
|
|
74
91
|
it('converts CliError without hint (omits help field)', () => {
|
|
75
92
|
const err = new CommandExecutionError('Something broke');
|
|
76
93
|
const envelope = toEnvelope(err);
|
|
@@ -120,3 +137,43 @@ describe('toEnvelope', () => {
|
|
|
120
137
|
});
|
|
121
138
|
});
|
|
122
139
|
});
|
|
140
|
+
describe('SessionBusyError platform hints', () => {
|
|
141
|
+
const holder = {
|
|
142
|
+
command: 'chatgpt ask',
|
|
143
|
+
pid: 4242,
|
|
144
|
+
acquiredAt: 1_000,
|
|
145
|
+
heartbeatAt: 2_000,
|
|
146
|
+
};
|
|
147
|
+
it('uses PowerShell process guidance on Windows when the holder pid is known', () => {
|
|
148
|
+
const err = new SessionBusyError(holder, 'win32');
|
|
149
|
+
expect(err.hint).toContain('Stop-Process -Id 4242');
|
|
150
|
+
expect(err.hint).not.toContain('kill 4242');
|
|
151
|
+
});
|
|
152
|
+
it('uses Task Manager guidance on Windows when the holder pid is unavailable', () => {
|
|
153
|
+
const err = new SessionBusyError({ ...holder, pid: undefined }, 'win32');
|
|
154
|
+
expect(err.hint).toMatch(/wait/i);
|
|
155
|
+
expect(err.hint).toContain('Task Manager');
|
|
156
|
+
expect(err.hint).not.toContain('Stop-Process');
|
|
157
|
+
});
|
|
158
|
+
it('uses kill guidance on POSIX when the holder pid is known', () => {
|
|
159
|
+
const err = new SessionBusyError(holder, 'linux');
|
|
160
|
+
expect(err.hint).toContain('kill 4242');
|
|
161
|
+
expect(err.hint).not.toContain('Stop-Process');
|
|
162
|
+
});
|
|
163
|
+
it.each([
|
|
164
|
+
0,
|
|
165
|
+
-1,
|
|
166
|
+
1.5,
|
|
167
|
+
Number.NaN,
|
|
168
|
+
Number.POSITIVE_INFINITY,
|
|
169
|
+
Number.MAX_SAFE_INTEGER + 1,
|
|
170
|
+
])('does not expose non-actionable pid %s in process guidance', (pid) => {
|
|
171
|
+
const windowsError = new SessionBusyError({ ...holder, pid }, 'win32');
|
|
172
|
+
expect(windowsError.message).not.toContain(`pid ${pid}`);
|
|
173
|
+
expect(windowsError.hint).toContain('Task Manager');
|
|
174
|
+
expect(windowsError.hint).not.toContain('Stop-Process');
|
|
175
|
+
const posixError = new SessionBusyError({ ...holder, pid }, 'linux');
|
|
176
|
+
expect(posixError.message).not.toContain(`pid ${pid}`);
|
|
177
|
+
expect(posixError.hint).not.toContain('kill');
|
|
178
|
+
});
|
|
179
|
+
});
|
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
|