kradle 0.3.1 → 0.4.0
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 +20 -3
- package/dist/commands/challenge/list.js +4 -3
- package/dist/commands/challenge/pull.js +6 -60
- package/dist/commands/challenge/watch.js +6 -1
- package/dist/commands/experiment/logs.d.ts +18 -0
- package/dist/commands/experiment/logs.js +220 -0
- package/dist/commands/experiment/run.d.ts +1 -0
- package/dist/commands/experiment/run.js +6 -0
- package/dist/commands/init.js +1 -2
- package/dist/commands/world/list.js +3 -2
- package/dist/lib/api-client.d.ts +18 -1
- package/dist/lib/api-client.js +43 -2
- package/dist/lib/challenge.d.ts +8 -0
- package/dist/lib/challenge.js +46 -0
- package/dist/lib/experiment/experimenter.d.ts +9 -0
- package/dist/lib/experiment/experimenter.js +101 -9
- package/dist/lib/experiment/runner.js +12 -4
- package/dist/lib/experiment/types.d.ts +1 -22
- package/dist/lib/experiment/types.js +0 -18
- package/dist/lib/schemas.d.ts +63 -0
- package/dist/lib/schemas.js +37 -0
- package/oclif.manifest.json +81 -1
- package/package.json +1 -1
- package/static/ai_docs/LLM_CLI_REFERENCE.md +78 -1
- package/static/project_template/prod.env +4 -4
package/README.md
CHANGED
|
@@ -18,18 +18,19 @@ Kradle's CLI for managing Minecraft challenges, experiments, agents, and more!
|
|
|
18
18
|
|
|
19
19
|
## Installation
|
|
20
20
|
|
|
21
|
+
Make sure you have [NodeJS 22.18 or higher](https://nodejs.org/en/download/current) installed.
|
|
22
|
+
|
|
21
23
|
1. Install Kradle's CLI globally
|
|
22
24
|
```
|
|
23
25
|
npm i -g kradle
|
|
24
26
|
```
|
|
25
|
-
2. Initialize a new directory to store challenges and
|
|
27
|
+
2. Initialize a new directory to store challenges, and other Kradle resources:
|
|
26
28
|
```
|
|
27
29
|
kradle init
|
|
28
30
|
```
|
|
29
|
-
3. Congrats 🎉 You can now create a new challenge
|
|
31
|
+
3. Congrats 🎉 You can now create a new challenge:
|
|
30
32
|
```
|
|
31
33
|
kradle challenge create <challenge-name>
|
|
32
|
-
kradle experiment create <experiment-name>
|
|
33
34
|
```
|
|
34
35
|
|
|
35
36
|
In addition, you can enable [autocomplete](#Autocomplete).
|
|
@@ -184,6 +185,7 @@ kradle experiment run <name> # Resume current version or c
|
|
|
184
185
|
kradle experiment run <name> --new-version # Start a new version
|
|
185
186
|
kradle experiment run <name> --max-concurrent 10 # Control parallelism (default: 5)
|
|
186
187
|
kradle experiment run <name> --download-recordings # Auto-download recordings as runs complete
|
|
188
|
+
kradle experiment run <name> --download-logs # Auto-download logs as runs complete
|
|
187
189
|
```
|
|
188
190
|
|
|
189
191
|
The run command:
|
|
@@ -207,6 +209,21 @@ kradle experiment recordings <name> <run-id> --all # Download all participants
|
|
|
207
209
|
|
|
208
210
|
Recordings are saved to `experiments/<name>/versions/<version>/recordings/<run-id>/`.
|
|
209
211
|
|
|
212
|
+
### Download Logs
|
|
213
|
+
|
|
214
|
+
Download logs and run results from completed experiment runs:
|
|
215
|
+
|
|
216
|
+
```bash
|
|
217
|
+
kradle experiment logs <name> # Interactive selection of run
|
|
218
|
+
kradle experiment logs <name> <run-id> # Download specific run
|
|
219
|
+
kradle experiment logs <name> --all # Download all runs
|
|
220
|
+
kradle experiment logs <name> --version 2 # Download from specific version
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Files are saved to `experiments/<name>/versions/<version>/logs/<run-id>/`:
|
|
224
|
+
- `run.json` - Run result with status, end_state, and participant results
|
|
225
|
+
- `logs.json` - Log entries from the run
|
|
226
|
+
|
|
210
227
|
### List Experiments
|
|
211
228
|
|
|
212
229
|
List all local experiments:
|
|
@@ -13,13 +13,14 @@ export default class List extends Command {
|
|
|
13
13
|
const { flags } = await this.parse(List);
|
|
14
14
|
const api = new ApiClient(flags["api-url"], flags["api-key"]);
|
|
15
15
|
this.log(pc.blue(">> Loading challenges..."));
|
|
16
|
-
const [cloudChallenges, localChallenges, human] = await Promise.all([
|
|
16
|
+
const [cloudChallenges, kradleChallenges, localChallenges, human] = await Promise.all([
|
|
17
17
|
api.listChallenges(),
|
|
18
|
+
api.listKradleChallenges(),
|
|
18
19
|
Challenge.getLocalChallenges(),
|
|
19
20
|
api.getHuman(),
|
|
20
21
|
]);
|
|
21
|
-
// Create a map for easy lookup
|
|
22
|
-
const cloudMap = new Map(cloudChallenges.map((c) => [c.slug, c]));
|
|
22
|
+
// Create a map for easy lookup (user's challenges + team-kradle challenges)
|
|
23
|
+
const cloudMap = new Map([...cloudChallenges, ...kradleChallenges].map((c) => [c.slug, c]));
|
|
23
24
|
const allSlugs = new Set([...cloudMap.keys(), ...localChallenges.map((id) => `${human.username}:${id}`)]);
|
|
24
25
|
this.log(pc.bold("\nChallenges:\n"));
|
|
25
26
|
this.log(`${"Status".padEnd(15)} ${"Slug".padEnd(40)} ${"Name".padEnd(30)}`);
|
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
|
-
import path from "node:path";
|
|
4
3
|
import { Command, Flags } from "@oclif/core";
|
|
5
4
|
import enquirer from "enquirer";
|
|
6
5
|
import { Listr } from "listr2";
|
|
7
6
|
import pc from "picocolors";
|
|
8
|
-
import * as tar from "tar";
|
|
9
7
|
import { ApiClient } from "../../lib/api-client.js";
|
|
10
8
|
import { extractShortSlug, getChallengeSlugArgument } from "../../lib/arguments.js";
|
|
11
|
-
import { Challenge
|
|
9
|
+
import { Challenge } from "../../lib/challenge.js";
|
|
12
10
|
import { getConfigFlags } from "../../lib/flags.js";
|
|
13
11
|
export default class Pull extends Command {
|
|
14
12
|
static description = "Pull a challenge from the cloud and extract source files locally";
|
|
@@ -97,67 +95,16 @@ export default class Pull extends Command {
|
|
|
97
95
|
return;
|
|
98
96
|
}
|
|
99
97
|
}
|
|
100
|
-
const tempTarballPath = path.join(flags["challenges-path"], `${challenge.shortSlug}-pull-temp.tar.gz`);
|
|
101
98
|
const tasks = new Listr([
|
|
102
99
|
{
|
|
103
|
-
title: "Downloading
|
|
104
|
-
task: async (_, task) => {
|
|
105
|
-
const { downloadUrl } = await api.getChallengeDownloadUrl(challengeSlug);
|
|
106
|
-
const response = await fetch(downloadUrl);
|
|
107
|
-
if (!response.ok) {
|
|
108
|
-
throw new Error(`Failed to download: ${response.status} ${response.statusText}`);
|
|
109
|
-
}
|
|
110
|
-
const buffer = await response.arrayBuffer();
|
|
111
|
-
await fs.mkdir(path.dirname(tempTarballPath), { recursive: true });
|
|
112
|
-
await fs.writeFile(tempTarballPath, Buffer.from(buffer));
|
|
113
|
-
task.title = "Downloaded challenge";
|
|
114
|
-
},
|
|
115
|
-
},
|
|
116
|
-
{
|
|
117
|
-
title: "Creating challenge directory",
|
|
100
|
+
title: "Downloading and extracting source files",
|
|
118
101
|
task: async (_, task) => {
|
|
119
102
|
await fs.mkdir(challenge.challengeDir, { recursive: true });
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
{
|
|
124
|
-
title: "Extracting source files",
|
|
125
|
-
task: async (_, task) => {
|
|
126
|
-
const filesToExtract = [`${SOURCE_FOLDER}/challenge.ts`, `${SOURCE_FOLDER}/config.ts`];
|
|
127
|
-
const tempExtractDir = path.join(flags["challenges-path"], `${challenge.shortSlug}-extract-temp`);
|
|
128
|
-
await fs.mkdir(tempExtractDir, { recursive: true });
|
|
129
|
-
try {
|
|
130
|
-
await tar.extract({
|
|
131
|
-
file: tempTarballPath,
|
|
132
|
-
cwd: tempExtractDir,
|
|
133
|
-
filter: (entryPath) => filesToExtract.some((f) => entryPath === f),
|
|
134
|
-
});
|
|
135
|
-
const srcChallengeTs = path.join(tempExtractDir, SOURCE_FOLDER, "challenge.ts");
|
|
136
|
-
const srcConfigTs = path.join(tempExtractDir, SOURCE_FOLDER, "config.ts");
|
|
137
|
-
let extractedCount = 0;
|
|
138
|
-
if (existsSync(srcChallengeTs)) {
|
|
139
|
-
await fs.copyFile(srcChallengeTs, challenge.challengePath);
|
|
140
|
-
extractedCount++;
|
|
141
|
-
}
|
|
142
|
-
if (existsSync(srcConfigTs)) {
|
|
143
|
-
await fs.copyFile(srcConfigTs, challenge.configPath);
|
|
144
|
-
extractedCount++;
|
|
145
|
-
}
|
|
146
|
-
if (extractedCount === 0) {
|
|
147
|
-
throw new Error(`No source files found in tarball. The challenge may not have been built with source files.`);
|
|
148
|
-
}
|
|
149
|
-
task.title = `Extracted ${extractedCount} source file(s)`;
|
|
103
|
+
const extractedCount = await Challenge.downloadSourceFiles(api, challengeSlug, challenge.challengeDir);
|
|
104
|
+
if (extractedCount === 0) {
|
|
105
|
+
throw new Error("No source files found in tarball. The challenge may not have been built with source files.");
|
|
150
106
|
}
|
|
151
|
-
|
|
152
|
-
await fs.rm(tempExtractDir, { recursive: true, force: true });
|
|
153
|
-
}
|
|
154
|
-
},
|
|
155
|
-
},
|
|
156
|
-
{
|
|
157
|
-
title: "Cleaning up",
|
|
158
|
-
task: async (_, task) => {
|
|
159
|
-
await fs.rm(tempTarballPath, { force: true });
|
|
160
|
-
task.title = "Cleaned up temporary files";
|
|
107
|
+
task.title = `Extracted ${extractedCount} source file(s)`;
|
|
161
108
|
},
|
|
162
109
|
},
|
|
163
110
|
{
|
|
@@ -175,7 +122,6 @@ export default class Pull extends Command {
|
|
|
175
122
|
this.log(pc.dim(` → config.ts: ${challenge.configPath}`));
|
|
176
123
|
}
|
|
177
124
|
catch (error) {
|
|
178
|
-
await fs.rm(tempTarballPath, { force: true }).catch(() => { });
|
|
179
125
|
this.error(pc.red(`Pull failed: ${error instanceof Error ? error.message : String(error)}`));
|
|
180
126
|
}
|
|
181
127
|
}
|
|
@@ -33,6 +33,9 @@ export default class Watch extends Command {
|
|
|
33
33
|
// Clear screen before subsequent rebuilds for clean output
|
|
34
34
|
clearScreen();
|
|
35
35
|
this.log(pc.cyan(`\n Rebuild started...\n`));
|
|
36
|
+
// Track if config changed so we can force tarball re-upload
|
|
37
|
+
// (tarball includes source files which contain config.ts)
|
|
38
|
+
let configChanged = false;
|
|
36
39
|
const tasks = new Listr([
|
|
37
40
|
{
|
|
38
41
|
title: "Checking configuration",
|
|
@@ -42,6 +45,7 @@ export default class Watch extends Command {
|
|
|
42
45
|
task.title = "Uploading configuration";
|
|
43
46
|
await api.updateChallenge(challenge.shortSlug, newConfig, "private");
|
|
44
47
|
lastConfig = newConfig;
|
|
48
|
+
configChanged = true;
|
|
45
49
|
task.title = "Configuration uploaded";
|
|
46
50
|
}
|
|
47
51
|
else {
|
|
@@ -61,7 +65,8 @@ export default class Watch extends Command {
|
|
|
61
65
|
title: "Checking datapack changes",
|
|
62
66
|
task: async (_ctx, task) => {
|
|
63
67
|
const newHash = await challenge.getDatapackHash();
|
|
64
|
-
if (
|
|
68
|
+
// Upload if datapack changed OR if config changed (since tarball includes source files)
|
|
69
|
+
if (newHash !== lastHash || configChanged) {
|
|
65
70
|
task.title = "Uploading datapack";
|
|
66
71
|
await api.uploadChallengeDatapack(challenge.shortSlug, challenge.tarballPath);
|
|
67
72
|
lastHash = newHash;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Command } from "@oclif/core";
|
|
2
|
+
export default class Logs extends Command {
|
|
3
|
+
static description: string;
|
|
4
|
+
static examples: string[];
|
|
5
|
+
static args: {
|
|
6
|
+
experimentName: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
|
|
7
|
+
runId: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
|
|
8
|
+
};
|
|
9
|
+
static flags: {
|
|
10
|
+
"api-key": import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
11
|
+
"api-url": import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
12
|
+
all: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
13
|
+
version: import("@oclif/core/interfaces").OptionFlag<number | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
14
|
+
};
|
|
15
|
+
run(): Promise<void>;
|
|
16
|
+
private downloadForExperiment;
|
|
17
|
+
private downloadLogs;
|
|
18
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { Args, Command, Flags } from "@oclif/core";
|
|
4
|
+
import enquirer from "enquirer";
|
|
5
|
+
import { Listr } from "listr2";
|
|
6
|
+
import pc from "picocolors";
|
|
7
|
+
import { ApiClient } from "../../lib/api-client.js";
|
|
8
|
+
import { Experimenter } from "../../lib/experiment/experimenter.js";
|
|
9
|
+
import { getConfigFlags } from "../../lib/flags.js";
|
|
10
|
+
// Get all versions for an experiment
|
|
11
|
+
async function getAllVersions(experimentDir) {
|
|
12
|
+
const versionsDir = path.join(experimentDir, "versions");
|
|
13
|
+
try {
|
|
14
|
+
const entries = await fs.readdir(versionsDir, { withFileTypes: true });
|
|
15
|
+
return entries
|
|
16
|
+
.filter((e) => e.isDirectory())
|
|
17
|
+
.map((e) => parseInt(e.name, 10))
|
|
18
|
+
.filter((n) => !Number.isNaN(n))
|
|
19
|
+
.sort((a, b) => a - b);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export default class Logs extends Command {
|
|
26
|
+
static description = "Download logs from an experiment run";
|
|
27
|
+
static examples = [
|
|
28
|
+
"<%= config.bin %> <%= command.id %> my-experiment",
|
|
29
|
+
"<%= config.bin %> <%= command.id %> my-experiment <run-id>",
|
|
30
|
+
"<%= config.bin %> <%= command.id %> my-experiment --all",
|
|
31
|
+
"<%= config.bin %> <%= command.id %> my-experiment --version 2",
|
|
32
|
+
"<%= config.bin %> <%= command.id %> my-experiment --version 1 --all",
|
|
33
|
+
];
|
|
34
|
+
static args = {
|
|
35
|
+
experimentName: Args.string({
|
|
36
|
+
description: "Experiment name",
|
|
37
|
+
required: true,
|
|
38
|
+
}),
|
|
39
|
+
runId: Args.string({
|
|
40
|
+
description: "Specific run ID to download logs from (optional)",
|
|
41
|
+
required: false,
|
|
42
|
+
}),
|
|
43
|
+
};
|
|
44
|
+
static flags = {
|
|
45
|
+
all: Flags.boolean({
|
|
46
|
+
description: "Download logs for all runs",
|
|
47
|
+
default: false,
|
|
48
|
+
}),
|
|
49
|
+
version: Flags.integer({
|
|
50
|
+
description: "Specific experiment version to download logs from (e.g., 0, 1, 2)",
|
|
51
|
+
required: false,
|
|
52
|
+
}),
|
|
53
|
+
...getConfigFlags("api-key", "api-url"),
|
|
54
|
+
};
|
|
55
|
+
async run() {
|
|
56
|
+
const { args, flags } = await this.parse(Logs);
|
|
57
|
+
const api = new ApiClient(flags["api-url"], flags["api-key"]);
|
|
58
|
+
const { experimentName, runId } = args;
|
|
59
|
+
await this.downloadForExperiment(experimentName, runId, api, flags.all, flags.version);
|
|
60
|
+
}
|
|
61
|
+
async downloadForExperiment(experimentName, runId, api, all, version) {
|
|
62
|
+
const experimenter = new Experimenter(experimentName, "", api);
|
|
63
|
+
// Check if experiment exists
|
|
64
|
+
if (!(await experimenter.exists())) {
|
|
65
|
+
this.error(pc.red(`Experiment '${experimentName}' does not exist. Run 'kradle experiment list' to see available experiments.`));
|
|
66
|
+
}
|
|
67
|
+
const experimentDir = experimenter.experimentDir;
|
|
68
|
+
// Get all versions
|
|
69
|
+
const allVersions = await getAllVersions(experimentDir);
|
|
70
|
+
if (allVersions.length === 0) {
|
|
71
|
+
this.error(pc.red("No experiment versions found. Run the experiment first."));
|
|
72
|
+
}
|
|
73
|
+
// Default to latest version if not specified
|
|
74
|
+
let targetVersion;
|
|
75
|
+
if (version !== undefined) {
|
|
76
|
+
if (!allVersions.includes(version)) {
|
|
77
|
+
this.error(pc.red(`Version ${version} not found in experiment '${experimentName}'. ` +
|
|
78
|
+
`Available versions: ${allVersions.join(", ")}`));
|
|
79
|
+
}
|
|
80
|
+
targetVersion = version;
|
|
81
|
+
this.log(pc.blue(`>> Filtering to version ${version}`));
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
// Default to latest version
|
|
85
|
+
targetVersion = Math.max(...allVersions);
|
|
86
|
+
}
|
|
87
|
+
const allRunInfos = [];
|
|
88
|
+
const completedStatuses = new Set(["completed", "finished", "game_over"]);
|
|
89
|
+
const progressPath = path.join(experimentDir, "versions", targetVersion.toString().padStart(3, "0"), "progress.json");
|
|
90
|
+
try {
|
|
91
|
+
const progressData = await fs.readFile(progressPath, "utf-8");
|
|
92
|
+
const progress = JSON.parse(progressData);
|
|
93
|
+
for (const entry of progress.entries) {
|
|
94
|
+
// Only include runs that are completed (exclude in-progress, queued, or error runs)
|
|
95
|
+
if (entry.runId && completedStatuses.has(entry.status)) {
|
|
96
|
+
allRunInfos.push({
|
|
97
|
+
version: targetVersion,
|
|
98
|
+
runId: entry.runId,
|
|
99
|
+
index: entry.index,
|
|
100
|
+
status: entry.status,
|
|
101
|
+
participantIds: entry.participantIds,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
catch { }
|
|
107
|
+
if (allRunInfos.length === 0) {
|
|
108
|
+
this.error(pc.yellow("No completed runs found. Wait for runs to finish or run the experiment first."));
|
|
109
|
+
}
|
|
110
|
+
let selectedRuns;
|
|
111
|
+
if (all && !runId) {
|
|
112
|
+
// Download all runs (--all without specific run)
|
|
113
|
+
selectedRuns = allRunInfos;
|
|
114
|
+
this.log(pc.blue(`>> Downloading logs for all ${selectedRuns.length} runs`));
|
|
115
|
+
}
|
|
116
|
+
else if (runId) {
|
|
117
|
+
// Find specific run by ID
|
|
118
|
+
const matchingRun = allRunInfos.find((r) => r.runId === runId);
|
|
119
|
+
if (!matchingRun) {
|
|
120
|
+
this.error(pc.red(`Run ID '${runId}' not found in experiment '${experimentName}'. ` +
|
|
121
|
+
`Run 'kradle experiment logs ${experimentName}' to see available runs.`));
|
|
122
|
+
}
|
|
123
|
+
selectedRuns = [matchingRun];
|
|
124
|
+
this.log(pc.blue(`>> Downloading logs for run: ${runId}`));
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
// Interactive run selection
|
|
128
|
+
const choices = [
|
|
129
|
+
// Only show "All runs" option if there are multiple runs
|
|
130
|
+
...(allRunInfos.length > 1
|
|
131
|
+
? [
|
|
132
|
+
{
|
|
133
|
+
name: "all",
|
|
134
|
+
message: `All runs (${allRunInfos.length} total)`,
|
|
135
|
+
hint: "Download all",
|
|
136
|
+
},
|
|
137
|
+
]
|
|
138
|
+
: []),
|
|
139
|
+
...allRunInfos.map((run) => {
|
|
140
|
+
const participants = run.participantIds?.join(", ") || "No participants";
|
|
141
|
+
return {
|
|
142
|
+
name: run.runId,
|
|
143
|
+
message: `${participants} - ${run.runId}`,
|
|
144
|
+
hint: run.status,
|
|
145
|
+
};
|
|
146
|
+
}),
|
|
147
|
+
];
|
|
148
|
+
const { selectedRunId } = await enquirer.prompt({
|
|
149
|
+
type: "select",
|
|
150
|
+
name: "selectedRunId",
|
|
151
|
+
message: "Select a run to download logs from",
|
|
152
|
+
choices,
|
|
153
|
+
});
|
|
154
|
+
if (selectedRunId === "all") {
|
|
155
|
+
selectedRuns = allRunInfos;
|
|
156
|
+
this.log(pc.blue(`>> Downloading logs for all ${selectedRuns.length} runs`));
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
const selectedRun = allRunInfos.find((r) => r.runId === selectedRunId);
|
|
160
|
+
if (!selectedRun) {
|
|
161
|
+
this.error(pc.red("Selected run not found."));
|
|
162
|
+
}
|
|
163
|
+
selectedRuns = [selectedRun];
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
// Build download targets
|
|
167
|
+
const downloadTargets = selectedRuns.map((run) => ({
|
|
168
|
+
version: run.version,
|
|
169
|
+
runId: run.runId,
|
|
170
|
+
experimentDir,
|
|
171
|
+
}));
|
|
172
|
+
this.log(pc.blue(`>> Fetching and downloading logs for ${downloadTargets.length} run(s)...`));
|
|
173
|
+
await this.downloadLogs(api, downloadTargets);
|
|
174
|
+
const logsDir = path.join(experimentDir, "versions", targetVersion.toString().padStart(3, "0"), "logs");
|
|
175
|
+
this.log(pc.green(`\n Downloaded logs for ${downloadTargets.length} run(s) to ${logsDir}`));
|
|
176
|
+
}
|
|
177
|
+
async downloadLogs(api, targets) {
|
|
178
|
+
const allTasks = [];
|
|
179
|
+
for (const target of targets) {
|
|
180
|
+
const { version, runId, experimentDir } = target;
|
|
181
|
+
allTasks.push({
|
|
182
|
+
title: `${runId}`,
|
|
183
|
+
task: async (_, task) => {
|
|
184
|
+
const runDir = path.join(experimentDir, "versions", version.toString().padStart(3, "0"), "logs", runId);
|
|
185
|
+
const runPath = path.join(runDir, "run.json");
|
|
186
|
+
const logsPath = path.join(runDir, "logs.json");
|
|
187
|
+
// Fetch run result
|
|
188
|
+
let runResult;
|
|
189
|
+
try {
|
|
190
|
+
runResult = await api.getRunResult(runId);
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
throw new Error(`Failed to fetch run result: ${error instanceof Error ? error.message : String(error)}`);
|
|
194
|
+
}
|
|
195
|
+
// Fetch logs
|
|
196
|
+
let logs = [];
|
|
197
|
+
try {
|
|
198
|
+
logs = await api.getRunLogs(runId);
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
throw new Error(`Failed to fetch logs: ${error instanceof Error ? error.message : String(error)}`);
|
|
202
|
+
}
|
|
203
|
+
// Save run result and logs
|
|
204
|
+
await fs.mkdir(runDir, { recursive: true });
|
|
205
|
+
await fs.writeFile(runPath, JSON.stringify(runResult, null, 2));
|
|
206
|
+
await fs.writeFile(logsPath, JSON.stringify(logs, null, 2));
|
|
207
|
+
task.title = `${runId} (${logs.length} log entries)`;
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
if (allTasks.length === 0) {
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
const tasks = new Listr(allTasks, {
|
|
215
|
+
concurrent: 3,
|
|
216
|
+
exitOnError: false,
|
|
217
|
+
});
|
|
218
|
+
await tasks.run();
|
|
219
|
+
}
|
|
220
|
+
}
|
|
@@ -12,6 +12,7 @@ export default class Run extends Command {
|
|
|
12
12
|
"new-version": import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
13
13
|
"max-concurrent": import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
|
|
14
14
|
"download-recordings": import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
15
|
+
"download-logs": import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
15
16
|
};
|
|
16
17
|
run(): Promise<void>;
|
|
17
18
|
}
|
|
@@ -33,6 +33,11 @@ export default class Run extends Command {
|
|
|
33
33
|
description: "Automatically download recordings after each run finishes",
|
|
34
34
|
default: false,
|
|
35
35
|
}),
|
|
36
|
+
"download-logs": Flags.boolean({
|
|
37
|
+
char: "l",
|
|
38
|
+
description: "Automatically download logs after each run finishes",
|
|
39
|
+
default: false,
|
|
40
|
+
}),
|
|
36
41
|
...getConfigFlags("api-key", "api-url", "web-url"),
|
|
37
42
|
};
|
|
38
43
|
async run() {
|
|
@@ -57,6 +62,7 @@ export default class Run extends Command {
|
|
|
57
62
|
maxConcurrent: flags["max-concurrent"],
|
|
58
63
|
openMetabase: true,
|
|
59
64
|
downloadRecordings: flags["download-recordings"],
|
|
65
|
+
downloadLogs: flags["download-logs"],
|
|
60
66
|
});
|
|
61
67
|
this.log(pc.green("\n✓ Experiment complete!"));
|
|
62
68
|
}
|
package/dist/commands/init.js
CHANGED
|
@@ -72,8 +72,7 @@ export default class Init extends Command {
|
|
|
72
72
|
// this.log(pc.green("Using Kradle's production environment."));
|
|
73
73
|
// }
|
|
74
74
|
this.log();
|
|
75
|
-
|
|
76
|
-
const useDev = true;
|
|
75
|
+
const useDev = false;
|
|
77
76
|
const domain = useDev ? "dev.kradle.ai" : "kradle.ai";
|
|
78
77
|
let apiKey;
|
|
79
78
|
if (flags["api-key"]) {
|
|
@@ -13,12 +13,13 @@ export default class List extends Command {
|
|
|
13
13
|
const { flags } = await this.parse(List);
|
|
14
14
|
const api = new ApiClient(flags["api-url"], flags["api-key"]);
|
|
15
15
|
this.log(pc.blue(">> Loading worlds..."));
|
|
16
|
-
const [cloudWorlds, localWorlds, human] = await Promise.all([
|
|
16
|
+
const [cloudWorlds, kradleWorlds, localWorlds, human] = await Promise.all([
|
|
17
17
|
api.listWorlds(),
|
|
18
|
+
api.listKradleWorlds(),
|
|
18
19
|
World.getLocalWorlds(),
|
|
19
20
|
api.getHuman(),
|
|
20
21
|
]);
|
|
21
|
-
const cloudMap = new Map(cloudWorlds.map((w) => [w.slug, w]));
|
|
22
|
+
const cloudMap = new Map([...cloudWorlds, ...kradleWorlds].map((w) => [w.slug, w]));
|
|
22
23
|
const allSlugs = new Set([...cloudMap.keys(), ...localWorlds.map((slug) => `${human.username}:${slug}`)]);
|
|
23
24
|
this.log(pc.bold("\nWorlds:\n"));
|
|
24
25
|
this.log(`${"Status".padEnd(15)} ${"Slug".padEnd(40)} ${"Name".padEnd(30)}`);
|
package/dist/lib/api-client.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type z from "zod";
|
|
2
|
-
import { type AgentSchemaType, type ChallengeConfigSchemaType, type ChallengeSchemaType, type DownloadUrlResponse, HumanSchema, type RecordingMetadata, type RunStatusSchemaType, type WorldConfigSchemaType, type WorldSchemaType } from "./schemas.js";
|
|
2
|
+
import { type AgentSchemaType, type ChallengeConfigSchemaType, type ChallengeSchemaType, type DashboardUrlResponse, type DownloadUrlResponse, HumanSchema, type ParsedLogEntry, type RecordingMetadata, type RunResultResponse, type RunStatusSchemaType, type WorldConfigSchemaType, type WorldSchemaType } from "./schemas.js";
|
|
3
3
|
export declare class ApiClient {
|
|
4
4
|
private apiUrl;
|
|
5
5
|
private kradleApiKey;
|
|
@@ -114,6 +114,18 @@ export declare class ApiClient {
|
|
|
114
114
|
* @returns Download URL and expiration time.
|
|
115
115
|
*/
|
|
116
116
|
getRecordingDownloadUrl(runId: string, participantId: string, timestamp: string): Promise<DownloadUrlResponse>;
|
|
117
|
+
/**
|
|
118
|
+
* Get logs for a run, and parse the message if it is a JSON object.
|
|
119
|
+
* @param runId - The ID of the run.
|
|
120
|
+
* @returns Array of log entries.
|
|
121
|
+
*/
|
|
122
|
+
getRunLogs(runId: string): Promise<ParsedLogEntry[]>;
|
|
123
|
+
/**
|
|
124
|
+
* Get the result of a run.
|
|
125
|
+
* @param runId - The ID of the run.
|
|
126
|
+
* @returns Run result with status, end_state, and participant results.
|
|
127
|
+
*/
|
|
128
|
+
getRunResult(runId: string): Promise<RunResultResponse>;
|
|
117
129
|
listWorlds(): Promise<WorldSchemaType[]>;
|
|
118
130
|
listKradleWorlds(): Promise<WorldSchemaType[]>;
|
|
119
131
|
getWorld(slug: string): Promise<WorldSchemaType>;
|
|
@@ -130,4 +142,9 @@ export declare class ApiClient {
|
|
|
130
142
|
getWorldUploadUrl(slug: string): Promise<string>;
|
|
131
143
|
getWorldDownloadUrl(slug: string): Promise<DownloadUrlResponse>;
|
|
132
144
|
uploadWorldFile(slug: string, tarballPath: string): Promise<void>;
|
|
145
|
+
/**
|
|
146
|
+
* Get the dashboard URL for the current environment.
|
|
147
|
+
* @returns The dashboard URL.
|
|
148
|
+
*/
|
|
149
|
+
getDashboardUrl(): Promise<DashboardUrlResponse>;
|
|
133
150
|
}
|
package/dist/lib/api-client.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
|
-
import { AgentsResponseSchema, ChallengeSchema, ChallengesResponseSchema, DownloadUrlResponseSchema, HumanSchema, JobResponseSchema, RecordingsListResponseSchema, RunStatusSchema, UploadUrlResponseSchema, WorldSchema, WorldsResponseSchema, } from "./schemas.js";
|
|
3
|
+
import { AgentsResponseSchema, ChallengeSchema, ChallengesResponseSchema, DashboardUrlResponseSchema, DownloadUrlResponseSchema, HumanSchema, JobResponseSchema, RecordingsListResponseSchema, RunLogsResponseSchema, RunResultResponseSchema, RunStatusSchema, UploadUrlResponseSchema, WorldSchema, WorldsResponseSchema, } from "./schemas.js";
|
|
4
4
|
const DEFAULT_PAGE_SIZE = 30;
|
|
5
5
|
const DEFAULT_CHALLENGE_SCHEMA = {
|
|
6
6
|
slug: "",
|
|
7
7
|
name: "",
|
|
8
8
|
visibility: "private",
|
|
9
9
|
domain: "minecraft",
|
|
10
|
-
world: "team-kradle:
|
|
10
|
+
world: "team-kradle:flat-world",
|
|
11
11
|
challengeConfig: { cheat: false, datapack: true, gameMode: "survival" },
|
|
12
12
|
task: ".",
|
|
13
13
|
roles: { "default-role": { description: "default-role", specificTask: "do your best!" } },
|
|
@@ -277,6 +277,40 @@ export class ApiClient {
|
|
|
277
277
|
const url = `runs/${runId}/recordings/${participantId}/downloadUrl?timestamp=${encodeURIComponent(timestamp)}`;
|
|
278
278
|
return this.get(url, {}, DownloadUrlResponseSchema);
|
|
279
279
|
}
|
|
280
|
+
/**
|
|
281
|
+
* Get logs for a run, and parse the message if it is a JSON object.
|
|
282
|
+
* @param runId - The ID of the run.
|
|
283
|
+
* @returns Array of log entries.
|
|
284
|
+
*/
|
|
285
|
+
async getRunLogs(runId) {
|
|
286
|
+
const url = `runs/${runId}/logs`;
|
|
287
|
+
const response = await this.get(url, {}, RunLogsResponseSchema);
|
|
288
|
+
return response.logs.map((log) => {
|
|
289
|
+
const { message, ...rest } = log;
|
|
290
|
+
try {
|
|
291
|
+
const parsedMessage = JSON.parse(message);
|
|
292
|
+
return {
|
|
293
|
+
...rest,
|
|
294
|
+
parsedMessage: parsedMessage,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
return {
|
|
299
|
+
...rest,
|
|
300
|
+
message: message,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Get the result of a run.
|
|
307
|
+
* @param runId - The ID of the run.
|
|
308
|
+
* @returns Run result with status, end_state, and participant results.
|
|
309
|
+
*/
|
|
310
|
+
async getRunResult(runId) {
|
|
311
|
+
const url = `runs/${runId}`;
|
|
312
|
+
return this.get(url, {}, RunResultResponseSchema);
|
|
313
|
+
}
|
|
280
314
|
async listWorlds() {
|
|
281
315
|
return this.listResource("worlds", "worlds", WorldsResponseSchema);
|
|
282
316
|
}
|
|
@@ -343,4 +377,11 @@ export class ApiClient {
|
|
|
343
377
|
throw new Error(`Failed to upload world: ${response.statusText}`);
|
|
344
378
|
}
|
|
345
379
|
}
|
|
380
|
+
/**
|
|
381
|
+
* Get the dashboard URL for the current environment.
|
|
382
|
+
* @returns The dashboard URL.
|
|
383
|
+
*/
|
|
384
|
+
async getDashboardUrl() {
|
|
385
|
+
return this.get("dashboard", {}, DashboardUrlResponseSchema);
|
|
386
|
+
}
|
|
346
387
|
}
|
package/dist/lib/challenge.d.ts
CHANGED
|
@@ -57,4 +57,12 @@ export declare class Challenge {
|
|
|
57
57
|
* Note: config.ts is NOT created here - it should be generated later via `challenge config download`
|
|
58
58
|
*/
|
|
59
59
|
static createLocal(slug: string, kradleChallengesPath: string): Promise<void>;
|
|
60
|
+
/**
|
|
61
|
+
* Download challenge source files (challenge.ts and config.ts) from the cloud
|
|
62
|
+
* @param api - The API client to use
|
|
63
|
+
* @param slug - The challenge slug to download
|
|
64
|
+
* @param targetDir - The directory to save the source files to
|
|
65
|
+
* @returns The number of files extracted
|
|
66
|
+
*/
|
|
67
|
+
static downloadSourceFiles(api: ApiClient, slug: string, targetDir: string): Promise<number>;
|
|
60
68
|
}
|
package/dist/lib/challenge.js
CHANGED
|
@@ -194,4 +194,50 @@ export class Challenge {
|
|
|
194
194
|
// Copy challenge.ts template
|
|
195
195
|
await fs.copyFile(challengeTemplatePath, challenge.challengePath);
|
|
196
196
|
}
|
|
197
|
+
/**
|
|
198
|
+
* Download challenge source files (challenge.ts and config.ts) from the cloud
|
|
199
|
+
* @param api - The API client to use
|
|
200
|
+
* @param slug - The challenge slug to download
|
|
201
|
+
* @param targetDir - The directory to save the source files to
|
|
202
|
+
* @returns The number of files extracted
|
|
203
|
+
*/
|
|
204
|
+
static async downloadSourceFiles(api, slug, targetDir) {
|
|
205
|
+
const { downloadUrl } = await api.getChallengeDownloadUrl(slug);
|
|
206
|
+
const response = await fetch(downloadUrl);
|
|
207
|
+
if (!response.ok) {
|
|
208
|
+
throw new Error(`Failed to download challenge "${slug}": ${response.status} ${response.statusText}`);
|
|
209
|
+
}
|
|
210
|
+
// Save tarball to temp location
|
|
211
|
+
const tempTarballPath = path.join(targetDir, `${slug}-temp.tar.gz`);
|
|
212
|
+
const buffer = await response.arrayBuffer();
|
|
213
|
+
await fs.mkdir(targetDir, { recursive: true });
|
|
214
|
+
await fs.writeFile(tempTarballPath, Buffer.from(buffer));
|
|
215
|
+
const filesToExtract = [`${SOURCE_FOLDER}/challenge.ts`, `${SOURCE_FOLDER}/config.ts`];
|
|
216
|
+
const tempExtractDir = path.join(targetDir, `${slug}-extract-temp`);
|
|
217
|
+
try {
|
|
218
|
+
await fs.mkdir(tempExtractDir, { recursive: true });
|
|
219
|
+
await tar.extract({
|
|
220
|
+
file: tempTarballPath,
|
|
221
|
+
cwd: tempExtractDir,
|
|
222
|
+
filter: (entryPath) => filesToExtract.some((f) => entryPath === f),
|
|
223
|
+
});
|
|
224
|
+
const srcChallengeTs = path.join(tempExtractDir, SOURCE_FOLDER, "challenge.ts");
|
|
225
|
+
const srcConfigTs = path.join(tempExtractDir, SOURCE_FOLDER, "config.ts");
|
|
226
|
+
let extractedCount = 0;
|
|
227
|
+
if (existsSync(srcChallengeTs)) {
|
|
228
|
+
await fs.copyFile(srcChallengeTs, path.join(targetDir, "challenge.ts"));
|
|
229
|
+
extractedCount++;
|
|
230
|
+
}
|
|
231
|
+
if (existsSync(srcConfigTs)) {
|
|
232
|
+
await fs.copyFile(srcConfigTs, path.join(targetDir, "config.ts"));
|
|
233
|
+
extractedCount++;
|
|
234
|
+
}
|
|
235
|
+
return extractedCount;
|
|
236
|
+
}
|
|
237
|
+
finally {
|
|
238
|
+
// Clean up temp files
|
|
239
|
+
await fs.rm(tempExtractDir, { recursive: true, force: true });
|
|
240
|
+
await fs.rm(tempTarballPath, { force: true });
|
|
241
|
+
}
|
|
242
|
+
}
|
|
197
243
|
}
|