spawnfile 0.1.34 → 0.1.35
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/dist/audit/readAudit.d.ts +3 -3
- package/dist/auth/profileStore.d.ts +3 -1
- package/dist/auth/profileStore.js +31 -30
- package/dist/auth/types.d.ts +16 -0
- package/dist/cli/authCommands.d.ts +1 -1
- package/dist/cli/authCommands.js +21 -1
- package/dist/cli/runCli.d.ts +2 -0
- package/dist/cli/runCli.js +2 -1
- package/dist/cli/trainingBooted.d.ts +2 -0
- package/dist/cli/trainingBooted.js +5 -1
- package/dist/compiler/containerDaimonBrokerRender.js +12 -2
- package/dist/compiler/containerDaimonGatewayRender.js +4 -0
- package/dist/compiler/containerDaimonUidEntrypointRender.js +3 -1
- package/dist/compiler/runProject.d.ts +2 -0
- package/dist/compiler/runProject.js +6 -5
- package/dist/compiler/runProjectAuth.js +4 -1
- package/dist/compiler/training/rewind/contract.d.ts +6 -6
- package/dist/compiler/training/trial/boot.d.ts +20 -1
- package/dist/compiler/training/trial/boot.js +102 -24
- package/dist/compiler/training/trial/docker.d.ts +2 -0
- package/dist/compiler/training/trial/docker.js +1 -1
- package/dist/compiler/training/trial/gateway.d.ts +5 -0
- package/dist/compiler/training/trial/gateway.js +18 -3
- package/dist/compiler/training/trial/homeLogin.d.ts +31 -0
- package/dist/compiler/training/trial/homeLogin.js +69 -0
- package/dist/compiler/training/trial/index.d.ts +5 -1
- package/dist/compiler/training/trial/index.js +5 -1
- package/dist/compiler/training/trial/judge.d.ts +15 -0
- package/dist/compiler/training/trial/judge.js +42 -0
- package/dist/compiler/training/trial/launcher.d.ts +28 -13
- package/dist/compiler/training/trial/launcher.js +168 -24
- package/dist/compiler/training/trial/login.d.ts +35 -0
- package/dist/compiler/training/trial/login.js +85 -0
- package/dist/compiler/training/trial/realmTurns.d.ts +20 -0
- package/dist/compiler/training/trial/realmTurns.js +69 -0
- package/dist/manifest/renderSpawnfile.js +2 -1
- package/dist/runtime/daimon/config.js +3 -3
- package/dist/runtime/daimon/engineDescriptors.generated.d.ts +1 -1
- package/dist/runtime/daimon/engineDescriptors.generated.js +1 -1
- package/dist/runtime/daimon/index.d.ts +1 -1
- package/dist/runtime/daimon/index.js +1 -1
- package/dist/runtime/daimon/runAuth.js +8 -0
- package/dist/runtime/daimon/subscriptionRealms.d.ts +18 -0
- package/dist/runtime/daimon/subscriptionRealms.js +14 -1
- package/dist/runtime/daimon/training/booted/adapter.d.ts +5 -1
- package/dist/runtime/daimon/training/booted/adapter.js +3 -2
- package/dist/runtime/daimon/training/booted/adapter.js.map +1 -1
- package/dist/runtime/daimon/training/booted/index.d.ts +3 -1
- package/dist/runtime/daimon/training/booted/index.js +2 -1
- package/dist/runtime/daimon/training/booted/index.js.map +1 -1
- package/dist/runtime/daimon/training/booted/judge.d.ts +39 -0
- package/dist/runtime/daimon/training/booted/judge.js +122 -0
- package/dist/runtime/daimon/training/booted/judge.js.map +1 -0
- package/dist/runtime/trainingGraderProviders.js +10 -4
- package/dist/runtime/trainingProviders.d.ts +4 -1
- package/dist/runtime/trainingProviders.js +13 -6
- package/dist/runtime/usageRequestLedger.d.ts +2 -0
- package/dist/runtime/usageRequestLedger.js +3 -1
- package/dist/shared/trainingLegacy/trainingLegacyHashDomains.d.ts +1 -0
- package/dist/shared/trainingLegacy/trainingLegacyHashDomains.js +1 -0
- package/dist/shared/trainingLegacy/trainingLegacyHashDomains.js.map +1 -1
- package/package.json +1 -1
- package/runtimes.yaml +5 -5
- package/dist/runtime/daimon/training/providers/codex-subscription/index.d.ts +0 -1
- package/dist/runtime/daimon/training/providers/codex-subscription/index.js +0 -2
- package/dist/runtime/daimon/training/providers/codex-subscription/index.js.map +0 -1
- package/dist/runtime/daimon/training/providers/codex-subscription/protocol.d.ts +0 -17
- package/dist/runtime/daimon/training/providers/codex-subscription/protocol.js +0 -80
- package/dist/runtime/daimon/training/providers/codex-subscription/protocol.js.map +0 -1
- package/dist/runtime/daimon/training/providers/codex-subscription/provider.d.ts +0 -12
- package/dist/runtime/daimon/training/providers/codex-subscription/provider.js +0 -125
- package/dist/runtime/daimon/training/providers/codex-subscription/provider.js.map +0 -1
- package/dist/runtime/daimon/training/providers/codex-subscription/strict-schema.d.ts +0 -6
- package/dist/runtime/daimon/training/providers/codex-subscription/strict-schema.js +0 -219
- package/dist/runtime/daimon/training/providers/codex-subscription/strict-schema.js.map +0 -1
- package/dist/runtime/daimon/training/providers/grok-subscription/cli.d.ts +0 -19
- package/dist/runtime/daimon/training/providers/grok-subscription/cli.js +0 -81
- package/dist/runtime/daimon/training/providers/grok-subscription/cli.js.map +0 -1
- package/dist/runtime/daimon/training/providers/grok-subscription/index.d.ts +0 -3
- package/dist/runtime/daimon/training/providers/grok-subscription/index.js +0 -4
- package/dist/runtime/daimon/training/providers/grok-subscription/index.js.map +0 -1
- package/dist/runtime/daimon/training/providers/grok-subscription/protocol.d.ts +0 -14
- package/dist/runtime/daimon/training/providers/grok-subscription/protocol.js +0 -89
- package/dist/runtime/daimon/training/providers/grok-subscription/protocol.js.map +0 -1
- package/dist/runtime/daimon/training/providers/grok-subscription/provider.d.ts +0 -9
- package/dist/runtime/daimon/training/providers/grok-subscription/provider.js +0 -37
- package/dist/runtime/daimon/training/providers/grok-subscription/provider.js.map +0 -1
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
2
|
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import { DAIMON_GATEWAY_KEY_ENV, DAIMON_GATEWAY_URL_ENV, getDaimonEngineDescriptor, isDaimonGatewayRealmMountId } from "../../../runtime/daimon/index.js";
|
|
4
|
+
import { DAIMON_GATEWAY_KEY_ENV, DAIMON_GATEWAY_URL_ENV, daimonProfileRealmVolume, getDaimonEngineDescriptor, isDaimonGatewayRealmMountId } from "../../../runtime/daimon/index.js";
|
|
5
5
|
import { daimonHelperControlCall } from "../../../runtime/daimon/control.js";
|
|
6
6
|
import { RUNTIME_INSTALL_ROOT } from "../../../runtime/container.js";
|
|
7
7
|
import { parseUsageLedger } from "../../../runtime/usageLedger.js";
|
|
8
|
+
import { parseUsageRequestLedger } from "../../../runtime/usageRequestLedger.js";
|
|
8
9
|
import { DAIMON_GROK_TURN_USAGE_LEDGER } from "../../../runtime/daimon/contractManifest.js";
|
|
9
10
|
import { SpawnfileError } from "../../../shared/index.js";
|
|
10
11
|
import { buildCompiledProject } from "../../buildProject.js";
|
|
@@ -14,6 +15,7 @@ import { slugify } from "../../helpers.js";
|
|
|
14
15
|
import { createDockerRunInvocation, runDockerContainer } from "../../runProject.js";
|
|
15
16
|
import { executeDockerRunWithSupportCleanup } from "../../runProjectLifecycle.js";
|
|
16
17
|
import { createTrialDocker, waitFor } from "./docker.js";
|
|
18
|
+
import { HOME_LOGIN_FILES, saveHomeLogin, seedHomeLogin } from "./homeLogin.js";
|
|
17
19
|
import { writeTrialProject } from "./project.js";
|
|
18
20
|
/**
|
|
19
21
|
* Lineage namespace for training trials: every trial derives its own durable
|
|
@@ -25,27 +27,61 @@ const CONTROL_TOKEN_ENV = "SPAWNFILE_DAIMON_CONTROL_TOKEN";
|
|
|
25
27
|
const WAKE_VERSION = "noopolis.daimon.wake.v2";
|
|
26
28
|
const TERMINAL = new Set(["completed", "failed", "stopped"]);
|
|
27
29
|
const MAX_TRANSCRIPT_BYTES = 1024 * 1024;
|
|
30
|
+
/**
|
|
31
|
+
* Readable and collision-free: trial ids may share long prefixes. The cut can
|
|
32
|
+
* land just after a hyphen, so it is trimmed: the slug names a kebab-case
|
|
33
|
+
* deployment, which refuses a doubled hyphen.
|
|
34
|
+
*/
|
|
35
|
+
export const trialSlug = (trialId) => `${slugify(trialId).slice(0, 32).replace(/-+$/u, "") || "trial"}-${createHash("sha256").update(trialId).digest("hex").slice(0, 8)}`;
|
|
28
36
|
/** Compiles and builds the trial's one-agent organization under its own lineage, as production does. */
|
|
29
37
|
export const prepareTrial = async (options) => {
|
|
30
38
|
const docker = options.docker ?? createTrialDocker();
|
|
31
|
-
|
|
32
|
-
const slug = `${slugify(options.trialId).slice(0, 32) || "trial"}-${createHash("sha256").update(options.trialId).digest("hex").slice(0, 8)}`;
|
|
33
|
-
const project = await writeTrialProject(options.agentManifestPath, options.instructions, path.join(options.directory, "project"));
|
|
34
|
-
const compiled = await compileProject(project, {
|
|
35
|
-
deploymentLineage: resolveDeploymentLineage(slug, TRAINING_TRIAL_LINEAGE_NAMESPACE),
|
|
36
|
-
outputDirectory: path.join(options.directory, "out")
|
|
37
|
-
});
|
|
39
|
+
const slug = trialSlug(options.trialId);
|
|
38
40
|
const image = `spawnfile-training-trial:${slug}`;
|
|
39
|
-
|
|
41
|
+
const compiled = await (async () => {
|
|
42
|
+
const project = await writeTrialProject(options.agentManifestPath, options.instructions, path.join(options.directory, "project"));
|
|
43
|
+
const compiled = await compileProject(project, {
|
|
44
|
+
deploymentLineage: resolveDeploymentLineage(slug, TRAINING_TRIAL_LINEAGE_NAMESPACE),
|
|
45
|
+
outputDirectory: path.join(options.directory, "out")
|
|
46
|
+
});
|
|
47
|
+
await buildCompiledProject(project, compiled, { imageTag: image });
|
|
48
|
+
return compiled;
|
|
49
|
+
})().catch(async (error) => {
|
|
50
|
+
await rm(options.directory, { recursive: true, force: true });
|
|
51
|
+
throw error;
|
|
52
|
+
});
|
|
40
53
|
const instance = compiled.report.container?.runtime_instances.find((entry) => entry.runtime === "daimon");
|
|
41
54
|
const agents = Object.entries(instance?.engine_by_node_id ?? {});
|
|
42
55
|
if (!instance || agents.length !== 1)
|
|
43
56
|
throw new SpawnfileError("validation_error", "A production-booted trial compiles exactly one Daimon agent");
|
|
44
|
-
const [agentId] = agents[0];
|
|
57
|
+
const [agentId, engine] = agents[0];
|
|
45
58
|
const home = instance.agent_paths?.[agentId]?.home_path;
|
|
46
59
|
// The engine writes its own session records under its home inside the agent's runtime home.
|
|
47
|
-
const engineHome = getDaimonEngineDescriptor(
|
|
60
|
+
const engineHome = getDaimonEngineDescriptor(engine)?.homeDirectory;
|
|
48
61
|
const sessionsPath = home && engineHome !== undefined ? path.posix.join(home, engineHome, "sessions") : null;
|
|
62
|
+
const lineageVolumes = (realms) => (compiled.report.container?.persistent_mounts ?? [])
|
|
63
|
+
.filter((mount) => !isDaimonGatewayRealmMountId(mount.id) && daimonProfileRealmVolume(mount.id, realms) === undefined)
|
|
64
|
+
.map((mount) => mount.volume_name);
|
|
65
|
+
/**
|
|
66
|
+
* The profile's home login (`spawnfile auth login`) for this organization's
|
|
67
|
+
* fresh runtime home: seeded into it before the container starts, written
|
|
68
|
+
* back after it stops. Only the engine's login files cross; the home itself
|
|
69
|
+
* is the trial's own and is removed with it.
|
|
70
|
+
*/
|
|
71
|
+
const homeLoginFor = (realms) => {
|
|
72
|
+
const files = HOME_LOGIN_FILES[engine];
|
|
73
|
+
const loginVolume = realms?.realms?.[engine]?.homeVolume;
|
|
74
|
+
if (!files || !loginVolume)
|
|
75
|
+
return undefined;
|
|
76
|
+
const mount = (compiled.report.container?.persistent_mounts ?? []).find((entry) => entry.mount_path === home);
|
|
77
|
+
if (!mount)
|
|
78
|
+
throw new SpawnfileError("validation_error", `The ${engine} runtime home of ${agentId} is not a persistent mount; its login cannot be seeded`);
|
|
79
|
+
return { files, homeMountPath: mount.mount_path, homeVolume: mount.volume_name, loginVolume };
|
|
80
|
+
};
|
|
81
|
+
const removeVolumes = async (volumes) => {
|
|
82
|
+
for (const volume of volumes)
|
|
83
|
+
await docker(["volume", "rm", "-f", volume]).catch(() => undefined);
|
|
84
|
+
};
|
|
49
85
|
const dispose = async () => {
|
|
50
86
|
await docker(["image", "rm", "-f", image]).catch(() => undefined);
|
|
51
87
|
await rm(options.directory, { recursive: true, force: true });
|
|
@@ -54,33 +90,67 @@ export const prepareTrial = async (options) => {
|
|
|
54
90
|
agentId, dispose, image,
|
|
55
91
|
engines: agents.map(([, engine]) => engine),
|
|
56
92
|
runtimeRoot: `${RUNTIME_INSTALL_ROOT}/daimon`,
|
|
93
|
+
login: async (realms, loginOptions = {}) => {
|
|
94
|
+
const homeLogin = homeLoginFor(realms);
|
|
95
|
+
const invocation = await createDockerRunInvocation(compiled, image, {
|
|
96
|
+
authProfile: realms, command: ["auth", engine, "login", "--config", instance.config_path],
|
|
97
|
+
containerName: `spawnfile-login-${slug}`, interactive: true
|
|
98
|
+
});
|
|
99
|
+
if (loginOptions.dryRun) {
|
|
100
|
+
await rm(invocation.supportDirectory, { recursive: true, force: true });
|
|
101
|
+
return [invocation.command, ...invocation.args];
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
await executeDockerRunWithSupportCleanup(invocation, runDockerContainer);
|
|
105
|
+
if (homeLogin)
|
|
106
|
+
await saveHomeLogin(docker, image, homeLogin);
|
|
107
|
+
}
|
|
108
|
+
finally {
|
|
109
|
+
await removeVolumes(lineageVolumes(realms));
|
|
110
|
+
}
|
|
111
|
+
return [invocation.command, ...invocation.args];
|
|
112
|
+
},
|
|
57
113
|
boot: async (gateway, bootOptions = {}) => {
|
|
58
114
|
const token = randomBytes(32).toString("base64url");
|
|
59
115
|
const container = `spawnfile-trial-${slug}`;
|
|
60
116
|
await mkdir(options.directory, { recursive: true });
|
|
61
117
|
const envFilePath = path.join(options.directory, "boot.env");
|
|
62
|
-
// The boot inputs: control token, gateway URL and key. No login, no
|
|
63
|
-
|
|
118
|
+
// The boot inputs: control token, plus the gateway URL and key. No gatewayed login, no
|
|
119
|
+
// gatewayed realm; only a volume-held login of the selected profile is mounted.
|
|
120
|
+
const gatewayEnv = gateway ? `${DAIMON_GATEWAY_URL_ENV}=${gateway.url}\n${DAIMON_GATEWAY_KEY_ENV}=${gateway.key}\n` : "";
|
|
121
|
+
await writeFile(envFilePath, `${CONTROL_TOKEN_ENV}=${token}\n${gatewayEnv}`, { mode: 0o600 });
|
|
64
122
|
const invocation = await createDockerRunInvocation(compiled, image, {
|
|
65
|
-
containerName: container, deploymentName: slug, detach: true, envFilePath,
|
|
123
|
+
authProfile: bootOptions.realms ?? null, containerName: container, deploymentName: slug, detach: true, envFilePath,
|
|
124
|
+
...(gateway ? { network: gateway.network } : {})
|
|
66
125
|
});
|
|
67
|
-
const volumes = (
|
|
68
|
-
|
|
126
|
+
const volumes = lineageVolumes(bootOptions.realms);
|
|
127
|
+
const homeLogin = homeLoginFor(bootOptions.realms);
|
|
69
128
|
const teardown = async () => {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
129
|
+
try {
|
|
130
|
+
// Stopped, not killed, so the engine finishes any login rewrite before it is saved.
|
|
131
|
+
if (homeLogin)
|
|
132
|
+
await docker(["stop", "--time", "10", container]).catch(() => undefined);
|
|
133
|
+
await docker(["rm", "-f", container]).catch(() => undefined);
|
|
134
|
+
if (homeLogin)
|
|
135
|
+
await saveHomeLogin(docker, image, homeLogin);
|
|
136
|
+
}
|
|
137
|
+
finally {
|
|
138
|
+
await removeVolumes(volumes);
|
|
139
|
+
await rm(invocation.supportDirectory, { recursive: true, force: true });
|
|
140
|
+
await rm(envFilePath, { force: true });
|
|
141
|
+
}
|
|
75
142
|
};
|
|
76
143
|
try {
|
|
144
|
+
if (homeLogin)
|
|
145
|
+
await seedHomeLogin(docker, image, homeLogin);
|
|
77
146
|
await executeDockerRunWithSupportCleanup(invocation, runDockerContainer);
|
|
78
147
|
await rm(envFilePath, { force: true });
|
|
79
148
|
const target = { containerRef: container, dockerArgs: [], dockerCommand: "docker", imageRef: image, token };
|
|
80
149
|
const call = options.control ?? daimonHelperControlCall;
|
|
81
150
|
await waitFor(async () => {
|
|
82
|
-
|
|
83
|
-
|
|
151
|
+
// A restart means the organization already exited once (`--restart unless-stopped` hides it).
|
|
152
|
+
if (await docker(["inspect", "--format", "{{.State.Running}} {{.RestartCount}}", container]) !== "true 0") {
|
|
153
|
+
const logs = await docker(["logs", "--tail", "30", container], { combined: true }).catch(() => "");
|
|
84
154
|
throw new SpawnfileError("runtime_error", `trial organization exited before readiness: ${logs.replace(/\s+/gu, " ").slice(-600)}`);
|
|
85
155
|
}
|
|
86
156
|
return call(target, "GET", "/v2/availability").then((response) => response.status === 200, () => false);
|
|
@@ -88,12 +158,19 @@ export const prepareTrial = async (options) => {
|
|
|
88
158
|
return createBootedTrial({ agentId, call, container, docker, sessionsPath, target, teardown });
|
|
89
159
|
}
|
|
90
160
|
catch (error) {
|
|
91
|
-
|
|
161
|
+
// The boot failure is the error to report, not a teardown that follows it.
|
|
162
|
+
await teardown().catch(() => undefined);
|
|
92
163
|
throw error;
|
|
93
164
|
}
|
|
94
165
|
}
|
|
95
166
|
};
|
|
96
167
|
};
|
|
168
|
+
/** Daimon's per-request stream sits beside the usage ledger. */
|
|
169
|
+
const REQUEST_LEDGER = path.posix.join(path.posix.dirname(DAIMON_GROK_TURN_USAGE_LEDGER.filePath), "requests.jsonl");
|
|
170
|
+
const readRequestToolCalls = async (docker, container, agentId, deliveryId) => {
|
|
171
|
+
const text = await docker(["exec", container, "cat", REQUEST_LEDGER]).catch(() => "");
|
|
172
|
+
return parseUsageRequestLedger(text).filter((row) => row.agent === agentId && row.wake === deliveryId).map((row) => row.tool_calls ?? null);
|
|
173
|
+
};
|
|
97
174
|
const readLedger = async (docker, container) => {
|
|
98
175
|
const text = await docker(["exec", container, "cat", DAIMON_GROK_TURN_USAGE_LEDGER.filePath]).catch(() => "");
|
|
99
176
|
return parseUsageLedger(text);
|
|
@@ -132,6 +209,7 @@ const createBootedTrial = (input) => {
|
|
|
132
209
|
acceptanceId: id, code: receipt.code ?? null, executionId: receipt.execution_id ?? null, usage,
|
|
133
210
|
state: TERMINAL.has(receipt.state ?? "") ? receipt.state : "timeout",
|
|
134
211
|
text: receipt.text ?? "",
|
|
212
|
+
requestToolCalls: await readRequestToolCalls(input.docker, input.container, input.agentId, options.deliveryId),
|
|
135
213
|
transcript: input.sessionsPath === null ? null : await readTranscript(input.docker, input.container, input.sessionsPath)
|
|
136
214
|
};
|
|
137
215
|
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/** Bounded Docker CLI call for trial lifecycle; stdout trimmed, stderr bounded into the error. */
|
|
2
|
+
/** `combined` returns stderr after stdout (container logs write both). */
|
|
2
3
|
export type TrialDocker = (args: readonly string[], options?: {
|
|
3
4
|
timeoutMs?: number;
|
|
4
5
|
stdin?: string;
|
|
6
|
+
combined?: boolean;
|
|
5
7
|
}) => Promise<string>;
|
|
6
8
|
export declare const createTrialDocker: (dockerCommand?: string) => TrialDocker;
|
|
7
9
|
/** Poll `probe` until it resolves true, or fail after `timeoutMs`. */
|
|
@@ -3,7 +3,7 @@ import { SpawnfileError } from "../../../shared/index.js";
|
|
|
3
3
|
export const createTrialDocker = (dockerCommand = "docker") => (args, options = {}) => new Promise((resolve, reject) => {
|
|
4
4
|
const child = execFile(dockerCommand, [...args], { maxBuffer: 16 * 1024 * 1024, timeout: options.timeoutMs ?? 120_000 }, (error, stdout, stderr) => error
|
|
5
5
|
? reject(new SpawnfileError("runtime_error", `docker ${args[0]} ${args[1] ?? ""} failed: ${String(stderr).replace(/\s+/gu, " ").slice(-400)}`))
|
|
6
|
-
: resolve(
|
|
6
|
+
: resolve(`${stdout}${options.combined ? stderr : ""}`.trim()));
|
|
7
7
|
if (options.stdin !== undefined)
|
|
8
8
|
child.stdin?.end(options.stdin);
|
|
9
9
|
else
|
|
@@ -28,6 +28,11 @@ export interface TrainingGateway extends TrainingGatewayAccess {
|
|
|
28
28
|
/** Replaces a dead or stuck sidecar in place: same key, networks and realm, so booted trials keep working. */
|
|
29
29
|
restart(script?: (script: string) => string): Promise<void>;
|
|
30
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* The gatewayed engines whose login the auth profile holds: the run's gateway
|
|
33
|
+
* serves all of them, so subject and judges may run on any of these engines.
|
|
34
|
+
*/
|
|
35
|
+
export declare const trainingGatewayLogins: (profile: ResolvedAuthProfile) => string[];
|
|
31
36
|
/** One durable realm volume per auth profile and engine: the gateway is its sole holder (exclusive flock). */
|
|
32
37
|
export declare const trainingGatewayRealmVolume: (profile: string, engine: string) => string;
|
|
33
38
|
/**
|
|
@@ -3,7 +3,7 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { DAIMON_GATEWAY_PORT, daimonGatewayBootstrapMountPath, daimonGatewayedEngines, daimonGatewayRealmPath } from "../../../runtime/daimon/gateway.js";
|
|
6
|
-
import { assertSafeDaimonSourceFile
|
|
6
|
+
import { assertSafeDaimonSourceFile } from "../../../runtime/daimon/runAuth.js";
|
|
7
7
|
import { DAIMON_DOCKER_RUNTIME_SECURITY_ARGS, SpawnfileError } from "../../../shared/index.js";
|
|
8
8
|
import { createDockerTargetExecutors } from "../../../target/dockerCommandExecutor.js";
|
|
9
9
|
import { removeExactDockerResource } from "../../../target/dockerResources.js";
|
|
@@ -15,6 +15,21 @@ import { createTrialDocker, waitFor } from "./docker.js";
|
|
|
15
15
|
const hex = (value) => createHash("sha256").update(value).digest("hex");
|
|
16
16
|
const opaque = (value) => parseOpaqueTargetHandle(`opaque_${hex(value)}`);
|
|
17
17
|
const LABEL = "spawnfile.training-gateway";
|
|
18
|
+
/**
|
|
19
|
+
* The gatewayed engines whose login the auth profile holds: the run's gateway
|
|
20
|
+
* serves all of them, so subject and judges may run on any of these engines.
|
|
21
|
+
*/
|
|
22
|
+
export const trainingGatewayLogins = (profile) => daimonGatewayedEngines(Object.keys(profile.imports)).map(({ id }) => id);
|
|
23
|
+
/**
|
|
24
|
+
* The bootstrap leaf comes only from the profile's own import: a training
|
|
25
|
+
* gateway never falls back to the operator's personal engine home.
|
|
26
|
+
*/
|
|
27
|
+
const trainingGatewaySource = (profile, engine, fileName) => {
|
|
28
|
+
const imported = profile.imports[engine];
|
|
29
|
+
if (!imported)
|
|
30
|
+
throw new SpawnfileError("validation_error", `Auth profile ${profile.name} has no imported ${engine} login`);
|
|
31
|
+
return path.join(imported.path, fileName);
|
|
32
|
+
};
|
|
18
33
|
/** One durable realm volume per auth profile and engine: the gateway is its sole holder (exclusive flock). */
|
|
19
34
|
export const trainingGatewayRealmVolume = (profile, engine) => `spawnfile-training-${slugify(profile) || "profile"}-${engine}-realm`;
|
|
20
35
|
/**
|
|
@@ -44,7 +59,7 @@ export const startTrainingGateway = async (options) => {
|
|
|
44
59
|
const mounts = [];
|
|
45
60
|
const realmVolumes = [];
|
|
46
61
|
for (const descriptor of descriptors) {
|
|
47
|
-
const source =
|
|
62
|
+
const source = trainingGatewaySource(options.authProfile, descriptor.id, descriptor.auth.fileName);
|
|
48
63
|
await assertSafeDaimonSourceFile(source, descriptor.id, descriptor.auth.maxCredentialBytes, descriptor.id);
|
|
49
64
|
const volume = trainingGatewayRealmVolume(options.authProfile.name, descriptor.id);
|
|
50
65
|
realmVolumes.push(volume);
|
|
@@ -73,7 +88,7 @@ export const startTrainingGateway = async (options) => {
|
|
|
73
88
|
await waitFor(async () => {
|
|
74
89
|
const state = await docker(["inspect", "--format", "{{.State.Running}}", container]);
|
|
75
90
|
if (state !== "true") {
|
|
76
|
-
const logs = await docker(["logs", "--tail", "20", container]).catch(() => "");
|
|
91
|
+
const logs = await docker(["logs", "--tail", "20", container], { combined: true }).catch(() => "");
|
|
77
92
|
throw new SpawnfileError("runtime_error", `training auth gateway exited before readiness: ${logs.replace(/\s+/gu, " ").slice(-400)}`);
|
|
78
93
|
}
|
|
79
94
|
return docker(["exec", container, "bash", "-c", `(exec 3<>/dev/tcp/127.0.0.1/${DAIMON_GATEWAY_PORT})`]).then(() => true, () => false);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { TrialDocker } from "./docker.js";
|
|
2
|
+
/**
|
|
3
|
+
* The login files an engine with a volume-held login keeps in its runtime home,
|
|
4
|
+
* relative to that home. AGY's OAuth token (which AGY rewrites when it
|
|
5
|
+
* refreshes), its installation id and its onboarded project: nothing else, so
|
|
6
|
+
* no conversation, history or knowledge ever passes between trials.
|
|
7
|
+
*/
|
|
8
|
+
export declare const HOME_LOGIN_FILES: Readonly<Record<string, readonly string[]>>;
|
|
9
|
+
/** One organization's runtime home and the profile volume its login files persist in. */
|
|
10
|
+
export interface HomeLogin {
|
|
11
|
+
/** The organization's own runtime home volume and where it is mounted. */
|
|
12
|
+
homeVolume: string;
|
|
13
|
+
homeMountPath: string;
|
|
14
|
+
/** The profile's volume holding only the login files. */
|
|
15
|
+
loginVolume: string;
|
|
16
|
+
files: readonly string[];
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Copies each listed file that exists from the profile's login root into the
|
|
20
|
+
* runtime home (`seed`) or back (`save`), as the organization uid: regular
|
|
21
|
+
* single-link files only, no link anywhere on either path, written beside the
|
|
22
|
+
* target and renamed over it, identical files left alone. Started as root only
|
|
23
|
+
* to hand the destination root to the uid and to enter the home, whose image
|
|
24
|
+
* ancestors the uid cannot traverse until the organization's entrypoint opens
|
|
25
|
+
* them; the bytes are never printed.
|
|
26
|
+
*/
|
|
27
|
+
export declare const HOME_LOGIN_PROGRAM: string;
|
|
28
|
+
/** Seeds the profile's login files into the organization's fresh runtime home, before it boots. */
|
|
29
|
+
export declare const seedHomeLogin: (docker: TrialDocker, image: string, login: HomeLogin) => Promise<string>;
|
|
30
|
+
/** Writes the login files the engine wrote or rewrote (a refreshed token) back to the profile, after the container stops. */
|
|
31
|
+
export declare const saveHomeLogin: (docker: TrialDocker, image: string, login: HomeLogin) => Promise<string>;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { DAIMON_ORGANIZATION_UID } from "../../../runtime/daimon/runtimeIdentity.js";
|
|
2
|
+
/**
|
|
3
|
+
* The login files an engine with a volume-held login keeps in its runtime home,
|
|
4
|
+
* relative to that home. AGY's OAuth token (which AGY rewrites when it
|
|
5
|
+
* refreshes), its installation id and its onboarded project: nothing else, so
|
|
6
|
+
* no conversation, history or knowledge ever passes between trials.
|
|
7
|
+
*/
|
|
8
|
+
export const HOME_LOGIN_FILES = {
|
|
9
|
+
agy: [
|
|
10
|
+
".gemini/antigravity-cli/antigravity-oauth-token",
|
|
11
|
+
".gemini/antigravity-cli/installation_id",
|
|
12
|
+
".gemini/antigravity-cli/cache/default_project_id.txt",
|
|
13
|
+
".gemini/antigravity-cli/cache/onboarding.json"
|
|
14
|
+
]
|
|
15
|
+
};
|
|
16
|
+
const LOGIN_ROOT = "/spawnfile-home-login";
|
|
17
|
+
/**
|
|
18
|
+
* Copies each listed file that exists from the profile's login root into the
|
|
19
|
+
* runtime home (`seed`) or back (`save`), as the organization uid: regular
|
|
20
|
+
* single-link files only, no link anywhere on either path, written beside the
|
|
21
|
+
* target and renamed over it, identical files left alone. Started as root only
|
|
22
|
+
* to hand the destination root to the uid and to enter the home, whose image
|
|
23
|
+
* ancestors the uid cannot traverse until the organization's entrypoint opens
|
|
24
|
+
* them; the bytes are never printed.
|
|
25
|
+
*/
|
|
26
|
+
export const HOME_LOGIN_PROGRAM = [
|
|
27
|
+
"const fs = require('node:fs'), path = require('node:path'), { constants } = fs;",
|
|
28
|
+
"const [uidText, direction, home, login, ...files] = process.argv.slice(1); const uid = Number(uidText);",
|
|
29
|
+
"const fail = (message) => { console.error(message); process.exit(3); };",
|
|
30
|
+
"if (direction !== 'seed' && direction !== 'save') fail('invalid direction');",
|
|
31
|
+
"process.chdir(home); const [from, to] = direction === 'seed' ? [login, '.'] : ['.', login];",
|
|
32
|
+
"if (process.getuid() === 0) {",
|
|
33
|
+
" const root = fs.lstatSync(to); if (!root.isDirectory()) fail('login destination is not a directory');",
|
|
34
|
+
" if (root.uid !== uid || (root.mode & 0o777) !== 0o700) { fs.chmodSync(to, 0o700); fs.chownSync(to, uid, uid); }",
|
|
35
|
+
" process.setgroups([]); process.setgid(uid); process.setuid(uid);",
|
|
36
|
+
"}",
|
|
37
|
+
"const walk = (root, segments, create) => { let current = root; for (const segment of segments) { current = path.join(current, segment);",
|
|
38
|
+
" let entry; try { entry = fs.lstatSync(current); } catch (error) { if (error.code !== 'ENOENT') throw error; if (!create) return null; fs.mkdirSync(current, { mode: 0o700 }); entry = fs.lstatSync(current); }",
|
|
39
|
+
" if (!entry.isDirectory()) fail('login directory is not a directory'); } return current; };",
|
|
40
|
+
"for (const file of files) {",
|
|
41
|
+
" const segments = file.split('/'); if (path.isAbsolute(file) || segments.some((segment) => segment === '' || segment === '.' || segment === '..')) fail('invalid login file path');",
|
|
42
|
+
" if (walk(from, segments.slice(0, -1), false) === null) continue;",
|
|
43
|
+
" let fd; try { fd = fs.openSync(path.join(from, file), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); } catch (error) { if (error.code === 'ENOENT') continue; fail('login file is unreadable or a link'); }",
|
|
44
|
+
" const entry = fs.fstatSync(fd); if (!entry.isFile() || entry.nlink !== 1) fail('login file is not a regular file');",
|
|
45
|
+
" const bytes = fs.readFileSync(fd); fs.closeSync(fd); const mode = entry.mode & 0o777;",
|
|
46
|
+
" const directory = walk(to, segments.slice(0, -1), true), target = path.join(directory, segments.at(-1));",
|
|
47
|
+
" let current = null; try { const existing = fs.lstatSync(target); if (!existing.isFile()) fail('login target is not a regular file'); current = fs.readFileSync(target); } catch (error) { if (error.code !== 'ENOENT') throw error; }",
|
|
48
|
+
" if (current !== null && current.equals(bytes) && (fs.lstatSync(target).mode & 0o777) === mode) { bytes.fill(0); current.fill(0); continue; }",
|
|
49
|
+
" current?.fill(0); const temporary = `${target}.spawnfile-${process.pid}`;",
|
|
50
|
+
" const out = fs.openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, mode);",
|
|
51
|
+
" let offset = 0; while (offset < bytes.length) offset += fs.writeSync(out, bytes, offset, bytes.length - offset, offset);",
|
|
52
|
+
" bytes.fill(0); fs.fchmodSync(out, mode); fs.fsyncSync(out); fs.closeSync(out); fs.renameSync(temporary, target);",
|
|
53
|
+
" const parent = fs.openSync(directory, constants.O_RDONLY); fs.fsyncSync(parent); fs.closeSync(parent);",
|
|
54
|
+
"}"
|
|
55
|
+
].join("\n");
|
|
56
|
+
const copy = (docker, image, login, direction) => docker([
|
|
57
|
+
"run", "--rm", "--network", "none", "--user", "0:0", "--cap-drop", "ALL",
|
|
58
|
+
// DAC_READ_SEARCH: root enters a home the organization already made its own (0700) to save from it.
|
|
59
|
+
"--cap-add", "CHOWN", "--cap-add", "DAC_READ_SEARCH", "--cap-add", "FOWNER", "--cap-add", "SETUID", "--cap-add", "SETGID",
|
|
60
|
+
"--security-opt", "no-new-privileges", "--entrypoint", "node",
|
|
61
|
+
"--mount", `type=volume,source=${login.loginVolume},target=${LOGIN_ROOT}`,
|
|
62
|
+
// Mounted where the organization mounts it, so a fresh home gets the image's own preimage first.
|
|
63
|
+
"--mount", `type=volume,source=${login.homeVolume},target=${login.homeMountPath}`,
|
|
64
|
+
image, "-e", HOME_LOGIN_PROGRAM, String(DAIMON_ORGANIZATION_UID), direction, login.homeMountPath, LOGIN_ROOT, ...login.files
|
|
65
|
+
]);
|
|
66
|
+
/** Seeds the profile's login files into the organization's fresh runtime home, before it boots. */
|
|
67
|
+
export const seedHomeLogin = (docker, image, login) => copy(docker, image, login, "seed");
|
|
68
|
+
/** Writes the login files the engine wrote or rewrote (a refreshed token) back to the profile, after the container stops. */
|
|
69
|
+
export const saveHomeLogin = (docker, image, login) => copy(docker, image, login, "save");
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
export { TRAINING_TRIAL_LINEAGE_NAMESPACE, prepareTrial, type BootedTrial, type PreparedTrial, type TrialOptions, type TrialWake } from "./boot.js";
|
|
2
|
-
export { startTrainingGateway, trainingGatewayRealmVolume, type TrainingGateway, type TrainingGatewayAccess, type TrainingGatewayOptions } from "./gateway.js";
|
|
2
|
+
export { startTrainingGateway, trainingGatewayLogins, trainingGatewayRealmVolume, type TrainingGateway, type TrainingGatewayAccess, type TrainingGatewayOptions } from "./gateway.js";
|
|
3
|
+
export { HOME_LOGIN_FILES, saveHomeLogin, seedHomeLogin, type HomeLogin } from "./homeLogin.js";
|
|
4
|
+
export { JUDGE_INSTRUCTIONS, writeJudgeAgent, type JudgeSpec } from "./judge.js";
|
|
3
5
|
export { createBootedTrialRun, type BootedTrialLauncherOptions, type BootedTrialRun } from "./launcher.js";
|
|
4
6
|
export { TRIAL_INSTRUCTIONS_FILE, writeTrialProject } from "./project.js";
|
|
7
|
+
export { ensureSubscriptionRealmVolumes, loginSubscriptionRealm, subscriptionRealmBinding, type SubscriptionRealmLoginOptions, type SubscriptionRealmLoginResult } from "./login.js";
|
|
8
|
+
export { createRealmTurns, type RealmTurnOptions, type RealmTurns, type ReleaseRealmTurn } from "./realmTurns.js";
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
export { TRAINING_TRIAL_LINEAGE_NAMESPACE, prepareTrial } from "./boot.js";
|
|
2
|
-
export { startTrainingGateway, trainingGatewayRealmVolume } from "./gateway.js";
|
|
2
|
+
export { startTrainingGateway, trainingGatewayLogins, trainingGatewayRealmVolume } from "./gateway.js";
|
|
3
|
+
export { HOME_LOGIN_FILES, saveHomeLogin, seedHomeLogin } from "./homeLogin.js";
|
|
4
|
+
export { JUDGE_INSTRUCTIONS, writeJudgeAgent } from "./judge.js";
|
|
3
5
|
export { createBootedTrialRun } from "./launcher.js";
|
|
4
6
|
export { TRIAL_INSTRUCTIONS_FILE, writeTrialProject } from "./project.js";
|
|
7
|
+
export { ensureSubscriptionRealmVolumes, loginSubscriptionRealm, subscriptionRealmBinding } from "./login.js";
|
|
8
|
+
export { createRealmTurns } from "./realmTurns.js";
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** One judge identity of a run: the engine, and the model and effort its compiled organization pins. */
|
|
2
|
+
export interface JudgeSpec {
|
|
3
|
+
engine: string;
|
|
4
|
+
model: string;
|
|
5
|
+
effort?: string;
|
|
6
|
+
}
|
|
7
|
+
/** The judge's system document: one self-contained judgment per wake, answered in the reply alone. */
|
|
8
|
+
export declare const JUDGE_INSTRUCTIONS: string;
|
|
9
|
+
/**
|
|
10
|
+
* Writes the judge's tool-free one-agent Daimon declaration: no skills, no
|
|
11
|
+
* MCP, no memory, no surfaces, no schedule. The model is pinned only where
|
|
12
|
+
* the engine's descriptor makes model selection configurable; otherwise the
|
|
13
|
+
* engine's own default model judges and the requested model is an assumption.
|
|
14
|
+
*/
|
|
15
|
+
export declare const writeJudgeAgent: (spec: JudgeSpec, directory: string) => Promise<string>;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { getDaimonEngineDescriptor } from "../../../runtime/daimon/index.js";
|
|
4
|
+
import { SpawnfileError } from "../../../shared/index.js";
|
|
5
|
+
/** The judge's system document: one self-contained judgment per wake, answered in the reply alone. */
|
|
6
|
+
export const JUDGE_INSTRUCTIONS = [
|
|
7
|
+
"You are a judge in a training run. Every message is one self-contained judging task.",
|
|
8
|
+
"Answer it directly in your reply. Never use a tool, command, file, memory, skill or the network:",
|
|
9
|
+
"a reply produced with any tool activity is discarded.",
|
|
10
|
+
"When the task gives a JSON Schema, reply with exactly one JSON value that matches it and nothing else."
|
|
11
|
+
].join("\n");
|
|
12
|
+
/**
|
|
13
|
+
* Writes the judge's tool-free one-agent Daimon declaration: no skills, no
|
|
14
|
+
* MCP, no memory, no surfaces, no schedule. The model is pinned only where
|
|
15
|
+
* the engine's descriptor makes model selection configurable; otherwise the
|
|
16
|
+
* engine's own default model judges and the requested model is an assumption.
|
|
17
|
+
*/
|
|
18
|
+
export const writeJudgeAgent = async (spec, directory) => {
|
|
19
|
+
const descriptor = getDaimonEngineDescriptor(spec.engine);
|
|
20
|
+
if (!descriptor)
|
|
21
|
+
throw new SpawnfileError("validation_error", `Judge engine ${spec.engine} is not a Daimon engine`);
|
|
22
|
+
const { provider, method } = descriptor.auth;
|
|
23
|
+
const pinned = descriptor.modelSelection.configurable && provider !== null && method !== null;
|
|
24
|
+
if (pinned && spec.effort !== undefined && !descriptor.modelSelection.reasoningEfforts.includes(spec.effort)) {
|
|
25
|
+
throw new SpawnfileError("validation_error", `Judge engine ${spec.engine} does not accept reasoning effort ${spec.effort}`);
|
|
26
|
+
}
|
|
27
|
+
const manifest = {
|
|
28
|
+
spawnfile_version: "0.1",
|
|
29
|
+
kind: "agent",
|
|
30
|
+
name: `judge-${spec.engine}`,
|
|
31
|
+
description: `Tool-free ${spec.engine} training judge`,
|
|
32
|
+
runtime: { name: "daimon", options: { engine: spec.engine } },
|
|
33
|
+
...(pinned ? { execution: { model: { primary: {
|
|
34
|
+
provider, name: spec.model, auth: { method }, ...(spec.effort === undefined ? {} : { reasoning_effort: spec.effort })
|
|
35
|
+
} } } } : {})
|
|
36
|
+
};
|
|
37
|
+
await mkdir(directory, { recursive: true });
|
|
38
|
+
const manifestPath = path.join(directory, "Spawnfile");
|
|
39
|
+
// JSON is YAML; the trial project re-renders it with the judge instructions as its system document.
|
|
40
|
+
await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
41
|
+
return manifestPath;
|
|
42
|
+
};
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { ResolvedAuthProfile } from "../../../auth/index.js";
|
|
2
|
-
import { type TrialWake } from "./boot.js";
|
|
2
|
+
import { prepareTrial, type BootedTrial, type TrialWake } from "./boot.js";
|
|
3
3
|
import { type TrialDocker } from "./docker.js";
|
|
4
|
+
import { type JudgeSpec } from "./judge.js";
|
|
5
|
+
import { type RealmTurns } from "./realmTurns.js";
|
|
4
6
|
export interface BootedTrialLauncherOptions {
|
|
5
7
|
/** The subject agent's own Spawnfile. */
|
|
6
8
|
agentManifestPath: string;
|
|
@@ -8,25 +10,38 @@ export interface BootedTrialLauncherOptions {
|
|
|
8
10
|
/** Trial projects, compile outputs and boot env files live here, one fresh directory per trial. */
|
|
9
11
|
scratchRoot: string;
|
|
10
12
|
docker?: TrialDocker;
|
|
13
|
+
turns?: RealmTurns;
|
|
14
|
+
/** Compiles and builds one organization (tests only). */
|
|
15
|
+
prepare?: typeof prepareTrial;
|
|
16
|
+
/** Bound on a judge's wait for a volume-held login another container holds (default 30 minutes). */
|
|
17
|
+
judgeRealmWaitMs?: number;
|
|
11
18
|
}
|
|
19
|
+
type Wake = (text: string, options: {
|
|
20
|
+
deliveryId: string;
|
|
21
|
+
timeoutMs: number;
|
|
22
|
+
signal?: AbortSignal;
|
|
23
|
+
}) => Promise<TrialWake>;
|
|
24
|
+
type Booted = Pick<BootedTrial, "container" | "scanCredentials" | "teardown"> & {
|
|
25
|
+
wake: Wake;
|
|
26
|
+
};
|
|
12
27
|
export interface BootedTrialRun {
|
|
13
28
|
boot(input: {
|
|
14
29
|
trialId: string;
|
|
15
30
|
instructions: string;
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
}): Promise<TrialWake>;
|
|
22
|
-
teardown(): Promise<void>;
|
|
23
|
-
}>;
|
|
24
|
-
/** Stops the run's gateway and removes its image and networks; the realm volume stays. */
|
|
31
|
+
signal?: AbortSignal;
|
|
32
|
+
}): Promise<Booted>;
|
|
33
|
+
/** The run's warm judge for this engine, model and effort: booted once, woken once per judgment. */
|
|
34
|
+
judge(spec: JudgeSpec): Promise<Omit<Booted, "teardown">>;
|
|
35
|
+
/** Tears down every judge, stops the run's gateway and removes its image and networks; the realm volumes stay. */
|
|
25
36
|
close(): Promise<void>;
|
|
26
37
|
}
|
|
27
38
|
/**
|
|
28
|
-
* One training run's
|
|
29
|
-
* one-agent organization,
|
|
30
|
-
*
|
|
39
|
+
* One training run's containers: each trial boot compiles and starts the
|
|
40
|
+
* subject's one-agent organization, each judge is a tool-free one-agent
|
|
41
|
+
* organization booted the same way, and all of them share the run's single
|
|
42
|
+
* auth gateway, started from the first image on first use. The gateway holds
|
|
43
|
+
* every gatewayed login of the run's auth profile, so trials and judges on
|
|
44
|
+
* any engine reach it with one key.
|
|
31
45
|
*/
|
|
32
46
|
export declare const createBootedTrialRun: (options: BootedTrialLauncherOptions) => BootedTrialRun;
|
|
47
|
+
export {};
|