@schlenkr/ragents 0.1.3 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/server/src/config-file.ts +2 -1
- package/apps/server/src/host-record.ts +31 -0
- package/apps/server/src/profile-target.ts +32 -0
- package/package.json +1 -1
- package/scripts/agent/agent-cli.ts +65 -47
- package/scripts/package/ragents.mjs +10 -0
- package/scripts/remote/connect.ts +26 -3
- package/scripts/remote/start.ts +1 -24
|
@@ -51,8 +51,9 @@ const resolveUserToken = (
|
|
|
51
51
|
if (!isEnvironmentReference(raw) || typeof raw.name !== "string" || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(raw.name)) {
|
|
52
52
|
throw new Error(`${location}.token braucht env("ENV_NAME"); ein Token steht nie im Klartext in der Profildatei`);
|
|
53
53
|
}
|
|
54
|
+
// Ein Benutzer meldet sich weiter mit seinem Passwort an; ohne gesetzte Variable hat er nur keinen persönlichen Token.
|
|
54
55
|
const token = environment[raw.name];
|
|
55
|
-
if (!token)
|
|
56
|
+
if (!token) return undefined;
|
|
56
57
|
// $ und ! sind tabu, weil der Verbraucher den Token als API-Schlüssel mit Vorlagen- und Befehlssyntax auswertet.
|
|
57
58
|
if (token.length < MIN_TOKEN_LENGTH || token.length > MAX_TOKEN_LENGTH || !/^[\x21-\x7e]+$/.test(token) || /[$!]/.test(token)) {
|
|
58
59
|
throw new Error(`${location}.token (${raw.name}) braucht ${MIN_TOKEN_LENGTH} bis ${MAX_TOKEN_LENGTH} druckbare ASCII-Zeichen ohne Leerzeichen, $ und !`);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export interface HostRecord {
|
|
5
|
+
readonly profile: string;
|
|
6
|
+
readonly url: string;
|
|
7
|
+
readonly pid: number;
|
|
8
|
+
readonly log: string;
|
|
9
|
+
readonly startedAt: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Der gemerkte Host eines Profils; ragents run und ragents start schreiben dieselbe Datei, stop --host liest sie. */
|
|
13
|
+
export const hostRecordFile = (dataDirectory: string): string => path.join(dataDirectory, "host.json");
|
|
14
|
+
|
|
15
|
+
export const readHostRecord = (dataDirectory: string): HostRecord | undefined => {
|
|
16
|
+
const file = hostRecordFile(dataDirectory);
|
|
17
|
+
if (!statSync(file, { throwIfNoEntry: false })?.isFile()) return undefined;
|
|
18
|
+
const parsed = JSON.parse(readFileSync(file, "utf8")) as Partial<HostRecord>;
|
|
19
|
+
// Eine PID unter 1 wäre für process.kill die ganze Prozessgruppe; sie darf nie aus dieser Datei kommen.
|
|
20
|
+
if (typeof parsed.url !== "string" || !Number.isInteger(parsed.pid) || (parsed.pid ?? 0) < 1) throw new Error(`${file} nennt keine Adresse oder keine PID des Hosts`);
|
|
21
|
+
return parsed as HostRecord;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const writeHostRecord = (dataDirectory: string, record: HostRecord): void => {
|
|
25
|
+
mkdirSync(dataDirectory, { recursive: true });
|
|
26
|
+
writeFileSync(hostRecordFile(dataDirectory), `${JSON.stringify(record, null, 2)}\n`);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const removeHostRecord = (dataDirectory: string): void => {
|
|
30
|
+
rmSync(hostRecordFile(dataDirectory), { force: true });
|
|
31
|
+
};
|
|
@@ -2,7 +2,9 @@ import { statSync } from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { pathToFileURL } from "node:url";
|
|
4
4
|
import { isEnvironmentReference } from "./config-definition.js";
|
|
5
|
+
import { profileFileName } from "./config-file.js";
|
|
5
6
|
import { defaultDataDirectory } from "./data-directory.js";
|
|
7
|
+
import { hostRoot } from "./host-version.js";
|
|
6
8
|
|
|
7
9
|
export interface ProfileTarget {
|
|
8
10
|
readonly profile: string;
|
|
@@ -12,6 +14,29 @@ export interface ProfileTarget {
|
|
|
12
14
|
readonly baseUrl: string;
|
|
13
15
|
}
|
|
14
16
|
|
|
17
|
+
export interface LocalProfile {
|
|
18
|
+
readonly profile: string;
|
|
19
|
+
readonly profileFile: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const PROFILE_FILE = /^ragents\.config\.([a-z0-9]+(?:-[a-z0-9]+)*)\.ts$/;
|
|
23
|
+
|
|
24
|
+
/** pnpm und der bin-Befehl führen die Skripte mit dem Arbeitsverzeichnis apps/server aus; ein relativer Pfad gilt trotzdem ab dem Aufrufer. */
|
|
25
|
+
export const callerDirectory = (): string => process.env.RAGENTS_CWD ?? process.env.INIT_CWD ?? process.cwd();
|
|
26
|
+
|
|
27
|
+
/** Ein Profil ist ein Name neben dem Host (Checkout wie Paket) oder ein Pfad zu einer ragents.config.<profil>.ts an beliebiger Stelle. */
|
|
28
|
+
export const localProfile = (selection: string, root = hostRoot(), caller = callerDirectory()): LocalProfile | undefined => {
|
|
29
|
+
if (!selection.includes("/") && !selection.endsWith(".ts")) {
|
|
30
|
+
const file = path.join(root, profileFileName(selection));
|
|
31
|
+
return statSync(file, { throwIfNoEntry: false })?.isFile() ? { profile: selection, profileFile: file } : undefined;
|
|
32
|
+
}
|
|
33
|
+
const profileFile = path.resolve(caller, selection);
|
|
34
|
+
const match = PROFILE_FILE.exec(path.basename(profileFile));
|
|
35
|
+
if (!match) throw new Error(`Eine Profildatei heißt ragents.config.<profil>.ts, nicht ${path.basename(profileFile)}`);
|
|
36
|
+
if (!statSync(profileFile, { throwIfNoEntry: false })?.isFile()) throw new Error(`Die Profildatei fehlt: ${profileFile}`);
|
|
37
|
+
return { profile: match[1]!, profileFile };
|
|
38
|
+
};
|
|
39
|
+
|
|
15
40
|
/** Port und Datenordner eines Profils, wie der Server sie später selbst bestimmt: Umgebung vor Profildatei vor Vorgabe. */
|
|
16
41
|
export const readProfileTarget = async (profile: string, profileFile: string, environment = process.env): Promise<ProfileTarget> => {
|
|
17
42
|
if (!statSync(profileFile, { throwIfNoEntry: false })?.isFile()) throw new Error(`Die Profildatei fehlt: ${profileFile}`);
|
|
@@ -23,3 +48,10 @@ export const readProfileTarget = async (profile: string, profileFile: string, en
|
|
|
23
48
|
const dataDirectory = path.resolve(environment.DATA_DIR ?? fromFile ?? defaultDataDirectory(profile));
|
|
24
49
|
return { profile, profileFile, port, dataDirectory, baseUrl: `http://localhost:${port}` };
|
|
25
50
|
};
|
|
51
|
+
|
|
52
|
+
/** Die eine Auflösung für alle Befehle: Name oder Pfad zum Profil, danach Port und Datenordner wie beim Server. */
|
|
53
|
+
export const selectProfileTarget = async (selection: string, root = hostRoot(), environment = process.env): Promise<ProfileTarget> => {
|
|
54
|
+
const own = localProfile(selection, root);
|
|
55
|
+
if (!own) throw new Error(`Die Profildatei fehlt: ${path.join(root, profileFileName(selection))}`);
|
|
56
|
+
return readProfileTarget(own.profile, own.profileFile, environment);
|
|
57
|
+
};
|
package/package.json
CHANGED
|
@@ -2,14 +2,15 @@
|
|
|
2
2
|
import "../../apps/server/src/host-resolution.ts";
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
|
-
import { closeSync, mkdirSync, openSync, readFileSync,
|
|
5
|
+
import { closeSync, mkdirSync, openSync, readFileSync, statSync } from "node:fs";
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
8
|
import { coreContracts } from "../../apps/server/src/api/contracts.ts";
|
|
9
|
-
import {
|
|
9
|
+
import { hostRecordFile, readHostRecord, removeHostRecord, writeHostRecord } from "../../apps/server/src/host-record.ts";
|
|
10
10
|
import { hostRoot } from "../../apps/server/src/host-version.ts";
|
|
11
|
-
import {
|
|
11
|
+
import { callerDirectory, selectProfileTarget, type ProfileTarget } from "../../apps/server/src/profile-target.ts";
|
|
12
12
|
import { RpcClient } from "../../apps/web/src/rpc/client.ts";
|
|
13
|
+
import { RpcError } from "../../packages/ragents/src/rpc/protocol.ts";
|
|
13
14
|
import { WORKSPACE_BINDING_OPTION_ID } from "../../plugins/ragents.workspace/contract.ts";
|
|
14
15
|
import { JournalReader, journalLines, readJournal, RUN_ID_PATTERN, type JournalEvent } from "./journal.ts";
|
|
15
16
|
|
|
@@ -18,6 +19,10 @@ const HOST_START_TIMEOUT_MS = 120_000;
|
|
|
18
19
|
const HOST_STOP_TIMEOUT_MS = 20_000;
|
|
19
20
|
const POLL_INTERVAL_MS = 400;
|
|
20
21
|
const HEALTH_TIMEOUT_MS = 2_000;
|
|
22
|
+
const CHAT_READY_TIMEOUT_MS = 60_000;
|
|
23
|
+
|
|
24
|
+
/** Vorgabeprofil der Agentenbefehle; RAGENTS_PROFILE gilt für alle Befehle derselben Shell. */
|
|
25
|
+
export const defaultProfile = (): string => process.env.RAGENTS_PROFILE ?? DEFAULT_PROFILE;
|
|
21
26
|
|
|
22
27
|
export const usage = (): string => `Verwendung: ragents <befehl> [argumente]
|
|
23
28
|
|
|
@@ -31,11 +36,13 @@ export const usage = (): string => `Verwendung: ragents <befehl> [argumente]
|
|
|
31
36
|
stop <run> [--profile <p>] den laufenden Turn abbrechen
|
|
32
37
|
stop --host [--profile <p>] den gemerkten Host beenden
|
|
33
38
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
+
<p> ist ein Profilname neben dem Host oder der Pfad zu einer ragents.config.<profil>.ts an
|
|
40
|
+
beliebiger Stelle; ohne --profile gilt RAGENTS_PROFILE, sonst ${DEFAULT_PROFILE}. Exit-Code:
|
|
41
|
+
0 fertig, 2 abgebrochen, 1 fehlgeschlagen oder Verbindungsproblem. Die letzte Zeile auf stdout
|
|
42
|
+
ist "run: <id>". Die Adresse kommt aus <Datenordner>/host.json, sonst aus RAGENTS_URL, sonst aus
|
|
43
|
+
host.PORT des Profils; RAGENTS_TOKEN geht als Bearer-Token mit, falls das Profil eine Anmeldung
|
|
44
|
+
verlangt. Der so gestartete Host baut die Oberfläche nicht - ein Agent braucht sie nicht; mit
|
|
45
|
+
Oberfläche startet ragents start <profil>.`;
|
|
39
46
|
|
|
40
47
|
export type AgentCommand =
|
|
41
48
|
| { readonly kind: "run"; readonly profile: string; readonly folder: string; readonly text: string; readonly entry: string | undefined; readonly json: boolean }
|
|
@@ -76,7 +83,7 @@ const scan = (argv: readonly string[], allowed: readonly string[]): Flags => {
|
|
|
76
83
|
index += 1;
|
|
77
84
|
}
|
|
78
85
|
return {
|
|
79
|
-
profile: values.get("--profile") ??
|
|
86
|
+
profile: values.get("--profile") ?? defaultProfile(),
|
|
80
87
|
entry: values.get("--entry"),
|
|
81
88
|
json: switches.has("--json"),
|
|
82
89
|
tools: switches.has("--tools"),
|
|
@@ -125,35 +132,7 @@ export const parseArguments = (argv: readonly string[]): AgentCommand => {
|
|
|
125
132
|
throw new Error(command ? `Unbekannter Befehl: ${command}\n\n${usage()}` : usage());
|
|
126
133
|
};
|
|
127
134
|
|
|
128
|
-
export
|
|
129
|
-
readonly profile: string;
|
|
130
|
-
readonly url: string;
|
|
131
|
-
readonly pid: number;
|
|
132
|
-
readonly log: string;
|
|
133
|
-
readonly startedAt: string;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
export const hostRecordFile = (dataDirectory: string): string => path.join(dataDirectory, "host.json");
|
|
137
|
-
|
|
138
|
-
export const readHostRecord = (dataDirectory: string): HostRecord | undefined => {
|
|
139
|
-
const file = hostRecordFile(dataDirectory);
|
|
140
|
-
if (!statSync(file, { throwIfNoEntry: false })?.isFile()) return undefined;
|
|
141
|
-
const parsed = JSON.parse(readFileSync(file, "utf8")) as Partial<HostRecord>;
|
|
142
|
-
// Eine PID unter 1 wäre für process.kill die ganze Prozessgruppe; sie darf nie aus dieser Datei kommen.
|
|
143
|
-
if (typeof parsed.url !== "string" || !Number.isInteger(parsed.pid) || (parsed.pid ?? 0) < 1) throw new Error(`${file} nennt keine Adresse oder keine PID des Hosts`);
|
|
144
|
-
return parsed as HostRecord;
|
|
145
|
-
};
|
|
146
|
-
|
|
147
|
-
export const writeHostRecord = (dataDirectory: string, record: HostRecord): void => {
|
|
148
|
-
mkdirSync(dataDirectory, { recursive: true });
|
|
149
|
-
writeFileSync(hostRecordFile(dataDirectory), `${JSON.stringify(record, null, 2)}\n`);
|
|
150
|
-
};
|
|
151
|
-
|
|
152
|
-
export const loadProfile = (profile: string, root = hostRoot()): Promise<ProfileTarget> =>
|
|
153
|
-
readProfileTarget(profile, path.join(root, profileFileName(profile)));
|
|
154
|
-
|
|
155
|
-
/** pnpm und der bin-Befehl führen dieses Skript mit dem Arbeitsverzeichnis apps/server aus; ein relativer Ordner gilt trotzdem ab dem Aufrufer. */
|
|
156
|
-
export const callerDirectory = (): string => process.env.RAGENTS_CWD ?? process.env.INIT_CWD ?? process.cwd();
|
|
135
|
+
export const loadProfile = (selection: string, root = hostRoot()): Promise<ProfileTarget> => selectProfileTarget(selection, root);
|
|
157
136
|
|
|
158
137
|
const delay = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
|
159
138
|
|
|
@@ -239,6 +218,34 @@ const client = (baseUrl: string): RpcClient => {
|
|
|
239
218
|
});
|
|
240
219
|
};
|
|
241
220
|
|
|
221
|
+
export const LOGIN_REQUIRED = "Das Profil verlangt eine Anmeldung; setze RAGENTS_TOKEN auf den persönlichen Token deines Benutzers "
|
|
222
|
+
+ "(im Profil `token: env(...)`).";
|
|
223
|
+
|
|
224
|
+
/** Ein 401 ist keine Fehlermeldung des Servers wert, sondern der Hinweis auf den persönlichen Token des Profils. */
|
|
225
|
+
export const withLoginHint = async <T>(call: () => Promise<T>): Promise<T> => {
|
|
226
|
+
try {
|
|
227
|
+
return await call();
|
|
228
|
+
} catch (error) {
|
|
229
|
+
if (error instanceof RpcError && error.status === 401) throw new Error(`${LOGIN_REQUIRED} ${error.message}`);
|
|
230
|
+
throw error;
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
/** Ein Einstieg richtet den Run erst ein und bestimmt dabei seinen Chatpartner; bis dahin lehnt der Server die Nachricht ab. */
|
|
235
|
+
const sendWhenChatReady = async (rpc: RpcClient, runId: string, text: string): Promise<void> => {
|
|
236
|
+
const deadline = Date.now() + CHAT_READY_TIMEOUT_MS;
|
|
237
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
238
|
+
try {
|
|
239
|
+
await rpc.call(coreContracts.chat.send, { runId, text });
|
|
240
|
+
return;
|
|
241
|
+
} catch (error) {
|
|
242
|
+
if (!(error instanceof RpcError) || error.domainCode !== "actor-chat-unsupported" || Date.now() >= deadline) throw error;
|
|
243
|
+
if (attempt === 0) note("== Der Einstieg richtet den Run ein; der Auftrag wartet auf seinen Chatpartner.");
|
|
244
|
+
await delay(POLL_INTERVAL_MS);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
|
|
242
249
|
export type TurnOutcome = "completed" | "interrupted" | "failed";
|
|
243
250
|
|
|
244
251
|
export const EXIT_CODES: Readonly<Record<TurnOutcome, number>> = { completed: 0, interrupted: 2, failed: 1 };
|
|
@@ -351,11 +358,18 @@ const runCommand = async (command: Extract<AgentCommand, { kind: "run" }>, write
|
|
|
351
358
|
const baseUrl = await ensureHost(target);
|
|
352
359
|
const rpc = client(baseUrl);
|
|
353
360
|
const runId = randomUUID();
|
|
354
|
-
await rpc.call(coreContracts.startOptions.
|
|
355
|
-
|
|
361
|
+
const options = await withLoginHint(() => rpc.call(coreContracts.startOptions.list, { runId }));
|
|
362
|
+
if (options.some((option) => option.id === WORKSPACE_BINDING_OPTION_ID)) {
|
|
363
|
+
await withLoginHint(() => rpc.call(coreContracts.startOptions.select, { runId, optionId: WORKSPACE_BINDING_OPTION_ID, value: { kind: "path", path: folder } }));
|
|
364
|
+
note(`== Run ${runId} auf ${folder} (${baseUrl})`);
|
|
365
|
+
} else {
|
|
366
|
+
note(`== Run ${runId} (${baseUrl}); das Profil ${target.profile} kennt ${WORKSPACE_BINDING_OPTION_ID} nicht und legt seinen Arbeitsbereich selbst an, ${folder} bleibt ungebunden.`);
|
|
367
|
+
}
|
|
356
368
|
const reader = new JournalReader(target.dataDirectory, runId);
|
|
357
|
-
if (command.entry) await rpc.call(coreContracts.chat.start, { runId, entry: command.entry });
|
|
358
|
-
await
|
|
369
|
+
if (command.entry) await withLoginHint(() => rpc.call(coreContracts.chat.start, { runId, entry: command.entry }));
|
|
370
|
+
await withLoginHint(() => command.entry
|
|
371
|
+
? sendWhenChatReady(rpc, runId, command.text)
|
|
372
|
+
: rpc.call(coreContracts.chat.send, { runId, text: command.text }).then(() => undefined));
|
|
359
373
|
const outcome = await follow({ reader, baseUrl, text: command.text.trim(), json: command.json, write });
|
|
360
374
|
write(`run: ${runId}`);
|
|
361
375
|
return EXIT_CODES[outcome];
|
|
@@ -368,7 +382,7 @@ const sendCommand = async (command: Extract<AgentCommand, { kind: "send" }>, wri
|
|
|
368
382
|
const rpc = client(baseUrl);
|
|
369
383
|
const reader = new JournalReader(target.dataDirectory, command.runId);
|
|
370
384
|
reader.next();
|
|
371
|
-
await rpc.call(coreContracts.chat.send, { runId: command.runId, text: command.text });
|
|
385
|
+
await withLoginHint(() => rpc.call(coreContracts.chat.send, { runId: command.runId, text: command.text }));
|
|
372
386
|
const outcome = await follow({ reader, baseUrl, text: command.text.trim(), json: command.json, write });
|
|
373
387
|
write(`run: ${command.runId}`);
|
|
374
388
|
return EXIT_CODES[outcome];
|
|
@@ -385,7 +399,7 @@ const journalCommand = async (command: Extract<AgentCommand, { kind: "journal" }
|
|
|
385
399
|
const stopCommand = async (command: Extract<AgentCommand, { kind: "stop" }>): Promise<number> => {
|
|
386
400
|
const target = await loadProfile(command.profile);
|
|
387
401
|
const baseUrl = await addressOf(target);
|
|
388
|
-
await client(baseUrl).call(coreContracts.chat.stop, { runId: command.runId });
|
|
402
|
+
await withLoginHint(() => client(baseUrl).call(coreContracts.chat.stop, { runId: command.runId }));
|
|
389
403
|
note(`== Turn von ${command.runId} abgebrochen`);
|
|
390
404
|
return 0;
|
|
391
405
|
};
|
|
@@ -404,7 +418,7 @@ const stopHostCommand = async (command: Extract<AgentCommand, { kind: "stop-host
|
|
|
404
418
|
const noted = readHostRecord(target.dataDirectory);
|
|
405
419
|
if (!noted) throw new Error(`Für das Profil ${command.profile} ist kein Host gemerkt (${hostRecordFile(target.dataDirectory)} fehlt).`);
|
|
406
420
|
if (!alive(noted.pid)) {
|
|
407
|
-
|
|
421
|
+
removeHostRecord(target.dataDirectory);
|
|
408
422
|
note(`== Der gemerkte Host (PID ${noted.pid}) läuft nicht mehr; der Vermerk ist weg.`);
|
|
409
423
|
return 0;
|
|
410
424
|
}
|
|
@@ -415,7 +429,7 @@ const stopHostCommand = async (command: Extract<AgentCommand, { kind: "stop-host
|
|
|
415
429
|
process.kill(noted.pid, "SIGKILL");
|
|
416
430
|
await delay(500);
|
|
417
431
|
}
|
|
418
|
-
|
|
432
|
+
removeHostRecord(target.dataDirectory);
|
|
419
433
|
note(`== Host ${noted.url} (PID ${noted.pid}) beendet`);
|
|
420
434
|
return 0;
|
|
421
435
|
};
|
|
@@ -430,10 +444,14 @@ export const execute = async (command: AgentCommand, write: LineWriter = toStdou
|
|
|
430
444
|
|
|
431
445
|
const main = async (): Promise<number> => {
|
|
432
446
|
const argv = process.argv.slice(2);
|
|
433
|
-
if (argv
|
|
447
|
+
if (argv[0] === "--help" || argv[0] === "help") {
|
|
434
448
|
console.log(usage());
|
|
435
449
|
return 0;
|
|
436
450
|
}
|
|
451
|
+
if (argv.length === 0) {
|
|
452
|
+
console.error(usage());
|
|
453
|
+
return 1;
|
|
454
|
+
}
|
|
437
455
|
return execute(parseArguments(argv));
|
|
438
456
|
};
|
|
439
457
|
|
|
@@ -29,11 +29,21 @@ const usage = `Verwendung: ragents <befehl> [argumente]
|
|
|
29
29
|
geholten Stand starten; baut die Oberfläche bei Bedarf
|
|
30
30
|
provision [<profil>|--workspace] Werkzeuge der Plugins holen
|
|
31
31
|
workspace-client <server-url> diesen Rechner als Arbeitsplatz anmelden
|
|
32
|
+
--help, help diese Verwendung
|
|
33
|
+
|
|
34
|
+
run, send, journal und stop nehmen mit --profile <profil|pfad> ein anderes Profil: einen Namen
|
|
35
|
+
neben dem Host oder den Pfad zu einer eigenen ragents.config.<profil>.ts. RAGENTS_PROFILE setzt
|
|
36
|
+
dasselbe für alle Befehle einer Shell; verlangt das Profil eine Anmeldung, gehört der
|
|
37
|
+
persönliche Token des Benutzers in RAGENTS_TOKEN.
|
|
32
38
|
`;
|
|
33
39
|
|
|
34
40
|
const main = () => {
|
|
35
41
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
36
42
|
const [command, ...rest] = process.argv.slice(2);
|
|
43
|
+
if (command === "--help" || command === "help") {
|
|
44
|
+
console.log(usage);
|
|
45
|
+
process.exit(0);
|
|
46
|
+
}
|
|
37
47
|
const script = command ? commands[command] : undefined;
|
|
38
48
|
if (!script) {
|
|
39
49
|
console.error(command ? `Unbekannter Befehl: ${command}\n\n${usage}` : usage);
|
|
@@ -7,6 +7,7 @@ import path from "node:path";
|
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
8
|
import { extract as extractTar } from "tar";
|
|
9
9
|
import { ragentsDataRoot } from "../../apps/server/src/data-directory.ts";
|
|
10
|
+
import { removeHostRecord, writeHostRecord } from "../../apps/server/src/host-record.ts";
|
|
10
11
|
import { hostRoot, readHostPackage, readHostVersion } from "../../apps/server/src/host-version.ts";
|
|
11
12
|
import { readProfileTarget } from "../../apps/server/src/profile-target.ts";
|
|
12
13
|
import { ensureWebBuild } from "../../apps/server/src/web-build.ts";
|
|
@@ -187,8 +188,13 @@ export const hostScript = (script: string, args: readonly string[] = []): { file
|
|
|
187
188
|
cwd: path.join(hostRoot(), "apps/server"),
|
|
188
189
|
});
|
|
189
190
|
|
|
190
|
-
const run = (
|
|
191
|
+
const run = (
|
|
192
|
+
command: { file: string; args: readonly string[]; cwd: string },
|
|
193
|
+
env: NodeJS.ProcessEnv,
|
|
194
|
+
started?: (pid: number) => void,
|
|
195
|
+
): Promise<number> => new Promise((resolve, reject) => {
|
|
191
196
|
const child = spawn(command.file, [...command.args], { cwd: command.cwd, env, stdio: "inherit" });
|
|
197
|
+
if (child.pid) started?.(child.pid);
|
|
192
198
|
const forward = (signal: NodeJS.Signals) => () => child.kill(signal);
|
|
193
199
|
const onInterrupt = forward("SIGINT");
|
|
194
200
|
const onTerminate = forward("SIGTERM");
|
|
@@ -233,9 +239,21 @@ export interface StartedProfile {
|
|
|
233
239
|
readonly port: number | undefined;
|
|
234
240
|
}
|
|
235
241
|
|
|
242
|
+
/** ragents run und ragents start merken denselben Host im Datenordner des Profils; ein Port 0 sucht sich seinen Port selbst und lässt sich nicht merken. */
|
|
243
|
+
export const noteHost = (profile: string, dataDirectory: string, port: number): ((pid: number) => void) | undefined =>
|
|
244
|
+
port > 0 ? (pid: number): void => writeHostRecord(dataDirectory, {
|
|
245
|
+
profile,
|
|
246
|
+
url: `http://localhost:${port}`,
|
|
247
|
+
pid,
|
|
248
|
+
log: path.join(dataDirectory, "logs", "server.log"),
|
|
249
|
+
startedAt: new Date().toISOString(),
|
|
250
|
+
}) : undefined;
|
|
251
|
+
|
|
236
252
|
/** Provisioniert die Werkzeuge des Profils, baut sein Web falls nötig und startet den Server damit. */
|
|
237
253
|
export const startProfile = async (started: StartedProfile): Promise<number> => {
|
|
238
|
-
const
|
|
254
|
+
const target = await readProfileTarget(started.profile, started.profileFile);
|
|
255
|
+
const dataDirectory = started.dataDirectory ?? target.dataDirectory;
|
|
256
|
+
const port = started.port ?? target.port;
|
|
239
257
|
const env: NodeJS.ProcessEnv = {
|
|
240
258
|
...process.env,
|
|
241
259
|
PRODUCT_PROFILE: started.profile,
|
|
@@ -254,7 +272,12 @@ export const startProfile = async (started: StartedProfile): Promise<number> =>
|
|
|
254
272
|
log: (line) => console.log(line),
|
|
255
273
|
});
|
|
256
274
|
console.log(`== Server starten (Web ${webDirectory}, Daten ${dataDirectory})`);
|
|
257
|
-
|
|
275
|
+
const note = noteHost(started.profile, dataDirectory, port);
|
|
276
|
+
try {
|
|
277
|
+
return await run(hostScript("apps/server/src/main.ts", started.port !== undefined ? ["--port", String(started.port)] : []), { ...env, WEB_DIST_DIR: webDirectory }, note);
|
|
278
|
+
} finally {
|
|
279
|
+
if (note) removeHostRecord(dataDirectory);
|
|
280
|
+
}
|
|
258
281
|
};
|
|
259
282
|
|
|
260
283
|
/** Startet einen Stand, den connect geholt hat, mit dem Web aus dem Cache. */
|
package/scripts/remote/start.ts
CHANGED
|
@@ -1,13 +1,10 @@
|
|
|
1
1
|
// Der Hook löst @aicontainer/* auch für eine Profildatei außerhalb des Hosts auf; im Paket gibt es dort kein node_modules.
|
|
2
2
|
import "../../apps/server/src/host-resolution.ts";
|
|
3
|
-
import { statSync } from "node:fs";
|
|
4
3
|
import { readFile, readdir } from "node:fs/promises";
|
|
5
4
|
import path from "node:path";
|
|
6
5
|
import { fileURLToPath } from "node:url";
|
|
7
|
-
import { profileFileName } from "../../apps/server/src/config-file.ts";
|
|
8
6
|
import { ragentsDataRoot } from "../../apps/server/src/data-directory.ts";
|
|
9
|
-
import {
|
|
10
|
-
import { callerDirectory } from "../agent/agent-cli.ts";
|
|
7
|
+
import { localProfile } from "../../apps/server/src/profile-target.ts";
|
|
11
8
|
import { startCached, startProfile, type CachedProfile } from "./connect.ts";
|
|
12
9
|
|
|
13
10
|
const usage = (): string => `Verwendung: ragents start <profil|pfad> [--port <n>]
|
|
@@ -64,26 +61,6 @@ export const selectCachedProfile = (cached: readonly CachedProfile[], profile: s
|
|
|
64
61
|
+ "nimm ragents connect <server-url>.");
|
|
65
62
|
};
|
|
66
63
|
|
|
67
|
-
const PROFILE_FILE = /^ragents\.config\.([a-z0-9]+(?:-[a-z0-9]+)*)\.ts$/;
|
|
68
|
-
|
|
69
|
-
export interface LocalProfile {
|
|
70
|
-
readonly profile: string;
|
|
71
|
-
readonly profileFile: string;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** Ein Profil ist ein Name neben dem Host (Checkout wie Paket) oder ein Pfad zu einer ragents.config.<profil>.ts an beliebiger Stelle. */
|
|
75
|
-
export const localProfile = (selection: string, root = hostRoot(), caller = callerDirectory()): LocalProfile | undefined => {
|
|
76
|
-
if (!selection.includes("/") && !selection.endsWith(".ts")) {
|
|
77
|
-
const file = path.join(root, profileFileName(selection));
|
|
78
|
-
return statSync(file, { throwIfNoEntry: false })?.isFile() ? { profile: selection, profileFile: file } : undefined;
|
|
79
|
-
}
|
|
80
|
-
const profileFile = path.resolve(caller, selection);
|
|
81
|
-
const match = PROFILE_FILE.exec(path.basename(profileFile));
|
|
82
|
-
if (!match) throw new Error(`Eine Profildatei heißt ragents.config.<profil>.ts, nicht ${path.basename(profileFile)}`);
|
|
83
|
-
if (!statSync(profileFile, { throwIfNoEntry: false })?.isFile()) throw new Error(`Die Profildatei fehlt: ${profileFile}`);
|
|
84
|
-
return { profile: match[1]!, profileFile };
|
|
85
|
-
};
|
|
86
|
-
|
|
87
64
|
const main = async (): Promise<void> => {
|
|
88
65
|
const arguments_ = parseArguments(process.argv.slice(2));
|
|
89
66
|
const own = localProfile(arguments_.selection);
|