@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
|
@@ -103,6 +103,68 @@ describe('HostedClient', () => {
|
|
|
103
103
|
});
|
|
104
104
|
expect(requests).toEqual([{ url: 'https://api.example.com/v1/manifest', authorization: 'Bearer wcmd_live_test' }]);
|
|
105
105
|
});
|
|
106
|
+
it('accepts boolean freshPage command metadata', async () => {
|
|
107
|
+
const client = new HostedClient({
|
|
108
|
+
apiBaseUrl: 'https://api.example.com',
|
|
109
|
+
apiKey: 'key',
|
|
110
|
+
fetchImpl: async () => new Response(JSON.stringify({
|
|
111
|
+
ok: true,
|
|
112
|
+
manifest: {
|
|
113
|
+
userId: 'user_demo',
|
|
114
|
+
metadata: {
|
|
115
|
+
contractSchemaVersion: 1,
|
|
116
|
+
webcmdPackageVersion: '0.3.0',
|
|
117
|
+
generatedAt: 'now',
|
|
118
|
+
},
|
|
119
|
+
commands: [{
|
|
120
|
+
site: 'district',
|
|
121
|
+
name: 'checkout',
|
|
122
|
+
command: 'district/checkout',
|
|
123
|
+
description: 'Checkout',
|
|
124
|
+
access: 'write',
|
|
125
|
+
strategy: 'UI',
|
|
126
|
+
browser: true,
|
|
127
|
+
args: [],
|
|
128
|
+
columns: [],
|
|
129
|
+
freshPage: true,
|
|
130
|
+
}],
|
|
131
|
+
},
|
|
132
|
+
}), { status: 200 }),
|
|
133
|
+
});
|
|
134
|
+
await expect(client.getManifest()).resolves.toMatchObject({
|
|
135
|
+
commands: [expect.objectContaining({ freshPage: true })],
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
it('rejects non-boolean freshPage command metadata', async () => {
|
|
139
|
+
const client = new HostedClient({
|
|
140
|
+
apiBaseUrl: 'https://api.example.com',
|
|
141
|
+
apiKey: 'key',
|
|
142
|
+
fetchImpl: async () => new Response(JSON.stringify({
|
|
143
|
+
ok: true,
|
|
144
|
+
manifest: {
|
|
145
|
+
userId: 'user_demo',
|
|
146
|
+
metadata: {
|
|
147
|
+
contractSchemaVersion: 1,
|
|
148
|
+
webcmdPackageVersion: '0.3.0',
|
|
149
|
+
generatedAt: 'now',
|
|
150
|
+
},
|
|
151
|
+
commands: [{
|
|
152
|
+
site: 'district',
|
|
153
|
+
name: 'checkout',
|
|
154
|
+
command: 'district/checkout',
|
|
155
|
+
description: 'Checkout',
|
|
156
|
+
access: 'write',
|
|
157
|
+
strategy: 'UI',
|
|
158
|
+
browser: true,
|
|
159
|
+
args: [],
|
|
160
|
+
columns: [],
|
|
161
|
+
freshPage: 'yes',
|
|
162
|
+
}],
|
|
163
|
+
},
|
|
164
|
+
}), { status: 200 }),
|
|
165
|
+
});
|
|
166
|
+
await expect(client.getManifest()).rejects.toMatchObject({ code: 'HOSTED_PROTOCOL' });
|
|
167
|
+
});
|
|
106
168
|
it('parses hosted profile rows without provider identifiers', async () => {
|
|
107
169
|
const client = new HostedClient({
|
|
108
170
|
apiBaseUrl: 'https://api.example.com',
|
|
@@ -24,6 +24,18 @@ const command = {
|
|
|
24
24
|
columns: ['value'],
|
|
25
25
|
defaultFormat: 'plain',
|
|
26
26
|
};
|
|
27
|
+
const authCommand = {
|
|
28
|
+
site: 'auth',
|
|
29
|
+
name: 'status',
|
|
30
|
+
command: 'auth/status',
|
|
31
|
+
description: 'Show hosted login status',
|
|
32
|
+
access: 'read',
|
|
33
|
+
strategy: 'PUBLIC',
|
|
34
|
+
browser: false,
|
|
35
|
+
args: [],
|
|
36
|
+
columns: ['value'],
|
|
37
|
+
defaultFormat: 'plain',
|
|
38
|
+
};
|
|
27
39
|
afterEach(async () => {
|
|
28
40
|
await Promise.all(servers.splice(0).map(server => new Promise((resolve, reject) => {
|
|
29
41
|
server.close(error => error ? reject(error) : resolve());
|
|
@@ -77,6 +89,44 @@ describe('hosted CLI process lifecycle', () => {
|
|
|
77
89
|
expect(result.stdout).toContain('Local-only commands:');
|
|
78
90
|
await expect(readFile(fixture.discoverySentinel, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
|
|
79
91
|
}, 20_000);
|
|
92
|
+
it('runs skills add locally without contacting Cloud when hosted mode is configured', async () => {
|
|
93
|
+
const fixture = await createHostedFixture('success');
|
|
94
|
+
const installDir = path.join(fixture.root, 'agent-skills');
|
|
95
|
+
const result = await runCli(['skills', 'add', '--path', installDir, '--json'], fixture.env);
|
|
96
|
+
expect(result.status).toBe(0);
|
|
97
|
+
expect(result.stderr).toBe('');
|
|
98
|
+
const body = JSON.parse(result.stdout);
|
|
99
|
+
expect(body.skills.map(skill => skill.name)).toEqual([
|
|
100
|
+
'smart-search',
|
|
101
|
+
'webcmd-adapter-author',
|
|
102
|
+
'webcmd-autofix',
|
|
103
|
+
'webcmd-browser',
|
|
104
|
+
'webcmd-browser-sitemap',
|
|
105
|
+
'webcmd-sitemap-author',
|
|
106
|
+
'webcmd-usage',
|
|
107
|
+
]);
|
|
108
|
+
expect(body.skills.every(skill => skill.destination?.startsWith(installDir))).toBe(true);
|
|
109
|
+
await expect(readFile(path.join(installDir, 'webcmd-usage', 'SKILL.md'), 'utf8'))
|
|
110
|
+
.resolves.toContain('webcmd-usage');
|
|
111
|
+
await expect(readFile(fixture.discoverySentinel, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
|
|
112
|
+
expect(fixture.requests).toEqual([]);
|
|
113
|
+
}, 20_000);
|
|
114
|
+
it('keeps hosted auth on Cloud without local discovery', async () => {
|
|
115
|
+
const fixture = await createHostedFixture('success');
|
|
116
|
+
const result = await runCli(['auth', 'status', '-f', 'plain'], fixture.env);
|
|
117
|
+
expect(result.stderr).toBe('');
|
|
118
|
+
expect(result.status).toBe(0);
|
|
119
|
+
expect(fixture.requests).toEqual(['GET /v1/manifest', 'POST /v1/execute']);
|
|
120
|
+
await expect(readFile(fixture.discoverySentinel, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
|
|
121
|
+
}, 20_000);
|
|
122
|
+
it('rejects daemon commands in hosted mode without local discovery', async () => {
|
|
123
|
+
const fixture = await createHostedFixture('success');
|
|
124
|
+
const result = await runCli(['daemon', 'status'], fixture.env);
|
|
125
|
+
expect(result.status).toBe(78);
|
|
126
|
+
expect(result.stderr).toContain('Hosted mode has no local daemon.');
|
|
127
|
+
expect(fixture.requests).toEqual([]);
|
|
128
|
+
await expect(readFile(fixture.discoverySentinel, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
|
|
129
|
+
}, 20_000);
|
|
80
130
|
});
|
|
81
131
|
async function createHostedFixture(outcome) {
|
|
82
132
|
const root = await mkdtemp(path.join(tmpdir(), 'webcmd-hosted-lifecycle-'));
|
|
@@ -84,6 +134,7 @@ async function createHostedFixture(outcome) {
|
|
|
84
134
|
const configDir = path.join(root, 'config');
|
|
85
135
|
const userClis = path.join(root, '.webcmd', 'clis', 'lifecycle-sentinel');
|
|
86
136
|
const discoverySentinel = path.join(root, 'local-discovery-ran');
|
|
137
|
+
const requests = [];
|
|
87
138
|
await mkdir(configDir, { recursive: true });
|
|
88
139
|
await mkdir(userClis, { recursive: true });
|
|
89
140
|
await writeFile(path.join(userClis, 'sentinel.js'), [
|
|
@@ -92,7 +143,8 @@ async function createHostedFixture(outcome) {
|
|
|
92
143
|
"export const sentinel = 'cli(';",
|
|
93
144
|
'',
|
|
94
145
|
].join('\n'));
|
|
95
|
-
const server = createServer((request, response) => {
|
|
146
|
+
const server = createServer(async (request, response) => {
|
|
147
|
+
requests.push(`${request.method ?? 'GET'} ${request.url ?? '/'}`);
|
|
96
148
|
if (request.url === '/v1/manifest') {
|
|
97
149
|
sendChunkedJson(response, {
|
|
98
150
|
ok: true,
|
|
@@ -103,12 +155,16 @@ async function createHostedFixture(outcome) {
|
|
|
103
155
|
webcmdPackageVersion: '0.3.0',
|
|
104
156
|
generatedAt: '2026-07-14T00:00:00.000Z',
|
|
105
157
|
},
|
|
106
|
-
commands: [command],
|
|
158
|
+
commands: [command, authCommand],
|
|
107
159
|
},
|
|
108
160
|
});
|
|
109
161
|
return;
|
|
110
162
|
}
|
|
111
163
|
if (request.url === '/v1/execute' && request.method === 'POST') {
|
|
164
|
+
let requestBody = '';
|
|
165
|
+
for await (const chunk of request)
|
|
166
|
+
requestBody += chunk;
|
|
167
|
+
const invocation = JSON.parse(requestBody);
|
|
112
168
|
if (outcome === 'failure') {
|
|
113
169
|
sendChunkedJson(response, {
|
|
114
170
|
ok: false,
|
|
@@ -118,7 +174,7 @@ async function createHostedFixture(outcome) {
|
|
|
118
174
|
help: 'Retry the lifecycle fixture.',
|
|
119
175
|
exitCode: 69,
|
|
120
176
|
},
|
|
121
|
-
execution: { id: 'exec_lifecycle', command:
|
|
177
|
+
execution: { id: 'exec_lifecycle', command: invocation.command, status: 'failed' },
|
|
122
178
|
}, 422);
|
|
123
179
|
return;
|
|
124
180
|
}
|
|
@@ -126,8 +182,10 @@ async function createHostedFixture(outcome) {
|
|
|
126
182
|
ok: true,
|
|
127
183
|
result: { value: largeOutput },
|
|
128
184
|
columns: ['value'],
|
|
129
|
-
execution: { id: 'exec_lifecycle', command:
|
|
130
|
-
trace
|
|
185
|
+
execution: { id: 'exec_lifecycle', command: invocation.command, status: 'succeeded' },
|
|
186
|
+
...(invocation.trace === 'on'
|
|
187
|
+
? { trace: { executionId: 'exec_lifecycle', receipt: traceReceipt } }
|
|
188
|
+
: {}),
|
|
131
189
|
});
|
|
132
190
|
return;
|
|
133
191
|
}
|
|
@@ -156,10 +214,13 @@ async function createHostedFixture(outcome) {
|
|
|
156
214
|
updatedAt: '2026-07-14T00:00:00.000Z',
|
|
157
215
|
})}\n`, { mode: 0o600 });
|
|
158
216
|
return {
|
|
217
|
+
root,
|
|
159
218
|
discoverySentinel,
|
|
219
|
+
requests,
|
|
160
220
|
env: {
|
|
161
221
|
...process.env,
|
|
162
222
|
HOME: root,
|
|
223
|
+
USERPROFILE: root,
|
|
163
224
|
WEBCMD_CONFIG_DIR: configDir,
|
|
164
225
|
WEBCMD_NO_UPDATE_CHECK: '1',
|
|
165
226
|
},
|
package/dist/src/main.js
CHANGED
|
@@ -65,6 +65,10 @@ if (!fastPathHandled) {
|
|
|
65
65
|
const { runHostedSetup } = await import('./hosted/setup.js');
|
|
66
66
|
process.exitCode = await runHostedSetup();
|
|
67
67
|
}
|
|
68
|
+
else if (argv[0] === 'skills') {
|
|
69
|
+
const { createProgram } = await import('./cli.js');
|
|
70
|
+
await createProgram(BUILTIN_CLIS, USER_CLIS).parseAsync(argv, { from: 'user' });
|
|
71
|
+
}
|
|
68
72
|
else {
|
|
69
73
|
const { shouldUseHostedMode } = await import('./hosted/config.js');
|
|
70
74
|
if (shouldUseHostedMode()) {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
type SpawnOutput = string | Buffer | null | undefined;
|
|
2
|
+
export interface PackageBinSpawnFailure {
|
|
3
|
+
error?: Error;
|
|
4
|
+
signal?: NodeJS.Signals | null;
|
|
5
|
+
status: number | null;
|
|
6
|
+
stdout?: SpawnOutput;
|
|
7
|
+
stderr?: SpawnOutput;
|
|
8
|
+
}
|
|
9
|
+
export declare function packageBinSpawnOptions(platform: NodeJS.Platform, command: string): {
|
|
10
|
+
shell?: true;
|
|
11
|
+
};
|
|
12
|
+
export declare function formatPackageBinSpawnFailure(command: string, args: string[], result: PackageBinSpawnFailure): string;
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
export function packageBinSpawnOptions(platform, command) {
|
|
3
|
+
if (platform !== 'win32')
|
|
4
|
+
return {};
|
|
5
|
+
const basename = path.win32.basename(command).toLowerCase();
|
|
6
|
+
return basename === 'npm' || basename.endsWith('.cmd') ? { shell: true } : {};
|
|
7
|
+
}
|
|
8
|
+
function outputText(output) {
|
|
9
|
+
return output == null ? '' : output.toString().trim();
|
|
10
|
+
}
|
|
11
|
+
export function formatPackageBinSpawnFailure(command, args, result) {
|
|
12
|
+
const invocation = [command, ...args].join(' ');
|
|
13
|
+
if (result.error) {
|
|
14
|
+
return `${invocation} failed to start: ${result.error.message}`;
|
|
15
|
+
}
|
|
16
|
+
const outcome = result.status === null
|
|
17
|
+
? `terminated by ${result.signal ?? 'an unknown signal'}`
|
|
18
|
+
: `exited ${result.status}`;
|
|
19
|
+
return [
|
|
20
|
+
`${invocation} ${outcome}`,
|
|
21
|
+
outputText(result.stdout),
|
|
22
|
+
outputText(result.stderr),
|
|
23
|
+
].filter(Boolean).join('\n');
|
|
24
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { formatPackageBinSpawnFailure, packageBinSpawnOptions, } from './package-bin-process.js';
|
|
3
|
+
describe('package bin process options', () => {
|
|
4
|
+
it('uses a shell for npm and cmd shims on Windows', () => {
|
|
5
|
+
expect(packageBinSpawnOptions('win32', 'npm')).toEqual({ shell: true });
|
|
6
|
+
expect(packageBinSpawnOptions('win32', 'C:\\prefix\\webcmd.cmd')).toEqual({ shell: true });
|
|
7
|
+
});
|
|
8
|
+
it('keeps native executables shell-free', () => {
|
|
9
|
+
expect(packageBinSpawnOptions('linux', 'npm')).toEqual({});
|
|
10
|
+
expect(packageBinSpawnOptions('darwin', '/tmp/webcmd')).toEqual({});
|
|
11
|
+
expect(packageBinSpawnOptions('win32', 'node.exe')).toEqual({});
|
|
12
|
+
});
|
|
13
|
+
it('reports launch errors without assuming output streams exist', () => {
|
|
14
|
+
const error = Object.assign(new Error('spawnSync npm EINVAL'), { code: 'EINVAL' });
|
|
15
|
+
expect(formatPackageBinSpawnFailure('npm', ['pack'], {
|
|
16
|
+
error,
|
|
17
|
+
status: null,
|
|
18
|
+
stdout: undefined,
|
|
19
|
+
stderr: undefined,
|
|
20
|
+
})).toBe('npm pack failed to start: spawnSync npm EINVAL');
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/** Inactivity window after which an unowned session lease expires. */
|
|
2
|
+
export declare const SESSION_LEASE_TTL_MS = 45000;
|
|
3
|
+
/** Generate an id for one complete logical CLI command run. */
|
|
4
|
+
export declare function generateRunId(): string;
|
|
5
|
+
export interface DaemonRunContext {
|
|
6
|
+
runId: string;
|
|
7
|
+
command: string;
|
|
8
|
+
access: 'read' | 'write';
|
|
9
|
+
}
|
|
10
|
+
/** Run one logical execution with context isolated across its async chain. */
|
|
11
|
+
export declare function runWithDaemonRunContext<T>(context: DaemonRunContext, callback: () => T): T;
|
|
12
|
+
export declare function setDaemonRunContext(context: DaemonRunContext): void;
|
|
13
|
+
export declare function getDaemonRunContext(): DaemonRunContext | undefined;
|
|
14
|
+
/**
|
|
15
|
+
* Clear only the context still owned by `runId`. Deferred cleanup from an old
|
|
16
|
+
* command must not clear a newer command's run identity.
|
|
17
|
+
*/
|
|
18
|
+
export declare function clearDaemonRunContext(runId: string): void;
|
|
19
|
+
/**
|
|
20
|
+
* Detect errors whose browser-side result is unknown. The traversal includes
|
|
21
|
+
* wrapper causes and AggregateError members and tolerates cyclic error graphs.
|
|
22
|
+
*/
|
|
23
|
+
export declare function isUnknownOutcomeError(error: unknown): boolean;
|
|
24
|
+
export interface SessionLeaseHolder {
|
|
25
|
+
command: string;
|
|
26
|
+
pid?: number;
|
|
27
|
+
acquiredAt: number;
|
|
28
|
+
heartbeatAt: number;
|
|
29
|
+
}
|
|
30
|
+
export interface SessionLease extends SessionLeaseHolder {
|
|
31
|
+
key: string;
|
|
32
|
+
runId: string;
|
|
33
|
+
}
|
|
34
|
+
/** Public status shape: the internal ownership token is intentionally omitted. */
|
|
35
|
+
export type SessionLeaseStatus = Omit<SessionLease, 'runId'>;
|
|
36
|
+
export interface AcquireSessionLeaseInput {
|
|
37
|
+
key: string;
|
|
38
|
+
runId: string;
|
|
39
|
+
command: string;
|
|
40
|
+
pid?: number;
|
|
41
|
+
}
|
|
42
|
+
export type AcquireResult = {
|
|
43
|
+
acquired: true;
|
|
44
|
+
lease: SessionLease;
|
|
45
|
+
} | {
|
|
46
|
+
acquired: false;
|
|
47
|
+
holder: SessionLease;
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Lease key for a site session after the daemon has resolved its actual Cloak
|
|
51
|
+
* profile. Encoding the session keeps key partitions unambiguous.
|
|
52
|
+
*/
|
|
53
|
+
export declare function getSessionLeaseKey(profileId: string, surface: string, session: string): string;
|
|
54
|
+
/** Whether a process id is safe to interpolate into local process guidance. */
|
|
55
|
+
export declare function isActionablePid(pid: unknown): pid is number;
|
|
56
|
+
export interface SessionLeaseCommand {
|
|
57
|
+
surface?: unknown;
|
|
58
|
+
siteSession?: unknown;
|
|
59
|
+
access?: unknown;
|
|
60
|
+
session?: unknown;
|
|
61
|
+
runId?: unknown;
|
|
62
|
+
}
|
|
63
|
+
/** Only persistent adapter writes with a complete owner identity need a lease. */
|
|
64
|
+
export declare function isSessionLeaseCommand<T extends SessionLeaseCommand>(command: T): command is T & {
|
|
65
|
+
surface: 'adapter';
|
|
66
|
+
siteSession: 'persistent';
|
|
67
|
+
access: 'write';
|
|
68
|
+
session: string;
|
|
69
|
+
runId: string;
|
|
70
|
+
};
|
|
71
|
+
export declare class SessionLeaseRegistry {
|
|
72
|
+
private readonly now;
|
|
73
|
+
private readonly leases;
|
|
74
|
+
constructor(now?: () => number);
|
|
75
|
+
acquire(input: AcquireSessionLeaseInput, hasPendingWork: (runId: string) => boolean): AcquireResult;
|
|
76
|
+
/** Refresh a lease only when both its key and logical owner match. */
|
|
77
|
+
heartbeat(key: string, runId: string): boolean;
|
|
78
|
+
/** Release all leases owned by one logical run. */
|
|
79
|
+
releaseByRunId(runId: string): number;
|
|
80
|
+
/** Return a snapshot of currently live leases without exposing mutable state. */
|
|
81
|
+
list(hasPendingWork: (runId: string) => boolean): SessionLease[];
|
|
82
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
/** Inactivity window after which an unowned session lease expires. */
|
|
3
|
+
export const SESSION_LEASE_TTL_MS = 45_000;
|
|
4
|
+
let runIdCounter = 0;
|
|
5
|
+
/** Generate an id for one complete logical CLI command run. */
|
|
6
|
+
export function generateRunId() {
|
|
7
|
+
return `run_${process.pid}_${Date.now()}_${++runIdCounter}`;
|
|
8
|
+
}
|
|
9
|
+
let activeRun;
|
|
10
|
+
const daemonRunContextStorage = new AsyncLocalStorage();
|
|
11
|
+
/** Run one logical execution with context isolated across its async chain. */
|
|
12
|
+
export function runWithDaemonRunContext(context, callback) {
|
|
13
|
+
return daemonRunContextStorage.run(context, callback);
|
|
14
|
+
}
|
|
15
|
+
export function setDaemonRunContext(context) {
|
|
16
|
+
activeRun = context;
|
|
17
|
+
}
|
|
18
|
+
export function getDaemonRunContext() {
|
|
19
|
+
return daemonRunContextStorage.getStore() ?? activeRun;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Clear only the context still owned by `runId`. Deferred cleanup from an old
|
|
23
|
+
* command must not clear a newer command's run identity.
|
|
24
|
+
*/
|
|
25
|
+
export function clearDaemonRunContext(runId) {
|
|
26
|
+
if (activeRun?.runId === runId)
|
|
27
|
+
activeRun = undefined;
|
|
28
|
+
}
|
|
29
|
+
const UNKNOWN_OUTCOME_CODES = [
|
|
30
|
+
'command_result_unknown',
|
|
31
|
+
'command_lost',
|
|
32
|
+
'result_evicted',
|
|
33
|
+
];
|
|
34
|
+
function containsUnknownOutcomeCode(value) {
|
|
35
|
+
if (typeof value !== 'string')
|
|
36
|
+
return false;
|
|
37
|
+
const normalized = value.toLowerCase();
|
|
38
|
+
return UNKNOWN_OUTCOME_CODES.some((code) => {
|
|
39
|
+
const start = normalized.indexOf(code);
|
|
40
|
+
if (start < 0)
|
|
41
|
+
return false;
|
|
42
|
+
const before = normalized[start - 1];
|
|
43
|
+
const after = normalized[start + code.length];
|
|
44
|
+
return (!before || !/[a-z0-9_]/.test(before)) && (!after || !/[a-z0-9_]/.test(after));
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Detect errors whose browser-side result is unknown. The traversal includes
|
|
49
|
+
* wrapper causes and AggregateError members and tolerates cyclic error graphs.
|
|
50
|
+
*/
|
|
51
|
+
export function isUnknownOutcomeError(error) {
|
|
52
|
+
const queue = [error];
|
|
53
|
+
const seen = new Set();
|
|
54
|
+
while (queue.length > 0) {
|
|
55
|
+
const current = queue.pop();
|
|
56
|
+
if (!current || (typeof current !== 'object' && typeof current !== 'function') || seen.has(current)) {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
seen.add(current);
|
|
60
|
+
const record = current;
|
|
61
|
+
if (containsUnknownOutcomeCode(record.code)
|
|
62
|
+
|| containsUnknownOutcomeCode(record.errorCode)
|
|
63
|
+
|| containsUnknownOutcomeCode(record.daemonCode)
|
|
64
|
+
|| containsUnknownOutcomeCode(record.message)
|
|
65
|
+
|| containsUnknownOutcomeCode(record.error)) {
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
if (record.cause !== undefined)
|
|
69
|
+
queue.push(record.cause);
|
|
70
|
+
if (Array.isArray(record.errors))
|
|
71
|
+
queue.push(...record.errors);
|
|
72
|
+
}
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Lease key for a site session after the daemon has resolved its actual Cloak
|
|
77
|
+
* profile. Encoding the session keeps key partitions unambiguous.
|
|
78
|
+
*/
|
|
79
|
+
export function getSessionLeaseKey(profileId, surface, session) {
|
|
80
|
+
return `${profileId}␟${surface}␟${encodeURIComponent(session)}`;
|
|
81
|
+
}
|
|
82
|
+
/** Whether a process id is safe to interpolate into local process guidance. */
|
|
83
|
+
export function isActionablePid(pid) {
|
|
84
|
+
return typeof pid === 'number' && Number.isSafeInteger(pid) && pid > 0;
|
|
85
|
+
}
|
|
86
|
+
function pidFromRunId(runId) {
|
|
87
|
+
const match = /^run_(\d+)_/.exec(runId);
|
|
88
|
+
if (!match)
|
|
89
|
+
return undefined;
|
|
90
|
+
const pid = Number(match[1]);
|
|
91
|
+
return isActionablePid(pid) ? pid : undefined;
|
|
92
|
+
}
|
|
93
|
+
/** Only persistent adapter writes with a complete owner identity need a lease. */
|
|
94
|
+
export function isSessionLeaseCommand(command) {
|
|
95
|
+
return command.surface === 'adapter'
|
|
96
|
+
&& command.siteSession === 'persistent'
|
|
97
|
+
&& command.access === 'write'
|
|
98
|
+
&& typeof command.session === 'string'
|
|
99
|
+
&& command.session.length > 0
|
|
100
|
+
&& typeof command.runId === 'string'
|
|
101
|
+
&& command.runId.length > 0;
|
|
102
|
+
}
|
|
103
|
+
export class SessionLeaseRegistry {
|
|
104
|
+
now;
|
|
105
|
+
leases = new Map();
|
|
106
|
+
constructor(now = Date.now) {
|
|
107
|
+
this.now = now;
|
|
108
|
+
}
|
|
109
|
+
acquire(input, hasPendingWork) {
|
|
110
|
+
const now = this.now();
|
|
111
|
+
const current = this.leases.get(input.key);
|
|
112
|
+
const currentIsLive = current !== undefined
|
|
113
|
+
&& (now - current.heartbeatAt <= SESSION_LEASE_TTL_MS || hasPendingWork(current.runId));
|
|
114
|
+
if (current && currentIsLive && current.runId !== input.runId) {
|
|
115
|
+
return { acquired: false, holder: { ...current } };
|
|
116
|
+
}
|
|
117
|
+
const pid = input.pid === undefined
|
|
118
|
+
? pidFromRunId(input.runId)
|
|
119
|
+
: (isActionablePid(input.pid) ? input.pid : undefined);
|
|
120
|
+
const lease = current?.runId === input.runId
|
|
121
|
+
? { ...current, heartbeatAt: now }
|
|
122
|
+
: {
|
|
123
|
+
key: input.key,
|
|
124
|
+
runId: input.runId,
|
|
125
|
+
command: input.command,
|
|
126
|
+
...(pid === undefined ? {} : { pid }),
|
|
127
|
+
acquiredAt: now,
|
|
128
|
+
heartbeatAt: now,
|
|
129
|
+
};
|
|
130
|
+
this.leases.set(input.key, lease);
|
|
131
|
+
return { acquired: true, lease: { ...lease } };
|
|
132
|
+
}
|
|
133
|
+
/** Refresh a lease only when both its key and logical owner match. */
|
|
134
|
+
heartbeat(key, runId) {
|
|
135
|
+
const current = this.leases.get(key);
|
|
136
|
+
if (!current || current.runId !== runId)
|
|
137
|
+
return false;
|
|
138
|
+
current.heartbeatAt = this.now();
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
/** Release all leases owned by one logical run. */
|
|
142
|
+
releaseByRunId(runId) {
|
|
143
|
+
let released = 0;
|
|
144
|
+
for (const [key, lease] of this.leases) {
|
|
145
|
+
if (lease.runId !== runId)
|
|
146
|
+
continue;
|
|
147
|
+
this.leases.delete(key);
|
|
148
|
+
released += 1;
|
|
149
|
+
}
|
|
150
|
+
return released;
|
|
151
|
+
}
|
|
152
|
+
/** Return a snapshot of currently live leases without exposing mutable state. */
|
|
153
|
+
list(hasPendingWork) {
|
|
154
|
+
const now = this.now();
|
|
155
|
+
const active = [];
|
|
156
|
+
for (const lease of this.leases.values()) {
|
|
157
|
+
if (now - lease.heartbeatAt <= SESSION_LEASE_TTL_MS || hasPendingWork(lease.runId)) {
|
|
158
|
+
active.push({ ...lease });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return active;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|