@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/cli.js
CHANGED
|
@@ -18,7 +18,7 @@ import { render as renderOutput } from './output.js';
|
|
|
18
18
|
import { PKG_VERSION } from './version.js';
|
|
19
19
|
import { printCompletionScript } from './completion.js';
|
|
20
20
|
import { loadExternalClis, executeExternalCli, installExternalCli, registerExternalCli, isBinaryInstalled, formatExternalCliLabel } from './external.js';
|
|
21
|
-
import {
|
|
21
|
+
import { addWebcmdSkills, listWebcmdSkills, removeWebcmdSkills, updateWebcmdSkill } from './skills.js';
|
|
22
22
|
import { registerAllCommands } from './commanderAdapter.js';
|
|
23
23
|
import { buildRootHelpPresentation, classifyAdapter, installCommanderNamespaceStructuredHelp, installRootPresentationHelp, leadingPositionalFromUsage, rootHelpData } from './help.js';
|
|
24
24
|
import { EXIT_CODES, getErrorMessage, BrowserConnectError, CliError, ArgumentError } from './errors.js';
|
|
@@ -62,15 +62,15 @@ function parseDurationMs(raw, flagName) {
|
|
|
62
62
|
function timestampFromRaw(value) {
|
|
63
63
|
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : Date.now();
|
|
64
64
|
}
|
|
65
|
-
function
|
|
65
|
+
function isInteractiveSkillAdd(opts) {
|
|
66
66
|
return !opts.json && process.stdin.isTTY === true && process.stdout.isTTY === true;
|
|
67
67
|
}
|
|
68
|
-
async function
|
|
69
|
-
if (!
|
|
68
|
+
async function resolveSkillAddOptions(opts) {
|
|
69
|
+
if (!isInteractiveSkillAdd(opts))
|
|
70
70
|
return opts;
|
|
71
71
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
72
72
|
try {
|
|
73
|
-
const scope = opts.scope ?? await choosePrompt(rl, 'Where should Webcmd
|
|
73
|
+
const scope = opts.scope ?? await choosePrompt(rl, 'Where should Webcmd add skills?', [
|
|
74
74
|
{ key: '1', label: 'Global', value: 'user', aliases: ['global', 'user', 'g'] },
|
|
75
75
|
{ key: '2', label: 'Local project', value: 'project', aliases: ['local', 'project', 'l'] },
|
|
76
76
|
], '1');
|
|
@@ -126,6 +126,24 @@ async function handleSkillLinkCommand(action, json, verb) {
|
|
|
126
126
|
process.exitCode = err instanceof CliError ? err.exitCode : EXIT_CODES.GENERIC_ERROR;
|
|
127
127
|
}
|
|
128
128
|
}
|
|
129
|
+
function handleSkillRemoveCommand(customPath, json) {
|
|
130
|
+
try {
|
|
131
|
+
const result = removeWebcmdSkills({ customPath });
|
|
132
|
+
if (json) {
|
|
133
|
+
console.log(JSON.stringify(result, null, 2));
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
console.log(`Webcmd skill links removed: ${result.removed.length}`);
|
|
137
|
+
for (const linkPath of result.removed)
|
|
138
|
+
console.log(linkPath);
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
console.error(`Error: ${getErrorMessage(err)}`);
|
|
142
|
+
if (err instanceof CliError && err.hint)
|
|
143
|
+
console.error(`Hint: ${err.hint}`);
|
|
144
|
+
process.exitCode = err instanceof CliError ? err.exitCode : EXIT_CODES.GENERIC_ERROR;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
129
147
|
function toIsoTimestamp(timestamp) {
|
|
130
148
|
if (typeof timestamp !== 'number' || !Number.isFinite(timestamp) || timestamp <= 0)
|
|
131
149
|
return undefined;
|
|
@@ -706,7 +724,7 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
|
|
|
706
724
|
});
|
|
707
725
|
const skillsCmd = program
|
|
708
726
|
.command('skills')
|
|
709
|
-
.description('List,
|
|
727
|
+
.description('List, add, update, and remove bundled Webcmd skills')
|
|
710
728
|
.action(() => {
|
|
711
729
|
const rows = listWebcmdSkills();
|
|
712
730
|
renderOutput(rows, {
|
|
@@ -732,24 +750,24 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
|
|
|
732
750
|
});
|
|
733
751
|
});
|
|
734
752
|
skillsCmd
|
|
735
|
-
.command('
|
|
736
|
-
.description('
|
|
753
|
+
.command('add')
|
|
754
|
+
.description('Add bundled Webcmd skills to an agent skills folder')
|
|
737
755
|
.option('-p, --provider <provider>', 'Agent provider: agents, codex, claude')
|
|
738
|
-
.option('-s, --scope <scope>', '
|
|
756
|
+
.option('-s, --scope <scope>', 'Add scope: user/global or project/local')
|
|
739
757
|
.option('--path <path>', 'Custom agent skills directory')
|
|
740
758
|
.option('--json', 'Output a JSON envelope', false)
|
|
741
759
|
.action(async (opts) => {
|
|
742
760
|
await handleSkillLinkCommand(async () => {
|
|
743
|
-
const resolved = await
|
|
761
|
+
const resolved = await resolveSkillAddOptions(opts);
|
|
744
762
|
if (resolved.provider === 'custom' && !resolved.path) {
|
|
745
763
|
throw new ArgumentError('Custom skill provider requires --path.', 'Pass --path <skills-dir> or run interactively.');
|
|
746
764
|
}
|
|
747
|
-
return
|
|
765
|
+
return addWebcmdSkills({
|
|
748
766
|
provider: resolved.provider,
|
|
749
767
|
scope: resolved.scope,
|
|
750
768
|
customPath: resolved.path,
|
|
751
769
|
});
|
|
752
|
-
}, opts.json, '
|
|
770
|
+
}, opts.json, 'added');
|
|
753
771
|
});
|
|
754
772
|
skillsCmd
|
|
755
773
|
.command('update')
|
|
@@ -765,6 +783,12 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
|
|
|
765
783
|
customPath: opts.path,
|
|
766
784
|
}), opts.json, 'updated');
|
|
767
785
|
});
|
|
786
|
+
skillsCmd
|
|
787
|
+
.command('remove')
|
|
788
|
+
.description('Remove bundled Webcmd skill symlinks from supported locations')
|
|
789
|
+
.option('--path <path>', 'Also remove links from a custom agent skills directory')
|
|
790
|
+
.option('--json', 'Output a JSON envelope', false)
|
|
791
|
+
.action((opts) => handleSkillRemoveCommand(opts.path, opts.json));
|
|
768
792
|
const authCmd = registerAuthCommands(program);
|
|
769
793
|
program
|
|
770
794
|
.command('convention-audit')
|
package/dist/src/cli.test.js
CHANGED
|
@@ -60,6 +60,11 @@ describe('createProgram root help descriptions', () => {
|
|
|
60
60
|
expect(descriptionFor(program, 'daemon')).toBe('restart, status, stop');
|
|
61
61
|
expect(descriptionFor(program, 'external')).toBe('install, list, register');
|
|
62
62
|
});
|
|
63
|
+
it('exposes add and remove without an install command', () => {
|
|
64
|
+
const skills = createProgram('', '').commands.find((command) => command.name() === 'skills');
|
|
65
|
+
expect(skills.commands.map((command) => command.name())).toEqual(['list', 'add', 'update', 'remove']);
|
|
66
|
+
expect(skills.commands.find((command) => command.name() === 'add')?.aliases()).toEqual([]);
|
|
67
|
+
});
|
|
63
68
|
it('renders auth namespace structured help', () => {
|
|
64
69
|
const argv = process.argv;
|
|
65
70
|
try {
|
|
@@ -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
|
+
});
|