@schlenkr/ragents 0.1.17 → 0.1.18
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 +5 -1
- package/apps/web/dist/help/guide-distributed.html +22 -8
- package/apps/web/dist/help/guide-distributed.md +28 -7
- package/apps/web/src/server-client.ts +165 -0
- package/bundles/ragents.workspace/ragents-bundle.json +1 -1
- package/package.json +10 -2
- package/packages/workspace-executor/src/bundled-tools.ts +49 -0
- package/packages/workspace-executor/src/owner-lifetime.d.mts +18 -0
- package/packages/workspace-executor/src/owner-lifetime.mjs +102 -0
- package/plugins/ragents.workspace/client/transport.ts +9 -11
- package/plugins/ragents.workspace/client/workspace-client.ts +8 -1
- package/scripts/package/ragents.mjs +30 -4
- package/scripts/workspace-client/run-workspace-client.ts +51 -34
- package/scripts/package/build-package.ts +0 -319
- package/scripts/package/publish-package.ts +0 -144
package/README.md
CHANGED
|
@@ -41,7 +41,11 @@ npm install -g @schlenkr/ragents
|
|
|
41
41
|
```
|
|
42
42
|
|
|
43
43
|
- `workspace-client <server-url> [folder ...]` - register this machine and its folders as a
|
|
44
|
-
workspace for a server, so a run on that server works in these folders.
|
|
44
|
+
workspace for a server, so a run on that server works in these folders. Includes the same
|
|
45
|
+
ripgrep and Windows Bash as the VS Code extension. `RAGENTS_USER` and `RAGENTS_PASSWORD` in
|
|
46
|
+
the environment enable session renewal; `RAGENTS_TOKEN` supplies an existing or personal token.
|
|
47
|
+
Terminal or parent closure stops it and its children. Use `--detached` explicitly for nohup
|
|
48
|
+
or a service manager; SIGINT/SIGTERM still stop it.
|
|
45
49
|
|
|
46
50
|
```sh
|
|
47
51
|
ragents workspace-client https://ragents.example.com ~/projects/demo
|
|
@@ -130,7 +130,7 @@
|
|
|
130
130
|
<a class="nav-github" href="https://github.com/SchlenkR/RAgents" aria-label="GitHub"><svg class="gh-icon" viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M12 2a10 10 0 0 0-3.2 19.5c.5.1.7-.2.7-.5v-1.7c-2.8.6-3.4-1.3-3.4-1.3-.5-1.2-1.1-1.5-1.1-1.5-.9-.6.1-.6.1-.6 1 .1 1.5 1 1.5 1 .9 1.6 2.4 1.1 3 .9.1-.7.4-1.1.6-1.4-2.2-.3-4.6-1.1-4.6-5 0-1.1.4-2 1-2.7-.1-.3-.4-1.3.1-2.7 0 0 .8-.3 2.8 1a9.6 9.6 0 0 1 5 0c1.9-1.3 2.8-1 2.8-1 .5 1.4.2 2.4.1 2.7.6.7 1 1.6 1 2.7 0 3.9-2.4 4.7-4.6 5 .4.3.7.9.7 1.9V21c0 .3.2.6.7.5A10 10 0 0 0 12 2Z"/></svg><span class="gh-label">GitHub</span></a>
|
|
131
131
|
</nav>
|
|
132
132
|
<button class="nav-menu" type="button" aria-expanded="false" aria-controls="main-navigation"><span data-menu-label>Menu</span><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg></button>
|
|
133
|
-
</header><main class="shell" id="main"><div class="guide-layout"><aside class="guide-sidebar"><nav class="guide-chapters" aria-label="Guide navigation"><a href="guide.html">Overview</a><div class="guide-nav-group"><p>Understand</p><a href="guide-ideas.html">Core ideas</a><a href="guide-runtime.html">Actors and messages</a></div><div class="guide-nav-group"><p>Use</p><a href="guide-getting-started.html">Get started</a><a href="guide-clients.html">Web and VS Code</a><a href="guide-distributed.html" aria-current="page">Distributed work</a></div><div class="guide-nav-group"><p>Build</p><a href="guide-functions.html">Work with TypeScript</a><a href="guide-programs.html">Build mini-apps</a><a href="guide-plugins.html">Plugins and skills</a></div><div class="guide-nav-group"><p>Access</p><a href="guide-access.html">Assign permissions</a></div></nav></aside><details class="guide-mobile-nav"><summary>Guide: Distributed work</summary><nav class="guide-chapters" aria-label="Guide navigation"><a href="guide.html">Overview</a><div class="guide-nav-group"><p>Understand</p><a href="guide-ideas.html">Core ideas</a><a href="guide-runtime.html">Actors and messages</a></div><div class="guide-nav-group"><p>Use</p><a href="guide-getting-started.html">Get started</a><a href="guide-clients.html">Web and VS Code</a><a href="guide-distributed.html" aria-current="page">Distributed work</a></div><div class="guide-nav-group"><p>Build</p><a href="guide-functions.html">Work with TypeScript</a><a href="guide-programs.html">Build mini-apps</a><a href="guide-plugins.html">Plugins and skills</a></div><div class="guide-nav-group"><p>Access</p><a href="guide-access.html">Assign permissions</a></div></nav></details><article class="guide-body"><header class="guide-title"><p class="eyebrow"><a href="guide.html">Guide</a></p><h1>Distributed work</h1><p>Run the host locally or remotely, keep tools beside the project, and move runs between servers.</p></header><details class="guide-page-nav"><summary>On this page</summary><nav aria-label="On this page"><a href="#work-without-a-checkout">Work without a checkout</a><a href="#connect-to-a-server">Connect to a server</a><a href="#transfer-a-run">Transfer a run</a><a href="#work-on-windows">Work on Windows</a></nav></details><h2 id="work-without-a-checkout">Work without a checkout</h2>
|
|
133
|
+
</header><main class="shell" id="main"><div class="guide-layout"><aside class="guide-sidebar"><nav class="guide-chapters" aria-label="Guide navigation"><a href="guide.html">Overview</a><div class="guide-nav-group"><p>Understand</p><a href="guide-ideas.html">Core ideas</a><a href="guide-runtime.html">Actors and messages</a></div><div class="guide-nav-group"><p>Use</p><a href="guide-getting-started.html">Get started</a><a href="guide-clients.html">Web and VS Code</a><a href="guide-distributed.html" aria-current="page">Distributed work</a></div><div class="guide-nav-group"><p>Build</p><a href="guide-functions.html">Work with TypeScript</a><a href="guide-programs.html">Build mini-apps</a><a href="guide-plugins.html">Plugins and skills</a></div><div class="guide-nav-group"><p>Access</p><a href="guide-access.html">Assign permissions</a></div></nav></aside><details class="guide-mobile-nav"><summary>Guide: Distributed work</summary><nav class="guide-chapters" aria-label="Guide navigation"><a href="guide.html">Overview</a><div class="guide-nav-group"><p>Understand</p><a href="guide-ideas.html">Core ideas</a><a href="guide-runtime.html">Actors and messages</a></div><div class="guide-nav-group"><p>Use</p><a href="guide-getting-started.html">Get started</a><a href="guide-clients.html">Web and VS Code</a><a href="guide-distributed.html" aria-current="page">Distributed work</a></div><div class="guide-nav-group"><p>Build</p><a href="guide-functions.html">Work with TypeScript</a><a href="guide-programs.html">Build mini-apps</a><a href="guide-plugins.html">Plugins and skills</a></div><div class="guide-nav-group"><p>Access</p><a href="guide-access.html">Assign permissions</a></div></nav></details><article class="guide-body"><header class="guide-title"><p class="eyebrow"><a href="guide.html">Guide</a></p><h1>Distributed work</h1><p>Run the host locally or remotely, keep tools beside the project, and move runs between servers.</p></header><details class="guide-page-nav"><summary>On this page</summary><nav aria-label="On this page"><a href="#work-without-a-checkout">Work without a checkout</a><a href="#connect-to-a-server">Connect to a server</a><a href="#run-a-cli-workstation">Run a CLI workstation</a><a href="#transfer-a-run">Transfer a run</a><a href="#work-on-windows">Work on Windows</a></nav></details><h2 id="work-without-a-checkout">Work without a checkout</h2>
|
|
134
134
|
<p>Using RAgents does not require a repository. <code>pnpm build:package</code> creates the npm package
|
|
135
135
|
<code>@schlenkr/ragents</code> from this checkout in <code>dist/ragents</code>; with <code>--pack</code>, it also creates
|
|
136
136
|
<code>dist/schlenkr-ragents-<version>.tgz</code>. The package contains the server, engine, executor, all
|
|
@@ -246,6 +246,22 @@ records only forwarded calls with user, alias, target, status, and token count.<
|
|
|
246
246
|
starts with the new web interface as long as the host API stays the same. Otherwise the start
|
|
247
247
|
refuses the fetched bundles, and <code>connect</code> names the version to install.</li>
|
|
248
248
|
</ul>
|
|
249
|
+
<h2 id="run-a-cli-workstation">Run a CLI workstation</h2>
|
|
250
|
+
<p><code>ragents workspace-client <server-url> [folders ...]</code> registers this machine with the same tools
|
|
251
|
+
and executor as the VS Code extension. No folder means the current directory; <code>--id</code> and <code>--label</code>
|
|
252
|
+
set its stable identity and display name. The npm installation includes ripgrep on every supported
|
|
253
|
+
platform and the curated Bash on Windows. Plugin tools are provisioned before registration.</p>
|
|
254
|
+
<p>Set <code>RAGENTS_TOKEN</code> for a personal or existing session token. For automatic sign-in and renewal,
|
|
255
|
+
configure <code>RAGENTS_USER</code> and <code>RAGENTS_PASSWORD</code> in the process environment. Neither is a command-line
|
|
256
|
+
option. Invalid or absent credentials when sign-in is required produce a visible failure and a
|
|
257
|
+
nonzero exit. A temporary connection loss reconnects and registers the workstation again.</p>
|
|
258
|
+
<p>Closing the terminal or SSH session, closing stdin, or ending the owning process unregisters the
|
|
259
|
+
workstation and stops its children. SIGINT, SIGTERM, and SIGHUP also stop it. For an intentional
|
|
260
|
+
background service, pass <code>--detached</code> and let a service manager own it, or run:</p>
|
|
261
|
+
<pre><code class="language-sh">nohup ragents workspace-client https://ragents.example.com /home/user/project --detached >workspace.log 2>&1 </dev/null &
|
|
262
|
+
</code></pre>
|
|
263
|
+
<p>The flag ignores terminal closure, parent loss, and SIGHUP; it does not daemonize. SIGINT and
|
|
264
|
+
SIGTERM still unregister and shut down. Use the service manager's stop command to end a service.</p>
|
|
249
265
|
<h2 id="transfer-a-run">Transfer a run</h2>
|
|
250
266
|
<p>A run can move from one server to another and continue there, for example from a notebook to an
|
|
251
267
|
always-on machine or from a central server to a developer for local inspection.</p>
|
|
@@ -299,13 +315,11 @@ otherwise it tells the model to exclude dependency and build folders from <code>
|
|
|
299
315
|
contains no git, so your login works as usual: Git Credential Manager, <code>~/.gitconfig</code> and
|
|
300
316
|
<code>~/.ssh</code>. On your own machine the bash inherits your whole environment, except the variables of
|
|
301
317
|
VS Code itself and <code>BASH_ENV</code>/<code>ENV</code>.</li>
|
|
302
|
-
<li><strong>
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
<code
|
|
307
|
-
<code>PATH</code> is used if there is one. A <code>RAGENTS_RG</code> that points to a missing file stops the server
|
|
308
|
-
start or the workstation's registration.</li>
|
|
318
|
+
<li><strong>CLI workstation</strong> (<code>ragents workspace-client</code>) carries the same Bash and ripgrep as the
|
|
319
|
+
extension. npm installs only the optional tools package for this machine; keep optional
|
|
320
|
+
dependencies enabled. A checkout uses the extension build from <code>pnpm bundle:rg</code> and
|
|
321
|
+
<code>pnpm bundle:bash</code>. A standalone server (<code>ragents start</code>) still takes <code>RAGENTS_BASH</code> and
|
|
322
|
+
<code>RAGENTS_RG</code> from its environment; these variables do not override workstation bundles.</li>
|
|
309
323
|
<li><strong>Node.js</strong> is enough with <code>@schlenkr/ragents</code>. A checkout additionally needs <strong>pnpm</strong>, and
|
|
310
324
|
<code>pnpm install</code>, <code>pnpm build:agent</code>, and <code>scripts/start.sh</code> need a bash of your own, such as
|
|
311
325
|
Git Bash.</li>
|
|
@@ -143,6 +143,29 @@ expose only the alias.
|
|
|
143
143
|
starts with the new web interface as long as the host API stays the same. Otherwise the start
|
|
144
144
|
refuses the fetched bundles, and `connect` names the version to install.
|
|
145
145
|
|
|
146
|
+
## Run a CLI workstation
|
|
147
|
+
|
|
148
|
+
`ragents workspace-client <server-url> [folders ...]` registers this machine with the same tools
|
|
149
|
+
and executor as the VS Code extension. No folder means the current directory; `--id` and `--label`
|
|
150
|
+
set its stable identity and display name. The npm installation includes ripgrep on every supported
|
|
151
|
+
platform and the curated Bash on Windows. Plugin tools are provisioned before registration.
|
|
152
|
+
|
|
153
|
+
Set `RAGENTS_TOKEN` for a personal or existing session token. For automatic sign-in and renewal,
|
|
154
|
+
configure `RAGENTS_USER` and `RAGENTS_PASSWORD` in the process environment. Neither is a command-line
|
|
155
|
+
option. Invalid or absent credentials when sign-in is required produce a visible failure and a
|
|
156
|
+
nonzero exit. A temporary connection loss reconnects and registers the workstation again.
|
|
157
|
+
|
|
158
|
+
Closing the terminal or SSH session, closing stdin, or ending the owning process unregisters the
|
|
159
|
+
workstation and stops its children. SIGINT, SIGTERM, and SIGHUP also stop it. For an intentional
|
|
160
|
+
background service, pass `--detached` and let a service manager own it, or run:
|
|
161
|
+
|
|
162
|
+
```sh
|
|
163
|
+
nohup ragents workspace-client https://ragents.example.com /home/user/project --detached >workspace.log 2>&1 </dev/null &
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
The flag ignores terminal closure, parent loss, and SIGHUP; it does not daemonize. SIGINT and
|
|
167
|
+
SIGTERM still unregister and shut down. Use the service manager's stop command to end a service.
|
|
168
|
+
|
|
146
169
|
## Transfer a run
|
|
147
170
|
|
|
148
171
|
A run can move from one server to another and continue there, for example from a notebook to an
|
|
@@ -203,13 +226,11 @@ The local host and workspace also run on Windows. Requirements are:
|
|
|
203
226
|
contains no git, so your login works as usual: Git Credential Manager, `~/.gitconfig` and
|
|
204
227
|
`~/.ssh`. On your own machine the bash inherits your whole environment, except the variables of
|
|
205
228
|
VS Code itself and `BASH_ENV`/`ENV`.
|
|
206
|
-
- **
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
`PATH` is used if there is one. A `RAGENTS_RG` that points to a missing file stops the server
|
|
212
|
-
start or the workstation's registration.
|
|
229
|
+
- **CLI workstation** (`ragents workspace-client`) carries the same Bash and ripgrep as the
|
|
230
|
+
extension. npm installs only the optional tools package for this machine; keep optional
|
|
231
|
+
dependencies enabled. A checkout uses the extension build from `pnpm bundle:rg` and
|
|
232
|
+
`pnpm bundle:bash`. A standalone server (`ragents start`) still takes `RAGENTS_BASH` and
|
|
233
|
+
`RAGENTS_RG` from its environment; these variables do not override workstation bundles.
|
|
213
234
|
- **Node.js** is enough with `@schlenkr/ragents`. A checkout additionally needs **pnpm**, and
|
|
214
235
|
`pnpm install`, `pnpm build:agent`, and `scripts/start.sh` need a bash of your own, such as
|
|
215
236
|
Git Bash.
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import type { AccessSnapshot } from "../../../packages/ragents/src/access";
|
|
2
|
+
import { accessSnapshotFrom } from "./access-session";
|
|
3
|
+
import { RpcClient } from "./rpc/client";
|
|
4
|
+
|
|
5
|
+
export class ServerError extends Error {
|
|
6
|
+
constructor(message: string, readonly status: number, readonly code?: string) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = "ServerError";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class UnreachableError extends Error {
|
|
13
|
+
constructor(message: string) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = "UnreachableError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const sessionTokenFrom = (setCookie: readonly string[]): string => {
|
|
20
|
+
for (const cookie of setCookie) {
|
|
21
|
+
const pair = cookie.split(";")[0] ?? "";
|
|
22
|
+
const token = pair.slice(pair.indexOf("=") + 1).trim();
|
|
23
|
+
if (/^[A-Za-z0-9_-]{43}$/.test(token)) return token;
|
|
24
|
+
}
|
|
25
|
+
throw new ServerError("The server did not deliver a session token.", 500);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const errorFrom = async (response: Response): Promise<ServerError> => {
|
|
29
|
+
let message = `The server responded with ${response.status}.`;
|
|
30
|
+
let code: string | undefined;
|
|
31
|
+
try {
|
|
32
|
+
const body = await response.json() as { error?: unknown; message?: unknown; code?: unknown };
|
|
33
|
+
if (typeof body.error === "string" && body.error) message = body.error;
|
|
34
|
+
else if (typeof body.message === "string" && body.message) message = body.message;
|
|
35
|
+
if (typeof body.code === "string") code = body.code;
|
|
36
|
+
} catch {}
|
|
37
|
+
return new ServerError(message, response.status, code);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** A workstation's access: sign-in and delivery over HTTP, everything else as JSON-RPC; the session token goes as bearer. */
|
|
41
|
+
export interface SignInOptions {
|
|
42
|
+
readonly credentials: () => Promise<{ id: string; password: string } | undefined>;
|
|
43
|
+
readonly onToken?: (token: string) => Promise<void>;
|
|
44
|
+
readonly log?: (line: string) => void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class ServerClient {
|
|
48
|
+
#authenticating: Promise<boolean> | undefined;
|
|
49
|
+
#signInFailure: string | undefined;
|
|
50
|
+
readonly origin: string;
|
|
51
|
+
readonly rpc: RpcClient;
|
|
52
|
+
|
|
53
|
+
constructor(readonly baseUrl: string, private token: string | undefined, private readonly request: typeof fetch = fetch, private readonly signIn?: SignInOptions) {
|
|
54
|
+
this.origin = new URL(baseUrl).origin;
|
|
55
|
+
this.rpc = new RpcClient({
|
|
56
|
+
baseUrl: this.origin,
|
|
57
|
+
fetch: (input, init) => this.#authorized(input, init),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
signInFailure(): string | undefined {
|
|
62
|
+
return this.#signInFailure;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async #authorized(input: Parameters<typeof fetch>[0], init?: RequestInit): Promise<Response> {
|
|
66
|
+
const token = this.token;
|
|
67
|
+
const send = () => this.request(input, { ...init, headers: this.headers(init?.headers as Record<string, string> | undefined ?? {}) });
|
|
68
|
+
const response = await send();
|
|
69
|
+
if (response.status !== 401 || !this.signIn || init?.signal?.aborted) return response;
|
|
70
|
+
const rejection = await response.clone().json().catch(() => undefined) as { code?: unknown } | undefined;
|
|
71
|
+
if (rejection?.code !== "login-required") {
|
|
72
|
+
this.#signInFailure = "Access token required: this server does not accept automatic user/password sign-in. Supply a valid access token.";
|
|
73
|
+
return response;
|
|
74
|
+
}
|
|
75
|
+
if (token === this.token) {
|
|
76
|
+
this.#authenticating ??= this.#authenticate().finally(() => { this.#authenticating = undefined; });
|
|
77
|
+
if (!await this.#authenticating) return response;
|
|
78
|
+
}
|
|
79
|
+
if (init?.signal?.aborted) return response;
|
|
80
|
+
await response.body?.cancel();
|
|
81
|
+
const retried = await send();
|
|
82
|
+
if (retried.status === 401) this.#signInFailure = "Sign-in required: the server rejected the renewed session.";
|
|
83
|
+
return retried;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async #authenticate(): Promise<boolean> {
|
|
87
|
+
try {
|
|
88
|
+
const credentials = await this.signIn!.credentials();
|
|
89
|
+
if (!credentials) {
|
|
90
|
+
this.#signInFailure = "Sign-in required: no credentials are configured for session renewal.";
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
this.signIn!.log?.(`== Signing in to ${this.origin} with configured credentials`);
|
|
94
|
+
const { token } = await this.login(credentials.id, credentials.password);
|
|
95
|
+
await this.signIn!.onToken?.(token);
|
|
96
|
+
this.useToken(token);
|
|
97
|
+
this.#signInFailure = undefined;
|
|
98
|
+
return true;
|
|
99
|
+
} catch {
|
|
100
|
+
this.#signInFailure = "Sign-in required: automatic sign-in failed. Check the configured credentials and server availability.";
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
get hasToken(): boolean {
|
|
106
|
+
return this.token !== undefined;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
get accessToken(): string | undefined {
|
|
110
|
+
return this.token;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The client reads the token on every request, so the messaging layer stays in place. */
|
|
114
|
+
useToken(token: string | undefined): void {
|
|
115
|
+
this.token = token;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
url(path: string): string {
|
|
119
|
+
return new URL(path, `${this.origin}/`).toString();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
headers(extra: Record<string, string> = {}): Record<string, string> {
|
|
123
|
+
return this.token === undefined ? extra : { ...extra, authorization: `Bearer ${this.token}` };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async fetch(path: string, init: RequestInit = {}): Promise<Response> {
|
|
127
|
+
try {
|
|
128
|
+
return await this.#authorized(this.url(path), init);
|
|
129
|
+
} catch (cause) {
|
|
130
|
+
throw new UnreachableError(`${this.origin} is unreachable: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async json<T>(path: string, init: RequestInit = {}, validate: (value: unknown) => T = (value) => value as T): Promise<T> {
|
|
135
|
+
const response = await this.fetch(path, init);
|
|
136
|
+
if (!response.ok) throw await errorFrom(response);
|
|
137
|
+
return validate(await response.json());
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async access(): Promise<AccessSnapshot> {
|
|
141
|
+
const snapshot = await this.json("/api/access", { cache: "no-store" }, accessSnapshotFrom);
|
|
142
|
+
if (!snapshot.enabled || snapshot.user || !this.signIn) return snapshot;
|
|
143
|
+
this.#authenticating ??= this.#authenticate().finally(() => { this.#authenticating = undefined; });
|
|
144
|
+
return await this.#authenticating ? this.json("/api/access", { cache: "no-store" }, accessSnapshotFrom) : snapshot;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async login(id: string, password: string): Promise<{ snapshot: AccessSnapshot; token: string }> {
|
|
148
|
+
const response = await this.request(this.url("/api/access/login"), {
|
|
149
|
+
method: "POST",
|
|
150
|
+
headers: { "content-type": "application/json" },
|
|
151
|
+
body: JSON.stringify({ id, password }),
|
|
152
|
+
signal: AbortSignal.timeout(10_000),
|
|
153
|
+
redirect: "error",
|
|
154
|
+
});
|
|
155
|
+
if (!response.ok) throw await errorFrom(response);
|
|
156
|
+
const snapshot = accessSnapshotFrom(await response.json());
|
|
157
|
+
if (!snapshot.user) throw new ServerError("The server did not confirm the sign-in.", 500);
|
|
158
|
+
return { snapshot, token: sessionTokenFrom(response.headers.getSetCookie()) };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async logout(): Promise<void> {
|
|
162
|
+
const response = await this.fetch("/api/access/logout", { method: "POST" });
|
|
163
|
+
if (!response.ok && response.status !== 401) throw await errorFrom(response);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -137,7 +137,7 @@
|
|
|
137
137
|
}
|
|
138
138
|
},
|
|
139
139
|
"revision": "db9370b7b3449c44be49986b074806351b857c430963aa878e209d2d5c9ba4f8",
|
|
140
|
-
"sourceRevision": "
|
|
140
|
+
"sourceRevision": "25a05a48b591f8f4b575b06d495f7f191037d7419b79a98ad321328c455f4e0d",
|
|
141
141
|
"server": "server/index.js",
|
|
142
142
|
"web": {
|
|
143
143
|
"entry": "web/index.js",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@schlenkr/ragents",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.18",
|
|
4
4
|
"description": "RAgents host: server, engine, plugins and tools without a source checkout",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agents",
|
|
@@ -70,7 +70,15 @@
|
|
|
70
70
|
"vscode-jsonrpc": "9.0.2",
|
|
71
71
|
"yaml": "2.9.0"
|
|
72
72
|
},
|
|
73
|
+
"optionalDependencies": {
|
|
74
|
+
"@schlenkr/ragents-tools-win32-x64": "0.1.18",
|
|
75
|
+
"@schlenkr/ragents-tools-win32-arm64": "0.1.18",
|
|
76
|
+
"@schlenkr/ragents-tools-darwin-x64": "0.1.18",
|
|
77
|
+
"@schlenkr/ragents-tools-darwin-arm64": "0.1.18",
|
|
78
|
+
"@schlenkr/ragents-tools-linux-x64": "0.1.18",
|
|
79
|
+
"@schlenkr/ragents-tools-linux-arm64": "0.1.18"
|
|
80
|
+
},
|
|
73
81
|
"ragents": {
|
|
74
|
-
"hostVersion": "
|
|
82
|
+
"hostVersion": "6f896323c8bdb2622d4dc9459a901949bf5fae33"
|
|
75
83
|
}
|
|
76
84
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
export const WORKSPACE_TOOL_TARGETS = ["win32-x64", "win32-arm64", "darwin-x64", "darwin-arm64", "linux-x64", "linux-arm64"] as const;
|
|
6
|
+
export type WorkspaceToolTarget = typeof WORKSPACE_TOOL_TARGETS[number];
|
|
7
|
+
|
|
8
|
+
export const workspaceToolsPackageName = (target: string): string => `@schlenkr/ragents-tools-${target}`;
|
|
9
|
+
|
|
10
|
+
export interface BundledToolsOptions {
|
|
11
|
+
readonly root: string;
|
|
12
|
+
readonly distribution: "extension" | "package";
|
|
13
|
+
readonly platform?: NodeJS.Platform;
|
|
14
|
+
readonly arch?: string;
|
|
15
|
+
readonly paths?: typeof path;
|
|
16
|
+
readonly exists?: (file: string) => boolean;
|
|
17
|
+
readonly resolvePackage?: (specifier: string) => string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface BundledTools {
|
|
21
|
+
readonly bash: string | undefined;
|
|
22
|
+
readonly rg: string | undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const resolveBundledTools = (options: BundledToolsOptions): BundledTools => {
|
|
26
|
+
const platform = options.platform ?? process.platform;
|
|
27
|
+
const target = `${platform}-${options.arch ?? process.arch}`;
|
|
28
|
+
const paths = options.paths ?? path;
|
|
29
|
+
const exists = options.exists ?? existsSync;
|
|
30
|
+
const packageRoot = (): string => {
|
|
31
|
+
if (!WORKSPACE_TOOL_TARGETS.includes(target as WorkspaceToolTarget)) throw new Error(`Unsupported workstation platform: ${target}`);
|
|
32
|
+
const name = workspaceToolsPackageName(target);
|
|
33
|
+
try {
|
|
34
|
+
const resolve = options.resolvePackage ?? createRequire(paths.join(options.root, "package.json")).resolve;
|
|
35
|
+
return paths.dirname(resolve(`${name}/package.json`));
|
|
36
|
+
} catch {
|
|
37
|
+
throw new Error(`The bundled workstation tools ${name} are missing. Reinstall @schlenkr/ragents with optional dependencies enabled.`);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
const root = options.distribution === "package" ? packageRoot() : options.root;
|
|
41
|
+
const bash = platform === "win32" ? paths.join(root, "dist", "bash", target, "usr", "bin", "bash.exe") : undefined;
|
|
42
|
+
const rg = paths.join(root, "dist", "rg", target, platform === "win32" ? "rg.exe" : "rg");
|
|
43
|
+
if (options.distribution === "package") {
|
|
44
|
+
for (const file of [rg, bash]) {
|
|
45
|
+
if (file !== undefined && !exists(file)) throw new Error(`Bundled workstation tool is missing: ${file}. Reinstall @schlenkr/ragents with optional dependencies enabled.`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return { bash, rg: exists(rg) ? rg : undefined };
|
|
49
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { EventEmitter } from "node:events";
|
|
2
|
+
import type { Readable } from "node:stream";
|
|
3
|
+
|
|
4
|
+
export interface OwnerLifetimeOptions {
|
|
5
|
+
readonly detached?: boolean;
|
|
6
|
+
readonly parents?: readonly number[];
|
|
7
|
+
readonly onStop: (reason: string) => void;
|
|
8
|
+
readonly stdin?: Pick<Readable, "on" | "off" | "resume" | "pause" | "destroyed" | "readableEnded"> | null;
|
|
9
|
+
readonly signals?: EventEmitter;
|
|
10
|
+
readonly alive?: (pid: number) => boolean;
|
|
11
|
+
readonly intervalMs?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function processAlive(pid: number): boolean;
|
|
15
|
+
export function watchOwnerLifetime(options: OwnerLifetimeOptions): () => void;
|
|
16
|
+
export function processChildren(pid?: number): number[];
|
|
17
|
+
export function terminateProcessTree(pid: number): void;
|
|
18
|
+
export function exitWorkspaceProcess(code: number): never;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
3
|
+
|
|
4
|
+
export const processAlive = (pid) => {
|
|
5
|
+
try {
|
|
6
|
+
process.kill(pid, 0);
|
|
7
|
+
return true;
|
|
8
|
+
} catch (error) {
|
|
9
|
+
return error.code === "EPERM";
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const watchOwnerLifetime = ({ detached = false, parents = [process.ppid], onStop, stdin = process.stdin, signals = process, alive = processAlive, intervalMs = 1000 }) => {
|
|
14
|
+
const releases = [];
|
|
15
|
+
const signalReleases = [];
|
|
16
|
+
let stopped = false;
|
|
17
|
+
const dispose = () => {
|
|
18
|
+
for (const release of releases.splice(0)) release();
|
|
19
|
+
};
|
|
20
|
+
const stop = (reason) => {
|
|
21
|
+
if (stopped) return;
|
|
22
|
+
stopped = true;
|
|
23
|
+
dispose();
|
|
24
|
+
onStop(reason);
|
|
25
|
+
};
|
|
26
|
+
const listen = (emitter, event, handler, keepDuringStop = false) => {
|
|
27
|
+
emitter.on(event, handler);
|
|
28
|
+
(keepDuringStop ? signalReleases : releases).push(() => emitter.off(event, handler));
|
|
29
|
+
};
|
|
30
|
+
listen(signals, "SIGINT", () => stop("SIGINT"), true);
|
|
31
|
+
listen(signals, "SIGTERM", () => stop("SIGTERM"), true);
|
|
32
|
+
listen(signals, "SIGHUP", () => { if (!detached) stop("SIGHUP"); }, true);
|
|
33
|
+
if (!detached) {
|
|
34
|
+
if (parents.some((pid) => !Number.isSafeInteger(pid) || pid <= 0)) throw new Error("The workspace owner PID must be a positive integer.");
|
|
35
|
+
const check = () => {
|
|
36
|
+
if (parents.some((pid) => !alive(pid))) stop("parent exited");
|
|
37
|
+
};
|
|
38
|
+
const timer = setInterval(check, intervalMs);
|
|
39
|
+
timer.unref();
|
|
40
|
+
releases.push(() => clearInterval(timer));
|
|
41
|
+
listen(signals, "disconnect", () => stop("owner disconnected"));
|
|
42
|
+
if (stdin) {
|
|
43
|
+
listen(stdin, "end", () => stop("stdin closed"));
|
|
44
|
+
listen(stdin, "close", () => stop("stdin closed"));
|
|
45
|
+
listen(stdin, "error", () => stop("stdin failed"));
|
|
46
|
+
stdin.resume();
|
|
47
|
+
releases.push(() => stdin.pause());
|
|
48
|
+
if (stdin.destroyed || stdin.readableEnded) queueMicrotask(() => stop("stdin closed"));
|
|
49
|
+
}
|
|
50
|
+
queueMicrotask(check);
|
|
51
|
+
}
|
|
52
|
+
return () => {
|
|
53
|
+
stopped = true;
|
|
54
|
+
dispose();
|
|
55
|
+
for (const release of signalReleases.splice(0)) release();
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export const processChildren = (pid = process.pid) => {
|
|
60
|
+
if (process.platform === "win32") {
|
|
61
|
+
const result = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command",
|
|
62
|
+
`Get-CimInstance Win32_Process -Filter 'ParentProcessId = ${pid}' | ForEach-Object { $_.ProcessId }`], { encoding: "utf8", windowsHide: true, timeout: 5000 });
|
|
63
|
+
if (result.status !== 0) throw new Error("Cannot inspect workspace child processes for shutdown.");
|
|
64
|
+
return result.stdout.trim().split(/\s+/).map(Number).filter((child) => child > 0);
|
|
65
|
+
}
|
|
66
|
+
const rows = process.platform === "linux" ? readdirSync("/proc").filter((entry) => /^\d+$/.test(entry)).flatMap((entry) => {
|
|
67
|
+
try {
|
|
68
|
+
const stat = readFileSync(`/proc/${entry}/stat`, "utf8");
|
|
69
|
+
return [[Number(entry), Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[1])]];
|
|
70
|
+
} catch (error) {
|
|
71
|
+
if (["ENOENT", "ESRCH", "EACCES"].includes(error.code)) return [];
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
}) : (() => {
|
|
75
|
+
const table = spawnSync("ps", ["-axo", "pid=,ppid="], { encoding: "utf8" });
|
|
76
|
+
if (table.status !== 0) throw new Error("Cannot inspect workspace child processes for shutdown.");
|
|
77
|
+
return table.stdout.trim().split("\n").map((line) => line.trim().split(/\s+/).map(Number));
|
|
78
|
+
})();
|
|
79
|
+
const descendants = (parent) => rows.filter((row) => row[1] === parent).flatMap((row) => [...descendants(row[0]), row[0]]);
|
|
80
|
+
return descendants(pid);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export const terminateProcessTree = (pid) => {
|
|
84
|
+
if (process.platform === "win32") {
|
|
85
|
+
const killed = spawnSync("taskkill", ["/T", "/F", "/PID", String(pid)], { stdio: "ignore", windowsHide: true, timeout: 5000 });
|
|
86
|
+
if (killed.status !== 0 && processAlive(pid)) throw new Error(`Could not stop workspace child process ${pid}.`);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
for (const child of [...processChildren(pid), pid]) {
|
|
90
|
+
try { process.kill(child, "SIGKILL"); } catch (error) { if (error.code !== "ESRCH") throw error; }
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export const exitWorkspaceProcess = (code) => {
|
|
95
|
+
for (const pid of processChildren()) {
|
|
96
|
+
if (process.platform === "win32") terminateProcessTree(pid);
|
|
97
|
+
else {
|
|
98
|
+
try { process.kill(pid, "SIGKILL"); } catch (error) { if (error.code !== "ESRCH") throw error; }
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
process.exit(code);
|
|
102
|
+
};
|
|
@@ -1,13 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type { WorkspaceClientTransport } from "./workspace-client";
|
|
1
|
+
import { ServerClient } from "../../../apps/web/src/server-client";
|
|
3
2
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
const
|
|
10
|
-
return
|
|
3
|
+
export const workspaceClientTransport = (baseUrl: string, token: string | undefined, environment: NodeJS.ProcessEnv = process.env): ServerClient =>
|
|
4
|
+
new ServerClient(baseUrl, token, fetch, {
|
|
5
|
+
log: (line) => console.error(line),
|
|
6
|
+
credentials: async () => {
|
|
7
|
+
const id = environment.RAGENTS_USER;
|
|
8
|
+
const password = environment.RAGENTS_PASSWORD;
|
|
9
|
+
return id && password ? { id, password } : undefined;
|
|
11
10
|
},
|
|
12
|
-
})
|
|
13
|
-
});
|
|
11
|
+
});
|
|
@@ -65,6 +65,7 @@ export interface WorkspaceClientOptions {
|
|
|
65
65
|
/** What the workstation needs for the server: the message layer. */
|
|
66
66
|
export interface WorkspaceClientTransport {
|
|
67
67
|
readonly rpc: RpcClient;
|
|
68
|
+
readonly signInFailure?: () => string | undefined;
|
|
68
69
|
}
|
|
69
70
|
|
|
70
71
|
type ExecuteInput = OperationInput<typeof workspaceClientContracts.execute>;
|
|
@@ -255,6 +256,11 @@ export class WorkspaceClient {
|
|
|
255
256
|
releases.push(
|
|
256
257
|
rpc.handle(workspaceClientContracts.execute, (input, context) => this.#execute(attachment, input, context)),
|
|
257
258
|
rpc.onConnected(() => void this.#announce()),
|
|
259
|
+
rpc.onStatus((status) => {
|
|
260
|
+
if (status.kind === "unauthorized" && attachment === this.#attachment) {
|
|
261
|
+
this.#set({ kind: "failed", message: this.transport.signInFailure?.() ?? "The server requires a sign-in.", mismatch: false });
|
|
262
|
+
}
|
|
263
|
+
}),
|
|
258
264
|
);
|
|
259
265
|
this.#attachment = attachment;
|
|
260
266
|
return attachment;
|
|
@@ -288,13 +294,14 @@ export class WorkspaceClient {
|
|
|
288
294
|
#connected(): Promise<void> {
|
|
289
295
|
const rpc = this.transport.rpc;
|
|
290
296
|
if (rpc.status.kind === "connected") return Promise.resolve();
|
|
297
|
+
if (rpc.status.kind === "unauthorized") return Promise.reject(new Error(this.transport.signInFailure?.() ?? "The server requires a sign-in."));
|
|
291
298
|
return new Promise((settle, fail) => {
|
|
292
299
|
const release = rpc.onStatus((status) => {
|
|
293
300
|
if (status.kind === "connecting") return;
|
|
294
301
|
release();
|
|
295
302
|
if (status.kind === "connected") settle();
|
|
296
303
|
else if (status.kind === "retrying") fail(new Error(status.message));
|
|
297
|
-
else if (status.kind === "unauthorized") fail(new Error("The server requires a sign-in."));
|
|
304
|
+
else if (status.kind === "unauthorized") fail(new Error(this.transport.signInFailure?.() ?? "The server requires a sign-in."));
|
|
298
305
|
else fail(new Error("The server's event stream is closed."));
|
|
299
306
|
});
|
|
300
307
|
});
|
|
@@ -3,6 +3,7 @@ import { spawn } from "node:child_process";
|
|
|
3
3
|
import { realpathSync } from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { terminateProcessTree, watchOwnerLifetime } from "../../packages/workspace-executor/src/owner-lifetime.mjs";
|
|
6
7
|
import { ensureHostLinks } from "./host-links.mjs";
|
|
7
8
|
|
|
8
9
|
const AGENT_CLI = "scripts/agent/agent-cli.ts";
|
|
@@ -55,14 +56,39 @@ const main = () => {
|
|
|
55
56
|
}
|
|
56
57
|
ensureHostLinks(root);
|
|
57
58
|
const passed = script === AGENT_CLI ? [command, ...rest] : rest;
|
|
59
|
+
const workspace = command === "workspace-client";
|
|
60
|
+
const detached = rest.includes("--detached");
|
|
58
61
|
const child = spawn(process.execPath, ["--import", "tsx", path.join(root, script), ...passed], {
|
|
59
62
|
cwd: path.join(root, "apps/server"),
|
|
60
|
-
env: { ...process.env, RAGENTS_CWD: process.cwd() },
|
|
61
|
-
stdio: "inherit",
|
|
63
|
+
env: { ...process.env, RAGENTS_CWD: process.cwd(), ...(workspace ? { RAGENTS_WORKSPACE_OWNER_PID: String(process.ppid) } : {}) },
|
|
64
|
+
stdio: workspace ? ["ignore", "inherit", "inherit", "ipc"] : "inherit",
|
|
65
|
+
detached: workspace && process.platform !== "win32",
|
|
62
66
|
});
|
|
67
|
+
if (workspace) {
|
|
68
|
+
let stopReason;
|
|
69
|
+
let deadline;
|
|
70
|
+
const requestStop = () => {
|
|
71
|
+
if (child.connected && stopReason) child.send({ type: "workspace-stop", reason: stopReason }, () => undefined);
|
|
72
|
+
};
|
|
73
|
+
const release = watchOwnerLifetime({ detached, onStop: (reason) => {
|
|
74
|
+
stopReason = reason;
|
|
75
|
+
requestStop();
|
|
76
|
+
deadline = setTimeout(() => { if (child.pid) terminateProcessTree(child.pid); }, 20_000);
|
|
77
|
+
} });
|
|
78
|
+
child.on("message", (message) => { if (message?.type === "workspace-ready") requestStop(); });
|
|
79
|
+
child.once("exit", () => {
|
|
80
|
+
release();
|
|
81
|
+
clearTimeout(deadline);
|
|
82
|
+
if (process.platform !== "win32" && child.pid) {
|
|
83
|
+
try { process.kill(-child.pid, "SIGKILL"); } catch {}
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
}
|
|
63
87
|
const forward = (signal) => () => child.kill(signal);
|
|
64
|
-
|
|
65
|
-
|
|
88
|
+
if (!workspace) {
|
|
89
|
+
process.on("SIGINT", forward("SIGINT"));
|
|
90
|
+
process.on("SIGTERM", forward("SIGTERM"));
|
|
91
|
+
}
|
|
66
92
|
child.once("error", (error) => {
|
|
67
93
|
console.error(`ragents ${command} could not be started: ${error.message}`);
|
|
68
94
|
process.exit(1);
|
|
@@ -3,25 +3,28 @@ import { statSync } from "node:fs";
|
|
|
3
3
|
import { hostname } from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { resolveBundledTools } from "../../packages/workspace-executor/src/bundled-tools.ts";
|
|
7
|
+
import { exitWorkspaceProcess, watchOwnerLifetime } from "../../packages/workspace-executor/src/owner-lifetime.mjs";
|
|
8
|
+
import { isCheckout } from "../../apps/server/src/host-web.ts";
|
|
6
9
|
import { hostRoot } from "../../apps/server/src/host-version.ts";
|
|
7
10
|
import { callerDirectory } from "../../apps/server/src/profile-target.ts";
|
|
8
11
|
import { provisionWorkspace } from "../../apps/server/src/profile/provisioning.ts";
|
|
9
12
|
import { workspaceClientTransport } from "../../plugins/ragents.workspace/client/transport.ts";
|
|
10
13
|
import { WorkspaceClient, workstationRunsDirectory } from "../../plugins/ragents.workspace/client/workspace-client.ts";
|
|
11
14
|
|
|
12
|
-
const usage = (): string => `Usage:
|
|
13
|
-
Registers the folders as a workspace
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
script runs until it is ended with Ctrl-C.`;
|
|
15
|
+
const usage = (): string => `Usage: ragents workspace-client <server-url> [folder ...] [--id <id>] [--label <name>] [--detached]
|
|
16
|
+
Registers the folders as a workspace and executes tasks with the same tools as VS Code.
|
|
17
|
+
Without folders the current directory applies. Tools are provisioned before registration.
|
|
18
|
+
The npm package includes ripgrep and, on Windows, Bash. In a checkout run pnpm bundle:rg
|
|
19
|
+
(and pnpm bundle:bash on Windows) first.
|
|
20
|
+
Set RAGENTS_TOKEN for an existing session or RAGENTS_USER and RAGENTS_PASSWORD for automatic
|
|
21
|
+
sign-in and renewal. Credentials belong only in the environment.
|
|
22
|
+
By default closing stdin, the terminal, or the owning process ends the client and its children.
|
|
23
|
+
--detached explicitly opts out of terminal and parent ownership for nohup or a service manager;
|
|
24
|
+
SIGINT and SIGTERM still stop it. It does not daemonize the process.`;
|
|
23
25
|
|
|
24
26
|
export interface WorkspaceClientArguments {
|
|
27
|
+
readonly detached: boolean;
|
|
25
28
|
readonly serverUrl: string;
|
|
26
29
|
readonly folders: readonly string[];
|
|
27
30
|
readonly id: string | undefined;
|
|
@@ -30,11 +33,13 @@ export interface WorkspaceClientArguments {
|
|
|
30
33
|
|
|
31
34
|
export const parseArguments = (argv: readonly string[]): WorkspaceClientArguments => {
|
|
32
35
|
const folders: string[] = [];
|
|
36
|
+
let detached = false;
|
|
33
37
|
let serverUrl: string | undefined;
|
|
34
38
|
let id: string | undefined;
|
|
35
39
|
let label: string | undefined;
|
|
36
40
|
for (let index = 0; index < argv.length; index += 1) {
|
|
37
41
|
const argument = argv[index]!;
|
|
42
|
+
if (argument === "--detached") { detached = true; continue; }
|
|
38
43
|
if (argument === "--id" || argument === "--label") {
|
|
39
44
|
const value = argv[index + 1];
|
|
40
45
|
if (!value || value.startsWith("-")) throw new Error(`${argument} needs a value.\n${usage()}`);
|
|
@@ -49,7 +54,7 @@ export const parseArguments = (argv: readonly string[]): WorkspaceClientArgument
|
|
|
49
54
|
}
|
|
50
55
|
if (!serverUrl) throw new Error(`The server address is missing.\n${usage()}`);
|
|
51
56
|
if (id !== undefined && !/^[A-Za-z0-9_-]{8,64}$/.test(id)) throw new Error("--id needs 8 to 64 characters from A-Z, a-z, 0-9, _ and -.");
|
|
52
|
-
return { serverUrl, folders, id, label };
|
|
57
|
+
return { serverUrl, folders, id, label, detached };
|
|
53
58
|
};
|
|
54
59
|
|
|
55
60
|
/** A stable id per machine and folder set, so the server recognizes the same workspace again. */
|
|
@@ -67,6 +72,27 @@ export const resolvedFolders = (folders: readonly string[], caller = callerDirec
|
|
|
67
72
|
|
|
68
73
|
const main = async (): Promise<void> => {
|
|
69
74
|
const parsed = parseArguments(process.argv.slice(2));
|
|
75
|
+
let stopping: Promise<void> | undefined;
|
|
76
|
+
const stop = (code: number, reason: string): Promise<void> => stopping ??= (async () => {
|
|
77
|
+
console.log(`== Stopping workspace: ${reason}`);
|
|
78
|
+
const force = setTimeout(() => exitWorkspaceProcess(code || 1), 15_000);
|
|
79
|
+
try {
|
|
80
|
+
await client.unregister();
|
|
81
|
+
transport.rpc.close();
|
|
82
|
+
} finally {
|
|
83
|
+
clearTimeout(force);
|
|
84
|
+
exitWorkspaceProcess(code);
|
|
85
|
+
}
|
|
86
|
+
})();
|
|
87
|
+
const releaseOwner = watchOwnerLifetime({
|
|
88
|
+
detached: parsed.detached,
|
|
89
|
+
parents: [process.ppid, ...process.env.RAGENTS_WORKSPACE_OWNER_PID ? [Number(process.env.RAGENTS_WORKSPACE_OWNER_PID)] : []],
|
|
90
|
+
stdin: process.send ? null : process.stdin,
|
|
91
|
+
onStop: (reason) => { void stop(0, reason); },
|
|
92
|
+
});
|
|
93
|
+
process.on("message", (message: unknown) => {
|
|
94
|
+
if (typeof message === "object" && message !== null && (message as { type?: string }).type === "workspace-stop") void stop(0, "owner stopped");
|
|
95
|
+
});
|
|
70
96
|
const folders = resolvedFolders(parsed.folders);
|
|
71
97
|
const host = hostname();
|
|
72
98
|
const identity = {
|
|
@@ -78,46 +104,37 @@ const main = async (): Promise<void> => {
|
|
|
78
104
|
runsDirectory: workstationRunsDirectory(),
|
|
79
105
|
};
|
|
80
106
|
const transport = workspaceClientTransport(parsed.serverUrl, process.env.RAGENTS_TOKEN);
|
|
107
|
+
const root = hostRoot();
|
|
108
|
+
const tools = resolveBundledTools({ root: isCheckout(root) ? path.join(root, "apps/vscode") : root, distribution: isCheckout(root) ? "extension" : "package" });
|
|
81
109
|
const client = new WorkspaceClient(transport, identity, {
|
|
82
110
|
hostRoot,
|
|
83
|
-
|
|
84
|
-
rg: process.env.RAGENTS_RG || undefined,
|
|
111
|
+
...tools,
|
|
85
112
|
onExecuted: ({ runId, operation, durationMs, error }) =>
|
|
86
113
|
console.log(`== ${runId.slice(0, 8)} ${operation} ${durationMs} ms ${error ?? "ok"}`),
|
|
87
114
|
});
|
|
88
115
|
console.log(`== Workspace ${identity.label} (${identity.id})`);
|
|
89
116
|
for (const folder of folders) console.log(`== Folder ${folder}`);
|
|
117
|
+
process.send?.({ type: "workspace-ready" });
|
|
90
118
|
console.log("== Provisioning tools");
|
|
91
119
|
await provisionWorkspace((line) => console.log(line));
|
|
92
120
|
console.log(`== Server ${parsed.serverUrl}`);
|
|
93
|
-
|
|
94
|
-
|
|
121
|
+
const workspace = client;
|
|
122
|
+
workspace.onChange(() => {
|
|
123
|
+
const status = workspace.status;
|
|
95
124
|
console.log(status.kind === "failed" ? `== Registration failed: ${status.message}` : `== State ${status.kind}`);
|
|
125
|
+
if (status.kind === "failed" && !stopping && (status.mismatch || transport.rpc.status.kind === "unauthorized")) void stop(1, "registration or sign-in failed");
|
|
96
126
|
});
|
|
97
|
-
await
|
|
98
|
-
if (
|
|
99
|
-
|
|
100
|
-
transport.rpc.close();
|
|
101
|
-
return;
|
|
102
|
-
}
|
|
127
|
+
await workspace.register();
|
|
128
|
+
if (stopping) return;
|
|
129
|
+
if (workspace.status.kind !== "registered") return stop(1, "registration failed");
|
|
103
130
|
console.log("== Registered; Ctrl-C ends the workspace");
|
|
104
|
-
|
|
105
|
-
const stop = () => {
|
|
106
|
-
process.off("SIGINT", stop);
|
|
107
|
-
process.off("SIGTERM", stop);
|
|
108
|
-
finish();
|
|
109
|
-
};
|
|
110
|
-
process.on("SIGINT", stop);
|
|
111
|
-
process.on("SIGTERM", stop);
|
|
112
|
-
});
|
|
113
|
-
await client.unregister();
|
|
114
|
-
transport.rpc.close();
|
|
131
|
+
process.once("exit", releaseOwner);
|
|
115
132
|
};
|
|
116
133
|
|
|
117
134
|
const moduleUrl: string | undefined = import.meta.url;
|
|
118
135
|
if (moduleUrl && process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(moduleUrl)) {
|
|
119
136
|
main().catch((error: unknown) => {
|
|
120
137
|
console.error(error instanceof Error ? error.message : String(error));
|
|
121
|
-
|
|
138
|
+
exitWorkspaceProcess(1);
|
|
122
139
|
});
|
|
123
140
|
}
|
|
@@ -1,319 +0,0 @@
|
|
|
1
|
-
import { spawnSync } from "node:child_process";
|
|
2
|
-
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
3
|
-
import { copyFile, mkdir, readdir, rm, writeFile } from "node:fs/promises";
|
|
4
|
-
import { builtinModules } from "node:module";
|
|
5
|
-
import path from "node:path";
|
|
6
|
-
import { fileURLToPath } from "node:url";
|
|
7
|
-
import { readHostVersion, readPackageVersion } from "../../apps/server/src/host-version.ts";
|
|
8
|
-
import { hostWebDirectory, hostWebProblem, isCheckout } from "../../apps/server/src/host-web.ts";
|
|
9
|
-
import { runtimeLibraries } from "../../apps/server/src/plugin-support/actor-programs/runtime-libraries.ts";
|
|
10
|
-
import { assertBundleRevision, readBundleManifest, sourceRevisionOf } from "../../apps/server/src/profile/bundle-manifest.ts";
|
|
11
|
-
import { builtInPluginFolders } from "../plugin/build-builtin-plugins.ts";
|
|
12
|
-
|
|
13
|
-
export const PACKAGE_NAME = "@schlenkr/ragents";
|
|
14
|
-
/** The folder under dist/; the package name has a scope, the folder name stays plain. */
|
|
15
|
-
export const PACKAGE_FOLDER = "ragents";
|
|
16
|
-
|
|
17
|
-
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
18
|
-
|
|
19
|
-
const SKIPPED_FOLDERS = new Set(["node_modules", "dist", ".git"]);
|
|
20
|
-
const SKIPPED_FILES = new Set([".DS_Store"]);
|
|
21
|
-
|
|
22
|
-
/** The subcommands' tests run in the checkout; in the package only their test material would be visible. */
|
|
23
|
-
const SKIPPED_IN_PACKAGE = /^scripts\/.*\.test\.tsx?$/;
|
|
24
|
-
|
|
25
|
-
const packagePath = (packageRoot: string, file: string): string => path.relative(packageRoot, file).split(path.sep).join("/");
|
|
26
|
-
|
|
27
|
-
/** The repository's neutral profiles; ragents start runs them from the package as from the checkout. */
|
|
28
|
-
export const PACKAGED_PROFILES = ["core", "developer", "showcase"] as const;
|
|
29
|
-
|
|
30
|
-
export const PACKAGE_LICENSE = "PolyForm-Shield-1.0.0";
|
|
31
|
-
export const PACKAGE_AUTHOR = "Ronald Schlenker";
|
|
32
|
-
export const REPOSITORY_URL = "https://github.com/SchlenkR/RAgents";
|
|
33
|
-
export const PACKAGE_KEYWORDS = ["agents", "llm", "actors", "typescript", "csharp", "fsharp", "vscode"] as const;
|
|
34
|
-
|
|
35
|
-
/** License and listing README are at the root of the package, their sources elsewhere in the repository. */
|
|
36
|
-
export const PACKAGE_ROOT_FILES: readonly (readonly [string, string])[] = [
|
|
37
|
-
["LICENSE", "LICENSE"],
|
|
38
|
-
["scripts/package/README.md", "README.md"],
|
|
39
|
-
];
|
|
40
|
-
|
|
41
|
-
const ROOT_FILE_SOURCES = new Set(PACKAGE_ROOT_FILES.map(([source]) => source));
|
|
42
|
-
|
|
43
|
-
const skippedInPackage = (relative: string): boolean => SKIPPED_IN_PACKAGE.test(relative) || ROOT_FILE_SOURCES.has(relative);
|
|
44
|
-
|
|
45
|
-
/** What the host loads at runtime: server, engine, the built web, the built-in bundles, plugin sources for contracts and the build tool, profiles and the scripts behind the subcommands; plus the types from declarationFolders. */
|
|
46
|
-
export const packageContents = (root = repositoryRoot): readonly string[] => [
|
|
47
|
-
"apps/server/package.json",
|
|
48
|
-
"apps/server/tsconfig.json",
|
|
49
|
-
"apps/server/tsconfig.plugin.json",
|
|
50
|
-
"apps/server/src",
|
|
51
|
-
"apps/web/package.json",
|
|
52
|
-
"apps/web/tsconfig.json",
|
|
53
|
-
"apps/web/tsconfig.plugin.json",
|
|
54
|
-
"apps/web/src",
|
|
55
|
-
"apps/web/dist",
|
|
56
|
-
...readdirSync(path.join(root, "packages"), { withFileTypes: true })
|
|
57
|
-
.filter((entry) => entry.isDirectory())
|
|
58
|
-
.flatMap((entry) => [`packages/${entry.name}/package.json`, `packages/${entry.name}/src`]),
|
|
59
|
-
"bundles",
|
|
60
|
-
"plugins",
|
|
61
|
-
...PACKAGED_PROFILES.map((profile) => `ragents.config.${profile}.ts`),
|
|
62
|
-
"scripts/agent",
|
|
63
|
-
"scripts/package",
|
|
64
|
-
"scripts/plugin",
|
|
65
|
-
"scripts/provision",
|
|
66
|
-
"scripts/remote",
|
|
67
|
-
"scripts/workspace-client",
|
|
68
|
-
];
|
|
69
|
-
|
|
70
|
-
/** The forked agent runtime runs from its sources, its types exist only built; the build tool checks third-party plugins against them. */
|
|
71
|
-
export const declarationFolders = (root = repositoryRoot): readonly string[] =>
|
|
72
|
-
readdirSync(path.join(root, "packages"), { withFileTypes: true })
|
|
73
|
-
.filter((entry) => entry.isDirectory())
|
|
74
|
-
.filter((entry) => {
|
|
75
|
-
const manifest = path.join(root, "packages", entry.name, "package.json");
|
|
76
|
-
const types = statSync(manifest, { throwIfNoEntry: false })?.isFile() ? (JSON.parse(readFileSync(manifest, "utf8")) as { types?: unknown }).types : undefined;
|
|
77
|
-
return typeof types === "string" && types.startsWith("./dist/");
|
|
78
|
-
})
|
|
79
|
-
.map((entry) => `packages/${entry.name}/dist`);
|
|
80
|
-
|
|
81
|
-
/** What the host executes, bundles into a mini-app or reads when building a plugin. */
|
|
82
|
-
const SCANNED = ["apps/server/src", "apps/web", "packages", "plugins", "scripts"];
|
|
83
|
-
const PACKAGE_NAME_PATTERN = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/;
|
|
84
|
-
const SCAN_SKIPPED = new Set(["node_modules", "dist"]);
|
|
85
|
-
const SCANNED_EXTENSIONS = new Set([".ts", ".tsx", ".mts", ".mjs", ".js", ".css"]);
|
|
86
|
-
const IMPORT_PATTERN = /\b(?:from|import|require(?:\.resolve)?)\s*\(?\s*["']([^"'\n]+)["']/g;
|
|
87
|
-
/** An executor contribution resolves packages from the host at runtime; the package must carry them too. */
|
|
88
|
-
const HOST_PACKAGE_PATTERN = /\bhostPackageFile\([^,()]*,\s*["']([^"'\n]+)["']/g;
|
|
89
|
-
|
|
90
|
-
const files = async (folder: string, skippedFolders: ReadonlySet<string> = SKIPPED_FOLDERS): Promise<readonly string[]> => {
|
|
91
|
-
const found: string[] = [];
|
|
92
|
-
for (const entry of (await readdir(folder, { withFileTypes: true })).sort((left, right) => left.name.localeCompare(right.name, "en"))) {
|
|
93
|
-
if (entry.isDirectory()) {
|
|
94
|
-
if (!skippedFolders.has(entry.name)) found.push(...await files(path.join(folder, entry.name), skippedFolders));
|
|
95
|
-
} else if (entry.isFile() && !SKIPPED_FILES.has(entry.name)) {
|
|
96
|
-
found.push(path.join(folder, entry.name));
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
return found;
|
|
100
|
-
};
|
|
101
|
-
|
|
102
|
-
const copyTo = async (source: string, target: string, packageRoot: string, skippedFolders?: ReadonlySet<string>): Promise<readonly string[]> => {
|
|
103
|
-
if (statSync(source).isFile()) {
|
|
104
|
-
if (skippedInPackage(packagePath(packageRoot, target))) return [];
|
|
105
|
-
await mkdir(path.dirname(target), { recursive: true });
|
|
106
|
-
await copyFile(source, target);
|
|
107
|
-
return [target];
|
|
108
|
-
}
|
|
109
|
-
const copied: string[] = [];
|
|
110
|
-
for (const file of await files(source, skippedFolders)) {
|
|
111
|
-
const destination = path.join(target, path.relative(source, file));
|
|
112
|
-
if (skippedInPackage(packagePath(packageRoot, destination))) continue;
|
|
113
|
-
await mkdir(path.dirname(destination), { recursive: true });
|
|
114
|
-
await copyFile(file, destination);
|
|
115
|
-
copied.push(destination);
|
|
116
|
-
}
|
|
117
|
-
return copied;
|
|
118
|
-
};
|
|
119
|
-
|
|
120
|
-
const scanRoots = (root: string): readonly string[] => SCANNED.map((entry) => path.join(root, entry));
|
|
121
|
-
|
|
122
|
-
const scannedFiles = (folder: string): readonly string[] => {
|
|
123
|
-
if (!statSync(folder, { throwIfNoEntry: false })?.isDirectory()) return [];
|
|
124
|
-
return readdirSync(folder, { withFileTypes: true }).flatMap((entry) => {
|
|
125
|
-
const absolute = path.join(folder, entry.name);
|
|
126
|
-
if (entry.isDirectory()) return SCAN_SKIPPED.has(entry.name) ? [] : scannedFiles(absolute);
|
|
127
|
-
return entry.isFile() && SCANNED_EXTENSIONS.has(path.extname(entry.name)) ? [absolute] : [];
|
|
128
|
-
});
|
|
129
|
-
};
|
|
130
|
-
|
|
131
|
-
const packageNameOf = (specifier: string): string | undefined => {
|
|
132
|
-
if (/^(\.{1,2}\/|\/|node:|data:|@ragents\/|virtual:)/.test(specifier) || specifier === "vscode") return undefined;
|
|
133
|
-
const parts = specifier.split("/");
|
|
134
|
-
const name = specifier.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0]!;
|
|
135
|
-
if (builtinModules.includes(name)) return undefined;
|
|
136
|
-
return PACKAGE_NAME_PATTERN.test(name) ? name : undefined;
|
|
137
|
-
};
|
|
138
|
-
|
|
139
|
-
const declaredWebDependencies = (packageDirectory: string): readonly string[] => {
|
|
140
|
-
const file = path.join(packageDirectory, "apps/web/package.json");
|
|
141
|
-
const declared = (JSON.parse(readFileSync(file, "utf8")) as { dependencies?: Record<string, string> }).dependencies ?? {};
|
|
142
|
-
return Object.keys(declared);
|
|
143
|
-
};
|
|
144
|
-
|
|
145
|
-
/** The packages that the executed part of the package actually imports, plus the libraries linked at runtime
|
|
146
|
-
* and those that the host's web offers to the web halves of third-party plugins. */
|
|
147
|
-
export const requiredPackages = (packageDirectory: string): ReadonlyMap<string, string> => {
|
|
148
|
-
const sources = new Map<string, string>();
|
|
149
|
-
for (const name of runtimeLibraries) sources.set(name, "plugins/ragents.actor-programs (linked at runtime)");
|
|
150
|
-
for (const name of declaredWebDependencies(packageDirectory)) sources.set(name, "apps/web/package.json (for the web halves of the plugins)");
|
|
151
|
-
for (const folder of scanRoots(packageDirectory)) {
|
|
152
|
-
for (const file of scannedFiles(folder)) {
|
|
153
|
-
const content = readFileSync(file, "utf8");
|
|
154
|
-
for (const match of [...content.matchAll(IMPORT_PATTERN), ...content.matchAll(HOST_PACKAGE_PATTERN)]) {
|
|
155
|
-
const name = packageNameOf(match[1]!);
|
|
156
|
-
if (name && !sources.has(name)) sources.set(name, path.relative(packageDirectory, file));
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
return new Map([...sources].sort(([left], [right]) => left.localeCompare(right, "en")));
|
|
161
|
-
};
|
|
162
|
-
|
|
163
|
-
const compareVersions = (left: string, right: string): number => {
|
|
164
|
-
const parts = (value: string) => value.split(/[.+-]/).map((part) => Number.isInteger(Number(part)) ? Number(part) : part);
|
|
165
|
-
const [first, second] = [parts(left), parts(right)];
|
|
166
|
-
for (let index = 0; index < Math.max(first.length, second.length); index += 1) {
|
|
167
|
-
const [one, other] = [first[index] ?? 0, second[index] ?? 0];
|
|
168
|
-
if (one === other) continue;
|
|
169
|
-
return typeof one === "number" && typeof other === "number" ? one - other : String(one).localeCompare(String(other), "en");
|
|
170
|
-
}
|
|
171
|
-
return 0;
|
|
172
|
-
};
|
|
173
|
-
|
|
174
|
-
const anchors = (root: string): readonly string[] => [
|
|
175
|
-
path.join(root, "package.json"),
|
|
176
|
-
...["apps", "packages"].flatMap((group) => readdirSync(path.join(root, group), { withFileTypes: true })
|
|
177
|
-
.filter((entry) => entry.isDirectory())
|
|
178
|
-
.map((entry) => path.join(root, group, entry.name, "package.json"))
|
|
179
|
-
.filter((file) => statSync(file, { throwIfNoEntry: false })?.isFile() === true)),
|
|
180
|
-
];
|
|
181
|
-
|
|
182
|
-
/** The versions this checkout has installed for a package; the newest goes into the package. */
|
|
183
|
-
export const installedVersions = (name: string, root = repositoryRoot): readonly string[] => {
|
|
184
|
-
const found = new Set<string>();
|
|
185
|
-
for (const anchor of anchors(root)) {
|
|
186
|
-
for (let directory = path.dirname(anchor); ; directory = path.dirname(directory)) {
|
|
187
|
-
const file = path.join(directory, "node_modules", ...name.split("/"), "package.json");
|
|
188
|
-
if (statSync(file, { throwIfNoEntry: false })?.isFile()) {
|
|
189
|
-
found.add((JSON.parse(readFileSync(file, "utf8")) as { version: string }).version);
|
|
190
|
-
break;
|
|
191
|
-
}
|
|
192
|
-
if (path.dirname(directory) === directory) break;
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
const versions = [...found].sort(compareVersions);
|
|
196
|
-
if (versions.length === 0) throw new Error(`The package ${name} is not installed in this checkout; without a version it cannot go into the package`);
|
|
197
|
-
return versions;
|
|
198
|
-
};
|
|
199
|
-
|
|
200
|
-
export interface BuiltPackage {
|
|
201
|
-
readonly directory: string;
|
|
202
|
-
readonly manifest: Record<string, unknown>;
|
|
203
|
-
readonly fileCount: number;
|
|
204
|
-
readonly byteSize: number;
|
|
205
|
-
readonly flattened: readonly string[];
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/** The package ships the web prebuilt; it must be built and match its sources. */
|
|
209
|
-
const assertHostWeb = (root: string): void => {
|
|
210
|
-
const problem = hostWebProblem(hostWebDirectory(root), root, isCheckout(root));
|
|
211
|
-
if (problem) throw new Error(`${problem}; run pnpm build:web first`);
|
|
212
|
-
};
|
|
213
|
-
|
|
214
|
-
/** Every built-in plugin must exist as a bundle and match its sources, otherwise the package starts no profile. */
|
|
215
|
-
const assertBuiltInBundles = (root: string): void => {
|
|
216
|
-
for (const folder of builtInPluginFolders(path.join(root, "plugins"))) {
|
|
217
|
-
const bundle = path.join(root, "bundles", path.basename(folder));
|
|
218
|
-
if (!statSync(bundle, { throwIfNoEntry: false })?.isDirectory()) throw new Error(`The bundle ${bundle} is missing; run pnpm build:plugins first`);
|
|
219
|
-
if (readBundleManifest(bundle).sourceRevision !== sourceRevisionOf(folder)) throw new Error(`The bundle ${bundle} no longer matches ${folder}; run pnpm build:plugins first`);
|
|
220
|
-
}
|
|
221
|
-
};
|
|
222
|
-
|
|
223
|
-
const copyDeclarations = async (root: string, target: string): Promise<readonly string[]> => {
|
|
224
|
-
const copied: string[] = [];
|
|
225
|
-
for (const folder of declarationFolders(root)) {
|
|
226
|
-
if (!statSync(path.join(root, folder), { throwIfNoEntry: false })?.isDirectory()) throw new Error(`${folder} is missing; run pnpm build:agent first`);
|
|
227
|
-
for (const file of (await files(path.join(root, folder))).filter((name) => name.endsWith(".d.ts"))) {
|
|
228
|
-
const destination = path.join(target, path.relative(root, file));
|
|
229
|
-
await mkdir(path.dirname(destination), { recursive: true });
|
|
230
|
-
await copyFile(file, destination);
|
|
231
|
-
copied.push(destination);
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
return copied;
|
|
235
|
-
};
|
|
236
|
-
|
|
237
|
-
export const buildPackage = async (target: string, root = repositoryRoot): Promise<BuiltPackage> => {
|
|
238
|
-
assertBuiltInBundles(root);
|
|
239
|
-
assertHostWeb(root);
|
|
240
|
-
await rm(target, { recursive: true, force: true });
|
|
241
|
-
await mkdir(target, { recursive: true });
|
|
242
|
-
let fileCount = 0;
|
|
243
|
-
let byteSize = 0;
|
|
244
|
-
for (const entry of packageContents(root)) {
|
|
245
|
-
// A bundle is fully built and goes into the package completely, including an asset folder named dist.
|
|
246
|
-
const copied = await copyTo(path.join(root, entry), path.join(target, entry), target, entry === "bundles" ? new Set() : undefined);
|
|
247
|
-
fileCount += copied.length;
|
|
248
|
-
for (const file of copied) byteSize += statSync(file).size;
|
|
249
|
-
}
|
|
250
|
-
for (const folder of builtInPluginFolders(path.join(root, "plugins"))) {
|
|
251
|
-
const bundle = path.join(target, "bundles", path.basename(folder));
|
|
252
|
-
assertBundleRevision(bundle, readBundleManifest(bundle));
|
|
253
|
-
}
|
|
254
|
-
for (const file of await copyDeclarations(root, target)) {
|
|
255
|
-
fileCount += 1;
|
|
256
|
-
byteSize += statSync(file).size;
|
|
257
|
-
}
|
|
258
|
-
for (const [source, packaged] of PACKAGE_ROOT_FILES) {
|
|
259
|
-
const destination = path.join(target, packaged);
|
|
260
|
-
await mkdir(path.dirname(destination), { recursive: true });
|
|
261
|
-
await copyFile(path.join(root, source), destination);
|
|
262
|
-
fileCount += 1;
|
|
263
|
-
byteSize += statSync(destination).size;
|
|
264
|
-
}
|
|
265
|
-
const flattened: string[] = [];
|
|
266
|
-
const dependencies: Record<string, string> = {};
|
|
267
|
-
for (const [name] of requiredPackages(target)) {
|
|
268
|
-
const versions = installedVersions(name, root);
|
|
269
|
-
if (versions.length > 1) flattened.push(`${name}: ${versions.join(", ")} -> ${versions.at(-1)!}`);
|
|
270
|
-
dependencies[name] = versions.at(-1)!;
|
|
271
|
-
}
|
|
272
|
-
const manifest = {
|
|
273
|
-
name: PACKAGE_NAME,
|
|
274
|
-
version: readPackageVersion(root),
|
|
275
|
-
description: "RAgents host: server, engine, plugins and tools without a source checkout",
|
|
276
|
-
keywords: [...PACKAGE_KEYWORDS],
|
|
277
|
-
homepage: "https://schlenkr.github.io/RAgents/",
|
|
278
|
-
bugs: { url: `${REPOSITORY_URL}/issues` },
|
|
279
|
-
license: PACKAGE_LICENSE,
|
|
280
|
-
author: PACKAGE_AUTHOR,
|
|
281
|
-
repository: { type: "git", url: `git+${REPOSITORY_URL}.git` },
|
|
282
|
-
type: "module",
|
|
283
|
-
bin: { ragents: "scripts/package/ragents.mjs" },
|
|
284
|
-
engines: { node: ">=22.19.0" },
|
|
285
|
-
publishConfig: { access: "public" },
|
|
286
|
-
dependencies,
|
|
287
|
-
ragents: { hostVersion: readHostVersion(root) },
|
|
288
|
-
};
|
|
289
|
-
const manifestFile = path.join(target, "package.json");
|
|
290
|
-
await writeFile(manifestFile, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
291
|
-
fileCount += 1;
|
|
292
|
-
byteSize += statSync(manifestFile).size;
|
|
293
|
-
return { directory: target, manifest, fileCount, byteSize, flattened };
|
|
294
|
-
};
|
|
295
|
-
|
|
296
|
-
const main = async (): Promise<void> => {
|
|
297
|
-
const pack = process.argv.includes("--pack");
|
|
298
|
-
const unknown = process.argv.slice(2).filter((argument) => argument !== "--pack");
|
|
299
|
-
if (unknown.length) throw new Error(`Unknown argument: ${unknown.join(" ")} (allowed: --pack)`);
|
|
300
|
-
const target = path.join(repositoryRoot, "dist", PACKAGE_FOLDER);
|
|
301
|
-
const built = await buildPackage(target);
|
|
302
|
-
console.log(`== Package ${PACKAGE_NAME}@${built.manifest.version as string} in ${built.directory}`);
|
|
303
|
-
console.log(`== Host version ${(built.manifest.ragents as { hostVersion: string }).hostVersion}`);
|
|
304
|
-
console.log(`== ${built.fileCount} files, ${(built.byteSize / 1024 / 1024).toFixed(1)} MB, ${Object.keys(built.manifest.dependencies as object).length} dependencies`);
|
|
305
|
-
for (const [name, version] of Object.entries(built.manifest.dependencies as Record<string, string>)) console.log(` ${name}@${version}`);
|
|
306
|
-
for (const entry of built.flattened) console.log(`== Multiple versions flattened: ${entry}`);
|
|
307
|
-
if (!pack) return;
|
|
308
|
-
const packed = spawnSync("npm", ["pack", "--pack-destination", path.dirname(built.directory)], { cwd: built.directory, encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] });
|
|
309
|
-
if (packed.status !== 0) throw new Error(`npm pack ended with code ${packed.status}`);
|
|
310
|
-
console.log(`== Archive ${path.join(path.dirname(built.directory), packed.stdout.trim().split("\n").at(-1) ?? "")}`);
|
|
311
|
-
};
|
|
312
|
-
|
|
313
|
-
const moduleUrl: string | undefined = import.meta.url;
|
|
314
|
-
if (moduleUrl && process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(moduleUrl)) {
|
|
315
|
-
main().catch((error: unknown) => {
|
|
316
|
-
console.error(error instanceof Error ? error.message : String(error));
|
|
317
|
-
process.exit(1);
|
|
318
|
-
});
|
|
319
|
-
}
|
|
@@ -1,144 +0,0 @@
|
|
|
1
|
-
import { spawnSync } from "node:child_process";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { fileURLToPath } from "node:url";
|
|
4
|
-
import { compareVersions, readVersion, releaseVersion, versionLine, writeHostPackageVersion, writeVersion } from "../publish-version.ts";
|
|
5
|
-
import { buildPackage, PACKAGE_FOLDER, PACKAGE_NAME } from "./build-package.ts";
|
|
6
|
-
|
|
7
|
-
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
8
|
-
|
|
9
|
-
const usage = `Usage: pnpm publish:package [--dry-run]
|
|
10
|
-
Builds the package ${PACKAGE_NAME} and publishes it on npm. The token comes from the
|
|
11
|
-
environment variable npm_key. --dry-run passes the dry run through to npm and publishes nothing.
|
|
12
|
-
The version is in the root package.json; before building, the script raises the last place
|
|
13
|
-
above the last published version, in a dry run only in the output.`;
|
|
14
|
-
|
|
15
|
-
const REGISTRY_TOKEN_KEY = "npm_config_//registry.npmjs.org/:_authToken";
|
|
16
|
-
|
|
17
|
-
export interface NpmResult {
|
|
18
|
-
readonly status: number;
|
|
19
|
-
readonly stdout: string;
|
|
20
|
-
readonly stderr: string;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export type NpmRunner = (args: readonly string[], options: { cwd: string; token?: string }) => NpmResult;
|
|
24
|
-
|
|
25
|
-
/** The token goes only as configuration into the child process's environment, never onto the command line. */
|
|
26
|
-
export const publishEnvironment = (token: string, base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv =>
|
|
27
|
-
({ ...base, [REGISTRY_TOKEN_KEY]: token });
|
|
28
|
-
|
|
29
|
-
/** Everything that comes from npm goes through this mask so that the token does not show up even in an error message. */
|
|
30
|
-
export const redacting = (token: string) => (text: string): string => token ? text.split(token).join("<npm_key>") : text;
|
|
31
|
-
|
|
32
|
-
/** An error message is one line; npm's output is therefore moved onto one. */
|
|
33
|
-
export const oneLine = (text: string): string => text.split("\n").map((line) => line.trim()).filter(Boolean).join("; ");
|
|
34
|
-
|
|
35
|
-
const runNpm: NpmRunner = (args, options) => {
|
|
36
|
-
const result = spawnSync("npm", [...args], {
|
|
37
|
-
cwd: options.cwd,
|
|
38
|
-
encoding: "utf8",
|
|
39
|
-
env: options.token ? publishEnvironment(options.token) : process.env,
|
|
40
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
41
|
-
});
|
|
42
|
-
if (result.error) throw new Error(`npm ${args[0]} could not be started: ${result.error.message}`);
|
|
43
|
-
return { status: result.status ?? 1, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
/** The versions already on npm; a package unknown there is the empty list, not an error. */
|
|
47
|
-
export const publishedVersions = (name: string, npm: NpmRunner): readonly string[] => {
|
|
48
|
-
const result = npm(["view", name, "versions", "--json"], { cwd: repositoryRoot });
|
|
49
|
-
if (result.status !== 0) {
|
|
50
|
-
if (/E404|404 Not Found/.test(`${result.stdout}\n${result.stderr}`)) return [];
|
|
51
|
-
throw new Error(`npm view ${name} versions ended with code ${result.status}: ${oneLine(result.stderr)}`);
|
|
52
|
-
}
|
|
53
|
-
const parsed = JSON.parse(result.stdout.trim() || "[]") as string | readonly string[];
|
|
54
|
-
return typeof parsed === "string" ? [parsed] : parsed;
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
/** The highest version on npm; a package unknown there has none. */
|
|
58
|
-
export const latestPublishedVersion = (npm: NpmRunner = runNpm, name = PACKAGE_NAME): string | undefined =>
|
|
59
|
-
[...publishedVersions(name, npm)].sort(compareVersions).at(-1);
|
|
60
|
-
|
|
61
|
-
export interface PublishPlan {
|
|
62
|
-
readonly name: string;
|
|
63
|
-
readonly version: string;
|
|
64
|
-
readonly hostVersion: string;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/** What must be right before a publish: the built manifest and a version that does not yet exist on npm. */
|
|
68
|
-
export const publishPlan = (manifest: Record<string, unknown>, published: readonly string[], version: string): PublishPlan => {
|
|
69
|
-
const { name } = manifest as { name?: unknown };
|
|
70
|
-
const access = (manifest.publishConfig as { access?: unknown } | undefined)?.access;
|
|
71
|
-
const hostVersion = (manifest.ragents as { hostVersion?: unknown } | undefined)?.hostVersion;
|
|
72
|
-
if (name !== PACKAGE_NAME) throw new Error(`The built package is named ${String(name)}, but ${PACKAGE_NAME} is published`);
|
|
73
|
-
if (access !== "public") throw new Error(`${PACKAGE_NAME} has a scope and needs publishConfig.access "public", not ${JSON.stringify(access)}`);
|
|
74
|
-
if (!/^\d+\.\d+\.\d+(?:-[0-9a-z.-]+)?$/.test(version)) {
|
|
75
|
-
throw new Error(`The version ${JSON.stringify(version)} is not a version; it is the version in the root package.json`);
|
|
76
|
-
}
|
|
77
|
-
if (typeof hostVersion !== "string" || !/^[0-9a-f]{40}$/.test(hostVersion)) {
|
|
78
|
-
throw new Error("The package lacks ragents.hostVersion as a full Git commit; it is built only from a checkout");
|
|
79
|
-
}
|
|
80
|
-
if (published.includes(version)) {
|
|
81
|
-
throw new Error(`${PACKAGE_NAME}@${version} is already on npm (there: ${published.join(", ")}); `
|
|
82
|
-
+ "raise version in the root package.json and rebuild");
|
|
83
|
-
}
|
|
84
|
-
return { name: PACKAGE_NAME, version, hostVersion };
|
|
85
|
-
};
|
|
86
|
-
|
|
87
|
-
export interface PublishOptions {
|
|
88
|
-
readonly directory: string;
|
|
89
|
-
readonly version: string;
|
|
90
|
-
readonly token: string;
|
|
91
|
-
readonly dryRun: boolean;
|
|
92
|
-
readonly npm: NpmRunner;
|
|
93
|
-
readonly log: (line: string) => void;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/** npm reports a missing organization as 404; without the hint you look for the error in the package. */
|
|
97
|
-
const scopeAdvice = (output: string): string => /Scope not found/i.test(output)
|
|
98
|
-
? ` The organization ${PACKAGE_NAME.split("/")[0]} does not exist on npm or the token may not use it: `
|
|
99
|
-
+ "create it on npmjs.com or grant the token access to the scope."
|
|
100
|
-
: "";
|
|
101
|
-
|
|
102
|
-
/** Publishes the built folder; exit 0 of npm publish is the confirmation. */
|
|
103
|
-
export const publishDirectory = (options: PublishOptions): void => {
|
|
104
|
-
const hide = redacting(options.token);
|
|
105
|
-
const published = options.npm(["publish", "--access", "public", ...options.dryRun ? ["--dry-run"] : []], { cwd: options.directory, token: options.token });
|
|
106
|
-
const output = `${published.stdout}${published.stderr}`;
|
|
107
|
-
for (const line of output.split("\n")) if (line.trim()) options.log(hide(line));
|
|
108
|
-
if (published.status !== 0) throw new Error(`npm publish ended with code ${published.status}.${scopeAdvice(output)}`);
|
|
109
|
-
if (options.dryRun) {
|
|
110
|
-
options.log("== Dry run: nothing published");
|
|
111
|
-
return;
|
|
112
|
-
}
|
|
113
|
-
options.log(`== Published: ${PACKAGE_NAME}@${options.version}`);
|
|
114
|
-
};
|
|
115
|
-
|
|
116
|
-
const main = async (): Promise<void> => {
|
|
117
|
-
const dryRun = process.argv.includes("--dry-run");
|
|
118
|
-
const unknown = process.argv.slice(2).filter((argument) => argument !== "--dry-run");
|
|
119
|
-
if (unknown.length) throw new Error(`Unknown argument: ${unknown.join(" ")}\n${usage}`);
|
|
120
|
-
const token = process.env.npm_key;
|
|
121
|
-
if (!token) throw new Error(`npm_key is missing: the npm token with write access to ${PACKAGE_NAME}.\n${usage}`);
|
|
122
|
-
const manifestFile = path.join(repositoryRoot, "package.json");
|
|
123
|
-
const published = publishedVersions(PACKAGE_NAME, runNpm);
|
|
124
|
-
const extensionManifestFile = path.join(repositoryRoot, "apps", "vscode", "package.json");
|
|
125
|
-
const next = releaseVersion([readVersion(manifestFile), readVersion(extensionManifestFile)], published);
|
|
126
|
-
console.log(versionLine(next));
|
|
127
|
-
if (!dryRun) {
|
|
128
|
-
writeVersion(manifestFile, next.version);
|
|
129
|
-
writeVersion(extensionManifestFile, next.version);
|
|
130
|
-
writeHostPackageVersion(extensionManifestFile, next.version);
|
|
131
|
-
}
|
|
132
|
-
const built = await buildPackage(path.join(repositoryRoot, "dist", PACKAGE_FOLDER));
|
|
133
|
-
const plan = publishPlan(built.manifest, published, next.version);
|
|
134
|
-
console.log(`== ${plan.name}@${plan.version} from host ${plan.hostVersion.slice(0, 12)}, ${built.fileCount} files, ${(built.byteSize / 1024 / 1024).toFixed(1)} MB`);
|
|
135
|
-
publishDirectory({ directory: built.directory, version: plan.version, token, dryRun, npm: runNpm, log: (line) => console.log(line) });
|
|
136
|
-
};
|
|
137
|
-
|
|
138
|
-
const moduleUrl: string | undefined = import.meta.url;
|
|
139
|
-
if (moduleUrl && process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(moduleUrl)) {
|
|
140
|
-
main().catch((error: unknown) => {
|
|
141
|
-
console.error(error instanceof Error ? error.message : String(error));
|
|
142
|
-
process.exit(1);
|
|
143
|
-
});
|
|
144
|
-
}
|